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

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

View file

@ -43,6 +43,22 @@ jobs:
- run: npm ci
- run: npm test
test-e2e:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: [18, 24]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- run: npm run test:e2e
publish:
runs-on: ubuntu-latest
if: github.event_name == 'release'

View file

@ -30,5 +30,13 @@ export default tseslint.config(
'no-console': 'error'
}
},
{
// The e2e suite's `await using _ = await wire(...)` disposal idiom binds a
// variable solely for its disposer; allow _-prefixed unused variables there.
files: ['test/e2e/**/*.ts'],
rules: {
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }]
}
},
eslintConfigPrettier
);

View file

@ -88,7 +88,8 @@
"lint": "eslint src/ && prettier --check .",
"lint:fix": "eslint src/ --fix && prettier --write .",
"check": "npm run typecheck && npm run lint",
"test": "vitest run",
"test": "vitest run --exclude \"test/e2e/**\"",
"test:e2e": "vitest run test/e2e",
"test:watch": "vitest",
"start": "npm run server",
"server": "tsx watch --clear-screen=false scripts/cli.ts server",

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

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

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

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

View file

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

View file

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

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

@ -0,0 +1,286 @@
/**
* Test wiring helpers.
*
* `wire(transport, makeServer, client)` connects a server (built per call by
* `makeServer`) and a client over the named transport, returning an
* `AsyncDisposable` for `await using` teardown. All wiring is in-process —
* no real sockets, no child processes — except the legacy SSE transport, whose
* server half requires Node req/res and therefore runs over a real loopback
* HTTP listener on an ephemeral port.
*/
import { randomUUID } from 'node:crypto';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { PassThrough } from 'node:stream';
import type { Client } from '../../../src/client/index.js';
import { SSEClientTransport } from '../../../src/client/sse.js';
import { StreamableHTTPClientTransport } from '../../../src/client/streamableHttp.js';
import { InMemoryTransport } from '../../../src/inMemory.js';
import type { Server } from '../../../src/server/index.js';
import type { McpServer } from '../../../src/server/mcp.js';
import { SSEServerTransport } from '../../../src/server/sse.js';
import { StdioServerTransport } from '../../../src/server/stdio.js';
import { WebStandardStreamableHTTPServerTransport, type EventStore } from '../../../src/server/webStandardStreamableHttp.js';
import { ReadBuffer, serializeMessage } from '../../../src/shared/stdio.js';
import type { JSONRPCMessage } from '../../../src/types.js';
import type { Transport } from '../types.js';
import { sniffTransport, type SnifferOptions } from './wire-sniffer.js';
export type ServerFactory = () => McpServer | Server;
export interface Wired extends AsyncDisposable {
readonly fetch?: (url: URL | string, init?: RequestInit) => Promise<Response>;
readonly url?: URL;
}
/**
* The fourth argument controls the wire-format sniffer (see wire-sniffer.ts):
* every message the client sends or receives is validated against the SDK's
* spec-anchored Zod schemas. Tests that intentionally use vendor-extension
* methods pass `{ allowCustomMethods: true }`; tests that deliberately put
* malformed MCP on the wire pass `{ strictValidation: false }`.
*/
export async function wire(transport: Transport, makeServer: ServerFactory, client: Client, sniff: SnifferOptions = {}): Promise<Wired> {
switch (transport) {
case 'inMemory': {
const server = makeServer();
const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
await server.connect(serverTx);
await client.connect(sniffTransport(clientTx, 'client', sniff));
return { [Symbol.asyncDispose]: () => Promise.all([client.close(), server.close()]).then(() => {}) };
}
case 'stdio': {
const server = makeServer();
const c2s = new PassThrough();
const s2c = new PassThrough();
await server.connect(new StdioServerTransport(c2s, s2c));
await client.connect(sniffTransport(stdioClientOverPipes(s2c, c2s), 'client', sniff));
return { [Symbol.asyncDispose]: () => Promise.all([client.close(), server.close()]).then(() => {}) };
}
case 'streamableHttp':
case 'streamableHttpStateless': {
const handle = transport === 'streamableHttpStateless' ? hostStateless(makeServer) : hostPerSession(makeServer);
const url = new URL('http://in-process/mcp');
const fetch = (u: URL | string, init?: RequestInit) => handle.handleRequest(new Request(u, init));
await client.connect(sniffTransport(new StreamableHTTPClientTransport(url, { fetch }), 'client', sniff));
return {
fetch,
url,
[Symbol.asyncDispose]: () => Promise.all([client.close(), handle.close()]).then(() => {})
};
}
case 'sse': {
// The legacy SSE server transport writes to a Node ServerResponse, so this branch hosts it
// on a real loopback listener: GET /sse opens the stream (one server instance per
// connection, mirroring hostPerSession) and POST /messages?sessionId=… delivers messages.
const sessions = new Map<string, { tx: SSEServerTransport; server: McpServer | Server }>();
const handleSseRequest = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
const reqUrl = new URL(req.url ?? '/', 'http://127.0.0.1');
if (req.method === 'GET' && reqUrl.pathname === '/sse') {
const tx = new SSEServerTransport('/messages', res);
const server = makeServer();
sessions.set(tx.sessionId, { tx, server });
tx.onclose = () => void sessions.delete(tx.sessionId);
await server.connect(tx);
return;
}
if (req.method === 'POST' && reqUrl.pathname === '/messages') {
const session = sessions.get(reqUrl.searchParams.get('sessionId') ?? '');
if (!session) {
res.writeHead(404, { 'Content-Type': 'text/plain' }).end('Session not found');
return;
}
await session.tx.handlePostMessage(req, res);
return;
}
res.writeHead(404).end();
};
const httpServer = createServer((req, res) => {
handleSseRequest(req, res).catch(() => {
// Mirror the SDK's own SSE test harness: handler failures become a 500, not an unhandled rejection.
if (!res.headersSent) res.writeHead(500).end();
});
});
await new Promise<void>(resolve => httpServer.listen(0, '127.0.0.1', resolve));
const address = httpServer.address();
if (address === null || typeof address === 'string') throw new Error('expected the SSE host to listen on a TCP port');
const url = new URL(`http://127.0.0.1:${address.port}/sse`);
await client.connect(sniffTransport(new SSEClientTransport(url), 'client', sniff));
return {
url,
[Symbol.asyncDispose]: async () => {
await client.close();
for (const { tx, server } of sessions.values()) {
await server.close();
await tx.close();
}
sessions.clear();
httpServer.closeAllConnections();
await new Promise<void>(resolve => httpServer.close(() => resolve()));
}
};
}
}
}
/**
* Tap a connected client's transport so every JSON-RPC message crossing the
* wire is recorded. `sent` = client→server, `received` = server→client.
* Call after `wire()` so `client.transport` is set. The transport is
* monkey-patched in place; teardown via `await using` on `wire()` discards it.
*/
export function tapWire(client: Client): { sent: JSONRPCMessage[]; received: JSONRPCMessage[] } {
const tx = client.transport;
if (!tx) throw new Error('tapWire: client not connected');
const sent: JSONRPCMessage[] = [];
const received: JSONRPCMessage[] = [];
const origSend = tx.send.bind(tx);
const origOnMessage = tx.onmessage;
tx.send = async (m, opts) => {
sent.push(m);
return origSend(m, opts);
};
tx.onmessage = (m, extra) => {
received.push(m);
origOnMessage?.(m, extra);
};
return { sent, received };
}
// ───────────────────────────────────────────────────────────────────────────────
// HTTP hosting (the two production patterns)
// ───────────────────────────────────────────────────────────────────────────────
export type HttpHandler = (req: Request) => Promise<Response>;
export function hostPerSession(makeServer: ServerFactory): { handleRequest: HttpHandler; close(): Promise<void> } {
const sessions = new Map<string, WebStandardStreamableHTTPServerTransport>();
return {
handleRequest: async req => {
const sid = req.headers.get('mcp-session-id') ?? undefined;
const existing = sid ? sessions.get(sid) : undefined;
if (existing) return existing.handleRequest(req);
if (sid !== undefined) {
// Mirror the SDK's documented hosting pattern: an unrecognized session id is
// rejected at the app level, so the transport's own 404 is never reached.
return new Response(
JSON.stringify({
jsonrpc: '2.0',
error: { code: -32000, message: 'Bad Request: No valid session ID provided' },
id: null
}),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const tx = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: randomUUID,
onsessioninitialized: id => void sessions.set(id, tx),
onsessionclosed: id => void sessions.delete(id)
});
await makeServer().connect(tx);
return tx.handleRequest(req);
},
close: async () => {
for (const t of sessions.values()) await t.close();
sessions.clear();
}
};
}
export interface ResumeHostOptions {
eventStore: EventStore;
retryInterval?: number;
}
export function hostResumable(makeServer: ServerFactory, opts: ResumeHostOptions): { handleRequest: HttpHandler; close(): Promise<void> } {
const sessions = new Map<string, { tx: WebStandardStreamableHTTPServerTransport; server: McpServer | Server }>();
return {
handleRequest: async req => {
const sid = req.headers.get('mcp-session-id') ?? undefined;
const existing = sid ? sessions.get(sid) : undefined;
if (existing) return existing.tx.handleRequest(req);
const tx = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: randomUUID,
eventStore: opts.eventStore,
retryInterval: opts.retryInterval,
onsessioninitialized: id => void sessions.set(id, { tx, server }),
onsessionclosed: id => void sessions.delete(id)
});
const server = makeServer();
await server.connect(tx);
return tx.handleRequest(req);
},
close: async () => {
for (const { tx, server } of sessions.values()) {
await server.close();
await tx.close();
}
sessions.clear();
}
};
}
export function hostStateless(makeServer: ServerFactory): { handleRequest: HttpHandler; close(): Promise<void> } {
const cleanups: Array<() => Promise<void>> = [];
return {
handleRequest: async req => {
if (req.method !== 'POST') {
return new Response(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: 'Method not allowed.' }, id: null }), {
status: 405,
headers: { 'Content-Type': 'application/json' }
});
}
const server = makeServer();
const tx = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
await server.connect(tx);
cleanups.push(async () => {
await server.close();
await tx.close();
});
return tx.handleRequest(req);
},
close: async () => {
for (const c of cleanups) await c();
}
};
}
// ───────────────────────────────────────────────────────────────────────────────
// In-process stdio client — TEST-ONLY
//
// Production stdio uses `StdioClientTransport`, which spawns a child process.
// This is the in-process equivalent for tests: same newline-framed JSON wire
// format (uses the SDK's `serializeMessage`/`ReadBuffer`), but over PassThrough
// streams instead of a spawned process. Tests that specifically exercise spawn,
// env, signals, or stderr must use the real `StdioClientTransport`.
// ───────────────────────────────────────────────────────────────────────────────
function stdioClientOverPipes(serverStdout: NodeJS.ReadableStream, serverStdin: NodeJS.WritableStream) {
const buf = new ReadBuffer();
return {
onmessage: undefined as ((m: JSONRPCMessage) => void) | undefined,
onerror: undefined as ((e: Error) => void) | undefined,
onclose: undefined as (() => void) | undefined,
async start() {
serverStdout.on('data', chunk => {
buf.append(chunk);
let m: JSONRPCMessage | null;
while ((m = buf.readMessage())) this.onmessage?.(m);
});
serverStdout.on('error', e => this.onerror?.(e));
serverStdout.on('close', () => this.onclose?.());
},
async send(m: JSONRPCMessage) {
serverStdin.write(serializeMessage(m));
},
async close() {
serverStdin.end();
}
};
}

