Add consumer-sourced e2e requirements and the legacy SSE matrix column (#2203)

This commit is contained in:
Felix Weinberger 2026-05-29 14:06:43 +01:00 • committed by GitHub
commit 4a5c863a21
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1431 additions and 11 deletions

View file

@ -0,0 +1,5 @@
---
'@modelcontextprotocol/test-e2e': patch
---
Add consumer-sourced e2e requirements (behaviors real SDK dependents rely on) and run the interaction matrix over the legacy HTTP+SSE transport, with known failures recording where v2 intentionally differs.

View file

@ -4,19 +4,22 @@
* `wire(transport, makeServer, client)` connects a server (built per call by
* `makeServer`) and a client over the named transport, returning an
* `AsyncDisposable` for `await using` teardown. All wiring is in-process —
* no real sockets, no child processes.
* no real sockets, no child processes — except the legacy SSE transport,
* whose client half opens a real EventSource stream, so it runs over a
* loopback HTTP listener backed by the test-only bridge in sse-host.ts.
*/
import { randomUUID } from 'node:crypto';
import { PassThrough } from 'node:stream';
import type { Client } from '@modelcontextprotocol/client';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { SSEClientTransport, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import type { EventStore, JSONRPCMessage, McpServer, Server } from '@modelcontextprotocol/server';
import { InMemoryTransport, ReadBuffer, serializeMessage, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server';
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
import type { Transport } from '../types.js';
import { startLegacySseHost } from './sse-host.js';
import type { SnifferOptions } from './wire-sniffer.js';
import { sniffTransport } from './wire-sniffer.js';
@ -63,6 +66,19 @@ export async function wire(transport: Transport, makeServer: ServerFactory, clie
[Symbol.asyncDispose]: () => Promise.all([client.close(), handle.close()]).then(() => {})
};
}
case 'sse': {
// v2 removed the server-side SSE transport, so the factory's server is hosted behind the
// test-only bridge in sse-host.ts and the real shipped SSEClientTransport connects to it.
const host = await startLegacySseHost(makeServer);
await client.connect(sniffTransport(new SSEClientTransport(host.url), 'client', sniff));
return {
url: host.url,
[Symbol.asyncDispose]: async () => {
await client.close();
await host.close();
}
};
}
}
}

View file

@ -0,0 +1,159 @@
/**
* Test-only legacy HTTP+SSE host bridge.
*
* v2 removed the server-side SSE transport, but the client-side
* SSEClientTransport is still shipped for talking to legacy servers. This
* bridge stands in for the removed server half so the e2e matrix can exercise
* the real client transport end to end: it speaks the legacy wire protocol
* (an `endpoint` SSE event carrying the POST URL with the sessionId,
* `message` SSE events for server→client JSON-RPC, plain POSTs for
* client→server JSON-RPC) over a real loopback listener and bridges to a real
* v2 server through the Transport interface.
*/
import { randomUUID } from 'node:crypto';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { createServer } from 'node:http';
import { JSONRPCMessageSchema } from '@modelcontextprotocol/core';
import type { JSONRPCMessage, McpServer, Server, Transport } from '@modelcontextprotocol/server';
const SSE_PATH = '/sse';
const POST_PATH = '/messages';
type AnyServer = McpServer | Server;
function toError(value: unknown): Error {
return value instanceof Error ? value : new Error(String(value));
}
/** Test-only server half of the legacy HTTP+SSE transport (v2 ships only the client half). */
export class LegacySseServerTransport implements Transport {
private _response?: ServerResponse;
readonly sessionId: string = randomUUID();
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage) => void;
constructor(private readonly _res: ServerResponse) {}
async start(): Promise<void> {
if (this._response) throw new Error('LegacySseServerTransport already started');
this._res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache, no-transform',
connection: 'keep-alive'
});
// The legacy protocol's first event tells the client where to POST its messages.
this._res.write(`event: endpoint\ndata: ${POST_PATH}?sessionId=${this.sessionId}\n\n`);
this._response = this._res;
this._res.on('close', () => {
this._response = undefined;
this.onclose?.();
});
}
async send(message: JSONRPCMessage): Promise<void> {
if (!this._response) throw new Error('SSE stream not established');
this._response.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`);
}
async close(): Promise<void> {
this._response?.end();
this._response = undefined;
this.onclose?.();
}
/** Delivers a client→server POST to the server side and answers the HTTP request (202 on success). */
async handlePostMessage(req: IncomingMessage, res: ServerResponse): Promise<void> {
if (!this._response) {
res.writeHead(500).end('SSE connection not established');
return;
}
const contentTypeHeader = req.headers['content-type'] ?? '';
if (!contentTypeHeader.includes('application/json')) {
res.writeHead(400).end(`Unsupported content-type: ${contentTypeHeader}`);
return;
}
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
}
const raw = Buffer.concat(chunks).toString('utf8');
let message: JSONRPCMessage;
try {
message = JSONRPCMessageSchema.parse(JSON.parse(raw));
} catch (error) {
res.writeHead(400).end(`Invalid message: ${raw}`);
this.onerror?.(toError(error));
return;
}
res.writeHead(202).end('Accepted');
this.onmessage?.(message);
}
}
export interface LegacySseHost {
/** URL of the SSE endpoint; GET it to open a stream. */
readonly url: URL;
close(): Promise<void>;
}
/**
* Runs a loopback legacy-SSE host: every GET on the SSE path gets its own
* server instance from the factory (mirroring hostPerSession), POSTs are
* routed to the owning session, unknown sessions get 404 and handler failures
* become 500.
*/
export async function startLegacySseHost(makeServer: () => AnyServer): Promise<LegacySseHost> {
const sessions = new Map<string, { tx: LegacySseServerTransport; server: AnyServer }>();
const handle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
const requestUrl = new URL(req.url ?? '/', 'http://127.0.0.1');
if (req.method === 'GET' && requestUrl.pathname === SSE_PATH) {
const tx = new LegacySseServerTransport(res);
const server = makeServer();
sessions.set(tx.sessionId, { tx, server });
// connect() starts the transport, which writes the SSE headers and the endpoint event.
await server.connect(tx);
return;
}
if (req.method === 'POST' && requestUrl.pathname === POST_PATH) {
const session = sessions.get(requestUrl.searchParams.get('sessionId') ?? '');
if (!session) {
res.writeHead(404, { 'content-type': 'text/plain' }).end('Session not found');
return;
}
await session.tx.handlePostMessage(req, res);
return;
}
res.writeHead(404).end();
};
const httpServer = createServer((req, res) => {
handle(req, res).catch((error: unknown) => {
// Handler failures become a 500 rather than an unhandled rejection.
if (!res.headersSent) res.writeHead(500).end(toError(error).message);
});
});
await new Promise<void>(resolve => httpServer.listen(0, '127.0.0.1', resolve));
const address = httpServer.address();
if (address === null || typeof address === 'string') throw new Error('expected the SSE host to listen on a TCP port');
const url = new URL(`http://127.0.0.1:${address.port}${SSE_PATH}`);
return {
url,
close: async () => {
for (const { tx, server } of sessions.values()) {
await server.close();
await tx.close();
}
sessions.clear();
httpServer.closeAllConnections();
await new Promise<void>(resolve => httpServer.close(() => resolve()));
}
};
}

View file

