inspector/core/mcp/remote/createRemoteFetch.ts
Sam Morrow 5d91d4acd0
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
2026-07-28 19:19:48 -04:00

157 lines
4.9 KiB
TypeScript

/**
* Creates a fetch implementation that POSTs requests to the remote /api/fetch endpoint.
* Use in the browser to bypass CORS for OAuth and MCP HTTP requests.
*
* SEP-2243 header mirroring note (verified end-to-end against a modern server):
* the modern Streamable HTTP client builds its mirrored headers *before* it
* calls this fetch, and `serializeRequest` copies every request header verbatim
* into the JSON payload, which the Node `/api/fetch` handler re-sends with
* `new Headers(headers)`. So the proxy preserves whatever the SDK emits. But the
* two header families are built differently in the SDK:
* - The STANDARD headers (`Mcp-Method` / `Mcp-Name` / `MCP-Protocol-Version`)
* are derived unconditionally from the request envelope, so they ARE present
* 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 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 {
/** Base URL of the remote server (e.g. http://localhost:3000) */
baseUrl: string;
/** Optional auth token for x-mcp-remote-auth header */
authToken?: string;
/** Base fetch to use for the POST to the remote (default: globalThis.fetch) */
fetchFn?: typeof fetch;
}
/**
* Serialize request for the remote. Handles URLSearchParams body for OAuth token exchange.
*/
async function serializeRequest(
input: RequestInfo | URL,
init?: RequestInit,
): Promise<{
url: string;
method: string;
headers: Record<string, string>;
body?: string;
}> {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: (input as Request).url;
const method =
init?.method ??
(typeof input === "object" && "method" in input
? (input as Request).method
: "GET");
const headers: Record<string, string> = {};
if (input instanceof Request) {
input.headers.forEach((v, k) => {
headers[k] = v;
});
}
if (init?.headers) {
const h = new Headers(init.headers);
h.forEach((v, k) => {
headers[k] = v;
});
}
let body: string | undefined;
if (init?.body !== undefined && init?.body !== null) {
if (typeof init.body === "string") {
body = init.body;
} else if (init.body instanceof URLSearchParams) {
body = init.body.toString();
} else if (init.body instanceof FormData) {
const params = new URLSearchParams();
for (const [key, value] of init.body.entries()) {
if (typeof value === "string") {
params.set(key, value);
}
}
body = params.toString();
} else {
body = String(init.body);
}
} else if (input instanceof Request && input.body) {
const cloned = input.clone();
body = await cloned.text();
}
return { url, method, headers, body };
}
/**
* Deserialize remote response into a Response object.
*/
function deserializeResponse(data: {
ok: boolean;
status: number;
statusText: string;
headers: Record<string, string>;
body?: string;
}): Response {
return new Response(data.body ?? null, {
status: data.status,
statusText: data.statusText,
headers: new Headers(data.headers ?? {}),
});
}
/**
* Returns a fetch function that forwards requests to the remote /api/fetch endpoint.
* The remote server performs the actual HTTP request in Node (no CORS).
*/
export function createRemoteFetch(options: RemoteFetchOptions): typeof fetch {
const baseUrl = options.baseUrl.replace(/\/$/, "");
const fetchFn = options.fetchFn ?? globalThis.fetch;
return async (
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
const { url, method, headers, body } = await serializeRequest(input, init);
const reqHeaders: Record<string, string> = {
"Content-Type": "application/json",
...headers,
};
if (options.authToken) {
reqHeaders["x-mcp-remote-auth"] = `Bearer ${options.authToken}`;
}
const res = await fetchFn(`${baseUrl}/api/fetch`, {
method: "POST",
headers: reqHeaders,
body: JSON.stringify({ url, method, headers, body }),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Remote fetch failed (${res.status}): ${text}`);
}
const data = (await res.json()) as {
ok: boolean;
status: number;
statusText: string;
headers: Record<string, string>;
body?: string;
};
return deserializeResponse(data);
};
}