mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-24 09:56:51 +00:00
chore: publish from main
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
@@ -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`.',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user