@ -10,7 +10,7 @@
import type { Requirement } from './types.js';
/** Transports with a persistent server instance / standalone notification stream. */
const STATEFUL_TRANSPORTS = ['inMemory', 'stdio', 'streamableHttp'] as const;
const STATEFUL_TRANSPORTS = ['inMemory', 'stdio', 'streamableHttp', 'sse'] as const;
export const REQUIREMENTS: Record<string, Requirement> = {
// Lifecycle & version negotiation
@ -178,11 +178,23 @@ export const REQUIREMENTS: Record<string, Requirement> = {
'protocol:progress:callback': {
source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress#progress-flow',
behavior:
"Progress notifications emitted by a handler during a request are delivered to the caller's progress callback, in order, with their progress, total, and message."
"Progress notifications emitted by a handler during a request are delivered to the caller's progress callback, in order, with their progress, total, and message.",
knownFailures: [
{
transport: 'sse',
note: "Real-socket SSE delivers a handler's progress notifications and its response in one batch; the response is processed first, so the progress notifications never reach the caller's progress callback."
}
]
},
'typescript:protocol:progress:token-injected': {
source: 'sdk',
behavior: 'Passing onprogress causes a progressToken to be injected into request _meta, preserving existing _meta fields.'
behavior: 'Passing onprogress causes a progressToken to be injected into request _meta, preserving existing _meta fields.',
knownFailures: [
{
transport: 'sse',
note: "Real-socket SSE delivers a handler's progress notifications and its response in one batch; the response is processed first, so the progress notifications never reach the caller's progress callback."
}
]
},
'protocol:progress:token-unique': {
source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress#progress-flow',
@ -198,7 +210,13 @@ export const REQUIREMENTS: Record<string, Requirement> = {
},
'protocol:timeout:reset-on-progress': {
source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#timeouts',
behavior: "When configured to do so, each progress notification resets the request's read timeout."
behavior: "When configured to do so, each progress notification resets the request's read timeout.",
knownFailures: [
{
transport: 'sse',
note: 'Same real-socket SSE batching race as protocol:progress:callback: the progress notifications are dropped before they can reset the timeout, so the request times out.'
}
]
},
'protocol:timeout:sends-cancellation': {
source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#timeouts',
@ -298,7 +316,13 @@ export const REQUIREMENTS: Record<string, Requirement> = {
},
'tools:call:progress': {
source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress#progress-flow',
behavior: "Progress notifications emitted by a tool handler reach the caller's progress callback before the tool result returns."
behavior: "Progress notifications emitted by a tool handler reach the caller's progress callback before the tool result returns.",
knownFailures: [
{
transport: 'sse',
note: "Real-socket SSE delivers a handler's progress notifications and its response in one batch; the response is processed first, so the progress notifications never reach the caller's progress callback."
}
]
},
'tools:call:sampling-roundtrip': {
transports: STATEFUL_TRANSPORTS,
@ -2578,6 +2602,127 @@ export const REQUIREMENTS: Record<string, Requirement> = {
'When a task-augmented tool call fails, the failure is stored with status failed and a subsequent tasks/result request for that task returns the stored error result instead of losing it.',
transports: STATEFUL_TRANSPORTS,
note: 'Task polling and result retrieval need the same server instance across requests, which stateless hosting does not provide.'
},
// Consumer-contract additions (sourced from real SDK dependents)
'client-transport:http:error-status-code': {
source: 'sdk',
behavior:
'When a Streamable HTTP POST or connect receives a non-OK response, the transport rejects with an SdkHttpError whose .status property is the HTTP status code so callers can branch on 401/403/404/4xx.',
transports: ['streamableHttp'],
note: 'This exercises the Streamable HTTP client transport directly; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.'
},
'typescript:protocol:error:not-connected': {
source: 'sdk',
behavior:
"Calling a request method on a Client whose transport is closed or never connected rejects with an Error containing 'Not connected', and client.transport is undefined before connect and after close.",
knownFailures: [
{
note: "changed in v2: capability-lenient list methods resolve with an empty result before connect instead of rejecting 'Not connected'; the after-close rejection still behaves as required."
}
]
},
'typescript:client-transport:http:session-id-property': {
source: 'sdk',
behavior:
'StreamableHTTPClientTransport exposes the negotiated session id via a readable .sessionId property after initialization so consumers can persist and display it.',
transports: ['streamableHttp'],
note: 'This exercises the Streamable HTTP client transport directly; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.'
},
'typescript:client-transport:http:session-id-option': {
source: 'sdk',
behavior:
'A sessionId passed to the StreamableHTTPClientTransport constructor is sent as the Mcp-Session-Id header from the first request onwards.',
transports: ['streamableHttp'],
note: 'This exercises the Streamable HTTP client transport directly; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.'
},
'client-transport:http:reconnect-failure-onerror': {
source: 'sdk',
behavior:
'When the SSE stream drops and automatic reconnection ultimately fails, the failure is delivered to the transport onerror callback rather than throwing out of an unrelated request.',
transports: ['streamableHttp'],
note: 'This exercises the Streamable HTTP client transport directly; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.'
},
'transport:standalone:raw-relay': {
source: 'sdk',
behavior:
'Client and server transports can be driven directly (start/send/onmessage/onclose/onerror) without wrapping them in a Client or Server, supporting message-relay proxies.'
},
'transport:custom:client-connect': {
source: 'sdk',
behavior:
'Client.connect accepts any consumer-implemented object satisfying the Transport interface and completes the handshake over it.',
transports: ['inMemory'],
note: 'The test supplies its own custom Transport implementation, so the matrix transport arg is ignored; it runs as a single inMemory-labelled cell to avoid duplicate runs.'
},
'protocol:transport-callbacks:wrappable-after-connect': {
source: 'sdk',
behavior:
'Consumers can wrap or replace transport.onmessage/onclose/onerror after Client.connect without breaking protocol dispatch, because the Protocol layer assigns its handlers at connect time and tolerates chaining.'
},
'transport:stdio:pre-started-tolerated': {
source: 'sdk',
behavior:
'Client.connect succeeds (or fails with a recognizable already-started error that consumers can ignore) when the StdioClientTransport was started before being passed to connect.',
transports: ['stdio'],
note: 'Spawn-based test against the real StdioClientTransport child process; only meaningful on stdio.'
},
'client-auth:auth-helper:result-values': {
source: 'sdk',
behavior:
"The auth() helper resolves to the literal string 'REDIRECT' when user authorization is required and 'AUTHORIZED' when tokens were obtained, and consumers branch on these exact values.",
transports: ['streamableHttp'],
note: 'This exercises the HTTP hosting/auth layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.'
},
'client-auth:refresh:typed-errors': {
source: 'sdk',
behavior:
'Token refresh and authorization-code exchange failures surface as typed OAuth error classes (e.g. InvalidGrantError, InvalidClientError, ServerError) so consumers can decide between re-auth and hard failure.',
transports: ['streamableHttp'],
note: 'This exercises the HTTP hosting/auth layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.',
knownFailures: [
{
note: 'changed in v2: the per-code OAuth error subclasses were consolidated into the single OAuthError class (carrying the machine-readable code), so refresh and exchange rejections are OAuthError rather than InvalidGrantError, InvalidClientError, ServerError, etc.'
}
]
},
'client-auth:no-tokens:no-auth-header': {
source: 'sdk',
behavior:
"When the OAuth provider's tokens() returns undefined the transport sends no Authorization header and the resulting 401 re-enters the auth flow, and a token response without refresh_token leads back to a full authorization-code flow on expiry rather than a refresh attempt.",
transports: ['streamableHttp'],
note: 'This exercises the HTTP hosting/auth layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.'
},
'client-transport:sse:401-unauthorized-code': {
source: 'sdk',
behavior:
'The legacy SSEClientTransport surfaces a 401 response as an SseError with code === 401 (and supports finishAuth) with the same auth-retry semantics as the Streamable HTTP transport.',
transports: ['streamableHttp'],
note: 'This exercises the legacy SSE client transport against an in-process fixture; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.'
},
'mcpserver:tool:metadata-roundtrip': {
source: 'sdk',
behavior:
'Tool metadata supplied to registerTool (title, annotations, _meta, icons) is returned verbatim in tools/list results to connected clients.'
},
'hosting:session:lifecycle-callbacks': {
source: 'sdk',
behavior:
'StreamableHTTPServerTransport invokes onsessioninitialized with the new session id after initialization and onsessionclosed when the client issues DELETE, allowing hosts to maintain a session-to-transport map.',
transports: ['streamableHttp'],
note: 'This exercises the HTTP hosting layer and session management; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.'
},
// Legacy SSE
'transport:sse:server-transport': {
source: 'sdk',
behavior:
'The SDK provides a server-side legacy HTTP+SSE transport so existing SSE deployments can be hosted on SDK components alone.',
transports: ['sse'],
note: 'This asserts the availability of the server half of the legacy SSE transport; the matrix transport arg is ignored, so it runs as a single sse-labelled cell.',
knownFailures: [
{
note: 'changed in v2: the server-side SSE transport was removed from the SDK; only the client-side SSEClientTransport remains, so the e2e sse column is hosted by a test-only bridge.'
}
]
}
} satisfies Record<string, Requirement>;

View file

@ -11,6 +11,7 @@ import { createHash, generateKeyPairSync, sign } from 'node:crypto';
import type { AuthProvider, OAuthClientProvider } from '@modelcontextprotocol/client';
import {
applyMiddlewares,
auth,
Client,
ClientCredentialsProvider,
createMiddleware,
@ -20,7 +21,10 @@ import {
OAuthError,
OAuthErrorCode,
PrivateKeyJwtProvider,
refreshAuthorization,
SdkError,
SSEClientTransport,
SseError,
startAuthorization,
StaticPrivateKeyJwtProvider,
StreamableHTTPClientTransport,
@ -1807,3 +1811,230 @@ verifies('client-auth:authprovider:oauth-provider-adapted', async (_args: TestAr
await mcpHost.close();
}
});
verifies('client-auth:auth-helper:result-values', async (_args: TestArgs) => {
const ISSUED = 'auth-helper-access-token';
const as = createMockAuthorizationServer({
tokenResponses: [{ access_token: ISSUED, token_type: 'Bearer', refresh_token: 'auth-helper-refresh-token' }]
});
const provider = new RecordingOAuthClientProvider({ clientInformation: { client_id: 'auth-helper-client' } });
const fetchFn = (url: URL | string, init?: RequestInit) => as.handleRequest(new Request(url, init));
// No tokens and no authorization code: the helper starts the redirect flow and reports it with the literal string.
const redirectResult = await auth(provider, { serverUrl: MCP_URL, fetchFn });
expect(redirectResult).toBe('REDIRECT');
expect(provider.redirectedTo).toHaveLength(1);
const redirect = defined(provider.redirectedTo[0], 'authorization redirect URL');
expect(redirect.origin + redirect.pathname).toBe(`${ISSUER}/authorize`);
expect(provider.saved.codeVerifier).toBeDefined();
expect(as.tokenCalls).toHaveLength(0);
// Completing the code exchange: tokens are persisted and the helper reports success with the literal string.
const authorizedResult = await auth(provider, { serverUrl: MCP_URL, authorizationCode: 'granted-authorization-code', fetchFn });
expect(authorizedResult).toBe('AUTHORIZED');
expect(as.tokenCalls).toHaveLength(1);
const tokenCall = defined(as.tokenCalls[0], 'token call');
expect(tokenCall.body.get('grant_type')).toBe('authorization_code');
expect(tokenCall.body.get('code')).toBe('granted-authorization-code');
expect(provider.saved.tokens?.access_token).toBe(ISSUED);
});
verifies('client-auth:refresh:typed-errors', async (_args: TestArgs) => {
// Token endpoint that always rejects with the given RFC 6749 error body.
const oauthErrorFetch =
(errorCode: string) =>
async (_url: URL | string, _init?: RequestInit): Promise<Response> =>
Response.json({ error: errorCode, error_description: `mock ${errorCode} response` }, { status: 400 });
// The per-code classes are not exported by v2, so the typed-class contract is asserted via the rejection's constructor name.
const expectTypedRejection = async (attempt: Promise<unknown>, expectedClassName: string, expectedCode: string) => {
const rejection: unknown = await attempt.then(
() => {
throw new Error('token request unexpectedly resolved');
},
(error: unknown) => error
);
expect(rejection).toBeInstanceOf(OAuthError);
if (!(rejection instanceof OAuthError)) throw new Error('expected an OAuthError rejection');
expect(rejection.name).toBe(expectedClassName);
expect(rejection.code).toBe(expectedCode);
expect(rejection.message).toContain(`mock ${expectedCode} response`);
};
const clientInformation = { client_id: 'typed-error-client' };
const refreshCases: Array<{ error: string; expectedClassName: string }> = [
{ error: 'invalid_grant', expectedClassName: 'InvalidGrantError' },
{ error: 'invalid_client', expectedClassName: 'InvalidClientError' },
{ error: 'server_error', expectedClassName: 'ServerError' },
{ error: 'temporarily_unavailable', expectedClassName: 'TemporarilyUnavailableError' }
];
for (const { error, expectedClassName } of refreshCases) {
await expectTypedRejection(
refreshAuthorization(ISSUER, {
clientInformation,
refreshToken: 'long-lived-refresh-token',
fetchFn: oauthErrorFetch(error)
}),
expectedClassName,
error
);
}
const exchangeCases: Array<{ error: string; expectedClassName: string }> = [
{ error: 'invalid_grant', expectedClassName: 'InvalidGrantError' },
{ error: 'invalid_client', expectedClassName: 'InvalidClientError' }
];
for (const { error, expectedClassName } of exchangeCases) {
await expectTypedRejection(
exchangeAuthorization(ISSUER, {
clientInformation,
authorizationCode: 'granted-authorization-code',
codeVerifier: 'a-code-verifier',
redirectUri: 'http://localhost:3000/callback',
fetchFn: oauthErrorFetch(error)
}),
expectedClassName,
error
);
}
});
verifies('client-auth:no-tokens:no-auth-header', async (_args: TestArgs) => {
// Phase 1: tokens() returns undefined — the request goes out with no Authorization header and the 401 re-enters the auth flow.
const noTokensAs = createMockAuthorizationServer();
const noTokensProvider = new RecordingOAuthClientProvider({ clientInformation: { client_id: 'no-tokens-client' } });
const noTokensHost = createAuthenticatedHost('token-never-issued');
const noTokensBaseFetch = createCombinedFetch({ as: noTokensAs, mcpHost: noTokensHost, validToken: 'token-never-issued' });
const noTokensMcpHeaders: Array<Record<string, string>> = [];
const noTokensFetch = async (url: URL | string, init?: RequestInit): Promise<Response> => {
const urlObj = typeof url === 'string' ? new URL(url) : url;
if (urlObj.origin !== ISSUER && !urlObj.pathname.includes('/.well-known/') && init?.method === 'POST') {
const headers: Record<string, string> = {};
for (const [k, v] of new Headers(init?.headers).entries()) {
headers[k] = v;
}
noTokensMcpHeaders.push(headers);
}
return noTokensBaseFetch(url, init);
};
const noTokensClient = new Client({ name: 'c', version: '0' });
const noTokensTransport = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: noTokensProvider, fetch: noTokensFetch });
try {
await expect(noTokensClient.connect(noTokensTransport)).rejects.toThrow(UnauthorizedError);
// The single unauthenticated POST carried no Authorization header at all.
expect(noTokensMcpHeaders).toHaveLength(1);
expect(defined(noTokensMcpHeaders[0], 'unauthenticated POST headers')['authorization']).toBeUndefined();
// The resulting 401 re-entered the auth flow: the user is redirected to the authorization endpoint.
expect(noTokensProvider.redirectedTo).toHaveLength(1);
const noTokensRedirect = defined(noTokensProvider.redirectedTo[0], 'authorization redirect URL');
expect(noTokensRedirect.origin + noTokensRedirect.pathname).toBe(`${ISSUER}/authorize`);
expect(noTokensProvider.saved.codeVerifier).toBeDefined();
} finally {
await noTokensClient.close();
await noTokensHost.close();
}
// Phase 2: stored tokens lack refresh_token — expiry leads back to the authorization-code flow, never a refresh attempt.
const noRefreshAs = createMockAuthorizationServer();
const noRefreshProvider = new RecordingOAuthClientProvider({
tokens: { access_token: 'expired-access-token', token_type: 'Bearer' },
clientInformation: { client_id: 'no-refresh-client' }
});
const noRefreshHost = createAuthenticatedHost('token-never-issued');
const noRefreshBaseFetch = createCombinedFetch({ as: noRefreshAs, mcpHost: noRefreshHost, validToken: 'token-never-issued' });
const noRefreshMcpAuthHeaders: Array<string | null> = [];
const noRefreshFetch = async (url: URL | string, init?: RequestInit): Promise<Response> => {
const urlObj = typeof url === 'string' ? new URL(url) : url;
if (urlObj.origin !== ISSUER && !urlObj.pathname.includes('/.well-known/') && init?.method === 'POST') {
noRefreshMcpAuthHeaders.push(new Headers(init?.headers).get('authorization'));
}
return noRefreshBaseFetch(url, init);
};
const noRefreshClient = new Client({ name: 'c', version: '0' });
const noRefreshTransport = new StreamableHTTPClientTransport(new URL(MCP_URL), {
authProvider: noRefreshProvider,
fetch: noRefreshFetch
});
try {
await expect(noRefreshClient.connect(noRefreshTransport)).rejects.toThrow(UnauthorizedError);
// The expired bearer was sent once, but with no refresh_token there is no token-endpoint call at all (no refresh grant).
expect(noRefreshMcpAuthHeaders).toEqual(['Bearer expired-access-token']);
expect(noRefreshAs.tokenCalls).toHaveLength(0);
// Instead the full authorization-code flow restarts.
expect(noRefreshProvider.redirectedTo).toHaveLength(1);
const noRefreshRedirect = defined(noRefreshProvider.redirectedTo[0], 'authorization redirect URL');
expect(noRefreshRedirect.origin + noRefreshRedirect.pathname).toBe(`${ISSUER}/authorize`);
} finally {
await noRefreshClient.close();
await noRefreshHost.close();
}
});
verifies('client-transport:sse:401-unauthorized-code', async (_args: TestArgs) => {
const wwwAuthenticate = `Bearer resource_metadata="${MCP_URL}/.well-known/oauth-protected-resource"`;
// Phase 1: no authProvider — the 401 surfaces as an SseError carrying the HTTP status code.
const bareFetch = async (_url: URL | string, _init?: RequestInit): Promise<Response> =>
new Response(null, { status: 401, headers: { 'WWW-Authenticate': wwwAuthenticate } });
const bareTransport = new SSEClientTransport(new URL(MCP_URL), { fetch: bareFetch });
try {
const rejection: unknown = await bareTransport.start().then(
() => {
throw new Error('start() unexpectedly resolved');
},
(error: unknown) => error
);
expect(rejection).toBeInstanceOf(SseError);
if (!(rejection instanceof SseError)) throw new Error('expected an SseError rejection');
expect(rejection.code).toBe(401);
} finally {
await bareTransport.close();
}
// Phase 2: with an authProvider the same 401 drives the auth flow (redirect + UnauthorizedError), and finishAuth completes the exchange.
const as = createMockAuthorizationServer({
tokenResponses: [{ access_token: 'sse-access-token', token_type: 'Bearer' }]
});
const provider = new RecordingOAuthClientProvider({ clientInformation: { client_id: 'sse-client' } });
const combinedFetch = async (url: URL | string, init?: RequestInit): Promise<Response> => {
const urlObj = typeof url === 'string' ? new URL(url) : url;
if (urlObj.origin === ISSUER || urlObj.pathname.includes('/.well-known/')) {
return as.handleRequest(new Request(url, init));
}
return new Response(null, { status: 401, headers: { 'WWW-Authenticate': wwwAuthenticate } });
};
const authTransport = new SSEClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: combinedFetch });
try {
await expect(authTransport.start()).rejects.toBeInstanceOf(UnauthorizedError);
// Same retry semantics as the streamable HTTP transport: the 401 redirected the user to the authorization endpoint.
expect(provider.redirectedTo).toHaveLength(1);
const redirect = defined(provider.redirectedTo[0], 'authorization redirect URL');
expect(redirect.origin + redirect.pathname).toBe(`${ISSUER}/authorize`);
expect(provider.saved.codeVerifier).toBeDefined();
// finishAuth exchanges the callback code for tokens, mirroring the streamable HTTP transport surface.
await authTransport.finishAuth('granted-authorization-code');
expect(as.tokenCalls).toHaveLength(1);
const tokenCall = defined(as.tokenCalls[0], 'token call');
expect(tokenCall.body.get('grant_type')).toBe('authorization_code');
expect(tokenCall.body.get('code')).toBe('granted-authorization-code');
expect(provider.saved.tokens?.access_token).toBe('sse-access-token');
} finally {
await authTransport.close();
}
});

View file

@ -896,3 +896,60 @@ verifies('hosting:session:delete-cancels-inflight', async (_args: TestArgs) => {
await host.close();
}
});
verifies('hosting:session:lifecycle-callbacks', async (_args: TestArgs) => {
// Hosts use these two callbacks to maintain a session-id -> transport map, so the test routes through one.
const initializedSessions: string[] = [];
const closedSessions: string[] = [];
const sessions = new Map<string, NodeStreamableHTTPServerTransport>();
const router = express.Router();
router.all('/mcp', async (req, res) => {
const sid = req.headers['mcp-session-id'];
const existing = typeof sid === 'string' ? sessions.get(sid) : undefined;
if (existing) {
await existing.handleRequest(req, res, req.body);
return;
}
const tx = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: randomUUID,
onsessioninitialized: id => {
initializedSessions.push(id);
sessions.set(id, tx);
},
onsessionclosed: id => {
closedSessions.push(id);
sessions.delete(id);
}
});
await echoServer().connect(tx);
await tx.handleRequest(req, res, req.body);
});
await using host = await startExpressMinimal(router);
const client = newClient();
const clientTx = new StreamableHTTPClientTransport(new URL('/mcp', host.baseUrl));
try {
await client.connect(clientTx);
const sessionId = clientTx.sessionId;
if (sessionId === undefined) throw new Error('initialize did not assign a session id');
expect(initializedSessions).toEqual([sessionId]);
expect(closedSessions).toEqual([]);
expect([...sessions.keys()]).toEqual([sessionId]);
// The map populated by onsessioninitialized routes the follow-up call back to the same transport.
const result = await client.callTool({ name: 'echo', arguments: { text: 'lifecycle' } });
expect(result.content).toEqual([{ type: 'text', text: 'lifecycle' }]);
expect(initializedSessions).toEqual([sessionId]);
expect(closedSessions).toEqual([]);
await clientTx.terminateSession();
expect(closedSessions).toEqual([sessionId]);
expect(sessions.size).toBe(0);
} finally {
for (const tx of sessions.values()) await tx.close();
await client.close();
}
});

