diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..2abaf5c --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,50 @@ +# pluginpack + +pluginpack compiles one authored source of agent plugins into the plugin +layouts and catalogs each AI client expects. + +## Language + +### Authoring + +**Source**: +A directory holding one plugin's shared, client-neutral content. +_Avoid_: shared plugin, source plugin (that term names the legacy 0.10 discovered-plugin model) + +**Overrides**: +A per-target directory applied after a source, adding or replacing files for one target. +_Avoid_: target overlay, targets/ replacements + +**Content kind**: +A selectable category of source content: a component directory, `static`, or `mcp`. +_Avoid_: component (when `static` or `mcp` is meant) + +### Output + +**Target**: +One output configuration that pluginpack builds, named for the client or format it serves. +_Avoid_: host, adapter (in config), platform + +**Package**: +One plugin directory laid out to the Agent Plugins specification: `plugin.json`, `skills/`, `mcp.json`, and any extension directories. +_Avoid_: portable plugin, AP plugin + +**Client profile**: +A client's additions to a package: its extension namespace, the manifest data under it, and the files in its extension directory. +_Avoid_: overlay, flavor + +**Extension namespace**: +The reverse-domain key a client owns inside a package, such as `com.openai`. +_Avoid_: vendor key, client key + +**Marketplace**: +A client's catalog file listing installable plugins, such as `.agents/plugins/marketplace.json`. Not part of a package. +_Avoid_: registry, index, catalog (as a term) + +**MCP dialect**: +The plugin-root and plugin-data variable names and transport labels one client's MCP config expects. +_Avoid_: MCP format, MCP flavor + +**Artifact**: +The in-memory map of every file one target build emits, plus which paths pluginpack manages. +_Avoid_: output bundle diff --git a/README.md b/README.md index 351d049..2f6151c 100644 --- a/README.md +++ b/README.md @@ -342,17 +342,27 @@ shared/acme/mcp/ } ``` -Pluginpack translates the configuration into each target's native MCP layout. The MCP source and tests remain in `mcp/`; only declared shipping files are emitted. Add `"mcp"` to `exclude` to omit the complete capability for a target. -| Target | How MCP is wired | -| ------------- | -------------------------------------------------------- | -| `claude` | ships `.mcp.json` at the plugin root (auto-discovered) | -| `cursor` | ships `.mcp.json`, referenced from `plugin.json` | -| `codex` | ships `.mcp.json`, referenced from `plugin.json` | -| `copilot` | ships `.mcp.json`, referenced from the marketplace entry | -| `antigravity` | writes `mcp_config.json` beside `plugin.json` | +Author `config.json` once, in either the +[Agent Plugins](https://agent-plugins.org/specification) form (an explicit +`type` per server, `${PLUGIN_ROOT}` / `${PLUGIN_DATA}`) or the Claude-style +form (`type` optional, `${CLAUDE_PLUGIN_ROOT}`). Pluginpack renders it into +each target's MCP dialect, so you don't need a per-target `config.json` +override just to change a variable name: + +| Target | File | Plugin root / data variables | Transport labels | +| ------------- | -------------------------------------------- | ------------------------------------------------- | ------------------------------------------------------ | +| `claude` | `.mcp.json` at the plugin root | `${CLAUDE_PLUGIN_ROOT}` / `${CLAUDE_PLUGIN_DATA}` | `streamable-http` becomes `http` | +| `cursor` | `.mcp.json`, referenced from `plugin.json` | `${CURSOR_PLUGIN_ROOT}` / none (a build error) | `type` dropped for stdio and HTTP (Cursor infers them) | +| `copilot` | `.mcp.json`, referenced from the marketplace | `${PLUGIN_ROOT}` / as authored | `streamable-http` becomes `http` | +| `codex` | `.mcp.json`, referenced from `plugin.json` | as authored | as authored | +| `antigravity` | `mcp_config.json` beside `plugin.json` | as authored | as authored | + +A `./bin/server` command becomes `${}/bin/server` for targets +whose clients don't resolve plugin-relative commands. Config already written in +a target's own dialect is emitted unchanged. ## Legacy Additional Plugin-Root Files diff --git a/docs/adr/0001-codex-emits-agent-plugins-packages.md b/docs/adr/0001-codex-emits-agent-plugins-packages.md new file mode 100644 index 0000000..13167ae --- /dev/null +++ b/docs/adr/0001-codex-emits-agent-plugins-packages.md @@ -0,0 +1,18 @@ +--- +status: accepted +--- + +# Codex emits Agent Plugins packages by default, with no dual layout + +OpenAI's packaging docs make the Agent Plugins root `plugin.json` (OpenAI +settings under `extensions.com.openai`) the preferred format and keep +`.codex-plugin/plugin.json` only as a fallback. From 0.12.0 the `codex` target +therefore emits Agent Plugins packages by default. A `format: "legacy"` option +keeps the old layout for one release window, for users on Codex older than +v0.146. + +We deliberately do not emit both layouts in one build. A dual layout would need +two MCP files in different shapes (`mcp.json` and `.mcp.json`) and a +`.codex-plugin` overlay that current Codex ignores whenever +`extensions.com.openai` is present. That doubles what can drift, only to serve +older clients. diff --git a/package-lock.json b/package-lock.json index 6b6d7f2..407e194 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6719,9 +6719,9 @@ } }, "node_modules/gray-matter/node_modules/js-yaml": { - "version": "3.15.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", - "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", "license": "MIT", "dependencies": { "argparse": "^1.0.7", diff --git a/src/mcp.ts b/src/mcp.ts new file mode 100644 index 0000000..21aa14a --- /dev/null +++ b/src/mcp.ts @@ -0,0 +1,511 @@ +import { isSafeRelativePath } from "./fs.js"; + +/** + * MCP configuration rendering. + * + * Authored MCP config (`mcp/config.json`, or a legacy `.mcp.json`) may be + * written in either of two shapes: + * + * - the Agent Plugins 1.0 form — an explicit `type` per server and the + * standard `${PLUGIN_ROOT}` / `${PLUGIN_DATA}` placeholders; + * - the Claude-style form most plugins started from — `type` optional + * (`command` implies stdio), `${CLAUDE_PLUGIN_ROOT}` or + * `${CURSOR_PLUGIN_ROOT}` placeholders. + * + * Each client reads a different MCP dialect: its own plugin-root and + * plugin-data variable names and its own transport labels. This module owns + * every dialect, so a target only names the dialect it emits — no target + * rewrites MCP config itself, and no author has to fork `config.json` per + * target just to change a variable name. + */ + +/** + * - `agent-plugins` — the portable Agent Plugins 1.0 `mcp.json`: `$schema`, + * an explicit `type` per server, and full validation against the spec, + * because a non-conforming server is silently skipped by clients. + * - `claude`, `cursor`, `copilot` — the client's native dialect. + * - `verbatim` — emitted exactly as authored, for a client whose dialect + * isn't documented. + */ +export type McpDialect = + "agent-plugins" | "claude" | "cursor" | "copilot" | "verbatim"; + +export const AGENT_PLUGINS_MCP_SCHEMA = + "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json"; + +/** + * Renders authored MCP servers into one dialect's complete config file + * object (ready for `json()`). `context` prefixes error messages, e.g. + * `Target "codex" plugin "acme"`. Throws when the config can't be expressed + * in the dialect — a server the client would silently skip is a build error. + */ +export function renderMcpConfig( + servers: Record, + dialect: McpDialect, + context: string, +): Record { + if (dialect === "verbatim") { + return { mcpServers: servers }; + } + if (dialect === "agent-plugins") { + const mcpServers: Record = {}; + for (const [name, server] of Object.entries(servers)) { + mcpServers[name] = toAgentPluginsServer( + server, + `${context}: MCP server "${name}"`, + ); + } + return { $schema: AGENT_PLUGINS_MCP_SCHEMA, mcpServers }; + } + const rules = nativeDialects[dialect]; + const mcpServers: Record = {}; + for (const [name, server] of Object.entries(servers)) { + mcpServers[name] = toNativeServer( + server, + rules, + `${context}: MCP server "${name}"`, + ); + } + return { mcpServers }; +} + +const ROOT_VARIABLES = [ + "PLUGIN_ROOT", + "CLAUDE_PLUGIN_ROOT", + "CURSOR_PLUGIN_ROOT", +]; +const DATA_VARIABLES = ["PLUGIN_DATA", "CLAUDE_PLUGIN_DATA"]; + +/** + * How a native dialect treats one family of variables (plugin root or + * plugin data): `native` names are left untouched, any other known name is + * rewritten to `canonical`. `unsupported` makes any use a build error; + * `passthrough` leaves every name as authored because the client's support + * isn't documented. + */ +type VariableRule = + { canonical: string; native: string[] } | "unsupported" | "passthrough"; + +type NativeDialectRules = { + client: string; + root: { canonical: string; native: string[] }; + data: VariableRule; + /** + * Authored transport label → emitted label; `null` drops the field (the + * client infers the transport from `command`/`url`). Labels not listed + * pass through unchanged, and a server without a `type` never gains one. + */ + types: Record; +}; + +const nativeDialects: Record< + Exclude, + NativeDialectRules +> = { + // Claude Code: ${CLAUDE_PLUGIN_ROOT} / ${CLAUDE_PLUGIN_DATA}; a missing + // `type` means stdio; remote Streamable HTTP is labelled "http". + claude: { + client: "Claude Code", + root: { canonical: "CLAUDE_PLUGIN_ROOT", native: ["CLAUDE_PLUGIN_ROOT"] }, + data: { canonical: "CLAUDE_PLUGIN_DATA", native: ["CLAUDE_PLUGIN_DATA"] }, + types: { "streamable-http": "http" }, + }, + // Cursor expands ${CURSOR_PLUGIN_ROOT} and ${CLAUDE_PLUGIN_ROOT}, not the + // standard ${PLUGIN_ROOT}; it has no plugin-data variable; it infers the + // transport from `command`/`url`. + cursor: { + client: "Cursor", + root: { + canonical: "CURSOR_PLUGIN_ROOT", + native: ["CURSOR_PLUGIN_ROOT", "CLAUDE_PLUGIN_ROOT"], + }, + data: "unsupported", + types: { stdio: null, http: null, "streamable-http": null }, + }, + // Copilot-format plugins expand ${PLUGIN_ROOT} and ${CLAUDE_PLUGIN_ROOT}; + // its plugin-data behaviour isn't documented for this format. + copilot: { + client: "Copilot", + root: { + canonical: "PLUGIN_ROOT", + native: ["PLUGIN_ROOT", "CLAUDE_PLUGIN_ROOT"], + }, + data: "passthrough", + types: { "streamable-http": "http" }, + }, +}; + +function toNativeServer( + server: unknown, + rules: NativeDialectRules, + context: string, +): unknown { + if (!isObject(server)) { + return server; + } + const rewrite = (value: string) => rewriteNative(value, rules, context); + const result: Record = {}; + for (const [key, value] of Object.entries(server)) { + if (key === "type" && typeof value === "string" && value in rules.types) { + const label = rules.types[value]; + if (label !== null) { + result.type = label; + } + continue; + } + if (key === "command" && typeof value === "string") { + // An Agent Plugins plugin-relative command (`./bin/server`) resolves + // against the plugin root; native clients need the root spelled out. + result.command = value.startsWith("./") + ? `\${${rules.root.canonical}}/${value.slice(2)}` + : rewrite(value); + continue; + } + result[key] = rewriteField(key, value, rewrite); + } + return result; +} + +function rewriteNative( + value: string, + rules: NativeDialectRules, + context: string, +): string { + let result = rewriteVariables(value, ROOT_VARIABLES, rules.root); + if (rules.data === "unsupported") { + const used = DATA_VARIABLES.find((name) => value.includes(`\${${name}}`)); + if (used) { + throw new Error( + `${context} uses \${${used}}, but ${rules.client} has no plugin-data variable.`, + ); + } + } else if (rules.data !== "passthrough") { + result = rewriteVariables(result, DATA_VARIABLES, rules.data); + } + return result; +} + +function rewriteVariables( + value: string, + family: string[], + rule: { canonical: string; native: string[] }, +): string { + let result = value; + for (const name of family) { + if (!rule.native.includes(name)) { + result = result.replaceAll(`\${${name}}`, `\${${rule.canonical}}`); + } + } + return result; +} + +/** Applies `rewrite` to the server fields where clients expand plugin variables. */ +function rewriteField( + key: string, + value: unknown, + rewrite: (value: string) => string, +): unknown { + if (key === "cwd" && typeof value === "string") { + return rewrite(value); + } + if (key === "args" && Array.isArray(value)) { + return value.map((arg) => (typeof arg === "string" ? rewrite(arg) : arg)); + } + if (key === "env" && isObject(value)) { + return Object.fromEntries( + Object.entries(value).map(([name, envValue]) => [ + name, + typeof envValue === "string" ? rewrite(envValue) : envValue, + ]), + ); + } + return value; +} + +type AgentPluginsTransport = "stdio" | "streamable-http" | "sse"; + +/** Authored transport labels the Agent Plugins dialect understands. */ +const agentPluginsTypes: Record = { + stdio: "stdio", + "streamable-http": "streamable-http", + http: "streamable-http", + sse: "sse", +}; + +const STDIO_FIELDS = new Set(["type", "command", "args", "env", "cwd"]); +const REMOTE_FIELDS = new Set(["type", "url", "headers"]); + +/** + * Converts one authored server to the Agent Plugins 1.0 shape and enforces + * spec §7.2.1 and §9 — each violation would make a conforming client skip + * the server, so it fails the build here instead. + */ +function toAgentPluginsServer( + server: unknown, + context: string, +): Record { + if (!isObject(server)) { + throw new Error(`${context} must be an object.`); + } + const type = agentPluginsTransport(server, context); + const allowed = type === "stdio" ? STDIO_FIELDS : REMOTE_FIELDS; + for (const key of Object.keys(server)) { + if (!allowed.has(key)) { + throw new Error( + `${context} has field "${key}", which the Agent Plugins ${type} server shape does not allow (allowed: ${[...allowed].join(", ")}).`, + ); + } + } + return type === "stdio" + ? toAgentPluginsStdio(server, context) + : toAgentPluginsRemote(type, server, context); +} + +function agentPluginsTransport( + server: Record, + context: string, +): AgentPluginsTransport { + if (server.type !== undefined) { + const type = + typeof server.type === "string" + ? agentPluginsTypes[server.type] + : undefined; + if (!type) { + throw new Error( + `${context} has type ${JSON.stringify(server.type)}; Agent Plugins supports "stdio", "streamable-http", and "sse".`, + ); + } + return type; + } + if (server.command !== undefined) { + return "stdio"; + } + if (server.url !== undefined) { + return "streamable-http"; + } + throw new Error(`${context} needs a "command" (stdio) or a "url" (remote).`); +} + +function toAgentPluginsStdio( + server: Record, + context: string, +): Record { + const result: Record = { + type: "stdio", + command: agentPluginsCommand(server.command, context), + }; + if (server.args !== undefined) { + if ( + !Array.isArray(server.args) || + !server.args.every((arg) => typeof arg === "string") + ) { + throw new Error(`${context} "args" must be an array of strings.`); + } + result.args = server.args.map((arg: string) => + agentPluginsExpandable(arg, `${context} "args"`), + ); + } + if (server.env !== undefined) { + result.env = agentPluginsEnv(server.env, context); + } + if (server.cwd !== undefined) { + result.cwd = agentPluginsCwd(server.cwd, context); + } + return result; +} + +/** + * `command` is one executable token — a bare name resolved on the client's + * search path, or a `./` path inside the plugin. No placeholder expansion + * happens in it, so an authored `${CLAUDE_PLUGIN_ROOT}/bin/x` becomes the + * equivalent `./bin/x`. + */ +function agentPluginsCommand(value: unknown, context: string): string { + if (typeof value !== "string" || !value) { + throw new Error(`${context} "command" must be a non-empty string.`); + } + let command = value; + for (const name of ROOT_VARIABLES) { + const prefix = `\${${name}}/`; + if (command.startsWith(prefix)) { + command = `./${command.slice(prefix.length)}`; + break; + } + } + if (command.includes("${")) { + throw new Error( + `${context} "command" is ${JSON.stringify(value)}; Agent Plugins does not expand placeholders in "command" — use a bare executable name or a ./ path inside the plugin.`, + ); + } + if (command.startsWith("./")) { + if (!isSafeRelativePath(command.slice(2)) || command === "./") { + throw new Error( + `${context} "command" ${JSON.stringify(value)} must stay inside the plugin root.`, + ); + } + return command; + } + if (/[\s/\\]/.test(command)) { + throw new Error( + `${context} "command" is ${JSON.stringify(value)}; Agent Plugins requires a single executable token — a bare name such as "node", or a ./ path inside the plugin. Move arguments into "args".`, + ); + } + return command; +} + +function agentPluginsEnv( + value: unknown, + context: string, +): Record { + if (!isObject(value)) { + throw new Error(`${context} "env" must be an object of strings.`); + } + const result: Record = {}; + for (const [name, envValue] of Object.entries(value)) { + if (typeof envValue !== "string") { + throw new Error(`${context} "env.${name}" must be a string.`); + } + if (["PLUGIN_ROOT", "PLUGIN_DATA"].includes(name.toUpperCase())) { + throw new Error( + `${context} "env" sets ${name}, which Agent Plugins reserves for the client.`, + ); + } + result[name] = agentPluginsExpandable(envValue, `${context} "env.${name}"`); + } + return result; +} + +/** + * `cwd` is a `./` path, or rooted at `${PLUGIN_ROOT}` or `${PLUGIN_DATA}`, + * and stays inside that root. + */ +function agentPluginsCwd(value: unknown, context: string): string { + if (typeof value !== "string") { + throw new Error(`${context} "cwd" must be a string.`); + } + const cwd = agentPluginsExpandable(value, `${context} "cwd"`); + const rest = ["./", "${PLUGIN_ROOT}/", "${PLUGIN_DATA}/"] + .filter((prefix) => cwd.startsWith(prefix)) + .map((prefix) => cwd.slice(prefix.length))[0]; + const isRoot = cwd === "${PLUGIN_ROOT}" || cwd === "${PLUGIN_DATA}"; + if ( + !isRoot && + (rest === undefined || (rest !== "" && !isSafeRelativePath(rest))) + ) { + throw new Error( + `${context} "cwd" is ${JSON.stringify(value)}; Agent Plugins requires a ./ path or a path under \${PLUGIN_ROOT} or \${PLUGIN_DATA} that stays inside it (omit "cwd" to use the plugin root).`, + ); + } + return cwd; +} + +/** + * Normalizes known plugin-root/data variables to the standard names, then + * rejects any other `${...}` — Agent Plugins leaves it literal, so e.g. an + * env-var reference that worked in Claude would silently stop working. + */ +function agentPluginsExpandable(value: string, context: string): string { + let result = rewriteVariables(value, ROOT_VARIABLES, { + canonical: "PLUGIN_ROOT", + native: ["PLUGIN_ROOT"], + }); + result = rewriteVariables(result, DATA_VARIABLES, { + canonical: "PLUGIN_DATA", + native: ["PLUGIN_DATA"], + }); + for (const match of result.matchAll(/\$\{([^}]*)\}/g)) { + if (match[1] !== "PLUGIN_ROOT" && match[1] !== "PLUGIN_DATA") { + throw new Error( + `${context} uses \${${match[1]}}; Agent Plugins expands only \${PLUGIN_ROOT} and \${PLUGIN_DATA} and leaves anything else literal.`, + ); + } + } + return result; +} + +function toAgentPluginsRemote( + type: "streamable-http" | "sse", + server: Record, + context: string, +): Record { + const result: Record = { + type, + url: agentPluginsUrl(server.url, context), + }; + if (server.headers !== undefined) { + result.headers = agentPluginsHeaders(server.headers, context); + } + return result; +} + +function agentPluginsUrl(value: unknown, context: string): string { + if (typeof value !== "string") { + throw new Error(`${context} "url" must be a string.`); + } + if (value.includes("${")) { + throw new Error( + `${context} "url" contains a placeholder; Agent Plugins does not expand placeholders in "url".`, + ); + } + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error( + `${context} "url" ${JSON.stringify(value)} is not an absolute URL.`, + ); + } + if (url.protocol !== "https:" && url.protocol !== "http:") { + throw new Error(`${context} "url" must use https (or http for loopback).`); + } + if (url.username || url.password || url.hash) { + throw new Error( + `${context} "url" must not contain user information or a fragment.`, + ); + } + if (url.protocol === "http:" && !isLoopbackHost(url.hostname)) { + throw new Error( + `${context} "url" uses http for a non-loopback host; Agent Plugins requires https.`, + ); + } + return value; +} + +function isLoopbackHost(hostname: string): boolean { + return ( + hostname === "localhost" || + hostname === "[::1]" || + /^127(?:\.\d{1,3}){3}$/.test(hostname) + ); +} + +function agentPluginsHeaders( + value: unknown, + context: string, +): Record { + if (!isObject(value)) { + throw new Error(`${context} "headers" must be an object of strings.`); + } + const seen = new Set(); + for (const [name, headerValue] of Object.entries(value)) { + if (typeof headerValue !== "string") { + throw new Error(`${context} header "${name}" must be a string.`); + } + if (seen.has(name.toLowerCase())) { + throw new Error( + `${context} sets header "${name}" more than once (header names are case-insensitive).`, + ); + } + seen.add(name.toLowerCase()); + if (headerValue.includes("${")) { + throw new Error( + `${context} header "${name}" contains a placeholder; Agent Plugins does not expand placeholders in headers, and headers must not carry secrets.`, + ); + } + } + return value as Record; +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/targets/antigravity.ts b/src/targets/antigravity.ts index d8399e6..d3b4dc9 100644 --- a/src/targets/antigravity.ts +++ b/src/targets/antigravity.ts @@ -52,6 +52,8 @@ export const antigravity: PluginTargetDefinition = { marketplacePaths: () => [], mcpConfigPath: (pluginPath) => path.join(pluginPath, "mcp_config.json"), + // Antigravity doesn't document its MCP variables; emit as authored. + mcpDialect: "verbatim", // Root-level hooks.json, not a hooks/ directory (see citations). hooksPath: (pluginPath) => path.join(pluginPath, "hooks.json"), diff --git a/src/targets/claude.ts b/src/targets/claude.ts index 4dde86b..31c2cc1 100644 --- a/src/targets/claude.ts +++ b/src/targets/claude.ts @@ -73,6 +73,7 @@ export const claude: PluginTargetDefinition = { ], mcpConfigPath: (pluginPath) => path.join(pluginPath, ".mcp.json"), + mcpDialect: "claude", hooksPath: (pluginPath) => path.join(pluginPath, "hooks", "hooks.json"), validateManifest: (manifest, pluginName, issues) => { diff --git a/src/targets/codex.ts b/src/targets/codex.ts index db46b72..0d603ec 100644 --- a/src/targets/codex.ts +++ b/src/targets/codex.ts @@ -184,6 +184,9 @@ export const codex: PluginTargetDefinition = { marketplacePaths: () => [path.join(".agents", "plugins", "marketplace.json")], mcpConfigPath: (pluginPath) => path.join(pluginPath, ".mcp.json"), + // Legacy `.codex-plugin` layout: emitted as authored until the Agent + // Plugins format lands for this target. + mcpDialect: "verbatim", hooksPath: (pluginPath) => path.join(pluginPath, "hooks", "hooks.json"), validateManifest: (manifest, pluginName, issues) => { diff --git a/src/targets/copilot.ts b/src/targets/copilot.ts index 4ef9804..cb53387 100644 --- a/src/targets/copilot.ts +++ b/src/targets/copilot.ts @@ -115,6 +115,7 @@ export const copilot: PluginTargetDefinition = { ], mcpConfigPath: (pluginPath) => path.join(pluginPath, ".mcp.json"), + mcpDialect: "copilot", hooksPath: (pluginPath) => path.join(pluginPath, "hooks", "hooks.json"), validateManifest: (manifest, pluginName, issues) => { diff --git a/src/targets/cursor.ts b/src/targets/cursor.ts index bf860da..4038c46 100644 --- a/src/targets/cursor.ts +++ b/src/targets/cursor.ts @@ -137,6 +137,7 @@ export const cursor: PluginTargetDefinition = { ], mcpConfigPath: (pluginPath) => path.join(pluginPath, ".mcp.json"), + mcpDialect: "cursor", hooksPath: (pluginPath) => path.join(pluginPath, "hooks", "hooks.json"), validateManifest: (manifest, pluginName, issues) => { diff --git a/src/targets/engine.ts b/src/targets/engine.ts index 68e0271..379c241 100644 --- a/src/targets/engine.ts +++ b/src/targets/engine.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { collectPluginFiles, resolveMcpServers } from "../render.js"; import { readAuthoredPlugin } from "../source.js"; import { isSafeRelativePath, json, toPosix } from "../fs.js"; +import { renderMcpConfig } from "../mcp.js"; import { resolvePartials } from "../partials.js"; import { validateNoSurvivingPartialTags } from "./validation-shared.js"; import { deepMerge, stripUndefined } from "./shared.js"; @@ -180,7 +181,16 @@ export async function emitFromDefinition( const mcpConfigPath = definition.mcpConfigPath(pluginPath); if (mcpServers && mcpConfigPath) { - files.set(toPosix(mcpConfigPath), json({ mcpServers })); + files.set( + toPosix(mcpConfigPath), + json( + renderMcpConfig( + mcpServers, + definition.mcpDialect, + `Target "${target}" plugin "${pluginName}"`, + ), + ), + ); } const metadata = emittedPluginMetadata( diff --git a/src/targets/types.ts b/src/targets/types.ts index 0593c53..77619da 100644 --- a/src/targets/types.ts +++ b/src/targets/types.ts @@ -1,3 +1,4 @@ +import type { McpDialect } from "../mcp.js"; import type { EmittedPluginConfig, FileValue, @@ -112,6 +113,8 @@ export type PluginTargetDefinition = { /** Return `undefined` for a target with no bundled-MCP-config file convention. */ mcpConfigPath: (pluginPath: string) => string | undefined; + /** The MCP dialect this target's MCP config file is rendered in — see `../mcp.ts`. */ + mcpDialect: McpDialect; hooksPath: (pluginPath: string) => string; validateManifest: ( diff --git a/tests/core.test.ts b/tests/core.test.ts index c137066..514f992 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -1860,6 +1860,60 @@ export default defineConfig({ }); }); + it("renders one authored MCP config into each target's MCP dialect", async () => { + const project = await recommendedShapeFixture(); + const root = project.baseDir; + await mergeFixture(project, { + shared: { + acme: { + mcp: { + "config.json": `${JSON.stringify( + { + mcpServers: { + local: { + type: "stdio", + command: "node", + args: ["${PLUGIN_ROOT}/mcp/start.mjs"], + }, + }, + }, + null, + 2, + )}\n`, + }, + }, + }, + }); + + await build({ cwd: root }); + + const readServers = async (file: string) => + ( + JSON.parse(await readFile(path.join(root, file), "utf8")) as { + mcpServers: Record; + } + ).mcpServers; + expect(await readServers("plugins/claude/acme/.mcp.json")).toEqual({ + local: { + type: "stdio", + command: "node", + args: ["${CLAUDE_PLUGIN_ROOT}/mcp/start.mjs"], + }, + }); + expect(await readServers("plugins/cursor/acme/.mcp.json")).toEqual({ + local: { command: "node", args: ["${CURSOR_PLUGIN_ROOT}/mcp/start.mjs"] }, + }); + expect(await readServers("plugins/copilot/plugins/acme/.mcp.json")).toEqual( + { + local: { + type: "stdio", + command: "node", + args: ["${PLUGIN_ROOT}/mcp/start.mjs"], + }, + }, + ); + }); + it("validates surviving partials in managed repository-root files", async () => { const project = await recommendedShapeFixture(); const root = project.baseDir; diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts new file mode 100644 index 0000000..bb22a57 --- /dev/null +++ b/tests/mcp.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, it } from "vitest"; +import { + AGENT_PLUGINS_MCP_SCHEMA, + renderMcpConfig, + type McpDialect, +} from "../src/mcp.js"; + +const context = 'Target "t" plugin "p"'; + +function render(servers: Record, dialect: McpDialect) { + return renderMcpConfig(servers, dialect, context); +} + +// The shape most existing plugins author today (Glean's shared config). +const claudeStyle = { + local: { + command: "node", + args: ["${CLAUDE_PLUGIN_ROOT}/mcp/start.mjs"], + env: { ENABLE_HITL: "true" }, + }, +}; + +// The same server authored in the Agent Plugins 1.0 form. +const agentPluginsStyle = { + local: { + type: "stdio", + command: "node", + args: ["${PLUGIN_ROOT}/mcp/start.mjs"], + env: { ENABLE_HITL: "true" }, + }, +}; + +describe("renderMcpConfig: native dialects", () => { + it("leaves Claude-style config byte-identical for claude, cursor, and copilot", () => { + for (const dialect of ["claude", "cursor", "copilot"] as const) { + expect(JSON.stringify(render(claudeStyle, dialect))).toBe( + JSON.stringify({ mcpServers: claudeStyle }), + ); + } + }); + + it("emits verbatim for the verbatim dialect, whatever the shape", () => { + const odd = { s: { command: "x", type: "ws", whatever: 1 } }; + expect(render(odd, "verbatim")).toEqual({ mcpServers: odd }); + }); + + it.each([ + ["claude", "${CLAUDE_PLUGIN_ROOT}/mcp/start.mjs", "stdio"], + ["copilot", "${PLUGIN_ROOT}/mcp/start.mjs", "stdio"], + ] as const)( + "renders Agent Plugins-authored config into the %s dialect", + (dialect, arg, type) => { + const servers = render(agentPluginsStyle, dialect).mcpServers as Record< + string, + Record + >; + expect(servers.local).toEqual({ + type, + command: "node", + args: [arg], + env: { ENABLE_HITL: "true" }, + }); + }, + ); + + it("drops transports Cursor infers and uses ${CURSOR_PLUGIN_ROOT}", () => { + const servers = render( + { + ...agentPluginsStyle, + remote: { type: "streamable-http", url: "https://example.com/mcp" }, + legacy: { type: "sse", url: "https://example.com/sse" }, + }, + "cursor", + ).mcpServers; + expect(servers).toEqual({ + local: { + command: "node", + args: ["${CURSOR_PLUGIN_ROOT}/mcp/start.mjs"], + env: { ENABLE_HITL: "true" }, + }, + remote: { url: "https://example.com/mcp" }, + legacy: { type: "sse", url: "https://example.com/sse" }, + }); + }); + + it("labels Streamable HTTP as http for claude and copilot", () => { + for (const dialect of ["claude", "copilot"] as const) { + expect( + render( + { r: { type: "streamable-http", url: "https://example.com/mcp" } }, + dialect, + ).mcpServers, + ).toEqual({ r: { type: "http", url: "https://example.com/mcp" } }); + } + }); + + it("spells out the plugin root for a ./ command", () => { + const servers = { s: { type: "stdio", command: "./bin/server" } }; + expect(render(servers, "claude").mcpServers).toEqual({ + s: { type: "stdio", command: "${CLAUDE_PLUGIN_ROOT}/bin/server" }, + }); + expect(render(servers, "cursor").mcpServers).toEqual({ + s: { command: "${CURSOR_PLUGIN_ROOT}/bin/server" }, + }); + }); + + it("maps plugin-data variables for claude and leaves copilot's alone", () => { + const servers = { + s: { command: "node", env: { DATA: "${PLUGIN_DATA}/cache" } }, + }; + expect(render(servers, "claude").mcpServers).toEqual({ + s: { command: "node", env: { DATA: "${CLAUDE_PLUGIN_DATA}/cache" } }, + }); + expect(render(servers, "copilot").mcpServers).toEqual(servers); + }); + + it("fails when Cursor would need a plugin-data variable it doesn't have", () => { + expect(() => + render({ s: { command: "node", cwd: "${PLUGIN_DATA}" } }, "cursor"), + ).toThrow( + 'Target "t" plugin "p": MCP server "s" uses ${PLUGIN_DATA}, but Cursor has no plugin-data variable.', + ); + }); +}); + +describe("renderMcpConfig: agent-plugins dialect", () => { + it("converts Claude-style config to the portable shape", () => { + expect(render(claudeStyle, "agent-plugins")).toEqual({ + $schema: AGENT_PLUGINS_MCP_SCHEMA, + mcpServers: agentPluginsStyle, + }); + }); + + it("puts $schema first and type first in each server", () => { + const output = render( + { s: { args: ["a"], command: "node" } }, + "agent-plugins", + ); + expect(Object.keys(output)).toEqual(["$schema", "mcpServers"]); + expect( + Object.keys((output.mcpServers as Record).s), + ).toEqual(["type", "command", "args"]); + }); + + it.each([ + [{ command: "x" }, "stdio"], + [{ url: "https://example.com/mcp" }, "streamable-http"], + [{ type: "http", url: "https://example.com/mcp" }, "streamable-http"], + [{ type: "sse", url: "https://example.com/sse" }, "sse"], + ])("infers or maps the transport for %j", (server, type) => { + const servers = render({ s: server }, "agent-plugins").mcpServers as Record< + string, + Record + >; + expect(servers.s.type).toBe(type); + }); + + it("turns a root-variable command into a plugin-relative one", () => { + const servers = render( + { s: { command: "${CURSOR_PLUGIN_ROOT}/bin/server" } }, + "agent-plugins", + ).mcpServers; + expect(servers).toEqual({ s: { type: "stdio", command: "./bin/server" } }); + }); + + it("keeps valid cwd forms and normalizes their variables", () => { + for (const [cwd, expected] of [ + ["./data", "./data"], + ["${CLAUDE_PLUGIN_ROOT}", "${PLUGIN_ROOT}"], + ["${CLAUDE_PLUGIN_DATA}/state", "${PLUGIN_DATA}/state"], + ]) { + const servers = render({ s: { command: "x", cwd } }, "agent-plugins") + .mcpServers as Record>; + expect(servers.s.cwd).toBe(expected); + } + }); + + it.each([ + [ + "an unsupported transport", + { type: "ws", url: "wss://example.com" }, + 'has type "ws"; Agent Plugins supports "stdio", "streamable-http", and "sse".', + ], + [ + "a shell command string", + { command: "node server.js" }, + "requires a single executable token", + ], + [ + "an absolute command", + { command: "/usr/bin/node" }, + "requires a single executable token", + ], + [ + "a placeholder inside command", + { command: "${HOME}/bin/x" }, + 'does not expand placeholders in "command"', + ], + [ + "a command that escapes the plugin", + { command: "./../outside" }, + "must stay inside the plugin root", + ], + [ + "a field outside the variant", + { command: "x", url: "https://example.com" }, + 'has field "url", which the Agent Plugins stdio server shape does not allow', + ], + [ + "a bare relative cwd", + { command: "x", cwd: "data" }, + "Agent Plugins requires a ./ path or a path under ${PLUGIN_ROOT} or ${PLUGIN_DATA}", + ], + [ + "a cwd that escapes the plugin", + { command: "x", cwd: "${PLUGIN_ROOT}/../up" }, + "that stays inside it", + ], + [ + "a reserved env name", + { command: "x", env: { PLUGIN_ROOT: "/tmp" } }, + "sets PLUGIN_ROOT, which Agent Plugins reserves for the client.", + ], + [ + "an env reference the client won't expand", + { command: "x", env: { TOKEN: "${API_TOKEN}" } }, + '"env.TOKEN" uses ${API_TOKEN}; Agent Plugins expands only ${PLUGIN_ROOT} and ${PLUGIN_DATA}', + ], + [ + "plain http to a remote host", + { url: "http://example.com/mcp" }, + "uses http for a non-loopback host", + ], + [ + "credentials in the url", + { url: "https://user:pw@example.com/mcp" }, + "must not contain user information or a fragment", + ], + [ + "a placeholder in a header", + { url: "https://example.com", headers: { Authorization: "${TOKEN}" } }, + "does not expand placeholders in headers", + ], + [ + "a header repeated with different casing", + { url: "https://example.com", headers: { "X-A": "1", "x-a": "2" } }, + 'sets header "x-a" more than once', + ], + [ + "neither command nor url", + { env: {} }, + 'needs a "command" (stdio) or a "url" (remote).', + ], + ])("rejects %s", (_label, server, message) => { + expect(() => render({ s: server }, "agent-plugins")).toThrow(message); + }); + + it("allows plain http to loopback hosts", () => { + for (const url of [ + "http://localhost:3000/mcp", + "http://127.0.0.1:3000/mcp", + "http://[::1]:3000/mcp", + ]) { + expect(() => render({ s: { url } }, "agent-plugins")).not.toThrow(); + } + }); + + it("names the target, plugin, and server in errors", () => { + expect(() => + render({ broken: { command: "a b" } }, "agent-plugins"), + ).toThrow('Target "t" plugin "p": MCP server "broken" "command"'); + }); +});