Repository navigation
feat(client): add an opt-in for sending scope on token refresh - #3085
Conversation
Some authorization servers reject a refresh-token request that carries no `scope` (#2718: Microsoft Entra ID answers AADSTS90009 when the client and the resource are the same app registration). Omitting it is allowed by RFC 6749 section 6 and other servers reject or narrow a refresh that does carry one, so the default request does not change. - `OAuthClientProvider.scopeForRefresh?(tokens)` returns the scope `auth()` sends on a refresh. Every caller of `auth()` takes the provider (both HTTP transports, their step-up path, `withOAuth`), so one member reaches them all. - `refreshAuthorization()` takes an optional `scope` for hosts that call it directly. An empty string sends nothing. - When a scope was sent and the response has none, the sent scope stays on the returned tokens (RFC 6749 section 5.1: an omitted scope equals the requested one). Without this, `tokens => tokens.scope` has nothing to return on the second refresh. It never applies when no scope is sent. A rejected refresh is handled as before: `invalid_scope` is thrown to the caller. An error thrown by the hook rejects `auth()`. Alternative to #2720, which sends the stored scope by default.
🦋 Changeset detectedLatest commit: 4b559da The changes in this PR will be included in the next version bump. This PR includes changesets to release 6 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/core
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
| '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': { |
There was a problem hiding this comment.
🟡 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
| } | ||
|
|
||
| verifies('client-auth:refresh:no-scope-by-default', async (_args: TestArgs) => { | ||
| const world = createScopeRequiringWorld(); |
There was a problem hiding this comment.
🟡 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
| } | ||
| }); | ||
|
|
||
| /** An authorization server that rejects a refresh without `scope`, and an MCP host whose access tokens can be expired. */ |
There was a problem hiding this comment.
🟡 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
There was a problem hiding this comment.
Code review found no new issues
No new issues were found in this update; 4 findings from earlier reviews are still open above.
Still open from earlier reviews (4):
- 🔴
packages/client/src/client/auth.ts:1409—Clients that opt in to scopeForRefresh can get stuck: once the server answers a refresh with invalid_scope, every later… - Also unresolved: 3 minor or pre-existing.
If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.
Resolves conflicts with #3085 (opt-in scope on token refresh) in refreshAuthorization's result merge and its tests. The merge keeps this branch's hardened spread order, in which a present-but-undefined refresh_token from the parsed response can never clobber the preserved refresh token, and adds #3085's fallback that keeps the sent scope when the response has none. The refreshAuthorization test blocks from both sides are retained. One #3085 assertion is adjusted: the test that pins no scope being sent on refresh by default also pinned the saved payload without the stored scope, which is the pre-existing behavior this branch changes on review (a refresh response that omits scope asserts an unchanged grant, so the stored scope is preserved rather than erased). The request-body assertion that is the test's subject is unchanged. The two behaviors compose: no scope is sent by default, a sent scope is saved when the response omits one, and otherwise the stored scope survives. Also shrinks the comment above the scope-preservation merge to one line, per the inline-comment convention. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KXnNnp3fxQYR5HF9BUVhYP
Requested by Felix Weinberger · Slack thread
Some authorization servers reject a refresh-token request that carries no
scope(#2718: Microsoft Entra ID answers AADSTS90009 when the client and theresource are the same app registration). Omitting it is allowed by RFC 6749
section 6 and other servers reject or narrow a refresh that does carry one, so
the default request does not change.
OAuthClientProvider.scopeForRefresh?(tokens)returns the scopeauth()sends on a refresh. Every caller of
auth()takes the provider (both HTTPtransports, their step-up path,
withOAuth), so one member reaches them all.refreshAuthorization()takes an optionalscopefor hosts that call itdirectly. An empty string sends nothing.
returned tokens (RFC 6749 section 5.1: an omitted scope equals the requested
one). Without this,
tokens => tokens.scopehas nothing to return on thesecond refresh. It never applies when no scope is sent.
A rejected refresh is handled as before:
invalid_scopeis thrown to the caller. An error thrown by the hook rejectsauth().Alternative to #2720, which sends the stored scope by default.
Generated by Claude Code