View file

@ -13,12 +13,16 @@ import type {
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
MessageExtraInfo,
Notification,
Progress,
RequestId
RequestId,
Result,
Transport
} from '@modelcontextprotocol/server';
import {
InMemoryTransport,
isJSONRPCResultResponse,
McpServer,
ProtocolError,
ProtocolErrorCode,
@ -1460,3 +1464,217 @@ verifies('protocol:result-validation:invalid-result-sdkerror', async ({ transpor
await expect(call).rejects.toMatchObject({ code: SdkErrorCode.InvalidResult });
await expect(call).rejects.toThrow(/Invalid result for x-e2e\/wrong-shape/);
});
verifies('typescript:protocol:error:not-connected', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('summarize_document', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({
content: [{ type: 'text', text: `summary: ${text}` }]
}));
return s;
};
// Never-connected client: no transport yet, and request methods reject immediately.
const client = newClient();
expect(client.transport).toBeUndefined();
const beforeConnect = client.listTools();
await expect(beforeConnect).rejects.toBeInstanceOf(Error);
await expect(beforeConnect).rejects.toMatchObject({ message: 'Not connected' });
// Once connected over the matrix transport, the same client serves requests normally.
await using _ = await wire(transport, makeServer, client);
expect(client.transport).toBeDefined();
const listed = await client.listTools();
expect(listed.tools.map(t => t.name)).toEqual(['summarize_document']);
const result = await client.callTool({ name: 'summarize_document', arguments: { text: 'Q3 sales were flat.' } });
expect(result.content).toEqual([{ type: 'text', text: 'summary: Q3 sales were flat.' }]);
// Closed-state checks use a direct InMemory pair: the in-process stdio harness never fires the client transport's onclose.
const closedClient = newClient();
const closedServer = makeServer();
const [closedClientTx, closedServerTx] = InMemoryTransport.createLinkedPair();
try {
await closedServer.connect(closedServerTx);
await closedClient.connect(closedClientTx);
expect(closedClient.transport).toBe(closedClientTx);
await closedClient.close();
expect(closedClient.transport).toBeUndefined();
const afterClose = closedClient.listTools();
await expect(afterClose).rejects.toBeInstanceOf(Error);
await expect(afterClose).rejects.toMatchObject({ message: 'Not connected' });
} finally {
await closedClient.close();
await closedServer.close();
}
});
/** Consumer-implemented Transport: an in-process loopback that answers initialize and tools/list with canned results. */
class LoopbackTransport implements Transport {
onclose?: () => void;
onerror?: (error: Error) => void;
onmessage?: (message: JSONRPCMessage, extra?: MessageExtraInfo) => void;
readonly events: string[] = [];
readonly clientRequests: JSONRPCRequest[] = [];
callbacksPresentAtStart?: { onmessage: boolean; onclose: boolean; onerror: boolean };
constructor(private readonly serverProtocolVersion: string) {}
async start(): Promise<void> {
this.callbacksPresentAtStart = {
onmessage: this.onmessage !== undefined,
onclose: this.onclose !== undefined,
onerror: this.onerror !== undefined
};
this.events.push('start');
}
async send(message: JSONRPCMessage): Promise<void> {
this.events.push('method' in message ? `send:${message.method}` : 'send:response');
if (!isRequest(message)) return;
this.clientRequests.push(message);
if (message.method === 'initialize') {
this.respond(message.id, {
protocolVersion: this.serverProtocolVersion,
capabilities: { tools: {} },
serverInfo: { name: 'loopback-server', version: '3.1.4' }
});
} else if (message.method === 'tools/list') {
this.respond(message.id, {
tools: [{ name: 'lookup_order', description: 'Look up an order by id', inputSchema: { type: 'object' } }]
});
}
}
async close(): Promise<void> {
this.events.push('close');
this.onclose?.();
}
private respond(id: RequestId, result: Result): void {
queueMicrotask(() => this.onmessage?.({ jsonrpc: '2.0', id, result }));
}
}
verifies('transport:custom:client-connect', async ({ protocolVersion }: TestArgs) => {
// The body supplies its own consumer-implemented Transport, so the matrix transport arg is unused by design.
const customTransport = new LoopbackTransport(protocolVersion);
const client = newClient();
const clientOnclose = vi.fn();
client.onclose = clientOnclose;
try {
await client.connect(customTransport);
// Protocol installed its callbacks on the consumer object before invoking start().
expect(customTransport.callbacksPresentAtStart).toEqual({ onmessage: true, onclose: true, onerror: true });
// The full handshake ran over the consumer transport, and its canned identity is what the client now reports.
expect(customTransport.events).toEqual(['start', 'send:initialize', 'send:notifications/initialized']);
expect(client.getServerCapabilities()).toEqual({ tools: {} });
expect(client.getServerVersion()).toEqual({ name: 'loopback-server', version: '3.1.4' });
// A post-handshake request round-trips through the consumer transport's send().
const listed = await client.listTools();
expect(listed.tools).toEqual([{ name: 'lookup_order', description: 'Look up an order by id', inputSchema: { type: 'object' } }]);
expect(customTransport.clientRequests.map(m => m.method)).toEqual(['initialize', 'tools/list']);
await client.close();
// close() reached the consumer transport, and its onclose callback fed back into the client's close handling.
expect(customTransport.events).toEqual(['start', 'send:initialize', 'send:notifications/initialized', 'send:tools/list', 'close']);
expect(clientOnclose).toHaveBeenCalledTimes(1);
expect(client.transport).toBeUndefined();
} finally {
await client.close();
}
});
verifies('protocol:transport-callbacks:wrappable-after-connect', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({
content: [{ type: 'text', text }]
}));
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const tx = client.transport;
if (!tx) throw new Error('client transport not set after connect');
// Protocol assigned all three handlers at connect time, so a consumer has originals to chain to.
const protocolOnMessage = tx.onmessage;
const protocolOnClose = tx.onclose;
const protocolOnError = tx.onerror;
expect(protocolOnMessage).toBeDefined();
expect(protocolOnClose).toBeDefined();
expect(protocolOnError).toBeDefined();
// Consumer-style wrapping after connect: record, then delegate to the original handlers.
const observedMessages: JSONRPCMessage[] = [];
const observedErrors: Error[] = [];
tx.onmessage = (message: JSONRPCMessage, extra?: MessageExtraInfo) => {
observedMessages.push(message);
protocolOnMessage?.(message, extra);
};
tx.onerror = (error: Error) => {
observedErrors.push(error);
protocolOnError?.(error);
};
tx.onclose = () => {
protocolOnClose?.();
};
const outbound = tapOutbound(client);
const first = await client.callTool({ name: 'echo', arguments: { text: 'wrapped dispatch' } });
expect(first.content).toEqual([{ type: 'text', text: 'wrapped dispatch' }]);
const second = await client.callTool({ name: 'echo', arguments: { text: 'still wrapped' } });
expect(second.content).toEqual([{ type: 'text', text: 'still wrapped' }]);
// The wrapper observed the exact responses that resolved both calls, and no errors surfaced.
const callIds = outbound
.filter(m => isRequest(m))
.filter(m => m.method === 'tools/call')
.map(m => m.id);
expect(callIds).toHaveLength(2);
const [firstCallId, secondCallId] = callIds;
if (firstCallId === undefined || secondCallId === undefined) throw new Error('tools/call request ids not captured');
const observedResponsesFor = (id: RequestId) => observedMessages.filter(m => isJSONRPCResultResponse(m)).filter(m => m.id === id);
expect(observedResponsesFor(firstCallId)).toHaveLength(1);
expect(observedResponsesFor(firstCallId)[0]?.result).toMatchObject({ content: [{ type: 'text', text: 'wrapped dispatch' }] });
expect(observedResponsesFor(secondCallId)).toHaveLength(1);
expect(observedResponsesFor(secondCallId)[0]?.result).toMatchObject({ content: [{ type: 'text', text: 'still wrapped' }] });
expect(observedErrors).toEqual([]);
// Close-event chaining is checked on a direct InMemory pair: the in-process stdio harness never fires the client transport's onclose.
const closingClient = newClient();
const closingServer = makeServer();
const [closingClientTx, closingServerTx] = InMemoryTransport.createLinkedPair();
const closingClientOnclose = vi.fn();
closingClient.onclose = closingClientOnclose;
try {
await closingServer.connect(closingServerTx);
await closingClient.connect(closingClientTx);
const protocolCloseHandler = closingClientTx.onclose;
expect(protocolCloseHandler).toBeDefined();
let wrapperObservedClose = 0;
closingClientTx.onclose = () => {
wrapperObservedClose += 1;
protocolCloseHandler?.();
};
// Server-initiated close: the consumer wrapper sees the close event and protocol close handling still runs through it.
await closingServer.close();
expect(wrapperObservedClose).toBe(1);
expect(closingClientOnclose).toHaveBeenCalledTimes(1);
expect(closingClient.transport).toBeUndefined();
} finally {
await closingClient.close();
await closingServer.close();
}
});

View file

@ -364,3 +364,58 @@ verifies('transport:stdio:default-env-safelist', async (_args: TestArgs) => {
}
}
});
verifies('transport:stdio:pre-started-tolerated', async (_args: TestArgs) => {
// Real consumers call transport.start() themselves (to capture early stderr) before handing the transport to connect().
const transport = new StdioClientTransport({
command: process.execPath,
args: ['--import', 'tsx', FIXTURE_PATH],
cwd: E2E_ROOT,
stderr: 'pipe'
});
const stderr = transport.stderr;
if (stderr === null) throw new Error('expected transport.stderr when spawned with stderr: "pipe"');
let captured = '';
stderr.on('data', (chunk: Buffer) => {
captured += chunk.toString();
});
// Counts fixture boots observed on stderr — more than one means connect() double-spawned the server.
const readyCount = () => captured.split('[stdio-server] ready').length - 1;
const client = newClient();
try {
await transport.start();
const preStartedPid = transport.pid;
if (preStartedPid === null) throw new Error('expected a child pid after manual start()');
expect(processAlive(preStartedPid)).toBe(true);
await vi.waitFor(() => expect(readyCount()).toBe(1), { timeout: 5000, interval: 25 });
// The contract is disjunctive: connect() over a pre-started transport either completes the handshake or rejects with the recognizable already-started error.
let connectOutcome: { ok: true } | { ok: false; error: unknown } = { ok: true };
try {
await client.connect(transport);
} catch (error) {
connectOutcome = { ok: false, error };
}
if (connectOutcome.ok) {
// Tolerated branch: the handshake ran over the manually started child, so tool calls work...
const echoed = await client.callTool({ name: 'echo', arguments: { text: 'pre-started transport' } });
expect(echoed.isError).toBeFalsy();
expect(echoed.content).toEqual([{ type: 'text', text: 'pre-started transport' }]);
// ...and connect() did not spawn a second server process behind the consumer's back.
expect(transport.pid).toBe(preStartedPid);
expect(readyCount()).toBe(1);
} else {
if (!(connectOutcome.error instanceof Error)) throw new Error('expected connect() to reject with an Error');
// Recognizable: the message names the already-started condition consumers branch on.
expect(connectOutcome.error.message).toContain('StdioClientTransport already started');
// Ignorable: the rejection left the manually started child alive, still owned by the transport, and not double-spawned.
expect(processAlive(preStartedPid)).toBe(true);
expect(transport.pid).toBe(preStartedPid);
expect(readyCount()).toBe(1);
}
} finally {
await transport.close();
}
});

