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
6 changes: 6 additions & 0 deletions .changeset/auth-dcr-hygiene.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@modelcontextprotocol/client': minor
'@modelcontextprotocol/core': minor
---

Dynamic Client Registration hygiene for the 2026-07-28 authorization requirements (SEP-837, SEP-2207). New `resolveClientMetadata(provider)` reads `provider.clientMetadata` and applies the spec defaults — `application_type` derived from the redirect URIs (loopback or custom scheme → `'native'`, otherwise `'web'`), `grant_types: ['authorization_code', 'refresh_token']` when omitted — and `auth()` feeds the resolved document to DCR only (scope selection still reads the raw consumer-supplied `clientMetadata` so statically-registered/CIMD clients are not pushed into `offline_access` + `prompt=consent`); consumer-set values are never overwritten. DCR rejection now throws the new `RegistrationRejectedError` carrying the HTTP status, raw body, and submitted metadata — **breaking for direct `registerClient()` callers**: rejection no longer throws `OAuthError`, so update `instanceof` checks. `OAuthClientMetadata` gains a typed `application_type?: string` field (expected `'native'` / `'web'`; tolerant on parse). `OAuthErrorCode` adds `InvalidRedirectUri`. The token-exchange, refresh, and Cross-App Access (`requestJwtAuthorizationGrant` / `exchangeJwtAuthGrant`) paths now throw the new `InsecureTokenEndpointError` for a non-`https:` token endpoint (`localhost` / `127.0.0.1` / `::1` exempt), and `auth()` surfaces it on the refresh branch instead of silently re-authorizing.
Comment on lines +1 to +6

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 The PR title and description describe a SEP-2350 scope step-up feature (onInsufficientScope on the HTTP transports, exported computeScopeUnion()/InsufficientScopeError, an examples/scoped-tools story, conformance withOAuthRetry wiring, and client-auth:step-up:* e2e scenarios) that does not exist anywhere in this diff or at HEAD (52b1cfc) — the actual change is the SEP-837/SEP-2207 DCR-hygiene work described by this changeset and the HEAD commit message. Please rewrite the title/description to match the DCR-hygiene change actually contained here (or push the intended step-up commits); as-is the Motivation, How-Has-This-Been-Tested, and Breaking-Changes sections describe a different change and will mislead reviewers and the merge-commit history.

Extended reasoning...

What the mismatch is. The PR title ("feat(client): SEP-2350 scope step-up — union, retry cap, superset-gated refresh bypass") and the entire PR description claim a scope step-up feature: an onInsufficientScope option on both HTTP transports, exported computeScopeUnion() and InsufficientScopeError, an examples/scoped-tools headless story with a hardened demo AS, conformance-fixture withOAuthRetry wiring, and client-auth:step-up:* e2e scenarios. None of that content is present in this PR. The diff (and this changeset, .changeset/auth-dcr-hygiene.md) implements SEP-837/SEP-2207 Dynamic Client Registration hygiene instead: resolveClientMetadata() with application_type/grant_types defaults, RegistrationRejectedError, InsecureTokenEndpointError + assertSecureTokenEndpoint(), OAuthErrorCode.InvalidRedirectUri, and the matching e2e/conformance updates.\n\nStep-by-step verification at the PR head. (1) The changeset bot reports the PR's latest commit as 52b1cfca1d5a625d82aec89f373d0db4be4ffca3, which is exactly HEAD of this changeset, so this is not a stale-checkout artifact. (2) Grepping packages/client/src at that commit for computeScopeUnion, InsufficientScopeError (the transport-level class), onInsufficientScope, or _stepUpAuthorize returns no matches. (3) examples/scoped-tools does not exist, and the 18 changed files include no streamableHttp.ts, sse.ts, or test/conformance/src changes. (4) The new e2e scenarios are client-auth:dcr:*, client-auth:token-endpoint:https-guard, client-auth:refresh:rotation-handling, and client-auth:scope:offline-access-gate — not the client-auth:step-up:* rows the description claims under How Has This Been Tested. (5) The expected-failures edits remove SEP-837 application_type baseline entries; auth/scope-step-up remains baselined on the 2026-07-28 leg, contradicting the description's claim that it "burns" with this change. (6) The HEAD commit message itself reads "feat(client): SEP-837/2207 — application_type heuristic, grant_types default, https token-endpoint guard", matching the diff.\n\nWhy nothing else catches it. The earlier claude[bot] review comments referencing _stepUpAuthorize at an older head (8b2f271) indicate the branch previously carried the SEP-2350 work and was later force-pushed to the DCR-hygiene stack without updating the PR title/description. CI passes because the code that is present is internally consistent — there is no automated check that the description matches the diff.\n\nImpact. Reviewers approving this PR based on the description would be approving claims about transport behavior, breaking-change posture ("onInsufficientScope defaults to 'reauthorize'"), and testing evidence (step-up e2e scenarios, the conformance auth/scope-step-up burn) that the diff does not back. On squash-merge the description typically becomes the merge-commit body, permanently misdescribing what landed; future readers tracing SEP-2350 vs SEP-837/2207 history will be pointed at the wrong commit.\n\nHow to fix. Either (a) rewrite the PR title and description to describe the SEP-837/SEP-2207 DCR-hygiene change actually contained — the changeset text and the HEAD commit message are accurate starting points — including correcting the testing section to the client-auth:dcr:* / token-endpoint:https-guard scenarios and the SEP-837 expected-failures burn-down, or (b) push the intended SEP-2350 step-up commits to this branch if this PR was meant to carry them. This is an editorial/process issue rather than a code defect, but the description-vs-diff mismatch is total and should be resolved before merge.

