Skip to content

Commit e9bfdfd

Browse files
committed
Merge branch 'v1.x' into claude/oauth-tokens-null-fields-v1x
Resolves conflicts with the #2888 issuer-binding backport, which added a client-only issuer member to OAuthTokensSchema and parsed wire responses through local OAuthTokensSchema.omit({ issuer: true }) schemas. The resolution folds that omission into OAuthTokenResponseSchema itself: the exported wrapper now validates against the issuer-less wire shape, so an authorization server can neither slip null-valued optional members past validation nor inject the issuer value the client binds stored credentials to. The three parse sites (executeTokenRequest and both proxy provider exchanges) keep using OAuthTokenResponseSchema, and the now-redundant local TokenResponseSchema constants are removed. The exported OAuthTokensSchema remains byte-identical to v1.x. Both test additions are kept: this branch's null-optional-field proxy provider suite and v1.x's issuer-forwarding rejection cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KXnNnp3fxQYR5HF9BUVhYP
2 parents eb19889 + 51ad4f0 commit e9bfdfd

21 files changed

Lines changed: 1327 additions & 64 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@modelcontextprotocol/sdk': patch
3+
---
4+
5+
Bind stored OAuth client credentials to the authorization server that issued them. `auth()` adds an `issuer` property to what it passes to `saveClientInformation()` / `saveTokens()` and does not reuse a stored value with a different authorization server: the client registers or
6+
authorizes again, or `auth()` throws when the credentials cannot be re-created by registering. `OAuthTokensSchema` and `OAuthClientInformationSchema` accept the optional `issuer`. `ClientCredentialsProvider`, `PrivateKeyJwtProvider` and `StaticPrivateKeyJwtProvider` accept
7+
`expectedIssuer`; omitting it is deprecated.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@modelcontextprotocol/sdk': patch
3+
---
4+
5+
Preserve the exact OAuth resource indicator from protected resource metadata when building authorization and token requests. Previously a pathless `resource` such as `https://example.com` was normalized to `https://example.com/` via `URL.href`, which breaks authorization servers
6+
that require the `resource` parameter to match the published value exactly (Microsoft Entra ID rejects it with `AADSTS9010010`). The exported OAuth helpers (`startAuthorization`, `exchangeAuthorization`, `refreshAuthorization`, `fetchToken`) now also accept a `string` for
7+
`resource`; `selectResourceURL` still returns a `URL`, and a provider's `validateResourceURL` result is used unchanged. Fixes #1968.

‎docs/client.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ For OAuth-secured MCP servers, the client `auth` module exposes:
4646
- `PrivateKeyJwtProvider`
4747
- `StaticPrivateKeyJwtProvider`
4848

49+
Pass `expectedIssuer` (the issuer URL of the authorization server the credentials were registered with) to each of these. `auth()` then only presents the credentials to that authorization server and throws if the MCP server advertises a different one. Omitting it is deprecated.
50+
51+
If you implement `OAuthClientProvider` yourself, store the objects passed to `saveClientInformation()` and `saveTokens()` unchanged: they carry an `issuer` property naming the authorization server they came from, and `auth()` does not reuse them with a different one. If
52+
`clientInformation()` returns pre-registered credentials, include `issuer` yourself.
53+
4954
Examples:
5055

5156
- [`simpleOAuthClient.ts`](../src/examples/client/simpleOAuthClient.ts)

‎package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@modelcontextprotocol/sdk",
3-
"version": "1.30.0",
3+
"version": "1.30.1",
44
"description": "Model Context Protocol implementation for TypeScript",
55
"license": "MIT",
66
"author": "Anthropic, PBC (https://anthropic.com)",

‎src/client/auth-extensions.ts‎

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,24 @@ export function createPrivateKeyJwtAuth(options: {
9090
};
9191
}
9292

93+
/**
94+
* The `issuer` stamp for constructor-supplied client information. Omitting `expectedIssuer` is
95+
* deprecated: nothing else says which authorization server such credentials belong to.
96+
*/
97+
function checkedExpectedIssuer(expectedIssuer: string | undefined): string | undefined {
98+
if (expectedIssuer === undefined) {
99+
// eslint-disable-next-line no-console
100+
console.warn(
101+
'[mcp-sdk] Omitting `expectedIssuer` is deprecated. Without it, the MCP server decides which authorization ' +
102+
"server receives this client's credentials; pass your authorization server's issuer URL as " +
103+
'`expectedIssuer` so they are only sent there.'
104+
);
105+
} else if (typeof expectedIssuer !== 'string' || !expectedIssuer) {
106+
throw new Error("expectedIssuer must be the authorization server's issuer URL");
107+
}
108+
return expectedIssuer;
109+
}
110+
93111
/**
94112
* Options for creating a ClientCredentialsProvider.
95113
*/
@@ -113,6 +131,16 @@ export interface ClientCredentialsProviderOptions {
113131
* Space-separated scopes values requested by the client.
114132
*/
115133
scope?: string;
134+
135+
/**
136+
* The issuer URL of the authorization server these credentials were registered with.
137+
* When set, `auth()` only presents them to that authorization server and throws if the
138+
* MCP server advertises a different URL (one trailing slash aside).
139+
*
140+
* Omitting it is deprecated: the credentials then go to whichever authorization server
141+
* the MCP server advertises first.
142+
*/
143+
expectedIssuer?: string;
116144
}
117145