View file

@ -1247,3 +1247,42 @@ verifies('typescript:mcpserver:tool:extra', async ({ transport }: TestArgs) => {
if (firstCallSeen === undefined || secondCallSeen === undefined) throw new Error('expected both calls to be recorded');
expect(secondCallSeen.requestId).not.toBe(firstCallSeen.requestId);
});
verifies('mcpserver:tool:metadata-roundtrip', async ({ transport }: TestArgs) => {
// registerTool's public config carries title, description, annotations and _meta (no icons field), so those are asserted verbatim.
const annotations = {
title: 'Annotated Echo',
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false
};
const meta = { 'example.com/source': 'metadata-roundtrip-fixture', 'example.com/revision': 3 };
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool(
'annotated-echo',
{
title: 'Annotated Echo',
description: 'Echo tool carrying every metadata field registerTool accepts.',
inputSchema: z.object({ text: z.string() }),
annotations,
_meta: meta
},
({ text }) => ({ content: [{ type: 'text', text }] })
);
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const { tools } = await client.listTools();
expect(tools).toHaveLength(1);
const tool = tools[0];
if (tool === undefined) throw new Error('expected the registered tool to be listed');
expect(tool.name).toBe('annotated-echo');
expect(tool.title).toBe('Annotated Echo');
expect(tool.description).toBe('Echo tool carrying every metadata field registerTool accepts.');
expect(tool.annotations).toEqual(annotations);
expect(tool._meta).toEqual(meta);
});

