fix(auth): never read a stream, and say what the issuer override does not do

Two review findings on the fetch wrapper:

The content-type test was a substring match for "json", which also
matches `application/x-ndjson` — how the streamable-HTTP transport can
serve its long-lived server-push channel (`isLongLivedStreamResponse`).
Awaiting `.json()` on a clone of an unbounded stream never resolves, so
the MCP connection would hang for as long as an override was configured.
It now matches the media type exactly (`application/json` or a `+json`
suffix), with a regression test driven by a stream that never closes.

The overrides also leave `issuer` as discovery returned it, so pointing
one at an authorization server that advertises a different issuer is
rejected on callback by the RFC 9207 / SEP-2352 mix-up defense. That
check is deliberately not weakened; the limit is now stated on the
module, in the field description, and in the configuration guide.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-16 21:52:09 -04:00
commit e5c20608bf

View file

@ -282,6 +282,46 @@ describe("withOAuthEndpointOverrides", () => {
await expect(response.text()).resolves.toBe("not json at all");
});
// `application/x-ndjson` satisfies a naive `includes("json")` test, and the
// streamable-HTTP transport serves its long-lived server-push channel that way
// (see `isLongLivedStreamResponse`). Awaiting `.json()` on a clone of an
// unbounded stream never resolves, so the MCP connection would hang for as
// long as an override was configured. Regression test for PR #2037 review.
it("does not read an unbounded NDJSON stream", async () => {
// A body that never closes: if the wrapper reads it, this test times out.
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode('{"jsonrpc":"2.0"}\n'));
// Deliberately never closed.
},
});
const original = new Response(stream, {
headers: { "content-type": "application/x-ndjson" },
});
const wrapped = withOAuthEndpointOverrides(passThrough(original), () => ({
tokenUrl: STAGING_TOKEN,
}));
await expect(wrapped("https://mcp.example.com/mcp")).resolves.toBe(
original,
);
await original.body?.cancel();
});
it("ignores content-type parameters when deciding a body is JSON", async () => {
const wrapped = withOAuthEndpointOverrides(
passThrough(
new Response(JSON.stringify(METADATA), {
headers: { "content-type": "application/json; charset=utf-8" },
}),
),
() => ({ tokenUrl: STAGING_TOKEN }),
);
const response = await wrapped("https://as.example.com/x");
await expect(response.json()).resolves.toMatchObject({
token_endpoint: STAGING_TOKEN,
});
});
// Fetch forbids a body on 204/205, so rebuilding one would throw. A JSON
// content-type on an empty response is unusual but legal, and it must not
// take down an unrelated transport request.