Skip to content
Open
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
8 changes: 8 additions & 0 deletions .changeset/discovery-url-policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@modelcontextprotocol/client': patch
'@modelcontextprotocol/core-internal': patch
---

Validate every network-derived URL in the client OAuth flow before it is requested or adopted. A new fail-closed URL policy (`assertAllowedDiscoveryUrl`, `DiscoveryUrlBlockedError`, `DiscoveryUrlPolicyOptions`) runs ahead of all discovery I/O: `https:` required everywhere with the RFC 8252 loopback `http:` exemption, no userinfo, no loopback/private/link-local IP-literal targets unless the producing step — the MCP server, or the authorization server that published an endpoint (`ctx.producer`) — is itself local (DNS names are never resolved, so private https DNS names keep working), RFC 8414 §2 issuer syntax for authorization server identifiers, and RFC 9728 §3 same-origin for protected-resource metadata. An explicit metadata URL carries its provenance (`resourceMetadataUrlSource`): the transports label the URLs they extract from a `WWW-Authenticate` challenge `'www-authenticate'`, and a non-conformant challenge-relayed URL is ignored with a warning while discovery falls back to the SDK's own same-origin well-known derivation — whereas a caller-configured `resourceMetadataUrl` (`'caller'`, the default) is a configuration statement and fails closed with `DiscoveryUrlBlockedError` instead of silently discovering at a different location. A rejected `authorization_servers[0]` fails the flow instead of silently switching to the legacy server-derived authorization server, and cached `discoveryState` is re-validated on every restore. Discovery requests now use `redirect: 'manual'` with a bounded 3-hop follow that re-validates each `Location` target and drops headers on cross-origin hops. `startAuthorization`, `registerClient`, and the token request path (`exchangeAuthorization`, `refreshAuthorization`, `fetchToken`) apply the same https-or-loopback and locality rules to the authorization, registration, and token endpoints, keyed on the authorization server that published them (the plain-http scheme rule on the token endpoint keeps throwing `InsecureTokenEndpointError`, unchanged). The Cross-App Access token exchanges (`requestJwtAuthorizationGrant`, `discoverAndRequestJwtAuthGrant`, `exchangeJwtAuthGrant`) go through the same validated path: a token endpoint discovered from the IdP's metadata is keyed on the IdP issuer, while a caller-supplied endpoint anchors its own locality check; these standalone helpers accept a per-call `discoveryPolicy` and, running without a provider, have no provider hook. Token and registration POSTs are issued with `redirect: 'manual'` and are never re-sent to a `Location` target — token responses are terminal (RFC 6749), so a redirected POST surfaces as an error instead of re-sending credentials. In browser runtimes the Fetch API filters `redirect: 'manual'` responses into opaque redirects (status 0, no readable `Location`), so a redirect on an auth-flow request cannot be examined or followed there; the handling depends on what the request was for: the SDK's own well-known discovery probes treat a filtered redirect as an unavailable document and degrade exactly like a 404 (a browser deployment whose web tier answers unknown paths with a redirect keeps taking the legacy authorization-server fallback), a challenge-relayed metadata URL whose response is a filtered redirect is ignored with a warning while discovery falls back to the well-known derivation, a caller-configured `resourceMetadataUrl` fails the flow, and a redirected token or registration POST fails in every runtime — those requests are never re-sent to a redirect target, so only serving the endpoint without a redirect resolves it. New opt-outs, all policy-weakening and off by default, ride an optional `discoveryPolicy` bag: `OAuthClientProvider.discoveryPolicy` is the durable per-deployment home read by every flow the provider drives (including the transports' `401`/`403` and `finishAuth` flows), and a per-call `discoveryPolicy` on `AuthOptions` or the standalone discovery and endpoint functions takes precedence — the effective policy is resolved once at flow entry. Trust verification is configured on the provider only (there is no per-call trust override): the exhaustive `OAuthClientProvider.trustedAuthorizationServers` list of issuer identifiers (matched on the full identifier, tolerating only a trailing-slash difference — a path-distinguished issuer must be listed exactly, and an origin-only entry does not cover pathed issuers on that host) applies to every RFC 9728 §7.6 adoption path, including the legacy fallback where a server without protected-resource metadata acts as its own authorization server (hooks see that path as `ctx.source === 'sdk-derived'`); and the optional `OAuthClientProvider.validateDiscoveryURL(ctx)` hook runs after the mechanical policy at every checkpoint — before each discovery, token, and registration request (including each redirect hop) and at each assert-only adoption (authorization-server adoption, cached restore, and the authorization endpoint before redirect). A hook throw that is not a `DiscoveryUrlBlockedError` is wrapped into one (original error as `cause`), so callers observe a single failure type for every URL-policy or trust rejection.