View file

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

View file

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

View file

@ -0,0 +1,142 @@
/**
* Wire-format sniffer: asserts every JSON-RPC message crossing a transport is a
* well-formed MCP message for its direction.
*
* Validation runs against the SDK's runtime Zod schemas in `src/types.ts`
* (`ClientRequestSchema`, `ServerResultSchema`, …). Those schemas are proven
* equivalent to the spec-synced `src/spec.types.ts` by `test/spec.types.test.ts`
* (mutual assignability + per-type completeness). So conformance here is
* transitively conformance to the spec, with `spec.types.ts` as the anchor: if
* the SDK schemas ever drift off-spec, that equivalence test goes red first.
*
* Two layers:
* 1. JSON-RPC envelope (always) — request / notification / response / error.
* 2. MCP shape (unless `strictValidation: false`) — the method is a spec
* method for the sender's direction and its params/result match the schema.
* Vendor-extension method names are rejected unless `allowCustomMethods`.
*/
import {
ClientNotificationSchema,
ClientRequestSchema,
ClientResultSchema,
isJSONRPCErrorResponse,
isJSONRPCNotification,
isJSONRPCRequest,
isJSONRPCResultResponse,
JSONRPCMessageSchema,
ServerNotificationSchema,
ServerRequestSchema,
ServerResultSchema
} from '../../../src/types.js';
import type { Transport } from '../../../src/shared/transport.js';
export type WireParty = 'client' | 'server';
export interface SnifferOptions {
/** Permit non-spec (vendor-extension) method names. Spec methods stay strict. */
allowCustomMethods?: boolean;
/** `false` → envelope check only (for tests that deliberately send malformed messages). */
strictValidation?: boolean;
}
const OUTBOUND = {
client: { request: ClientRequestSchema, notification: ClientNotificationSchema, result: ClientResultSchema },
server: { request: ServerRequestSchema, notification: ServerNotificationSchema, result: ServerResultSchema }
} as const;
/** Method names valid as an outbound request/notification for each party. */
const SPEC_METHODS: Record<WireParty, { request: Set<string>; notification: Set<string> }> = {
client: { request: methodSet(ClientRequestSchema), notification: methodSet(ClientNotificationSchema) },
server: { request: methodSet(ServerRequestSchema), notification: methodSet(ServerNotificationSchema) }
};
function methodSet(union: { options?: ReadonlyArray<{ shape?: { method?: { value?: string } } }> }): Set<string> {
const out = new Set<string>();
for (const member of union.options ?? []) {
const v = member.shape?.method?.value;
if (typeof v === 'string') out.add(v);
}
return out;
}
function fail(party: WireParty, reason: string, msg: unknown): never {
throw new Error(`[wire] ${party} sent an invalid message: ${reason}\n${JSON.stringify(msg, null, 2)}`);
}
/**
* Assert a single message is valid for the given sending party.
* @param msg the raw JSON-RPC message
* @param party who put it on the wire (`client` outbound = ClientRequest/Notification/Result)
*/
export function assertWireMessage(msg: unknown, party: WireParty, opts: SnifferOptions = {}): void {
if (!JSONRPCMessageSchema.safeParse(msg).success) {
fail(party, 'not a JSON-RPC message', msg);
}
if (opts.strictValidation === false) return;
const schemas = OUTBOUND[party];
if (isJSONRPCRequest(msg) || isJSONRPCNotification(msg)) {
const kind = isJSONRPCRequest(msg) ? 'request' : 'notification';
const method = (msg as { method: string }).method;
if (!SPEC_METHODS[party][kind].has(method)) {
if (opts.allowCustomMethods) return;
fail(party, `non-spec ${kind} method '${method}' (pass { allowCustomMethods: true } if intentional)`, msg);
}
const params = (msg as { params?: unknown }).params;
const r = schemas[kind].safeParse({ method, params });
if (!r.success) {
fail(party, `spec method '${method}' params do not conform: ${r.error.message}`, msg);
}
return;
}
if (isJSONRPCResultResponse(msg)) {
const result = (msg as { result: unknown }).result;
const r = schemas.result.safeParse(result);
if (!r.success) {
// A result for a vendor-extension request legitimately won't match the spec union.
if (opts.allowCustomMethods) return;
fail(party, `result does not conform to any spec result: ${r.error.message}`, msg);
}
return;
}
if (isJSONRPCErrorResponse(msg)) return; // envelope already validated; error bodies are not method-specific
}
/**
* Wrap a transport so every outbound `send` (validated as `party`) and inbound
* `onmessage` (validated as the counterpart) is asserted. Returns the same
* transport instance (monkey-patched in place).
*/
export function sniffTransport<T extends Transport>(transport: T, party: WireParty, opts: SnifferOptions = {}): T {
const counterpart: WireParty = party === 'client' ? 'server' : 'client';
const origSend = transport.send.bind(transport);
transport.send = (message, sendOpts) => {
assertWireMessage(message, party, opts);
return origSend(message, sendOpts);
};
// `onmessage` is assigned by Protocol.connect() after we wrap. Intercept via
// an accessor so we wrap whatever handler it installs, validating each
// inbound message (sent by the counterpart) before passing it through.
let handler: Transport['onmessage'];
Object.defineProperty(transport, 'onmessage', {
configurable: true,
enumerable: true,
get: () => handler,
set: next => {
handler = next
? (message, extra) => {
assertWireMessage(message, counterpart, opts);
return next(message, extra);
}
: next;
}
});
return transport;
}

2454
test/e2e/requirements.ts Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

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