* 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
157 lines
4.9 KiB
TypeScript
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);
|
|
};
|
|
}
|