Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/refresh-scope-opt-in.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/client': minor
---

Add an opt-in for sending `scope` on refresh-token requests, for authorization servers that reject a refresh without it (Microsoft Entra ID answers `AADSTS90009` when the client and the resource are the same app registration, #2718). `OAuthClientProvider` gains an optional `scopeForRefresh(tokens)`: `auth()`, and so the transports and `withOAuth`, sends the string it returns. `refreshAuthorization()` gains an optional `scope`. When a scope is sent and the response has none, the sent scope is kept on the returned tokens. Without either option the refresh request and the saved tokens are unchanged.
19 changes: 19 additions & 0 deletions docs/clients/oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,24 @@ class PinnedResourceProvider extends MyOAuthProvider {

`PinnedResourceProvider` sends the server's own URL as `resource` on every leg of the flow and refuses metadata that names a different one. `checkResourceAllowed` and `resourceUrlFromServerUrl` are exported for exactly this override.

## Send scope on refresh

A refresh request carries no `scope` by default, which asks for the scope already granted (RFC 6749 §6). Some authorization servers reject that — Microsoft Entra ID answers `AADSTS90009` when the client and the resource are the same app registration. Implement `scopeForRefresh` to send one.

```ts source="../../examples/guides/clients/oauth.examples.ts#scopeForRefresh_granted"
class ScopeOnRefreshProvider extends MyOAuthProvider {
scopeForRefresh(tokens: OAuthTokens) {
return tokens.scope; // the scope saved with these tokens
}
}
```

The SDK sends the returned string on every refresh it makes for this provider, and saves it with the new tokens when the refresh response has no `scope`; returning `undefined` sends none. If the sign-in response had no `scope`, `tokens.scope` is `undefined`: return the scope that was requested instead, which is the `scope` parameter of the URL passed to `redirectToAuthorization`. `refreshAuthorization()` takes the same value as its `scope` option.

::: warning
Implement it only for an authorization server that needs it. Another server may reject a refresh that carries `scope`, or fail it once a granted scope is retired: a refresh that works without `scope` then ends in an `OAuthError` or a new sign-in. The `OAuthError` repeats on every request until you clear the stored tokens.
:::

## Recap

- This page signs in an end user; machine-to-machine flows live on [Authenticate without a user](./machine-auth.md).
Expand All @@ -169,3 +187,4 @@ class PinnedResourceProvider extends MyOAuthProvider {
- Reconnect on a fresh transport; OAuth state lives on the provider, not the transport.
- `IssuerMismatchError` is the mix-up defense — never render the callback's `error_description`.
- `validateResourceURL` overrides the RFC 8707 `resource` parameter the SDK sends.
- `scopeForRefresh` adds a `scope` to refresh requests, for authorization servers that reject a refresh without one.
12 changes: 12 additions & 0 deletions examples/guides/clients/oauth.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,3 +179,15 @@ class PinnedResourceProvider extends MyOAuthProvider {
}
}
//#endregion validateResourceURL_pin

// ---------------------------------------------------------------------------
// "Send scope on refresh"
// ---------------------------------------------------------------------------

//#region scopeForRefresh_granted
class ScopeOnRefreshProvider extends MyOAuthProvider {
scopeForRefresh(tokens: OAuthTokens) {
return tokens.scope; // the scope saved with these tokens
}
}
//#endregion scopeForRefresh_granted
28 changes: 26 additions & 2 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,19 @@ export interface OAuthClientProvider {
*/
validateResourceURL?(serverUrl: string | URL, resource?: string): Promise<URL | undefined>;

/**
* Returns the `scope` that {@linkcode auth} sends on a refresh-token request. Left undefined
* (the default), or when it returns `undefined` or `''`, the request carries no `scope`, which
* asks for the scope already granted (RFC 6749 §6).
*
* Implement it only for an authorization server that rejects a refresh without `scope`:
* other servers may reject or narrow a refresh that carries one. The returned value must
* not include a scope that was not originally granted.
*
* @param tokens - The stored tokens being refreshed; `tokens.scope` is the scope last saved with them.
*/
scopeForRefresh?(tokens: StoredOAuthTokens): string | undefined | Promise<string | undefined>;

/**
* If implemented, provides a way for the client to invalidate (e.g. delete) the specified
* credentials, in the case where the server has indicated that they are no longer valid.
Expand Down Expand Up @@ -1385,13 +1398,15 @@ async function authInternal(
// current token's granted scope — refreshing would not widen it (RFC 6749
// §6), so skip straight to a fresh authorization request.
if (tokens?.refresh_token && !forceReauthorization) {
const refreshScope = await provider.scopeForRefresh?.(tokens);
let newTokens: OAuthTokens | undefined;
try {
// Attempt to refresh the token
newTokens = await refreshAuthorization(authorizationServerUrl, {
metadata,
clientInformation,
refreshToken: tokens.refresh_token,
scope: refreshScope,
Comment thread
felixweinberger marked this conversation as resolved.
resource,
addClientAuthentication: provider.addClientAuthentication,
dpop: await provider.dpop?.(),
Expand Down Expand Up @@ -2353,6 +2368,7 @@ export async function refreshAuthorization(
metadata,
clientInformation,
refreshToken,
scope,
resource,
addClientAuthentication,
dpop,
Expand All @@ -2361,6 +2377,11 @@ export async function refreshAuthorization(
metadata?: AuthorizationServerMetadata;
clientInformation: OAuthClientInformationMixed;
refreshToken: string;
/**
* `scope` to send on the request; omitted or `''` sends none, which asks for the scope
* already granted (RFC 6749 §6). When sent, it is also the result's `scope` if the response has none.
*/
scope?: string;
resource?: string | URL;
addClientAuthentication?: OAuthClientProvider['addClientAuthentication'];
/** SEP-1932 / RFC 9449: see {@linkcode executeTokenRequest}'s `dpop` option. */
Expand All @@ -2372,6 +2393,9 @@ export async function refreshAuthorization(
grant_type: 'refresh_token',
refresh_token: refreshToken
});
if (scope) {
tokenRequestParams.set('scope', scope);
}

const tokens = await executeTokenRequest(authorizationServerUrl, {
metadata,
Expand All @@ -2383,8 +2407,8 @@ export async function refreshAuthorization(
fetchFn
});

// Preserve original refresh token if server didn't return a new one
return { refresh_token: refreshToken, ...tokens };
// Preserve the original refresh token, and the scope sent (RFC 6749 §5.1), if the server didn't return new ones
return { refresh_token: refreshToken, ...tokens, ...(scope && !tokens.scope && { scope }) };
}

/**
Expand Down
112 changes: 112 additions & 0 deletions packages/client/test/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2278,6 +2278,59 @@ describe('OAuth Authorization', () => {
expect(tokens).toEqual({ refresh_token: refreshToken, ...validTokens });
});

it.each([undefined, ''])('sends no scope parameter when scope is %j', async scope => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => validTokens });

const tokens = await refreshAuthorization('https://auth.example.com', {
clientInformation: { client_id: 'client123' },
refreshToken: 'refresh123',
scope,
resource: 'https://api.example.com/mcp-server'
});

expect(String(mockFetch.mock.calls[0]![1].body)).toBe(
'grant_type=refresh_token&refresh_token=refresh123&resource=https%3A%2F%2Fapi.example.com%2Fmcp-server&client_id=client123'
);
expect(tokens).toEqual({ refresh_token: 'refresh123', ...validTokens });
});

it('sends the given scope, and returns it when the response has none', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => validTokens });

const tokens = await refreshAuthorization('https://auth.example.com', {
clientInformation: validClientInfo,
refreshToken: 'refresh123',
scope: 'read write'
});

expect((mockFetch.mock.calls[0]![1].body as URLSearchParams).get('scope')).toBe('read write');
expect(tokens.scope).toBe('read write');
});

it('returns the scope from the response over the one sent', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ ...validTokens, scope: 'read' }) });

const tokens = await refreshAuthorization('https://auth.example.com', {
clientInformation: validClientInfo,
refreshToken: 'refresh123',
scope: 'read write'
});

expect(tokens.scope).toBe('read');
});

it('returns the scope sent when the response scope is empty', async () => {
mockFetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ ...validTokens, scope: '' }) });

const tokens = await refreshAuthorization('https://auth.example.com', {
clientInformation: validClientInfo,
refreshToken: 'refresh123',
scope: 'read write'
});

expect(tokens.scope).toBe('read write');
});

it('validates token response schema', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
Expand Down Expand Up @@ -3103,6 +3156,65 @@ describe('OAuth Authorization', () => {

const storedTokens = { access_token: 'old-access', refresh_token: 'refresh123', issuer: 'https://auth.example.com' };

const refreshedWithoutScope = () => ({
ok: true,
status: 200,
json: async () => ({ access_token: 'new-access', token_type: 'Bearer' })
});
const refreshRequestBody = () => String(mockFetch.mock.calls.find(call => call[0].toString().includes('/token'))![1].body);

it('sends no scope on refresh by default, even when the stored tokens record one', async () => {
mockDiscoveryWithTokenEndpoint(refreshedWithoutScope);
(mockProvider.clientInformation as Mock).mockResolvedValue({ client_id: 'test-client' });
(mockProvider.tokens as Mock).mockResolvedValue({ ...storedTokens, scope: 'read write' });
(mockProvider.saveTokens as Mock).mockResolvedValue(undefined);

await expect(auth(mockProvider, { serverUrl: 'https://api.example.com/mcp-server', scope: 'read write' })).resolves.toBe(
'AUTHORIZED'
);

expect(refreshRequestBody()).toBe(
'grant_type=refresh_token&refresh_token=refresh123&resource=https%3A%2F%2Fapi.example.com%2Fmcp-server&client_id=test-client'
);
expect(mockProvider.saveTokens).toHaveBeenCalledWith(
{ access_token: 'new-access', token_type: 'Bearer', refresh_token: 'refresh123', issuer: 'https://auth.example.com' },
expect.anything()
);
});

it('sends what scopeForRefresh() returns on refresh, and saves it when the response has no scope', async () => {
mockDiscoveryWithTokenEndpoint(refreshedWithoutScope);
const stored = { ...storedTokens, scope: 'read write' };
const scopeForRefresh = vi.fn(async (tokens: StoredOAuthTokens) => tokens.scope);
const provider: OAuthClientProvider = { ...mockProvider, scopeForRefresh };
(mockProvider.clientInformation as Mock).mockResolvedValue({ client_id: 'test-client' });
(mockProvider.tokens as Mock).mockResolvedValue(stored);
(mockProvider.saveTokens as Mock).mockResolvedValue(undefined);

await expect(auth(provider, { serverUrl: 'https://api.example.com/mcp-server' })).resolves.toBe('AUTHORIZED');

expect(scopeForRefresh).toHaveBeenCalledExactlyOnceWith(stored);
expect(new URLSearchParams(refreshRequestBody()).get('scope')).toBe('read write');
expect(mockProvider.saveTokens).toHaveBeenCalledWith(expect.objectContaining({ scope: 'read write' }), expect.anything());
});

it('rejects with the error scopeForRefresh() throws, and starts no new authorization', async () => {
mockDiscoveryWithTokenEndpoint(refreshedWithoutScope);
const provider: OAuthClientProvider = {
...mockProvider,
scopeForRefresh: () => {
throw new Error('boom');
}
};
(mockProvider.clientInformation as Mock).mockResolvedValue({ client_id: 'test-client' });
(mockProvider.tokens as Mock).mockResolvedValue(storedTokens);

await expect(auth(provider, { serverUrl: 'https://api.example.com/mcp-server' })).rejects.toThrow('boom');

expect(mockProvider.redirectToAuthorization).not.toHaveBeenCalled();
expect(mockProvider.saveTokens).not.toHaveBeenCalled();
});

it('propagates saveTokens errors after a successful refresh (#2034)', async () => {
// Regression test: previously the catch block that wraps
// refreshAuthorization() also wrapped saveTokens(), silently
Expand Down
14 changes: 14 additions & 0 deletions test/e2e/requirements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2249,6 +2249,20 @@ export const REQUIREMENTS: Record<string, Requirement> = {
transports: ['streamableHttp'],
note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.'
},
'client-auth:refresh:no-scope-by-default': {
source: 'sdk',
behavior:
'Unless the provider implements scopeForRefresh(), a refresh request carries grant_type, refresh_token, the resource indicator and client authentication only: no scope parameter, even when the stored tokens record a scope.',
transports: ['streamableHttp'],
note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.'
},
'client-auth:refresh:scope-opt-in': {
Comment on lines +2252 to +2259

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): Maintainers of the shared e2e manifest get two unprefixed requirement ids whose wording names a TypeScript-only provider hook. client-auth:refresh:no-scope-by-default and client-auth:refresh:scope-opt-in both describe scopeForRefresh(), so the wording cannot be shared with the Python suite. Fix: give ids whose behaviour is defined by this SDK's own API the typescript: prefix, in the manifest and in the two verifies() calls in test/e2e/scenarios/client-auth.test.ts, or reword them API-neutrally; this covers the 2 sites listed. Same instruction at 2 sites (test/e2e/requirements.ts:2244, test/e2e/requirements.ts:2251). [also at: test/e2e/requirements.ts:2251 - nit: test/e2e/CLAUDE.md puts requirements that describe this SDK's own API surface under a typescript: slug and keeps unprefixed slugs for ids and wording shared with the Python interaction suite.]

Why this was flagged

No runtime trigger: this is a manifest naming convention. test/e2e/requirements.ts:2244 and :2251 add unprefixed ids with source: 'sdk' whose behavior text is written in terms of scopeForRefresh() / scopeForRefresh(tokens), a hook that exists only on this SDK's OAuthClientProvider (packages/client/src/client/auth.ts:400). test/e2e/CLAUDE.md:95 reserves unprefixed slugs for ids and wording shared with the Python interaction suite and puts requirements that describe this SDK's own API surface under typescript:. The closest existing entry, a provider hook requirement at test/e2e/requirements.ts:2204, is typescript:client-auth:state:verify. The base branch has neither id, so the mismatch is new. Precedent is mixed (client-auth:middleware:with-oauth at :2223 is unprefixed and names withOAuth), and no gate catches it, so the cost is a cross-SDK id that the Python suite cannot adopt verbatim.

Verification: Triggering condition: none at runtime; it is a manifest naming/taxonomy break that exists as soon as the two entries merge. The diff adds two unprefixed ids whose behavior text is defined by a TypeScript provider hook, at test/e2e/requirements.ts:2244-2247 and :2251-2254. Nothing in coverage.test.ts gates the prefix, so nothing fails.

Finding ID: f-1317a947

source: 'sdk',
behavior:
'A provider that implements scopeForRefresh(tokens) has the returned scope sent on every refresh request, and that scope is saved with the new tokens when the refresh response has none, so a client stays signed in across expiries at an authorization server that rejects a refresh without scope.',
transports: ['streamableHttp'],
note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.'
},
'client-auth:as-metadata-discovery:issuer-validation': {
source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#authorization-server-metadata-discovery',
behavior:
Expand Down
89 changes: 89 additions & 0 deletions test/e2e/scenarios/client-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1485,6 +1485,95 @@ verifies('client-auth:refresh:transparent', async (_args: TestArgs) => {
}
});

/** An authorization server that rejects a refresh without `scope`, and an MCP host whose access tokens can be expired. */

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): CLAUDE.md principle 8 keeps JSDoc to the public API. The added /** An authorization server that rejects a refresh without scope, ... */ block documents createScopeRequiringWorld(), a file-private test helper. Fix: use a one-line // comment (or none) on helpers that are not part of the published API; the JSDoc added in packages/client/src/client/auth.ts on scopeForRefresh and the scope option is on public API and is fine.

Why this was flagged

Nothing fails at runtime. The instruction keeps doc-comment blocks reserved for the published surface so that JSDoc signals 'public contract' and internal code carries only brief inline notes. This is a single one-line /** */ on a test-local function, so the practical cost is negligible; it is a wording/layout matter only. The same file already has JSDoc on other private helpers (lines 807, 2863, 2925), so this follows local precedent.

Verification: Instruction: root CLAUDE.md at base 9adb8d5, principle 8, verbatim as quoted: "Brief comments — Keep inline comments to one line and JSDoc to the public API; the reasoning goes in the commit message or PR description." It is a root-level file, so it binds test/e2e/scenarios/client-auth.test.ts.

Finding ID: f-01c04ab5

function createScopeRequiringWorld() {
const as = createMockAuthorizationServer();
const mcpHost = hostPerSession(() => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('probe', { inputSchema: z.object({}) }, () => ({ content: [{ type: 'text', text: 'ok' }] }));
return s;
});
const refreshBodies: string[] = [];
const liveTokens = new Set(['access-0']);
const fetch = async (url: URL | string, init?: RequestInit): Promise<Response> => {
const urlObj = new URL(url);
if (urlObj.href === `${ISSUER}/token`) {
const body = String(init?.body);
refreshBodies.push(body);
if (!new URLSearchParams(body).has('scope')) {
return Response.json({ error: 'invalid_request', error_description: 'scope is required' }, { status: 400 });
}
const accessToken = `access-${refreshBodies.length}`;
liveTokens.add(accessToken);
// RFC 6749 §5.1 and §6: neither `scope` nor `refresh_token` has to be repeated.
return Response.json({ access_token: accessToken, token_type: 'Bearer' });
}
if (urlObj.origin === ISSUER || urlObj.pathname.includes('/.well-known/')) {
return as.handleRequest(new Request(url, init));
}
if (!liveTokens.has(new Headers(init?.headers).get('authorization')?.replace('Bearer ', '') ?? '')) {
return new Response(null, { status: 401 });
}
return mcpHost.handleRequest(new Request(url, init));
};
const signedIn = {
tokens: { access_token: 'access-0', token_type: 'Bearer', refresh_token: 'rt', scope: 'mcp:read mcp:write' },
clientInformation: { client_id: 'refresh-client' }
};
return { fetch, mcpHost, refreshBodies, signedIn, expireAccessTokens: () => liveTokens.clear() };
}

