mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-26 10:45:03 +00:00
chore: publish from main
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user