Skip to content

Commit 9cdc5af

Browse files
aaronpowellCopilot
andcommitted
Validate mcp.json against the full v1.0.0 schema with Ajv
Replace the hand-rolled MCP checks with Ajv validation against the canonical Agent Plugins v1.0.0 MCP schema, so non-spec configs (empty command/url, non-string args, reserved PLUGIN_ROOT/PLUGIN_DATA env keys, invalid cwd, unknown server fields) are rejected. Per-server errors are re-derived from the matching discriminated branch to avoid unhelpful oneOf output. Also reject a top-level extensions.mcpServers placement, which slipped through because the manifest schema allows arbitrary object-valued extension keys. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 764c5bb4-2811-4dc1-b61d-56c4a5597cc9
1 parent e1049fb commit 9cdc5af

3 files changed

Lines changed: 221 additions & 38 deletions

File tree

eng/agent-plugin-schema.mjs

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,135 @@ export function validateAgentPluginManifest(manifest) {
2323
return validate(manifest) ? [] : (validate.errors ?? []).map((error) =>
2424
`${error.instancePath || "manifest"} ${error.message}`);
2525
}
26+
27+
export const AGENT_PLUGIN_MCP_SCHEMA_URL = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";
28+
export const AGENT_PLUGIN_MCP_SCHEMA = {
29+
$schema: "https://json-schema.org/draft/2020-12/schema",
30+
$id: AGENT_PLUGIN_MCP_SCHEMA_URL,
31+
title: "Agent Plugins MCP Configuration",
32+
type: "object",
33+
properties: {
34+
$schema: { const: AGENT_PLUGIN_MCP_SCHEMA_URL },
35+
mcpServers: { type: "object", additionalProperties: { $ref: "#/$defs/server" } },
36+
},
37+
required: ["$schema", "mcpServers"],
38+
additionalProperties: false,
39+
$defs: {
40+
server: {
41+
title: "MCP server",
42+
oneOf: [
43+
{ $ref: "#/$defs/stdioServer" },
44+
{ $ref: "#/$defs/streamableHttpServer" },
45+
{ $ref: "#/$defs/sseServer" },
46+
],
47+
},
48+
stdioServer: {
49+
title: "stdio MCP server",
50+
type: "object",
51+
properties: {
52+
type: { const: "stdio" },
53+
command: { type: "string", minLength: 1 },
54+
args: { type: "array", items: { type: "string" } },
55+
env: {
56+
type: "object",
57+
propertyNames: { not: { enum: ["PLUGIN_ROOT", "PLUGIN_DATA"] } },
58+
additionalProperties: { type: "string" },
59+
},
60+
cwd: {
61+
type: "string",
62+
pattern: "^(?:\\./|\\$\\{PLUGIN_ROOT\\}(?:/|$)|\\$\\{PLUGIN_DATA\\}(?:/|$))",
63+
},
64+
},
65+
required: ["type", "command"],
66+
additionalProperties: false,
67+
},
68+
streamableHttpServer: {
69+
title: "Streamable HTTP MCP server",
70+
type: "object",
71+
properties: {
72+
type: { const: "streamable-http" },
73+
url: { type: "string", minLength: 1 },
74+
headers: { $ref: "#/$defs/headers" },
75+
},
76+
required: ["type", "url"],
77+
additionalProperties: false,
78+
},
79+
sseServer: {
80+
title: "Legacy HTTP+SSE MCP server",
81+
type: "object",
82+
properties: {
83+
type: { const: "sse" },
84+
url: { type: "string", minLength: 1 },
85+
headers: { $ref: "#/$defs/headers" },
86+
},
87+
required: ["type", "url"],
88+
additionalProperties: false,
89+
},
90+
headers: { title: "HTTP headers", type: "object", additionalProperties: { type: "string" } },
91+
},
92+
};
93+
94+
const mcpAjv = new Ajv2020({ allErrors: true });
95+
const validateMcp = mcpAjv.compile(AGENT_PLUGIN_MCP_SCHEMA);
96+
97+
// A bare oneOf failure reports every branch at once, so errors for a server whose
98+
// `type` is a known discriminator are re-derived from that branch alone.
99+
const MCP_SERVER_BRANCHES = {
100+
stdio: "stdioServer",
101+
"streamable-http": "streamableHttpServer",
102+
sse: "sseServer",
103+
};
104+
const MCP_SERVER_TYPES = Object.keys(MCP_SERVER_BRANCHES);
105+
106+
function formatMcpError(error) {
107+
const extra = error.params?.additionalProperty
108+
? ` (${error.params.additionalProperty})`
109+
: "";
110+
return `${error.instancePath || "config"} ${error.message}${extra}`;
111+
}
112+
113+
export function validateAgentPluginMcpConfig(config) {
114+
if (validateMcp(config)) {
115+
return [];
116+
}
117+
const rawErrors = validateMcp.errors ?? [];
118+
const servers = config?.mcpServers;
119+
const hasServerObject = typeof servers === "object" && servers !== null && !Array.isArray(servers);
120+
121+
const messages = [];
122+
for (const error of rawErrors) {
123+
if (hasServerObject && error.instancePath.startsWith("/mcpServers/")) {
124+
continue;
125+
}
126+
messages.push(formatMcpError(error));
127+
}
128+
129+
if (hasServerObject) {
130+
for (const [name, server] of Object.entries(servers)) {
131+
if (typeof server !== "object" || server === null || Array.isArray(server)) {
132+
messages.push(`/mcpServers/${name} must be an object`);
133+
continue;
134+
}
135+
const branch = MCP_SERVER_BRANCHES[server.type];
136+
if (!branch) {
137+
messages.push(`/mcpServers/${name}/type must be one of ${MCP_SERVER_TYPES.join(", ")}`);
138+
continue;
139+
}
140+
const branchValidator = mcpAjv.getSchema(`${AGENT_PLUGIN_MCP_SCHEMA_URL}#/$defs/${branch}`);
141+
if (branchValidator(server)) {
142+
continue;
143+
}
144+
for (const error of branchValidator.errors ?? []) {
145+
if (error.keyword === "not") {
146+
continue;
147+
}
148+
const suffix = error.keyword === "propertyNames"
149+
? ` "${error.params?.propertyName}" is reserved`
150+
: formatMcpError(error).slice(error.instancePath.length || "config".length);
151+
messages.push(`/mcpServers/${name}${error.instancePath}${suffix}`);
152+
}
153+
}
154+
}
155+
156+
return messages;
157+
}

eng/validate-plugins.mjs

Lines changed: 7 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,12 @@ import { fileURLToPath } from "url";
66
import { ROOT_FOLDER } from "./constants.mjs";
77
import { readExternalPlugins } from "./external-plugin-validation.mjs";
88
import { validateLicenseField } from "./lib/license.mjs";
9-
import { AGENT_PLUGIN_SCHEMA_URL, validateAgentPluginManifest } from "./agent-plugin-schema.mjs";
9+
import { AGENT_PLUGIN_SCHEMA_URL, validateAgentPluginManifest, validateAgentPluginMcpConfig } from "./agent-plugin-schema.mjs";
1010

1111
const PLUGINS_DIR = path.join(ROOT_FOLDER, "plugins");
1212
const EXTENSIONS_DIR = path.join(ROOT_FOLDER, "extensions");
1313

1414
const AGENT_PLUGINS_SCHEMA = AGENT_PLUGIN_SCHEMA_URL;
15-
const AGENT_PLUGINS_MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";
16-
const MCP_SERVER_TYPES = new Set(["stdio", "streamable-http", "sse"]);
1715
const COPILOT_NAMESPACE = "com.github.copilot";
1816
const AWESOME_COPILOT_NAMESPACE = "com.github.awesome-copilot";
1917

@@ -239,40 +237,12 @@ export function validateMcpConfig(pluginDir) {
239237
errors.push("mcp.json must contain a top-level object");
240238
return errors;
241239
}
242-
if (parsed["$schema"] !== AGENT_PLUGINS_MCP_SCHEMA) {
243-
errors.push(`mcp.json $schema must be "${AGENT_PLUGINS_MCP_SCHEMA}"`);
244-
}
245-
const servers = parsed.mcpServers;
246-
if (typeof servers !== "object" || servers === null || Array.isArray(servers)) {
247-
errors.push("mcp.json mcpServers must be an object");
248-
return errors;
249-
}
250-
for (const key of Object.keys(parsed)) {
251-
if (key !== "$schema" && key !== "mcpServers") {
252-
errors.push(`mcp.json must not contain the top-level field "${key}"`);
253-
}
254-
}
255-
for (const [name, server] of Object.entries(servers)) {
256-
if (typeof server !== "object" || server === null || Array.isArray(server)) {
257-
errors.push(`mcp.json mcpServers["${name}"] must be an object`);
258-
continue;
259-
}
260-
if (!MCP_SERVER_TYPES.has(server.type)) {
261-
errors.push(`mcp.json mcpServers["${name}"].type must be one of ${[...MCP_SERVER_TYPES].join(", ")}`);
262-
continue;
263-
}
264-
if (server.type === "stdio" && typeof server.command !== "string") {
265-
errors.push(`mcp.json mcpServers["${name}"].command is required for stdio servers`);
266-
}
267-
if (server.type !== "stdio" && typeof server.url !== "string") {
268-
errors.push(`mcp.json mcpServers["${name}"].url is required for ${server.type} servers`);
269-
}
270-
}
240+
errors.push(...validateAgentPluginMcpConfig(parsed).map((message) => `mcp.json ${message}`));
271241

