fix: narrow the exemption per transport (#2319 round 18)

Round 17 widened exemptMcpEndpoint to the origin for everything, which
exempted same-origin OAuth work as well as MCP traffic — on the very
401/discovery path #2319 exists to bound.

The rule is now as narrow as each transport allows. Streamable HTTP
sends every MCP request to the configured URL, so origin + pathname is
exact there and same-origin OAuth stays bounded. Legacy SSE negotiates
its message endpoint inside the stream, where we never see it, so the
origin rule is the narrowest available — and it is exact rather than
approximate, since the SDK enforces that the message endpoint shares
the stream URL's origin.

The residual gap is therefore confined to legacy SSE against a server
hosting its own OAuth endpoints, and it is stated in the docblock and
pinned by a test rather than left implicit. Neither alternative was
reachable: the SDK takes a single fetch for all network requests, so
there is no separate auth seam to route through, and the negotiated SSE
endpoint is parsed inside the SDK's own stream handler.

Fails open on every uncertainty, including not knowing the transport —
a path rule applied to what turns out to be SSE severs real MCP
traffic, so an absent or throwing hint falls back to the origin rule.

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:40:07 -04:00
commit 42339ff4a4

View file

@ -506,6 +506,58 @@ describe("withOAuthRequestTimeout", () => {
expect(isExempt(`${SERVER}#frag`)).toBe(true);
});
/** 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("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)).toBe(true);
expect(
isExempt(
"https://srv.example.com/.well-known/oauth-protected-resource/mcp",
),
).toBe(false);
expect(isExempt("https://srv.example.com/token")).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"),
).toBe(true);
expect(isExempt("https://as.example.com/token")).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")).toBe(true);
expect(throws("https://srv.example.com/token")).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
@ -533,21 +585,6 @@ describe("withOAuthRequestTimeout", () => {
expect(isExempt("https://srv.example.com:8443/token")).toBe(false);
});
it("exempts a same-origin OAuth endpoint, which is the cost of the rule", () => {
// Stated as a test rather than left implicit: protected-resource metadata
// lives on the resource's own origin, so it is exempt here. The OAuth
// chain still bounds the Inspector's own requests to it; only the SDK's
// transport-internal OAuth against a same-origin server falls back to the
// SDK's per-request timeout.
const isExempt = exemptMcpEndpoint(() => SERVER);
expect(
isExempt(
"https://srv.example.com/.well-known/oauth-protected-resource/mcp",
),
).toBe(true);
});
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