12 changes: 12 additions & 0 deletions docs/migration-SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,14 @@ Individual OAuth error classes replaced with single `OAuthError` class and `OAut

Removed: `OAUTH_ERRORS` constant.

The OAuth client flow additionally throws dedicated classes from `@modelcontextprotocol/client` (all extend `OAuthClientFlowError`, **not** `OAuthError` — `auth()`'s `OAuthError` retry path will not catch them):

| Throw site | v2 class |
| -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| `registerClient()` rejected by AS (any RFC 7591 error incl. `invalid_client_metadata`, `invalid_redirect_uri`) | `RegistrationRejectedError` (`status`, `body`, `submittedMetadata`) |
| `exchangeAuthorization()` / `refreshAuthorization()` / `fetchToken()` / `requestJwtAuthorizationGrant()` / `exchangeJwtAuthGrant()` non-https token endpoint | `InsecureTokenEndpointError` (`tokenEndpoint`) |
| RFC 9207 `iss` mismatch / RFC 8414 §3.3 issuer-echo mismatch | `IssuerMismatchError` (`kind`, `expected`, `received`) |

Update OAuth error handling:

```typescript
Expand Down Expand Up @@ -580,6 +588,10 @@ OAuth callback handling: pass the callback URL's `URLSearchParams` to `transport

`discoverAuthorizationServerMetadata()` now rejects metadata whose `issuer` does not exactly match the URL it was fetched for (RFC 8414 §3.3), throwing `IssuerMismatchError`. Pass `skipIssuerMetadataValidation: true` on `AuthOptions` (or `skipIssuerValidation: true` on the helper) only as a temporary workaround for a known-misconfigured AS.

`auth()` reads `provider.clientMetadata` once via `resolveClientMetadata()` and applies SEP-837/SEP-2207 defaults to the DCR body: `grant_types` defaults to `['authorization_code', 'refresh_token']`; `application_type` is derived from `redirect_uris` (loopback / custom URI scheme → `'native'`, otherwise `'web'`). A field you set explicitly is never overwritten — set `clientMetadata.application_type` / `clientMetadata.grant_types` to override. Direct `registerClient()` callers wanting the same defaults pass `resolveClientMetadata(provider)` as `clientMetadata`. The `grant_types` default applies to the Dynamic Client Registration body only; it does **not** drive the `offline_access` scope / `prompt=consent` augmentation on the authorize request — statically-registered and CIMD clients that want that augmentation must set `clientMetadata.grant_types` explicitly. Non-interactive providers (no `redirectUrl`) get no `grant_types` default.

Token-exchange / refresh now refuse to send credentials to a non-`https:` token endpoint (loopback `localhost` / `127.0.0.1` / `::1` exempt), throwing `InsecureTokenEndpointError` with no opt-out. `auth()` surfaces this on every path including refresh — switch any plain-`http:` AS on a non-loopback host to TLS.