272242
return errors;
273243
}
274244

275-
function validateCompositionNamespace(plugin) {
245+
export function validateCompositionNamespace(plugin) {
276246
const errors = [];
277247
const compositionFields = ["agents", "hooks", "skills"];
278248
const extensions = plugin.extensions;
@@ -294,6 +264,10 @@ function validateCompositionNamespace(plugin) {
294264
errors.push(`extensions["${AWESOME_COPILOT_NAMESPACE}"].mcpServers is not supported; declare MCP servers in mcp.json at the plugin root`);
295265
}
296266

267+
if (extensions?.mcpServers !== undefined) {
268+
errors.push("extensions.mcpServers is not supported; declare MCP servers in mcp.json at the plugin root");
269+
}
270+
297271
for (const field of compositionFields) {
298272
if (extensions?.[field] !== undefined) {
299273
errors.push(`extensions.${field} must be moved to extensions["${AWESOME_COPILOT_NAMESPACE}"].${field}`);

eng/validate-plugins.test.mjs

Lines changed: 82 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import fs from "node:fs";
33
import os from "node:os";
44
import path from "node:path";
55
import { test } from "node:test";
6-
import { isReusableExtensionRegistered, validateMcpConfig } from "./validate-plugins.mjs";
6+
import { isReusableExtensionRegistered, validateCompositionNamespace, validateMcpConfig } from "./validate-plugins.mjs";
77

88
const MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";
99

@@ -56,8 +56,8 @@ test("rejects mcp.json with a wrong $schema and an unknown top-level field", ()
5656
});
5757
const errors = validateMcpConfig(dir);
5858
assert.equal(errors.length, 2);
59-
assert.match(errors[0], /\$schema must be/);
60-
assert.match(errors[1], /must not contain the top-level field "inputs"/);
59+
assert.match(errors[0], /must have required property '\$schema'/);
60+
assert.match(errors[1], /must NOT have additional properties \(inputs\)/);
6161
});
6262

6363
test("rejects a server entry missing required transport fields", () => {
@@ -71,8 +71,85 @@ test("rejects a server entry missing required transport fields", () => {
7171
},
7272
});
7373
const errors = validateMcpConfig(dir);
74-
assert.match(errors[0], /\.url is required for streamable-http servers/);
75-
assert.match(errors[1], /\.type must be one of/);
74+
assert.deepEqual(errors, [
75+
"mcp.json /mcpServers/bad must have required property 'url'",
76+
"mcp.json /mcpServers/worse/type must be one of stdio, streamable-http, sse",
77+
]);
78+
});
79+
80+
test("rejects a stdio server with an empty command", () => {
81+
const dir = makePluginDir({
82+
"mcp.json": {
83+
$schema: MCP_SCHEMA,
84+
mcpServers: { demo: { type: "stdio", command: "" } },
85+
},
86+
});
87+
assert.deepEqual(validateMcpConfig(dir), [
88+
"mcp.json /mcpServers/demo/command must NOT have fewer than 1 characters",
89+
]);
90+
});
91+
92+
test("rejects an unknown field on a server entry", () => {
93+
const dir = makePluginDir({
94+
"mcp.json": {
95+
$schema: MCP_SCHEMA,
96+
mcpServers: { demo: { type: "stdio", command: "docker", timeout: 5 } },
97+
},
98+
});
99+
assert.deepEqual(validateMcpConfig(dir), [
100+
"mcp.json /mcpServers/demo must NOT have additional properties (timeout)",
101+
]);
102+
});
103+
104+
test("rejects a reserved PLUGIN_ROOT environment key", () => {
105+
const dir = makePluginDir({
106+
"mcp.json": {
107+
$schema: MCP_SCHEMA,
108+
mcpServers: { demo: { type: "stdio", command: "docker", env: { PLUGIN_ROOT: "/x" } } },
109+
},
110+
});
111+
assert.deepEqual(validateMcpConfig(dir), [
112+
'mcp.json /mcpServers/demo/env "PLUGIN_ROOT" is reserved',
113+
]);
114+
});
115+
116+
test("rejects an absolute cwd on a stdio server", () => {
117+
const dir = makePluginDir({
118+
"mcp.json": {
119+
$schema: MCP_SCHEMA,
120+
mcpServers: { demo: { type: "stdio", command: "docker", cwd: "/abs" } },
121+
},
122+
});
123+
const errors = validateMcpConfig(dir);
124+
assert.equal(errors.length, 1);
125+
assert.match(errors[0], /^mcp\.json \/mcpServers\/demo\/cwd must match pattern/);
126+
});
127+
128+
test("rejects non-string args on a stdio server", () => {
129+
const dir = makePluginDir({
130+
"mcp.json": {
131+
$schema: MCP_SCHEMA,
132+
mcpServers: { demo: { type: "stdio", command: "docker", args: [1] } },
133+
},
134+
});
135+
assert.deepEqual(validateMcpConfig(dir), [
136+
"mcp.json /mcpServers/demo/args/0 must be string",
137+
]);
138+
});
139+
140+
test("rejects mcpServers declared under extensions in plugin.json", () => {
141+
assert.deepEqual(
142+
validateCompositionNamespace({ extensions: { mcpServers: { demo: {} } } }),
143+
["extensions.mcpServers is not supported; declare MCP servers in mcp.json at the plugin root"]
144+
);
145+
});
146+
147+
test("rejects mcpServers declared under the awesome-copilot namespace", () => {
148+
const errors = validateCompositionNamespace({
149+
extensions: { "com.github.awesome-copilot": { mcpServers: "./mcp.json" } },
150+
});
151+
assert.equal(errors.length, 1);
152+
assert.match(errors[0], /mcpServers is not supported; declare MCP servers in mcp\.json/);
76153
});
77154

78155
test("accepts a same-named standalone extension plugin", () => {

0 commit comments

Comments
 (0)