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:
parent
e234343702
commit
5a1c157a79
2 changed files with 111 additions and 13 deletions
|
|
@ -290,8 +290,10 @@ export function withRfc8414OidcCompat(fetchFn: typeof fetch): typeof fetch {
|
|||
if (err instanceof OAuthRequestTimeoutError) 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.
|
||||
if (callerSignal?.aborted) throw err;
|
||||
// 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) throw callerSignal.reason;
|
||||
return response;
|
||||
}
|
||||
if (!probe.ok) {
|
||||
|
|
@ -301,6 +303,11 @@ 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) throw callerSignal.reason;
|
||||
if (continuesDiscovery(probe.status)) continue;
|
||||
return response;
|
||||
}
|
||||
|
|
@ -317,6 +324,14 @@ 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) throw callerSignal.reason;
|
||||
return response;
|
||||
}
|
||||
if (!isRfc8414OnlyMetadata(parsed)) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue