diff --git a/.changeset/aie-1355-cli-mcp-dual-era.md b/.changeset/aie-1355-cli-mcp-dual-era.md new file mode 100644 index 00000000..743010ee --- /dev/null +++ b/.changeset/aie-1355-cli-mcp-dual-era.md @@ -0,0 +1,8 @@ +--- +"clerk": patch +--- + +Support MCP 2026-07-28 servers in `clerk doctor` and `clerk mcp run`. + +- `clerk doctor` now probes with the modern `server/discover` handshake first and falls back to the legacy `initialize` handshake, so servers speaking only the 2026-07-28 revision are reported as healthy while older servers keep probing correctly. +- `clerk mcp run` now sends the request-metadata headers the 2026-07-28 revision requires (`MCP-Protocol-Version`, `Mcp-Method`, `Mcp-Name`, and `Mcp-Param-*` mirroring for `x-mcp-header` tool parameters), so strict new-spec servers no longer reject relayed requests, and it stays connected to stateless servers that never issue a session id. diff --git a/packages/cli-core/src/commands/doctor/README.md b/packages/cli-core/src/commands/doctor/README.md index 443043cc..d84239c3 100644 --- a/packages/cli-core/src/commands/doctor/README.md +++ b/packages/cli-core/src/commands/doctor/README.md @@ -25,17 +25,17 @@ clerk doctor --fix # Offer to auto-fix issues ## Checks -| Check | Category | What it verifies | -| --------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Authentication token | Authentication | Credential store has a stored token | -| Token validity | Authentication | Token is still valid (calls `/oauth/userinfo`) | -| Project linkage | Project | Current directory is linked to a Clerk app | -| Linked application | Project | Linked application ID is accessible via the API | -| Instances | Project | Configured dev/prod instance IDs match the application's instances | -| Environment variables | Environment | .env.local or .env has Clerk keys | -| CLI configuration | Configuration | CLI config file exists and parses | -| Shell completion | Configuration | Shell autocompletion is installed for the detected shell | -| MCP server | Integration | If a Clerk MCP entry is installed, every distinct configured server answers the `initialize` handshake; warns on an unreadable client config (skipped when nothing is installed; warns, never fails) | +| Check | Category | What it verifies | +| --------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Authentication token | Authentication | Credential store has a stored token | +| Token validity | Authentication | Token is still valid (calls `/oauth/userinfo`) | +| Project linkage | Project | Current directory is linked to a Clerk app | +| Linked application | Project | Linked application ID is accessible via the API | +| Instances | Project | Configured dev/prod instance IDs match the application's instances | +| Environment variables | Environment | .env.local or .env has Clerk keys | +| CLI configuration | Configuration | CLI config file exists and parses | +| Shell completion | Configuration | Shell autocompletion is installed for the detected shell | +| MCP server | Integration | If a Clerk MCP entry is installed, every distinct configured server answers a dual-era handshake probe (modern `server/discover` first, body-aware fallback to the legacy `initialize`); warns on an unreadable client config (skipped when nothing is installed; warns, never fails) | ### Keyless applications diff --git a/packages/cli-core/src/commands/mcp/README.md b/packages/cli-core/src/commands/mcp/README.md index cc79872b..7872426c 100644 --- a/packages/cli-core/src/commands/mcp/README.md +++ b/packages/cli-core/src/commands/mcp/README.md @@ -14,8 +14,9 @@ no flags or profile setup (see [Development](#development) for the override order). No Clerk API endpoints are called. To verify the server is reachable, run -`clerk doctor` — its MCP check performs the `initialize` handshake against each -distinct configured URL whenever a Clerk MCP entry is installed. +`clerk doctor` — its MCP check performs a dual-era handshake probe against each +distinct configured URL whenever a Clerk MCP entry is installed (see the +Reachability note under [`clerk mcp uninstall`](#clerk-mcp-uninstall)). ## Supported clients @@ -176,11 +177,32 @@ writes replies to stdout. stdout carries **only** JSON-RPC frames; all diagnostics go to stderr. It takes no flags — the server URL is resolved from `CLERK_MCP_URL` / the active env profile / the hosted default at spawn time. -Transport-only: a `401`/`403` from the upstream before a session exists (the -initial handshake) throws `mcp_client_config_invalid` and kills the bridge; -once a session id exists, the same status is instead answered per-request as a -JSON-RPC error (`-32001`, "requires authentication") and the bridge keeps -running. +Every relayed POST carries the MCP 2026-07-28 request-metadata headers, +derived from the body so strict servers never reject with `HeaderMismatch` +(`-32020`): + +- `Mcp-Method` — the body's `method`, on every request. +- `Mcp-Name` — `params.name` / `params.uri` on `tools/call`, `resources/read`, + and `prompts/get`; values that aren't header-safe ASCII are carried in the + spec's Base64 sentinel form (`=?base64?…?=`). +- `MCP-Protocol-Version` — the request's own + `_meta["io.modelcontextprotocol/protocolVersion"]` when the client declares + one (modern clients), otherwise the version negotiated at the legacy + `initialize`. +- `Mcp-Param-*` — tool parameters designated by `x-mcp-header` annotations in + the tool's `inputSchema`, learned from `tools/list` responses as they pass + through the bridge. A tool whose annotations violate the spec's constraints + is excluded from the forwarded `tools/list` result (with a stderr warning), + as the spec requires of Streamable HTTP clients. + +Session tracking is tolerant of stateless servers: 2026-07-28 servers never +mint `Mcp-Session-Id`, so the bridge treats "any request has succeeded" — not +"a session id exists" — as the sign of a working connection. + +Transport-only: a `401`/`403` from the upstream before any request has +succeeded throws `mcp_client_config_invalid` and kills the bridge; after that, +the same status is instead answered per-request as a JSON-RPC error (`-32001`, +"requires authentication") and the bridge keeps running. ### `clerk mcp uninstall` @@ -205,11 +227,22 @@ editor session, so (in human mode) it prints a next step to reload each affected editor. > **Reachability:** there is no `mcp doctor` subcommand. Server health is part -> of `clerk doctor`, which probes each distinct configured MCP URL via the -> `initialize` handshake when an entry is installed (warns, does not fail, when -> any is unreachable). A `401`/`403` answer counts as reachable — the server is -> there, it just gates the handshake behind the OAuth flow the editor runs -> itself — and is reported as "authentication required". +> of `clerk doctor`, which probes each distinct configured MCP URL when an +> entry is installed (warns, does not fail, when any is unreachable). The probe +> implements the MCP 2026-07-28 backward-compatibility algorithm: a modern +> `server/discover` request first (with per-request `_meta` and the +> `MCP-Protocol-Version`/`Mcp-Method` headers); on a `400`, the response body +> is inspected — a recognized modern JSON-RPC error (`-32020` +> `HeaderMismatch`, `-32021` `MissingRequiredClientCapability`, `-32022` +> `UnsupportedProtocolVersion`) proves a modern server, so the probe never +> falls back: a capability rejection counts as reachable (the server is +> demonstrably there, just gated), and an unsupported-version rejection is +> retried once with an advertised supported version. Anything else (including +> reserved MCP codes on non-400 statuses, e.g. proxy error pages) falls back +> to the legacy `initialize` handshake so pre-2026 servers still probe +> correctly. A `401`/`403` answer at either stage counts as reachable — +> the server is there, it just gates the handshake behind the OAuth flow the +> editor runs itself — and is reported as "authentication required". ## Development diff --git a/packages/cli-core/src/commands/mcp/headers.test.ts b/packages/cli-core/src/commands/mcp/headers.test.ts new file mode 100644 index 00000000..6787fd9e --- /dev/null +++ b/packages/cli-core/src/commands/mcp/headers.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, test } from "bun:test"; +import { + MCP_ERROR_CODE, + encodeHeaderValue, + extractToolHeaderAnnotations, + headerValueForParam, + isModernErrorCode, + isHeaderSafe, +} from "./headers.ts"; + +describe("encodeHeaderValue", () => { + test.each([ + ["us-west1", "us-west1"], + ["file:///projects/myapp/config.json", "file:///projects/myapp/config.json"], + ["Hello, 世界", "=?base64?SGVsbG8sIOS4lueVjA==?="], + [" padded ", "=?base64?IHBhZGRlZCA=?="], + ["line1\nline2", "=?base64?bGluZTEKbGluZTI=?="], + ["=?base64?literal?=", "=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?="], + ])("encodes %j as %j", (input, expected) => { + expect(encodeHeaderValue(input)).toBe(expected); + }); +}); + +describe("isHeaderSafe", () => { + test.each([ + ["tools/call", true], + ["server/discover", true], + ["", false], + ["a\nb", false], + ["café", false], + [" leading", false], + ["trailing ", false], + ["mid space ok", true], + ])("%j -> %p", (value, expected) => { + expect(isHeaderSafe(value)).toBe(expected); + }); +}); + +describe("isModernErrorCode", () => { + test.each([ + [MCP_ERROR_CODE.HEADER_MISMATCH, true], + [MCP_ERROR_CODE.MISSING_REQUIRED_CLIENT_CAPABILITY, true], + [MCP_ERROR_CODE.UNSUPPORTED_PROTOCOL_VERSION, true], + [-32601, false], + [-32000, false], + ])("%i -> %p", (code, expected) => { + expect(isModernErrorCode(code)).toBe(expected); + }); +}); + +describe("extractToolHeaderAnnotations", () => { + const tool = (inputSchema: unknown): unknown => ({ name: "t", inputSchema }); + + test("collects annotations on top-level primitive properties", () => { + const result = extractToolHeaderAnnotations( + tool({ + type: "object", + properties: { + region: { type: "string", "x-mcp-header": "Region" }, + query: { type: "string" }, + }, + }), + ); + expect(result).toEqual({ + ok: true, + annotations: [{ header: "Region", path: ["region"] }], + }); + }); + + test("collects annotations on nested properties reachable via properties chains", () => { + const result = extractToolHeaderAnnotations( + tool({ + type: "object", + properties: { + options: { + type: "object", + properties: { tenant: { type: "string", "x-mcp-header": "Tenant" } }, + }, + }, + }), + ); + expect(result).toEqual({ + ok: true, + annotations: [{ header: "Tenant", path: ["options", "tenant"] }], + }); + }); + + test("a tool with no annotations is valid with none", () => { + const result = extractToolHeaderAnnotations( + tool({ type: "object", properties: { q: { type: "string" } } }), + ); + expect(result).toEqual({ ok: true, annotations: [] }); + }); + + test.each([ + ["empty name", { type: "string", "x-mcp-header": "" }], + ["non-string name", { type: "string", "x-mcp-header": 7 }], + ["invalid token chars", { type: "string", "x-mcp-header": "bad name" }], + ["CR/LF in name", { type: "string", "x-mcp-header": "a\r\nb" }], + ["number type", { type: "number", "x-mcp-header": "N" }], + ["object type", { type: "object", "x-mcp-header": "O" }], + ])("rejects the tool on %s", (_label, property) => { + const result = extractToolHeaderAnnotations( + tool({ type: "object", properties: { p: property } }), + ); + expect(result.ok).toBe(false); + }); + + test("rejects duplicate names case-insensitively", () => { + const result = extractToolHeaderAnnotations( + tool({ + type: "object", + properties: { + a: { type: "string", "x-mcp-header": "Region" }, + b: { type: "string", "x-mcp-header": "region" }, + }, + }), + ); + expect(result.ok).toBe(false); + }); + + test.each([ + ["items", { type: "array", items: { type: "string", "x-mcp-header": "X" } }], + ["oneOf", { oneOf: [{ type: "string", "x-mcp-header": "X" }] }], + ["anyOf", { anyOf: [{ type: "string", "x-mcp-header": "X" }] }], + ["allOf", { allOf: [{ type: "string", "x-mcp-header": "X" }] }], + ["not", { not: { type: "string", "x-mcp-header": "X" } }], + ["if", { if: { type: "string", "x-mcp-header": "X" } }], + ["$defs", { $defs: { d: { type: "string", "x-mcp-header": "X" } } }], + ])("rejects an annotation reached through %s", (_label, property) => { + const result = extractToolHeaderAnnotations( + tool({ type: "object", properties: { p: property } }), + ); + expect(result.ok).toBe(false); + }); + + test("a properties chain nested under items is not reachable", () => { + const result = extractToolHeaderAnnotations( + tool({ + type: "object", + properties: { + list: { + type: "array", + items: { + type: "object", + properties: { x: { type: "string", "x-mcp-header": "X" } }, + }, + }, + }, + }), + ); + expect(result.ok).toBe(false); + }); + + test("a tool without an object inputSchema is valid with no annotations", () => { + expect(extractToolHeaderAnnotations({ name: "t" })).toEqual({ ok: true, annotations: [] }); + }); +}); + +describe("headerValueForParam", () => { + test.each([ + [{ region: "us-west1" }, ["region"], "us-west1"], + [{ flag: true }, ["flag"], "true"], + [{ flag: false }, ["flag"], "false"], + [{ n: 42 }, ["n"], "42"], + [{ n: -7 }, ["n"], "-7"], + [{ opts: { tenant: "acme" } }, ["opts", "tenant"], "acme"], + ])("extracts %j at %j as %j", (args, path, expected) => { + expect(headerValueForParam(args, path)).toBe(expected); + }); + + test.each([ + ["missing value", {}, ["region"]], + ["null value", { region: null }, ["region"]], + ["non-integer number", { n: 1.5 }, ["n"]], + ["unsafe integer", { n: 2 ** 53 }, ["n"]], + ["object value", { region: {} }, ["region"]], + ["missing intermediate", { a: "x" }, ["a", "b"]], + ["non-record arguments", "nope", ["a"]], + ])("omits on %s", (_label, args, path) => { + expect(headerValueForParam(args, path)).toBeUndefined(); + }); +}); diff --git a/packages/cli-core/src/commands/mcp/headers.ts b/packages/cli-core/src/commands/mcp/headers.ts new file mode 100644 index 00000000..da528f06 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/headers.ts @@ -0,0 +1,157 @@ +/** + * MCP 2026-07-28 request-metadata helpers, shared by the `doctor` probe and + * the `clerk mcp run` bridge. + * + * The 2026-07-28 revision mirrors selected JSON-RPC body fields into HTTP + * headers (`MCP-Protocol-Version`, `Mcp-Method`, `Mcp-Name`, `Mcp-Param-*`) so + * intermediaries can route without parsing bodies. Servers reject any + * header/body mismatch with `HeaderMismatch` (-32020), which is why every + * value here is derived from the body it accompanies, never invented. + * Spec: https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http + */ + +import { isRecord } from "../../lib/objects.ts"; + +/** Newest protocol revision the CLI speaks; used for modern-first probing. */ +export const MODERN_PROTOCOL_VERSION = "2026-07-28"; + +/** `_meta` keys the 2026-07-28 revision defines for per-request metadata. */ +export const META_PROTOCOL_VERSION = "io.modelcontextprotocol/protocolVersion"; +export const META_CLIENT_INFO = "io.modelcontextprotocol/clientInfo"; +export const META_CLIENT_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities"; +export const META_SERVER_INFO = "io.modelcontextprotocol/serverInfo"; + +/** + * JSON-RPC error codes the MCP spec reserves for itself (-32020 to -32099). + * Receiving one proves the far side speaks a modern (2026-07-28+) revision — + * the signal the backward-compatibility algorithm keys on before falling back + * to a legacy `initialize`. + */ +export const MCP_ERROR_CODE = { + HEADER_MISMATCH: -32020, + MISSING_REQUIRED_CLIENT_CAPABILITY: -32021, + UNSUPPORTED_PROTOCOL_VERSION: -32022, +} as const; + +const MODERN_ERROR_CODES: ReadonlySet = new Set(Object.values(MCP_ERROR_CODE)); + +/** True when a JSON-RPC error code identifies a modern (2026-07-28+) server. */ +export function isModernErrorCode(code: number): boolean { + return MODERN_ERROR_CODES.has(code); +} + +// Deliberately stricter than RFC 9110 (which also allows htab): only visible +// ASCII with interior spaces travels plain; everything else — including htab +// and leading/trailing whitespace, which HTTP parsing would strip — is +// Base64-encoded. Over-encoding is always safe; under-encoding never is. +const SAFE_HEADER_VALUE = /^[\x21-\x7e][\x20-\x7e]*$/; +const BASE64_SENTINEL = /^=\?base64\?.*\?=$/; + +/** True when a value can travel as a plain HTTP header value untouched. */ +export function isHeaderSafe(value: string): boolean { + return SAFE_HEADER_VALUE.test(value) && !value.endsWith(" "); +} + +/** + * Encode a value for an `Mcp-Name` or `Mcp-Param-*` header. Header-safe ASCII + * passes through; anything else (non-ASCII, control chars, padding whitespace, + * or a value that itself matches the sentinel pattern) is carried as + * `=?base64??=` per the spec's Value Encoding rules. + */ +export function encodeHeaderValue(value: string): string { + if (isHeaderSafe(value) && !BASE64_SENTINEL.test(value)) return value; + return `=?base64?${Buffer.from(value, "utf8").toString("base64")}?=`; +} + +/** A tool parameter designated for header mirroring via `x-mcp-header`. */ +export interface HeaderAnnotation { + /** Name portion of the resulting `Mcp-Param-{name}` header. */ + header: string; + /** Chain of `properties` keys locating the parameter in the arguments. */ + path: string[]; +} + +export type ToolAnnotationResult = + | { ok: true; annotations: HeaderAnnotation[] } + | { ok: false; reason: string }; + +// HTTP field-name token syntax (RFC 9110 `1*tchar`). +const TCHAR = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; +const PRIMITIVE_TYPES = new Set(["string", "integer", "boolean"]); + +/** + * Collect the `x-mcp-header` annotations of a tool definition, validating the + * spec's constraints. A single violation anywhere in the schema invalidates + * the whole tool — the caller must exclude it from `tools/list` results, so a + * malformed definition can't be called with silently missing headers. + */ +export function extractToolHeaderAnnotations(tool: unknown): ToolAnnotationResult { + if (!isRecord(tool)) return { ok: false, reason: "tool is not an object" }; + const schema = tool.inputSchema; + if (!isRecord(schema)) return { ok: true, annotations: [] }; + + const annotations: HeaderAnnotation[] = []; + const seen = new Set(); + // Only chains made purely of `properties` keys are statically reachable; an + // annotation under `items`, composition/conditional keywords, or `$ref` + // invalidates the tool. Walk everything so those stray annotations surface. + const walk = (node: unknown, reachable: boolean, path: string[]): string | undefined => { + if (Array.isArray(node)) { + for (const item of node) { + const problem = walk(item, false, path); + if (problem) return problem; + } + return undefined; + } + if (!isRecord(node)) return undefined; + if ("x-mcp-header" in node) { + const name = node["x-mcp-header"]; + if (!reachable) return `"x-mcp-header" at an unreachable schema location`; + if (typeof name !== "string" || name.length === 0 || !TCHAR.test(name)) { + return `invalid "x-mcp-header" name ${JSON.stringify(name)}`; + } + if (seen.has(name.toLowerCase())) return `duplicate "x-mcp-header" name "${name}"`; + if (typeof node.type !== "string" || !PRIMITIVE_TYPES.has(node.type)) { + return `"x-mcp-header" on non-primitive type ${JSON.stringify(node.type)}`; + } + seen.add(name.toLowerCase()); + annotations.push({ header: name, path }); + } + for (const [key, value] of Object.entries(node)) { + if (key === "x-mcp-header") continue; + if (key === "properties" && reachable && isRecord(value)) { + for (const [prop, sub] of Object.entries(value)) { + const problem = walk(sub, true, [...path, prop]); + if (problem) return problem; + } + continue; + } + const problem = walk(value, false, path); + if (problem) return problem; + } + return undefined; + }; + + const problem = walk(schema, true, []); + if (problem) return { ok: false, reason: problem }; + return { ok: true, annotations }; +} + +/** + * Read the annotated parameter's value from `tools/call` arguments and render + * it as a header string. Returns `undefined` (header omitted, per spec) when + * the value is absent, `null`, or not a mirrorable primitive. + */ +export function headerValueForParam(args: unknown, path: string[]): string | undefined { + let value: unknown = args; + for (const key of path) { + if (!isRecord(value)) return undefined; + value = value[key]; + } + if (typeof value === "string") return value; + if (typeof value === "boolean") return value ? "true" : "false"; + if (typeof value === "number" && Number.isInteger(value) && Number.isSafeInteger(value)) { + return String(value); + } + return undefined; +} diff --git a/packages/cli-core/src/commands/mcp/probe.test.ts b/packages/cli-core/src/commands/mcp/probe.test.ts index d09c7fe2..87a7dc0d 100644 --- a/packages/cli-core/src/commands/mcp/probe.test.ts +++ b/packages/cli-core/src/commands/mcp/probe.test.ts @@ -10,6 +10,19 @@ const INITIALIZE_RESULT = { id: 1, }; +const DISCOVER_RESULT = { + jsonrpc: "2.0", + id: 1, + result: { + resultType: "complete", + supportedVersions: ["2026-07-28"], + capabilities: { tools: {} }, + _meta: { + "io.modelcontextprotocol/serverInfo": { name: "Clerk MCP Server", version: "1.0.0" }, + }, + }, +}; + function jsonResponse(payload: unknown, status = 200): Response { return new Response(JSON.stringify(payload), { status, @@ -28,6 +41,27 @@ function sse(body: string): Response { return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } }); } +interface Recorded { + body: Record; + headers: Headers; +} + +/** Stub upstream with one handler per request body `method`. */ +function stubByMethod(handlers: Record Response>): Recorded[] { + const requests: Recorded[] = []; + stubFetch(async (_input, init) => { + const rec: Recorded = { + body: JSON.parse(String(init?.body)) as Record, + headers: new Headers(init?.headers), + }; + requests.push(rec); + const handler = handlers[String(rec.body.method)]; + if (!handler) throw new Error(`unstubbed method ${String(rec.body.method)}`); + return handler(rec); + }); + return requests; +} + describe("probeMcp", () => { useCaptureLog(); const originalFetch = globalThis.fetch; @@ -36,36 +70,193 @@ describe("probeMcp", () => { globalThis.fetch = originalFetch; }); - test.each([ - ["text/event-stream", sseResponse(INITIALIZE_RESULT)], - ["application/json", jsonResponse(INITIALIZE_RESULT)], - ])("returns ok with the server name via %s", async (_label, response) => { - stubFetch(async () => response); + // ── Modern era (2026-07-28-only and dual-era servers) ────────────────────── + + test("reports a modern server healthy from server/discover alone", async () => { + const requests = stubByMethod({ "server/discover": () => jsonResponse(DISCOVER_RESULT) }); + + expect(await probeMcp(URL)).toEqual({ ok: true, status: 200, serverName: "Clerk MCP Server" }); + expect(requests).toHaveLength(1); + }); + + test("sends modern per-request metadata and headers on server/discover", async () => { + const requests = stubByMethod({ "server/discover": () => jsonResponse(DISCOVER_RESULT) }); + + await probeMcp(URL); + + const [discover] = requests; + expect(discover!.headers.get("mcp-protocol-version")).toBe("2026-07-28"); + expect(discover!.headers.get("mcp-method")).toBe("server/discover"); + const params = discover!.body.params as Record>; + expect(params._meta!["io.modelcontextprotocol/protocolVersion"]).toBe("2026-07-28"); + expect(params._meta!["io.modelcontextprotocol/clientCapabilities"]).toEqual({}); + }); + + test("parses a server/discover result delivered over SSE", async () => { + stubByMethod({ "server/discover": () => sseResponse(DISCOVER_RESULT) }); + expect(await probeMcp(URL)).toMatchObject({ ok: true, serverName: "Clerk MCP Server" }); + }); + + test("reports a modern server without serverInfo healthy with a placeholder name", async () => { + // `io.modelcontextprotocol/serverInfo` is only SHOULD-required. + const bare = { jsonrpc: "2.0", id: 1, result: { supportedVersions: ["2026-07-28"] } }; + stubByMethod({ "server/discover": () => jsonResponse(bare) }); + expect(await probeMcp(URL)).toMatchObject({ ok: true, serverName: "unnamed MCP server" }); + }); + + test("retries with an advertised version on UnsupportedProtocolVersionError instead of falling back", async () => { + const versions: (string | undefined)[] = []; + const requests = stubByMethod({ + "server/discover": (req) => { + const params = req.body.params as Record>; + const version = params._meta?.["io.modelcontextprotocol/protocolVersion"]; + versions.push(typeof version === "string" ? version : undefined); + if (version !== "2027-01-01") { + return jsonResponse( + { + jsonrpc: "2.0", + id: 1, + error: { + code: -32022, + message: "Unsupported protocol version", + data: { supported: ["2027-01-01"], requested: "2026-07-28" }, + }, + }, + 400, + ); + } + return jsonResponse(DISCOVER_RESULT); + }, + }); + + expect(await probeMcp(URL)).toMatchObject({ ok: true, serverName: "Clerk MCP Server" }); + expect(versions).toEqual(["2026-07-28", "2027-01-01"]); + // A recognized modern error must never trigger the legacy fallback. + expect(requests.every((r) => r.body.method === "server/discover")).toBe(true); + }); + + test("reports a modern server rejecting for a missing client capability as reachable", async () => { + // -32021 proves a live modern server; the probe can't know what capability + // to declare, so like 401/403 this reads as "there, just gated" — healthy. + const requests = stubByMethod({ + "server/discover": () => + jsonResponse( + { jsonrpc: "2.0", id: 1, error: { code: -32021, message: "capability required" } }, + 400, + ), + }); + + expect(await probeMcp(URL)).toMatchObject({ ok: true, serverName: "unnamed MCP server" }); + expect(requests).toHaveLength(1); + }); + + test("a modern-looking error on a non-400 status is not proof of a modern server", async () => { + // A proxy 5xx page that happens to parse as JSON with a reserved MCP code + // must not suppress the legacy fallback — the spec's body inspection is + // defined for 400 Bad Request only. + stubByMethod({ + "server/discover": () => + jsonResponse( + { jsonrpc: "2.0", id: 1, error: { code: -32020, message: "gateway broke" } }, + 502, + ), + initialize: () => jsonResponse(INITIALIZE_RESULT), + }); + + expect(await probeMcp(URL)).toEqual({ ok: true, status: 200, serverName: "Clerk MCP Server" }); + }); + + test("a recognized modern error with no usable retry fails without probing legacy", async () => { + const requests = stubByMethod({ + "server/discover": () => + jsonResponse( + { jsonrpc: "2.0", id: 1, error: { code: -32020, message: "Header mismatch" } }, + 400, + ), + }); + + expect(await probeMcp(URL)).toMatchObject({ + ok: false, + status: 400, + error: expect.stringContaining("Header mismatch"), + }); + expect(requests).toHaveLength(1); + }); + + // ── Legacy fallback ───────────────────────────────────────────────────────── + + test("falls back to initialize when discover gets a 400 with no modern error body", async () => { + stubByMethod({ + "server/discover": () => new Response("Bad Request", { status: 400 }), + initialize: () => jsonResponse(INITIALIZE_RESULT), + }); expect(await probeMcp(URL)).toEqual({ ok: true, status: 200, serverName: "Clerk MCP Server" }); }); - test("POSTs an initialize handshake", async () => { - let method = ""; - let body = ""; - stubFetch(async (_input, init) => { - method = init?.method ?? ""; - body = typeof init?.body === "string" ? init.body : ""; - return sseResponse(INITIALIZE_RESULT); + test("falls back when a legacy server answers discover 200 with a JSON-RPC method-not-found error", async () => { + stubByMethod({ + "server/discover": () => + jsonResponse({ jsonrpc: "2.0", id: 1, error: { code: -32601, message: "not found" } }), + initialize: () => sseResponse(INITIALIZE_RESULT), + }); + expect(await probeMcp(URL)).toMatchObject({ ok: true, serverName: "Clerk MCP Server" }); + }); + + test("the legacy fallback still sends the legacy initialize handshake", async () => { + const requests = stubByMethod({ + "server/discover": () => new Response(null, { status: 405 }), + initialize: () => jsonResponse(INITIALIZE_RESULT), }); + await probeMcp(URL); - expect(method).toBe("POST"); - expect(body).toContain('"method":"initialize"'); + + const init = requests.find((r) => r.body.method === "initialize"); + const params = init!.body.params as Record; + expect(params.protocolVersion).toBe("2024-11-05"); }); - test("parses an SSE frame with CRLF line endings", async () => { - stubFetch(async () => - sse(`event: message\r\ndata: ${JSON.stringify(INITIALIZE_RESULT)}\r\n\r\n`), - ); + test("reports the legacy failure when both eras fail, carrying the status", async () => { + stubByMethod({ + "server/discover": () => new Response("nope", { status: 404 }), + initialize: () => new Response("nope", { status: 404 }), + }); + expect(await probeMcp(URL)).toEqual({ ok: false, status: 404 }); + }); + + // ── Auth gating (either era) ──────────────────────────────────────────────── + + test.each([[401], [403]])( + "marks a %i answer on discover as auth-required without falling back", + async (status) => { + const requests = stubByMethod({ + "server/discover": () => new Response("unauthorized", { status }), + }); + expect(await probeMcp(URL)).toEqual({ ok: false, status, authRequired: true }); + expect(requests).toHaveLength(1); + }, + ); + + test("marks a 401 on the legacy fallback as auth-required", async () => { + stubByMethod({ + "server/discover": () => new Response("Bad Request", { status: 400 }), + initialize: () => new Response("unauthorized", { status: 401 }), + }); + expect(await probeMcp(URL)).toEqual({ ok: false, status: 401, authRequired: true }); + }); + + // ── Legacy parsing details (unchanged behavior) ───────────────────────────── + + const legacy = (initialize: () => Response): void => { + stubByMethod({ "server/discover": () => new Response(null, { status: 405 }), initialize }); + }; + + test("parses a legacy SSE frame with CRLF line endings", async () => { + legacy(() => sse(`event: message\r\ndata: ${JSON.stringify(INITIALIZE_RESULT)}\r\n\r\n`)); expect((await probeMcp(URL)).ok).toBe(true); }); - test("reassembles an SSE payload split across multiple data lines", async () => { - stubFetch(async () => + test("reassembles a legacy SSE payload split across multiple data lines", async () => { + legacy(() => sse( `event: message\n` + `data: {"result":{"serverInfo":{"name":"Clerk MCP Server"}},\n` + @@ -75,36 +266,29 @@ describe("probeMcp", () => { expect(await probeMcp(URL)).toMatchObject({ ok: true, serverName: "Clerk MCP Server" }); }); - test("fails when the SSE frame has no data line", async () => { - stubFetch(async () => sse("event: message\n\n")); + test("fails when the legacy SSE frame has no data line", async () => { + legacy(() => sse("event: message\n\n")); expect(await probeMcp(URL)).toMatchObject({ ok: false }); }); - test("fails when the SSE data line is malformed JSON", async () => { - stubFetch(async () => sse("event: message\ndata: {broken\n\n")); + test("fails when the legacy SSE data line is malformed JSON", async () => { + legacy(() => sse("event: message\ndata: {broken\n\n")); expect(await probeMcp(URL)).toMatchObject({ ok: false }); }); - test("fails when 200 but not an MCP initialize result", async () => { - stubFetch(async () => jsonResponse({ hello: "world" })); + test("fails when the legacy answer is 200 but not an MCP initialize result", async () => { + legacy(() => jsonResponse({ hello: "world" })); expect(await probeMcp(URL)).toMatchObject({ ok: false, status: 200 }); }); - test("fails on non-2xx, carrying the status", async () => { - stubFetch(async () => new Response("nope", { status: 404 })); - expect(await probeMcp(URL)).toEqual({ ok: false, status: 404 }); + test("a discover 200 with a non-JSON-RPC body falls back and fails cleanly", async () => { + stubByMethod({ + "server/discover": () => jsonResponse({ hello: "world" }), + initialize: () => jsonResponse({ hello: "world" }), + }); + expect(await probeMcp(URL)).toMatchObject({ ok: false, status: 200 }); }); - test.each([[401], [403]])( - "marks a %i answer as auth-required, not unreachable", - async (status) => { - // An auth-gated server answered — it's demonstrably there. The editor runs - // its own OAuth flow, so doctor must not flag the entry as unreachable. - stubFetch(async () => new Response("unauthorized", { status })); - expect(await probeMcp(URL)).toEqual({ ok: false, status, authRequired: true }); - }, - ); - test("fails on a network error, carrying the message", async () => { stubFetch(async () => { throw new Error("ECONNREFUSED"); diff --git a/packages/cli-core/src/commands/mcp/probe.ts b/packages/cli-core/src/commands/mcp/probe.ts index 664c1f70..d2a233c8 100644 --- a/packages/cli-core/src/commands/mcp/probe.ts +++ b/packages/cli-core/src/commands/mcp/probe.ts @@ -1,17 +1,30 @@ /** - * MCP `initialize` handshake probe. + * Dual-era MCP handshake probe. * - * Performs the JSON-RPC `initialize` call every MCP client makes and confirms - * the server answers with a result carrying `serverInfo` — the actual MCP - * contract, independent of any OAuth-metadata side channel. Used by the - * `clerk doctor` MCP health check. Returns a result rather than throwing so the - * caller can fold it into a `CheckResult`. + * Implements the 2026-07-28 backward-compatibility algorithm: attempt a + * modern `server/discover` first, and on failure inspect the body — a + * recognized modern JSON-RPC error (-32020..-32022) proves a modern server + * (retry with an advertised version, never fall back), while anything else + * falls back to the legacy `initialize` handshake so pre-2026 servers keep + * probing correctly. Used by the `clerk doctor` MCP health check. Returns a + * result rather than throwing so the caller can fold it into a `CheckResult`. */ import { isRecord } from "../../lib/objects.ts"; import { errorMessage } from "../../lib/errors.ts"; import { loggedFetch } from "../../lib/fetch.ts"; +import { log } from "../../lib/log.ts"; import { DEV_CLI_VERSION, resolveCliVersion } from "../../lib/version.ts"; +import { + META_CLIENT_CAPABILITIES, + META_CLIENT_INFO, + META_PROTOCOL_VERSION, + META_SERVER_INFO, + MCP_ERROR_CODE, + MODERN_PROTOCOL_VERSION, + isHeaderSafe, + isModernErrorCode, +} from "./headers.ts"; import { sseEventData } from "./sse.ts"; // Type-only: erased at compile, so the SDK stays a devDependency and is never // bundled — it exists purely as a TS gate keeping this request spec-valid. @@ -26,11 +39,17 @@ export type McpProbeResult = | { ok: true; status: number; serverName: string } | { ok: false; status?: number; error?: string; authRequired?: boolean }; -// A hostile or wrong URL shouldn't hang the CLI: cap the probe so a slow or -// never-ending response surfaces as a failure instead of blocking forever. -// 5s covers a cold-start server while keeping `clerk doctor` snappy on a dead one. +// A hostile or wrong URL shouldn't hang the CLI: cap each probe request so a +// slow or never-ending response surfaces as a failure instead of blocking +// forever. 5s covers a cold-start server while keeping `clerk doctor` snappy +// on a dead one. const PROBE_TIMEOUT_MS = 5_000; +const CLIENT_INFO = { + name: "clerk-cli", + version: resolveCliVersion() ?? DEV_CLI_VERSION, +}; + const INITIALIZE_REQUEST = { jsonrpc: "2.0", id: 1, @@ -38,10 +57,27 @@ const INITIALIZE_REQUEST = { params: { protocolVersion: "2024-11-05", capabilities: {}, - clientInfo: { name: "clerk-cli", version: resolveCliVersion() ?? DEV_CLI_VERSION }, + clientInfo: CLIENT_INFO, }, } satisfies JSONRPCRequest & InitializeRequest; +// Modern requests are stateless: no handshake, the version and capabilities +// ride in `_meta` on every request — and must match the HTTP headers. +function discoverRequest(protocolVersion: string): JSONRPCRequest { + return { + jsonrpc: "2.0", + id: 1, + method: "server/discover", + params: { + _meta: { + [META_PROTOCOL_VERSION]: protocolVersion, + [META_CLIENT_INFO]: CLIENT_INFO, + [META_CLIENT_CAPABILITIES]: {}, + }, + }, + }; +} + function safeJsonParse(text: string): unknown { try { return JSON.parse(text); @@ -50,7 +86,7 @@ function safeJsonParse(text: string): unknown { } } -// The streamable-HTTP transport answers `initialize` as either application/json +// The streamable-HTTP transport answers a handshake as either application/json // or a text/event-stream frame (`event: message\ndata: {…}`). Pull the JSON-RPC // payload out of whichever the server returned. For SSE, reassemble the first // event's `data:` lines (the spec allows a payload to span several). @@ -72,46 +108,172 @@ function stripControl(value: string): string { return out; } -// A valid `initialize` result carries `serverInfo.name`; its presence is what -// distinguishes a real MCP server from a URL that merely returns 200. -function readServerName(payload: unknown): string | undefined { +// A valid legacy `initialize` result carries `serverInfo.name`; its presence is +// what distinguishes a real MCP server from a URL that merely returns 200. +function readLegacyServerName(payload: unknown): string | undefined { if (!isRecord(payload)) return undefined; - const result = (payload as { result?: unknown }).result; + const result = payload.result; if (!isRecord(result)) return undefined; - const serverInfo = (result as { serverInfo?: unknown }).serverInfo; + const serverInfo = result.serverInfo; if (!isRecord(serverInfo)) return undefined; - const name = (serverInfo as { name?: unknown }).name; + const name = serverInfo.name; return typeof name === "string" ? stripControl(name) : undefined; } -export async function probeMcp(url: string): Promise { +// A modern `DiscoverResult` is identified by its required `supportedVersions` +// list; identity lives in `_meta` and is only SHOULD-required, so a missing +// name still reads as a healthy server. +function readDiscoverServerName(payload: unknown): string | undefined { + if (!isRecord(payload)) return undefined; + const result = payload.result; + if (!isRecord(result) || !Array.isArray(result.supportedVersions)) return undefined; + const meta = result._meta; + const serverInfo = isRecord(meta) ? meta[META_SERVER_INFO] : undefined; + const name = isRecord(serverInfo) ? serverInfo.name : undefined; + return typeof name === "string" ? stripControl(name) : "unnamed MCP server"; +} + +// The modern JSON-RPC error inside a response body, when there is one. +type ModernError = { code: number; message: string; supported: string[] }; + +function readModernError(payload: unknown): ModernError | undefined { + if (!isRecord(payload)) return undefined; + const error = payload.error; + if (!isRecord(error) || typeof error.code !== "number") return undefined; + if (!isModernErrorCode(error.code)) return undefined; + const data = isRecord(error.data) ? error.data : {}; + const supported = Array.isArray(data.supported) + ? data.supported.filter((v): v is string => typeof v === "string" && isHeaderSafe(v)) + : []; + return { + code: error.code, + message: typeof error.message === "string" ? stripControl(error.message) : "MCP error", + supported, + }; +} + +// One POST leg of the probe, with outcomes the decision table branches on. +type Attempt = + | { kind: "ok"; status: number; payload: unknown } + | { kind: "auth"; status: number } + | { kind: "http-error"; status: number; payload: unknown } + | { kind: "network"; error: string }; + +async function post(url: string, body: JSONRPCRequest, headers: Record) { + return loggedFetch(url, { + tag: "mcp", + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + ...headers, + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), + }); +} + +async function attempt( + url: string, + body: JSONRPCRequest, + headers: Record, +): Promise { + let response: Response; try { - const response = await loggedFetch(url, { - tag: "mcp", - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json, text/event-stream", - }, - body: JSON.stringify(INITIALIZE_REQUEST), - signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), - }); - // The hosted server currently answers an unauthenticated `initialize` with - // 200 (verified live), but a server-side tightening to 401 must not make - // `doctor` flag a just-installed entry as unreachable — editors run their - // own OAuth flow that this probe deliberately doesn't perform. - if (response.status === 401 || response.status === 403) { - return { ok: false, status: response.status, authRequired: true }; + response = await post(url, body, headers); + } catch (error) { + return { kind: "network", error: errorMessage(error) }; + } + // An auth-gated server answered — it's demonstrably there, it just gates the + // handshake behind auth this probe deliberately doesn't send (editors run + // their own OAuth flow). + if (response.status === 401 || response.status === 403) { + return { kind: "auth", status: response.status }; + } + const contentType = response.headers.get("content-type") ?? ""; + const payload = parseHandshake(contentType, await response.text()); + if (!response.ok) return { kind: "http-error", status: response.status, payload }; + return { kind: "ok", status: response.status, payload }; +} + +async function probeDiscover(url: string, protocolVersion: string): Promise { + return attempt(url, discoverRequest(protocolVersion), { + "MCP-Protocol-Version": protocolVersion, + "Mcp-Method": "server/discover", + }); +} + +// The pre-2026 handshake, kept as the fallback leg for legacy-only servers. +async function probeLegacy(url: string): Promise { + const result = await attempt(url, INITIALIZE_REQUEST, {}); + switch (result.kind) { + case "network": + return { ok: false, error: result.error }; + case "auth": + return { ok: false, status: result.status, authRequired: true }; + case "http-error": + return { ok: false, status: result.status }; + case "ok": { + const serverName = readLegacyServerName(result.payload); + if (serverName === undefined) { + return { ok: false, status: result.status, error: "no MCP initialize result" }; + } + return { ok: true, status: result.status, serverName }; } - if (!response.ok) return { ok: false, status: response.status }; + } +} - const contentType = response.headers.get("content-type") ?? ""; - const serverName = readServerName(parseHandshake(contentType, await response.text())); - if (serverName === undefined) { - return { ok: false, status: response.status, error: "no MCP initialize result" }; +export async function probeMcp(url: string): Promise { + const discover = await probeDiscover(url, MODERN_PROTOCOL_VERSION); + if (discover.kind === "network") return { ok: false, error: discover.error }; + if (discover.kind === "auth") { + return { ok: false, status: discover.status, authRequired: true }; + } + + if (discover.kind === "ok") { + const serverName = readDiscoverServerName(discover.payload); + if (serverName !== undefined) { + log.debug(`mcp: ${url} answered server/discover — modern era`); + return { ok: true, status: discover.status, serverName }; + } + // 200 but not a DiscoverResult: either a legacy server answering the + // unknown method with a JSON-RPC error, or not an MCP server at all — + // both resolve on the legacy leg. + log.debug(`mcp: ${url} returned 200 without a DiscoverResult — trying legacy initialize`); + return probeLegacy(url); + } + + // The spec's body inspection is defined for 400 Bad Request only: on any + // other status (a proxy 5xx page, a legacy 404/405) a body that happens to + // contain a reserved MCP error code proves nothing. + const modernError = discover.status === 400 ? readModernError(discover.payload) : undefined; + // No recognized modern error — the spec's signal for a legacy server (or a + // proxy error page). Fall back to `initialize`. + if (modernError === undefined) { + log.debug( + `mcp: ${url} rejected server/discover (HTTP ${discover.status}, no modern error body) — trying legacy initialize`, + ); + return probeLegacy(url); + } + + // A modern server rejected the probe; never fall back — `initialize` is not + // part of the modern era. + log.debug(`mcp: ${url} answered with modern error ${modernError.code} — modern era`); + // The probe can't know what capability the server wants, so like 401/403 a + // capability rejection reads as "demonstrably there, just gated" — healthy. + if (modernError.code === MCP_ERROR_CODE.MISSING_REQUIRED_CLIENT_CAPABILITY) { + return { ok: true, status: discover.status, serverName: "unnamed MCP server" }; + } + // If it advertised versions we can name, retry once with its first choice; + // otherwise surface its error. + const retryVersion = modernError.supported.find((v) => v !== MODERN_PROTOCOL_VERSION); + if (modernError.code === MCP_ERROR_CODE.UNSUPPORTED_PROTOCOL_VERSION && retryVersion) { + const retry = await probeDiscover(url, retryVersion); + if (retry.kind === "auth") return { ok: false, status: retry.status, authRequired: true }; + if (retry.kind === "ok") { + const serverName = readDiscoverServerName(retry.payload); + if (serverName !== undefined) return { ok: true, status: retry.status, serverName }; } - return { ok: true, status: response.status, serverName }; - } catch (error) { - return { ok: false, error: errorMessage(error) }; } + return { ok: false, status: discover.status, error: modernError.message }; } diff --git a/packages/cli-core/src/commands/mcp/run.test.ts b/packages/cli-core/src/commands/mcp/run.test.ts index 71c3c71c..ba335c34 100644 --- a/packages/cli-core/src/commands/mcp/run.test.ts +++ b/packages/cli-core/src/commands/mcp/run.test.ts @@ -58,6 +58,37 @@ async function* lines(...messages: unknown[]): AsyncGenerator { for (const message of messages) yield JSON.stringify(message) + "\n"; } +/** + * Input/write pair where each message is yielded only after the reply to the + * previous one has been written back — how a real MCP client sequences + * dependent requests (e.g. tools/list before tools/call). + */ +function replyGated(...messages: Array>): { + input: AsyncGenerator; + write: (chunk: string) => void; +} { + const waiters = new Map void>(); + const arrived = new Set(); + const write = (chunk: string): void => { + for (const line of chunk.split("\n").filter((l) => l.length > 0)) { + const frame = JSON.parse(line) as { id?: unknown }; + if (frame.id === undefined) continue; + arrived.add(frame.id); + waiters.get(frame.id)?.(); + } + }; + async function* input(): AsyncGenerator { + for (const [i, message] of messages.entries()) { + const prevId = i > 0 ? (messages[i - 1] as { id?: unknown }).id : undefined; + if (prevId !== undefined && !arrived.has(prevId)) { + await new Promise((resolve) => waiters.set(prevId, resolve)); + } + yield JSON.stringify(message) + "\n"; + } + } + return { input: input(), write }; +} + function framesFrom(chunks: string[]): Array> { return ( chunks @@ -562,6 +593,246 @@ describe("mcp run (stdio bridge)", () => { expect(posts.at(-1)?.headers.get("mcp-session-id")).toBe("sess-1"); }); + test("mirrors the body method into Mcp-Method on every relayed request", async () => { + stub((req) => noServerStream(req) ?? json({ jsonrpc: "2.0", id: 1, result: {} })); + + await mcpRun( + { url: URL }, + { + input: lines({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }), + write: () => {}, + }, + ); + + const posts = requests.filter((r) => r.method === "POST"); + expect(posts[0]!.headers.get("mcp-method")).toBe("tools/list"); + }); + + test.each([ + ["tools/call", { name: "get_weather" }, "get_weather"], + ["resources/read", { uri: "file:///a/b.json" }, "file:///a/b.json"], + ["prompts/get", { name: "greet" }, "greet"], + ])("mirrors the %s name into Mcp-Name", async (method, params, expected) => { + stub((req) => noServerStream(req) ?? json({ jsonrpc: "2.0", id: 1, result: {} })); + + await mcpRun( + { url: URL }, + { input: lines({ jsonrpc: "2.0", id: 1, method, params }), write: () => {} }, + ); + + const posts = requests.filter((r) => r.method === "POST"); + expect(posts[0]!.headers.get("mcp-name")).toBe(expected); + }); + + test("Base64-sentinel-encodes a non-ASCII tool name in Mcp-Name", async () => { + stub((req) => noServerStream(req) ?? json({ jsonrpc: "2.0", id: 1, result: {} })); + + await mcpRun( + { url: URL }, + { + input: lines({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "天気" } }), + write: () => {}, + }, + ); + + const posts = requests.filter((r) => r.method === "POST"); + expect(posts[0]!.headers.get("mcp-name")).toBe( + `=?base64?${Buffer.from("天気", "utf8").toString("base64")}?=`, + ); + }); + + test("omits Mcp-Name on methods that don't define it", async () => { + stub((req) => noServerStream(req) ?? json({ jsonrpc: "2.0", id: 1, result: {} })); + + await mcpRun( + { url: URL }, + { + input: lines({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }), + write: () => {}, + }, + ); + + const posts = requests.filter((r) => r.method === "POST"); + expect(posts[0]!.headers.get("mcp-name")).toBeNull(); + }); + + test("derives MCP-Protocol-Version from the body _meta of a modern request", async () => { + // A modern stdio client never sends initialize; the header must match the + // per-request metadata or a strict server rejects with HeaderMismatch. + stub((req) => noServerStream(req) ?? json({ jsonrpc: "2.0", id: 1, result: {} })); + + await mcpRun( + { url: URL }, + { + input: lines({ + jsonrpc: "2.0", + id: 1, + method: "tools/list", + params: { _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, + }), + write: () => {}, + }, + ); + + const posts = requests.filter((r) => r.method === "POST"); + expect(posts[0]!.headers.get("mcp-protocol-version")).toBe("2026-07-28"); + }); + + test("body _meta wins over the legacy-negotiated protocol version", async () => { + stub((req) => noServerStream(req) ?? json(INIT_RESULT)); + + await mcpRun( + { url: URL }, + { + input: lines( + { jsonrpc: "2.0", id: 1, method: "initialize", params: {} }, + { + jsonrpc: "2.0", + id: 2, + method: "tools/list", + params: { _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } }, + }, + ), + write: () => {}, + }, + ); + + const posts = requests.filter((r) => r.method === "POST"); + expect(posts[1]!.headers.get("mcp-protocol-version")).toBe("2026-07-28"); + }); + + test("mirrors x-mcp-header tool parameters into Mcp-Param headers on tools/call", async () => { + const tools = { + jsonrpc: "2.0", + id: 1, + result: { + tools: [ + { + name: "execute_sql", + inputSchema: { + type: "object", + properties: { + region: { type: "string", "x-mcp-header": "Region" }, + query: { type: "string" }, + }, + }, + }, + ], + }, + }; + stub((req, postIndex) => { + const blocked = noServerStream(req); + if (blocked) return blocked; + return postIndex === 0 ? json(tools) : json({ jsonrpc: "2.0", id: 2, result: {} }); + }); + + // Like a real MCP client, wait for the tools/list reply (which carries + // the schema annotations) before issuing the tools/call. + const gate = replyGated( + { jsonrpc: "2.0", id: 1, method: "tools/list" }, + { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "execute_sql", arguments: { region: "us-west1", query: "SELECT 1" } }, + }, + ); + await mcpRun({ url: URL }, gate); + + const posts = requests.filter((r) => r.method === "POST"); + expect(posts[1]!.headers.get("mcp-param-region")).toBe("us-west1"); + expect(posts[1]!.headers.get("mcp-param-query")).toBeNull(); + }); + + test("omits the Mcp-Param header when the annotated argument is absent", async () => { + const tools = { + jsonrpc: "2.0", + id: 1, + result: { + tools: [ + { + name: "t", + inputSchema: { + type: "object", + properties: { region: { type: "string", "x-mcp-header": "Region" } }, + }, + }, + ], + }, + }; + stub((req, postIndex) => { + const blocked = noServerStream(req); + if (blocked) return blocked; + return postIndex === 0 ? json(tools) : json({ jsonrpc: "2.0", id: 2, result: {} }); + }); + + const gate = replyGated( + { jsonrpc: "2.0", id: 1, method: "tools/list" }, + { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "t", arguments: {} } }, + ); + await mcpRun({ url: URL }, gate); + + const posts = requests.filter((r) => r.method === "POST"); + expect(posts[1]!.headers.get("mcp-param-region")).toBeNull(); + }); + + test("excludes a tool with an invalid x-mcp-header annotation from the forwarded tools/list", async () => { + const tools = { + jsonrpc: "2.0", + id: 1, + result: { + tools: [ + { name: "good", inputSchema: { type: "object", properties: {} } }, + { + name: "bad", + inputSchema: { + type: "object", + properties: { n: { type: "number", "x-mcp-header": "N" } }, + }, + }, + ], + }, + }; + stub((req) => noServerStream(req) ?? json(tools)); + const out: string[] = []; + + await mcpRun( + { url: URL }, + { input: lines({ jsonrpc: "2.0", id: 1, method: "tools/list" }), write: (c) => out.push(c) }, + ); + + const frame = framesFrom(out).find((f) => f.id === 1); + const result = frame?.result as { tools: Array<{ name: string }> }; + expect(result.tools.map((t) => t.name)).toEqual(["good"]); + }); + + test("a 401 after a successful request against a stateless server does not kill the bridge", async () => { + // 2026-07-28 servers never mint Mcp-Session-Id; "no session yet" must not + // be the signal for "connection never worked". + stub((req, postIndex) => { + const blocked = noServerStream(req); + if (blocked) return blocked; + return postIndex === 0 + ? json({ jsonrpc: "2.0", id: 1, result: { tools: [] } }) + : new Response("unauthorized", { status: 401 }); + }); + const out: string[] = []; + + await mcpRun( + { url: URL }, + { + input: lines( + { jsonrpc: "2.0", id: 1, method: "tools/list" }, + { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "t" } }, + ), + write: (c) => out.push(c), + }, + ); + + const err = framesFrom(out).find((f) => f.id === 2); + expect((err?.error as { code?: number } | undefined)?.code).toBe(-32001); + }); + test("targets the --url value", async () => { const custom = "http://localhost:9000/mcp"; stub((req) => noServerStream(req) ?? json(INIT_RESULT)); diff --git a/packages/cli-core/src/commands/mcp/run.ts b/packages/cli-core/src/commands/mcp/run.ts index 3219b28f..a103f051 100644 --- a/packages/cli-core/src/commands/mcp/run.ts +++ b/packages/cli-core/src/commands/mcp/run.ts @@ -19,6 +19,14 @@ import { CliError, ERROR_CODE, errorMessage } from "../../lib/errors.ts"; import { loggedFetch } from "../../lib/fetch.ts"; import { log } from "../../lib/log.ts"; import { isRecord } from "../../lib/objects.ts"; +import { + META_PROTOCOL_VERSION, + encodeHeaderValue, + extractToolHeaderAnnotations, + headerValueForParam, + isHeaderSafe, + type HeaderAnnotation, +} from "./headers.ts"; import { resolveUrl, type McpOptions } from "./shared.ts"; import { sseEventData } from "./sse.ts"; import type { JSONRPCMessage, RequestId } from "@modelcontextprotocol/sdk/types.js"; @@ -29,9 +37,15 @@ interface RunStreams { write?: (chunk: string) => void; } -type Session = { id?: string; protocolVersion?: string }; +// `established` flips once any request has succeeded: 2026-07-28 servers are +// stateless and never mint a session id, so "no session yet" can't be the +// signal for "the connection never worked". +type Session = { id?: string; protocolVersion?: string; established?: boolean }; type Emit = (message: unknown) => Promise; +/** Per-tool `x-mcp-header` annotations, learned from `tools/list` results. */ +type ToolHeaderRegistry = Map; + const SESSION_HEADER = "mcp-session-id"; // Cap an unterminated stdin line so a misbehaving client can't grow the buffer // without bound. MCP frames are small; 16 MiB is far above any real message. @@ -61,11 +75,17 @@ export async function mcpRun(options: McpOptions = {}, streams: RunStreams = {}) return writeTail; }; + // Header mirroring state: which in-flight request ids are `tools/list` (so + // their results can be scanned for `x-mcp-header` annotations), and the + // annotations learned per tool for later `tools/call` requests. + const pendingToolsLists = new Set(); + const toolHeaders: ToolHeaderRegistry = new Map(); + // MCP allows batch responses as a top-level JSON array; fan each item out as // its own frame, dropping anything that isn't a routable JSON-RPC object. const emitPayload = async (parsed: unknown): Promise => { for (const item of Array.isArray(parsed) ? parsed : [parsed]) { - if (isRecord(item)) await emit(item); + if (isRecord(item)) await emit(captureToolHeaders(item, pendingToolsLists, toolHeaders)); else log.warn("mcp run: dropping non-object frame from upstream"); } }; @@ -107,6 +127,8 @@ export async function mcpRun(options: McpOptions = {}, streams: RunStreams = {}) ensureServerStream, resetServerStream, signal: abort.signal, + pendingToolsLists, + toolHeaders, }); } } finally { @@ -125,17 +147,24 @@ interface DispatchCtx { ensureServerStream: () => void; resetServerStream: () => void; signal: AbortSignal; + pendingToolsLists: Set; + toolHeaders: ToolHeaderRegistry; } async function dispatch(message: JSONRPCMessage, ctx: DispatchCtx): Promise { const { url, session, emit, emitPayload, track, ensureServerStream, resetServerStream, signal } = ctx; + // Flag before the fetch so the response drain (which may start concurrently) + // can recognize the reply as a tools/list result. + if ("method" in message && message.method === "tools/list" && "id" in message) { + ctx.pendingToolsLists.add(message.id); + } let response: Response; try { response = await loggedFetch(url, { tag: "mcp", method: "POST", - headers: requestHeaders(session), + headers: requestHeaders(session, message, ctx.toolHeaders), body: JSON.stringify(message), signal, }); @@ -152,11 +181,15 @@ async function dispatch(message: JSONRPCMessage, ctx: DispatchCtx): Promise { - return { +// Methods whose `params.name`/`params.uri` must be mirrored into `Mcp-Name`. +const NAMED_METHODS = new Set(["tools/call", "resources/read", "prompts/get"]); + +/** + * Build the 2026-07-28 request-metadata headers for a relayed message. Every + * value is derived from the body it accompanies — strict servers reject any + * header/body mismatch with `HeaderMismatch` (-32020). + */ +function requestHeaders( + session: Session, + message: JSONRPCMessage, + toolHeaders: ToolHeaderRegistry, +): Record { + const headers: Record = { "Content-Type": "application/json", Accept: "application/json, text/event-stream", ...sessionHeaders(session), }; + // A modern client declares its version per-request in `_meta`; the header + // must match it exactly. Legacy clients don't, so the version negotiated at + // `initialize` (via sessionHeaders) stays in effect for them. + const bodyVersion = bodyProtocolVersion(message); + if (bodyVersion !== undefined) headers["MCP-Protocol-Version"] = bodyVersion; + + const method = "method" in message ? message.method : undefined; + if (typeof method !== "string") return headers; + // Method names are protocol-defined ASCII; skip (rather than encode) a + // malformed one — the spec defines no encoded form for `Mcp-Method`, and an + // unsafe value would corrupt the request at the fetch layer. + if (isHeaderSafe(method)) headers["Mcp-Method"] = method; + + const params = "params" in message && isRecord(message.params) ? message.params : undefined; + if (params === undefined) return headers; + if (NAMED_METHODS.has(method)) { + const name = params.name ?? params.uri; + if (typeof name === "string") headers["Mcp-Name"] = encodeHeaderValue(name); + } + if (method === "tools/call" && typeof params.name === "string") { + for (const annotation of toolHeaders.get(params.name) ?? []) { + const value = headerValueForParam(params.arguments, annotation.path); + if (value !== undefined) { + headers[`Mcp-Param-${annotation.header}`] = encodeHeaderValue(value); + } + } + } + return headers; +} + +// The protocol version a modern request self-declares. Untrusted stdio input: +// reject non-header-safe values so they can't inject headers. +function bodyProtocolVersion(message: JSONRPCMessage): string | undefined { + if (!("params" in message) || !isRecord(message.params)) return undefined; + const meta = message.params._meta; + if (!isRecord(meta)) return undefined; + const version = meta[META_PROTOCOL_VERSION]; + return typeof version === "string" && isHeaderSafe(version) ? version : undefined; +} + +/** + * Learn (and enforce) `x-mcp-header` annotations from a `tools/list` result + * frame. Valid tools register their annotations for later `tools/call` + * mirroring; a tool with an invalid annotation is excluded from the forwarded + * result, as the spec requires, so it can't be called with silently missing + * headers. Frames that aren't a pending `tools/list` reply pass through + * untouched. + */ +function captureToolHeaders( + frame: Record, + pending: Set, + registry: ToolHeaderRegistry, +): Record { + if (!("id" in frame) || !pending.has(frame.id as RequestId)) return frame; + pending.delete(frame.id as RequestId); + const result = frame.result; + if (!isRecord(result) || !Array.isArray(result.tools)) return frame; + const kept = result.tools.filter((tool) => { + const name = isRecord(tool) && typeof tool.name === "string" ? tool.name : undefined; + const extracted = extractToolHeaderAnnotations(tool); + if (!extracted.ok) { + log.warn(`mcp run: excluding tool "${name ?? "(unnamed)"}" — ${extracted.reason}`); + return false; + } + if (name !== undefined) registry.set(name, extracted.annotations); + return true; + }); + if (kept.length === result.tools.length) return frame; + return { ...frame, result: { ...result, tools: kept } }; } function sessionHeaders(session: Session): Record {