diff --git a/AGENTS.md b/AGENTS.md index 08944062d..4c00b29f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,9 @@ inspector/ │ ├── json/ # JSON utilities and parameter/argument conversion │ │ # (xMcpHeader.ts: SEP-2243 `x-mcp-header` │ │ # annotation scan/validation + mirrored-param -│ │ # derivation, used by the Tools tab — #1632) +│ │ # derivation, used by the Tools tab — #1632; +│ │ # plus `Mcp-Param-*` header building for the +│ │ # wire, used by both `tools/call` paths — #1846) │ ├── logging/ # Silent pino logger singleton │ ├── mcp/ # InspectorClient runtime + state stores │ │ # (modernTaskSchemas.ts: SEP-2663 modern Tasks diff --git a/README.md b/README.md index 058fb77ea..cf034ba32 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ The Vite alias `@modelcontextprotocol/inspector-test-server` (in `clients/web/vi A streamable-HTTP server can also serve the **modern (2026-07-28) protocol era** via the SDK's `createMcpHandler` — set `transport.modern` in the JSON config (`true` for dual-era stateless serving, or `{ "legacy": "reject" }` for modern-only strict), or pass `modern` on the `ServerConfig` for an in-process `createTestServerHttp`. This is what lets an Inspector connection negotiating `protocolEra: "auto" | "modern"` reach the modern leg (populated `server/discover`, sessionless). See `test-servers/configs/modern-http.json`. `test-servers/configs/modern-mrtr-http.json` additionally serves the `mrtr_confirm` tool (preset `mrtr_confirm`, `createMrtrTool`) over the modern leg: its handler returns `inputRequired(...)` embedding a form elicitation, so invoking it produces a real MRTR round-trip (`input_required` → the client fulfils the embedded elicitation and retries with a new id → `complete`). The Inspector drives MRTR manually (`inputRequired: { autoFulfill: false }`), so the embedded elicitation pauses at the pending-request modal (tagged "input_required") for you to answer, then the retry completes — useful for eyeballing both that pending-request UX and the Protocol view's MRTR conversation grouping. `test-servers/configs/mrtr-showcase-http.json` bundles every MRTR preset in one modern server for manual testing: `mrtr_confirm` (single round), `mrtr_two_step` (two elicitation rounds via `requestState`), `mrtr_sample` (embedded sampling → the Sampling panel), `mrtr_roots` (embedded `roots/list`, auto-answered silently from configured roots — no modal), `mrtr_edge` (an `inputRequests`-only round then a `requestState`-only round), and `mrtr_loop` (never completes → the `MRTR_MAX_ROUNDS` bound trips). (The legacy `collect_elicitation` preset calls `server.elicitInput`, which errors on the 2026-07-28 leg — server→client requests aren't allowed there; MRTR is the modern replacement.) -`test-servers/configs/modern-network-http.json` is the **Network-tab showcase** for the standardized HTTP headers and new error taxonomy (SEP-2243 / SEP-2575). It serves a `get_weather` tool whose `city` argument carries an `x-mcp-header: "City"` annotation (so a modern client mirrors it to `Mcp-Param-City`), plus four `trigger_*` tools that the modern leg's spec-error injector (`transport.modern.injectSpecErrors: true`) answers with a real HTTP status + JSON-RPC error body: `trigger_header_mismatch` → `400 / -32020`, `trigger_missing_capability` → `400 / -32021`, `trigger_unsupported_version` → `400 / -32022` (with `data.supported`), `trigger_method_not_found` → `404 / -32601`. Connect to it with **Protocol Era = Modern** and open the Network tab to see the mirrored `Mcp-*` headers highlighted, sentinel values decoded, and each error rendered distinctly. Note: `Mcp-Param-*` mirroring is **skipped by the SDK in the browser** (`detectProbeEnvironment() !== "browser"`), so calling `get_weather` from the **web** client omits `Mcp-Param-City` and the strict server answers `-32020` — the same tool is callable from the Node CLI/TUI, where mirroring is active. +`test-servers/configs/modern-network-http.json` is the **Network-tab showcase** for the standardized HTTP headers and new error taxonomy (SEP-2243 / SEP-2575). It serves a `get_weather` tool whose `city` argument carries an `x-mcp-header: "City"` annotation (so a modern client mirrors it to `Mcp-Param-City`), plus four `trigger_*` tools that the modern leg's spec-error injector (`transport.modern.injectSpecErrors: true`) answers with a real HTTP status + JSON-RPC error body: `trigger_header_mismatch` → `400 / -32020`, `trigger_missing_capability` → `400 / -32021`, `trigger_unsupported_version` → `400 / -32022` (with `data.supported`), `trigger_method_not_found` → `404 / -32601`. Connect to it with **Protocol Era = Modern** and open the Network tab to see the mirrored `Mcp-*` headers highlighted, sentinel values decoded, and each error rendered distinctly. Note: the SDK only mirrors `Mcp-Param-*` inside `client.callTool()`, and skips it in the browser (`detectProbeEnvironment() !== "browser"`). The Inspector routes `tools/call` through `client.request()` to drive MRTR manually, so it builds the mirrored headers itself (#1846) — on **every** client, web included, since the web client's upstream request is issued by the Node backend rather than the browser. So `get_weather` is callable from web, CLI, and TUI alike, in both the plain and "Run as task" forms. `test-servers/configs/xmcpheader-modern-http.json` is the **`x-mcp-header` Tools-tab showcase** (#1632). It serves `echo`, a `get_weather` tool with a **valid** `x-mcp-header: "City"` annotation on its `city` argument, an `invalid_header_tool` whose annotation uses the header name `"Bad Header"` (a space makes it an invalid RFC 9110 token, so the whole tool definition is invalid), and a `trigger_invalid_params` tool that the modern leg's spec-error injector (`transport.modern.injectSpecErrors: true`) answers with a real `-32602 Invalid params` JSON-RPC error whose message is not about a missing tool. Connect with **Protocol Era = Modern** and open the Tools tab: `get_weather`'s detail panel shows a **"Mirrored request headers (SEP-2243)"** section (`city → Mcp-Param-City`), and `invalid_header_tool` appears struck-through under an **"Excluded (SEP-2243)"** divider in the sidebar with the reason on hover (a conforming Streamable HTTP client MUST drop it from `tools/list`; the Inspector surfaces _why_). Under SDK v2 a `tools/call` that rejects with **`-32602`** now renders as a distinct error panel rather than an `isError` result — headed **"Unknown Tool"** when the message names a missing tool (reproduce by calling a tool the server dropped from its list), or **"Invalid Parameters"** for any other `-32602` (run `trigger_invalid_params`). diff --git a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx index 64b0ef12a..a74c63a96 100644 --- a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx +++ b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.tsx @@ -306,8 +306,8 @@ export function ToolDetailPanel({ ))} These argument values are mirrored into HTTP headers on the - call. The SDK sends them only on a Node/proxy transport — the - browser omits Mcp-Param-* headers. + call. In the web client the Mcp-Param-* headers are + applied by the Node backend that issues the upstream request. )} diff --git a/clients/web/src/test/core/xMcpHeader.test.ts b/clients/web/src/test/core/xMcpHeader.test.ts index 0f043da05..07235c892 100644 --- a/clients/web/src/test/core/xMcpHeader.test.ts +++ b/clients/web/src/test/core/xMcpHeader.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest"; import { scanXMcpHeaderDeclarations, getMirroredHeaderParams, + buildMcpParamHeaders, + mcpParamHeadersForTool, MCP_PARAM_HEADER_PREFIX, X_MCP_HEADER_KEY, } from "@inspector/core/json/xMcpHeader.js"; @@ -279,3 +281,153 @@ describe("getMirroredHeaderParams", () => { ).toEqual([]); }); }); + +function declsFor(inputSchema: Tool["inputSchema"]) { + const scan = scanXMcpHeaderDeclarations(inputSchema); + if (!scan.valid) throw new Error(`expected valid scan: ${scan.reason}`); + return scan.declarations; +} + +const P = MCP_PARAM_HEADER_PREFIX; + +function decodeSentinel(value: string): string { + const inner = value.slice("=?base64?".length, -"?=".length); + const bin = atob(inner); + const bytes = Uint8Array.from(bin, (ch) => ch.codePointAt(0) ?? 0); + return new TextDecoder().decode(bytes); +} + +describe("buildMcpParamHeaders", () => { + const decls = declsFor({ + type: "object", + properties: { + owner: { type: "string", [X_MCP_HEADER_KEY]: "owner" }, + count: { type: "integer", [X_MCP_HEADER_KEY]: "Count" }, + flag: { type: "boolean", [X_MCP_HEADER_KEY]: "Flag" }, + ratio: { type: "number", [X_MCP_HEADER_KEY]: "Ratio" }, + }, + }); + + it("mirrors a string argument verbatim into Mcp-Param-{Name}", () => { + expect(buildMcpParamHeaders(decls, { owner: "octocat" })).toEqual({ + [`${P}owner`]: "octocat", + }); + }); + + it("stringifies boolean and numeric values per the spec", () => { + expect( + buildMcpParamHeaders(decls, { + count: 42, + flag: false, + ratio: 3.5, + }), + ).toEqual({ + [`${P}Count`]: "42", + [`${P}Flag`]: "false", + [`${P}Ratio`]: "3.5", + }); + expect(buildMcpParamHeaders(decls, { flag: true })).toEqual({ + [`${P}Flag`]: "true", + }); + }); + + it("omits declarations whose value is absent or null", () => { + expect(buildMcpParamHeaders(decls, { owner: null })).toEqual({}); + expect(buildMcpParamHeaders(decls, {})).toEqual({}); + }); + + it("omits non-primitive values rather than emitting malformed headers", () => { + expect( + buildMcpParamHeaders(decls, { + owner: { nested: true }, + count: [1, 2], + }), + ).toEqual({}); + }); + + it("omits non-finite numbers and unsafe integers", () => { + expect(buildMcpParamHeaders(decls, { ratio: Infinity })).toEqual({}); + expect(buildMcpParamHeaders(decls, { ratio: NaN })).toEqual({}); + expect( + buildMcpParamHeaders(decls, { count: Number.MAX_SAFE_INTEGER + 2 }), + ).toEqual({}); + }); + + it("base64-wraps values that are not safe plain-ASCII field values", () => { + const out = buildMcpParamHeaders(decls, { owner: "münchen" }); + const encoded = out[`${P}owner`]; + expect(encoded.startsWith("=?base64?")).toBe(true); + expect(encoded.endsWith("?=")).toBe(true); + expect(decodeSentinel(encoded)).toBe("münchen"); + }); + + it("base64-wraps empty, whitespace-padded, and sentinel-colliding values", () => { + const empty = buildMcpParamHeaders(decls, { owner: "" })[`${P}owner`]; + expect(decodeSentinel(empty)).toBe(""); + const padded = buildMcpParamHeaders(decls, { owner: " x " })[`${P}owner`]; + expect(decodeSentinel(padded)).toBe(" x "); + const collide = "=?base64?zzz?="; + const wrapped = buildMcpParamHeaders(decls, { owner: collide })[ + `${P}owner` + ]; + expect(wrapped).not.toBe(collide); + expect(decodeSentinel(wrapped)).toBe(collide); + }); + + it("reads a nested property path", () => { + const nested = declsFor({ + type: "object", + properties: { + filter: { + type: "object", + properties: { + city: { type: "string", [X_MCP_HEADER_KEY]: "City" }, + }, + }, + }, + }); + expect( + buildMcpParamHeaders(nested, { filter: { city: "London" } }), + ).toEqual({ [`${P}City`]: "London" }); + expect(buildMcpParamHeaders(nested, { filter: "not-an-object" })).toEqual( + {}, + ); + }); +}); + +describe("mcpParamHeadersForTool", () => { + it("builds headers for a tool's valid annotations", () => { + expect( + mcpParamHeadersForTool( + tool({ + type: "object", + properties: { + owner: { type: "string", [X_MCP_HEADER_KEY]: "owner" }, + }, + }), + { owner: "octocat" }, + ), + ).toEqual({ [`${P}owner`]: "octocat" }); + }); + + it("returns {} for a tool with no annotations", () => { + expect( + mcpParamHeadersForTool( + tool({ type: "object", properties: { a: { type: "string" } } }), + { a: "x" }, + ), + ).toEqual({}); + }); + + it("returns {} for a tool whose annotations are invalid", () => { + expect( + mcpParamHeadersForTool( + tool({ + type: "object", + properties: { a: { type: "object", [X_MCP_HEADER_KEY]: "A" } }, + }), + { a: "x" }, + ), + ).toEqual({}); + }); +}); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-xmcpheader-mirroring.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-xmcpheader-mirroring.test.ts new file mode 100644 index 000000000..fadb29877 --- /dev/null +++ b/clients/web/src/test/integration/mcp/inspectorClient-xmcpheader-mirroring.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; +import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js"; +import { + createTestServerHttp, + type TestServerHttp, + createTestServerInfo, + createEchoTool, + createGetWeatherTool, +} from "@modelcontextprotocol/inspector-test-server"; +import type { Tool } from "@modelcontextprotocol/client"; + +/** + * SEP-2243 `x-mcp-header` → `Mcp-Param-*` mirroring on `tools/call` (#1846). + * + * The SDK only mirrors inside `client.callTool()` (and skips it in a browser + * environment); the Inspector routes `tools/call` through `client.request()` + * for manual MRTR driving (#1704), so it mirrors the headers itself. A strict + * modern server (e.g. GitHub's) rejects a call whose annotated argument isn't + * mirrored, so this must ride the wire. Here we spy on the transport `fetch` and + * assert the `tools/call` POST carries the mirrored header with the spec's + * value encoding. + */ +describe("x-mcp-header Mcp-Param-* mirroring on tools/call", () => { + let client: InspectorClient | null = null; + let server: TestServerHttp | null = null; + + afterEach(async () => { + if (client) { + try { + await client.disconnect(); + } catch { + // Ignore disconnect errors + } + client = null; + } + if (server) { + try { + await server.stop(); + } catch { + // Ignore server stop errors + } + server = null; + } + }); + + /** Records the request headers of every `tools/call` POST the client sends. */ + function makeSpyFetch(): { + fetch: typeof fetch; + toolCallHeaders: Headers[]; + } { + const toolCallHeaders: Headers[] = []; + const spy: typeof fetch = async (input, init) => { + const body = init?.body; + if (typeof body === "string" && body.includes('"tools/call"')) { + try { + const parsed = JSON.parse(body) as { method?: string }; + if (parsed.method === "tools/call") { + toolCallHeaders.push(new Headers(init?.headers)); + } + } catch { + // Non-JSON body — ignore. + } + } + return fetch(input, init); + }; + return { fetch: spy, toolCallHeaders }; + } + + async function connectModern( + url: string, + fetchFn: typeof fetch, + ): Promise { + const connected = new InspectorClient( + { type: "streamable-http", url }, + { + environment: { transport: createTransportNode, fetch: fetchFn }, + versionNegotiation: eraToVersionNegotiation("auto"), + }, + ); + await connected.connect(); + client = connected; + return connected; + } + + async function startWeatherServer(): Promise { + const started = createTestServerHttp({ + serverInfo: createTestServerInfo("xmcpheader-test", "1.0.0"), + tools: [createEchoTool(), createGetWeatherTool()], + modern: {}, + }); + await started.start(); + server = started; + return started; + } + + async function weatherTool(c: InspectorClient): Promise { + const { tools } = await c.listTools(); + const weather = tools.find((t) => t.name === "get_weather"); + expect(weather).toBeDefined(); + return weather!; + } + + it("mirrors an annotated argument into the Mcp-Param-* request header", async () => { + const started = await startWeatherServer(); + const spy = makeSpyFetch(); + const connected = await connectModern(started.url, spy.fetch); + expect(connected.getProtocolEra()).toBe("modern"); + + const result = await connected.callTool(await weatherTool(connected), { + city: "London", + }); + + expect(result.success).toBe(true); + expect(spy.toolCallHeaders.length).toBeGreaterThan(0); + const sent = spy.toolCallHeaders.at(-1)!; + expect(sent.get("Mcp-Param-City")).toBe("London"); + }); + + it("mirrors on the task-augmented tools/call too (callToolStream)", async () => { + // The "Run as task" path builds its own request options, so it needs the + // same mirroring — a strict modern server rejects the task-augmented + // `tools/call` with -32020 when the header is missing, exactly as it does + // the plain one. + const started = await startWeatherServer(); + const spy = makeSpyFetch(); + const connected = await connectModern(started.url, spy.fetch); + + const result = await connected.callToolStream( + await weatherTool(connected), + { city: "London" }, + undefined, + undefined, + { ttl: 60_000 }, + ); + + expect(result.success).toBe(true); + expect(spy.toolCallHeaders.at(-1)!.get("Mcp-Param-City")).toBe("London"); + }); + + it("sends no Mcp-Param-* header for a tool without annotations", async () => { + const started = await startWeatherServer(); + const spy = makeSpyFetch(); + const connected = await connectModern(started.url, spy.fetch); + + const { tools } = await connected.listTools(); + const echo = tools.find((t) => t.name === "echo")!; + await connected.callTool(echo, { message: "hi" }); + + const sent = spy.toolCallHeaders.at(-1)!; + let sawMcpParam = false; + sent.forEach((_v, k) => { + if (k.toLowerCase().startsWith("mcp-param-")) sawMcpParam = true; + }); + expect(sawMcpParam).toBe(false); + }); + + it("does not mirror on a legacy connection", async () => { + const started = createTestServerHttp({ + serverInfo: createTestServerInfo("xmcpheader-legacy", "1.0.0"), + tools: [createGetWeatherTool()], + modern: { legacy: "stateless" }, + }); + await started.start(); + server = started; + + const spy = makeSpyFetch(); + const connected = new InspectorClient( + { type: "streamable-http", url: started.url }, + { + environment: { transport: createTransportNode, fetch: spy.fetch }, + versionNegotiation: eraToVersionNegotiation("legacy"), + }, + ); + await connected.connect(); + client = connected; + expect(connected.getProtocolEra()).toBe("legacy"); + + await connected.callTool(await weatherTool(connected), { city: "London" }); + const sent = spy.toolCallHeaders.at(-1)!; + expect(sent.get("Mcp-Param-City")).toBeNull(); + }); +}); diff --git a/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts b/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts index 05b8346d6..fddc10c9d 100644 --- a/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts +++ b/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts @@ -642,6 +642,58 @@ describe("RemoteClientTransport (focused branch coverage)", () => { await t.close(); }); + it("forwards per-send headers (SEP-2243 Mcp-Param-*) in the send body", async () => { + let sentBody: { headers?: Record } | undefined; + const encoder = new TextEncoder(); + let sseController: ReadableStreamDefaultController | null = + null; + const pushSseMessage = (message: JSONRPCMessage) => { + const payload = JSON.stringify({ type: "message", data: message }); + sseController?.enqueue(encoder.encode(`data: ${payload}\n\n`)); + }; + const fetchFn = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/connect")) return jsonResponse({ sessionId: "s" }); + if (url.includes("/events")) { + return new Response( + new ReadableStream({ + start(controller) { + sseController = controller; + controller.enqueue(encoder.encode(": keepalive\n\n")); + }, + }), + { status: 200 }, + ); + } + if (url.includes("/send")) { + sentBody = JSON.parse(init!.body as string); + const requestId = ( + sentBody as { message: { id?: string | number } } + ).message.id; + pushSseMessage({ jsonrpc: "2.0", id: requestId!, result: {} }); + return jsonResponse({ ok: true }); + } + return jsonResponse({ ok: true }); + }, + ); + const t = new RemoteClientTransport( + { + baseUrl: "http://remote.test", + fetchFn: fetchFn as unknown as typeof fetch, + sseResponseTimeoutMs: 2000, + }, + CONFIG, + ); + await t.start(); + await t.send( + { jsonrpc: "2.0", id: 7, method: "tools/call" }, + { headers: { "Mcp-Param-City": "London" } }, + ); + expect(sentBody?.headers).toEqual({ "Mcp-Param-City": "London" }); + await t.close(); + }); + it("throws Remote send failed with status on non-OK send", async () => { const t = makeTransport({ events: () => diff --git a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts index 04e55ed20..613d4c6a7 100644 --- a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts +++ b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts @@ -22,6 +22,7 @@ import type pinoType from "pino"; import { createRemoteApp, requestIdForSendWait, + mcpParamHeadersOnly, } from "@inspector/core/mcp/remote/node/server.js"; import { InMemorySecretStore, @@ -139,6 +140,43 @@ describe("server.ts supplemental coverage", () => { }); }); + describe("mcpParamHeadersOnly (SEP-2243 upstream header allowlist, #1846)", () => { + it("keeps only Mcp-Param-* headers (case-insensitive)", () => { + expect( + mcpParamHeadersOnly({ + "Mcp-Param-City": "London", + "mcp-param-owner": "octocat", + }), + ).toEqual({ "Mcp-Param-City": "London", "mcp-param-owner": "octocat" }); + }); + + it("drops non-Mcp-Param headers a client tries to inject", () => { + expect( + mcpParamHeadersOnly({ + Authorization: "Bearer evil", + "X-Custom": "nope", + "Mcp-Param-City": "London", + }), + ).toEqual({ "Mcp-Param-City": "London" }); + }); + + it("drops non-string values", () => { + expect( + mcpParamHeadersOnly({ + "Mcp-Param-Bad": 5, + "Mcp-Param-City": "London", + }), + ).toEqual({ "Mcp-Param-City": "London" }); + }); + + it("returns undefined when nothing survives the filter", () => { + expect(mcpParamHeadersOnly({ Authorization: "x" })).toBeUndefined(); + expect(mcpParamHeadersOnly(undefined)).toBeUndefined(); + expect(mcpParamHeadersOnly(null)).toBeUndefined(); + expect(mcpParamHeadersOnly("not-an-object")).toBeUndefined(); + }); + }); + describe("/api/log forwardLogEvent shapes", () => { let h: Harness; let records: Array<{ level: string; args: unknown[] }>; diff --git a/clients/web/src/test/integration/mcp/remote/transport.test.ts b/clients/web/src/test/integration/mcp/remote/transport.test.ts index 35f9ec70e..d66ae36a5 100644 --- a/clients/web/src/test/integration/mcp/remote/transport.test.ts +++ b/clients/web/src/test/integration/mcp/remote/transport.test.ts @@ -30,9 +30,13 @@ import { createTestServerHttp, getTestMcpServerCommand, createEchoTool, + createGetWeatherTool, createTestServerInfo, } from "@modelcontextprotocol/inspector-test-server"; -import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; +import { + eraToVersionNegotiation, + type MCPServerConfig, +} from "@inspector/core/mcp/types.js"; interface StartRemoteServerOptions { logger?: pino.Logger; @@ -447,6 +451,70 @@ describe("Remote transport e2e", () => { await client.disconnect(); } }); + + it("end-to-end: SEP-2243 Mcp-Param-* mirroring reaches the upstream modern server (#1846)", async () => { + // A modern (2026-07-28) server whose `get_weather` tool annotates `city` + // with `x-mcp-header: "City"`. The SDK's modern handler validates the + // mirrored header and rejects the call with -32020 when it is missing, so + // a *successful* call proves the header rode browser → backend → upstream. + mcpHttpServer = createTestServerHttp({ + serverInfo: createTestServerInfo(), + tools: [createGetWeatherTool()], + serverType: "streamable-http", + modern: {}, + }); + await mcpHttpServer.start(); + + const { baseUrl, server, authToken } = await startRemoteServer(0); + remoteServer = server; + + const config: MCPServerConfig = { + type: "streamable-http", + url: mcpHttpServer.url, + }; + const createTransport = createRemoteTransport({ baseUrl, authToken }); + const client = new InspectorClient(config, { + environment: { transport: createTransport }, + versionNegotiation: eraToVersionNegotiation("auto"), + }); + const fetchRequestLogState = new FetchRequestLogState(client); + + try { + await client.connect(); + expect(client.getProtocolEra()).toBe("modern"); + + const { tools } = await client.listTools(); + const weather = tools.find((t) => t.name === "get_weather"); + expect(weather).toBeDefined(); + + const invocation = await client.callTool(weather!, { city: "London" }); + // Success only happens if the strict modern server saw Mcp-Param-City. + expect(invocation.success).toBe(true); + + // And the backend's upstream fetch tracking shows the mirrored header on + // the wire it sent to the modern server. + const toolCallPost = fetchRequestLogState + .getFetchRequests() + .filter((r) => r.method === "POST") + .find((r) => { + const lowered: Record = {}; + for (const [k, v] of Object.entries(r.requestHeaders ?? {})) { + lowered[k.toLowerCase()] = v; + } + return lowered["mcp-param-city"] !== undefined; + }); + expect(toolCallPost).toBeDefined(); + const lowered: Record = {}; + for (const [k, v] of Object.entries( + toolCallPost?.requestHeaders ?? {}, + )) { + lowered[k.toLowerCase()] = v; + } + expect(lowered["mcp-param-city"]).toBe("London"); + } finally { + await client.disconnect(); + } + }); }); describe("authentication", () => { diff --git a/core/json/xMcpHeader.ts b/core/json/xMcpHeader.ts index 97000dd83..739eb6a2b 100644 --- a/core/json/xMcpHeader.ts +++ b/core/json/xMcpHeader.ts @@ -194,6 +194,125 @@ export function scanXMcpHeaderDeclarations( : { valid: false, reason: fault }; } +/** + * The `=?base64?…?=` sentinel wrapping a value that cannot be sent as a plain + * ASCII HTTP field value (SEP-2243 value-encoding rules). + */ +const BASE64_SENTINEL_PREFIX = "=?base64?"; +const BASE64_SENTINEL_SUFFIX = "?="; + +/** + * Convert a primitive argument value to its string form per the spec's + * type-conversion rules: strings pass through, booleans become lowercase + * `'true'`/`'false'`, integers/numbers become their decimal string. Non-finite + * numbers and integers outside the safe range are refused (returns `undefined`, + * meaning "do not emit a header for this value"). Anything non-primitive + * (object/array/null/undefined) also yields `undefined`. + */ +function mcpParamPrimitiveToString(value: unknown): string | undefined { + if (typeof value === "string") return value; + if (typeof value === "boolean") return value ? "true" : "false"; + if (typeof value === "number") { + if (!Number.isFinite(value)) return undefined; + if (Number.isInteger(value) && !Number.isSafeInteger(value)) { + return undefined; + } + return String(value); + } + return undefined; +} + +/** + * `true` when `s` cannot be safely represented as a plain ASCII HTTP field + * value (RFC 9110 §5.5): it is empty, contains a byte outside `0x20–0x7E`/`0x09`, + * has leading/trailing whitespace (which field parsing strips), or already + * matches the Base64 sentinel pattern (the spec's "to avoid ambiguity" rule). + */ +function needsBase64(s: string): boolean { + if (s.length === 0) return true; + if ( + s.startsWith(BASE64_SENTINEL_PREFIX) && + s.endsWith(BASE64_SENTINEL_SUFFIX) + ) { + return true; + } + if (s !== s.trim()) return true; + for (let i = 0; i < s.length; i++) { + // Non-null: `i` is always in bounds, so `codePointAt` returns a number. + const c = s.codePointAt(i)!; + if (c === 9 || (c >= 32 && c <= 126)) continue; + return true; + } + return false; +} + +function utf8ToBase64(s: string): string { + const bytes = new TextEncoder().encode(s); + let bin = ""; + for (const b of bytes) bin += String.fromCodePoint(b); + return btoa(bin); +} + +/** + * Encode a string value as an HTTP field value per SEP-2243: a value that is + * already a safe plain-ASCII field value passes through unchanged; anything + * else is wrapped as `=?base64?{b64-of-utf8}?=`. + */ +function encodeMcpParamValue(value: string): string { + return needsBase64(value) + ? `${BASE64_SENTINEL_PREFIX}${utf8ToBase64(value)}${BASE64_SENTINEL_SUFFIX}` + : value; +} + +function valueAtPath(root: unknown, path: string[]): unknown { + let node: unknown = root; + for (const key of path) { + if (node === null || typeof node !== "object") return undefined; + node = (node as Record)[key]; + } + return node; +} + +/** + * Build the `Mcp-Param-{Name}` headers for one `tools/call` from validated + * `x-mcp-header` declarations and the call's `arguments`. A declaration whose + * value is `null` or absent is omitted (the spec's "client MUST omit the header" + * rows); a value that is not a primitive of the declared kind is omitted rather + * than emitted malformed. Faithful port of the SDK's internal helper (not part + * of its public surface), so the headers match what a conforming client sends. + */ +export function buildMcpParamHeaders( + declarations: XMcpHeaderDeclaration[], + args: Record, +): Record { + const out: Record = {}; + for (const decl of declarations) { + const raw = valueAtPath(args, decl.path); + if (raw === undefined || raw === null) continue; + const stringValue = mcpParamPrimitiveToString(raw); + if (stringValue === undefined) continue; + out[`${MCP_PARAM_HEADER_PREFIX}${decl.headerName}`] = + encodeMcpParamValue(stringValue); + } + return out; +} + +/** + * SEP-2243 `Mcp-Param-*` headers a `tools/call` must carry for a given tool and + * arguments. Returns `{}` when the tool declares no `x-mcp-header`, when its + * annotations are invalid (such a tool is excluded from `tools/list`), or when + * no declared argument has a mirrorable value. Callers attach the result to the + * `tools/call` request headers on a modern connection. + */ +export function mcpParamHeadersForTool( + tool: Tool, + args: Record, +): Record { + const scan = scanXMcpHeaderDeclarations(tool.inputSchema); + if (!scan.valid || scan.declarations.length === 0) return {}; + return buildMcpParamHeaders(scan.declarations, args); +} + /** A tool the Inspector keeps, paired with its mirrored-header declarations. */ export interface MirroredHeaderParam { /** Dot-joined property path (e.g. `region` or `filter.city`). */ diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 527219ad9..b53beb842 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -18,7 +18,10 @@ import type { ResourceSubscriptionStreamState, ExcludedTool, } from "./types.js"; -import { scanXMcpHeaderDeclarations } from "../json/xMcpHeader.js"; +import { + scanXMcpHeaderDeclarations, + mcpParamHeadersForTool, +} from "../json/xMcpHeader.js"; // Re-export so v1.5 tests that do `import { InspectorClientOptions } from // "@inspector/core/mcp/inspectorClient.js"` keep resolving. export type { @@ -3304,6 +3307,52 @@ export class InspectorClient extends InspectorClientEventTarget { } } + /** + * Coerce the string-valued entries of a tool's arguments to the types its + * `inputSchema` declares (the Tools form hands everything over as text). + * Shared by the two `tools/call` entry points — {@link attemptToolCall} and + * {@link callToolStream} — so both put the SAME arguments on the wire. + */ + private convertStringToolArgs( + tool: Tool, + args: Record, + ): Record { + const stringArgs: Record = {}; + for (const [key, value] of Object.entries(args)) { + if (typeof value === "string") { + stringArgs[key] = value; + } + } + if (Object.keys(stringArgs).length === 0) return args; + return { ...args, ...convertToolParameters(tool, stringArgs) }; + } + + /** + * SEP-2243: mirror `x-mcp-header`-annotated arguments into `Mcp-Param-*` + * headers on a modern connection. The SDK only does this inside + * `client.callTool()` (and skips it in the browser), but we route + * `tools/call` through `client.request()` for manual MRTR driving (#1704), so + * we mirror ourselves. `Protocol.request` forwards `headers` (preserved + * across MRTR retry legs) to the transport, and the remote transport relays + * them to the backend's upstream send — issued server-side, where the browser + * skip doesn't apply. No-op on legacy/stdio (no annotations). + * + * Applied by BOTH `tools/call` entry points: a plain call + * ({@link attemptToolCall}) and a task-augmented one + * ({@link callToolStream}) — a strict modern server rejects either with + * `-32020` when the mirrored header is missing. + */ + private applyMirroredParamHeaders( + tool: Tool, + convertedArgs: Record, + requestOptions: RequestOptions, + ): void { + if (this.protocolEra !== "modern") return; + const paramHeaders = mcpParamHeadersForTool(tool, convertedArgs); + if (Object.keys(paramHeaders).length === 0) return; + requestOptions.headers = { ...requestOptions.headers, ...paramHeaders }; + } + /** * Run a single tools/call attempt: convert args, issue the request, validate, * and return a successful {@link ToolCallInvocation}. Throws on any error @@ -3326,17 +3375,7 @@ export class InspectorClient extends InspectorClientEventTarget { if (!client) { throw new Error("Client is not connected"); } - let convertedArgs: Record = args; - const stringArgs: Record = {}; - for (const [key, value] of Object.entries(args)) { - if (typeof value === "string") { - stringArgs[key] = value; - } - } - if (Object.keys(stringArgs).length > 0) { - const convertedStringArgs = convertToolParameters(tool, stringArgs); - convertedArgs = { ...args, ...convertedStringArgs }; - } + const convertedArgs = this.convertStringToolArgs(tool, args); // Merge general metadata with tool-specific metadata; tool-specific wins. const callMetadata: Record | undefined = @@ -3367,6 +3406,7 @@ export class InspectorClient extends InspectorClientEventTarget { metadata?.progressToken, signal, ); + this.applyMirroredParamHeaders(tool, convertedArgs, requestOptions); // Route through the MRTR driver (`requestWithInputRequired`) so a modern // `input_required` result pauses at the pending-request UI and retries with // the user's answer (#1704). Both eras use `client.request` with @@ -3949,17 +3989,7 @@ export class InspectorClient extends InspectorClientEventTarget { throw new Error("Client is not connected"); } try { - let convertedArgs: Record = args; - const stringArgs: Record = {}; - for (const [key, value] of Object.entries(args)) { - if (typeof value === "string") { - stringArgs[key] = value; - } - } - if (Object.keys(stringArgs).length > 0) { - const convertedStringArgs = convertToolParameters(tool, stringArgs); - convertedArgs = { ...args, ...convertedStringArgs }; - } + const convertedArgs = this.convertStringToolArgs(tool, args); // Merge general metadata with tool-specific metadata; tool-specific wins. const callMetadata: Record | undefined = @@ -3999,6 +4029,9 @@ export class InspectorClient extends InspectorClientEventTarget { // emit requestorTaskProgress) for task calls only, bypassing the toggle // that governs every other call path. const requestOptions = this.getRequestOptions(metadata?.progressToken); + // The task-augmented `tools/call` needs the same SEP-2243 mirroring as the + // plain one — a strict modern server rejects it with -32020 otherwise. + this.applyMirroredParamHeaders(tool, convertedArgs, requestOptions); if (this.progress) { const innerOnProgress = requestOptions.onprogress; requestOptions.onprogress = (progress: Progress) => { diff --git a/core/mcp/remote/createRemoteFetch.ts b/core/mcp/remote/createRemoteFetch.ts index cfcec90d0..4dcbf6891 100644 --- a/core/mcp/remote/createRemoteFetch.ts +++ b/core/mcp/remote/createRemoteFetch.ts @@ -13,11 +13,12 @@ * on the browser path and reach the server through this proxy. * - The custom `Mcp-Param-*` headers are gated on a non-browser environment in * the SDK (`detectProbeEnvironment() !== "browser"`, avoiding a CORS - * preflight), so they are NEVER built in the browser — the proxy would - * forward them, but the SDK never creates them here. A tool with an - * `x-mcp-header` annotation is therefore uncallable from the web client - * against a strict modern server (it answers `-32020 HeaderMismatch`); such - * tools work from the Node CLI/TUI, where mirroring is active. + * preflight), so the SDK never builds them here. The Inspector builds them + * itself instead (`mcpParamHeadersForTool`, #1846) — but they do NOT travel + * this path: they ride the transport's per-send `headers` through + * `/api/mcp/send`, where the Node backend applies them to the upstream + * request it issues. So an `x-mcp-header`-annotated tool IS callable from + * the web client against a strict modern server, same as from the CLI/TUI. */ export interface RemoteFetchOptions { diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 4f5c2a2db..4bbc518a0 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -33,6 +33,7 @@ import type { } from "../types.js"; import type { JSONRPCMessage } from "@modelcontextprotocol/client"; import { AuthChallengeError } from "../../../auth/challenge.js"; +import { MCP_PARAM_HEADER_PREFIX } from "../../../json/xMcpHeader.js"; import { DEFAULT_MAX_FETCH_REQUESTS, DEFAULT_TASK_TTL_MS, @@ -362,6 +363,28 @@ export function requestIdForSendWait( return undefined; } +/** + * Restrict client-supplied per-send headers to SEP-2243 `Mcp-Param-*` mirroring + * with string values. The sanctioned channel for arbitrary upstream headers is + * the server's configured `settings.headers`; this per-send channel must not let + * a client inject other headers (e.g. `Authorization`) onto the upstream fetch. + */ +export function mcpParamHeadersOnly( + headers: unknown, +): Record | undefined { + if (headers === null || typeof headers !== "object") return undefined; + const out: Record = {}; + for (const [key, value] of Object.entries(headers)) { + if ( + typeof value === "string" && + key.toLowerCase().startsWith(MCP_PARAM_HEADER_PREFIX.toLowerCase()) + ) { + out[key] = value; + } + } + return Object.keys(out).length > 0 ? out : undefined; +} + export function createRemoteApp( options: RemoteServerOptions, ): CreateRemoteAppResult { @@ -705,7 +728,7 @@ export function createRemoteApp( return c.json({ error: "Invalid JSON body" }, 400); } - const { sessionId, message, relatedRequestId } = body; + const { sessionId, message, relatedRequestId, headers } = body; if (!sessionId || !message) { return c.json({ error: "Missing sessionId or message" }, 400); } @@ -730,8 +753,14 @@ export function createRemoteApp( void responseWait.catch(() => {}); } try { + // SEP-2243: apply the client's mirrored headers to the upstream request, + // restricted to the `Mcp-Param-` prefix so a client can't inject other + // upstream headers. The StreamableHTTP transport merges these onto the + // outbound fetch; other transports ignore unknown send options. + const paramHeaders = mcpParamHeadersOnly(headers); await session.transport.send(message, { relatedRequestId: relatedRequestId as string | number | undefined, + ...(paramHeaders && { headers: paramHeaders }), }); if (responseWait) { await responseWait; diff --git a/core/mcp/remote/remoteClientTransport.ts b/core/mcp/remote/remoteClientTransport.ts index 9f3ac4198..03c924611 100644 --- a/core/mcp/remote/remoteClientTransport.ts +++ b/core/mcp/remote/remoteClientTransport.ts @@ -704,6 +704,9 @@ export class RemoteClientTransport implements Transport { ...(options?.relatedRequestId != null && { relatedRequestId: options.relatedRequestId, }), + // Forward per-send `Mcp-Param-*` headers (SEP-2243) for the backend to + // apply to the upstream send; the browser can't set them cross-origin. + ...(options?.headers != null && { headers: options.headers }), }; const res = await this.fetchFn(`${this.baseUrl}/api/mcp/send`, { diff --git a/core/mcp/remote/types.ts b/core/mcp/remote/types.ts index 2b9c0e584..23bb6aa98 100644 --- a/core/mcp/remote/types.ts +++ b/core/mcp/remote/types.ts @@ -120,6 +120,11 @@ export interface RemoteSendRequest { message: JSONRPCMessage; /** Optional, for associating response with request (e.g. streamable-http) */ relatedRequestId?: string | number; + /** + * Per-send `Mcp-Param-*` headers (SEP-2243 mirroring). The backend applies + * them to the upstream `transport.send`, filtered to the `Mcp-Param-` prefix. + */ + headers?: Record; } export type RemoteEventType =