Port the end-to-end test suite to v2 (#2179)

This commit is contained in:
Felix Weinberger 2026-05-29 12:04:45 +01:00 • committed by GitHub
commit 1998a186ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 20108 additions and 1 deletions

View file

@ -0,0 +1,64 @@
/**
* Express helper for hosting-related e2e tests.
*
* Builds real Express apps with SDK middleware, listens on ephemeral ports,
* closes cleanly. Used by hosting-express.ts scenarios.
*/
import type { Server as HttpServer } from 'node:http';
import { hostHeaderValidation } from '@modelcontextprotocol/express';
import type { Express, RequestHandler } from 'express';
import express from 'express';
export interface ExpressHost extends AsyncDisposable {
readonly baseUrl: URL;
close(): Promise<void>;
}
async function listen(app: Express, host = '127.0.0.1'): Promise<{ baseUrl: URL; close: () => Promise<void> }> {
return new Promise((resolve, reject) => {
const server: HttpServer = app.listen(0, host, () => {
const addr = server.address();
if (!addr || typeof addr === 'string') {
reject(new Error(`listen failed: ${addr}`));
return;
}
resolve({
baseUrl: new URL(`http://${host}:${addr.port}`),
close: () =>
new Promise((res, rej) => {
server.close(err => (err ? rej(err) : res()));
})
});
});
server.on('error', reject);
});
}
export async function startExpressMinimal(handler: RequestHandler): Promise<ExpressHost> {
const app = express();
app.use(express.json());
app.use(handler);
const { baseUrl, close } = await listen(app);
return {
baseUrl,
close,
[Symbol.asyncDispose]: close
};
}
export async function startExpressWithHostValidation(allowedHosts: string[], handler: RequestHandler): Promise<ExpressHost> {
const app = express();
app.use(express.json());
app.use(hostHeaderValidation(allowedHosts));
app.use(handler);
const { baseUrl, close } = await listen(app);
return {
baseUrl,
close,
[Symbol.asyncDispose]: close
};
}

229
test/e2e/helpers/index.ts Normal file
View file

@ -0,0 +1,229 @@
/**
* Test wiring helpers.
*
* `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.
*/
import { randomUUID } from 'node:crypto';
import { PassThrough } from 'node:stream';
import type { Client } from '@modelcontextprotocol/client';
import { 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 type { SnifferOptions } from './wire-sniffer.js';
import { sniffTransport } from './wire-sniffer.js';
export type ServerFactory = () => McpServer | Server;
export interface Wired extends AsyncDisposable {
readonly fetch?: (url: URL | string, init?: RequestInit) => Promise<Response>;
readonly url?: URL;
}
/**
* The fourth argument controls the wire-format sniffer (see wire-sniffer.ts):
* every message the client sends or receives is validated against the SDK's
* spec-anchored Zod schemas. Tests that intentionally use vendor-extension
* methods pass `{ allowCustomMethods: true }`; tests that deliberately put
* malformed MCP on the wire pass `{ strictValidation: false }`.
*/
export async function wire(transport: Transport, makeServer: ServerFactory, client: Client, sniff: SnifferOptions = {}): Promise<Wired> {
switch (transport) {
case 'inMemory': {
const server = makeServer();
const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
await server.connect(serverTx);
await client.connect(sniffTransport(clientTx, 'client', sniff));
return { [Symbol.asyncDispose]: () => Promise.all([client.close(), server.close()]).then(() => {}) };
}
case 'stdio': {
const server = makeServer();
const c2s = new PassThrough();
const s2c = new PassThrough();
await server.connect(new StdioServerTransport(c2s, s2c));
await client.connect(sniffTransport(stdioClientOverPipes(s2c, c2s), 'client', sniff));
return { [Symbol.asyncDispose]: () => Promise.all([client.close(), server.close()]).then(() => {}) };
}
case 'streamableHttp':
case 'streamableHttpStateless': {
const handle = transport === 'streamableHttpStateless' ? hostStateless(makeServer) : hostPerSession(makeServer);
const url = new URL('http://in-process/mcp');
const fetch = (u: URL | string, init?: RequestInit) => handle.handleRequest(new Request(u, init));
await client.connect(sniffTransport(new StreamableHTTPClientTransport(url, { fetch }), 'client', sniff));
return {
fetch,
url,
[Symbol.asyncDispose]: () => Promise.all([client.close(), handle.close()]).then(() => {})
};
}
}
}
/**
* Tap a connected client's transport so every JSON-RPC message crossing the
* wire is recorded. `sent` = client→server, `received` = server→client.
* Call after `wire()` so `client.transport` is set. The transport is
* monkey-patched in place; teardown via `await using` on `wire()` discards it.
*/
export function tapWire(client: Client): { sent: JSONRPCMessage[]; received: JSONRPCMessage[] } {
const tx = client.transport;
if (!tx) throw new Error('tapWire: client not connected');
const sent: JSONRPCMessage[] = [];
const received: JSONRPCMessage[] = [];
const origSend = tx.send.bind(tx);
const origOnMessage = tx.onmessage;
tx.send = async (m, opts) => {
sent.push(m);
return origSend(m, opts);
};
tx.onmessage = (m, extra) => {
received.push(m);
origOnMessage?.(m, extra);
};
return { sent, received };
}
// ───────────────────────────────────────────────────────────────────────────────
// HTTP hosting (the two production patterns)
// ───────────────────────────────────────────────────────────────────────────────
export type HttpHandler = (req: Request) => Promise<Response>;
export function hostPerSession(makeServer: ServerFactory): { handleRequest: HttpHandler; close(): Promise<void> } {
const sessions = new Map<string, WebStandardStreamableHTTPServerTransport>();
return {
handleRequest: async req => {
const sid = req.headers.get('mcp-session-id') ?? undefined;
const existing = sid ? sessions.get(sid) : undefined;
if (existing) return existing.handleRequest(req);
if (sid !== undefined) {
// Mirror the SDK's documented hosting pattern: an unrecognized session id is
// rejected at the app level, so the transport's own 404 is never reached.
return Response.json(
{
jsonrpc: '2.0',
error: { code: -32_000, message: 'Bad Request: No valid session ID provided' },
id: null
},
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const tx = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: randomUUID,
onsessioninitialized: id => void sessions.set(id, tx),
onsessionclosed: id => void sessions.delete(id)
});
await makeServer().connect(tx);
return tx.handleRequest(req);
},
close: async () => {
for (const t of sessions.values()) await t.close();
sessions.clear();
}
};
}
export interface ResumeHostOptions {
eventStore: EventStore;
retryInterval?: number;
}
export function hostResumable(makeServer: ServerFactory, opts: ResumeHostOptions): { handleRequest: HttpHandler; close(): Promise<void> } {
const sessions = new Map<string, { tx: WebStandardStreamableHTTPServerTransport; server: McpServer | Server }>();
return {
handleRequest: async req => {
const sid = req.headers.get('mcp-session-id') ?? undefined;
const existing = sid ? sessions.get(sid) : undefined;
if (existing) return existing.tx.handleRequest(req);
const tx = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: randomUUID,
eventStore: opts.eventStore,
retryInterval: opts.retryInterval,
onsessioninitialized: id => void sessions.set(id, { tx, server }),
onsessionclosed: id => void sessions.delete(id)
});
const server = makeServer();
await server.connect(tx);
return tx.handleRequest(req);
},
close: async () => {
for (const { tx, server } of sessions.values()) {
await server.close();
await tx.close();
}
sessions.clear();
}
};
}
export function hostStateless(makeServer: ServerFactory): { handleRequest: HttpHandler; close(): Promise<void> } {
const cleanups: Array<() => Promise<void>> = [];
return {
handleRequest: async req => {
if (req.method !== 'POST') {
return Response.json(
{ jsonrpc: '2.0', error: { code: -32_000, message: 'Method not allowed.' }, id: null },
{
status: 405,
headers: { 'Content-Type': 'application/json' }
}
);
}
const server = makeServer();
const tx = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
await server.connect(tx);
cleanups.push(async () => {
await server.close();
await tx.close();
});
return tx.handleRequest(req);
},
close: async () => {
for (const c of cleanups) await c();
}
};
}
// ───────────────────────────────────────────────────────────────────────────────
// In-process stdio client — TEST-ONLY
//
// Production stdio uses `StdioClientTransport`, which spawns a child process.
// This is the in-process equivalent for tests: same newline-framed JSON wire
// format (uses the SDK's `serializeMessage`/`ReadBuffer`), but over PassThrough
// streams instead of a spawned process. Tests that specifically exercise spawn,
// env, signals, or stderr must use the real `StdioClientTransport`.
// ───────────────────────────────────────────────────────────────────────────────
function stdioClientOverPipes(serverStdout: NodeJS.ReadableStream, serverStdin: NodeJS.WritableStream) {
const buf = new ReadBuffer();
return {
onmessage: undefined as ((m: JSONRPCMessage) => void) | undefined,
onerror: undefined as ((e: Error) => void) | undefined,
onclose: undefined as (() => void) | undefined,
async start() {
serverStdout.on('data', chunk => {
buf.append(chunk);
let m: JSONRPCMessage | null;
while ((m = buf.readMessage())) this.onmessage?.(m);
});
serverStdout.on('error', e => this.onerror?.(e));
serverStdout.on('close', () => this.onclose?.());
},
async send(m: JSONRPCMessage) {
serverStdin.write(serializeMessage(m));
},
async close() {
serverStdin.end();
}
};
}

