fix: exempt MCP traffic by origin, not path (#2319 round 17)

exemptMcpEndpoint compared origin + pathname, which misses legacy SSE
entirely: SSEClientTransport opens the configured URL and is handed a
SEPARATE message endpoint by the server, then POSTs every JSON-RPC
message to that second pathname. Those were bounded and body-buffered
as OAuth traffic, so a slow tool call over SSE would be severed at 30s
and reported as an OAuthRequestTimeoutError.

It compares origin alone now. That is exact rather than approximate:
the SDK enforces that the SSE message endpoint shares the stream URL's
origin and rejects one that does not, so every MCP request this chain
carries is same-origin with the configured URL by construction.

The cost, stated in the docblock and pinned by a test: an OAuth
endpoint on the MCP server's own origin is exempt here and so unbounded
on this chain. The OAuth chain still bounds the Inspector's own
requests to it, so only the SDK's transport-internal OAuth against a
same-origin server falls back to where it was before #2319.

Caller cancellation was also still swallowed in two later places in the
compat probe loop — `probe.text()` has its own catch, and `fetch`
resolves on headers, so an abort mid-body landed there and was answered
with the preceding 404; and an abort during releaseBody's await would
have carried on probing. Both recheck the signal now, and all three
sites throw the caller's own `reason` rather than whatever shape the
layer beneath rejected with.

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:
cliffhall 2026-09-11 06:24:24 -04:00
commit 5a1c157a79

View file

@ -157,9 +157,25 @@ export function isOAuthRequestTimeoutWire(
* uncertain — no server URL yet, a URL that will not parse, either side — is
* treated as the MCP endpoint and exempted.
*
* Compared on origin + pathname: the SDK varies method and query across its MCP
* requests but not the endpoint, while OAuth discovery goes to a different path
* (`/.well-known/…`) and usually a different origin entirely.
* Compared on **origin alone**, not origin + pathname. Path matching looks more
* precise and is wrong: 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 — so a path rule bounds and body-buffers real MCP
* traffic, and a slow tool call over SSE is severed at 30s and reported as an
* `OAuthRequestTimeoutError` (Copilot).
*
* Origin is not a loose approximation of the right rule; it is exact, because
* the SDK *enforces* that the SSE message endpoint shares the stream URL's
* origin and rejects one that does not. Every MCP request this chain carries is
* therefore same-origin with the configured URL, by construction.
*
* What it costs: an OAuth endpoint hosted on the MCP server's own origin — the
* protected-resource metadata document, or an authorization server deployed
* beside the resource — is exempt here and so unbounded on this chain. That is
* the fail-open direction again, and the loss is narrow: the OAuth *chain*
* bounds the Inspector's own discovery and token requests to those same URLs,
* so only the SDK's transport-internal OAuth against a same-origin server falls
* back to the SDK's per-request timeout, which is where it was before #2319.
*/
export function exemptMcpEndpoint(
getServerUrl: () => string | undefined,
@ -168,9 +184,7 @@ export function exemptMcpEndpoint(
const serverUrl = getServerUrl();
if (!serverUrl) return true;
try {
const a = new URL(url);
const b = new URL(serverUrl);
return a.origin === b.origin && a.pathname === b.pathname;
return new URL(url).origin === new URL(serverUrl).origin;
} catch {
return true;
}