Add end-to-end test suite (#2167)
This commit is contained in:
parent
bf1e022bd2
commit
c36e1ef5bb
33 changed files with 19371 additions and 1 deletions
64
test/e2e/CLAUDE.md
Normal file
64
test/e2e/CLAUDE.md
Normal 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
97
test/e2e/coverage.test.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
61
test/e2e/fixtures/stdio-server.ts
Normal file
61
test/e2e/fixtures/stdio-server.ts
Normal 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');
|
||||
}
|
||||
63
test/e2e/helpers/express.ts
Normal file
63
test/e2e/helpers/express.ts
Normal 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
286
test/e2e/helpers/index.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
}
|
||||
53
test/e2e/helpers/verifies.ts
Normal file
53
test/e2e/helpers/verifies.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
69
test/e2e/helpers/wire-sniffer.test.ts
Normal file
69
test/e2e/helpers/wire-sniffer.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
142
test/e2e/helpers/wire-sniffer.ts
Normal file
142
test/e2e/helpers/wire-sniffer.ts
Normal 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
2454
test/e2e/requirements.ts
Normal file
File diff suppressed because it is too large
Load diff
1747
test/e2e/scenarios/client-auth.test.ts
Normal file
1747
test/e2e/scenarios/client-auth.test.ts
Normal file
File diff suppressed because it is too large
Load diff
355
test/e2e/scenarios/completion.test.ts
Normal file
355
test/e2e/scenarios/completion.test.ts
Normal 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' }
|
||||
);
|
||||
359
test/e2e/scenarios/dynamic.test.ts
Normal file
359
test/e2e/scenarios/dynamic.test.ts
Normal 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' }]);
|
||||
});
|
||||
817
test/e2e/scenarios/elicitation.test.ts
Normal file
817
test/e2e/scenarios/elicitation.test.ts
Normal 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([]);
|
||||
});
|
||||
840
test/e2e/scenarios/flow.test.ts
Normal file
840
test/e2e/scenarios/flow.test.ts
Normal 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 });
|
||||
});
|
||||
140
test/e2e/scenarios/hosting-auth.test.ts
Normal file
140
test/e2e/scenarios/hosting-auth.test.ts
Normal 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}`]);
|
||||
});
|
||||
780
test/e2e/scenarios/hosting-express.test.ts
Normal file
780
test/e2e/scenarios/hosting-express.test.ts
Normal 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' });
|
||||
});
|
||||
1031
test/e2e/scenarios/hosting-http.test.ts
Normal file
1031
test/e2e/scenarios/hosting-http.test.ts
Normal file
File diff suppressed because it is too large
Load diff
840
test/e2e/scenarios/hosting-resume.test.ts
Normal file
840
test/e2e/scenarios/hosting-resume.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
942
test/e2e/scenarios/hosting-session.test.ts
Normal file
942
test/e2e/scenarios/hosting-session.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
461
test/e2e/scenarios/lifecycle.test.ts
Normal file
461
test/e2e/scenarios/lifecycle.test.ts
Normal 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
Loading…
Reference in a new issue