View file

@ -18,7 +18,7 @@
import { randomUUID } from 'node:crypto';
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { Client, SdkHttpError, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { JSONRPCRequestSchema } from '@modelcontextprotocol/core';
import {
LATEST_PROTOCOL_VERSION,
@ -30,7 +30,7 @@ import { expect, vi } from 'vitest';
import { z } from 'zod/v4';
import type { HttpHandler } from '../helpers/index.js';
import { hostPerSession } from '../helpers/index.js';
import { hostPerSession, hostStateless } from '../helpers/index.js';
import { verifies } from '../helpers/verifies.js';
import type { TestArgs } from '../types.js';
@ -1270,3 +1270,235 @@ verifies('client-transport:http:reconnection-scheduler', async (_args: TestArgs)
await handle.close();
}
});
verifies('client-transport:http:error-status-code', async (_args: TestArgs) => {
const handle = hostPerSession(() => echoServer());
const expectHttpStatusError = (err: unknown, status: number): void => {
expect(err).toBeInstanceOf(SdkHttpError);
expect(err instanceof SdkHttpError ? err.status : undefined).toBe(status);
};
try {
const url = new URL('http://in-process/mcp');
// 404 on the connect POST: connect rejects with an SdkHttpError carrying the HTTP status on .status
{
const transport = new StreamableHTTPClientTransport(url, {
fetch: async () => new Response('Not Found', { status: 404 })
});
const client = newClient();
const err = await client.connect(transport).then(
() => {},
(error: unknown) => error
);
expectHttpStatusError(err, 404);
await transport.close();
}
// 401 with no authProvider configured: rejects with .status 401 so callers can branch on auth failures
{
const transport = new StreamableHTTPClientTransport(url, {
fetch: async () => new Response('Unauthorized', { status: 401 })
});
const client = newClient();
const err = await client.connect(transport).then(
() => {},
(error: unknown) => error
);
expectHttpStatusError(err, 401);
await transport.close();
}
// 500 on a POST after a successful connect: the in-flight request rejects with .status 500
{
let failToolCalls = false;
const transport = new StreamableHTTPClientTransport(url, {
fetch: async (u, init) => {
const body = init?.body ? String(init.body) : undefined;
if (failToolCalls && body?.includes('"tools/call"')) {
return new Response('Internal Server Error', { status: 500 });
}
return handle.handleRequest(new Request(u, init));
}
});
const client = newClient();
await client.connect(transport);
failToolCalls = true;
const err = await client.callTool({ name: 'echo', arguments: { text: 'boom' } }).then(
() => {},
(error: unknown) => error
);
expectHttpStatusError(err, 500);
await client.close();
await transport.close();
}
} finally {
await handle.close();
}
});
verifies('typescript:client-transport:http:session-id-property', async (_args: TestArgs) => {
const serverAssignedIds: Array<string | null> = [];
const handle = hostPerSession(() => echoServer());
const statelessHandle = hostStateless(() => echoServer());
try {
const url = new URL('http://in-process/mcp');
const transport = new StreamableHTTPClientTransport(url, {
fetch: async (u, init) => {
const response = await handle.handleRequest(new Request(u, init));
if (init?.body && String(init.body).includes('"method":"initialize"')) {
serverAssignedIds.push(response.headers.get('mcp-session-id'));
}
return response;
}
});
const client = newClient();
expect(transport.sessionId).toBeUndefined();
await client.connect(transport);
expect(serverAssignedIds).toHaveLength(1);
const assigned = serverAssignedIds[0];
expect(assigned).not.toBeNull();
expect(transport.sessionId).toBe(assigned);
await client.close();
await transport.close();
// Stateless hosting assigns no Mcp-Session-Id, so the property stays undefined after connect
const statelessTransport = new StreamableHTTPClientTransport(url, {
fetch: (u, init) => statelessHandle.handleRequest(new Request(u, init))
});
const statelessClient = newClient();
expect(statelessTransport.sessionId).toBeUndefined();
await statelessClient.connect(statelessTransport);
expect(statelessTransport.sessionId).toBeUndefined();
await statelessClient.close();
await statelessTransport.close();
} finally {
await handle.close();
await statelessHandle.close();
}
});
verifies('typescript:client-transport:http:session-id-option', async (_args: TestArgs) => {
const records: RecordedRequest[] = [];
const handle = hostPerSession(() => echoServer());
try {
const url = new URL('http://in-process/mcp');
// Establish a real session first so the configured sessionId refers to a live server session
const firstTransport = new StreamableHTTPClientTransport(url, {
fetch: (u, init) => handle.handleRequest(new Request(u, init))
});
const firstClient = newClient();
await firstClient.connect(firstTransport);
const sessionId = firstTransport.sessionId;
if (sessionId === undefined) throw new Error('expected the first connection to negotiate a session id');
const reusingTransport = new StreamableHTTPClientTransport(url, {
sessionId,
fetch: recordingFetch(records, handle.handleRequest)
});
const reusingClient = newClient();
await reusingClient.connect(reusingTransport);
const result = await reusingClient.callTool({ name: 'echo', arguments: { text: 'reused session' } });
expect(result.content).toEqual([{ type: 'text', text: 'reused session' }]);
expect(reusingTransport.sessionId).toBe(sessionId);
expect(records.length).toBeGreaterThanOrEqual(1);
const firstRecorded = defined(records[0], 'first recorded request');
expect(firstRecorded.method).toBe('POST');
expect(firstRecorded.headers['mcp-session-id']).toBe(sessionId);
for (const req of records) {
expect(req.headers['mcp-session-id']).toBe(sessionId);
}
await reusingClient.close();
await reusingTransport.close();
await firstClient.close();
await firstTransport.close();
} finally {
await handle.close();
}
});
verifies('client-transport:http:reconnect-failure-onerror', async (_args: TestArgs) => {
const records: RecordedRequest[] = [];
const transportErrors: Error[] = [];
let firstGetController: ReadableStreamDefaultController<Uint8Array> | undefined;
let getCount = 0;
const handle = hostPerSession(() => echoServer());
try {
const url = new URL('http://in-process/mcp');
const transport = new StreamableHTTPClientTransport(url, {
fetch: recordingFetch(records, async req => {
if (req.method === 'GET') {
getCount++;
if (getCount === 1) {
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
firstGetController = controller;
}
}),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } }
);
}
// Every reconnection attempt fails so the retry budget is exhausted
return new Response('Not Found', { status: 404 });
}
return handle.handleRequest(req);
}),
reconnectionOptions: {
initialReconnectionDelay: 10,
maxReconnectionDelay: 10,
reconnectionDelayGrowFactor: 1,
maxRetries: 2
}
});
transport.onerror = error => void transportErrors.push(error);
const client = newClient();
await client.connect(transport);
await vi.waitFor(() => expect(firstGetController).toBeDefined());
defined(firstGetController, 'first GET stream controller').error(new Error('connection reset'));
// Each failed attempt and the final budget-exhausted failure are delivered to onerror
await vi.waitFor(() =>
expect(transportErrors.filter(e => e.message === 'Maximum reconnection attempts (2) exceeded.')).toHaveLength(1)
);
expect(transportErrors.filter(e => e.message.startsWith('Failed to reconnect SSE stream:'))).toHaveLength(2);
expect(records.filter(r => r.method === 'GET')).toHaveLength(3);
// The reconnection failure stays on onerror: an unrelated request issued afterwards still succeeds
const result = await client.callTool({ name: 'echo', arguments: { text: 'still works' } });
expect(result.content).toEqual([{ type: 'text', text: 'still works' }]);
expect(records.filter(r => r.method === 'GET')).toHaveLength(3);
await client.close();
await transport.close();
} finally {
await handle.close();
}
});