View file

@ -0,0 +1,54 @@
/**
* Inverted manifest linkage: tests declare which requirement(s) they satisfy.
* (Staged here in the harness; the restructure codemod copies this file to
* sdk/test/e2e/helpers/verifies.ts when the inversion is executed.)
*
* `verifies(id, fn, opts?)` looks the requirement up in the pure-data manifest
* and registers one cell per applicable (transport, spec version) — the same
* names and semantics the old matrix runner produced — applying `test.fails`
* for knownFailures and the global 15s timeout. Unknown or deferred ids throw
* at registration time, so a typo can never silently drop coverage.
*
* Bodies are anonymous; the registered test title is `opts?.title ?? 'verifies'`.
* An explicit `opts.title` is only needed when a requirement is cited by more
* than one body (to tell them apart) — a knownFailure with a `test` string only
* applies to the body registered with that exact title.
*/
import { describe, test } from 'vitest';
import { REQUIREMENTS } from '../requirements.js';
import type { TestArgs } from '../types.js';
import { ALL_SPEC_VERSIONS, ALL_TRANSPORTS } from '../types.js';
type TestBody = (args: TestArgs) => Promise<void>;
export function verifies(id: string | readonly string[], fn: TestBody, opts?: { title?: string }): void {
const ids = Array.isArray(id) ? id : [id];
for (const rid of ids) registerOne(rid, fn, opts);
}
function registerOne(id: string, fn: TestBody, opts?: { title?: string }): void {
const req = REQUIREMENTS[id];
if (!req) throw new Error(`verifies('${id}'): unknown requirement id`);
if (req.deferred) throw new Error(`verifies('${id}'): requirement is deferred — drop the deferral or the test`);
const transports = req.transports ?? ALL_TRANSPORTS;
const versions = ALL_SPEC_VERSIONS.filter(
v =>
(req.addedInSpecVersion === undefined || v >= req.addedInSpecVersion) &&
(req.removedInSpecVersion === undefined || v < req.removedInSpecVersion)
);
const cells = versions.flatMap(v => transports.map(t => [t, v] as const));
describe.each(cells)(`${id} [%s %s]`, (transport, protocolVersion) => {
const kf = req.knownFailures?.find(
k =>
(k.test === undefined || k.test === opts?.title) &&
(k.transport === undefined || k.transport === transport) &&
(k.specVersion === undefined || k.specVersion === protocolVersion)
);
const run = kf ? test.fails : test;
run(opts?.title ?? 'verifies', () => fn({ transport, protocolVersion }), 15_000);
});
}

View file

@ -0,0 +1,69 @@
import { LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/server';
import { describe, expect, it } from 'vitest';
import { assertWireMessage } from './wire-sniffer.js';
const req = (method: string, params?: unknown, id = 1) => ({
jsonrpc: '2.0' as const,
id,
method,
...(params === undefined ? {} : { params })
});
const notif = (method: string, params?: unknown) => ({ jsonrpc: '2.0' as const, method, ...(params === undefined ? {} : { params }) });
const resp = (result: unknown, id = 1) => ({ jsonrpc: '2.0' as const, id, result });
describe('assertWireMessage', () => {
it('accepts a valid client initialize request', () => {
expect(() =>
assertWireMessage(
req('initialize', { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'c', version: '0' } }),
'client'
)
).not.toThrow();
});
it('accepts a valid client tools/list result on the server→client return', () => {
// server sends the ListToolsResult back
expect(() => assertWireMessage(resp({ tools: [] }), 'server')).not.toThrow();
});
it('accepts a valid client ping and the empty result', () => {
expect(() => assertWireMessage(req('ping'), 'client')).not.toThrow();
expect(() => assertWireMessage(resp({}), 'server')).not.toThrow();
});
it('rejects a non-JSON-RPC envelope', () => {
expect(() => assertWireMessage({ jsonrpc: '1.0', id: 1, method: 'ping' }, 'client')).toThrow(/not a JSON-RPC message/);
});
it('rejects an unknown method without allowCustomMethods', () => {
expect(() => assertWireMessage(req('x/custom', { a: 1 }), 'client')).toThrow(/non-spec request method 'x\/custom'/);
});
it('accepts an unknown method with allowCustomMethods', () => {
expect(() => assertWireMessage(req('x/custom', { a: 1 }), 'client', { allowCustomMethods: true })).not.toThrow();
});
it('rejects a spec method with malformed params under strict, accepts with strictValidation:false', () => {
const bad = notif('notifications/progress', { progressToken: 1, progress: 'not-a-number' });
expect(() => assertWireMessage(bad, 'client')).toThrow(/params do not conform/);
expect(() => assertWireMessage(bad, 'client', { strictValidation: false })).not.toThrow();
});
it('rejects a server-only request sent by the client (direction check)', () => {
// sampling/createMessage is a Server→Client request; the client must not originate it
expect(() => assertWireMessage(req('sampling/createMessage', { messages: [], maxTokens: 1 }), 'client')).toThrow(
/non-spec request method 'sampling\/createMessage'/
);
});
it('accepts that same request when the server sends it', () => {
expect(() => assertWireMessage(req('sampling/createMessage', { messages: [], maxTokens: 1 }), 'server')).not.toThrow();
});
it('accepts a JSON-RPC error response for either party', () => {
const err = { jsonrpc: '2.0' as const, id: 1, error: { code: -32_601, message: 'Method not found' } };
expect(() => assertWireMessage(err, 'server')).not.toThrow();
expect(() => assertWireMessage(err, 'client')).not.toThrow();
});
});

View file

@ -0,0 +1,135 @@
import {
ClientNotificationSchema,
ClientRequestSchema,
ClientResultSchema,
ServerNotificationSchema,
ServerRequestSchema,
ServerResultSchema
} from '@modelcontextprotocol/core';
import type { Transport } from '@modelcontextprotocol/server';
import {
isJSONRPCErrorResponse,
isJSONRPCNotification,
isJSONRPCRequest,
isJSONRPCResultResponse,
isSpecType
} from '@modelcontextprotocol/server';
export type WireParty = 'client' | 'server';
export interface SnifferOptions {
/** Permit non-spec (vendor-extension) method names. Spec methods stay strict. */
allowCustomMethods?: boolean;
/** `false` → envelope check only (for tests that deliberately send malformed messages). */
strictValidation?: boolean;
}
const OUTBOUND = {
client: {
request: ClientRequestSchema,
notification: ClientNotificationSchema,
result: ClientResultSchema
},
server: {
request: ServerRequestSchema,
notification: ServerNotificationSchema,
result: ServerResultSchema
}
} as const;
/** Method names valid as an outbound request/notification for each party. */
const SPEC_METHODS: Record<WireParty, { request: Set<string>; notification: Set<string> }> = {
client: { request: methodSet(ClientRequestSchema), notification: methodSet(ClientNotificationSchema) },
server: { request: methodSet(ServerRequestSchema), notification: methodSet(ServerNotificationSchema) }
};
function methodSet(union: { options?: ReadonlyArray<{ shape?: { method?: { values?: ReadonlySet<unknown> } } }> }): Set<string> {
const out = new Set<string>();
for (const member of union.options ?? []) {
for (const v of member.shape?.method?.values ?? []) {
if (typeof v === 'string') out.add(v);
}
}
return out;
}
function fail(party: WireParty, reason: string, msg: unknown): never {
throw new Error(`[wire] ${party} sent an invalid message: ${reason}\n${JSON.stringify(msg, null, 2)}`);
}
/**
* Assert a single message is valid for the given sending party.
* @param msg the raw JSON-RPC message
* @param party who put it on the wire (`client` outbound = ClientRequest/Notification/Result)
*/
export function assertWireMessage(msg: unknown, party: WireParty, opts: SnifferOptions = {}): void {
if (!isSpecType.JSONRPCMessage(msg)) {
fail(party, 'not a JSON-RPC message', msg);
}
if (opts.strictValidation === false) return;
const schemas = OUTBOUND[party];
if (isJSONRPCRequest(msg) || isJSONRPCNotification(msg)) {
const kind = isJSONRPCRequest(msg) ? 'request' : 'notification';
const method = (msg as { method: string }).method;
if (!SPEC_METHODS[party][kind].has(method)) {
if (opts.allowCustomMethods) return;
fail(party, `non-spec ${kind} method '${method}' (pass { allowCustomMethods: true } if intentional)`, msg);
}
const params = (msg as { params?: unknown }).params;
const r = schemas[kind].safeParse({ method, params });
if (!r.success) {
fail(party, `spec method '${method}' params do not conform: ${r.error.message}`, msg);
}
return;
}
if (isJSONRPCResultResponse(msg)) {
const result = (msg as { result: unknown }).result;
const r = schemas.result.safeParse(result);
if (!r.success) {
// A result for a vendor-extension request legitimately won't match the spec union.
if (opts.allowCustomMethods) return;
fail(party, `result does not conform to any spec result: ${r.error.message}`, msg);
}
return;
}
if (isJSONRPCErrorResponse(msg)) return; // envelope already validated; error bodies are not method-specific
}
/**
* Wrap a transport so every outbound `send` (validated as `party`) and inbound
* `onmessage` (validated as the counterpart) is asserted. Returns the same
* transport instance (monkey-patched in place).
*/
export function sniffTransport<T extends Transport>(transport: T, party: WireParty, opts: SnifferOptions = {}): T {
const counterpart: WireParty = party === 'client' ? 'server' : 'client';
const origSend = transport.send.bind(transport);
transport.send = (message, sendOpts) => {
assertWireMessage(message, party, opts);
return origSend(message, sendOpts);
};
// `onmessage` is assigned by Protocol.connect() after we wrap. Intercept via
// an accessor so we wrap whatever handler it installs, validating each
// inbound message (sent by the counterpart) before passing it through.
let handler: Transport['onmessage'];
Object.defineProperty(transport, 'onmessage', {
configurable: true,
enumerable: true,
get: () => handler,
set: next => {
handler = next
? (message, extra) => {
assertWireMessage(message, counterpart, opts);
return next(message, extra);
}
: next;
}
});
return transport;
}