From 8840fa951460f3a9b32d9e37c3d8c2ce0bb3ad59 Mon Sep 17 00:00:00 2001 From: KlyneChrysler Date: Mon, 6 Jul 2026 07:00:45 +0800 Subject: [PATCH] fix(client): decompress gzip token response bodies in oauth flows Fixes #2408. Fetch implementations only auto-decompress when the response carries a usable Content-Encoding header. A proxy that strips the header, or a custom fetchFn that surfaces raw bytes, hands the SDK gzip bytes that crash response.json() during token exchange. Token and OAuth error response bodies are now sniffed for the gzip magic bytes and decompressed via the web-standard DecompressionStream before JSON parsing, with a json()/text() fallback for minimal Response-like objects. --- .changeset/gzip-token-response.md | 11 ++++ packages/client/src/client/auth.ts | 46 +++++++++++++- packages/client/test/client/auth.test.ts | 79 ++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 .changeset/gzip-token-response.md diff --git a/.changeset/gzip-token-response.md b/.changeset/gzip-token-response.md new file mode 100644 index 0000000000..f9519dea1f --- /dev/null +++ b/.changeset/gzip-token-response.md @@ -0,0 +1,11 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Fix OAuth token exchange crashing with a JSON `SyntaxError` when the token +response body arrives as raw gzip bytes. Fetch implementations only +auto-decompress when the response carries a usable `Content-Encoding` header; +a proxy that strips the header (or a custom `fetchFn` that surfaces raw bytes) +handed the SDK compressed bytes that `response.json()` could not parse. Token +and OAuth error response bodies are now sniffed for the gzip magic bytes and +transparently decompressed via `DecompressionStream` before JSON parsing. diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index a4d5b14c62..204bbafa0d 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -873,6 +873,48 @@ export function resolveClientMetadata(provider: Pick= 2 && bytes[0] === GZIP_MAGIC_BYTES[0] && bytes[1] === GZIP_MAGIC_BYTES[1]; +} + +async function gunzip(bytes: Uint8Array): Promise { + const decompressed = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip')); + return new Uint8Array(await new Response(decompressed).arrayBuffer()); +} + +/** + * Reads a response body as decoded text, transparently gunzipping compressed bodies. + * + * Fetch implementations only auto-decompress when the response carries a usable + * `Content-Encoding` header. Some proxies strip that header while leaving the body + * compressed, and custom {@linkcode FetchLike} implementations may surface raw + * compressed bytes; both crash naive `response.json()` calls. Falls back to + * `response.text()` for minimal Response-like objects that lack `arrayBuffer`. + */ +async function readResponseText(response: Response): Promise { + if (typeof response.arrayBuffer !== 'function') { + return response.text(); + } + + const bytes = new Uint8Array(await response.arrayBuffer()); + return new TextDecoder().decode(isGzipBytes(bytes) ? await gunzip(bytes) : bytes); +} + +/** + * Reads a response body as parsed JSON via {@linkcode readResponseText}. Falls back to + * `response.json()` for minimal Response-like objects that lack `arrayBuffer`. + */ +async function readResponseJson(response: Response): Promise { + if (typeof response.arrayBuffer !== 'function') { + return response.json(); + } + + return JSON.parse(await readResponseText(response)) as unknown; +} + /** * Parses an OAuth error response from a string or Response object. * @@ -886,7 +928,7 @@ export function resolveClientMetadata(provider: Pick { const statusCode = input instanceof Response ? input.status : undefined; - const body = input instanceof Response ? await input.text() : input; + const body = input instanceof Response ? await readResponseText(input) : input; try { const result = OAuthErrorResponseSchema.parse(JSON.parse(body)); @@ -2067,7 +2109,7 @@ export async function executeTokenRequest( throw await parseErrorResponse(response); } - const json: unknown = await response.json(); + const json: unknown = await readResponseJson(response); try { return OAuthTokensSchema.parse(json); diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 62c6faed9a..864ddf4f5c 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -7,6 +7,8 @@ import type { StoredOAuthTokens } from '@modelcontextprotocol/core-internal'; import { LATEST_PROTOCOL_VERSION, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/core-internal'; +import { gzipSync } from 'node:zlib'; + import type { Mock } from 'vitest'; import { expect, vi } from 'vitest'; @@ -2134,6 +2136,83 @@ describe('OAuth Authorization', () => { }); }); + describe('gzip-compressed token responses', () => { + // https://github.com/modelcontextprotocol/typescript-sdk/issues/2408 — fetch + // implementations only auto-decompress when the response carries a usable + // Content-Encoding header. A proxy that strips the header (or a custom fetchFn + // that surfaces raw bytes) hands the SDK gzip bytes, which crashed response.json(). + const validTokens: OAuthTokens = { + access_token: 'access123', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'refresh123' + }; + + const validClientInfo = { + client_id: 'client123', + client_secret: 'secret123', + redirect_uris: ['http://localhost:3000/callback'], + client_name: 'Test Client' + }; + + const exchange = () => + exchangeAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + authorizationCode: 'code123', + codeVerifier: 'verifier123', + redirectUri: 'http://localhost:3000/callback' + }); + + it('parses a token response whose body is gzip bytes without Content-Encoding', async () => { + // Proxy stripped the Content-Encoding header but left the body compressed. + mockFetch.mockResolvedValueOnce( + new Response(gzipSync(JSON.stringify(validTokens)), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ); + + await expect(exchange()).resolves.toEqual(validTokens); + }); + + it('parses a token response whose body is gzip bytes with Content-Encoding set', async () => { + // Custom fetchFn (e.g. a raw undici.request wrapper) that does not auto-decompress. + mockFetch.mockResolvedValueOnce( + new Response(gzipSync(JSON.stringify(validTokens)), { + status: 200, + headers: { 'Content-Type': 'application/json', 'Content-Encoding': 'gzip' } + }) + ); + + await expect(exchange()).resolves.toEqual(validTokens); + }); + + it('parses a gzip-compressed OAuth error response', async () => { + mockFetch.mockResolvedValueOnce( + new Response(gzipSync(JSON.stringify({ error: 'invalid_grant', error_description: 'expired code' })), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }) + ); + + const error = await exchange().catch((e: unknown) => e); + expect(error).toBeInstanceOf(OAuthError); + expect((error as OAuthError).code).toBe('invalid_grant'); + expect((error as OAuthError).message).toBe('expired code'); + }); + + it('still parses a plain JSON token response delivered as a real Response', async () => { + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify(validTokens), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + ); + + await expect(exchange()).resolves.toEqual(validTokens); + }); + }); + describe('refreshAuthorization', () => { const validTokens = { access_token: 'newaccess123',