Implement MCP 2026-07-28 (#2286)

Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com>
Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Felix Weinberger 2026-06-26 14:01:10 +01:00 • committed by GitHub
commit 1823aae891
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 69131 additions and 9001 deletions

View file

@ -73,7 +73,7 @@
- [#2275](https://github.com/modelcontextprotocol/typescript-sdk/pull/2275) [`1b53a41`](https://github.com/modelcontextprotocol/typescript-sdk/commit/1b53a415ea2c33aa11ac413fc9c2d68ccffde784) Thanks [@KKonstantinov](https://github.com/KKonstantinov)! - Add a configurable
`maxBufferSize` (default 10 MB) to the stdio transports. When a single message would push the read buffer past the limit, the transport now emits an `onerror` and closes instead of growing the buffer unbounded. Configure via `new StdioClientTransport({ ..., maxBufferSize })` or
`new StdioServerTransport(stdin, stdout, { maxBufferSize })`. The default is exported from `@modelcontextprotocol/core-internal` as `STDIO_DEFAULT_MAX_BUFFER_SIZE`.
`new StdioServerTransport(stdin, stdout, { maxBufferSize })`. The default is exported from `@modelcontextprotocol/client` / `@modelcontextprotocol/server` as `STDIO_DEFAULT_MAX_BUFFER_SIZE`.
- [#2088](https://github.com/modelcontextprotocol/typescript-sdk/pull/2088) [`16d13ab`](https://github.com/modelcontextprotocol/typescript-sdk/commit/16d13abf78b5dba5de73dfa284325b13d4219bb2) Thanks [@mattzcarey](https://github.com/mattzcarey)! - Bundle automatic JSON Schema
validator defaults in `@modelcontextprotocol/client` and `@modelcontextprotocol/server` runtime shims.
@ -160,7 +160,8 @@
For raw JSON Schema (e.g. TypeBox output), use the new `fromJsonSchema` adapter:
```typescript
import { fromJsonSchema, AjvJsonSchemaValidator } from '@modelcontextprotocol/core-internal';
import { fromJsonSchema } from '@modelcontextprotocol/server';
import { AjvJsonSchemaValidator } from '@modelcontextprotocol/server/validators/ajv';
server.registerTool(
'greet',

View file

@ -8,7 +8,7 @@ The MCP (Model Context Protocol) TypeScript client SDK. Build MCP clients that c
<!-- prettier-ignore -->
> [!NOTE]
> This is **v2** of the MCP TypeScript SDK. It replaces the monolithic `@modelcontextprotocol/sdk` package from v1. See the **[migration guide](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/migration.md)** if you're coming from v1.
> This is **v2** of the MCP TypeScript SDK. It replaces the monolithic `@modelcontextprotocol/sdk` package from v1. See the **[migration guide](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/migration/upgrade-to-v2.md)** if you're coming from v1.
## Install

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,176 @@
/**
* Error classes thrown by the OAuth client flow ({@linkcode auth} and helpers).
*
* Each behavior change in the 2026-07-28 authorization requirements adds its
* dedicated error class to this module so callers can `instanceof`-dispatch on
* the failure mode without string-matching messages.
*/
import type { OAuthClientMetadata } from '@modelcontextprotocol/core-internal';
/**
* Base class for the OAuth-client-flow error family. Concrete subclasses are
* added to this module alongside the SEP-2468/837/2207/2350/2352 behavior
* changes that throw them, so callers can catch the whole family with a single
* `instanceof OAuthClientFlowError` guard once those land.
*
* @remarks Nothing in the SDK throws this base class directly. In the release
* that introduces it no subclass exists yet — the guard is a forward-compat
* hook and will not match anything until the first behavior change ships.
*/
export class OAuthClientFlowError extends Error {
constructor(message: string) {
super(message);
this.name = new.target.name;
}
}
/**
* Thrown when an authorization-server issuer identifier fails validation.
*
* Two checks raise this error, distinguished by {@linkcode IssuerMismatchError.kind | kind}:
* - `'metadata'` — the `issuer` in fetched authorization-server metadata does
* not match the issuer identifier the well-known URL was constructed from
* (RFC 8414 §3.3 / OpenID Connect Discovery §4.3).
* - `'authorization_response'` — the `iss` parameter on the authorization
* callback failed RFC 9207 §2.4 validation against the recorded issuer.
*
* Intentionally does **not** extend `OAuthError`: the `auth()`
* orchestrator's `OAuthError` retry block must not swallow this — a mix-up
* indication is fatal for the flow, not a retryable credential problem.
*
* On the `'authorization_response'` path the {@linkcode IssuerMismatchError.received | received}
* value is attacker-controllable in a mix-up attack; callers **MUST NOT** display
* it (or any `error`/`error_description`/`error_uri` from the same callback) to
* end users. The values are JSON-encoded in the message to neutralize log-injection.
*/
export class IssuerMismatchError extends OAuthClientFlowError {
/** Which check failed — metadata echo (RFC 8414 §3.3) or authorization-response `iss` (RFC 9207). */
readonly kind: 'metadata' | 'authorization_response';
/** The issuer the client expected (from validated metadata / discovery input). */
readonly expected: string | undefined;
/** The issuer value that was received. Attacker-controllable on the `'authorization_response'` path. */
readonly received: string | undefined;
constructor(kind: 'metadata' | 'authorization_response', expected: string | undefined, received: string | undefined) {
const where = kind === 'metadata' ? 'authorization server metadata (RFC 8414 §3.3)' : 'authorization response (RFC 9207)';
// JSON-stringify embedded values so attacker-supplied control characters cannot forge log lines.
super(`Issuer mismatch in ${where}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(received)}`);
this.kind = kind;
this.expected = expected;
this.received = received;
}
}
/**
* Thrown by `registerClient()` when the authorization server rejects a
* Dynamic Client Registration request. Carries the HTTP status, the raw
* response body, and the metadata that was submitted, so callers can inspect
* the AS's `error` / `error_description` and retry with adjusted metadata
* (for example a different `application_type`) per SEP-837.
*
* The `body` is the raw RFC 7591 error JSON; compare `JSON.parse(body).error`
* against `OAuthErrorCode` (e.g. `OAuthErrorCode.InvalidRedirectUri`,
* `OAuthErrorCode.InvalidClientMetadata`).
*
* Intentionally does **not** extend `OAuthError`: registration rejection is
* not a recoverable-by-credential-invalidation condition, and staying outside
* that hierarchy keeps it from being caught by `auth()`'s `OAuthError` retry
* path.
*/
export class RegistrationRejectedError extends OAuthClientFlowError {
/** HTTP status code returned by the registration endpoint. */
public readonly status: number;
/** Raw response body text (typically an RFC 7591 error JSON document). */
public readonly body: string;
/** The exact client metadata that was POSTed (after SDK defaults were applied). */
public readonly submittedMetadata: OAuthClientMetadata;
constructor(args: { status: number; body: string; submittedMetadata: OAuthClientMetadata }) {
super(`Dynamic Client Registration rejected (HTTP ${args.status}): ${args.body}`);
this.status = args.status;
this.body = args.body;
this.submittedMetadata = args.submittedMetadata;
}
}
/**
* Thrown by the token-exchange and refresh paths when the resolved token
* endpoint is not `https:` and is not a loopback host (SEP-2207). This is a
* configuration error — re-authorizing cannot fix it — so it intentionally does
* **not** extend `OAuthError` and `auth()`'s refresh branch rethrows it instead
* of falling through to a fresh `/authorize` redirect.
*/
export class InsecureTokenEndpointError extends OAuthClientFlowError {
/** The token endpoint URL that was rejected. */
public readonly tokenEndpoint: string;
constructor(tokenEndpoint: string) {
super(
`Refusing to send credentials to non-https token endpoint '${tokenEndpoint}'. ` +
`OAuth token requests MUST use TLS (localhost / 127.0.0.1 / ::1 are exempt).`
);
this.tokenEndpoint = tokenEndpoint;
}
}
/**
* Thrown by the HTTP client transport when the server responds with
* `403 Forbidden` and `WWW-Authenticate: Bearer error="insufficient_scope"`,
* and either (a) the transport's `onInsufficientScope` option is `'throw'`, or
* (b) `onInsufficientScope` is the default `'reauthorize'` but the transport
* has no {@linkcode index.OAuthClientProvider | OAuthClientProvider} to drive
* step-up (e.g. a minimal `AuthProvider`, `requestInit`-only headers, or no
* `authProvider`).
*
* Carries the challenge parameters so the host can decide whether to initiate
* step-up authorization itself (e.g., behind a UX gate) or surface the error.
*
* Does **not** extend `OAuthError`: that class represents OAuth protocol errors
* from the authorization server; this is a resource-server challenge surfaced
* at the transport layer.
*
* All fields originate from the resource server's `WWW-Authenticate` header;
* treat them as untrusted input when displaying or logging (this includes
* `requiredScope`, which appears in the error message).
*/
/**
* Thrown by `auth()` on the authorization-code callback leg when the
* authorization server resolved by discovery differs from the one recorded in
* `discoveryState()` at redirect time. The `authorization_code` and PKCE
* `code_verifier` are bound to the AS that minted the code (RFC 7636); sending
* them to a different AS's token endpoint is a credential-exfiltration vector.
*
* This is the only runtime check left in the SEP-2352 model — stored tokens and
* client credentials are protected structurally by the `issuer` stamp instead.
*/
export class AuthorizationServerMismatchError extends OAuthClientFlowError {
constructor(
/** The issuer recorded in `discoveryState()` when the authorization redirect was issued. */
public readonly recordedIssuer: string,
/** The issuer resolved by discovery on this call. */
public readonly currentIssuer: string
) {
super(
`Authorization server changed between redirect and callback ` +
`(redirected to ${JSON.stringify(recordedIssuer)}, callback resolved ${JSON.stringify(currentIssuer)}); ` +
`refusing to send authorization_code/code_verifier to a different token endpoint`
);
}
}
export class InsufficientScopeError extends OAuthClientFlowError {
/** The `scope` value from the `WWW-Authenticate` challenge — the scopes the resource server says are required. */
readonly requiredScope?: string;
/** The `resource_metadata` URL from the `WWW-Authenticate` challenge, if present. */
readonly resourceMetadataUrl?: URL;
/** The `error_description` from the `WWW-Authenticate` challenge, if present. */
readonly errorDescription?: string;
constructor(init: { requiredScope?: string; resourceMetadataUrl?: URL; errorDescription?: string }) {
super(`Insufficient scope${init.requiredScope ? `: required "${init.requiredScope}"` : ''}`);
this.requiredScope = init.requiredScope;
this.resourceMetadataUrl = init.resourceMetadataUrl;
this.errorDescription = init.errorDescription;
}
}

View file

@ -5,7 +5,7 @@
* for common machine-to-machine authentication scenarios.
*/
import type { FetchLike, OAuthClientInformation, OAuthClientMetadata, OAuthTokens } from '@modelcontextprotocol/core-internal';
import type { FetchLike, OAuthClientMetadata, StoredOAuthClientInformation, StoredOAuthTokens } from '@modelcontextprotocol/core-internal';
import type { CryptoKey, JWK } from 'jose';
import type { AddClientAuthentication, OAuthClientProvider } from './auth';
@ -117,6 +117,15 @@ export interface ClientCredentialsProviderOptions {
* Space-separated scopes values requested by the client.
*/
scope?: string;
/**
* The authorization server's `issuer` identifier these credentials were registered with.
* Stamped onto the stored client information so `auth()`'s SEP-2352 issuer check
* refuses to send the credential to any other authorization server. Hosts supplying
* static client credentials SHOULD set this; omitting it preserves the legacy
* (no-binding) behaviour for back-compat.
*/
expectedIssuer?: string;
}
/**
@ -138,14 +147,15 @@ export interface ClientCredentialsProviderOptions {
* ```
*/
export class ClientCredentialsProvider implements OAuthClientProvider {
private _tokens?: OAuthTokens;
private _clientInfo: OAuthClientInformation;
private _tokens?: StoredOAuthTokens;
private _clientInfo: StoredOAuthClientInformation;
private _clientMetadata: OAuthClientMetadata;
constructor(options: ClientCredentialsProviderOptions) {
this._clientInfo = {
client_id: options.clientId,
client_secret: options.clientSecret
client_secret: options.clientSecret,
issuer: options.expectedIssuer
};
this._clientMetadata = {
client_name: options.clientName ?? 'client-credentials-client',
@ -164,19 +174,20 @@ export class ClientCredentialsProvider implements OAuthClientProvider {
return this._clientMetadata;
}
clientInformation(): OAuthClientInformation {
clientInformation(): StoredOAuthClientInformation {
return this._clientInfo;
}
saveClientInformation(info: OAuthClientInformation): void {
this._clientInfo = info;
}
// No saveClientInformation: credentials are constructor-supplied and bound to a single
// authorization server. When `expectedIssuer` is set and the resolved AS differs, the
// SEP-2352 stamp check discards `clientInformation()` and auth() throws
// AuthorizationServerMismatchError(expectedIssuer, resolved) rather than sending the credential.
tokens(): OAuthTokens | undefined {
tokens(): StoredOAuthTokens | undefined {
return this._tokens;
}
saveTokens(tokens: OAuthTokens): void {
saveTokens(tokens: StoredOAuthTokens): void {
this._tokens = tokens;
}
@ -243,6 +254,12 @@ export interface PrivateKeyJwtProviderOptions {
* with finer granularity than what scopes alone allow.
*/
claims?: Record<string, unknown>;
/**
* The authorization server's `issuer` identifier these credentials were registered with.
* Seeds the SEP-2352 issuer stamp — see {@linkcode ClientCredentialsProviderOptions.expectedIssuer}.
*/
expectedIssuer?: string;
}
/**
@ -266,14 +283,15 @@ export interface PrivateKeyJwtProviderOptions {
* ```
*/
export class PrivateKeyJwtProvider implements OAuthClientProvider {
private _tokens?: OAuthTokens;
private _clientInfo: OAuthClientInformation;
private _tokens?: StoredOAuthTokens;
private _clientInfo: StoredOAuthClientInformation;
private _clientMetadata: OAuthClientMetadata;
addClientAuthentication: AddClientAuthentication;
constructor(options: PrivateKeyJwtProviderOptions) {
this._clientInfo = {
client_id: options.clientId
client_id: options.clientId,
issuer: options.expectedIssuer
};
this._clientMetadata = {
client_name: options.clientName ?? 'private-key-jwt-client',
@ -300,19 +318,19 @@ export class PrivateKeyJwtProvider implements OAuthClientProvider {
return this._clientMetadata;
}
clientInformation(): OAuthClientInformation {
clientInformation(): StoredOAuthClientInformation {
return this._clientInfo;
}
saveClientInformation(info: OAuthClientInformation): void {
this._clientInfo = info;
}
// No saveClientInformation: credentials are constructor-supplied; the SEP-2352 stamp
// check enforces `expectedIssuer` and auth() throws
// AuthorizationServerMismatchError(expectedIssuer, resolved) on mismatch.
tokens(): OAuthTokens | undefined {
tokens(): StoredOAuthTokens | undefined {
return this._tokens;
}
saveTokens(tokens: OAuthTokens): void {
saveTokens(tokens: StoredOAuthTokens): void {
this._tokens = tokens;
}
@ -361,6 +379,12 @@ export interface StaticPrivateKeyJwtProviderOptions {
* Space-separated scopes values requested by the client.
*/
scope?: string;
/**
* The authorization server's `issuer` identifier this assertion was minted for.
* Seeds the SEP-2352 issuer stamp — see {@linkcode ClientCredentialsProviderOptions.expectedIssuer}.
*/
expectedIssuer?: string;
}
/**
@ -371,14 +395,15 @@ export interface StaticPrivateKeyJwtProviderOptions {
* uses it directly for authentication.
*/
export class StaticPrivateKeyJwtProvider implements OAuthClientProvider {
private _tokens?: OAuthTokens;
private _clientInfo: OAuthClientInformation;
private _tokens?: StoredOAuthTokens;
private _clientInfo: StoredOAuthClientInformation;
private _clientMetadata: OAuthClientMetadata;
addClientAuthentication: AddClientAuthentication;
constructor(options: StaticPrivateKeyJwtProviderOptions) {
this._clientInfo = {
client_id: options.clientId
client_id: options.clientId,
issuer: options.expectedIssuer
};
this._clientMetadata = {
client_name: options.clientName ?? 'static-private-key-jwt-client',
@ -403,19 +428,19 @@ export class StaticPrivateKeyJwtProvider implements OAuthClientProvider {
return this._clientMetadata;
}
clientInformation(): OAuthClientInformation {
clientInformation(): StoredOAuthClientInformation {
return this._clientInfo;
}
saveClientInformation(info: OAuthClientInformation): void {
this._clientInfo = info;
}
// No saveClientInformation: credentials are constructor-supplied; the SEP-2352 stamp
// check enforces `expectedIssuer` and auth() throws
// AuthorizationServerMismatchError(expectedIssuer, resolved) on mismatch.
tokens(): OAuthTokens | undefined {
tokens(): StoredOAuthTokens | undefined {
return this._tokens;
}
saveTokens(tokens: OAuthTokens): void {
saveTokens(tokens: StoredOAuthTokens): void {
this._tokens = tokens;
}
@ -527,6 +552,12 @@ export interface CrossAppAccessProviderOptions {
* Custom fetch implementation. Defaults to global fetch.
*/
fetchFn?: FetchLike;
/**
* The MCP authorization server's `issuer` identifier these credentials were registered with.
* Seeds the SEP-2352 issuer stamp — see {@linkcode ClientCredentialsProviderOptions.expectedIssuer}.
*/
expectedIssuer?: string;
}
/**
@ -569,8 +600,8 @@ export interface CrossAppAccessProviderOptions {
* ```
*/
export class CrossAppAccessProvider implements OAuthClientProvider {
private _tokens?: OAuthTokens;
private _clientInfo: OAuthClientInformation;
private _tokens?: StoredOAuthTokens;
private _clientInfo: StoredOAuthClientInformation;
private _clientMetadata: OAuthClientMetadata;
private _assertionCallback: AssertionCallback;
private _fetchFn: FetchLike;
@ -581,7 +612,8 @@ export class CrossAppAccessProvider implements OAuthClientProvider {
constructor(options: CrossAppAccessProviderOptions) {
this._clientInfo = {
client_id: options.clientId,
client_secret: options.clientSecret
client_secret: options.clientSecret,
issuer: options.expectedIssuer
};
this._clientMetadata = {
client_name: options.clientName ?? 'cross-app-access-client',
@ -601,19 +633,19 @@ export class CrossAppAccessProvider implements OAuthClientProvider {
return this._clientMetadata;
}
clientInformation(): OAuthClientInformation {
clientInformation(): StoredOAuthClientInformation {
return this._clientInfo;
}
saveClientInformation(info: OAuthClientInformation): void {
this._clientInfo = info;
}
// No saveClientInformation: credentials are constructor-supplied; the SEP-2352 stamp
// check enforces `expectedIssuer` and auth() throws
// AuthorizationServerMismatchError(expectedIssuer, resolved) on mismatch.
tokens(): OAuthTokens | undefined {
tokens(): StoredOAuthTokens | undefined {
return this._tokens;
}
saveTokens(tokens: OAuthTokens): void {
saveTokens(tokens: StoredOAuthTokens): void {
this._tokens = tokens;
}
@ -633,14 +665,14 @@ export class CrossAppAccessProvider implements OAuthClientProvider {
* Saves the authorization server URL discovered during OAuth flow.
* This is called by the auth() function after RFC 9728 discovery.
*/
saveAuthorizationServerUrl?(authorizationServerUrl: string): void {
saveAuthorizationServerUrl(authorizationServerUrl: string): void {
this._authorizationServerUrl = authorizationServerUrl;
}
/**
* Returns the cached authorization server URL if available.
*/
authorizationServerUrl?(): string | undefined {
authorizationServerUrl(): string | undefined {
return this._authorizationServerUrl;
}

View file

@ -7,8 +7,6 @@
* @module
*/
import type { Prompt, Resource, Tool } from '@modelcontextprotocol/core-internal';
import { Client } from './client';
import { SSEClientTransport } from './sse';
import { StdioClientTransport } from './stdio';
@ -107,9 +105,13 @@ async function Client_callTool_structuredOutput(client: Client) {
arguments: { weightKg: 70, heightM: 1.75 }
});
// Machine-readable output for the client application
if (result.structuredContent) {
console.log(result.structuredContent); // e.g. { bmi: 22.86 }
// Machine-readable output for the client application. SEP-2106: structuredContent is
// `unknown` (any JSON value). Check for presence with `!== undefined` and narrow before use.
if (result.structuredContent !== undefined) {
const sc: unknown = result.structuredContent; // e.g. { bmi: 22.86 }
if (typeof sc === 'object' && sc !== null && 'bmi' in sc) {
console.log(sc.bmi);
}
}
//#endregion Client_callTool_structuredOutput
}
@ -137,61 +139,43 @@ function Client_setRequestHandler_sampling(client: Client) {
}
/**
* Example: List tools with cursor-based pagination.
* Example: List tools (auto-aggregated across pages).
*/
async function Client_listTools_pagination(client: Client) {
//#region Client_listTools_pagination
const allTools: Tool[] = [];
let cursor: string | undefined;
// Note: an empty-string cursor is valid and does not signal the end of results.
do {
const { tools, nextCursor } = await client.listTools({ cursor });
allTools.push(...tools);
cursor = nextCursor;
} while (cursor !== undefined);
// No cursor → all pages aggregated for you.
const { tools } = await client.listTools();
console.log(
'Available tools:',
allTools.map(t => t.name)
tools.map(t => t.name)
);
//#endregion Client_listTools_pagination
}
/**
* Example: List prompts with cursor-based pagination.
* Example: List prompts (auto-aggregated across pages).
*/
async function Client_listPrompts_pagination(client: Client) {
//#region Client_listPrompts_pagination
const allPrompts: Prompt[] = [];
let cursor: string | undefined;
// Note: an empty-string cursor is valid and does not signal the end of results.
do {
const { prompts, nextCursor } = await client.listPrompts({ cursor });
allPrompts.push(...prompts);
cursor = nextCursor;
} while (cursor !== undefined);
// No cursor → all pages aggregated for you.
const { prompts } = await client.listPrompts();
console.log(
'Available prompts:',
allPrompts.map(p => p.name)
prompts.map(p => p.name)
);
//#endregion Client_listPrompts_pagination
}
/**
* Example: List resources with cursor-based pagination.
* Example: List resources (auto-aggregated across pages).
*/
async function Client_listResources_pagination(client: Client) {
//#region Client_listResources_pagination
const allResources: Resource[] = [];
let cursor: string | undefined;
// Note: an empty-string cursor is valid and does not signal the end of results.
do {
const { resources, nextCursor } = await client.listResources({ cursor });
allResources.push(...resources);
cursor = nextCursor;
} while (cursor !== undefined);
// No cursor → all pages aggregated for you.
const { resources } = await client.listResources();
console.log(
'Available resources:',
allResources.map(r => r.name)
resources.map(r => r.name)
);
//#endregion Client_listResources_pagination
}

File diff suppressed because it is too large Load diff

View file

@ -12,7 +12,7 @@ import type { FetchLike } from '@modelcontextprotocol/core-internal';
import { IdJagTokenExchangeResponseSchema, OAuthErrorResponseSchema, OAuthTokensSchema } from '@modelcontextprotocol/core-internal';
import type { ClientAuthMethod } from './auth';
import { applyClientAuthentication, discoverAuthorizationServerMetadata } from './auth';
import { applyClientAuthentication, assertSecureTokenEndpoint, discoverAuthorizationServerMetadata } from './auth';
/**
* Options for requesting a JWT Authorization Grant via RFC 8693 Token Exchange.
@ -124,6 +124,8 @@ export interface JwtAuthGrantResult {
export async function requestJwtAuthorizationGrant(options: RequestJwtAuthGrantOptions): Promise<JwtAuthGrantResult> {
const { tokenEndpoint, audience, resource, idToken, clientId, clientSecret, scope, fetchFn = fetch } = options;
const tokenUrl = assertSecureTokenEndpoint(tokenEndpoint);
// Prepare token exchange request per RFC 8693
const params = new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
@ -145,7 +147,7 @@ export async function requestJwtAuthorizationGrant(options: RequestJwtAuthGrantO
params.set('scope', scope);
}
const response = await fetchFn(String(tokenEndpoint), {
const response = await fetchFn(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
@ -260,6 +262,8 @@ export async function exchangeJwtAuthGrant(options: {
}): Promise<{ access_token: string; token_type: string; expires_in?: number; scope?: string }> {
const { tokenEndpoint, jwtAuthGrant, clientId, clientSecret, authMethod = 'client_secret_basic', fetchFn = fetch } = options;
const tokenUrl = assertSecureTokenEndpoint(tokenEndpoint);
// Prepare JWT bearer grant request per RFC 7523
const params = new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
@ -272,7 +276,7 @@ export async function exchangeJwtAuthGrant(options: {
applyClientAuthentication(authMethod, { client_id: clientId, client_secret: clientSecret }, headers, params);
const response = await fetchFn(String(tokenEndpoint), {
const response = await fetchFn(tokenUrl, {
method: 'POST',
headers,
body: params.toString()

View file

@ -0,0 +1,262 @@
/**
* Probe outcome classifier (pure module): maps the outcome of the connect-time
* `server/discover` probe onto one of four verdicts — modern era, the
* spec-mandated `-32022` corrective continuation, legacy fallback (the plain
* 2025 `initialize` handshake on the same connection), or a typed connect error.
*
* The classifier is deliberately conservative: anything it does not positively
* recognize as modern resolves to the legacy fallback, and a network outage is a
* typed connect error, never an era verdict. The verdicts apply to the
* negotiation phase only — an established modern connection is never silently
* demoted to `initialize` by a later failure.
*/
import type { DiscoverResult } from '@modelcontextprotocol/core-internal';
import {
codecForVersion,
MODERN_WIRE_REVISION,
modernProtocolVersions,
SdkError,
SdkErrorCode,
UnsupportedProtocolVersionError
} from '@modelcontextprotocol/core-internal';
/**
* The runtime environment the probe executed in. Only consulted for the
* network-failure row: a browser CORS-preflight rejection is treated as a
* legacy signal, while in Node a network failure stays a typed connect error.
*/
export type ProbeEnvironment = 'node' | 'browser';
/**
* The transport class the probe ran on. Only consulted for the timeout row: a
* stdio probe that times out signals a legacy server, while an HTTP timeout
* stays a typed error. Anything that is not the stdio child-process transport
* is treated like HTTP.
*/
export type ProbeTransportKind = 'stdio' | 'http';
/**
* A normalized probe outcome, produced by the connect-time wiring from the raw
* transport exchange.
*/
export type ProbeOutcome =
| { kind: 'result'; result: unknown }
/** Answered with a JSON-RPC error (any HTTP status, including 200-bodied errors and stdio in-band errors). */
| { kind: 'rpc-error'; code: number; message: string; data?: unknown }
/** The HTTP layer rejected the probe POST (non-2xx); `body` is the raw response text, when available. */
| { kind: 'http-error'; status: number; body?: string }
| { kind: 'network-error'; error: unknown }
/** No response arrived within the probe timeout. */
| { kind: 'timeout'; timeoutMs: number };
export interface ProbeClassifierContext {
/** Modern-era versions this client can negotiate, in preference order (never empty). */
clientModernVersions: readonly string[];
/** The version the probe carried in its `_meta` envelope (used to synthesize `data.requested` on typed errors). */
requestedVersion: string;
/**
* Whether a legacy `initialize` fallback is possible — `false` for a
* modern-only client and for `pin` mode. Without a fallback, rows carrying
* modern evidence but no usable version overlap — a `DiscoverResult` with
* no overlapping version, or a `-32022` whose `data.supported` lists only
* legacy revisions — yield a typed `UnsupportedProtocolVersionError` built
* from that evidence; the remaining rows that would have fallen back still
* classify as `legacy`, and the caller reports them as a typed negotiation
* error instead of starting an `initialize` handshake.
*/
fallbackAvailable: boolean;
/** See {@linkcode ProbeEnvironment}. */
environment: ProbeEnvironment;
/** See {@linkcode ProbeTransportKind}. */
transportKind: ProbeTransportKind;
}
export type ProbeVerdict =
/** Definitive modern evidence: select `version` and continue without `initialize`. */
| { kind: 'modern'; version: string; discover: DiscoverResult }
/**
* `-32022` with a mutual modern version: re-send the probe at `version`.
* Spec-mandated select-and-continue — the caller runs it exactly once and
* arms a loop guard on the second rejection, throwing `error`.
*/
| { kind: 'corrective'; version: string; error: UnsupportedProtocolVersionError }
/** Definitive legacy signal or unrecognized shape: perform the plain legacy `initialize` handshake on the same connection. */
| { kind: 'legacy' }
/** Typed connect error — never converted to an era verdict. */
| { kind: 'error'; error: Error };
/** The `-32022` UnsupportedProtocolVersion protocol error code (negotiation-phase recognition). */
const UNSUPPORTED_PROTOCOL_VERSION = -32_022;
/**
* Deliberately not probe-recognized in either direction: deployed servers
* overload `-32001` (the SDK-conventional `Session not found` body on a 2025
* stateful server), and the spec-assigned `-32020` (`HeaderMismatch`) /
* `-32021` (`MissingRequiredClientCapability`) are not era evidence — all
* fall into the conservative legacy default.
*/
const NOT_PROBE_RECOGNIZED = new Set([-32_001, -32_020, -32_021]);
/**
* Classify a single probe outcome. Pure: no I/O, no state — loop-guard and
* retry state live in the caller.
*/
export function classifyProbeOutcome(outcome: ProbeOutcome, context: ProbeClassifierContext): ProbeVerdict {
switch (outcome.kind) {
case 'result': {
return classifyResult(outcome.result, context);
}
case 'rpc-error': {
return classifyRpcError(outcome, context);
}
case 'http-error': {
return classifyHttpError(outcome, context);
}
case 'network-error': {
return classifyNetworkError(outcome.error, context);
}
case 'timeout': {
if (context.transportKind === 'stdio') {
// Per the stdio transport's backward-compatibility rule, a probe
// nobody answers within the timeout indicates a legacy server —
// fall back to `initialize` on the same stream.
return { kind: 'legacy' };
}
// On HTTP a deployed server answers, so silence is an outage, not a
// legacy signal: keep the typed timeout error (the compatibility
// matrix keys the HTTP legacy signal to a 4xx, never to silence).
return {
kind: 'error',
error: new SdkError(SdkErrorCode.RequestTimeout, `Version negotiation probe timed out after ${outcome.timeoutMs}ms`, {
timeout: outcome.timeoutMs
})
};
}
}
}
function classifyResult(result: unknown, context: ProbeClassifierContext): ProbeVerdict {
// The 2026 wire schema carries the spec receiver-side leniency for
// `resultType` ('complete'), `ttlMs` (0) and `cacheScope` ('private'), so
// routing through the codec is behavior-neutral with the prior public-schema
// parse for absent and malformed cache hints (`.catch()` per spec receiver
// leniency): a server that omits or malforms them still classifies `modern`.
const parsed = codecForVersion(MODERN_WIRE_REVISION).validateResult('server/discover', result);
if (!parsed.ok) {
// Unrecognized result shape: not modern evidence — conservative legacy fallback.
return { kind: 'legacy' };
}
const supportedVersions = parsed.value.supportedVersions;
const overlap = context.clientModernVersions.find(version => supportedVersions.includes(version));
if (overlap !== undefined) {
return { kind: 'modern', version: overlap, discover: parsed.value };
}
// A DiscoverResult with no overlap still drives era selection: initialize on
// the same connection when fallback is possible, otherwise a typed error.
if (context.fallbackAvailable) {
return { kind: 'legacy' };
}
return {
kind: 'error',
error: new UnsupportedProtocolVersionError({ supported: [...supportedVersions], requested: context.requestedVersion })
};
}
function classifyRpcError(outcome: { code: number; message: string; data?: unknown }, context: ProbeClassifierContext): ProbeVerdict {
const { code, message, data } = outcome;
if (code === UNSUPPORTED_PROTOCOL_VERSION) {
const supported = parseSupportedList(data);
if (supported === undefined) {
// -32022 without a valid data.supported list is not actionable modern evidence.
return { kind: 'legacy' };
}
const requested = parseRequested(data) ?? context.requestedVersion;
const error = new UnsupportedProtocolVersionError({ supported, requested }, message);
const supportedModern = modernProtocolVersions(supported);
const mutual = context.clientModernVersions.find(version => supportedModern.includes(version));
if (mutual !== undefined) {
// Mutual modern version: spec-mandated select-and-continue — never
// fall back to initialize here.
return { kind: 'corrective', version: mutual, error };
}
if (supportedModern.length > 0) {
// Disjoint-but-modern list: typed error, never initialize.
return { kind: 'error', error };
}
// Legacy-only list: definitive legacy signal (typed error for a modern-only client).
return context.fallbackAvailable ? { kind: 'legacy' } : { kind: 'error', error };
}
if (NOT_PROBE_RECOGNIZED.has(code)) {
return { kind: 'legacy' };
}
// Everything else — -32601, the deployed -32000 literals/free-text, code 0,
// any unrecognized code — is a legacy signal or the conservative default.
return { kind: 'legacy' };
}
function classifyHttpError(outcome: { status: number; body?: string }, context: ProbeClassifierContext): ProbeVerdict {
// HTTP-rejected probes carry their JSON-RPC error in the response body — classify it like an in-band error.
const rpcError = parseJsonRpcErrorBody(outcome.body);
if (rpcError !== undefined) {
return classifyRpcError(rpcError, context);
}
// Unparseable or unrecognized HTTP rejection: conservative legacy fallback.
return { kind: 'legacy' };
}
function classifyNetworkError(error: unknown, context: ProbeClassifierContext): ProbeVerdict {
if (context.environment === 'browser' && isOpaqueFetchTypeError(error)) {
// A browser CORS-preflight rejection against a deployed 2025 server is an
// opaque TypeError; the legacy fallback carries no custom headers (no
// preflight), so it can proceed where the probe could not.
return { kind: 'legacy' };
}
return {
kind: 'error',
error: new SdkError(SdkErrorCode.EraNegotiationFailed, `Version negotiation probe failed: ${describeError(error)}`, {
cause: error
})
};
}
function isOpaqueFetchTypeError(error: unknown): boolean {
// Cross-realm safe: a bundled or sandboxed fetch may not share this realm's TypeError identity.
return error instanceof TypeError || (error instanceof Error && error.name === 'TypeError');
}
function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function parseSupportedList(data: unknown): string[] | undefined {
if (typeof data !== 'object' || data === null) return undefined;
const supported = (data as { supported?: unknown }).supported;
if (!Array.isArray(supported) || supported.length === 0 || !supported.every(v => typeof v === 'string')) {
return undefined;
}
return supported as string[];
}
function parseRequested(data: unknown): string | undefined {
if (typeof data !== 'object' || data === null) return undefined;
const requested = (data as { requested?: unknown }).requested;
return typeof requested === 'string' ? requested : undefined;
}
function parseJsonRpcErrorBody(body: string | undefined): { code: number; message: string; data?: unknown } | undefined {
if (body === undefined || body === '') return undefined;
let parsed: unknown;
try {
parsed = JSON.parse(body);
} catch {
return undefined;
}
if (typeof parsed !== 'object' || parsed === null) return undefined;
const error = (parsed as { error?: unknown }).error;
if (typeof error !== 'object' || error === null) return undefined;
const { code, message, data } = error as { code?: unknown; message?: unknown; data?: unknown };
if (typeof code !== 'number') return undefined;
return { code, message: typeof message === 'string' ? message : '', data };
}

View file

@ -0,0 +1,640 @@
import type { ListToolsResult, Tool } from '@modelcontextprotocol/core-internal';
/**
* Client-side response cache for SEP-2549 (`CacheableResult`) freshness hints.
*
* The store is a dumb keyed-value carrier: every freshness, scope and
* invalidation decision lives in the {@linkcode ClientResponseCache} (the
* `Client`'s single cache-coordination collaborator). The `stamp` field is
* mcp.d's re-derivation key — a derived view (e.g. the `name → Tool` index)
* re-computes only when the backing entry's stamp changes.
*
* Reference design: mcp.d `client/cache.d` / `client/client.d` (`CacheStore`,
* `cachedTool`, `cachedFetch`, `invalidateLogical`).
*/
/** A value or a promise of one. The store interface is async-ready; the in-memory default returns plain values. */
export type MaybePromise<T> = T | Promise<T>;
/** The freshness scope of a cached entry (SEP-2549 `cacheHints.scope`). */
export type CacheScope = 'public' | 'private';
/**
* Per-call cache disposition for the cacheable verbs (`listTools()` /
* `listPrompts()` / `listResources()` / `listResourceTemplates()` /
* `readResource()`):
*
* - `'use'` (the default) — serve a still-fresh cached entry without a round
* trip; on miss/stale, fetch and write.
* - `'refresh'` — always fetch (ignore any held entry) and write the fresh
* result.
* - `'bypass'` — fetch without consulting OR writing the cache (the result is
* not stored). The `tools/list`-derived index (mirroring / output
* validation) is therefore unaffected by a `'bypass'` call.
*/
export type CacheMode = 'use' | 'refresh' | 'bypass';
/**
* A logical cache address. `params` is the canonical result-affecting params
* key (`''` for the four list ops, the `uri` for `resources/read`); omitted is
* equivalent to `''`. `partition` namespaces the entry by connected-server
* identity AND per-principal scope: the `Client` writes a JSON-encoded
* `[serverIdentity, principal]` pair (so a server-controlled `serverInfo`
* string cannot bleed into the principal slot regardless of what characters
* it contains). A `'public'`-scoped entry lives at `[serverIdentity, '']`; a
* `'private'`-scoped entry at `[serverIdentity, cachePartition]`. Omitted is
* equivalent to `''`.
*/
export interface CacheKey {
readonly method: string;
readonly params?: string;
readonly partition?: string;
}
/**
* One cached response body. `value` is the verbatim decoded result; `stamp` is
* the store-generated monotonically increasing write counter — opaque to
* callers. Derived views (e.g. a `name → Tool` index) memoize against it and
* re-derive only when it changes. `expiresAt` (absolute ms epoch, `now +
* ttlMs`) and `scope` are the client-computed freshness metadata; the store
* MUST persist them and hand them back on `get` so the read path can decide
* freshness and gate the shared-partition fallback on `scope === 'public'`.
*/
export interface CacheEntry {
readonly value: unknown;
readonly stamp: number;
readonly expiresAt?: number;
readonly scope?: CacheScope;
}
/**
* The pluggable response-cache store. The interface is intentionally narrow;
* the in-memory default is the only implementation the SDK ships.
*
* Every method is async-ready ({@linkcode MaybePromise}) so a Redis-style
* store can implement the same interface without a later breaking change; the
* in-memory default stays synchronous (plain values are valid under
* `MaybePromise`). The `Client` `await`s every call site.
*
* Entries are keyed by `{method, params, partition}` where `partition` is the
* `Client`-derived `[serverIdentity, principal]` JSON pair, so one store
* instance is safe to share across `Client` instances connected to different
* servers and/or principals: writes from distinct connections never collide,
* the shared-partition read fallback is gated on the stored
* `scope === 'public'`, and `list_changed` / `HEADER_MISMATCH` evictions are
* scoped to the connected server's two partitions — co-tenants on a shared
* store are unaffected. The `Client` constructor still allocates a fresh
* {@linkcode InMemoryResponseCacheStore} per instance by default; supply your
* own to share or persist.
*/
export interface ResponseCacheStore {
get(key: CacheKey): MaybePromise<CacheEntry | undefined>;
/**
* Writes `entry` under `key` and returns the store-generated stamp the
* resulting {@linkcode CacheEntry} carries. The store owns the stamp
* counter; callers do not supply one. The caller owns `expiresAt` and
* `scope` (the client-computed freshness metadata); the store MUST persist
* them and hand them back on `get`.
*/
set(key: CacheKey, entry: { value: unknown; expiresAt?: number; scope?: CacheScope }): MaybePromise<number>;
/**
* Drop the single entry under `key` (no-op if absent). Called for both
* `notifications/resources/updated` (per-URI) and the `list_changed`
* notifications (the list singletons live at `{method, params: '', partition}`).
*/
delete(key: CacheKey): MaybePromise<void>;
/**
* Drop every entry for `method` across every partition. The `Client` does
* NOT call this (its `list_changed` path issues two partition-scoped
* `delete()` calls so co-tenants on a shared store keep their entries);
* kept on the interface for callers that want a method-wide bulk-clear.
*/
evict(method: string): MaybePromise<void>;
/** Drop every entry (connection reset). */
clear(): MaybePromise<void>;
}
/** Options for {@linkcode InMemoryResponseCacheStore}. */
export interface InMemoryResponseCacheStoreOptions {
/**
* Maximum number of held `resources/read` entries (the only
* unbounded-keyspace method). When inserting a new `resources/read` key
* would exceed this, the oldest such entry (by insertion order) is
* evicted first. The list-singleton methods (`tools/list`,
* `prompts/list`, `resources/list`, `resources/templates/list`,
* `server/discover`) are **exempt** — they hold at most one entry per
* partition and back the `tools/list`-derived index, so an unbounded URI
* working set never displaces them. The default of `512` bounds growth on
* a long-lived client against template-expanded URIs. `0` disables the
* bound.
*/
maxEntries?: number;
}
/**
* Methods whose entries are exempt from the
* {@linkcode InMemoryResponseCacheStoreOptions.maxEntries} cap. Each holds at
* most one entry per partition (a small bounded set) and the
* `tools/list`-derived index depends on its entry surviving regardless of the
* `resources/read` working-set size. Only `resources/read` keys count toward
* the cap and are eligible for FIFO eviction.
*/
const CAP_EXEMPT_METHODS: ReadonlySet<string> = new Set([
'tools/list',
'prompts/list',
'resources/list',
'resources/templates/list',
'server/discover'
]);
/**
* In-memory default. Bounded by an insertion-ordered size cap (default `512`;
* see {@linkcode InMemoryResponseCacheStoreOptions.maxEntries}) on the
* `resources/read` keyspace so an unbounded stream of distinct URIs cannot
* grow it without limit; the list-singleton methods are exempt and never
* evicted by the cap. `Map` preserves insertion order, so the oldest live
* capped key is the first matching iteration entry.
*/
export class InMemoryResponseCacheStore implements ResponseCacheStore {
private readonly _entries = new Map<string, CacheEntry>();
private readonly _maxEntries: number;
private _stamp = 0;
/** Count of held entries that are subject to the cap (i.e. not in {@linkcode CAP_EXEMPT_METHODS}). */
private _cappedSize = 0;
constructor(options?: InMemoryResponseCacheStoreOptions) {
this._maxEntries = options?.maxEntries ?? 512;
}
/** Number of held entries (for diagnostics / bounding tests). */
get size(): number {
return this._entries.size;
}
get(key: CacheKey): CacheEntry | undefined {
return this._entries.get(keyOf(key));
}
set(key: CacheKey, entry: { value: unknown; expiresAt?: number; scope?: CacheScope }): number {
const k = keyOf(key);
const exempt = CAP_EXEMPT_METHODS.has(key.method);
const isNew = !this._entries.has(k);
// Evict the oldest CAPPED entry first when adding a NEW capped key
// would exceed the cap (re-set of an existing key never evicts; an
// exempt-method write never evicts). `Map` iteration order is
// insertion order, so the first non-exempt key is the oldest one.
if (!exempt && isNew && this._maxEntries > 0 && this._cappedSize >= this._maxEntries) {
for (const oldKey of this._entries.keys()) {
if (!CAP_EXEMPT_METHODS.has(oldKey.slice(0, oldKey.indexOf('\0')))) {
this._entries.delete(oldKey);
this._cappedSize--;
break;
}
}
}
const stamp = ++this._stamp;
this._entries.set(k, { ...entry, stamp });
if (isNew && !exempt) this._cappedSize++;
return stamp;
}
delete(key: CacheKey): void {
if (this._entries.delete(keyOf(key)) && !CAP_EXEMPT_METHODS.has(key.method)) this._cappedSize--;
}
evict(method: string): void {
const prefix = `${method}\0`;
const exempt = CAP_EXEMPT_METHODS.has(method);
for (const k of this._entries.keys()) {
if (k.startsWith(prefix)) {
this._entries.delete(k);
if (!exempt) this._cappedSize--;
}
}
}
clear(): void {
this._entries.clear();
this._cappedSize = 0;
}
}
/**
* Serialize a {@linkcode CacheKey} for the in-memory map. `method` is always
* an SDK-set MCP method string (never contains a NUL), so the `\0` prefix
* delimiter is safe and lets {@linkcode InMemoryResponseCacheStore.evict} do a
* cheap prefix scan. `partition` (already a JSON-encoded
* `[serverIdentity, principal]` pair) and `params` (a resource URI on the
* `resources/read` path — caller-controlled) are JSON-array-encoded together,
* which is collision-free regardless of any NUL or delimiter characters they
* carry.
*/
function keyOf(key: CacheKey): string {
return `${key.method}\0${JSON.stringify([key.partition ?? '', key.params ?? ''])}`;
}
/**
* Serialize a `{method, params}` pair for the eviction-generation map. The
* list singletons key on `method` alone (their {@linkcode ClientResponseCache.evict}
* is whole-method); `resources/read` keys on `` `${method}\0${uri}` `` so
* {@linkcode ClientResponseCache.evictKey} bumps a per-URI counter.
*/
function genKey(method: string, params?: string): string {
return params === undefined ? method : `${method}\0${params}`;
}
/**
* Upper bound on the server-supplied `ttlMs` honoured by
* {@linkcode ClientResponseCache} (24h). A server cannot pin an entry
* indefinitely.
*/
export const MAX_CACHE_TTL_MS = 86_400_000;
/**
* The `Client`'s cache-coordination collaborator.
*
* Owns the per-connection cache state that used to live as five private
* fields on `Client` — the backing {@linkcode ResponseCacheStore}, the
* per-method eviction-generation counter, the user-supplied/default flag, and
* the stamp-memoized derived indices over the `tools/list` entry. `Client`
* holds exactly one instance and never reaches past it to the store.
*
* Not exported from the package index — internal to the client package.
*
* @internal
*/
export class ClientResponseCache {
/**
* Per-logical-key eviction-generation counter. {@linkcode evict} (whole
* method) and {@linkcode evictKey} (single `{method, params}`) bump it
* before touching the store; {@linkcode captureGeneration} reads it before
* the request; {@linkcode write} skips when it moved — so a `list_changed`
* arriving mid-walk, or a `resources/updated` arriving while a
* `readResource()` for the same URI is in flight, is not overwritten by
* the in-flight request's stale write. The map key is `method` for the
* list singletons and `` `${method}\0${params}` `` for per-URI keys.
*
* Growth is bounded by keys the CLIENT has issued a `captureGeneration`
* for: {@linkcode captureGeneration} records the key (so an interleaved
* {@linkcode evictKey} sees there is an in-flight write to suppress);
* {@linkcode evictKey} only bumps a key that is already recorded — a
* server streaming `notifications/resources/updated` for URIs the client
* has never read therefore cannot grow this map.
*/
private readonly _evictionGeneration = new Map<string, number>();
/**
* `name → Tool` index derived from the cached `tools/list` entry, memoized
* against the entry's `stamp` so it re-derives only when the backing entry
* changes (mcp.d's `cachedTool` pattern).
*/
private _toolIndex?: { stamp: number; byName: Map<string, Tool> };
/**
* `name → compiled output-schema validator` derived from the cached
* `tools/list` entry; same stamp-keyed memoization as `_toolIndex`. Typed
* `unknown` so this class stays free of any validator-provider dependency
* — the compile callback supplied to {@linkcode outputValidator} owns the
* concrete type.
*/
private _toolOutputValidatorIndex?: { stamp: number; byName: Map<string, unknown> };
/**
* The connected server's identity (`serverInfo.name@version`, or a
* transport-supplied surrogate). Set by the `Client` immediately after a
* successful connect; `''` is the pre-connect sentinel. Every storage
* partition is derived from this (see `_partitionFor`), so two
* clients sharing one store but connected to different servers never
* collide on `tools/list` and a server cannot read another server's
* `'public'` entries.
*/
private _serverIdentity = '';
constructor(
private readonly _store: ResponseCacheStore,
/**
* Whether `_store` was supplied by the caller. A user-supplied store is
* never `clear()`ed by {@linkcode resetForReconnect} (defeats the only
* reason to supply one).
*/
private readonly _isUserSupplied: boolean,
/**
* Sink for a custom store's `set()`/`evict()` failure. {@linkcode write}
* never lets a store rejection cost the caller a result it already
* fetched — the failure is reported here and the write resolves. The
* `Client` wires this to `onerror`.
*/
private readonly _reportError: (error: unknown) => void = () => {},
/**
* The opaque per-principal identifier for this client (the
* `private`-scope storage slot within the connected server's
* namespace). `''` (the default) makes the `private` slot identical to
* the server's shared `public` slot — the safe single-tenant posture.
* See `_partitionFor`.
*/
private readonly _cachePartition: string = '',
/**
* Clock seam (testing). The freshness check (`entry.expiresAt > now()`)
* and the `expiresAt = now() + ttlMs` stamp both read it via
* {@linkcode now}. Default `Date.now`.
*/
private readonly _now: () => number = Date.now
) {}
/** The clock used for every freshness computation and check. */
now(): number {
return this._now();
}
/**
* Record the connected server's identity. Called by `Client` immediately
* after a successful connect (`serverInfo.name@version`, or the
* transport's `sessionId` when `serverInfo` is unavailable). Every
* partition derived after this call is scoped to this identity; entries
* written under the pre-connect `''` sentinel are no longer reachable.
*/
setServerIdentity(identity: string): void {
this._serverIdentity = identity;
}
/**
* Derive the storage partition for `scope`. The encoding is
* `JSON.stringify([serverIdentity, principal])` — JSON escaping makes it
* collision-free by construction: a malicious server cannot craft a
* `serverInfo.name`/`version` whose concatenated form bleeds into another
* server's namespace or another principal's slot, regardless of `@` / `|`
* / `"` / NUL in the server-controlled strings. `'public'` →
* `[serverIdentity, '']` (shared within this server); `'private'` →
* `[serverIdentity, cachePartition]`. When `cachePartition` is `''` the
* two coincide.
*/
private _partitionFor(scope: CacheScope): string {
return JSON.stringify([this._serverIdentity, scope === 'public' ? '' : this._cachePartition]);
}
/**
* Two-probe lookup: this client's own (private) partition first, then the
* connected server's shared (public) partition. The shared probe is gated
* on `entry.scope === 'public'` — a co-tenant client that omits
* `cachePartition` writes its `'private'`-scoped entries at the public
* partition, and serving those to a correctly-partitioned client would
* leak private bodies (mcp.d's `cachedEntry` two-probe order; the scope
* gate is defence-in-depth on top of the partition split). When
* `cachePartition` is `''` the two partitions are identical and only one
* probe is issued.
*/
private async _probe(method: string, params?: string): Promise<CacheEntry | undefined> {
const key = { method, params: params ?? '' };
const ownPartition = this._partitionFor('private');
const own = await this._store.get({ ...key, partition: ownPartition });
if (own !== undefined) return own;
const sharedPartition = this._partitionFor('public');
if (sharedPartition === ownPartition) return undefined;
const shared = await this._store.get({ ...key, partition: sharedPartition });
return shared?.scope === 'public' ? shared : undefined;
}
/**
* Bump the per-method generation (so an in-flight {@linkcode write} for the
* same method becomes a no-op) and drop the connected server's two list
* singletons (own + shared partition; `params: ''`). The generation bump
* is unconditional and FIRST — the {@linkcode write} race guard relies on
* the bump, not on the store's deletes completing.
*
* Eviction is scoped to this client's `[serverIdentity, principal]`
* partitions (mirroring {@linkcode evictKey}) — the method-wide
* `store.evict()` is NOT called, so on a shared store one server's
* `list_changed` cannot wipe a co-tenant's entry. A custom store's
* `delete()` may throw or reject; each partition is guarded
* independently so a failure on one does not skip the other, the failure
* is reported via the constructor's sink, and the call resolves so
* dispatch proceeds.
*/
async evict(method: string): Promise<void> {
this._evictionGeneration.set(method, (this._evictionGeneration.get(method) ?? 0) + 1);
const ownPartition = this._partitionFor('private');
const sharedPartition = this._partitionFor('public');
try {
await this._store.delete({ method, params: '', partition: ownPartition });
} catch (error) {
this._reportError(error);
}
if (sharedPartition !== ownPartition) {
try {
await this._store.delete({ method, params: '', partition: sharedPartition });
} catch (error) {
this._reportError(error);
}
}
}
/**
* Drop the single logical entry `{method, params}` from BOTH the private
* and public partitions for this client's connected server (mcp.d's
* `invalidateLogical`). Used for `notifications/resources/updated`'s
* per-URI eviction. The per-key generation is bumped FIRST (so an
* in-flight {@linkcode write} for the same `{method, params}` becomes a
* no-op and cannot re-cache the now-stale body) but only when the key was
* already recorded by {@linkcode captureGeneration} — bounding the map to
* keys the client has actually read. A custom store's `delete()` may
* throw or reject; each partition's delete is guarded independently so a
* failure on one does not skip the other, and the call resolves so
* dispatch proceeds.
*/
async evictKey(method: string, params: string): Promise<void> {
const gk = genKey(method, params);
// Only bump a key the client has actually captured: if no entry is
// present there is no in-flight write to suppress, and an
// unconditional bump would let a server streaming distinct-URI
// `resources/updated` notifications grow this map without bound. The
// store deletes still run regardless (a previously-written entry may
// be held even when the generation entry has since been cleared by
// `resetForReconnect`).
const current = this._evictionGeneration.get(gk);
if (current !== undefined) this._evictionGeneration.set(gk, current + 1);
const ownPartition = this._partitionFor('private');
const sharedPartition = this._partitionFor('public');
try {
await this._store.delete({ method, params, partition: ownPartition });
} catch (error) {
this._reportError(error);
}
if (sharedPartition !== ownPartition) {
try {
await this._store.delete({ method, params, partition: sharedPartition });
} catch (error) {
this._reportError(error);
}
}
}
/**
* Snapshot the eviction generation for `{method, params}` before issuing
* the request (a list walk's page 1, or a `resources/read` for `params`).
* Records the key so an interleaved {@linkcode evictKey} for the same
* `{method, params}` knows there is an in-flight write to suppress and
* bumps; without the record, `evictKey`'s recorded-only bump would skip
* and the stale body would be cached.
*/
captureGeneration(method: string, params?: string): number {
const gk = genKey(method, params);
const current = this._evictionGeneration.get(gk) ?? 0;
this._evictionGeneration.set(gk, current);
return current;
}
/**
* Write `value` under `{method}` unless the per-method generation moved
* since `capturedGen` was taken — a `list_changed` that landed mid-walk has
* already invalidated the result the caller is about to write, and
* overwriting the eviction with the stale aggregate would lose the
* invalidation.
*
* The stored value is a `structuredClone` of `value`, so a caller
* mutating the aggregate it was returned (e.g. `result.tools.sort(...)`)
* cannot reach the cache or the stamp-memoized indices derived from it. A
* custom store whose `set()` throws or rejects is routed to the
* `reportError` sink and the write resolves — cache bookkeeping never
* costs the caller a result it already fetched (consistent with the
* eviction path).
*
* `freshness` carries the client-computed `expiresAt` (absolute ms epoch,
* `now + ttlMs`) and the server-reported `cacheScope`. The storage
* `partition` is derived from the scope via `_partitionFor`:
* `'public'` → `[serverIdentity, '']` (shared within this server);
* `'private'` → `[serverIdentity, cachePartition]` (so a shared store
* never serves a private entry to another identity). Absent `freshness`
* preserves the substrate write (no `expiresAt`, private partition) — the
* `tools/list` retain-for-schema posture: never served by
* {@linkcode read}'s freshness gate, always readable by
* {@linkcode toolDefinition}.
*
* After storing under the derived partition, the same `{method, params}`
* is deleted from the OPPOSITE partition (mirroring {@linkcode evictKey}'s
* two-partition posture). A server that flips a result's `cacheScope` for
* the same key would otherwise leave the previous entry in the other slot
* — and since `_probe` checks own-partition first, a stale private entry
* would shadow the fresh public one (or a stale public entry would keep
* serving co-tenants). Both store calls are independently guarded so a
* custom store's failure on one does not skip the other.
*/
async write(
method: string,
value: unknown,
capturedGen: number,
freshness?: { expiresAt: number; scope: CacheScope; params?: string }
): Promise<void> {
if ((this._evictionGeneration.get(genKey(method, freshness?.params)) ?? 0) !== capturedGen) return;
const params = freshness?.params ?? '';
const ownPartition = this._partitionFor('private');
const sharedPartition = this._partitionFor('public');
const partition = (freshness?.scope ?? 'private') === 'public' ? sharedPartition : ownPartition;
try {
await this._store.set(
{ method, params, partition },
{ value: structuredClone(value), expiresAt: freshness?.expiresAt, scope: freshness?.scope }
);
} catch (error) {
this._reportError(error);
}
if (sharedPartition !== ownPartition) {
try {
await this._store.delete({
method,
params,
partition: partition === ownPartition ? sharedPartition : ownPartition
});
} catch (error) {
this._reportError(error);
}
}
}
/**
* Read the cached entry for `{method, params}` via the two-probe lookup
* (own-partition then this server's shared partition, gated on
* `scope === 'public'`). The caller owns the freshness check
* (`entry.expiresAt > now()`); a missing `expiresAt` is never fresh.
*/
async read(method: string, params?: string): Promise<CacheEntry | undefined> {
return this._probe(method, params);
}
/**
* Connection reset. The per-instance default store IS cleared
* (connection-scoped); a user-supplied store is NOT — that would defeat
* the only reason to supply one. The generation map and every derived
* index are dropped regardless: they are connection-scoped even when the
* backing store survives, so the next read re-derives from whatever the
* store still holds. The server identity returns to the pre-connect
* sentinel. The default impl is synchronous, so the `MaybePromise<void>`
* return is a plain void here and the caller need not await.
*/
resetForReconnect(): void {
if (!this._isUserSupplied) void this._store.clear();
this._evictionGeneration.clear();
this._toolIndex = undefined;
this._toolOutputValidatorIndex = undefined;
this._serverIdentity = '';
}
/**
* The descriptor for tool `name` taken from the cached `tools/list` entry.
* The `name → Tool` index is memoized against the entry's `stamp` and
* re-derived only when the backing entry changes (mcp.d's `cachedTool`).
* Returns `undefined` only when no `tools/list` response is held at all,
* or the held list does not contain `name`.
*
* Consumed by `callTool()`'s SEP-2243 `_resolveXMcpHeaderScan` (mirroring)
* and, via {@linkcode outputValidator}, its output-schema validation.
*/
async toolDefinition(name: string): Promise<Tool | undefined> {
const entry = await this._probe('tools/list');
if (entry === undefined) {
this._toolIndex = undefined;
return undefined;
}
if (this._toolIndex?.stamp !== entry.stamp) {
const byName = new Map<string, Tool>();
for (const tool of (entry.value as ListToolsResult).tools) byName.set(tool.name, tool);
this._toolIndex = { stamp: entry.stamp, byName };
}
return this._toolIndex.byName.get(name);
}
/**
* The compiled output-schema validator for tool `name`, derived from the
* cached `tools/list` entry — same source and same stamp-keyed
* memoization as {@linkcode toolDefinition}. The `name → validator` index
* re-derives only when the backing entry's stamp changes (a refetched
* `tools/list` recompiles; a `list_changed` eviction drops it). Returns
* `undefined` when no `tools/list` is held, the tool is absent, or it has
* no `outputSchema`.
*
* `compile` is the caller-supplied validator-compile callback (the
* `Client` passes its `_jsonSchemaValidator` wrapper) so this
* class carries no validator-provider dependency. One tool's uncompilable
* `outputSchema` (e.g. an invalid `pattern` regex or unresolvable `$ref`)
* must not poison every other tool's `callTool` — the callback isolates
* that compile error per tool by returning a per-tool error variant which
* the index stores alongside the good ones, and `callTool` surfaces it as
* a typed `InvalidParams` only for that name. Because the error is held on
* this stamp-keyed substrate (not a parallel map), it inherits the
* substrate's invalidation lifecycle: a `list_changed` eviction drops it,
* a refetched `tools/list` re-derives it, and `resetForReconnect` clears
* the lot.
*/
async outputValidator<V>(name: string, compile: (tool: Tool) => V | undefined): Promise<V | undefined> {
const entry = await this._probe('tools/list');
if (entry === undefined) {
this._toolOutputValidatorIndex = undefined;
return undefined;
}
if (this._toolOutputValidatorIndex?.stamp !== entry.stamp) {
const byName = new Map<string, unknown>();
for (const tool of (entry.value as ListToolsResult).tools) {
const compiled = compile(tool);
if (compiled !== undefined) byName.set(tool.name, compiled);
}
this._toolOutputValidatorIndex = { stamp: entry.stamp, byName };
}
return this._toolOutputValidatorIndex.byName.get(name) as V | undefined;
}
}

View file

@ -11,7 +11,16 @@ import type { ErrorEvent, EventSourceInit } from 'eventsource';
import { EventSource } from 'eventsource';
import type { AuthProvider, OAuthClientProvider } from './auth';
import { adaptOAuthProvider, auth, extractWWWAuthenticateParams, isOAuthClientProvider, UnauthorizedError } from './auth';
import {
adaptOAuthProvider,
auth,
extractWWWAuthenticateParams,
isOAuthClientProvider,
resolveAuthorizationCallbackParams,
UnauthorizedError
} from './auth';
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced in JSDoc {@linkcode}
import type { IssuerMismatchError } from './authErrors';
export class SseError extends Error {
constructor(
@ -44,6 +53,15 @@ export type SSEClientTransportOptions = {
*/
authProvider?: AuthProvider | OAuthClientProvider;
/**
* Opt-out for the RFC 8414 §3.3 issuer-echo check during authorization-server
* metadata discovery. **Security-weakening** — see
* {@linkcode index.AuthOptions.skipIssuerMetadataValidation | AuthOptions.skipIssuerMetadataValidation}.
* Only honoured when {@linkcode SSEClientTransportOptions.authProvider | authProvider}
* is an `OAuthClientProvider`.
*/
skipIssuerMetadataValidation?: boolean;
/**
* Customizes the initial SSE request to the server (the request that begins the stream).
*
@ -81,6 +99,7 @@ export class SSEClientTransport implements Transport {
private _requestInit?: RequestInit;
private _authProvider?: AuthProvider;
private _oauthProvider?: OAuthClientProvider;
private _skipIssuerMetadataValidation?: boolean;
private _fetch?: FetchLike;
private _fetchWithInit: FetchLike;
private _protocolVersion?: string;
@ -95,9 +114,12 @@ export class SSEClientTransport implements Transport {
this._scope = undefined;
this._eventSourceInit = opts?.eventSourceInit;
this._requestInit = opts?.requestInit;
this._skipIssuerMetadataValidation = opts?.skipIssuerMetadataValidation;
if (isOAuthClientProvider(opts?.authProvider)) {
this._oauthProvider = opts.authProvider;
this._authProvider = adaptOAuthProvider(opts.authProvider);
this._authProvider = adaptOAuthProvider(opts.authProvider, {
skipIssuerMetadataValidation: opts.skipIssuerMetadataValidation
});
} else {
this._authProvider = opts?.authProvider;
}
@ -228,18 +250,47 @@ export class SSEClientTransport implements Transport {
/**
* Call this method after the user has finished authorizing via their user agent and is redirected back to the MCP client application. This will exchange the authorization code for an access token, enabling the next connection attempt to successfully auth.
*
* **Preferred:** pass the callback URL's `searchParams` directly. The SDK extracts `code`
* and `iss`, validates `iss` against the recorded issuer (RFC 9207) **before** reading any
* other parameter, and on mismatch throws an {@linkcode IssuerMismatchError} that carries
* none of the callback's `error`/`error_description`/`error_uri` text. The `(code, iss?)`
* positional form remains supported for back-compat.
*
* The SDK does **not** validate `state`; compare it to your stored value before calling
* `finishAuth`.
*
* @param callbackParams - The `URLSearchParams` from the authorization callback URL
* (e.g. `new URL(callbackUrl).searchParams`). `code` and `iss` are read from it.
*/
async finishAuth(authorizationCode: string): Promise<void> {
async finishAuth(callbackParams: URLSearchParams): Promise<void>;
/**
* @param authorizationCode - The `code` query parameter from the authorization callback URL.
* @param iss - The form-urldecoded `iss` query parameter from the same callback URL, if
* present. Validated per RFC 9207 against the recorded issuer before the code is redeemed.
*/
async finishAuth(authorizationCode: string, iss?: string): Promise<void>;
async finishAuth(codeOrParams: string | URLSearchParams, iss?: string): Promise<void> {
if (!this._oauthProvider) {
throw new UnauthorizedError('finishAuth requires an OAuthClientProvider');
}
const { authorizationCode, iss: issParam } = await resolveAuthorizationCallbackParams(
codeOrParams,
iss,
this._oauthProvider,
this._url,
{ fetchFn: this._fetchWithInit, resourceMetadataUrl: this._resourceMetadataUrl }
);
const result = await auth(this._oauthProvider, {
serverUrl: this._url,
authorizationCode,
iss: issParam,
resourceMetadataUrl: this._resourceMetadataUrl,
scope: this._scope,
fetchFn: this._fetchWithInit
fetchFn: this._fetchWithInit,
skipIssuerMetadataValidation: this._skipIssuerMetadataValidation
});
if (result !== 'AUTHORIZED') {
throw new UnauthorizedError('Failed to authorize');

View file

@ -3,12 +3,15 @@ import type { ReadableWritablePair } from 'node:stream/web';
import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol/core-internal';
import {
createFetchWithInit,
encodeMcpParamValue,
isInitializedNotification,
isJSONRPCErrorResponse,
isJSONRPCRequest,
isJSONRPCResultResponse,
isModernProtocolVersion,
JSONRPCMessageSchema,
normalizeHeaders,
PROTOCOL_VERSION_META_KEY,
SdkError,
SdkErrorCode,
SdkHttpError
@ -16,7 +19,22 @@ import {
import { EventSourceParserStream } from 'eventsource-parser/stream';
import type { AuthProvider, OAuthClientProvider } from './auth';
import { adaptOAuthProvider, auth, extractWWWAuthenticateParams, isOAuthClientProvider, UnauthorizedError } from './auth';
import {
adaptOAuthProvider,
auth,
computeScopeUnion,
extractWWWAuthenticateParams,
isOAuthClientProvider,
isStrictScopeSuperset,
resolveAuthorizationCallbackParams,
UnauthorizedError
} from './auth';
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced via {@linkcode} in finishAuth JSDoc
import type { IssuerMismatchError } from './authErrors';
import { InsufficientScopeError } from './authErrors';
/** Default cap on step-up re-authorization retries within a single send/stream-open. */
const DEFAULT_MAX_STEP_UP_RETRIES = 1;
// Default reconnection options for StreamableHTTP connections
const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOptions = {
@ -49,6 +67,24 @@ export interface StartSSEOptions {
* so that the response can be associated with the new resumed request.
*/
replayMessageId?: string | number;
/**
* The per-request abort signal supplied by the caller via
* `TransportSendOptions.requestSignal`. When this signal is aborted the
* originating POST and its SSE response stream are torn down
* intentionally — `_handleSseStream` treats it exactly like the
* transport-level abort: no `onerror`, no reconnect.
*/
requestSignal?: AbortSignal;
/**
* The per-request stream-end callback supplied via
* `TransportSendOptions.onRequestStreamEnd`. Fired when the SSE response
* stream for the originating POST ends or errors for any non-deliberate
* reason (server closed, network dropped, reconnection exhausted) — never
* when `requestSignal` was aborted.
*/
onRequestStreamEnd?: () => void;
}
/**
@ -128,6 +164,15 @@ export type StreamableHTTPClientTransportOptions = {
*/
authProvider?: AuthProvider | OAuthClientProvider;
/**
* Opt-out for the RFC 8414 §3.3 issuer-echo check during authorization-server
* metadata discovery. **Security-weakening** — see
* {@linkcode index.AuthOptions.skipIssuerMetadataValidation | AuthOptions.skipIssuerMetadataValidation}.
* Only honoured when {@linkcode StreamableHTTPClientTransportOptions.authProvider | authProvider}
* is an `OAuthClientProvider`.
*/
skipIssuerMetadataValidation?: boolean;
/**
* Customizes HTTP requests to the server.
*/
@ -161,8 +206,97 @@ export type StreamableHTTPClientTransportOptions = {
* handshake so the reconnected transport continues sending the required header.
*/
protocolVersion?: string;
/**
* How the transport reacts to a `403 Forbidden` response carrying
* `WWW-Authenticate: Bearer error="insufficient_scope"`.
*
* - `'reauthorize'` (default): the transport runs the step-up authorization
* flow — computes the union of the previously-requested scope and the
* challenged scope, calls {@linkcode index.auth | auth()} (forcing a
* fresh authorization request when the union strictly exceeds the current
* token's granted scope, since refresh cannot widen scope per RFC 6749
* §6), and retries the request once. Retries are bounded by
* {@linkcode StreamableHTTPClientTransportOptions.maxStepUpRetries | maxStepUpRetries}.
* If no {@linkcode index.OAuthClientProvider | OAuthClientProvider} is
* configured, step-up cannot run and the transport throws
* {@linkcode index.InsufficientScopeError | InsufficientScopeError} instead.
* - `'throw'`: the transport throws {@linkcode index.InsufficientScopeError | InsufficientScopeError}
* carrying the challenge parameters and does not re-authorize. Use this
* for `client_credentials` / m2m clients where re-authorization cannot
* widen scope, or for interactive clients that want to gate the consent
* prompt behind UX.
*
* @default 'reauthorize'
*/
onInsufficientScope?: 'reauthorize' | 'throw';
/**
* Maximum number of step-up re-authorization attempts the transport makes
* per send (and per GET stream open) before giving up. Only consulted when
* {@linkcode StreamableHTTPClientTransportOptions.onInsufficientScope | onInsufficientScope}
* is `'reauthorize'`. Cross-request tracking ("this resource+operation
* already failed N times across the session") is host responsibility.
*
* @default 1
*/
maxStepUpRetries?: number;
};
/**
* Standard/auth header names the transport owns. The per-request
* `TransportSendOptions.headers` carrier MUST NOT be able to override these —
* they are derived from connection state (`authorization`, `mcp-session-id`)
* or from the message body itself (`mcp-protocol-version`, `mcp-method`,
* `mcp-name`), and a per-request override would let a caller produce a
* header/body disagreement the server's SEP-2243 cross-checks reject.
*/
const RESERVED_REQUEST_HEADER_NAMES: ReadonlySet<string> = new Set([
'authorization',
'content-type',
'mcp-protocol-version',
'mcp-method',
'mcp-name',
'mcp-session-id'
]);
/**
* `AbortSignal.any` with a manual fallback. `AbortSignal.any` landed in
* Node 20.3; this package's `engines` floor is `>=20`, so 20.0–20.2 must be
* served by the fallback combinator (a controller that aborts on the first
* of `a` or `b`). The native path is preferred because it propagates the
* originating signal's `reason` and participates in GC the way the spec
* defines.
*/
function anySignal(a: AbortSignal, b: AbortSignal): AbortSignal {
if (typeof AbortSignal.any === 'function') {
return AbortSignal.any([a, b]);
}
const controller = new AbortController();
if (a.aborted) return (controller.abort(a.reason), controller.signal);
if (b.aborted) return (controller.abort(b.reason), controller.signal);
// Standard polyfill shape: when EITHER input fires, remove the listener
// registered on the OTHER input too. `{once:true}` alone leaks the
// sibling listener — for `_send()`, `a` is the transport-lifetime signal,
// so every request-scoped `b` that aborts would otherwise leave one
// listener + closure pinned on `a` for the life of the transport.
const cleanup = (): void => {
a.removeEventListener('abort', onA);
b.removeEventListener('abort', onB);
};
function onA(): void {
cleanup();
controller.abort(a.reason);
}
function onB(): void {
cleanup();
controller.abort(b.reason);
}
a.addEventListener('abort', onA, { once: true });
b.addEventListener('abort', onB, { once: true });
return controller.signal;
}
/**
* Client transport for Streamable HTTP: this implements the MCP Streamable HTTP transport specification.
* It will connect to a server using HTTP `POST` for sending messages and HTTP `GET` with Server-Sent Events
@ -176,12 +310,14 @@ export class StreamableHTTPClientTransport implements Transport {
private _requestInit?: RequestInit;
private _authProvider?: AuthProvider;
private _oauthProvider?: OAuthClientProvider;
private _skipIssuerMetadataValidation?: boolean;
private _fetch?: FetchLike;
private _fetchWithInit: FetchLike;
private _sessionId?: string;
private _reconnectionOptions: StreamableHTTPReconnectionOptions;
private _protocolVersion?: string;
private _lastUpscopingHeader?: string; // Track last upscoping header to prevent infinite upscoping.
private _onInsufficientScope: 'reauthorize' | 'throw';
private _maxStepUpRetries: number;
private _serverRetryMs?: number; // Server-provided retry delay from SSE retry field
private readonly _reconnectionScheduler?: ReconnectionScheduler;
private _cancelReconnection?: () => void;
@ -190,14 +326,25 @@ export class StreamableHTTPClientTransport implements Transport {
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;
/**
* Streamable HTTP opens one POST (and SSE response stream) per outbound
* request and honors `TransportSendOptions.requestSignal`. On a 2026-era
* connection the protocol layer aborts that per-request stream as the
* spec cancellation signal instead of POSTing `notifications/cancelled`.
*/
readonly hasPerRequestStream = true;
constructor(url: URL, opts?: StreamableHTTPClientTransportOptions) {
this._url = url;
this._resourceMetadataUrl = undefined;
this._scope = undefined;
this._requestInit = opts?.requestInit;
this._skipIssuerMetadataValidation = opts?.skipIssuerMetadataValidation;
if (isOAuthClientProvider(opts?.authProvider)) {
this._oauthProvider = opts.authProvider;
this._authProvider = adaptOAuthProvider(opts.authProvider);
this._authProvider = adaptOAuthProvider(opts.authProvider, {
skipIssuerMetadataValidation: opts.skipIssuerMetadataValidation
});
} else {
this._authProvider = opts?.authProvider;
}
@ -207,6 +354,68 @@ export class StreamableHTTPClientTransport implements Transport {
this._protocolVersion = opts?.protocolVersion;
this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS;
this._reconnectionScheduler = opts?.reconnectionScheduler;
this._onInsufficientScope = opts?.onInsufficientScope ?? 'reauthorize';
this._maxStepUpRetries = Math.max(0, opts?.maxStepUpRetries ?? DEFAULT_MAX_STEP_UP_RETRIES);
}
/**
* SEP-2350 step-up: compute the union scope, decide whether refresh must be
* bypassed, and run {@linkcode auth}. Returns the auth result so the caller
* can decide whether to retry. Shared by the POST `_send` path and the GET
* `_startOrAuthSse` path so both apply the same `'throw'` short-circuit,
* the same superset-gated refresh bypass, and the same retry cap.
*/
private async _stepUpAuthorize(
challenge: { scope?: string; resourceMetadataUrl?: URL; errorDescription?: string; statusText?: string; text?: string | null },
stepUpRetries: number
): Promise<'AUTHORIZED' | 'REDIRECT'> {
if (this._onInsufficientScope === 'throw') {
throw new InsufficientScopeError({
requiredScope: challenge.scope,
resourceMetadataUrl: challenge.resourceMetadataUrl,
errorDescription: challenge.errorDescription
});
}
if (!this._oauthProvider) {
// No OAuth provider to drive step-up; surface the typed error so the
// host can act on it.
throw new InsufficientScopeError({
requiredScope: challenge.scope,
resourceMetadataUrl: challenge.resourceMetadataUrl,
errorDescription: challenge.errorDescription
});
}
if (stepUpRetries >= this._maxStepUpRetries) {
throw new SdkHttpError(
SdkErrorCode.ClientHttpForbidden,
`Server returned 403 insufficient_scope after step-up re-authorization (retry limit ${this._maxStepUpRetries} reached)`,
{ status: 403, statusText: challenge.statusText ?? 'Forbidden', text: challenge.text }
);
}
if (challenge.resourceMetadataUrl) {
this._resourceMetadataUrl = challenge.resourceMetadataUrl;
}
// Spec step-up: union of previously-requested scope and challenged scope,
// so previously-granted permissions are not lost on re-authorization.
const tokens = await this._oauthProvider.tokens();
const unionScope = computeScopeUnion(this._scope, tokens?.scope, challenge.scope);
this._scope = unionScope;
// Superset-gated refresh bypass: refresh cannot widen scope (RFC 6749 §6),
// so when the union strictly exceeds what the current token was granted
// we must force a fresh authorization request.
const forceReauthorization = isStrictScopeSuperset(unionScope, tokens?.scope);
return auth(this._oauthProvider, {
serverUrl: this._url,
resourceMetadataUrl: this._resourceMetadataUrl,
scope: unionScope,
forceReauthorization,
fetchFn: this._fetchWithInit,
skipIssuerMetadataValidation: this._skipIssuerMetadataValidation
});
}
private async _commonHeaders(): Promise<Headers> {
@ -231,8 +440,70 @@ export class StreamableHTTPClientTransport implements Transport {
});
}
private async _startOrAuthSse(options: StartSSEOptions, isAuthRetry = false): Promise<void> {
const { resumptionToken } = options;
/**
* Body-derived per-request headers: when an outgoing request carries a
* protocol-version claim in its `_meta` envelope (the version negotiation
* probe is the first such sender), `MCP-Protocol-Version` and `Mcp-Method`
* derive from the message itself. The connection-level version slot is
* neither consulted nor mutated; messages without an envelope claim are
* untouched, so no 2026 header can appear on a legacy exchange.
*/
private _applyBodyDerivedHeaders(headers: Headers, message: JSONRPCMessage | JSONRPCMessage[]): void {
if (Array.isArray(message) || !isJSONRPCRequest(message)) {
return;
}
const meta = (message.params as { _meta?: Record<string, unknown> } | undefined)?._meta;
const envelopeVersion = meta?.[PROTOCOL_VERSION_META_KEY];
if (typeof envelopeVersion !== 'string') {
return;
}
headers.set('mcp-protocol-version', envelopeVersion);
headers.set('mcp-method', message.method);
// SEP-2243 standard headers, step 2 of the 5-step client algorithm:
// Mcp-Name mirrors `params.name` (tools/call, prompts/get) or
// `params.uri` (resources/read). The value is run through the same
// `=?base64?…?=` sentinel encoding the `Mcp-Param-*` codec uses so a
// non-ASCII name/URI (or one with leading/trailing whitespace,
// control characters, or CR/LF) cannot make `Headers.set()` throw a
// TypeError or silently normalize to a value that differs from the
// body. The spec's value-encoding rules apply to `Mcp-Name`; the SDK
// server's `validateStandardRequestHeaders` decodes the sentinel via
// `decodeMcpParamValue` before the `Mcp-Name` ↔ body cross-check.
const params = message.params as { name?: unknown; uri?: unknown } | undefined;
const nameHeader =
message.method === 'resources/read'
? typeof params?.uri === 'string'
? params.uri
: undefined
: typeof params?.name === 'string'
? params.name
: undefined;
if (nameHeader !== undefined) {
headers.set('mcp-name', encodeMcpParamValue(nameHeader));
}
}
/**
* `true` when the outbound message is a single request carrying a
* modern-era protocol-version envelope claim — the same predicate that
* gates body-derived `mcp-method`/`mcp-name` emission. Used to confine the
* 400-body-as-ProtocolError delivery to modern-era exchanges only.
*/
private _isModernEnvelopedRequest(message: JSONRPCMessage | JSONRPCMessage[]): boolean {
if (Array.isArray(message) || !isJSONRPCRequest(message)) return false;
const meta = (message.params as { _meta?: Record<string, unknown> } | undefined)?._meta;
const v = meta?.[PROTOCOL_VERSION_META_KEY];
return typeof v === 'string' && isModernProtocolVersion(v);
}
private async _startOrAuthSse(options: StartSSEOptions, isAuthRetry = false, stepUpRetries = 0): Promise<void> {
const { resumptionToken, requestSignal } = options;
// Same guard as `_handleSseStream`: a resurrected listen stream (the
// POST-SSE → GET reconnect path threads `requestSignal` through
// `StartSSEOptions`) must honour the per-request abort exactly as the
// original POST did — both as a fetch signal and as a "do not surface
// onerror" gate.
const isIntentionalAbort = (): boolean => this._abortController?.signal.aborted === true || requestSignal?.aborted === true;
try {
// Try to open an initial SSE stream with GET to listen for server messages
@ -247,11 +518,16 @@ export class StreamableHTTPClientTransport implements Transport {
headers.set('last-event-id', resumptionToken);
}
const transportSignal = this._abortController?.signal;
const signal =
requestSignal !== undefined && transportSignal !== undefined
? anySignal(transportSignal, requestSignal)
: (requestSignal ?? transportSignal);
const response = await (this._fetch ?? fetch)(this._url, {
...this._requestInit,
method: 'GET',
headers,
signal: this._abortController?.signal
signal
});
if (!response.ok) {
@ -259,7 +535,9 @@ export class StreamableHTTPClientTransport implements Transport {
if (response.headers.has('www-authenticate')) {
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
this._resourceMetadataUrl = resourceMetadataUrl;
this._scope = scope;
// Preserve any union accumulated by `_stepUpAuthorize` so a 401
// mid-chain does not narrow `_scope` back to the challenge value.
this._scope = computeScopeUnion(this._scope, scope);
}
if (this._authProvider.onUnauthorized && !isAuthRetry) {
@ -270,7 +548,7 @@ export class StreamableHTTPClientTransport implements Transport {
});
await response.text?.().catch(() => {});
// Purposely _not_ awaited, so we don't call onerror twice
return this._startOrAuthSse(options, true);
return this._startOrAuthSse(options, true, stepUpRetries);
}
await response.text?.().catch(() => {});
if (isAuthRetry) {
@ -282,11 +560,35 @@ export class StreamableHTTPClientTransport implements Transport {
throw new UnauthorizedError();
}
if (response.status === 403) {
const { resourceMetadataUrl, scope, error, errorDescription } = extractWWWAuthenticateParams(response);
if (error === 'insufficient_scope') {
const text = await response.text?.().catch(() => null);
const result = await this._stepUpAuthorize(
{ scope, resourceMetadataUrl, errorDescription, statusText: response.statusText, text },
stepUpRetries
);
if (result !== 'AUTHORIZED') {
throw new UnauthorizedError();
}
return this._startOrAuthSse(options, isAuthRetry, stepUpRetries + 1);
}
}
await response.text?.().catch(() => {});
// 405 indicates that the server does not offer an SSE stream at GET endpoint
// This is an expected case that should not trigger an error
if (response.status === 405) {
// A 405 on the standalone-GET path is benign (the caller
// never had a per-request stream). On the POST→GET resume
// path it is a TERMINAL non-resumable outcome for a
// per-request stream the caller is observing — fire the
// stream-end callback so the caller can settle (otherwise
// a resumed listen subscription dead-ends silently). The
// standalone-GET callers never pass `onRequestStreamEnd`,
// so this is a no-op for them.
options.onRequestStreamEnd?.();
return;
}
@ -298,7 +600,9 @@ export class StreamableHTTPClientTransport implements Transport {
this._handleSseStream(response.body, options, true);
} catch (error) {
this.onerror?.(error as Error);
if (!isIntentionalAbort()) {
this.onerror?.(error as Error);
}
throw error;
}
}
@ -337,6 +641,8 @@ export class StreamableHTTPClientTransport implements Transport {
// Check if we've exceeded maximum retry attempts
if (attemptCount >= maxRetries) {
this.onerror?.(new Error(`Maximum reconnection attempts (${maxRetries}) exceeded.`));
// The per-request stream is now definitively gone.
options.onRequestStreamEnd?.();
return;
}
@ -345,8 +651,12 @@ export class StreamableHTTPClientTransport implements Transport {
const reconnect = (): void => {
this._cancelReconnection = undefined;
if (this._abortController?.signal.aborted) return;
// Honour BOTH the transport-wide abort and the per-request abort
// (a listen subscription closed during the backoff delay): do not
// resurrect a stream the caller already tore down.
if (this._abortController?.signal.aborted || options.requestSignal?.aborted) return;
this._startOrAuthSse(options).catch(error => {
if (this._abortController?.signal.aborted || options.requestSignal?.aborted) return;
this.onerror?.(new Error(`Failed to reconnect SSE stream: ${error instanceof Error ? error.message : String(error)}`));
try {
this._scheduleReconnection(options, attemptCount + 1);
@ -367,9 +677,20 @@ export class StreamableHTTPClientTransport implements Transport {
private _handleSseStream(stream: ReadableStream<Uint8Array> | null, options: StartSSEOptions, isReconnectable: boolean): void {
if (!stream) {
// A null body on a per-request stream (or its GET resume) is the
// same terminal non-resumable outcome as a 405 — fire the
// stream-end callback so the caller can settle. No-op for
// standalone-GET callers (they never pass `onRequestStreamEnd`).
options.onRequestStreamEnd?.();
return;
}
const { onresumptiontoken, replayMessageId } = options;
const { onresumptiontoken, replayMessageId, requestSignal, onRequestStreamEnd } = options;
// An intentional abort — transport-wide close OR a per-request abort
// (McpSubscription.close() aborting its `requestSignal`) — must read as
// a clean shutdown: no misleading "SSE stream disconnected" onerror,
// and no GET+Last-Event-ID reconnect that would resurrect a stream the
// caller just tore down.
const isIntentionalAbort = (): boolean => this._abortController?.signal.aborted === true || requestSignal?.aborted === true;
let lastEventId: string | undefined;
// Track whether we've received a priming event (event with ID)
@ -438,17 +759,29 @@ export class StreamableHTTPClientTransport implements Transport {
// BUT don't reconnect if we already received a response - the request is complete
const canResume = isReconnectable || hasPrimingEvent;
const needsReconnect = canResume && !receivedResponse;
if (needsReconnect && this._abortController && !this._abortController.signal.aborted) {
if (needsReconnect && this._abortController && !isIntentionalAbort()) {
this._scheduleReconnection(
{
resumptionToken: lastEventId,
onresumptiontoken,
replayMessageId
replayMessageId,
requestSignal,
onRequestStreamEnd
},
0
);
} else if (!isIntentionalAbort()) {
// The per-request stream ended without reconnecting (no
// priming event for a POST stream, or response already
// received). Not a deliberate abort — notify the caller.
onRequestStreamEnd?.();
}
} catch (error) {
if (isIntentionalAbort()) {
// The reader threw because we aborted it. Not an error; do
// not surface onerror, do not reconnect.
return;
}
// Handle stream errors - likely a network disconnect
this.onerror?.(new Error(`SSE stream disconnected: ${error}`));
@ -457,20 +790,27 @@ export class StreamableHTTPClientTransport implements Transport {
// BUT don't reconnect if we already received a response - the request is complete
const canResume = isReconnectable || hasPrimingEvent;
const needsReconnect = canResume && !receivedResponse;
if (needsReconnect && this._abortController && !this._abortController.signal.aborted) {
if (needsReconnect && this._abortController && !isIntentionalAbort()) {
// Use the exponential backoff reconnection strategy
try {
this._scheduleReconnection(
{
resumptionToken: lastEventId,
onresumptiontoken,
replayMessageId
replayMessageId,
requestSignal,
onRequestStreamEnd
},
0
);
} catch (error) {
this.onerror?.(new Error(`Failed to reconnect: ${error instanceof Error ? error.message : String(error)}`));
onRequestStreamEnd?.();
}
} else {
// Non-deliberate stream error without reconnection: the
// per-request stream is gone — notify the caller.
onRequestStreamEnd?.();
}
}
};
@ -489,18 +829,50 @@ export class StreamableHTTPClientTransport implements Transport {
/**
* Call this method after the user has finished authorizing via their user agent and is redirected back to the MCP client application. This will exchange the authorization code for an access token, enabling the next connection attempt to successfully auth.
*
* **Preferred:** pass the callback URL's `searchParams` directly. The SDK extracts `code`
* and `iss`, validates `iss` against the recorded issuer (RFC 9207) **before** reading any
* other parameter, and on mismatch throws an {@linkcode IssuerMismatchError} that carries
* none of the callback's `error`/`error_description`/`error_uri` text — those are
* attacker-controlled in a mix-up attack and MUST NOT be displayed. The `(code, iss?)`
* positional form remains supported for back-compat.
*
* The SDK does **not** validate `state`; compare it to your stored value before calling
* `finishAuth`.
*
* @param callbackParams - The `URLSearchParams` from the authorization callback URL
* (e.g. `new URL(callbackUrl).searchParams`). `code` and `iss` are read from it.
*/
async finishAuth(authorizationCode: string): Promise<void> {
async finishAuth(callbackParams: URLSearchParams): Promise<void>;
/**
* @param authorizationCode - The `code` query parameter from the authorization callback URL.
* @param iss - The form-urldecoded `iss` query parameter from the same callback URL, if
* present. Validated per RFC 9207 against the recorded issuer before the code is redeemed.
* When the authorization server advertises `authorization_response_iss_parameter_supported: true`,
* omitting this causes the exchange to be **rejected** with {@linkcode IssuerMismatchError}.
*/
async finishAuth(authorizationCode: string, iss?: string): Promise<void>;
async finishAuth(codeOrParams: string | URLSearchParams, iss?: string): Promise<void> {
if (!this._oauthProvider) {
throw new UnauthorizedError('finishAuth requires an OAuthClientProvider');
}
const { authorizationCode, iss: issParam } = await resolveAuthorizationCallbackParams(
codeOrParams,
iss,
this._oauthProvider,
this._url,
{ fetchFn: this._fetchWithInit, resourceMetadataUrl: this._resourceMetadataUrl }
);
const result = await auth(this._oauthProvider, {
serverUrl: this._url,
authorizationCode,
iss: issParam,
resourceMetadataUrl: this._resourceMetadataUrl,
scope: this._scope,
fetchFn: this._fetchWithInit
fetchFn: this._fetchWithInit,
skipIssuerMetadataValidation: this._skipIssuerMetadataValidation
});
if (result !== 'AUTHORIZED') {
throw new UnauthorizedError('Failed to authorize');
@ -519,39 +891,83 @@ export class StreamableHTTPClientTransport implements Transport {
async send(
message: JSONRPCMessage | JSONRPCMessage[],
options?: { resumptionToken?: string; onresumptiontoken?: (token: string) => void }
options?: {
resumptionToken?: string;
onresumptiontoken?: (token: string) => void;
requestSignal?: AbortSignal;
onRequestStreamEnd?: () => void;
headers?: Readonly<Record<string, string>>;
}
): Promise<void> {
return this._send(message, options, false);
}
private async _send(
message: JSONRPCMessage | JSONRPCMessage[],
options: { resumptionToken?: string; onresumptiontoken?: (token: string) => void } | undefined,
isAuthRetry: boolean
options:
| {
resumptionToken?: string;
onresumptiontoken?: (token: string) => void;
requestSignal?: AbortSignal;
onRequestStreamEnd?: () => void;
headers?: Readonly<Record<string, string>>;
}
| undefined,
isAuthRetry: boolean,
stepUpRetries = 0
): Promise<void> {
try {
const { resumptionToken, onresumptiontoken } = options || {};
if (resumptionToken) {
// If we have a last event ID, we need to reconnect the SSE stream
this._startOrAuthSse({ resumptionToken, replayMessageId: isJSONRPCRequest(message) ? message.id : undefined }).catch(
error => this.onerror?.(error)
);
// If we have a last event ID, we need to reconnect the SSE stream.
// Thread `requestSignal` through so the resumed GET honours the
// same per-request abort as the original POST — modern-era
// cancel-via-stream-close routes through `requestSignal`, and
// without it a resumed long-running request would not cancel.
this._startOrAuthSse({
resumptionToken,
replayMessageId: isJSONRPCRequest(message) ? message.id : undefined,
requestSignal: options?.requestSignal
}).catch(error => this.onerror?.(error));
return;
}
const headers = await this._commonHeaders();
this._applyBodyDerivedHeaders(headers, message);
// Per-request additional headers (the Client passes SEP-2243
// `Mcp-Param-*` here on a 2026-07-28 connection). Reserved
// standard/auth header names are skipped so a caller cannot
// accidentally override the body-derived or connection-level
// headers — `Headers.set` overwrites, so the only way to keep the
// transport-owned values authoritative is to refuse to write over
// them here.
if (options?.headers !== undefined) {
for (const [name, value] of Object.entries(options.headers)) {
if (RESERVED_REQUEST_HEADER_NAMES.has(name.toLowerCase())) continue;
headers.set(name, value);
}
}
headers.set('content-type', 'application/json');
const userAccept = headers.get('accept');
const types = [...(userAccept?.split(',').map(s => s.trim().toLowerCase()) ?? []), 'application/json', 'text/event-stream'];
headers.set('accept', [...new Set(types)].join(', '));
// Per-request abort: when the caller supplies a request-scoped
// signal (the `subscriptions/listen` driver), aborting it cancels
// this POST and its SSE response stream without closing the
// transport.
const transportSignal = this._abortController?.signal;
const signal =
options?.requestSignal !== undefined && transportSignal !== undefined
? anySignal(transportSignal, options.requestSignal)
: (options?.requestSignal ?? transportSignal);
const init = {
...this._requestInit,
method: 'POST',
headers,
body: JSON.stringify(message),
signal: this._abortController?.signal
signal
};
const response = await (this._fetch ?? fetch)(this._url, init);
@ -568,7 +984,9 @@ export class StreamableHTTPClientTransport implements Transport {
if (response.headers.has('www-authenticate')) {
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response);
this._resourceMetadataUrl = resourceMetadataUrl;
this._scope = scope;
// Preserve any union accumulated by `_stepUpAuthorize` so a 401
// mid-chain does not narrow `_scope` back to the challenge value.
this._scope = computeScopeUnion(this._scope, scope);
}
if (this._authProvider.onUnauthorized && !isAuthRetry) {
@ -579,7 +997,7 @@ export class StreamableHTTPClientTransport implements Transport {
});
await response.text?.().catch(() => {});
// Purposely _not_ awaited, so we don't call onerror twice
return this._send(message, options, true);
return this._send(message, options, true, stepUpRetries);
}
await response.text?.().catch(() => {});
if (isAuthRetry) {
@ -593,43 +1011,48 @@ export class StreamableHTTPClientTransport implements Transport {
const text = await response.text?.().catch(() => null);
if (response.status === 403 && this._oauthProvider) {
const { resourceMetadataUrl, scope, error } = extractWWWAuthenticateParams(response);
if (response.status === 403) {
const { resourceMetadataUrl, scope, error, errorDescription } = extractWWWAuthenticateParams(response);
if (error === 'insufficient_scope') {
const wwwAuthHeader = response.headers.get('WWW-Authenticate');
// Check if we've already tried upscoping with this header to prevent infinite loops.
if (this._lastUpscopingHeader === wwwAuthHeader) {
throw new SdkHttpError(SdkErrorCode.ClientHttpForbidden, 'Server returned 403 after trying upscoping', {
status: 403,
statusText: response.statusText,
text
});
}
if (scope) {
this._scope = scope;
}
if (resourceMetadataUrl) {
this._resourceMetadataUrl = resourceMetadataUrl;
}
// Mark that upscoping was tried.
this._lastUpscopingHeader = wwwAuthHeader ?? undefined;
const result = await auth(this._oauthProvider, {
serverUrl: this._url,
resourceMetadataUrl: this._resourceMetadataUrl,
scope: this._scope,
fetchFn: this._fetchWithInit
});
const result = await this._stepUpAuthorize(
{ scope, resourceMetadataUrl, errorDescription, statusText: response.statusText, text },
stepUpRetries
);
if (result !== 'AUTHORIZED') {
throw new UnauthorizedError();
}
return this._send(message, options, isAuthRetry, stepUpRetries + 1);
}
}
return this._send(message, options, isAuthRetry);
// SEP-2243 (and the rest of the inbound validation ladder)
// emit ladder rejections as HTTP 400 carrying a JSON-RPC error
// response body. Surface those in-band so `Protocol._onresponse`
// converts them to a typed `ProtocolError` matched to the
// pending request id — instead of an opaque transport error.
// Any 400 whose body is not a well-formed JSON-RPC error
// response (or whose id does not match an outstanding request)
// still falls through to the generic `SdkHttpError`.
//
// Modern-era only: gated on the outbound message carrying a
// 2026-07-28 envelope claim (the same gate the body-derived
// `mcp-method`/`mcp-name` headers use), so a legacy-era
// exchange keeps surfacing 400 as `SdkHttpError` exactly as
// before — the changeset's "legacy-era paths are unchanged"
// claim stays true and existing
// `e instanceof SdkHttpError && e.status === 400` callers do
// not silently stop matching.
if (response.status === 400 && typeof text === 'string' && this._isModernEnvelopedRequest(message)) {
try {
const parsed = JSONRPCMessageSchema.parse(JSON.parse(text));
const requests = (Array.isArray(message) ? message : [message]).filter(m => isJSONRPCRequest(m));
if (isJSONRPCErrorResponse(parsed) && requests.some(r => r.id === parsed.id)) {
this.onmessage?.(parsed);
return;
}
} catch {
// not a JSON-RPC error body — fall through to the generic SdkHttpError below.
}
}
@ -640,8 +1063,6 @@ export class StreamableHTTPClientTransport implements Transport {
});
}
this._lastUpscopingHeader = undefined;
// If the response is 202 Accepted, there's no body to process
if (response.status === 202) {
await response.text?.().catch(() => {});
@ -667,7 +1088,15 @@ export class StreamableHTTPClientTransport implements Transport {
// Handle SSE stream responses for requests
// We use the same handler as standalone streams, which now supports
// reconnection with the last event ID
this._handleSseStream(response.body, { onresumptiontoken }, false);
this._handleSseStream(
response.body,
{
onresumptiontoken,
requestSignal: options?.requestSignal,
onRequestStreamEnd: options?.onRequestStreamEnd
},
false
);
} else if (contentType?.includes('application/json')) {
// For non-streaming servers, we might get direct JSON responses
const data = await response.json();
@ -689,7 +1118,15 @@ export class StreamableHTTPClientTransport implements Transport {
await response.text?.().catch(() => {});
}
} catch (error) {
this.onerror?.(error as Error);
// Intentional per-request abort BEFORE response headers (the
// `subscriptions/listen` driver aborting its `requestSignal`):
// fetch rejects with AbortError. Same guard as
// `_handleSseStream`'s `isIntentionalAbort` — do not surface a
// misleading onerror; still rethrow so `listen()`'s send-catch
// settles the per-subscription state machine.
if (options?.requestSignal?.aborted !== true) {
this.onerror?.(error as Error);
}
throw error;
}
}

View file

@ -0,0 +1,422 @@
/**
* Connect-time protocol version negotiation (opt-in via
* `ClientOptions.versionNegotiation`): the option surface, the probe window (a
* raw transport exchange run before the Protocol machinery attaches), and the
* negotiation engine driving the pure {@linkcode classifyProbeOutcome} classifier.
*
* Invariants: the probe uses string ids and consumes no Protocol message ids, so
* a legacy fallback's `initialize` is byte-equivalent to a plain legacy connect;
* the transport's protocol-version slot is never mutated during negotiation
* (probe headers derive from the probe message body) and is set exactly once
* after a modern resolution; while the probe window is open, inbound messages
* that are not the probe response are dropped with zero bytes written back.
*/
import type { ClientCapabilities, DiscoverResult, Implementation, JSONRPCRequest, Transport } from '@modelcontextprotocol/core-internal';
import {
codecForVersion,
isJSONRPCErrorResponse,
isJSONRPCResultResponse,
isModernProtocolVersion,
legacyProtocolVersions,
modernProtocolVersions,
SdkError,
SdkErrorCode,
SdkHttpError,
SUPPORTED_MODERN_PROTOCOL_VERSIONS
} from '@modelcontextprotocol/core-internal';
import type { ProbeEnvironment, ProbeOutcome, ProbeTransportKind, ProbeVerdict } from './probeClassifier';
import { classifyProbeOutcome } from './probeClassifier';
/**
* Probe policy for `'auto'` and pinned negotiation modes.
*
* There is no special probe timeout opinion: the probe inherits the client's
* STANDARD request timeout unless `timeoutMs` overrides it.
*/
export interface VersionNegotiationProbeOptions {
/**
* Timeout for the probe exchange, in milliseconds.
*
* The timeout verdict is transport-aware: on stdio, a probe that gets no
* response within the timeout indicates a legacy server and falls back to
* the `initialize` handshake on the same stream; on HTTP, where a deployed
* server answers and silence means an outage, `connect()` rejects with the
* standard typed timeout error instead.
*
* @default the standard request timeout (`DEFAULT_REQUEST_TIMEOUT_MSEC`, or the `timeout` passed to `connect()`)
*/
timeoutMs?: number;
/**
* Number of times to re-send the probe after a timeout before reaching the
* timeout verdict. Governs timeout re-sends only — the spec-mandated
* `-32022` corrective continuation (select-and-continue with a mutual
* version) is a separate negotiation step and is never counted against
* `maxRetries`.
*
* @default 0 (no retries)
*/
maxRetries?: number;
}
/**
* Negotiation mode:
*
* - `'legacy'` — no negotiation: the plain 2025 connect sequence, byte-identical
* to a client without this option.
* - `'auto'` — probe with `server/discover` at connect; conservative fallback to
* the plain legacy `initialize` handshake on the same connection unless the
* outcome is definitive modern evidence. Network outage rejects with a typed
* connect error; a probe timeout falls back to `initialize` on stdio (a silent
* server on a local pipe is a legacy server) and rejects with a typed timeout
* error on HTTP (silence there is an outage).
* - `{ pin: '<version>' }` — modern era at exactly the pinned revision: the
* connect-time `server/discover` must offer it. No fallback — anything else
* fails loudly with a typed error.
*/
export type VersionNegotiationMode = 'legacy' | 'auto' | { pin: string };
/**
* Opt-in protocol version negotiation, configured on
* `ClientOptions.versionNegotiation`.
*/
export interface VersionNegotiationOptions {
/**
* @default 'legacy'
*/
mode?: VersionNegotiationMode;
/**
* Probe timeout/retry policy (only consulted by the probing modes).
*/
probe?: VersionNegotiationProbeOptions;
}
/**
* The default mode when `versionNegotiation` (or its `mode`) is absent;
* changing the default later is a flip of this single line.
*/
const DEFAULT_VERSION_NEGOTIATION_MODE: VersionNegotiationMode = 'legacy';
/** A fully resolved negotiation plan for one `connect()` call. */
export type ResolvedVersionNegotiation =
| { kind: 'legacy' }
| {
kind: 'auto';
/** Modern versions this client offers, in preference order (never empty). */
modernVersions: string[];
/** Whether this client can fall back to the legacy `initialize` handshake. */
fallbackAvailable: boolean;
probe: VersionNegotiationProbeOptions;
}
| { kind: 'pin'; version: string; probe: VersionNegotiationProbeOptions };
/**
* Resolve the negotiation options into a per-connect plan. The raw (not
* defaulted) `supportedProtocolVersions` option supplies the modern offer list;
* a list without any legacy version makes this a modern-only client (no fallback).
*/
export function resolveVersionNegotiation(
options: VersionNegotiationOptions | undefined,
supportedProtocolVersionsOption: readonly string[] | undefined
): ResolvedVersionNegotiation {
const mode = options?.mode ?? DEFAULT_VERSION_NEGOTIATION_MODE;
if (mode === 'legacy') {
return { kind: 'legacy' };
}
const probe = options?.probe ?? {};
if (typeof mode === 'object') {
if (!isModernProtocolVersion(mode.pin)) {
throw new TypeError(
`versionNegotiation: { pin: '${mode.pin}' } is not a modern protocol revision — ` +
`pinning is for 2026-07-28 and later; omit versionNegotiation (or use mode: 'legacy') for 2025-era servers.`
);
}
return { kind: 'pin', version: mode.pin, probe };
}
const explicitModern = supportedProtocolVersionsOption ? modernProtocolVersions(supportedProtocolVersionsOption) : [];
const modernVersions = explicitModern.length > 0 ? explicitModern : [...SUPPORTED_MODERN_PROTOCOL_VERSIONS];
const fallbackAvailable = supportedProtocolVersionsOption ? legacyProtocolVersions(supportedProtocolVersionsOption).length > 0 : true;
return { kind: 'auto', modernVersions, fallbackAvailable, probe };
}
/** Detect the probe environment for the network-failure row — see {@linkcode ProbeEnvironment}. */
export function detectProbeEnvironment(): ProbeEnvironment {
const g = globalThis as { window?: unknown; document?: unknown };
return g.window !== undefined && g.document !== undefined ? 'browser' : 'node';
}
/**
* Detect the transport class for the transport-aware timeout verdict (see
* {@linkcode ProbeTransportKind}). The stdio child-process transport is
* recognized structurally (`stderr`/`pid` accessors, no `instanceof` — safe
* across bundles); everything else is treated like HTTP.
*/
export function detectProbeTransportKind(transport: Transport): ProbeTransportKind {
return 'stderr' in transport && 'pid' in transport ? 'stdio' : 'http';
}
/** Raw reply from one probe exchange, before normalization. */
type RawProbeReply =
| { kind: 'response'; result?: unknown; error?: { code: number; message: string; data?: unknown } }
| { kind: 'send-error'; error: unknown }
| { kind: 'closed' }
| { kind: 'timeout' };
/**
* Temporary ownership of a raw transport for the negotiation exchange, before
* the Protocol machinery attaches. `open()` installs the window's handlers and
* starts the transport; `release()` detaches them and arms a one-shot `start()`
* pass-through so the subsequent Protocol connect (which always starts its
* transport) takes over the already-started channel without a double-start error.
*/
class ProbeWindow {
private _pending: { id: string; resolve: (reply: RawProbeReply) => void } | undefined;
private _probeCounter = 0;
private constructor(private readonly _transport: Transport) {}
static async open(transport: Transport): Promise<ProbeWindow> {
const window = new ProbeWindow(transport);
transport.onmessage = message => {
const pending = window._pending;
if (
pending !== undefined &&
(isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) &&
message.id === pending.id
) {
window._pending = undefined;
if (isJSONRPCResultResponse(message)) {
pending.resolve({ kind: 'response', result: message.result });
} else {
pending.resolve({ kind: 'response', error: message.error });
}
return;
}
// Probe-window guard: drop everything else with zero bytes written back (see module doc).
};
transport.onerror = () => {
// Out-of-band transport errors are not necessarily fatal; the probe
// resolves via a send failure, the close signal, or the timeout.
};
transport.onclose = () => {
const pending = window._pending;
if (pending !== undefined) {
window._pending = undefined;
pending.resolve({ kind: 'closed' });
}
};
await transport.start();
return window;
}
/**
* Send one probe request and await its reply. Probe ids are strings, so they
* never collide with Protocol's numeric ids (e.g. on a shared stdio pipe).
*/
async exchange(buildRequest: (id: string) => JSONRPCRequest, timeoutMs: number): Promise<RawProbeReply> {
const id = `server-discover-probe-${++this._probeCounter}`;
return new Promise<RawProbeReply>(resolve => {
let settled = false;
const settle = (reply: RawProbeReply) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (this._pending?.id === id) {
this._pending = undefined;
}
resolve(reply);
};
const timer = setTimeout(() => settle({ kind: 'timeout' }), timeoutMs);
this._pending = { id, resolve: settle };
this._transport.send(buildRequest(id)).catch((error: unknown) => settle({ kind: 'send-error', error }));
});
}
/** Detach the window's handlers, leaving the transport's own `start` untouched. */
detach(): void {
this._pending = undefined;
this._transport.onmessage = undefined;
this._transport.onerror = undefined;
this._transport.onclose = undefined;
}
/** Detach the handlers and arm the one-shot `start()` pass-through for the `Protocol.connect()` handover. */
release(): void {
this.detach();
const transport = this._transport;
const originalStart = transport.start.bind(transport);
let armed = true;
transport.start = async (): Promise<void> => {
if (armed) {
armed = false;
transport.start = originalStart;
return;
}
return originalStart();
};
}
}
/** Build the probe request: `server/discover` carrying the full per-request `_meta` envelope. */
export function buildProbeRequest(
id: string,
protocolVersion: string,
clientInfo: Implementation,
capabilities: ClientCapabilities
): JSONRPCRequest {
return {
jsonrpc: '2.0',
id,
method: 'server/discover',
params: {
// The era codec owns the keyed-envelope shape; the probe is sent
// for a modern version, so this is always the 2026 envelope.
_meta: codecForVersion(protocolVersion).outboundEnvelope({
protocolVersion,
clientInfo,
clientCapabilities: capabilities
})
}
};
}
function normalizeReply(reply: RawProbeReply, timeoutMs: number): ProbeOutcome {
switch (reply.kind) {
case 'response': {
return reply.error === undefined ? { kind: 'result', result: reply.result } : { kind: 'rpc-error', ...reply.error };
}
case 'send-error': {
const error = reply.error;
if (error instanceof SdkHttpError) {
const text = (error.data as { text?: unknown } | undefined)?.text;
return { kind: 'http-error', status: error.data.status, body: typeof text === 'string' ? text : undefined };
}
if (error instanceof Error && error.name === 'UnauthorizedError') {
// Auth-gated server: not era evidence — the conservative legacy
// fallback re-runs the auth flow through the plain connect path.
return { kind: 'http-error', status: 401 };
}
return { kind: 'network-error', error };
}
case 'closed': {
return { kind: 'network-error', error: new Error('Connection closed during the version negotiation probe') };
}
case 'timeout': {
return { kind: 'timeout', timeoutMs };
}
}
}
export interface NegotiationDeps {
transport: Transport;
clientInfo: Implementation;
capabilities: ClientCapabilities;
environment: ProbeEnvironment;
/** The transport class, for the transport-aware timeout verdict (see {@linkcode ProbeTransportKind}). */
transportKind: ProbeTransportKind;
/** The standard request timeout for this connect (probe inherits it unless `probe.timeoutMs` overrides). */
defaultTimeoutMs: number;
}
export type NegotiationResult = { era: 'modern'; version: string; discover: DiscoverResult } | { era: 'legacy' };
/**
* Run the negotiation probe state machine on a raw (not yet Protocol-connected)
* transport. Resolves with the negotiated era; throws typed connect errors. On
* return the probe window has been released: the transport is started,
* handler-free, and ready for `Protocol.connect()` handover. On throw the
* window is detached and the transport's `start` is left untouched.
*/
export async function negotiateEra(
negotiation: Extract<ResolvedVersionNegotiation, { kind: 'auto' | 'pin' }>,
deps: NegotiationDeps
): Promise<NegotiationResult> {
const timeoutMs = negotiation.probe.timeoutMs ?? deps.defaultTimeoutMs;
const maxRetries = Math.max(0, negotiation.probe.maxRetries ?? 0);
const clientModernVersions = negotiation.kind === 'pin' ? [negotiation.version] : negotiation.modernVersions;
const fallbackAvailable = negotiation.kind === 'auto' && negotiation.fallbackAvailable;
const window = await ProbeWindow.open(deps.transport);
const probe = async (): Promise<NegotiationResult> => {
let requestedVersion = clientModernVersions[0]!;
// The -32022 corrective continuation runs exactly once (even when the
// mutual version equals the just-rejected one); the loop guard arms on
// the second rejection.
let correctiveUsed = false;
// `maxRetries` governs timeout re-sends only — independent of (and
// never counted against) the corrective continuation.
let timeoutRetriesRemaining = maxRetries;
for (;;) {
const reply = await window.exchange(
id => buildProbeRequest(id, requestedVersion, deps.clientInfo, deps.capabilities),
timeoutMs
);
if (reply.kind === 'timeout' && timeoutRetriesRemaining > 0) {
timeoutRetriesRemaining--;
continue;
}
const outcome = normalizeReply(reply, timeoutMs);
const verdict: ProbeVerdict = classifyProbeOutcome(outcome, {
clientModernVersions,
requestedVersion,
fallbackAvailable,
environment: deps.environment,
transportKind: deps.transportKind
});
switch (verdict.kind) {
case 'modern': {
return { era: 'modern', version: verdict.version, discover: verdict.discover };
}
case 'corrective': {
if (correctiveUsed) {
// Second rejection: loop guard.
throw verdict.error;
}
correctiveUsed = true;
requestedVersion = verdict.version;
continue;
}
case 'legacy': {
if (negotiation.kind === 'pin') {
throw new SdkError(
SdkErrorCode.EraNegotiationFailed,
`Version negotiation failed: the server did not offer pinned protocol version ${negotiation.version} ` +
`via server/discover (no fallback in pin mode)`
);
}
if (!negotiation.fallbackAvailable) {
// Modern-only client: the legacy initialize fallback is
// unavailable and must never carry a 2026-era version string.
throw new SdkError(
SdkErrorCode.EraNegotiationFailed,
'Version negotiation failed: the server gave no modern evidence and this client supports no ' +
'pre-2026-07-28 protocol version to fall back to'
);
}
return { era: 'legacy' };
}
case 'error': {
throw verdict.error;
}
}
}
};
let result: NegotiationResult;
try {
result = await probe();
} catch (error) {
// A failed negotiation leaves the transport exactly as it found it:
// handlers detached, original start untouched (no pass-through armed).
window.detach();
throw error;
}
window.release();
return result;
}

View file

@ -8,16 +8,20 @@
export type {
AddClientAuthentication,
AuthOptions,
AuthProvider,
AuthResult,
ClientAuthMethod,
OAuthClientInformationContext,
OAuthClientProvider,
OAuthDiscoveryState,
OAuthServerInfo
} from './client/auth';
export {
assertSecureTokenEndpoint,
auth,
buildDiscoveryUrls,
computeScopeUnion,
discoverAuthorizationServerMetadata,
discoverOAuthMetadata,
discoverOAuthProtectedResourceMetadata,
@ -27,16 +31,27 @@ export {
extractWWWAuthenticateParams,
fetchToken,
isHttpsUrl,
isStrictScopeSuperset,
parseErrorResponse,
prepareAuthorizationCodeRequest,
refreshAuthorization,
registerClient,
resolveClientMetadata,
selectClientAuthMethod,
selectResourceURL,
startAuthorization,
UnauthorizedError,
validateAuthorizationResponseIssuer,
validateClientMetadataUrl
} from './client/auth';
export {
AuthorizationServerMismatchError,
InsecureTokenEndpointError,
InsufficientScopeError,
IssuerMismatchError,
OAuthClientFlowError,
RegistrationRejectedError
} from './client/authErrors';
export type {
AssertionCallback,
ClientCredentialsProviderOptions,
@ -52,15 +67,26 @@ export {
PrivateKeyJwtProvider,
StaticPrivateKeyJwtProvider
} from './client/authExtensions';
export type { ClientOptions } from './client/client';
export type { CacheableRequestOptions, CallToolRequestOptions, ClientOptions, ConnectOptions, McpSubscription } from './client/client';
export { Client } from './client/client';
export { getSupportedElicitationModes } from './client/client';
export type { DiscoverAndRequestJwtAuthGrantOptions, JwtAuthGrantResult, RequestJwtAuthGrantOptions } from './client/crossAppAccess';
export { discoverAndRequestJwtAuthGrant, exchangeJwtAuthGrant, requestJwtAuthorizationGrant } from './client/crossAppAccess';
export type { LoggingOptions, Middleware, RequestLogger } from './client/middleware';
export { applyMiddlewares, createMiddleware, withLogging, withOAuth } from './client/middleware';
export type {
CacheEntry,
CacheKey,
CacheMode,
CacheScope,
InMemoryResponseCacheStoreOptions,
MaybePromise,
ResponseCacheStore
} from './client/responseCache';
export { InMemoryResponseCacheStore, MAX_CACHE_TTL_MS } from './client/responseCache';
export type { SSEClientTransportOptions } from './client/sse';
export { SSEClientTransport, SseError } from './client/sse';
export type { VersionNegotiationMode, VersionNegotiationOptions, VersionNegotiationProbeOptions } from './client/versionNegotiation';
// StdioClientTransport, getDefaultEnvironment, DEFAULT_INHERITED_ENV_VARS, StdioServerParameters are exported from
// the './stdio' subpath to keep the root entry free of process-spawning runtime dependencies (child_process, cross-spawn).
export type {
@ -74,5 +100,11 @@ export { StreamableHTTPClientTransport } from './client/streamableHttp';
// runtime-aware wrapper (shadows core/public's fromJsonSchema with optional validator)
export { fromJsonSchema } from './fromJsonSchema';
// Multi-round-trip requests (protocol revision 2026-07-28): the client-side
// auto-fulfilment knobs (ClientOptions.inputRequired) and the manual-mode
// schema wrapper for callers that opt out of auto-fulfilment per call.
export type { InputRequiredOptions } from '@modelcontextprotocol/core-internal';
export { withInputRequired } from '@modelcontextprotocol/core-internal';
// re-export curated public API from core
export * from '@modelcontextprotocol/core-internal/public';

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,128 @@
/**
* Body-derived per-request headers on the streamable HTTP client transport:
* when a single outgoing request carries the 2026-07-28 protocol-version claim
* in its `_meta` envelope (the negotiation probe is the first such sender), the
* `MCP-Protocol-Version` and `Mcp-Method` headers derive from the message
* itself. The connection-level version slot is never consulted or mutated for
* those sends, and envelope-less (2025-era) traffic gets no new headers.
*/
import type { JSONRPCMessage } from '@modelcontextprotocol/core-internal';
import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core-internal';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp';
describe('body-derived probe headers', () => {
let transport: StreamableHTTPClientTransport;
let fetchSpy: ReturnType<typeof vi.fn>;
const okJson = (body: unknown) => ({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'application/json' }),
json: () => Promise.resolve(body)
});
beforeEach(async () => {
fetchSpy = vi.fn();
globalThis.fetch = fetchSpy as unknown as typeof fetch;
transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'));
await transport.start();
});
afterEach(async () => {
await transport.close().catch(() => {});
vi.restoreAllMocks();
});
const probeRequest: JSONRPCMessage = {
jsonrpc: '2.0',
id: 'server-discover-probe-1',
method: 'server/discover',
params: {
_meta: {
[PROTOCOL_VERSION_META_KEY]: '2026-07-28',
[CLIENT_INFO_META_KEY]: { name: 'c', version: '0' },
[CLIENT_CAPABILITIES_META_KEY]: {}
}
}
};
const sentHeaders = (): Headers => {
const init = fetchSpy.mock.calls.at(-1)?.[1] as RequestInit;
return init.headers as Headers;
};
it('derives MCP-Protocol-Version and Mcp-Method from the probe message body', async () => {
fetchSpy.mockResolvedValueOnce(
okJson({ jsonrpc: '2.0', id: 'server-discover-probe-1', result: { supportedVersions: ['2026-07-28'] } })
);
await transport.send(probeRequest);
const headers = sentHeaders();
expect(headers.get('mcp-protocol-version')).toBe('2026-07-28');
expect(headers.get('mcp-method')).toBe('server/discover');
});
it('never mutates the transport version slot for body-derived sends', async () => {
fetchSpy.mockResolvedValueOnce(
okJson({ jsonrpc: '2.0', id: 'server-discover-probe-1', result: { supportedVersions: ['2026-07-28'] } })
);
await transport.send(probeRequest);
expect(transport.protocolVersion).toBeUndefined();
// A follow-up envelope-less message gets no version header at all — the
// slot is still unset; nothing leaked from the probe.
fetchSpy.mockResolvedValueOnce(okJson({ jsonrpc: '2.0', id: 0, result: {} }));
await transport.send({ jsonrpc: '2.0', id: 0, method: 'ping', params: {} });
const headers = sentHeaders();
expect(headers.get('mcp-protocol-version')).toBeNull();
expect(headers.get('mcp-method')).toBeNull();
});
it('envelope-less (2025-era) requests are untouched: no 2026 headers, slot-driven behavior unchanged', async () => {
fetchSpy.mockResolvedValueOnce(okJson({ jsonrpc: '2.0', id: 1, result: {} }));
await transport.send({ jsonrpc: '2.0', id: 1, method: 'ping', params: {} });
let headers = sentHeaders();
expect(headers.get('mcp-protocol-version')).toBeNull();
expect(headers.get('mcp-method')).toBeNull();
expect(headers.get('mcp-name')).toBeNull();
// setProtocolVersion (the legacy post-initialize call site, byte-untouched)
// still drives the header for subsequent slot-based sends.
transport.setProtocolVersion('2025-11-25');
fetchSpy.mockResolvedValueOnce(okJson({ jsonrpc: '2.0', id: 2, result: {} }));
await transport.send({ jsonrpc: '2.0', id: 2, method: 'ping', params: {} });
headers = sentHeaders();
expect(headers.get('mcp-protocol-version')).toBe('2025-11-25');
expect(headers.get('mcp-method')).toBeNull();
});
it('a body-derived claim takes precedence over the slot for its own request only', async () => {
transport.setProtocolVersion('2025-11-25');
fetchSpy.mockResolvedValueOnce(
okJson({ jsonrpc: '2.0', id: 'server-discover-probe-1', result: { supportedVersions: ['2026-07-28'] } })
);
await transport.send(probeRequest);
expect(sentHeaders().get('mcp-protocol-version')).toBe('2026-07-28');
fetchSpy.mockResolvedValueOnce(okJson({ jsonrpc: '2.0', id: 3, result: {} }));
await transport.send({ jsonrpc: '2.0', id: 3, method: 'ping', params: {} });
expect(sentHeaders().get('mcp-protocol-version')).toBe('2025-11-25');
});
it('batch (array) sends are never body-derived', async () => {
fetchSpy.mockResolvedValueOnce(okJson([{ jsonrpc: '2.0', id: 4, result: {} }]));
await transport.send([probeRequest as never]);
const headers = sentHeaders();
expect(headers.get('mcp-protocol-version')).toBeNull();
expect(headers.get('mcp-method')).toBeNull();
});
});

View file

@ -0,0 +1,30 @@
/**
* Type-surface pins for the client's high-level methods.
*
* `callTool` returns plain `CallToolResult` on every protocol era — no task
* union (a v2 client never sends a task-augmented call, so a task result is
* unreachable from its API) and no wire-only members (`resultType` is
* consumed at the protocol layer and never reaches consumers).
*/
import type { CallToolResult, EmptyResult, ListToolsResult, ReadResourceResult } from '@modelcontextprotocol/core-internal';
import { describe, expectTypeOf, test } from 'vitest';
import { Client } from '../../src/client/client';
type KnownKeyOf<T> = keyof { [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K] };
describe('client method return types', () => {
test('callTool returns plain CallToolResult (no union, no wire-only members)', () => {
type Return = Awaited<ReturnType<Client['callTool']>>;
expectTypeOf<Return>().toEqualTypeOf<CallToolResult>();
expectTypeOf<Extract<KnownKeyOf<Return>, 'resultType'>>().toEqualTypeOf<never>();
expectTypeOf<Extract<KnownKeyOf<Return>, 'task'>>().toEqualTypeOf<never>();
});
test('the other request methods return the public result types', () => {
expectTypeOf<Awaited<ReturnType<Client['ping']>>>().toEqualTypeOf<EmptyResult>();
expectTypeOf<Awaited<ReturnType<Client['listTools']>>>().toEqualTypeOf<ListToolsResult>();
expectTypeOf<Awaited<ReturnType<Client['readResource']>>>().toEqualTypeOf<ReadResourceResult>();
expectTypeOf<Extract<KnownKeyOf<Awaited<ReturnType<Client['listTools']>>>, 'resultType'>>().toEqualTypeOf<never>();
});
});

View file

@ -0,0 +1,198 @@
/**
* `connect({ prior: DiscoverResult })` — zero-round-trip reconnect for the
* gateway / distributed-client pattern (issue #79). A previously-obtained
* `DiscoverResult` adopted directly: on a modern overlap nothing reaches the
* wire during connect; no modern overlap throws `EraNegotiationFailed` (no
* legacy fallback). Populates `getDiscoverResult()` (alongside the
* `'auto'`-mode probe path) and round-trips through JSON.
*/
import type { DiscoverResult, JSONRPCMessage, Transport } from '@modelcontextprotocol/core-internal';
import { isJSONRPCRequest, SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal';
import { describe, expect, test } from 'vitest';
import { Client } from '../../src/client/client';
const MODERN = '2026-07-28';
class ScriptedTransport implements Transport {
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;
sessionId?: string;
sent: JSONRPCMessage[] = [];
setProtocolVersionCalls: string[] = [];
constructor(private readonly script: (message: JSONRPCMessage, transport: ScriptedTransport) => void = () => {}) {}
async start(): Promise<void> {}
async close(): Promise<void> {
this.onclose?.();
}
async send(message: JSONRPCMessage): Promise<void> {
this.sent.push(message);
queueMicrotask(() => this.script(message, this));
}
setProtocolVersion(version: string): void {
this.setProtocolVersionCalls.push(version);
}
reply(message: JSONRPCMessage): void {
this.onmessage?.(message);
}
}
const prior = (supportedVersions: string[]): DiscoverResult => ({
supportedVersions,
capabilities: { tools: { listChanged: true } },
serverInfo: { name: 'persisted-server', version: '1.0.0' },
instructions: 'persisted instructions'
});
describe('connect({ prior }) — modern overlap: zero round trips', () => {
test('nothing reaches the wire during connect; era state is the post-probe state', async () => {
const transport = new ScriptedTransport();
const client = new Client({ name: 'worker', version: '0' });
await client.connect(transport, { prior: prior([MODERN]) });
// ZERO requests sent during connect.
expect(transport.sent).toHaveLength(0);
// The transport's protocol-version slot is stamped exactly once.
expect(transport.setProtocolVersionCalls).toEqual([MODERN]);
// Adopted directly from prior.
expect(client.getNegotiatedProtocolVersion()).toBe(MODERN);
expect(client.getProtocolEra()).toBe('modern');
expect(client.getServerCapabilities()).toEqual({ tools: { listChanged: true } });
expect(client.getServerVersion()).toEqual({ name: 'persisted-server', version: '1.0.0' });
expect(client.getInstructions()).toBe('persisted instructions');
expect(client.getDiscoverResult()).toEqual(prior([MODERN]));
await client.close();
});
test('callTool works immediately after a zero-round-trip connect', async () => {
const transport = new ScriptedTransport((message, t) => {
if (!isJSONRPCRequest(message)) return;
if (message.method === 'tools/call') {
t.reply({
jsonrpc: '2.0',
id: message.id,
result: { resultType: 'complete', content: [{ type: 'text', text: 'ok' }] }
});
}
});
const client = new Client({ name: 'worker', version: '0' });
await client.connect(transport, { prior: prior([MODERN]) });
// First wire traffic is the tools/call itself.
expect(transport.sent).toHaveLength(0);
const result = await client.callTool({ name: 'echo' });
expect(result.content?.[0]).toEqual({ type: 'text', text: 'ok' });
const reqs = transport.sent.filter(isJSONRPCRequest);
expect(reqs).toHaveLength(1);
expect(reqs[0]!.method).toBe('tools/call');
await client.close();
});
test('prior bypasses versionNegotiation resolution (no probe even with mode: auto)', async () => {
const transport = new ScriptedTransport();
const client = new Client({ name: 'worker', version: '0' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(transport, { prior: prior([MODERN]) });
expect(transport.sent).toHaveLength(0);
await client.close();
});
});
describe('connect({ prior }) — no modern overlap: throws (no legacy fallback)', () => {
test('legacy-only prior → SdkError(EraNegotiationFailed) steering to mode: auto', async () => {
const transport = new ScriptedTransport();
const client = new Client({ name: 'worker', version: '0' });
await expect(client.connect(transport, { prior: prior(['2025-06-18']) })).rejects.toSatisfy(
error =>
error instanceof SdkError &&
error.code === SdkErrorCode.EraNegotiationFailed &&
/2026-07-28\+ mutual/.test(error.message) &&
/mode: 'auto'/.test(error.message)
);
// Nothing reached the transport (the throw is before super.connect()).
expect(transport.sent).toHaveLength(0);
expect(client.getDiscoverResult()).toBeUndefined();
});
test('disjoint modern lists → SdkError(EraNegotiationFailed)', async () => {
const transport = new ScriptedTransport();
const client = new Client({ name: 'worker', version: '0' });
await expect(client.connect(transport, { prior: prior(['2099-01-01']) })).rejects.toSatisfy(
error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed
);
expect(transport.sent).toHaveLength(0);
});
});
describe('getDiscoverResult() round-trip', () => {
test("'auto'-mode probe populates it; JSON.stringify/parse round-trips into connect({ prior })", async () => {
// Bootstrap: a real probe against a scripted modern server.
const bootstrapTransport = new ScriptedTransport((message, t) => {
if (!isJSONRPCRequest(message)) return;
if (message.method === 'server/discover') {
t.reply({
jsonrpc: '2.0',
id: message.id,
result: {
supportedVersions: [MODERN],
capabilities: { tools: {} },
serverInfo: { name: 'probed-server', version: '2.0.0' }
}
});
}
});
const bootstrap = new Client({ name: 'bootstrap', version: '0' }, { versionNegotiation: { mode: 'auto' } });
await bootstrap.connect(bootstrapTransport);
const probed = bootstrap.getDiscoverResult();
expect(probed?.serverInfo).toEqual({ name: 'probed-server', version: '2.0.0' });
expect(probed?.supportedVersions).toEqual([MODERN]);
await bootstrap.close();
// close() clears per-connection state.
expect(bootstrap.getDiscoverResult()).toBeUndefined();
// Persist + revive (the gateway pattern's "write to Redis/config" step).
const persisted = JSON.stringify(probed);
const revived = JSON.parse(persisted) as DiscoverResult;
// Worker: zero-round-trip connect from the revived blob.
const workerTransport = new ScriptedTransport();
const worker = new Client({ name: 'worker', version: '0' });
await worker.connect(workerTransport, { prior: revived });
expect(workerTransport.sent).toHaveLength(0);
expect(worker.getServerVersion()).toEqual({ name: 'probed-server', version: '2.0.0' });
expect(worker.getDiscoverResult()).toEqual(revived);
await worker.close();
});
test('discover() populates it on an already-connected modern client', async () => {
const transport = new ScriptedTransport((message, t) => {
if (!isJSONRPCRequest(message)) return;
if (message.method === 'server/discover') {
t.reply({
jsonrpc: '2.0',
id: message.id,
result: {
resultType: 'complete',
ttlMs: 0,
cacheScope: 'public',
supportedVersions: [MODERN],
capabilities: { tools: {} },
serverInfo: { name: 'rediscovered', version: '3.0.0' }
}
});
}
});
const client = new Client({ name: 'c', version: '0' });
await client.connect(transport, { prior: prior([MODERN]) });
expect(client.getDiscoverResult()?.serverInfo.name).toBe('persisted-server');
const fresh = await client.discover();
expect(fresh.serverInfo.name).toBe('rediscovered');
expect(client.getDiscoverResult()?.serverInfo.name).toBe('rediscovered');
await client.close();
});
});

View file

@ -1,10 +1,49 @@
import type { FetchLike } from '@modelcontextprotocol/core-internal';
import { describe, expect, it, vi } from 'vitest';
import { InsecureTokenEndpointError } from '../../src/client/authErrors';
import { discoverAndRequestJwtAuthGrant, exchangeJwtAuthGrant, requestJwtAuthorizationGrant } from '../../src/client/crossAppAccess';
describe('crossAppAccess', () => {
describe('requestJwtAuthorizationGrant', () => {
it('rejects a non-https token endpoint before sending credentials (SEP-2207)', async () => {
const mockFetch = vi.fn<FetchLike>();
await expect(
requestJwtAuthorizationGrant({
tokenEndpoint: 'http://idp.internal/token',
audience: 'https://auth.chat.example/',
resource: 'https://mcp.chat.example/',
idToken: 'id-token',
clientId: 'client',
clientSecret: 'secret',
fetchFn: mockFetch
})
).rejects.toThrow(InsecureTokenEndpointError);
expect(mockFetch).not.toHaveBeenCalled();
});
it('permits a loopback http token endpoint (SEP-2207 exemption)', async () => {
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
ok: true,
json: async () => ({
issued_token_type: 'urn:ietf:params:oauth:token-type:id-jag',
access_token: 'jag',
token_type: 'N_A'
})
} as Response);
await expect(
requestJwtAuthorizationGrant({
tokenEndpoint: 'http://localhost:3000/token',
audience: 'https://auth.chat.example/',
resource: 'https://mcp.chat.example/',
idToken: 'id-token',
clientId: 'client',
fetchFn: mockFetch
})
).resolves.toMatchObject({ jwtAuthGrant: 'jag' });
expect(mockFetch).toHaveBeenCalledOnce();
});
it('successfully exchanges ID token for JWT Authorization Grant', async () => {
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
ok: true,
@ -34,7 +73,7 @@ describe('crossAppAccess', () => {
expect(mockFetch).toHaveBeenCalledOnce();
const [url, init] = mockFetch.mock.calls[0]!;
expect(url).toBe('https://idp.example.com/token');
expect(String(url)).toBe('https://idp.example.com/token');
expect(init?.method).toBe('POST');
expect(init?.headers).toEqual({
'Content-Type': 'application/x-www-form-urlencoded'
@ -290,6 +329,20 @@ describe('crossAppAccess', () => {
});
describe('exchangeJwtAuthGrant', () => {
it('rejects a non-https token endpoint before sending credentials (SEP-2207)', async () => {
const mockFetch = vi.fn<FetchLike>();
await expect(
exchangeJwtAuthGrant({
tokenEndpoint: 'http://as.internal/token',
jwtAuthGrant: 'jwt',
clientId: 'client',
clientSecret: 'secret',
fetchFn: mockFetch
})
).rejects.toThrow(InsecureTokenEndpointError);
expect(mockFetch).not.toHaveBeenCalled();
});
it('exchanges JAG for access token using client_secret_basic by default', async () => {
const mockFetch = vi.fn<FetchLike>().mockResolvedValue({
ok: true,
@ -316,7 +369,7 @@ describe('crossAppAccess', () => {
expect(mockFetch).toHaveBeenCalledOnce();
const [url, init] = mockFetch.mock.calls[0]!;
expect(url).toBe('https://auth.chat.example/token');
expect(String(url)).toBe('https://auth.chat.example/token');
expect(init?.method).toBe('POST');
// SEP-990 conformance: credentials in Authorization header, NOT in body

View file

@ -0,0 +1,101 @@
/**
* Typed `Client.discover()`: issues `server/discover` through the typed
* request funnel on a 2026-era connection; on a 2025-era connection the
* method does not exist (it is absent from the legacy registry), so the
* outbound era gate rejects it locally with a typed error before anything
* reaches the transport.
*/
import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/core-internal';
import { isJSONRPCRequest, SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal';
import { describe, expect, test } from 'vitest';
import { Client } from '../../src/client/client';
const MODERN = '2026-07-28';
class ScriptedTransport implements Transport {
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;
sessionId?: string;
sent: JSONRPCMessage[] = [];
constructor(private readonly script: (message: JSONRPCMessage, transport: ScriptedTransport) => void) {}
async start(): Promise<void> {}
async close(): Promise<void> {
this.onclose?.();
}
async send(message: JSONRPCMessage): Promise<void> {
this.sent.push(message);
queueMicrotask(() => this.script(message, this));
}
setProtocolVersion(_version: string): void {}
reply(message: JSONRPCMessage): void {
this.onmessage?.(message);
}
}
const discoverBody = {
// A real 2026-era server stamps the resultType discriminator on the wire,
// and the 2026 wire shape carries the cacheable-result fields.
resultType: 'complete',
ttlMs: 0,
cacheScope: 'public',
supportedVersions: [MODERN],
capabilities: { tools: {} },
serverInfo: { name: 'modern-server', version: '1.0.0' },
instructions: 'modern instructions'
};
/** Answers server/discover (probe and typed request alike) like a modern server. */
const modernScript = (message: JSONRPCMessage, t: ScriptedTransport) => {
if (!isJSONRPCRequest(message)) return;
if (message.method === 'server/discover') {
t.reply({ jsonrpc: '2.0', id: message.id, result: discoverBody });
}
};
/** A plain 2025 server: answers initialize, -32601 for everything else. */
const legacyScript = (message: JSONRPCMessage, t: ScriptedTransport) => {
if (!isJSONRPCRequest(message)) return;
if (message.method === 'initialize') {
t.reply({
jsonrpc: '2.0',
id: message.id,
result: { protocolVersion: '2025-11-25', capabilities: {}, serverInfo: { name: 'legacy-server', version: '1.0.0' } }
});
} else {
t.reply({ jsonrpc: '2.0', id: message.id, error: { code: -32_601, message: 'Method not found' } });
}
};
describe('Client.discover()', () => {
test('issues a typed server/discover request on a 2026-era connection', async () => {
const transport = new ScriptedTransport(modernScript);
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN } } });
await client.connect(transport);
const advertisement = await client.discover();
expect(advertisement.supportedVersions).toEqual([MODERN]);
expect(advertisement.serverInfo).toEqual({ name: 'modern-server', version: '1.0.0' });
expect(advertisement.instructions).toBe('modern instructions');
await client.close();
});
test('is rejected locally with a typed error on a 2025-era connection (the method does not exist on that era)', async () => {
const transport = new ScriptedTransport(legacyScript);
const client = new Client({ name: 'c', version: '0' });
await client.connect(transport);
const sentBefore = transport.sent.length;
await expect(client.discover()).rejects.toSatisfy(
error => error instanceof SdkError && error.code === SdkErrorCode.MethodNotSupportedByProtocolVersion
);
// Rejected locally: nothing new reached the transport.
expect(transport.sent.length).toBe(sentBefore);
await client.close();
});
});

Some files were not shown because too many files have changed in this diff Show more