verifies('client-auth:refresh:no-scope-by-default', async (_args: TestArgs) => {
const world = createScopeRequiringWorld();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): test/e2e/CLAUDE.md asks each verifies() body to be self-contained (build server inline, build client inline, assert). The two new scenarios take their MCP host, mock authorization server, fetch routing and stored tokens from a new module-level createScopeRequiringWorld() helper, so neither body shows the server it runs against. Fix: build the host and the scope-requiring token endpoint inside each verifies() body (as client-auth:refresh:transparent just above does), which covers the 2 sites listed. Same instruction at 2 sites (test/e2e/scenarios/client-auth.test.ts:1527, test/e2e/scenarios/client-auth.test.ts:1553).

Why this was flagged

Nothing fails at runtime. The instruction guards readability and independence of each scenario: a reader of one verifies() body should see the server, client and assertion without chasing a shared fixture, and a change made for one scenario should not silently alter another. Here the rule that the AS rejects a refresh without scope, the omission of scope/refresh_token from the response, and the token-expiry mechanism all live in the 37-line helper at lines 1489-1524, so the expected invalid_request in the first body and the 'two expiries' logic in the second are only understandable by reading the helper. Size is small: the helper is in the same file (not a shared fixture file, which the same line also forbids), it is used by exactly two adjacent bodies, and this file already has module-level helpers of the same kind (createAuthenticatedHost, createCombinedFetch, runFinishAuthScenario, createMigratingAuthorizationServer), so the author is following local precedent.

Verification: Instruction: test/e2e/CLAUDE.md at base 9adb8d5, section "Writing a test": "Self-contained: build server inline (factory), build client inline, wire(), assert. No shared fixture files." It is present as quoted and binds test/e2e/scenarios/client-auth.test.ts. Code: the added bodies at test/e2e/scenarios/client-auth.test.ts:1527 (const world = createScopeRequiringWorld(); in client-auth:refresh:no-scope-by-default) and :1554 (same call in client-auth:refresh:scope-opt-in) do not build the server inline.

Finding ID: f-25bff81a

const provider = new RecordingOAuthClientProvider(world.signedIn);
const client = new Client({ name: 'c', version: '0' });

try {
await client.connect(new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: world.fetch }));
world.expireAccessTokens();

