Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/gzip-token-response.md
Original file line number Diff line number Diff line change
@@ -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.
46 changes: 44 additions & 2 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -873,6 +873,48 @@ export function resolveClientMetadata(provider: Pick<OAuthClientProvider, 'clien
};
}

/** Magic bytes identifying a gzip stream (RFC 1952 §2.3.1). */
const GZIP_MAGIC_BYTES = [0x1f, 0x8b] as const;

function isGzipBytes(bytes: Uint8Array): boolean {
return bytes.length >= 2 && bytes[0] === GZIP_MAGIC_BYTES[0] && bytes[1] === GZIP_MAGIC_BYTES[1];
}

async function gunzip(bytes: Uint8Array): Promise<Uint8Array> {
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<string> {
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<unknown> {
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.
*
Expand All @@ -886,7 +928,7 @@ export function resolveClientMetadata(provider: Pick<OAuthClientProvider, 'clien
*/
export async function parseErrorResponse(input: Response | string): Promise<OAuthError> {
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));
Expand Down Expand Up @@ -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);
Expand Down
79 changes: 79 additions & 0 deletions packages/client/test/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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',
Expand Down
Loading