inspector/core/mcp/remote/createRemoteFetch.ts
cliffhall 93965936ba docs: describe the shipped timeout architecture (#2319 round 14)
Three comments still said transport-internal discovery has no
client-side wrapper, or that the wrapper must never be applied to the
transport fetch. Round 11 made both false, and the second actively
tells a caller to do the opposite of what InspectorClient does — while
hiding that isExempt is the safety-critical part.

- createRemoteFetch's marker comment now gives the real reason the
  reconstruction matters: both ends run the same budget, so this path
  is the backend winning the race, most plausibly a backgrounded tab
  throttling setTimeout while the server's fires on schedule. Which
  deadline fired must not decide whether the error names its endpoint.
- The body-buffering comment now says buffering is the second reason
  isExempt is not optional on a mixed chain, since an exempt request
  skips it and an SSE body stays open rather than being drained.
- The matching test comment is reframed as testing the proxy boundary
  independently of which deadline wins.

The PR description is rewritten around what is actually shipped: both
chains bounded and differing only in the exemption, the per-request
envelope that replaced the fetchTimeoutMs option, and the three
mutation-verified tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-11 05:42:00 -04:00

217 lines
7.6 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.
*/
import {
deadlineForRequestInit,
isOAuthRequestTimeoutWire,
OAuthRequestTimeoutError,
} from "../../auth/requestTimeout.js";
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}`;
}
// #2319: forward the caller's cancellation onto the proxy hop. Without it
// an abort — including the OAuth deadline's own — settled only the caller's
// promise: the POST stayed in flight, so the backend never saw its request
// cancelled and its outbound fetch to the authorization server was left
// running detached, holding a handler and a socket for as long as the
// server cared to stall (Copilot). `init.signal` wins when present and not
// `undefined` (a WebIDL dictionary member present as `undefined` converts
// as absent), otherwise a `Request` carries its own.
const explicitSignal = init?.signal;
const signal =
explicitSignal !== undefined
? (explicitSignal ?? undefined)
: input instanceof Request
? input.signal
: undefined;
// #2319: tell the route whether *this* request is bounded, and by how much.
// The route serves MCP traffic as well as OAuth work, and a Streamable HTTP
// tool call can legitimately withhold its response headers for minutes — so
// a timer there must be per-request rather than unconditional (Copilot). An
// exempt request carries no deadline and the route applies none.
//
// In the JSON envelope rather than a header: `headers` is re-sent verbatim
// to the upstream server, so a marker header would leak to a third party.
const timeoutMs = deadlineForRequestInit(init);
const res = await fetchFn(`${baseUrl}/api/fetch`, {
method: "POST",
headers: reqHeaders,
body: JSON.stringify({
url,
method,
headers,
body,
...(timeoutMs !== undefined && { timeoutMs }),
}),
signal,
});
if (!res.ok) {
const text = await res.text();
// A deadline the backend enforced arrives as an ordinary error response;
// rebuild the typed error from its marker so the `instanceof` checks
// downstream — most importantly the one that lets a stalled probe escape
// `withRfc8414OidcCompat` — still see a timeout for what it is (#2319,
// Copilot).
//
// Both ends run the same budget, so the client's race usually settles
// first and this path is not taken. It matters when the backend's timer
// wins anyway — most plausibly a backgrounded tab, where the browser
// throttles `setTimeout` while the server's fires on schedule. Without
// this, which of two equal deadlines happened to fire would decide
// whether the error carried its endpoint.
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
parsed = undefined;
}
if (isOAuthRequestTimeoutWire(parsed)) {
throw new OAuthRequestTimeoutError(parsed.url, parsed.timeoutMs);
}
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);
};
}