View file

@ -0,0 +1,239 @@
/**
* Raw transport relay tests: drive client and server transports directly via the
* Transport interface (start/send/onmessage/onerror/onclose) without wrapping them
* in a Client or Server — the pattern message-relay proxies depend on.
*
* One verifies() body dispatches on the matrix transport arg:
* - inMemory: both ends of InMemoryTransport.createLinkedPair driven by hand
* - stdio: StdioClientTransport spawning the stdio fixture server as a child process
* - streamableHttp / streamableHttpStateless: StreamableHTTPClientTransport against
* the in-process hostPerSession()/hostStateless() hosts
*
* Every JSON-RPC message is hand-built and every response is observed through the
* transport's own onmessage callback, exactly as a relay would forward traffic.
*/
import { fileURLToPath } from 'node:url';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
import { CallToolResultSchema, InitializeResultSchema, JSONRPCResultResponseSchema } from '@modelcontextprotocol/core';
import type { JSONRPCMessage, JSONRPCNotification, JSONRPCRequest } from '@modelcontextprotocol/server';
import { InMemoryTransport, McpServer } from '@modelcontextprotocol/server';
import { expect, vi } from 'vitest';
import { z } from 'zod/v4';
import { hostPerSession, hostStateless } from '../helpers/index.js';
import { verifies } from '../helpers/verifies.js';
import type { TestArgs } from '../types.js';
/** Absolute path to the runnable stdio fixture server (executed with tsx). */
const FIXTURE_PATH = fileURLToPath(new URL('../fixtures/stdio-server.ts', import.meta.url));
/** E2E package root — spawn cwd so the workspace-local `tsx` resolves and tsconfig paths map workspace packages to source. */
const E2E_ROOT = fileURLToPath(new URL('../', import.meta.url));
/** Hand-built initialize request a relay would forward verbatim (no Client involved). */
function initializeRequest(id: number, protocolVersion: string): JSONRPCRequest {
return {
jsonrpc: '2.0',
id,
method: 'initialize',
params: { protocolVersion, capabilities: {}, clientInfo: { name: 'raw-relay-client', version: '0' } }
};
}
const INITIALIZED_NOTIFICATION: JSONRPCNotification = { jsonrpc: '2.0', method: 'notifications/initialized' };
/** Hand-built tools/call request for the echo tool exposed by both real servers used below. */
function echoCallRequest(id: number): JSONRPCRequest {
return { jsonrpc: '2.0', id, method: 'tools/call', params: { name: 'echo', arguments: { text: 'relayed raw' } } };
}
function echoServer(): McpServer {
const s = new McpServer({ name: 'raw-relay-http-server', version: '0' });
s.registerTool('echo', { description: 'Echo tool', inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({
content: [{ type: 'text', text }]
}));
return s;
}
/** Narrows away `undefined` for values the surrounding test has already proven exist (replaces non-null assertions). */
function defined<T>(value: T | undefined, label: string): T {
if (value === undefined) throw new Error(`expected ${label} to be defined`);
return value;
}
/** Asserts the message observed via onmessage is the initialize response for `id` with the negotiated version. */
function expectInitializeResponse(message: JSONRPCMessage, id: number, protocolVersion: string, serverName: string): void {
const response = JSONRPCResultResponseSchema.parse(message);
expect(response.id).toBe(id);
const result = InitializeResultSchema.parse(response.result);
expect(result.protocolVersion).toBe(protocolVersion);
expect(result.serverInfo.name).toBe(serverName);
}
/** Asserts the message observed via onmessage is the tools/call response for `id` carrying the echoed text. */
function expectEchoResponse(message: JSONRPCMessage, id: number): void {
const response = JSONRPCResultResponseSchema.parse(message);
expect(response.id).toBe(id);
const result = CallToolResultSchema.parse(response.result);
expect(result.content).toEqual([{ type: 'text', text: 'relayed raw' }]);
}
async function rawRelayInMemory(protocolVersion: string): Promise<void> {
const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
const clientReceived: JSONRPCMessage[] = [];
const serverReceived: JSONRPCMessage[] = [];
const errors: Error[] = [];
let clientClosed = false;
let serverClosed = false;
const initializeResult: JSONRPCMessage = {
jsonrpc: '2.0',
id: 1,
result: { protocolVersion, capabilities: {}, serverInfo: { name: 'raw-relay-inmemory-server', version: '0' } }
};
try {
// The server-side transport is driven directly too: relay the hand-built result back without a Server.
await serverTx.start();
serverTx.onmessage = message => {
serverReceived.push(message);
void serverTx.send(initializeResult);
};
serverTx.onerror = error => void errors.push(error);
serverTx.onclose = () => {
serverClosed = true;
};
await clientTx.start();
clientTx.onmessage = message => void clientReceived.push(message);
clientTx.onerror = error => void errors.push(error);
clientTx.onclose = () => {
clientClosed = true;
};
const request = initializeRequest(1, protocolVersion);
await clientTx.send(request);
// The raw request and hand-built response crossed the pair verbatim — nothing rewrote them.
expect(serverReceived).toEqual([request]);
await vi.waitFor(() => expect(clientReceived).toHaveLength(1));
expect(clientReceived).toEqual([initializeResult]);
expectInitializeResponse(defined(clientReceived[0], 'initialize response'), 1, protocolVersion, 'raw-relay-inmemory-server');
expect(errors).toEqual([]);
expect(clientClosed).toBe(false);
expect(serverClosed).toBe(false);
await clientTx.close();
expect(clientClosed).toBe(true);
expect(serverClosed).toBe(true);
} finally {
await clientTx.close();
await serverTx.close();
}
}
async function rawRelayStdio(protocolVersion: string): Promise<void> {
// Direct spawn (node --import tsx) so the spawned process IS the fixture server, as in the other stdio scenarios.
const transport = new StdioClientTransport({
command: process.execPath,
args: ['--import', 'tsx', FIXTURE_PATH],
cwd: E2E_ROOT
});
const received: JSONRPCMessage[] = [];
const errors: Error[] = [];
let closed = false;
try {
await transport.start();
transport.onmessage = message => void received.push(message);
transport.onerror = error => void errors.push(error);
transport.onclose = () => {
closed = true;
};
await transport.send(initializeRequest(1, protocolVersion));
// Generous first wait: tsx compiles the fixture inside the freshly spawned child before it can answer.
await vi.waitFor(() => expect(received).toHaveLength(1), { timeout: 10_000, interval: 25 });
expectInitializeResponse(defined(received[0], 'initialize response'), 1, protocolVersion, 'stdio-echo-server');
// Forward the rest of a relay's traffic by hand: initialized notification, then a tools/call.
await transport.send(INITIALIZED_NOTIFICATION);
await transport.send(echoCallRequest(2));
await vi.waitFor(() => expect(received).toHaveLength(2), { timeout: 5000, interval: 25 });
expectEchoResponse(defined(received[1], 'echo response'), 2);
expect(errors).toEqual([]);
expect(closed).toBe(false);
await transport.close();
// close() ends the child's stdin; the resulting process exit must surface via onclose.
await vi.waitFor(() => expect(closed).toBe(true), { timeout: 5000, interval: 25 });
} finally {
await transport.close();
}
}
async function rawRelayStreamableHttp(protocolVersion: string, stateless: boolean): Promise<void> {
const handle = stateless ? hostStateless(echoServer) : hostPerSession(echoServer);
const records: Array<{ method: string }> = [];
const received: JSONRPCMessage[] = [];
const errors: Error[] = [];
let closed = false;
const transport = new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), {
fetch: (url, init) => {
const request = new Request(url, init);
records.push({ method: request.method });
return handle.handleRequest(request);
}
});
try {
await transport.start();
// start() must not touch the network — the first HTTP request may only happen on send().
expect(records).toEqual([]);
transport.onmessage = message => void received.push(message);
transport.onerror = error => void errors.push(error);
transport.onclose = () => {
closed = true;
};
await transport.send(initializeRequest(1, protocolVersion));
expect(records).toEqual([{ method: 'POST' }]);
await vi.waitFor(() => expect(received).toHaveLength(1), { timeout: 5000, interval: 10 });
expectInitializeResponse(defined(received[0], 'initialize response'), 1, protocolVersion, 'raw-relay-http-server');
// Forward the rest of a relay's traffic by hand: initialized notification, then a tools/call.
await transport.send(INITIALIZED_NOTIFICATION);
await transport.send(echoCallRequest(2));
await vi.waitFor(() => expect(received).toHaveLength(2), { timeout: 5000, interval: 10 });
expectEchoResponse(defined(received[1], 'echo response'), 2);
// Every POST on the wire came from an explicit send(); the only other traffic is the optional standalone GET.
expect(records.filter(r => r.method === 'POST')).toHaveLength(3);
expect(records.filter(r => r.method !== 'POST' && r.method !== 'GET')).toEqual([]);
expect(errors).toEqual([]);
expect(closed).toBe(false);
await transport.close();
expect(closed).toBe(true);
} finally {
await transport.close();
await handle.close();
}
}
verifies('transport:standalone:raw-relay', async ({ transport, protocolVersion }: TestArgs) => {
if (transport === 'inMemory') {
await rawRelayInMemory(protocolVersion);
} else if (transport === 'stdio') {
await rawRelayStdio(protocolVersion);
} else {
await rawRelayStreamableHttp(protocolVersion, transport === 'streamableHttpStateless');
}
});

