Add consumer-sourced e2e requirements and the legacy SSE matrix column (#2203)
This commit is contained in:
parent
1998a186ee
commit
4a5c863a21
13 changed files with 1431 additions and 11 deletions
5
.changeset/add-consumer-sse-e2e.md
Normal file
5
.changeset/add-consumer-sse-e2e.md
Normal 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.
|
||||
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
159
test/e2e/helpers/sse-host.ts
Normal file
159
test/e2e/helpers/sse-host.ts
Normal 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()));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
239
test/e2e/scenarios/transport-raw.test.ts
Normal file
239
test/e2e/scenarios/transport-raw.test.ts
Normal 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');
|
||||
}
|
||||
});
|
||||
24
test/e2e/scenarios/transport-sse.test.ts
Normal file
24
test/e2e/scenarios/transport-sse.test.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue