fix: make the proxy deadline per-request, not unconditional (#2319 round 13)
The server-side timer added in round 9 applied to every /api/fetch request. But that route carries MCP traffic as well as OAuth work, so a Streamable HTTP tool call that legitimately withholds its response headers past the budget was aborted there — reintroducing on the backend exactly the regression exemptMcpEndpoint prevents on the client, and reporting it as an OAuth timeout besides. The caller now decides. `withOAuthRequestTimeout` stamps its budget on the RequestInit it hands down, `createRemoteFetch` reads it back and puts it in the /api/fetch envelope, and the route applies a timer only when one is present — so an exempt MCP request, which never gets an init of its own, is bounded by nothing. The carrier is a WeakMap keyed on the init rather than a header: 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. It needs no cast onto RequestInit and is collected with the object. The `fetchTimeoutMs` option from round 9 is gone — the deadline now lives in exactly one place, the client wrapper, which is also what makes the 504 marker honest: only a request the OAuth wrapper bounded can produce one. Ten tests: the stamping and its rounding, nothing stamped on an exempt call or an init that never went through the wrapper, the envelope carrying the budget (and not a header), its omission on both exempt and direct calls, and at the route the 504 marker, no deadline without one, and a non-numeric deadline ignored. The no-deadline case is verified by mutation: an unconditional timer makes it fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
234d0a9ac4
commit
de6960ec5a
7 changed files with 259 additions and 50 deletions
|
|
@ -25,6 +25,7 @@ import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
import {
|
import {
|
||||||
DEFAULT_OAUTH_REQUEST_TIMEOUT_MS,
|
DEFAULT_OAUTH_REQUEST_TIMEOUT_MS,
|
||||||
OAuthRequestTimeoutError,
|
OAuthRequestTimeoutError,
|
||||||
|
deadlineForRequestInit,
|
||||||
exemptMcpEndpoint,
|
exemptMcpEndpoint,
|
||||||
withOAuthRequestTimeout,
|
withOAuthRequestTimeout,
|
||||||
} from "@inspector/core/auth/requestTimeout.js";
|
} from "@inspector/core/auth/requestTimeout.js";
|
||||||
|
|
@ -414,6 +415,46 @@ describe("withOAuthRequestTimeout", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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)", () => {
|
describe("exemptMcpEndpoint (the transport chain's mixed traffic)", () => {
|
||||||
const SERVER = "https://srv.example.com/mcp";
|
const SERVER = "https://srv.example.com/mcp";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { createRemoteFetch } from "@inspector/core/mcp/remote/createRemoteFetch.
|
||||||
import {
|
import {
|
||||||
OAUTH_TIMEOUT_WIRE_CODE,
|
OAUTH_TIMEOUT_WIRE_CODE,
|
||||||
OAuthRequestTimeoutError,
|
OAuthRequestTimeoutError,
|
||||||
|
withOAuthRequestTimeout,
|
||||||
} from "@inspector/core/auth/requestTimeout.js";
|
} from "@inspector/core/auth/requestTimeout.js";
|
||||||
|
|
||||||
function ok(body: object): Response {
|
function ok(body: object): Response {
|
||||||
|
|
@ -334,4 +335,61 @@ describe("createRemoteFetch", () => {
|
||||||
expect(err).not.toBeInstanceOf(OAuthRequestTimeoutError);
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,6 @@ interface StartOpts {
|
||||||
secretStore?: SecretStore;
|
secretStore?: SecretStore;
|
||||||
logger?: pinoType.Logger;
|
logger?: pinoType.Logger;
|
||||||
seedConfig?: string;
|
seedConfig?: string;
|
||||||
fetchTimeoutMs?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function start(opts: StartOpts = {}): Promise<Harness> {
|
async function start(opts: StartOpts = {}): Promise<Harness> {
|
||||||
|
|
@ -66,9 +65,6 @@ async function start(opts: StartOpts = {}): Promise<Harness> {
|
||||||
initialConfig: { defaultEnvironment: {} },
|
initialConfig: { defaultEnvironment: {} },
|
||||||
secretStore: opts.secretStore ?? new InMemorySecretStore(),
|
secretStore: opts.secretStore ?? new InMemorySecretStore(),
|
||||||
logger: opts.logger,
|
logger: opts.logger,
|
||||||
...(opts.fetchTimeoutMs !== undefined && {
|
|
||||||
fetchTimeoutMs: opts.fetchTimeoutMs,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
const { baseUrl, server } = await new Promise<{
|
const { baseUrl, server } = await new Promise<{
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
|
|
@ -475,19 +471,16 @@ describe("server.ts supplemental coverage", () => {
|
||||||
await openStreamClosed;
|
await openStreamClosed;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("answers 504 with a typed marker when its own deadline fires (#2319)", async () => {
|
it("answers 504 with a typed marker when the request's deadline fires (#2319)", async () => {
|
||||||
// The backstop, on its own short budget so this costs milliseconds rather
|
// The caller's budget travels in the envelope, so this costs milliseconds
|
||||||
// than the production thirty seconds. It matters because this is the only
|
// rather than the production thirty seconds. The marker matters because
|
||||||
// deadline on the transport-internal discovery path, which has no
|
// a backend-enforced deadline is the only one on the transport-internal
|
||||||
// client-side wrapper — so its error has to survive the hop as something
|
// discovery path, and its error has to survive the hop as something an
|
||||||
// an `instanceof OAuthRequestTimeoutError` check can still recognize.
|
// `instanceof OAuthRequestTimeoutError` check can still recognize.
|
||||||
await stop(h);
|
|
||||||
h = await start({ fetchTimeoutMs: 120 });
|
|
||||||
|
|
||||||
const res = await fetch(`${h.baseUrl}/api/fetch`, {
|
const res = await fetch(`${h.baseUrl}/api/fetch`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ url: `${targetUrl}/stall` }),
|
body: JSON.stringify({ url: `${targetUrl}/stall`, timeoutMs: 120 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(504);
|
expect(res.status).toBe(504);
|
||||||
|
|
@ -505,6 +498,65 @@ describe("server.ts supplemental coverage", () => {
|
||||||
await stallClosed;
|
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.
|
||||||
|
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("routes the outbound request through HTTP_PROXY (#2067)", async () => {
|
it("routes the outbound request through HTTP_PROXY (#2067)", async () => {
|
||||||
// /api/fetch is the browser's ONLY way out to the network — the web
|
// /api/fetch is the browser's ONLY way out to the network — the web
|
||||||
// client's `environment.fetch` is `createRemoteFetch()`, which forwards
|
// client's `environment.fetch` is `createRemoteFetch()`, which forwards
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ export {
|
||||||
DEFAULT_OAUTH_REQUEST_TIMEOUT_MS,
|
DEFAULT_OAUTH_REQUEST_TIMEOUT_MS,
|
||||||
OAUTH_TIMEOUT_WIRE_CODE,
|
OAUTH_TIMEOUT_WIRE_CODE,
|
||||||
OAuthRequestTimeoutError,
|
OAuthRequestTimeoutError,
|
||||||
|
deadlineForRequestInit,
|
||||||
exemptMcpEndpoint,
|
exemptMcpEndpoint,
|
||||||
isOAuthRequestTimeoutWire,
|
isOAuthRequestTimeoutWire,
|
||||||
withOAuthRequestTimeout,
|
withOAuthRequestTimeout,
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,39 @@ export function exemptMcpEndpoint(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 URL, whatever form `fetch`'s first argument took. */
|
/** The request URL, whatever form `fetch`'s first argument took. */
|
||||||
function requestUrlOf(input: RequestInfo | URL): string {
|
function requestUrlOf(input: RequestInfo | URL): string {
|
||||||
if (typeof input === "string") return input;
|
if (typeof input === "string") return input;
|
||||||
|
|
@ -342,9 +375,14 @@ export function withOAuthRequestTimeout(
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await Promise.race([
|
const response = await Promise.race([
|
||||||
fetchFn(input, { ...init, signal }),
|
fetchFn(input, nextInit),
|
||||||
abandoned,
|
abandoned,
|
||||||
]);
|
]);
|
||||||
// `fetch` resolves once the response *headers* arrive, so stopping here
|
// `fetch` resolves once the response *headers* arrive, so stopping here
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
deadlineForRequestInit,
|
||||||
isOAuthRequestTimeoutWire,
|
isOAuthRequestTimeoutWire,
|
||||||
OAuthRequestTimeoutError,
|
OAuthRequestTimeoutError,
|
||||||
} from "../../auth/requestTimeout.js";
|
} from "../../auth/requestTimeout.js";
|
||||||
|
|
@ -154,10 +155,26 @@ export function createRemoteFetch(options: RemoteFetchOptions): typeof fetch {
|
||||||
? input.signal
|
? input.signal
|
||||||
: undefined;
|
: 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`, {
|
const res = await fetchFn(`${baseUrl}/api/fetch`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: reqHeaders,
|
headers: reqHeaders,
|
||||||
body: JSON.stringify({ url, method, headers, body }),
|
body: JSON.stringify({
|
||||||
|
url,
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
...(timeoutMs !== undefined && { timeoutMs }),
|
||||||
|
}),
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,7 @@ import { bodyLimit } from "hono/body-limit";
|
||||||
import { watch as chokidarWatch, type FSWatcher } from "chokidar";
|
import { watch as chokidarWatch, type FSWatcher } from "chokidar";
|
||||||
import { createTransportNode } from "../../node/transport.js";
|
import { createTransportNode } from "../../node/transport.js";
|
||||||
import { createProxyFetch } from "../../node/proxyFetch.js";
|
import { createProxyFetch } from "../../node/proxyFetch.js";
|
||||||
import {
|
import { OAUTH_TIMEOUT_WIRE_CODE } from "../../../auth/requestTimeout.js";
|
||||||
DEFAULT_OAUTH_REQUEST_TIMEOUT_MS,
|
|
||||||
OAUTH_TIMEOUT_WIRE_CODE,
|
|
||||||
} from "../../../auth/requestTimeout.js";
|
|
||||||
import type {
|
import type {
|
||||||
RemoteConnectRequest,
|
RemoteConnectRequest,
|
||||||
RemoteSendRequest,
|
RemoteSendRequest,
|
||||||
|
|
@ -238,21 +235,6 @@ export interface RemoteServerOptions {
|
||||||
/** Optional path for the user's server list file (/api/servers). Default: ~/.mcp-inspector/mcp.json */
|
/** Optional path for the user's server list file (/api/servers). Default: ~/.mcp-inspector/mcp.json */
|
||||||
mcpConfigPath?: string;
|
mcpConfigPath?: string;
|
||||||
|
|
||||||
/**
|
|
||||||
* Deadline for the outbound request `/api/fetch` makes, in milliseconds
|
|
||||||
* (#2319). Defaults to {@link DEFAULT_OAUTH_REQUEST_TIMEOUT_MS}, the same
|
|
||||||
* budget the browser-side `withOAuthRequestTimeout` uses, so the two agree on
|
|
||||||
* when an attempt is hopeless.
|
|
||||||
*
|
|
||||||
* This is the backstop for a client that vanishes without aborting; the
|
|
||||||
* primary mechanism is the caller's own signal, which `createRemoteFetch`
|
|
||||||
* forwards onto this hop. Exposed rather than hardcoded so a test can prove
|
|
||||||
* the backstop actually fires without spending thirty seconds of wall clock
|
|
||||||
* on it — and because a very slow authorization server is a legitimate reason
|
|
||||||
* to want a longer one.
|
|
||||||
*/
|
|
||||||
fetchTimeoutMs?: number;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When false, the server list is read-only for this session: all
|
* When false, the server list is read-only for this session: all
|
||||||
* `/api/servers` mutations (POST/PUT/DELETE/order) are rejected with 403,
|
* `/api/servers` mutations (POST/PUT/DELETE/order) are rejected with 403,
|
||||||
|
|
@ -588,10 +570,6 @@ export function createRemoteApp(
|
||||||
options: RemoteServerOptions,
|
options: RemoteServerOptions,
|
||||||
): CreateRemoteAppResult {
|
): CreateRemoteAppResult {
|
||||||
const dangerouslyOmitAuth = !!options.dangerouslyOmitAuth;
|
const dangerouslyOmitAuth = !!options.dangerouslyOmitAuth;
|
||||||
const fetchTimeoutMs = Math.max(
|
|
||||||
0,
|
|
||||||
Math.round(options.fetchTimeoutMs ?? DEFAULT_OAUTH_REQUEST_TIMEOUT_MS),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Determine auth token when auth is enabled: options > env var > generate
|
// Determine auth token when auth is enabled: options > env var > generate
|
||||||
const authToken = dangerouslyOmitAuth
|
const authToken = dangerouslyOmitAuth
|
||||||
|
|
@ -1227,6 +1205,8 @@ export function createRemoteApp(
|
||||||
method?: string;
|
method?: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
body?: string;
|
body?: string;
|
||||||
|
/** Per-request deadline, set only by a bounded OAuth call (#2319). */
|
||||||
|
timeoutMs?: number;
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
body = (await c.req.json()) as typeof body;
|
body = (await c.req.json()) as typeof body;
|
||||||
|
|
@ -1234,7 +1214,13 @@ export function createRemoteApp(
|
||||||
return c.json({ error: "Invalid JSON body" }, 400);
|
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) {
|
if (!url) {
|
||||||
return c.json({ error: "Missing url" }, 400);
|
return c.json({ error: "Missing url" }, 400);
|
||||||
}
|
}
|
||||||
|
|
@ -1262,14 +1248,30 @@ export function createRemoteApp(
|
||||||
// the body has been either read or explicitly cancelled — this route never
|
// 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
|
// hands a live stream to its caller, so there is nothing left for the timer
|
||||||
// to protect and nothing it could sever.
|
// to protect and nothing it could sever.
|
||||||
const timer = setTimeout(() => {
|
// ⚠️ Only the caller decides whether this request is bounded, and by how
|
||||||
deadlineFired = true;
|
// much. This route is the browser's way out to the network for MCP traffic
|
||||||
controller.abort(
|
// as well as OAuth work, so an unconditional timer here would abort a
|
||||||
new Error(
|
// Streamable HTTP tool call that legitimately withholds its response
|
||||||
`proxied request to ${url} timed out after ${fetchTimeoutMs}ms`,
|
// 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
|
||||||
}, fetchTimeoutMs);
|
// envelope means no timer at all.
|
||||||
|
const deadlineMs =
|
||||||
|
typeof requestedTimeoutMs === "number" &&
|
||||||
|
Number.isFinite(requestedTimeoutMs)
|
||||||
|
? Math.max(0, Math.round(requestedTimeoutMs))
|
||||||
|
: undefined;
|
||||||
|
const timer =
|
||||||
|
deadlineMs === undefined
|
||||||
|
? undefined
|
||||||
|
: setTimeout(() => {
|
||||||
|
deadlineFired = true;
|
||||||
|
controller.abort(
|
||||||
|
new Error(
|
||||||
|
`proxied request to ${url} timed out after ${deadlineMs}ms`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, deadlineMs);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Proxy-aware, not the bare global. This route is the browser's ONLY way
|
// Proxy-aware, not the bare global. This route is the browser's ONLY way
|
||||||
|
|
@ -1332,14 +1334,14 @@ export function createRemoteApp(
|
||||||
error: msg,
|
error: msg,
|
||||||
code: OAUTH_TIMEOUT_WIRE_CODE,
|
code: OAUTH_TIMEOUT_WIRE_CODE,
|
||||||
url,
|
url,
|
||||||
timeoutMs: fetchTimeoutMs,
|
timeoutMs: deadlineMs,
|
||||||
},
|
},
|
||||||
504,
|
504,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return c.json({ error: msg }, 500);
|
return c.json({ error: msg }, 500);
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timer);
|
if (timer !== undefined) clearTimeout(timer);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue