From 9ab2d4fdcc21142634f428d0fcc340c449180837 Mon Sep 17 00:00:00 2001 From: Eduardo Gomes Date: Wed, 26 Nov 2025 11:23:57 +0100 Subject: [PATCH 1/3] Make sure to consume HTTP error response bodies --- scripts/fetch-spec-types.ts | 2 ++ src/client/auth.ts | 5 ++++ src/client/sse.ts | 3 ++- src/client/streamableHttp.test.ts | 27 +++++++++++++------- src/client/streamableHttp.ts | 7 ++++- src/examples/server/elicitationUrlExample.ts | 1 + src/examples/server/simpleStreamableHttp.ts | 1 + src/server/auth/providers/proxyProvider.ts | 4 +++ 8 files changed, 39 insertions(+), 11 deletions(-) diff --git a/scripts/fetch-spec-types.ts b/scripts/fetch-spec-types.ts index a64e0848e..920ce1ce1 100644 --- a/scripts/fetch-spec-types.ts +++ b/scripts/fetch-spec-types.ts @@ -15,6 +15,7 @@ async function fetchLatestSHA(): Promise { const response = await fetch(url); if (!response.ok) { + await response.body?.cancel(); throw new Error(`Failed to fetch commit info: ${response.status} ${response.statusText}`); } @@ -31,6 +32,7 @@ async function fetchSpecTypes(sha: string): Promise { const response = await fetch(url); if (!response.ok) { + await response.body?.cancel(); throw new Error(`Failed to fetch spec types: ${response.status} ${response.statusText}`); } diff --git a/src/client/auth.ts b/src/client/auth.ts index 536ff6859..ae47261eb 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -625,10 +625,12 @@ export async function discoverOAuthProtectedResourceMetadata( }); if (!response || response.status === 404) { + await response?.body?.cancel(); throw new Error(`Resource server does not implement OAuth 2.0 Protected Resource Metadata.`); } if (!response.ok) { + await response.body?.cancel(); throw new Error(`HTTP ${response.status} trying to load well-known OAuth protected resource metadata.`); } return OAuthProtectedResourceMetadataSchema.parse(await response.json()); @@ -756,10 +758,12 @@ export async function discoverOAuthMetadata( }); if (!response || response.status === 404) { + await response?.body?.cancel(); return undefined; } if (!response.ok) { + await response.body?.cancel(); throw new Error(`HTTP ${response.status} trying to load well-known OAuth metadata`); } @@ -869,6 +873,7 @@ export async function discoverAuthorizationServerMetadata( } if (!response.ok) { + await response.body?.cancel(); // Continue looking for any 4xx response code. if (response.status >= 400 && response.status < 500) { continue; // Try next URL diff --git a/src/client/sse.ts b/src/client/sse.ts index 3a51837dc..94eafe1b1 100644 --- a/src/client/sse.ts +++ b/src/client/sse.ts @@ -253,6 +253,8 @@ export class SSEClientTransport implements Transport { const response = await (this._fetch ?? fetch)(this._endpoint, init); if (!response.ok) { + const text = await response.text().catch(() => null); + if (response.status === 401 && this._authProvider) { const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); this._resourceMetadataUrl = resourceMetadataUrl; @@ -272,7 +274,6 @@ export class SSEClientTransport implements Transport { return this.send(message); } - const text = await response.text().catch(() => null); throw new Error(`Error POSTing to endpoint (HTTP ${response.status}): ${text}`); } } catch (error) { diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index 2799aa67e..7c6895416 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -582,11 +582,13 @@ describe('StreamableHTTPClientTransport', () => { ok: false, status: 401, statusText: 'Unauthorized', - headers: new Headers() + headers: new Headers(), + text: async () => Promise.reject('dont read my body') }) .mockResolvedValue({ ok: false, - status: 404 + status: 404, + text: async () => Promise.reject('dont read my body') }); await expect(transport.send(message)).rejects.toThrow(UnauthorizedError); @@ -883,7 +885,8 @@ describe('StreamableHTTPClientTransport', () => { ok: false, status: 401, statusText: 'Unauthorized', - headers: new Headers() + headers: new Headers(), + text: async () => Promise.reject('dont read my body') }; (global.fetch as Mock) // Initial connection @@ -936,7 +939,8 @@ describe('StreamableHTTPClientTransport', () => { ok: false, status: 401, statusText: 'Unauthorized', - headers: new Headers() + headers: new Headers(), + text: async () => Promise.reject('dont read my body') }; (global.fetch as Mock) // Initial connection @@ -962,7 +966,8 @@ describe('StreamableHTTPClientTransport', () => { // Fallback should fail to complete the flow .mockResolvedValue({ ok: false, - status: 404 + status: 404, + text: async () => Promise.reject('dont read my body') }); await expect(transport.send(message)).rejects.toThrow(UnauthorizedError); @@ -987,7 +992,8 @@ describe('StreamableHTTPClientTransport', () => { ok: false, status: 401, statusText: 'Unauthorized', - headers: new Headers() + headers: new Headers(), + text: async () => Promise.reject('dont read my body') }; (global.fetch as Mock) // Initial connection @@ -1013,7 +1019,8 @@ describe('StreamableHTTPClientTransport', () => { // Fallback should fail to complete the flow .mockResolvedValue({ ok: false, - status: 404 + status: 404, + text: async () => Promise.reject('dont read my body') }); await expect(transport.send(message)).rejects.toThrow(UnauthorizedError); @@ -1026,7 +1033,8 @@ describe('StreamableHTTPClientTransport', () => { ok: false, status: 401, statusText: 'Unauthorized', - headers: new Headers() + headers: new Headers(), + text: async () => Promise.reject('dont read my body') }; // Create custom fetch @@ -1328,7 +1336,8 @@ describe('StreamableHTTPClientTransport', () => { ok: false, status: 401, statusText: 'Unauthorized', - headers: new Headers() + headers: new Headers(), + text: async () => Promise.reject('dont read my body') }; (global.fetch as Mock) diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 9d34c7b7d..aa52ec732 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -223,6 +223,8 @@ export class StreamableHTTPClientTransport implements Transport { }); if (!response.ok) { + await response.body?.cancel(); + if (response.status === 401 && this._authProvider) { // Need to authenticate return await this._authThenStart(); @@ -463,6 +465,8 @@ export class StreamableHTTPClientTransport implements Transport { } if (!response.ok) { + const text = await response.text().catch(() => null); + if (response.status === 401 && this._authProvider) { // Prevent infinite recursion when server returns 401 after successful auth if (this._hasCompletedAuthFlow) { @@ -525,7 +529,6 @@ export class StreamableHTTPClientTransport implements Transport { } } - const text = await response.text().catch(() => null); throw new Error(`Error POSTing to endpoint (HTTP ${response.status}): ${text}`); } @@ -535,6 +538,7 @@ export class StreamableHTTPClientTransport implements Transport { // If the response is 202 Accepted, there's no body to process if (response.status === 202) { + await response.body?.cancel(); // if the accepted notification is initialized, we start the SSE stream // if it's supported by the server if (isInitializedNotification(message)) { @@ -609,6 +613,7 @@ export class StreamableHTTPClientTransport implements Transport { }; const response = await (this._fetch ?? fetch)(this._url, init); + await response.body?.cancel(); // We specifically handle 405 as a valid response according to the spec, // meaning the server does not support explicit session termination diff --git a/src/examples/server/elicitationUrlExample.ts b/src/examples/server/elicitationUrlExample.ts index 089c6f887..c11f59d68 100644 --- a/src/examples/server/elicitationUrlExample.ts +++ b/src/examples/server/elicitationUrlExample.ts @@ -253,6 +253,7 @@ const tokenVerifier = { }); if (!response.ok) { + await response.body?.cancel(); throw new Error(`Invalid or expired token: ${await response.text()}`); } diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 33568bc82..45a011cee 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -485,6 +485,7 @@ if (useOAuth) { }); if (!response.ok) { + await response.body?.cancel(); throw new Error(`Invalid or expired token: ${await response.text()}`); } diff --git a/src/server/auth/providers/proxyProvider.ts b/src/server/auth/providers/proxyProvider.ts index 32f256450..855856c89 100644 --- a/src/server/auth/providers/proxyProvider.ts +++ b/src/server/auth/providers/proxyProvider.ts @@ -84,6 +84,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { }, body: params.toString() }); + await response.body?.cancel(); if (!response.ok) { throw new ServerError(`Token revocation failed: ${response.status}`); @@ -107,6 +108,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { }); if (!response.ok) { + await response.body?.cancel(); throw new ServerError(`Client registration failed: ${response.status}`); } @@ -181,6 +183,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { }); if (!response.ok) { + await response.body?.cancel(); throw new ServerError(`Token exchange failed: ${response.status}`); } @@ -221,6 +224,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { }); if (!response.ok) { + await response.body?.cancel(); throw new ServerError(`Token refresh failed: ${response.status}`); } From 05eb2ba8b5c1ae6653dde0589aeff97401cdeb8c Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 26 Nov 2025 13:28:31 +0000 Subject: [PATCH 2/3] fix: don't cancel response body before reading text in examples Calling body.cancel() then response.text() would fail because the stream is already closed. Use text() first to consume the body. --- src/examples/server/elicitationUrlExample.ts | 4 ++-- src/examples/server/simpleStreamableHttp.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/examples/server/elicitationUrlExample.ts b/src/examples/server/elicitationUrlExample.ts index c11f59d68..af014fdc1 100644 --- a/src/examples/server/elicitationUrlExample.ts +++ b/src/examples/server/elicitationUrlExample.ts @@ -253,8 +253,8 @@ const tokenVerifier = { }); if (!response.ok) { - await response.body?.cancel(); - throw new Error(`Invalid or expired token: ${await response.text()}`); + const text = await response.text().catch(() => null); + throw new Error(`Invalid or expired token: ${text}`); } const data = await response.json(); diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 45a011cee..123e38eae 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -485,8 +485,8 @@ if (useOAuth) { }); if (!response.ok) { - await response.body?.cancel(); - throw new Error(`Invalid or expired token: ${await response.text()}`); + const text = await response.text().catch(() => null); + throw new Error(`Invalid or expired token: ${text}`); } const data = await response.json(); From 719604f82f2f78070bca245b400642091ee2b15d Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Wed, 26 Nov 2025 13:30:52 +0000 Subject: [PATCH 3/3] revert: remove body.cancel from build script This script runs in Node.js at build time, not in Cloudflare Workers, so it doesn't need the body consumption fix. --- scripts/fetch-spec-types.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/scripts/fetch-spec-types.ts b/scripts/fetch-spec-types.ts index 920ce1ce1..a64e0848e 100644 --- a/scripts/fetch-spec-types.ts +++ b/scripts/fetch-spec-types.ts @@ -15,7 +15,6 @@ async function fetchLatestSHA(): Promise { const response = await fetch(url); if (!response.ok) { - await response.body?.cancel(); throw new Error(`Failed to fetch commit info: ${response.status} ${response.statusText}`); } @@ -32,7 +31,6 @@ async function fetchSpecTypes(sha: string): Promise { const response = await fetch(url); if (!response.ok) { - await response.body?.cancel(); throw new Error(`Failed to fetch spec types: ${response.status} ${response.statusText}`); }