Files
awesome-copilot/eng/agent-plugin-schema.mjs
T
github-actions[bot] 7f365df724 chore: publish from main
2026-08-24 00:26:50 +00:00

334 lines
12 KiB
JavaScript

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 = {
$schema: "https://json-schema.org/draft/2020-12/schema",
$id: AGENT_PLUGIN_SCHEMA_URL,
type: "object",
properties: {
$schema: { const: AGENT_PLUGIN_SCHEMA_URL },
name: { type: "string", minLength: 1, maxLength: 64, pattern: "^(?!.*(?:--|\\.\\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$" },
version: { type: "string" }, description: { type: "string" },
author: { type: "object", properties: { name: { type: "string" }, email: { type: "string" }, url: { type: "string" } }, additionalProperties: false },
homepage: { type: "string" }, repository: { type: "string" }, license: { type: "string" },
keywords: { type: "array", items: { type: "string" } },
extensions: { type: "object", additionalProperties: { type: "object" } },
},
required: ["$schema", "name"],
additionalProperties: false,
};
const validate = new Ajv2020({ allErrors: true }).compile(AGENT_PLUGIN_SCHEMA);
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;
}