Transport-configured `requestInit` headers no longer ride on the OAuth requests the client transports issue. `StreamableHTTPClientTransport` and `SSEClientTransport` previously merged `requestInit` into every request, including protected-resource metadata, authorization-server metadata, token, and client registration requests that can target a different origin than the MCP server; those requests now go through the bare `fetch` (a custom `fetch` option still applies everywhere). The new `oauthRequestInit` transport option is the explicit opt-in for configuring authorization requests; its headers follow the discovery path's per-hop rule and are not re-applied when a discovery redirect leaves the current origin. See the "Auth" section of `docs/migration/upgrade-to-v2.md` for the migration notes covering both the URL policy defaults and the header isolation.
101 changes: 101 additions & 0 deletions docs/migration/upgrade-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -1226,6 +1226,107 @@ every path including refresh — switch any plain-`http:` AS on a non-loopback h
TLS; there is no opt-out. Storage confidentiality of `refresh_token` remains your
`saveTokens()` implementation's responsibility.

#### Discovery URL policy (RFC 9728 §3 / §7.6, RFC 8414 §2)

Every URL the client OAuth flow derives from the network — the `WWW-Authenticate`
`resource_metadata` URL, the protected-resource metadata's `authorization_servers[0]`
(including restore from cached `discoveryState`), each well-known discovery URL, the
authorization, registration, and token endpoints, and every redirect `Location` on a
discovery request — is now validated by `assertAllowedDiscoveryUrl` **before** any
request is made or the URL is adopted. The default policy: `https:` everywhere (`http:` only for
loopback hosts, matching the SEP-2207 token-endpoint rule), no userinfo, no
loopback/private/link-local IP-literal targets unless the step that produced the URL
(`ctx.producer` — the MCP server for URLs adopted or derived during discovery, or the
authorization server that published an endpoint) is itself local,
RFC 8414 §2 issuer syntax (no query/fragment) for authorization server identifiers, and
same-origin-with-the-server for protected-resource metadata. An explicit metadata URL
carries its provenance (`resourceMetadataUrlSource`): the transports label URLs
extracted from a `WWW-Authenticate` challenge `'www-authenticate'`, and a
non-conformant challenge-relayed URL is ignored with a `console.warn` while discovery
falls back to the SDK's own same-origin well-known derivation; a caller-configured
`resourceMetadataUrl` (`'caller'`, the default) fails closed instead — like every
other violation, with `DiscoveryUrlBlockedError` (a rejected
`authorization_servers[0]` does **not** fall back to the legacy server-derived
authorization server). Discovery
Comment on lines +1229 to +1250

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 new client OAuth surface added by this PR — OAuthClientProvider.discoveryPolicy (with allowHttpDiscovery / allowCrossOriginResourceMetadata / allowPrivateAddressTargets), trustedAuthorizationServers, validateDiscoveryURL, DiscoveryUrlBlockedError, and the requestInitoauthRequestInit header isolation on both client transports — is documented only in this migration-guide entry and the changeset. The client OAuth guide page docs/clients/oauth.md (which documents the OAuthClientProvider surface and transport-driven auth) and docs/clients/machine-auth.md (which documents the Cross-App Access helpers now subject to the URL policy) are not updated, so users configuring OAuth via the guide pages won't discover the new fail-closed defaults, the DiscoveryUrlBlockedError failure mode, or the remedies for previously-working deployments.

Extended reasoning...

What's missing. This PR adds a substantial amount of new public API to the client OAuth flow: the fail-closed discovery URL policy (OAuthClientProvider.discoveryPolicy / AuthOptions.discoveryPolicy with the allowHttpDiscovery, allowCrossOriginResourceMetadata, and allowPrivateAddressTargets opt-outs), the RFC 9728 §7.6 trust members trustedAuthorizationServers and validateDiscoveryURL(ctx), the new DiscoveryUrlBlockedError failure type, and the requestInitoauthRequestInit header isolation on StreamableHTTPClientTransport / SSEClientTransport. The only prose documentation for all of this lives in docs/migration/upgrade-to-v2.md (this section) and the changeset.