await expect(client.callTool({ name: 'probe', arguments: {} })).rejects.toMatchObject({ code: OAuthErrorCode.InvalidRequest });

// A Set: the standalone GET stream may meet the same 401 and refresh too.
expect(new Set(world.refreshBodies)).toEqual(
new Set(['grant_type=refresh_token&refresh_token=rt&resource=http%3A%2F%2Fin-process%2Fmcp&client_id=refresh-client'])
);
} finally {
await client.close();
await world.mcpHost.close();
}
});

verifies('client-auth:refresh:scope-opt-in', async (_args: TestArgs) => {
class ScopeOnRefreshProvider extends RecordingOAuthClientProvider {
scopeForRefresh(tokens: StoredOAuthTokens) {
return tokens.scope;
}
}
const world = createScopeRequiringWorld();
const provider = new ScopeOnRefreshProvider(world.signedIn);
const client = new Client({ name: 'c', version: '0' });

try {
await client.connect(new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: world.fetch }));

// Two expiries: the second refresh depends on the scope kept from the first, whose response had none.
for (let expiry = 1; expiry <= 2; expiry++) {
world.expireAccessTokens();
const result = await client.callTool({ name: 'probe', arguments: {} });
expect(result.content).toEqual([{ type: 'text', text: 'ok' }]);
expect(provider.saved.tokens).toMatchObject({ refresh_token: 'rt', scope: 'mcp:read mcp:write' });
expect(world.refreshBodies.length).toBeGreaterThanOrEqual(expiry);
}

expect(new Set(world.refreshBodies.map(body => new URLSearchParams(body).get('scope')))).toEqual(new Set(['mcp:read mcp:write']));
expect(provider.redirectedTo).toHaveLength(0);
} finally {
await client.close();
await world.mcpHost.close();
}
});

verifies('client-auth:resource-parameter', async (_args: TestArgs) => {
const as = createMockAuthorizationServer({ tokenResponses: [{ access_token: 'resource-param-token', token_type: 'Bearer' }] });
const provider = new RecordingOAuthClientProvider({ clientInformation: { client_id: 'resource-test-client' } });
Expand Down
Loading