Skip to content

SEP-2207: Refresh token guidance#1523

Open
wdawson wants to merge 5 commits intomodelcontextprotocol:mainfrom
ArcadeAI:feat/sep-2207
Open

SEP-2207: Refresh token guidance#1523
wdawson wants to merge 5 commits intomodelcontextprotocol:mainfrom
ArcadeAI:feat/sep-2207

Conversation

@wdawson
Copy link
Copy Markdown

@wdawson wdawson commented Feb 11, 2026

Implement OIDC-flavored refresh token guidance (modelcontextprotocol/modelcontextprotocol#2207) in the Python SDK client.

Motivation and Context

MCP clients interacting with OIDC-flavored Authorization Servers often don't receive refresh tokens because they aren't requesting offline_access. This leads to poor UX (frequent re-authentication). SEP-2207 provides guidance for how MCP clients should handle this.

The SDK currently expects the caller to supply the supported grant_types for the client. The example already shows providing refresh_token, and callers are encouraged to do so as well.

This PR:

  • Extracts scope selection into an exported, testable determineScope() helper that follows the MCP Scope Selection Strategy
  • Automatically appends offline_access to the authorization request scope when the AS advertises it in scopes_supported and the client's grant_types includes refresh_token

No changes are needed on the server side — the SDK's example AS already includes offline_access in its scopes_supported, and the example middleware already omits it from WWW-Authenticate (both compliant with the SEP).

How Has This Been Tested?

  • 14 new unit tests for determineScope() covering:
    • MCP spec scope selection priority (explicit scope, PRM scopes_supported, clientMetadata.scope fallback, omit)
    • SEP-2207 augmentation (adds offline_access when conditions met)
    • Guard conditions (no augmentation when AS doesn't support it, when already present in clientMetadata.scope or non-compliant PRM, when grant_types omits refresh_token, when grant_types is undefined, when no scopes are present)

Breaking Changes

None. The offline_access augmentation only activates when:

  1. The AS metadata includes offline_access in scopes_supported
  2. The client's grant_types includes refresh_token

Clients that don't set grant_types or don't include refresh_token are unaffected. Authorization Servers that don't recognize offline_access will simply ignore it per OAuth 2.1. The newly exported determineScope() function is additive.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

  • determineScope() now encodes the MCP Scope Selection Strategy as a testable, exported function. It accepts requestedScope, resourceMetadata, authServerMetadata, and clientMetadata, and returns the effective scope string (or undefined to omit the parameter).
  • The existing startAuthorization() already handles adding prompt=consent when offline_access is in scope, per the OIDC Core spec. No changes were needed there.
  • grant_types defaulting was intentionally not added at the SDK level. The OAuthClientMetadata type is shared between client creation (DCR) and server-side parsing (CIMD), so schema-level defaults would violate OAuth spec defaults for CIMD. The example clients already include refresh_token in grant_types.
  • The 403 insufficient_scope handler in streamableHttp.ts already passes scope through to auth(), which now delegates to determineScope() — no additional changes needed for step-up authorization flows.

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Feb 11, 2026

⚠️ No Changeset found

Latest commit: 8939679

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Feb 11, 2026

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@1523

@modelcontextprotocol/server

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@1523

@modelcontextprotocol/express

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/express@1523

@modelcontextprotocol/hono

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/hono@1523

@modelcontextprotocol/node

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/node@1523

commit: 8939679

@wdawson wdawson marked this pull request as ready for review March 11, 2026 20:42
@wdawson wdawson requested a review from a team as a code owner March 11, 2026 20:42
@felixweinberger felixweinberger added the auth Issues and PRs related to Authentication / OAuth label Mar 12, 2026
@wdawson
Copy link
Copy Markdown
Author

wdawson commented Mar 12, 2026

This is ready for review now that the SEP is accepted. There is also a pending conformance test PR that we can wait for if desired: modelcontextprotocol/conformance#166

Edit: the conformance test has been merged

@felixweinberger
Copy link
Copy Markdown
Contributor

@claude review

Comment on lines +590 to 599
// Scope selection used consistently for DCR and the authorization request.
const resolvedScope = determineScope({
requestedScope: scope,
resourceMetadata,
authServerMetadata: metadata,
clientMetadata: provider.clientMetadata
});

// Handle client registration if needed
let clientInformation = await Promise.resolve(provider.clientInformation());
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Nit: fetchToken() (line 650) receives the raw scope parameter instead of resolvedScope, so non-interactive flows using prepareTokenRequest() miss the PRM scopes_supported fallback and the offline_access augmentation from determineScope(). In practice the impact is minimal — authorization_code flows don't send scope in the token request at all, and client_credentials + refresh tokens is not a valid combination per RFC 6749 §4.4.3 — but passing resolvedScope here would be more consistent with the stated goal of centralizing scope selection.

Extended reasoning...

What the bug is

In authInternal(), determineScope() computes resolvedScope (line 590-596) with a three-level fallback (WWW-Authenticate scope → PRM scopes_supportedclientMetadata.scope) plus SEP-2207 offline_access augmentation. This resolvedScope is correctly passed to registerClient() (line 631) and startAuthorization() (line 688). However, at line 650, fetchToken() receives the raw scope parameter — the original value from auth() options — not resolvedScope.

How fetchToken handles scope differently

Inside fetchToken() (line 1503), scope resolution follows a completely different chain: const effectiveScope = scope ?? provider.clientMetadata.scope. This bypasses both the PRM scopes_supported fallback (priority 2 in the MCP spec) and the offline_access augmentation added by this PR. The effectiveScope is then passed to provider.prepareTokenRequest(effectiveScope) for non-interactive flows.

Step-by-step proof for non-interactive flow

Consider a client_credentials provider where: (1) no explicit scope is passed to auth(), (2) PRM metadata has scopes_supported: ["mcp:read", "mcp:write"], (3) AS metadata has scopes_supported including offline_access, and (4) clientMetadata.grant_types includes refresh_token but clientMetadata.scope is undefined.

  • determineScope() returns "mcp:read mcp:write offline_access" (PRM fallback + augmentation)
  • DCR registration uses this resolved scope ✓
  • fetchToken() receives scope = undefined, computes effectiveScope = undefined ?? undefined = undefined
  • prepareTokenRequest(undefined) gets no scope at all, vs the intended "mcp:read mcp:write offline_access"

Why the practical impact is minimal

For authorization_code flows (the primary target of this PR), prepareAuthorizationCodeRequest() only sends grant_type, code, code_verifier, and redirect_uri — scope is not included in the token exchange request because it was already baked into the authorization URL via startAuthorization(resolvedScope). So this bug has zero impact on auth code flows.

For client_credentials flows, RFC 6749 §4.4.3 states the AS "MUST NOT issue a refresh token," making offline_access semantically meaningless. The PRM fallback gap is more relevant here, but providers implementing prepareTokenRequest() have full control over what scope they send.

Suggested fix

Pass resolvedScope instead of scope at line 650 for consistency with the PR's goal of centralizing scope selection via determineScope(). This is a one-line change: scoperesolvedScope in the fetchToken() call.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

@felixweinberger happy to update this if you want. Was trying to be surgical, but the review is probably accurate. lmk

if (
effectiveScope &&
authServerMetadata?.scopes_supported?.includes('offline_access') &&
!effectiveScope.split(' ').includes('offline_access') &&
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟣 Nit (pre-existing): startAuthorization() at line 1259 uses scope?.includes('offline_access') (substring match on the string), while the new determineScope() correctly uses .split(' ').includes('offline_access') (exact token match). A hypothetical scope like "no_offline_access" would incorrectly trigger prompt=consent. Consider updating line 1259 to scope?.split(' ').includes('offline_access') for consistency.

Extended reasoning...

Bug Analysis

The new determineScope() function (line 493) correctly checks whether offline_access is present in a space-delimited scope string using exact token matching:

\!effectiveScope.split(' ').includes('offline_access')

However, the consumer of this scope — startAuthorization() at line 1259 — checks for the same scope token using String.prototype.includes(), which is a substring match:

if (scope?.includes('offline_access')) {
    authorizationUrl.searchParams.append('prompt', 'consent');
}

How it manifests

Consider a scope string like "mcp:read no_offline_access". The determineScope() function would correctly determine that offline_access is not present as a discrete scope token. But when this scope string reaches startAuthorization(), the substring check "mcp:read no_offline_access".includes("offline_access") returns true, causing prompt=consent to be incorrectly appended to the authorization URL.

Step-by-step proof

  1. determineScope() returns "mcp:read no_offline_access" (no augmentation, since offline_access is not a discrete token).
  2. authInternal() passes this as scope: resolvedScope to startAuthorization().
  3. Inside startAuthorization(), line 1259 evaluates: "mcp:read no_offline_access"?.includes("offline_access")true.
  4. prompt=consent is appended to the authorization URL — incorrectly, since offline_access was never actually requested.

Why existing code does not prevent it

The determineScope() function uses the correct exact-token approach, but it cannot control how downstream consumers check the scope string. The startAuthorization() function predates this PR and was not updated.

Impact

This is a pre-existing issue — the substring check in startAuthorization() was there before this PR. However, this PR makes offline_access appear in scope strings more frequently via the SEP-2207 augmentation logic, which means the code path at line 1259 gets exercised more often. In practice, a scope token containing "offline_access" as a substring is extremely unlikely in real OAuth deployments, so the practical impact is negligible.

Fix

Replace the substring check on line 1259 with an exact token match:

if (scope?.split(' ').includes('offline_access')) {

This is a one-line change that aligns startAuthorization() with the correct pattern already used in determineScope().

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

@felixweinberger I'm happy to update this as well if you like, but we don't need to increase the scope here unnecessarily.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auth Issues and PRs related to Authentication / OAuth

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants