chore: publish from main

This commit is contained in:
github-actions[bot]
2026-08-24 00:26:50 +00:00
parent 060daade7c
commit 7f365df724
11 changed files with 835 additions and 32 deletions
@@ -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:
+2 -1
View File
@@ -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`.',
+2
View File
@@ -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
+308
View File
@@ -1,4 +1,6 @@
import Ajv2020 from "ajv/dist/2020.js";
import fs from "node:fs";
import path from "node:path";
export const AGENT_PLUGIN_SCHEMA_URL = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
export const AGENT_PLUGIN_SCHEMA = {
@@ -23,3 +25,309 @@ 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 isBareExecutableOrRelativePath(command) {
if (typeof command !== "string" || command.length === 0) {
return false;
}
if (command.startsWith("./")) {
return true;
}
return !command.includes("/") && !command.includes("\\");
}
function isPathWithinRoot(root, value) {
const normalizedValue = value.replaceAll("\\", path.sep).replaceAll("/", path.sep).replace(/^[/\\]+/, "");
const candidate = path.resolve(root, normalizedValue);
const relative = path.relative(root, candidate);
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
return false;
}
let rootRealPath;
try {
rootRealPath = fs.realpathSync.native(root);
} catch {
return false;
}
let existingPath = candidate;
const missingSegments = [];
while (true) {
let resolvedExistingPath;
try {
fs.lstatSync(existingPath);
resolvedExistingPath = fs.realpathSync.native(existingPath);
} catch (error) {
if (error.code !== "ENOENT") {
return false;
}
try {
fs.readlinkSync(existingPath);
return false;
} catch (readlinkError) {
if (readlinkError.code !== "EINVAL" && readlinkError.code !== "ENOENT") {
return false;
}
}
const parent = path.dirname(existingPath);
if (parent === existingPath) {
return false;
}
missingSegments.unshift(path.basename(existingPath));
existingPath = parent;
continue;
}
const resolvedCandidate = path.join(resolvedExistingPath, ...missingSegments);
const resolvedRelative = path.relative(rootRealPath, resolvedCandidate);
return resolvedRelative !== ".." &&
!resolvedRelative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(resolvedRelative);
}
}
function isContainedRelativeCwd(cwd, pluginDir) {
if (typeof cwd !== "string" || cwd.length === 0) {
return false;
}
const placeholder = cwd.match(/^\$\{(PLUGIN_ROOT|PLUGIN_DATA)\}(\/.*)?$/);
if (placeholder) {
if (placeholder[1] === "PLUGIN_DATA") {
return isLexicallyWithinRoot(placeholder[2] ?? "");
}
return !pluginDir || isPathWithinRoot(pluginDir, placeholder[2] ?? "");
}
if (!cwd.startsWith("./")) {
return false;
}
return !pluginDir || isPathWithinRoot(pluginDir, cwd);
}
function isLexicallyWithinRoot(value) {
let depth = 0;
for (const segment of value.split(/[\\/]/)) {
if (!segment || segment === ".") continue;
if (segment === "..") {
if (depth === 0) return false;
depth--;
} else {
depth++;
}
}
return true;
}
function isLoopbackHostname(hostname) {
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
return normalized === "localhost" || normalized === "::1" || /^127(?:\.\d{1,3}){3}$/.test(normalized);
}
// Headers in mcp.json are visible package data. These names unambiguously carry
// credentials; API-key names are intentionally rejected even when their value is
// a placeholder, so users configure them in their local MCP client instead.
const CREDENTIAL_HEADER_NAMES = new Set([
"authorization",
"proxy-authorization",
"cookie",
"set-cookie",
"api-key",
"x-api-key",
"x-api-token",
"x-auth-token",
"x-access-token",
"access-token",
]);
function validateRemoteServer(server, name) {
const errors = [];
let parsedUrl;
try {
parsedUrl = new URL(server.url);
} catch {
errors.push(`/mcpServers/${name}/url must be an absolute HTTP(S) URL`);
return errors;
}
if (!/^https?:\/\//i.test(server.url) ||
parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:" ||
!parsedUrl.hostname || parsedUrl.username || parsedUrl.password || parsedUrl.hash) {
errors.push(`/mcpServers/${name}/url must be an absolute HTTP(S) URL without userinfo or fragment`);
} else if (parsedUrl.protocol === "http:" && !isLoopbackHostname(parsedUrl.hostname)) {
errors.push(`/mcpServers/${name}/url must use HTTPS for non-loopback hosts`);
}
if (server.headers !== undefined) {
const seen = new Set();
for (const [headerName, headerValue] of Object.entries(server.headers)) {
if (!/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(headerName)) {
errors.push(`/mcpServers/${name}/headers/${headerName} must be a valid HTTP header name`);
}
const normalizedName = headerName.toLowerCase();
if (CREDENTIAL_HEADER_NAMES.has(normalizedName)) {
errors.push(`/mcpServers/${name}/headers/${headerName} must not contain credentials or secrets`);
}
if (seen.has(normalizedName)) {
errors.push(`/mcpServers/${name}/headers must not contain duplicate header names`);
}
seen.add(normalizedName);
if (/[\u0000-\u0008\u000A-\u001F\u007F]/.test(headerValue)) {
errors.push(`/mcpServers/${name}/headers/${headerName} must be a valid HTTP header value`);
}
}
}
return errors;
}
function formatMcpError(error) {
const extra = error.params?.additionalProperty
? ` (${error.params.additionalProperty})`
: "";
return `${error.instancePath || "config"} ${error.message}${extra}`;
}
export function validateAgentPluginMcpConfig(config, pluginDir) {
if (validateMcp(config)) {
const semanticErrors = [];
const servers = config?.mcpServers;
if (typeof servers === "object" && servers !== null && !Array.isArray(servers)) {
for (const [name, server] of Object.entries(servers)) {
if (typeof server !== "object" || server === null || Array.isArray(server)) {
continue;
}
if (server.type === "streamable-http" || server.type === "sse") {
semanticErrors.push(...validateRemoteServer(server, name));
continue;
}
if (server.type !== "stdio") {
continue;
}
const commandIsContained = !server.command.startsWith("./") ||
!pluginDir || isPathWithinRoot(pluginDir, server.command);
if (!isBareExecutableOrRelativePath(server.command) || !commandIsContained) {
semanticErrors.push(`/mcpServers/${name}/command must be a bare executable name or a plugin-relative path starting with "./"`);
}
if (server.cwd !== undefined && !isContainedRelativeCwd(server.cwd, pluginDir)) {
semanticErrors.push(`/mcpServers/${name}/cwd must stay within the plugin root or plugin data directory`);
}
}
}
return semanticErrors;
}
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;
}
+11 -21
View File
@@ -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
}
}
+87 -4
View File
@@ -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,77 @@ 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");
let mcpStat;
try {
mcpStat = fs.lstatSync(mcpJsonPath);
} catch (error) {
if (error.code === "ENOENT") {
try {
fs.readlinkSync(mcpJsonPath);
errors.push("mcp.json is a dangling symbolic link");
} catch (readlinkError) {
if (readlinkError.code !== "EINVAL" && readlinkError.code !== "ENOENT") {
errors.push(`mcp.json could not be inspected: ${readlinkError.message}`);
}
}
return errors;
}
errors.push(`mcp.json could not be inspected: ${error.message}`);
return errors;
}
let pluginRoot;
let resolvedMcpJsonPath;
try {
pluginRoot = fs.realpathSync.native(pluginDir);
resolvedMcpJsonPath = fs.realpathSync.native(mcpJsonPath);
} catch (error) {
if (mcpStat.isSymbolicLink() && error.code === "ENOENT") {
errors.push("mcp.json is a dangling symbolic link");
} else {
errors.push(`mcp.json could not be resolved: ${error.message}`);
}
return errors;
}
const relativeMcpPath = path.relative(pluginRoot, resolvedMcpJsonPath);
if (relativeMcpPath === ".." ||
relativeMcpPath.startsWith(`..${path.sep}`) ||
path.isAbsolute(relativeMcpPath)) {
errors.push("mcp.json must resolve to a file inside the plugin root");
return errors;
}
if (!fs.statSync(resolvedMcpJsonPath).isFile()) {
errors.push("mcp.json must be a regular file");
return errors;
}
const parsed = parseJsonFile(resolvedMcpJsonPath);
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, pluginDir).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 +298,17 @@ function validateCompositionNamespace(plugin) {
return errors;
}
if (extensions && typeof extensions === "object" && !Array.isArray(extensions)) {
for (const [namespace, value] of Object.entries(extensions)) {
if (value && typeof value === "object" && !Array.isArray(value) && value.mcpServers !== undefined) {
errors.push(`extensions["${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 +369,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);
+417 -1
View File
@@ -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,408 @@ 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 an mcp.json symlink outside the plugin root", (t) => {
const dir = makePluginDir({});
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-mcp-outside-"));
const outsideMcp = path.join(outside, "mcp.json");
fs.writeFileSync(outsideMcp, JSON.stringify({ $schema: MCP_SCHEMA, mcpServers: {} }));
try {
fs.symlinkSync(outsideMcp, path.join(dir, "mcp.json"), "file");
} catch {
t.skip("symlink creation is not available");
return;
}
assert.deepEqual(validateMcpConfig(dir), [
"mcp.json must resolve to a file inside the plugin root",
]);
});
test("reports a dangling mcp.json symlink", (t) => {
const dir = makePluginDir({});
try {
fs.symlinkSync(path.join(dir, "missing-mcp.json"), path.join(dir, "mcp.json"), "file");
} catch {
t.skip("symlink creation is not available");
return;
}
assert.deepEqual(validateMcpConfig(dir), ["mcp.json is a dangling symbolic link"]);
});
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 a stdio server command that is an absolute path", () => {
const dir = makePluginDir({
"mcp.json": {
$schema: MCP_SCHEMA,
mcpServers: { demo: { type: "stdio", command: "/bin/tool" } },
},
});
assert.deepEqual(validateMcpConfig(dir), [
'mcp.json /mcpServers/demo/command must be a bare executable name or a plugin-relative path starting with "./"',
]);
});
test("rejects a plugin-relative command that escapes plugin root", () => {
const dir = makePluginDir({
"mcp.json": {
$schema: MCP_SCHEMA,
mcpServers: { demo: { type: "stdio", command: "./../../outside" } },
},
});
assert.deepEqual(validateMcpConfig(dir), [
'mcp.json /mcpServers/demo/command must be a bare executable name or a plugin-relative path starting with "./"',
]);
});
test("rejects a stdio server cwd that escapes plugin root", () => {
const dir = makePluginDir({
"mcp.json": {
$schema: MCP_SCHEMA,
mcpServers: { demo: { type: "stdio", command: "docker", cwd: "./../../outside" } },
},
});
assert.deepEqual(validateMcpConfig(dir), [
"mcp.json /mcpServers/demo/cwd must stay within the plugin root or plugin data directory",
]);
});
test("rejects traversal in plugin root and data placeholders", () => {
const dir = makePluginDir({
"mcp.json": {
$schema: MCP_SCHEMA,
mcpServers: {
root: { type: "stdio", command: "docker", cwd: "${PLUGIN_ROOT}/../../outside" },
data: { type: "stdio", command: "docker", cwd: "${PLUGIN_DATA}/../outside" },
},
},
});
assert.deepEqual(validateMcpConfig(dir), [
"mcp.json /mcpServers/root/cwd must stay within the plugin root or plugin data directory",
"mcp.json /mcpServers/data/cwd must stay within the plugin root or plugin data directory",
]);
});
test("rejects mixed-separator traversal in a PLUGIN_DATA placeholder", () => {
const dir = makePluginDir({
"mcp.json": {
$schema: MCP_SCHEMA,
mcpServers: {
data: { type: "stdio", command: "docker", cwd: "${PLUGIN_DATA}/..\\outside" },
},
},
});
assert.deepEqual(validateMcpConfig(dir), [
"mcp.json /mcpServers/data/cwd must stay within the plugin root or plugin data directory",
]);
});
test("rejects Windows-style traversal in plugin-relative paths", () => {
const dir = makePluginDir({
"mcp.json": {
$schema: MCP_SCHEMA,
mcpServers: {
command: { type: "stdio", command: ".\\..\\..\\outside" },
cwd: { type: "stdio", command: "docker", cwd: "${PLUGIN_DATA}\\..\\outside" },
},
},
});
assert.deepEqual(validateMcpConfig(dir), [
"mcp.json /mcpServers/cwd/cwd must match pattern \"^(?:\\./|\\$\\{PLUGIN_ROOT\\}(?:/|$)|\\$\\{PLUGIN_DATA\\}(?:/|$))\"",
]);
});
test("rejects a backslash-prefixed stdio command", () => {
const dir = makePluginDir({
"mcp.json": {
$schema: MCP_SCHEMA,
mcpServers: { demo: { type: "stdio", command: ".\\tool" } },
},
});
assert.deepEqual(validateMcpConfig(dir), [
'mcp.json /mcpServers/demo/command must be a bare executable name or a plugin-relative path starting with "./"',
]);
});
test("rejects plugin-relative paths that traverse a symlink outside the root", (t) => {
const dir = makePluginDir({});
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-mcp-outside-"));
try {
fs.symlinkSync(outside, path.join(dir, "linked"), "junction");
} catch {
t.skip("symlink creation is not available");
return;
}
fs.writeFileSync(path.join(dir, "mcp.json"), JSON.stringify({
$schema: MCP_SCHEMA,
mcpServers: {
command: { type: "stdio", command: "./linked/tool" },
root: { type: "stdio", command: "docker", cwd: "${PLUGIN_ROOT}/linked" },
data: { type: "stdio", command: "docker", cwd: "${PLUGIN_DATA}/linked" },
},
}));
assert.deepEqual(validateMcpConfig(dir), [
'mcp.json /mcpServers/command/command must be a bare executable name or a plugin-relative path starting with "./"',
"mcp.json /mcpServers/root/cwd must stay within the plugin root or plugin data directory",
]);
});
test("rejects command and PLUGIN_ROOT cwd paths through a dangling symlink", (t) => {
const dir = makePluginDir({});
try {
fs.symlinkSync(path.join(dir, "missing-directory"), path.join(dir, "dangling"), "junction");
} catch {
t.skip("symlink creation is not available");
return;
}
fs.writeFileSync(path.join(dir, "mcp.json"), JSON.stringify({
$schema: MCP_SCHEMA,
mcpServers: {
command: { type: "stdio", command: "./dangling/tool" },
cwd: { type: "stdio", command: "docker", cwd: "${PLUGIN_ROOT}/dangling/work" },
},
}));
assert.deepEqual(validateMcpConfig(dir), [
'mcp.json /mcpServers/command/command must be a bare executable name or a plugin-relative path starting with "./"',
"mcp.json /mcpServers/cwd/cwd must stay within the plugin root or plugin data directory",
]);
});
test("accepts PLUGIN_DATA paths without checking unrelated plugin symlinks", (t) => {
const dir = makePluginDir({});
const outside = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-mcp-data-outside-"));
try {
fs.symlinkSync(outside, path.join(dir, "linked"), "junction");
} catch {
t.skip("symlink creation is not available");
return;
}
fs.writeFileSync(path.join(dir, "mcp.json"), JSON.stringify({
$schema: MCP_SCHEMA,
mcpServers: {
data: { type: "stdio", command: "docker", cwd: "${PLUGIN_DATA}/linked" },
},
}));
assert.deepEqual(validateMcpConfig(dir), []);
});
test("rejects non-HTTP remote URLs", () => {
const dir = makePluginDir({
"mcp.json": {
$schema: MCP_SCHEMA,
mcpServers: { demo: { type: "sse", url: "javascript:alert(1)" } },
},
});
assert.deepEqual(validateMcpConfig(dir), [
"mcp.json /mcpServers/demo/url must be an absolute HTTP(S) URL without userinfo or fragment",
]);
});
test("rejects public HTTP remote URLs", () => {
const dir = makePluginDir({
"mcp.json": {
$schema: MCP_SCHEMA,
mcpServers: { demo: { type: "streamable-http", url: "http://example.com/mcp" } },
},
});
assert.deepEqual(validateMcpConfig(dir), [
"mcp.json /mcpServers/demo/url must use HTTPS for non-loopback hosts",
]);
});
test("rejects duplicate and invalid remote headers", () => {
const dir = makePluginDir({
"mcp.json": {
$schema: MCP_SCHEMA,
mcpServers: {
demo: {
type: "sse",
url: "https://example.com/mcp",
headers: { "X-Custom": "ok", "x-custom": "also ok", "Bad Header": "ok", "X-Bad": "bad\nvalue" },
},
},
},
});
assert.deepEqual(validateMcpConfig(dir), [
"mcp.json /mcpServers/demo/headers must not contain duplicate header names",
"mcp.json /mcpServers/demo/headers/Bad Header must be a valid HTTP header name",
"mcp.json /mcpServers/demo/headers/X-Bad must be a valid HTTP header value",
]);
});
test("rejects credential-bearing and API-key-style remote headers", () => {
const dir = makePluginDir({
"mcp.json": {
$schema: MCP_SCHEMA,
mcpServers: {
demo: {
type: "sse",
url: "https://example.com/mcp",
headers: {
Authorization: "Bearer fixed",
"Proxy-Authorization": "Basic fixed",
"x-api-key": "<YOUR_TOKEN>",
"api-key": "fixed",
},
},
},
},
});
assert.deepEqual(validateMcpConfig(dir), [
"mcp.json /mcpServers/demo/headers/Authorization must not contain credentials or secrets",
"mcp.json /mcpServers/demo/headers/Proxy-Authorization must not contain credentials or secrets",
"mcp.json /mcpServers/demo/headers/x-api-key must not contain credentials or secrets",
"mcp.json /mcpServers/demo/headers/api-key must not contain credentials or secrets",
]);
});
test("accepts an ordinary non-secret custom remote header", () => {
const dir = makePluginDir({
"mcp.json": {
$schema: MCP_SCHEMA,
mcpServers: {
demo: {
type: "streamable-http",
url: "https://example.com/mcp",
headers: { "X-Apimatic-Mcp-Client": "VsCode" },
},
},
},
});
assert.deepEqual(validateMcpConfig(dir), []);
});
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("rejects mcpServers declared under the com.github.copilot namespace", () => {
const errors = validateCompositionNamespace({
extensions: { "com.github.copilot": { mcpServers: { demo: {} } } },
});
assert.deepEqual(errors, [
'extensions["com.github.copilot"].mcpServers is not supported; declare MCP servers in mcp.json at the plugin root',
]);
});
test("accepts a same-named standalone extension plugin", () => {
assert.equal(
isReusableExtensionRegistered(
+1 -1
View File
@@ -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
@@ -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 @@
]
}
}
}
}
+1 -1
View File
@@ -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.
---
@@ -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"
}
}
}
}
}