Add end-to-end test suite (#2167)

This commit is contained in:
Felix Weinberger 2026-05-29 13:29:47 +01:00 • committed by GitHub
commit c36e1ef5bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 19371 additions and 1 deletions

64
test/e2e/CLAUDE.md Normal file
View file

@ -0,0 +1,64 @@
# E2E test suite
Conformance-style tests for the SDK's public surface. `requirements.ts` is the manifest: every behavior the SDK must satisfy, linked to the test cases that prove it. `matrix.test.ts` runs each over `ALL_TRANSPORTS`.
## Writing a test
A test is an exported async function in `scenarios/<area>.ts`:
```ts
export async function toolsCallContentText(transport: Transport) {
const makeServer = () => {
const s = new McpServer({ name: 't', version: '0' });
s.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({ content: [{ type: 'text', text }] }));
return s;
};
const client = new Client({ name: 'c', version: '0' });
await using _ = await wire(transport, makeServer, client);
const r = await client.callTool({ name: 'echo', arguments: { text: 'hi' } });
expect(r.content).toEqual([{ type: 'text', text: 'hi' }]);
}
```
Self-contained: build server inline (factory), build client inline, `wire()`, assert. No shared fixture files.
Then link it in `requirements.ts`:
```ts
'tools:call:content:text': {
source: 'https://modelcontextprotocol.io/...',
behavior: 'tools/call returns content[] with type:text...',
tests: [tools.toolsCallContentText]
},
```
## knownFailures and transport restrictions
When a test asserts spec-correct behavior the SDK doesn't yet implement:
```ts
knownFailures: [{ test: tools.toolsCallUnknownName, note: 'McpServer wraps as isError; spec says JSON-RPC error' }];
```
`matrix.test.ts` runs it as `test.fails()` — passes when it fails as expected, fails when the SDK is fixed (then remove the entry).
When a transport structurally cannot express the behavior (e.g., server→client roundtrip on stateless hosting), restrict the requirement itself rather than skipping tests:
```ts
transports: STATEFUL_TRANSPORTS, // or an explicit list
note: 'stateless hosting has no server→client back-channel'
```
## Running
```bash
npx vitest run test/e2e # all
npx vitest run test/e2e/tools.test.ts # one area
npx vitest run test/e2e -t 'tools:' # one requirement-id prefix
npx vitest run test/e2e/coverage.test.ts # gate: every req id is cited by a verifies() test
```
Slugs prefixed `typescript:` are TypeScript-SDK-specific requirements (they describe this SDK's own API surface and intentionally have no shared cross-SDK meaning); unprefixed slugs share their id and behavior wording with the Python interaction suite where both cover the
behavior.

97
test/e2e/coverage.test.ts Normal file
View file

@ -0,0 +1,97 @@
/**
* Manifest gates for the e2e suite.
*
* The linkage is inverted: test files cite the requirement id(s) they prove via
* `verifies(...)` (helpers/verifies.ts) and requirements.ts is pure data. These
* tests statically scan test/e2e/scenarios/*.test.ts for the cited ids and check them
* against the manifest, plus the manifest's own internal consistency rules.
*/
import { readdirSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { expect, test } from 'vitest';
import { REQUIREMENTS } from './requirements.js';
const E2E_DIR = dirname(fileURLToPath(import.meta.url));
interface VerifiesCall {
file: string;
/** Explicit `{ title: '...' }` passed to verifies(), if any (undefined for an untitled body). */
title: string | undefined;
ids: string[];
}
/** Statically scan test/e2e/scenarios/*.test.ts for `verifies(<ids>, ...)` calls. */
function scanVerifiesCalls(): VerifiesCall[] {
const calls: VerifiesCall[] = [];
const scenariosDir = join(E2E_DIR, 'scenarios');
const files = readdirSync(scenariosDir)
.filter(f => f.endsWith('.test.ts'))
.sort();
for (const file of files) {
const text = readFileSync(join(scenariosDir, file), 'utf8');
// Each call spans from its header to the first column-0 close (`});` for an
// untitled hugged call, `);` for a call expanded by an opts third argument).
for (const m of text.matchAll(/verifies\(\s*('[^']*'|\[[^\]]*\])\s*,\s*async\s*\([\s\S]*?\n(?:\}\);|\);)/g)) {
const ids = [...m[1].matchAll(/'([^']*)'/g)].map(x => x[1]);
const title = m[0].match(/\{\s*title:\s*'([^']*)'\s*\}\s*\n?\);$/)?.[1];
calls.push({ file, title, ids });
}
}
return calls;
}
const CALLS = scanVerifiesCalls();
const CITED = new Set(CALLS.flatMap(c => c.ids));
test('every non-deferred requirement id is cited by at least one verifies() call', () => {
const missing = Object.entries(REQUIREMENTS)
.filter(([id, r]) => !r.deferred && !CITED.has(id))
.map(([id]) => id);
expect(missing).toEqual([]);
});
test('every cited requirement id exists in the manifest and is not deferred', () => {
const bad: string[] = [];
for (const c of CALLS) {
for (const id of c.ids) {
const req = REQUIREMENTS[id];
if (!req) bad.push(`${c.file}: a verifies() call cites unknown requirement '${id}'`);
else if (req.deferred) bad.push(`${c.file}: a verifies() call cites deferred requirement '${id}'`);
}
}
expect(bad).toEqual([]);
});
test('every knownFailure with a test string names an explicit verifies() title that cites the requirement', () => {
const bad: string[] = [];
for (const [id, r] of Object.entries(REQUIREMENTS)) {
for (const kf of r.knownFailures ?? []) {
if (kf.test === undefined) continue;
const cited = CALLS.some(c => c.title === kf.test && c.ids.includes(id));
if (!cited)
bad.push(
`${id}: knownFailure references title '${kf.test}', which is not an explicit verifies() title citing this requirement`
);
}
}
expect(bad).toEqual([]);
});
test('every transport-restricted requirement explains why in note', () => {
const missing = Object.entries(REQUIREMENTS)
.filter(([, r]) => r.transports !== undefined && !r.note)
.map(([id]) => id);
expect(missing).toEqual([]);
});
test('every supersedes reference points at an existing requirement id', () => {
for (const [id, req] of Object.entries(REQUIREMENTS)) {
if (req.supersedes !== undefined) {
expect(REQUIREMENTS[req.supersedes], `${id} supersedes unknown id '${req.supersedes}'`).toBeDefined();
}
}
});

View file

@ -0,0 +1,61 @@
/**
* Runnable stdio MCP server fixture for the transport:stdio:* e2e tests.
*
* Spawned as a real child process by test/e2e/scenarios/stdio.ts. Registers a
* single `echo` tool, writes a readiness marker line to stderr once it is
* serving, and — when E2E_IGNORE_SIGTERM=1 — keeps running after stdin EOF and
* swallows SIGTERM so the client transport's shutdown escalation
* (stdin EOF → SIGTERM → SIGKILL) is observable.
*/
import { z } from 'zod/v4';
import { McpServer } from '../../../src/server/mcp.js';
import { StdioServerTransport } from '../../../src/server/stdio.js';
const server = new McpServer({ name: 'stdio-echo-server', version: '1.0.0' });
server.registerTool(
'echo',
{
description: 'Echoes the input text back as a text content block, including multi-line text.',
inputSchema: z.object({ text: z.string() })
},
({ text }) => ({ content: [{ type: 'text', text }] })
);
// env-report tool: returns JSON array of environment variable names (sorted) that
// reached the child process. This allows tests to verify the env safelist behavior.
server.registerTool(
'env-report',
{
description: 'Returns sorted array of environment variable names present in this process.',
inputSchema: z.object({})
},
() => {
const envKeys = Object.keys(process.env).sort();
return { content: [{ type: 'text', text: JSON.stringify(envKeys) }] };
}
);
if (process.env.E2E_IGNORE_SIGTERM === '1') {
// Misbehaving-server mode: keep alive after stdin EOF via interval (load-bearing — without it the child exits on stdin EOF and SIGTERM never arrives) and ignore SIGTERM, so only SIGKILL can end the process.
setInterval(() => {}, 1_000);
setTimeout(() => process.exit(1), 30_000);
process.on('SIGTERM', () => {
process.stderr.write('[stdio-server] sigterm ignored\n');
});
}
if (process.env.E2E_GARBAGE_STDOUT === '1') {
// Broken-server mode: write non-JSON garbage to stdout before the server connects, simulating a broken or misconfigured server that pollutes the JSON-RPC channel.
process.stdout.write('GARBAGE LINE 1: not json\n');
process.stdout.write('GARBAGE LINE 2: {malformed json\n');
process.stdout.write('GARBAGE LINE 3: also not valid jsonrpc\n');
process.stdin.resume();
process.stdin.on('end', () => process.exit(0));
setTimeout(() => process.exit(1), 30_000);
} else {
await server.connect(new StdioServerTransport());
process.stderr.write('[stdio-server] ready\n');
}

View file

@ -0,0 +1,63 @@
/**
* 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 express, { Express, RequestHandler } from 'express';
import type { Server as HttpServer } from 'node:http';
import { hostHeaderValidation } from '../../../src/server/middleware/hostHeaderValidation.js';
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
};
}

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

@ -0,0 +1,286 @@
/**
* 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 — except the legacy SSE transport, whose
* server half requires Node req/res and therefore runs over a real loopback
* HTTP listener on an ephemeral port.
*/
import { randomUUID } from 'node:crypto';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { PassThrough } from 'node:stream';
import type { Client } from '../../../src/client/index.js';
import { SSEClientTransport } from '../../../src/client/sse.js';
import { StreamableHTTPClientTransport } from '../../../src/client/streamableHttp.js';
import { InMemoryTransport } from '../../../src/inMemory.js';
import type { Server } from '../../../src/server/index.js';
import type { McpServer } from '../../../src/server/mcp.js';
import { SSEServerTransport } from '../../../src/server/sse.js';
import { StdioServerTransport } from '../../../src/server/stdio.js';
import { WebStandardStreamableHTTPServerTransport, type EventStore } from '../../../src/server/webStandardStreamableHttp.js';
import { ReadBuffer, serializeMessage } from '../../../src/shared/stdio.js';
import type { JSONRPCMessage } from '../../../src/types.js';
import type { Transport } from '../types.js';
import { sniffTransport, type SnifferOptions } 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(() => {})
};
}
case 'sse': {
// The legacy SSE server transport writes to a Node ServerResponse, so this branch hosts it
// on a real loopback listener: GET /sse opens the stream (one server instance per
// connection, mirroring hostPerSession) and POST /messages?sessionId=… delivers messages.
const sessions = new Map<string, { tx: SSEServerTransport; server: McpServer | Server }>();
const handleSseRequest = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
const reqUrl = new URL(req.url ?? '/', 'http://127.0.0.1');
if (req.method === 'GET' && reqUrl.pathname === '/sse') {
const tx = new SSEServerTransport('/messages', res);
const server = makeServer();
sessions.set(tx.sessionId, { tx, server });
tx.onclose = () => void sessions.delete(tx.sessionId);
await server.connect(tx);
return;
}
if (req.method === 'POST' && reqUrl.pathname === '/messages') {
const session = sessions.get(reqUrl.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) => {
handleSseRequest(req, res).catch(() => {
// Mirror the SDK's own SSE test harness: handler failures become a 500, not an unhandled rejection.
if (!res.headersSent) res.writeHead(500).end();
});
});
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`);
await client.connect(sniffTransport(new SSEClientTransport(url), 'client', sniff));
return {
url,
[Symbol.asyncDispose]: async () => {
await client.close();
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()));
}
};
}
}
}
/**
* 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 new Response(
JSON.stringify({
jsonrpc: '2.0',
error: { code: -32000, 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 new Response(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, 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,53 @@
/**
* 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 { ALL_SPEC_VERSIONS, ALL_TRANSPORTS, type TestArgs } 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 { describe, expect, it } from 'vitest';
import { LATEST_PROTOCOL_VERSION } from '../../../src/types.js';
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: -32601, message: 'Method not found' } };
expect(() => assertWireMessage(err, 'server')).not.toThrow();
expect(() => assertWireMessage(err, 'client')).not.toThrow();
});
});

View file

@ -0,0 +1,142 @@
/**
* Wire-format sniffer: asserts every JSON-RPC message crossing a transport is a
* well-formed MCP message for its direction.
*
* Validation runs against the SDK's runtime Zod schemas in `src/types.ts`
* (`ClientRequestSchema`, `ServerResultSchema`, …). Those schemas are proven
* equivalent to the spec-synced `src/spec.types.ts` by `test/spec.types.test.ts`
* (mutual assignability + per-type completeness). So conformance here is
* transitively conformance to the spec, with `spec.types.ts` as the anchor: if
* the SDK schemas ever drift off-spec, that equivalence test goes red first.
*
* Two layers:
* 1. JSON-RPC envelope (always) — request / notification / response / error.
* 2. MCP shape (unless `strictValidation: false`) — the method is a spec
* method for the sender's direction and its params/result match the schema.
* Vendor-extension method names are rejected unless `allowCustomMethods`.
*/
import {
ClientNotificationSchema,
ClientRequestSchema,
ClientResultSchema,
isJSONRPCErrorResponse,
isJSONRPCNotification,
isJSONRPCRequest,
isJSONRPCResultResponse,
JSONRPCMessageSchema,
ServerNotificationSchema,
ServerRequestSchema,
ServerResultSchema
} from '../../../src/types.js';
import type { Transport } from '../../../src/shared/transport.js';
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?: { value?: string } } }> }): Set<string> {
const out = new Set<string>();
for (const member of union.options ?? []) {
const v = member.shape?.method?.value;
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 (!JSONRPCMessageSchema.safeParse(msg).success) {
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;
}

2454
test/e2e/requirements.ts Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,355 @@
/**
* Self-contained test bodies for the completion surface.
*
* Completion provides autocompletion for prompt arguments and resource template
* variables. The server declares the `completions` capability and handles
* `completion/complete` requests, returning up to 100 string suggestions based
* on the partial value and optional context (already-resolved variables).
*/
import { expect } from 'vitest';
import { z } from 'zod/v4';
import { Client } from '../../../src/client/index.js';
import { Server } from '../../../src/server/index.js';
import { completable } from '../../../src/server/completable.js';
import { McpServer, ResourceTemplate } from '../../../src/server/mcp.js';
import {
CompleteRequestSchema,
CompleteResultSchema,
ErrorCode,
ListPromptsRequestSchema,
ListResourcesRequestSchema,
McpError,
ReadResourceRequestSchema
} from '../../../src/types.js';
import { wire } from '../helpers/index.js';
import type { TestArgs } from '../types.js';
import { verifies } from '../helpers/verifies.js';
const COLORS = ['red', 'green', 'blue', 'rebeccapurple'] as const;
const FILE_PATHS = ['README.md', 'src/index.ts', 'src/types.ts'] as const;
const REPOS_BY_OWNER: Record<string, readonly string[]> = {
'acme-corp': ['widget-sdk', 'gadget-sdk', 'docs'],
globex: ['frobnicator', 'reticulator']
};
const MANY_TOTAL = 150;
const newClient = () => new Client({ name: 'c', version: '0' });
function colorCompletionServer(): McpServer {
const s = new McpServer({ name: 's', version: '0' });
s.registerPrompt(
'complete-color',
{ argsSchema: { color: completable(z.string(), value => COLORS.filter(c => c.startsWith(value))) } },
({ color }) => ({ messages: [{ role: 'user', content: { type: 'text', text: `color=${color}` } }] })
);
return s;
}
verifies('completion:capability:declared', async ({ transport }: TestArgs) => {
const client = newClient();
await using _ = await wire(transport, colorCompletionServer, client);
const caps = client.getServerCapabilities();
expect(caps?.completions).toBeDefined();
expect(typeof caps?.completions).toBe('object');
const result = await client.complete({
ref: { type: 'ref/prompt', name: 'complete-color' },
argument: { name: 'color', value: '' }
});
expect(Array.isArray(result.completion.values)).toBe(true);
});
verifies('completion:complete:not-supported', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerPrompt('summarize-code', { argsSchema: { code: z.string() } }, ({ code }) => ({
messages: [{ role: 'user', content: { type: 'text', text: `Summarize the following code:\n${code}` } }]
}));
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const caps = client.getServerCapabilities();
expect(caps).toBeDefined();
expect(caps?.completions).toBeUndefined();
expect(caps?.prompts).toBeDefined();
// Raw request bypasses any client-side capability gating, so the rejection observed is the server's own.
await expect(
client.request(
{
method: 'completion/complete',
params: { ref: { type: 'ref/prompt', name: 'summarize-code' }, argument: { name: 'code', value: 'co' } }
},
CompleteResultSchema
)
).rejects.toMatchObject({ code: ErrorCode.MethodNotFound });
});
verifies('completion:context-arguments', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerResource(
'github-repo',
new ResourceTemplate('github://{owner}/{repo}', {
list: undefined,
complete: {
repo: (value, context) => {
const owner = context?.arguments?.owner;
if (!owner || typeof owner !== 'string') return [];
const repos = REPOS_BY_OWNER[owner] ?? [];
return repos.filter(r => r.startsWith(value));
}
}
}),
{ mimeType: 'text/plain' },
(uri, { owner, repo }) => ({ contents: [{ uri: uri.href, mimeType: 'text/plain', text: `${owner}/${repo}` }] })
);
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const acme = await client.complete({
ref: { type: 'ref/resource', uri: 'github://{owner}/{repo}' },
argument: { name: 'repo', value: '' },
context: { arguments: { owner: 'acme-corp' } }
});
expect(acme.completion.values).toEqual(['widget-sdk', 'gadget-sdk', 'docs']);
const globex = await client.complete({
ref: { type: 'ref/resource', uri: 'github://{owner}/{repo}' },
argument: { name: 'repo', value: '' },
context: { arguments: { owner: 'globex' } }
});
expect(globex.completion.values).toEqual(['frobnicator', 'reticulator']);
expect(acme.completion.values).not.toEqual(globex.completion.values);
const bare = await client.complete({
ref: { type: 'ref/resource', uri: 'github://{owner}/{repo}' },
argument: { name: 'repo', value: '' }
});
expect(bare.completion.values).toEqual([]);
});
verifies(
'completion:error:invalid-ref',
async ({ transport }: TestArgs) => {
const client = newClient();
await using _ = await wire(transport, colorCompletionServer, client);
await expect(
client.complete({ ref: { type: 'ref/prompt', name: 'no-such-prompt' }, argument: { name: 'whatever', value: '' } })
).rejects.toMatchObject({ code: ErrorCode.InvalidParams });
await expect(
client.complete({ ref: { type: 'ref/resource', uri: 'nosuchscheme://nowhere/{x}' }, argument: { name: 'x', value: '' } })
).rejects.toMatchObject({ code: ErrorCode.InvalidParams });
},
{ title: 'mcpserver' }
);
verifies(
'completion:error:invalid-ref',
async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new Server({ name: 's', version: '0' }, { capabilities: { completions: {} } });
s.setRequestHandler(CompleteRequestSchema, req => {
if (req.params.ref.type === 'ref/prompt' && req.params.ref.name === 'known') {
return { completion: { values: ['ok'] } };
}
throw new McpError(ErrorCode.InvalidParams, `No completion target: ${JSON.stringify(req.params.ref)}`);
});
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const known = await client.complete({ ref: { type: 'ref/prompt', name: 'known' }, argument: { name: 'a', value: '' } });
expect(known.completion.values).toEqual(['ok']);
await expect(
client.complete({ ref: { type: 'ref/prompt', name: 'no-such-prompt' }, argument: { name: 'a', value: '' } })
).rejects.toMatchObject({ code: ErrorCode.InvalidParams });
},
{ title: 'raw server' }
);
verifies('completion:prompt-arg', async ({ transport }: TestArgs) => {
const client = newClient();
await using _ = await wire(transport, colorCompletionServer, client);
const result = await client.complete({
ref: { type: 'ref/prompt', name: 'complete-color' },
argument: { name: 'color', value: 're' }
});
const expected = COLORS.filter(c => c.startsWith('re'));
expect(result.completion.values).toEqual(expected);
expect(result.completion.hasMore ?? false).toBe(false);
});
verifies('completion:resource-template-arg', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerResource(
'file-path',
new ResourceTemplate('completion://files/{path}', {
list: undefined,
complete: { path: value => FILE_PATHS.filter(p => p.startsWith(value)) }
}),
{ mimeType: 'text/plain' },
(uri, { path }) => ({ contents: [{ uri: uri.href, mimeType: 'text/plain', text: `path=${path}` }] })
);
s.registerResource(
'github-repo',
new ResourceTemplate('github://{owner}/{repo}', {
list: undefined,
complete: { owner: () => [] }
}),
{ mimeType: 'text/plain' },
(uri, { owner, repo }) => ({ contents: [{ uri: uri.href, mimeType: 'text/plain', text: `${owner}/${repo}` }] })
);
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const result = await client.complete({
ref: { type: 'ref/resource', uri: 'completion://files/{path}' },
argument: { name: 'path', value: 'src/' }
});
expect(result.completion.values).toEqual(['src/index.ts', 'src/types.ts']);
expect(result.completion.hasMore ?? false).toBe(false);
const other = await client.complete({
ref: { type: 'ref/resource', uri: 'github://{owner}/{repo}' },
argument: { name: 'path', value: 'src/' }
});
expect(other.completion.values).toEqual([]);
});
verifies('completion:result-shape', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerPrompt(
'complete-color',
{ argsSchema: { color: completable(z.string(), value => COLORS.filter(c => c.startsWith(value))) } },
({ color }) => ({ messages: [{ role: 'user', content: { type: 'text', text: `color=${color}` } }] })
);
const many = Array.from({ length: MANY_TOTAL }, (_, i) => `item-${String(i).padStart(3, '0')}`);
s.registerPrompt(
'complete-many',
{ argsSchema: { n: completable(z.string(), value => many.filter(s => s.startsWith(value))) } },
({ n }) => ({ messages: [{ role: 'user', content: { type: 'text', text: n } }] })
);
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const small = await client.complete({
ref: { type: 'ref/prompt', name: 'complete-color' },
argument: { name: 'color', value: 're' }
});
expect(Array.isArray(small.completion.values)).toBe(true);
expect(small.completion.values).toEqual(COLORS.filter(c => c.startsWith('re')));
expect(small.completion.values.length).toBeLessThanOrEqual(100);
expect(small.completion.total).toBe(small.completion.values.length);
expect(small.completion.total).toBe(2);
expect(small.completion.hasMore).toBe(false);
const empty = await client.complete({
ref: { type: 'ref/prompt', name: 'complete-color' },
argument: { name: 'no-such-arg', value: '' }
});
expect(empty.completion.values).toEqual([]);
expect(empty.completion.total).toBeUndefined();
expect(empty.completion.hasMore).toBe(false);
const many = await client.complete({
ref: { type: 'ref/prompt', name: 'complete-many' },
argument: { name: 'n', value: '' }
});
expect(many.completion.values).toHaveLength(100);
expect(many.completion.values.every(v => typeof v === 'string')).toBe(true);
expect(many.completion.values[0]).toBe('item-000');
expect(many.completion.values[99]).toBe('item-099');
expect(many.completion.total).toBe(MANY_TOTAL);
expect(many.completion.hasMore).toBe(true);
});
verifies(
'mcpserver:completion:capability-auto',
async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerPrompt('non-completable', { argsSchema: { arg: z.string() } }, ({ arg }) => ({
messages: [{ role: 'user', content: { type: 'text', text: arg } }]
}));
s.registerResource(
'plain-resource',
new ResourceTemplate('plain://resource/{id}', { list: undefined }),
{ mimeType: 'text/plain' },
(uri, { id }) => ({ contents: [{ uri: uri.href, mimeType: 'text/plain', text: `id=${id}` }] })
);
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const caps = client.getServerCapabilities();
expect(caps).toBeDefined();
expect(caps?.completions).toBeUndefined();
expect(caps?.prompts).toBeDefined();
expect(caps?.resources).toBeDefined();
await expect(
client.complete({
ref: { type: 'ref/prompt', name: 'non-completable' },
argument: { name: 'arg', value: '' }
})
).rejects.toThrow();
},
{ title: 'mcpserver' }
);
verifies(
'mcpserver:completion:capability-auto',
async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new Server({ name: 's', version: '0' }, { capabilities: { prompts: {}, resources: {} } });
s.setRequestHandler(ListPromptsRequestSchema, () => ({ prompts: [{ name: 'plain', arguments: [] }] }));
s.setRequestHandler(ListResourcesRequestSchema, () => ({ resources: [] }));
s.setRequestHandler(ReadResourceRequestSchema, () => ({ contents: [] }));
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const caps = client.getServerCapabilities();
expect(caps?.completions).toBeUndefined();
expect(caps?.prompts).toBeDefined();
expect(caps?.resources).toBeDefined();
await expect(
client.complete({
ref: { type: 'ref/prompt', name: 'plain' },
argument: { name: 'arg', value: '' }
})
).rejects.toThrow();
},
{ title: 'raw server' }
);

View file

@ -0,0 +1,359 @@
/**
* Self-contained test bodies for dynamic (list_changed) behaviors.
*
* Tests for client-side listChanged handlers (auto-refresh, capability gating,
* signal-only mode) and server-side dynamic registration (post-connect,
* debounce, enable/disable, low-level handler reach-through).
*/
import { expect, vi } from 'vitest';
import { z } from 'zod/v4';
import { Client } from '../../../src/client/index.js';
import { McpServer, type RegisteredTool } from '../../../src/server/mcp.js';
import { Server } from '../../../src/server/index.js';
import {
ListPromptsRequestSchema,
ListResourcesRequestSchema,
ListToolsRequestSchema,
PromptListChangedNotificationSchema,
ResourceListChangedNotificationSchema,
SubscribeRequestSchema,
ToolListChangedNotificationSchema,
type Prompt,
type Resource,
type Tool
} from '../../../src/types.js';
import { wire } from '../helpers/index.js';
import type { TestArgs } from '../types.js';
import { verifies } from '../helpers/verifies.js';
const newClient = () => new Client({ name: 'c', version: '0' });
// vi.waitFor only retries on throw, not on falsy return — wrap predicates so a
// false result throws and the poll continues.
const waitUntil = (pred: () => boolean) => vi.waitFor(() => expect(pred()).toBe(true));
verifies('client:list-changed:auto-refresh', async ({ transport }: TestArgs) => {
const toolCalls: Array<{ error: Error | null; items: Tool[] | null }> = [];
const promptCalls: Array<{ error: Error | null; items: Prompt[] | null }> = [];
const resourceCalls: Array<{ error: Error | null; items: Resource[] | null }> = [];
let server!: McpServer;
const makeServer = () => {
server = new McpServer({ name: 's', version: '0' });
server.registerTool('seed', { inputSchema: z.object({}) }, () => ({ content: [] }));
server.registerPrompt('seed', { description: '' }, () => ({ messages: [] }));
server.registerResource('seed', 'test://seed', {}, () => ({ contents: [] }));
return server;
};
const client = new Client(
{ name: 'c', version: '0' },
{
listChanged: {
tools: { debounceMs: 0, onChanged: (error, items) => toolCalls.push({ error, items }) },
prompts: { debounceMs: 0, onChanged: (error, items) => promptCalls.push({ error, items }) },
resources: { debounceMs: 0, onChanged: (error, items) => resourceCalls.push({ error, items }) }
}
}
);
await using _ = await wire(transport, makeServer, client);
server.registerTool('probe-tool', { inputSchema: z.object({}) }, () => ({ content: [] }));
await waitUntil(() => toolCalls.some(c => c.items?.some(t => t.name === 'probe-tool')));
const toolCall = toolCalls.find(c => c.items?.some(t => t.name === 'probe-tool'));
expect(toolCall?.error).toBeNull();
expect(toolCall?.items?.map(t => t.name)).toContain('probe-tool');
server.registerPrompt('probe-prompt', { description: '' }, () => ({ messages: [] }));
await waitUntil(() => promptCalls.some(c => c.items?.some(p => p.name === 'probe-prompt')));
const promptCall = promptCalls.find(c => c.items?.some(p => p.name === 'probe-prompt'));
expect(promptCall?.error).toBeNull();
expect(promptCall?.items?.map(p => p.name)).toContain('probe-prompt');
server.registerResource('probe', 'test://probe', {}, () => ({ contents: [] }));
await waitUntil(() => resourceCalls.some(c => c.items?.some(r => r.uri === 'test://probe')));
const resourceCall = resourceCalls.find(c => c.items?.some(r => r.uri === 'test://probe'));
expect(resourceCall?.error).toBeNull();
expect(resourceCall?.items?.map(r => r.uri)).toContain('test://probe');
});
verifies('client:list-changed:capability-gated', async ({ transport }: TestArgs) => {
const toolsCalls: unknown[] = [];
const promptsCalls: unknown[] = [];
const resourcesCalls: unknown[] = [];
let server!: Server;
const makeServer = () => {
server = new Server({ name: 's', version: '0' }, { capabilities: { tools: { listChanged: true }, prompts: {}, resources: {} } });
server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: [] }));
server.setRequestHandler(ListPromptsRequestSchema, () => ({ prompts: [] }));
server.setRequestHandler(ListResourcesRequestSchema, () => ({ resources: [] }));
return server;
};
const client = new Client(
{ name: 'c', version: '0' },
{
listChanged: {
tools: { autoRefresh: false, debounceMs: 0, onChanged: (err, items) => toolsCalls.push({ err, items }) },
prompts: {
autoRefresh: false,
debounceMs: 0,
onChanged: (err, items) => promptsCalls.push({ err, items })
},
resources: {
autoRefresh: false,
debounceMs: 0,
onChanged: (err, items) => resourcesCalls.push({ err, items })
}
}
}
);
await using _ = await wire(transport, makeServer, client);
const caps = client.getServerCapabilities();
expect(caps?.tools?.listChanged).toBe(true);
expect(caps?.prompts?.listChanged).toBeFalsy();
expect(caps?.resources?.listChanged).toBeFalsy();
toolsCalls.length = 0;
promptsCalls.length = 0;
resourcesCalls.length = 0;
await server.sendToolListChanged();
await server.sendPromptListChanged();
await server.sendResourceListChanged();
await waitUntil(() => toolsCalls.length >= 1);
expect(toolsCalls[0]).toEqual({ err: null, items: null });
await client.listTools();
expect(promptsCalls).toHaveLength(0);
expect(resourcesCalls).toHaveLength(0);
});
verifies('client:list-changed:signal-only', async ({ transport }: TestArgs) => {
const toolCalls: Array<{ error: Error | null; items: Tool[] | null }> = [];
const promptCalls: Array<{ error: Error | null; items: Prompt[] | null }> = [];
const resourceCalls: Array<{ error: Error | null; items: Resource[] | null }> = [];
let server!: McpServer;
const makeServer = () => {
server = new McpServer({ name: 's', version: '0' });
server.registerTool('seed', { inputSchema: z.object({}) }, () => ({ content: [] }));
server.registerPrompt('seed', { description: '' }, () => ({ messages: [] }));
server.registerResource('seed', 'test://seed', {}, () => ({ contents: [] }));
return server;
};
const client = new Client(
{ name: 'c', version: '0' },
{
listChanged: {
tools: { autoRefresh: false, debounceMs: 0, onChanged: (error, items) => toolCalls.push({ error, items }) },
prompts: {
autoRefresh: false,
debounceMs: 0,
onChanged: (error, items) => promptCalls.push({ error, items })
},
resources: {
autoRefresh: false,
debounceMs: 0,
onChanged: (error, items) => resourceCalls.push({ error, items })
}
}
}
);
await using _ = await wire(transport, makeServer, client);
const before = await client.listTools();
toolCalls.length = 0;
promptCalls.length = 0;
resourceCalls.length = 0;
server.registerTool('signal-only-tool', { inputSchema: z.object({}) }, () => ({ content: [] }));
await waitUntil(() => toolCalls.length >= 1);
expect(toolCalls[0]).toEqual({ error: null, items: null });
server.registerPrompt('signal-only-prompt', { description: '' }, () => ({ messages: [] }));
await waitUntil(() => promptCalls.length >= 1);
expect(promptCalls[0]).toEqual({ error: null, items: null });
server.registerResource('signal-only', 'test://signal-only', {}, () => ({ contents: [] }));
await waitUntil(() => resourceCalls.length >= 1);
expect(resourceCalls[0]).toEqual({ error: null, items: null });
const after = await client.listTools();
expect(after.tools.length).toBe(before.tools.length + 1);
});
verifies('mcpserver:handle:enable-disable', async ({ transport }: TestArgs) => {
let handle!: RegisteredTool;
let server!: McpServer;
const makeServer = () => {
server = new McpServer({ name: 's', version: '0' });
handle = server.registerTool('toggle-probe', { inputSchema: z.object({}) }, () => ({
content: [{ type: 'text', text: 'toggle-probe' }]
}));
return server;
};
let listChanged = 0;
const client = newClient();
client.setNotificationHandler(ToolListChangedNotificationSchema, () => {
listChanged++;
});
await using _ = await wire(transport, makeServer, client);
expect((await client.listTools()).tools.map(t => t.name)).toContain('toggle-probe');
const baseline = await client.callTool({ name: 'toggle-probe', arguments: {} });
expect(baseline.isError).toBeFalsy();
expect(baseline.content).toEqual([{ type: 'text', text: 'toggle-probe' }]);
const beforeDisable = listChanged;
handle.disable();
await waitUntil(() => listChanged > beforeDisable);
expect((await client.listTools()).tools.map(t => t.name)).not.toContain('toggle-probe');
const disabledCall = await client.callTool({ name: 'toggle-probe', arguments: {} });
expect(disabledCall.isError).toBe(true);
expect(disabledCall.content).toEqual([{ type: 'text', text: expect.stringMatching(/disabled/i) }]);
const beforeEnable = listChanged;
handle.enable();
await waitUntil(() => listChanged > beforeEnable);
expect((await client.listTools()).tools.map(t => t.name)).toContain('toggle-probe');
const restored = await client.callTool({ name: 'toggle-probe', arguments: {} });
expect(restored.isError).toBeFalsy();
expect(restored.content).toEqual([{ type: 'text', text: 'toggle-probe' }]);
});
verifies('mcpserver:list-changed:debounce', async ({ transport }: TestArgs) => {
let server!: McpServer;
const makeServer = () => {
server = new McpServer(
{ name: 's', version: '0' },
{
debouncedNotificationMethods: [
'notifications/tools/list_changed',
'notifications/resources/list_changed',
'notifications/prompts/list_changed'
]
}
);
// Seed one of each so capability registration + request handlers are set
// up pre-connect; post-connect registrations would otherwise try to
// registerCapabilities and throw.
server.registerTool('seed', { inputSchema: z.object({}) }, () => ({ content: [] }));
server.registerResource('seed', 'test://seed', {}, () => ({ contents: [] }));
server.registerPrompt('seed', { description: '' }, () => ({ messages: [] }));
return server;
};
const counts = { tools: 0, resources: 0, prompts: 0 };
const client = newClient();
client.setNotificationHandler(ToolListChangedNotificationSchema, () => void counts.tools++);
client.setNotificationHandler(ResourceListChangedNotificationSchema, () => void counts.resources++);
client.setNotificationHandler(PromptListChangedNotificationSchema, () => void counts.prompts++);
await using _ = await wire(transport, makeServer, client);
counts.tools = 0;
counts.resources = 0;
counts.prompts = 0;
server.registerTool('a', { inputSchema: z.object({}) }, () => ({ content: [] }));
server.registerTool('b', { inputSchema: z.object({}) }, () => ({ content: [] }));
server.registerTool('c', { inputSchema: z.object({}) }, () => ({ content: [] }));
server.registerResource('a', 'test://debounce/a', {}, () => ({ contents: [] }));
server.registerResource('b', 'test://debounce/b', {}, () => ({ contents: [] }));
server.registerResource('c', 'test://debounce/c', {}, () => ({ contents: [] }));
server.registerPrompt('a', { description: '' }, () => ({ messages: [] }));
server.registerPrompt('b', { description: '' }, () => ({ messages: [] }));
server.registerPrompt('c', { description: '' }, () => ({ messages: [] }));
await waitUntil(() => counts.tools >= 1 && counts.resources >= 1 && counts.prompts >= 1);
await client.listTools();
expect(counts.tools).toBe(1);
expect(counts.resources).toBe(1);
expect(counts.prompts).toBe(1);
const { tools } = await client.listTools();
expect(tools.map(t => t.name)).toEqual(expect.arrayContaining(['a', 'b', 'c']));
});
verifies('mcpserver:register:post-connect', async ({ transport }: TestArgs) => {
let server!: McpServer;
const makeServer = () => {
server = new McpServer({ name: 's', version: '0' });
// Seed so handlers + capabilities are wired pre-connect.
server.registerTool('seed', { inputSchema: z.object({}) }, () => ({ content: [] }));
server.registerResource('seed', 'test://seed', {}, () => ({ contents: [] }));
server.registerPrompt('seed', { description: '' }, () => ({ messages: [] }));
return server;
};
const seen: string[] = [];
const client = newClient();
client.setNotificationHandler(ToolListChangedNotificationSchema, () => void seen.push('tools'));
client.setNotificationHandler(ResourceListChangedNotificationSchema, () => void seen.push('resources'));
client.setNotificationHandler(PromptListChangedNotificationSchema, () => void seen.push('prompts'));
await using _ = await wire(transport, makeServer, client);
seen.length = 0;
server.registerTool('post-connect-tool', { inputSchema: z.object({}) }, () => ({ content: [] }));
await waitUntil(() => seen.includes('tools'));
expect((await client.listTools()).tools.map(t => t.name)).toContain('post-connect-tool');
seen.length = 0;
server.registerResource('post-connect', 'test://post-connect', {}, () => ({ contents: [] }));
await waitUntil(() => seen.includes('resources'));
expect((await client.listResources()).resources.map(r => r.uri)).toContain('test://post-connect');
seen.length = 0;
server.registerPrompt('post-connect-prompt', { description: '' }, () => ({ messages: [] }));
await waitUntil(() => seen.includes('prompts'));
expect((await client.listPrompts()).prompts.map(p => p.name)).toContain('post-connect-prompt');
});
verifies('mcpserver:reach-through:set-request-handler', async ({ transport }: TestArgs) => {
const handlerHits: string[] = [];
let server!: McpServer;
const makeServer = () => {
server = new McpServer({ name: 's', version: '0' }, { capabilities: { resources: { subscribe: true, listChanged: true } } });
server.registerResource('baseline', 'test://baseline', {}, () => ({ contents: [] }));
server.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({
content: [{ type: 'text', text }]
}));
return server;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
server.server.setRequestHandler(SubscribeRequestSchema, async request => {
handlerHits.push(request.params.uri);
return {};
});
await expect(client.subscribeResource({ uri: 'test://reach-through' })).resolves.toEqual({});
await waitUntil(() => handlerHits.includes('test://reach-through'));
expect(handlerHits).toEqual(['test://reach-through']);
const echo = await client.callTool({ name: 'echo', arguments: { text: 'still wired' } });
expect(echo.isError).toBeFalsy();
expect(echo.content).toEqual([{ type: 'text', text: 'still wired' }]);
});

View file

@ -0,0 +1,817 @@
/**
* Self-contained test bodies for elicitation (form and URL modes).
*
* Each export is a {@link TestCase}: it builds its own server, client, wires
* them with {@link wire}, and asserts. Elicitation is a server→client request
* flow, so tests inline `client.setRequestHandler(ElicitRequestSchema, ...)` to
* script the responses the server will receive.
*
* Function names mirror the requirement id in camelCase.
*/
import { expect } from 'vitest';
import { z } from 'zod/v4';
import { Client } from '../../../src/client/index.js';
import { Server } from '../../../src/server/index.js';
import { McpServer } from '../../../src/server/mcp.js';
import {
CallToolRequestSchema,
ElicitationCompleteNotificationSchema,
type ElicitRequest,
type ElicitRequestFormParams,
ElicitRequestSchema,
ElicitResultSchema,
ErrorCode,
ListToolsRequestSchema,
McpError,
UrlElicitationRequiredError
} from '../../../src/types.js';
import { tapWire, wire } from '../helpers/index.js';
import type { TestArgs } from '../types.js';
import { verifies } from '../helpers/verifies.js';
/** Client with form-mode elicitation support. */
const formClient = () => new Client({ name: 'c', version: '0' }, { capabilities: { elicitation: { form: {} } } });
/** Client with form-mode elicitation AND applyDefaults support. */
const formClientWithDefaults = () =>
new Client({ name: 'c', version: '0' }, { capabilities: { elicitation: { form: { applyDefaults: true } } } });
/** Client with URL-mode elicitation support. */
const urlClient = () => new Client({ name: 'c', version: '0' }, { capabilities: { elicitation: { url: {} } } });
verifies('elicitation:form:basic', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('ask-name', { inputSchema: z.object({}) }, async () => {
const ans = await s.server.elicitInput({
mode: 'form',
message: 'What is your name?',
requestedSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }
});
const name = ans.action === 'accept' ? String(ans.content?.name) : '<declined>';
return { content: [{ type: 'text', text: `Hello, ${name}` }] };
});
return s;
};
const received: Array<{ method: string; params: unknown }> = [];
const client = formClient();
client.setRequestHandler(ElicitRequestSchema, async req => {
received.push({ method: req.method, params: req.params });
return { action: 'accept', content: { name: 'Ada' } };
});
await using _ = await wire(transport, makeServer, client);
const r = await client.callTool({ name: 'ask-name', arguments: {} });
expect(received).toHaveLength(1);
expect(received[0].method).toBe('elicitation/create');
expect(received[0].params).toMatchObject({
mode: 'form',
message: 'What is your name?',
requestedSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }
});
expect(r.isError).toBeFalsy();
expect(r.content).toEqual([{ type: 'text', text: 'Hello, Ada' }]);
});
verifies('elicitation:form:action:accept', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('ask', { inputSchema: z.object({}) }, async () => {
const ans = await s.server.elicitInput({
mode: 'form',
message: 'Enter name',
requestedSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }
});
return {
content: [{ type: 'text', text: ans.action === 'accept' ? String(ans.content?.name) : 'none' }]
};
});
return s;
};
const client = formClient();
client.setRequestHandler(ElicitRequestSchema, async () => {
return { action: 'accept', content: { name: 'Ada' } };
});
await using _ = await wire(transport, makeServer, client);
const r = await client.callTool({ name: 'ask', arguments: {} });
expect(r.content).toEqual([{ type: 'text', text: 'Ada' }]);
});
verifies('elicitation:form:action:cancel', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('ask', { inputSchema: z.object({}) }, async () => {
const ans = await s.server.elicitInput({
mode: 'form',
message: 'Enter name',
requestedSchema: { type: 'object', properties: { name: { type: 'string' } } }
});
return {
content: [{ type: 'text', text: `action:${ans.action},hasContent:${'content' in ans && ans.content !== undefined}` }]
};
});
return s;
};
const client = formClient();
client.setRequestHandler(ElicitRequestSchema, async () => {
return { action: 'cancel' };
});
await using _ = await wire(transport, makeServer, client);
const r = await client.callTool({ name: 'ask', arguments: {} });
expect(r.content).toEqual([{ type: 'text', text: 'action:cancel,hasContent:false' }]);
});
verifies('elicitation:form:action:decline', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('ask', { inputSchema: z.object({}) }, async () => {
const ans = await s.server.elicitInput({
mode: 'form',
message: 'Enter name',
requestedSchema: { type: 'object', properties: { name: { type: 'string' } } }
});
return {
content: [{ type: 'text', text: `action:${ans.action},hasContent:${'content' in ans && ans.content !== undefined}` }]
};
});
return s;
};
const client = formClient();
client.setRequestHandler(ElicitRequestSchema, async () => {
return { action: 'decline' };
});
await using _ = await wire(transport, makeServer, client);
const r = await client.callTool({ name: 'ask', arguments: {} });
expect(r.content).toEqual([{ type: 'text', text: 'action:decline,hasContent:false' }]);
});
verifies('elicitation:form:defaults', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('ask', { inputSchema: z.object({}) }, async () => {
const ans = await s.server.elicitInput({
mode: 'form',
message: 'Fill form',
requestedSchema: {
type: 'object',
properties: {
name: { type: 'string', default: 'anon' },
age: { type: 'integer', default: 42 },
subscribe: { type: 'boolean', default: true }
}
}
});
return { content: [], structuredContent: ans.action === 'accept' ? ans.content : undefined };
});
return s;
};
const client = formClientWithDefaults();
client.setRequestHandler(ElicitRequestSchema, async () => ({ action: 'accept', content: { name: 'Ada' } }));
await using _ = await wire(transport, makeServer, client);
const r = await client.callTool({ name: 'ask', arguments: {} });
expect(r.structuredContent).toEqual({ name: 'Ada', age: 42, subscribe: true });
});
verifies('elicitation:form:mode-omitted-default', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } });
s.setRequestHandler(ListToolsRequestSchema, () => ({ tools: [{ name: 'ask', inputSchema: { type: 'object' } }] }));
s.setRequestHandler(CallToolRequestSchema, async (_req, extra) => {
try {
const ans = await extra.sendRequest(
{
method: 'elicitation/create',
params: {
message: 'Enter name',
requestedSchema: { type: 'object', properties: { name: { type: 'string' } } }
}
},
ElicitResultSchema
);
return { content: [{ type: 'text', text: ans.action }] };
} catch (e) {
if (!(e instanceof McpError)) throw e;
return { content: [{ type: 'text', text: `error:${e.code}` }] };
}
});
return s;
};
const formReceived: ElicitRequest['params'][] = [];
const formClientInstance = formClient();
formClientInstance.setRequestHandler(ElicitRequestSchema, async req => {
formReceived.push(req.params);
return { action: 'accept', content: { name: 'Test' } };
});
await using _1 = await wire(transport, makeServer, formClientInstance);
const formResult = await formClientInstance.callTool({ name: 'ask', arguments: {} });
expect(formReceived).toHaveLength(1);
expect(formReceived[0].mode).toBeUndefined();
expect(formResult.content).toEqual([{ type: 'text', text: 'accept' }]);
let urlHandlerInvoked = 0;
const urlClientInstance = urlClient();
urlClientInstance.setRequestHandler(ElicitRequestSchema, async () => {
urlHandlerInvoked++;
return { action: 'accept' };
});
await using _2 = await wire(transport, makeServer, urlClientInstance);
const urlResult = await urlClientInstance.callTool({ name: 'ask', arguments: {} });
expect(urlResult.content).toEqual([{ type: 'text', text: `error:${ErrorCode.InvalidParams}` }]);
expect(urlHandlerInvoked).toBe(0);
});
verifies('elicitation:form:schema:primitives', async ({ transport }: TestArgs) => {
const requestedSchema: ElicitRequestFormParams['requestedSchema'] = {
type: 'object',
properties: {
name: { type: 'string' },
email: { type: 'string', format: 'email' },
age: { type: 'integer' },
score: { type: 'number' },
active: { type: 'boolean' }
}
};
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('ask', { inputSchema: z.object({}) }, async () => {
const ans = await s.server.elicitInput({ mode: 'form', message: 'Fill form', requestedSchema });
return { content: [], structuredContent: ans.action === 'accept' ? ans.content : undefined };
});
return s;
};
const expected = { name: 'Ada', email: 'ada@example.com', age: 30, score: 95.5, active: true };
const received: ElicitRequest['params'][] = [];
const client = formClient();
client.setRequestHandler(ElicitRequestSchema, async req => {
received.push(req.params);
return { action: 'accept', content: expected };
});
await using _ = await wire(transport, makeServer, client);
const r = await client.callTool({ name: 'ask', arguments: {} });
expect(received).toHaveLength(1);
expect(received[0]).toEqual({ mode: 'form', message: 'Fill form', requestedSchema });
expect(r.structuredContent).toEqual(expected);
});
verifies('elicitation:form:schema:enum-variants', async ({ transport }: TestArgs) => {
const requestedSchema: ElicitRequestFormParams['requestedSchema'] = {
type: 'object',
properties: {
bare: { type: 'string', enum: ['Red', 'Green', 'Blue'] },
titled: {
type: 'string',
oneOf: [
{ const: '#FF0000', title: 'Red' },
{ const: '#00FF00', title: 'Green' }
]
},
multi: {
type: 'array',
items: {
anyOf: [
{ const: '#FF0000', title: 'Red' },
{ const: '#0000FF', title: 'Blue' }
]
}
}
}
};
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('ask', { inputSchema: z.object({}) }, async () => {
const ans = await s.server.elicitInput({ mode: 'form', message: 'Pick colors', requestedSchema });
return { content: [], structuredContent: ans.action === 'accept' ? ans.content : undefined };
});
return s;
};
const expected = { bare: 'Red', titled: '#00FF00', multi: ['#FF0000', '#0000FF'] };
const received: ElicitRequest['params'][] = [];
const client = formClient();
client.setRequestHandler(ElicitRequestSchema, async req => {
received.push(req.params);
return { action: 'accept', content: expected };
});
await using _ = await wire(transport, makeServer, client);
const r = await client.callTool({ name: 'ask', arguments: {} });
expect(received).toHaveLength(1);
expect(received[0]).toEqual({ mode: 'form', message: 'Pick colors', requestedSchema });
expect(r.structuredContent).toEqual(expected);
});
verifies('elicitation:form:response-validation', async ({ transport }: TestArgs) => {
const requestedSchema: ElicitRequestFormParams['requestedSchema'] = {
type: 'object',
properties: { username: { type: 'string' } },
required: ['username']
};
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('signup', { inputSchema: z.object({}) }, async () => {
try {
const ans = await s.server.elicitInput({ mode: 'form', message: 'Choose a username', requestedSchema });
return {
isError: true,
content: [{ type: 'text', text: `SDK accepted invalid content: ${JSON.stringify(ans.content)}` }]
};
} catch (e) {
if (!(e instanceof McpError)) throw e;
return { content: [{ type: 'text', text: `rejected:${e.code}:${e.message}` }] };
}
});
return s;
};
// Each accepted response violates the requested schema: wrong type for `username`, then missing required `username`.
const invalidContents: Array<Record<string, string | number | boolean>> = [{ username: 42 }, {}];
let handlerInvoked = 0;
const client = formClient();
client.setRequestHandler(ElicitRequestSchema, async () => {
const content = invalidContents[handlerInvoked];
handlerInvoked++;
return { action: 'accept', content };
});
await using _ = await wire(transport, makeServer, client);
const schemaRejection = expect.stringMatching(new RegExp(`^rejected:${ErrorCode.InvalidParams}:.*does not match requested schema`));
const wrongType = await client.callTool({ name: 'signup', arguments: {} });
expect(wrongType.isError).toBeFalsy();
expect(wrongType.content).toEqual([{ type: 'text', text: schemaRejection }]);
const missingRequired = await client.callTool({ name: 'signup', arguments: {} });
expect(missingRequired.isError).toBeFalsy();
expect(missingRequired.content).toEqual([{ type: 'text', text: schemaRejection }]);
expect(handlerInvoked).toBe(2);
});
verifies('elicitation:form:schema:restricted-subset', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } });
s.setRequestHandler(ListToolsRequestSchema, () => ({ tools: [{ name: 'profile', inputSchema: { type: 'object' } }] }));
s.setRequestHandler(CallToolRequestSchema, async (_req, extra) => {
try {
await extra.sendRequest(
{
method: 'elicitation/create',
params: {
mode: 'form',
message: 'Profile details',
requestedSchema: {
type: 'object',
properties: {
address: {
type: 'object',
properties: { street: { type: 'string' }, city: { type: 'string' } }
},
contacts: {
type: 'array',
items: { type: 'object', properties: { name: { type: 'string' } } }
}
}
}
}
},
ElicitResultSchema
);
return { content: [{ type: 'text', text: 'should-not-reach' }] };
} catch (e) {
if (!(e instanceof McpError)) throw e;
return { content: [{ type: 'text', text: `error:${e.code}` }] };
}
});
return s;
};
let handlerInvoked = 0;
const client = formClient();
client.setRequestHandler(ElicitRequestSchema, async () => {
handlerInvoked++;
return { action: 'accept', content: {} };
});
// strictValidation off: the nested requestedSchema deliberately violates the spec's flat-primitive restriction on the wire.
await using _ = await wire(transport, makeServer, client, { strictValidation: false });
const r = await client.callTool({ name: 'profile', arguments: {} });
expect(r.isError).toBeFalsy();
expect(r.content).toEqual([{ type: 'text', text: `error:${ErrorCode.InvalidParams}` }]);
expect(handlerInvoked).toBe(0);
});
verifies('elicitation:url:basic', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('auth', { inputSchema: z.object({}) }, async () => {
const ans = await s.server.elicitInput({
mode: 'url',
message: 'Please sign in',
elicitationId: 'url-1',
url: 'https://example.com/auth?state=test'
});
return { content: [{ type: 'text', text: ans.action }] };
});
return s;
};
const received: ElicitRequest['params'][] = [];
const client = urlClient();
client.setRequestHandler(ElicitRequestSchema, async req => {
received.push(req.params);
return { action: 'accept' };
});
await using _ = await wire(transport, makeServer, client);
const r = await client.callTool({ name: 'auth', arguments: {} });
expect(received).toHaveLength(1);
expect(received[0]).toMatchObject({
mode: 'url',
message: 'Please sign in',
elicitationId: 'url-1',
url: 'https://example.com/auth?state=test'
});
expect(r.content).toEqual([{ type: 'text', text: 'accept' }]);
});
verifies('elicitation:url:action:accept-no-content', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('auth', { inputSchema: z.object({}) }, async () => {
const ans = await s.server.elicitInput({
mode: 'url',
message: 'Please sign in',
elicitationId: 'url-2',
url: 'https://example.com/auth'
});
return {
content: [{ type: 'text', text: `action:${ans.action},hasContent:${'content' in ans && ans.content !== undefined}` }]
};
});
return s;
};
const client = urlClient();
client.setRequestHandler(ElicitRequestSchema, async () => ({ action: 'accept' }));
await using _ = await wire(transport, makeServer, client);
const r = await client.callTool({ name: 'auth', arguments: {} });
expect(r.content).toEqual([{ type: 'text', text: 'action:accept,hasContent:false' }]);
});
verifies('elicitation:url:action:cancel', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('auth', { inputSchema: z.object({}) }, async () => {
const ans = await s.server.elicitInput({
mode: 'url',
message: 'Please sign in',
elicitationId: 'url-cancel-1',
url: 'https://example.com/auth'
});
return {
content: [{ type: 'text', text: `action:${ans.action},hasContent:${'content' in ans && ans.content !== undefined}` }]
};
});
return s;
};
const client = urlClient();
client.setRequestHandler(ElicitRequestSchema, async () => ({ action: 'cancel' }));
await using _ = await wire(transport, makeServer, client);
const r = await client.callTool({ name: 'auth', arguments: {} });
expect(r.isError).toBeFalsy();
expect(r.content).toEqual([{ type: 'text', text: 'action:cancel,hasContent:false' }]);
});
verifies('elicitation:url:action:decline', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('auth', { inputSchema: z.object({}) }, async () => {
const ans = await s.server.elicitInput({
mode: 'url',
message: 'Please sign in',
elicitationId: 'url-decline-1',
url: 'https://example.com/auth'
});
return {
content: [{ type: 'text', text: `action:${ans.action},hasContent:${'content' in ans && ans.content !== undefined}` }]
};
});
return s;
};
const client = urlClient();
client.setRequestHandler(ElicitRequestSchema, async () => ({ action: 'decline' }));
await using _ = await wire(transport, makeServer, client);
const r = await client.callTool({ name: 'auth', arguments: {} });
expect(r.isError).toBeFalsy();
expect(r.content).toEqual([{ type: 'text', text: 'action:decline,hasContent:false' }]);
});
verifies('elicitation:url:complete-notification', async ({ transport }: TestArgs) => {
const notifications: Array<{ method: string; params: unknown }> = [];
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('auth', { inputSchema: z.object({}) }, async () => {
throw new UrlElicitationRequiredError([
{
mode: 'url',
message: 'Sign in required',
elicitationId: 'complete-1',
url: 'https://example.com/auth'
}
]);
});
s.registerTool('complete', { inputSchema: z.object({ elicitationId: z.string() }) }, async ({ elicitationId }, extra) => {
const notify = s.server.createElicitationCompletionNotifier(elicitationId, { relatedRequestId: extra.requestId });
await notify();
return { content: [{ type: 'text', text: 'notified' }] };
});
return s;
};
const client = urlClient();
client.setNotificationHandler(ElicitationCompleteNotificationSchema, n => {
notifications.push({ method: n.method, params: n.params });
});
await using _ = await wire(transport, makeServer, client);
const err = await client.callTool({ name: 'auth', arguments: {} }).catch(e => e);
if (!(err instanceof UrlElicitationRequiredError)) throw new Error(`expected UrlElicitationRequiredError, got ${err}`);
const elicitationId = err.elicitations[0].elicitationId;
await client.callTool({ name: 'complete', arguments: { elicitationId } });
expect(notifications).toHaveLength(1);
expect(notifications[0]).toEqual({
method: 'notifications/elicitation/complete',
params: { elicitationId: 'complete-1' }
});
});
verifies('elicitation:url:complete-unknown-ignored', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('auth', { inputSchema: z.object({}) }, () => {
throw new UrlElicitationRequiredError([
{ mode: 'url', message: 'Sign in required', elicitationId: 'seen-1', url: 'https://example.com/auth' }
]);
});
// Raw send: the server-side capability gate cannot pass on stateless (client capabilities never learned),
// and the behavior under test is the client's handling of the notification.
s.registerTool('complete', { inputSchema: z.object({ elicitationId: z.string() }) }, async ({ elicitationId }, extra) => {
await s.server.transport!.send(
{ jsonrpc: '2.0', method: 'notifications/elicitation/complete', params: { elicitationId } },
{ relatedRequestId: extra.requestId }
);
return { content: [{ type: 'text', text: 'sent' }] };
});
s.registerTool('noop', { inputSchema: z.object({}) }, () => ({ content: [] }));
return s;
};
const notifications: Array<{ method: string; params: unknown }> = [];
const errors: Error[] = [];
const client = urlClient();
client.onerror = e => errors.push(e);
client.setNotificationHandler(ElicitationCompleteNotificationSchema, n => {
notifications.push({ method: n.method, params: n.params });
});
await using _ = await wire(transport, makeServer, client);
const err = await client.callTool({ name: 'auth', arguments: {} }).catch(e => e);
if (!(err instanceof UrlElicitationRequiredError)) throw new Error(`expected UrlElicitationRequiredError, got ${err}`);
const seenId = err.elicitations[0].elicitationId;
await client.callTool({ name: 'complete', arguments: { elicitationId: seenId } });
// Already-completed id, then an id the client never saw: both must be ignored without error.
await client.callTool({ name: 'complete', arguments: { elicitationId: seenId } });
await client.callTool({ name: 'complete', arguments: { elicitationId: 'never-issued' } });
expect(notifications).toEqual([
{ method: 'notifications/elicitation/complete', params: { elicitationId: 'seen-1' } },
{ method: 'notifications/elicitation/complete', params: { elicitationId: 'seen-1' } },
{ method: 'notifications/elicitation/complete', params: { elicitationId: 'never-issued' } }
]);
expect(errors).toEqual([]);
const after = await client.callTool({ name: 'noop', arguments: {} });
expect(after.isError).toBeFalsy();
expect(after.content).toEqual([]);
});
verifies('elicitation:url:required-error', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('auth-required', { inputSchema: z.object({}) }, () => {
throw new UrlElicitationRequiredError([
{
mode: 'url',
message: 'Please authenticate',
elicitationId: 'err-1',
url: 'https://example.com/oauth'
}
]);
});
return s;
};
const client = urlClient();
await using _ = await wire(transport, makeServer, client);
const err = await client.callTool({ name: 'auth-required', arguments: {} }).catch(e => e);
if (!(err instanceof UrlElicitationRequiredError)) throw new Error(`expected UrlElicitationRequiredError, got ${err}`);
expect(err.code).toBe(ErrorCode.UrlElicitationRequired);
expect(err.elicitations).toHaveLength(1);
expect(err.elicitations[0]).toMatchObject({
mode: 'url',
message: 'Please authenticate',
elicitationId: 'err-1',
url: 'https://example.com/oauth'
});
});
verifies('elicitation:capability:empty-is-form', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('ask', { inputSchema: z.object({}) }, async () => {
const ans = await s.server.elicitInput({
mode: 'form',
message: 'Name?',
requestedSchema: { type: 'object', properties: { name: { type: 'string' } } }
});
return { content: [{ type: 'text', text: ans.action }] };
});
return s;
};
const client = new Client({ name: 'c', version: '0' }, { capabilities: { elicitation: {} } });
client.setRequestHandler(ElicitRequestSchema, async () => {
return { action: 'accept', content: { name: 'Test' } };
});
await using _ = await wire(transport, makeServer, client);
const r = await client.callTool({ name: 'ask', arguments: {} });
expect(r.content).toEqual([{ type: 'text', text: 'accept' }]);
});
verifies('elicitation:capability:mode-mismatch', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } });
s.setRequestHandler(ListToolsRequestSchema, () => ({ tools: [{ name: 'auth', inputSchema: { type: 'object' } }] }));
s.setRequestHandler(CallToolRequestSchema, async (_req, extra) => {
try {
await extra.sendRequest(
{
method: 'elicitation/create',
params: { mode: 'url', message: 'Sign in', elicitationId: 'mismatch-1', url: 'https://example.com/auth' }
},
ElicitResultSchema
);
return { content: [{ type: 'text', text: 'should-not-reach' }] };
} catch (e) {
if (!(e instanceof McpError)) throw e;
return { content: [{ type: 'text', text: `error:${e.code}` }] };
}
});
return s;
};
let handlerInvoked = 0;
const client = formClient();
client.setRequestHandler(ElicitRequestSchema, async () => {
handlerInvoked++;
return { action: 'accept', content: {} };
});
await using _ = await wire(transport, makeServer, client);
const r = await client.callTool({ name: 'auth', arguments: {} });
expect(r.content).toEqual([{ type: 'text', text: `error:${ErrorCode.InvalidParams}` }]);
expect(handlerInvoked).toBe(0);
});
verifies('elicitation:capability:server-respects-mode', async ({ transport }: TestArgs) => {
let reachedClient = 0;
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('auth', { inputSchema: z.object({}) }, async () => {
try {
await s.server.elicitInput({
mode: 'url',
message: 'Sign in',
elicitationId: 'gate-1',
url: 'https://example.com/auth'
});
return { isError: true, content: [{ type: 'text', text: 'SDK let it through' }] };
} catch (e) {
return { content: [{ type: 'text', text: `refused:${e instanceof Error ? e.message : e}` }] };
}
});
return s;
};
const client = formClient();
client.setRequestHandler(ElicitRequestSchema, async () => {
reachedClient++;
return { action: 'accept' };
});
await using _ = await wire(transport, makeServer, client);
const r = await client.callTool({ name: 'auth', arguments: {} });
expect(r.isError).toBeFalsy();
expect(r.content).toEqual([{ type: 'text', text: expect.stringMatching(/^refused:.*url elicitation/i) }]);
expect(reachedClient).toBe(0);
});
verifies('elicitation:capability:not-declared', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('ask-form', { inputSchema: z.object({}) }, async () => {
try {
await s.server.elicitInput({
mode: 'form',
message: 'What is your name?',
requestedSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }
});
return { isError: true, content: [{ type: 'text', text: 'SDK let the form elicitation through' }] };
} catch (e) {
return { content: [{ type: 'text', text: `refused:${e instanceof Error ? e.message : e}` }] };
}
});
s.registerTool('ask-url', { inputSchema: z.object({}) }, async () => {
try {
await s.server.elicitInput({
mode: 'url',
message: 'Please sign in',
elicitationId: 'no-cap-1',
url: 'https://example.com/auth'
});
return { isError: true, content: [{ type: 'text', text: 'SDK let the url elicitation through' }] };
} catch (e) {
return { content: [{ type: 'text', text: `refused:${e instanceof Error ? e.message : e}` }] };
}
});
return s;
};
// No elicitation capability declared at all — neither mode may reach the wire.
const client = new Client({ name: 'c', version: '0' });
await using _ = await wire(transport, makeServer, client);
const tap = tapWire(client);
const form = await client.callTool({ name: 'ask-form', arguments: {} });
expect(form.isError).toBeFalsy();
expect(form.content).toEqual([{ type: 'text', text: 'refused:Client does not support form elicitation.' }]);
const url = await client.callTool({ name: 'ask-url', arguments: {} });
expect(url.isError).toBeFalsy();
expect(url.content).toEqual([{ type: 'text', text: 'refused:Client does not support url elicitation.' }]);
expect(tap.received.filter(m => 'method' in m && m.method === 'elicitation/create')).toEqual([]);
});

View file

@ -0,0 +1,840 @@
/**
* Self-contained test bodies for composite end-to-end flows.
*
* These are longer journeys that combine multiple SDK features: multi-step
* elicitation, OAuth roundtrip, resumption, session management, proxy
* forwarding. Each builds whatever in-test pieces it needs (mock AS, minimal
* EventStore, secondary Clients, proxy McpServer delegating to an upstream
* Client) rather than importing shared fixtures.
*/
import { expect, vi } from 'vitest';
import { z } from 'zod/v4';
import { Client } from '../../../src/client/index.js';
import { type OAuthClientProvider, UnauthorizedError } from '../../../src/client/auth.js';
import { StreamableHTTPClientTransport } from '../../../src/client/streamableHttp.js';
import { Server } from '../../../src/server/index.js';
import { McpServer } from '../../../src/server/mcp.js';
import type { EventStore } from '../../../src/server/webStandardStreamableHttp.js';
import { type OAuthTokens, type OAuthClientMetadata, type OAuthClientInformationMixed } from '../../../src/shared/auth.js';
import {
type CallToolResult,
ElicitationCompleteNotificationSchema,
type ElicitRequest,
type ElicitResult,
ElicitRequestSchema,
ElicitResultSchema,
ErrorCode,
type JSONRPCMessage,
ListResourcesRequestSchema,
ListToolsRequestSchema,
type Progress,
ReadResourceRequestSchema,
UrlElicitationRequiredError
} from '../../../src/types.js';
import { hostPerSession, hostResumable, wire } from '../helpers/index.js';
import type { TestArgs } from '../types.js';
import { verifies } from '../helpers/verifies.js';
verifies('flow:elicitation:multi-step-form', async ({ transport }: TestArgs) => {
// Server: tool that issues three sequential elicitation/create requests
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('multi-step', { inputSchema: z.object({}) }, async (_args, extra) => {
const step1 = await extra.sendRequest(
{
method: 'elicitation/create',
params: {
mode: 'form',
message: 'What is your name?',
requestedSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }
}
},
ElicitResultSchema
);
if (step1.action !== 'accept' || typeof step1.content?.name !== 'string') {
return { content: [{ type: 'text', text: `aborted at step 1: ${step1.action}` }] };
}
const name = step1.content.name;
const step2 = await extra.sendRequest(
{
method: 'elicitation/create',
params: {
mode: 'form',
message: `Hello ${name}, what is your favorite color?`,
requestedSchema: { type: 'object', properties: { color: { type: 'string' } }, required: ['color'] }
}
},
ElicitResultSchema
);
if (step2.action !== 'accept' || typeof step2.content?.color !== 'string') {
return { content: [{ type: 'text', text: `aborted at step 2: ${step2.action}` }] };
}
const color = step2.content.color;
const step3 = await extra.sendRequest(
{
method: 'elicitation/create',
params: {
mode: 'form',
message: `${name}, you picked ${color}. Confirm?`,
requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] }
}
},
ElicitResultSchema
);
if (step3.action !== 'accept') {
return { content: [{ type: 'text', text: `aborted at step 3: ${step3.action}` }] };
}
return { content: [{ type: 'text', text: `${name}'s favorite color is ${color}` }] };
});
return s;
};
// Client: registers handler for elicitation/create, queues responses
const received: ElicitRequest[] = [];
const queued: ElicitResult[] = [];
const client = new Client({ name: 'c', version: '0' }, { capabilities: { elicitation: { form: {} } } });
client.setRequestHandler(ElicitRequestSchema, async req => {
received.push(req);
const resp = queued.shift();
if (!resp) throw new Error('no queued elicitation response');
return resp;
});
await using _ = await wire(transport, makeServer, client);
// Happy path: accept all three steps
queued.push({ action: 'accept', content: { name: 'Ada' } });
queued.push({ action: 'accept', content: { color: 'blue' } });
queued.push({ action: 'accept', content: { confirm: true } });
const ok = await client.callTool({ name: 'multi-step', arguments: {} });
expect(ok.isError).toBeFalsy();
expect(ok.content).toEqual([{ type: 'text', text: "Ada's favorite color is blue" }]);
expect(received).toHaveLength(3);
expect(received[0].params).toMatchObject({ mode: 'form', requestedSchema: { properties: { name: { type: 'string' } } } });
expect(received[1].params.message).toContain('Ada');
expect(received[1].params).toMatchObject({ mode: 'form', requestedSchema: { properties: { color: { type: 'string' } } } });
expect(received[2].params.message).toContain('Ada');
expect(received[2].params.message).toContain('blue');
expect(received[2].params).toMatchObject({ mode: 'form', requestedSchema: { properties: { confirm: { type: 'boolean' } } } });
// Decline at step 2
queued.push({ action: 'accept', content: { name: 'Bob' } });
queued.push({ action: 'decline' });
const declined = await client.callTool({ name: 'multi-step', arguments: {} });
expect(declined.isError).toBeFalsy();
expect(declined.content).toEqual([{ type: 'text', text: 'aborted at step 2: decline' }]);
expect(received).toHaveLength(5);
// Cancel at step 1
queued.push({ action: 'cancel' });
queued.push({ action: 'accept', content: { color: 'red' } }); // sentinel: must not be consumed
const cancelled = await client.callTool({ name: 'multi-step', arguments: {} });
expect(cancelled.isError).toBeFalsy();
expect(cancelled.content).toEqual([{ type: 'text', text: 'aborted at step 1: cancel' }]);
expect(received).toHaveLength(6);
expect(queued).toHaveLength(1); // sentinel still queued
});
verifies('flow:elicitation:url-at-session-init', async (_args: TestArgs) => {
// Not wire(): a transport.send tap must be installed before connect to prove no client request precedes the unsolicited elicitation.
// Server: issues URL-mode elicitation immediately after onsessioninitialized
const makeServer = () => {
const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } });
s.oninitialized = () => {
setTimeout(() => {
void s
.elicitInput({
mode: 'url',
message: 'Authorize the session',
url: 'https://example.com/auth',
elicitationId: 'session-init-elicit'
})
.catch(() => {});
}, 0);
};
s.setRequestHandler(ListToolsRequestSchema, () => ({ tools: [] }));
return s;
};
const received: ElicitRequest[] = [];
const client = new Client({ name: 'c', version: '0' }, { capabilities: { elicitation: { url: {} } } });
client.setRequestHandler(ElicitRequestSchema, async req => {
received.push(req);
return { action: 'accept' };
});
const handle = hostPerSession(makeServer);
const url = new URL('http://in-process/mcp');
const customFetch = (u: URL | string, init?: RequestInit) => handle.handleRequest(new Request(u, init));
const transport = new StreamableHTTPClientTransport(url, { fetch: customFetch });
// Tap wire before connecting
const sent: Array<{ method?: string }> = [];
const origSend = transport.send.bind(transport);
transport.send = async (m, opts) => {
sent.push(m as { method?: string });
return origSend(m, opts);
};
await client.connect(transport);
try {
// Wait for the unsolicited server→client elicitation/create
await vi.waitFor(() => expect(received.length).toBeGreaterThanOrEqual(1));
expect(received).toHaveLength(1);
expect(received[0].method).toBe('elicitation/create');
const params = received[0].params;
if (params.mode !== 'url') throw new Error('expected url mode');
expect(params.elicitationId).toBe('session-init-elicit');
expect(() => new URL(params.url)).not.toThrow();
// Client has only sent initialize so far (no post-init requests)
const requests = sent.filter(m => 'method' in m && 'id' in m);
expect(requests.map(r => r.method)).toEqual(['initialize']);
// Session survived
await expect(client.ping()).resolves.toBeDefined();
} finally {
await Promise.all([client.close(), handle.close()]);
}
});
verifies('flow:elicitation:url-required-then-retry', async ({ transport }: TestArgs) => {
// Server: tool that throws UrlElicitationRequiredError until elicitation is completed
const completed = new Set<string>();
let server!: McpServer;
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('url-gated', { inputSchema: z.object({}) }, () => {
const elicitationId = 'url-elicit-1';
if (!completed.has(elicitationId)) {
throw new UrlElicitationRequiredError([
{
mode: 'url',
message: 'Please sign in',
elicitationId,
url: 'https://example.com/auth'
}
]);
}
return { content: [{ type: 'text', text: 'authenticated' }] };
});
server = s;
return s;
};
const client = new Client({ name: 'c', version: '0' }, { capabilities: { elicitation: { url: {} } } });
const completionsSeen: string[] = [];
client.setNotificationHandler(ElicitationCompleteNotificationSchema, async n => {
completionsSeen.push(n.params.elicitationId);
});
await using _ = await wire(transport, makeServer, client);
// Step 1: first call rejects with UrlElicitationRequiredError
const err = await client.callTool({ name: 'url-gated', arguments: {} }).catch(e => e);
expect(err).toBeInstanceOf(UrlElicitationRequiredError);
const required = err as UrlElicitationRequiredError;
expect(required.code).toBe(ErrorCode.UrlElicitationRequired);
expect(required.elicitations).toHaveLength(1);
const elicitation = required.elicitations[0];
expect(elicitation.mode).toBe('url');
expect(typeof elicitation.elicitationId).toBe('string');
expect(elicitation.url).toMatch(/^https?:\/\//);
// Step 2: user "opens" the URL (out-of-band, simulated by marking complete)
completed.add(elicitation.elicitationId);
// Step 3: server emits notifications/elicitation/complete and the client receives it for that exact elicitation
await server.server.createElicitationCompletionNotifier(elicitation.elicitationId)();
await vi.waitFor(() => expect(completionsSeen).toEqual([elicitation.elicitationId]));
// Step 4: retry succeeds
const result = await client.callTool({ name: 'url-gated', arguments: {} });
expect(result.isError).toBeFalsy();
expect(result.content).toEqual([{ type: 'text', text: 'authenticated' }]);
});
verifies('flow:multi-client:stateful-isolation', async (_args: TestArgs) => {
// Not wire(): three clients share one host and the test reads each transport's sessionId; wire() binds a single client to its own host.
// Server: per-session McpServer with progress tool
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('progress', { inputSchema: z.object({ steps: z.number().int().positive() }) }, async ({ steps }, extra) => {
const token = extra._meta?.progressToken;
if (token !== undefined) {
for (let i = 1; i <= steps; i++) {
await extra.sendNotification({
method: 'notifications/progress',
params: { progressToken: token, progress: i, total: steps, message: `step ${i}/${steps}` }
});
}
}
return { content: [{ type: 'text', text: `done after ${steps} steps` }] };
});
return s;
};
// Three clients connect to the same per-session host
const clientA = new Client({ name: 'a', version: '0' });
const clientB = new Client({ name: 'b', version: '0' });
const clientC = new Client({ name: 'c', version: '0' });
const handle = hostPerSession(makeServer);
const url = new URL('http://in-process/mcp');
const customFetch = (u: URL | string, init?: RequestInit) => handle.handleRequest(new Request(u, init));
await clientA.connect(new StreamableHTTPClientTransport(url, { fetch: customFetch }));
await clientB.connect(new StreamableHTTPClientTransport(url, { fetch: customFetch }));
await clientC.connect(new StreamableHTTPClientTransport(url, { fetch: customFetch }));
try {
const clients = [clientA, clientB, clientC] as const;
const sessionIds = clients.map(c => (c.transport as StreamableHTTPClientTransport).sessionId);
for (const id of sessionIds) {
expect(id).toEqual(expect.any(String));
}
expect(new Set(sessionIds).size).toBe(3);
// Concurrent progress calls with distinct step counts
const STEPS = [2, 3, 4] as const;
const errors: Error[][] = [[], [], []];
clients.forEach((cl, i) => {
cl.onerror = e => errors[i].push(e);
});
const runs = await Promise.all(
clients.map(async (cl, i) => {
const received: Progress[] = [];
const result = await cl.callTool({ name: 'progress', arguments: { steps: STEPS[i] } }, undefined, {
onprogress: p => received.push({ progress: p.progress, total: p.total, message: p.message })
});
return { received, result };
})
);
for (let i = 0; i < 3; i++) {
const steps = STEPS[i];
const { received, result } = runs[i];
expect(received).toEqual(
Array.from({ length: steps }, (_, k) => ({
progress: k + 1,
total: steps,
message: `step ${k + 1}/${steps}`
}))
);
expect(result.content).toEqual([{ type: 'text', text: `done after ${steps} steps` }]);
}
for (const errs of errors) {
expect(errs).toEqual([]);
}
} finally {
await Promise.all([clientA.close(), clientB.close(), clientC.close(), handle.close()]);
}
});
verifies('flow:oauth:authorization-code-roundtrip', async (_args: TestArgs) => {
// Not wire(): needs an authProvider-equipped client transport plus 401/PRM/AS fetch routing in front of the host, which wire() does not expose.
const ACCESS_TOKEN = 'roundtrip-access-token';
const AUTH_CODE = 'granted-authorization-code';
const AS_ORIGIN = 'https://as.example.com';
const PRM_PATH = '/.well-known/oauth-protected-resource';
// Mock Authorization Server: metadata discovery, dynamic client registration, code exchange.
const registerRequests: Array<Record<string, unknown>> = [];
const tokenRequests: URLSearchParams[] = [];
const mockASFetch = async (url: URL | string, init?: RequestInit): Promise<Response> => {
const req = new Request(url, init);
const u = new URL(req.url);
if (u.pathname === '/.well-known/oauth-authorization-server') {
return new Response(
JSON.stringify({
issuer: AS_ORIGIN,
authorization_endpoint: `${AS_ORIGIN}/authorize`,
token_endpoint: `${AS_ORIGIN}/token`,
registration_endpoint: `${AS_ORIGIN}/register`,
grant_types_supported: ['authorization_code'],
response_types_supported: ['code'],
code_challenge_methods_supported: ['S256']
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
}
if (u.pathname === '/register' && req.method === 'POST') {
const body: Record<string, unknown> = await req.json();
registerRequests.push(body);
// RFC 7591: the registration response echoes the submitted metadata plus the issued client_id.
return new Response(JSON.stringify({ ...body, client_id: 'mock-client-id' }), {
status: 201,
headers: { 'Content-Type': 'application/json' }
});
}
if (u.pathname === '/token' && req.method === 'POST') {
tokenRequests.push(new URLSearchParams(await req.text()));
return new Response(JSON.stringify({ access_token: ACCESS_TOKEN, token_type: 'Bearer', expires_in: 3600 }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
return new Response('Not Found', { status: 404 });
};
// Mock OAuthClientProvider
const redirectedTo: string[] = [];
const saved: { tokens?: OAuthTokens; clientInfo?: OAuthClientInformationMixed; codeVerifier?: string } = {};
const provider: OAuthClientProvider = {
get redirectUrl() {
return 'http://localhost/callback';
},
get clientMetadata(): OAuthClientMetadata {
return {
client_name: 'test-client',
client_uri: 'http://localhost',
redirect_uris: ['http://localhost/callback']
};
},
clientInformation: () => saved.clientInfo,
saveClientInformation: async ci => {
saved.clientInfo = ci;
},
tokens: () => saved.tokens,
saveTokens: async t => {
saved.tokens = t;
},
codeVerifier: () => {
if (!saved.codeVerifier) throw new Error('No code verifier saved');
return saved.codeVerifier;
},
saveCodeVerifier: async v => {
saved.codeVerifier = v;
},
redirectToAuthorization: async url => {
redirectedTo.push(url.toString());
}
};
// Server with bearer auth
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;
};
// Protected host: serves RFC 9728 resource metadata, otherwise requires the issued bearer token.
const handle = hostPerSession(makeServer);
const url = new URL('http://in-process/mcp');
const serverFetch = async (u: URL | string, init?: RequestInit): Promise<Response> => {
const req = new Request(u, init);
const requestUrl = new URL(req.url);
if (requestUrl.pathname.startsWith(PRM_PATH)) {
return new Response(JSON.stringify({ resource: url.toString(), authorization_servers: [AS_ORIGIN] }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
const auth = req.headers.get('authorization');
if (auth !== `Bearer ${ACCESS_TOKEN}`) {
return new Response(null, {
status: 401,
headers: { 'WWW-Authenticate': `Bearer resource_metadata="http://in-process${PRM_PATH}/mcp"` }
});
}
return handle.handleRequest(req);
};
const combinedFetch = async (u: URL | string, init?: RequestInit): Promise<Response> => {
const requestUrl = typeof u === 'string' ? new URL(u) : u;
if (requestUrl.hostname === 'as.example.com') return mockASFetch(requestUrl, init);
return serverFetch(requestUrl, init);
};
const client = new Client({ name: 'c', version: '0' });
try {
// Step 1: first connect fails with 401 → discovery + DCR + redirect to the authorization endpoint
const transport1 = new StreamableHTTPClientTransport(url, { authProvider: provider, fetch: combinedFetch });
await expect(client.connect(transport1)).rejects.toBeInstanceOf(UnauthorizedError);
expect(redirectedTo).toHaveLength(1);
const authorizeUrl = new URL(redirectedTo[0]);
expect(authorizeUrl.origin).toBe(AS_ORIGIN);
expect(authorizeUrl.pathname).toBe('/authorize');
expect(authorizeUrl.searchParams.get('client_id')).toBe('mock-client-id');
expect(authorizeUrl.searchParams.get('code_challenge')).toBeTruthy();
expect(registerRequests).toHaveLength(1);
expect(saved.tokens).toBeUndefined();
const codeVerifier = saved.codeVerifier;
if (!codeVerifier) throw new Error('No code verifier saved during the redirect step');
// Step 2: user completes redirect, finishAuth exchanges the code for tokens
await transport1.finishAuth(AUTH_CODE);
expect(tokenRequests).toHaveLength(1);
expect(tokenRequests[0].get('grant_type')).toBe('authorization_code');
expect(tokenRequests[0].get('code')).toBe(AUTH_CODE);
expect(tokenRequests[0].get('code_verifier')).toBe(codeVerifier);
expect(tokenRequests[0].get('redirect_uri')).toBe('http://localhost/callback');
expect(saved.tokens?.access_token).toBe(ACCESS_TOKEN);
// Step 3: second connect with fresh transport succeeds and tools/list works
const transport2 = new StreamableHTTPClientTransport(url, { authProvider: provider, fetch: combinedFetch });
await client.connect(transport2);
const { tools } = await client.listTools();
expect(tools.some(t => t.name === 'echo')).toBe(true);
} finally {
await Promise.all([client.close(), handle.close()]);
}
});
verifies('flow:resume:tool-call-resumption-token', async (_args: TestArgs) => {
// Not wire(): needs an EventStore-backed host and a severable fetch to simulate a mid-stream disconnect, which wire() does not expose.
const TOTAL_STEPS = 4;
const DELIVERED_BEFORE_DISCONNECT = 2;
// The tool pauses after the first two progress notifications so the test can sever the call's SSE
// stream before the remaining notifications and the result are produced.
let releaseRemainingSteps!: () => void;
const remainingStepsReleased = new Promise<void>(resolve => {
releaseRemainingSteps = resolve;
});
let toolRuns = 0;
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('import-records', { inputSchema: z.object({ steps: z.number().int().positive() }) }, async ({ steps }, extra) => {
toolRuns++;
const token = extra._meta?.progressToken;
if (token === undefined) throw new Error('import-records expects a progressToken');
for (let i = 1; i <= steps; i++) {
if (i === DELIVERED_BEFORE_DISCONNECT + 1) {
await remainingStepsReleased;
}
await extra.sendNotification({
method: 'notifications/progress',
params: { progressToken: token, progress: i, total: steps }
});
}
return { content: [{ type: 'text', text: `imported ${steps} record batches` }] };
});
return s;
};
// Insertion-ordered EventStore: replay order (and therefore the exact-sequence assertions below) must not depend on timestamps.
const storedEvents: Array<{ eventId: string; streamId: string; message: JSONRPCMessage }> = [];
const eventStore: EventStore = {
storeEvent: async (streamId, message) => {
const eventId = `${streamId}|${storedEvents.length + 1}`;
storedEvents.push({ eventId, streamId, message });
return eventId;
},
replayEventsAfter: async (lastEventId, { send }) => {
const lastIndex = storedEvents.findIndex(e => e.eventId === lastEventId);
if (lastIndex === -1) return '';
const { streamId } = storedEvents[lastIndex];
for (const event of storedEvents.slice(lastIndex + 1)) {
if (event.streamId === streamId) await send(event.eventId, event.message);
}
return streamId;
}
};
const handle = hostResumable(makeServer, { eventStore, retryInterval: 0 });
const url = new URL('http://in-process/mcp');
const requests: Array<{ method: string; lastEventId: string | undefined; sessionId: string | undefined; body: string | undefined }> =
[];
// The tools/call response is wrapped so the test can sever the stream the client is reading (a simulated
// network drop); the server keeps writing the rest of the stream, which only the EventStore retains.
let severToolCallStream: () => void = () => {
throw new Error('tools/call SSE stream not opened yet');
};
let originalStreamFinished!: () => void;
const originalStreamComplete = new Promise<void>(resolve => {
originalStreamFinished = resolve;
});
const customFetch = async (u: URL | string, init?: RequestInit): Promise<Response> => {
const request = new Request(u, init);
const lastEventId = request.headers.get('last-event-id') ?? undefined;
const body = typeof init?.body === 'string' ? init.body : undefined;
requests.push({ method: request.method, lastEventId, sessionId: request.headers.get('mcp-session-id') ?? undefined, body });
// Hold the transport's recovery GET until the server has finished the interrupted stream, so the
// replay deterministically contains every message the client missed.
if (request.method === 'GET' && lastEventId !== undefined) {
await originalStreamComplete;
}
const response = await handle.handleRequest(request);
if (request.method !== 'POST' || !body?.includes('"tools/call"') || !response.body) {
return response;
}
const upstream = response.body.getReader();
let severed = false;
const severable = new ReadableStream<Uint8Array>({
start: controller => {
severToolCallStream = () => {
severed = true;
controller.error(new Error('simulated mid-stream network drop'));
};
void (async () => {
while (true) {
const { value, done } = await upstream.read();
if (done) break;
if (!severed && value) controller.enqueue(value);
}
originalStreamFinished();
if (!severed) controller.close();
})();
}
});
return new Response(severable, { status: response.status, headers: response.headers });
};
const client = new Client({ name: 'c', version: '0' });
const transport = new StreamableHTTPClientTransport(url, {
fetch: customFetch,
reconnectionOptions: { initialReconnectionDelay: 10, maxReconnectionDelay: 10, reconnectionDelayGrowFactor: 1, maxRetries: 2 }
});
await client.connect(transport);
try {
const progressSeen: number[] = [];
const eventIdsSeen: string[] = [];
const callSettled = vi.fn();
// The one and only tools/call of this test: this same promise must resolve after the disconnect.
const call = client.callTool({ name: 'import-records', arguments: { steps: TOTAL_STEPS } }, undefined, {
onprogress: (p: Progress) => progressSeen.push(p.progress),
onresumptiontoken: id => eventIdsSeen.push(id)
});
call.then(callSettled, callSettled);
await vi.waitFor(() => expect(progressSeen).toEqual([1, 2]));
const lastEventIdBeforeDisconnect = eventIdsSeen[eventIdsSeen.length - 1];
// Sever mid-call, then let the server finish steps 3..4 and the result while the client is disconnected.
severToolCallStream();
expect(callSettled).not.toHaveBeenCalled();
releaseRemainingSteps();
await originalStreamComplete;
expect(progressSeen).toEqual([1, 2]);
expect(callSettled).not.toHaveBeenCalled();
// The original promise resolves with the tool's result; the handler ran exactly once.
const result = await call;
expect(result.isError).toBeFalsy();
expect(result.content).toEqual([{ type: 'text', text: `imported ${TOTAL_STEPS} record batches` }]);
expect(toolRuns).toBe(1);
// Recovery delivered only the missed notifications: no duplicates, none missing.
expect(progressSeen).toEqual([1, 2, 3, 4]);
const lastEventBeforeDisconnect = storedEvents.find(e => e.eventId === lastEventIdBeforeDisconnect);
if (!lastEventBeforeDisconnect) throw new Error('the last event id delivered before the disconnect was never stored');
const toolCallStreamEvents = storedEvents.filter(e => e.streamId === lastEventBeforeDisconnect.streamId);
// The whole stream was persisted (priming placeholder, four progress notifications, result), and the
// client saw each of its events exactly once, in order, across the original stream and the replay.
expect(
toolCallStreamEvents.map(e => ('method' in e.message ? e.message.method : 'result' in e.message ? 'response' : 'priming'))
).toEqual([
'priming',
'notifications/progress',
'notifications/progress',
'notifications/progress',
'notifications/progress',
'response'
]);
expect(eventIdsSeen).toEqual(toolCallStreamEvents.map(e => e.eventId));
// Index DELIVERED_BEFORE_DISCONNECT skips the priming event, landing on the second progress notification.
expect(lastEventIdBeforeDisconnect).toBe(toolCallStreamEvents[DELIVERED_BEFORE_DISCONNECT].eventId);
// Transparency: the caller never re-issued the call; the transport recovered with a single GET
// carrying Last-Event-ID for the last event delivered before the disconnect, on the same session.
expect(requests.filter(r => r.method === 'POST' && r.body?.includes('"tools/call"'))).toHaveLength(1);
const recoveryGets = requests.filter(r => r.method === 'GET' && r.lastEventId !== undefined);
expect(recoveryGets).toHaveLength(1);
expect(recoveryGets[0].lastEventId).toBe(lastEventIdBeforeDisconnect);
expect(transport.sessionId).toEqual(expect.any(String));
expect(recoveryGets[0].sessionId).toBe(transport.sessionId);
} finally {
await Promise.all([client.close(), handle.close()]);
}
});
verifies('flow:session:terminate-then-reconnect', async (_args: TestArgs) => {
// Not wire(): drives StreamableHTTPClientTransport directly for sessionId/terminateSession() and connects a second transport to the same host.
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 handle = hostPerSession(makeServer);
const url = new URL('http://in-process/mcp');
const customFetch = (u: URL | string, init?: RequestInit) => handle.handleRequest(new Request(u, init));
const client1 = new Client({ name: 'c', version: '0' });
const transport1 = new StreamableHTTPClientTransport(url, { fetch: customFetch });
await client1.connect(transport1);
try {
const originalSessionId = transport1.sessionId;
expect(originalSessionId).toEqual(expect.any(String));
// Terminate session
await transport1.terminateSession();
expect(transport1.sessionId).toBeUndefined();
// Close first client
await client1.close();
// Fresh client and transport obtain new session
const client2 = new Client({ name: 'c', version: '0' });
const transport2 = new StreamableHTTPClientTransport(url, { fetch: customFetch });
await client2.connect(transport2);
const newSessionId = transport2.sessionId;
expect(newSessionId).toEqual(expect.any(String));
expect(newSessionId).not.toBe(originalSessionId);
// Operations succeed on new session
const result = await client2.callTool({ name: 'echo', arguments: { text: 'after-reconnect' } });
expect(result.isError).toBeFalsy();
expect(result.content).toEqual([{ type: 'text', text: 'after-reconnect' }]);
await client2.close();
} finally {
await handle.close();
}
});
verifies('flow:tool-result:resource-link-follow', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('resource-link', { inputSchema: z.object({}) }, () => ({
content: [
{
type: 'resource_link',
uri: 'file:///linked.txt',
name: 'linked.txt',
mimeType: 'text/plain'
}
]
}));
s.registerResource('linked.txt', 'file:///linked.txt', { mimeType: 'text/plain' }, async () => ({
contents: [{ uri: 'file:///linked.txt', mimeType: 'text/plain', text: 'linked resource contents' }]
}));
return s;
};
const client = new Client({ name: 'c', version: '0' });
await using _ = await wire(transport, makeServer, client);
// Call tool and get resource_link
const toolResult = (await client.callTool({ name: 'resource-link', arguments: {} })) as CallToolResult;
expect(toolResult.isError).toBeFalsy();
const link = toolResult.content.find(c => c.type === 'resource_link');
expect(link).toBeDefined();
if (link?.type !== 'resource_link') throw new Error('unreachable');
expect(link.uri).toBe('file:///linked.txt');
// Follow the link with resources/read
const readResult = await client.readResource({ uri: link.uri });
expect(readResult.contents).toHaveLength(1);
const [entry] = readResult.contents;
expect(entry.uri).toBe(link.uri);
expect(entry.mimeType).toBe('text/plain');
if (entry.mimeType?.startsWith('text/')) {
expect('text' in entry ? entry.text : '').toBe('linked resource contents');
}
});
verifies('flow:proxy:forward-tools-resources', async ({ transport }: TestArgs) => {
// Upstream server with tools and resources
const upstreamServer = new McpServer({ name: 'upstream', version: '0' });
upstreamServer.registerTool('echo', { description: 'Echoes text', inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({
content: [{ type: 'text', text }]
}));
upstreamServer.registerTool(
'annotated',
{
description: 'Annotated tool',
inputSchema: z.object({}),
annotations: { readOnlyHint: true, idempotentHint: true },
_meta: { 'example.com/fixture': true }
},
() => ({ content: [] })
);
upstreamServer.registerResource(
'annotated',
'file:///annotated.md',
{
mimeType: 'text/markdown',
_meta: { 'example.com/fixture': true }
},
async () => ({
contents: [{ uri: 'file:///annotated.md', mimeType: 'text/markdown', text: '# Annotated' }]
})
);
// Proxy: low-level Server downstream + Client upstream
const upstreamClient = new Client({ name: 'proxy-upstream', version: '0' });
const proxyServer = new Server({ name: 'proxy', version: '0' }, { capabilities: { tools: {}, resources: {} } });
proxyServer.setRequestHandler(ListToolsRequestSchema, async req => {
const result = await upstreamClient.listTools(req.params);
return { tools: result.tools, nextCursor: result.nextCursor };
});
proxyServer.setRequestHandler(ListResourcesRequestSchema, async req => {
const result = await upstreamClient.listResources(req.params);
return { resources: result.resources, nextCursor: result.nextCursor };
});
proxyServer.setRequestHandler(ReadResourceRequestSchema, async req => {
return upstreamClient.readResource(req.params);
});
// Wire: downstream client → proxy server → upstream client → upstream server
const downstreamClient = new Client({ name: 'downstream', version: '0' });
await using _upstreamW = await wire(transport, () => upstreamServer, upstreamClient);
await using _proxyW = await wire(transport, () => proxyServer, downstreamClient);
// Downstream sees upstream tools
const { tools } = await downstreamClient.listTools();
const echo = tools.find(t => t.name === 'echo');
expect(echo).toBeDefined();
expect(echo!.description).toBe('Echoes text');
expect(echo!.inputSchema.properties).toMatchObject({ text: { type: 'string' } });
const annotatedTool = tools.find(t => t.name === 'annotated');
expect(annotatedTool).toBeDefined();
expect(annotatedTool!.annotations).toMatchObject({ readOnlyHint: true, idempotentHint: true });
expect(annotatedTool!._meta).toEqual({ 'example.com/fixture': true });
// Downstream sees upstream resources
const { resources } = await downstreamClient.listResources();
const annotatedRes = resources.find(r => r.uri === 'file:///annotated.md');
expect(annotatedRes).toBeDefined();
expect(annotatedRes!.name).toBe('annotated');
expect(annotatedRes!._meta).toEqual({ 'example.com/fixture': true });
});

View file

@ -0,0 +1,140 @@
/**
* Self-contained test bodies for server-side bearer auth on StreamableHTTP
* hosting.
*
* The SDK ships its bearer-auth enforcement (`requireBearerAuth`), AS router
* (`mcpAuthRouter`) and discovery-metadata router (`mcpAuthMetadataRouter`)
* as Express `RequestHandler`s only — they consume Express `req`/`res`, so
* they cannot be driven in-process with Web-standard Request/Response the way
* this suite hosts StreamableHTTP. The requirements those components alone
* satisfy (401/403 challenges, audience validation, AS endpoint mounting,
* `.well-known` publication) are therefore not covered here: re-implementing
* that behavior in a test wrapper and asserting on the wrapper would prove
* nothing about the SDK. What this file does cover is the hosting-integration
* path the Web-standard transport supports natively: a user-shaped bearer
* gate verifies the Authorization header, then delegates to
* `WebStandardStreamableHTTPServerTransport.handleRequest(req, { authInfo })`,
* and the SDK must surface that verified AuthInfo to tool handlers as
* `extra.authInfo`.
*
* Function names mirror the requirement id in camelCase. Bodies are
* streamableHttp-only and host the transport themselves (the gate has to sit
* between the client's fetch and the transport), so `TestArgs.transport` is
* ignored.
*/
import { randomUUID } from 'node:crypto';
import { expect } from 'vitest';
import { z } from 'zod/v4';
import { Client } from '../../../src/client/index.js';
import { StreamableHTTPClientTransport } from '../../../src/client/streamableHttp.js';
import type { AuthInfo } from '../../../src/server/auth/types.js';
import { McpServer } from '../../../src/server/mcp.js';
import { WebStandardStreamableHTTPServerTransport } from '../../../src/server/webStandardStreamableHttp.js';
import type { TestArgs } from '../types.js';
import { verifies } from '../helpers/verifies.js';
const VALID_TOKEN = 'analytics-dashboard-access-token';
verifies('hosting:auth:authinfo-propagates', async (_args: TestArgs) => {
const expiresAt = Math.floor(Date.now() / 1000) + 3600;
// What the user's verifier derives from VALID_TOKEN; a fresh object is built
// per request so the handler-side assertion checks delivery, not identity.
const verifyBearer = (header: string | null): AuthInfo | undefined =>
header === `Bearer ${VALID_TOKEN}`
? {
token: VALID_TOKEN,
clientId: 'analytics-dashboard',
scopes: ['mcp:tools:read', 'mcp:tools:call'],
expiresAt,
extra: { userId: 'user-42' }
}
: undefined;
// Recorders live outside the per-session factory.
const seenByTool: Array<AuthInfo | undefined> = [];
const postAuthHeaders: Array<string | null> = [];
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool(
'whoami',
{ description: 'Reports the authenticated caller derived from extra.authInfo.', inputSchema: z.object({}) },
(_a, extra) => {
seenByTool.push(extra.authInfo);
return {
content: [
{
type: 'text',
text: extra.authInfo ? `${extra.authInfo.clientId} [${extra.authInfo.scopes.join(' ')}]` : 'no-auth-info'
}
]
};
}
);
return s;
};
// User-shaped hosting: a bearer gate verifies the Authorization header and
// hands the verified AuthInfo to the SDK transport via handleRequest options.
const sessions = new Map<string, WebStandardStreamableHTTPServerTransport>();
const handleRequest = async (req: Request): Promise<Response> => {
if (req.method === 'POST') postAuthHeaders.push(req.headers.get('authorization'));
const authInfo = verifyBearer(req.headers.get('authorization'));
if (!authInfo) {
return new Response(JSON.stringify({ error: 'invalid_token' }), {
status: 401,
headers: { 'content-type': 'application/json', 'www-authenticate': 'Bearer error="invalid_token"' }
});
}
const sid = req.headers.get('mcp-session-id') ?? undefined;
const existing = sid ? sessions.get(sid) : undefined;
if (existing) return existing.handleRequest(req, { authInfo });
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, { authInfo });
};
const client = new Client({ name: 'c', version: '0' });
await using _ = {
[Symbol.asyncDispose]: async () => {
await client.close();
for (const t of sessions.values()) await t.close();
}
};
await client.connect(
new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), {
fetch: (url, init) => handleRequest(new Request(url, init)),
requestInit: { headers: { Authorization: `Bearer ${VALID_TOKEN}` } }
})
);
const r = await client.callTool({ name: 'whoami', arguments: {} });
expect(r.isError).toBeFalsy();
expect(r.content).toEqual([{ type: 'text', text: 'analytics-dashboard [mcp:tools:read mcp:tools:call]' }]);
// The SDK transport delivered the verified AuthInfo into the tool handler's
// RequestHandlerExtra unchanged — not dropped, not replaced by a placeholder.
expect(seenByTool).toHaveLength(1);
expect(seenByTool[0]).toEqual({
token: VALID_TOKEN,
clientId: 'analytics-dashboard',
scopes: ['mcp:tools:read', 'mcp:tools:call'],
expiresAt,
extra: { userId: 'user-42' }
});
// Sanity: the bearer was on the wire for every POST the SDK client sent
// (initialize, notifications/initialized, tools/call).
expect(postAuthHeaders).toEqual([`Bearer ${VALID_TOKEN}`, `Bearer ${VALID_TOKEN}`, `Bearer ${VALID_TOKEN}`]);
});

View file

@ -0,0 +1,780 @@
/**
* Self-contained test bodies for Express-bound hosting surfaces.
*
* These tests cover the SDK's Express-bound surface (auth middleware/routers, host
* validation, createMcpExpressApp) over real HTTP — the layer a server operator
* deploys and remote clients depend on; Client/Server are not the subject.
*
* The SDK's requireBearerAuth, mcpAuthRouter, mcpAuthMetadataRouter, and host-
* header validation middleware are Express RequestHandlers; they cannot be
* exercised with in-process Web-standard Request/Response. These tests build
* real Express apps, listen on ephemeral ports (127.0.0.1), drive them with
* fetch(), and assert exact HTTP status + header + body shapes.
*
* Function names mirror the requirement id in camelCase. NO casts, exact
* assertions, closure recorders outside factories (for stateless compat),
* minimal comments, every server closed in finally.
*/
import crypto from 'node:crypto';
import http from 'node:http';
import express, { type RequestHandler } from 'express';
import { expect } from 'vitest';
import { requireBearerAuth } from '../../../src/server/auth/middleware/bearerAuth.js';
import { mcpAuthRouter, mcpAuthMetadataRouter, createOAuthMetadata } from '../../../src/server/auth/router.js';
import { ProxyOAuthServerProvider } from '../../../src/server/auth/providers/proxyProvider.js';
import type { OAuthServerProvider } from '../../../src/server/auth/provider.js';
import { InvalidGrantError, InvalidTokenError } from '../../../src/server/auth/errors.js';
import { createMcpExpressApp } from '../../../src/server/express.js';
import type { OAuthClientInformationFull } from '../../../src/shared/auth.js';
import type { TestArgs } from '../types.js';
import { startExpressMinimal, startExpressWithHostValidation, type ExpressHost } from '../helpers/express.js';
import { verifies } from '../helpers/verifies.js';
const RESOURCE_METADATA_URL = 'https://mcp.example.com/.well-known/oauth-protected-resource';
const VALID_TOKEN = 'analytics-dashboard-token';
const EXPIRED_TOKEN = 'expired-access-token';
const MALFORMED_TOKEN = 'not-a-valid-jwt';
/**
* POST `body` to `url` via `node:http`, forcing `Host: <host>`.
* Unlike undici fetch(), node:http sends caller-supplied Host header verbatim.
*/
function postWithHost(url: URL, host: string, body: string): Promise<{ status: number; body: string; headers: http.IncomingHttpHeaders }> {
return new Promise((resolve, reject) => {
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: 'POST',
headers: {
Host: host,
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'Content-Length': Buffer.byteLength(body)
}
},
res => {
let data = '';
res.setEncoding('utf8');
res.on('data', chunk => (data += chunk));
res.on('end', () => resolve({ status: res.statusCode ?? 0, body: data, headers: res.headers }));
res.on('error', reject);
}
);
req.on('error', reject);
req.end(body);
});
}
verifies('hosting:auth:missing-401', async (_args: TestArgs) => {
const verifier = { verifyAccessToken: async (_token: string) => ({ token: '', clientId: 'test', scopes: [], expiresAt: 1e12 }) };
await using host = await startExpressMinimal(requireBearerAuth({ verifier, resourceMetadataUrl: RESOURCE_METADATA_URL }));
const res = await fetch(new URL('/mcp', host.baseUrl), {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
});
expect(res.status).toBe(401);
const wwwAuth = res.headers.get('www-authenticate');
expect(wwwAuth).toBeTruthy();
expect(wwwAuth).toMatch(/^Bearer\b/i);
expect(wwwAuth).toContain('error="invalid_token"');
expect(wwwAuth).toContain(`resource_metadata="${RESOURCE_METADATA_URL}"`);
const body = (await res.json()) as { error?: string };
expect(body.error).toBe('invalid_token');
});
verifies('hosting:auth:invalid-401', async (_args: TestArgs) => {
const verifier = {
verifyAccessToken: async (token: string) => {
if (token === MALFORMED_TOKEN) throw new InvalidTokenError('Token verification failed');
return { token, clientId: 'test', scopes: [], expiresAt: 1e12 };
}
};
await using host = await startExpressMinimal(requireBearerAuth({ verifier }));
const res = await fetch(new URL('/mcp', host.baseUrl), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: `Bearer ${MALFORMED_TOKEN}`
},
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
});
expect(res.status).toBe(401);
const wwwAuth = res.headers.get('www-authenticate');
expect(wwwAuth).toBeTruthy();
expect(wwwAuth).toMatch(/^Bearer\b/i);
expect(wwwAuth).toContain('error="invalid_token"');
});
verifies('hosting:auth:expired-401', async (_args: TestArgs) => {
const PAST_EXPIRY = 1;
const verifier = {
verifyAccessToken: async (token: string) => ({
token,
clientId: 'test-client',
scopes: [],
expiresAt: token === EXPIRED_TOKEN ? PAST_EXPIRY : Date.now() / 1000 + 3600
})
};
await using host = await startExpressMinimal(requireBearerAuth({ verifier }));
const res = await fetch(new URL('/mcp', host.baseUrl), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: `Bearer ${EXPIRED_TOKEN}`
},
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
});
expect(res.status).toBe(401);
const wwwAuth = res.headers.get('www-authenticate');
expect(wwwAuth).toBeTruthy();
expect(wwwAuth).toContain('error="invalid_token"');
});
verifies('hosting:auth:scope-403', async (_args: TestArgs) => {
const verifier = {
verifyAccessToken: async (token: string) => ({
token,
clientId: 'test-client',
scopes: token === VALID_TOKEN ? ['mcp:tools:read'] : ['mcp:tools:call'],
expiresAt: Date.now() / 1000 + 3600
})
};
await using host = await startExpressMinimal(
requireBearerAuth({
verifier,
requiredScopes: ['mcp:tools:read', 'mcp:tools:call'],
resourceMetadataUrl: RESOURCE_METADATA_URL
})
);
const res = await fetch(new URL('/mcp', host.baseUrl), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: `Bearer ${VALID_TOKEN}`
},
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
});
expect(res.status).toBe(403);
const wwwAuth = res.headers.get('www-authenticate');
expect(wwwAuth).toBeTruthy();
expect(wwwAuth).toMatch(/^Bearer\b/i);
expect(wwwAuth).toContain('error="insufficient_scope"');
expect(wwwAuth).toContain('scope="mcp:tools:read mcp:tools:call"');
expect(wwwAuth).toContain(`resource_metadata="${RESOURCE_METADATA_URL}"`);
const body = (await res.json()) as { error?: string };
expect(body.error).toBe('insufficient_scope');
});
verifies('hosting:auth:aud-validation', async (_args: TestArgs) => {
const SERVER_RESOURCE_ID = 'https://mcp.example.com/api';
const WRONG_AUDIENCE = 'https://other.example.com/api';
const verifier = {
verifyAccessToken: async (token: string) => {
const aud = token === 'wrong-aud-token' ? WRONG_AUDIENCE : SERVER_RESOURCE_ID;
return {
token,
clientId: 'test-client',
scopes: [],
expiresAt: Date.now() / 1000 + 3600,
resource: new URL(aud)
};
}
};
const app = express();
app.use(express.json());
app.use(requireBearerAuth({ verifier, resourceMetadataUrl: SERVER_RESOURCE_ID }));
app.post('/mcp', (_req, res) => {
res.json({ jsonrpc: '2.0', id: 1, result: { ok: true } });
});
await using host = await startExpressMinimal(app);
const wrongAud = await fetch(new URL('/mcp', host.baseUrl), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: 'Bearer wrong-aud-token'
},
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
});
expect(wrongAud.status).toBeGreaterThanOrEqual(401);
expect(wrongAud.status).toBeLessThanOrEqual(403);
const wwwAuth = wrongAud.headers.get('www-authenticate');
expect(wwwAuth).toBeTruthy();
expect(wwwAuth).toMatch(/^Bearer\b/i);
expect(wwwAuth).toContain('error=');
const body = (await wrongAud.json()) as { error?: string };
expect(body.error).toBeTruthy();
});
verifies('hosting:auth:metadata-endpoints', async (_args: TestArgs) => {
const issuer = new URL('https://auth.example.com');
const provider = {
authorize: async () => {
throw new Error('not needed');
},
challengeForAuthorizationCode: async () => 'test-challenge',
exchangeAuthorizationCode: async () => ({ access_token: 'test-token', token_type: 'Bearer' }),
exchangeRefreshToken: async () => ({ access_token: 'test-token', token_type: 'Bearer' }),
verifyAccessToken: async () => ({ token: '', clientId: '', scopes: [], expiresAt: 1e12 }),
clientsStore: { getClient: async () => undefined }
};
const app = express();
app.use(express.json());
app.use(
mcpAuthRouter({
provider,
issuerUrl: issuer
})
);
await using host = await startExpressMinimal(app);
const asMetadata = await fetch(new URL('/.well-known/oauth-authorization-server', host.baseUrl));
expect(asMetadata.status).toBe(200);
const asBody = (await asMetadata.json()) as { issuer?: string; authorization_endpoint?: string };
expect(asBody.issuer).toBe(issuer.href);
expect(asBody.authorization_endpoint).toBeTruthy();
const prmMetadata = await fetch(new URL('/.well-known/oauth-protected-resource', host.baseUrl));
expect(prmMetadata.status).toBe(200);
const prmBody = (await prmMetadata.json()) as { resource?: string; authorization_servers?: string[] };
expect(prmBody.authorization_servers).toContain(issuer.href);
});
verifies('hosting:auth:prm:authorization-servers-field', async (_args: TestArgs) => {
const issuer = new URL('https://auth.example.com');
const oauthMetadata = createOAuthMetadata({
provider: {
authorize: async () => {
throw new Error('stub');
},
challengeForAuthorizationCode: async () => 'test',
exchangeAuthorizationCode: async () => ({ access_token: 'test', token_type: 'Bearer' }),
exchangeRefreshToken: async () => ({ access_token: 'test', token_type: 'Bearer' }),
verifyAccessToken: async () => ({ token: '', clientId: '', scopes: [], expiresAt: 1e12 }),
clientsStore: { getClient: async () => undefined }
},
issuerUrl: issuer
});
const app = express();
app.use(
mcpAuthMetadataRouter({
oauthMetadata,
resourceServerUrl: new URL('https://mcp.example.com')
})
);
await using host = await startExpressMinimal(app);
const res = await fetch(new URL('/.well-known/oauth-protected-resource', host.baseUrl));
expect(res.status).toBe(200);
const body = (await res.json()) as { authorization_servers?: string[] };
expect(body.authorization_servers).toBeInstanceOf(Array);
expect(body.authorization_servers?.length).toBeGreaterThan(0);
expect(body.authorization_servers).toContain(issuer.href);
});
verifies('hosting:auth:as-router', async (_args: TestArgs) => {
const issuer = new URL('https://auth.example.com');
const provider: OAuthServerProvider = {
authorize: async (_client, _params, res) => {
res.redirect(302, 'https://example.com/callback?code=test-code&state=test');
},
challengeForAuthorizationCode: async () => 'test-challenge',
exchangeAuthorizationCode: async () => ({ access_token: 'test-token', token_type: 'Bearer' }),
exchangeRefreshToken: async () => ({ access_token: 'test-token', token_type: 'Bearer' }),
verifyAccessToken: async (token: string) => ({ token, clientId: 'test', scopes: [], expiresAt: 1e12 }),
clientsStore: {
getClient: async (id: string) =>
id === 'test-client'
? ({
client_id: 'test-client',
client_secret: 'secret',
redirect_uris: ['https://example.com/callback']
} as OAuthClientInformationFull)
: undefined,
registerClient: async () =>
({
client_id: 'new-client',
client_secret: 'new-secret',
redirect_uris: ['https://example.com/callback']
}) as OAuthClientInformationFull
},
revokeToken: async () => {}
};
const app = express();
app.use(express.json());
app.use(mcpAuthRouter({ provider, issuerUrl: issuer }));
await using host = await startExpressMinimal(app);
const authRes = await fetch(new URL('/authorize', host.baseUrl));
expect(authRes.status).not.toBe(404);
const tokenRes = await fetch(new URL('/token', host.baseUrl), { method: 'POST' });
expect(tokenRes.status).not.toBe(404);
const registerRes = await fetch(new URL('/register', host.baseUrl), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ redirect_uris: ['https://example.com/callback'] })
});
expect(registerRes.status).not.toBe(404);
const registerBody = (await registerRes.json()) as { client_id?: string };
expect(registerBody.client_id).toBeTruthy();
const revokeRes = await fetch(new URL('/revoke', host.baseUrl), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'token=old-token&client_id=test-client&client_secret=secret'
});
expect([200, 400]).toContain(revokeRes.status);
});
verifies('hosting:auth:proxy-provider', async (_args: TestArgs) => {
const upstreamRequests: Array<{ url: string; method: string; body?: string }> = [];
const upstreamAS = express();
upstreamAS.use(express.json());
upstreamAS.use(express.urlencoded({ extended: true }));
upstreamAS.get('/authorize', (req, res) => {
upstreamRequests.push({ url: req.url, method: req.method });
res.redirect(302, `https://example.com/callback?code=upstream-code&state=${req.query.state ?? ''}`);
});
upstreamAS.post('/token', (req, res) => {
upstreamRequests.push({ url: req.url, method: req.method, body: JSON.stringify(req.body) });
res.json({ access_token: 'upstream-token', token_type: 'Bearer' });
});
upstreamAS.post('/revoke', (req, res) => {
upstreamRequests.push({ url: req.url, method: req.method, body: JSON.stringify(req.body) });
res.sendStatus(200);
});
await using upstream = await startExpressMinimal(upstreamAS);
const provider = new ProxyOAuthServerProvider({
endpoints: {
authorizationUrl: new URL('/authorize', upstream.baseUrl).href,
tokenUrl: new URL('/token', upstream.baseUrl).href,
revocationUrl: new URL('/revoke', upstream.baseUrl).href
},
verifyAccessToken: async token => ({ token, clientId: 'proxy-client', scopes: [], expiresAt: 1e12 }),
getClient: async (id: string) =>
id === 'proxy-client'
? ({
client_id: 'proxy-client',
client_secret: 'proxy-secret',
redirect_uris: ['https://example.com/cb']
} as OAuthClientInformationFull)
: undefined
});
const app = express();
app.use(express.json());
app.use(mcpAuthRouter({ provider, issuerUrl: new URL('https://proxy.example.com') }));
await using proxy = await startExpressMinimal(app);
const authRes = await fetch(new URL('/authorize', proxy.baseUrl));
expect(authRes.status).not.toBe(404);
const tokenRes = await fetch(new URL('/token', proxy.baseUrl), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'grant_type=authorization_code&code=test&redirect_uri=https://example.com/cb&client_id=proxy-client&code_verifier=test'
});
expect(tokenRes.status).not.toBe(404);
const revokeRes = await fetch(new URL('/revoke', proxy.baseUrl), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'token=old-token&client_id=proxy-client&client_secret=proxy-secret'
});
expect([200, 400]).toContain(revokeRes.status);
});
verifies('hosting:http:host-validation-middleware', async (_args: TestArgs) => {
const handler: RequestHandler = (_req, res) => {
res.json({ ok: true });
};
await using host = await startExpressWithHostValidation(['localhost', '127.0.0.1'], handler);
const good = await fetch(new URL('/test', host.baseUrl));
expect(good.status).toBe(200);
const spoofed = await postWithHost(new URL('/test', host.baseUrl), 'evil.example.com', JSON.stringify({ test: 'data' }));
expect(spoofed.status).toBe(403);
const body = JSON.parse(spoofed.body) as { error?: { message?: string } };
expect(body.error?.message).toMatch(/Invalid Host/i);
});
verifies('hosting:express-app-helper', async (_args: TestArgs) => {
const app = createMcpExpressApp();
app.post('/mcp', (_req, res) => {
res.json({ jsonrpc: '2.0', id: 1, result: { ok: true } });
});
await using host = await startExpressMinimal(app);
expect(host.baseUrl.hostname).toBe('127.0.0.1');
const good = await fetch(new URL('/mcp', host.baseUrl), {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
});
expect(good.status).toBe(200);
const spoofed = await postWithHost(
new URL('/mcp', host.baseUrl),
'evil.example.com',
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
);
expect(spoofed.status).toBe(403);
const body = JSON.parse(spoofed.body) as { error?: { message?: string } };
expect(body.error?.message).toMatch(/Invalid Host/i);
});
// ---------------------------------------------------------------------------
// Bundled authorization-server endpoints (/register, /authorize, /token):
// in-memory provider + dynamic registration drive the SDK's own handlers
// over real HTTP, mirroring what a real OAuth client would send.
// ---------------------------------------------------------------------------
const AS_REDIRECT_URI = 'https://client.example.com/callback';
interface IssuedAuthorizationCode {
clientId: string;
codeChallenge: string;
redirectUri: string;
}
function createAuthorizationServerProvider(): OAuthServerProvider {
const clients = new Map<string, OAuthClientInformationFull>();
const codes = new Map<string, IssuedAuthorizationCode>();
let clientCount = 0;
let codeCount = 0;
return {
clientsStore: {
getClient: async (clientId: string) => clients.get(clientId),
registerClient: async client => {
clientCount += 1;
const registered: OAuthClientInformationFull = { ...client, client_id: `registered-client-${clientCount}` };
clients.set(registered.client_id, registered);
return registered;
}
},
authorize: async (client, params, res) => {
codeCount += 1;
const code = `authorization-code-${codeCount}`;
codes.set(code, { clientId: client.client_id, codeChallenge: params.codeChallenge, redirectUri: params.redirectUri });
const target = new URL(params.redirectUri);
target.searchParams.set('code', code);
if (params.state !== undefined) {
target.searchParams.set('state', params.state);
}
res.redirect(302, target.href);
},
challengeForAuthorizationCode: async (_client, authorizationCode) => {
const issued = codes.get(authorizationCode);
if (!issued) {
throw new InvalidGrantError('authorization code does not exist');
}
return issued.codeChallenge;
},
exchangeAuthorizationCode: async (client, authorizationCode, _codeVerifier, redirectUri) => {
const issued = codes.get(authorizationCode);
if (!issued) {
throw new InvalidGrantError('authorization code does not exist');
}
if (issued.clientId !== client.client_id) {
throw new InvalidGrantError('authorization code was issued to a different client');
}
if (redirectUri !== undefined && redirectUri !== issued.redirectUri) {
throw new InvalidGrantError('redirect_uri does not match the one used at authorization');
}
// Single-use: the first successful exchange consumes the code.
codes.delete(authorizationCode);
return { access_token: `access-token-for-${authorizationCode}`, token_type: 'Bearer' };
},
exchangeRefreshToken: async () => {
throw new InvalidGrantError('refresh tokens are not issued by this provider');
},
verifyAccessToken: async (token: string) => ({
token,
clientId: 'registered-client-1',
scopes: [],
expiresAt: Date.now() / 1000 + 3600
})
};
}
async function startAuthorizationServer(): Promise<ExpressHost> {
const app = express();
app.use(express.json());
app.use(mcpAuthRouter({ provider: createAuthorizationServerProvider(), issuerUrl: new URL('https://auth.example.com') }));
return startExpressMinimal(app);
}
function createPkcePair(): { verifier: string; challenge: string } {
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
return { verifier, challenge };
}
function postRegistration(baseUrl: URL, redirectUris: string[]): Promise<Response> {
return fetch(new URL('/register', baseUrl), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_name: 'analytics-dashboard', redirect_uris: redirectUris })
});
}
async function registerConfidentialClient(baseUrl: URL): Promise<{ clientId: string; clientSecret: string }> {
const res = await postRegistration(baseUrl, [AS_REDIRECT_URI]);
expect(res.status).toBe(201);
const body = (await res.json()) as { client_id?: string; client_secret?: string };
if (typeof body.client_id !== 'string' || typeof body.client_secret !== 'string') {
throw new Error('registration response did not include client_id and client_secret');
}
return { clientId: body.client_id, clientSecret: body.client_secret };
}
async function mintAuthorizationCode(baseUrl: URL, clientId: string, codeChallenge: string): Promise<string> {
const authorizeUrl = new URL('/authorize', baseUrl);
authorizeUrl.searchParams.set('response_type', 'code');
authorizeUrl.searchParams.set('client_id', clientId);
authorizeUrl.searchParams.set('redirect_uri', AS_REDIRECT_URI);
authorizeUrl.searchParams.set('code_challenge', codeChallenge);
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
authorizeUrl.searchParams.set('state', 'request-state');
const res = await fetch(authorizeUrl, { redirect: 'manual' });
expect(res.status).toBe(302);
const location = res.headers.get('location');
if (location === null) {
throw new Error('authorize response did not include a Location header');
}
const redirect = new URL(location);
expect(redirect.origin + redirect.pathname).toBe(AS_REDIRECT_URI);
expect(redirect.searchParams.get('state')).toBe('request-state');
const code = redirect.searchParams.get('code');
if (code === null) {
throw new Error('authorize redirect did not include a code parameter');
}
return code;
}
function postTokenExchange(baseUrl: URL, form: Record<string, string>): Promise<Response> {
return fetch(new URL('/token', baseUrl), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(form).toString()
});
}
verifies('hosting:auth:as:redirect-uri-scheme', async (_args: TestArgs) => {
await using host = await startAuthorizationServer();
const httpsRegistration = await postRegistration(host.baseUrl, ['https://client.example.com/callback']);
expect(httpsRegistration.status).toBe(201);
const loopbackRegistration = await postRegistration(host.baseUrl, ['http://127.0.0.1:49152/callback']);
expect(loopbackRegistration.status).toBe(201);
const nonLoopbackHttp = await postRegistration(host.baseUrl, ['http://attacker.example.com/callback']);
expect(nonLoopbackHttp.status).toBe(400);
const body = (await nonLoopbackHttp.json()) as { error?: string };
expect(body.error).toBe('invalid_client_metadata');
});
verifies('hosting:auth:as:redirect-uri-binding', async (_args: TestArgs) => {
await using host = await startAuthorizationServer();
const { clientId, clientSecret } = await registerConfidentialClient(host.baseUrl);
const { verifier, challenge } = createPkcePair();
// Authorize leg: an unregistered redirect_uri gets a direct 400, never a redirect to the attacker URI.
const authorizeUrl = new URL('/authorize', host.baseUrl);
authorizeUrl.searchParams.set('response_type', 'code');
authorizeUrl.searchParams.set('client_id', clientId);
authorizeUrl.searchParams.set('redirect_uri', 'https://attacker.example.com/callback');
authorizeUrl.searchParams.set('code_challenge', challenge);
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
const authorizeRes = await fetch(authorizeUrl, { redirect: 'manual' });
expect(authorizeRes.status).toBe(400);
expect(authorizeRes.headers.get('location')).toBeNull();
expect(await authorizeRes.json()).toEqual({ error: 'invalid_request', error_description: 'Unregistered redirect_uri' });
// Token leg: the handler forwards the request's redirect_uri to the provider, which rejects a value differing from authorize.
const code = await mintAuthorizationCode(host.baseUrl, clientId, challenge);
const tokenRes = await postTokenExchange(host.baseUrl, {
grant_type: 'authorization_code',
client_id: clientId,
client_secret: clientSecret,
code,
code_verifier: verifier,
redirect_uri: 'https://client.example.com/callback/other'
});
expect(tokenRes.status).toBe(400);
expect(await tokenRes.json()).toEqual({
error: 'invalid_grant',
error_description: 'redirect_uri does not match the one used at authorization'
});
});
verifies('hosting:auth:query-token-ignored', async (_args: TestArgs) => {
const verifier = {
verifyAccessToken: async (token: string) => {
if (token !== VALID_TOKEN) {
throw new InvalidTokenError('Token verification failed');
}
return { token, clientId: 'analytics-client', scopes: [], expiresAt: Date.now() / 1000 + 3600 };
}
};
const app = express();
app.use(express.json());
app.use(requireBearerAuth({ verifier, resourceMetadataUrl: RESOURCE_METADATA_URL }));
app.post('/mcp', (_req, res) => {
res.json({ jsonrpc: '2.0', id: 1, result: { ok: true } });
});
await using host = await startExpressMinimal(app);
const queryUrl = new URL('/mcp', host.baseUrl);
queryUrl.searchParams.set('access_token', VALID_TOKEN);
const queryRes = await fetch(queryUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
});
expect(queryRes.status).toBe(401);
const wwwAuth = queryRes.headers.get('www-authenticate');
expect(wwwAuth).toBeTruthy();
expect(wwwAuth).toMatch(/^Bearer\b/i);
expect(wwwAuth).toContain('error="invalid_token"');
const queryBody = (await queryRes.json()) as { error?: string };
expect(queryBody.error).toBe('invalid_token');
// Control: the same token in the Authorization header authenticates, proving only the query-string placement was ignored.
const headerRes = await fetch(new URL('/mcp', host.baseUrl), {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Bearer ${VALID_TOKEN}` },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
});
expect(headerRes.status).toBe(200);
});
verifies('hosting:auth:as:authorize-requires-pkce', async (_args: TestArgs) => {
await using host = await startAuthorizationServer();
const { clientId } = await registerConfidentialClient(host.baseUrl);
const authorizeUrl = new URL('/authorize', host.baseUrl);
authorizeUrl.searchParams.set('response_type', 'code');
authorizeUrl.searchParams.set('client_id', clientId);
authorizeUrl.searchParams.set('redirect_uri', AS_REDIRECT_URI);
authorizeUrl.searchParams.set('state', 'pkce-required-state');
const res = await fetch(authorizeUrl, { redirect: 'manual' });
expect(res.status).toBe(302);
const location = res.headers.get('location');
if (location === null) {
throw new Error('authorize response did not include a Location header');
}
const redirect = new URL(location);
expect(redirect.origin + redirect.pathname).toBe(AS_REDIRECT_URI);
expect(redirect.searchParams.get('error')).toBe('invalid_request');
expect(redirect.searchParams.get('error_description')).toContain('code_challenge');
expect(redirect.searchParams.get('code')).toBeNull();
});
verifies('hosting:auth:as:verifier-mismatch', async (_args: TestArgs) => {
await using host = await startAuthorizationServer();
const { clientId, clientSecret } = await registerConfidentialClient(host.baseUrl);
const { challenge } = createPkcePair();
const code = await mintAuthorizationCode(host.baseUrl, clientId, challenge);
// A well-formed verifier from a different PKCE pair cannot hash to the stored challenge.
const { verifier: wrongVerifier } = createPkcePair();
const res = await postTokenExchange(host.baseUrl, {
grant_type: 'authorization_code',
client_id: clientId,
client_secret: clientSecret,
code,
code_verifier: wrongVerifier,
redirect_uri: AS_REDIRECT_URI
});
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: 'invalid_grant', error_description: 'code_verifier does not match the challenge' });
});
verifies('hosting:auth:as:code-single-use', async (_args: TestArgs) => {
await using host = await startAuthorizationServer();
const { clientId, clientSecret } = await registerConfidentialClient(host.baseUrl);
const { verifier, challenge } = createPkcePair();
const code = await mintAuthorizationCode(host.baseUrl, clientId, challenge);
const form = {
grant_type: 'authorization_code',
client_id: clientId,
client_secret: clientSecret,
code,
code_verifier: verifier,
redirect_uri: AS_REDIRECT_URI
};
const first = await postTokenExchange(host.baseUrl, form);
expect(first.status).toBe(200);
const firstBody = (await first.json()) as { access_token?: string; token_type?: string };
expect(firstBody.token_type).toBe('Bearer');
expect(firstBody.access_token).toBe(`access-token-for-${code}`);
const second = await postTokenExchange(host.baseUrl, form);
expect(second.status).toBe(400);
expect(await second.json()).toEqual({ error: 'invalid_grant', error_description: 'authorization code does not exist' });
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,840 @@
/**
* Self-contained test bodies for hosting resume (StreamableHTTP resumability/EventStore).
*
* Each export is a {@link TestCase}: it builds its own server (via a factory),
* builds its own client, wires them with {@link wire}, and asserts. SSE stream
* parsing and raw GET requests drive resume scenarios.
*/
import { expect, vi } from 'vitest';
import { z } from 'zod/v4';
import { Client } from '../../../src/client/index.js';
import { StreamableHTTPClientTransport } from '../../../src/client/streamableHttp.js';
import type { EventStore, EventId, StreamId } from '../../../src/server/webStandardStreamableHttp.js';
import { McpServer } from '../../../src/server/mcp.js';
import type { JSONRPCMessage } from '../../../src/types.js';
import { hostResumable } from '../helpers/index.js';
import type { TestArgs } from '../types.js';
import { LATEST_PROTOCOL_VERSION } from '../../../src/types.js';
import { verifies } from '../helpers/verifies.js';
/**
* These three tests assert the raw frame sequence of the POST SSE stream itself, so they cannot run their traffic through a connected client.
*/
async function completeHandshake(
url: URL,
fetch: (u: URL | string, init?: RequestInit) => Promise<Response>,
capabilities: Record<string, unknown> = {}
): Promise<{ sessionId: string; baseHeaders: Record<string, string> }> {
const initRes = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 0,
method: 'initialize',
params: {
protocolVersion: LATEST_PROTOCOL_VERSION,
capabilities,
clientInfo: { name: 'test', version: '0' }
}
})
});
if (!initRes.ok) throw new Error(`Initialize failed: ${initRes.status}`);
const sessionId = initRes.headers.get('mcp-session-id');
if (!sessionId) throw new Error('No session ID in initialize response');
await initRes.body?.cancel();
const baseHeaders = { 'mcp-session-id': sessionId, 'mcp-protocol-version': LATEST_PROTOCOL_VERSION };
const notifRes = await fetch(url, {
method: 'POST',
headers: {
...baseHeaders,
'content-type': 'application/json',
accept: 'application/json, text/event-stream'
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'notifications/initialized'
})
});
if (!notifRes.ok) throw new Error(`notifications/initialized failed: ${notifRes.status}`);
await notifRes.body?.cancel();
return { sessionId, baseHeaders };
}
/**
* Minimal in-memory EventStore for tests. Models user implementation.
* Ids are `<streamId>_<seq>` for deterministic ordering.
*/
class InMemoryEventStore implements EventStore {
private events: Array<{ id: string; streamId: string; message: JSONRPCMessage }> = [];
private seq = 0;
async storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise<EventId> {
const id = `${streamId}_${String(this.seq++).padStart(6, '0')}`;
this.events.push({ id, streamId, message });
return id;
}
async getStreamIdForEventId(eventId: EventId): Promise<StreamId | undefined> {
const found = this.events.find(e => e.id === eventId);
return found?.streamId;
}
async replayEventsAfter(
lastEventId: EventId,
{ send }: { send: (eventId: EventId, message: JSONRPCMessage) => Promise<void> }
): Promise<StreamId> {
const idx = this.events.findIndex(e => e.id === lastEventId);
if (idx < 0) return '';
const streamId = this.events[idx].streamId;
for (const e of this.events.slice(idx + 1)) {
if (e.streamId === streamId) await send(e.id, e.message);
}
return streamId;
}
reset(): void {
this.events.length = 0;
this.seq = 0;
}
getStoredEvents(): ReadonlyArray<{ id: string; streamId: string; message: JSONRPCMessage }> {
return this.events;
}
}
/** Parse SSE blocks with non-empty data lines from an SSE body. */
function parseSseFrames(body: string): Array<{ id: string; data: string }> {
const frames: Array<{ id: string; data: string }> = [];
for (const block of body.split(/\r?\n\r?\n/)) {
const idMatch = block.match(/^id:\s*(.+)$/m);
const dataMatch = block.match(/^data:\s*(.+)$/m);
if (idMatch?.[1]?.trim() && dataMatch?.[1]) {
frames.push({ id: idMatch[1].trim(), data: dataMatch[1] });
}
}
return frames;
}
/** Read an SSE Response body to completion. */
async function readSseBody(body: ReadableStream<Uint8Array>): Promise<string> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buf = '';
try {
while (true) {
const { value, done } = await reader.read();
if (value) buf += decoder.decode(value, { stream: true });
if (done) break;
}
} finally {
await reader.cancel().catch(() => {});
}
return buf;
}
/** Read SSE frames until we have at least `count` frames, then cancel. */
async function readSseFramesUntil(body: ReadableStream<Uint8Array>, count: number, maxIterations = 64): Promise<string> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buf = '';
try {
for (let i = 0; i < maxIterations; i++) {
const { value, done } = await reader.read();
if (value) buf += decoder.decode(value, { stream: true });
const frames = parseSseFrames(buf);
if (frames.length >= count) break;
if (done) break;
}
} finally {
await reader.cancel().catch(() => {});
}
return buf;
}
verifies('hosting:resume:event-ids', async (_args: TestArgs) => {
const eventStore = new InMemoryEventStore();
let serverRef: McpServer | undefined;
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' }, { capabilities: { logging: {} } });
s.registerTool('progress', { inputSchema: z.object({ steps: z.number().int().positive() }) }, async ({ steps }, extra) => {
const token = extra._meta?.progressToken;
if (token !== undefined) {
for (let i = 1; i <= steps; i++) {
await extra.sendNotification({
method: 'notifications/progress',
params: { progressToken: token, progress: i, total: steps }
});
}
}
return { content: [{ type: 'text', text: `done ${steps}` }] };
});
serverRef = s;
return s;
};
const handle = hostResumable(makeServer, { eventStore });
const url = new URL('http://in-process/mcp');
const fetch = (u: URL | string, init?: RequestInit) => handle.handleRequest(new Request(u, init));
try {
const { baseHeaders } = await completeHandshake(url, fetch, { logging: {} });
if (!serverRef) throw new Error('serverRef not set');
const postRes = await fetch(url, {
method: 'POST',
headers: { ...baseHeaders, 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 'evt-1',
method: 'tools/call',
params: { name: 'progress', arguments: { steps: 3 }, _meta: { progressToken: 'evt-1' } }
})
});
expect(postRes.status).toBe(200);
expect(postRes.headers.get('content-type')).toMatch(/text\/event-stream/);
const postBody = await readSseBody(postRes.body!);
const postBlocks = postBody.split(/\r?\n\r?\n/).filter(b => b.trim().length > 0);
expect(postBlocks).toHaveLength(5);
const postIds = postBlocks.map(b => b.match(/^id: (\S+)$/m)?.[1]).filter((id): id is string => id !== undefined);
expect(postIds).toHaveLength(postBlocks.length);
const postMessages = postBlocks
.map(b => b.match(/^data: (.+)$/m)?.[1])
.filter((d): d is string => d !== undefined && d.trim().length > 0)
.map(d => JSON.parse(d) as JSONRPCMessage);
expect(postMessages.filter(m => 'method' in m && m.method === 'notifications/progress')).toHaveLength(3);
expect(postMessages.filter(m => 'id' in m && m.id === 'evt-1')).toHaveLength(1);
const getRes = await fetch(url, {
method: 'GET',
headers: { ...baseHeaders, accept: 'application/json, text/event-stream' }
});
expect(getRes.status).toBe(200);
expect(getRes.headers.get('content-type')).toMatch(/text\/event-stream/);
await serverRef.sendLoggingMessage({ level: 'info', data: 'standalone-1' });
await serverRef.sendLoggingMessage({ level: 'warning', data: 'standalone-2' });
const getBuf = await readSseFramesUntil(getRes.body!, 2);
const getBlocks = getBuf.split(/\r?\n\r?\n/).filter(b => b.trim().length > 0);
expect(getBlocks).toHaveLength(2);
const getIds = getBlocks.map(b => b.match(/^id: (\S+)$/m)?.[1]).filter((id): id is string => id !== undefined);
expect(getIds).toHaveLength(getBlocks.length);
const storedIds = eventStore.getStoredEvents().map(e => e.id);
for (const id of [...postIds, ...getIds]) {
expect(storedIds).toContain(id);
}
} finally {
await handle.close();
}
});
verifies('hosting:resume:replay', async (_args: TestArgs) => {
const eventStore = new InMemoryEventStore();
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('progress', { inputSchema: z.object({ steps: z.number().int().positive() }) }, async ({ steps }, extra) => {
const token = extra._meta?.progressToken;
if (token !== undefined) {
for (let i = 1; i <= steps; i++) {
await extra.sendNotification({
method: 'notifications/progress',
params: { progressToken: token, progress: i, total: steps }
});
}
}
return { content: [{ type: 'text', text: `done ${steps}` }] };
});
return s;
};
const handle = hostResumable(makeServer, { eventStore });
const url = new URL('http://in-process/mcp');
const fetch = (u: URL | string, init?: RequestInit) => handle.handleRequest(new Request(u, init));
const client = new Client({ name: 'c', version: '0' });
const transport = new StreamableHTTPClientTransport(url, { fetch });
try {
await client.connect(transport);
const sessionId = transport.sessionId!;
const baseHeaders = { 'mcp-session-id': sessionId };
const postRes = await fetch(url, {
method: 'POST',
headers: { ...baseHeaders, 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 'replay-1',
method: 'tools/call',
params: { name: 'progress', arguments: { steps: 3 }, _meta: { progressToken: 'replay-1' } }
})
});
expect(postRes.status).toBe(200);
const postBody = await readSseBody(postRes.body!);
const originalFrames = parseSseFrames(postBody);
expect(originalFrames.length).toBeGreaterThanOrEqual(2);
const anchorId = originalFrames[0].id;
const expectedAfterIds = originalFrames.slice(1).map(f => f.id);
const resumeRes = await fetch(url, {
method: 'GET',
headers: { ...baseHeaders, accept: 'application/json, text/event-stream', 'last-event-id': anchorId }
});
expect(resumeRes.status).toBe(200);
expect(resumeRes.headers.get('content-type')).toMatch(/text\/event-stream/);
const resumeBody = await readSseFramesUntil(resumeRes.body!, expectedAfterIds.length);
const replayedFrames = parseSseFrames(resumeBody);
const replayedIds = replayedFrames.map(f => f.id);
expect(replayedIds).toEqual(expectedAfterIds);
expect(replayedIds).not.toContain(anchorId);
for (const f of replayedFrames) {
const msg = JSON.parse(f.data) as JSONRPCMessage;
expect(msg).toHaveProperty('jsonrpc', '2.0');
}
} finally {
await client.close();
await handle.close();
}
});
verifies('hosting:resume:buffered-replay', async (_args: TestArgs) => {
const eventStore = new InMemoryEventStore();
let serverRef: McpServer | undefined;
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' }, { capabilities: { logging: {} } });
serverRef = s;
return s;
};
const handle = hostResumable(makeServer, { eventStore });
const url = new URL('http://in-process/mcp');
const fetch = (u: URL | string, init?: RequestInit) => handle.handleRequest(new Request(u, init));
try {
const initRes = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 'init-1',
method: 'initialize',
params: { protocolVersion: '2024-11-05', capabilities: { logging: {} }, clientInfo: { name: 'c', version: '0' } }
})
});
expect(initRes.status).toBe(200);
const sessionId = initRes.headers.get('mcp-session-id');
expect(sessionId).toBeTruthy();
await readSseBody(initRes.body!);
const baseHeaders = { 'mcp-session-id': sessionId! };
await fetch(url, {
method: 'POST',
headers: { ...baseHeaders, 'content-type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'notifications/initialized',
params: {}
})
});
if (!serverRef) throw new Error('serverRef not set');
const getRes = await fetch(url, {
method: 'GET',
headers: { ...baseHeaders, accept: 'application/json, text/event-stream' }
});
expect(getRes.status).toBe(200);
const reader = getRes.body!.getReader();
const decoder = new TextDecoder();
let anchorBuf = '';
await serverRef.sendLoggingMessage({ level: 'info', data: 'anchor' });
try {
for (let i = 0; i < 32; i++) {
const { value, done } = await reader.read();
if (value) anchorBuf += decoder.decode(value, { stream: true });
const frames = parseSseFrames(anchorBuf);
if (frames.length >= 1) break;
if (done) break;
}
} finally {
await reader.cancel().catch(() => {});
}
const anchorFrames = parseSseFrames(anchorBuf);
expect(anchorFrames.length).toBeGreaterThanOrEqual(1);
const anchorId = anchorFrames[anchorFrames.length - 1].id;
await serverRef.sendLoggingMessage({ level: 'info', data: 'buffered-1' });
await serverRef.sendLoggingMessage({ level: 'warning', data: 'buffered-2' });
await serverRef.sendLoggingMessage({ level: 'error', data: 'buffered-3' });
const resumeRes = await fetch(url, {
method: 'GET',
headers: { ...baseHeaders, accept: 'application/json, text/event-stream', 'last-event-id': anchorId }
});
expect(resumeRes.status).toBe(200);
const resumeBody = await readSseFramesUntil(resumeRes.body!, 3);
const replayedFrames = parseSseFrames(resumeBody);
expect(replayedFrames.length).toBeGreaterThanOrEqual(3);
const logData = replayedFrames
.map(f => {
const msg = JSON.parse(f.data) as JSONRPCMessage;
if ('method' in msg && msg.method === 'notifications/message' && 'params' in msg) {
const params = msg.params as { data: unknown };
return params.data;
}
return undefined;
})
.filter((d): d is unknown => d !== undefined);
expect(logData.slice(0, 3)).toEqual(['buffered-1', 'buffered-2', 'buffered-3']);
expect(logData).not.toContain('anchor');
} finally {
await handle.close();
}
});
verifies('typescript:hosting:resume:bad-event-id', async (_args: TestArgs) => {
const UNMAPPABLE_EVENT_ID = 'evt-unmappable';
const THROWING_EVENT_ID = 'evt-replay-throws';
const scriptedEventStore: EventStore = {
async storeEvent(streamId) {
return `${streamId}_stored`;
},
async getStreamIdForEventId(eventId) {
if (eventId === UNMAPPABLE_EVENT_ID) return undefined;
if (eventId === THROWING_EVENT_ID) return 'stream-will-throw';
return undefined;
},
async replayEventsAfter(lastEventId) {
if (lastEventId === THROWING_EVENT_ID) {
throw new Error('boom: replay failed');
}
return 'unused-stream';
}
};
const makeServer = () => new McpServer({ name: 's', version: '0' });
const handle = hostResumable(makeServer, { eventStore: scriptedEventStore });
const url = new URL('http://in-process/mcp');
const fetch = (u: URL | string, init?: RequestInit) => handle.handleRequest(new Request(u, init));
const client = new Client({ name: 'c', version: '0' });
const transport = new StreamableHTTPClientTransport(url, { fetch });
try {
await client.connect(transport);
const sessionId = transport.sessionId!;
const baseHeaders = { 'mcp-session-id': sessionId };
const unmappedRes = await fetch(url, {
method: 'GET',
headers: { ...baseHeaders, accept: 'application/json, text/event-stream', 'last-event-id': UNMAPPABLE_EVENT_ID }
});
expect(unmappedRes.status).toBe(400);
const unmappedBody = await unmappedRes.json();
expect(unmappedBody.error?.code).toBe(-32000);
const failedRes = await fetch(url, {
method: 'GET',
headers: { ...baseHeaders, accept: 'application/json, text/event-stream', 'last-event-id': THROWING_EVENT_ID }
});
expect(failedRes.status).toBe(500);
const failedBody = await failedRes.json();
expect(failedBody.error?.code).toBe(-32000);
} finally {
await client.close();
await handle.close();
}
});
verifies('hosting:resume:priming', async (_args: TestArgs) => {
const RETRY_MS = 7331;
const eventStore = new InMemoryEventStore();
const makeServer = () => new McpServer({ name: 's', version: '0' });
const handle = hostResumable(makeServer, {
eventStore,
retryInterval: RETRY_MS
});
const url = new URL('http://in-process/mcp');
const fetch = (u: URL | string, init?: RequestInit) => handle.handleRequest(new Request(u, init));
try {
const { baseHeaders } = await completeHandshake(url, fetch);
const postRes = await fetch(url, {
method: 'POST',
headers: { ...baseHeaders, 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 'priming-probe',
method: 'tools/list',
params: {}
})
});
expect(postRes.status).toBe(200);
expect(postRes.headers.get('content-type')).toMatch(/text\/event-stream/);
const body = await readSseBody(postRes.body!);
const events = body.split('\n\n').filter(e => e.trim().length > 0);
expect(events.length).toBeGreaterThanOrEqual(2);
const priming = events[0];
const primingLines = priming.split('\n');
const idLine = primingLines.find(l => l.startsWith('id:'));
expect(idLine).toBeDefined();
expect(idLine!.slice('id:'.length).trim().length).toBeGreaterThan(0);
expect(primingLines).toContain(`retry: ${RETRY_MS}`);
const dataLine = primingLines.find(l => l.startsWith('data:'));
expect(dataLine).toBeDefined();
expect(dataLine!.replace(/^data:\s?/, '')).toBe('');
expect(priming).not.toMatch(/^event: message$/m);
const responseEvent = events.find(e => /^event: message$/m.test(e));
expect(responseEvent).toBeDefined();
expect(events.indexOf(responseEvent!)).toBeGreaterThan(0);
} finally {
await handle.close();
}
});
verifies('hosting:resume:stream-scoped', async (_args: TestArgs) => {
const eventStore = new InMemoryEventStore();
let serverRef: McpServer | undefined;
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' }, { capabilities: { logging: {} } });
s.registerTool('progress', { inputSchema: z.object({ steps: z.number().int().positive() }) }, async ({ steps }, extra) => {
const token = extra._meta?.progressToken;
if (token !== undefined) {
for (let i = 1; i <= steps; i++) {
await extra.sendNotification({
method: 'notifications/progress',
params: { progressToken: token, progress: i, total: steps }
});
}
}
return { content: [{ type: 'text', text: `done ${steps}` }] };
});
serverRef = s;
return s;
};
const handle = hostResumable(makeServer, { eventStore });
const url = new URL('http://in-process/mcp');
const fetch = (u: URL | string, init?: RequestInit) => handle.handleRequest(new Request(u, init));
try {
const initRes = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 'init-1',
method: 'initialize',
params: { protocolVersion: '2024-11-05', capabilities: { logging: {} }, clientInfo: { name: 'c', version: '0' } }
})
});
expect(initRes.status).toBe(200);
const sessionId = initRes.headers.get('mcp-session-id');
expect(sessionId).toBeTruthy();
await readSseBody(initRes.body!);
const baseHeaders = { 'mcp-session-id': sessionId! };
await fetch(url, {
method: 'POST',
headers: { ...baseHeaders, 'content-type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'notifications/initialized',
params: {}
})
});
const getRes = await fetch(url, {
method: 'GET',
headers: { ...baseHeaders, accept: 'application/json, text/event-stream' }
});
expect(getRes.status).toBe(200);
const reader = getRes.body!.getReader();
const decoder = new TextDecoder();
let anchorBuf = '';
if (!serverRef) throw new Error('serverRef not set');
await serverRef.sendLoggingMessage({ level: 'info', data: 'anchor' });
try {
for (let i = 0; i < 32; i++) {
const { value, done } = await reader.read();
if (value) anchorBuf += decoder.decode(value, { stream: true });
const frames = parseSseFrames(anchorBuf);
if (frames.length >= 1) break;
if (done) break;
}
} finally {
await reader.cancel().catch(() => {});
}
const anchorFrames = parseSseFrames(anchorBuf);
expect(anchorFrames.length).toBeGreaterThanOrEqual(1);
const anchorId = anchorFrames[anchorFrames.length - 1].id;
const stored = eventStore.getStoredEvents();
const anchorEvent = stored.find(e => e.id === anchorId);
expect(anchorEvent).toBeDefined();
const streamAId = anchorEvent!.streamId;
const postRes = await fetch(url, {
method: 'POST',
headers: { ...baseHeaders, 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 'prog-1',
method: 'tools/call',
params: { name: 'progress', arguments: { steps: 2 }, _meta: { progressToken: 'prog-1' } }
})
});
expect(postRes.status).toBe(200);
await readSseBody(postRes.body!);
const storedAfterProgress = eventStore.getStoredEvents();
const streamBEvents = storedAfterProgress.filter(e => e.streamId !== streamAId);
expect(streamBEvents.length).toBeGreaterThan(0);
await serverRef.sendLoggingMessage({ level: 'info', data: 'after-1' });
await serverRef.sendLoggingMessage({ level: 'warning', data: 'after-2' });
const resumeRes = await fetch(url, {
method: 'GET',
headers: { ...baseHeaders, accept: 'application/json, text/event-stream', 'last-event-id': anchorId }
});
expect(resumeRes.status).toBe(200);
const resumeReader = resumeRes.body!.getReader();
const resumeDecoder = new TextDecoder();
let resumeBuf = '';
const readResumedUntil = async (haveEnough: (frames: ReturnType<typeof parseSseFrames>) => boolean) => {
for (let i = 0; i < 64 && !haveEnough(parseSseFrames(resumeBuf)); i++) {
const { value, done } = await resumeReader.read();
if (value) resumeBuf += resumeDecoder.decode(value, { stream: true });
if (done) break;
}
return parseSseFrames(resumeBuf);
};
try {
const replayedFrames = await readResumedUntil(frames => frames.length >= 2);
expect(replayedFrames.length).toBeGreaterThanOrEqual(2);
const replayedEvents = replayedFrames.map(f => ({
id: f.id,
message: JSON.parse(f.data) as JSONRPCMessage
}));
const storedFinal = eventStore.getStoredEvents();
const eventIdToStream = new Map(storedFinal.map(e => [e.id, e.streamId]));
for (const r of replayedEvents) {
expect(eventIdToStream.get(r.id)).toBe(streamAId);
}
const replayedMethods = replayedEvents
.map(r => ('method' in r.message ? r.message.method : undefined))
.filter((m): m is string => m !== undefined);
expect(replayedMethods).not.toContain('notifications/progress');
const logData = replayedEvents
.map(r => {
if ('method' in r.message && r.message.method === 'notifications/message' && 'params' in r.message) {
const params = r.message.params as { data: unknown };
return params.data;
}
return undefined;
})
.filter((d): d is unknown => d !== undefined);
expect(logData.slice(0, 2)).toEqual(['after-1', 'after-2']);
expect(logData).not.toContain('anchor');
const livePostRes = await fetch(url, {
method: 'POST',
headers: { ...baseHeaders, 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 'live-1',
method: 'tools/call',
params: { name: 'progress', arguments: { steps: 2 }, _meta: { progressToken: 'live-1' } }
})
});
expect(livePostRes.status).toBe(200);
expect(livePostRes.headers.get('content-type')).toMatch(/text\/event-stream/);
const liveMessages = parseSseFrames(await readSseBody(livePostRes.body!))
.filter(f => f.data.trim().length > 0)
.map(f => JSON.parse(f.data) as JSONRPCMessage);
expect(liveMessages.filter(m => 'method' in m && m.method === 'notifications/progress')).toHaveLength(2);
expect(liveMessages.filter(m => 'id' in m && m.id === 'live-1')).toHaveLength(1);
await serverRef.sendLoggingMessage({ level: 'info', data: 'sentinel' });
const resumedFrames = await readResumedUntil(frames =>
frames.some(f => {
const m = JSON.parse(f.data) as JSONRPCMessage;
return 'method' in m && m.method === 'notifications/message' && (m.params as { data?: unknown })?.data === 'sentinel';
})
);
const resumedMessages = resumedFrames.map(f => JSON.parse(f.data) as JSONRPCMessage);
expect(
resumedMessages.some(
m => 'method' in m && m.method === 'notifications/message' && (m.params as { data?: unknown })?.data === 'sentinel'
)
).toBe(true);
expect(resumedMessages.filter(m => 'method' in m && m.method === 'notifications/progress')).toHaveLength(0);
expect(resumedMessages.filter(m => 'id' in m && m.id === 'live-1')).toHaveLength(0);
} finally {
await resumeReader.cancel().catch(() => {});
}
} finally {
await handle.close();
}
});
verifies('hosting:resume:close-stream', async (_args: TestArgs) => {
const eventStore = new InMemoryEventStore();
const hooksSeen: Array<{ closeRequestStream: boolean; closeStandalone: boolean }> = [];
let releaseTool: () => void = () => {};
const toolGate = new Promise<void>(resolve => {
releaseTool = resolve;
});
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('generate-report', { inputSchema: z.object({}) }, async (_input, extra) => {
hooksSeen.push({
closeRequestStream: typeof extra.closeSSEStream === 'function',
closeStandalone: typeof extra.closeStandaloneSSEStream === 'function'
});
extra.closeStandaloneSSEStream?.();
extra.closeSSEStream?.();
await toolGate;
return { content: [{ type: 'text', text: 'report ready' }] };
});
return s;
};
const handle = hostResumable(makeServer, { eventStore });
const url = new URL('http://in-process/mcp');
const fetch = (u: URL | string, init?: RequestInit) => handle.handleRequest(new Request(u, init));
try {
const { baseHeaders } = await completeHandshake(url, fetch);
const standaloneRes = await fetch(url, {
method: 'GET',
headers: { ...baseHeaders, accept: 'application/json, text/event-stream' }
});
expect(standaloneRes.status).toBe(200);
expect(standaloneRes.headers.get('content-type')).toMatch(/text\/event-stream/);
const postRes = await fetch(url, {
method: 'POST',
headers: { ...baseHeaders, 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 'close-1',
method: 'tools/call',
params: { name: 'generate-report', arguments: {} }
})
});
expect(postRes.status).toBe(200);
expect(postRes.headers.get('content-type')).toMatch(/text\/event-stream/);
await vi.waitFor(() => expect(hooksSeen).toHaveLength(1));
expect(hooksSeen[0]).toEqual({ closeRequestStream: true, closeStandalone: true });
const postBody = await readSseBody(postRes.body!);
const standaloneBody = await readSseBody(standaloneRes.body!);
expect(standaloneBody).toBe('');
expect(postBody).not.toMatch(/^event: message$/m);
const primingIdMatch = postBody.match(/^id:\s*(.+)$/m);
expect(primingIdMatch?.[1]?.trim()).toBeTruthy();
const primingId = primingIdMatch![1].trim();
const resumeRes = await fetch(url, {
method: 'GET',
headers: { ...baseHeaders, accept: 'application/json, text/event-stream', 'last-event-id': primingId }
});
expect(resumeRes.status).toBe(200);
expect(resumeRes.headers.get('content-type')).toMatch(/text\/event-stream/);
releaseTool();
const resumeBody = await readSseBody(resumeRes.body!);
const resumeFrames = parseSseFrames(resumeBody);
expect(resumeFrames).toHaveLength(1);
const response: unknown = JSON.parse(resumeFrames[0].data);
expect(response).toMatchObject({
jsonrpc: '2.0',
id: 'close-1',
result: { content: [{ type: 'text', text: 'report ready' }] }
});
} finally {
releaseTool();
await handle.close();
}
});

View file

@ -0,0 +1,942 @@
/**
* Self-contained test bodies for the hosting-session surface.
*
* These tests exercise HTTP server-side semantics: session management
* (create/reuse/delete), CORS headers, stateless vs. stateful hosting,
* and in-flight request cancellation. Most tests build the hosting layer
* directly with `hostPerSession()` or `hostStateless()` from helpers and
* drive it with raw HTTP (new Request(...)) to assert status codes and
* headers.
*/
import { randomUUID } from 'node:crypto';
import cors from 'cors';
import express from 'express';
import { expect, vi } from 'vitest';
import { z } from 'zod/v4';
import { Client } from '../../../src/client/index.js';
import { StreamableHTTPClientTransport } from '../../../src/client/streamableHttp.js';
import { McpServer } from '../../../src/server/mcp.js';
import { StreamableHTTPServerTransport } from '../../../src/server/streamableHttp.js';
import { WebStandardStreamableHTTPServerTransport } from '../../../src/server/webStandardStreamableHttp.js';
import { CreateMessageRequestSchema, LATEST_PROTOCOL_VERSION } from '../../../src/types.js';
import { startExpressMinimal } from '../helpers/express.js';
import { hostPerSession, hostStateless, wire } from '../helpers/index.js';
import type { TestArgs } from '../types.js';
import { verifies } from '../helpers/verifies.js';
const newClient = () => new Client({ name: 'c', version: '0' });
function echoServer(): McpServer {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool(
'echo',
{ description: 'Echoes the input text back as a text content block.', inputSchema: z.object({ text: z.string() }) },
({ text }) => ({ content: [{ type: 'text', text }] })
);
return s;
}
verifies('hosting:session:create', async (_args: TestArgs) => {
const initializedSessions: string[] = [];
const server = echoServer();
const tx = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: randomUUID,
onsessioninitialized: id => void initializedSessions.push(id)
});
await server.connect(tx);
const url = new URL('http://in-process/mcp');
try {
const res = await tx.handleRequest(
new Request(url, {
method: 'POST',
headers: {
'mcp-protocol-version': LATEST_PROTOCOL_VERSION,
'content-type': 'application/json',
accept: 'application/json, text/event-stream'
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'c', version: '0' } }
})
})
);
expect(res.status).toBe(200);
const sessionId = res.headers.get('mcp-session-id');
if (sessionId === null) throw new Error('initialize response is missing the mcp-session-id header');
expect(sessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
expect(initializedSessions).toEqual([sessionId]);
} finally {
await server.close();
}
});
verifies('hosting:session:cors-expose', async (_args: TestArgs) => {
const browserOrigin = 'http://dashboard.example.com';
const servers: McpServer[] = [];
// The transport sets no CORS headers itself; the documented hosting layer is cors() with exposedHeaders.
const router = express.Router();
router.use(cors({ origin: browserOrigin, exposedHeaders: ['Mcp-Session-Id'] }));
router.post('/mcp', async (req, res) => {
const tx = new StreamableHTTPServerTransport({ sessionIdGenerator: randomUUID });
const server = echoServer();
await server.connect(tx);
servers.push(server);
await tx.handleRequest(req, res, req.body);
});
await using host = await startExpressMinimal(router);
try {
const res = await fetch(new URL('/mcp', host.baseUrl), {
method: 'POST',
headers: {
origin: browserOrigin,
'mcp-protocol-version': LATEST_PROTOCOL_VERSION,
'content-type': 'application/json',
accept: 'application/json, text/event-stream'
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'c', version: '0' } }
})
});
expect(res.status).toBe(200);
expect(res.headers.get('mcp-session-id')).not.toBeNull();
expect(res.headers.get('access-control-allow-origin')).toBe(browserOrigin);
const exposeHeaders = res.headers.get('access-control-expose-headers');
if (exposeHeaders === null) throw new Error('initialize response is missing the access-control-expose-headers header');
expect(exposeHeaders.toLowerCase()).toContain('mcp-session-id');
await res.text();
} finally {
for (const server of servers) await server.close();
}
});
verifies('hosting:session:reuse', async (_args: TestArgs) => {
const host = hostPerSession(() => echoServer());
const url = new URL('http://in-process/mcp');
const fetch = (u: URL | string, init?: RequestInit) => host.handleRequest(new Request(u, init));
const client = newClient();
await client.connect(new StreamableHTTPClientTransport(url, { fetch }));
const sessionId = (client.transport as StreamableHTTPClientTransport).sessionId;
expect(sessionId).toBeDefined();
expect(typeof sessionId).toBe('string');
const r1 = await client.listTools();
expect(r1.tools.map(t => t.name)).toContain('echo');
const r2 = await client.callTool({ name: 'echo', arguments: { text: 'reuse-test' } });
expect(r2.content).toEqual([{ type: 'text', text: 'reuse-test' }]);
const stillSameSession = (client.transport as StreamableHTTPClientTransport).sessionId;
expect(stillSameSession).toBe(sessionId);
await client.close();
await host.close();
});
verifies('hosting:session:unknown-id', async (_args: TestArgs) => {
const { handleRequest, close } = hostPerSession(echoServer);
try {
const init = await handleRequest(
new Request('http://in-process/mcp', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'c', version: '0' } }
})
})
);
expect(init.status).toBe(200);
const unknownId = randomUUID();
const headers = {
'mcp-session-id': unknownId,
'mcp-protocol-version': LATEST_PROTOCOL_VERSION,
'content-type': 'application/json',
accept: 'application/json, text/event-stream'
};
const post = await handleRequest(
new Request('http://in-process/mcp', {
method: 'POST',
headers,
body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} })
})
);
expect(post.status).toBe(404);
const get = await handleRequest(new Request('http://in-process/mcp', { method: 'GET', headers }));
expect(get.status).toBe(404);
const del = await handleRequest(new Request('http://in-process/mcp', { method: 'DELETE', headers }));
expect(del.status).toBe(404);
} finally {
await close();
}
});
verifies('hosting:session:missing-id', async (_args: TestArgs) => {
// Initialize the transport first so the missing-header branch is hit, not the uninitialized-server branch.
const server = echoServer();
const tx = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: randomUUID });
await server.connect(tx);
const url = new URL('http://in-process/mcp');
const headers = {
'mcp-protocol-version': LATEST_PROTOCOL_VERSION,
'content-type': 'application/json',
accept: 'application/json, text/event-stream'
};
try {
const initRes = await tx.handleRequest(
new Request(url, {
method: 'POST',
headers,
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'c', version: '0' } }
})
})
);
expect(initRes.status).toBe(200);
expect(initRes.headers.get('mcp-session-id')).not.toBeNull();
const res = await tx.handleRequest(
new Request(url, {
method: 'POST',
headers,
body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} })
})
);
expect(res.status).toBe(400);
const body = (await res.json()) as { error?: { code?: number; message?: string } };
expect(body.error?.code).toBe(-32000);
expect(body.error?.message).toBe('Bad Request: Mcp-Session-Id header is required');
} finally {
await server.close();
}
});
verifies('hosting:session:delete', async (_args: TestArgs) => {
// hostPerSession owns its session callbacks, so build the per-session map inline to observe onsessionclosed.
const closedSessions: string[] = [];
const sessions = new Map<string, WebStandardStreamableHTTPServerTransport>();
const handle = async (req: Request): Promise<Response> => {
const sid = req.headers.get('mcp-session-id');
const existing = sid ? sessions.get(sid) : undefined;
if (existing) return existing.handleRequest(req);
const tx = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: randomUUID,
onsessioninitialized: id => void sessions.set(id, tx),
onsessionclosed: id => {
closedSessions.push(id);
sessions.delete(id);
}
});
await echoServer().connect(tx);
return tx.handleRequest(req);
};
const url = new URL('http://in-process/mcp');
const headers = {
'mcp-protocol-version': LATEST_PROTOCOL_VERSION,
'content-type': 'application/json',
accept: 'application/json, text/event-stream'
};
try {
const initRes = await handle(
new Request(url, {
method: 'POST',
headers,
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'c', version: '0' } }
})
})
);
expect(initRes.status).toBe(200);
const sessionId = initRes.headers.get('mcp-session-id');
if (sessionId === null) throw new Error('initialize response is missing the mcp-session-id header');
expect(Array.from(sessions.keys())).toEqual([sessionId]);
const listRes = await handle(
new Request(url, {
method: 'POST',
headers: { ...headers, 'mcp-session-id': sessionId },
body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} })
})
);
expect(listRes.status).toBe(200);
expect(closedSessions).toEqual([]);
const deleteRes = await handle(
new Request(url, {
method: 'DELETE',
headers: { 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, 'mcp-session-id': sessionId }
})
);
expect(deleteRes.status).toBe(200);
expect(closedSessions).toEqual([sessionId]);
expect(sessions.size).toBe(0);
// The old id no longer routes to a live transport once onsessionclosed removed it from the map.
const reuseRes = await handle(
new Request(url, {
method: 'POST',
headers: { ...headers, 'mcp-session-id': sessionId },
body: JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} })
})
);
expect(reuseRes.status).toBeGreaterThanOrEqual(400);
} finally {
for (const tx of sessions.values()) await tx.close();
}
});
verifies('hosting:session:post-termination-404', async (_args: TestArgs) => {
// The documented per-session hosting pattern is the layer that owns session lifetime, so termination is asserted through it.
const { handleRequest, close } = hostPerSession(echoServer);
const url = new URL('http://in-process/mcp');
const headers = {
'mcp-protocol-version': LATEST_PROTOCOL_VERSION,
'content-type': 'application/json',
accept: 'application/json, text/event-stream'
};
try {
const initRes = await handleRequest(
new Request(url, {
method: 'POST',
headers,
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'c', version: '0' } }
})
})
);
expect(initRes.status).toBe(200);
const sessionId = initRes.headers.get('mcp-session-id');
if (sessionId === null) throw new Error('initialize response is missing the mcp-session-id header');
// The session answers a request before termination, so any later 404 is attributable to the DELETE alone.
const liveRes = await handleRequest(
new Request(url, {
method: 'POST',
headers: { ...headers, 'mcp-session-id': sessionId },
body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} })
})
);
expect(liveRes.status).toBe(200);
const deleteRes = await handleRequest(
new Request(url, {
method: 'DELETE',
headers: { 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, 'mcp-session-id': sessionId }
})
);
expect(deleteRes.status).toBe(200);
const staleHeaders = { ...headers, 'mcp-session-id': sessionId };
const stalePost = await handleRequest(
new Request(url, {
method: 'POST',
headers: staleHeaders,
body: JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} })
})
);
expect(stalePost.status).toBe(404);
const staleGet = await handleRequest(new Request(url, { method: 'GET', headers: staleHeaders }));
expect(staleGet.status).toBe(404);
const staleDelete = await handleRequest(new Request(url, { method: 'DELETE', headers: staleHeaders }));
expect(staleDelete.status).toBe(404);
} finally {
await close();
}
});
verifies('hosting:session:id-charset', async (_args: TestArgs) => {
// The SDK has no default generator; its contract is emitting the configured generator's value verbatim in the header.
const generatedIds = ['!session-0x21-low-boundary', '~session-0x7E-high-boundary', randomUUID()];
const url = new URL('http://in-process/mcp');
for (const generatedId of generatedIds) {
const server = echoServer();
const tx = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => generatedId });
await server.connect(tx);
try {
const res = await tx.handleRequest(
new Request(url, {
method: 'POST',
headers: {
'mcp-protocol-version': LATEST_PROTOCOL_VERSION,
'content-type': 'application/json',
accept: 'application/json, text/event-stream'
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'c', version: '0' } }
})
})
);
expect(res.status).toBe(200);
const headerValue = res.headers.get('mcp-session-id');
if (headerValue === null) throw new Error('initialize response is missing the mcp-session-id header');
expect(headerValue).toBe(generatedId);
for (const ch of headerValue) {
const code = ch.charCodeAt(0);
expect(code).toBeGreaterThanOrEqual(0x21);
expect(code).toBeLessThanOrEqual(0x7e);
}
} finally {
await server.close();
}
}
});
verifies('hosting:session:reinitialize', async (_args: TestArgs) => {
const host = hostPerSession(() => echoServer());
const url = new URL('http://in-process/mcp');
const initReq = new Request(url, {
method: 'POST',
headers: {
'mcp-protocol-version': LATEST_PROTOCOL_VERSION,
'content-type': 'application/json',
accept: 'application/json, text/event-stream'
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'c', version: '0' } }
})
});
const initRes = await host.handleRequest(initReq);
expect(initRes.status).toBe(200);
const sessionId = initRes.headers.get('mcp-session-id');
expect(sessionId).toBeDefined();
const reinitReq = new Request(url, {
method: 'POST',
headers: {
'mcp-session-id': sessionId!,
'mcp-protocol-version': LATEST_PROTOCOL_VERSION,
'content-type': 'application/json',
accept: 'application/json, text/event-stream'
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 2,
method: 'initialize',
params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'c', version: '0' } }
})
});
const reinitRes = await host.handleRequest(reinitReq);
expect(reinitRes.status).toBe(400);
await host.close();
});
verifies('hosting:session:isolation', async (_args: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
let visits = 0;
s.registerTool(
'record_visit',
{ description: 'Increments and returns the visit counter for this session.', inputSchema: z.object({}) },
() => {
visits += 1;
return { content: [{ type: 'text', text: `visits:${visits}` }] };
}
);
return s;
};
const host = hostPerSession(makeServer);
const url = new URL('http://in-process/mcp');
const fetch = (u: URL | string, init?: RequestInit) => host.handleRequest(new Request(u, init));
const clientA = newClient();
const txA = new StreamableHTTPClientTransport(url, { fetch });
await clientA.connect(txA);
const clientB = newClient();
const txB = new StreamableHTTPClientTransport(url, { fetch });
await clientB.connect(txB);
const sessionIdA = txA.sessionId;
const sessionIdB = txB.sessionId;
if (sessionIdA === undefined || sessionIdB === undefined) throw new Error('initialize did not assign a session id');
expect(sessionIdA).not.toBe(sessionIdB);
try {
const a1 = await clientA.callTool({ name: 'record_visit', arguments: {} });
expect(a1.content).toEqual([{ type: 'text', text: 'visits:1' }]);
const a2 = await clientA.callTool({ name: 'record_visit', arguments: {} });
expect(a2.content).toEqual([{ type: 'text', text: 'visits:2' }]);
// B starts at 1: counter state accumulated in A's McpServer instance never leaks into B's.
const b1 = await clientB.callTool({ name: 'record_visit', arguments: {} });
expect(b1.content).toEqual([{ type: 'text', text: 'visits:1' }]);
await clientB.close();
const deleteRes = await host.handleRequest(
new Request(url, {
method: 'DELETE',
headers: { 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, 'mcp-session-id': sessionIdB }
})
);
expect(deleteRes.status).toBe(200);
const a3 = await clientA.callTool({ name: 'record_visit', arguments: {} });
expect(a3.content).toEqual([{ type: 'text', text: 'visits:3' }]);
const reuseRes = await host.handleRequest(
new Request(url, {
method: 'POST',
headers: {
'mcp-session-id': sessionIdB,
'mcp-protocol-version': LATEST_PROTOCOL_VERSION,
'content-type': 'application/json',
accept: 'application/json, text/event-stream'
},
body: JSON.stringify({ jsonrpc: '2.0', id: 9, method: 'tools/list', params: {} })
})
);
expect(reuseRes.status).toBeGreaterThanOrEqual(400);
} finally {
await clientA.close();
await host.close();
}
});
verifies('hosting:stateless:no-session-id', async (_args: TestArgs) => {
const host = hostStateless(() => echoServer());
const url = new URL('http://in-process/mcp');
const req = new Request(url, {
method: 'POST',
headers: {
'mcp-protocol-version': LATEST_PROTOCOL_VERSION,
'content-type': 'application/json',
accept: 'application/json, text/event-stream'
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'c', version: '0' } }
})
});
const res = await host.handleRequest(req);
expect(res.status).toBe(200);
const sessionId = res.headers.get('mcp-session-id');
expect(sessionId).toBeNull();
await host.close();
});
verifies('hosting:stateless:concurrent-clients', async (_args: TestArgs) => {
const host = hostStateless(() => echoServer());
const url = new URL('http://in-process/mcp');
const fetch = (u: URL | string, init?: RequestInit) => host.handleRequest(new Request(u, init));
const clients = [
new Client({ name: 'c1', version: '0' }),
new Client({ name: 'c2', version: '0' }),
new Client({ name: 'c3', version: '0' })
];
await Promise.all(clients.map(c => c.connect(new StreamableHTTPClientTransport(url, { fetch }))));
const [r1, r2, r3] = await Promise.all([
clients[0].callTool({ name: 'echo', arguments: { text: 'client-1' } }),
clients[1].callTool({ name: 'echo', arguments: { text: 'client-2' } }),
clients[2].callTool({ name: 'echo', arguments: { text: 'client-3' } })
]);
expect(r1.content).toEqual([{ type: 'text', text: 'client-1' }]);
expect(r2.content).toEqual([{ type: 'text', text: 'client-2' }]);
expect(r3.content).toEqual([{ type: 'text', text: 'client-3' }]);
await Promise.all(clients.map(c => c.close()));
await host.close();
});
verifies('hosting:stateless:get-delete-405', async (_args: TestArgs) => {
const url = new URL('http://in-process/mcp');
// hostStateless hand-rolls a 405 for non-POST before the SDK runs; hit the SDK transport directly so its own behavior is asserted.
const handleStateless = async (req: Request): Promise<Response> => {
const server = echoServer();
const tx = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
await server.connect(tx);
try {
return await tx.handleRequest(req);
} finally {
await server.close();
}
};
const getRes = await handleStateless(
new Request(url, {
method: 'GET',
headers: { 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, accept: 'text/event-stream' }
})
);
expect(getRes.status).toBe(405);
const deleteRes = await handleStateless(
new Request(url, { method: 'DELETE', headers: { 'mcp-protocol-version': LATEST_PROTOCOL_VERSION } })
);
expect(deleteRes.status).toBe(405);
});
verifies('hosting:stateless:progress-in-post-stream', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('progress-tool', { inputSchema: z.object({ steps: z.number() }) }, async ({ steps }, extra) => {
const token = extra._meta?.progressToken;
if (token !== undefined) {
for (let i = 1; i <= steps; i++) {
await extra.sendNotification({
method: 'notifications/progress',
params: { progressToken: token, progress: i, total: steps }
});
}
}
return { content: [{ type: 'text', text: 'done' }] };
});
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const progressEvents: Array<{ progress: number; total?: number }> = [];
let receivedAtResolve = -1;
const result = await client
.callTool({ name: 'progress-tool', arguments: { steps: 3 } }, undefined, {
onprogress: p => progressEvents.push({ progress: p.progress, total: p.total })
})
.then(res => {
receivedAtResolve = progressEvents.length;
return res;
});
expect(receivedAtResolve).toBe(3);
expect(progressEvents).toEqual([
{ progress: 1, total: 3 },
{ progress: 2, total: 3 },
{ progress: 3, total: 3 }
]);
expect(result.content).toEqual([{ type: 'text', text: 'done' }]);
});
verifies('hosting:stateless:no-reuse', async (_args: TestArgs) => {
// Build the transport directly: hostStateless creates a fresh transport per request, which would never hit the reuse guard.
const server = echoServer();
const tx = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
await server.connect(tx);
const url = new URL('http://in-process/mcp');
try {
const initRes = await tx.handleRequest(
new Request(url, {
method: 'POST',
headers: {
'mcp-protocol-version': LATEST_PROTOCOL_VERSION,
'content-type': 'application/json',
accept: 'application/json, text/event-stream'
},
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'c', version: '0' } }
})
})
);
expect(initRes.status).toBe(200);
const secondReq = new Request(url, {
method: 'POST',
headers: {
'mcp-protocol-version': LATEST_PROTOCOL_VERSION,
'content-type': 'application/json',
accept: 'application/json, text/event-stream'
},
body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} })
});
await expect(tx.handleRequest(secondReq)).rejects.toThrow(/cannot be reused across requests/);
} finally {
await server.close();
await tx.close();
}
});
verifies('transport:streamable-http:stateless-restrictions', async (_args: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool(
'needs-sampling',
{ description: 'Asks the client LLM to draft a one-line status update.', inputSchema: z.object({}) },
async () => {
try {
const draft = await s.server.createMessage({
messages: [{ role: 'user', content: { type: 'text', text: 'Draft a one-line status update.' } }],
maxTokens: 50
});
return { content: [{ type: 'text', text: JSON.stringify(draft.content) }] };
} catch (err) {
return { isError: true, content: [{ type: 'text', text: err instanceof Error ? err.message : String(err) }] };
}
}
);
return s;
};
const host = hostStateless(makeServer);
const url = new URL('http://in-process/mcp');
const client = new Client({ name: 'c', version: '0' }, { capabilities: { sampling: {} } });
// A real sampling handler proves any failure comes from the stateless hosting gap, not a missing client capability.
client.setRequestHandler(CreateMessageRequestSchema, async () => ({
model: 'mock-model',
role: 'assistant',
content: { type: 'text', text: 'A drafted status update.' }
}));
try {
await client.connect(new StreamableHTTPClientTransport(url, { fetch: (u, init) => host.handleRequest(new Request(u, init)) }));
let timer: NodeJS.Timeout | undefined;
const settled = client.callTool({ name: 'needs-sampling', arguments: {} }).then(
value => ({ kind: 'resolved' as const, value }),
(reason: unknown) => ({ kind: 'rejected' as const, reason })
);
const outcome = await Promise.race([
settled,
new Promise<{ kind: 'pending' }>(resolve => {
timer = setTimeout(() => resolve({ kind: 'pending' }), 1500);
})
]);
clearTimeout(timer);
if (outcome.kind === 'pending') {
throw new Error('tools/call never settled: server.createMessage() hangs in stateless mode instead of rejecting promptly');
}
if (outcome.kind === 'rejected') {
throw new Error(`tools/call rejected instead of returning an isError result: ${String(outcome.reason)}`);
}
const result = outcome.value;
expect(result.isError).toBe(true);
if (!Array.isArray(result.content)) throw new Error('tools/call result has no content array');
expect(result.content).toHaveLength(1);
const block: unknown = result.content[0];
if (
typeof block !== 'object' ||
block === null ||
!('type' in block) ||
block.type !== 'text' ||
!('text' in block) ||
typeof block.text !== 'string'
) {
throw new Error('tools/call error content is not a single text block');
}
expect(block.text.length).toBeGreaterThan(0);
expect(block.text).not.toMatch(/does not support sampling|method not found/i);
} finally {
await client.close();
await host.close();
}
});
verifies('hosting:session:delete-cancels-inflight', async (_args: TestArgs) => {
const started: string[] = [];
const aborted: string[] = [];
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
// Resolves only when its abort signal fires, so the call stays in flight until DELETE cancels it.
s.registerTool(
'index_repository',
{ description: 'Indexes a source repository for code search.', inputSchema: z.object({ repository: z.string() }) },
({ repository }, extra) =>
new Promise(resolve => {
started.push(repository);
extra.signal.addEventListener('abort', () => {
aborted.push(repository);
resolve({ content: [{ type: 'text', text: `${repository} indexing interrupted` }] });
});
})
);
return s;
};
const host = hostPerSession(makeServer);
const url = new URL('http://in-process/mcp');
const headers = {
'mcp-protocol-version': LATEST_PROTOCOL_VERSION,
'content-type': 'application/json',
accept: 'application/json, text/event-stream'
};
try {
const initRes = await host.handleRequest(
new Request(url, {
method: 'POST',
headers,
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'c', version: '0' } }
})
})
);
expect(initRes.status).toBe(200);
const sessionId = initRes.headers.get('mcp-session-id');
expect(sessionId).toBeDefined();
const callRequest = (id: number, repository: string) =>
host.handleRequest(
new Request(url, {
method: 'POST',
headers: { ...headers, 'mcp-session-id': sessionId! },
body: JSON.stringify({
jsonrpc: '2.0',
id,
method: 'tools/call',
params: { name: 'index_repository', arguments: { repository } }
})
})
);
const firstCall = await callRequest(2, 'docs-site');
const secondCall = await callRequest(3, 'billing-service');
expect(firstCall.status).toBe(200);
expect(secondCall.status).toBe(200);
expect(firstCall.headers.get('content-type')).toMatch(/text\/event-stream/);
expect(secondCall.headers.get('content-type')).toMatch(/text\/event-stream/);
await vi.waitFor(() => expect([...started].sort()).toEqual(['billing-service', 'docs-site']));
expect(aborted).toEqual([]);
const deleteRes = await host.handleRequest(
new Request(url, {
method: 'DELETE',
headers: { 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, 'mcp-session-id': sessionId! }
})
);
expect(deleteRes.status).toBe(200);
await vi.waitFor(() => expect([...aborted].sort()).toEqual(['billing-service', 'docs-site']));
// text() resolves only once the server ends the stream; no data event means no JSON-RPC response was written.
const bodies = await Promise.all([firstCall.text(), secondCall.text()]);
for (const body of bodies) {
expect(body.split('\n').filter(line => line.startsWith('data:'))).toEqual([]);
}
} finally {
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, StreamableHTTPServerTransport>();
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 StreamableHTTPServerTransport({
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(Array.from(sessions.keys())).toEqual([sessionId]);
// The map populated by onsessioninitialized routes the follow-up call back to the same transport.
const result = await client.callTool({ name: 'echo', arguments: { text: 'lifecycle' } });
expect(result.content).toEqual([{ type: 'text', text: 'lifecycle' }]);
expect(initializedSessions).toEqual([sessionId]);
expect(closedSessions).toEqual([]);
await clientTx.terminateSession();
expect(closedSessions).toEqual([sessionId]);
expect(sessions.size).toBe(0);
} finally {
for (const tx of sessions.values()) await tx.close();
await client.close();
}
});

View file

@ -0,0 +1,461 @@
/**
* Self-contained test bodies for the lifecycle surface.
*
* Lifecycle tests cover the initialize handshake, version negotiation,
* the `notifications/initialized` ordering rule, ping, and the metadata
* accessors on both ends (`getServerVersion`/`getServerCapabilities` on the
* client, `getClientVersion`/`getClientCapabilities` on the server). Each
* export is a {@link TestCase}: it builds its own server (via a factory),
* builds its own client, wires them with {@link wire}, and asserts. Function
* names mirror the requirement id in camelCase.
*/
import { expect } from 'vitest';
import { z } from 'zod/v4';
import { Client } from '../../../src/client/index.js';
import { Server } from '../../../src/server/index.js';
import { McpServer } from '../../../src/server/mcp.js';
import {
type ClientCapabilities,
CreateMessageRequestSchema,
ElicitRequestSchema,
type Implementation,
type InitializeRequest,
InitializeRequestSchema,
isJSONRPCNotification,
isJSONRPCRequest,
isJSONRPCResultResponse,
type JSONRPCMessage,
LATEST_PROTOCOL_VERSION,
ListRootsRequestSchema,
type ServerCapabilities,
SUPPORTED_PROTOCOL_VERSIONS
} from '../../../src/types.js';
import { tapWire, wire } from '../helpers/index.js';
import type { TestArgs } from '../types.js';
import { verifies } from '../helpers/verifies.js';
const OLDER_SUPPORTED_VERSION = SUPPORTED_PROTOCOL_VERSIONS.find(v => v !== LATEST_PROTOCOL_VERSION)!;
const BOGUS_VERSION = '1999-01-01';
const DEFAULT_INSTRUCTIONS = 'This is the default server instruction set for lifecycle tests.';
function minimalClient() {
return new Client({ name: 'minimal-client', version: '0.0.0' });
}
function minimalServer(): McpServer {
return new McpServer({ name: 'minimal-server', version: '0.0.0' });
}
/**
* Raw `Server` whose initialize handler echoes the inbound request to
* `received` and replies with the given `protocolVersion`. Used by the
* version-negotiation tests so both sides of the handshake are observable
* via public API only.
*/
function recordingInitServer(replyVersion: string, received: InitializeRequest['params'][]): Server {
const s = new Server({ name: 's', version: '0' }, { capabilities: {} });
s.setRequestHandler(InitializeRequestSchema, async req => {
received.push(req.params);
return { protocolVersion: replyVersion, capabilities: {}, serverInfo: { name: 's', version: '0' } };
});
return s;
}
interface HandshakeLogEntry {
direction: 'client-to-server' | 'server-to-client';
message: JSONRPCMessage;
}
/**
* Patch `client.connect` so the transport `wire()` hands it is tapped from the
* very first handshake message — `tapWire()` can only attach after connect, too
* late to observe initialize. `rewriteOutbound` lets a test alter client→server
* messages before they reach the wire (e.g. to request a protocol version the
* SDK client would never ask for itself).
*/
function tapHandshake(client: Client, rewriteOutbound?: (message: JSONRPCMessage) => JSONRPCMessage): HandshakeLogEntry[] {
const log: HandshakeLogEntry[] = [];
const originalConnect = client.connect.bind(client);
client.connect = (clientTransport, options) => {
const originalSend = clientTransport.send.bind(clientTransport);
clientTransport.send = (message, sendOptions) => {
const outbound = rewriteOutbound ? rewriteOutbound(message) : message;
log.push({ direction: 'client-to-server', message: outbound });
return originalSend(outbound, sendOptions);
};
// Protocol.connect chains a pre-set onmessage, so inbound messages are logged before the client reacts to them.
clientTransport.onmessage = message => {
log.push({ direction: 'server-to-client', message });
};
return originalConnect(clientTransport, options);
};
return log;
}
verifies('lifecycle:initialize:basic', async ({ transport }: TestArgs) => {
const SERVER_CAPS: ServerCapabilities = { tools: { listChanged: true }, logging: {} };
const initReqs: InitializeRequest['params'][] = [];
const makeServer = () => {
const s = new Server({ name: 'lifecycle-server', version: '1.2.3' }, { capabilities: SERVER_CAPS });
s.setRequestHandler(InitializeRequestSchema, async req => {
initReqs.push(req.params);
return {
protocolVersion: req.params.protocolVersion,
capabilities: SERVER_CAPS,
serverInfo: { name: 'lifecycle-server', version: '1.2.3' }
};
});
return s;
};
const client = new Client({ name: 'lifecycle-client', version: '0.0.0' }, { capabilities: { roots: {} } });
await using _ = await wire(transport, makeServer, client);
// Server saw an InitializeRequest with all spec-mandated fields populated.
expect(initReqs).toHaveLength(1);
expect(initReqs[0].protocolVersion).toBe(LATEST_PROTOCOL_VERSION);
expect(initReqs[0].clientInfo).toEqual({ name: 'lifecycle-client', version: '0.0.0' });
expect(initReqs[0].capabilities).toEqual({ roots: {} });
// Client surfaces what the server returned.
expect(client.getServerVersion()).toEqual({ name: 'lifecycle-server', version: '1.2.3' });
expect(client.getServerCapabilities()).toEqual(SERVER_CAPS);
});
verifies('lifecycle:initialize:instructions', async ({ transport }: TestArgs) => {
const makeServer = () => new McpServer({ name: 's', version: '0' }, { instructions: DEFAULT_INSTRUCTIONS });
const client = minimalClient();
await using _ = await wire(transport, makeServer, client);
expect(client.getInstructions()).toBe(DEFAULT_INSTRUCTIONS);
});
verifies('lifecycle:initialized-notification', async ({ transport }: TestArgs) => {
const order: string[] = [];
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' }, { capabilities: { tools: {} } });
s.server.oninitialized = () => order.push('initialized');
s.registerTool('marker', { inputSchema: z.object({}) }, () => {
order.push('request');
return { content: [{ type: 'text', text: 'ok' }] };
});
return s;
};
const client = minimalClient();
await using _ = await wire(transport, makeServer, client);
expect(order).toContain('initialized');
await client.callTool({ name: 'marker', arguments: {} });
// Wherever a request arrived, the immediately-preceding event in the
// server-side order log is the initialized hook firing — i.e., the client
// sent notifications/initialized before any other request.
const reqIdx = order.indexOf('request');
expect(reqIdx).toBeGreaterThan(0);
expect(order[reqIdx - 1]).toBe('initialized');
});
verifies('lifecycle:pre-initialization-ordering', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 'weather-server', version: '1.0.0' });
s.registerTool('get_weather', { inputSchema: z.object({ city: z.string() }) }, ({ city }) => ({
content: [{ type: 'text', text: `Sunny in ${city}` }]
}));
return s;
};
const client = minimalClient();
const log = tapHandshake(client);
await using _ = await wire(transport, makeServer, client);
const result = await client.callTool({ name: 'get_weather', arguments: { city: 'Berlin' } });
expect(result.content).toEqual([{ type: 'text', text: 'Sunny in Berlin' }]);
const requestMethods = new Map<string | number, string>();
const summary = log.map(({ direction, message }) => {
if (isJSONRPCRequest(message)) {
requestMethods.set(message.id, message.method);
return `${direction} request ${message.method}`;
}
if (isJSONRPCNotification(message)) return `${direction} notification ${message.method}`;
if (isJSONRPCResultResponse(message)) return `${direction} result for ${requestMethods.get(message.id) ?? 'unknown request'}`;
return `${direction} error response`;
});
// Before initialization completes the client sends only initialize and the server only its result; the first feature request follows notifications/initialized.
expect(summary).toEqual([
'client-to-server request initialize',
'server-to-client result for initialize',
'client-to-server notification notifications/initialized',
'client-to-server request tools/call',
'server-to-client result for tools/call'
]);
});
verifies('lifecycle:ping', async ({ transport }: TestArgs) => {
const client = minimalClient();
await using _ = await wire(transport, minimalServer, client);
const tap = tapWire(client);
const result = await client.ping();
expect(result).toEqual({});
const req = tap.sent.find(m => isJSONRPCRequest(m) && m.method === 'ping');
expect(req).toBeDefined();
if (!req || !isJSONRPCRequest(req)) throw new Error('expected ping request');
const res = tap.received.find(m => isJSONRPCResultResponse(m) && m.id === req.id);
if (!res || !isJSONRPCResultResponse(res)) throw new Error('expected ping result');
expect(res.result).toEqual({});
});
verifies('lifecycle:version:match', async ({ transport }: TestArgs) => {
const initReqs: InitializeRequest['params'][] = [];
const makeServer = () => recordingInitServer(LATEST_PROTOCOL_VERSION, initReqs);
const client = minimalClient();
await using _ = await wire(transport, makeServer, client);
expect(initReqs).toHaveLength(1);
expect(initReqs[0].protocolVersion).toBe(LATEST_PROTOCOL_VERSION);
// Connect succeeded at the matched version: server state is populated.
expect(client.getServerCapabilities()).toEqual({});
expect(client.getServerVersion()).toEqual({ name: 's', version: '0' });
});
verifies('lifecycle:version:downgrade', async ({ transport }: TestArgs) => {
const initReqs: InitializeRequest['params'][] = [];
const makeServer = () => recordingInitServer(OLDER_SUPPORTED_VERSION, initReqs);
const client = minimalClient();
await using _ = await wire(transport, makeServer, client);
// Client requested LATEST; server replied with an older supported version;
// connect resolved (no throw) and server state is populated, so the client
// accepted the downgrade. There is no transport-agnostic SDK getter for the
// negotiated version (`client.transport.protocolVersion` is HTTP-only).
expect(initReqs).toHaveLength(1);
expect(initReqs[0].protocolVersion).toBe(LATEST_PROTOCOL_VERSION);
expect(OLDER_SUPPORTED_VERSION).not.toBe(LATEST_PROTOCOL_VERSION);
expect(client.getServerCapabilities()).toEqual({});
expect(client.getServerVersion()).toEqual({ name: 's', version: '0' });
});
verifies('lifecycle:version:server-fallback-latest', async ({ transport }: TestArgs) => {
const client = minimalClient();
// The SDK client only ever requests the latest version, so the unsupported version is injected into the outbound initialize; the server runs the SDK's default initialize handler.
const log = tapHandshake(client, message =>
isJSONRPCRequest(message) && message.method === 'initialize'
? { ...message, params: { ...message.params, protocolVersion: BOGUS_VERSION } }
: message
);
expect(SUPPORTED_PROTOCOL_VERSIONS).not.toContain(BOGUS_VERSION);
await using _ = await wire(transport, minimalServer, client);
const initRequest = log.find(
e => e.direction === 'client-to-server' && isJSONRPCRequest(e.message) && e.message.method === 'initialize'
);
if (!initRequest || !isJSONRPCRequest(initRequest.message)) throw new Error('expected an initialize request on the wire');
expect(initRequest.message.params?.protocolVersion).toBe(BOGUS_VERSION);
const initRequestId = initRequest.message.id;
const initResponse = log.find(
e => e.direction === 'server-to-client' && isJSONRPCResultResponse(e.message) && e.message.id === initRequestId
);
if (!initResponse || !isJSONRPCResultResponse(initResponse.message)) throw new Error('expected a result for the initialize request');
expect(initResponse.message.result.protocolVersion).toBe(LATEST_PROTOCOL_VERSION);
// The client accepted the fallback version: initialization completed and server state is populated.
expect(client.getServerVersion()).toEqual({ name: 'minimal-server', version: '0.0.0' });
expect(client.getServerCapabilities()).toEqual({});
});
verifies('lifecycle:version:reject-unsupported', async ({ transport }: TestArgs) => {
const initReqs: InitializeRequest['params'][] = [];
const makeServer = () => recordingInitServer(BOGUS_VERSION, initReqs);
const client = minimalClient();
await expect(wire(transport, makeServer, client)).rejects.toThrow(/protocol version.*not supported|1999-01-01/i);
expect(client.transport).toBeUndefined();
expect(client.getServerCapabilities()).toBeUndefined();
expect(client.getServerVersion()).toBeUndefined();
});
verifies('lifecycle:capability:client-not-declared', async ({ transport }: TestArgs) => {
let observedCaps: ClientCapabilities | undefined;
const makeServer = () => {
const s = minimalServer();
s.server.oninitialized = () => {
observedCaps = s.server.getClientCapabilities();
};
return s;
};
const client = new Client({ name: 'no-caps-client', version: '0.0.0' }, { capabilities: {} });
await using _ = await wire(transport, makeServer, client);
// Client side: cannot send notifications / register handlers for undeclared caps.
await expect(client.sendRootsListChanged()).rejects.toThrow(/roots.*list.?changed/i);
expect(() =>
client.setRequestHandler(CreateMessageRequestSchema, async () => ({
role: 'assistant',
content: { type: 'text', text: 'unreachable' },
model: 'stub'
}))
).toThrow(/sampling/i);
expect(() => client.setRequestHandler(ElicitRequestSchema, async () => ({ action: 'cancel' }))).toThrow(/elicitation/i);
expect(() => client.setRequestHandler(ListRootsRequestSchema, async () => ({ roots: [] }))).toThrow(/roots/i);
// Server side: it sees the empty client capabilities. (Server-side request
// gating on these is opt-in via `enforceStrictCapabilities` and is covered
// by the elicitation/sampling capability tests, not here.)
expect(observedCaps).toEqual({});
});
verifies('lifecycle:capability:server-not-advertised', async ({ transport }: TestArgs) => {
const makeServer = () => new McpServer({ name: 's', version: '0' }, { capabilities: {} });
const client = new Client({ name: 'c', version: '0' }, { enforceStrictCapabilities: true });
await using _ = await wire(transport, makeServer, client);
const caps = client.getServerCapabilities();
expect(caps).toEqual({});
const calls: Array<[string, () => Promise<unknown>]> = [
['tools', () => client.listTools()],
['resources', () => client.listResources()],
['resources', () => client.listResourceTemplates()],
['prompts', () => client.listPrompts()],
['logging', () => client.setLoggingLevel('debug')],
['completions', () => client.complete({ ref: { type: 'ref/prompt', name: 'x' }, argument: { name: 'a', value: '' } })]
];
for (const [cap, call] of calls) {
await expect(call(), `${cap} should be gated`).rejects.toThrow(new RegExp(cap, 'i'));
}
});
verifies('lifecycle:initialize:capabilities:minimal', async ({ transport }: TestArgs) => {
// Bare McpServer: no feature handlers registered and no capabilities option passed.
const client = minimalClient();
await using _ = await wire(transport, minimalServer, client);
// Exactly empty: no tools, resources, prompts, completions, or logging advertised.
expect(client.getServerCapabilities()).toEqual({});
});
verifies('lifecycle:capability:experimental-passthrough', async ({ transport }: TestArgs) => {
const SERVER_EXPERIMENTAL = {
'x-vendor/streaming': { version: 2, modes: ['delta', 'full'] },
'org.example.preview': { nested: { limit: 42 }, enabled: true }
};
const CLIENT_EXPERIMENTAL = {
'x-vendor/telemetry': { level: 'debug', sinks: ['stdout'] }
};
let observedClientCaps: ClientCapabilities | undefined;
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' }, { capabilities: { experimental: SERVER_EXPERIMENTAL } });
s.server.oninitialized = () => {
observedClientCaps = s.server.getClientCapabilities();
};
return s;
};
const client = new Client(
{ name: 'c', version: '0' },
{ capabilities: { roots: { listChanged: true }, experimental: CLIENT_EXPERIMENTAL } }
);
await using _ = await wire(transport, makeServer, client);
// Server → client direction.
const serverCaps = client.getServerCapabilities();
expect(serverCaps?.experimental).toEqual(SERVER_EXPERIMENTAL);
expect(serverCaps?.experimental?.['x-vendor/streaming']).toEqual({ version: 2, modes: ['delta', 'full'] });
expect(serverCaps?.experimental?.['x-vendor/undeclared']).toBeUndefined();
// Client → server direction (symmetric).
expect(observedClientCaps?.experimental).toEqual(CLIENT_EXPERIMENTAL);
expect(observedClientCaps?.experimental?.['x-vendor/undeclared']).toBeUndefined();
});
verifies('lifecycle:initialize:server-info', async ({ transport }: TestArgs) => {
const extendedServerInfo: Implementation = {
name: 'everything-extended',
version: '1.2.3',
title: 'Everything Server (Extended)',
websiteUrl: 'https://example.com/everything',
description: 'Reference everything-server with all optional Implementation fields populated.',
icons: [
{ src: 'https://example.com/icon-48.png', mimeType: 'image/png', sizes: ['48x48'] },
{ src: 'https://example.com/icon.svg', mimeType: 'image/svg+xml', sizes: ['any'] }
]
};
const makeServer = () => new McpServer(extendedServerInfo);
const client = minimalClient();
await using _ = await wire(transport, makeServer, client);
expect(client.getServerVersion()).toEqual(extendedServerInfo);
});
verifies('lifecycle:initialize:client-info', async ({ transport }: TestArgs) => {
let observed: { before: Implementation | undefined; after: Implementation | undefined } | undefined;
const makeServer = () => {
const s = minimalServer();
const before = s.server.getClientVersion();
s.server.oninitialized = () => {
observed = { before, after: s.server.getClientVersion() };
};
return s;
};
const client = minimalClient();
await using _ = await wire(transport, makeServer, client);
expect(observed?.before).toBeUndefined();
expect(observed?.after).toEqual({ name: 'minimal-client', version: '0.0.0' });
});
verifies('typescript:server:get-client-capabilities', async ({ transport }: TestArgs) => {
const DECLARED = {
roots: { listChanged: true },
sampling: {},
experimental: { 'e2e-cap-marker': {} }
};
let observed:
| { before: ClientCapabilities | undefined; after: ClientCapabilities | undefined; second: ClientCapabilities | undefined }
| undefined;
const makeServer = () => {
const s = minimalServer();
const before = s.server.getClientCapabilities();
s.server.oninitialized = () => {
const after = s.server.getClientCapabilities();
observed = { before, after, second: s.server.getClientCapabilities() };
};
return s;
};
const client = new Client({ name: 'caps-probe-client', version: '0.0.0' }, { capabilities: DECLARED });
await using _ = await wire(transport, makeServer, client);
expect(observed?.before).toBeUndefined();
expect(observed?.after).toEqual(DECLARED);
expect(observed?.after?.elicitation).toBeUndefined();
// Stable reference across calls.
expect(observed?.second).toBe(observed?.after);
});

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