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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/refresh-token-scope.md
Original file line number Diff line number Diff line change
@@ -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.
34 changes: 32 additions & 2 deletions src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1430,13 +1436,24 @@ export async function refreshAuthorization(
metadata,
clientInformation,
refreshToken,
scope,
resource,
addClientAuthentication,
fetchFn
}: {
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;
Expand All @@ -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,
Expand All @@ -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 } : {})
};
}

/**
Expand Down
66 changes: 66 additions & 0 deletions test/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading