mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-25 18:25:01 +00:00
334 lines
12 KiB
JavaScript
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;
|
|
}
|