118146
/**
@@ -124,7 +152,8 @@ export interface ClientCredentialsProviderOptions {
124152
* @example
125153
* const provider = new ClientCredentialsProvider({
126154
* clientId: 'my-client',
127-
* clientSecret: 'my-secret'
155+
* clientSecret: 'my-secret',
156+
* expectedIssuer: 'https://auth.example.com'
128157
* });
129158
*
130159
* const transport = new StreamableHTTPClientTransport(serverUrl, {
@@ -136,10 +165,14 @@ export class ClientCredentialsProvider implements OAuthClientProvider {
136165
private _clientInfo: OAuthClientInformation;
137166
private _clientMetadata: OAuthClientMetadata;
138167

168+
constructor(options: ClientCredentialsProviderOptions & { expectedIssuer: string });
169+
/** @deprecated Pass `expectedIssuer` so the credentials are only sent to that authorization server. */
170+
constructor(options: ClientCredentialsProviderOptions);
139171
constructor(options: ClientCredentialsProviderOptions) {
140172
this._clientInfo = {
141173
client_id: options.clientId,
142-
client_secret: options.clientSecret
174+
client_secret: options.clientSecret,
175+
issuer: checkedExpectedIssuer(options.expectedIssuer)
143176
};
144177
this._clientMetadata = {
145178
client_name: options.clientName ?? 'client-credentials-client',
@@ -227,6 +260,16 @@ export interface PrivateKeyJwtProviderOptions {
227260
* Space-separated scopes values requested by the client.
228261
*/
229262
scope?: string;
263+
264+
/**
265+
* The issuer URL of the authorization server these credentials were registered with.
266+
* When set, `auth()` only presents them to that authorization server and throws if the
267+
* MCP server advertises a different URL (one trailing slash aside).
268+
*
269+
* Omitting it is deprecated: the credentials then go to whichever authorization server
270+
* the MCP server advertises first.
271+
*/
272+
expectedIssuer?: string;
230273
}
231274

232275
/**
@@ -239,7 +282,8 @@ export interface PrivateKeyJwtProviderOptions {
239282
* const provider = new PrivateKeyJwtProvider({
240283
* clientId: 'my-client',
241284
* privateKey: pemEncodedPrivateKey,
242-
* algorithm: 'RS256'
285+
* algorithm: 'RS256',
286+
* expectedIssuer: 'https://auth.example.com'
243287
* });
244288
*
245289
* const transport = new StreamableHTTPClientTransport(serverUrl, {
@@ -252,9 +296,13 @@ export class PrivateKeyJwtProvider implements OAuthClientProvider {
252296
private _clientMetadata: OAuthClientMetadata;
253297
addClientAuthentication: AddClientAuthentication;
254298

299+
constructor(options: PrivateKeyJwtProviderOptions & { expectedIssuer: string });
300+
/** @deprecated Pass `expectedIssuer` so the credentials are only sent to that authorization server. */
301+
constructor(options: PrivateKeyJwtProviderOptions);
255302
constructor(options: PrivateKeyJwtProviderOptions) {
256303
this._clientInfo = {
257-
client_id: options.clientId
304+
client_id: options.clientId,
305+
issuer: checkedExpectedIssuer(options.expectedIssuer)
258306
};
259307
this._clientMetadata = {
260308
client_name: options.clientName ?? 'private-key-jwt-client',
@@ -341,6 +389,16 @@ export interface StaticPrivateKeyJwtProviderOptions {
341389
* Space-separated scopes values requested by the client.
342390
*/
343391
scope?: string;
392+
393+
/**
394+
* The issuer URL of the authorization server these credentials were registered with.
395+
* When set, `auth()` only presents them to that authorization server and throws if the
396+
* MCP server advertises a different URL (one trailing slash aside).
397+
*
398+
* Omitting it is deprecated: the credentials then go to whichever authorization server
399+
* the MCP server advertises first.
400+
*/
401+
expectedIssuer?: string;
344402
}
345403

346404
/**
@@ -356,9 +414,13 @@ export class StaticPrivateKeyJwtProvider implements OAuthClientProvider {
356414
private _clientMetadata: OAuthClientMetadata;
357415
addClientAuthentication: AddClientAuthentication;
358416

417+
constructor(options: StaticPrivateKeyJwtProviderOptions & { expectedIssuer: string });
418+
/** @deprecated Pass `expectedIssuer` so the credentials are only sent to that authorization server. */
419+
constructor(options: StaticPrivateKeyJwtProviderOptions);
359420
constructor(options: StaticPrivateKeyJwtProviderOptions) {
360421
this._clientInfo = {
361-
client_id: options.clientId
422+
client_id: options.clientId,
423+
issuer: checkedExpectedIssuer(options.expectedIssuer)
362424
};
363425
this._clientMetadata = {
364426
client_name: options.clientName ?? 'static-private-key-jwt-client',

0 commit comments

Comments
 (0)