Merge pull request #2324 from modelcontextprotocol/v2/feat/2319-oauth-timeouts

feat: bound every OAuth-path request with a deadline
This commit is contained in:
Cliff Hall 2026-09-11 09:16:06 -04:00 • committed by GitHub
commit fcfc9ba359
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 2444 additions and 29 deletions

View file

@ -53,6 +53,7 @@ import {
getAuthorizationServerUrlCandidates,
} from "@inspector/core/auth/discovery.js";
import { withRfc8414OidcCompat } from "@inspector/core/auth/oidcDiscoveryCompat.js";
import { withOAuthRequestTimeout } from "@inspector/core/auth/requestTimeout.js";
import { writeStoreFile } from "@inspector/core/storage/store-io.js";
import {
refreshAuthorization,
@ -353,7 +354,14 @@ export async function refreshStoredAuthToken(
// else puts a proxy under it, and a server reachable only through
// `HTTPS_PROXY` would otherwise be probed directly. The same fetch is handed
// to the token request below, so neither leg bypasses the proxy (Copilot).
const storedAuthFetch = withRfc8414OidcCompat(createProxyFetch() ?? fetch);
// #2319: this path runs outside `InspectorClient`, so nothing else bounds it
// — the discovery and the token request below would otherwise hang forever
// against an authorization server that accepts the connection and never
// answers. Innermost, so the compat wrapper's own probe requests inherit the
// deadline too.
const storedAuthFetch = withRfc8414OidcCompat(
withOAuthRequestTimeout(createProxyFetch() ?? fetch),
);
const discover: typeof discoverAuthorizationServerMetadata =
deps.discover ??
((authorizationServerUrl, options) =>

View file

@ -439,3 +439,186 @@ describe("withRfc8414OidcCompat", () => {
expect(base).toHaveBeenCalledTimes(2);
});
});
describe("probe cancellation (#2319)", () => {
const RFC8414 =
"https://as.example.com/.well-known/oauth-authorization-server/tenant";
/** 404 on the RFC 8414 leg, then never answers the OIDC probe. */
function stallingProbeFetch() {
return vi.fn<typeof fetch>((input, init) => {
if (String(input) === RFC8414) {
return Promise.resolve(new Response(null, { status: 404 }));
}
return new Promise<Response>((_resolve, reject) => {
// A real `fetch` rejects immediately on a signal that is already
// aborted, so the double is only useful if it does the same.
if (init?.signal?.aborted) {
reject(init.signal.reason as Error);
return;
}
init?.signal?.addEventListener("abort", () =>
reject(init.signal?.reason as Error),
);
});
});
}
it("carries the caller's signal into the probe", async () => {
const inner = stallingProbeFetch();
const wrapped = withRfc8414OidcCompat(inner);
const caller = new AbortController();
const settled = wrapped(RFC8414, { signal: caller.signal }).then(
(r) => r,
(e: unknown) => e,
);
// Wait until the probe is actually in flight, so the abort exercises
// cancellation rather than the pre-aborted short-circuit.
await vi.waitFor(() => {
expect(inner).toHaveBeenCalledTimes(2);
});
const reason = new Error("caller gave up");
caller.abort(reason);
// Not the preceding 404: substituting it would report a discovery failure
// for a request the caller deliberately cancelled.
expect(await settled).toBe(reason);
expect((inner.mock.calls[1][1] as RequestInit).signal).toBe(caller.signal);
});
it("does not issue a probe the caller already cancelled", async () => {
const inner = stallingProbeFetch();
const wrapped = withRfc8414OidcCompat(inner);
const reason = new Error("already gone");
const settled = wrapped(RFC8414, {
signal: AbortSignal.abort(reason),
}).then(
(r) => r,
(e: unknown) => e,
);
expect(await settled).toBe(reason);
// Only the RFC 8414 leg, which the caller did make.
expect(inner).toHaveBeenCalledTimes(1);
});
it("rethrows an abort that lands while the probe body is pending", async () => {
// `fetch` resolves on headers, so a late abort rejects `probe.text()`
// rather than the fetch — a separate catch, which used to answer with the
// preceding 404.
const caller = new AbortController();
const inner = vi.fn<typeof fetch>((input, init) => {
if (String(input) === RFC8414) {
return Promise.resolve(new Response(null, { status: 404 }));
}
// Headers now, body never.
return Promise.resolve(
new Response(
new ReadableStream<Uint8Array>({
start(ctrl) {
ctrl.enqueue(new TextEncoder().encode('{"issuer":'));
init?.signal?.addEventListener("abort", () =>
ctrl.error(init.signal?.reason as Error),
);
},
}),
{ headers: { "content-type": "application/json" } },
),
);
});
const wrapped = withRfc8414OidcCompat(inner);
const settled = wrapped(RFC8414, { signal: caller.signal }).then(
(r) => r,
(e: unknown) => e,
);
await vi.waitFor(() => {
expect(inner).toHaveBeenCalledTimes(2);
});
const reason = new Error("caller gave up mid-body");
caller.abort(reason);
expect(await settled).toBe(reason);
});
it("releases the discarded discovery response on an exceptional exit", async () => {
// Nobody receives `response` when the loop throws, and an unread body holds
// its connection open on Node/undici — the same reason the successful
// substitution path releases it.
const cancels = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
const discovery = new Response(
new ReadableStream<Uint8Array>({
start(ctrl) {
ctrl.enqueue(new TextEncoder().encode("not found"));
},
}),
{ status: 404 },
);
// Spy on the body's cancel, which is what `releaseBody` calls.
const body = discovery.body;
if (body) vi.spyOn(body, "cancel").mockImplementation(cancels);
const inner = vi.fn<typeof fetch>((input) =>
String(input) === RFC8414
? Promise.resolve(discovery)
: new Promise<Response>(() => {}),
);
const wrapped = withRfc8414OidcCompat(inner);
const settled = wrapped(RFC8414, {
signal: AbortSignal.abort(new Error("gone")),
}).then(
() => "resolved",
() => "threw",
);
expect(await settled).toBe("threw");
expect(cancels).toHaveBeenCalled();
});
it("does not wait on a cancel that never settles", async () => {
// ⚠️ `ReadableStream.cancel()` adopts the underlying source's cancel
// promise, which is permitted never to settle — so awaiting the release on
// a path that is propagating a cancellation would hang the very thing it
// was meant to end.
const discovery = new Response(
new ReadableStream<Uint8Array>({
start(ctrl) {
ctrl.enqueue(new TextEncoder().encode("not found"));
},
cancel() {
return new Promise<void>(() => {});
},
}),
{ status: 404 },
);
const inner = vi.fn<typeof fetch>((input) =>
String(input) === RFC8414
? Promise.resolve(discovery)
: new Promise<Response>(() => {}),
);
const wrapped = withRfc8414OidcCompat(inner);
const reason = new Error("gone");
// Would hang if the release were awaited; the assertion is that it settles.
await expect(
wrapped(RFC8414, { signal: AbortSignal.abort(reason) }),
).rejects.toBe(reason);
});
it("still falls back to the original response on an ordinary probe failure", async () => {
const inner = vi.fn<typeof fetch>((input) => {
if (String(input) === RFC8414) {
return Promise.resolve(new Response(null, { status: 404 }));
}
return Promise.reject(new TypeError("network error"));
});
const wrapped = withRfc8414OidcCompat(inner);
const response = await wrapped(RFC8414);
expect(response.status).toBe(404);
});
});

View file

@ -0,0 +1,895 @@
/**
* Unit coverage for `withOAuthRequestTimeout` (#2319) — the deadline every
* OAuth-path request runs under.
*
* What is pinned here, and why each case exists rather than being obvious:
*
* - **The bound itself**, including the *body*: `fetch` resolves on headers, so
* a server that sends them and then stalls has to be caught by the buffering
* race, not by the fetch promise.
* - **The response's identity.** The bound drains a *clone* and hands back the
* original, so the cases assert identity, that the body is still readable
* after the drain, and that a caller's own `clone()` keeps its metadata —
* the property a rebuilt response could not have, since `url` / `redirected`
* / `type` are internal slots.
* - **Cancellation semantics.** The caller's signal is forwarded *and* raced,
* so the tests distinguish the two: one uses a fetch that honours the signal,
* another uses one that ignores it entirely.
* - **Composition with `withRfc8414OidcCompat`**, written as a contrast between
* the two orderings. It is the ordering that is under test, not one
* arrangement's behaviour, so the wrong one is asserted to misattribute.
*
* Fake timers throughout: every budget here is exercised by advancing the clock
* rather than by waiting, so the suite costs milliseconds.
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import {
DEFAULT_OAUTH_REQUEST_TIMEOUT_MS,
OAuthRequestTimeoutError,
deadlineForRequestInit,
exemptMcpEndpoint,
withOAuthRequestTimeout,
} from "@inspector/core/auth/requestTimeout.js";
import { withRfc8414OidcCompat } from "@inspector/core/auth/oidcDiscoveryCompat.js";
const URL_UNDER_TEST =
"https://as.example.com/.well-known/oauth-authorization-server";
/** A fetch that never settles — the wedged authorization server of #2319. */
const neverSettles: typeof fetch = () => new Promise<Response>(() => {});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe("withOAuthRequestTimeout", () => {
it("passes a prompt response through", async () => {
// The same object: the body is drained through a clone under the deadline
// (see the stalled-body case below), so nothing about the response the
// caller receives changes.
const inner = vi
.fn<typeof fetch>()
.mockResolvedValue(new Response('{"ok":true}', { status: 200 }));
const wrapped = withOAuthRequestTimeout(inner, 1000);
const response = await wrapped(URL_UNDER_TEST);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ ok: true });
expect(inner).toHaveBeenCalledTimes(1);
});
it("rejects a stalled request with an error naming the endpoint", async () => {
vi.useFakeTimers();
const wrapped = withOAuthRequestTimeout(neverSettles, 1000);
const pending = wrapped(URL_UNDER_TEST);
const assertion = expect(pending).rejects.toThrow(
`OAuth request to ${URL_UNDER_TEST} timed out after 1000ms`,
);
await vi.advanceTimersByTimeAsync(1000);
await assertion;
});
it("reports the timeout as OAuthRequestTimeoutError carrying url and budget", async () => {
vi.useFakeTimers();
const wrapped = withOAuthRequestTimeout(neverSettles, 1000);
const pending = wrapped(new URL(URL_UNDER_TEST));
const assertion = pending.catch((err: unknown) => err);
await vi.advanceTimersByTimeAsync(1000);
const err = await assertion;
expect(err).toBeInstanceOf(OAuthRequestTimeoutError);
const timeout = err as OAuthRequestTimeoutError;
expect(timeout.name).toBe("OAuthRequestTimeoutError");
expect(timeout.url).toBe(URL_UNDER_TEST);
expect(timeout.timeoutMs).toBe(1000);
});
it("redacts sensitive query values from the endpoint it names", async () => {
vi.useFakeTimers();
// This message is recorded verbatim by `createFetchTracker` into the
// Network log and the persisted session, and an OAuth endpoint's query
// string can carry a `code`, an `access_token` or a `client_secret` —
// which `fetchTracking` already redacts everywhere else it records a URL.
const sensitive =
"https://as.example.com/token?code=abc123&client_secret=shhh&state=keepme";
const wrapped = withOAuthRequestTimeout(neverSettles, 1000);
const assertion = wrapped(sensitive).catch((err: unknown) => err);
await vi.advanceTimersByTimeAsync(1000);
const err = (await assertion) as OAuthRequestTimeoutError;
for (const secret of ["abc123", "shhh"]) {
expect(err.message).not.toContain(secret);
expect(err.url).not.toContain(secret);
}
// Still identifiable, which was the point of naming the endpoint: the path
// and every non-sensitive parameter survive. Read through `URL`, since
// `URLSearchParams.toString()` percent-encodes the redaction marker.
const redacted = new URL(err.url);
expect(redacted.origin + redacted.pathname).toBe(
"https://as.example.com/token",
);
expect(redacted.searchParams.get("state")).toBe("keepme");
expect(redacted.searchParams.get("code")).toBe("[REDACTED]");
expect(redacted.searchParams.get("client_secret")).toBe("[REDACTED]");
});
it("takes the URL off a Request object too", async () => {
vi.useFakeTimers();
const wrapped = withOAuthRequestTimeout(neverSettles, 1000);
const pending = wrapped(new Request(URL_UNDER_TEST, { method: "POST" }));
const assertion = pending.catch((err: unknown) => err);
await vi.advanceTimersByTimeAsync(1000);
expect((await assertion) as OAuthRequestTimeoutError).toMatchObject({
url: URL_UNDER_TEST,
});
});
it("aborts the underlying fetch rather than leaving it running detached", async () => {
vi.useFakeTimers();
let seen: AbortSignal | undefined;
const inner: typeof fetch = (_input, init) => {
seen = init?.signal ?? undefined;
return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () =>
reject(new Error("aborted by signal")),
);
});
};
const wrapped = withOAuthRequestTimeout(inner, 1000);
const assertion = wrapped(URL_UNDER_TEST).catch((err: unknown) => err);
await vi.advanceTimersByTimeAsync(1000);
expect(seen?.aborted).toBe(true);
// The underlying rejection can win the race against our own; either way the
// caller must be told it was a timeout, and which endpoint stalled.
expect((await assertion) as Error).toBeInstanceOf(OAuthRequestTimeoutError);
});
it("still enforces the deadline when the fetch ignores the signal", async () => {
vi.useFakeTimers();
// The signal now reaches all the way out — `createRemoteFetch` forwards it
// onto the proxy hop and `/api/fetch` composes it into its outbound call —
// but a `fetchFn` that ignores `AbortSignal` cancels nothing, so the race
// is what bounds this case.
const signalIgnoring: typeof fetch = () => new Promise<Response>(() => {});
const wrapped = withOAuthRequestTimeout(signalIgnoring, 1000);
const assertion = wrapped(URL_UNDER_TEST).catch((err: unknown) => err);
await vi.advanceTimersByTimeAsync(1000);
expect((await assertion) as Error).toBeInstanceOf(OAuthRequestTimeoutError);
});
it("forwards a caller-supplied signal to the inner fetch", async () => {
const inner: typeof fetch = (_input, init) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () =>
reject(new Error("caller cancelled")),
);
});
const wrapped = withOAuthRequestTimeout(inner, 60_000);
const caller = new AbortController();
const pending = wrapped(URL_UNDER_TEST, { signal: caller.signal });
caller.abort();
await expect(pending).rejects.toThrow("caller cancelled");
});
it("races caller cancellation too, so it wins on a signal-ignoring fetch", async () => {
// Forwarding alone is not enough against a `fetchFn` that ignores the
// signal: without the race the caller's abort would sit pending for the
// whole budget instead of winning.
const wrapped = withOAuthRequestTimeout(neverSettles, 60_000);
const caller = new AbortController();
const pending = wrapped(URL_UNDER_TEST, { signal: caller.signal });
const reason = new Error("caller gave up");
caller.abort(reason);
await expect(pending).rejects.toBe(reason);
});
it("rejects an already-aborted signal without sending the request", async () => {
const inner = vi.fn<typeof fetch>(neverSettles);
const wrapped = withOAuthRequestTimeout(inner, 60_000);
const reason = new Error("already gone");
await expect(
wrapped(URL_UNDER_TEST, { signal: AbortSignal.abort(reason) }),
).rejects.toBe(reason);
// Racing it would still have evaluated the inner fetch, and a fetch that
// does not check an already-aborted signal would send the request.
expect(inner).not.toHaveBeenCalled();
});
it("treats an undefined init.signal as absent, keeping a Request's own", async () => {
// `RequestInit` is a WebIDL dictionary: a member present as `undefined` is
// converted as absent. `{ ...base, signal: undefined }` is what a spread
// over an options object with no signal produces.
const wrapped = withOAuthRequestTimeout(neverSettles, 60_000);
const caller = new AbortController();
const pending = wrapped(
new Request(URL_UNDER_TEST, { signal: caller.signal }),
{ signal: undefined },
);
const reason = new Error("request cancelled");
caller.abort(reason);
await expect(pending).rejects.toBe(reason);
});
it("honours the signal embedded in a Request input", async () => {
// `init.signal` is absent here, so the Request's own signal is the caller's
// — reading `init` alone would silently override it.
const wrapped = withOAuthRequestTimeout(neverSettles, 60_000);
const caller = new AbortController();
const pending = wrapped(
new Request(URL_UNDER_TEST, { signal: caller.signal }),
);
const reason = new Error("request cancelled");
caller.abort(reason);
await expect(pending).rejects.toBe(reason);
});
it("lets an explicit null init.signal override a Request's own", async () => {
vi.useFakeTimers();
// Per the fetch spec a present `signal` key wins, `null` included.
const wrapped = withOAuthRequestTimeout(neverSettles, 1000);
const caller = new AbortController();
const pending = wrapped(
new Request(URL_UNDER_TEST, { signal: caller.signal }),
{ signal: null },
);
const assertion = pending.catch((err: unknown) => err);
caller.abort(new Error("ignored"));
await vi.advanceTimersByTimeAsync(1000);
expect((await assertion) as Error).toBeInstanceOf(OAuthRequestTimeoutError);
});
it("removes its abort listener once the request settles", async () => {
const caller = new AbortController();
const removeSpy = vi.spyOn(caller.signal, "removeEventListener");
const wrapped = withOAuthRequestTimeout(
vi.fn<typeof fetch>().mockResolvedValue(new Response("{}")),
60_000,
);
await wrapped(URL_UNDER_TEST, { signal: caller.signal });
// A caller signal outlives one request — one connect attempt makes several
// — so a listener left behind per request would accumulate on it.
expect(removeSpy).toHaveBeenCalledWith("abort", expect.any(Function));
});
it("bounds a stalled response body, not just the headers", async () => {
vi.useFakeTimers();
// `fetch` resolves on headers. A server that sends them and then stalls
// would leave the caller's `response.json()` hanging unwatched.
const stalledBody: typeof fetch = () =>
Promise.resolve(
new Response(
new ReadableStream<Uint8Array>({
start(ctrl) {
ctrl.enqueue(new TextEncoder().encode('{"issuer":'));
},
}),
{ headers: { "content-type": "application/json" } },
),
);
const wrapped = withOAuthRequestTimeout(stalledBody, 1000);
const assertion = wrapped(URL_UNDER_TEST).catch((err: unknown) => err);
await vi.advanceTimersByTimeAsync(1000);
expect((await assertion) as Error).toBeInstanceOf(OAuthRequestTimeoutError);
});
it("releases both tee branches when the deadline wins", async () => {
vi.useFakeTimers();
// Losing the race rejects the caller; on its own it does not stop the read.
// A drain still running would keep pulling bytes *and* keep buffering them
// for the unread original branch — unbounded memory on exactly the
// signal-ignoring body the race exists to contain. Observed through the
// stream's own `cancel`, which is what tearing the branches down calls.
let cancelled = false;
const stalled = new ReadableStream<Uint8Array>({
start(ctrl) {
ctrl.enqueue(new TextEncoder().encode('{"issuer":'));
},
// Never resolves: headers arrived, the body never finishes.
pull() {
return new Promise<void>(() => {});
},
cancel() {
cancelled = true;
},
});
const wrapped = withOAuthRequestTimeout(
vi
.fn<typeof fetch>()
.mockResolvedValue(
new Response(stalled, { headers: { "content-type": "text/plain" } }),
),
1000,
);
const assertion = wrapped(URL_UNDER_TEST).catch((err: unknown) => err);
await vi.advanceTimersByTimeAsync(1000);
expect((await assertion) as Error).toBeInstanceOf(OAuthRequestTimeoutError);
// The cancels are deliberately not awaited — cancelling one branch of a tee
// can wait on the other — so let them settle before observing.
for (let i = 0; i < 20; i++) await Promise.resolve();
expect(cancelled).toBe(true);
});
it("still reports the timeout when tearing the branches down fails", async () => {
vi.useFakeTimers();
// Cancelling a stream that is already errored, locked elsewhere, or whose
// source rejects is not a failure here — the cleanup runs on a path that is
// already rejecting with something the caller cares about far more.
const hostile = new ReadableStream<Uint8Array>({
start(ctrl) {
ctrl.enqueue(new TextEncoder().encode("x"));
},
pull() {
return new Promise<void>(() => {});
},
cancel() {
throw new Error("cancel blew up");
},
});
const wrapped = withOAuthRequestTimeout(
vi
.fn<typeof fetch>()
.mockResolvedValue(
new Response(hostile, { headers: { "content-type": "text/plain" } }),
),
1000,
);
const assertion = wrapped(URL_UNDER_TEST).catch((err: unknown) => err);
await vi.advanceTimersByTimeAsync(1000);
expect((await assertion) as Error).toBeInstanceOf(OAuthRequestTimeoutError);
// And no unhandled rejection escapes from the cleanup.
for (let i = 0; i < 20; i++) await Promise.resolve();
});
it("hands back the original response, not a copy", async () => {
const original = new Response('{"issuer":"https://as.example.com"}', {
status: 201,
statusText: "Created",
headers: { "content-type": "application/json", "x-probe": "kept" },
});
const wrapped = withOAuthRequestTimeout(
vi.fn<typeof fetch>().mockResolvedValue(original),
1000,
);
const response = await wrapped(URL_UNDER_TEST);
// Identity, which is the strongest form of "nothing was changed": every
// native slot, the headers guard and `clone()` behave as they would if this
// wrapper were not in the chain at all.
expect(response).toBe(original);
await expect(response.json()).resolves.toEqual({
issuer: "https://as.example.com",
});
});
it("leaves the body readable after draining the clone", async () => {
// The bound works by draining a clone; teeing buffers the other branch, so
// the caller must still be able to read the original without a second trip.
const wrapped = withOAuthRequestTimeout(
vi.fn<typeof fetch>().mockResolvedValue(new Response("hello")),
1000,
);
const response = await wrapped(URL_UNDER_TEST);
expect(response.bodyUsed).toBe(false);
await expect(response.text()).resolves.toBe("hello");
});
it("leaves the caller free to clone it again, with metadata intact", async () => {
// The case a rebuilt response could never satisfy: `url` / `redirected` /
// `type` are internal slots, so a shadowed own property survives a direct
// read and is lost the moment anyone calls `clone()`.
const original = new Response("{}", { status: 200 });
Object.defineProperty(original, "url", {
value: "https://as.example.com/redirected",
configurable: true,
});
Object.defineProperty(original, "redirected", {
value: true,
configurable: true,
});
const wrapped = withOAuthRequestTimeout(
vi.fn<typeof fetch>().mockResolvedValue(original),
1000,
);
const response = await wrapped(URL_UNDER_TEST);
const copy = response.clone();
expect(copy.url).toBe("https://as.example.com/redirected");
expect(copy.redirected).toBe(true);
await expect(copy.json()).resolves.toEqual({});
});
it("keeps content-encoding and content-length, which are the server's own", async () => {
// Nothing is rebuilt, so nothing invalidates them: the caller gets the
// response `fetch` produced and decodes it the way `fetch` intends.
const original = new Response("{}", {
headers: {
"content-type": "application/json",
"content-encoding": "gzip",
},
});
const wrapped = withOAuthRequestTimeout(
vi.fn<typeof fetch>().mockResolvedValue(original),
1000,
);
const response = await wrapped(URL_UNDER_TEST);
expect(response.headers.get("content-encoding")).toBe("gzip");
});
it("passes a null-body status through untouched", async () => {
const original = new Response(null, { status: 204 });
const wrapped = withOAuthRequestTimeout(
vi.fn<typeof fetch>().mockResolvedValue(original),
1000,
);
const response = await wrapped(URL_UNDER_TEST);
expect(response).toBe(original);
expect(response.status).toBe(204);
expect(response.body).toBeNull();
});
it("propagates a non-timeout failure unchanged", async () => {
const boom = new TypeError("network error");
const inner = vi.fn<typeof fetch>().mockRejectedValue(boom);
const wrapped = withOAuthRequestTimeout(inner, 1000);
await expect(wrapped(URL_UNDER_TEST)).rejects.toBe(boom);
});
it("rounds a fractional budget, which is reported to the caller", async () => {
vi.useFakeTimers();
// `setTimeout` would accept the fraction and truncate it. The rounding is
// for the budget's *reported* form: a `performance.now()` subtraction would
// otherwise name "1000.4000000953674ms" in the message and expose a
// non-integer `timeoutMs`.
const wrapped = withOAuthRequestTimeout(neverSettles, 1000.4);
const assertion = wrapped(URL_UNDER_TEST).catch((err: unknown) => err);
await vi.advanceTimersByTimeAsync(1000);
expect((await assertion) as OAuthRequestTimeoutError).toMatchObject({
timeoutMs: 1000,
});
});
it("falls back to the default on a non-finite budget", async () => {
vi.useFakeTimers();
// `NaN` survives `Math.max(0, Math.round(NaN))` and `setTimeout(fn, NaN)`
// fires immediately, so without this every OAuth request under a budget
// that came out of bad arithmetic would fail at once with "timed out after
// NaNms".
for (const bad of [NaN, Infinity, -Infinity]) {
const wrapped = withOAuthRequestTimeout(neverSettles, bad);
const assertion = wrapped(URL_UNDER_TEST).catch((err: unknown) => err);
await vi.advanceTimersByTimeAsync(DEFAULT_OAUTH_REQUEST_TIMEOUT_MS - 1);
expect(await Promise.race([assertion, Promise.resolve("pending")])).toBe(
"pending",
);
await vi.advanceTimersByTimeAsync(1);
expect((await assertion) as OAuthRequestTimeoutError).toMatchObject({
timeoutMs: DEFAULT_OAUTH_REQUEST_TIMEOUT_MS,
});
}
});
it("clamps an over-large budget to what setTimeout can schedule", async () => {
vi.useFakeTimers();
// Past 2**31-1 the delay overflows a 32-bit signed int and Node falls back
// to 1ms — an immediate timeout, the opposite of what was asked for.
const wrapped = withOAuthRequestTimeout(neverSettles, 2 ** 40);
const assertion = wrapped(URL_UNDER_TEST).catch((err: unknown) => err);
await vi.advanceTimersByTimeAsync(1000);
expect(await Promise.race([assertion, Promise.resolve("pending")])).toBe(
"pending",
);
await vi.advanceTimersByTimeAsync(2_147_483_647);
expect((await assertion) as OAuthRequestTimeoutError).toMatchObject({
timeoutMs: 2_147_483_647,
});
});
it("clamps a negative budget to zero rather than throwing", async () => {
vi.useFakeTimers();
const wrapped = withOAuthRequestTimeout(neverSettles, -1);
const assertion = wrapped(URL_UNDER_TEST).catch((err: unknown) => err);
await vi.advanceTimersByTimeAsync(0);
expect((await assertion) as OAuthRequestTimeoutError).toMatchObject({
timeoutMs: 0,
});
});
it("defaults to a generous budget a slow authorization server can meet", async () => {
vi.useFakeTimers();
const wrapped = withOAuthRequestTimeout(neverSettles);
const assertion = wrapped(URL_UNDER_TEST).catch((err: unknown) => err);
await vi.advanceTimersByTimeAsync(DEFAULT_OAUTH_REQUEST_TIMEOUT_MS - 1);
expect(await Promise.race([assertion, Promise.resolve("pending")])).toBe(
"pending",
);
await vi.advanceTimersByTimeAsync(1);
expect((await assertion) as OAuthRequestTimeoutError).toMatchObject({
timeoutMs: DEFAULT_OAUTH_REQUEST_TIMEOUT_MS,
});
});
describe("the deadline stamped on the init (#2319 proxy hop)", () => {
// How the budget reaches `/api/fetch` without travelling upstream: a header
// would be copied verbatim into the payload and re-sent to the
// authorization server, so the carrier is a WeakMap keyed on the init.
it("stamps the budget on the init it hands down", async () => {
const inner = vi.fn<typeof fetch>().mockResolvedValue(new Response("{}"));
const wrapped = withOAuthRequestTimeout(inner, 1234);
await wrapped(URL_UNDER_TEST);
expect(deadlineForRequestInit(inner.mock.calls[0][1])).toBe(1234);
});
it("stamps the rounded budget, matching what is reported", async () => {
const inner = vi.fn<typeof fetch>().mockResolvedValue(new Response("{}"));
const wrapped = withOAuthRequestTimeout(inner, 1000.4);
await wrapped(URL_UNDER_TEST);
expect(deadlineForRequestInit(inner.mock.calls[0][1])).toBe(1000);
});
it("stamps nothing on an exempt request", async () => {
// The route must apply no deadline to MCP traffic, and it decides that
// from the absence of a budget in the envelope.
const inner = vi.fn<typeof fetch>().mockResolvedValue(new Response("{}"));
const wrapped = withOAuthRequestTimeout(inner, 1234, () => true);
await wrapped(URL_UNDER_TEST, { method: "POST" });
expect(deadlineForRequestInit(inner.mock.calls[0][1])).toBeUndefined();
});
it("reads nothing off an init that never went through the wrapper", () => {
expect(deadlineForRequestInit(undefined)).toBeUndefined();
expect(deadlineForRequestInit({})).toBeUndefined();
expect(deadlineForRequestInit({ method: "GET" })).toBeUndefined();
});
});
describe("exemptMcpEndpoint (the transport chain's mixed traffic)", () => {
const SERVER = "https://srv.example.com/mcp";
/** Most cases are plain JSON-RPC traffic, which carries no form media type. */
const JSON_HEADERS = new Headers({ "content-type": "application/json" });
/** The token family: RFC 6749 §4.1.3 and RFC 7009 §2.1 are form-encoded. */
const FORM_HEADERS = new Headers({
"content-type": "application/x-www-form-urlencoded;charset=UTF-8",
});
/** Streamable HTTP: every MCP request goes to the configured URL. */
const streamable = (serverUrl: string) =>
exemptMcpEndpoint(
() => serverUrl,
() => false,
);
/** Legacy SSE: the message endpoint arrives inside the stream. */
const sse = (serverUrl: string) =>
exemptMcpEndpoint(
() => serverUrl,
() => true,
);
it("exempts the MCP endpoint itself, whatever method or query", () => {
const isExempt = exemptMcpEndpoint(() => SERVER);
expect(isExempt(SERVER, JSON_HEADERS)).toBe(true);
expect(isExempt(`${SERVER}?sessionId=abc`, JSON_HEADERS)).toBe(true);
expect(isExempt(`${SERVER}#frag`, JSON_HEADERS)).toBe(true);
});
it("never exempts a form-encoded request, whatever URL it uses", () => {
// `oauthTokenUrl` takes any absolute URL, including the MCP endpoint's
// own — at which point a URL rule would exempt the transport-internal
// refresh, the one request this change exists to bound. The token family
// is form-encoded and MCP never is, so the media type settles it first.
for (const isExempt of [
streamable(SERVER),
sse(SERVER),
exemptMcpEndpoint(() => SERVER),
]) {
expect(isExempt(SERVER, FORM_HEADERS)).toBe(false);
}
// And a missing server URL no longer fails open past it either.
expect(exemptMcpEndpoint(() => undefined)(SERVER, FORM_HEADERS)).toBe(
false,
);
});
it("bounds same-origin OAuth work on a Streamable HTTP connection", () => {
// The narrow rule, available because the endpoint is the configured URL.
const isExempt = streamable(SERVER);
expect(isExempt(SERVER, JSON_HEADERS)).toBe(true);
expect(
isExempt(
"https://srv.example.com/.well-known/oauth-protected-resource/mcp",
JSON_HEADERS,
),
).toBe(false);
expect(isExempt("https://srv.example.com/token", JSON_HEADERS)).toBe(
false,
);
});
it("widens to the origin on legacy SSE, whose message path is unknowable", () => {
// The cost of the wider rule, asserted rather than implied: same-origin
// OAuth is exempt here, and falls back to the SDK's per-request timeout.
const isExempt = sse("https://srv.example.com/sse");
expect(
isExempt(
"https://srv.example.com/.well-known/openid-configuration",
JSON_HEADERS,
),
).toBe(true);
expect(isExempt("https://as.example.com/token", JSON_HEADERS)).toBe(
false,
);
});
it("falls back to the origin rule when the transport is unknown", () => {
// Fails open on this uncertainty like every other: a path rule applied to
// a connection that turns out to be SSE severs real MCP traffic.
const noHint = exemptMcpEndpoint(() => SERVER);
const throws = exemptMcpEndpoint(
() => SERVER,
() => {
throw new Error("not yet connected");
},
);
expect(noHint("https://srv.example.com/token", JSON_HEADERS)).toBe(true);
expect(throws("https://srv.example.com/token", JSON_HEADERS)).toBe(true);
});
it("exempts legacy SSE's separate message endpoint", () => {
// The case a path rule gets wrong: `SSEClientTransport` opens the
// configured URL and is handed a *different* pathname to POST every
// JSON-RPC message to. Bounding those would sever a slow tool call over
// SSE and report it as an OAuth timeout. The SDK enforces that the
// message endpoint shares the stream URL's origin, which is what makes
// an origin rule exact rather than approximate.
const isExempt = exemptMcpEndpoint(() => "https://srv.example.com/sse");
expect(
isExempt(
"https://srv.example.com/messages?sessionId=abc",
JSON_HEADERS,
),
).toBe(true);
expect(isExempt("https://srv.example.com/", JSON_HEADERS)).toBe(true);
});
it("bounds OAuth work on a different origin", () => {
const isExempt = exemptMcpEndpoint(() => SERVER);
expect(isExempt("https://as.example.com/token", JSON_HEADERS)).toBe(
false,
);
expect(
isExempt(
"https://as.example.com/.well-known/openid-configuration",
JSON_HEADERS,
),
).toBe(false);
// Origin is scheme + host + port, so any of the three differing bounds it.
expect(isExempt("http://srv.example.com/token", JSON_HEADERS)).toBe(
false,
);
expect(isExempt("https://srv.example.com:8443/token", JSON_HEADERS)).toBe(
false,
);
});
it("fails open when the server URL is unknown or unparseable", () => {
// The two errors are not symmetric: bounding what should not be bounded
// severs a long-running tool call, while failing to bound leaves the
// SDK's own per-request timeout as the backstop it already was.
expect(
exemptMcpEndpoint(() => undefined)(
"https://as.example.com/token",
JSON_HEADERS,
),
).toBe(true);
expect(
exemptMcpEndpoint(() => "")(
"https://as.example.com/token",
JSON_HEADERS,
),
).toBe(true);
expect(exemptMcpEndpoint(() => "not a url")(SERVER, JSON_HEADERS)).toBe(
true,
);
expect(exemptMcpEndpoint(() => SERVER)("not a url", JSON_HEADERS)).toBe(
true,
);
});
it("fails open when the server-URL getter throws", () => {
// `InspectorClient.getServerUrl()` throws for a non-HTTP configuration,
// and this wrapped fetch can still be handed to a transport or a custom
// factory that calls it — propagating a configuration error out of a
// fetch would turn the documented fail-open into a hard failure.
const isExempt = exemptMcpEndpoint(() => {
throw new Error("Server URL is only available for HTTP transports");
});
expect(isExempt("https://as.example.com/token", JSON_HEADERS)).toBe(true);
// Still not past the form-encoded check, which runs first.
expect(isExempt("https://as.example.com/token", FORM_HEADERS)).toBe(
false,
);
});
it("reads the server URL per call, since it changes between connects", () => {
let current = SERVER;
const isExempt = exemptMcpEndpoint(() => current);
expect(isExempt("https://other.example.com/mcp", JSON_HEADERS)).toBe(
false,
);
current = "https://other.example.com/mcp";
expect(isExempt("https://other.example.com/mcp", JSON_HEADERS)).toBe(
true,
);
expect(isExempt(SERVER, JSON_HEADERS)).toBe(false);
});
});
it("passes an exempt request through with no deadline and no buffering", async () => {
vi.useFakeTimers();
// Untouched means untouched: no signal of ours, and the body is left as a
// live stream rather than buffered — which is what an SSE response needs.
const body = new ReadableStream<Uint8Array>({
start(ctrl) {
ctrl.enqueue(new TextEncoder().encode("data: hi\n\n"));
},
});
const original = new Response(body, {
headers: { "content-type": "text/event-stream" },
});
const inner = vi.fn<typeof fetch>().mockResolvedValue(original);
const wrapped = withOAuthRequestTimeout(inner, 1000, () => true);
const response = await wrapped(URL_UNDER_TEST);
expect(response).toBe(original);
expect(
(inner.mock.calls[0][1] as RequestInit | undefined)?.signal,
).toBeUndefined();
// Nothing is armed, so advancing past the budget cannot sever it.
await vi.advanceTimersByTimeAsync(5000);
expect(response.bodyUsed).toBe(false);
});
describe("composed under withRfc8414OidcCompat (#2319 ordering)", () => {
// `withRfc8414OidcCompat` re-fetches an OIDC candidate after a failed RFC
// 8414 discovery, so where the deadline sits relative to it decides which
// request is being timed. `InspectorClient` and the CLI both put it
// innermost; these two cases are why.
const RFC8414 =
"https://as.example.com/.well-known/oauth-authorization-server/tenant";
// A path-suffixed RFC 8414 candidate has two OIDC siblings, and the compat
// wrapper tries both.
const PROBES = [
"https://as.example.com/.well-known/openid-configuration/tenant",
"https://as.example.com/tenant/.well-known/openid-configuration",
];
/** Answers the RFC 8414 leg with a prompt 404, then stalls on the probe. */
function stallingProbeFetch() {
return vi.fn<typeof fetch>((input) => {
if (String(input) === RFC8414) {
return Promise.resolve(new Response(null, { status: 404 }));
}
return new Promise<Response>(() => {});
});
}
it("outside, a stalled probe is misreported under the preceding URL", async () => {
vi.useFakeTimers();
const inner = stallingProbeFetch();
const wrapped = withOAuthRequestTimeout(
withRfc8414OidcCompat(inner),
1000,
);
const assertion = wrapped(RFC8414).catch((err: unknown) => err);
await vi.advanceTimersByTimeAsync(1000);
const err = (await assertion) as OAuthRequestTimeoutError;
expect(err).toBeInstanceOf(OAuthRequestTimeoutError);
// The endpoint that stalled was PROBE. One budget covers both legs, and
// the error names the request the caller made rather than the one that
// hung — exactly the diagnostic this change exists to provide.
expect(err.url).toBe(RFC8414);
});
it("innermost, the probe's own timeout escapes and names the probe", async () => {
vi.useFakeTimers();
const inner = stallingProbeFetch();
const wrapped = withRfc8414OidcCompat(
withOAuthRequestTimeout(inner, 1000),
);
const assertion = wrapped(RFC8414).catch((err: unknown) => err);
// The probe is timed from when it starts, on a budget of its own — not on
// whatever an outer one had left after the RFC 8414 leg.
await vi.advanceTimersByTimeAsync(1000);
const err = (await assertion) as OAuthRequestTimeoutError;
// The compat wrapper is inert on an ordinary probe failure, but a deadline
// the Inspector imposed escapes it, so the caller is told which endpoint
// stalled rather than being handed the preceding 404.
expect(err).toBeInstanceOf(OAuthRequestTimeoutError);
expect(err.url).toBe(PROBES[0]);
expect(inner).toHaveBeenCalledTimes(2);
});
});
it("clears its timer once the request settles", async () => {
vi.useFakeTimers();
const clearSpy = vi.spyOn(globalThis, "clearTimeout");
const wrapped = withOAuthRequestTimeout(
vi.fn<typeof fetch>().mockResolvedValue(new Response("{}")),
1000,
);
await wrapped(URL_UNDER_TEST);
expect(clearSpy).toHaveBeenCalled();
// Nothing is left to fire, so no unhandled rejection can surface later.
await vi.advanceTimersByTimeAsync(5000);
});
});

