From 97ee7bd00b8c058a1cc2e7055610fc40536b6a20 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 18:58:31 +0000 Subject: [PATCH 1/5] fix(auth): treat null optional fields in OAuth token responses as absent Some authorization servers serialize absent optional members as JSON null, which RFC 6749 does not sanction but is common in the wild. Previously, OAuthTokensSchema rejected refresh_token/scope/id_token when null with a Zod validation error, and expires_in: null silently coerced to 0 (Number(null) === 0), producing a token the client treated as already expired. This broke token exchange and refresh against such servers. Normalize null optional members to absent (undefined) before validation. Inferred output types are unchanged (string | undefined, number | undefined), so OAuthTokens consumers are unaffected. Related: #754 (same null-serialization pattern hitting the client registration schema). --- packages/client/test/client/auth.test.ts | 31 ++++++++ packages/core-internal/src/shared/auth.ts | 14 ++-- .../core-internal/test/shared/auth.test.ts | 71 +++++++++++++++++++ 3 files changed, 112 insertions(+), 4 deletions(-) diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 62c6faed9a..9e392c85a3 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -2005,6 +2005,37 @@ describe('OAuth Authorization', () => { expect(body.get('redirect_uri')).toBe('http://localhost:3000/callback'); expect(body.get('resource')).toBe('https://api.example.com/mcp-server'); }); + it('treats null optional fields as absent (some auth servers serialize absent members as null)', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'access123', + token_type: 'Bearer', + expires_in: null, + scope: null, + refresh_token: null, + id_token: null + }) + }); + + const tokens = await exchangeAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + authorizationCode: 'code123', + codeVerifier: 'verifier123', + redirectUri: 'http://localhost:3000/callback', + resource: new URL('https://api.example.com/mcp-server') + }); + + expect(tokens.access_token).toBe('access123'); + expect(tokens.token_type).toBe('Bearer'); + // expires_in: null must not coerce to 0 (an instantly-expired token) + expect(tokens.expires_in).toBeUndefined(); + expect(tokens.scope).toBeUndefined(); + expect(tokens.refresh_token).toBeUndefined(); + expect(tokens.id_token).toBeUndefined(); + }); + it('exchanges code for tokens with auth', async () => { mockFetch.mockResolvedValueOnce({ ok: true, diff --git a/packages/core-internal/src/shared/auth.ts b/packages/core-internal/src/shared/auth.ts index e21076d817..903a4108b7 100644 --- a/packages/core-internal/src/shared/auth.ts +++ b/packages/core-internal/src/shared/auth.ts @@ -131,15 +131,21 @@ export const OpenIdProviderDiscoveryMetadataSchema = z.object({ /** * OAuth 2.1 token response + * + * Some authorization servers serialize absent optional members as JSON `null` + * (not sanctioned by RFC 6749, but common in the wild), so null values are + * normalized to absent (`undefined`) rather than rejected. For `expires_in`, + * `null` must be normalized before coercion — `Number(null) === 0` would + * otherwise yield an instantly-expired token. */ export const OAuthTokensSchema = z .object({ access_token: z.string(), - id_token: z.string().optional(), // Optional for OAuth 2.1, but necessary in OpenID Connect + id_token: z.preprocess(value => value ?? undefined, z.string().optional()), // Optional for OAuth 2.1, but necessary in OpenID Connect token_type: z.string(), - expires_in: z.coerce.number().optional(), - scope: z.string().optional(), - refresh_token: z.string().optional() + expires_in: z.preprocess(value => value ?? undefined, z.coerce.number().optional()), + scope: z.preprocess(value => value ?? undefined, z.string().optional()), + refresh_token: z.preprocess(value => value ?? undefined, z.string().optional()) }) .strip(); diff --git a/packages/core-internal/test/shared/auth.test.ts b/packages/core-internal/test/shared/auth.test.ts index 4561d8fb11..bb9b609253 100644 --- a/packages/core-internal/test/shared/auth.test.ts +++ b/packages/core-internal/test/shared/auth.test.ts @@ -1,6 +1,7 @@ import { OAuthClientMetadataSchema, OAuthMetadataSchema, + OAuthTokensSchema, OpenIdProviderMetadataSchema, OptionalSafeUrlSchema, SafeUrlSchema @@ -100,6 +101,76 @@ describe('OpenIdProviderMetadataSchema', () => { }); }); +describe('OAuthTokensSchema', () => { + it('parses a fully-populated token response unchanged', () => { + const tokens = OAuthTokensSchema.parse({ + access_token: 'access123', + id_token: 'id123', + token_type: 'Bearer', + expires_in: 3600, + scope: 'read write', + refresh_token: 'refresh123' + }); + + expect(tokens).toEqual({ + access_token: 'access123', + id_token: 'id123', + token_type: 'Bearer', + expires_in: 3600, + scope: 'read write', + refresh_token: 'refresh123' + }); + }); + + it('parses a token response with optional fields absent', () => { + const tokens = OAuthTokensSchema.parse({ + access_token: 'access123', + token_type: 'Bearer' + }); + + expect(tokens.access_token).toBe('access123'); + expect(tokens.token_type).toBe('Bearer'); + expect(tokens.id_token).toBeUndefined(); + expect(tokens.expires_in).toBeUndefined(); + expect(tokens.scope).toBeUndefined(); + expect(tokens.refresh_token).toBeUndefined(); + }); + + it.each(['refresh_token', 'scope', 'id_token'] as const)( + 'treats a null %s as absent (some auth servers serialize absent members as null)', + field => { + const tokens = OAuthTokensSchema.parse({ + access_token: 'access123', + token_type: 'Bearer', + [field]: null + }); + + expect(tokens[field]).toBeUndefined(); + } + ); + + it('treats a null expires_in as absent rather than coercing it to 0', () => { + const tokens = OAuthTokensSchema.parse({ + access_token: 'access123', + token_type: 'Bearer', + expires_in: null + }); + + expect(tokens.expires_in).toBeUndefined(); + expect(tokens.expires_in).not.toBe(0); + }); + + it('still coerces string expires_in values to numbers', () => { + const tokens = OAuthTokensSchema.parse({ + access_token: 'access123', + token_type: 'Bearer', + expires_in: '3600' + }); + + expect(tokens.expires_in).toBe(3600); + }); +}); + describe('OAuthClientMetadataSchema', () => { it('validates client metadata with safe URLs', () => { const metadata = { From 16398278105ccad4fd8de4db3ce26467d604c2fe Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 18:59:46 +0000 Subject: [PATCH 2/5] chore: add changeset for OAuth token null-field normalization --- .changeset/oauth-tokens-null-optional-fields.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/oauth-tokens-null-optional-fields.md diff --git a/.changeset/oauth-tokens-null-optional-fields.md b/.changeset/oauth-tokens-null-optional-fields.md new file mode 100644 index 0000000000..a8afa1965d --- /dev/null +++ b/.changeset/oauth-tokens-null-optional-fields.md @@ -0,0 +1,14 @@ +--- +'@modelcontextprotocol/core-internal': patch +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/core': patch +'@modelcontextprotocol/server-legacy': patch +--- + +`OAuthTokensSchema` now treats null-valued optional members in OAuth token +responses as absent. Some authorization servers serialize absent optional +members as JSON `null` (not sanctioned by RFC 6749, but common in the wild); +previously `refresh_token`, `scope`, or `id_token` set to `null` failed +validation during token exchange and refresh, and `expires_in: null` silently +coerced to `0`, yielding an instantly-expired token. Nulls are now normalized +to `undefined` before validation; parsed output types are unchanged. From 20893fb456f005f5d02c6fb215612dbeb0e771f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 20:50:54 +0000 Subject: [PATCH 3/5] fix(auth): normalize null token-response members at parse sites, not in OAuthTokensSchema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework after review: the per-field z.preprocess mechanic left null-valued keys present-as-undefined in the parsed output rather than absent, so refreshAuthorization's spread over the previous refresh token was clobbered by the explicit refresh_token: undefined — for exactly the null-emitting servers this fix targets, every refresh silently destroyed the stored refresh token. It also degraded z.input of the exported schema on zod <4.4. - Revert OAuthTokensSchema to its original plain object definition, restoring .shape/.extend/z.input for consumers (and the derived specTypeSchemas/isSpecType input types). - Add OAuthTokenResponseSchema, which removes null-valued optional members (derived from the schema shape, not a hardcoded field list) before validation, mirroring ElicitResult's null-leniency idiom, and use it at the SDK's own token-response parse sites: executeTokenRequest, the JWT-grant cross-app exchange, and server-legacy's proxyProvider. - Harden refreshAuthorization's merge to { ...tokens, refresh_token: tokens.refresh_token ?? refreshToken } so a present-but-undefined key can never clobber the preserved token. - Tests now pin strict key absence (toStrictEqual / 'in' checks), null access_token and missing token_type rejection, the exported schema's unchanged shape/extend/input behavior, a shape-driven drift guard for future optional members, and a refreshAuthorization e2e with refresh_token: null that fails against the previous mechanic. Related: #754 (same null-serialization pattern hitting the client registration schema). --- .../oauth-tokens-null-optional-fields.md | 21 ++-- packages/client/src/client/auth.ts | 6 +- packages/client/src/client/crossAppAccess.ts | 4 +- packages/client/test/client/auth.test.ts | 36 ++++-- packages/core-internal/src/shared/auth.ts | 41 ++++-- .../core-internal/test/shared/auth.test.ts | 118 +++++++++++++++++- .../src/auth/providers/proxyProvider.ts | 6 +- 7 files changed, 194 insertions(+), 38 deletions(-) diff --git a/.changeset/oauth-tokens-null-optional-fields.md b/.changeset/oauth-tokens-null-optional-fields.md index a8afa1965d..f14536feff 100644 --- a/.changeset/oauth-tokens-null-optional-fields.md +++ b/.changeset/oauth-tokens-null-optional-fields.md @@ -5,10 +5,17 @@ '@modelcontextprotocol/server-legacy': patch --- -`OAuthTokensSchema` now treats null-valued optional members in OAuth token -responses as absent. Some authorization servers serialize absent optional -members as JSON `null` (not sanctioned by RFC 6749, but common in the wild); -previously `refresh_token`, `scope`, or `id_token` set to `null` failed -validation during token exchange and refresh, and `expires_in: null` silently -coerced to `0`, yielding an instantly-expired token. Nulls are now normalized -to `undefined` before validation; parsed output types are unchanged. +OAuth token responses with null-valued optional members no longer fail +validation. Some authorization servers serialize absent optional members as +JSON `null` (nonconformant with RFC 6749 §5.1, but common in the wild); +previously `refresh_token`, `scope`, or `id_token` set to `null` failed token +exchange and refresh, and `expires_in: null` silently coerced to `0`, yielding +an instantly-expired token. The SDK's own token-response parse sites (client +token exchange/refresh, JWT-grant cross-app exchange, and the server-legacy +proxy provider) now validate with a new `OAuthTokenResponseSchema` that removes +null-valued optional members before validation, so they are strictly absent +from the parsed output. The exported `OAuthTokensSchema` is unchanged — still a +plain object schema that rejects nulls, with its `.shape`/`.extend` and input +types intact. `refreshAuthorization` additionally hardens its merge with the +previously-stored refresh token, so an explicitly `undefined` `refresh_token` +in a parsed response can never clobber the preserved token. diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 9ebc6fd251..28c86448a0 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -22,7 +22,7 @@ import { OAuthErrorResponseSchema, OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, - OAuthTokensSchema, + OAuthTokenResponseSchema, OpenIdProviderDiscoveryMetadataSchema, resourceUrlFromServerUrl, stampErrorBrands @@ -2100,7 +2100,7 @@ export async function executeTokenRequest( const json: unknown = await response.json(); try { - return OAuthTokensSchema.parse(json); + return OAuthTokenResponseSchema.parse(json); } catch (parseError) { // Some OAuth servers (e.g., GitHub) return error responses with HTTP 200 status. // Check for error field only if token parsing failed. @@ -2215,7 +2215,7 @@ export async function refreshAuthorization( }); // Preserve original refresh token if server didn't return a new one - return { refresh_token: refreshToken, ...tokens }; + return { ...tokens, refresh_token: tokens.refresh_token ?? refreshToken }; } /** diff --git a/packages/client/src/client/crossAppAccess.ts b/packages/client/src/client/crossAppAccess.ts index a7703dbf15..d7f75d28cb 100644 --- a/packages/client/src/client/crossAppAccess.ts +++ b/packages/client/src/client/crossAppAccess.ts @@ -9,7 +9,7 @@ */ import type { FetchLike } from '@modelcontextprotocol/core-internal'; -import { IdJagTokenExchangeResponseSchema, OAuthErrorResponseSchema, OAuthTokensSchema } from '@modelcontextprotocol/core-internal'; +import { IdJagTokenExchangeResponseSchema, OAuthErrorResponseSchema, OAuthTokenResponseSchema } from '@modelcontextprotocol/core-internal'; import type { ClientAuthMethod } from './auth'; import { applyClientAuthentication, assertSecureTokenEndpoint, discoverAuthorizationServerMetadata } from './auth'; @@ -298,7 +298,7 @@ export async function exchangeJwtAuthGrant(options: { const responseBody = await response.json(); // Validate response using core schema - const parseResult = OAuthTokensSchema.safeParse(responseBody); + const parseResult = OAuthTokenResponseSchema.safeParse(responseBody); if (!parseResult.success) { throw new Error(`Invalid token response: ${parseResult.error.message}`); } diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 9e392c85a3..6487546740 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -2027,13 +2027,15 @@ describe('OAuth Authorization', () => { resource: new URL('https://api.example.com/mcp-server') }); - expect(tokens.access_token).toBe('access123'); - expect(tokens.token_type).toBe('Bearer'); - // expires_in: null must not coerce to 0 (an instantly-expired token) - expect(tokens.expires_in).toBeUndefined(); - expect(tokens.scope).toBeUndefined(); - expect(tokens.refresh_token).toBeUndefined(); - expect(tokens.id_token).toBeUndefined(); + // toStrictEqual pins the null-valued members as strictly ABSENT keys, not + // present-but-undefined: refreshAuthorization spreads the parsed response + // when merging with stored tokens, so a present `refresh_token: undefined` + // key would clobber the preserved refresh token. It also pins that + // expires_in: null did not coerce to 0 (an instantly-expired token). + expect(tokens).toStrictEqual({ + access_token: 'access123', + token_type: 'Bearer' + }); }); it('exchanges code for tokens with auth', async () => { @@ -2288,6 +2290,26 @@ describe('OAuth Authorization', () => { expect(tokens).toEqual({ refresh_token: refreshToken, ...validTokens }); }); + it('keeps the existing refresh token when the server returns refresh_token: null', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ ...validTokens, refresh_token: null }) + }); + + const refreshToken = 'refresh123'; + const tokens = await refreshAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + refreshToken + }); + + // The null must not clobber the preserved token when the parsed response + // is merged with the previously-stored tokens (it would then be persisted + // via saveTokens, silently destroying the stored refresh token). + expect(tokens.refresh_token).toBe(refreshToken); + expect(tokens).toStrictEqual({ ...validTokens, refresh_token: refreshToken }); + }); + it('validates token response schema', async () => { mockFetch.mockResolvedValueOnce({ ok: true, diff --git a/packages/core-internal/src/shared/auth.ts b/packages/core-internal/src/shared/auth.ts index 903a4108b7..64594b8345 100644 --- a/packages/core-internal/src/shared/auth.ts +++ b/packages/core-internal/src/shared/auth.ts @@ -131,24 +131,45 @@ export const OpenIdProviderDiscoveryMetadataSchema = z.object({ /** * OAuth 2.1 token response - * - * Some authorization servers serialize absent optional members as JSON `null` - * (not sanctioned by RFC 6749, but common in the wild), so null values are - * normalized to absent (`undefined`) rather than rejected. For `expires_in`, - * `null` must be normalized before coercion — `Number(null) === 0` would - * otherwise yield an instantly-expired token. */ export const OAuthTokensSchema = z .object({ access_token: z.string(), - id_token: z.preprocess(value => value ?? undefined, z.string().optional()), // Optional for OAuth 2.1, but necessary in OpenID Connect + id_token: z.string().optional(), // Optional for OAuth 2.1, but necessary in OpenID Connect token_type: z.string(), - expires_in: z.preprocess(value => value ?? undefined, z.coerce.number().optional()), - scope: z.preprocess(value => value ?? undefined, z.string().optional()), - refresh_token: z.preprocess(value => value ?? undefined, z.string().optional()) + expires_in: z.coerce.number().optional(), + scope: z.string().optional(), + refresh_token: z.string().optional() }) .strip(); +/** + * OAuth 2.1 token response as received over the wire from an authorization server. + * + * Some authorization servers serialize absent optional members as JSON `null` + * (nonconformant with RFC 6749 §5.1, but common in the wild). Null-valued + * optional members are normalized to absent — the key is removed, mirroring + * ElicitResult's `content` null normalization — before {@linkcode OAuthTokensSchema} + * validates, so valid-but-sloppy responses parse and `expires_in: null` never + * reaches `z.coerce.number()` (`Number(null) === 0` would yield an + * instantly-expired token). Removing the key (rather than mapping it to + * `undefined`) matters: callers such as `refreshAuthorization` spread the parsed + * response when merging with previously-stored tokens, and a present-but-undefined + * `refresh_token` key would clobber the preserved value. + */ +export const OAuthTokenResponseSchema = z.preprocess(value => { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return value; + } + const normalized: Record = { ...value }; + for (const [key, memberSchema] of Object.entries(OAuthTokensSchema.shape)) { + if (normalized[key] === null && memberSchema.safeParse(undefined).success) { + delete normalized[key]; + } + } + return normalized; +}, OAuthTokensSchema); + /** * RFC 8693 §2.2.1 Token Exchange response for ID-JAG tokens. * diff --git a/packages/core-internal/test/shared/auth.test.ts b/packages/core-internal/test/shared/auth.test.ts index bb9b609253..e2cdab509d 100644 --- a/packages/core-internal/test/shared/auth.test.ts +++ b/packages/core-internal/test/shared/auth.test.ts @@ -1,6 +1,9 @@ +import * as z from 'zod/v4'; + import { OAuthClientMetadataSchema, OAuthMetadataSchema, + OAuthTokenResponseSchema, OAuthTokensSchema, OpenIdProviderMetadataSchema, OptionalSafeUrlSchema, @@ -136,32 +139,102 @@ describe('OAuthTokensSchema', () => { expect(tokens.refresh_token).toBeUndefined(); }); + it('rejects null-valued members (null normalization lives in OAuthTokenResponseSchema)', () => { + expect( + OAuthTokensSchema.safeParse({ + access_token: 'access123', + token_type: 'Bearer', + refresh_token: null + }).success + ).toBe(false); + expect( + OAuthTokensSchema.safeParse({ + access_token: null, + token_type: 'Bearer' + }).success + ).toBe(false); + }); + + it('remains a plain ZodObject with an introspectable shape (regression pin: consumers use .shape/.extend)', () => { + expect(OAuthTokensSchema).toBeInstanceOf(z.ZodObject); + expect(Object.keys(OAuthTokensSchema.shape).sort()).toEqual([ + 'access_token', + 'expires_in', + 'id_token', + 'refresh_token', + 'scope', + 'token_type' + ]); + + const extended = OAuthTokensSchema.extend({ example_extension: z.string() }); + expect( + extended.parse({ + access_token: 'access123', + token_type: 'Bearer', + example_extension: 'ext' + }).example_extension + ).toBe('ext'); + + // Optional members must stay optional in z.input — a compile-time pin + // (a schema-level preprocess would degrade these keys to required `unknown`). + const minimalInput: z.input = { + access_token: 'access123', + token_type: 'Bearer' + }; + expect(OAuthTokensSchema.parse(minimalInput)).toStrictEqual({ + access_token: 'access123', + token_type: 'Bearer' + }); + }); +}); + +describe('OAuthTokenResponseSchema', () => { it.each(['refresh_token', 'scope', 'id_token'] as const)( 'treats a null %s as absent (some auth servers serialize absent members as null)', field => { - const tokens = OAuthTokensSchema.parse({ + const tokens = OAuthTokenResponseSchema.parse({ access_token: 'access123', token_type: 'Bearer', [field]: null }); - expect(tokens[field]).toBeUndefined(); + expect(field in tokens).toBe(false); } ); - it('treats a null expires_in as absent rather than coercing it to 0', () => { - const tokens = OAuthTokensSchema.parse({ + it('strips all null-valued optional members so the keys are strictly absent', () => { + const tokens = OAuthTokenResponseSchema.parse({ + access_token: 'access123', + token_type: 'Bearer', + expires_in: null, + scope: null, + refresh_token: null, + id_token: null + }); + + // toStrictEqual distinguishes absent keys from present-but-undefined keys. + // Key absence is load-bearing: refreshAuthorization spreads the parsed + // response when merging with previously-stored tokens, and a present + // `refresh_token: undefined` key would clobber the preserved refresh token. + expect(tokens).toStrictEqual({ + access_token: 'access123', + token_type: 'Bearer' + }); + }); + + it('treats a null expires_in as absent rather than coercing it to 0 (an instantly-expired token)', () => { + const tokens = OAuthTokenResponseSchema.parse({ access_token: 'access123', token_type: 'Bearer', expires_in: null }); - expect(tokens.expires_in).toBeUndefined(); + expect('expires_in' in tokens).toBe(false); expect(tokens.expires_in).not.toBe(0); }); it('still coerces string expires_in values to numbers', () => { - const tokens = OAuthTokensSchema.parse({ + const tokens = OAuthTokenResponseSchema.parse({ access_token: 'access123', token_type: 'Bearer', expires_in: '3600' @@ -169,6 +242,39 @@ describe('OAuthTokensSchema', () => { expect(tokens.expires_in).toBe(3600); }); + + it('still rejects a null access_token (only optional members are normalized)', () => { + expect( + OAuthTokenResponseSchema.safeParse({ + access_token: null, + token_type: 'Bearer' + }).success + ).toBe(false); + }); + + it('still rejects a missing token_type', () => { + expect( + OAuthTokenResponseSchema.safeParse({ + access_token: 'access123' + }).success + ).toBe(false); + }); + + it('normalizes a null in every optional member of OAuthTokensSchema (drift guard for future members)', () => { + for (const [key, memberSchema] of Object.entries(OAuthTokensSchema.shape)) { + if (!memberSchema.safeParse(undefined).success) { + continue; // Required member — nulls must keep failing, covered above. + } + + const tokens = OAuthTokenResponseSchema.parse({ + access_token: 'access123', + token_type: 'Bearer', + [key]: null + }); + + expect(key in tokens).toBe(false); + } + }); }); describe('OAuthClientMetadataSchema', () => { diff --git a/packages/server-legacy/src/auth/providers/proxyProvider.ts b/packages/server-legacy/src/auth/providers/proxyProvider.ts index 347589198a..8d484c5488 100644 --- a/packages/server-legacy/src/auth/providers/proxyProvider.ts +++ b/packages/server-legacy/src/auth/providers/proxyProvider.ts @@ -1,5 +1,5 @@ import type { FetchLike, OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/core-internal'; -import { OAuthClientInformationFullSchema, OAuthTokensSchema } from '@modelcontextprotocol/core-internal'; +import { OAuthClientInformationFullSchema, OAuthTokenResponseSchema } from '@modelcontextprotocol/core-internal'; import type { Response } from 'express'; import type { OAuthRegisteredClientsStore } from '../clients'; @@ -194,7 +194,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { } const data = await response.json(); - return OAuthTokensSchema.parse(data); + return OAuthTokenResponseSchema.parse(data); } async exchangeRefreshToken( @@ -235,7 +235,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { } const data = await response.json(); - return OAuthTokensSchema.parse(data); + return OAuthTokenResponseSchema.parse(data); } async verifyAccessToken(token: string): Promise { From 8d9dee8a0763fafe2f0ca81b39dfb6ce6d85881e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 22:23:02 +0000 Subject: [PATCH 4/5] =?UTF-8?q?docs:=20note=20RFC=206749=20=C2=A75.1=20sco?= =?UTF-8?q?pe-absence=20semantics=20of=20null=20stripping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stripping a null scope from a token response makes it indistinguishable from an omitted scope, which RFC 6749 §5.1 defines as an assertion that the granted scope is identical to the requested scope. Document that consumers must not infer the granted scope from its absence and should use token introspection for the authoritative grant. --- .changeset/oauth-tokens-null-optional-fields.md | 6 +++++- packages/core-internal/src/shared/auth.ts | 9 +++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.changeset/oauth-tokens-null-optional-fields.md b/.changeset/oauth-tokens-null-optional-fields.md index f14536feff..a674c12cec 100644 --- a/.changeset/oauth-tokens-null-optional-fields.md +++ b/.changeset/oauth-tokens-null-optional-fields.md @@ -18,4 +18,8 @@ from the parsed output. The exported `OAuthTokensSchema` is unchanged — still plain object schema that rejects nulls, with its `.shape`/`.extend` and input types intact. `refreshAuthorization` additionally hardens its merge with the previously-stored refresh token, so an explicitly `undefined` `refresh_token` -in a parsed response can never clobber the preserved token. +in a parsed response can never clobber the preserved token. Note that a +stripped null `scope` is thereafter indistinguishable from an omitted `scope` +— which RFC 6749 §5.1 defines as an assertion that the granted scope is +identical to the requested scope — so consumers should not infer the granted +scope from its absence. diff --git a/packages/core-internal/src/shared/auth.ts b/packages/core-internal/src/shared/auth.ts index 64594b8345..733ce3c953 100644 --- a/packages/core-internal/src/shared/auth.ts +++ b/packages/core-internal/src/shared/auth.ts @@ -156,6 +156,15 @@ export const OAuthTokensSchema = z * `undefined`) matters: callers such as `refreshAuthorization` spread the parsed * response when merging with previously-stored tokens, and a present-but-undefined * `refresh_token` key would clobber the preserved value. + * + * Per RFC 6749 §5.1, an absent `scope` member is a positive assertion that + * the granted scope is identical to the scope the client requested, so + * stripping `scope: null` converts a response with undefined semantics into + * that assertion. This has no bearing on enforcement — the SDK never uses + * `tokens.scope` for authorization decisions, and the resource server remains + * authoritative — but consumers must not derive granted-scope conclusions + * from the member's absence. Consumers that need the authoritative grant + * should use token introspection instead. */ export const OAuthTokenResponseSchema = z.preprocess(value => { if (value === null || typeof value !== 'object' || Array.isArray(value)) { From 4e62b738166d5dadde53fc4090d9f772562962d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 22:35:56 +0000 Subject: [PATCH 5/5] chore: retrigger CI (flaky test job on Node 24) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KXnNnp3fxQYR5HF9BUVhYP