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',