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
9 changes: 9 additions & 0 deletions .changeset/fix-transport-exact-optional-property-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@modelcontextprotocol/core': patch
---

Add explicit `| undefined` to optional properties on the `Transport` interface and `TransportSendOptions` (`onclose`, `onerror`, `onmessage`, `sessionId`, `setProtocolVersion`, `setSupportedProtocolVersions`, `onresumptiontoken`).

This fixes TS2420 errors for consumers using `exactOptionalPropertyTypes: true` without `skipLibCheck`, where the emitted `.d.ts` for implementing classes included `| undefined` but the interface did not.

Workaround for older SDK versions: enable `skipLibCheck: true` in your tsconfig.
5 changes: 5 additions & 0 deletions .changeset/reconnection-scheduler.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/client': minor
---

Add `reconnectionScheduler` option to `StreamableHTTPClientTransport`. Lets non-persistent environments (serverless, mobile, desktop sleep/wake) override the default `setTimeout`-based SSE reconnection scheduling. The scheduler may return a cancel function that is invoked on `transport.close()`.
5 changes: 5 additions & 0 deletions .changeset/spotty-cats-tickle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/client': minor
---

The client credentials providers now support scopes being added to the token request.
10 changes: 10 additions & 0 deletions docs/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,16 @@ server.registerTool(
>
> For protocol details, see [Tools](https://modelcontextprotocol.io/specification/latest/server/tools) in the MCP specification.

> [!NOTE]
> When defining a named type for `structuredContent`, use a `type` alias rather than an `interface`. Named interfaces lack implicit index signatures in TypeScript, so they aren't assignable to `{ [key: string]: unknown }`:
>
> ```ts
> type BmiResult = { bmi: number }; // assignable
> interface BmiResult { bmi: number } // type error
> ```
>
> Alternatively, spread the value: `structuredContent: { ...result }`.

#### `ResourceLink` outputs

Tools can return `resource_link` content items to reference large resources without embedding them directly, allowing clients to fetch only what they need:
Expand Down
49 changes: 41 additions & 8 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,38 @@ export async function auth(
}
}

/**
* Selects scopes per the MCP spec and augment for refresh token support.
*/
export function determineScope(options: {
requestedScope?: string;
resourceMetadata?: OAuthProtectedResourceMetadata;
authServerMetadata?: AuthorizationServerMetadata;
clientMetadata: OAuthClientMetadata;
}): string | undefined {
const { requestedScope, resourceMetadata, authServerMetadata, clientMetadata } = options;

// Scope selection priority (MCP spec):
// 1. WWW-Authenticate header scope
// 2. PRM scopes_supported
// 3. clientMetadata.scope (SDK fallback)
// 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.
if (
effectiveScope &&
authServerMetadata?.scopes_supported?.includes('offline_access') &&
!effectiveScope.split(' ').includes('offline_access') &&
clientMetadata.grant_types?.includes('refresh_token')
) {
effectiveScope = `${effectiveScope} offline_access`;
}

return effectiveScope;
}

async function authInternal(
provider: OAuthClientProvider,
{
Expand Down Expand Up @@ -659,12 +691,13 @@ async function authInternal(
await provider.saveResourceUrl?.(String(resource));
}

// Apply scope selection strategy (SEP-835):
// 1. WWW-Authenticate scope (passed via `scope` param)
// 2. PRM scopes_supported
// 3. Client metadata scope (user-configured fallback)
// The resolved scope is used consistently for both DCR and the authorization request.
const resolvedScope = scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope;
// Scope selection used consistently for DCR and the authorization request.
const resolvedScope = determineScope({
requestedScope: scope,
resourceMetadata,
authServerMetadata: metadata,
clientMetadata: provider.clientMetadata
});

// Handle client registration if needed
let clientInformation = await Promise.resolve(provider.clientInformation());
Expand Down Expand Up @@ -718,7 +751,7 @@ async function authInternal(
metadata,
resource,
authorizationCode,
scope,
scope: resolvedScope,
fetchFn
});

Expand Down Expand Up @@ -1360,7 +1393,7 @@ export async function startAuthorization(
authorizationUrl.searchParams.set('scope', scope);
}

if (scope?.includes('offline_access')) {
if (scope?.split(' ').includes('offline_access')) {
// if the request includes the OIDC-only "offline_access" scope,
// we need to set the prompt to "consent" to ensure the user is prompted to grant offline access
// https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
Expand Down
24 changes: 21 additions & 3 deletions packages/client/src/client/authExtensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ export interface ClientCredentialsProviderOptions {
* Optional client name for metadata.
*/
clientName?: string;

/**
* Space-separated scopes values requested by the client.
*/
scope?: string;
}

/**
Expand Down Expand Up @@ -146,7 +151,8 @@ export class ClientCredentialsProvider implements OAuthClientProvider {
client_name: options.clientName ?? 'client-credentials-client',
redirect_uris: [],
grant_types: ['client_credentials'],
token_endpoint_auth_method: 'client_secret_basic'
token_endpoint_auth_method: 'client_secret_basic',
scope: options.scope
};
}

Expand Down Expand Up @@ -222,6 +228,11 @@ export interface PrivateKeyJwtProviderOptions {
* Optional JWT lifetime in seconds (default: 300).
*/
jwtLifetimeSeconds?: number;

/**
* Space-separated scopes values requested by the client.
*/
scope?: string;
}

/**
Expand Down Expand Up @@ -258,7 +269,8 @@ export class PrivateKeyJwtProvider implements OAuthClientProvider {
client_name: options.clientName ?? 'private-key-jwt-client',
redirect_uris: [],
grant_types: ['client_credentials'],
token_endpoint_auth_method: 'private_key_jwt'
token_endpoint_auth_method: 'private_key_jwt',
scope: options.scope
};
this.addClientAuthentication = createPrivateKeyJwtAuth({
issuer: options.clientId,
Expand Down Expand Up @@ -333,6 +345,11 @@ export interface StaticPrivateKeyJwtProviderOptions {
* Optional client name for metadata.
*/
clientName?: string;

/**
* Space-separated scopes values requested by the client.
*/
scope?: string;
}

/**
Expand All @@ -356,7 +373,8 @@ export class StaticPrivateKeyJwtProvider implements OAuthClientProvider {
client_name: options.clientName ?? 'static-private-key-jwt-client',
redirect_uris: [],
grant_types: ['client_credentials'],
token_endpoint_auth_method: 'private_key_jwt'
token_endpoint_auth_method: 'private_key_jwt',
scope: options.scope
};

const assertion = options.jwtBearerAssertion;
Expand Down
31 changes: 31 additions & 0 deletions packages/client/src/client/streamableHttp.examples.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Type-checked examples for `streamableHttp.ts`.
*
* These examples are synced into JSDoc comments via the sync-snippets script.
* Each function's region markers define the code snippet that appears in the docs.
*
* @module
*/

/* eslint-disable unicorn/consistent-function-scoping -- examples must live inside region blocks */

import type { ReconnectionScheduler } from './streamableHttp.js';

// Stub for a hypothetical platform-specific background scheduling API
declare const platformBackgroundTask: {
schedule(callback: () => void, delay: number): number;
cancel(id: number): void;
};

/**
* Example: Using a platform background-task API to schedule reconnections.
*/
function ReconnectionScheduler_basicUsage() {
//#region ReconnectionScheduler_basicUsage
const scheduler: ReconnectionScheduler = (reconnect, delay) => {
const id = platformBackgroundTask.schedule(reconnect, delay);
return () => platformBackgroundTask.cancel(id);
};
//#endregion ReconnectionScheduler_basicUsage
return scheduler;
}
69 changes: 57 additions & 12 deletions packages/client/src/client/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,31 @@ export interface StreamableHTTPReconnectionOptions {
maxRetries: number;
}

/**
* Custom scheduler for SSE stream reconnection attempts.
*
* Called instead of `setTimeout` when the transport needs to schedule a reconnection.
* Useful in environments where `setTimeout` is unsuitable (serverless functions that
* terminate before the timer fires, mobile apps that need platform background scheduling,
* desktop apps handling sleep/wake).
*
* @param reconnect - Call this to perform the reconnection attempt.
* @param delay - Suggested delay in milliseconds (from backoff calculation).
* @param attemptCount - Zero-indexed retry attempt number.
* @returns An optional cancel function. If returned, it will be called on
* {@linkcode StreamableHTTPClientTransport.close | transport.close()} to abort the
* pending reconnection.
*
* @example
* ```ts source="./streamableHttp.examples.ts#ReconnectionScheduler_basicUsage"
* const scheduler: ReconnectionScheduler = (reconnect, delay) => {
* const id = platformBackgroundTask.schedule(reconnect, delay);
* return () => platformBackgroundTask.cancel(id);
* };
* ```
*/
export type ReconnectionScheduler = (reconnect: () => void, delay: number, attemptCount: number) => (() => void) | void;

/**
* Configuration options for the {@linkcode StreamableHTTPClientTransport}.
*/
Expand Down Expand Up @@ -116,6 +141,12 @@ export type StreamableHTTPClientTransportOptions = {
*/
reconnectionOptions?: StreamableHTTPReconnectionOptions;

/**
* Custom scheduler for reconnection attempts. If not provided, `setTimeout` is used.
* See {@linkcode ReconnectionScheduler}.
*/
reconnectionScheduler?: ReconnectionScheduler;

/**
* Session ID for the connection. This is used to identify the session on the server.
* When not provided and connecting to a server that supports session IDs, the server will generate a new session ID.
Expand Down Expand Up @@ -150,7 +181,8 @@ export class StreamableHTTPClientTransport implements Transport {
private _protocolVersion?: string;
private _lastUpscopingHeader?: string; // Track last upscoping header to prevent infinite upscoping.
private _serverRetryMs?: number; // Server-provided retry delay from SSE retry field
private _reconnectionTimeout?: ReturnType<typeof setTimeout>;
private readonly _reconnectionScheduler?: ReconnectionScheduler;
private _cancelReconnection?: () => void;

onclose?: () => void;
onerror?: (error: Error) => void;
Expand All @@ -172,6 +204,7 @@ export class StreamableHTTPClientTransport implements Transport {
this._sessionId = opts?.sessionId;
this._protocolVersion = opts?.protocolVersion;
this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS;
this._reconnectionScheduler = opts?.reconnectionScheduler;
}

private async _commonHeaders(): Promise<Headers> {
Expand Down Expand Up @@ -305,15 +338,26 @@ export class StreamableHTTPClientTransport implements Transport {
// Calculate next delay based on current attempt count
const delay = this._getNextReconnectionDelay(attemptCount);

// Schedule the reconnection
this._reconnectionTimeout = setTimeout(() => {
// Use the last event ID to resume where we left off
const reconnect = (): void => {
this._cancelReconnection = undefined;
if (this._abortController?.signal.aborted) return;
this._startOrAuthSse(options).catch(error => {
this.onerror?.(new Error(`Failed to reconnect SSE stream: ${error instanceof Error ? error.message : String(error)}`));
// Schedule another attempt if this one failed, incrementing the attempt counter
this._scheduleReconnection(options, attemptCount + 1);
try {
this._scheduleReconnection(options, attemptCount + 1);
} catch (scheduleError) {
this.onerror?.(scheduleError instanceof Error ? scheduleError : new Error(String(scheduleError)));
}
});
}, delay);
};

if (this._reconnectionScheduler) {
const cancel = this._reconnectionScheduler(reconnect, delay, attemptCount);
this._cancelReconnection = typeof cancel === 'function' ? cancel : undefined;
} else {
const handle = setTimeout(reconnect, delay);
this._cancelReconnection = () => clearTimeout(handle);
}
}

private _handleSseStream(stream: ReadableStream<Uint8Array> | null, options: StartSSEOptions, isReconnectable: boolean): void {
Expand Down Expand Up @@ -458,12 +502,13 @@ export class StreamableHTTPClientTransport implements Transport {
}

async close(): Promise<void> {
if (this._reconnectionTimeout) {
clearTimeout(this._reconnectionTimeout);
this._reconnectionTimeout = undefined;
try {
this._cancelReconnection?.();
} finally {
this._cancelReconnection = undefined;
this._abortController?.abort();
this.onclose?.();
}
this._abortController?.abort();
this.onclose?.();
}

async send(
Expand Down
7 changes: 6 additions & 1 deletion packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,12 @@ export type { SSEClientTransportOptions } from './client/sse.js';
export { SSEClientTransport, SseError } from './client/sse.js';
export type { StdioServerParameters } from './client/stdio.js';
export { DEFAULT_INHERITED_ENV_VARS, getDefaultEnvironment, StdioClientTransport } from './client/stdio.js';
export type { StartSSEOptions, StreamableHTTPClientTransportOptions, StreamableHTTPReconnectionOptions } from './client/streamableHttp.js';
export type {
ReconnectionScheduler,
StartSSEOptions,
StreamableHTTPClientTransportOptions,
StreamableHTTPReconnectionOptions
} from './client/streamableHttp.js';
export { StreamableHTTPClientTransport } from './client/streamableHttp.js';
export { WebSocketClientTransport } from './client/websocket.js';

Expand Down
Loading
Loading