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: 0 additions & 6 deletions .changeset/await-notification-send.md

This file was deleted.

5 changes: 0 additions & 5 deletions .changeset/client-cjs-types-inline-jose.md

This file was deleted.

5 changes: 0 additions & 5 deletions .changeset/codemod-preserve-file-header.md

This file was deleted.

5 changes: 0 additions & 5 deletions .changeset/correct-token-endpoint-tls-citation.md

This file was deleted.

5 changes: 0 additions & 5 deletions .changeset/curvy-rivers-restore.md

This file was deleted.

5 changes: 0 additions & 5 deletions .changeset/dcr-deprecation-citation.md

This file was deleted.

5 changes: 0 additions & 5 deletions .changeset/input-required-meta-passthrough.md

This file was deleted.

5 changes: 0 additions & 5 deletions .changeset/keep-next-cursor-on-early-stop.md

This file was deleted.

5 changes: 0 additions & 5 deletions .changeset/listen-opening-unhandled-rejection.md

This file was deleted.

5 changes: 0 additions & 5 deletions .changeset/loopback-localhost-subdomains.md

This file was deleted.

13 changes: 7 additions & 6 deletions docs/clients/machine-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import { Client, ClientCredentialsProvider, StreamableHTTPClientTransport } from

const authProvider = new ClientCredentialsProvider({
clientId: 'reporting-job',
clientSecret: 'reporting-job-secret'
clientSecret: 'reporting-job-secret',
expectedIssuer: 'https://auth.example.com'
});

const client = new Client({ name: 'reporting-job', version: '1.0.0' });
Expand All @@ -26,9 +27,7 @@ await client.connect(transport);

`connect` discovers the server's authorization server, posts the grant to its token endpoint, and attaches the access token to every request. On a 401 the provider refreshes the token and the transport retries once. No browser, no end user.

::: tip
Pass `expectedIssuer` to pin the credential to the authorization server it was registered with. If discovery resolves a different issuer, the SDK throws `AuthorizationServerMismatchError` instead of sending the secret.
:::
`expectedIssuer` is the `issuer` of the authorization server the credential was registered with. If discovery resolves a different issuer, the SDK throws `AuthorizationServerMismatchError` instead of sending the secret. `PrivateKeyJwtProvider` and `CrossAppAccessProvider` below take it too; omitting it is deprecated, and the credential then goes to whichever authorization server the MCP server advertises.

## Bring your own bearer token

Expand All @@ -50,7 +49,8 @@ The transport calls `token()` before every request and sets the `Authorization`
const authProvider = new PrivateKeyJwtProvider({
clientId: 'reporting-job',
privateKey: pemEncodedKey,
algorithm: 'RS256'
algorithm: 'RS256',
expectedIssuer: 'https://auth.example.com'
});

const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider });
Expand Down Expand Up @@ -80,7 +80,8 @@ const authProvider = new CrossAppAccessProvider({
return grant.jwtAuthGrant;
},
clientId: 'reporting-job',
clientSecret: 'reporting-job-secret'
clientSecret: 'reporting-job-secret',
expectedIssuer: 'https://auth.example.com'
});

const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider });
Expand Down
13 changes: 8 additions & 5 deletions docs/migration/upgrade-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -1155,6 +1155,7 @@ path will not catch them):
| RFC 9207 `iss` mismatch / RFC 8414 §3.3 issuer-echo mismatch | `IssuerMismatchError` (`kind`, `expected`, `received`) |
| Transport 403 `insufficient_scope` with `onInsufficientScope: 'throw'`, or default mode without an `OAuthClientProvider` | `InsufficientScopeError` (`requiredScope`, `resourceMetadataUrl`, `errorDescription`) |
| `auth()` callback leg: discovery resolves a different AS than the recorded redirect target | `AuthorizationServerMismatchError` (`recordedIssuer`, `currentIssuer`) |
| `auth()` on a provider that cannot re-register, or `fetchToken()`: client information stamped for a different AS | `AuthorizationServerMismatchError` (`recordedIssuer`, `currentIssuer`) |
#### Connect-time OAuth retry (`UnauthorizedError`)
Expand Down Expand Up @@ -1268,7 +1269,8 @@ same handling as the POST send path.
`auth()` stamps an `issuer` field onto every value it passes to `saveTokens()` /
`saveClientInformation()` and threads `{ issuer }` as the `ctx` argument to those
methods plus `tokens()` / `clientInformation()`. On read, a stored value whose `issuer`
names a different AS is treated as `undefined` and the flow re-registers / re-authorizes.
names a different AS is treated as `undefined` and the flow re-registers / re-authorizes
(or throws `AuthorizationServerMismatchError` when the provider has no `saveClientInformation()`).
**Round-trip the stored object verbatim and you're protected** — single-slot storage
works. Dropping the stamp is easy to miss: a `saveTokens()` implementation that
rebuilds the object field-by-field and drops `issuer` leaves the value unstamped —
Expand All @@ -1278,14 +1280,15 @@ re-stamps on first use where the provider can persist it). If you see that warni
repeating after upgrading, check this first. To hold credentials for several authorization servers at once, key your storage
on `ctx.issuer` (treat **`ctx === undefined` as "return the most-recently-saved token
set"** — the transport's per-request `Authorization: Bearer` read calls `tokens()` with
no `ctx`). New TypeScript-only aliases `StoredOAuthTokens` / `StoredOAuthClientInformation`
add an optional `issuer?: string` field on top of the wire types.
no `ctx`). `OAuthTokensSchema` / `OAuthClientInformationSchema` keep the optional `issuer`, so
reading storage back through them is fine; the `StoredOAuthTokens` / `StoredOAuthClientInformation`
aliases name the stored shape.
`OAuthClientProvider.saveAuthorizationServerUrl()` / `authorizationServerUrl()` are
`@deprecated` (still written for back-compat, never read by the SDK). The bundled
`ClientCredentialsProvider`, `PrivateKeyJwtProvider`, `StaticPrivateKeyJwtProvider`, and
`CrossAppAccessProvider` gain `expectedIssuer?: string` and no longer define
`saveClientInformation()`. Implement `discoveryState()` / `saveDiscoveryState()` so the
`CrossAppAccessProvider` gain `expectedIssuer?: string` (omitting it is deprecated) and no
longer define `saveClientInformation()`. Implement `discoveryState()` / `saveDiscoveryState()` so the
callback leg can verify it is exchanging the code at the same AS the redirect targeted;
without it the SDK `console.warn`s once per callback (`discoveryState` must persist with
the same durability as `codeVerifier`). Both methods are optional on
Expand Down
9 changes: 6 additions & 3 deletions examples/guides/clients/machine-auth.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ import { Client, ClientCredentialsProvider, StreamableHTTPClientTransport } from

