Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/skills/create-canvas-extension/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ Create `plugins/<extension-id>/plugin.json` with this shape:
}
```

Keep Agent Plugins fields at the manifest top level. Repository composition belongs only under `extensions.com.github.awesome-copilot`; do not put `agents`, `commands`, `hooks`, `mcpServers`, or `skills` at the top level or directly under `extensions`. Do not add `x-awesome-copilot`, `standalone`, or other repository-specific top-level fields.
Keep Agent Plugins fields at the manifest top level. Repository composition belongs only under `extensions.com.github.awesome-copilot`; do not put `agents`, `commands`, `hooks`, or `skills` at the top level or directly under `extensions`. MCP servers are declared in `mcp.json` at the plugin root, never in `plugin.json`. Do not add `x-awesome-copilot`, `standalone`, or other repository-specific top-level fields.

For an existing parent plugin, create or update:

Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/validate-plugins.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ jobs:
'All internal plugins and extensions must include:',
'- `"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"` in `plugin.json`',
'- A valid `name`, `description`, and `version`',
'- Repository composition (`agents`, `commands`, `hooks`, `mcpServers`, `skills`, and reusable `extensions`) under `extensions.com.github.awesome-copilot`',
'- Repository composition (`agents`, `commands`, `hooks`, `skills`, and reusable `extensions`) under `extensions.com.github.awesome-copilot`',
'- MCP servers declared in `mcp.json` at the plugin root, not in `plugin.json`',
'- For **extensions**: `extensions.com.github.copilot.logo` must be set to `"assets/preview.png"`',
'',
'Do not put repository composition fields at the manifest top level or directly under `extensions`; they must be nested under `extensions.com.github.awesome-copilot`.',
Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ All agent files (`*.agent.md`) and instruction files (`*.instructions.md`) must
- plugin.json must have `description` field (describing the plugin's purpose)
- plugin.json must have `version` field (semantic version, e.g., "1.0.0")
- Plugin content is defined declaratively in plugin.json under `extensions.com.github.awesome-copilot` using source-only composition fields (`agents`, `hooks`, `skills`, and `extensions`). Source files live in top-level directories and are materialized into plugins by CI. This namespace is stripped from the served manifest — skills use the standard `skills/` directory and Copilot-specific content uses `com.github.copilot/`.
- MCP servers are **not** a composition field. Per the Agent Plugins spec they are declared in an `mcp.json` file at the plugin root, which is committed alongside `plugin.json` and shipped as-is. Do not add `mcpServers` to `plugin.json`, and do not use the legacy `.mcp.json` filename.
- The `marketplace.json` file is automatically generated from all plugins during build
- Plugins are discoverable and installable via GitHub Copilot CLI

Expand Down Expand Up @@ -331,6 +332,7 @@ For plugins (plugins/\*/):
- [ ] Directory name is lower case with hyphens
- [ ] If `keywords` is present, it is an array of lowercase hyphenated strings
- [ ] If composition arrays are present under `extensions.com.github.awesome-copilot`, each entry is a valid relative path
- [ ] If the plugin ships MCP servers, they are declared in `mcp.json` at the plugin root (with the `mcp.schema.json` `$schema`), not in `plugin.json` or `.mcp.json`
- [ ] The plugin does not reference non-existent files
- [ ] Run `npm run plugin:validate` and `npm run build` to verify the plugin passes all checks

Expand Down
132 changes: 132 additions & 0 deletions eng/agent-plugin-schema.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,135 @@ export function validateAgentPluginManifest(manifest) {
return validate(manifest) ? [] : (validate.errors ?? []).map((error) =>
`${error.instancePath || "manifest"} ${error.message}`);
}

export const AGENT_PLUGIN_MCP_SCHEMA_URL = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";
export const AGENT_PLUGIN_MCP_SCHEMA = {
$schema: "https://json-schema.org/draft/2020-12/schema",
$id: AGENT_PLUGIN_MCP_SCHEMA_URL,
title: "Agent Plugins MCP Configuration",
type: "object",
properties: {
$schema: { const: AGENT_PLUGIN_MCP_SCHEMA_URL },
mcpServers: { type: "object", additionalProperties: { $ref: "#/$defs/server" } },
},
required: ["$schema", "mcpServers"],
additionalProperties: false,
$defs: {
server: {
title: "MCP server",
oneOf: [
{ $ref: "#/$defs/stdioServer" },
{ $ref: "#/$defs/streamableHttpServer" },
{ $ref: "#/$defs/sseServer" },
],
},
stdioServer: {
title: "stdio MCP server",
type: "object",
properties: {
type: { const: "stdio" },
command: { type: "string", minLength: 1 },
args: { type: "array", items: { type: "string" } },
env: {
type: "object",
propertyNames: { not: { enum: ["PLUGIN_ROOT", "PLUGIN_DATA"] } },
additionalProperties: { type: "string" },
},
cwd: {
type: "string",
pattern: "^(?:\\./|\\$\\{PLUGIN_ROOT\\}(?:/|$)|\\$\\{PLUGIN_DATA\\}(?:/|$))",
},
},
required: ["type", "command"],
additionalProperties: false,
},
streamableHttpServer: {
title: "Streamable HTTP MCP server",
type: "object",
properties: {
type: { const: "streamable-http" },
url: { type: "string", minLength: 1 },
headers: { $ref: "#/$defs/headers" },
},
required: ["type", "url"],
additionalProperties: false,
},
sseServer: {
title: "Legacy HTTP+SSE MCP server",
type: "object",
properties: {
type: { const: "sse" },
url: { type: "string", minLength: 1 },
headers: { $ref: "#/$defs/headers" },
},
required: ["type", "url"],
additionalProperties: false,
},
headers: { title: "HTTP headers", type: "object", additionalProperties: { type: "string" } },
},
};

const mcpAjv = new Ajv2020({ allErrors: true });
const validateMcp = mcpAjv.compile(AGENT_PLUGIN_MCP_SCHEMA);

// A bare oneOf failure reports every branch at once, so errors for a server whose
// `type` is a known discriminator are re-derived from that branch alone.
const MCP_SERVER_BRANCHES = {
stdio: "stdioServer",
"streamable-http": "streamableHttpServer",
sse: "sseServer",
};
const MCP_SERVER_TYPES = Object.keys(MCP_SERVER_BRANCHES);

function formatMcpError(error) {
const extra = error.params?.additionalProperty
? ` (${error.params.additionalProperty})`
: "";
return `${error.instancePath || "config"} ${error.message}${extra}`;
}

export function validateAgentPluginMcpConfig(config) {
if (validateMcp(config)) {
return [];
Comment on lines +113 to +115
}
const rawErrors = validateMcp.errors ?? [];
const servers = config?.mcpServers;
const hasServerObject = typeof servers === "object" && servers !== null && !Array.isArray(servers);

const messages = [];
for (const error of rawErrors) {
if (hasServerObject && error.instancePath.startsWith("/mcpServers/")) {
continue;
}
messages.push(formatMcpError(error));
}

if (hasServerObject) {
for (const [name, server] of Object.entries(servers)) {
if (typeof server !== "object" || server === null || Array.isArray(server)) {
messages.push(`/mcpServers/${name} must be an object`);
continue;
}
const branch = MCP_SERVER_BRANCHES[server.type];
if (!branch) {
messages.push(`/mcpServers/${name}/type must be one of ${MCP_SERVER_TYPES.join(", ")}`);
continue;
}
const branchValidator = mcpAjv.getSchema(`${AGENT_PLUGIN_MCP_SCHEMA_URL}#/$defs/${branch}`);
if (branchValidator(server)) {
continue;
}
for (const error of branchValidator.errors ?? []) {
if (error.keyword === "not") {
continue;
}
const suffix = error.keyword === "propertyNames"
? ` "${error.params?.propertyName}" is reserved`
: formatMcpError(error).slice(error.instancePath.length || "config".length);
messages.push(`/mcpServers/${name}${error.instancePath}${suffix}`);
}
}
}

