fix(core): mirror SEP-2243 x-mcp-header args to Mcp-Param-* on tools/call (#1847)

* fix(core): mirror SEP-2243 x-mcp-header args to Mcp-Param-* on tools/call (#1846)

A strict modern (2026-07-28) HTTP server (e.g. the GitHub MCP Server)
rejects a tools/call whose argument carries an `x-mcp-header` annotation
when the matching `Mcp-Param-*` request header is missing (-32020
"header mismatch"). The Inspector never sent that header.

The SDK only mirrors inside `client.callTool()` (and skips it entirely in
a browser environment), but the Inspector routes tools/call through
`client.request()` for manual MRTR driving (#1704), so the SDK never
mirrors for us — on any transport. On top of that, the remote (web)
transport dropped per-request headers before they reached the backend's
upstream fetch.

- core/json/xMcpHeader.ts: port the SDK's non-public `buildMcpParamHeaders`
  + value encoding (Base64 sentinel for non-ASCII/whitespace/empty values)
  and add `mcpParamHeadersForTool`.
- InspectorClient.attemptToolCall: on a modern connection, build the
  headers from the tool's declarations + call arguments and attach them to
  the tools/call request options. `Protocol.request` forwards `headers`
  to the transport and preserves them across MRTR retry legs, so the
  direct StreamableHTTP transport applies them. Fixes CLI/TUI.
- RemoteClientTransport.requestSend + /api/mcp/send + RemoteSendRequest:
  forward per-send headers and apply them to the backend's upstream
  transport.send. The browser can't set cross-origin headers, but the
  Node backend can — where the Inspector's real request is issued. Fixes web.

Verified end-to-end against the live GitHub MCP Server (modern HTTP).

Closes #1846

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 36dbeb09-12b0-4c19-af36-2573772a7dbd

* review: harden backend header allowlist, fix breadcrumbs, trim comments

Address Copilot review on #1847:
- server.ts: restrict client-supplied per-send headers to the `Mcp-Param-`
  prefix (string values only) via `mcpParamHeadersOnly` before applying them
  upstream, so the per-send channel can't inject arbitrary headers (e.g.
  Authorization). `settings.headers` remains the channel for configured
  upstream headers. Covered by new unit tests.
- Fix stale `#1810` breadcrumbs → `#1846` in the two new tests.
- Trim the verbose mirroring comments in inspectorClient/types/remote transport.
- Drop the redundant base64-on-the-wire integration test (encoding is unit-
  tested; wire delivery is proven by the verbatim test), remove two unneeded
  test casts, and cover the boolean `true` branch.

Not changed (declined with reason on the PR): buildMcpParamHeaders remains a
faithful port of the SDK's helper and does not re-validate values against the
declared JSON Schema type — arg types are enforced by schema validation on
both sides, and matching the SDK's conforming wire output is the module's goal.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 36dbeb09-12b0-4c19-af36-2573772a7dbd

* review: mirror Mcp-Param-* on the task-augmented tools/call too

Address the maintainer review on #1847:

- `callToolStream` (the "Run as task" path) built its own request options and
  never mirrored, so a task-augmented `tools/call` on an annotated tool was
  still rejected by a strict modern server with -32020. It now mirrors like the
  plain path.
- Extract the two blocks both `tools/call` entry points had duplicated —
  `convertStringToolArgs` (the string-arg coercion) and
  `applyMirroredParamHeaders` (the SEP-2243 mirroring) — so the paths can't
  drift again. That duplication is why the task path was missed.
- Regression test: `callToolStream` against the modern `get_weather` server
  asserts the POST carries `Mcp-Param-City` (verified failing without the fix).
- README: the modern-network showcase claimed mirroring is skipped in the
  browser so the web client gets -32020, and that CLI/TUI mirror. Both are now
  wrong — the Inspector mirrors itself, on every client. AGENTS: note that
  xMcpHeader.ts now also builds the wire headers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkhhCHryVp5H3dqEnhwZ7t

* docs: correct the stale "browser omits Mcp-Param-*" copy

Both statements predate this PR and are now the opposite of true, per the
wire capture on #1847 (proxy-observed upstream headers, web leg included).

- ToolDetailPanel: the mirrored-headers note claimed the browser omits
  `Mcp-Param-*`. It now says the web client's headers are applied by the Node
  backend that issues the upstream request.
- createRemoteFetch: the header comment claimed an `x-mcp-header`-annotated
  tool is "uncallable from the web client against a strict modern server" —
  the exact wrong conclusion to reach while debugging a -32020. It now records
  that the Inspector builds the headers itself, and that they do NOT travel
  this `/api/fetch` proxy path: they ride the transport's per-send `headers`
  through `/api/mcp/send`.

Copy only — no test asserted the old note text (ToolDetailPanel.test.tsx
matches the section title only).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkhhCHryVp5H3dqEnhwZ7t

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: cliffhall <cliff@futurescale.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot-Session: 36dbeb09-12b0-4c19-af36-2573772a7dbd
This commit is contained in:
Sam Morrow 2026-07-29 01:19:48 +02:00 • committed by GitHub
commit 5d91d4acd0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -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({});
});
});