From 51079359999fa2c840ccd60fb267ea0b995dfe02 Mon Sep 17 00:00:00 2001 From: Greg Hayes <881593+thegodshatetexas@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:41:52 -0500 Subject: [PATCH 1/2] fix(client): send the scope parameter on refresh-token requests refreshAuthorization() accepts a new optional `scope` and sets it on the request body when it is a non-empty string. The auth() flow sends exactly the granted scope recorded on the stored tokens. When no granted scope is recorded, no scope parameter is sent, so the wire shape is unchanged by default. Omitting scope on a refresh grant is legal per RFC 6749 section 6, but some authorization servers require it to resolve the target resource. Microsoft Entra ID rejects a scope-less refresh with AADSTS90009 whenever the OAuth client application is also the resource, a common setup for Entra-protected MCP servers. Initial authorization succeeds because the scope travels on the authorize request, so affected clients work until the first access-token expiry and then wedge. Backport of the v2 fix for #2718. --- .changeset/refresh-token-scope.md | 18 +++++++++ src/client/auth.ts | 34 +++++++++++++++- test/client/auth.test.ts | 66 +++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 .changeset/refresh-token-scope.md diff --git a/.changeset/refresh-token-scope.md b/.changeset/refresh-token-scope.md new file mode 100644 index 0000000000..0bed1580eb --- /dev/null +++ b/.changeset/refresh-token-scope.md @@ -0,0 +1,18 @@ +--- +'@modelcontextprotocol/sdk': patch +--- + +Send the `scope` parameter on refresh-token requests. + +`refreshAuthorization()` accepts a new optional `scope` and sets it on the request body when it is +a non-empty string. The `auth()` flow sends exactly the granted scope recorded on the stored +tokens. When no granted scope is recorded, no `scope` parameter is sent, so the wire shape is +unchanged by default. + +Omitting `scope` on a refresh grant is legal per RFC 6749 section 6, but some authorization servers +require it to resolve the target resource. Microsoft Entra ID rejects a scope-less refresh with +`AADSTS90009` whenever the OAuth client application is also the resource, a common setup for +Entra-protected MCP servers. Initial authorization succeeds because the scope travels on the +authorize request, so affected clients work until the first access-token expiry and then wedge. + +Backport of the v2 fix for #2718. diff --git a/src/client/auth.ts b/src/client/auth.ts index dd3976f712..826c92dd1c 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -672,6 +672,12 @@ async function authInternal( metadata, clientInformation, refreshToken: tokens.refresh_token, + // Exactly the granted scope recorded on the stored tokens (RFC 6749 §5.1), + // normalized so an empty string means "none". Deliberately NOT a recomputed + // selectResourceScopes()/scope value: that may have widened since the grant, and + // a strict server would reject the refresh with `invalid_scope` where today's + // scope-less request succeeds. + scope: tokens.scope || undefined, resource, addClientAuthentication: provider.addClientAuthentication, fetchFn @@ -1430,6 +1436,7 @@ export async function refreshAuthorization( metadata, clientInformation, refreshToken, + scope, resource, addClientAuthentication, fetchFn @@ -1437,6 +1444,16 @@ export async function refreshAuthorization( metadata?: AuthorizationServerMetadata; clientInformation: OAuthClientInformationMixed; refreshToken: string; + /** + * Scope to send on the refresh request. Per RFC 6749 §6 this MUST NOT exceed the + * originally granted scope; omitting it means "the originally granted scope". An absent + * or empty value sends no `scope` parameter, so the wire shape is unchanged by default. + * + * Some authorization servers require it to resolve the target resource. Microsoft Entra + * ID rejects a scope-less refresh with `AADSTS90009` whenever the OAuth client + * application is also the resource. + */ + scope?: string; resource?: string | URL; addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; fetchFn?: FetchLike; @@ -1447,6 +1464,12 @@ export async function refreshAuthorization( refresh_token: refreshToken }); + // RFC 6749 §3.3: a literal empty `scope=` is syntactically invalid, so only send a + // non-empty value. Servers that return `"scope": ""` therefore keep today's behavior. + if (scope) { + tokenRequestParams.set('scope', scope); + } + const tokens = await executeTokenRequest(authorizationServerUrl, { metadata, tokenRequestParams, @@ -1456,8 +1479,15 @@ export async function refreshAuthorization( fetchFn }); - // Preserve original refresh token if server didn't return a new one - return { refresh_token: refreshToken, ...tokens }; + // Preserve original refresh token if server didn't return a new one. + // Preserve the granted scope the same way: RFC 6749 §5.1 lets the server omit `scope` when + // it is identical to the requested scope, and dropping it here would strand the *next* + // refresh with no scope to send. A scope returned by the server always wins. + return { + refresh_token: refreshToken, + ...tokens, + ...(tokens.scope === undefined && scope ? { scope } : {}) + }; } /** diff --git a/test/client/auth.test.ts b/test/client/auth.test.ts index 45ec2806d0..357c86fc99 100644 --- a/test/client/auth.test.ts +++ b/test/client/auth.test.ts @@ -1789,6 +1789,72 @@ describe('OAuth Authorization', () => { expect(body.get('resource')).toBe('https://api.example.com/mcp-server'); }); + it('sends the scope parameter on the refresh request when provided', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => validTokens }); + + await refreshAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + refreshToken: 'refresh123', + scope: 'api://server/mcp.access offline_access' + }); + + const body = mockFetch.mock.calls[0][1].body as URLSearchParams; + expect(body.get('scope')).toBe('api://server/mcp.access offline_access'); + }); + + it('sends no scope parameter when scope is omitted', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => validTokens }); + + await refreshAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + refreshToken: 'refresh123' + }); + + const body = mockFetch.mock.calls[0][1].body as URLSearchParams; + expect(body.get('scope')).toBeNull(); + }); + + it('sends no scope parameter when scope is an empty string (RFC 6749 section 3.3)', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => validTokens }); + + await refreshAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + refreshToken: 'refresh123', + scope: '' + }); + + const body = mockFetch.mock.calls[0][1].body as URLSearchParams; + expect(body.get('scope')).toBeNull(); + }); + + it('preserves the granted scope when the response omits it', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => validTokens }); + + const tokens = await refreshAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + refreshToken: 'refresh123', + scope: 'api://server/mcp.access' + }); + + expect(tokens.scope).toBe('api://server/mcp.access'); + }); + + it('lets a scope returned by the server win over the requested scope', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ ...validTokens, scope: 'api://server/mcp.read' }) + }); + + const tokens = await refreshAuthorization('https://auth.example.com', { + clientInformation: validClientInfo, + refreshToken: 'refresh123', + scope: 'api://server/mcp.access' + }); + + expect(tokens.scope).toBe('api://server/mcp.read'); + }); + it('exchanges refresh token for new tokens with auth', async () => { mockFetch.mockResolvedValueOnce({ ok: true, From a40eb50359d2ae1350eacccce6846e2c0d9244c5 Mon Sep 17 00:00:00 2001 From: Greg Hayes <881593+thegodshatetexas@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:50:15 -0500 Subject: [PATCH 2/2] chore: format the changeset with prettier --- .changeset/refresh-token-scope.md | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/.changeset/refresh-token-scope.md b/.changeset/refresh-token-scope.md index 0bed1580eb..51b604f954 100644 --- a/.changeset/refresh-token-scope.md +++ b/.changeset/refresh-token-scope.md @@ -4,15 +4,10 @@ Send the `scope` parameter on refresh-token requests. -`refreshAuthorization()` accepts a new optional `scope` and sets it on the request body when it is -a non-empty string. The `auth()` flow sends exactly the granted scope recorded on the stored -tokens. When no granted scope is recorded, no `scope` parameter is sent, so the wire shape is -unchanged by default. +`refreshAuthorization()` accepts a new optional `scope` and sets it on the request body when it is a non-empty string. The `auth()` flow sends exactly the granted scope recorded on the stored tokens. When no granted scope is recorded, no `scope` parameter is sent, so the wire +shape is unchanged by default. -Omitting `scope` on a refresh grant is legal per RFC 6749 section 6, but some authorization servers -require it to resolve the target resource. Microsoft Entra ID rejects a scope-less refresh with -`AADSTS90009` whenever the OAuth client application is also the resource, a common setup for -Entra-protected MCP servers. Initial authorization succeeds because the scope travels on the -authorize request, so affected clients work until the first access-token expiry and then wedge. +Omitting `scope` on a refresh grant is legal per RFC 6749 section 6, but some authorization servers require it to resolve the target resource. Microsoft Entra ID rejects a scope-less refresh with `AADSTS90009` whenever the OAuth client application is also the resource, a common +setup for Entra-protected MCP servers. Initial authorization succeeds because the scope travels on the authorize request, so affected clients work until the first access-token expiry and then wedge. Backport of the v2 fix for #2718.