No code changes required; wire-behavior note: on a 2026-07-28 Streamable HTTP connection, aborting an in-flight client request (caller `signal` / timeout) closes that request's SSE response stream as the spec cancellation signal — `notifications/cancelled` is no longer POSTed
there. 2025-era connections and stdio at any era still send `notifications/cancelled`. Custom `Transport` implementations that open one underlying request per outbound message and honor `TransportSendOptions.requestSignal` may declare `readonly hasPerRequestStream = true` to opt
into the same routing.
Expand Down
29 changes: 29 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -930,6 +930,35 @@ try {
}
```

### Dynamic Client Registration: `application_type` and `grant_types` defaults (SEP-837, SEP-2207)

`OAuthClientMetadata` now has a typed `application_type?: string` field (expected `'native'` / `'web'`; tolerant on parse). `auth()` resolves your provider's `clientMetadata` once via the new `resolveClientMetadata()` and uses the resolved document for the Dynamic Client Registration body (scope selection still reads your raw `clientMetadata`). When `application_type` is unset, it is derived from your `redirect_uris`: a loopback host (`localhost` / `127.0.0.1` / `[::1]`) or a custom URI scheme yields `'native'`; anything else yields `'web'`. Set it explicitly when the heuristic is wrong for your deployment (for example a web app dev-served on `localhost`); a value you set is never overwritten.

`resolveClientMetadata()` also defaults `grant_types` to `['authorization_code', 'refresh_token']` when you omit it, so authorization servers that gate refresh-token issuance on the registered grant types issue one. If you set `grant_types` explicitly, include `'refresh_token'` yourself if you want refresh tokens. CIMD users author the hosted metadata document themselves and should include `refresh_token` there. Direct callers of `registerClient()` that want the same defaults should pass `resolveClientMetadata(provider)` as `clientMetadata`. The `grant_types` default applies to the Dynamic Client Registration body only; it does **not** drive the `offline_access` scope / `prompt=consent` augmentation on the authorize request. Statically-registered and CIMD clients that want that augmentation must set `clientMetadata.grant_types = ['authorization_code', 'refresh_token']` explicitly. Non-interactive providers (no `redirectUrl`) get no `grant_types` default.

DCR rejection now throws `RegistrationRejectedError` (carrying `status`, `body`, and `submittedMetadata`) instead of a generic `OAuthError`. Catch it to inspect the AS's `error` / `error_description` and retry with adjusted metadata, or surface a meaningful error.

```typescript
import { registerClient, RegistrationRejectedError, OAuthErrorCode } from '@modelcontextprotocol/client';

try {
await registerClient(authorizationServerUrl, { metadata, clientMetadata });
} catch (e) {
if (e instanceof RegistrationRejectedError) {
const parsed = JSON.parse(e.body) as { error?: string; error_description?: string };
if (parsed.error === OAuthErrorCode.InvalidRedirectUri) {
// AS rejected redirect_uris — retry with adjusted metadata
}
}
}
```

### Token endpoint must use TLS (SEP-2207)

`exchangeAuthorization()`, `refreshAuthorization()`, `fetchToken()`, and the Cross-App Access helpers `requestJwtAuthorizationGrant()` / `exchangeJwtAuthGrant()` now throw `InsecureTokenEndpointError` when the resolved token endpoint is not `https:`. Only `localhost`, `127.0.0.1`, and `::1` are exempt for local development. `auth()` surfaces this error on every path (including the refresh branch) rather than silently re-authorizing. If you were pointing at a plain-`http:` authorization server on a non-loopback host — including cluster-DNS names like `http://oauth.svc.cluster.local` or private addresses like `http://10.0.0.5` — switch it to TLS; there is no opt-out.

**Storage confidentiality remains yours.** `OAuthClientProvider.saveTokens()` receives the raw `refresh_token`; store it in platform-appropriate secure storage. The SDK guarantees transit confidentiality but cannot secure your storage layer.

### Experimental tasks interception removed

