diff --git a/.changeset/refresh-token-scope.md b/.changeset/refresh-token-scope.md new file mode 100644 index 0000000000..51b604f954 --- /dev/null +++ b/.changeset/refresh-token-scope.md @@ -0,0 +1,13 @@ +--- +'@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,