const authProvider = new ClientCredentialsProvider({
clientId: 'reporting-job',
clientSecret: 'reporting-job-secret'
clientSecret: 'reporting-job-secret',
expectedIssuer: 'https://auth.example.com'
});

const client = new Client({ name: 'reporting-job', version: '1.0.0' });
Expand All @@ -48,7 +49,8 @@ function privateKeyJwt_provider(pemEncodedKey: string) {
const authProvider = new PrivateKeyJwtProvider({
clientId: 'reporting-job',
privateKey: pemEncodedKey,
algorithm: 'RS256'
algorithm: 'RS256',
expectedIssuer: 'https://auth.example.com'
});

const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider });
Expand Down Expand Up @@ -76,7 +78,8 @@ function crossAppAccess_provider(getIdToken: () => Promise<string>) {
return grant.jwtAuthGrant;
},
clientId: 'reporting-job',
clientSecret: 'reporting-job-secret'
clientSecret: 'reporting-job-secret',
expectedIssuer: 'https://auth.example.com'
});

const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider });
Expand Down
3 changes: 2 additions & 1 deletion examples/oauth-client-credentials/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ import { PrivateKeyJwtProvider } from '@modelcontextprotocol/client';
const authProvider = new PrivateKeyJwtProvider({
clientId: 'my-service',
privateKey: pemEncodedKey,
algorithm: 'RS256'
algorithm: 'RS256',
expectedIssuer: 'https://auth.example.com'
});
```

Expand Down
7 changes: 6 additions & 1 deletion examples/oauth-client-credentials/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
* granted scopes.
*
* No browser, no readline. The SDK's auth driver does the discovery; the only
* thing the caller supplies is the pre-registered client's id+secret.
* thing the caller supplies is the pre-registered client's id+secret and the
* issuer of the AS it is registered with.
*/
import { check, parseExampleArgs } from '@mcp-examples/shared';
import { Client, ClientCredentialsProvider, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
Expand All @@ -28,9 +29,13 @@ check.match(unauth.headers.get('www-authenticate') ?? '', /Bearer/);
check.match(unauth.headers.get('www-authenticate') ?? '', /oauth-protected-resource/);

// Authenticated via client_credentials → 200, ctx.authInfo carries the granted scopes.
// `expectedIssuer` names the AS the client is registered with (the story's AS listens on PORT+1).
const authServerUrl = new URL('/', url);
authServerUrl.port = String(Number(authServerUrl.port) + 1);
const provider = new ClientCredentialsProvider({
clientId: 'demo-m2m-client',
clientSecret: 'demo-m2m-secret',
expectedIssuer: authServerUrl.href,
scope: 'mcp:tools mcp:read'
});
const client = new Client(
Expand Down
16 changes: 11 additions & 5 deletions examples/oauth/dualModeAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,16 @@ function createHostManagedTransport(serverUrl: URL, session: HostSessionStore, u

// --- MODE B: User-configured OAuth -----------------------------------------

function createUserConfiguredTransport(serverUrl: URL, clientId: string, clientSecret: string): StreamableHTTPClientTransport {
function createUserConfiguredTransport(
serverUrl: URL,
clientId: string,
clientSecret: string,
expectedIssuer: string
): StreamableHTTPClientTransport {
// Built-in OAuth provider — the transport adapts it to AuthProvider internally.
// On 401, adaptOAuthProvider synthesizes onUnauthorized → handleOAuthUnauthorized,
// which runs token refresh (or redirect for interactive flows).
const authProvider = new ClientCredentialsProvider({ clientId, clientSecret });
const authProvider = new ClientCredentialsProvider({ clientId, clientSecret, expectedIssuer });

return new StreamableHTTPClientTransport(serverUrl, { authProvider });
}
Expand Down Expand Up @@ -92,11 +97,12 @@ async function main() {
} else if (mode === 'oauth') {
const clientId = process.env.OAUTH_CLIENT_ID;
const clientSecret = process.env.OAUTH_CLIENT_SECRET;
if (!clientId || !clientSecret) {
console.error('OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET required for oauth mode');
const expectedIssuer = process.env.OAUTH_EXPECTED_ISSUER;
if (!clientId || !clientSecret || !expectedIssuer) {
console.error('OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET and OAUTH_EXPECTED_ISSUER required for oauth mode');
process.exit(1);
}
transport = createUserConfiguredTransport(serverUrl, clientId, clientSecret);
transport = createUserConfiguredTransport(serverUrl, clientId, clientSecret, expectedIssuer);
} else {
console.error(`Unknown mode: ${mode}. Use 'host' or 'oauth'.`);
process.exit(1);
Expand Down
Loading
Loading