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>
217 lines
7.6 KiB
TypeScript
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);
|
|
};
|
|
}
|