Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
209 changes: 209 additions & 0 deletions eng/agent-plugin-schema.mjs
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -23,3 +25,210 @@ 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\\}(?:[/\\\\]|$))",
Comment thread
aaronpowell marked this conversation as resolved.
Outdated
},
},
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 (/^\.[/\\]/.test(command)) {
return true;
Comment thread
aaronpowell marked this conversation as resolved.
}
return !command.includes("/") && !command.includes("\\");
Comment thread
aaronpowell marked this conversation as resolved.
Outdated
}

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;
}

const rootRealPath = fs.realpathSync.native(root);
let existingPath = candidate;
const missingSegments = [];
while (!fs.existsSync(existingPath)) {
const parent = path.dirname(existingPath);
if (parent === existingPath) {
break;
}
missingSegments.unshift(path.basename(existingPath));
existingPath = parent;
}
const resolvedExistingPath = fs.realpathSync.native(existingPath);
const resolvedCandidate = path.join(resolvedExistingPath, ...missingSegments);
Comment thread
aaronpowell marked this conversation as resolved.
Outdated
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) {
const remainder = placeholder[2];
if (!remainder || /^[\/\\]/.test(remainder)) {
return !pluginDir || isPathWithinRoot(pluginDir, remainder);
}
Comment thread
aaronpowell marked this conversation as resolved.
Outdated
return false;
}
if (!/^\.[/\\]/.test(cwd)) {
return false;
}
return !pluginDir || isPathWithinRoot(pluginDir, cwd);
}

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 !== "stdio") {
continue;
Comment thread
aaronpowell marked this conversation as resolved.
}
const commandIsContained = !/^\.[/\\]/.test(server.command) ||
!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;
}
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
53 changes: 49 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;
}
Comment thread
aaronpowell marked this conversation as resolved.

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, 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];

Expand All @@ -230,6 +260,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}`);
Expand Down Expand Up @@ -290,12 +331,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