Which pages should cover it. docs/clients/oauth.md is the guide page that documents the OAuthClientProvider surface (clientInformation, tokens, discoveryState, validateResourceURL, skipIssuerMetadataValidation) and the transport-driven auth flow via connect() / finishAuth(), including the failure modes callers may see (UnauthorizedError, IssuerMismatchError). A grep across docs/**/*.md shows none of the new symbols — discoveryPolicy, trustedAuthorizationServers, validateDiscoveryURL, oauthRequestInit, DiscoveryUrlBlockedError — appear anywhere except the migration guide; the OAuth guide page is untouched by this PR. Similarly, docs/clients/machine-auth.md documents requestJwtAuthorizationGrant / discoverAndRequestJwtAuthGrant / exchangeJwtAuthGrant / CrossAppAccessProvider without mentioning that those token exchanges now go through the URL policy or that they accept a per-call discoveryPolicy option.

Why it matters. The new defaults are behavior changes that a user following the guide pages will hit directly: the fail-closed policy can reject previously-working deployments (e.g. cross-origin resource_metadata behind a gateway, or a private-IP-literal authorization server), and transport requestInit headers no longer reach OAuth requests at all. The remedies — setting provider.discoveryPolicy, listing trustedAuthorizationServers, or moving gateway headers to oauthRequestInit — are currently only discoverable from the migration guide, which is not the page a user configuring an OAuthClientProvider from scratch reads. The repo's own review checklist asks that new features carry prose documentation (not just JSDoc / migration notes) and that behavior changes be reflected where existing docs describe the old behavior.

Concrete example. A user reading docs/clients/oauth.md today implements a provider and connects with StreamableHTTPClientTransport + requestInit: { headers: { 'X-Api-Key': ... } } against a server whose WWW-Authenticate challenge points at a cross-origin metadata URL. After this PR: (1) the gateway header silently stops being sent on the well-known / token / registration requests (they need oauthRequestInit now), and (2) if the discovery flow adopts a URL that violates the policy, connect() rejects with DiscoveryUrlBlockedError — a failure type the guide never mentions. Nothing on the page they read explains either behavior or its fix.

How to fix. Add a section to docs/clients/oauth.md covering the new provider members (discoveryPolicy, trustedAuthorizationServers, validateDiscoveryURL), the default fail-closed rules and their opt-outs, DiscoveryUrlBlockedError as a failure callers of connect() / finishAuth() may see, and the requestInit vs oauthRequestInit split on the transports. Add a short note to docs/clients/machine-auth.md that the Cross-App Access token exchanges enforce the same URL policy and accept a discoveryPolicy option. This is a docs-completeness gap only — nothing breaks at runtime if merged as-is, and the migration guide/changeset do document the changes — so it is a nit, not a blocker.

requests use `redirect: 'manual'` with a bounded (3-hop) follow that re-validates each
hop and never carries headers across origins; token and registration POSTs also use
`redirect: 'manual'` but are never re-sent to the `Location` target — token responses
are terminal (RFC 6749), so a redirected POST fails with the redirect response's
status. In browser runtimes the Fetch API filters `redirect: 'manual'` responses into
opaque redirects (status 0, no readable `Location`), so a redirect on an auth-flow
request cannot be examined or followed there. The handling depends on the request: the
SDK's own well-known discovery probes treat a filtered redirect as an unavailable
document and degrade exactly like a 404 (a browser deployment whose web tier answers
unknown paths with a redirect keeps taking the legacy authorization-server fallback);
a challenge-relayed metadata URL whose response is a filtered redirect is ignored with
a warning while discovery falls back to the well-known derivation; a caller-configured
`resourceMetadataUrl` fails the flow; and a redirected token or registration POST
fails in every runtime — those requests are never re-sent to a redirect target — so
serve the token and registration endpoints without redirects. The Cross-App Access token exchanges (`requestJwtAuthorizationGrant`,
`discoverAndRequestJwtAuthGrant`, `exchangeJwtAuthGrant`) follow the same rules: a
token endpoint discovered from the IdP's metadata is keyed on the IdP issuer, while a
caller-supplied endpoint anchors its own locality check. Cross-origin authorization servers
(Auth0/Okta/Google-style) and private https DNS names are unaffected — the policy never
resolves DNS. Escape hatches (all **policy-weakening**): `discoveryPolicy`
(`allowHttpDiscovery`, `allowCrossOriginResourceMetadata`,
`allowPrivateAddressTargets`). Set it once on the provider
(`OAuthClientProvider.discoveryPolicy`) — the durable home read by every flow the
provider drives, including the transports' `401`/`403` challenge handling and
`finishAuth` — or per call on `AuthOptions` and the standalone discovery, token,
registration, and Cross-App Access functions; a per-call policy takes precedence, and
the effective policy is resolved once at flow entry (the standalone Cross-App Access
helpers run without a provider, so the per-call bag is their only policy input and the
provider trust hook does not apply to them).
For RFC 9728 §7.6 trust verification of the authorization server itself, implement the
new optional `OAuthClientProvider.validateDiscoveryURL(ctx)` hook or set
`OAuthClientProvider.trustedAuthorizationServers` (exhaustive allowlist of issuer
identifiers, matched on the full identifier with only a trailing-slash tolerance — a
path-distinguished issuer such as `https://as.example.com/tenant1` must be listed
exactly, and an origin-only entry does not cover pathed issuers on that host). Trust
is configured on the provider only — there is no per-call trust override; a caller
that needs trust verification constructs a minimal provider carrying these members
and drives the flow through `auth()`, `fetchToken()`, or a transport (the standalone
discovery functions apply the URL policy but run no trust verification). The trust
list applies on every adoption path,
including the legacy fallback where a server without protected-resource metadata acts
as its own authorization server — with a trust list configured, list the server's
base URL to allow that arrangement (hooks can tell the fallback apart by
`ctx.source === 'sdk-derived'`). The `validateDiscoveryURL` hook runs after the
mechanical policy at **every** checkpoint: before each discovery, token, and
registration request (including each followed redirect hop) and at each assert-only
adoption (authorization-server adoption, cached-state restore, and the authorization
endpoint before the user agent is redirected) — filter on `ctx.purpose` to scope a
check. A hook throw that is not a `DiscoveryUrlBlockedError` is wrapped into one
(original error as `cause`), so callers observe a single failure type. To
restore the previous behavior wholesale, set all three `discoveryPolicy` flags — but
prefer enabling only the specific override a deployment needs, since each one weakens a
conformance check.

