diff --git a/.github/skills/create-canvas-extension/SKILL.md b/.github/skills/create-canvas-extension/SKILL.md index 265faaed05..2de8825638 100644 --- a/.github/skills/create-canvas-extension/SKILL.md +++ b/.github/skills/create-canvas-extension/SKILL.md @@ -72,7 +72,7 @@ Create `plugins//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: diff --git a/.github/workflows/validate-plugins.yml b/.github/workflows/validate-plugins.yml index c56466cd11..e2c3b82274 100644 --- a/.github/workflows/validate-plugins.yml +++ b/.github/workflows/validate-plugins.yml @@ -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`.', diff --git a/AGENTS.md b/AGENTS.md index e3f6eebc1f..362de259c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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 diff --git a/eng/agent-plugin-schema.mjs b/eng/agent-plugin-schema.mjs index 65357a2468..708f7e00a9 100644 --- a/eng/agent-plugin-schema.mjs +++ b/eng/agent-plugin-schema.mjs @@ -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 []; + } + 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; +} diff --git a/eng/generate-website-data.mjs b/eng/generate-website-data.mjs index 57f058ec37..0bcd58094b 100755 --- a/eng/generate-website-data.mjs +++ b/eng/generate-website-data.mjs @@ -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 } } diff --git a/eng/validate-plugins.mjs b/eng/validate-plugins.mjs index 258facb427..faa0c754ec 100755 --- a/eng/validate-plugins.mjs +++ b/eng/validate-plugins.mjs @@ -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"); @@ -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]; @@ -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`); + } + + 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}`); @@ -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); diff --git a/eng/validate-plugins.test.mjs b/eng/validate-plugins.test.mjs index e1d2fd3d0d..937fc22147 100644 --- a/eng/validate-plugins.test.mjs +++ b/eng/validate-plugins.test.mjs @@ -1,6 +1,20 @@ import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { test } from "node:test"; -import { isReusableExtensionRegistered } from "./validate-plugins.mjs"; +import { isReusableExtensionRegistered, validateCompositionNamespace, validateMcpConfig } from "./validate-plugins.mjs"; + +const MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json"; + +function makePluginDir(files) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-mcp-")); + for (const [name, content] of Object.entries(files)) { + fs.writeFileSync(path.join(dir, name), typeof content === "string" ? content : JSON.stringify(content)); + } + return dir; +} + test("accepts a reusable extension bundled only by a parent plugin", () => { assert.equal( @@ -13,6 +27,131 @@ test("accepts a reusable extension bundled only by a parent plugin", () => { ); }); +test("accepts a spec-compliant mcp.json at the plugin root", () => { + const dir = makePluginDir({ + "mcp.json": { + $schema: MCP_SCHEMA, + mcpServers: { demo: { type: "stdio", command: "docker" } }, + }, + }); + assert.deepEqual(validateMcpConfig(dir), []); +}); + +test("accepts a plugin with no mcp.json", () => { + assert.deepEqual(validateMcpConfig(makePluginDir({})), []); +}); + +test("rejects a legacy .mcp.json location", () => { + const dir = makePluginDir({ + ".mcp.json": { mcpServers: {} }, + }); + assert.deepEqual(validateMcpConfig(dir), [ + "MCP configuration must live at mcp.json in the plugin root, not .mcp.json", + ]); +}); + +test("rejects mcp.json with a wrong $schema and an unknown top-level field", () => { + const dir = makePluginDir({ + "mcp.json": { mcpServers: {}, inputs: [] }, + }); + const errors = validateMcpConfig(dir); + assert.equal(errors.length, 2); + assert.match(errors[0], /must have required property '\$schema'/); + assert.match(errors[1], /must NOT have additional properties \(inputs\)/); +}); + +test("rejects a server entry missing required transport fields", () => { + const dir = makePluginDir({ + "mcp.json": { + $schema: MCP_SCHEMA, + mcpServers: { + bad: { type: "streamable-http" }, + worse: { type: "http" }, + }, + }, + }); + const errors = validateMcpConfig(dir); + assert.deepEqual(errors, [ + "mcp.json /mcpServers/bad must have required property 'url'", + "mcp.json /mcpServers/worse/type must be one of stdio, streamable-http, sse", + ]); +}); + +test("rejects a stdio server with an empty command", () => { + const dir = makePluginDir({ + "mcp.json": { + $schema: MCP_SCHEMA, + mcpServers: { demo: { type: "stdio", command: "" } }, + }, + }); + assert.deepEqual(validateMcpConfig(dir), [ + "mcp.json /mcpServers/demo/command must NOT have fewer than 1 characters", + ]); +}); + +test("rejects an unknown field on a server entry", () => { + const dir = makePluginDir({ + "mcp.json": { + $schema: MCP_SCHEMA, + mcpServers: { demo: { type: "stdio", command: "docker", timeout: 5 } }, + }, + }); + assert.deepEqual(validateMcpConfig(dir), [ + "mcp.json /mcpServers/demo must NOT have additional properties (timeout)", + ]); +}); + +test("rejects a reserved PLUGIN_ROOT environment key", () => { + const dir = makePluginDir({ + "mcp.json": { + $schema: MCP_SCHEMA, + mcpServers: { demo: { type: "stdio", command: "docker", env: { PLUGIN_ROOT: "/x" } } }, + }, + }); + assert.deepEqual(validateMcpConfig(dir), [ + 'mcp.json /mcpServers/demo/env "PLUGIN_ROOT" is reserved', + ]); +}); + +test("rejects an absolute cwd on a stdio server", () => { + const dir = makePluginDir({ + "mcp.json": { + $schema: MCP_SCHEMA, + mcpServers: { demo: { type: "stdio", command: "docker", cwd: "/abs" } }, + }, + }); + const errors = validateMcpConfig(dir); + assert.equal(errors.length, 1); + assert.match(errors[0], /^mcp\.json \/mcpServers\/demo\/cwd must match pattern/); +}); + +test("rejects non-string args on a stdio server", () => { + const dir = makePluginDir({ + "mcp.json": { + $schema: MCP_SCHEMA, + mcpServers: { demo: { type: "stdio", command: "docker", args: [1] } }, + }, + }); + assert.deepEqual(validateMcpConfig(dir), [ + "mcp.json /mcpServers/demo/args/0 must be string", + ]); +}); + +test("rejects mcpServers declared under extensions in plugin.json", () => { + assert.deepEqual( + validateCompositionNamespace({ extensions: { mcpServers: { demo: {} } } }), + ["extensions.mcpServers is not supported; declare MCP servers in mcp.json at the plugin root"] + ); +}); + +test("rejects mcpServers declared under the awesome-copilot namespace", () => { + const errors = validateCompositionNamespace({ + extensions: { "com.github.awesome-copilot": { mcpServers: "./mcp.json" } }, + }); + assert.equal(errors.length, 1); + assert.match(errors[0], /mcpServers is not supported; declare MCP servers in mcp\.json/); +}); + test("accepts a same-named standalone extension plugin", () => { assert.equal( isReusableExtensionRegistered( diff --git a/plugins/awesome-copilot/README.md b/plugins/awesome-copilot/README.md index ba56338283..b02df2fcda 100644 --- a/plugins/awesome-copilot/README.md +++ b/plugins/awesome-copilot/README.md @@ -33,7 +33,7 @@ copilot plugin install awesome-copilot@awesome-copilot ### MCP server -This plugin includes the `awesome-copilot` MCP server configured in [`./.mcp.json`](./.mcp.json). If Docker is unavailable, MCP startup will fail. +This plugin includes the `awesome-copilot` MCP server configured in [`./mcp.json`](./mcp.json). If Docker is unavailable, MCP startup will fail. ## Source diff --git a/plugins/awesome-copilot/.mcp.json b/plugins/awesome-copilot/mcp.json similarity index 76% rename from plugins/awesome-copilot/.mcp.json rename to plugins/awesome-copilot/mcp.json index 905c61264e..1a2d2ee5f8 100644 --- a/plugins/awesome-copilot/.mcp.json +++ b/plugins/awesome-copilot/mcp.json @@ -1,4 +1,5 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", "mcpServers": { "awesome-copilot": { "type": "stdio", @@ -11,4 +12,4 @@ ] } } -} +} \ No newline at end of file diff --git a/plugins/awesome-copilot/plugin.json b/plugins/awesome-copilot/plugin.json index b5bd348825..2a7e0f1899 100644 --- a/plugins/awesome-copilot/plugin.json +++ b/plugins/awesome-copilot/plugin.json @@ -20,7 +20,6 @@ "agents": [ "./agents/meta-agentic-project-scaffold.md" ], - "mcpServers": "./.mcp.json", "skills": [ "./skills/suggest-awesome-github-copilot-agents/", "./skills/suggest-awesome-github-copilot-instructions/", diff --git a/plugins/context-matic/README.md b/plugins/context-matic/README.md index 0e66aa22de..1aa1edfacf 100644 --- a/plugins/context-matic/README.md +++ b/plugins/context-matic/README.md @@ -93,7 +93,7 @@ This plugin uses the ContextMatic MCP endpoint: https://chatbotapi.apimatic.io/mcp/plugins ``` -The plugin registers the MCP server through its plugin-root `.mcp.json` file so the server is available alongside the bundled skills. +The plugin registers the MCP server through its plugin-root `mcp.json` file so the server is available alongside the bundled skills. --- diff --git a/plugins/context-matic/.mcp.json b/plugins/context-matic/mcp.json similarity index 63% rename from plugins/context-matic/.mcp.json rename to plugins/context-matic/mcp.json index 45542e51f5..95fc62a77f 100644 --- a/plugins/context-matic/.mcp.json +++ b/plugins/context-matic/mcp.json @@ -1,10 +1,12 @@ { + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", "mcpServers": { "context-matic": { + "type": "streamable-http", "url": "https://chatbotapi.apimatic.io/mcp/plugins", "headers": { "X-Apimatic-Mcp-Client": "VsCode" } } } -} +} \ No newline at end of file