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

@ -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<string, unknown>)[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<string, unknown>,
): Record<string, string> {
const out: Record<string, string> = {};
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<string, unknown>,
): Record<string, string> {
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`). */