Repository navigation
feat(client): add an opt-in for sending scope on token refresh #3085
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d6381d1
0f4f96b
164ac1a
4b559da
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. Why this was flaggedNo runtime trigger: this is a manifest naming convention. test/e2e/requirements.ts:2244 and :2251 add unprefixed ids with 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: |
||
| 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: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. */ | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Why this was flaggedNothing 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 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: |
||
| 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(); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 nit (optional): test/e2e/CLAUDE.md asks each Why this was flaggedNothing fails at runtime. The instruction guards readability and independence of each scenario: a reader of one Verification: Instruction: test/e2e/CLAUDE.md at base 9adb8d5, section "Writing a test": "Self-contained: build server inline (factory), build client inline, Finding ID: |
||
| 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' } }); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.