return messages;
}
32 changes: 11 additions & 21 deletions eng/generate-website-data.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -591,30 +591,20 @@ function generatePluginsData(gitDates, resourceIndex = {}) {
];
});

// Parse mcpServers: supports a path to a .mcp.json file or an inline object
// Discover MCP servers from the spec-mandated mcp.json at the plugin root.
const mcpItems = [];
if (composition.mcpServers) {
let mcpServersObj = null;
let mcpConfigPath = relPath;
if (typeof composition.mcpServers === "string") {
const manifestMcpPath = composition.mcpServers.replace(/^\.\//, "");
mcpConfigPath = manifestMcpPath ? `${relPath}/${manifestMcpPath}` : relPath;
const mcpJsonPath = path.join(pluginDir, manifestMcpPath);
if (fs.existsSync(mcpJsonPath)) {
try {
const mcpJson = JSON.parse(fs.readFileSync(mcpJsonPath, "utf-8"));
mcpServersObj = mcpJson.mcpServers || mcpJson;
} catch {
// ignore parse errors
const mcpJsonPath = path.join(pluginDir, "mcp.json");
if (fs.existsSync(mcpJsonPath) && fs.statSync(mcpJsonPath).isFile()) {
try {
const mcpJson = JSON.parse(fs.readFileSync(mcpJsonPath, "utf-8"));
const mcpServers = mcpJson.mcpServers;
if (mcpServers && typeof mcpServers === "object") {
for (const serverName of Object.keys(mcpServers)) {
mcpItems.push({ kind: "mcp", path: `${relPath}/mcp.json`, title: serverName });
}
}
} else if (typeof composition.mcpServers === "object") {
mcpServersObj = composition.mcpServers;
}
if (mcpServersObj) {
for (const serverName of Object.keys(mcpServersObj)) {
mcpItems.push({ kind: "mcp", path: mcpConfigPath, title: serverName });
}
} catch {
// ignore parse errors
}
}

Expand Down
50 changes: 46 additions & 4 deletions eng/validate-plugins.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { fileURLToPath } from "url";
import { ROOT_FOLDER } from "./constants.mjs";
import { readExternalPlugins } from "./external-plugin-validation.mjs";
import { validateLicenseField } from "./lib/license.mjs";
import { AGENT_PLUGIN_SCHEMA_URL, validateAgentPluginManifest } from "./agent-plugin-schema.mjs";
import { AGENT_PLUGIN_SCHEMA_URL, validateAgentPluginManifest, validateAgentPluginMcpConfig } from "./agent-plugin-schema.mjs";

const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins");
const EXTENSIONS_DIR = path.join(ROOT_FOLDER, "extensions");
Expand Down Expand Up @@ -212,9 +212,39 @@ function validateExtensionReferences(plugin, pluginDir) {
return errors;
}

function validateCompositionNamespace(plugin) {
export function validateMcpConfig(pluginDir) {
const errors = [];
const compositionFields = ["agents", "hooks", "mcpServers", "skills"];
const legacyPath = path.join(pluginDir, ".mcp.json");
if (fs.existsSync(legacyPath)) {
errors.push("MCP configuration must live at mcp.json in the plugin root, not .mcp.json");
}

const mcpJsonPath = path.join(pluginDir, "mcp.json");
if (!fs.existsSync(mcpJsonPath)) {
return errors;
}
if (!fs.statSync(mcpJsonPath).isFile()) {
errors.push("mcp.json must be a regular file");
return errors;
}

const parsed = parseJsonFile(mcpJsonPath);
if (parsed.parseError) {
errors.push(`failed to parse mcp.json: ${parsed.parseError}`);
return errors;
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
errors.push("mcp.json must contain a top-level object");
return errors;
}
errors.push(...validateAgentPluginMcpConfig(parsed).map((message) => `mcp.json ${message}`));

return errors;
}

export function validateCompositionNamespace(plugin) {
const errors = [];
const compositionFields = ["agents", "hooks", "skills"];
const extensions = plugin.extensions;
const composition = extensions?.[AWESOME_COPILOT_NAMESPACE];

Expand All @@ -230,6 +260,14 @@ function validateCompositionNamespace(plugin) {
return errors;
}

if (composition?.mcpServers !== undefined) {
errors.push(`extensions["${AWESOME_COPILOT_NAMESPACE}"].mcpServers is not supported; declare MCP servers in mcp.json at the plugin root`);
}
Comment thread
aaronpowell marked this conversation as resolved.
Comment on lines +263 to +265

if (extensions?.mcpServers !== undefined) {
errors.push("extensions.mcpServers is not supported; declare MCP servers in mcp.json at the plugin root");
}

for (const field of compositionFields) {
if (extensions?.[field] !== undefined) {
errors.push(`extensions.${field} must be moved to extensions["${AWESOME_COPILOT_NAMESPACE}"].${field}`);
Expand Down Expand Up @@ -290,12 +328,16 @@ function validatePlugin(folderName) {

// Rule 5b: license (shared with external plugins). Non-SPDX is a warning, not an error.
const warnings = [];
for (const field of ["agents", "hooks", "mcpServers", "skills"]) {
if (plugin.mcpServers !== undefined) {
errors.push("mcpServers must be declared in mcp.json at the plugin root, not in plugin.json");
}
for (const field of ["agents", "hooks", "skills"]) {
if (plugin[field] !== undefined) {
errors.push(`${field} must be moved to extensions["${AWESOME_COPILOT_NAMESPACE}"].${field}`);
}
}
errors.push(...validateCompositionNamespace(plugin));
errors.push(...validateMcpConfig(pluginDir));
const licenseResult = validateLicenseField(plugin.license, { required: false });
errors.push(...licenseResult.errors);
warnings.push(...licenseResult.warnings);
Expand Down
Loading
Loading