View file

@ -393,11 +393,11 @@ describe("revokeToken", () => {
});
});
// The web fetch is `createRemoteFetch`, which re-issues the call as a POST to
// `/api/fetch` and drops `init.signal`; the backend's outbound fetch gets no
// signal either. A signal-only bound is therefore inert on exactly the path
// the timeout exists for, so the deadline has to hold against a fetch that
// ignores the signal entirely.
// The signal reaches every path since #2319 — `createRemoteFetch` forwards it
// onto the `/api/fetch` hop and that route composes it into its outbound
// fetch — but a signal-only bound is still only as good as the fetch beneath
// it. A `fetchFn` that ignores `AbortSignal`, or a wedged backend, would
// leave a signal-only deadline inert, so it has to hold without one.
it("gives up on a fetch that never settles and ignores the signal", async () => {
const outcome = await revokeToken({
endpoint: REVOKE_URL,

View file

@ -1,5 +1,10 @@
import { describe, it, expect, vi } from "vitest";
import { createRemoteFetch } from "@inspector/core/mcp/remote/createRemoteFetch.js";
import {
OAUTH_TIMEOUT_WIRE_CODE,
OAuthRequestTimeoutError,
withOAuthRequestTimeout,
} from "@inspector/core/auth/requestTimeout.js";
function ok(body: object): Response {
return new Response(JSON.stringify(body), {
@ -168,4 +173,225 @@ describe("createRemoteFetch", () => {
expect(res.headers.get("x-test")).toBe("yes");
expect(await res.text()).toBe("hello");
});
describe("cancellation forwarding (#2319)", () => {
// Without this the caller's abort settled only its own promise: the POST
// stayed in flight, the backend never saw its request cancelled, and its
// outbound fetch to the authorization server ran on detached.
function capture() {
const fetchFn = vi
.fn<typeof fetch>()
.mockResolvedValue(ok(standardRemoteBody));
const remoteFetch = createRemoteFetch({
baseUrl: "http://remote.example",
// A contextually typed forwarding function rather than a double cast:
// the mock stays inspectable and nothing bypasses the type system.
fetchFn: (input, init) => fetchFn(input, init),
});
return { fetchFn, remoteFetch };
}
function signalOf(fetchFn: ReturnType<typeof vi.fn<typeof fetch>>) {
return (fetchFn.mock.calls[0][1] as RequestInit).signal;
}
it("forwards init.signal onto the proxy hop", async () => {
const { fetchFn, remoteFetch } = capture();
const caller = new AbortController();
await remoteFetch("http://upstream.example/", { signal: caller.signal });
expect(signalOf(fetchFn)).toBe(caller.signal);
});
it("forwards a Request's own signal when init has none", async () => {
const { fetchFn, remoteFetch } = capture();
const caller = new AbortController();
// Compared against `request.signal`, not `caller.signal`: the Fetch
// standard gives `new Request(url, { signal })` a *dependent* signal, so
// the two are distinct objects in a spec-compliant runtime. Asserting
// against the controller's would encode non-standard identity and could
// pass or fail on the host rather than on the behaviour (Copilot).
const request = new Request("http://upstream.example/", {
signal: caller.signal,
});
await remoteFetch(request);
expect(signalOf(fetchFn)).toBe(request.signal);
});
it("treats an undefined init.signal as absent, keeping the Request's", async () => {
// WebIDL dictionary conversion: a member present as `undefined` is absent.
const { fetchFn, remoteFetch } = capture();
const caller = new AbortController();
const request = new Request("http://upstream.example/", {
signal: caller.signal,
});
await remoteFetch(request, { signal: undefined });
// Against `request.signal` for the dependent-signal reason above.
expect(signalOf(fetchFn)).toBe(request.signal);
});
it("honours an explicit null init.signal as no signal", async () => {
const { fetchFn, remoteFetch } = capture();
const caller = new AbortController();
await remoteFetch(
new Request("http://upstream.example/", { signal: caller.signal }),
{ signal: null },
);
expect(signalOf(fetchFn)).toBeUndefined();
});
it("sends no signal when the caller supplied none", async () => {
const { fetchFn, remoteFetch } = capture();
await remoteFetch("http://upstream.example/");
expect(signalOf(fetchFn)).toBeUndefined();
});
});
describe("proxy-side deadline (#2319)", () => {
function remoteReturning(status: number, body: string) {
const fetchFn = vi.fn<typeof fetch>().mockResolvedValue(
new Response(body, {
status,
headers: { "content-type": "application/json" },
}),
);
return createRemoteFetch({
baseUrl: "http://remote.example",
// Forwarding function, not a double cast — see `capture` above.
fetchFn: (input, init) => fetchFn(input, init),
});
}
it("rebuilds an OAuthRequestTimeoutError from the route's marker", async () => {
// The proxy boundary in isolation: whichever end's deadline fires, a
// timeout that comes back as an ordinary error response must still
// reconstruct as a typed one. Both ends run the same budget in
// production, so this path is the backend winning the race — a
// backgrounded tab throttling `setTimeout` is the plausible case — and
// which one fired must not decide whether the error names its endpoint.
const remoteFetch = remoteReturning(
504,
JSON.stringify({
error: "proxied request to https://as.example.com/x timed out",
code: OAUTH_TIMEOUT_WIRE_CODE,
url: "https://as.example.com/x",
timeoutMs: 30000,
}),
);
const err = await remoteFetch("https://as.example.com/x").catch(
(e: unknown) => e,
);
expect(err).toBeInstanceOf(OAuthRequestTimeoutError);
const timeout = err as OAuthRequestTimeoutError;
expect(timeout.url).toBe("https://as.example.com/x");
expect(timeout.timeoutMs).toBe(30000);
});
it("leaves an ordinary error response as a plain Error", async () => {
const remoteFetch = remoteReturning(
500,
JSON.stringify({ error: "connect ECONNREFUSED" }),
);
const err = await remoteFetch("https://as.example.com/x").catch(
(e: unknown) => e,
);
expect(err).not.toBeInstanceOf(OAuthRequestTimeoutError);
expect((err as Error).message).toMatch(/Remote fetch failed \(500\)/);
});
it("ignores a non-JSON error body rather than throwing on the parse", async () => {
const remoteFetch = remoteReturning(502, "<html>bad gateway</html>");
const err = await remoteFetch("https://as.example.com/x").catch(
(e: unknown) => e,
);
expect((err as Error).message).toMatch(/Remote fetch failed \(502\)/);
});
it("ignores a marker whose fields are the wrong shape", async () => {
// An upstream could serve JSON of its own through a failing proxy; the
// guard checks the field types, not just the code.
const remoteFetch = remoteReturning(
504,
JSON.stringify({ code: OAUTH_TIMEOUT_WIRE_CODE, url: 5 }),
);
const err = await remoteFetch("https://as.example.com/x").catch(
(e: unknown) => e,
);
expect(err).not.toBeInstanceOf(OAuthRequestTimeoutError);
});
});
describe("carrying the caller's deadline to the route (#2319)", () => {
function envelopeOf(fetchFn: ReturnType<typeof vi.fn<typeof fetch>>) {
const init = fetchFn.mock.calls[0][1] as RequestInit;
return JSON.parse(init.body as string) as Record<string, unknown>;
}
it("puts a bounded call's budget in the envelope, not in the headers", async () => {
const fetchFn = vi
.fn<typeof fetch>()
.mockResolvedValue(ok(standardRemoteBody));
const remoteFetch = createRemoteFetch({
baseUrl: "http://remote.example",
fetchFn: (input, init) => fetchFn(input, init),
});
// Composed the way `InspectorClient` composes it: the wrapper outside,
// the remote fetch beneath.
const bounded = withOAuthRequestTimeout(remoteFetch, 7000);
await bounded("http://upstream.example/token");
const envelope = envelopeOf(fetchFn);
expect(envelope.timeoutMs).toBe(7000);
// Not a header: `headers` is re-sent verbatim to the upstream server, so
// a marker there would leak the Inspector's internals to a third party.
expect(JSON.stringify(envelope.headers)).not.toContain("7000");
});
it("omits the budget for an exempt call, so the route bounds nothing", async () => {
const fetchFn = vi
.fn<typeof fetch>()
.mockResolvedValue(ok(standardRemoteBody));
const remoteFetch = createRemoteFetch({
baseUrl: "http://remote.example",
fetchFn: (input, init) => fetchFn(input, init),
});
const bounded = withOAuthRequestTimeout(remoteFetch, 7000, () => true);
await bounded("http://upstream.example/mcp");
expect(envelopeOf(fetchFn)).not.toHaveProperty("timeoutMs");
});
it("omits the budget for a direct call that no wrapper bounded", async () => {
const fetchFn = vi
.fn<typeof fetch>()
.mockResolvedValue(ok(standardRemoteBody));
const remoteFetch = createRemoteFetch({
baseUrl: "http://remote.example",
fetchFn: (input, init) => fetchFn(input, init),
});
await remoteFetch("http://upstream.example/mcp");
expect(envelopeOf(fetchFn)).not.toHaveProperty("timeoutMs");
});
});
});

View file

@ -300,6 +300,12 @@ describe("server.ts supplemental coverage", () => {
let h: Harness;
let target: ServerType;
let targetUrl: string;
/** Resolves when an upstream `/stall` request is closed by its client. */
let stallClosed: Promise<void>;
/** Paths the upstream fixture has actually received. */
let upstreamHits: string[];
/** Resolves when the never-ending `/stream-open` response is closed. */
let openStreamClosed: Promise<void>;
// Isolate the WHOLE suite from the developer's (or CI's) proxy environment.
// Setting only uppercase HTTP_PROXY in the one proxy test would not be
@ -336,7 +342,43 @@ describe("server.ts supplemental coverage", () => {
h = await start();
// A tiny upstream HTTP server we can point /api/fetch at.
const { createServer } = await import("node:http");
let markClosed: () => void = () => {};
stallClosed = new Promise<void>((resolve) => {
markClosed = resolve;
});
upstreamHits = [];
let markStreamClosed: () => void = () => {};
openStreamClosed = new Promise<void>((resolve) => {
markStreamClosed = resolve;
});
const srv = createServer((req, res) => {
upstreamHits.push(req.url ?? "");
if (req.url === "/stream-hostile") {
// Headers, one frame, and a connection whose teardown the route must
// not wait on: `ReadableStream.cancel()` adopts the source's cancel
// promise, which may never settle.
res.writeHead(200, { "Content-Type": "text/event-stream" });
res.write("data: hi\n\n");
return;
}
if (req.url === "/stream-open") {
// Event-stream headers, one frame, and then nothing — the shape that
// used to leave a socket open for good, because the route classifies
// it as a stream, returns JSON without the body, and clears its
// deadline on the way out.
res.writeHead(200, { "Content-Type": "text/event-stream" });
res.write("data: hi\n\n");
res.on("close", () => markStreamClosed());
return;
}
if (req.url === "/stall") {
// Accept the request, answer nothing, and report when the client
// gives up — the #2319 wedged-authorization-server shape. `close` on
// the response fires whether the socket was torn down or the handler
// simply ended, and nothing here ever ends it.
res.on("close", () => markClosed());
return;
}
if (req.url === "/stream") {
res.writeHead(200, { "Content-Type": "text/event-stream" });
res.write("data: hi\n\n");
@ -381,6 +423,175 @@ describe("server.ts supplemental coverage", () => {
await stop(h);
});
it("releases the upstream request when the browser cancels (#2319)", async () => {
// The point of forwarding the signal through `createRemoteFetch`: without
// it the browser's abort settled only its own promise, and this handler
// plus the upstream socket stayed pending for as long as the server cared
// to stall — once per timed-out attempt. Asserted at the route rather
// than on a mock, because the mock cannot show the socket being released.
const caller = new AbortController();
const pending = fetch(`${h.baseUrl}/api/fetch`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ url: `${targetUrl}/stall` }),
signal: caller.signal,
});
const rejected = pending.catch((err: unknown) => err);
// Wait until the upstream has actually received the request, so the abort
// cannot race ahead of it and pass for the wrong reason.
await vi.waitFor(() => {
expect(upstreamHits).toContain("/stall");
});
caller.abort();
expect(await rejected).toBeInstanceOf(Error);
// The upstream sees its connection go away. Without the signal composed
// into the route's outbound fetch this never resolves and the test times
// out.
await stallClosed;
});
it("cancels a discarded stream that sends headers and never ends (#2319)", async () => {
// The route answers with JSON and no body, so nobody downstream owns the
// upstream stream and nothing will ever read it; the route cancels it
// explicitly rather than relying on that being reclaimed for it.
//
// ⚠️ This pins the OUTCOME, not the mechanism: measured, it still passes
// with the explicit `cancel()` removed, because undici reclaims an unread
// response body here on its own. So the cancel is defensive — it makes
// the release explicit and independent of that behaviour — and this test
// will catch the route starting to hold the stream open, not the cancel
// being deleted. Said plainly rather than left to imply a stronger claim.
const res = await fetch(`${h.baseUrl}/api/fetch`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ url: `${targetUrl}/stream-open` }),
});
expect(res.status).toBe(200);
const payload = (await res.json()) as { status: number; body?: string };
expect(payload.status).toBe(200);
expect(payload.body).toBeUndefined();
// The observable contract: the route does not leave the upstream stream
// running after it has answered. (Per the qualification above, this does
// not isolate the explicit `cancel()` as the cause.)
await openStreamClosed;
});
it("answers 504 with a typed marker when the request's deadline fires (#2319)", async () => {
// The caller's budget travels in the envelope, so this costs milliseconds
// rather than the production thirty seconds. What it pins is typed-marker
// preservation when the *backend* wins the client-side race — both ends
// run the same budget, and a backgrounded tab throttling `setTimeout` is
// the plausible way the server's fires first. The error has to survive
// the hop as something an `instanceof OAuthRequestTimeoutError` check can
// still recognize, whichever end produced it.
const res = await fetch(`${h.baseUrl}/api/fetch`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ url: `${targetUrl}/stall`, timeoutMs: 120 }),
});
expect(res.status).toBe(504);
const payload = (await res.json()) as {
error: string;
code: string;
url: string;
timeoutMs: number;
};
expect(payload.code).toBe("oauth_request_timeout");
expect(payload.url).toBe(`${targetUrl}/stall`);
expect(payload.timeoutMs).toBe(120);
expect(payload.error).toContain("timed out after 120ms");
// And the upstream is released rather than left running.
await stallClosed;
});
it("applies no deadline to a request that carries none (#2319)", async () => {
// ⚠️ The regression this guards: an unconditional timer here would abort
// a Streamable HTTP tool call that legitimately withholds its response
// headers — reintroducing on the backend exactly what `exemptMcpEndpoint`
// prevents on the client, and reporting it as an OAuth timeout besides.
//
// ⚠️ What it does NOT catch, stated so nobody reads more into it: the
// wait below is 400ms, so an unconditional timer restored at the
// production 30s budget would still leave the request pending here and
// the test would pass (Copilot). Catching that needs either a ~31s wait
// on every CI run or a route-level deadline knob existing only for the
// test — and a second source of truth for the deadline is the defect
// round 13 removed. Measured, this fails against an unconditional timer
// at any budget shorter than the wait.
const caller = new AbortController();
const pending = fetch(`${h.baseUrl}/api/fetch`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ url: `${targetUrl}/stall` }),
signal: caller.signal,
});
const settled = pending.then(
(r) => `responded ${r.status}`,
() => "aborted by us",
);
await vi.waitFor(() => {
expect(upstreamHits).toContain("/stall");
});
// Well past any budget the route might have applied on its own. Nothing
// should have answered: an unbounded request is still in flight.
await new Promise((r) => setTimeout(r, 400));
expect(
await Promise.race([settled, Promise.resolve("still pending")]),
).toBe("still pending");
caller.abort();
expect(await settled).toBe("aborted by us");
});
it("ignores a non-numeric deadline rather than trusting it (#2319)", async () => {
const caller = new AbortController();
const pending = fetch(`${h.baseUrl}/api/fetch`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
url: `${targetUrl}/stall`,
timeoutMs: "soon",
}),
signal: caller.signal,
});
const settled = pending.then(
(r) => `responded ${r.status}`,
() => "aborted by us",
);
await vi.waitFor(() => {
expect(upstreamHits).toContain("/stall");
});
await new Promise((r) => setTimeout(r, 300));
expect(
await Promise.race([settled, Promise.resolve("still pending")]),
).toBe("still pending");
caller.abort();
expect(await settled).toBe("aborted by us");
});
it("answers a discarded stream without waiting on its cancellation", async () => {
// ⚠️ The route starts the cancel and returns. Awaiting it would keep the
// handler pending on a source that never settles its cancel promise —
// and on an unbounded request there is no timer to release it either.
const res = await fetch(`${h.baseUrl}/api/fetch`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ url: `${targetUrl}/stream-hostile` }),
});
expect(res.status).toBe(200);
const payload = (await res.json()) as { status: number; body?: string };
expect(payload.status).toBe(200);
expect(payload.body).toBeUndefined();
});
it("routes the outbound request through HTTP_PROXY (#2067)", async () => {
// /api/fetch is the browser's ONLY way out to the network — the web
// client's `environment.fetch` is `createRemoteFetch()`, which forwards

View file

@ -51,6 +51,18 @@ export {
withRfc8414OidcCompat,
} from "./oidcDiscoveryCompat.js";
// OAuth-path request deadline (#2319)
export {
DEFAULT_OAUTH_REQUEST_TIMEOUT_MS,
OAUTH_TIMEOUT_WIRE_CODE,
OAuthRequestTimeoutError,
deadlineForRequestInit,
exemptMcpEndpoint,
isOAuthRequestTimeoutWire,
withOAuthRequestTimeout,
} from "./requestTimeout.js";
export type { OAuthRequestTimeoutWire } from "./requestTimeout.js";
// Storage
export type {
OAuthStorage,

View file

@ -90,6 +90,7 @@
* wiring in `core/mcp/inspectorClient.ts`.
*/
import { callerSignalOf, OAuthRequestTimeoutError } from "./requestTimeout.js";
import {
OAuthMetadataSchema,
OpenIdProviderDiscoveryMetadataSchema,
@ -169,6 +170,22 @@ async function releaseBody(response: Response): Promise<void> {
await response.body?.cancel().catch(() => {});
}
/**
* The same release, started but not waited on.
*
* ⚠️ `ReadableStream.cancel()` adopts the underlying source's cancel promise,
* which is permitted never to settle — so awaiting it on a path that is
* *propagating a cancellation* can hang the very thing that was meant to end
* (Copilot). Every exceptional exit below uses this: the release is a courtesy
* to the connection pool, and the caller's abort or timeout must reach it
* regardless. The `void` is the documented case where the callee owns its
* failures — `releaseBody` swallows its own — and the caller genuinely cannot
* await.
*/
function releaseBodyDetached(response: Response): void {
void releaseBody(response);
}
/**
* Whether a parsed body is RFC 8414 authorization-server metadata that is *not*
* a valid OpenID provider document — the exact shape the upstream schema
@ -248,6 +265,13 @@ export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch {
if (candidates.length === 0) return response;
const headers = discoveryHeaders(input, init);
// The probe is a request the caller never made, issued on its behalf — so
// it has to be cancellable by the caller too. Without the signal, aborting
// after the initial 404 left the probe running to its own deadline and the
// caller was handed a timeout in place of its own abort (Copilot). Same
// precedence rule as everywhere else, hence the shared helper rather than a
// second copy of it.
const callerSignal = callerSignalOf(input, init);
// The loop advances to the next candidate only where the SDK's own loop
// would. Anywhere else it hands the original response back and lets
// discovery run its normal course, because *promoting a later candidate
@ -259,10 +283,50 @@ export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch {
// those, returning `response` leaves the SDK to make the same request and
// reach the same verdict it always would.
for (const candidate of candidates) {
// Don't issue a probe the caller has already given up on. A real `fetch`
// rejects immediately on a pre-aborted signal, so this is mostly about
// not sending the request at all — the same reasoning as the
// short-circuit in `withOAuthRequestTimeout`.
//
// Every exceptional exit below releases `response` first. On a normal
// return it is the value the caller gets and reads; on a throw nobody
// will ever read it, and an unread body holds its connection open on
// Node/undici — the same reason the successful-substitution path
// releases it (Copilot). Matters most for a standalone user of this
// exported wrapper, where no timeout wrapper is underneath to have
// already drained it.
if (callerSignal?.aborted) {
releaseBodyDetached(response);
throw callerSignal.reason;
}
let probe: Response;
try {
probe = await fetchFn(candidate, { headers });
} catch {
probe = await fetchFn(candidate, { headers, signal: callerSignal });
} catch (err) {
// A deadline the Inspector imposed is *our* error, not the server's
// answer, so it escapes rather than being folded back into the original
// response (#2319, Copilot). Swallowing it would hand the caller the
// preceding 404 and discard the one thing the deadline adds — the name
// of the endpoint that stalled — leaving discovery to fail later under
// a message that points at the wrong URL. There is no flow this can
// break that was not already broken: every candidate this loop probes
// is one the SDK's own `buildDiscoveryUrls` emits, so a stall here is a
// stall the SDK would have hit itself, unbounded. Everything else — a
// CORS rejection, DNS, a reset — still falls back as documented above.
if (err instanceof OAuthRequestTimeoutError) {
releaseBodyDetached(response);
throw err;
}
// Likewise a caller that gave up: substituting the preceding 404 for
// its own abort would report a discovery failure for a request it
// deliberately cancelled. The caller's own `reason` is what surfaces,
// here and at the two sites below, rather than whatever shape the
// underlying layer happened to reject with.
if (callerSignal?.aborted) {
releaseBodyDetached(response);
throw callerSignal.reason;
}
return response;
}
if (!probe.ok) {
@ -272,6 +336,14 @@ export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch {
// candidate exhaust the origin's pool (Copilot). Same discipline as
// `core/mcp/node/authChallengeFetch.ts`.
await releaseBody(probe);
// Rechecked after the release, which awaits: an abort that lands while
// the body is being discarded must not be answered with the preceding
// response either, and `continue` would otherwise carry on probing for
// a caller that has stopped waiting.
if (callerSignal?.aborted) {
releaseBodyDetached(response);
throw callerSignal.reason;
}
if (continuesDiscovery(probe.status)) continue;
return response;
}
@ -288,6 +360,17 @@ export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch {
body = await probe.text();
parsed = JSON.parse(body);
} catch {
// `fetch` resolves on headers, so the abort can land here rather than
// in the catch above: the probe's headers arrived, the caller gave up,
// and `probe.text()` rejects on the partial body. Substituting the
// preceding 404 would hand a standalone user of this wrapper a
// discovery failure in place of its own abort reason (Copilot). A body
// that merely will not parse still falls through, which is the case
// this catch exists for.
if (callerSignal?.aborted) {
releaseBodyDetached(response);
throw callerSignal.reason;
}
return response;
}
if (!isRfc8414OnlyMetadata(parsed)) {

577
core/auth/requestTimeout.ts Normal file
View file

@ -0,0 +1,577 @@
/**
* A bound on every network call the Inspector makes on the OAuth path (#2319).
*
* ## Why this exists
*
* Protected-resource-metadata discovery, authorization-server metadata
* discovery, dynamic client registration, the token exchange and the refresh
* all went out with no `AbortSignal` at all. Token revocation was the single
* exception — `revocation.ts` has carried its own deadline since #2144, and it
* is the pattern this module generalizes.
*
* An unbounded fetch matters more here than it usually would, because of where
* this work runs and what the UI does while it runs:
*
* - It runs **server-side**, in the Inspector's own Node process, proxied
* through the backend. A stall is therefore invisible in browser devtools —
* #2188's reporter correctly observed that no request left the browser at
* all while a Node-side socket sat established and idle.
* - Connect-time auth errors deliberately hold the connection status at
* `"connecting"` rather than moving it to `"error"`
* (`isConnectAuthRecoveryError`, `challenge.ts`), on the theory that a
* redirect is about to end the attempt. A stalled fetch inside that window is
* a silent, unbounded spinner rather than a surfaced failure.
*
* The deadline covers the **whole exchange**, not just the headers: `fetch`
* resolves as soon as response headers arrive, so a server that sends headers
* — or half a JSON document — and then stalls would leave the caller's
* `response.json()` hanging with nothing watching it. Every response on this
* path is a small finite document, so a *clone* of it is drained under the same
* race and the original — untouched, with every native slot intact — is what
* the caller gets.
*
* Today the common case is bounded, but only incidentally: a discovery stall
* that happens to sit inside an SDK `initialize` rides that request's own
* timeout and surfaces after 60s as `Request timed out`. This module makes the
* bound deliberate, applies it to the stalls that sit *outside* an SDK request
* too, and — the point of naming the URL in the error — says *which* endpoint
* stalled instead of blaming the handshake.
*
* ## The signal, the proxy hop, and why the race is still here
*
* The signal is the primary mechanism and it now reaches all the way out. On a
* direct fetch (CLI, TUI, backend) it cancels the request outright. In the
* browser the OAuth fetch is `createRemoteFetch`, which re-issues the call as a
* POST to `/api/fetch` — and as of #2319 it forwards the signal onto that hop,
* where the route composes `c.req.raw.signal` into its own outbound fetch. So
* an abort here tears down the backend's request to the authorization server
* rather than leaving it running detached, which is what it used to do.
*
* The race is kept anyway, and it is not redundant. It bounds what the signal
* cannot: a `fetchFn` that ignores `AbortSignal` altogether — an injected or
* test double, or any future transport that drops it the way the proxy used to
* — and a backend that is itself wedged, where the outbound request is
* cancelled but the hop back never completes. A deadline that depends on every
* layer beneath it honouring a signal is not a deadline. Same reasoning, same
* shape, as `revokeToken`.
*/
import { redactUrlQuery } from "../mcp/fetchTracking.js";
/**
* 30 seconds. Deliberately generous: discovery against a slow or cold-starting
* authorization server is legitimate, and a bound that fires on a server that
* was going to answer is worse than the unbounded wait it replaced. It still
* halves the 60s the SDK handshake incidentally provides, and it is the only
* bound at all on the legs that sit outside an SDK request.
*/
export const DEFAULT_OAUTH_REQUEST_TIMEOUT_MS = 30_000;
/**
* Raised when an OAuth-path request outlives its budget.
*
* Carries the URL because "discovery timed out" without one is only marginally
* better than a spinner: the OAuth path makes several requests to several
* hosts, and which of them stalled is the whole diagnostic.
*/
export class OAuthRequestTimeoutError extends Error {
/**
* The endpoint that stalled, with sensitive query values redacted.
*
* Redacted in the constructor rather than at each display site, so every
* consumer is covered by construction: this error's message is recorded
* verbatim by `createFetchTracker` into the Network log and the persisted
* session, and an OAuth endpoint can carry a `code`, an `access_token` or a
* `client_secret` in its query string — which `fetchTracking` already
* deliberately redacts everywhere else it records a URL (Copilot). The path
* and every non-sensitive parameter survive, so the endpoint stays
* identifiable, which was the point of naming it.
*/
readonly url: string;
readonly timeoutMs: number;
constructor(url: string, timeoutMs: number) {
const safeUrl = redactUrlQuery(url);
super(`OAuth request to ${safeUrl} timed out after ${timeoutMs}ms`);
this.name = "OAuthRequestTimeoutError";
this.url = safeUrl;
this.timeoutMs = timeoutMs;
}
}
/**
* The largest delay `setTimeout` can actually schedule. Past `2 ** 31 - 1` the
* delay overflows a 32-bit signed integer and Node falls back to **1ms**, so an
* `Infinity` or an over-large budget produces an *immediate* timeout — the exact
* opposite of what the caller asked for (Copilot).
*/
const MAX_TIMER_DELAY_MS = 2_147_483_647;
/**
* Coerce a caller's budget into something `setTimeout` can honour and the error
* can honestly report.
*
* - Non-finite (`NaN`, `±Infinity`) falls back to the default. `NaN` survives
* `Math.max(0, Math.round(...))` and `setTimeout(fn, NaN)` fires immediately,
* so a caller whose arithmetic produced a `NaN` would otherwise see every
* OAuth request fail at once with "timed out after NaNms".
* - Above `MAX_TIMER_DELAY_MS` it clamps rather than overflowing to 1ms.
* - Negative clamps to 0, which is a real budget (fire on the next tick), not
* an error.
*
* Rounded because the budget is *reported* — see the note at the call site.
*/
function normalizeBudget(timeoutMs: number): number {
if (!Number.isFinite(timeoutMs)) return DEFAULT_OAUTH_REQUEST_TIMEOUT_MS;
return Math.min(MAX_TIMER_DELAY_MS, Math.max(0, Math.round(timeoutMs)));
}
/**
* How a proxy-side deadline is carried back to the browser (#2319).
*
* `/api/fetch` serializes its failures as a JSON error response, and
* `createRemoteFetch` turns any non-OK answer into a plain `Error` — so a
* timeout enforced by the *backend* would arrive as an untyped error and the
* `instanceof OAuthRequestTimeoutError` checks downstream would all be false
* (Copilot). Both ends run the same budget, so the client's race usually
* settles first; this 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. Which of two equal deadlines happened to fire
* must not decide whether the error names its endpoint. So the route stamps
* this marker and `createRemoteFetch` reconstructs the typed error from it.
*/
export const OAUTH_TIMEOUT_WIRE_CODE = "oauth_request_timeout";
/** The body `/api/fetch` returns when its own deadline fired. */
export interface OAuthRequestTimeoutWire {
code: typeof OAUTH_TIMEOUT_WIRE_CODE;
url: string;
timeoutMs: number;
}
/** Whether a parsed `/api/fetch` error body is a proxy-side deadline. */
export function isOAuthRequestTimeoutWire(
value: unknown,
): value is OAuthRequestTimeoutWire {
if (typeof value !== "object" || value === null) return false;
const candidate = value as Record<string, unknown>;
return (
candidate.code === OAUTH_TIMEOUT_WIRE_CODE &&
typeof candidate.url === "string" &&
typeof candidate.timeoutMs === "number"
);
}
/**
* Build the `isExempt` predicate for a chain that carries MCP traffic as well as
* OAuth work: it returns true for the MCP endpoint itself.
*
* ⚠️ **Fails open, deliberately.** The two errors are not symmetric. Bounding a
* request that should not be bounded severs a long-running tool call or an SSE
* stream — a severe, user-visible regression. Failing to bound one leaves the
* SDK's own per-request timeout as the backstop it has always been. So anything
* uncertain — no server URL yet, a URL that will not parse, either side — is
* treated as the MCP endpoint and exempted.
*
* The comparison is **as narrow as the transport allows**, which is not the same
* rule for both of them:
*
* - **Streamable HTTP** sends every MCP request to the configured URL, so
* `origin + pathname` is exact. Same-origin OAuth work — a protected-resource
* document, or an authorization server deployed beside the resource — is a
* different path and stays bounded, which is what #2319 asks for (Copilot).
* - **Legacy SSE** opens the configured URL (`/sse`), is handed a *separate*
* message endpoint by the server, and POSTs every JSON-RPC message to that
* second pathname. We never see that URL — it arrives inside the stream, in
* the SDK's own parser — so a path rule there bounds and body-buffers real
* MCP traffic, severing a slow tool call at 30s and reporting it as an
* `OAuthRequestTimeoutError`. Origin is the narrowest rule available, and it
* is exact rather than approximate: the SDK *enforces* that the message
* endpoint shares the stream URL's origin and rejects one that does not.
*
* So the residual gap is confined to legacy SSE against a server that also
* hosts its own OAuth endpoints, where transport-internal OAuth falls back to
* the SDK's per-request timeout — where it was before #2319. The Inspector's
* own discovery and token requests to those URLs are bounded regardless, on the
* OAuth chain.
*
* ⚠️ **Fails open** on every uncertainty, and the uncertainties differ by rule:
* no server URL, an unparseable URL on either side, or *not knowing which
* transport this is* all resolve to exempt — the last one by falling back to
* the wider origin rule.
*/
/**
* Read the transport hint, treating "absent" and "threw" alike as the wide
* case. A path rule applied to a connection that turns out to be legacy SSE
* severs real MCP traffic, so every uncertainty resolves toward the origin rule.
*/
function readNegotiatesEndpoint(hint: (() => boolean) | undefined): boolean {
if (!hint) return true;
try {
return hint();
} catch {
return true;
}
}
/**
* The media type every OAuth token-family request uses — the token exchange,
* the refresh, and revocation are all `application/x-www-form-urlencoded` per
* RFC 6749 §4.1.3 and RFC 7009 §2.1. MCP is JSON-RPC over `application/json`
* and never uses it, which is what makes this a safe discriminator rather than
* a heuristic.
*/
const OAUTH_FORM_MEDIA_TYPE = "application/x-www-form-urlencoded";
export function exemptMcpEndpoint(
getServerUrl: () => string | undefined,
/**
* Whether the message endpoint is negotiated rather than configured — true
* for legacy SSE. Read per call, since the transport can change between
* connects. Absent, or throwing, is treated as `true`.
*/
negotiatesEndpoint?: () => boolean,
): (url: string, headers: Headers) => boolean {
return (url, headers) => {
// A URL match is not by itself proof of MCP traffic. `oauthTokenUrl` takes
// any absolute URL, so a user may point it at the MCP endpoint's own URL,
// and the transport-internal refresh then travels this chain to a path the
// rules below would exempt — leaving the one request this whole change
// exists to bound running unbounded (Copilot). The token family is
// form-encoded and MCP never is, so the media type settles it before any
// URL comparison happens.
if (
(headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase() ===
OAUTH_FORM_MEDIA_TYPE
) {
return false;
}
// `getServerUrl()` throws for a non-HTTP configuration, and this wrapped
// fetch can be handed to a transport or a custom factory that calls it
// anyway — propagating a configuration error out of a fetch would turn the
// documented fail-open into a hard failure (Copilot). Caught here for the
// same reason `readNegotiatesEndpoint` catches.
let serverUrl: string | undefined;
try {
serverUrl = getServerUrl();
} catch {
return true;
}
if (!serverUrl) return true;
const negotiated = readNegotiatesEndpoint(negotiatesEndpoint);
try {
const a = new URL(url);
const b = new URL(serverUrl);
if (a.origin !== b.origin) return false;
return negotiated || a.pathname === b.pathname;
} catch {
return true;
}
};
}
/**
* The deadline a wrapped call is running under, keyed by the exact `RequestInit`
* the wrapper handed down (#2319).
*
* This is how the budget crosses the proxy hop without travelling *upstream*.
* `/api/fetch` must not time every request it serves — `createRemoteFetch` also
* carries MCP traffic, and a Streamable HTTP tool call can legitimately withhold
* its response headers for minutes — so the route needs to know which requests
* are bounded and which are not (Copilot). A header would be the obvious
* carrier and is the wrong one: `serializeRequest` copies request headers
* verbatim into the payload and the route re-sends them to the authorization
* server, so a marker header would leak to a third party.
*
* A `WeakMap` keyed on the init object has neither problem. It is invisible to
* serialization, it needs no cast onto `RequestInit`, and it is collected with
* the object — the wrapper builds a fresh init per call, so there is one entry
* per in-flight request and nothing accumulates.
*/
const REQUEST_DEADLINES = new WeakMap<object, number>();
/**
* The deadline `withOAuthRequestTimeout` stamped on this init, if any.
*
* `undefined` means the request is not bounded by this wrapper and must not be
* bounded by anything downstream either — an exempt MCP request reads as
* `undefined` because the exempt path never builds an init of its own.
*/
export function deadlineForRequestInit(
init: RequestInit | undefined,
): number | undefined {
return init ? REQUEST_DEADLINES.get(init) : undefined;
}
/**
* The request's headers, whatever form `fetch`'s arguments took. `init` wins
* over a `Request`'s own, matching `fetch`.
*/
function requestHeadersOf(
input: RequestInfo | URL,
init: RequestInit | undefined,
): Headers {
if (init?.headers) return new Headers(init.headers);
if (typeof input !== "string" && !(input instanceof URL)) {
return new Headers(input.headers);
}
return new Headers();
}
/**
* Pull a reader to completion, discarding what it yields. An absent reader — a
* null-body response — is already complete.
*
* Read through an explicit reader rather than `clone().arrayBuffer()` so the
* losing side of the race has something it can cancel: `arrayBuffer()` locks
* the stream to an internal reader nothing else can reach.
*/
async function drainToEnd(
reader: ReadableStreamDefaultReader<Uint8Array> | undefined,
): Promise<void> {
if (!reader) return;
for (;;) {
const { done } = await reader.read();
if (done) return;
}
}
/**
* Tear down both branches of the tee.
*
* Deliberately **not awaited**, and the `void`s say which of the documented
* cases this is: the callee owns its failures (each carries its own `catch`),
* and the caller genuinely cannot await — cancelling one branch of a tee can
* itself wait on the other, which would hold the timeout open for exactly as
* long as the stall it is meant to end. Best-effort on each besides: a stream
* already cancelled, errored or locked elsewhere is not a failure here, and
* this runs on a path that is already rejecting with something the caller cares
* about more.
*/
function cancelBoth(
reader: ReadableStreamDefaultReader<Uint8Array> | undefined,
response: Response | undefined,
): void {
void reader?.cancel().catch(() => {});
void response?.body?.cancel().catch(() => {});
}
/** The request URL, whatever form `fetch`'s first argument took. */
function requestUrlOf(input: RequestInfo | URL): string {
if (typeof input === "string") return input;
if (input instanceof URL) return input.href;
return input.url;
}
/**
* The caller's own `AbortSignal`, if it supplied one.
*
* A `Request` carries its signal on the *input*, not in `init`, and this
* wrapper always passes a `signal` of its own in `init` — so reading `init`
* alone would silently override the embedded one and change ordinary `fetch`
* cancellation semantics for `withOAuthRequestTimeout(new Request(url, { signal }))`
* (Copilot).
*
* An `init.signal` still wins, but only when it is not `undefined`. `RequestInit`
* is a WebIDL dictionary, where a member present with the value `undefined` is
* converted as *absent* — so `{ ...base, signal: undefined }`, which is what a
* spread over an options object that had no signal produces, must inherit the
* `Request`'s signal rather than clear it (Copilot). An explicit `null` is the
* genuine "no signal" override and is honoured as one.
*/
export function callerSignalOf(
input: RequestInfo | URL,
init: RequestInit | undefined,
): AbortSignal | undefined {
const explicit = init?.signal;
if (explicit !== undefined) return explicit ?? undefined;
if (typeof input !== "string" && !(input instanceof URL)) return input.signal;
return undefined;
}
/**
* Wrap a `fetch` so every call through it is bounded by `timeoutMs`.
*
* Every request that reaches it unexempted is bounded — so on a chain that
* carries anything other than OAuth work, `isExempt` is not optional. A
* Streamable HTTP or SSE response is a long-lived stream that is *supposed* to
* stay open, and a Streamable HTTP POST for a long-running tool call
* legitimately withholds its response headers for minutes; a deadline over
* either severs a working connection. `InspectorClient` uses the wrapper on
* both of its chains and the two differ only in this: the OAuth chain passes no
* predicate because every request on it is OAuth work, while the transport
* chain passes `exemptMcpEndpoint`.
*
* `isExempt` receives the request's URL **and headers**, because a URL alone
* cannot always tell the two kinds of traffic apart — see `exemptMcpEndpoint`.
*
* `isExempt` lets one wrapped fetch serve a mixed chain. It is how the
* *transport* fetch can be bounded at all: that chain carries both the SDK's
* OAuth work and the MCP traffic itself, and the MCP traffic must not be
* timed — a Streamable HTTP POST for a long-running tool call legitimately
* withholds its response headers for minutes, and an SSE body stays open
* indefinitely. So `InspectorClient` exempts the MCP endpoint by URL and bounds
* everything else on that chain, and **fails open**: if the server URL cannot
* be determined or parsed, everything is exempt. Bounding a request that should
* not be bounded breaks a working tool call; failing to bound one leaves the
* SDK's own per-request timeout as the backstop it already was.
*
* A caller's own signal is preserved — whether it arrived in `init` or embedded
* in a `Request` — and is both forwarded to the inner fetch (composed with
* `AbortSignal.any`) and raced here, so an outer cancellation still wins even on
* a fetch that drops the signal. This wrapper only ever *adds* a reason to give
* up; it never removes the caller's.
*
* The response **body** is drained through a clone under the same deadline,
* because `fetch` resolves on headers and a stalled body would otherwise be
* unbounded. The caller receives the original response, not a copy.
*/
export function withOAuthRequestTimeout(
fetchFn: typeof fetch,
timeoutMs: number = DEFAULT_OAUTH_REQUEST_TIMEOUT_MS,
isExempt?: (url: string, headers: Headers) => boolean,
): typeof fetch {
// Whole milliseconds. Not for the reason `revocation.ts:253` gives — that one
// calls `AbortSignal.timeout`, which really does throw `ERR_OUT_OF_RANGE` on
// a fractional delay (verified on Node 26) and so would fail before the
// request was sent. This wrapper drives its deadline with `setTimeout` and an
// `AbortController`, and `setTimeout` accepts a fractional delay and
// truncates it, so nothing here would throw (Copilot).
//
// The real reason is that the budget is *reported*: it is interpolated into
// the timeout message and exposed as `OAuthRequestTimeoutError.timeoutMs`. A
// caller whose budget came from a `performance.now()` subtraction would
// otherwise produce "timed out after 1000.4000000953674ms" and a non-integer
// public field. Rounding rather than flooring so a caller's own
// whole-millisecond timeout survives the trip through that clock and is still
// the number the message names.
const budget = normalizeBudget(timeoutMs);
return async (input, init) => {
const url = requestUrlOf(input);
// An exempt request is passed through completely untouched — no deadline,
// no signal of ours, and crucially no body buffering, since the responses
// this exists for are streams that must stay open (see `isExempt` above).
if (isExempt?.(url, requestHeadersOf(input, init))) {
return fetchFn(input, init);
}
const controller = new AbortController();
const callerSignal = callerSignalOf(input, init);
// Before anything is constructed or sent. Racing an already-aborted signal
// would still evaluate `fetchFn(...)`, and a `fetchFn` that does not check
// an already-aborted signal — the proxy hop was one until #2319 — would
// send the OAuth request even though the caller cancelled before the call
// (Copilot).
if (callerSignal?.aborted) throw callerSignal.reason;
const signal = callerSignal
? AbortSignal.any([controller.signal, callerSignal])
: controller.signal;
let timer: ReturnType<typeof setTimeout> | undefined;
let timedOut = false;
let onCallerAbort: (() => void) | undefined;
// The losing side of every race below: it rejects when the budget runs out,
// and when the caller cancels.
const abandoned = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => {
timedOut = true;
// Abort first, which is what actually cancels the request — through
// the proxy hop too, since #2319 — then reject, so a `fetchFn` that
// ignores the signal is still abandoned on schedule.
controller.abort(new OAuthRequestTimeoutError(url, budget));
reject(new OAuthRequestTimeoutError(url, budget));
}, budget);
// The caller's cancellation is raced too, not merely forwarded. Forwarding
// it is enough wherever the signal is honoured, but a `fetchFn` that
// ignores it would otherwise leave this promise pending for the whole
// budget instead of the outer cancellation winning as documented
// (Copilot). The caller's own `reason` is preserved, so it still sees its
// abort rather than a substituted error.
// Not aborted — the short-circuit above returned for that case.
if (callerSignal) {
onCallerAbort = () => reject(callerSignal.reason);
callerSignal.addEventListener("abort", onCallerAbort, { once: true });
}
});
// Built once and stamped, so `createRemoteFetch` can read the budget off it
// and tell `/api/fetch` this particular request is bounded.
const nextInit: RequestInit = { ...init, signal };
REQUEST_DEADLINES.set(nextInit, budget);
// Held so the losing path can tear both tee branches down — see below.
let settled: Response | undefined;
let drainReader: ReadableStreamDefaultReader<Uint8Array> | undefined;
try {
const response = await Promise.race([
fetchFn(input, nextInit),
abandoned,
]);
settled = response;
// `fetch` resolves once the response *headers* arrive, so stopping here
// would leave the body unbounded: an authorization server can send
// headers, or half a JSON document, and then stall, and the caller's
// `response.json()` would hang with nothing watching it (Copilot). Every
// response on this path is a small, finite document — metadata,
// a registration, a token — so buffering it under the same deadline
// bounds the whole exchange. It is also the second reason `isExempt` is
// not optional on a mixed chain: an exempt request skips this buffering
// entirely, which is what lets an SSE body stay open rather than being
// drained into memory here.
// Read a *clone* under the deadline and hand back the original. Cloning
// tees the body, so draining one branch buffers the other: by the time
// this resolves the original is fully in memory and the caller reads it
// without touching the network again — the bound is the same, and the
// response the caller gets is the one `fetch` produced.
//
// The alternative, rebuilding a `Response` around the buffered bytes,
// could never be faithful (Copilot). `url`, `redirected` and `type` are
// internal slots; shadowing them as own properties satisfies a direct
// read and is lost again the moment a caller calls `clone()`, which
// returns a fresh native `Response`. The headers guard differs too, and
// the decoded bytes made the inherited `content-encoding` and
// `content-length` wrong, so those had to be stripped — a rebuilt
// response was observably not the original in at least four ways. Not
// rebuilding removes the whole class.
const copy = response.clone();
drainReader = copy.body?.getReader();
await Promise.race([drainToEnd(drainReader), abandoned]);
return response;
} catch (err) {
// ⚠️ Losing the race rejects the caller; on its own it does not stop the
// read. `clone()` tees the body, so a drain still running would keep
// pulling bytes *and* keep buffering them for the unread original branch
// — unbounded memory on exactly the signal-ignoring body the race exists
// to contain (Copilot). Both branches are cancelled here, which is safe
// precisely because this path throws: the caller never receives the
// response, so nothing is left to read it.
cancelBoth(drainReader, settled);
// `controller.abort()` above can reject the underlying fetch *before*
// `reject` runs, in which case the race settles with undici's
// `AbortError` instead of ours. Which of the two wins is an ordering
// detail of the fetch implementation, so normalize on the flag rather
// than on the error that surfaced: a timeout must always be reported as
// one, naming the endpoint. A caller-driven abort leaves the flag false
// and so passes through with its own reason intact.
if (timedOut) throw new OAuthRequestTimeoutError(url, budget);
throw err;
} finally {
/* v8 ignore next -- the Promise executor runs synchronously, so `timer`
is always assigned by the time this runs; the guard exists only because
TypeScript cannot see that. */
if (timer !== undefined) clearTimeout(timer);
// Drop the listener even though it is `once`: a caller signal can outlive
// this request (one `AbortController` per connect attempt, several
// requests through it), and a listener per request would accumulate on it.
if (onCallerAbort && callerSignal) {
callerSignal.removeEventListener("abort", onCallerAbort);
}
}
};
}

View file

@ -268,14 +268,15 @@ export async function revokeToken(
// outcome — and every caller has already cleared its local state by now, so
// a rejection would break the documented best-effort guarantee.
const { url, init } = buildRevocationRequest(params);
// The signal alone is not enough to bound this. In the browser the fetch is
// `createRemoteFetch`, which re-issues the call as a POST to `/api/fetch`
// and does not forward `init.signal`; the backend's outbound fetch gets no
// signal either. So a wedged authorization server would hold the teardown
// open indefinitely on exactly the path the timeout exists for. The race is
// what actually enforces the deadline; the signal is kept because it does
// cancel the direct-fetch paths (CLI, TUI, backend) rather than merely
// abandoning them.
// The signal cancels; the race is what guarantees the deadline. Since #2319
// the signal reaches every path — `createRemoteFetch` forwards it onto the
// `/api/fetch` hop and that route composes it into its outbound fetch, so a
// browser-side abort now tears down the backend's request to the
// authorization server as well as the direct-fetch paths (CLI, TUI,
// backend) it always did. The race is still not redundant: it bounds a
// `fetchFn` that ignores `AbortSignal` altogether, and a backend that is
// itself wedged. Same reasoning, and the same shape, as
// `withOAuthRequestTimeout`.
const response = await withDeadline(
params.fetchFn(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }),
timeoutMs,
@ -307,11 +308,12 @@ export async function revokeToken(
/**
* Reject with a timeout error if `promise` has not settled within `timeoutMs`.
*
* The underlying request is abandoned rather than cancelled — nothing here can
* cancel a fetch the proxy already stripped the signal from. That is the right
* trade for this caller: the point is that the *teardown* proceeds, and the
* response, if it ever arrives, is a revocation we no longer need to wait for.
* The timer is cleared on the settled path so a caller is never held awake by it.
* This abandons the promise; cancelling the request is the caller's signal's job
* (see `revokeToken`, where the two are used together). Abandonment alone is an
* acceptable floor for this caller even where the signal is ignored: the point
* is that the *teardown* proceeds, and the response, if it ever arrives, is a
* revocation we no longer need to wait for. The timer is cleared on the settled
* path so a caller is never held awake by it.
*/
async function withDeadline<T>(
promise: Promise<T>,

View file

@ -256,6 +256,11 @@ import {
} from "../auth/challenge.js";
import { withOAuthEndpointOverrides } from "../auth/endpointOverrides.js";
import { withRfc8414OidcCompat } from "../auth/oidcDiscoveryCompat.js";
import {
DEFAULT_OAUTH_REQUEST_TIMEOUT_MS,
exemptMcpEndpoint,
withOAuthRequestTimeout,
} from "../auth/requestTimeout.js";
import type { TokenRevocationOutcome } from "../auth/revocation.js";
import type { OAuthTokens } from "@modelcontextprotocol/client";
import { silentLogger, type InspectorLogger } from "../logging/logger.js";
@ -810,8 +815,9 @@ export class InspectorClient extends InspectorClientEventTarget {
// `this.fetchFn` directly. The overrides are read lazily — `oauthManager` is
// created a few lines below, and `setOAuthConfig` can change them later — so
// the wrapper is inert until a server actually configures one.
this.fetchFn = withOAuthEndpointOverrides(this.fetchFn ?? fetch, () =>
this.oauthManager?.getEndpointOverrides(),
const withOverrides = withOAuthEndpointOverrides(
this.fetchFn ?? fetch,
() => this.oauthManager?.getEndpointOverrides(),
);
// #2172: recover discovery when a plain OAuth 2.0 authorization server
// publishes RFC 8414 metadata at `/.well-known/openid-configuration`, which
@ -822,8 +828,40 @@ export class InspectorClient extends InspectorClientEventTarget {
// still hit the upstream failure. The substituted response is stamped with
// `COMPAT_SOURCE_HEADER` so a captured entry names the URL its body came
// from rather than appearing to be a 200 from the RFC 8414 path.
this.fetchFn = withRfc8414OidcCompat(this.fetchFn);
this.effectiveAuthFetch = this.buildEffectiveAuthFetch();
// The transport chain is bounded too, but only for the requests on it that
// are *not* MCP traffic — the OAuth work the SDK runs from inside the
// transport on the 401/refresh path, which otherwise waits out the SDK's
// incidental per-request timeout and reports a bare `Request timed out`
// naming no endpoint (Copilot). The MCP endpoint itself is exempt, and the
// predicate fails open: a long-running tool call withholding its response
// headers for minutes, or an SSE body that stays open, must never be timed
// here. Deadline innermost, for the same reason as the auth chain below.
this.fetchFn = withRfc8414OidcCompat(
withOAuthRequestTimeout(
withOverrides,
DEFAULT_OAUTH_REQUEST_TIMEOUT_MS,
exemptMcpEndpoint(
() => this.getServerUrl(),
// Legacy SSE negotiates its message endpoint inside the stream, so
// its path is unknowable here and the exemption has to widen to the
// origin. Streamable HTTP sends everything to the configured URL, so
// the path rule is exact there and same-origin OAuth stays bounded.
() => this.transportConfig?.type === "sse",
),
),
);
// #2319: the auth chain is composed separately so the deadline sits
// *inside* the compat wrapper. Reusing `this.fetchFn` as the base would put
// it outside, and then a stalled OIDC probe would be reported under the
// URL of the RFC 8414 request that preceded it — defeating the whole point
// of naming the endpoint — while every probe in the loop shared that one
// request's budget (Copilot). Innermost here matches the CLI's
// `storedAuthFetch`. It also passes no `isExempt`, unlike the transport
// chain above: every request on this chain is OAuth work, so there is
// nothing here to exempt.
this.effectiveAuthFetch = this.buildEffectiveAuthFetch(
withRfc8414OidcCompat(withOAuthRequestTimeout(withOverrides)),
);
this.sessionId = options.sessionId;
@ -990,8 +1028,22 @@ export class InspectorClient extends InspectorClientEventTarget {
);
}
private buildEffectiveAuthFetch(): typeof fetch {
const base = this.fetchFn ?? fetch;
/**
* @param base - the composed OAuth-path fetch, deadline innermost (#2319).
*
* Passed in rather than read off `this.fetchFn` because the two chains are
* bounded on different terms, not because one of them is unbounded. Both
* carry a deadline; the transport chain additionally exempts the MCP endpoint
* (`exemptMcpEndpoint`), since its bodies are streams meant to stay open and
* a long-running tool call may withhold its headers for minutes. Reusing it
* here would extend that exemption to the OAuth manager's own requests — and
* an authorization server published at the MCP endpoint's path would then go
* unbounded on the one chain that exists to bound it.
*
* The SDK's transport-internal discovery (the 401/refresh path) is covered by
* the transport chain's own deadline, not by this one.
*/
private buildEffectiveAuthFetch(base: typeof fetch): typeof fetch {
// Capture auth response bodies (OAuth discovery, DCR, token exchange) so
// they're inspectable in the Network tab. Token-exchange responses carry
// `access_token` / `refresh_token`; the Network UI masks those (and other

View file

@ -21,6 +21,12 @@
* 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;
@ -133,14 +139,68 @@ export function createRemoteFetch(options: RemoteFetchOptions): typeof fetch {
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 }),
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}`);
}

View file

@ -28,6 +28,8 @@ import { bodyLimit } from "hono/body-limit";
import { watch as chokidarWatch, type FSWatcher } from "chokidar";
import { createTransportNode } from "../../node/transport.js";
import { createProxyFetch } from "../../node/proxyFetch.js";
import { OAUTH_TIMEOUT_WIRE_CODE } from "../../../auth/requestTimeout.js";
import { redactUrlQuery } from "../../fetchTracking.js";
import type {
RemoteConnectRequest,
RemoteSendRequest,
@ -1204,6 +1206,8 @@ export function createRemoteApp(
method?: string;
headers?: Record<string, string>;
body?: string;
/** Per-request deadline, set only by a bounded OAuth call (#2319). */
timeoutMs?: number;
};
try {
body = (await c.req.json()) as typeof body;
@ -1211,11 +1215,75 @@ export function createRemoteApp(
return c.json({ error: "Invalid JSON body" }, 400);
}
const { url, method = "GET", headers = {}, body: reqBody } = body;
const {
url,
method = "GET",
headers = {},
body: reqBody,
timeoutMs: requestedTimeoutMs,
} = body;
if (!url) {
return c.json({ error: "Missing url" }, 400);
}
// #2319: bound the outbound call, and release it when the browser gives up.
// This hop is where a client-side deadline used to stop being enforceable:
// the browser abandons its promise, but the request it made is served here,
// and an outbound fetch with neither a signal nor a timeout holds a handler
// and an authorization-server socket open indefinitely — once per timed-out
// retry (Copilot). `c.req.raw.signal` is the propagated cancellation, now
// that `createRemoteFetch` forwards it; the timer is the backstop for a
// client that vanishes without aborting, and it matches the client-side
// budget so the two agree on when this is hopeless.
const controller = new AbortController();
// Set by the timer below and read in the `catch`. The fetch rejects with
// the signal's abort reason, but which of several aborts won is not worth
// inferring from the error — the flag says it directly, the same
// normalization `withOAuthRequestTimeout` uses.
let deadlineFired = false;
const clientSignal: AbortSignal | undefined = c.req.raw.signal;
const signal = clientSignal
? AbortSignal.any([controller.signal, clientSignal])
: controller.signal;
// Cleared in `finally`, before the handler returns. Safe because by then
// the body has been either read or explicitly cancelled — this route never
// hands a live stream to its caller, so there is nothing left for the timer
// to protect and nothing it could sever.
// ⚠️ Only the caller decides whether this request is bounded, and by how
// much. This route is the browser's way out to the network for MCP traffic
// as well as OAuth work, so an unconditional timer here would abort a
// Streamable HTTP tool call that legitimately withholds its response
// headers for longer than the budget — reintroducing on the backend exactly
// the regression `exemptMcpEndpoint` exists to prevent on the client, and
// reporting it as an OAuth timeout besides (Copilot). No deadline in the
// envelope means no timer at all.
// Clamped to what `setTimeout` can schedule: past 2**31-1 the delay
// overflows and Node falls back to 1ms, turning an over-large budget into
// an immediate timeout. Non-finite is ignored outright rather than
// defaulted — an envelope carrying `NaN` is a malformed request, and the
// safe reading of a malformed deadline is "no deadline".
const deadlineMs =
typeof requestedTimeoutMs === "number" &&
Number.isFinite(requestedTimeoutMs)
? Math.min(2_147_483_647, Math.max(0, Math.round(requestedTimeoutMs)))
: undefined;
// Redacted for the same reason `OAuthRequestTimeoutError` redacts: this
// message and this URL are handed back to the browser, recorded in the
// Network log and persisted, and an OAuth endpoint's query string can carry
// a `code`, an `access_token` or a `client_secret` (Copilot).
const safeUrl = redactUrlQuery(url);
const timer =
deadlineMs === undefined
? undefined
: setTimeout(() => {
deadlineFired = true;
controller.abort(
new Error(
`proxied request to ${safeUrl} timed out after ${deadlineMs}ms`,
),
);
}, deadlineMs);
try {
// Proxy-aware, not the bare global. This route is the browser's ONLY way
// out to the network: the web client's `environment.fetch` is
@ -1228,6 +1296,7 @@ export function createRemoteApp(
method,
headers: new Headers(headers),
body: reqBody,
signal,
});
const resHeaders: Record<string, string> = {};
@ -1242,6 +1311,26 @@ export function createRemoteApp(
let resBody: string | undefined;
if (!isStream && res.body) {
resBody = await res.text();
} else if (isStream) {
// This route does not return the stream — it answers with JSON and no
// body — so nobody downstream owns it and nothing will ever read it.
// Left un-cancelled, an OAuth endpoint that sends event-stream or
// NDJSON headers and never closes would hold the upstream socket open
// for good, since the `finally` below clears the only deadline once
// this handler returns (Copilot). Best-effort, like `releaseBody` in
// `oidcDiscoveryCompat`: a body already consumed, locked or absent is
// not an error here. Measured, undici does reclaim an unread body on
// its own in this configuration — so this is belt and braces rather
// than a demonstrated leak, and it is kept because relying on that is
// an implementation detail of the fetch beneath us, not a contract.
//
// ⚠️ Started, not awaited. `ReadableStream.cancel()` adopts the
// underlying source's cancel promise, which is permitted never to
// settle — awaiting it would keep this handler pending forever, and on
// an unbounded request there is no timer to release it either
// (Copilot). The `void` is the documented case where the callee owns
// its failures, via the `catch` below, and the caller cannot await.
void res.body?.cancel().catch(() => {});
}
return c.json({
@ -1253,7 +1342,24 @@ export function createRemoteApp(
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (deadlineFired) {
// Stamped so the browser can rebuild an `OAuthRequestTimeoutError`
// rather than receiving a plain `Error` that no `instanceof` check
// downstream can recognize (Copilot). 504 because that is what this
// is — the gateway gave up on the upstream, not a fault in the route.
return c.json(
{
error: msg,
code: OAUTH_TIMEOUT_WIRE_CODE,
url: safeUrl,
timeoutMs: deadlineMs,
},
504,
);
}
return c.json({ error: msg }, 500);
} finally {
if (timer !== undefined) clearTimeout(timer);
}
});