#### Transport request headers no longer applied to OAuth requests (`oauthRequestInit`)

In v1, the `requestInit` option on `StreamableHTTPClientTransport` and
`SSEClientTransport` was merged into **every** request the transport made — including
the protected-resource metadata, authorization-server metadata, token, and client
registration requests driven by the authorization flow, which can target a different
origin than the MCP server. In v2, `requestInit` applies to MCP data-plane traffic
only; authorization requests are issued through the bare `fetch` (the `fetch` option
still applies to all requests, so proxy agents, dispatchers, and tracing wrappers keep
working). Deployments that need extra configuration on authorization requests — e.g.
gateway headers on well-known endpoints — set the new `oauthRequestInit` transport
option, which is merged into authorization requests only:

```ts
const transport = new StreamableHTTPClientTransport(url, {
authProvider,
requestInit: { headers: { 'X-Api-Key': mcpGatewayKey } }, // MCP requests only
oauthRequestInit: { headers: { 'X-Api-Key': authGatewayKey } } // OAuth requests only
});
```

`oauthRequestInit` headers follow the same per-hop rule as the rest of the discovery
path: when a discovery request is redirected to a different origin, the redirected
request is issued without them — headers never travel across origins on a redirect.

#### Scope step-up on `403 insufficient_scope` (SEP-2350)

`StreamableHTTPClientTransport` accepts `onInsufficientScope: 'reauthorize' | 'throw'`
Expand Down
23 changes: 23 additions & 0 deletions packages/client/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,28 @@ export default [
settings: {
'import/internal-regex': '^@modelcontextprotocol/core-internal'
}
},
{
// Every request in the OAuth client flow (discovery GETs, token and
// registration POSTs — including the Cross-App Access token exchanges)
// must pass the URL policy (assertAllowedDiscoveryUrl) before any network
// I/O. fetchDiscoveryUrl applies it to the initial URL and to every
// redirect hop, so it is the only place raw fetch calls live.
files: ['src/client/auth.ts', 'src/client/crossAppAccess.ts'],
rules: {
'no-restricted-syntax': [
'error',
{
selector: "CallExpression[callee.name=/^(fetch|fetchFn)$/]:not(FunctionDeclaration[id.name='fetchDiscoveryUrl'] *)",
message:
'Route OAuth-flow requests through fetchDiscoveryUrl so the URL policy (assertAllowedDiscoveryUrl) and manual redirect handling apply before any request.'
},
{
selector: "CallExpression[callee.type='LogicalExpression'][callee.left.name='fetchFn'][callee.right.name='fetch']",
message:
'Direct (fetchFn ?? fetch)(...) calls bypass the URL policy; route the request through fetchDiscoveryUrl instead.'
}
]
}
}
];
Loading
Loading