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
125 changes: 120 additions & 5 deletions docs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,32 @@ A client connects to a server, discovers what it offers — tools, resources, pr
The examples below use these imports. Adjust based on which features and transport you need:

```ts source="../examples/guides/clientGuide.examples.ts#imports"
import type { AuthProvider } from '@modelcontextprotocol/client';
import type {
AuthProvider,
OAuthClientInformationContext,
OAuthClientInformationMixed,
OAuthClientMetadata,
OAuthClientProvider,
OAuthDiscoveryState,
OAuthTokens
} from '@modelcontextprotocol/client';
import {
applyMiddlewares,
Client,
ClientCredentialsProvider,
createMiddleware,
CrossAppAccessProvider,
discoverAndRequestJwtAuthGrant,
IssuerMismatchError,
PrivateKeyJwtProvider,
ProtocolError,
SdkError,
SdkErrorCode,
SSEClientTransport,
StreamableHTTPClientTransport,
TRACEPARENT_META_KEY,
TRACESTATE_META_KEY
TRACESTATE_META_KEY,
UnauthorizedError
} from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
```
Expand Down Expand Up @@ -191,9 +201,114 @@ Server only implements `client_secret_basic`/`client_secret_post`, so there is n

### Full OAuth with user authorization

For user-facing applications, implement the {@linkcode @modelcontextprotocol/client!client/auth.OAuthClientProvider | OAuthClientProvider} interface to handle the full authorization code flow (redirects, code verifiers, token storage, dynamic client registration). The {@linkcode
@modelcontextprotocol/client!client/client.Client#connect | connect()} call will throw {@linkcode @modelcontextprotocol/client!client/auth.UnauthorizedError | UnauthorizedError} when authorization is needed — catch it, complete the browser flow, pass the redirect URL's query to {@linkcode
@modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport#finishAuth | transport.finishAuth(url.searchParams)} (so the SDK can validate the RFC 9207 `iss` parameter), and reconnect.
For user-facing applications, implement the {@linkcode @modelcontextprotocol/client!client/auth.OAuthClientProvider | OAuthClientProvider} interface to handle the full authorization code flow (redirects, code verifiers, token storage, dynamic client registration). Key persisted
client credentials by the `ctx.issuer` passed to `clientInformation()` / `saveClientInformation()` so credentials registered with one authorization server are never sent to another:

```ts source="../examples/guides/clientGuide.examples.ts#auth_oauthClientProvider"
class MyOAuthProvider implements OAuthClientProvider {
// Key DCR-obtained credentials by issuer so a client_id registered with one
// authorization server is never returned for another (SEP-2352).
private creds = new Map<string, OAuthClientInformationMixed>();
private storedTokens?: OAuthTokens;
private verifier?: string;
private discovery?: OAuthDiscoveryState;
lastState?: string;

readonly redirectUrl = 'http://localhost:8090/callback';
readonly clientMetadata: OAuthClientMetadata = {
client_name: 'My MCP Client',
redirect_uris: ['http://localhost:8090/callback'],
// Loopback redirect → the SDK would default this to 'native'; set
// explicitly when the heuristic is wrong for your deployment (SEP-837).
application_type: 'native'
};

clientInformation(ctx?: OAuthClientInformationContext) {
return ctx ? this.creds.get(ctx.issuer) : undefined;
}
saveClientInformation(info: OAuthClientInformationMixed, ctx?: OAuthClientInformationContext) {
if (ctx) this.creds.set(ctx.issuer, info);
}
tokens() {
return this.storedTokens;
}
saveTokens(tokens: OAuthTokens) {
// In production, persist to OS keychain / secure storage — never plain files.
this.storedTokens = tokens;
}
// CSRF binding for the redirect — the SDK puts this on the authorize URL;
// your callback handler compares it before calling `finishAuth`.
state() {
this.lastState = crypto.randomUUID();
return this.lastState;
}
// Callback-leg AS-binding (SEP-2352): record what discovery resolved before
// the redirect so the SDK can verify the code is exchanged at the same AS.
saveDiscoveryState(state: OAuthDiscoveryState) {
this.discovery = state;
}
discoveryState() {
return this.discovery;
}
redirectToAuthorization(url: URL) {
onRedirect(url);
}
saveCodeVerifier(v: string) {
this.verifier = v;
}
codeVerifier() {
if (!this.verifier) throw new Error('no code verifier');
return this.verifier;
}
}

const provider = new MyOAuthProvider();
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), {
authProvider: provider
});
```

The {@linkcode @modelcontextprotocol/client!client/client.Client#connect | connect()} call throws {@linkcode @modelcontextprotocol/client!client/auth.UnauthorizedError | UnauthorizedError} when authorization is needed — catch it, complete the browser flow, hand the callback query
to {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport#finishAuth | transport.finishAuth()}, and reconnect. Passing the whole `URLSearchParams` lets the SDK extract `code` and validate the RFC 9207 `iss` parameter for you:

```ts source="../examples/guides/clientGuide.examples.ts#auth_finishAuth"
const client = new Client({ name: 'my-client', version: '1.0.0' });
const transport = new StreamableHTTPClientTransport(url, { authProvider: provider });
try {
await client.connect(transport);
return client;
} catch (error) {
Comment thread
felixweinberger marked this conversation as resolved.
// With version negotiation, the connect-time 401 may surface wrapped as
// SdkError(EraNegotiationFailed) whose .data.cause is the UnauthorizedError.
const root = error instanceof UnauthorizedError ? error : (error as { data?: { cause?: unknown } }).data?.cause;
if (!(root instanceof UnauthorizedError)) throw error;
// The transport called redirectToAuthorization(); fall through to the browser callback.
}

const callbackUrl = await waitForCallback();
const params = new URL(callbackUrl).searchParams;

// The SDK does not validate `state` — compare it to the value your provider generated.
if (params.get('state') !== provider.lastState) throw new Error('state mismatch');

try {
// Preferred: hand over the whole query — the SDK extracts `code` and
// `iss`, validates `iss` (RFC 9207), and never surfaces callback-derived
// `error`/`error_description` text on mismatch.
await transport.finishAuth(params);
} catch (error) {
if (error instanceof IssuerMismatchError) {
// Mix-up attack: do NOT render params.get('error_description') to the user.
throw new Error('Authorization failed: issuer mismatch');
}
throw error;
}

// Reconnect on a FRESH transport — a started transport cannot be restarted;
// OAuth state (tokens, verifier, discovery) lives on the provider, not the transport.
await client.connect(new StreamableHTTPClientTransport(url, { authProvider: provider }));
return client;
```

For a complete working OAuth flow, see [`simpleOAuthClient.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleOAuthClient.ts) and
[`simpleOAuthClientProvider.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/oauth/simpleOAuthClientProvider.ts).
Expand Down
29 changes: 19 additions & 10 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1580,20 +1580,21 @@ client logic; key off the HTTP `404` status instead.

## Authorization (2026-07-28 spec)

The 2026-07-28 protocol revision adds client-side authorization requirements (RFC 9207 `iss` validation, RFC 8414 §3.3 issuer-echo, per-authorization-server credential isolation, scope step-up, DCR `application_type`, and refresh-token guidance). The SDK adds the public surface for these now and will implement the parts that land in SDK code (defaulting them on) as the SEP-2468/2352/2350/837/2207 behavior changes land; the parts that live in your `OAuthClientProvider` implementation, your `clientMetadata`, or your host UI are listed under [Conformance obligations for `OAuthClientProvider` implementers](#conformance-obligations-for-oauthclientprovider-implementers).
The 2026-07-28 protocol revision adds client-side authorization requirements (RFC 9207 `iss` validation, RFC 8414 §3.3 issuer-echo, per-authorization-server credential isolation, scope step-up, DCR `application_type`, and refresh-token guidance). The SDK implements the parts that land in SDK code and defaults them on; the parts that live in your `OAuthClientProvider` implementation, your `clientMetadata`, or your host UI are listed under [Conformance obligations for `OAuthClientProvider` implementers](#conformance-obligations-for-oauthclientprovider-implementers).

### `auth()` options are now `AuthOptions`

The inline options object on `auth()` is now the named `AuthOptions` type, exported from `@modelcontextprotocol/client`. Existing call sites need no change. New fields (both currently inert — the validation behavior they feed lands in the follow-up changes tracked by SEP-2468):
The inline options object on `auth()` is now the named `AuthOptions` type, exported from `@modelcontextprotocol/client`. Existing call sites need no change. New fields:

- `iss?: string` — the form-urldecoded `iss` query parameter from the authorization callback. Pass it alongside `authorizationCode`; it is forwarded to RFC 9207 issuer validation once that lands.
- `skipIssuerMetadataValidation?: boolean` — opt-out for the RFC 8414 §3.3 issuer-echo check during discovery. **Security-weakening**; use only with authorization servers known to publish a mismatched `issuer`.
- `iss?: string` — the form-urldecoded `iss` query parameter from the authorization callback. Pass it alongside `authorizationCode` so the SDK can validate it per RFC 9207 before redeeming the code.
- `skipIssuerMetadataValidation?: boolean` — opt-out of the RFC 8414 §3.3 issuer-echo check during discovery. **Security-weakening**; use only with authorization servers known to publish a mismatched `issuer`.
- `forceReauthorization?: boolean` — skip the refresh-token branch and force a fresh authorization request. Set by the transport's step-up path when the required scope strictly exceeds the current token's; hosts driving step-up themselves set it under the same condition. See [Scope step-up](#scope-step-up-on-403-insufficient_scope-sep-2350).
Comment thread
felixweinberger marked this conversation as resolved.

### `OAuthClientProvider` credential methods receive an `issuer` context

`clientInformation(ctx?)`, `saveClientInformation(info, ctx?)`, `tokens(ctx?)`, and `saveTokens(tokens, ctx?)` now receive an optional `OAuthClientInformationContext` parameter carrying `{ issuer: string }` — the authorization server's `issuer` identifier. Providers that persist credentials should key storage by this value so that credentials registered with one authorization server are never sent to another. Providers with a single credential set may ignore the parameter; existing implementations compile unchanged. The SDK does not yet pass this argument; it begins doing so when the SEP-2352 behavior change lands.
`clientInformation(ctx?)`, `saveClientInformation(info, ctx?)`, `tokens(ctx?)`, and `saveTokens(tokens, ctx?)` now receive an optional `OAuthClientInformationContext` parameter carrying `{ issuer: string }` — the authorization server's `issuer` identifier. Providers that persist credentials should key storage by this value so that credentials registered with one authorization server are never sent to another. Providers with a single credential set may ignore the parameter; existing implementations compile unchanged.

New TypeScript-only aliases `StoredOAuthTokens` and `StoredOAuthClientInformation` add an optional `issuer?: string` field on top of the wire types and are used as the parameter/return types of `tokens()` / `saveTokens()` and `clientInformation()` / `saveClientInformation()`. The `issuer` field is **not** part of the RFC 6749/7591 wire responses and is intentionally absent from `OAuthTokensSchema` / `OAuthClientInformationSchema` so an authorization server cannot populate it; once the SEP-2352 behavior change lands the SDK will stamp it onto credentials before calling `saveTokens` / `saveClientInformation`. Provider implementations should round-trip it unchanged. The field is currently inert.
New TypeScript-only aliases `StoredOAuthTokens` and `StoredOAuthClientInformation` add an optional `issuer?: string` field on top of the wire types and are used as the parameter/return types of `tokens()` / `saveTokens()` and `clientInformation()` / `saveClientInformation()`. The `issuer` field is **not** part of the RFC 6749/7591 wire responses and is intentionally absent from `OAuthTokensSchema` / `OAuthClientInformationSchema` so an authorization server cannot populate it; the SDK stamps it onto credentials before calling `saveTokens` / `saveClientInformation`. Provider implementations should round-trip it unchanged. See [Per-authorization-server credential isolation](#per-authorization-server-credential-isolation-sep-2352) for how the stamp is used.

### Authorization-server mix-up defense (RFC 9207 / RFC 8414 §3.3)

Expand Down Expand Up @@ -1647,13 +1648,21 @@ The bundled `ClientCredentialsProvider`, `PrivateKeyJwtProvider`, `StaticPrivate

### Conformance obligations for `OAuthClientProvider` implementers

<!-- Filled in as the SEP-2352/2350/837/2207 behavior PRs land. -->
The SDK enforces every 2026-07-28 authorization MUST that lands in SDK code. The obligations below live in **your** `OAuthClientProvider` implementation, your `clientMetadata`, your host UI, or your resource-server configuration — the SDK structurally cannot enforce them. Each links to the example that demonstrates the conformant pattern.
Comment thread
felixweinberger marked this conversation as resolved.

#### SEP-2352 — per-authorization-server credential isolation
- **SEP-2352 — round-trip the `issuer` stamp on persisted credentials.** `saveTokens()` and `saveClientInformation()` receive values with an SDK-stamped `issuer` field; persist the value verbatim and return it verbatim from `tokens()` / `clientInformation()` and the binding holds — the SDK discards a stored value whose stamp names a different authorization server. If you serialise to a custom format, persist `issuer` alongside the rest. To hold credentials for several authorization servers at once, key your storage on `ctx.issuer` and return `undefined` for an issuer you have no entry for — but when `ctx === undefined` (the transport's per-request bearer read), return the most-recently-saved token set. You **SHOULD** implement `discoveryState()` / `saveDiscoveryState()` so the callback leg can verify it is exchanging the authorization code at the same AS the redirect targeted; without them the SDK `console.warn`s once per callback (RFC 9207 `iss` validation independently protects this leg when the AS emits `iss`). See [`examples/oauth/simpleOAuthClientProvider.ts`](../examples/oauth/simpleOAuthClientProvider.ts) for the reference pattern.

**No code change required for the common case.** If your `saveTokens()` / `saveClientInformation()` persist the value passed to them verbatim and your `tokens()` / `clientInformation()` return it verbatim, the SDK-stamped `issuer` round-trips and the binding holds.
- **SEP-2352 — pass `expectedIssuer` when supplying static client credentials.** Hosts that construct `ClientCredentialsProvider`, `PrivateKeyJwtProvider`, `StaticPrivateKeyJwtProvider`, or `CrossAppAccessProvider` with a constructor-supplied `client_secret` (or pre-signed assertion) **SHOULD** pass the new `expectedIssuer` option naming the authorization server those credentials were registered with. Without it, the credential is sent to whatever authorization server the protected resource advertises on first contact; with it, a mismatch fails before the credential leaves the process.

If you serialise to a custom format, persist the `issuer` field alongside the rest of the value. If you key storage by `ctx.issuer`, return `undefined` for an issuer you have no entry for, and treat **`ctx === undefined` as "return the most-recently-saved token set"** — the transport's per-request `Authorization: Bearer` read (`adaptOAuthProvider().token()`) calls `tokens()` with no `ctx`.
- **SEP-2207 — keep refresh tokens confidential in storage.** The SDK enforces in-transit confidentiality via the [`https:` token-endpoint guard](#token-endpoint-must-use-tls-sep-2207); in-storage confidentiality is your `saveTokens()` implementation. Use platform-appropriate secure storage (OS keychain, encrypted-at-rest store) — never persist `refresh_token` to plain files, `localStorage`, or logs.

- **SEP-2468 — extract `iss` from the callback URL and pass it to `finishAuth`.** Your callback handler must read the `iss` query parameter alongside `code` and call `transport.finishAuth(code, iss)` — or hand the whole `URLSearchParams` to the [overload](#authorization-server-mix-up-defense-rfc-9207--rfc-8414-33). The SDK validates the value but cannot extract it from a URL it never sees. When `IssuerMismatchError` is thrown, **do not** render the callback's raw `error` / `error_description` / `error_uri` in your UI — those values are attacker-controlled in a mix-up attack. See [`examples/oauth/simpleOAuthClient.ts`](../examples/oauth/simpleOAuthClient.ts) for the extraction pattern.

- **SEP-837 — set `application_type` correctly when overriding the heuristic.** The SDK defaults `clientMetadata.application_type` from your `redirect_uris` (loopback / custom scheme → `'native'`, else `'web'`). When the heuristic is wrong for your deployment — a web app dev-served on `localhost`, a native app with an `https:` claimed redirect — set the field explicitly; the SDK never overwrites a value you set but cannot know your deployment shape. See [Dynamic Client Registration defaults](#dynamic-client-registration-application_type-and-grant_types-defaults-sep-837-sep-2207).

- **SEP-2350 — track cross-request step-up failures yourself.** The SDK caps step-up retries **per request** (`maxStepUpRetries`). Tracking "this (resource, operation) has already failed step-up _N_ times across the session" — to back off, surface an error, or stop prompting the user — is host state the SDK has no visibility into. See the `client-auth:stepup:*` scenarios in [`test/e2e/scenarios/client-auth.test.ts`](../test/e2e/scenarios/client-auth.test.ts) for the transport-driven step-up flow.

- **SEP-2207 (resource-server operators) — do not advertise `offline_access` from the RS.** A resource server SHOULD NOT include `offline_access` in its `WWW-Authenticate` `scope` challenge or in its protected-resource metadata `scopes_supported` — refresh-token issuance is between the client and the authorization server. This is operator configuration of whatever serves your `WWW-Authenticate` header and PRM document, not SDK code.

## Using an LLM to migrate your code

Expand Down
Loading
Loading