View file

@ -0,0 +1,24 @@
/**
* Self-contained test bodies for the legacy HTTP+SSE transport surface.
*
* The interaction matrix exercises the legacy transport as the 'sse' column via
* wire(); this file holds the SSE-specific surface requirements that are not
* per-cell matrix behaviors.
*/
import { expect } from 'vitest';
import { verifies } from '../helpers/verifies.js';
import type { TestArgs } from '../types.js';
verifies('transport:sse:server-transport', async (_args: TestArgs) => {
// The server half of the legacy SSE transport must be on the public server-side surface for SSE deployments to be hosted on the SDK alone.
const surfaces = await Promise.all([
import('@modelcontextprotocol/server'),
import('@modelcontextprotocol/node'),
import('@modelcontextprotocol/express')
]);
const exported = surfaces.flatMap(surface => Object.keys(surface));
const sseServerExports = exported.filter(name => /sse/i.test(name) && /server/i.test(name));
expect(sseServerExports.length).toBeGreaterThan(0);
});

View file

@ -2,7 +2,7 @@
* Shared types for the e2e suite.
*/
export const ALL_TRANSPORTS = ['inMemory', 'stdio', 'streamableHttp', 'streamableHttpStateless'] as const;
export const ALL_TRANSPORTS = ['inMemory', 'stdio', 'streamableHttp', 'streamableHttpStateless', 'sse'] as const;
export type Transport = (typeof ALL_TRANSPORTS)[number];
export const ALL_SPEC_VERSIONS = ['2025-11-25'] as const;