diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bd42b0..b9d461b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to `mcp-parser` are documented here. Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [SemVer](https://semver.org). +## [0.4.3] - 2026-11-08 + +Requires mcp-schema 0.3.3 or newer. + +### Added +- Agent Plugins 1.0.0 MCP configuration parsing, offline schema and semantic validation, per-server diagnostics, and standard configuration generation from explicit transport hints. +- `parse-config`, `validate-config`, `snapshot --config --server`, and `generate --format agent-plugin` CLI workflows. +- Configured stdio snapshots resolve plugin paths, expand plugin variables once, provide persistent plugin data, and support `cwd` through the existing transport. + +### Fixed +- Client-generated HTTP/MCP headers take precedence over configured headers regardless of casing. Remote transports refuse redirects and cross-origin legacy SSE message endpoints. +- stdio spawn and stdin errors reject the snapshot instead of escaping as uncaught process errors. + ## [0.4.2] - 2026-09-24 ### Added diff --git a/README.md b/README.md index d9e6e28..2a896a9 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,73 @@ await writeFile("mcp.json", JSON.stringify(spec, null, 2)); await writeFile("mcp.md", generateMarkdown(spec)); ``` -## What is mcp.json? +## Snapshots and Agent Plugins configuration + +Two different documents commonly use the name `mcp.json`: + +| Document | Identifying fields | Purpose | +| --- | --- | --- | +| Sourcey server snapshot | `mcpSpec`, `server`, `tools`/`resources`/`prompts` | Capture and document one server's advertised surface | +| [Agent Plugins MCP configuration](https://agent-plugins.org/specification#721-discovery-and-configuration) | `$schema`, `mcpServers` | Locate and connect to the servers in a plugin | + +The package supports the published Agent Plugins **1.0.0** configuration contract. +Use `mcp.json` for that configuration at the plugin root, and a separate filename +such as `api.snapshot.json` for a captured surface. A connection configuration +does not contain tools or their schemas; snapshot its server to obtain them. + +```bash +mcp-parser validate-config ./plugin/mcp.json +mcp-parser parse-config ./plugin/mcp.json +mcp-parser snapshot --config ./plugin/mcp.json --server api -o api.snapshot.json +# For stdio, also supply a dedicated persistent directory: +mcp-parser snapshot --config ./plugin/mcp.json --server local \ + --plugin-data ./plugin-state/local -o local.snapshot.json +# Emit the standard plugin configuration from explicit snapshot transport hints: +mcp-parser generate api.snapshot.json --format agent-plugin --server api -o mcp.json +``` + +Config snapshots select exactly one server and default to `mcp.snapshot.json`; +they refuse to overwrite the input configuration. `--config` cannot be combined +with another transport or `--header`. `--plugin-root` defaults to the config's +directory. stdio uses that root as its default working directory, resolves +bundled executables and explicit working directories within their real roots, +and supplies `PLUGIN_ROOT` and `PLUGIN_DATA`. Only `args`, `env` values and `cwd` +expand those two variables, once. Other placeholders remain literal. + +Remote URLs require HTTPS except on loopback, and cannot contain user information +or fragments. Configured headers remain literal; generated HTTP/MCP headers take +precedence. Redirects and cross-origin SSE message endpoints are refused. +Snapshots omit environment values and HTTP headers. Add any required public +configuration values when authoring a plugin; never embed credentials in it. +Generation requires valid explicit transport hints and does not infer missing +commands, URLs, or authentication settings. + +These are MCP configuration utilities, not a plugin installer or a complete +Agent Plugins client. The caller owns manifest validation, matching its schema +version, skill discovery, authorization, and the per-plugin data directory's +lifecycle. `validateConfig(value, { pluginSchema })` can check the manifest's +schema identifier against the MCP configuration. Validation is offline, selects +only the recognized canonical schema, and rejects unsupported releases. + +### Configuration API + +`parseConfig(path)` and `parseConfigString(content)` require a fully valid config. +`validateConfig(value, options?)` returns `valid`, `topLevelValid`, `diagnostics`, +and a `servers` map containing independently valid entries. An invalid envelope +empties that map; an invalid server leaves valid siblings available. +`resolveConfigServer(config, name, { pluginRoot, pluginData? })` resolves one +entry for the existing `snapshot` API. `snapshotConfig` combines those steps. + +```ts +import { createConfig, snapshotConfig } from "mcp-parser"; + +const config = createConfig({ + api: { type: "streamable-http", url: "https://example.com/mcp" }, +}); +const surface = await snapshotConfig(config, "api", { pluginRoot: "/path/to/plugin" }); +``` + +### Server snapshot format A static snapshot of an MCP server's capabilities: its tools, resources, and prompts. Think of it as `openapi.json` for MCP servers. diff --git a/package-lock.json b/package-lock.json index f73d8b4..106cf71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "mcp-parser", - "version": "0.4.2", + "version": "0.4.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mcp-parser", - "version": "0.4.2", + "version": "0.4.3", "license": "MIT", "dependencies": { - "mcp-schema": "^0.3.2" + "ajv": "^8.20.0", + "mcp-schema": "^0.3.3" }, "bin": { "mcp-parser": "dist/cli.js" @@ -771,6 +772,22 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -829,6 +846,28 @@ "node": ">=12.0.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -863,6 +902,12 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -1159,9 +1204,9 @@ } }, "node_modules/mcp-schema": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/mcp-schema/-/mcp-schema-0.3.2.tgz", - "integrity": "sha512-s3OpiroBwXd09h2GBssi1vpjPtq8M8Sv1J7O4JTWEr2TYlHM3Gf0Ae4HaZLrTznQ0EDKPWEE7LqQ+2+JHWqoOw==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/mcp-schema/-/mcp-schema-0.3.3.tgz", + "integrity": "sha512-bRrAaiSPSJkpXcTfaToc5FpQHx+gW+BqpGeG4A4AkNwdLOH6Qr82dneaeV0pRQz1QTKi2Nesq1GDE10jMZJc4A==", "license": "MIT", "engines": { "node": ">=20" @@ -1252,6 +1297,15 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.2.11", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", diff --git a/package.json b/package.json index 4f8034e..1950ac3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mcp-parser", - "version": "0.4.2", + "version": "0.4.3", "description": "Snapshot, parse, validate, and document Model Context Protocol servers.", "type": "module", "engines": { @@ -58,6 +58,7 @@ }, "homepage": "https://github.com/sourcey/mcp-parser", "dependencies": { - "mcp-schema": "^0.3.2" + "ajv": "^8.20.0", + "mcp-schema": "^0.3.3" } } diff --git a/src/cli.ts b/src/cli.ts index 1ce2bc7..36f0e91 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,10 +10,12 @@ * mcp-parser generate Generate docs/context output from an mcp.json */ -import { readFile, writeFile } from "node:fs/promises"; +import { readFile, writeFile, realpath } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; import { parse } from "./parse.js"; import { validate } from "./validate.js"; import { snapshot } from "./snapshot.js"; +import { parseConfig, validateConfig, snapshotConfig, generateConfig } from "./config.js"; import { generateLlmsTxt, generateLlmsFullTxt, generateMarkdown } from "./generate.js"; const args = process.argv.slice(2); @@ -27,6 +29,22 @@ async function main(): Promise { case "validate": await cmdValidate(); break; + case "parse-config": { + if (!args[1]) throw new Error("Usage: mcp-parser parse-config "); + const json = JSON.stringify(await parseConfig(args[1]), null, 2) + "\n"; + const output = getOutputFlag(); + if (output) await writeFile(output, json); + else console.log(json.trimEnd()); + break; + } + case "validate-config": { + if (!args[1]) throw new Error("Usage: mcp-parser validate-config "); + const result = validateConfig(JSON.parse(await readFile(args[1], "utf8"))); + for (const diagnostic of result.diagnostics) console.error(`${diagnostic.path}: ${diagnostic.message}`); + if (!result.valid) process.exitCode = 1; + else console.log("Valid Agent Plugins MCP configuration."); + break; + } case "snapshot": await cmdSnapshot(); break; @@ -52,6 +70,8 @@ mcp-parser - Snapshot, parse, validate, and document MCP servers Usage: mcp-parser parse Parse and print an mcp.json mcp-parser validate Validate an mcp.json file + mcp-parser parse-config Parse Agent Plugins MCP configuration + mcp-parser validate-config Validate Agent Plugins MCP configuration mcp-parser snapshot [options] Snapshot a running MCP server mcp-parser generate [--format ] Generate output from mcp.json @@ -59,10 +79,14 @@ Transports: --stdio "" Spawn a process and communicate via stdin/stdout --sse Connect to an SSE endpoint --http Connect via streamable HTTP + --config Read Agent Plugins MCP configuration (select with --server) Options: --output, -o Write output to file instead of stdout - --format Output format: markdown (default), llms-txt, llms-full-txt + --format Output: markdown (default), llms-txt, llms-full-txt, agent-plugin + --server Configuration server to snapshot or generate + --plugin-root Plugin root (default: configuration file's directory) + --plugin-data Dedicated persistent plugin data directory (stdio config) --header Add HTTP header (SSE/HTTP transports, repeatable) --help, -h Show this help message @@ -74,6 +98,9 @@ Examples: mcp-parser snapshot --http http://localhost:3000/mcp --protocol-version 2026-07-28 mcp-parser generate ./mcp.json -o mcp.md mcp-parser generate ./mcp.json --format llms-txt -o llms.txt + mcp-parser validate-config ./plugin/mcp.json + mcp-parser snapshot --config ./plugin/mcp.json --server api -o api.snapshot.json + mcp-parser generate api.snapshot.json --format agent-plugin --server api -o mcp.json `); } @@ -135,13 +162,37 @@ async function cmdSnapshot(): Promise { process.exit(1); } const headers = parseHeaders(); - const output = getOutputFlag() ?? "mcp.json"; + const configFile = getFlagValue("--config"); + if (args.includes("--config") && !configFile) throw new Error("--config requires a file"); + const output = getOutputFlag() ?? (configFile ? "mcp.snapshot.json" : "mcp.json"); let transport: Parameters[0]["transport"]; const stdioIdx = args.indexOf("--stdio"); const sseIdx = args.indexOf("--sse"); const httpIdx = args.indexOf("--http"); + const modes = [stdioIdx !== -1, sseIdx !== -1, httpIdx !== -1, !!configFile].filter(Boolean).length; + if (modes !== 1) throw new Error("Choose exactly one transport: --stdio, --sse, --http, or --config"); + + if (configFile) { + if (args.includes("--header")) throw new Error("Configured headers come from mcp.json; --header cannot override them"); + const serverName = getFlagValue("--server"); + if (!serverName) throw new Error("--config requires --server "); + const inputPath = await realpath(configFile); + const outputPath = await realpath(output).catch(() => resolve(output)); + if (inputPath === outputPath) throw new Error("Snapshot output must differ from the connection configuration"); + const config: unknown = JSON.parse(await readFile(configFile, "utf8")); + const validation = validateConfig(config); + for (const diagnostic of validation.diagnostics) console.error(`${diagnostic.path}: ${diagnostic.message}`); + const spec = await snapshotConfig(config, serverName, { + pluginRoot: getFlagValue("--plugin-root") ?? dirname(inputPath), + pluginData: getFlagValue("--plugin-data"), + ...(protocolVersion && { protocolVersion }), + }); + await writeFile(output, JSON.stringify(spec, null, 2) + "\n"); + console.log(`Snapshot written to ${output}`); + return; + } if (stdioIdx !== -1 && args[stdioIdx + 1]) { const commandStr = args[stdioIdx + 1]; @@ -191,8 +242,11 @@ async function cmdGenerate(): Promise { case "llms-full-txt": content = generateLlmsFullTxt(spec); break; + case "agent-plugin": + content = generateConfig(spec, getFlagValue("--server") ?? spec.server.name); + break; default: - console.error(`Unknown format: ${format}. Use: markdown, llms-txt, llms-full-txt`); + console.error(`Unknown format: ${format}. Use: markdown, llms-txt, llms-full-txt, agent-plugin`); process.exit(1); } @@ -225,7 +279,7 @@ function getOutputFlag(): string | undefined { function getFlagValue(flag: string): string | undefined { const idx = args.indexOf(flag); - if (idx !== -1 && args[idx + 1]) return args[idx + 1]; + if (idx !== -1 && args[idx + 1] && !args[idx + 1].startsWith("-")) return args[idx + 1]; return undefined; } diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..e546fd7 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,190 @@ +/** Agent Plugins MCP configuration. The bundled upstream schema owns its shape. */ +import { readFile, mkdir, realpath } from "node:fs/promises"; +import { resolve, relative, isAbsolute, sep } from "node:path"; +import { isIP } from "node:net"; +import { validateHeaderName, validateHeaderValue } from "node:http"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { + agentPluginsMcpSchema, + AGENT_PLUGINS_MCP_SCHEMA_URL, +} from "mcp-schema"; +import type { AgentPluginsMcpConfig, AgentPluginsMcpServer, McpSpec } from "mcp-schema"; +import type { ValidationDiagnostic } from "./validate.js"; +import { McpParseError } from "./parse.js"; +import { snapshot } from "./snapshot.js"; +import type { SnapshotOptions, SnapshotTransport } from "./snapshot.js"; + +const ajv = new Ajv2020({ allErrors: true, strict: false }); +ajv.addSchema(agentPluginsMcpSchema); +const validateServer = ajv.getSchema(`${AGENT_PLUGINS_MCP_SCHEMA_URL}#/$defs/server`)!; +// Project just the envelope, so one invalid entry does not disable its siblings. +const validateEnvelope = ajv.compile({ + ...agentPluginsMcpSchema, + $id: undefined, + properties: { + ...agentPluginsMcpSchema.properties, + mcpServers: { ...agentPluginsMcpSchema.properties.mcpServers, additionalProperties: true }, + }, +}); + +export interface ConfigValidationResult { + valid: boolean; + topLevelValid: boolean; + /** Independently valid entries; empty if the envelope is invalid. */ + servers: Record; + diagnostics: ValidationDiagnostic[]; +} + +export interface ConfigValidationOptions { + /** If provided by a plugin loader, its manifest schema must target the same release. */ + pluginSchema?: string; +} + +/** Validate offline; never retrieve a schema or start a server. */ +export function validateConfig(value: unknown, options: ConfigValidationOptions = {}): ConfigValidationResult { + const diagnostics: ValidationDiagnostic[] = []; + const error = (path: string, message: string) => diagnostics.push({ severity: "error", path, message }); + const servers: Record = Object.create(null); + if (!validateEnvelope(value)) { + for (const issue of validateEnvelope.errors ?? []) error(issue.instancePath, issue.message ?? "Invalid configuration"); + return { valid: false, topLevelValid: false, servers, diagnostics }; + } + if (options.pluginSchema !== undefined && options.pluginSchema !== AGENT_PLUGINS_MCP_SCHEMA_URL.replace(/mcp\.schema\.json$/, "plugin.schema.json")) { + error("/$schema", "MCP configuration and plugin manifest must target the same supported Agent Plugins release"); + return { valid: false, topLevelValid: false, servers, diagnostics }; + } + const config = value as unknown as AgentPluginsMcpConfig; + for (const [name, server] of Object.entries(config.mcpServers)) { + const path = `/mcpServers/${name.replace(/~/g, "~0").replace(/\//g, "~1")}`; + if (!validateServer(server)) { + for (const issue of validateServer.errors ?? []) error(path + issue.instancePath, issue.message ?? "Invalid server"); + continue; + } + const before = diagnostics.length; + if (server.type === "stdio") { + // A relative executable is passed as one token, including spaces in its path. + const command = server.command; + if (command.includes("${PLUGIN_ROOT}") || command.includes("${PLUGIN_DATA}") || /[\x00-\x1f\x7f]/.test(command) || + (!command.startsWith("./") && (/[\s/\\'"`;|&<>$()]/.test(command) || command === "." || command === ".."))) { + error(`${path}/command`, "Expected a bare executable token or a plugin-relative ./ path; command placeholders are not supported"); + } + for (const key of Object.keys(server.env ?? {})) { + const reserved = process.platform === "win32" ? key.toUpperCase() : key; + if (reserved === "PLUGIN_ROOT" || reserved === "PLUGIN_DATA") error(`${path}/env`, "PLUGIN_ROOT and PLUGIN_DATA are client-managed"); + if (key.includes("=") || /\0/.test(key) || /\0/.test(server.env![key])) error(`${path}/env`, "Invalid process environment entry"); + } + } else { + try { + const url = new URL(server.url); + const host = url.hostname.replace(/^\[|\]$/g, ""); + const loopback = host === "localhost" || host === "::1" || (isIP(host) === 4 && host.startsWith("127.")); + if (!/^https?:\/\//i.test(server.url) || /[\s\x00-\x1f\x7f]/.test(server.url) || + url.username || url.password || server.url.includes("#") || server.url.slice(server.url.indexOf("://") + 3).split(/[/?]/, 1)[0].includes("@") || + (url.protocol !== "https:" && !(url.protocol === "http:" && loopback))) { + throw new Error(); + } + } catch { + error(`${path}/url`, "Expected an absolute HTTPS URL (HTTP is allowed only for loopback), without user information or a fragment"); + } + const names = new Set(); + for (const [name, value] of Object.entries(server.headers ?? {})) { + try { + validateHeaderName(name); + validateHeaderValue(name, value); + if (names.has(name.toLowerCase())) throw new Error(); + names.add(name.toLowerCase()); + } catch { + error(`${path}/headers`, "Invalid HTTP header or duplicate case-insensitive header name"); + } + } + } + if (diagnostics.length === before) servers[name] = server; + } + return { valid: diagnostics.length === 0, topLevelValid: true, servers, diagnostics }; +} + +function requireConfig(value: unknown): AgentPluginsMcpConfig { + const result = validateConfig(value); + if (!result.valid) throw new McpParseError(result.diagnostics.map((d) => `${d.path}: ${d.message}`).join("\n")); + return value as AgentPluginsMcpConfig; +} + +export function parseConfigString(content: string): AgentPluginsMcpConfig { + return requireConfig(JSON.parse(content)); +} + +export async function parseConfig(path: string): Promise { + return parseConfigString(await readFile(path, "utf8")); +} + +/** Create the standard configuration from authored server entries. */ +export function createConfig(servers: Record): AgentPluginsMcpConfig { + return requireConfig({ $schema: AGENT_PLUGINS_MCP_SCHEMA_URL, mcpServers: servers }); +} + +/** Generate a plugin config from a snapshot's explicit connection hints. No fields are inferred. */ +export function generateConfig(spec: McpSpec, serverName: string): string { + if (!serverName) throw new McpParseError("A server name is required for configuration output"); + if (!spec.transport) throw new McpParseError("Snapshot has no transport hints; supply an authored server entry to createConfig"); + const config = requireConfig({ $schema: AGENT_PLUGINS_MCP_SCHEMA_URL, mcpServers: { [serverName]: spec.transport } }); + return JSON.stringify(config, null, 2) + "\n"; +} + +export interface ConfigPaths { + /** Existing plugin root; resolved through the filesystem before use. */ + pluginRoot: string; + /** Caller-owned, per-installed-plugin persistent directory. Required for stdio. */ + pluginData?: string; +} + +/** Exact, single-pass expansion. Unknown placeholders and replacement text remain literal. */ +function expand(value: string, root: string, data: string): string { + return value.replace(/\$\{PLUGIN_(ROOT|DATA)\}/g, (_, name: string) => name === "ROOT" ? root : data); +} + +async function contained(root: string, path: string): Promise { + const actual = await realpath(path); + const rel = relative(root, actual); + if (isAbsolute(rel) || rel === ".." || rel.startsWith(`..${sep}`)) throw new McpParseError("Configured path escapes its filesystem-resolved root"); + return actual; +} + +/** Resolve one valid entry; invalid siblings remain isolated and are available in validateConfig diagnostics. */ +export async function resolveConfigServer(value: unknown, name: string, paths: ConfigPaths): Promise { + const result = validateConfig(value); + const server = result.servers[name]; + if (!server) { + throw new McpParseError(`Unavailable MCP server ${JSON.stringify(name)}: ${result.diagnostics.map((d) => `${d.path}: ${d.message}`).join("; ") || "entry not found"}`); + } + if (server.type !== "stdio") return { ...server }; + if (!paths.pluginData) throw new McpParseError("stdio configuration requires a dedicated pluginData directory"); + const root = await realpath(paths.pluginRoot); + await mkdir(resolve(paths.pluginData), { recursive: true }); + const data = await realpath(paths.pluginData); + const command = server.command.startsWith("./") ? await contained(root, resolve(root, server.command)) : server.command; + let cwd = root; + if (server.cwd !== undefined) { + const dataRooted = server.cwd === "${PLUGIN_DATA}" || server.cwd.startsWith("${PLUGIN_DATA}/"); + cwd = await contained(dataRooted ? data : root, resolve(root, expand(server.cwd, root, data))); + } + const env = Object.fromEntries(Object.entries(server.env ?? {}).map(([key, value]) => [key, expand(value, root, data)])); + return { + type: "stdio", command, cwd, + ...(server.args && { args: server.args.map((value) => expand(value, root, data)) }), + env: { ...env, PLUGIN_ROOT: root, PLUGIN_DATA: data }, + }; +} + +/** Snapshot one configured server using the existing MCP transport implementation. */ +export async function snapshotConfig( + config: unknown, serverName: string, options: ConfigPaths & Omit, +): Promise { + const transport = await resolveConfigServer(config, serverName, options); + const spec = await snapshot({ ...options, transport }); + // Keep portable authored paths, never resolved local paths, environment or credentials. + const server = (config as AgentPluginsMcpConfig).mcpServers[serverName]; + spec.transport = server.type === "stdio" + ? { type: server.type, command: server.command, ...(server.args && { args: server.args }), ...(server.cwd && { cwd: server.cwd }) } + : { type: server.type, url: server.url }; + return spec; +} diff --git a/src/index.ts b/src/index.ts index bc66178..bac7e95 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,6 +37,11 @@ export type { } from "mcp-schema"; export { MCP_SPEC_VERSION, mcpSpecSchema } from "mcp-schema"; +export { agentPluginsMcpSchema, AGENT_PLUGINS_MCP_SCHEMA_URL } from "mcp-schema"; +export type { AgentPluginsMcpConfig, AgentPluginsMcpServer } from "mcp-schema"; + +export { parseConfig, parseConfigString, validateConfig, createConfig, generateConfig, resolveConfigServer, snapshotConfig } from "./config.js"; +export type { ConfigValidationResult, ConfigValidationOptions, ConfigPaths } from "./config.js"; // Protocol identity export { diff --git a/src/parse.ts b/src/parse.ts index 2aa0406..838c096 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -50,6 +50,10 @@ export function parseString( const spec = raw as Record; + if ("mcpServers" in spec || (typeof spec.$schema === "string" && spec.$schema.startsWith("https://agent-plugins.org/"))) { + throw new McpParseError("This is an Agent Plugins connection configuration; use parseConfig/validateConfig (CLI: parse-config/validate-config), or snapshot --config --server to capture its server surface"); + } + if (typeof spec.mcpSpec !== "string") { throw new McpParseError('Missing or invalid "mcpSpec" version field'); } diff --git a/src/snapshot.ts b/src/snapshot.ts index 9153bd9..9eb87e7 100644 --- a/src/snapshot.ts +++ b/src/snapshot.ts @@ -72,6 +72,8 @@ export interface StdioTransport { args?: string[]; /** Environment variables. */ env?: Record; + /** Working directory for the subprocess. */ + cwd?: string; } export interface SseTransport { @@ -316,6 +318,7 @@ async function introspect( type: "stdio" as const, command: transport.command, ...(transport.args?.length && { args: transport.args }), + ...(transport.cwd && { cwd: transport.cwd }), } : { type: transport.type, url: transport.url }; @@ -410,9 +413,19 @@ export async function snapshot(options: SnapshotOptions): Promise { // --------------------------------------------------------------------------- function connectStdio(transport: StdioTransport, timeout: number): McpConnection { + const env: NodeJS.ProcessEnv = { ...process.env }; + for (const [key, value] of Object.entries(transport.env ?? {})) { + if (process.platform === "win32") { + for (const existing of Object.keys(env)) { + if (existing.toUpperCase() === key.toUpperCase()) delete env[existing]; + } + } + env[key] = value; + } const proc = spawn(transport.command, transport.args ?? [], { stdio: ["pipe", "pipe", "pipe"], - env: { ...process.env, ...transport.env }, + env, + cwd: transport.cwd, }); let nextId = 1; @@ -421,6 +434,15 @@ function connectStdio(transport: StdioTransport, timeout: number): McpConnection number, { resolve: (value: unknown) => void; reject: (reason: Error) => void } >(); + let failure: Error | undefined; + const fail = (error: Error) => { + failure = error; + for (const request of pending.values()) request.reject(error); + pending.clear(); + }; + proc.on("error", (error) => fail(new McpSnapshotError(`Could not start MCP server: ${error.message}`))); + proc.on("exit", () => fail(new McpSnapshotError("MCP server exited before responding"))); + proc.stdin.on("error", (error) => fail(new McpSnapshotError(`MCP stdin failed: ${error.message}`))); proc.stdout.on("data", (chunk: Buffer) => { buffer += chunk.toString(); @@ -449,6 +471,7 @@ function connectStdio(transport: StdioTransport, timeout: number): McpConnection return { send(method, params) { return new Promise((resolve, reject) => { + if (failure) { reject(failure); return; } const id = nextId++; const request: JsonRpcRequest = { jsonrpc: "2.0", @@ -458,12 +481,13 @@ function connectStdio(transport: StdioTransport, timeout: number): McpConnection }; pending.set(id, { resolve, reject }); proc.stdin.write(JSON.stringify(request) + "\n"); - setTimeout(() => { + const timer = setTimeout(() => { if (pending.has(id)) { pending.delete(id); reject(new McpSnapshotError(`Timeout waiting for response to ${method}`)); } }, timeout); + timer.unref(); }); }, notify(method, params) { @@ -481,13 +505,21 @@ function connectStdio(transport: StdioTransport, timeout: number): McpConnection // SSE transport // --------------------------------------------------------------------------- +/** Client-generated fields take precedence, including differently cased names. */ +function connectionHeaders(configured: Record | undefined, generated: Record): Headers { + const headers = new Headers(configured); + for (const name of ["host", "content-length", "transfer-encoding", "connection"]) headers.delete(name); + for (const [name, value] of Object.entries(generated)) headers.set(name, value); + return headers; +} + async function connectSse( transport: SseTransport, timeout: number, negotiation: Negotiation, ): Promise { - const baseUrl = transport.url.replace(/\/sse\/?$/, ""); let messageEndpoint: string | null = null; + let endpointError: Error | undefined; let nextId = 1; const pending = new Map< number, @@ -497,11 +529,11 @@ async function connectSse( // Connect to SSE endpoint and wait for the endpoint event const controller = new AbortController(); const sseResponse = await fetch(transport.url, { - headers: { + headers: connectionHeaders(transport.headers, { Accept: "text/event-stream", "MCP-Protocol-Version": negotiation.version, - ...transport.headers, - }, + }), + redirect: "error", signal: controller.signal, }); @@ -532,8 +564,17 @@ async function connectSse( } } if (eventType === "endpoint" && data) { - // The server tells us where to POST messages - messageEndpoint = data.startsWith("http") ? data : `${baseUrl}${data}`; + // Never send configured headers or messages to another origin. + try { + const endpoint = new URL(data, transport.url); + if (endpoint.origin !== new URL(transport.url).origin || endpoint.username || endpoint.password || endpoint.hash) { + throw new Error("SSE message endpoint must use the configured origin without user information or a fragment"); + } + messageEndpoint = endpoint.href; + } catch (error) { + endpointError = new McpSnapshotError(error instanceof Error ? error.message : "Invalid SSE endpoint"); + controller.abort(); + } } else if (eventType === "message" && data) { try { const msg = JSON.parse(data) as JsonRpcResponse; @@ -569,9 +610,10 @@ async function connectSse( // Wait for the endpoint event const endpointDeadline = Date.now() + timeout; - while (!messageEndpoint && Date.now() < endpointDeadline) { + while (!messageEndpoint && !endpointError && Date.now() < endpointDeadline) { await new Promise((r) => setTimeout(r, 50)); } + if (endpointError) throw endpointError; if (!messageEndpoint) { controller.abort(); throw new McpSnapshotError("Timeout waiting for SSE endpoint event"); @@ -591,23 +633,26 @@ async function connectSse( fetch(messageEndpoint!, { method: "POST", - headers: { + headers: connectionHeaders(transport.headers, { "Content-Type": "application/json", "MCP-Protocol-Version": negotiation.version, - ...transport.headers, - }, + }), + redirect: "error", body: JSON.stringify(request), + }).then(async (response) => { + if (!response.ok) throw await refusal(response); }).catch((err: Error) => { pending.delete(id); reject(new McpSnapshotError(`SSE POST failed: ${err.message}`)); }); - setTimeout(() => { + const timer = setTimeout(() => { if (pending.has(id)) { pending.delete(id); reject(new McpSnapshotError(`Timeout waiting for response to ${method}`)); } }, timeout); + timer.unref(); }); }, notify(method, params) { @@ -615,11 +660,11 @@ async function connectSse( if (params) msg.params = params; fetch(messageEndpoint!, { method: "POST", - headers: { + headers: connectionHeaders(transport.headers, { "Content-Type": "application/json", "MCP-Protocol-Version": negotiation.version, - ...transport.headers, - }, + }), + redirect: "error", body: JSON.stringify(msg), }).catch(() => {}); }, @@ -672,11 +717,11 @@ function connectStreamableHttp( function requestHeaders( method?: string, params?: Record, - ): Record { + ): Headers { // Stateless revisions mirror the method, and the tool, prompt, or resource // it names, into headers that intermediaries can route on. const name = params?.name ?? params?.uri; - return { + return connectionHeaders(transport.headers, { "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": negotiation.version, @@ -685,8 +730,7 @@ function connectStreamableHttp( ? { "Mcp-Name": headerValue(name) } : {}), ...(sessionId ? { "Mcp-Session-Id": sessionId } : {}), - ...transport.headers, - }; + }); } return { @@ -708,6 +752,7 @@ function connectStreamableHttp( headers: requestHeaders(method, params), body: JSON.stringify(request), signal: controller.signal, + redirect: "error", }); clearTimeout(timer); @@ -776,6 +821,7 @@ function connectStreamableHttp( method: "POST", headers: requestHeaders(method, params), body: JSON.stringify(msg), + redirect: "error", }).catch(() => {}); }, close() { @@ -785,6 +831,7 @@ function connectStreamableHttp( void fetch(transport.url, { method: "DELETE", headers: requestHeaders(), + redirect: "error", }).catch(() => {}); }, }; diff --git a/test/config.test.ts b/test/config.test.ts new file mode 100644 index 0000000..0192230 --- /dev/null +++ b/test/config.test.ts @@ -0,0 +1,161 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { + AGENT_PLUGINS_MCP_SCHEMA_URL, createConfig, generateConfig, parseConfigString, + parseString, resolveConfigServer, snapshotConfig, validateConfig, +} from "../src/index.js"; + +const run = promisify(execFile); +const config = (mcpServers: Record = {}) => ({ $schema: AGENT_PLUGINS_MCP_SCHEMA_URL, mcpServers }); +let temporary: string | undefined; +afterEach(async () => { vi.unstubAllEnvs(); if (temporary) await rm(temporary, { recursive: true, force: true }); temporary = undefined; }); + +async function paths() { + temporary = await mkdtemp(join(tmpdir(), "mcp-config-${PLUGIN_DATA}-")); + const pluginRoot = join(temporary, "plugin"); + const pluginData = join(temporary, "data"); + await mkdir(pluginRoot); + return { pluginRoot: await realpath(pluginRoot), pluginData }; +} + +describe("Agent Plugins configuration", () => { + it("parses the current official shape and keeps snapshot parsing unambiguous", () => { + const document = config({ api: { type: "streamable-http", url: "https://example.com/mcp" } }); + expect(parseConfigString(JSON.stringify(document))).toEqual(document); + expect(() => parseString(JSON.stringify(document))).toThrow(/parseConfig/); + expect(() => parseConfigString('{"mcpSpec":"0.3.2","server":{"name":"test","version":"fixture"}}')).toThrow(); + }); + + it("disables MCP for an invalid envelope, and isolates invalid entries otherwise", () => { + const document = config({ good: { type: "stdio", command: "node" }, bad: { type: "http", url: "https://example.com" } }); + const result = validateConfig(document); + expect(result).toMatchObject({ valid: false, topLevelValid: true }); + expect(Object.keys(result.servers)).toEqual(["good"]); + for (const invalid of [null, [], { mcpServers: {} }, { ...document, $schema: "urn:unsupported-test-schema" }, { ...document, tools: [] }, { ...document, mcpServers: [] }]) { + expect(validateConfig(invalid)).toMatchObject({ valid: false, topLevelValid: false, servers: {} }); + } + expect(validateConfig(config())).toMatchObject({ valid: true, topLevelValid: true }); + expect(validateConfig(document, { pluginSchema: "urn:unsupported-test-manifest" }).topLevelValid).toBe(false); + expect(validateConfig(config(), { pluginSchema: AGENT_PLUGINS_MCP_SCHEMA_URL.replace("mcp.schema", "plugin.schema") }).valid).toBe(true); + }); + + it.each([ + { type: "stdio", command: "node server.js" }, + { type: "stdio", command: "/usr/bin/node" }, + { type: "stdio", command: "../bin/server" }, + { type: "stdio", command: "${PLUGIN_ROOT}/server" }, + { type: "stdio", command: "node", cwd: "data" }, + { type: "stdio", command: "node", env: { PLUGIN_ROOT: "override" } }, + { type: "stdio", command: "node", env: { "BAD=NAME": "x" } }, + { type: "stdio", command: "node", url: "https://example.com" }, + { type: "streamable-http", url: "http://example.com/mcp" }, + { type: "streamable-http", url: "https://user:pass@example.com" }, + { type: "streamable-http", url: "https://@example.com" }, + { type: "streamable-http", url: "https://example.com/#" }, + { type: "streamable-http", url: "https://example.com/\npath" }, + { type: "streamable-http", url: "file:///tmp/mcp" }, + { type: "streamable-http", url: "https://example.com", headers: { "X-Tenant": "a", "x-tenant": "b" } }, + { type: "sse", url: "https://example.com", headers: { "Bad Name": "a" } }, + { type: "sse", url: "https://example.com", headers: { X: "a\r\nInjected: true" } }, + ])("rejects a nonconformant server: %j", (server) => { + expect(validateConfig(config({ server })).valid).toBe(false); + }); + + it.each(["https://example.com/mcp", "http://localhost:3000/mcp", "http://127.0.0.2/mcp", "http://[::1]/mcp"])("accepts remote URL %s", (url) => { + expect(validateConfig(config({ server: { type: "streamable-http", url } })).valid).toBe(true); + }); + + it("generates standard configuration from authored entries or explicit snapshot hints", () => { + const server = { type: "streamable-http" as const, url: "https://example.com/mcp" }; + const document = createConfig({ api: server }); + const snapshot = { mcpSpec: "0.3.2", server: { name: "api", version: "fixture" }, transport: server, tools: [] }; + expect(JSON.parse(generateConfig(snapshot, "api"))).toEqual(document); + expect(() => generateConfig({ ...snapshot, transport: undefined }, "api")).toThrow(/no transport/); + expect(() => generateConfig({ ...snapshot, transport: { type: "stdio", command: "/usr/bin/node" } }, "api")).toThrow(/executable/); + }); +}); + +describe("configured snapshot execution", () => { + it("launches a bundled executable with contained cwd, single-pass expansion and persistent data", async () => { + const p = await paths(); + const executable = join(p.pluginRoot, "server with spaces.mjs"); + await writeFile(executable, `#!/usr/bin/env node +import {readFileSync, writeFileSync} from 'node:fs'; +import {createInterface} from 'node:readline'; +const counter = process.env.PLUGIN_DATA + '/counter'; +let count = 0; try { count = Number(readFileSync(counter, 'utf8')); } catch {} +writeFileSync(counter, String(count + 1)); +for await (const line of createInterface({input: process.stdin})) { + const message = JSON.parse(line); if (message.id === undefined) continue; + const result = message.method === 'initialize' ? { protocolVersion: message.params.protocolVersion, serverInfo: {name:'config-test',version:'fixture'}, capabilities:{tools:{}} } + : {tools:[{name:'observed',inputSchema:{type:'object'},description:JSON.stringify({cwd:process.cwd(),root:process.env.PLUGIN_ROOT,data:process.env.PLUGIN_DATA,configured:process.env.CONFIGURED,args:process.argv.slice(2)})}]}; + process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:message.id,result}) + '\\n'); +} +`, { mode: 0o755 }); + vi.stubEnv("PLUGIN_ROOT", "ambient-root"); + vi.stubEnv("PLUGIN_DATA", "ambient-data"); + const document = config({ server: { type: "stdio", command: "./server with spaces.mjs", cwd: "${PLUGIN_DATA}", args: ["${PLUGIN_ROOT}", "${UNKNOWN}"], env: { CONFIGURED: "${PLUGIN_ROOT}|${PLUGIN_DATA}|${HOME}" } } }); + const first = await snapshotConfig(document, "server", { ...p, timeout: 2000 }); + const actualData = await realpath(p.pluginData); + expect(JSON.parse(first.tools![0].description!)).toEqual({ cwd: actualData, root: p.pluginRoot, data: actualData, configured: `${p.pluginRoot}|${actualData}|\${HOME}`, args: [p.pluginRoot, "${UNKNOWN}"] }); + expect(first.transport).toEqual({ type: "stdio", command: "./server with spaces.mjs", cwd: "${PLUGIN_DATA}", args: ["${PLUGIN_ROOT}", "${UNKNOWN}"] }); + expect(JSON.stringify(first)).not.toContain("ambient-data"); + await snapshotConfig(document, "server", { ...p, timeout: 2000 }); + expect(await readFile(join(p.pluginData, "counter"), "utf8")).toBe("2"); + }); + + it("defaults cwd to the resolved plugin root and leaves arguments opaque", async () => { + const p = await paths(); + const transport = await resolveConfigServer(config({ server: { type: "stdio", command: "node", args: ["../../opaque", "${HOME}"] } }), "server", p); + expect(transport).toMatchObject({ command: "node", cwd: p.pluginRoot, args: ["../../opaque", "${HOME}"] }); + await expect(resolveConfigServer(config({ server: { type: "stdio", command: "node" } }), "server", { pluginRoot: p.pluginRoot })).rejects.toThrow(/pluginData/); + }); + + it("contains real command and cwd paths, including symlinks and traversal", async () => { + const p = await paths(); + await writeFile(join(temporary!, "outside"), "outside"); + await writeFile(join(p.pluginRoot, "inside"), "inside"); + await symlink(join(temporary!, "outside"), join(p.pluginRoot, "escape")); + await symlink(join(p.pluginRoot, "inside"), join(p.pluginRoot, "allowed")); + for (const server of [ + { type: "stdio", command: "./escape" }, + { type: "stdio", command: "./../outside" }, + { type: "stdio", command: "node", cwd: "./.." }, + { type: "stdio", command: "node", cwd: "${PLUGIN_DATA}/.." }, + ]) await expect(resolveConfigServer(config({ server }), "server", p)).rejects.toThrow(/escapes/); + expect(await resolveConfigServer(config({ server: { type: "stdio", command: "./allowed" } }), "server", p)).toMatchObject({ command: join(p.pluginRoot, "inside") }); + }); + + it("selects a valid sibling and never expands remote strings", async () => { + const server = { type: "streamable-http", url: "https://example.com/${PLUGIN_ROOT}", headers: { "X-Tenant": "${PLUGIN_DATA}" } }; + const document = config({ server, invalid: { type: "unknown" } }); + expect(await resolveConfigServer(document, "server", { pluginRoot: "/unused" })).toEqual(server); + await expect(resolveConfigServer(document, "invalid", { pluginRoot: "/unused" })).rejects.toThrow(/Unavailable/); + }); + + it("reports spawn failures without an uncaught process error", async () => { + const p = await paths(); + await expect(snapshotConfig(config({ server: { type: "stdio", command: "missing-mcp-config-test-executable" } }), "server", { ...p, timeout: 1000 })).rejects.toThrow(/Could not start/); + }); + + it("validates, generates and snapshots through the packaged CLI without overwriting config", async () => { + const p = await paths(); + const input = join(p.pluginRoot, "mcp.json"); + const output = join(p.pluginRoot, "snapshot.json"); + const script = resolve("test/fixtures/fake-stdio-server.mjs"); + const document = config({ server: { type: "stdio", command: "node", args: [script] } }); + await writeFile(input, JSON.stringify(document)); + const cli = resolve("dist/cli.js"); + expect((await run(process.execPath, [cli, "validate-config", input])).stdout).toContain("Valid Agent Plugins"); + await run(process.execPath, [cli, "snapshot", "--config", input, "--server", "server", "--plugin-data", p.pluginData, "-o", output]); + const snapshot = JSON.parse(await readFile(output, "utf8")); + expect(snapshot.tools).toHaveLength(3); + expect(JSON.parse((await run(process.execPath, [cli, "generate", output, "--format", "agent-plugin", "--server", "server"])).stdout)).toEqual(document); + await expect(run(process.execPath, [cli, "snapshot", "--config", input, "--server", "server", "-o", input])).rejects.toThrow(/must differ/); + expect(JSON.parse(await readFile(input, "utf8"))).toEqual(document); + }); +}); diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts index 5114be2..d481385 100644 --- a/test/snapshot.test.ts +++ b/test/snapshot.test.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; import { MCP_PARSER_CLIENT_INFO, MCP_PROTOCOL_VERSION } from "../src/protocol.js"; import { snapshot } from "../src/snapshot.js"; +import { createConfig, snapshotConfig } from "../src/config.js"; const stdioServerPath = fileURLToPath( new URL("./fixtures/fake-stdio-server.mjs", import.meta.url), @@ -141,7 +142,7 @@ async function startStatefulHttpServer( } /** Legacy HTTP+SSE server: announces its message endpoint, then streams replies. */ -async function startSseServer(): Promise { +async function startSseServer(endpoint = "/messages"): Promise { const requests: RecordedRequest[] = []; let stream: import("node:http").ServerResponse | null = null; @@ -161,7 +162,7 @@ async function startSseServer(): Promise { connection: "keep-alive", }); stream = response; - response.write("event: endpoint\ndata: /messages\n\n"); + response.write(`event: endpoint\ndata: ${endpoint}\n\n`); return; } @@ -301,6 +302,23 @@ describe("snapshot over stdio", () => { }); describe("snapshot over streamable HTTP", () => { + it("snapshots a configured endpoint and lets generated headers override case-insensitive package fields", async () => { + running = await startStatefulHttpServer({ pages: 1 }); + const config = createConfig({ api: { + type: "streamable-http", url: running.url, + headers: { "mcp-protocol-version": "package-value", "content-type": "text/plain", "mcp-session-id": "package-session", "X-Tenant": "${PLUGIN_ROOT}" }, + } }); + const spec = await snapshotConfig(config, "api", { pluginRoot: "/unused" }); + expect(spec.tools).toHaveLength(1); + expect(spec.transport).toEqual({ type: "streamable-http", url: running.url }); + for (const entry of running.requests) { + expect(entry.headers["mcp-protocol-version"]).toBe(MCP_PROTOCOL_VERSION); + expect(entry.headers["content-type"]).toBe("application/json"); + expect(entry.headers["x-tenant"]).toBe("${PLUGIN_ROOT}"); + if ((entry.body as { method?: string })?.method !== "initialize") expect(entry.headers["mcp-session-id"]).toBe("session-abc-123"); + } + }); + it("requests the pinned protocol revision and identifies this package", async () => { running = await startStatefulHttpServer({ pages: 1 }); const spec = await snapshot({ transport: { type: "streamable-http", url: running.url } }); @@ -428,6 +446,23 @@ describe("snapshot at a chosen protocol revision", () => { }); describe("snapshot over legacy SSE", () => { + it("snapshots a configured legacy server with literal headers and generated protocol fields", async () => { + running = await startSseServer(); + const config = createConfig({ legacy: { type: "sse", url: running.url, headers: { "MCP-Protocol-Version": "package-value", "X-Tenant": "${PLUGIN_DATA}" } } }); + const spec = await snapshotConfig(config, "legacy", { pluginRoot: "/unused", timeout: 1000 }); + expect(spec.tools).toHaveLength(3); + for (const entry of running.requests) { + expect(entry.headers["mcp-protocol-version"]).toBe(MCP_PROTOCOL_VERSION); + expect(entry.headers["x-tenant"]).toBe("${PLUGIN_DATA}"); + } + }); + + it("refuses an SSE endpoint event that changes origins", async () => { + running = await startSseServer("https://example.org/messages"); + await expect(snapshot({ transport: { type: "sse", url: running.url, headers: { "X-Tenant": "public" } }, timeout: 1000 })).rejects.toThrow(/configured origin/); + expect(running.requests).toHaveLength(1); + }); + it("sends the protocol revision on the stream and on posted messages", async () => { running = await startSseServer(); const spec = await snapshot({ transport: { type: "sse", url: running.url }, timeout: 10_000 }); @@ -445,6 +480,22 @@ describe("snapshot over legacy SSE", () => { }); }); +describe("remote redirects", () => { + it.each(["streamable-http", "sse"] as const)("does not forward %s headers or messages through redirects", async (type) => { + let received = 0; + const destination = createServer((_request, response) => { received++; response.writeHead(500).end(); }); + const target = await listen(destination); + const origin = createServer((_request, response) => response.writeHead(307, { location: target }).end()); + const url = await listen(origin); + try { + await expect(snapshot({ transport: { type, url, headers: { "X-Tenant": "public" } }, timeout: 1000 })).rejects.toThrow(); + expect(received).toBe(0); + } finally { + await Promise.all([new Promise((resolve) => origin.close(() => resolve())), new Promise((resolve) => destination.close(() => resolve()))]); + } + }); +}); + describe("public protocol exports", () => { it("re-exports the revision helpers the changelog documents", async () => { const api = await import("../src/index.js");