* 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
344 lines
13 KiB
TypeScript
344 lines
13 KiB
TypeScript
/**
|
||
* SEP-2243 `x-mcp-header` annotation tooling.
|
||
*
|
||
* A modern (≥2026-07-28) Streamable HTTP server may annotate a tool
|
||
* `inputSchema` property with `x-mcp-header: "{Name}"`; a conforming client then
|
||
* mirrors that argument's value into an `Mcp-Param-{Name}` HTTP header on the
|
||
* `tools/call`. The spec places strict constraints on which properties may carry
|
||
* the annotation, and — crucially for a debugging tool — makes a *violating*
|
||
* annotation invalidate the WHOLE tool: a Streamable HTTP client MUST drop such
|
||
* a tool from `tools/list`.
|
||
*
|
||
* The client SDK enforces that exclusion internally (its `listTools()` silently
|
||
* filters invalid tools), but it does not surface *which* tools were dropped or
|
||
* *why*. This module re-implements the SDK's scan so the Inspector can show
|
||
* excluded tools with their reason, and indicate which args mirror to headers on
|
||
* the tools it keeps. It is a faithful port of the SDK's
|
||
* `scanXMcpHeaderDeclarations` (the helper is not part of the SDK's public
|
||
* surface), kept pure and fully unit-testable — no rendering, no I/O.
|
||
*/
|
||
|
||
import type { Tool } from "@modelcontextprotocol/client";
|
||
|
||
/** The schema-extension property name a tool's `inputSchema` carries. */
|
||
export const X_MCP_HEADER_KEY = "x-mcp-header";
|
||
|
||
/** The fixed prefix every mirrored custom-parameter header carries. */
|
||
export const MCP_PARAM_HEADER_PREFIX = "Mcp-Param-";
|
||
|
||
/**
|
||
* RFC 9110 §5.1 `token` syntax (`1*tchar`). Rejects empty, space, control
|
||
* characters (including CR/LF), and the listed HTTP delimiters.
|
||
*/
|
||
const RFC9110_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
||
|
||
/**
|
||
* JSON Schema `type` values the spec admits on an `x-mcp-header` property.
|
||
*
|
||
* The spec text names `integer`, `string`, `boolean` and explicitly excludes
|
||
* `number`. The published conformance referee at the pinned release ships its
|
||
* `http-custom-headers` scenario with `type: "number"` `x-mcp-header` params and
|
||
* expects the client to mirror them, so the SDK accepts `number` for the
|
||
* conformance gate; this port matches the SDK so exclusions agree exactly.
|
||
* Everything else (`object`, `array`, `null`, absent) is rejected.
|
||
*/
|
||
const PERMITTED_X_MCP_HEADER_TYPES: ReadonlySet<string> = new Set([
|
||
"string",
|
||
"integer",
|
||
"boolean",
|
||
"number",
|
||
]);
|
||
|
||
/**
|
||
* JSON Schema keywords whose subschemas the static-reachability constraint
|
||
* excludes from the `properties`-only chain. An `x-mcp-header` found under any
|
||
* of these invalidates the tool definition.
|
||
*/
|
||
const NON_REACHABLE_SUBSCHEMA_KEYWORDS = [
|
||
"items",
|
||
"prefixItems",
|
||
"contains",
|
||
"additionalProperties",
|
||
"unevaluatedProperties",
|
||
"unevaluatedItems",
|
||
"propertyNames",
|
||
"patternProperties",
|
||
"dependentSchemas",
|
||
"oneOf",
|
||
"anyOf",
|
||
"allOf",
|
||
"not",
|
||
"if",
|
||
"then",
|
||
"else",
|
||
"$defs",
|
||
"definitions",
|
||
] as const;
|
||
|
||
/**
|
||
* Subschema-carrying keywords whose value is a `name → subschema` object (not a
|
||
* single subschema or array of subschemas). The visit branches over
|
||
* `Object.values()` for these.
|
||
*/
|
||
const OBJECT_VALUED_SUBSCHEMA_KEYWORDS: ReadonlySet<string> = new Set([
|
||
"patternProperties",
|
||
"dependentSchemas",
|
||
"$defs",
|
||
"definitions",
|
||
]);
|
||
|
||
/** One validated `x-mcp-header` declaration found on a tool's input schema. */
|
||
export interface XMcpHeaderDeclaration {
|
||
/** The chain of `properties` keys locating the annotated property. */
|
||
path: string[];
|
||
/** The declared header suffix — the `{Name}` in `Mcp-Param-{Name}`. */
|
||
headerName: string;
|
||
/** The property's JSON Schema `type` (a permitted primitive). */
|
||
type: string;
|
||
}
|
||
|
||
/** The result of scanning a tool's input schema for `x-mcp-header` usage. */
|
||
export type XMcpHeaderScan =
|
||
| { valid: true; declarations: XMcpHeaderDeclaration[] }
|
||
| { valid: false; reason: string };
|
||
|
||
function pathName(path: string[]): string {
|
||
return path.length === 0 ? "<root>" : path.join(".");
|
||
}
|
||
|
||
function isRecord(node: unknown): node is Record<string, unknown> {
|
||
return node !== null && typeof node === "object";
|
||
}
|
||
|
||
/**
|
||
* Scan a tool's `inputSchema` for `x-mcp-header` declarations and validate every
|
||
* constraint the spec places on them. Returns the collected declarations
|
||
* (possibly empty) on success, or the first violated constraint's reason.
|
||
*
|
||
* The walk descends through `properties` at any depth (the spec's "any nesting
|
||
* depth" clause). The static-reachability MUST is enforced structurally: every
|
||
* position the chain MUST NOT pass through (`items`/`additionalProperties`,
|
||
* `oneOf`/`anyOf`/`allOf`/`not`, `if`/`then`/`else`, and `$defs`/`definitions`
|
||
* bodies) is visited too, and an `x-mcp-header` found anywhere off the
|
||
* `properties` chain invalidates the schema — "an annotation anywhere else makes
|
||
* the tool definition invalid". `$ref` is never followed: a property reachable
|
||
* only through a `$ref` is therefore correctly treated as non-statically-
|
||
* reachable (its annotation, if any, lives in the unreachable `$defs` body).
|
||
*/
|
||
export function scanXMcpHeaderDeclarations(
|
||
inputSchema: unknown,
|
||
): XMcpHeaderScan {
|
||
const declarations: XMcpHeaderDeclaration[] = [];
|
||
const seenLower = new Map<string, string>();
|
||
|
||
const visit = (
|
||
node: unknown,
|
||
path: string[],
|
||
reachable: boolean,
|
||
): string | undefined => {
|
||
if (!isRecord(node)) return undefined;
|
||
const schema = node;
|
||
|
||
if (X_MCP_HEADER_KEY in schema) {
|
||
if (!reachable || path.length === 0) {
|
||
return `${pathName(path)}: x-mcp-header is only permitted on properties statically reachable via a chain of 'properties' keys (not under items, additionalProperties, oneOf/anyOf/allOf/not, if/then/else, or $ref)`;
|
||
}
|
||
const raw = schema[X_MCP_HEADER_KEY];
|
||
if (typeof raw !== "string" || raw.length === 0) {
|
||
return `${pathName(path)}: x-mcp-header MUST be a non-empty string`;
|
||
}
|
||
if (!RFC9110_TOKEN.test(raw)) {
|
||
return `${pathName(path)}: x-mcp-header '${raw}' is not a valid RFC 9110 token (no spaces, control characters or HTTP delimiters)`;
|
||
}
|
||
const type = typeof schema.type === "string" ? schema.type : undefined;
|
||
if (type === undefined || !PERMITTED_X_MCP_HEADER_TYPES.has(type)) {
|
||
return `${pathName(path)}: x-mcp-header is only permitted on primitive-typed properties (string, integer, boolean); got ${type ?? "<none>"}`;
|
||
}
|
||
const lower = raw.toLowerCase();
|
||
const prior = seenLower.get(lower);
|
||
if (prior !== undefined) {
|
||
return `x-mcp-header '${raw}' is not case-insensitively unique (also declared as '${prior}')`;
|
||
}
|
||
seenLower.set(lower, raw);
|
||
declarations.push({ path, headerName: raw, type });
|
||
}
|
||
|
||
const properties = schema.properties;
|
||
if (isRecord(properties)) {
|
||
for (const [key, child] of Object.entries(properties)) {
|
||
const fault = visit(child, [...path, key], reachable);
|
||
if (fault !== undefined) return fault;
|
||
}
|
||
}
|
||
|
||
for (const k of NON_REACHABLE_SUBSCHEMA_KEYWORDS) {
|
||
const sub = schema[k];
|
||
if (sub === undefined) continue;
|
||
const branches = Array.isArray(sub)
|
||
? sub
|
||
: isRecord(sub) && OBJECT_VALUED_SUBSCHEMA_KEYWORDS.has(k)
|
||
? Object.values(sub)
|
||
: [sub];
|
||
for (const branch of branches) {
|
||
const fault = visit(branch, [...path, `<${k}>`], false);
|
||
if (fault !== undefined) return fault;
|
||
}
|
||
}
|
||
|
||
return undefined;
|
||
};
|
||
|
||
const fault = visit(inputSchema, [], true);
|
||
return fault === undefined
|
||
? { valid: true, declarations }
|
||
: { 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`). */
|
||
path: string;
|
||
/** The full header a conforming client sends: `Mcp-Param-{Name}`. */
|
||
header: string;
|
||
/** The declared header suffix. */
|
||
headerName: string;
|
||
/** The property's JSON Schema primitive type. */
|
||
type: string;
|
||
}
|
||
|
||
/**
|
||
* The mirrored-header params for a tool the Inspector kept (its annotations are
|
||
* all valid). Returns `[]` when the tool declares no `x-mcp-header`, and — since
|
||
* a caller only reaches here for *kept* tools — also `[]` for the (unreachable
|
||
* for kept tools) invalid case. Each entry names the arg and the
|
||
* `Mcp-Param-{Name}` header its value mirrors to on a `tools/call`.
|
||
*/
|
||
export function getMirroredHeaderParams(tool: Tool): MirroredHeaderParam[] {
|
||
const scan = scanXMcpHeaderDeclarations(tool.inputSchema);
|
||
if (!scan.valid) return [];
|
||
return scan.declarations.map((d) => ({
|
||
path: d.path.join("."),
|
||
header: `${MCP_PARAM_HEADER_PREFIX}${d.headerName}`,
|
||
headerName: d.headerName,
|
||
type: d.type,
|
||
}));
|
||
}
|