The 2025-11 experimental tasks side-channel woven through `Protocol` has been removed in preparation for the SEP-2663 Tasks Extension. The following are gone with no in-place replacement:
Expand Down
1 change: 1 addition & 0 deletions examples/oauth/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ runClient('oauth', async () => {
redirect_uris: [CALLBACK_URL],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
application_type: 'native',
token_endpoint_auth_method: 'client_secret_post'
};
const provider = new InMemoryOAuthClientProvider(CALLBACK_URL, clientMetadata, url => {
Expand Down
1 change: 1 addition & 0 deletions examples/oauth/simpleOAuthClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ class InteractiveOAuthClient {
redirect_uris: [CALLBACK_URL],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
application_type: 'native',
token_endpoint_auth_method: 'client_secret_post'
};

Expand Down
113 changes: 102 additions & 11 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ import {
} from '@modelcontextprotocol/core';
import pkceChallenge from 'pkce-challenge';

import { IssuerMismatchError } from './authErrors.js';
import { InsecureTokenEndpointError, IssuerMismatchError, RegistrationRejectedError } from './authErrors.js';

// Re-exported for back-compat — the canonical home is ./authErrors.js.
export { IssuerMismatchError } from './authErrors.js';
export { InsecureTokenEndpointError, IssuerMismatchError, RegistrationRejectedError } from './authErrors.js';

/**
* Function type for adding client authentication to token requests.
Expand Down Expand Up @@ -616,6 +616,80 @@ export function applyPublicAuth(clientId: string, params: URLSearchParams): void
params.set('client_id', clientId);
}

/** Loopback hosts exempt from the in-transit `https:` requirement (RFC 8252 §7.3). */
function isLoopbackHost(hostname: string): boolean {
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1';
}

/**
* SEP-2207: refuse to send credentials to a non-TLS, non-loopback token endpoint.
* Throws {@linkcode InsecureTokenEndpointError}. Loopback hosts are exempt.
*/
export function assertSecureTokenEndpoint(tokenEndpoint: string | URL): URL {
const url = new URL(String(tokenEndpoint));
if (url.protocol !== 'https:' && !isLoopbackHost(url.hostname)) {
throw new InsecureTokenEndpointError(url.href);
}
return url;
}

/**
* Derives an OIDC `application_type` from a client's registered redirect URIs
* when the consumer has not set one explicitly (SEP-837). Loopback hosts and
* non-`http(s)` custom URI schemes indicate a native application (RFC 8252);
* everything else is treated as a web application. The result is a heuristic
* default — callers that know better should set `clientMetadata.application_type`
* themselves, which {@linkcode resolveClientMetadata} never overwrites.
*
* A mixed redirect set (for example a public `https:` URI alongside a loopback
* URI) is inherently ambiguous under OIDC DCR §2 — neither value satisfies the
* AS for both URIs — so consumers with mixed sets should set `application_type`
* explicitly rather than relying on this heuristic.
*/
function deriveApplicationType(redirectUris: readonly string[] | undefined): 'native' | 'web' {
for (const raw of redirectUris ?? []) {
let url: URL;
try {
url = new URL(raw);
} catch {
continue;
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') return 'native';
if (isLoopbackHost(url.hostname)) return 'native';
}
return 'web';
}

/**
* Reads {@linkcode OAuthClientProvider.clientMetadata | clientMetadata} from the
* provider and fills the SEP-837 / SEP-2207 defaults the SDK relies on, so
* {@linkcode registerClient} sees a consistent, fully-populated document.
*
* - `grant_types` defaults to `['authorization_code', 'refresh_token']` for
* interactive providers (those with a {@linkcode OAuthClientProvider.redirectUrl | redirectUrl})
* so authorization servers that gate refresh-token issuance on the registered
* grant types issue one (SEP-2207). Non-interactive providers (no
* `redirectUrl`) get no `grant_types` default. This default applies to the
* Dynamic Client Registration body only — it does **not** drive
* {@linkcode determineScope}'s `offline_access` augmentation.
* - `application_type` defaults from `redirect_uris`: loopback redirect hosts
* and custom URI schemes → `'native'`, otherwise `'web'` (SEP-837 / RFC 8252).
*
* A field the consumer set explicitly is **never** overwritten. {@linkcode auth}
* calls this once at the top of the flow; direct callers of
* {@linkcode registerClient} that want the same defaults should pass the result
* of this function as `clientMetadata`.
*/
export function resolveClientMetadata(provider: Pick<OAuthClientProvider, 'clientMetadata' | 'redirectUrl'>): OAuthClientMetadata {
const clientMetadata = provider.clientMetadata;
return {
...clientMetadata,
grant_types:
clientMetadata.grant_types ?? (provider.redirectUrl === undefined ? undefined : ['authorization_code', 'refresh_token']),
application_type: clientMetadata.application_type ?? deriveApplicationType(clientMetadata.redirect_uris)
};
}

/**
* Parses an OAuth error response from a string or Response object.
*
Expand Down Expand Up @@ -722,8 +796,10 @@ export function determineScope(options: {
// 4. Omit scope parameter
let effectiveScope = requestedScope || resourceMetadata?.scopes_supported?.join(' ') || clientMetadata.scope;

// SEP-2207: Append offline_access when the AS advertises it
// and the client supports the refresh_token grant.
// SEP-2207: Append offline_access when the AS advertises it and the client
// supports the refresh_token grant. Gated on consumer-supplied grant_types;
// SDK DCR default intentionally NOT applied here so statically-registered/CIMD
// clients are not pushed into offline_access + prompt=consent.
if (
effectiveScope &&
authServerMetadata?.scopes_supported?.includes('offline_access') &&
Expand All @@ -740,6 +816,10 @@ async function authInternal(
provider: OAuthClientProvider,
{ serverUrl, authorizationCode, iss, scope, resourceMetadataUrl, fetchFn, skipIssuerMetadataValidation }: AuthOptions
): Promise<AuthResult> {
// SEP-837 / SEP-2207: resolve spec defaults for the DCR body. determineScope()
// intentionally reads the raw provider.clientMetadata instead.
const clientMetadata = resolveClientMetadata(provider);

// Check if the provider has cached discovery state to skip discovery
const cachedState = await provider.discoveryState?.();

Expand Down Expand Up @@ -866,7 +946,7 @@ async function authInternal(

const fullInformation = await registerClient(authorizationServerUrl, {
metadata,
clientMetadata: provider.clientMetadata,
clientMetadata,
scope: resolvedScope,
fetchFn
});
Expand Down Expand Up @@ -923,6 +1003,12 @@ async function authInternal(
await provider.saveTokens(newTokens);
return 'AUTHORIZED';
} catch (error) {
// A non-TLS token endpoint is a configuration error — re-authorizing cannot
// fix it. Surface it so the consumer sees the misconfiguration instead of an
// unexplained re-auth prompt.
if (error instanceof InsecureTokenEndpointError) {
throw error;
}
// If this is a ServerError, or an unknown type, log it out and try to continue. Otherwise, escalate so we can fix things and retry.
if (!(error instanceof OAuthError) || error.code === OAuthErrorCode.ServerError) {
// Could not refresh OAuth tokens
Expand Down Expand Up @@ -1660,7 +1746,7 @@ export async function executeTokenRequest(
fetchFn?: FetchLike;
}
): Promise<OAuthTokens> {
const tokenUrl = metadata?.token_endpoint ? new URL(metadata.token_endpoint) : new URL('/token', authorizationServerUrl);
const tokenUrl = assertSecureTokenEndpoint(metadata?.token_endpoint ?? new URL('/token', authorizationServerUrl));

const headers = new Headers({
'Content-Type': 'application/x-www-form-urlencoded',
Expand Down Expand Up @@ -1944,19 +2030,24 @@ export async function registerClient(
registrationUrl = new URL('/register', authorizationServerUrl);
}

// `clientMetadata` arrives via resolveClientMetadata() inside auth(), so the
// SEP-837/2207 defaults are already applied. Direct callers that want the
// same defaults should pass resolveClientMetadata(provider) here.
const submittedMetadata: OAuthClientMetadata = {
...clientMetadata,
...(scope === undefined ? {} : { scope })
};

const response = await (fetchFn ?? fetch)(registrationUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
...clientMetadata,
...(scope === undefined ? {} : { scope })
})
body: JSON.stringify(submittedMetadata)
});

if (!response.ok) {
throw await parseErrorResponse(response);
throw new RegistrationRejectedError({ status: response.status, body: await response.text(), submittedMetadata });
}

return OAuthClientInformationFullSchema.parse(await response.json());
Expand Down
Loading
Loading