From 97ee7bd00b8c058a1cc2e7055610fc40536b6a20 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 18:58:31 +0000 Subject: [PATCH 01/13] 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 02/13] 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 03/13] 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 04/13] =?UTF-8?q?docs:=20note=20RFC=206749=20=C2=A75.1=20s?= =?UTF-8?q?cope-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 05/13] chore: retrigger CI (flaky test job on Node 24) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KXnNnp3fxQYR5HF9BUVhYP From e3d7aabdd263b54abf3dad3e5568fb6372f47a8b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 05:32:47 +0000 Subject: [PATCH 06/13] docs: drop core from changeset; correct scope-usage claim in JSDoc The changeset no longer bumps @modelcontextprotocol/core: its exported OAuthTokensSchema is deliberately unchanged and OAuthTokenResponseSchema is not part of core's shipped surface (not exported from core's index, not in core-internal's authSchemas registry that core's export group is test-pinned to), matching the sibling changeset convention of patching core-internal without core. The OAuthTokenResponseSchema JSDoc no longer claims the SDK never uses tokens.scope for authorization decisions: the 403 insufficient_scope step-up path feeds tokens.scope into isStrictScopeSuperset, where an absent scope is treated as the empty set and forces a fresh authorization request rather than a refresh. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KXnNnp3fxQYR5HF9BUVhYP --- .changeset/oauth-tokens-null-optional-fields.md | 1 - packages/core-internal/src/shared/auth.ts | 12 +++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.changeset/oauth-tokens-null-optional-fields.md b/.changeset/oauth-tokens-null-optional-fields.md index a674c12cec..0d01d901dc 100644 --- a/.changeset/oauth-tokens-null-optional-fields.md +++ b/.changeset/oauth-tokens-null-optional-fields.md @@ -1,7 +1,6 @@ --- '@modelcontextprotocol/core-internal': patch '@modelcontextprotocol/client': patch -'@modelcontextprotocol/core': patch '@modelcontextprotocol/server-legacy': patch --- diff --git a/packages/core-internal/src/shared/auth.ts b/packages/core-internal/src/shared/auth.ts index 733ce3c953..a9bd1e0d4d 100644 --- a/packages/core-internal/src/shared/auth.ts +++ b/packages/core-internal/src/shared/auth.ts @@ -160,11 +160,13 @@ export const OAuthTokensSchema = z * 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. + * that assertion. This has little bearing on enforcement — the resource + * server remains authoritative — but the SDK does use `tokens.scope` + * conservatively in the 403 `insufficient_scope` step-up path, where an + * absent scope is treated as the empty set and forces a fresh authorization + * request rather than a refresh. Consumers must not derive granted-scope + * conclusions from the member's absence; those 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 791c37971563fb11b3362850fdf9a89e61d4c54c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 06:04:48 +0000 Subject: [PATCH 07/13] docs: note the null-tolerant token-response wrapper in wire-schemas guide The guide claimed core's exports are the exact constants the SDK validates OAuth payloads against, and pointed gateway authors at OAuthTokensSchema for token responses. Since the SDK's own token parse sites now validate with OAuthTokenResponseSchema (an internal null-tolerant wrapper around OAuthTokensSchema), qualify the intro and note the wrapper next to the OAuth naming-convention list. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KXnNnp3fxQYR5HF9BUVhYP --- docs/advanced/wire-schemas.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/advanced/wire-schemas.md b/docs/advanced/wire-schemas.md index 8ce47bda53..89c3173ad1 100644 --- a/docs/advanced/wire-schemas.md +++ b/docs/advanced/wire-schemas.md @@ -4,7 +4,7 @@ shape: how-to # Wire schemas -`@modelcontextprotocol/core` exports the **wire schemas** — the exact Zod constants the SDK validates protocol and OAuth payloads against — for code that holds raw JSON instead of SDK objects. +`@modelcontextprotocol/core` exports the **wire schemas** — the exact Zod constants the SDK validates protocol and OAuth payloads against — for code that holds raw JSON instead of SDK objects. (One qualification: at the SDK's own token-response parse sites, `OAuthTokensSchema` is additionally applied through a null-tolerant preprocessing wrapper — see the note in the OAuth section below.) ## Validate a wire payload @@ -129,7 +129,7 @@ A document missing a required endpoint fails the parse; a valid one comes back t https://auth.example.com/token ``` -The group follows the same naming convention: `OAuthTokensSchema` for token responses, `OAuthProtectedResourceMetadataSchema` for protected-resource metadata, `OpenIdProviderDiscoveryMetadataSchema` for OpenID provider discovery. +The group follows the same naming convention: `OAuthTokensSchema` for token responses, `OAuthProtectedResourceMetadataSchema` for protected-resource metadata, `OpenIdProviderDiscoveryMetadataSchema` for OpenID provider discovery. Note that the SDK's own token-response parse sites validate with `OAuthTokenResponseSchema`, an internal null-tolerant wrapper around `OAuthTokensSchema` that drops null-valued optional members before validating; the exported `OAuthTokensSchema` itself remains strict and rejects nulls. ## Get the TypeScript types, guards and errors from the SDK packages From 3219818ab61356269d1c5c3ef452504ad1f93755 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 06:24:38 +0000 Subject: [PATCH 08/13] feat: export OAuthTokenResponseSchema from core and map it in the v1-to-v2 codemod The sibling v1.x PR makes OAuthTokenResponseSchema public v1 API, so the codemod must route its imports somewhere that exports it. Core's root barrel now exports the schema, AUTH_SCHEMA_NAMES includes it, and the drift-guard tests pin the new membership. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Q2uDjqaRGTv8vFth6iiU17 --- .changeset/oauth-tokens-null-optional-fields.md | 9 +++++++++ .../migrations/v1-to-v2/mappings/authSchemaNames.ts | 5 ++++- .../codemod/test/v1-to-v2/authSchemaNames.test.ts | 7 +++++-- packages/core/src/index.ts | 10 +++++++--- packages/core/test/coreSchemas.test.ts | 12 ++++++++++-- 5 files changed, 35 insertions(+), 8 deletions(-) diff --git a/.changeset/oauth-tokens-null-optional-fields.md b/.changeset/oauth-tokens-null-optional-fields.md index 4ce5bf16bc..eec97247b0 100644 --- a/.changeset/oauth-tokens-null-optional-fields.md +++ b/.changeset/oauth-tokens-null-optional-fields.md @@ -3,6 +3,7 @@ '@modelcontextprotocol/core-internal': patch '@modelcontextprotocol/client': patch '@modelcontextprotocol/server-legacy': patch +'@modelcontextprotocol/codemod': patch --- OAuth token responses with null-valued optional members no longer fail @@ -25,3 +26,11 @@ 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. + +`OAuthTokenResponseSchema` is also a public export from +`@modelcontextprotocol/core`'s root, since the sibling v1 release exports it +from `@modelcontextprotocol/sdk/shared/auth.js` and migrating code needs a v2 +home for it. The v1-to-v2 codemod's auth schema allowlist now includes the +name, so `import { OAuthTokenResponseSchema } from +'@modelcontextprotocol/sdk/shared/auth.js'` rewrites to +`@modelcontextprotocol/core` alongside the other auth schema constants. diff --git a/packages/codemod/src/migrations/v1-to-v2/mappings/authSchemaNames.ts b/packages/codemod/src/migrations/v1-to-v2/mappings/authSchemaNames.ts index 89a9083b15..5120df17da 100644 --- a/packages/codemod/src/migrations/v1-to-v2/mappings/authSchemaNames.ts +++ b/packages/codemod/src/migrations/v1-to-v2/mappings/authSchemaNames.ts @@ -4,7 +4,9 @@ // set (the corresponding TYPES, e.g. OAuthTokens, resolve by context to @modelcontextprotocol/client | // /server). This is the v1 auth-schema set — a SUBSET of core's auth exports. v2-only auth schemas // (e.g. IdJagTokenExchangeResponseSchema) are exported by core but NOT listed here: v1 never had -// them, so there is nothing to migrate. test/v1-to-v2/authSchemaNames.test.ts asserts every name here is +// them, so there is nothing to migrate. OAuthTokenResponseSchema, the null-tolerant token-response +// wrapper, IS listed: v1 exports it from sdk/shared/auth.js and core exports it publicly, so its +// imports route to core like the rest. test/v1-to-v2/authSchemaNames.test.ts asserts every name here is // exported by core (so the rewritten import resolves). Keep alphabetized. export const AUTH_SCHEMA_NAMES: ReadonlySet = new Set([ 'OAuthClientInformationFullSchema', @@ -14,6 +16,7 @@ export const AUTH_SCHEMA_NAMES: ReadonlySet = new Set([ 'OAuthErrorResponseSchema', 'OAuthMetadataSchema', 'OAuthProtectedResourceMetadataSchema', + 'OAuthTokenResponseSchema', 'OAuthTokenRevocationRequestSchema', 'OAuthTokensSchema', 'OpenIdProviderDiscoveryMetadataSchema', diff --git a/packages/codemod/test/v1-to-v2/authSchemaNames.test.ts b/packages/codemod/test/v1-to-v2/authSchemaNames.test.ts index 97044c3b8c..d56140a2c7 100644 --- a/packages/codemod/test/v1-to-v2/authSchemaNames.test.ts +++ b/packages/codemod/test/v1-to-v2/authSchemaNames.test.ts @@ -21,8 +21,11 @@ describe('AUTH_SCHEMA_NAMES (codemod auth schema-routing allowlist)', () => { const notExportedByCore = [...AUTH_SCHEMA_NAMES].filter(name => !coreAuthExports.has(name)); expect(notExportedByCore).toEqual([]); - // The v1 auth-schema set is frozen; pin its size so an accidental add/remove is caught. - expect(AUTH_SCHEMA_NAMES.size).toBe(11); + // Pin the v1 auth-schema set's size so an accidental add/remove is caught. 12 = the 11 + // original v1 exports plus OAuthTokenResponseSchema (the null-tolerant token-response + // wrapper, public in v1 and exported from core's barrel). + expect(AUTH_SCHEMA_NAMES.size).toBe(12); + expect(AUTH_SCHEMA_NAMES.has('OAuthTokenResponseSchema')).toBe(true); }); it('keeps the no-v2-home auth schemas OUT of the routing allowlist', () => { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e8ddda2909..26c3677c41 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -184,11 +184,14 @@ export { // Auth schemas (OAuth / OpenID / IdJag) — kept as a SEPARATE group from the MCP spec schemas above, // mirroring the SDK's own spec-vs-auth split (these live in src/auth.ts, not src/schemas.ts, -// and are registered as `authSchemas` in core-internal's specTypeSchema.ts). This group is EXACTLY core-internal's +// and are registered as `authSchemas` in core-internal's specTypeSchema.ts). This group is core-internal's // `authSchemas` set — every auth schema that has a public spec type (so `isSpecType.OAuthTokens`, -// `isSpecType.IdJagTokenExchangeResponse`, etc. exist). The typeless internal URL field-validators +// `isSpecType.IdJagTokenExchangeResponse`, etc. exist). OAuthTokenResponseSchema is the one addition +// on top of that set: it introduces no spec type of its own (its output type is OAuthTokens) and +// stays out of `authSchemas`, but it is public v1 API and the v1-to-v2 codemod routes its imports +// here, so it needs this public home. The typeless internal URL field-validators // (SafeUrlSchema, OptionalSafeUrlSchema) are not auth schemas and stay out. The coreSchemas test -// asserts this group stays in sync with core-internal's `authSchemas`. +// asserts this group stays in sync with core-internal's `authSchemas` plus that one addition. export { IdJagTokenExchangeResponseSchema, OAuthClientInformationFullSchema, @@ -198,6 +201,7 @@ export { OAuthErrorResponseSchema, OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, + OAuthTokenResponseSchema, OAuthTokenRevocationRequestSchema, OAuthTokensSchema, OpenIdProviderDiscoveryMetadataSchema, diff --git a/packages/core/test/coreSchemas.test.ts b/packages/core/test/coreSchemas.test.ts index 3efcfdbd79..a0773fe38e 100644 --- a/packages/core/test/coreSchemas.test.ts +++ b/packages/core/test/coreSchemas.test.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import * as core from '../src/index'; -import { CursorSchema, InitializeRequestSchema, OAuthTokensSchema } from '../src/index'; +import { CursorSchema, InitializeRequestSchema, OAuthTokenResponseSchema, OAuthTokensSchema } from '../src/index'; function readCore(relativePath: string): string { return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8'); @@ -22,6 +22,11 @@ describe('@modelcontextprotocol/core', () => { expect(InitializeRequestSchema.safeParse({}).success).toBe(false); expect(OAuthTokensSchema.safeParse({}).success).toBe(false); expect(OAuthTokensSchema.safeParse({ access_token: 'tok', token_type: 'Bearer' }).success).toBe(true); + // The null-tolerant wrapper is real too: null optional members parse as absent. + expect(OAuthTokenResponseSchema.parse({ access_token: 'tok', token_type: 'Bearer', refresh_token: null })).toStrictEqual({ + access_token: 'tok', + token_type: 'Bearer' + }); }); it('re-exports exactly core’s spec + OAuth schemas — no internal helpers (drift guard)', () => { @@ -51,7 +56,10 @@ describe('@modelcontextprotocol/core', () => { const authObj = specTypeSrc.slice(authStart, specTypeSrc.indexOf('} as const', authStart)); const authSchemas = exportedSchemaConsts(authObj, /\b(\w+Schema)\b/g); - const expected = [...specSchemas, ...authSchemas].sort(); + // OAuthTokenResponseSchema is the one export on top of the two groups: it has no spec type + // of its own (its output type is OAuthTokens) so it is not in `authSchemas`, and it is + // public v1 API that the v1-to-v2 codemod routes here, so core's barrel must export it. + const expected = [...specSchemas, ...authSchemas, 'OAuthTokenResponseSchema'].sort(); const exported = Object.keys(core).sort(); // Exact match, both directions: a new core spec/auth schema missing here fails (we forgot to // re-export it), and any internal helper / non-spec symbol that leaks here also fails. From 3501e6264590916a225269c15c970fd98adb4917 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 06:43:21 +0000 Subject: [PATCH 09/13] docs: align wrapper wording with its public export; qualify null strictness OAuthTokenResponseSchema is a public core export as of the codemod mapping commit, so the wire-schemas guide no longer calls it internal and now points raw-wire consumers at it directly. Both the guide and the changeset also stop claiming the plain OAuthTokensSchema rejects nulls outright: it rejects null for its string-typed members, but expires_in: null coerces to 0 there (verified empirically against the branch schemas). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KXnNnp3fxQYR5HF9BUVhYP --- .changeset/oauth-tokens-null-optional-fields.md | 6 ++++-- docs/advanced/wire-schemas.md | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.changeset/oauth-tokens-null-optional-fields.md b/.changeset/oauth-tokens-null-optional-fields.md index eec97247b0..ceab7f31b4 100644 --- a/.changeset/oauth-tokens-null-optional-fields.md +++ b/.changeset/oauth-tokens-null-optional-fields.md @@ -18,8 +18,10 @@ proxy provider) now validate with a new `OAuthTokenResponseSchema` (defined in `core-internal`'s re-export shim) 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 +plain object schema, with its `.shape`/`.extend` and input types intact: it +rejects `null` for its string-typed optional members, though `expires_in: null` +still coerces to `0` there (use `OAuthTokenResponseSchema` for raw wire +input). `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. Note that a stripped null `scope` is thereafter indistinguishable from an omitted `scope` diff --git a/docs/advanced/wire-schemas.md b/docs/advanced/wire-schemas.md index 89c3173ad1..d59d31b4a3 100644 --- a/docs/advanced/wire-schemas.md +++ b/docs/advanced/wire-schemas.md @@ -4,7 +4,7 @@ shape: how-to # Wire schemas -`@modelcontextprotocol/core` exports the **wire schemas** — the exact Zod constants the SDK validates protocol and OAuth payloads against — for code that holds raw JSON instead of SDK objects. (One qualification: at the SDK's own token-response parse sites, `OAuthTokensSchema` is additionally applied through a null-tolerant preprocessing wrapper — see the note in the OAuth section below.) +`@modelcontextprotocol/core` exports the **wire schemas** — the exact Zod constants the SDK validates protocol and OAuth payloads against — for code that holds raw JSON instead of SDK objects. (One qualification: at the SDK's own token-response parse sites, `OAuthTokensSchema` is applied through the null-tolerant `OAuthTokenResponseSchema` wrapper — see the note in the OAuth section below.) ## Validate a wire payload @@ -129,7 +129,7 @@ A document missing a required endpoint fails the parse; a valid one comes back t https://auth.example.com/token ``` -The group follows the same naming convention: `OAuthTokensSchema` for token responses, `OAuthProtectedResourceMetadataSchema` for protected-resource metadata, `OpenIdProviderDiscoveryMetadataSchema` for OpenID provider discovery. Note that the SDK's own token-response parse sites validate with `OAuthTokenResponseSchema`, an internal null-tolerant wrapper around `OAuthTokensSchema` that drops null-valued optional members before validating; the exported `OAuthTokensSchema` itself remains strict and rejects nulls. +The group follows the same naming convention: `OAuthTokensSchema` for token responses, `OAuthProtectedResourceMetadataSchema` for protected-resource metadata, `OpenIdProviderDiscoveryMetadataSchema` for OpenID provider discovery. Note that the SDK's own token-response parse sites validate with `OAuthTokenResponseSchema` — also exported from `@modelcontextprotocol/core` — a null-tolerant wrapper around `OAuthTokensSchema` that drops null-valued optional members before validating. The plain `OAuthTokensSchema` rejects `null` for its string-typed members, but `expires_in: null` coerces to `0` there, so prefer `OAuthTokenResponseSchema` when validating raw wire input. ## Get the TypeScript types, guards and errors from the SDK packages From 8a715bf66a5f1963890ea2144868fba81c56fa5f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 07:12:22 +0000 Subject: [PATCH 10/13] fix(auth): preserve stored scope across refresh; fix inverted JSDoc analogy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 6749 sends no scope parameter on refresh, so a refresh response without a scope member (including one whose scope: null was stripped by OAuthTokenResponseSchema) asserts the grant is unchanged. The refresh branch previously saved the response as-is, erasing the stored scope; after a restart the insufficient_scope step-up would compute its union without the original grant and force interactive re-authorization. Mirror the refresh_token hardening: preserve the stored scope when the response has none, while a scope the server does return stays authoritative. Tests pin null, omitted, and server-narrowed scope on the auth() refresh path. Also reword the OAuthTokenResponseSchema JSDoc: it claimed the key removal mirrors ElicitResult's content null normalization, but that mechanic maps null to a present-but-undefined member — exactly what this schema rejects. The analogy now states the shared goal and the stronger mechanic. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KXnNnp3fxQYR5HF9BUVhYP --- packages/client/src/client/auth.ts | 7 +- packages/client/test/client/auth.test.ts | 144 +++++++++++++++++++++++ packages/core/src/auth.ts | 18 +-- 3 files changed, 160 insertions(+), 9 deletions(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 28c86448a0..1e2d330342 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -1314,7 +1314,12 @@ async function authInternal( fetchFn }); - await provider.saveTokens({ ...newTokens, issuer }, infoCtx); + // RFC 6749 §5.1/§6: the SDK sends no `scope` parameter on refresh, so a + // response without `scope` (including a null-stripped one) asserts the grant + // is unchanged — preserve the stored scope instead of erasing the recorded + // grant (the 403 insufficient_scope step-up unions it to avoid losing + // previously-granted permissions). + await provider.saveTokens({ ...newTokens, scope: newTokens.scope ?? tokens.scope, issuer }, infoCtx); return 'AUTHORIZED'; } catch (error) { // A non-TLS token endpoint is a configuration error — re-authorizing cannot diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 6487546740..c06dec316d 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -3088,6 +3088,150 @@ describe('OAuth Authorization', () => { expect(body.get('refresh_token')).toBe('refresh123'); }); + // RFC 6749 §5.1/§6: the SDK sends no `scope` parameter on refresh, so a response + // without `scope` (or with `scope: null`, stripped by OAuthTokenResponseSchema) + // asserts the grant is unchanged. The stored scope must survive the refresh — + // otherwise, after a restart, the insufficient_scope step-up would compute its + // scope union without the original grant and force interactive re-authorization. + const mockRefreshFetchWithTokenResponse = (tokenResponse: Record) => { + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://api.example.com/mcp-server', + authorization_servers: ['https://auth.example.com'] + }) + }); + } else if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }); + } else if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => tokenResponse + }); + } + + return Promise.resolve({ ok: false, status: 404 }); + }); + }; + + it('keeps the stored scope when the refresh response returns scope: null', async () => { + mockRefreshFetchWithTokenResponse({ + access_token: 'new-access123', + token_type: 'Bearer', + expires_in: 3600, + scope: null + }); + + (mockProvider.clientInformation as Mock).mockResolvedValue({ + client_id: 'test-client', + client_secret: 'test-secret' + }); + (mockProvider.tokens as Mock).mockResolvedValue({ + access_token: 'old-access', + refresh_token: 'refresh123', + scope: 'read write' + }); + (mockProvider.saveTokens as Mock).mockResolvedValue(undefined); + + const result = await auth(mockProvider, { + serverUrl: 'https://api.example.com/mcp-server' + }); + + expect(result).toBe('AUTHORIZED'); + expect(mockProvider.saveTokens).toHaveBeenCalledWith( + expect.objectContaining({ + access_token: 'new-access123', + refresh_token: 'refresh123', + scope: 'read write' + }), + expect.anything() + ); + }); + + it('keeps the stored scope when the refresh response omits scope', async () => { + mockRefreshFetchWithTokenResponse({ + access_token: 'new-access123', + token_type: 'Bearer', + expires_in: 3600 + }); + + (mockProvider.clientInformation as Mock).mockResolvedValue({ + client_id: 'test-client', + client_secret: 'test-secret' + }); + (mockProvider.tokens as Mock).mockResolvedValue({ + access_token: 'old-access', + refresh_token: 'refresh123', + scope: 'read write' + }); + (mockProvider.saveTokens as Mock).mockResolvedValue(undefined); + + const result = await auth(mockProvider, { + serverUrl: 'https://api.example.com/mcp-server' + }); + + expect(result).toBe('AUTHORIZED'); + expect(mockProvider.saveTokens).toHaveBeenCalledWith( + expect.objectContaining({ + access_token: 'new-access123', + refresh_token: 'refresh123', + scope: 'read write' + }), + expect.anything() + ); + }); + + it('stores the scope the refresh response grants when one is present', async () => { + mockRefreshFetchWithTokenResponse({ + access_token: 'new-access123', + token_type: 'Bearer', + expires_in: 3600, + scope: 'read' + }); + + (mockProvider.clientInformation as Mock).mockResolvedValue({ + client_id: 'test-client', + client_secret: 'test-secret' + }); + (mockProvider.tokens as Mock).mockResolvedValue({ + access_token: 'old-access', + refresh_token: 'refresh123', + scope: 'read write' + }); + (mockProvider.saveTokens as Mock).mockResolvedValue(undefined); + + const result = await auth(mockProvider, { + serverUrl: 'https://api.example.com/mcp-server' + }); + + expect(result).toBe('AUTHORIZED'); + // A scope the server does return is authoritative (a refresh may narrow the + // grant, RFC 6749 §6) — preservation only applies to an absent member. + expect(mockProvider.saveTokens).toHaveBeenCalledWith( + expect.objectContaining({ + access_token: 'new-access123', + scope: 'read' + }), + expect.anything() + ); + }); + it('skips default PRM resource validation when custom validateResourceURL is provided', async () => { const mockValidateResourceURL = vi.fn().mockResolvedValue(undefined); const providerWithCustomValidation = { diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index a9bd1e0d4d..f628f85bb5 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -148,14 +148,16 @@ export const OAuthTokensSchema = z * * 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. + * optional members are normalized to absent — the key is removed — 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. + * This shares the null-leniency goal of ElicitResult's `content` normalization + * but uses a stronger mechanic: ElicitResult maps `null` to a + * present-but-`undefined` member, whereas here the key must be strictly absent. * * 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 From 1142f7f36bb9c655ae0a9b26019c6510c92916cf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 07:37:17 +0000 Subject: [PATCH 11/13] fix(auth): keep scope strictly absent when neither refresh side has one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scope-preservation merge wrote scope: undefined into the saveTokens payload when both the refresh response and the stored tokens lacked a scope — the present-but-undefined key shape this PR's normalization exists to prevent. A conditional spread now includes the scope member only when a preserved value exists. New test pins the no-scope-anywhere refresh payload with toStrictEqual (asserting on the refresh save, not the SEP-2352 issuer back-stamp call). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KXnNnp3fxQYR5HF9BUVhYP --- packages/client/src/client/auth.ts | 6 ++-- packages/client/test/client/auth.test.ts | 38 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 1e2d330342..d6a765b274 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -1318,8 +1318,10 @@ async function authInternal( // response without `scope` (including a null-stripped one) asserts the grant // is unchanged — preserve the stored scope instead of erasing the recorded // grant (the 403 insufficient_scope step-up unions it to avoid losing - // previously-granted permissions). - await provider.saveTokens({ ...newTokens, scope: newTokens.scope ?? tokens.scope, issuer }, infoCtx); + // previously-granted permissions). The conditional spread keeps `scope` + // strictly absent when neither side has one — never present-but-undefined. + const preservedScope = newTokens.scope ?? tokens.scope; + await provider.saveTokens({ ...newTokens, ...(preservedScope !== undefined && { scope: preservedScope }), issuer }, infoCtx); return 'AUTHORIZED'; } catch (error) { // A non-TLS token endpoint is a configuration error — re-authorizing cannot diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index c06dec316d..544d398f34 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -3232,6 +3232,44 @@ describe('OAuth Authorization', () => { ); }); + it('saves a payload without a scope key when neither the response nor the stored tokens have one', async () => { + mockRefreshFetchWithTokenResponse({ + access_token: 'new-access123', + token_type: 'Bearer', + expires_in: 3600 + }); + + (mockProvider.clientInformation as Mock).mockResolvedValue({ + client_id: 'test-client', + client_secret: 'test-secret' + }); + (mockProvider.tokens as Mock).mockResolvedValue({ + access_token: 'old-access', + refresh_token: 'refresh123' + }); + (mockProvider.saveTokens as Mock).mockResolvedValue(undefined); + + const result = await auth(mockProvider, { + serverUrl: 'https://api.example.com/mcp-server' + }); + + expect(result).toBe('AUTHORIZED'); + // Scope preservation must not manufacture a present-but-undefined `scope` + // key — the exact key shape this PR's normalization exists to prevent. + // toStrictEqual distinguishes an absent key from one set to undefined. + // The last saveTokens call is the refresh save (the first is the + // SEP-2352 issuer back-stamp of the legacy unstamped token set). + const savedTokens = (mockProvider.saveTokens as Mock).mock.lastCall![0]; + expect('scope' in savedTokens).toBe(false); + expect(savedTokens).toStrictEqual({ + access_token: 'new-access123', + token_type: 'Bearer', + expires_in: 3600, + refresh_token: 'refresh123', + issuer: 'https://auth.example.com' + }); + }); + it('skips default PRM resource validation when custom validateResourceURL is provided', async () => { const mockValidateResourceURL = vi.fn().mockResolvedValue(undefined); const providerWithCustomValidation = { From 80adfcf4444ed10be468fe0552b161187b5f9bb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 07:55:19 +0000 Subject: [PATCH 12/13] chore: bump core as minor for the new public OAuthTokenResponseSchema export Additive public API on core's root barrel is a minor under semver, and core's changelog ships export-surface additions as minor (#2354, #2513). The changeset fixed group lifts core, client, server, server-legacy, and codemod together to 2.1.0. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KXnNnp3fxQYR5HF9BUVhYP --- .changeset/oauth-tokens-null-optional-fields.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/oauth-tokens-null-optional-fields.md b/.changeset/oauth-tokens-null-optional-fields.md index ceab7f31b4..cfcbf791f7 100644 --- a/.changeset/oauth-tokens-null-optional-fields.md +++ b/.changeset/oauth-tokens-null-optional-fields.md @@ -1,5 +1,5 @@ --- -'@modelcontextprotocol/core': patch +'@modelcontextprotocol/core': minor '@modelcontextprotocol/core-internal': patch '@modelcontextprotocol/client': patch '@modelcontextprotocol/server-legacy': patch From 0e4a6f1c072902397b7ccac05a97f8baac697b9f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 07:58:43 +0000 Subject: [PATCH 13/13] chore: keep core at patch pending a maintainer call on the bump level Flipping core to minor major-bumps express, fastify, hono, and node to 3.0.0: they peer-depend on server via workspace:^, server rides the fixed group to minor, and .changeset/config.json does not set onlyUpdatePeerDependentsWhenOutOfRange, so changesets' default major-bumps peer-dependents on any non-patch peer bump. The additive public export arguably warrants a minor per repo precedent, but that blast radius is a maintainer decision; the PR body records the options. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KXnNnp3fxQYR5HF9BUVhYP --- .changeset/oauth-tokens-null-optional-fields.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/oauth-tokens-null-optional-fields.md b/.changeset/oauth-tokens-null-optional-fields.md index cfcbf791f7..ceab7f31b4 100644 --- a/.changeset/oauth-tokens-null-optional-fields.md +++ b/.changeset/oauth-tokens-null-optional-fields.md @@ -1,5 +1,5 @@ --- -'@modelcontextprotocol/core': minor +'@modelcontextprotocol/core': patch '@modelcontextprotocol/core-internal': patch '@modelcontextprotocol/client': patch '@modelcontextprotocol/server-legacy': patch