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
5 changes: 5 additions & 0 deletions .changeset/sep-2350-scope-accumulation-followup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/client': patch
---

Add `OAuthClientProvider.includePreviouslyGrantedScopes`: when `true`, a new authorization started because a refresh was refused also requests the scope the dropped tokens held, so permissions granted earlier through step-up survive. See [Keep earlier scopes when a refresh is refused](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/clients/oauth.md#keep-earlier-scopes-when-a-refresh-is-refused).
14 changes: 14 additions & 0 deletions docs/clients/oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
shape: how-to
description: 'Sign an end user in from a client you build with the OAuth authorization-code flow.'
---

# Authenticate a user with OAuth

Protecting a server you run → [Require authorization](../serving/authorization.md). Signing a user in from a client → this page. No user present → [Authenticate without a user](./machine-auth.md).
Expand Down Expand Up @@ -183,6 +184,18 @@ The SDK sends the returned string on every refresh it makes for this provider, a
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.
:::

## Keep earlier scopes when a refresh is refused

When a refresh is refused (`invalid_grant` or `invalid_dpop_proof`), the SDK drops the stored tokens and sends the user to sign in again for the scope it would request on a first sign-in. Permissions granted earlier through step-up are not part of that, so the user steps up again for them. Set `includePreviouslyGrantedScopes` to request them in that sign-in too, as the MCP specification recommends.

```ts source="../../examples/guides/clients/oauth.examples.ts#includePreviouslyGrantedScopes"
class KeepGrantedScopesProvider extends MyOAuthProvider {
includePreviouslyGrantedScopes = true;
}
```

The SDK reads the scope saved with the dropped tokens and leaves out `offline_access` and any value the authorization server does not list in `scopes_supported`. It requests that scope once: if the server refuses it, the next sign-in requests the usual scope alone. Nothing is added when the sign-in response had no `scope`, when there is no refresh token, or when no scope would be requested at all.
Comment on lines +189 to +197

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 (optional) Developers who copy the new example get an option that never takes effect: a refused refresh throws OAuthError instead of a sign-in that keeps earlier scopes. KeepGrantedScopesProvider extends MyOAuthProvider, which has no invalidateCredentials, so the tokens are never dropped and auth.ts:1490 adds nothing. Fix: make the documented path work and say what it needs, by adding an invalidateCredentials that clears tokens to the example and naming that requirement in this section and the includePreviouslyGrantedScopes JSDoc (auth.ts:261-273).

Why this was flagged

A provider built from the page's own example sets includePreviouslyGrantedScopes = true (docs/clients/oauth.md:188-190) on top of MyOAuthProvider, which defines no invalidateCredentials (examples/guides/clients/oauth.examples.ts:43-98). On invalid_grant, packages/client/src/client/auth.ts:1114 calls provider.invalidateCredentials?.('tokens'), a no-op, so the retry at auth.ts:1115 finds the same tokens at auth.ts:1421, refreshes again at auth.ts:1433 and rethrows at auth.ts:1468. No redirect happens, and the tokens || guard at auth.ts:1490 would drop the carried scope anyway. The new test 'does not redirect when the provider cannot drop the grant' (packages/client/test/client/auth.test.ts:5780) pins exactly this. The base throws here too, but had no text claiming otherwise: line 185 says the SDK "drops the stored tokens and sends the user to sign in again", and the "Nothing is added when" list at line 193 omits this case.

Verification: examples/guides/clients/oauth.examples.ts:43-98 — MyOAuthProvider defines tokens, saveTokens, state, discovery and verifier methods, but no invalidateCredentials. auth.ts:1421 — the retry reads the same stored tokens; auth.ts:1433 sees tokens?.refresh_token and refreshes again at 1438; the same refusal is rethrown at 1468. Nothing regresses against the base.

Finding ID: f-65e95c33


## Recap

- This page signs in an end user; machine-to-machine flows live on [Authenticate without a user](./machine-auth.md).
Expand All @@ -192,3 +205,4 @@ Implement it only for an authorization server that needs it. Another server may
- `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.
- `includePreviouslyGrantedScopes` keeps scopes granted earlier when a refused refresh leads to a new sign-in.
6 changes: 6 additions & 0 deletions examples/guides/clients/oauth.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,9 @@ class ScopeOnRefreshProvider extends MyOAuthProvider {
}
}
//#endregion scopeForRefresh_granted

//#region includePreviouslyGrantedScopes
class KeepGrantedScopesProvider extends MyOAuthProvider {
includePreviouslyGrantedScopes = true;
}
//#endregion includePreviouslyGrantedScopes
47 changes: 43 additions & 4 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,20 @@ export interface OAuthClientProvider {
*/
skipOfflineAccessConsentPrompt?: boolean;

/**
* When `true`, a new authorization that {@linkcode auth} starts because a refresh was refused
* (`invalid_grant` or `invalid_dpop_proof`) also requests the scope the dropped tokens held, so
* permissions granted earlier (for example through step-up) are not lost. The MCP specification
* recommends this ("include these scopes alongside any previously granted scopes").
*
* Values the authorization server does not list in `scopes_supported` and `offline_access` are
* left out. The scope is requested only once: if the server refuses it, the next authorization
* requests the selected scope alone.
Comment on lines +280 to +282

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 nit (optional): readers whose authorization server publishes no scopes_supported are told unlisted values are left out, yet every dropped scope is requested again. The JSDoc at auth.ts:267-268 and docs/clients/oauth.md:193 state the filter without that case. filterToSupportedScopes (auth.ts:1134) keeps all values when the list is absent or empty. Fix: say in both places that every value except offline_access is kept when the server advertises no scopes_supported, which covers the 2 sites listed. Same instruction at 2 sites (auth.ts:268, docs/clients/oauth.md:193).

Why this was flagged

No runtime failure; this is a check of public wording the diff adds. It applies when the authorization server metadata has no scopes_supported or an empty one, which RFC 8414 allows. auth.ts:267-268 says "Values the authorization server does not list in scopes_supported and offline_access are left out", and docs/clients/oauth.md:193 says the SDK "leaves out offline_access and any value the authorization server does not list in scopes_supported". Read literally, nothing is listed, so nothing would be carried. The code at auth.ts:1134 is !supported?.length || supported.includes(value), so every value except offline_access is carried. Only the JSDoc on the private helper (auth.ts:1126) mentions "all of them when it advertises none". The base has no such text. The instruction guards against docs that describe something other than what the code does.

Verification: packages/client/src/client/auth.ts:1134 filters with value !== 'offline_access' && (!supported?.length || supported.includes(value)). When scopes_supported is absent or empty, !supported?.length is true, so every value except offline_access is kept and requested again. Neither public text mentions the keep-all case.

Finding ID: f-f55a0271

*
* @default false
*/
includePreviouslyGrantedScopes?: boolean;

/**
* Metadata about this OAuth client.
*/
Expand Down Expand Up @@ -1105,9 +1119,13 @@ export async function auth(provider: OAuthClientProvider, options: AuthOptions):
// (RFC 9449 §5) to a DPoP key this process no longer holds — e.g. a non-extractable
// key regenerated across a restart. Like invalid_grant, the token set is unusable;
// drop it and fall through to a fresh authorization.
// With `includePreviouslyGrantedScopes`, that authorization asks again for the scope the
// dropped grant held. Read it first: once the tokens are gone nothing stored can supply
// it again, so a refused scope is requested only once.
const discarded = provider.includePreviouslyGrantedScopes ? await provider.tokens() : undefined;
warnCredentialInvalidation(provider, error, 'tokens');
await provider.invalidateCredentials?.('tokens');
return await authInternal(provider, options);
return await authInternal(provider, options, discarded?.scope);
}
}

Expand All @@ -1116,6 +1134,20 @@ export async function auth(provider: OAuthClientProvider, options: AuthOptions):
}
}

/**
* The permissions in a dropped grant's `scope` worth asking for again: values the authorization
* server advertises in `scopes_supported` (all of them when it advertises none), since a token's
* `scope` can hold values the server does not accept back in a request, such as expanded forms.
* `offline_access` is left to {@linkcode determineScope}: it is not a resource permission, and
* requesting it adds `prompt=consent` (SEP-2207).
*/
function filterToSupportedScopes(scope: string | undefined, metadata: AuthorizationServerMetadata | undefined): string | undefined {
const supported = metadata?.scopes_supported;
return computeScopeUnion(
...(scope?.split(/\s+/) ?? []).filter(value => value !== 'offline_access' && (!supported?.length || supported.includes(value)))
);
}

/**
* Selects scopes per the MCP spec and augment for refresh token support.
*/
Expand Down Expand Up @@ -1161,7 +1193,8 @@ async function authInternal(
fetchFn,
skipIssuerMetadataValidation,
forceReauthorization
}: AuthOptions
}: AuthOptions,
discardedScope?: string
): Promise<AuthResult> {
// SEP-837 / SEP-2207: resolve spec defaults for the DCR body. determineScope()
// intentionally reads the raw provider.clientMetadata instead.
Expand Down Expand Up @@ -1461,13 +1494,19 @@ async function authInternal(

const state = provider.state ? await provider.state() : undefined;

// Start new authorization flow
// Start new authorization flow. After a refused refresh dropped the tokens, also ask for the
// scope that grant held (MCP 2026-07-28: re-authorization includes previously granted scopes).
// Only when no tokens remain, so a stored grant can never re-supply a scope the server refused,
// and only when a scope is requested at all: an omitted `scope` gets the server's default grant,
// which an explicit partial request would narrow (RFC 6749 §3.3).
const authorizationScope =
tokens || !resolvedScope ? resolvedScope : computeScopeUnion(resolvedScope, filterToSupportedScopes(discardedScope, metadata));
const { authorizationUrl, codeVerifier } = await startAuthorization(authorizationServerUrl, {
metadata,
clientInformation,
state,
redirectUrl: provider.redirectUrl,
scope: resolvedScope,
scope: authorizationScope,
resource,
skipOfflineAccessConsentPrompt: provider.skipOfflineAccessConsentPrompt
});
Expand Down
222 changes: 222 additions & 0 deletions packages/client/test/client/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5694,3 +5694,225 @@ describe('OAuth Authorization', () => {
});
});
});

describe('re-authorization after a refused refresh, with includePreviouslyGrantedScopes', () => {
const ORIGIN = 'https://mcp.example.com';
const MCP_URL = `${ORIGIN}/mcp`;

/**
* An OAuth-protected MCP server reached through `fetchFn`. The token endpoint refuses refresh
* tokens (`refresh`), which is what drops the stored grant.
*/
type RefreshError = 'invalid_grant' | 'server_error';
const createServer = (options: { refresh?: RefreshError | RefreshError[]; asScopes?: string[]; prmScopes?: string[] } = {}) => {
const tokenRequests: URLSearchParams[] = [];
const refreshErrors = [options.refresh ?? 'invalid_grant'].flat();
const fetchFn = async (input: string | URL, init?: RequestInit): Promise<Response> => {
const url = new URL(input);
if (url.pathname.startsWith('/.well-known/oauth-protected-resource')) {
return Response.json({
resource: MCP_URL,
authorization_servers: [ORIGIN],
...(options.prmScopes ? { scopes_supported: options.prmScopes } : {})
});
}
if (url.pathname.startsWith('/.well-known/oauth-authorization-server')) {
return Response.json({
issuer: ORIGIN,
authorization_endpoint: `${ORIGIN}/authorize`,
token_endpoint: `${ORIGIN}/token`,
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256'],
...(options.asScopes ? { scopes_supported: options.asScopes } : {})
});
}
if (url.pathname === '/token') {
const params = new URLSearchParams(init?.body as string | URLSearchParams);
tokenRequests.push(params);
if (params.get('grant_type') === 'refresh_token') {
const error = refreshErrors.length > 1 ? refreshErrors.shift()! : refreshErrors[0]!;
return Response.json({ error }, { status: error === 'server_error' ? 500 : 400 });
}
return Response.json({ access_token: 'new-token', token_type: 'Bearer' });
}
throw new Error(`Unexpected request: ${url}`);
};
return { fetchFn, tokenRequests };
};

/** An interactive provider that stores tokens like a real one. */
const createProvider = (
initial: OAuthTokens | undefined,
options: { canInvalidate?: boolean; keepsTokensOnInvalidate?: boolean; interactive?: boolean; optIn?: boolean } = {}
) => {
let stored: OAuthTokens | undefined = initial;
const interactive = options.interactive ?? true;
const provider: OAuthClientProvider & { authorizationScopes: Array<string | null> } = {
authorizationScopes: [],
includePreviouslyGrantedScopes: options.optIn ?? true,
get redirectUrl() {
return interactive ? 'http://localhost:3000/callback' : undefined;
},
get clientMetadata() {
return { redirect_uris: interactive ? ['http://localhost:3000/callback'] : [], client_name: 'Test Client' };
},
clientInformation: () => ({ client_id: 'client-1' }),
tokens: () => stored,
saveTokens: (tokens: OAuthTokens) => {
stored = tokens;
},
saveCodeVerifier: () => {},
codeVerifier: () => 'verifier',
redirectToAuthorization(url: URL) {
provider.authorizationScopes.push(url.searchParams.get('scope'));
},
...(options.canInvalidate === false
? {}
: {
invalidateCredentials: (scope: string) => {
if (!options.keepsTokensOnInvalidate && (scope === 'tokens' || scope === 'all')) stored = undefined;
}
}),
...(interactive
? {}
: {
prepareTokenRequest: (scope?: string) =>
new URLSearchParams({ grant_type: 'client_credentials', ...(scope ? { scope } : {}) })
})
};
return provider;
};
const asSet = (scope: string | null | undefined) => new Set(scope?.split(' '));

it('asks again for the scope the dropped grant held', async () => {
// A user granted `write` through step-up; the refresh is later refused.
const server = createServer();
const provider = createProvider({ access_token: 'old', token_type: 'Bearer', scope: 'read write', refresh_token: 'r' });

expect(await auth(provider, { serverUrl: MCP_URL, scope: 'read', fetchFn: server.fetchFn })).toBe('REDIRECT');

expect(asSet(provider.authorizationScopes[0])).toEqual(new Set(['read', 'write']));
});

it('leaves the request as it was unless the provider opts in', async () => {
const server = createServer();
const provider = createProvider(
{ access_token: 'old', token_type: 'Bearer', scope: 'read write', refresh_token: 'r' },
{ optIn: false }
);

await auth(provider, { serverUrl: MCP_URL, scope: 'read', fetchFn: server.fetchFn });

expect(provider.authorizationScopes).toEqual(['read']);
});

it('asks for the challenged scope alone once that request was refused', async () => {
// An admin removed `write`: the server answers that redirect with invalid_scope, so no token is saved.
const server = createServer();
const provider = createProvider({ access_token: 'old', token_type: 'Bearer', scope: 'read write', refresh_token: 'r' });
await auth(provider, { serverUrl: MCP_URL, scope: 'read', fetchFn: server.fetchFn });

// The next 401 starts over. The dropped grant is gone, so nothing re-supplies `write`.
expect(await auth(provider, { serverUrl: MCP_URL, scope: 'read', fetchFn: server.fetchFn })).toBe('REDIRECT');

expect(provider.authorizationScopes.map(asSet)).toEqual([new Set(['read', 'write']), new Set(['read'])]);
});

it('asks for the challenged scope alone when the refresh fails without dropping the grant', async () => {
const server = createServer({ refresh: 'server_error' });
const provider = createProvider({ access_token: 'old', token_type: 'Bearer', scope: 'read write', refresh_token: 'r' });
vi.spyOn(console, 'warn').mockImplementation(() => {});

await auth(provider, { serverUrl: MCP_URL, scope: 'read', fetchFn: server.fetchFn });

expect(provider.authorizationScopes).toEqual(['read']);
});

it('asks for the challenged scope alone when there is no refresh token', async () => {
const server = createServer();
const provider = createProvider({ access_token: 'old', token_type: 'Bearer', scope: 'read write' });

await auth(provider, { serverUrl: MCP_URL, scope: 'read', fetchFn: server.fetchFn });

expect(provider.authorizationScopes).toEqual(['read']);
});

it('does not redirect when the provider cannot drop the grant', async () => {
const server = createServer();
const provider = createProvider(
{ access_token: 'old', token_type: 'Bearer', scope: 'read write', refresh_token: 'r' },
{ canInvalidate: false }
);

await expect(auth(provider, { serverUrl: MCP_URL, scope: 'read', fetchFn: server.fetchFn })).rejects.toThrow(OAuthError);

expect(provider.authorizationScopes).toEqual([]);
});

it('does not carry the scope while a stored grant remains', async () => {
// The provider ignores the invalidation and the second refresh fails softly, so the grant is still stored.
const server = createServer({ refresh: ['invalid_grant', 'server_error'] });
const provider = createProvider(
{ access_token: 'old', token_type: 'Bearer', scope: 'read write', refresh_token: 'r' },
{ keepsTokensOnInvalidate: true }
);
vi.spyOn(console, 'warn').mockImplementation(() => {});

await auth(provider, { serverUrl: MCP_URL, scope: 'read', fetchFn: server.fetchFn });

expect(provider.authorizationScopes).toEqual(['read']);
});

it('keeps only values the authorization server advertises', async () => {
// The token reports expanded scopes the server does not accept back in a request.
const server = createServer({ asScopes: ['openid', 'offline_access'], prmScopes: ['api://app/.default'] });
const provider = createProvider({
access_token: 'old',
token_type: 'Bearer',
scope: 'openid api://app/Read',
refresh_token: 'r'
});

await auth(provider, { serverUrl: MCP_URL, fetchFn: server.fetchFn });

expect(asSet(provider.authorizationScopes[0])).toEqual(new Set(['api://app/.default', 'openid']));
});

it('leaves scope out when nothing selects one, so the server applies its default grant', async () => {
// No challenge scope, no PRM scopes_supported, no clientMetadata.scope; the server advertises only part of its scopes.
const server = createServer({ asScopes: ['openid', 'offline_access'] });
const provider = createProvider({ access_token: 'old', token_type: 'Bearer', scope: 'openid mcp:tools', refresh_token: 'r' });

await auth(provider, { serverUrl: MCP_URL, fetchFn: server.fetchFn });

expect(provider.authorizationScopes).toEqual([null]);
});

it('leaves offline_access to the scope selection', async () => {
// The client does not declare the refresh_token grant, so the selection does not add offline_access (SEP-2207).
const server = createServer({ asScopes: ['read', 'write', 'offline_access'] });
const provider = createProvider({
access_token: 'old',
token_type: 'Bearer',
scope: 'read write offline_access',
refresh_token: 'r'
});

await auth(provider, { serverUrl: MCP_URL, scope: 'read', fetchFn: server.fetchFn });

expect(asSet(provider.authorizationScopes[0])).toEqual(new Set(['read', 'write']));
});

it('leaves non-interactive token requests alone', async () => {
const server = createServer();
const provider = createProvider(
{ access_token: 'old', token_type: 'Bearer', scope: 'read write', refresh_token: 'r' },
{ interactive: false }
);

expect(await auth(provider, { serverUrl: MCP_URL, scope: 'read', fetchFn: server.fetchFn })).toBe('AUTHORIZED');

const tokenRequest = server.tokenRequests.find(params => params.get('grant_type') === 'client_credentials');
expect(tokenRequest?.get('scope')).toBe('read');
});
});
Loading