Add end-to-end test suite (#2167)
This commit is contained in:
parent
bf1e022bd2
commit
c36e1ef5bb
36 changed files with 19371 additions and 1 deletions
16
.github/workflows/main.yml
vendored
16
.github/workflows/main.yml
vendored
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
64
test/e2e/CLAUDE.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# E2E test suite
|
||||
|
||||
Conformance-style tests for the SDK's public surface. `requirements.ts` is the manifest: every behavior the SDK must satisfy, linked to the test cases that prove it. `matrix.test.ts` runs each over `ALL_TRANSPORTS`.
|
||||
|
||||
## Writing a test
|
||||
|
||||
A test is an exported async function in `scenarios/<area>.ts`:
|
||||
|
||||
```ts
|
||||
export async function toolsCallContentText(transport: Transport) {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 't', version: '0' });
|
||||
s.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({ content: [{ type: 'text', text }] }));
|
||||
return s;
|
||||
};
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'echo', arguments: { text: 'hi' } });
|
||||
expect(r.content).toEqual([{ type: 'text', text: 'hi' }]);
|
||||
}
|
||||
```
|
||||
|
||||
Self-contained: build server inline (factory), build client inline, `wire()`, assert. No shared fixture files.
|
||||
|
||||
Then link it in `requirements.ts`:
|
||||
|
||||
```ts
|
||||
'tools:call:content:text': {
|
||||
source: 'https://modelcontextprotocol.io/...',
|
||||
behavior: 'tools/call returns content[] with type:text...',
|
||||
tests: [tools.toolsCallContentText]
|
||||
},
|
||||
```
|
||||
|
||||
## knownFailures and transport restrictions
|
||||
|
||||
When a test asserts spec-correct behavior the SDK doesn't yet implement:
|
||||
|
||||
```ts
|
||||
knownFailures: [{ test: tools.toolsCallUnknownName, note: 'McpServer wraps as isError; spec says JSON-RPC error' }];
|
||||
```
|
||||
|
||||
`matrix.test.ts` runs it as `test.fails()` — passes when it fails as expected, fails when the SDK is fixed (then remove the entry).
|
||||
|
||||
When a transport structurally cannot express the behavior (e.g., server→client roundtrip on stateless hosting), restrict the requirement itself rather than skipping tests:
|
||||
|
||||
```ts
|
||||
transports: STATEFUL_TRANSPORTS, // or an explicit list
|
||||
note: 'stateless hosting has no server→client back-channel'
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
npx vitest run test/e2e # all
|
||||
npx vitest run test/e2e/tools.test.ts # one area
|
||||
npx vitest run test/e2e -t 'tools:' # one requirement-id prefix
|
||||
npx vitest run test/e2e/coverage.test.ts # gate: every req id is cited by a verifies() test
|
||||
```
|
||||
|
||||
Slugs prefixed `typescript:` are TypeScript-SDK-specific requirements (they describe this SDK's own API surface and intentionally have no shared cross-SDK meaning); unprefixed slugs share their id and behavior wording with the Python interaction suite where both cover the
|
||||
behavior.
|
||||
97
test/e2e/coverage.test.ts
Normal file
97
test/e2e/coverage.test.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* Manifest gates for the e2e suite.
|
||||
*
|
||||
* The linkage is inverted: test files cite the requirement id(s) they prove via
|
||||
* `verifies(...)` (helpers/verifies.ts) and requirements.ts is pure data. These
|
||||
* tests statically scan test/e2e/scenarios/*.test.ts for the cited ids and check them
|
||||
* against the manifest, plus the manifest's own internal consistency rules.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { REQUIREMENTS } from './requirements.js';
|
||||
|
||||
const E2E_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
interface VerifiesCall {
|
||||
file: string;
|
||||
/** Explicit `{ title: '...' }` passed to verifies(), if any (undefined for an untitled body). */
|
||||
title: string | undefined;
|
||||
ids: string[];
|
||||
}
|
||||
|
||||
/** Statically scan test/e2e/scenarios/*.test.ts for `verifies(<ids>, ...)` calls. */
|
||||
function scanVerifiesCalls(): VerifiesCall[] {
|
||||
const calls: VerifiesCall[] = [];
|
||||
const scenariosDir = join(E2E_DIR, 'scenarios');
|
||||
const files = readdirSync(scenariosDir)
|
||||
.filter(f => f.endsWith('.test.ts'))
|
||||
.sort();
|
||||
for (const file of files) {
|
||||
const text = readFileSync(join(scenariosDir, file), 'utf8');
|
||||
// Each call spans from its header to the first column-0 close (`});` for an
|
||||
// untitled hugged call, `);` for a call expanded by an opts third argument).
|
||||
for (const m of text.matchAll(/verifies\(\s*('[^']*'|\[[^\]]*\])\s*,\s*async\s*\([\s\S]*?\n(?:\}\);|\);)/g)) {
|
||||
const ids = [...m[1].matchAll(/'([^']*)'/g)].map(x => x[1]);
|
||||
const title = m[0].match(/\{\s*title:\s*'([^']*)'\s*\}\s*\n?\);$/)?.[1];
|
||||
calls.push({ file, title, ids });
|
||||
}
|
||||
}
|
||||
return calls;
|
||||
}
|
||||
|
||||
const CALLS = scanVerifiesCalls();
|
||||
const CITED = new Set(CALLS.flatMap(c => c.ids));
|
||||
|
||||
test('every non-deferred requirement id is cited by at least one verifies() call', () => {
|
||||
const missing = Object.entries(REQUIREMENTS)
|
||||
.filter(([id, r]) => !r.deferred && !CITED.has(id))
|
||||
.map(([id]) => id);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
test('every cited requirement id exists in the manifest and is not deferred', () => {
|
||||
const bad: string[] = [];
|
||||
for (const c of CALLS) {
|
||||
for (const id of c.ids) {
|
||||
const req = REQUIREMENTS[id];
|
||||
if (!req) bad.push(`${c.file}: a verifies() call cites unknown requirement '${id}'`);
|
||||
else if (req.deferred) bad.push(`${c.file}: a verifies() call cites deferred requirement '${id}'`);
|
||||
}
|
||||
}
|
||||
expect(bad).toEqual([]);
|
||||
});
|
||||
|
||||
test('every knownFailure with a test string names an explicit verifies() title that cites the requirement', () => {
|
||||
const bad: string[] = [];
|
||||
for (const [id, r] of Object.entries(REQUIREMENTS)) {
|
||||
for (const kf of r.knownFailures ?? []) {
|
||||
if (kf.test === undefined) continue;
|
||||
const cited = CALLS.some(c => c.title === kf.test && c.ids.includes(id));
|
||||
if (!cited)
|
||||
bad.push(
|
||||
`${id}: knownFailure references title '${kf.test}', which is not an explicit verifies() title citing this requirement`
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(bad).toEqual([]);
|
||||
});
|
||||
|
||||
test('every transport-restricted requirement explains why in note', () => {
|
||||
const missing = Object.entries(REQUIREMENTS)
|
||||
.filter(([, r]) => r.transports !== undefined && !r.note)
|
||||
.map(([id]) => id);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
test('every supersedes reference points at an existing requirement id', () => {
|
||||
for (const [id, req] of Object.entries(REQUIREMENTS)) {
|
||||
if (req.supersedes !== undefined) {
|
||||
expect(REQUIREMENTS[req.supersedes], `${id} supersedes unknown id '${req.supersedes}'`).toBeDefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
61
test/e2e/fixtures/stdio-server.ts
Normal file
61
test/e2e/fixtures/stdio-server.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* Runnable stdio MCP server fixture for the transport:stdio:* e2e tests.
|
||||
*
|
||||
* Spawned as a real child process by test/e2e/scenarios/stdio.ts. Registers a
|
||||
* single `echo` tool, writes a readiness marker line to stderr once it is
|
||||
* serving, and — when E2E_IGNORE_SIGTERM=1 — keeps running after stdin EOF and
|
||||
* swallows SIGTERM so the client transport's shutdown escalation
|
||||
* (stdin EOF → SIGTERM → SIGKILL) is observable.
|
||||
*/
|
||||
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { McpServer } from '../../../src/server/mcp.js';
|
||||
import { StdioServerTransport } from '../../../src/server/stdio.js';
|
||||
|
||||
const server = new McpServer({ name: 'stdio-echo-server', version: '1.0.0' });
|
||||
|
||||
server.registerTool(
|
||||
'echo',
|
||||
{
|
||||
description: 'Echoes the input text back as a text content block, including multi-line text.',
|
||||
inputSchema: z.object({ text: z.string() })
|
||||
},
|
||||
({ text }) => ({ content: [{ type: 'text', text }] })
|
||||
);
|
||||
|
||||
// env-report tool: returns JSON array of environment variable names (sorted) that
|
||||
// reached the child process. This allows tests to verify the env safelist behavior.
|
||||
server.registerTool(
|
||||
'env-report',
|
||||
{
|
||||
description: 'Returns sorted array of environment variable names present in this process.',
|
||||
inputSchema: z.object({})
|
||||
},
|
||||
() => {
|
||||
const envKeys = Object.keys(process.env).sort();
|
||||
return { content: [{ type: 'text', text: JSON.stringify(envKeys) }] };
|
||||
}
|
||||
);
|
||||
|
||||
if (process.env.E2E_IGNORE_SIGTERM === '1') {
|
||||
// Misbehaving-server mode: keep alive after stdin EOF via interval (load-bearing — without it the child exits on stdin EOF and SIGTERM never arrives) and ignore SIGTERM, so only SIGKILL can end the process.
|
||||
setInterval(() => {}, 1_000);
|
||||
setTimeout(() => process.exit(1), 30_000);
|
||||
process.on('SIGTERM', () => {
|
||||
process.stderr.write('[stdio-server] sigterm ignored\n');
|
||||
});
|
||||
}
|
||||
|
||||
if (process.env.E2E_GARBAGE_STDOUT === '1') {
|
||||
// Broken-server mode: write non-JSON garbage to stdout before the server connects, simulating a broken or misconfigured server that pollutes the JSON-RPC channel.
|
||||
process.stdout.write('GARBAGE LINE 1: not json\n');
|
||||
process.stdout.write('GARBAGE LINE 2: {malformed json\n');
|
||||
process.stdout.write('GARBAGE LINE 3: also not valid jsonrpc\n');
|
||||
process.stdin.resume();
|
||||
process.stdin.on('end', () => process.exit(0));
|
||||
setTimeout(() => process.exit(1), 30_000);
|
||||
} else {
|
||||
await server.connect(new StdioServerTransport());
|
||||
process.stderr.write('[stdio-server] ready\n');
|
||||
}
|
||||
63
test/e2e/helpers/express.ts
Normal file
63
test/e2e/helpers/express.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* Express helper for hosting-related e2e tests.
|
||||
*
|
||||
* Builds real Express apps with SDK middleware, listens on ephemeral ports,
|
||||
* closes cleanly. Used by hosting-express.ts scenarios.
|
||||
*/
|
||||
|
||||
import express, { Express, RequestHandler } from 'express';
|
||||
import type { Server as HttpServer } from 'node:http';
|
||||
|
||||
import { hostHeaderValidation } from '../../../src/server/middleware/hostHeaderValidation.js';
|
||||
|
||||
export interface ExpressHost extends AsyncDisposable {
|
||||
readonly baseUrl: URL;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
async function listen(app: Express, host = '127.0.0.1'): Promise<{ baseUrl: URL; close: () => Promise<void> }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server: HttpServer = app.listen(0, host, () => {
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === 'string') {
|
||||
reject(new Error(`listen failed: ${addr}`));
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
baseUrl: new URL(`http://${host}:${addr.port}`),
|
||||
close: () =>
|
||||
new Promise((res, rej) => {
|
||||
server.close(err => (err ? rej(err) : res()));
|
||||
})
|
||||
});
|
||||
});
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
export async function startExpressMinimal(handler: RequestHandler): Promise<ExpressHost> {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(handler);
|
||||
|
||||
const { baseUrl, close } = await listen(app);
|
||||
return {
|
||||
baseUrl,
|
||||
close,
|
||||
[Symbol.asyncDispose]: close
|
||||
};
|
||||
}
|
||||
|
||||
export async function startExpressWithHostValidation(allowedHosts: string[], handler: RequestHandler): Promise<ExpressHost> {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(hostHeaderValidation(allowedHosts));
|
||||
app.use(handler);
|
||||
|
||||
const { baseUrl, close } = await listen(app);
|
||||
return {
|
||||
baseUrl,
|
||||
close,
|
||||
[Symbol.asyncDispose]: close
|
||||
};
|
||||
}
|
||||
286
test/e2e/helpers/index.ts
Normal file
286
test/e2e/helpers/index.ts
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
/**
|
||||
* Test wiring helpers.
|
||||
*
|
||||
* `wire(transport, makeServer, client)` connects a server (built per call by
|
||||
* `makeServer`) and a client over the named transport, returning an
|
||||
* `AsyncDisposable` for `await using` teardown. All wiring is in-process —
|
||||
* no real sockets, no child processes — except the legacy SSE transport, whose
|
||||
* server half requires Node req/res and therefore runs over a real loopback
|
||||
* HTTP listener on an ephemeral port.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
import type { Client } from '../../../src/client/index.js';
|
||||
import { SSEClientTransport } from '../../../src/client/sse.js';
|
||||
import { StreamableHTTPClientTransport } from '../../../src/client/streamableHttp.js';
|
||||
import { InMemoryTransport } from '../../../src/inMemory.js';
|
||||
import type { Server } from '../../../src/server/index.js';
|
||||
import type { McpServer } from '../../../src/server/mcp.js';
|
||||
import { SSEServerTransport } from '../../../src/server/sse.js';
|
||||
import { StdioServerTransport } from '../../../src/server/stdio.js';
|
||||
import { WebStandardStreamableHTTPServerTransport, type EventStore } from '../../../src/server/webStandardStreamableHttp.js';
|
||||
import { ReadBuffer, serializeMessage } from '../../../src/shared/stdio.js';
|
||||
import type { JSONRPCMessage } from '../../../src/types.js';
|
||||
|
||||
import type { Transport } from '../types.js';
|
||||
|
||||
import { sniffTransport, type SnifferOptions } from './wire-sniffer.js';
|
||||
|
||||
export type ServerFactory = () => McpServer | Server;
|
||||
|
||||
export interface Wired extends AsyncDisposable {
|
||||
readonly fetch?: (url: URL | string, init?: RequestInit) => Promise<Response>;
|
||||
readonly url?: URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* The fourth argument controls the wire-format sniffer (see wire-sniffer.ts):
|
||||
* every message the client sends or receives is validated against the SDK's
|
||||
* spec-anchored Zod schemas. Tests that intentionally use vendor-extension
|
||||
* methods pass `{ allowCustomMethods: true }`; tests that deliberately put
|
||||
* malformed MCP on the wire pass `{ strictValidation: false }`.
|
||||
*/
|
||||
export async function wire(transport: Transport, makeServer: ServerFactory, client: Client, sniff: SnifferOptions = {}): Promise<Wired> {
|
||||
switch (transport) {
|
||||
case 'inMemory': {
|
||||
const server = makeServer();
|
||||
const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTx);
|
||||
await client.connect(sniffTransport(clientTx, 'client', sniff));
|
||||
return { [Symbol.asyncDispose]: () => Promise.all([client.close(), server.close()]).then(() => {}) };
|
||||
}
|
||||
case 'stdio': {
|
||||
const server = makeServer();
|
||||
const c2s = new PassThrough();
|
||||
const s2c = new PassThrough();
|
||||
await server.connect(new StdioServerTransport(c2s, s2c));
|
||||
await client.connect(sniffTransport(stdioClientOverPipes(s2c, c2s), 'client', sniff));
|
||||
return { [Symbol.asyncDispose]: () => Promise.all([client.close(), server.close()]).then(() => {}) };
|
||||
}
|
||||
case 'streamableHttp':
|
||||
case 'streamableHttpStateless': {
|
||||
const handle = transport === 'streamableHttpStateless' ? hostStateless(makeServer) : hostPerSession(makeServer);
|
||||
const url = new URL('http://in-process/mcp');
|
||||
const fetch = (u: URL | string, init?: RequestInit) => handle.handleRequest(new Request(u, init));
|
||||
await client.connect(sniffTransport(new StreamableHTTPClientTransport(url, { fetch }), 'client', sniff));
|
||||
return {
|
||||
fetch,
|
||||
url,
|
||||
[Symbol.asyncDispose]: () => Promise.all([client.close(), handle.close()]).then(() => {})
|
||||
};
|
||||
}
|
||||
case 'sse': {
|
||||
// The legacy SSE server transport writes to a Node ServerResponse, so this branch hosts it
|
||||
// on a real loopback listener: GET /sse opens the stream (one server instance per
|
||||
// connection, mirroring hostPerSession) and POST /messages?sessionId=… delivers messages.
|
||||
const sessions = new Map<string, { tx: SSEServerTransport; server: McpServer | Server }>();
|
||||
const handleSseRequest = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
||||
const reqUrl = new URL(req.url ?? '/', 'http://127.0.0.1');
|
||||
if (req.method === 'GET' && reqUrl.pathname === '/sse') {
|
||||
const tx = new SSEServerTransport('/messages', res);
|
||||
const server = makeServer();
|
||||
sessions.set(tx.sessionId, { tx, server });
|
||||
tx.onclose = () => void sessions.delete(tx.sessionId);
|
||||
await server.connect(tx);
|
||||
return;
|
||||
}
|
||||
if (req.method === 'POST' && reqUrl.pathname === '/messages') {
|
||||
const session = sessions.get(reqUrl.searchParams.get('sessionId') ?? '');
|
||||
if (!session) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' }).end('Session not found');
|
||||
return;
|
||||
}
|
||||
await session.tx.handlePostMessage(req, res);
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
};
|
||||
const httpServer = createServer((req, res) => {
|
||||
handleSseRequest(req, res).catch(() => {
|
||||
// Mirror the SDK's own SSE test harness: handler failures become a 500, not an unhandled rejection.
|
||||
if (!res.headersSent) res.writeHead(500).end();
|
||||
});
|
||||
});
|
||||
await new Promise<void>(resolve => httpServer.listen(0, '127.0.0.1', resolve));
|
||||
const address = httpServer.address();
|
||||
if (address === null || typeof address === 'string') throw new Error('expected the SSE host to listen on a TCP port');
|
||||
const url = new URL(`http://127.0.0.1:${address.port}/sse`);
|
||||
await client.connect(sniffTransport(new SSEClientTransport(url), 'client', sniff));
|
||||
return {
|
||||
url,
|
||||
[Symbol.asyncDispose]: async () => {
|
||||
await client.close();
|
||||
for (const { tx, server } of sessions.values()) {
|
||||
await server.close();
|
||||
await tx.close();
|
||||
}
|
||||
sessions.clear();
|
||||
httpServer.closeAllConnections();
|
||||
await new Promise<void>(resolve => httpServer.close(() => resolve()));
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tap a connected client's transport so every JSON-RPC message crossing the
|
||||
* wire is recorded. `sent` = client→server, `received` = server→client.
|
||||
* Call after `wire()` so `client.transport` is set. The transport is
|
||||
* monkey-patched in place; teardown via `await using` on `wire()` discards it.
|
||||
*/
|
||||
export function tapWire(client: Client): { sent: JSONRPCMessage[]; received: JSONRPCMessage[] } {
|
||||
const tx = client.transport;
|
||||
if (!tx) throw new Error('tapWire: client not connected');
|
||||
const sent: JSONRPCMessage[] = [];
|
||||
const received: JSONRPCMessage[] = [];
|
||||
const origSend = tx.send.bind(tx);
|
||||
const origOnMessage = tx.onmessage;
|
||||
tx.send = async (m, opts) => {
|
||||
sent.push(m);
|
||||
return origSend(m, opts);
|
||||
};
|
||||
tx.onmessage = (m, extra) => {
|
||||
received.push(m);
|
||||
origOnMessage?.(m, extra);
|
||||
};
|
||||
return { sent, received };
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────────
|
||||
// HTTP hosting (the two production patterns)
|
||||
// ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type HttpHandler = (req: Request) => Promise<Response>;
|
||||
|
||||
export function hostPerSession(makeServer: ServerFactory): { handleRequest: HttpHandler; close(): Promise<void> } {
|
||||
const sessions = new Map<string, WebStandardStreamableHTTPServerTransport>();
|
||||
return {
|
||||
handleRequest: async req => {
|
||||
const sid = req.headers.get('mcp-session-id') ?? undefined;
|
||||
const existing = sid ? sessions.get(sid) : undefined;
|
||||
if (existing) return existing.handleRequest(req);
|
||||
if (sid !== undefined) {
|
||||
// Mirror the SDK's documented hosting pattern: an unrecognized session id is
|
||||
// rejected at the app level, so the transport's own 404 is never reached.
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32000, message: 'Bad Request: No valid session ID provided' },
|
||||
id: null
|
||||
}),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const tx = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: randomUUID,
|
||||
onsessioninitialized: id => void sessions.set(id, tx),
|
||||
onsessionclosed: id => void sessions.delete(id)
|
||||
});
|
||||
await makeServer().connect(tx);
|
||||
return tx.handleRequest(req);
|
||||
},
|
||||
close: async () => {
|
||||
for (const t of sessions.values()) await t.close();
|
||||
sessions.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export interface ResumeHostOptions {
|
||||
eventStore: EventStore;
|
||||
retryInterval?: number;
|
||||
}
|
||||
|
||||
export function hostResumable(makeServer: ServerFactory, opts: ResumeHostOptions): { handleRequest: HttpHandler; close(): Promise<void> } {
|
||||
const sessions = new Map<string, { tx: WebStandardStreamableHTTPServerTransport; server: McpServer | Server }>();
|
||||
|
||||
return {
|
||||
handleRequest: async req => {
|
||||
const sid = req.headers.get('mcp-session-id') ?? undefined;
|
||||
const existing = sid ? sessions.get(sid) : undefined;
|
||||
if (existing) return existing.tx.handleRequest(req);
|
||||
|
||||
const tx = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: randomUUID,
|
||||
eventStore: opts.eventStore,
|
||||
retryInterval: opts.retryInterval,
|
||||
onsessioninitialized: id => void sessions.set(id, { tx, server }),
|
||||
onsessionclosed: id => void sessions.delete(id)
|
||||
});
|
||||
const server = makeServer();
|
||||
await server.connect(tx);
|
||||
return tx.handleRequest(req);
|
||||
},
|
||||
close: async () => {
|
||||
for (const { tx, server } of sessions.values()) {
|
||||
await server.close();
|
||||
await tx.close();
|
||||
}
|
||||
sessions.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function hostStateless(makeServer: ServerFactory): { handleRequest: HttpHandler; close(): Promise<void> } {
|
||||
const cleanups: Array<() => Promise<void>> = [];
|
||||
return {
|
||||
handleRequest: async req => {
|
||||
if (req.method !== 'POST') {
|
||||
return new Response(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: 'Method not allowed.' }, id: null }), {
|
||||
status: 405,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
const server = makeServer();
|
||||
const tx = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
||||
await server.connect(tx);
|
||||
cleanups.push(async () => {
|
||||
await server.close();
|
||||
await tx.close();
|
||||
});
|
||||
return tx.handleRequest(req);
|
||||
},
|
||||
close: async () => {
|
||||
for (const c of cleanups) await c();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────────
|
||||
// In-process stdio client — TEST-ONLY
|
||||
//
|
||||
// Production stdio uses `StdioClientTransport`, which spawns a child process.
|
||||
// This is the in-process equivalent for tests: same newline-framed JSON wire
|
||||
// format (uses the SDK's `serializeMessage`/`ReadBuffer`), but over PassThrough
|
||||
// streams instead of a spawned process. Tests that specifically exercise spawn,
|
||||
// env, signals, or stderr must use the real `StdioClientTransport`.
|
||||
// ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function stdioClientOverPipes(serverStdout: NodeJS.ReadableStream, serverStdin: NodeJS.WritableStream) {
|
||||
const buf = new ReadBuffer();
|
||||
return {
|
||||
onmessage: undefined as ((m: JSONRPCMessage) => void) | undefined,
|
||||
onerror: undefined as ((e: Error) => void) | undefined,
|
||||
onclose: undefined as (() => void) | undefined,
|
||||
async start() {
|
||||
serverStdout.on('data', chunk => {
|
||||
buf.append(chunk);
|
||||
let m: JSONRPCMessage | null;
|
||||
while ((m = buf.readMessage())) this.onmessage?.(m);
|
||||
});
|
||||
serverStdout.on('error', e => this.onerror?.(e));
|
||||
serverStdout.on('close', () => this.onclose?.());
|
||||
},
|
||||
async send(m: JSONRPCMessage) {
|
||||
serverStdin.write(serializeMessage(m));
|
||||
},
|
||||
async close() {
|
||||
serverStdin.end();
|
||||
}
|
||||
};
|
||||
}
|
||||
53
test/e2e/helpers/verifies.ts
Normal file
53
test/e2e/helpers/verifies.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* Inverted manifest linkage: tests declare which requirement(s) they satisfy.
|
||||
* (Staged here in the harness; the restructure codemod copies this file to
|
||||
* sdk/test/e2e/helpers/verifies.ts when the inversion is executed.)
|
||||
*
|
||||
* `verifies(id, fn, opts?)` looks the requirement up in the pure-data manifest
|
||||
* and registers one cell per applicable (transport, spec version) — the same
|
||||
* names and semantics the old matrix runner produced — applying `test.fails`
|
||||
* for knownFailures and the global 15s timeout. Unknown or deferred ids throw
|
||||
* at registration time, so a typo can never silently drop coverage.
|
||||
*
|
||||
* Bodies are anonymous; the registered test title is `opts?.title ?? 'verifies'`.
|
||||
* An explicit `opts.title` is only needed when a requirement is cited by more
|
||||
* than one body (to tell them apart) — a knownFailure with a `test` string only
|
||||
* applies to the body registered with that exact title.
|
||||
*/
|
||||
|
||||
import { describe, test } from 'vitest';
|
||||
|
||||
import { REQUIREMENTS } from '../requirements.js';
|
||||
import { ALL_SPEC_VERSIONS, ALL_TRANSPORTS, type TestArgs } from '../types.js';
|
||||
|
||||
type TestBody = (args: TestArgs) => Promise<void>;
|
||||
|
||||
export function verifies(id: string | readonly string[], fn: TestBody, opts?: { title?: string }): void {
|
||||
const ids = Array.isArray(id) ? id : [id];
|
||||
for (const rid of ids) registerOne(rid, fn, opts);
|
||||
}
|
||||
|
||||
function registerOne(id: string, fn: TestBody, opts?: { title?: string }): void {
|
||||
const req = REQUIREMENTS[id];
|
||||
if (!req) throw new Error(`verifies('${id}'): unknown requirement id`);
|
||||
if (req.deferred) throw new Error(`verifies('${id}'): requirement is deferred — drop the deferral or the test`);
|
||||
|
||||
const transports = req.transports ?? ALL_TRANSPORTS;
|
||||
const versions = ALL_SPEC_VERSIONS.filter(
|
||||
v =>
|
||||
(req.addedInSpecVersion === undefined || v >= req.addedInSpecVersion) &&
|
||||
(req.removedInSpecVersion === undefined || v < req.removedInSpecVersion)
|
||||
);
|
||||
const cells = versions.flatMap(v => transports.map(t => [t, v] as const));
|
||||
|
||||
describe.each(cells)(`${id} [%s %s]`, (transport, protocolVersion) => {
|
||||
const kf = req.knownFailures?.find(
|
||||
k =>
|
||||
(k.test === undefined || k.test === opts?.title) &&
|
||||
(k.transport === undefined || k.transport === transport) &&
|
||||
(k.specVersion === undefined || k.specVersion === protocolVersion)
|
||||
);
|
||||
const run = kf ? test.fails : test;
|
||||
run(opts?.title ?? 'verifies', () => fn({ transport, protocolVersion }), 15_000);
|
||||
});
|
||||
}
|
||||
69
test/e2e/helpers/wire-sniffer.test.ts
Normal file
69
test/e2e/helpers/wire-sniffer.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { LATEST_PROTOCOL_VERSION } from '../../../src/types.js';
|
||||
|
||||
import { assertWireMessage } from './wire-sniffer.js';
|
||||
|
||||
const req = (method: string, params?: unknown, id = 1) => ({
|
||||
jsonrpc: '2.0' as const,
|
||||
id,
|
||||
method,
|
||||
...(params !== undefined ? { params } : {})
|
||||
});
|
||||
const notif = (method: string, params?: unknown) => ({ jsonrpc: '2.0' as const, method, ...(params !== undefined ? { params } : {}) });
|
||||
const resp = (result: unknown, id = 1) => ({ jsonrpc: '2.0' as const, id, result });
|
||||
|
||||
describe('assertWireMessage', () => {
|
||||
it('accepts a valid client initialize request', () => {
|
||||
expect(() =>
|
||||
assertWireMessage(
|
||||
req('initialize', { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'c', version: '0' } }),
|
||||
'client'
|
||||
)
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts a valid client tools/list result on the server→client return', () => {
|
||||
// server sends the ListToolsResult back
|
||||
expect(() => assertWireMessage(resp({ tools: [] }), 'server')).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts a valid client ping and the empty result', () => {
|
||||
expect(() => assertWireMessage(req('ping'), 'client')).not.toThrow();
|
||||
expect(() => assertWireMessage(resp({}), 'server')).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a non-JSON-RPC envelope', () => {
|
||||
expect(() => assertWireMessage({ jsonrpc: '1.0', id: 1, method: 'ping' }, 'client')).toThrow(/not a JSON-RPC message/);
|
||||
});
|
||||
|
||||
it('rejects an unknown method without allowCustomMethods', () => {
|
||||
expect(() => assertWireMessage(req('x/custom', { a: 1 }), 'client')).toThrow(/non-spec request method 'x\/custom'/);
|
||||
});
|
||||
|
||||
it('accepts an unknown method with allowCustomMethods', () => {
|
||||
expect(() => assertWireMessage(req('x/custom', { a: 1 }), 'client', { allowCustomMethods: true })).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a spec method with malformed params under strict, accepts with strictValidation:false', () => {
|
||||
const bad = notif('notifications/progress', { progressToken: 1, progress: 'not-a-number' });
|
||||
expect(() => assertWireMessage(bad, 'client')).toThrow(/params do not conform/);
|
||||
expect(() => assertWireMessage(bad, 'client', { strictValidation: false })).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a server-only request sent by the client (direction check)', () => {
|
||||
// sampling/createMessage is a Server→Client request; the client must not originate it
|
||||
expect(() => assertWireMessage(req('sampling/createMessage', { messages: [], maxTokens: 1 }), 'client')).toThrow(
|
||||
/non-spec request method 'sampling\/createMessage'/
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts that same request when the server sends it', () => {
|
||||
expect(() => assertWireMessage(req('sampling/createMessage', { messages: [], maxTokens: 1 }), 'server')).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts a JSON-RPC error response for either party', () => {
|
||||
const err = { jsonrpc: '2.0' as const, id: 1, error: { code: -32601, message: 'Method not found' } };
|
||||
expect(() => assertWireMessage(err, 'server')).not.toThrow();
|
||||
expect(() => assertWireMessage(err, 'client')).not.toThrow();
|
||||
});
|
||||
});
|
||||
142
test/e2e/helpers/wire-sniffer.ts
Normal file
142
test/e2e/helpers/wire-sniffer.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
/**
|
||||
* Wire-format sniffer: asserts every JSON-RPC message crossing a transport is a
|
||||
* well-formed MCP message for its direction.
|
||||
*
|
||||
* Validation runs against the SDK's runtime Zod schemas in `src/types.ts`
|
||||
* (`ClientRequestSchema`, `ServerResultSchema`, …). Those schemas are proven
|
||||
* equivalent to the spec-synced `src/spec.types.ts` by `test/spec.types.test.ts`
|
||||
* (mutual assignability + per-type completeness). So conformance here is
|
||||
* transitively conformance to the spec, with `spec.types.ts` as the anchor: if
|
||||
* the SDK schemas ever drift off-spec, that equivalence test goes red first.
|
||||
*
|
||||
* Two layers:
|
||||
* 1. JSON-RPC envelope (always) — request / notification / response / error.
|
||||
* 2. MCP shape (unless `strictValidation: false`) — the method is a spec
|
||||
* method for the sender's direction and its params/result match the schema.
|
||||
* Vendor-extension method names are rejected unless `allowCustomMethods`.
|
||||
*/
|
||||
|
||||
import {
|
||||
ClientNotificationSchema,
|
||||
ClientRequestSchema,
|
||||
ClientResultSchema,
|
||||
isJSONRPCErrorResponse,
|
||||
isJSONRPCNotification,
|
||||
isJSONRPCRequest,
|
||||
isJSONRPCResultResponse,
|
||||
JSONRPCMessageSchema,
|
||||
ServerNotificationSchema,
|
||||
ServerRequestSchema,
|
||||
ServerResultSchema
|
||||
} from '../../../src/types.js';
|
||||
import type { Transport } from '../../../src/shared/transport.js';
|
||||
|
||||
export type WireParty = 'client' | 'server';
|
||||
|
||||
export interface SnifferOptions {
|
||||
/** Permit non-spec (vendor-extension) method names. Spec methods stay strict. */
|
||||
allowCustomMethods?: boolean;
|
||||
/** `false` → envelope check only (for tests that deliberately send malformed messages). */
|
||||
strictValidation?: boolean;
|
||||
}
|
||||
|
||||
const OUTBOUND = {
|
||||
client: { request: ClientRequestSchema, notification: ClientNotificationSchema, result: ClientResultSchema },
|
||||
server: { request: ServerRequestSchema, notification: ServerNotificationSchema, result: ServerResultSchema }
|
||||
} as const;
|
||||
|
||||
/** Method names valid as an outbound request/notification for each party. */
|
||||
const SPEC_METHODS: Record<WireParty, { request: Set<string>; notification: Set<string> }> = {
|
||||
client: { request: methodSet(ClientRequestSchema), notification: methodSet(ClientNotificationSchema) },
|
||||
server: { request: methodSet(ServerRequestSchema), notification: methodSet(ServerNotificationSchema) }
|
||||
};
|
||||
|
||||
function methodSet(union: { options?: ReadonlyArray<{ shape?: { method?: { value?: string } } }> }): Set<string> {
|
||||
const out = new Set<string>();
|
||||
for (const member of union.options ?? []) {
|
||||
const v = member.shape?.method?.value;
|
||||
if (typeof v === 'string') out.add(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function fail(party: WireParty, reason: string, msg: unknown): never {
|
||||
throw new Error(`[wire] ${party} sent an invalid message: ${reason}\n${JSON.stringify(msg, null, 2)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a single message is valid for the given sending party.
|
||||
* @param msg the raw JSON-RPC message
|
||||
* @param party who put it on the wire (`client` outbound = ClientRequest/Notification/Result)
|
||||
*/
|
||||
export function assertWireMessage(msg: unknown, party: WireParty, opts: SnifferOptions = {}): void {
|
||||
if (!JSONRPCMessageSchema.safeParse(msg).success) {
|
||||
fail(party, 'not a JSON-RPC message', msg);
|
||||
}
|
||||
if (opts.strictValidation === false) return;
|
||||
|
||||
const schemas = OUTBOUND[party];
|
||||
|
||||
if (isJSONRPCRequest(msg) || isJSONRPCNotification(msg)) {
|
||||
const kind = isJSONRPCRequest(msg) ? 'request' : 'notification';
|
||||
const method = (msg as { method: string }).method;
|
||||
if (!SPEC_METHODS[party][kind].has(method)) {
|
||||
if (opts.allowCustomMethods) return;
|
||||
fail(party, `non-spec ${kind} method '${method}' (pass { allowCustomMethods: true } if intentional)`, msg);
|
||||
}
|
||||
const params = (msg as { params?: unknown }).params;
|
||||
const r = schemas[kind].safeParse({ method, params });
|
||||
if (!r.success) {
|
||||
fail(party, `spec method '${method}' params do not conform: ${r.error.message}`, msg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isJSONRPCResultResponse(msg)) {
|
||||
const result = (msg as { result: unknown }).result;
|
||||
const r = schemas.result.safeParse(result);
|
||||
if (!r.success) {
|
||||
// A result for a vendor-extension request legitimately won't match the spec union.
|
||||
if (opts.allowCustomMethods) return;
|
||||
fail(party, `result does not conform to any spec result: ${r.error.message}`, msg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isJSONRPCErrorResponse(msg)) return; // envelope already validated; error bodies are not method-specific
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a transport so every outbound `send` (validated as `party`) and inbound
|
||||
* `onmessage` (validated as the counterpart) is asserted. Returns the same
|
||||
* transport instance (monkey-patched in place).
|
||||
*/
|
||||
export function sniffTransport<T extends Transport>(transport: T, party: WireParty, opts: SnifferOptions = {}): T {
|
||||
const counterpart: WireParty = party === 'client' ? 'server' : 'client';
|
||||
|
||||
const origSend = transport.send.bind(transport);
|
||||
transport.send = (message, sendOpts) => {
|
||||
assertWireMessage(message, party, opts);
|
||||
return origSend(message, sendOpts);
|
||||
};
|
||||
|
||||
// `onmessage` is assigned by Protocol.connect() after we wrap. Intercept via
|
||||
// an accessor so we wrap whatever handler it installs, validating each
|
||||
// inbound message (sent by the counterpart) before passing it through.
|
||||
let handler: Transport['onmessage'];
|
||||
Object.defineProperty(transport, 'onmessage', {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: () => handler,
|
||||
set: next => {
|
||||
handler = next
|
||||
? (message, extra) => {
|
||||
assertWireMessage(message, counterpart, opts);
|
||||
return next(message, extra);
|
||||
}
|
||||
: next;
|
||||
}
|
||||
});
|
||||
|
||||
return transport;
|
||||
}
|
||||
2454
test/e2e/requirements.ts
Normal file
2454
test/e2e/requirements.ts
Normal file
File diff suppressed because it is too large
Load diff
1747
test/e2e/scenarios/client-auth.test.ts
Normal file
1747
test/e2e/scenarios/client-auth.test.ts
Normal file
File diff suppressed because it is too large
Load diff
355
test/e2e/scenarios/completion.test.ts
Normal file
355
test/e2e/scenarios/completion.test.ts
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
/**
|
||||
* Self-contained test bodies for the completion surface.
|
||||
*
|
||||
* Completion provides autocompletion for prompt arguments and resource template
|
||||
* variables. The server declares the `completions` capability and handles
|
||||
* `completion/complete` requests, returning up to 100 string suggestions based
|
||||
* on the partial value and optional context (already-resolved variables).
|
||||
*/
|
||||
|
||||
import { expect } from 'vitest';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { Client } from '../../../src/client/index.js';
|
||||
import { Server } from '../../../src/server/index.js';
|
||||
import { completable } from '../../../src/server/completable.js';
|
||||
import { McpServer, ResourceTemplate } from '../../../src/server/mcp.js';
|
||||
import {
|
||||
CompleteRequestSchema,
|
||||
CompleteResultSchema,
|
||||
ErrorCode,
|
||||
ListPromptsRequestSchema,
|
||||
ListResourcesRequestSchema,
|
||||
McpError,
|
||||
ReadResourceRequestSchema
|
||||
} from '../../../src/types.js';
|
||||
|
||||
import { wire } from '../helpers/index.js';
|
||||
import type { TestArgs } from '../types.js';
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
|
||||
const COLORS = ['red', 'green', 'blue', 'rebeccapurple'] as const;
|
||||
const FILE_PATHS = ['README.md', 'src/index.ts', 'src/types.ts'] as const;
|
||||
const REPOS_BY_OWNER: Record<string, readonly string[]> = {
|
||||
'acme-corp': ['widget-sdk', 'gadget-sdk', 'docs'],
|
||||
globex: ['frobnicator', 'reticulator']
|
||||
};
|
||||
const MANY_TOTAL = 150;
|
||||
|
||||
const newClient = () => new Client({ name: 'c', version: '0' });
|
||||
|
||||
function colorCompletionServer(): McpServer {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerPrompt(
|
||||
'complete-color',
|
||||
{ argsSchema: { color: completable(z.string(), value => COLORS.filter(c => c.startsWith(value))) } },
|
||||
({ color }) => ({ messages: [{ role: 'user', content: { type: 'text', text: `color=${color}` } }] })
|
||||
);
|
||||
return s;
|
||||
}
|
||||
|
||||
verifies('completion:capability:declared', async ({ transport }: TestArgs) => {
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, colorCompletionServer, client);
|
||||
|
||||
const caps = client.getServerCapabilities();
|
||||
expect(caps?.completions).toBeDefined();
|
||||
expect(typeof caps?.completions).toBe('object');
|
||||
|
||||
const result = await client.complete({
|
||||
ref: { type: 'ref/prompt', name: 'complete-color' },
|
||||
argument: { name: 'color', value: '' }
|
||||
});
|
||||
expect(Array.isArray(result.completion.values)).toBe(true);
|
||||
});
|
||||
|
||||
verifies('completion:complete:not-supported', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerPrompt('summarize-code', { argsSchema: { code: z.string() } }, ({ code }) => ({
|
||||
messages: [{ role: 'user', content: { type: 'text', text: `Summarize the following code:\n${code}` } }]
|
||||
}));
|
||||
return s;
|
||||
};
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const caps = client.getServerCapabilities();
|
||||
expect(caps).toBeDefined();
|
||||
expect(caps?.completions).toBeUndefined();
|
||||
expect(caps?.prompts).toBeDefined();
|
||||
|
||||
// Raw request bypasses any client-side capability gating, so the rejection observed is the server's own.
|
||||
await expect(
|
||||
client.request(
|
||||
{
|
||||
method: 'completion/complete',
|
||||
params: { ref: { type: 'ref/prompt', name: 'summarize-code' }, argument: { name: 'code', value: 'co' } }
|
||||
},
|
||||
CompleteResultSchema
|
||||
)
|
||||
).rejects.toMatchObject({ code: ErrorCode.MethodNotFound });
|
||||
});
|
||||
|
||||
verifies('completion:context-arguments', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerResource(
|
||||
'github-repo',
|
||||
new ResourceTemplate('github://{owner}/{repo}', {
|
||||
list: undefined,
|
||||
complete: {
|
||||
repo: (value, context) => {
|
||||
const owner = context?.arguments?.owner;
|
||||
if (!owner || typeof owner !== 'string') return [];
|
||||
const repos = REPOS_BY_OWNER[owner] ?? [];
|
||||
return repos.filter(r => r.startsWith(value));
|
||||
}
|
||||
}
|
||||
}),
|
||||
{ mimeType: 'text/plain' },
|
||||
(uri, { owner, repo }) => ({ contents: [{ uri: uri.href, mimeType: 'text/plain', text: `${owner}/${repo}` }] })
|
||||
);
|
||||
return s;
|
||||
};
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const acme = await client.complete({
|
||||
ref: { type: 'ref/resource', uri: 'github://{owner}/{repo}' },
|
||||
argument: { name: 'repo', value: '' },
|
||||
context: { arguments: { owner: 'acme-corp' } }
|
||||
});
|
||||
expect(acme.completion.values).toEqual(['widget-sdk', 'gadget-sdk', 'docs']);
|
||||
|
||||
const globex = await client.complete({
|
||||
ref: { type: 'ref/resource', uri: 'github://{owner}/{repo}' },
|
||||
argument: { name: 'repo', value: '' },
|
||||
context: { arguments: { owner: 'globex' } }
|
||||
});
|
||||
expect(globex.completion.values).toEqual(['frobnicator', 'reticulator']);
|
||||
expect(acme.completion.values).not.toEqual(globex.completion.values);
|
||||
|
||||
const bare = await client.complete({
|
||||
ref: { type: 'ref/resource', uri: 'github://{owner}/{repo}' },
|
||||
argument: { name: 'repo', value: '' }
|
||||
});
|
||||
expect(bare.completion.values).toEqual([]);
|
||||
});
|
||||
|
||||
verifies(
|
||||
'completion:error:invalid-ref',
|
||||
async ({ transport }: TestArgs) => {
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, colorCompletionServer, client);
|
||||
|
||||
await expect(
|
||||
client.complete({ ref: { type: 'ref/prompt', name: 'no-such-prompt' }, argument: { name: 'whatever', value: '' } })
|
||||
).rejects.toMatchObject({ code: ErrorCode.InvalidParams });
|
||||
|
||||
await expect(
|
||||
client.complete({ ref: { type: 'ref/resource', uri: 'nosuchscheme://nowhere/{x}' }, argument: { name: 'x', value: '' } })
|
||||
).rejects.toMatchObject({ code: ErrorCode.InvalidParams });
|
||||
},
|
||||
{ title: 'mcpserver' }
|
||||
);
|
||||
|
||||
verifies(
|
||||
'completion:error:invalid-ref',
|
||||
async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new Server({ name: 's', version: '0' }, { capabilities: { completions: {} } });
|
||||
s.setRequestHandler(CompleteRequestSchema, req => {
|
||||
if (req.params.ref.type === 'ref/prompt' && req.params.ref.name === 'known') {
|
||||
return { completion: { values: ['ok'] } };
|
||||
}
|
||||
throw new McpError(ErrorCode.InvalidParams, `No completion target: ${JSON.stringify(req.params.ref)}`);
|
||||
});
|
||||
return s;
|
||||
};
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const known = await client.complete({ ref: { type: 'ref/prompt', name: 'known' }, argument: { name: 'a', value: '' } });
|
||||
expect(known.completion.values).toEqual(['ok']);
|
||||
|
||||
await expect(
|
||||
client.complete({ ref: { type: 'ref/prompt', name: 'no-such-prompt' }, argument: { name: 'a', value: '' } })
|
||||
).rejects.toMatchObject({ code: ErrorCode.InvalidParams });
|
||||
},
|
||||
{ title: 'raw server' }
|
||||
);
|
||||
|
||||
verifies('completion:prompt-arg', async ({ transport }: TestArgs) => {
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, colorCompletionServer, client);
|
||||
|
||||
const result = await client.complete({
|
||||
ref: { type: 'ref/prompt', name: 'complete-color' },
|
||||
argument: { name: 'color', value: 're' }
|
||||
});
|
||||
|
||||
const expected = COLORS.filter(c => c.startsWith('re'));
|
||||
expect(result.completion.values).toEqual(expected);
|
||||
expect(result.completion.hasMore ?? false).toBe(false);
|
||||
});
|
||||
|
||||
verifies('completion:resource-template-arg', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerResource(
|
||||
'file-path',
|
||||
new ResourceTemplate('completion://files/{path}', {
|
||||
list: undefined,
|
||||
complete: { path: value => FILE_PATHS.filter(p => p.startsWith(value)) }
|
||||
}),
|
||||
{ mimeType: 'text/plain' },
|
||||
(uri, { path }) => ({ contents: [{ uri: uri.href, mimeType: 'text/plain', text: `path=${path}` }] })
|
||||
);
|
||||
s.registerResource(
|
||||
'github-repo',
|
||||
new ResourceTemplate('github://{owner}/{repo}', {
|
||||
list: undefined,
|
||||
complete: { owner: () => [] }
|
||||
}),
|
||||
{ mimeType: 'text/plain' },
|
||||
(uri, { owner, repo }) => ({ contents: [{ uri: uri.href, mimeType: 'text/plain', text: `${owner}/${repo}` }] })
|
||||
);
|
||||
return s;
|
||||
};
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const result = await client.complete({
|
||||
ref: { type: 'ref/resource', uri: 'completion://files/{path}' },
|
||||
argument: { name: 'path', value: 'src/' }
|
||||
});
|
||||
|
||||
expect(result.completion.values).toEqual(['src/index.ts', 'src/types.ts']);
|
||||
expect(result.completion.hasMore ?? false).toBe(false);
|
||||
|
||||
const other = await client.complete({
|
||||
ref: { type: 'ref/resource', uri: 'github://{owner}/{repo}' },
|
||||
argument: { name: 'path', value: 'src/' }
|
||||
});
|
||||
|
||||
expect(other.completion.values).toEqual([]);
|
||||
});
|
||||
|
||||
verifies('completion:result-shape', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerPrompt(
|
||||
'complete-color',
|
||||
{ argsSchema: { color: completable(z.string(), value => COLORS.filter(c => c.startsWith(value))) } },
|
||||
({ color }) => ({ messages: [{ role: 'user', content: { type: 'text', text: `color=${color}` } }] })
|
||||
);
|
||||
const many = Array.from({ length: MANY_TOTAL }, (_, i) => `item-${String(i).padStart(3, '0')}`);
|
||||
s.registerPrompt(
|
||||
'complete-many',
|
||||
{ argsSchema: { n: completable(z.string(), value => many.filter(s => s.startsWith(value))) } },
|
||||
({ n }) => ({ messages: [{ role: 'user', content: { type: 'text', text: n } }] })
|
||||
);
|
||||
return s;
|
||||
};
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const small = await client.complete({
|
||||
ref: { type: 'ref/prompt', name: 'complete-color' },
|
||||
argument: { name: 'color', value: 're' }
|
||||
});
|
||||
|
||||
expect(Array.isArray(small.completion.values)).toBe(true);
|
||||
expect(small.completion.values).toEqual(COLORS.filter(c => c.startsWith('re')));
|
||||
expect(small.completion.values.length).toBeLessThanOrEqual(100);
|
||||
expect(small.completion.total).toBe(small.completion.values.length);
|
||||
expect(small.completion.total).toBe(2);
|
||||
expect(small.completion.hasMore).toBe(false);
|
||||
|
||||
const empty = await client.complete({
|
||||
ref: { type: 'ref/prompt', name: 'complete-color' },
|
||||
argument: { name: 'no-such-arg', value: '' }
|
||||
});
|
||||
|
||||
expect(empty.completion.values).toEqual([]);
|
||||
expect(empty.completion.total).toBeUndefined();
|
||||
expect(empty.completion.hasMore).toBe(false);
|
||||
|
||||
const many = await client.complete({
|
||||
ref: { type: 'ref/prompt', name: 'complete-many' },
|
||||
argument: { name: 'n', value: '' }
|
||||
});
|
||||
|
||||
expect(many.completion.values).toHaveLength(100);
|
||||
expect(many.completion.values.every(v => typeof v === 'string')).toBe(true);
|
||||
expect(many.completion.values[0]).toBe('item-000');
|
||||
expect(many.completion.values[99]).toBe('item-099');
|
||||
expect(many.completion.total).toBe(MANY_TOTAL);
|
||||
expect(many.completion.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
verifies(
|
||||
'mcpserver:completion:capability-auto',
|
||||
async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerPrompt('non-completable', { argsSchema: { arg: z.string() } }, ({ arg }) => ({
|
||||
messages: [{ role: 'user', content: { type: 'text', text: arg } }]
|
||||
}));
|
||||
s.registerResource(
|
||||
'plain-resource',
|
||||
new ResourceTemplate('plain://resource/{id}', { list: undefined }),
|
||||
{ mimeType: 'text/plain' },
|
||||
(uri, { id }) => ({ contents: [{ uri: uri.href, mimeType: 'text/plain', text: `id=${id}` }] })
|
||||
);
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const caps = client.getServerCapabilities();
|
||||
expect(caps).toBeDefined();
|
||||
expect(caps?.completions).toBeUndefined();
|
||||
expect(caps?.prompts).toBeDefined();
|
||||
expect(caps?.resources).toBeDefined();
|
||||
|
||||
await expect(
|
||||
client.complete({
|
||||
ref: { type: 'ref/prompt', name: 'non-completable' },
|
||||
argument: { name: 'arg', value: '' }
|
||||
})
|
||||
).rejects.toThrow();
|
||||
},
|
||||
{ title: 'mcpserver' }
|
||||
);
|
||||
|
||||
verifies(
|
||||
'mcpserver:completion:capability-auto',
|
||||
async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new Server({ name: 's', version: '0' }, { capabilities: { prompts: {}, resources: {} } });
|
||||
s.setRequestHandler(ListPromptsRequestSchema, () => ({ prompts: [{ name: 'plain', arguments: [] }] }));
|
||||
s.setRequestHandler(ListResourcesRequestSchema, () => ({ resources: [] }));
|
||||
s.setRequestHandler(ReadResourceRequestSchema, () => ({ contents: [] }));
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const caps = client.getServerCapabilities();
|
||||
expect(caps?.completions).toBeUndefined();
|
||||
expect(caps?.prompts).toBeDefined();
|
||||
expect(caps?.resources).toBeDefined();
|
||||
|
||||
await expect(
|
||||
client.complete({
|
||||
ref: { type: 'ref/prompt', name: 'plain' },
|
||||
argument: { name: 'arg', value: '' }
|
||||
})
|
||||
).rejects.toThrow();
|
||||
},
|
||||
{ title: 'raw server' }
|
||||
);
|
||||
359
test/e2e/scenarios/dynamic.test.ts
Normal file
359
test/e2e/scenarios/dynamic.test.ts
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
/**
|
||||
* Self-contained test bodies for dynamic (list_changed) behaviors.
|
||||
*
|
||||
* Tests for client-side listChanged handlers (auto-refresh, capability gating,
|
||||
* signal-only mode) and server-side dynamic registration (post-connect,
|
||||
* debounce, enable/disable, low-level handler reach-through).
|
||||
*/
|
||||
|
||||
import { expect, vi } from 'vitest';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { Client } from '../../../src/client/index.js';
|
||||
import { McpServer, type RegisteredTool } from '../../../src/server/mcp.js';
|
||||
import { Server } from '../../../src/server/index.js';
|
||||
import {
|
||||
ListPromptsRequestSchema,
|
||||
ListResourcesRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
PromptListChangedNotificationSchema,
|
||||
ResourceListChangedNotificationSchema,
|
||||
SubscribeRequestSchema,
|
||||
ToolListChangedNotificationSchema,
|
||||
type Prompt,
|
||||
type Resource,
|
||||
type Tool
|
||||
} from '../../../src/types.js';
|
||||
|
||||
import { wire } from '../helpers/index.js';
|
||||
import type { TestArgs } from '../types.js';
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
|
||||
const newClient = () => new Client({ name: 'c', version: '0' });
|
||||
|
||||
// vi.waitFor only retries on throw, not on falsy return — wrap predicates so a
|
||||
// false result throws and the poll continues.
|
||||
const waitUntil = (pred: () => boolean) => vi.waitFor(() => expect(pred()).toBe(true));
|
||||
|
||||
verifies('client:list-changed:auto-refresh', async ({ transport }: TestArgs) => {
|
||||
const toolCalls: Array<{ error: Error | null; items: Tool[] | null }> = [];
|
||||
const promptCalls: Array<{ error: Error | null; items: Prompt[] | null }> = [];
|
||||
const resourceCalls: Array<{ error: Error | null; items: Resource[] | null }> = [];
|
||||
|
||||
let server!: McpServer;
|
||||
const makeServer = () => {
|
||||
server = new McpServer({ name: 's', version: '0' });
|
||||
server.registerTool('seed', { inputSchema: z.object({}) }, () => ({ content: [] }));
|
||||
server.registerPrompt('seed', { description: '' }, () => ({ messages: [] }));
|
||||
server.registerResource('seed', 'test://seed', {}, () => ({ contents: [] }));
|
||||
return server;
|
||||
};
|
||||
|
||||
const client = new Client(
|
||||
{ name: 'c', version: '0' },
|
||||
{
|
||||
listChanged: {
|
||||
tools: { debounceMs: 0, onChanged: (error, items) => toolCalls.push({ error, items }) },
|
||||
prompts: { debounceMs: 0, onChanged: (error, items) => promptCalls.push({ error, items }) },
|
||||
resources: { debounceMs: 0, onChanged: (error, items) => resourceCalls.push({ error, items }) }
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
server.registerTool('probe-tool', { inputSchema: z.object({}) }, () => ({ content: [] }));
|
||||
await waitUntil(() => toolCalls.some(c => c.items?.some(t => t.name === 'probe-tool')));
|
||||
const toolCall = toolCalls.find(c => c.items?.some(t => t.name === 'probe-tool'));
|
||||
expect(toolCall?.error).toBeNull();
|
||||
expect(toolCall?.items?.map(t => t.name)).toContain('probe-tool');
|
||||
|
||||
server.registerPrompt('probe-prompt', { description: '' }, () => ({ messages: [] }));
|
||||
await waitUntil(() => promptCalls.some(c => c.items?.some(p => p.name === 'probe-prompt')));
|
||||
const promptCall = promptCalls.find(c => c.items?.some(p => p.name === 'probe-prompt'));
|
||||
expect(promptCall?.error).toBeNull();
|
||||
expect(promptCall?.items?.map(p => p.name)).toContain('probe-prompt');
|
||||
|
||||
server.registerResource('probe', 'test://probe', {}, () => ({ contents: [] }));
|
||||
await waitUntil(() => resourceCalls.some(c => c.items?.some(r => r.uri === 'test://probe')));
|
||||
const resourceCall = resourceCalls.find(c => c.items?.some(r => r.uri === 'test://probe'));
|
||||
expect(resourceCall?.error).toBeNull();
|
||||
expect(resourceCall?.items?.map(r => r.uri)).toContain('test://probe');
|
||||
});
|
||||
|
||||
verifies('client:list-changed:capability-gated', async ({ transport }: TestArgs) => {
|
||||
const toolsCalls: unknown[] = [];
|
||||
const promptsCalls: unknown[] = [];
|
||||
const resourcesCalls: unknown[] = [];
|
||||
|
||||
let server!: Server;
|
||||
const makeServer = () => {
|
||||
server = new Server({ name: 's', version: '0' }, { capabilities: { tools: { listChanged: true }, prompts: {}, resources: {} } });
|
||||
server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: [] }));
|
||||
server.setRequestHandler(ListPromptsRequestSchema, () => ({ prompts: [] }));
|
||||
server.setRequestHandler(ListResourcesRequestSchema, () => ({ resources: [] }));
|
||||
return server;
|
||||
};
|
||||
|
||||
const client = new Client(
|
||||
{ name: 'c', version: '0' },
|
||||
{
|
||||
listChanged: {
|
||||
tools: { autoRefresh: false, debounceMs: 0, onChanged: (err, items) => toolsCalls.push({ err, items }) },
|
||||
prompts: {
|
||||
autoRefresh: false,
|
||||
debounceMs: 0,
|
||||
onChanged: (err, items) => promptsCalls.push({ err, items })
|
||||
},
|
||||
resources: {
|
||||
autoRefresh: false,
|
||||
debounceMs: 0,
|
||||
onChanged: (err, items) => resourcesCalls.push({ err, items })
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const caps = client.getServerCapabilities();
|
||||
expect(caps?.tools?.listChanged).toBe(true);
|
||||
expect(caps?.prompts?.listChanged).toBeFalsy();
|
||||
expect(caps?.resources?.listChanged).toBeFalsy();
|
||||
|
||||
toolsCalls.length = 0;
|
||||
promptsCalls.length = 0;
|
||||
resourcesCalls.length = 0;
|
||||
|
||||
await server.sendToolListChanged();
|
||||
await server.sendPromptListChanged();
|
||||
await server.sendResourceListChanged();
|
||||
|
||||
await waitUntil(() => toolsCalls.length >= 1);
|
||||
expect(toolsCalls[0]).toEqual({ err: null, items: null });
|
||||
|
||||
await client.listTools();
|
||||
expect(promptsCalls).toHaveLength(0);
|
||||
expect(resourcesCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
verifies('client:list-changed:signal-only', async ({ transport }: TestArgs) => {
|
||||
const toolCalls: Array<{ error: Error | null; items: Tool[] | null }> = [];
|
||||
const promptCalls: Array<{ error: Error | null; items: Prompt[] | null }> = [];
|
||||
const resourceCalls: Array<{ error: Error | null; items: Resource[] | null }> = [];
|
||||
|
||||
let server!: McpServer;
|
||||
const makeServer = () => {
|
||||
server = new McpServer({ name: 's', version: '0' });
|
||||
server.registerTool('seed', { inputSchema: z.object({}) }, () => ({ content: [] }));
|
||||
server.registerPrompt('seed', { description: '' }, () => ({ messages: [] }));
|
||||
server.registerResource('seed', 'test://seed', {}, () => ({ contents: [] }));
|
||||
return server;
|
||||
};
|
||||
|
||||
const client = new Client(
|
||||
{ name: 'c', version: '0' },
|
||||
{
|
||||
listChanged: {
|
||||
tools: { autoRefresh: false, debounceMs: 0, onChanged: (error, items) => toolCalls.push({ error, items }) },
|
||||
prompts: {
|
||||
autoRefresh: false,
|
||||
debounceMs: 0,
|
||||
onChanged: (error, items) => promptCalls.push({ error, items })
|
||||
},
|
||||
resources: {
|
||||
autoRefresh: false,
|
||||
debounceMs: 0,
|
||||
onChanged: (error, items) => resourceCalls.push({ error, items })
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const before = await client.listTools();
|
||||
|
||||
toolCalls.length = 0;
|
||||
promptCalls.length = 0;
|
||||
resourceCalls.length = 0;
|
||||
|
||||
server.registerTool('signal-only-tool', { inputSchema: z.object({}) }, () => ({ content: [] }));
|
||||
await waitUntil(() => toolCalls.length >= 1);
|
||||
expect(toolCalls[0]).toEqual({ error: null, items: null });
|
||||
|
||||
server.registerPrompt('signal-only-prompt', { description: '' }, () => ({ messages: [] }));
|
||||
await waitUntil(() => promptCalls.length >= 1);
|
||||
expect(promptCalls[0]).toEqual({ error: null, items: null });
|
||||
|
||||
server.registerResource('signal-only', 'test://signal-only', {}, () => ({ contents: [] }));
|
||||
await waitUntil(() => resourceCalls.length >= 1);
|
||||
expect(resourceCalls[0]).toEqual({ error: null, items: null });
|
||||
|
||||
const after = await client.listTools();
|
||||
expect(after.tools.length).toBe(before.tools.length + 1);
|
||||
});
|
||||
|
||||
verifies('mcpserver:handle:enable-disable', async ({ transport }: TestArgs) => {
|
||||
let handle!: RegisteredTool;
|
||||
let server!: McpServer;
|
||||
|
||||
const makeServer = () => {
|
||||
server = new McpServer({ name: 's', version: '0' });
|
||||
handle = server.registerTool('toggle-probe', { inputSchema: z.object({}) }, () => ({
|
||||
content: [{ type: 'text', text: 'toggle-probe' }]
|
||||
}));
|
||||
return server;
|
||||
};
|
||||
|
||||
let listChanged = 0;
|
||||
const client = newClient();
|
||||
client.setNotificationHandler(ToolListChangedNotificationSchema, () => {
|
||||
listChanged++;
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
expect((await client.listTools()).tools.map(t => t.name)).toContain('toggle-probe');
|
||||
const baseline = await client.callTool({ name: 'toggle-probe', arguments: {} });
|
||||
expect(baseline.isError).toBeFalsy();
|
||||
expect(baseline.content).toEqual([{ type: 'text', text: 'toggle-probe' }]);
|
||||
|
||||
const beforeDisable = listChanged;
|
||||
handle.disable();
|
||||
await waitUntil(() => listChanged > beforeDisable);
|
||||
|
||||
expect((await client.listTools()).tools.map(t => t.name)).not.toContain('toggle-probe');
|
||||
const disabledCall = await client.callTool({ name: 'toggle-probe', arguments: {} });
|
||||
expect(disabledCall.isError).toBe(true);
|
||||
expect(disabledCall.content).toEqual([{ type: 'text', text: expect.stringMatching(/disabled/i) }]);
|
||||
|
||||
const beforeEnable = listChanged;
|
||||
handle.enable();
|
||||
await waitUntil(() => listChanged > beforeEnable);
|
||||
|
||||
expect((await client.listTools()).tools.map(t => t.name)).toContain('toggle-probe');
|
||||
const restored = await client.callTool({ name: 'toggle-probe', arguments: {} });
|
||||
expect(restored.isError).toBeFalsy();
|
||||
expect(restored.content).toEqual([{ type: 'text', text: 'toggle-probe' }]);
|
||||
});
|
||||
|
||||
verifies('mcpserver:list-changed:debounce', async ({ transport }: TestArgs) => {
|
||||
let server!: McpServer;
|
||||
const makeServer = () => {
|
||||
server = new McpServer(
|
||||
{ name: 's', version: '0' },
|
||||
{
|
||||
debouncedNotificationMethods: [
|
||||
'notifications/tools/list_changed',
|
||||
'notifications/resources/list_changed',
|
||||
'notifications/prompts/list_changed'
|
||||
]
|
||||
}
|
||||
);
|
||||
// Seed one of each so capability registration + request handlers are set
|
||||
// up pre-connect; post-connect registrations would otherwise try to
|
||||
// registerCapabilities and throw.
|
||||
server.registerTool('seed', { inputSchema: z.object({}) }, () => ({ content: [] }));
|
||||
server.registerResource('seed', 'test://seed', {}, () => ({ contents: [] }));
|
||||
server.registerPrompt('seed', { description: '' }, () => ({ messages: [] }));
|
||||
return server;
|
||||
};
|
||||
|
||||
const counts = { tools: 0, resources: 0, prompts: 0 };
|
||||
const client = newClient();
|
||||
client.setNotificationHandler(ToolListChangedNotificationSchema, () => void counts.tools++);
|
||||
client.setNotificationHandler(ResourceListChangedNotificationSchema, () => void counts.resources++);
|
||||
client.setNotificationHandler(PromptListChangedNotificationSchema, () => void counts.prompts++);
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
counts.tools = 0;
|
||||
counts.resources = 0;
|
||||
counts.prompts = 0;
|
||||
|
||||
server.registerTool('a', { inputSchema: z.object({}) }, () => ({ content: [] }));
|
||||
server.registerTool('b', { inputSchema: z.object({}) }, () => ({ content: [] }));
|
||||
server.registerTool('c', { inputSchema: z.object({}) }, () => ({ content: [] }));
|
||||
server.registerResource('a', 'test://debounce/a', {}, () => ({ contents: [] }));
|
||||
server.registerResource('b', 'test://debounce/b', {}, () => ({ contents: [] }));
|
||||
server.registerResource('c', 'test://debounce/c', {}, () => ({ contents: [] }));
|
||||
server.registerPrompt('a', { description: '' }, () => ({ messages: [] }));
|
||||
server.registerPrompt('b', { description: '' }, () => ({ messages: [] }));
|
||||
server.registerPrompt('c', { description: '' }, () => ({ messages: [] }));
|
||||
|
||||
await waitUntil(() => counts.tools >= 1 && counts.resources >= 1 && counts.prompts >= 1);
|
||||
|
||||
await client.listTools();
|
||||
expect(counts.tools).toBe(1);
|
||||
expect(counts.resources).toBe(1);
|
||||
expect(counts.prompts).toBe(1);
|
||||
|
||||
const { tools } = await client.listTools();
|
||||
expect(tools.map(t => t.name)).toEqual(expect.arrayContaining(['a', 'b', 'c']));
|
||||
});
|
||||
|
||||
verifies('mcpserver:register:post-connect', async ({ transport }: TestArgs) => {
|
||||
let server!: McpServer;
|
||||
const makeServer = () => {
|
||||
server = new McpServer({ name: 's', version: '0' });
|
||||
// Seed so handlers + capabilities are wired pre-connect.
|
||||
server.registerTool('seed', { inputSchema: z.object({}) }, () => ({ content: [] }));
|
||||
server.registerResource('seed', 'test://seed', {}, () => ({ contents: [] }));
|
||||
server.registerPrompt('seed', { description: '' }, () => ({ messages: [] }));
|
||||
return server;
|
||||
};
|
||||
|
||||
const seen: string[] = [];
|
||||
const client = newClient();
|
||||
client.setNotificationHandler(ToolListChangedNotificationSchema, () => void seen.push('tools'));
|
||||
client.setNotificationHandler(ResourceListChangedNotificationSchema, () => void seen.push('resources'));
|
||||
client.setNotificationHandler(PromptListChangedNotificationSchema, () => void seen.push('prompts'));
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
seen.length = 0;
|
||||
server.registerTool('post-connect-tool', { inputSchema: z.object({}) }, () => ({ content: [] }));
|
||||
await waitUntil(() => seen.includes('tools'));
|
||||
expect((await client.listTools()).tools.map(t => t.name)).toContain('post-connect-tool');
|
||||
|
||||
seen.length = 0;
|
||||
server.registerResource('post-connect', 'test://post-connect', {}, () => ({ contents: [] }));
|
||||
await waitUntil(() => seen.includes('resources'));
|
||||
expect((await client.listResources()).resources.map(r => r.uri)).toContain('test://post-connect');
|
||||
|
||||
seen.length = 0;
|
||||
server.registerPrompt('post-connect-prompt', { description: '' }, () => ({ messages: [] }));
|
||||
await waitUntil(() => seen.includes('prompts'));
|
||||
expect((await client.listPrompts()).prompts.map(p => p.name)).toContain('post-connect-prompt');
|
||||
});
|
||||
|
||||
verifies('mcpserver:reach-through:set-request-handler', async ({ transport }: TestArgs) => {
|
||||
const handlerHits: string[] = [];
|
||||
let server!: McpServer;
|
||||
|
||||
const makeServer = () => {
|
||||
server = new McpServer({ name: 's', version: '0' }, { capabilities: { resources: { subscribe: true, listChanged: true } } });
|
||||
server.registerResource('baseline', 'test://baseline', {}, () => ({ contents: [] }));
|
||||
server.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({
|
||||
content: [{ type: 'text', text }]
|
||||
}));
|
||||
return server;
|
||||
};
|
||||
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
server.server.setRequestHandler(SubscribeRequestSchema, async request => {
|
||||
handlerHits.push(request.params.uri);
|
||||
return {};
|
||||
});
|
||||
|
||||
await expect(client.subscribeResource({ uri: 'test://reach-through' })).resolves.toEqual({});
|
||||
await waitUntil(() => handlerHits.includes('test://reach-through'));
|
||||
expect(handlerHits).toEqual(['test://reach-through']);
|
||||
|
||||
const echo = await client.callTool({ name: 'echo', arguments: { text: 'still wired' } });
|
||||
expect(echo.isError).toBeFalsy();
|
||||
expect(echo.content).toEqual([{ type: 'text', text: 'still wired' }]);
|
||||
});
|
||||
817
test/e2e/scenarios/elicitation.test.ts
Normal file
817
test/e2e/scenarios/elicitation.test.ts
Normal file
|
|
@ -0,0 +1,817 @@
|
|||
/**
|
||||
* Self-contained test bodies for elicitation (form and URL modes).
|
||||
*
|
||||
* Each export is a {@link TestCase}: it builds its own server, client, wires
|
||||
* them with {@link wire}, and asserts. Elicitation is a server→client request
|
||||
* flow, so tests inline `client.setRequestHandler(ElicitRequestSchema, ...)` to
|
||||
* script the responses the server will receive.
|
||||
*
|
||||
* Function names mirror the requirement id in camelCase.
|
||||
*/
|
||||
|
||||
import { expect } from 'vitest';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { Client } from '../../../src/client/index.js';
|
||||
import { Server } from '../../../src/server/index.js';
|
||||
import { McpServer } from '../../../src/server/mcp.js';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ElicitationCompleteNotificationSchema,
|
||||
type ElicitRequest,
|
||||
type ElicitRequestFormParams,
|
||||
ElicitRequestSchema,
|
||||
ElicitResultSchema,
|
||||
ErrorCode,
|
||||
ListToolsRequestSchema,
|
||||
McpError,
|
||||
UrlElicitationRequiredError
|
||||
} from '../../../src/types.js';
|
||||
|
||||
import { tapWire, wire } from '../helpers/index.js';
|
||||
import type { TestArgs } from '../types.js';
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
|
||||
/** Client with form-mode elicitation support. */
|
||||
const formClient = () => new Client({ name: 'c', version: '0' }, { capabilities: { elicitation: { form: {} } } });
|
||||
|
||||
/** Client with form-mode elicitation AND applyDefaults support. */
|
||||
const formClientWithDefaults = () =>
|
||||
new Client({ name: 'c', version: '0' }, { capabilities: { elicitation: { form: { applyDefaults: true } } } });
|
||||
|
||||
/** Client with URL-mode elicitation support. */
|
||||
const urlClient = () => new Client({ name: 'c', version: '0' }, { capabilities: { elicitation: { url: {} } } });
|
||||
|
||||
verifies('elicitation:form:basic', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('ask-name', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'What is your name?',
|
||||
requestedSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }
|
||||
});
|
||||
const name = ans.action === 'accept' ? String(ans.content?.name) : '<declined>';
|
||||
return { content: [{ type: 'text', text: `Hello, ${name}` }] };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const received: Array<{ method: string; params: unknown }> = [];
|
||||
const client = formClient();
|
||||
client.setRequestHandler(ElicitRequestSchema, async req => {
|
||||
received.push({ method: req.method, params: req.params });
|
||||
return { action: 'accept', content: { name: 'Ada' } };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'ask-name', arguments: {} });
|
||||
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0].method).toBe('elicitation/create');
|
||||
expect(received[0].params).toMatchObject({
|
||||
mode: 'form',
|
||||
message: 'What is your name?',
|
||||
requestedSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }
|
||||
});
|
||||
|
||||
expect(r.isError).toBeFalsy();
|
||||
expect(r.content).toEqual([{ type: 'text', text: 'Hello, Ada' }]);
|
||||
});
|
||||
|
||||
verifies('elicitation:form:action:accept', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('ask', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Enter name',
|
||||
requestedSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text', text: ans.action === 'accept' ? String(ans.content?.name) : 'none' }]
|
||||
};
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = formClient();
|
||||
client.setRequestHandler(ElicitRequestSchema, async () => {
|
||||
return { action: 'accept', content: { name: 'Ada' } };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'ask', arguments: {} });
|
||||
expect(r.content).toEqual([{ type: 'text', text: 'Ada' }]);
|
||||
});
|
||||
|
||||
verifies('elicitation:form:action:cancel', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('ask', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Enter name',
|
||||
requestedSchema: { type: 'object', properties: { name: { type: 'string' } } }
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text', text: `action:${ans.action},hasContent:${'content' in ans && ans.content !== undefined}` }]
|
||||
};
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = formClient();
|
||||
client.setRequestHandler(ElicitRequestSchema, async () => {
|
||||
return { action: 'cancel' };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'ask', arguments: {} });
|
||||
expect(r.content).toEqual([{ type: 'text', text: 'action:cancel,hasContent:false' }]);
|
||||
});
|
||||
|
||||
verifies('elicitation:form:action:decline', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('ask', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Enter name',
|
||||
requestedSchema: { type: 'object', properties: { name: { type: 'string' } } }
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text', text: `action:${ans.action},hasContent:${'content' in ans && ans.content !== undefined}` }]
|
||||
};
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = formClient();
|
||||
client.setRequestHandler(ElicitRequestSchema, async () => {
|
||||
return { action: 'decline' };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'ask', arguments: {} });
|
||||
expect(r.content).toEqual([{ type: 'text', text: 'action:decline,hasContent:false' }]);
|
||||
});
|
||||
|
||||
verifies('elicitation:form:defaults', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('ask', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Fill form',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', default: 'anon' },
|
||||
age: { type: 'integer', default: 42 },
|
||||
subscribe: { type: 'boolean', default: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
return { content: [], structuredContent: ans.action === 'accept' ? ans.content : undefined };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = formClientWithDefaults();
|
||||
client.setRequestHandler(ElicitRequestSchema, async () => ({ action: 'accept', content: { name: 'Ada' } }));
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'ask', arguments: {} });
|
||||
expect(r.structuredContent).toEqual({ name: 'Ada', age: 42, subscribe: true });
|
||||
});
|
||||
|
||||
verifies('elicitation:form:mode-omitted-default', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } });
|
||||
s.setRequestHandler(ListToolsRequestSchema, () => ({ tools: [{ name: 'ask', inputSchema: { type: 'object' } }] }));
|
||||
s.setRequestHandler(CallToolRequestSchema, async (_req, extra) => {
|
||||
try {
|
||||
const ans = await extra.sendRequest(
|
||||
{
|
||||
method: 'elicitation/create',
|
||||
params: {
|
||||
message: 'Enter name',
|
||||
requestedSchema: { type: 'object', properties: { name: { type: 'string' } } }
|
||||
}
|
||||
},
|
||||
ElicitResultSchema
|
||||
);
|
||||
return { content: [{ type: 'text', text: ans.action }] };
|
||||
} catch (e) {
|
||||
if (!(e instanceof McpError)) throw e;
|
||||
return { content: [{ type: 'text', text: `error:${e.code}` }] };
|
||||
}
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const formReceived: ElicitRequest['params'][] = [];
|
||||
const formClientInstance = formClient();
|
||||
formClientInstance.setRequestHandler(ElicitRequestSchema, async req => {
|
||||
formReceived.push(req.params);
|
||||
return { action: 'accept', content: { name: 'Test' } };
|
||||
});
|
||||
|
||||
await using _1 = await wire(transport, makeServer, formClientInstance);
|
||||
|
||||
const formResult = await formClientInstance.callTool({ name: 'ask', arguments: {} });
|
||||
|
||||
expect(formReceived).toHaveLength(1);
|
||||
expect(formReceived[0].mode).toBeUndefined();
|
||||
expect(formResult.content).toEqual([{ type: 'text', text: 'accept' }]);
|
||||
|
||||
let urlHandlerInvoked = 0;
|
||||
const urlClientInstance = urlClient();
|
||||
urlClientInstance.setRequestHandler(ElicitRequestSchema, async () => {
|
||||
urlHandlerInvoked++;
|
||||
return { action: 'accept' };
|
||||
});
|
||||
|
||||
await using _2 = await wire(transport, makeServer, urlClientInstance);
|
||||
|
||||
const urlResult = await urlClientInstance.callTool({ name: 'ask', arguments: {} });
|
||||
expect(urlResult.content).toEqual([{ type: 'text', text: `error:${ErrorCode.InvalidParams}` }]);
|
||||
expect(urlHandlerInvoked).toBe(0);
|
||||
});
|
||||
|
||||
verifies('elicitation:form:schema:primitives', async ({ transport }: TestArgs) => {
|
||||
const requestedSchema: ElicitRequestFormParams['requestedSchema'] = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
email: { type: 'string', format: 'email' },
|
||||
age: { type: 'integer' },
|
||||
score: { type: 'number' },
|
||||
active: { type: 'boolean' }
|
||||
}
|
||||
};
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('ask', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({ mode: 'form', message: 'Fill form', requestedSchema });
|
||||
return { content: [], structuredContent: ans.action === 'accept' ? ans.content : undefined };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const expected = { name: 'Ada', email: 'ada@example.com', age: 30, score: 95.5, active: true };
|
||||
const received: ElicitRequest['params'][] = [];
|
||||
const client = formClient();
|
||||
client.setRequestHandler(ElicitRequestSchema, async req => {
|
||||
received.push(req.params);
|
||||
return { action: 'accept', content: expected };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'ask', arguments: {} });
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0]).toEqual({ mode: 'form', message: 'Fill form', requestedSchema });
|
||||
expect(r.structuredContent).toEqual(expected);
|
||||
});
|
||||
|
||||
verifies('elicitation:form:schema:enum-variants', async ({ transport }: TestArgs) => {
|
||||
const requestedSchema: ElicitRequestFormParams['requestedSchema'] = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
bare: { type: 'string', enum: ['Red', 'Green', 'Blue'] },
|
||||
titled: {
|
||||
type: 'string',
|
||||
oneOf: [
|
||||
{ const: '#FF0000', title: 'Red' },
|
||||
{ const: '#00FF00', title: 'Green' }
|
||||
]
|
||||
},
|
||||
multi: {
|
||||
type: 'array',
|
||||
items: {
|
||||
anyOf: [
|
||||
{ const: '#FF0000', title: 'Red' },
|
||||
{ const: '#0000FF', title: 'Blue' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('ask', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({ mode: 'form', message: 'Pick colors', requestedSchema });
|
||||
return { content: [], structuredContent: ans.action === 'accept' ? ans.content : undefined };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const expected = { bare: 'Red', titled: '#00FF00', multi: ['#FF0000', '#0000FF'] };
|
||||
const received: ElicitRequest['params'][] = [];
|
||||
const client = formClient();
|
||||
client.setRequestHandler(ElicitRequestSchema, async req => {
|
||||
received.push(req.params);
|
||||
return { action: 'accept', content: expected };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'ask', arguments: {} });
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0]).toEqual({ mode: 'form', message: 'Pick colors', requestedSchema });
|
||||
expect(r.structuredContent).toEqual(expected);
|
||||
});
|
||||
|
||||
verifies('elicitation:form:response-validation', async ({ transport }: TestArgs) => {
|
||||
const requestedSchema: ElicitRequestFormParams['requestedSchema'] = {
|
||||
type: 'object',
|
||||
properties: { username: { type: 'string' } },
|
||||
required: ['username']
|
||||
};
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('signup', { inputSchema: z.object({}) }, async () => {
|
||||
try {
|
||||
const ans = await s.server.elicitInput({ mode: 'form', message: 'Choose a username', requestedSchema });
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: 'text', text: `SDK accepted invalid content: ${JSON.stringify(ans.content)}` }]
|
||||
};
|
||||
} catch (e) {
|
||||
if (!(e instanceof McpError)) throw e;
|
||||
return { content: [{ type: 'text', text: `rejected:${e.code}:${e.message}` }] };
|
||||
}
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
// Each accepted response violates the requested schema: wrong type for `username`, then missing required `username`.
|
||||
const invalidContents: Array<Record<string, string | number | boolean>> = [{ username: 42 }, {}];
|
||||
let handlerInvoked = 0;
|
||||
const client = formClient();
|
||||
client.setRequestHandler(ElicitRequestSchema, async () => {
|
||||
const content = invalidContents[handlerInvoked];
|
||||
handlerInvoked++;
|
||||
return { action: 'accept', content };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const schemaRejection = expect.stringMatching(new RegExp(`^rejected:${ErrorCode.InvalidParams}:.*does not match requested schema`));
|
||||
|
||||
const wrongType = await client.callTool({ name: 'signup', arguments: {} });
|
||||
expect(wrongType.isError).toBeFalsy();
|
||||
expect(wrongType.content).toEqual([{ type: 'text', text: schemaRejection }]);
|
||||
|
||||
const missingRequired = await client.callTool({ name: 'signup', arguments: {} });
|
||||
expect(missingRequired.isError).toBeFalsy();
|
||||
expect(missingRequired.content).toEqual([{ type: 'text', text: schemaRejection }]);
|
||||
|
||||
expect(handlerInvoked).toBe(2);
|
||||
});
|
||||
|
||||
verifies('elicitation:form:schema:restricted-subset', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } });
|
||||
s.setRequestHandler(ListToolsRequestSchema, () => ({ tools: [{ name: 'profile', inputSchema: { type: 'object' } }] }));
|
||||
s.setRequestHandler(CallToolRequestSchema, async (_req, extra) => {
|
||||
try {
|
||||
await extra.sendRequest(
|
||||
{
|
||||
method: 'elicitation/create',
|
||||
params: {
|
||||
mode: 'form',
|
||||
message: 'Profile details',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
address: {
|
||||
type: 'object',
|
||||
properties: { street: { type: 'string' }, city: { type: 'string' } }
|
||||
},
|
||||
contacts: {
|
||||
type: 'array',
|
||||
items: { type: 'object', properties: { name: { type: 'string' } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
ElicitResultSchema
|
||||
);
|
||||
return { content: [{ type: 'text', text: 'should-not-reach' }] };
|
||||
} catch (e) {
|
||||
if (!(e instanceof McpError)) throw e;
|
||||
return { content: [{ type: 'text', text: `error:${e.code}` }] };
|
||||
}
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
let handlerInvoked = 0;
|
||||
const client = formClient();
|
||||
client.setRequestHandler(ElicitRequestSchema, async () => {
|
||||
handlerInvoked++;
|
||||
return { action: 'accept', content: {} };
|
||||
});
|
||||
|
||||
// strictValidation off: the nested requestedSchema deliberately violates the spec's flat-primitive restriction on the wire.
|
||||
await using _ = await wire(transport, makeServer, client, { strictValidation: false });
|
||||
|
||||
const r = await client.callTool({ name: 'profile', arguments: {} });
|
||||
expect(r.isError).toBeFalsy();
|
||||
expect(r.content).toEqual([{ type: 'text', text: `error:${ErrorCode.InvalidParams}` }]);
|
||||
expect(handlerInvoked).toBe(0);
|
||||
});
|
||||
|
||||
verifies('elicitation:url:basic', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('auth', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({
|
||||
mode: 'url',
|
||||
message: 'Please sign in',
|
||||
elicitationId: 'url-1',
|
||||
url: 'https://example.com/auth?state=test'
|
||||
});
|
||||
return { content: [{ type: 'text', text: ans.action }] };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const received: ElicitRequest['params'][] = [];
|
||||
const client = urlClient();
|
||||
client.setRequestHandler(ElicitRequestSchema, async req => {
|
||||
received.push(req.params);
|
||||
return { action: 'accept' };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'auth', arguments: {} });
|
||||
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0]).toMatchObject({
|
||||
mode: 'url',
|
||||
message: 'Please sign in',
|
||||
elicitationId: 'url-1',
|
||||
url: 'https://example.com/auth?state=test'
|
||||
});
|
||||
expect(r.content).toEqual([{ type: 'text', text: 'accept' }]);
|
||||
});
|
||||
|
||||
verifies('elicitation:url:action:accept-no-content', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('auth', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({
|
||||
mode: 'url',
|
||||
message: 'Please sign in',
|
||||
elicitationId: 'url-2',
|
||||
url: 'https://example.com/auth'
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text', text: `action:${ans.action},hasContent:${'content' in ans && ans.content !== undefined}` }]
|
||||
};
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = urlClient();
|
||||
client.setRequestHandler(ElicitRequestSchema, async () => ({ action: 'accept' }));
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'auth', arguments: {} });
|
||||
expect(r.content).toEqual([{ type: 'text', text: 'action:accept,hasContent:false' }]);
|
||||
});
|
||||
|
||||
verifies('elicitation:url:action:cancel', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('auth', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({
|
||||
mode: 'url',
|
||||
message: 'Please sign in',
|
||||
elicitationId: 'url-cancel-1',
|
||||
url: 'https://example.com/auth'
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text', text: `action:${ans.action},hasContent:${'content' in ans && ans.content !== undefined}` }]
|
||||
};
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = urlClient();
|
||||
client.setRequestHandler(ElicitRequestSchema, async () => ({ action: 'cancel' }));
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'auth', arguments: {} });
|
||||
expect(r.isError).toBeFalsy();
|
||||
expect(r.content).toEqual([{ type: 'text', text: 'action:cancel,hasContent:false' }]);
|
||||
});
|
||||
|
||||
verifies('elicitation:url:action:decline', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('auth', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({
|
||||
mode: 'url',
|
||||
message: 'Please sign in',
|
||||
elicitationId: 'url-decline-1',
|
||||
url: 'https://example.com/auth'
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text', text: `action:${ans.action},hasContent:${'content' in ans && ans.content !== undefined}` }]
|
||||
};
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = urlClient();
|
||||
client.setRequestHandler(ElicitRequestSchema, async () => ({ action: 'decline' }));
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'auth', arguments: {} });
|
||||
expect(r.isError).toBeFalsy();
|
||||
expect(r.content).toEqual([{ type: 'text', text: 'action:decline,hasContent:false' }]);
|
||||
});
|
||||
|
||||
verifies('elicitation:url:complete-notification', async ({ transport }: TestArgs) => {
|
||||
const notifications: Array<{ method: string; params: unknown }> = [];
|
||||
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('auth', { inputSchema: z.object({}) }, async () => {
|
||||
throw new UrlElicitationRequiredError([
|
||||
{
|
||||
mode: 'url',
|
||||
message: 'Sign in required',
|
||||
elicitationId: 'complete-1',
|
||||
url: 'https://example.com/auth'
|
||||
}
|
||||
]);
|
||||
});
|
||||
s.registerTool('complete', { inputSchema: z.object({ elicitationId: z.string() }) }, async ({ elicitationId }, extra) => {
|
||||
const notify = s.server.createElicitationCompletionNotifier(elicitationId, { relatedRequestId: extra.requestId });
|
||||
await notify();
|
||||
return { content: [{ type: 'text', text: 'notified' }] };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = urlClient();
|
||||
client.setNotificationHandler(ElicitationCompleteNotificationSchema, n => {
|
||||
notifications.push({ method: n.method, params: n.params });
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const err = await client.callTool({ name: 'auth', arguments: {} }).catch(e => e);
|
||||
if (!(err instanceof UrlElicitationRequiredError)) throw new Error(`expected UrlElicitationRequiredError, got ${err}`);
|
||||
const elicitationId = err.elicitations[0].elicitationId;
|
||||
|
||||
await client.callTool({ name: 'complete', arguments: { elicitationId } });
|
||||
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0]).toEqual({
|
||||
method: 'notifications/elicitation/complete',
|
||||
params: { elicitationId: 'complete-1' }
|
||||
});
|
||||
});
|
||||
|
||||
verifies('elicitation:url:complete-unknown-ignored', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('auth', { inputSchema: z.object({}) }, () => {
|
||||
throw new UrlElicitationRequiredError([
|
||||
{ mode: 'url', message: 'Sign in required', elicitationId: 'seen-1', url: 'https://example.com/auth' }
|
||||
]);
|
||||
});
|
||||
// Raw send: the server-side capability gate cannot pass on stateless (client capabilities never learned),
|
||||
// and the behavior under test is the client's handling of the notification.
|
||||
s.registerTool('complete', { inputSchema: z.object({ elicitationId: z.string() }) }, async ({ elicitationId }, extra) => {
|
||||
await s.server.transport!.send(
|
||||
{ jsonrpc: '2.0', method: 'notifications/elicitation/complete', params: { elicitationId } },
|
||||
{ relatedRequestId: extra.requestId }
|
||||
);
|
||||
return { content: [{ type: 'text', text: 'sent' }] };
|
||||
});
|
||||
s.registerTool('noop', { inputSchema: z.object({}) }, () => ({ content: [] }));
|
||||
return s;
|
||||
};
|
||||
|
||||
const notifications: Array<{ method: string; params: unknown }> = [];
|
||||
const errors: Error[] = [];
|
||||
const client = urlClient();
|
||||
client.onerror = e => errors.push(e);
|
||||
client.setNotificationHandler(ElicitationCompleteNotificationSchema, n => {
|
||||
notifications.push({ method: n.method, params: n.params });
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const err = await client.callTool({ name: 'auth', arguments: {} }).catch(e => e);
|
||||
if (!(err instanceof UrlElicitationRequiredError)) throw new Error(`expected UrlElicitationRequiredError, got ${err}`);
|
||||
const seenId = err.elicitations[0].elicitationId;
|
||||
|
||||
await client.callTool({ name: 'complete', arguments: { elicitationId: seenId } });
|
||||
// Already-completed id, then an id the client never saw: both must be ignored without error.
|
||||
await client.callTool({ name: 'complete', arguments: { elicitationId: seenId } });
|
||||
await client.callTool({ name: 'complete', arguments: { elicitationId: 'never-issued' } });
|
||||
|
||||
expect(notifications).toEqual([
|
||||
{ method: 'notifications/elicitation/complete', params: { elicitationId: 'seen-1' } },
|
||||
{ method: 'notifications/elicitation/complete', params: { elicitationId: 'seen-1' } },
|
||||
{ method: 'notifications/elicitation/complete', params: { elicitationId: 'never-issued' } }
|
||||
]);
|
||||
expect(errors).toEqual([]);
|
||||
|
||||
const after = await client.callTool({ name: 'noop', arguments: {} });
|
||||
expect(after.isError).toBeFalsy();
|
||||
expect(after.content).toEqual([]);
|
||||
});
|
||||
|
||||
verifies('elicitation:url:required-error', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('auth-required', { inputSchema: z.object({}) }, () => {
|
||||
throw new UrlElicitationRequiredError([
|
||||
{
|
||||
mode: 'url',
|
||||
message: 'Please authenticate',
|
||||
elicitationId: 'err-1',
|
||||
url: 'https://example.com/oauth'
|
||||
}
|
||||
]);
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = urlClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const err = await client.callTool({ name: 'auth-required', arguments: {} }).catch(e => e);
|
||||
if (!(err instanceof UrlElicitationRequiredError)) throw new Error(`expected UrlElicitationRequiredError, got ${err}`);
|
||||
expect(err.code).toBe(ErrorCode.UrlElicitationRequired);
|
||||
expect(err.elicitations).toHaveLength(1);
|
||||
expect(err.elicitations[0]).toMatchObject({
|
||||
mode: 'url',
|
||||
message: 'Please authenticate',
|
||||
elicitationId: 'err-1',
|
||||
url: 'https://example.com/oauth'
|
||||
});
|
||||
});
|
||||
|
||||
verifies('elicitation:capability:empty-is-form', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('ask', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Name?',
|
||||
requestedSchema: { type: 'object', properties: { name: { type: 'string' } } }
|
||||
});
|
||||
return { content: [{ type: 'text', text: ans.action }] };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = new Client({ name: 'c', version: '0' }, { capabilities: { elicitation: {} } });
|
||||
client.setRequestHandler(ElicitRequestSchema, async () => {
|
||||
return { action: 'accept', content: { name: 'Test' } };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'ask', arguments: {} });
|
||||
expect(r.content).toEqual([{ type: 'text', text: 'accept' }]);
|
||||
});
|
||||
|
||||
verifies('elicitation:capability:mode-mismatch', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } });
|
||||
s.setRequestHandler(ListToolsRequestSchema, () => ({ tools: [{ name: 'auth', inputSchema: { type: 'object' } }] }));
|
||||
s.setRequestHandler(CallToolRequestSchema, async (_req, extra) => {
|
||||
try {
|
||||
await extra.sendRequest(
|
||||
{
|
||||
method: 'elicitation/create',
|
||||
params: { mode: 'url', message: 'Sign in', elicitationId: 'mismatch-1', url: 'https://example.com/auth' }
|
||||
},
|
||||
ElicitResultSchema
|
||||
);
|
||||
return { content: [{ type: 'text', text: 'should-not-reach' }] };
|
||||
} catch (e) {
|
||||
if (!(e instanceof McpError)) throw e;
|
||||
return { content: [{ type: 'text', text: `error:${e.code}` }] };
|
||||
}
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
let handlerInvoked = 0;
|
||||
const client = formClient();
|
||||
client.setRequestHandler(ElicitRequestSchema, async () => {
|
||||
handlerInvoked++;
|
||||
return { action: 'accept', content: {} };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'auth', arguments: {} });
|
||||
expect(r.content).toEqual([{ type: 'text', text: `error:${ErrorCode.InvalidParams}` }]);
|
||||
expect(handlerInvoked).toBe(0);
|
||||
});
|
||||
|
||||
verifies('elicitation:capability:server-respects-mode', async ({ transport }: TestArgs) => {
|
||||
let reachedClient = 0;
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('auth', { inputSchema: z.object({}) }, async () => {
|
||||
try {
|
||||
await s.server.elicitInput({
|
||||
mode: 'url',
|
||||
message: 'Sign in',
|
||||
elicitationId: 'gate-1',
|
||||
url: 'https://example.com/auth'
|
||||
});
|
||||
return { isError: true, content: [{ type: 'text', text: 'SDK let it through' }] };
|
||||
} catch (e) {
|
||||
return { content: [{ type: 'text', text: `refused:${e instanceof Error ? e.message : e}` }] };
|
||||
}
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = formClient();
|
||||
client.setRequestHandler(ElicitRequestSchema, async () => {
|
||||
reachedClient++;
|
||||
return { action: 'accept' };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'auth', arguments: {} });
|
||||
expect(r.isError).toBeFalsy();
|
||||
expect(r.content).toEqual([{ type: 'text', text: expect.stringMatching(/^refused:.*url elicitation/i) }]);
|
||||
expect(reachedClient).toBe(0);
|
||||
});
|
||||
|
||||
verifies('elicitation:capability:not-declared', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('ask-form', { inputSchema: z.object({}) }, async () => {
|
||||
try {
|
||||
await s.server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'What is your name?',
|
||||
requestedSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }
|
||||
});
|
||||
return { isError: true, content: [{ type: 'text', text: 'SDK let the form elicitation through' }] };
|
||||
} catch (e) {
|
||||
return { content: [{ type: 'text', text: `refused:${e instanceof Error ? e.message : e}` }] };
|
||||
}
|
||||
});
|
||||
s.registerTool('ask-url', { inputSchema: z.object({}) }, async () => {
|
||||
try {
|
||||
await s.server.elicitInput({
|
||||
mode: 'url',
|
||||
message: 'Please sign in',
|
||||
elicitationId: 'no-cap-1',
|
||||
url: 'https://example.com/auth'
|
||||
});
|
||||
return { isError: true, content: [{ type: 'text', text: 'SDK let the url elicitation through' }] };
|
||||
} catch (e) {
|
||||
return { content: [{ type: 'text', text: `refused:${e instanceof Error ? e.message : e}` }] };
|
||||
}
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
// No elicitation capability declared at all — neither mode may reach the wire.
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
const tap = tapWire(client);
|
||||
|
||||
const form = await client.callTool({ name: 'ask-form', arguments: {} });
|
||||
expect(form.isError).toBeFalsy();
|
||||
expect(form.content).toEqual([{ type: 'text', text: 'refused:Client does not support form elicitation.' }]);
|
||||
|
||||
const url = await client.callTool({ name: 'ask-url', arguments: {} });
|
||||
expect(url.isError).toBeFalsy();
|
||||
expect(url.content).toEqual([{ type: 'text', text: 'refused:Client does not support url elicitation.' }]);
|
||||
|
||||
expect(tap.received.filter(m => 'method' in m && m.method === 'elicitation/create')).toEqual([]);
|
||||
});
|
||||
840
test/e2e/scenarios/flow.test.ts
Normal file
840
test/e2e/scenarios/flow.test.ts
Normal file
|
|
@ -0,0 +1,840 @@
|
|||
/**
|
||||
* Self-contained test bodies for composite end-to-end flows.
|
||||
*
|
||||
* These are longer journeys that combine multiple SDK features: multi-step
|
||||
* elicitation, OAuth roundtrip, resumption, session management, proxy
|
||||
* forwarding. Each builds whatever in-test pieces it needs (mock AS, minimal
|
||||
* EventStore, secondary Clients, proxy McpServer delegating to an upstream
|
||||
* Client) rather than importing shared fixtures.
|
||||
*/
|
||||
|
||||
import { expect, vi } from 'vitest';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { Client } from '../../../src/client/index.js';
|
||||
import { type OAuthClientProvider, UnauthorizedError } from '../../../src/client/auth.js';
|
||||
import { StreamableHTTPClientTransport } from '../../../src/client/streamableHttp.js';
|
||||
import { Server } from '../../../src/server/index.js';
|
||||
import { McpServer } from '../../../src/server/mcp.js';
|
||||
import type { EventStore } from '../../../src/server/webStandardStreamableHttp.js';
|
||||
import { type OAuthTokens, type OAuthClientMetadata, type OAuthClientInformationMixed } from '../../../src/shared/auth.js';
|
||||
import {
|
||||
type CallToolResult,
|
||||
ElicitationCompleteNotificationSchema,
|
||||
type ElicitRequest,
|
||||
type ElicitResult,
|
||||
ElicitRequestSchema,
|
||||
ElicitResultSchema,
|
||||
ErrorCode,
|
||||
type JSONRPCMessage,
|
||||
ListResourcesRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
type Progress,
|
||||
ReadResourceRequestSchema,
|
||||
UrlElicitationRequiredError
|
||||
} from '../../../src/types.js';
|
||||
|
||||
import { hostPerSession, hostResumable, wire } from '../helpers/index.js';
|
||||
import type { TestArgs } from '../types.js';
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
|
||||
verifies('flow:elicitation:multi-step-form', async ({ transport }: TestArgs) => {
|
||||
// Server: tool that issues three sequential elicitation/create requests
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('multi-step', { inputSchema: z.object({}) }, async (_args, extra) => {
|
||||
const step1 = await extra.sendRequest(
|
||||
{
|
||||
method: 'elicitation/create',
|
||||
params: {
|
||||
mode: 'form',
|
||||
message: 'What is your name?',
|
||||
requestedSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }
|
||||
}
|
||||
},
|
||||
ElicitResultSchema
|
||||
);
|
||||
if (step1.action !== 'accept' || typeof step1.content?.name !== 'string') {
|
||||
return { content: [{ type: 'text', text: `aborted at step 1: ${step1.action}` }] };
|
||||
}
|
||||
const name = step1.content.name;
|
||||
|
||||
const step2 = await extra.sendRequest(
|
||||
{
|
||||
method: 'elicitation/create',
|
||||
params: {
|
||||
mode: 'form',
|
||||
message: `Hello ${name}, what is your favorite color?`,
|
||||
requestedSchema: { type: 'object', properties: { color: { type: 'string' } }, required: ['color'] }
|
||||
}
|
||||
},
|
||||
ElicitResultSchema
|
||||
);
|
||||
if (step2.action !== 'accept' || typeof step2.content?.color !== 'string') {
|
||||
return { content: [{ type: 'text', text: `aborted at step 2: ${step2.action}` }] };
|
||||
}
|
||||
const color = step2.content.color;
|
||||
|
||||
const step3 = await extra.sendRequest(
|
||||
{
|
||||
method: 'elicitation/create',
|
||||
params: {
|
||||
mode: 'form',
|
||||
message: `${name}, you picked ${color}. Confirm?`,
|
||||
requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] }
|
||||
}
|
||||
},
|
||||
ElicitResultSchema
|
||||
);
|
||||
if (step3.action !== 'accept') {
|
||||
return { content: [{ type: 'text', text: `aborted at step 3: ${step3.action}` }] };
|
||||
}
|
||||
|
||||
return { content: [{ type: 'text', text: `${name}'s favorite color is ${color}` }] };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
// Client: registers handler for elicitation/create, queues responses
|
||||
const received: ElicitRequest[] = [];
|
||||
const queued: ElicitResult[] = [];
|
||||
const client = new Client({ name: 'c', version: '0' }, { capabilities: { elicitation: { form: {} } } });
|
||||
client.setRequestHandler(ElicitRequestSchema, async req => {
|
||||
received.push(req);
|
||||
const resp = queued.shift();
|
||||
if (!resp) throw new Error('no queued elicitation response');
|
||||
return resp;
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
// Happy path: accept all three steps
|
||||
queued.push({ action: 'accept', content: { name: 'Ada' } });
|
||||
queued.push({ action: 'accept', content: { color: 'blue' } });
|
||||
queued.push({ action: 'accept', content: { confirm: true } });
|
||||
|
||||
const ok = await client.callTool({ name: 'multi-step', arguments: {} });
|
||||
expect(ok.isError).toBeFalsy();
|
||||
expect(ok.content).toEqual([{ type: 'text', text: "Ada's favorite color is blue" }]);
|
||||
|
||||
expect(received).toHaveLength(3);
|
||||
expect(received[0].params).toMatchObject({ mode: 'form', requestedSchema: { properties: { name: { type: 'string' } } } });
|
||||
expect(received[1].params.message).toContain('Ada');
|
||||
expect(received[1].params).toMatchObject({ mode: 'form', requestedSchema: { properties: { color: { type: 'string' } } } });
|
||||
expect(received[2].params.message).toContain('Ada');
|
||||
expect(received[2].params.message).toContain('blue');
|
||||
expect(received[2].params).toMatchObject({ mode: 'form', requestedSchema: { properties: { confirm: { type: 'boolean' } } } });
|
||||
|
||||
// Decline at step 2
|
||||
queued.push({ action: 'accept', content: { name: 'Bob' } });
|
||||
queued.push({ action: 'decline' });
|
||||
const declined = await client.callTool({ name: 'multi-step', arguments: {} });
|
||||
expect(declined.isError).toBeFalsy();
|
||||
expect(declined.content).toEqual([{ type: 'text', text: 'aborted at step 2: decline' }]);
|
||||
expect(received).toHaveLength(5);
|
||||
|
||||
// Cancel at step 1
|
||||
queued.push({ action: 'cancel' });
|
||||
queued.push({ action: 'accept', content: { color: 'red' } }); // sentinel: must not be consumed
|
||||
const cancelled = await client.callTool({ name: 'multi-step', arguments: {} });
|
||||
expect(cancelled.isError).toBeFalsy();
|
||||
expect(cancelled.content).toEqual([{ type: 'text', text: 'aborted at step 1: cancel' }]);
|
||||
expect(received).toHaveLength(6);
|
||||
expect(queued).toHaveLength(1); // sentinel still queued
|
||||
});
|
||||
|
||||
verifies('flow:elicitation:url-at-session-init', async (_args: TestArgs) => {
|
||||
// Not wire(): a transport.send tap must be installed before connect to prove no client request precedes the unsolicited elicitation.
|
||||
// Server: issues URL-mode elicitation immediately after onsessioninitialized
|
||||
const makeServer = () => {
|
||||
const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } });
|
||||
s.oninitialized = () => {
|
||||
setTimeout(() => {
|
||||
void s
|
||||
.elicitInput({
|
||||
mode: 'url',
|
||||
message: 'Authorize the session',
|
||||
url: 'https://example.com/auth',
|
||||
elicitationId: 'session-init-elicit'
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 0);
|
||||
};
|
||||
s.setRequestHandler(ListToolsRequestSchema, () => ({ tools: [] }));
|
||||
return s;
|
||||
};
|
||||
|
||||
const received: ElicitRequest[] = [];
|
||||
const client = new Client({ name: 'c', version: '0' }, { capabilities: { elicitation: { url: {} } } });
|
||||
client.setRequestHandler(ElicitRequestSchema, async req => {
|
||||
received.push(req);
|
||||
return { action: 'accept' };
|
||||
});
|
||||
|
||||
const handle = hostPerSession(makeServer);
|
||||
const url = new URL('http://in-process/mcp');
|
||||
const customFetch = (u: URL | string, init?: RequestInit) => handle.handleRequest(new Request(u, init));
|
||||
const transport = new StreamableHTTPClientTransport(url, { fetch: customFetch });
|
||||
|
||||
// Tap wire before connecting
|
||||
const sent: Array<{ method?: string }> = [];
|
||||
const origSend = transport.send.bind(transport);
|
||||
transport.send = async (m, opts) => {
|
||||
sent.push(m as { method?: string });
|
||||
return origSend(m, opts);
|
||||
};
|
||||
|
||||
await client.connect(transport);
|
||||
|
||||
try {
|
||||
// Wait for the unsolicited server→client elicitation/create
|
||||
await vi.waitFor(() => expect(received.length).toBeGreaterThanOrEqual(1));
|
||||
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0].method).toBe('elicitation/create');
|
||||
const params = received[0].params;
|
||||
if (params.mode !== 'url') throw new Error('expected url mode');
|
||||
expect(params.elicitationId).toBe('session-init-elicit');
|
||||
expect(() => new URL(params.url)).not.toThrow();
|
||||
|
||||
// Client has only sent initialize so far (no post-init requests)
|
||||
const requests = sent.filter(m => 'method' in m && 'id' in m);
|
||||
expect(requests.map(r => r.method)).toEqual(['initialize']);
|
||||
|
||||
// Session survived
|
||||
await expect(client.ping()).resolves.toBeDefined();
|
||||
} finally {
|
||||
await Promise.all([client.close(), handle.close()]);
|
||||
}
|
||||
});
|
||||
|
||||
verifies('flow:elicitation:url-required-then-retry', async ({ transport }: TestArgs) => {
|
||||
// Server: tool that throws UrlElicitationRequiredError until elicitation is completed
|
||||
const completed = new Set<string>();
|
||||
let server!: McpServer;
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('url-gated', { inputSchema: z.object({}) }, () => {
|
||||
const elicitationId = 'url-elicit-1';
|
||||
if (!completed.has(elicitationId)) {
|
||||
throw new UrlElicitationRequiredError([
|
||||
{
|
||||
mode: 'url',
|
||||
message: 'Please sign in',
|
||||
elicitationId,
|
||||
url: 'https://example.com/auth'
|
||||
}
|
||||
]);
|
||||
}
|
||||
return { content: [{ type: 'text', text: 'authenticated' }] };
|
||||
});
|
||||
server = s;
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = new Client({ name: 'c', version: '0' }, { capabilities: { elicitation: { url: {} } } });
|
||||
const completionsSeen: string[] = [];
|
||||
client.setNotificationHandler(ElicitationCompleteNotificationSchema, async n => {
|
||||
completionsSeen.push(n.params.elicitationId);
|
||||
});
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
// Step 1: first call rejects with UrlElicitationRequiredError
|
||||
const err = await client.callTool({ name: 'url-gated', arguments: {} }).catch(e => e);
|
||||
expect(err).toBeInstanceOf(UrlElicitationRequiredError);
|
||||
const required = err as UrlElicitationRequiredError;
|
||||
expect(required.code).toBe(ErrorCode.UrlElicitationRequired);
|
||||
expect(required.elicitations).toHaveLength(1);
|
||||
const elicitation = required.elicitations[0];
|
||||
expect(elicitation.mode).toBe('url');
|
||||
expect(typeof elicitation.elicitationId).toBe('string');
|
||||
expect(elicitation.url).toMatch(/^https?:\/\//);
|
||||
|
||||
// Step 2: user "opens" the URL (out-of-band, simulated by marking complete)
|
||||
completed.add(elicitation.elicitationId);
|
||||
|
||||
// Step 3: server emits notifications/elicitation/complete and the client receives it for that exact elicitation
|
||||
await server.server.createElicitationCompletionNotifier(elicitation.elicitationId)();
|
||||
await vi.waitFor(() => expect(completionsSeen).toEqual([elicitation.elicitationId]));
|
||||
|
||||
// Step 4: retry succeeds
|
||||
const result = await client.callTool({ name: 'url-gated', arguments: {} });
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'authenticated' }]);
|
||||
});
|
||||
|
||||
verifies('flow:multi-client:stateful-isolation', async (_args: TestArgs) => {
|
||||
// Not wire(): three clients share one host and the test reads each transport's sessionId; wire() binds a single client to its own host.
|
||||
// Server: per-session McpServer with progress tool
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('progress', { inputSchema: z.object({ steps: z.number().int().positive() }) }, async ({ steps }, extra) => {
|
||||
const token = extra._meta?.progressToken;
|
||||
if (token !== undefined) {
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
await extra.sendNotification({
|
||||
method: 'notifications/progress',
|
||||
params: { progressToken: token, progress: i, total: steps, message: `step ${i}/${steps}` }
|
||||
});
|
||||
}
|
||||
}
|
||||
return { content: [{ type: 'text', text: `done after ${steps} steps` }] };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
// Three clients connect to the same per-session host
|
||||
const clientA = new Client({ name: 'a', version: '0' });
|
||||
const clientB = new Client({ name: 'b', version: '0' });
|
||||
const clientC = new Client({ name: 'c', version: '0' });
|
||||
|
||||
const handle = hostPerSession(makeServer);
|
||||
const url = new URL('http://in-process/mcp');
|
||||
const customFetch = (u: URL | string, init?: RequestInit) => handle.handleRequest(new Request(u, init));
|
||||
|
||||
await clientA.connect(new StreamableHTTPClientTransport(url, { fetch: customFetch }));
|
||||
await clientB.connect(new StreamableHTTPClientTransport(url, { fetch: customFetch }));
|
||||
await clientC.connect(new StreamableHTTPClientTransport(url, { fetch: customFetch }));
|
||||
|
||||
try {
|
||||
const clients = [clientA, clientB, clientC] as const;
|
||||
const sessionIds = clients.map(c => (c.transport as StreamableHTTPClientTransport).sessionId);
|
||||
|
||||
for (const id of sessionIds) {
|
||||
expect(id).toEqual(expect.any(String));
|
||||
}
|
||||
expect(new Set(sessionIds).size).toBe(3);
|
||||
|
||||
// Concurrent progress calls with distinct step counts
|
||||
const STEPS = [2, 3, 4] as const;
|
||||
const errors: Error[][] = [[], [], []];
|
||||
clients.forEach((cl, i) => {
|
||||
cl.onerror = e => errors[i].push(e);
|
||||
});
|
||||
|
||||
const runs = await Promise.all(
|
||||
clients.map(async (cl, i) => {
|
||||
const received: Progress[] = [];
|
||||
const result = await cl.callTool({ name: 'progress', arguments: { steps: STEPS[i] } }, undefined, {
|
||||
onprogress: p => received.push({ progress: p.progress, total: p.total, message: p.message })
|
||||
});
|
||||
return { received, result };
|
||||
})
|
||||
);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const steps = STEPS[i];
|
||||
const { received, result } = runs[i];
|
||||
expect(received).toEqual(
|
||||
Array.from({ length: steps }, (_, k) => ({
|
||||
progress: k + 1,
|
||||
total: steps,
|
||||
message: `step ${k + 1}/${steps}`
|
||||
}))
|
||||
);
|
||||
expect(result.content).toEqual([{ type: 'text', text: `done after ${steps} steps` }]);
|
||||
}
|
||||
|
||||
for (const errs of errors) {
|
||||
expect(errs).toEqual([]);
|
||||
}
|
||||
} finally {
|
||||
await Promise.all([clientA.close(), clientB.close(), clientC.close(), handle.close()]);
|
||||
}
|
||||
});
|
||||
|
||||
verifies('flow:oauth:authorization-code-roundtrip', async (_args: TestArgs) => {
|
||||
// Not wire(): needs an authProvider-equipped client transport plus 401/PRM/AS fetch routing in front of the host, which wire() does not expose.
|
||||
const ACCESS_TOKEN = 'roundtrip-access-token';
|
||||
const AUTH_CODE = 'granted-authorization-code';
|
||||
const AS_ORIGIN = 'https://as.example.com';
|
||||
const PRM_PATH = '/.well-known/oauth-protected-resource';
|
||||
|
||||
// Mock Authorization Server: metadata discovery, dynamic client registration, code exchange.
|
||||
const registerRequests: Array<Record<string, unknown>> = [];
|
||||
const tokenRequests: URLSearchParams[] = [];
|
||||
const mockASFetch = async (url: URL | string, init?: RequestInit): Promise<Response> => {
|
||||
const req = new Request(url, init);
|
||||
const u = new URL(req.url);
|
||||
if (u.pathname === '/.well-known/oauth-authorization-server') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
issuer: AS_ORIGIN,
|
||||
authorization_endpoint: `${AS_ORIGIN}/authorize`,
|
||||
token_endpoint: `${AS_ORIGIN}/token`,
|
||||
registration_endpoint: `${AS_ORIGIN}/register`,
|
||||
grant_types_supported: ['authorization_code'],
|
||||
response_types_supported: ['code'],
|
||||
code_challenge_methods_supported: ['S256']
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
if (u.pathname === '/register' && req.method === 'POST') {
|
||||
const body: Record<string, unknown> = await req.json();
|
||||
registerRequests.push(body);
|
||||
// RFC 7591: the registration response echoes the submitted metadata plus the issued client_id.
|
||||
return new Response(JSON.stringify({ ...body, client_id: 'mock-client-id' }), {
|
||||
status: 201,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
if (u.pathname === '/token' && req.method === 'POST') {
|
||||
tokenRequests.push(new URLSearchParams(await req.text()));
|
||||
return new Response(JSON.stringify({ access_token: ACCESS_TOKEN, token_type: 'Bearer', expires_in: 3600 }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
return new Response('Not Found', { status: 404 });
|
||||
};
|
||||
|
||||
// Mock OAuthClientProvider
|
||||
const redirectedTo: string[] = [];
|
||||
const saved: { tokens?: OAuthTokens; clientInfo?: OAuthClientInformationMixed; codeVerifier?: string } = {};
|
||||
const provider: OAuthClientProvider = {
|
||||
get redirectUrl() {
|
||||
return 'http://localhost/callback';
|
||||
},
|
||||
get clientMetadata(): OAuthClientMetadata {
|
||||
return {
|
||||
client_name: 'test-client',
|
||||
client_uri: 'http://localhost',
|
||||
redirect_uris: ['http://localhost/callback']
|
||||
};
|
||||
},
|
||||
clientInformation: () => saved.clientInfo,
|
||||
saveClientInformation: async ci => {
|
||||
saved.clientInfo = ci;
|
||||
},
|
||||
tokens: () => saved.tokens,
|
||||
saveTokens: async t => {
|
||||
saved.tokens = t;
|
||||
},
|
||||
codeVerifier: () => {
|
||||
if (!saved.codeVerifier) throw new Error('No code verifier saved');
|
||||
return saved.codeVerifier;
|
||||
},
|
||||
saveCodeVerifier: async v => {
|
||||
saved.codeVerifier = v;
|
||||
},
|
||||
redirectToAuthorization: async url => {
|
||||
redirectedTo.push(url.toString());
|
||||
}
|
||||
};
|
||||
|
||||
// Server with bearer auth
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({
|
||||
content: [{ type: 'text', text }]
|
||||
}));
|
||||
return s;
|
||||
};
|
||||
|
||||
// Protected host: serves RFC 9728 resource metadata, otherwise requires the issued bearer token.
|
||||
const handle = hostPerSession(makeServer);
|
||||
const url = new URL('http://in-process/mcp');
|
||||
const serverFetch = async (u: URL | string, init?: RequestInit): Promise<Response> => {
|
||||
const req = new Request(u, init);
|
||||
const requestUrl = new URL(req.url);
|
||||
if (requestUrl.pathname.startsWith(PRM_PATH)) {
|
||||
return new Response(JSON.stringify({ resource: url.toString(), authorization_servers: [AS_ORIGIN] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
const auth = req.headers.get('authorization');
|
||||
if (auth !== `Bearer ${ACCESS_TOKEN}`) {
|
||||
return new Response(null, {
|
||||
status: 401,
|
||||
headers: { 'WWW-Authenticate': `Bearer resource_metadata="http://in-process${PRM_PATH}/mcp"` }
|
||||
});
|
||||
}
|
||||
return handle.handleRequest(req);
|
||||
};
|
||||
|
||||
const combinedFetch = async (u: URL | string, init?: RequestInit): Promise<Response> => {
|
||||
const requestUrl = typeof u === 'string' ? new URL(u) : u;
|
||||
if (requestUrl.hostname === 'as.example.com') return mockASFetch(requestUrl, init);
|
||||
return serverFetch(requestUrl, init);
|
||||
};
|
||||
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
|
||||
try {
|
||||
// Step 1: first connect fails with 401 → discovery + DCR + redirect to the authorization endpoint
|
||||
const transport1 = new StreamableHTTPClientTransport(url, { authProvider: provider, fetch: combinedFetch });
|
||||
await expect(client.connect(transport1)).rejects.toBeInstanceOf(UnauthorizedError);
|
||||
|
||||
expect(redirectedTo).toHaveLength(1);
|
||||
const authorizeUrl = new URL(redirectedTo[0]);
|
||||
expect(authorizeUrl.origin).toBe(AS_ORIGIN);
|
||||
expect(authorizeUrl.pathname).toBe('/authorize');
|
||||
expect(authorizeUrl.searchParams.get('client_id')).toBe('mock-client-id');
|
||||
expect(authorizeUrl.searchParams.get('code_challenge')).toBeTruthy();
|
||||
expect(registerRequests).toHaveLength(1);
|
||||
expect(saved.tokens).toBeUndefined();
|
||||
|
||||
const codeVerifier = saved.codeVerifier;
|
||||
if (!codeVerifier) throw new Error('No code verifier saved during the redirect step');
|
||||
|
||||
// Step 2: user completes redirect, finishAuth exchanges the code for tokens
|
||||
await transport1.finishAuth(AUTH_CODE);
|
||||
|
||||
expect(tokenRequests).toHaveLength(1);
|
||||
expect(tokenRequests[0].get('grant_type')).toBe('authorization_code');
|
||||
expect(tokenRequests[0].get('code')).toBe(AUTH_CODE);
|
||||
expect(tokenRequests[0].get('code_verifier')).toBe(codeVerifier);
|
||||
expect(tokenRequests[0].get('redirect_uri')).toBe('http://localhost/callback');
|
||||
expect(saved.tokens?.access_token).toBe(ACCESS_TOKEN);
|
||||
|
||||
// Step 3: second connect with fresh transport succeeds and tools/list works
|
||||
const transport2 = new StreamableHTTPClientTransport(url, { authProvider: provider, fetch: combinedFetch });
|
||||
await client.connect(transport2);
|
||||
|
||||
const { tools } = await client.listTools();
|
||||
expect(tools.some(t => t.name === 'echo')).toBe(true);
|
||||
} finally {
|
||||
await Promise.all([client.close(), handle.close()]);
|
||||
}
|
||||
});
|
||||
|
||||
verifies('flow:resume:tool-call-resumption-token', async (_args: TestArgs) => {
|
||||
// Not wire(): needs an EventStore-backed host and a severable fetch to simulate a mid-stream disconnect, which wire() does not expose.
|
||||
const TOTAL_STEPS = 4;
|
||||
const DELIVERED_BEFORE_DISCONNECT = 2;
|
||||
|
||||
// The tool pauses after the first two progress notifications so the test can sever the call's SSE
|
||||
// stream before the remaining notifications and the result are produced.
|
||||
let releaseRemainingSteps!: () => void;
|
||||
const remainingStepsReleased = new Promise<void>(resolve => {
|
||||
releaseRemainingSteps = resolve;
|
||||
});
|
||||
let toolRuns = 0;
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('import-records', { inputSchema: z.object({ steps: z.number().int().positive() }) }, async ({ steps }, extra) => {
|
||||
toolRuns++;
|
||||
const token = extra._meta?.progressToken;
|
||||
if (token === undefined) throw new Error('import-records expects a progressToken');
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
if (i === DELIVERED_BEFORE_DISCONNECT + 1) {
|
||||
await remainingStepsReleased;
|
||||
}
|
||||
await extra.sendNotification({
|
||||
method: 'notifications/progress',
|
||||
params: { progressToken: token, progress: i, total: steps }
|
||||
});
|
||||
}
|
||||
return { content: [{ type: 'text', text: `imported ${steps} record batches` }] };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
// Insertion-ordered EventStore: replay order (and therefore the exact-sequence assertions below) must not depend on timestamps.
|
||||
const storedEvents: Array<{ eventId: string; streamId: string; message: JSONRPCMessage }> = [];
|
||||
const eventStore: EventStore = {
|
||||
storeEvent: async (streamId, message) => {
|
||||
const eventId = `${streamId}|${storedEvents.length + 1}`;
|
||||
storedEvents.push({ eventId, streamId, message });
|
||||
return eventId;
|
||||
},
|
||||
replayEventsAfter: async (lastEventId, { send }) => {
|
||||
const lastIndex = storedEvents.findIndex(e => e.eventId === lastEventId);
|
||||
if (lastIndex === -1) return '';
|
||||
const { streamId } = storedEvents[lastIndex];
|
||||
for (const event of storedEvents.slice(lastIndex + 1)) {
|
||||
if (event.streamId === streamId) await send(event.eventId, event.message);
|
||||
}
|
||||
return streamId;
|
||||
}
|
||||
};
|
||||
|
||||
const handle = hostResumable(makeServer, { eventStore, retryInterval: 0 });
|
||||
const url = new URL('http://in-process/mcp');
|
||||
|
||||
const requests: Array<{ method: string; lastEventId: string | undefined; sessionId: string | undefined; body: string | undefined }> =
|
||||
[];
|
||||
|
||||
// The tools/call response is wrapped so the test can sever the stream the client is reading (a simulated
|
||||
// network drop); the server keeps writing the rest of the stream, which only the EventStore retains.
|
||||
let severToolCallStream: () => void = () => {
|
||||
throw new Error('tools/call SSE stream not opened yet');
|
||||
};
|
||||
let originalStreamFinished!: () => void;
|
||||
const originalStreamComplete = new Promise<void>(resolve => {
|
||||
originalStreamFinished = resolve;
|
||||
});
|
||||
|
||||
const customFetch = async (u: URL | string, init?: RequestInit): Promise<Response> => {
|
||||
const request = new Request(u, init);
|
||||
const lastEventId = request.headers.get('last-event-id') ?? undefined;
|
||||
const body = typeof init?.body === 'string' ? init.body : undefined;
|
||||
requests.push({ method: request.method, lastEventId, sessionId: request.headers.get('mcp-session-id') ?? undefined, body });
|
||||
|
||||
// Hold the transport's recovery GET until the server has finished the interrupted stream, so the
|
||||
// replay deterministically contains every message the client missed.
|
||||
if (request.method === 'GET' && lastEventId !== undefined) {
|
||||
await originalStreamComplete;
|
||||
}
|
||||
|
||||
const response = await handle.handleRequest(request);
|
||||
if (request.method !== 'POST' || !body?.includes('"tools/call"') || !response.body) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const upstream = response.body.getReader();
|
||||
let severed = false;
|
||||
const severable = new ReadableStream<Uint8Array>({
|
||||
start: controller => {
|
||||
severToolCallStream = () => {
|
||||
severed = true;
|
||||
controller.error(new Error('simulated mid-stream network drop'));
|
||||
};
|
||||
void (async () => {
|
||||
while (true) {
|
||||
const { value, done } = await upstream.read();
|
||||
if (done) break;
|
||||
if (!severed && value) controller.enqueue(value);
|
||||
}
|
||||
originalStreamFinished();
|
||||
if (!severed) controller.close();
|
||||
})();
|
||||
}
|
||||
});
|
||||
return new Response(severable, { status: response.status, headers: response.headers });
|
||||
};
|
||||
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
const transport = new StreamableHTTPClientTransport(url, {
|
||||
fetch: customFetch,
|
||||
reconnectionOptions: { initialReconnectionDelay: 10, maxReconnectionDelay: 10, reconnectionDelayGrowFactor: 1, maxRetries: 2 }
|
||||
});
|
||||
await client.connect(transport);
|
||||
|
||||
try {
|
||||
const progressSeen: number[] = [];
|
||||
const eventIdsSeen: string[] = [];
|
||||
const callSettled = vi.fn();
|
||||
|
||||
// The one and only tools/call of this test: this same promise must resolve after the disconnect.
|
||||
const call = client.callTool({ name: 'import-records', arguments: { steps: TOTAL_STEPS } }, undefined, {
|
||||
onprogress: (p: Progress) => progressSeen.push(p.progress),
|
||||
onresumptiontoken: id => eventIdsSeen.push(id)
|
||||
});
|
||||
call.then(callSettled, callSettled);
|
||||
|
||||
await vi.waitFor(() => expect(progressSeen).toEqual([1, 2]));
|
||||
const lastEventIdBeforeDisconnect = eventIdsSeen[eventIdsSeen.length - 1];
|
||||
|
||||
// Sever mid-call, then let the server finish steps 3..4 and the result while the client is disconnected.
|
||||
severToolCallStream();
|
||||
expect(callSettled).not.toHaveBeenCalled();
|
||||
releaseRemainingSteps();
|
||||
await originalStreamComplete;
|
||||
expect(progressSeen).toEqual([1, 2]);
|
||||
expect(callSettled).not.toHaveBeenCalled();
|
||||
|
||||
// The original promise resolves with the tool's result; the handler ran exactly once.
|
||||
const result = await call;
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(result.content).toEqual([{ type: 'text', text: `imported ${TOTAL_STEPS} record batches` }]);
|
||||
expect(toolRuns).toBe(1);
|
||||
|
||||
// Recovery delivered only the missed notifications: no duplicates, none missing.
|
||||
expect(progressSeen).toEqual([1, 2, 3, 4]);
|
||||
|
||||
const lastEventBeforeDisconnect = storedEvents.find(e => e.eventId === lastEventIdBeforeDisconnect);
|
||||
if (!lastEventBeforeDisconnect) throw new Error('the last event id delivered before the disconnect was never stored');
|
||||
const toolCallStreamEvents = storedEvents.filter(e => e.streamId === lastEventBeforeDisconnect.streamId);
|
||||
|
||||
// The whole stream was persisted (priming placeholder, four progress notifications, result), and the
|
||||
// client saw each of its events exactly once, in order, across the original stream and the replay.
|
||||
expect(
|
||||
toolCallStreamEvents.map(e => ('method' in e.message ? e.message.method : 'result' in e.message ? 'response' : 'priming'))
|
||||
).toEqual([
|
||||
'priming',
|
||||
'notifications/progress',
|
||||
'notifications/progress',
|
||||
'notifications/progress',
|
||||
'notifications/progress',
|
||||
'response'
|
||||
]);
|
||||
expect(eventIdsSeen).toEqual(toolCallStreamEvents.map(e => e.eventId));
|
||||
// Index DELIVERED_BEFORE_DISCONNECT skips the priming event, landing on the second progress notification.
|
||||
expect(lastEventIdBeforeDisconnect).toBe(toolCallStreamEvents[DELIVERED_BEFORE_DISCONNECT].eventId);
|
||||
|
||||
// Transparency: the caller never re-issued the call; the transport recovered with a single GET
|
||||
// carrying Last-Event-ID for the last event delivered before the disconnect, on the same session.
|
||||
expect(requests.filter(r => r.method === 'POST' && r.body?.includes('"tools/call"'))).toHaveLength(1);
|
||||
const recoveryGets = requests.filter(r => r.method === 'GET' && r.lastEventId !== undefined);
|
||||
expect(recoveryGets).toHaveLength(1);
|
||||
expect(recoveryGets[0].lastEventId).toBe(lastEventIdBeforeDisconnect);
|
||||
expect(transport.sessionId).toEqual(expect.any(String));
|
||||
expect(recoveryGets[0].sessionId).toBe(transport.sessionId);
|
||||
} finally {
|
||||
await Promise.all([client.close(), handle.close()]);
|
||||
}
|
||||
});
|
||||
|
||||
verifies('flow:session:terminate-then-reconnect', async (_args: TestArgs) => {
|
||||
// Not wire(): drives StreamableHTTPClientTransport directly for sessionId/terminateSession() and connects a second transport to the same host.
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({
|
||||
content: [{ type: 'text', text }]
|
||||
}));
|
||||
return s;
|
||||
};
|
||||
|
||||
const handle = hostPerSession(makeServer);
|
||||
const url = new URL('http://in-process/mcp');
|
||||
const customFetch = (u: URL | string, init?: RequestInit) => handle.handleRequest(new Request(u, init));
|
||||
|
||||
const client1 = new Client({ name: 'c', version: '0' });
|
||||
const transport1 = new StreamableHTTPClientTransport(url, { fetch: customFetch });
|
||||
await client1.connect(transport1);
|
||||
|
||||
try {
|
||||
const originalSessionId = transport1.sessionId;
|
||||
expect(originalSessionId).toEqual(expect.any(String));
|
||||
|
||||
// Terminate session
|
||||
await transport1.terminateSession();
|
||||
expect(transport1.sessionId).toBeUndefined();
|
||||
|
||||
// Close first client
|
||||
await client1.close();
|
||||
|
||||
// Fresh client and transport obtain new session
|
||||
const client2 = new Client({ name: 'c', version: '0' });
|
||||
const transport2 = new StreamableHTTPClientTransport(url, { fetch: customFetch });
|
||||
await client2.connect(transport2);
|
||||
|
||||
const newSessionId = transport2.sessionId;
|
||||
expect(newSessionId).toEqual(expect.any(String));
|
||||
expect(newSessionId).not.toBe(originalSessionId);
|
||||
|
||||
// Operations succeed on new session
|
||||
const result = await client2.callTool({ name: 'echo', arguments: { text: 'after-reconnect' } });
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'after-reconnect' }]);
|
||||
|
||||
await client2.close();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('flow:tool-result:resource-link-follow', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('resource-link', { inputSchema: z.object({}) }, () => ({
|
||||
content: [
|
||||
{
|
||||
type: 'resource_link',
|
||||
uri: 'file:///linked.txt',
|
||||
name: 'linked.txt',
|
||||
mimeType: 'text/plain'
|
||||
}
|
||||
]
|
||||
}));
|
||||
s.registerResource('linked.txt', 'file:///linked.txt', { mimeType: 'text/plain' }, async () => ({
|
||||
contents: [{ uri: 'file:///linked.txt', mimeType: 'text/plain', text: 'linked resource contents' }]
|
||||
}));
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
// Call tool and get resource_link
|
||||
const toolResult = (await client.callTool({ name: 'resource-link', arguments: {} })) as CallToolResult;
|
||||
expect(toolResult.isError).toBeFalsy();
|
||||
|
||||
const link = toolResult.content.find(c => c.type === 'resource_link');
|
||||
expect(link).toBeDefined();
|
||||
if (link?.type !== 'resource_link') throw new Error('unreachable');
|
||||
expect(link.uri).toBe('file:///linked.txt');
|
||||
|
||||
// Follow the link with resources/read
|
||||
const readResult = await client.readResource({ uri: link.uri });
|
||||
expect(readResult.contents).toHaveLength(1);
|
||||
const [entry] = readResult.contents;
|
||||
expect(entry.uri).toBe(link.uri);
|
||||
expect(entry.mimeType).toBe('text/plain');
|
||||
if (entry.mimeType?.startsWith('text/')) {
|
||||
expect('text' in entry ? entry.text : '').toBe('linked resource contents');
|
||||
}
|
||||
});
|
||||
|
||||
verifies('flow:proxy:forward-tools-resources', async ({ transport }: TestArgs) => {
|
||||
// Upstream server with tools and resources
|
||||
const upstreamServer = new McpServer({ name: 'upstream', version: '0' });
|
||||
upstreamServer.registerTool('echo', { description: 'Echoes text', inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({
|
||||
content: [{ type: 'text', text }]
|
||||
}));
|
||||
upstreamServer.registerTool(
|
||||
'annotated',
|
||||
{
|
||||
description: 'Annotated tool',
|
||||
inputSchema: z.object({}),
|
||||
annotations: { readOnlyHint: true, idempotentHint: true },
|
||||
_meta: { 'example.com/fixture': true }
|
||||
},
|
||||
() => ({ content: [] })
|
||||
);
|
||||
upstreamServer.registerResource(
|
||||
'annotated',
|
||||
'file:///annotated.md',
|
||||
{
|
||||
mimeType: 'text/markdown',
|
||||
_meta: { 'example.com/fixture': true }
|
||||
},
|
||||
async () => ({
|
||||
contents: [{ uri: 'file:///annotated.md', mimeType: 'text/markdown', text: '# Annotated' }]
|
||||
})
|
||||
);
|
||||
|
||||
// Proxy: low-level Server downstream + Client upstream
|
||||
const upstreamClient = new Client({ name: 'proxy-upstream', version: '0' });
|
||||
const proxyServer = new Server({ name: 'proxy', version: '0' }, { capabilities: { tools: {}, resources: {} } });
|
||||
|
||||
proxyServer.setRequestHandler(ListToolsRequestSchema, async req => {
|
||||
const result = await upstreamClient.listTools(req.params);
|
||||
return { tools: result.tools, nextCursor: result.nextCursor };
|
||||
});
|
||||
proxyServer.setRequestHandler(ListResourcesRequestSchema, async req => {
|
||||
const result = await upstreamClient.listResources(req.params);
|
||||
return { resources: result.resources, nextCursor: result.nextCursor };
|
||||
});
|
||||
proxyServer.setRequestHandler(ReadResourceRequestSchema, async req => {
|
||||
return upstreamClient.readResource(req.params);
|
||||
});
|
||||
|
||||
// Wire: downstream client → proxy server → upstream client → upstream server
|
||||
const downstreamClient = new Client({ name: 'downstream', version: '0' });
|
||||
|
||||
await using _upstreamW = await wire(transport, () => upstreamServer, upstreamClient);
|
||||
await using _proxyW = await wire(transport, () => proxyServer, downstreamClient);
|
||||
|
||||
// Downstream sees upstream tools
|
||||
const { tools } = await downstreamClient.listTools();
|
||||
const echo = tools.find(t => t.name === 'echo');
|
||||
expect(echo).toBeDefined();
|
||||
expect(echo!.description).toBe('Echoes text');
|
||||
expect(echo!.inputSchema.properties).toMatchObject({ text: { type: 'string' } });
|
||||
|
||||
const annotatedTool = tools.find(t => t.name === 'annotated');
|
||||
expect(annotatedTool).toBeDefined();
|
||||
expect(annotatedTool!.annotations).toMatchObject({ readOnlyHint: true, idempotentHint: true });
|
||||
expect(annotatedTool!._meta).toEqual({ 'example.com/fixture': true });
|
||||
|
||||
// Downstream sees upstream resources
|
||||
const { resources } = await downstreamClient.listResources();
|
||||
const annotatedRes = resources.find(r => r.uri === 'file:///annotated.md');
|
||||
expect(annotatedRes).toBeDefined();
|
||||
expect(annotatedRes!.name).toBe('annotated');
|
||||
expect(annotatedRes!._meta).toEqual({ 'example.com/fixture': true });
|
||||
});
|
||||
140
test/e2e/scenarios/hosting-auth.test.ts
Normal file
140
test/e2e/scenarios/hosting-auth.test.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
/**
|
||||
* Self-contained test bodies for server-side bearer auth on StreamableHTTP
|
||||
* hosting.
|
||||
*
|
||||
* The SDK ships its bearer-auth enforcement (`requireBearerAuth`), AS router
|
||||
* (`mcpAuthRouter`) and discovery-metadata router (`mcpAuthMetadataRouter`)
|
||||
* as Express `RequestHandler`s only — they consume Express `req`/`res`, so
|
||||
* they cannot be driven in-process with Web-standard Request/Response the way
|
||||
* this suite hosts StreamableHTTP. The requirements those components alone
|
||||
* satisfy (401/403 challenges, audience validation, AS endpoint mounting,
|
||||
* `.well-known` publication) are therefore not covered here: re-implementing
|
||||
* that behavior in a test wrapper and asserting on the wrapper would prove
|
||||
* nothing about the SDK. What this file does cover is the hosting-integration
|
||||
* path the Web-standard transport supports natively: a user-shaped bearer
|
||||
* gate verifies the Authorization header, then delegates to
|
||||
* `WebStandardStreamableHTTPServerTransport.handleRequest(req, { authInfo })`,
|
||||
* and the SDK must surface that verified AuthInfo to tool handlers as
|
||||
* `extra.authInfo`.
|
||||
*
|
||||
* Function names mirror the requirement id in camelCase. Bodies are
|
||||
* streamableHttp-only and host the transport themselves (the gate has to sit
|
||||
* between the client's fetch and the transport), so `TestArgs.transport` is
|
||||
* ignored.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { expect } from 'vitest';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { Client } from '../../../src/client/index.js';
|
||||
import { StreamableHTTPClientTransport } from '../../../src/client/streamableHttp.js';
|
||||
import type { AuthInfo } from '../../../src/server/auth/types.js';
|
||||
import { McpServer } from '../../../src/server/mcp.js';
|
||||
import { WebStandardStreamableHTTPServerTransport } from '../../../src/server/webStandardStreamableHttp.js';
|
||||
|
||||
import type { TestArgs } from '../types.js';
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
|
||||
const VALID_TOKEN = 'analytics-dashboard-access-token';
|
||||
|
||||
verifies('hosting:auth:authinfo-propagates', async (_args: TestArgs) => {
|
||||
const expiresAt = Math.floor(Date.now() / 1000) + 3600;
|
||||
// What the user's verifier derives from VALID_TOKEN; a fresh object is built
|
||||
// per request so the handler-side assertion checks delivery, not identity.
|
||||
const verifyBearer = (header: string | null): AuthInfo | undefined =>
|
||||
header === `Bearer ${VALID_TOKEN}`
|
||||
? {
|
||||
token: VALID_TOKEN,
|
||||
clientId: 'analytics-dashboard',
|
||||
scopes: ['mcp:tools:read', 'mcp:tools:call'],
|
||||
expiresAt,
|
||||
extra: { userId: 'user-42' }
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Recorders live outside the per-session factory.
|
||||
const seenByTool: Array<AuthInfo | undefined> = [];
|
||||
const postAuthHeaders: Array<string | null> = [];
|
||||
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool(
|
||||
'whoami',
|
||||
{ description: 'Reports the authenticated caller derived from extra.authInfo.', inputSchema: z.object({}) },
|
||||
(_a, extra) => {
|
||||
seenByTool.push(extra.authInfo);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: extra.authInfo ? `${extra.authInfo.clientId} [${extra.authInfo.scopes.join(' ')}]` : 'no-auth-info'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
);
|
||||
return s;
|
||||
};
|
||||
|
||||
// User-shaped hosting: a bearer gate verifies the Authorization header and
|
||||
// hands the verified AuthInfo to the SDK transport via handleRequest options.
|
||||
const sessions = new Map<string, WebStandardStreamableHTTPServerTransport>();
|
||||
const handleRequest = async (req: Request): Promise<Response> => {
|
||||
if (req.method === 'POST') postAuthHeaders.push(req.headers.get('authorization'));
|
||||
const authInfo = verifyBearer(req.headers.get('authorization'));
|
||||
if (!authInfo) {
|
||||
return new Response(JSON.stringify({ error: 'invalid_token' }), {
|
||||
status: 401,
|
||||
headers: { 'content-type': 'application/json', 'www-authenticate': 'Bearer error="invalid_token"' }
|
||||
});
|
||||
}
|
||||
|
||||
const sid = req.headers.get('mcp-session-id') ?? undefined;
|
||||
const existing = sid ? sessions.get(sid) : undefined;
|
||||
if (existing) return existing.handleRequest(req, { authInfo });
|
||||
|
||||
const tx = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: randomUUID,
|
||||
onsessioninitialized: id => void sessions.set(id, tx),
|
||||
onsessionclosed: id => void sessions.delete(id)
|
||||
});
|
||||
await makeServer().connect(tx);
|
||||
return tx.handleRequest(req, { authInfo });
|
||||
};
|
||||
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
await using _ = {
|
||||
[Symbol.asyncDispose]: async () => {
|
||||
await client.close();
|
||||
for (const t of sessions.values()) await t.close();
|
||||
}
|
||||
};
|
||||
|
||||
await client.connect(
|
||||
new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), {
|
||||
fetch: (url, init) => handleRequest(new Request(url, init)),
|
||||
requestInit: { headers: { Authorization: `Bearer ${VALID_TOKEN}` } }
|
||||
})
|
||||
);
|
||||
|
||||
const r = await client.callTool({ name: 'whoami', arguments: {} });
|
||||
expect(r.isError).toBeFalsy();
|
||||
expect(r.content).toEqual([{ type: 'text', text: 'analytics-dashboard [mcp:tools:read mcp:tools:call]' }]);
|
||||
|
||||
// The SDK transport delivered the verified AuthInfo into the tool handler's
|
||||
// RequestHandlerExtra unchanged — not dropped, not replaced by a placeholder.
|
||||
expect(seenByTool).toHaveLength(1);
|
||||
expect(seenByTool[0]).toEqual({
|
||||
token: VALID_TOKEN,
|
||||
clientId: 'analytics-dashboard',
|
||||
scopes: ['mcp:tools:read', 'mcp:tools:call'],
|
||||
expiresAt,
|
||||
extra: { userId: 'user-42' }
|
||||
});
|
||||
|
||||
// Sanity: the bearer was on the wire for every POST the SDK client sent
|
||||
// (initialize, notifications/initialized, tools/call).
|
||||
expect(postAuthHeaders).toEqual([`Bearer ${VALID_TOKEN}`, `Bearer ${VALID_TOKEN}`, `Bearer ${VALID_TOKEN}`]);
|
||||
});
|
||||
780
test/e2e/scenarios/hosting-express.test.ts
Normal file
780
test/e2e/scenarios/hosting-express.test.ts
Normal file
|
|
@ -0,0 +1,780 @@
|
|||
/**
|
||||
* Self-contained test bodies for Express-bound hosting surfaces.
|
||||
*
|
||||
* These tests cover the SDK's Express-bound surface (auth middleware/routers, host
|
||||
* validation, createMcpExpressApp) over real HTTP — the layer a server operator
|
||||
* deploys and remote clients depend on; Client/Server are not the subject.
|
||||
*
|
||||
* The SDK's requireBearerAuth, mcpAuthRouter, mcpAuthMetadataRouter, and host-
|
||||
* header validation middleware are Express RequestHandlers; they cannot be
|
||||
* exercised with in-process Web-standard Request/Response. These tests build
|
||||
* real Express apps, listen on ephemeral ports (127.0.0.1), drive them with
|
||||
* fetch(), and assert exact HTTP status + header + body shapes.
|
||||
*
|
||||
* Function names mirror the requirement id in camelCase. NO casts, exact
|
||||
* assertions, closure recorders outside factories (for stateless compat),
|
||||
* minimal comments, every server closed in finally.
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import http from 'node:http';
|
||||
|
||||
import express, { type RequestHandler } from 'express';
|
||||
import { expect } from 'vitest';
|
||||
|
||||
import { requireBearerAuth } from '../../../src/server/auth/middleware/bearerAuth.js';
|
||||
import { mcpAuthRouter, mcpAuthMetadataRouter, createOAuthMetadata } from '../../../src/server/auth/router.js';
|
||||
import { ProxyOAuthServerProvider } from '../../../src/server/auth/providers/proxyProvider.js';
|
||||
import type { OAuthServerProvider } from '../../../src/server/auth/provider.js';
|
||||
import { InvalidGrantError, InvalidTokenError } from '../../../src/server/auth/errors.js';
|
||||
import { createMcpExpressApp } from '../../../src/server/express.js';
|
||||
import type { OAuthClientInformationFull } from '../../../src/shared/auth.js';
|
||||
|
||||
import type { TestArgs } from '../types.js';
|
||||
import { startExpressMinimal, startExpressWithHostValidation, type ExpressHost } from '../helpers/express.js';
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
|
||||
const RESOURCE_METADATA_URL = 'https://mcp.example.com/.well-known/oauth-protected-resource';
|
||||
const VALID_TOKEN = 'analytics-dashboard-token';
|
||||
const EXPIRED_TOKEN = 'expired-access-token';
|
||||
const MALFORMED_TOKEN = 'not-a-valid-jwt';
|
||||
|
||||
/**
|
||||
* POST `body` to `url` via `node:http`, forcing `Host: <host>`.
|
||||
* Unlike undici fetch(), node:http sends caller-supplied Host header verbatim.
|
||||
*/
|
||||
function postWithHost(url: URL, host: string, body: string): Promise<{ status: number; body: string; headers: http.IncomingHttpHeaders }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Host: host,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
'Content-Length': Buffer.byteLength(body)
|
||||
}
|
||||
},
|
||||
res => {
|
||||
let data = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', chunk => (data += chunk));
|
||||
res.on('end', () => resolve({ status: res.statusCode ?? 0, body: data, headers: res.headers }));
|
||||
res.on('error', reject);
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
verifies('hosting:auth:missing-401', async (_args: TestArgs) => {
|
||||
const verifier = { verifyAccessToken: async (_token: string) => ({ token: '', clientId: 'test', scopes: [], expiresAt: 1e12 }) };
|
||||
|
||||
await using host = await startExpressMinimal(requireBearerAuth({ verifier, resourceMetadataUrl: RESOURCE_METADATA_URL }));
|
||||
|
||||
const res = await fetch(new URL('/mcp', host.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
|
||||
const wwwAuth = res.headers.get('www-authenticate');
|
||||
expect(wwwAuth).toBeTruthy();
|
||||
expect(wwwAuth).toMatch(/^Bearer\b/i);
|
||||
expect(wwwAuth).toContain('error="invalid_token"');
|
||||
expect(wwwAuth).toContain(`resource_metadata="${RESOURCE_METADATA_URL}"`);
|
||||
|
||||
const body = (await res.json()) as { error?: string };
|
||||
expect(body.error).toBe('invalid_token');
|
||||
});
|
||||
|
||||
verifies('hosting:auth:invalid-401', async (_args: TestArgs) => {
|
||||
const verifier = {
|
||||
verifyAccessToken: async (token: string) => {
|
||||
if (token === MALFORMED_TOKEN) throw new InvalidTokenError('Token verification failed');
|
||||
return { token, clientId: 'test', scopes: [], expiresAt: 1e12 };
|
||||
}
|
||||
};
|
||||
|
||||
await using host = await startExpressMinimal(requireBearerAuth({ verifier }));
|
||||
|
||||
const res = await fetch(new URL('/mcp', host.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${MALFORMED_TOKEN}`
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
|
||||
const wwwAuth = res.headers.get('www-authenticate');
|
||||
expect(wwwAuth).toBeTruthy();
|
||||
expect(wwwAuth).toMatch(/^Bearer\b/i);
|
||||
expect(wwwAuth).toContain('error="invalid_token"');
|
||||
});
|
||||
|
||||
verifies('hosting:auth:expired-401', async (_args: TestArgs) => {
|
||||
const PAST_EXPIRY = 1;
|
||||
const verifier = {
|
||||
verifyAccessToken: async (token: string) => ({
|
||||
token,
|
||||
clientId: 'test-client',
|
||||
scopes: [],
|
||||
expiresAt: token === EXPIRED_TOKEN ? PAST_EXPIRY : Date.now() / 1000 + 3600
|
||||
})
|
||||
};
|
||||
|
||||
await using host = await startExpressMinimal(requireBearerAuth({ verifier }));
|
||||
|
||||
const res = await fetch(new URL('/mcp', host.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${EXPIRED_TOKEN}`
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
|
||||
const wwwAuth = res.headers.get('www-authenticate');
|
||||
expect(wwwAuth).toBeTruthy();
|
||||
expect(wwwAuth).toContain('error="invalid_token"');
|
||||
});
|
||||
|
||||
verifies('hosting:auth:scope-403', async (_args: TestArgs) => {
|
||||
const verifier = {
|
||||
verifyAccessToken: async (token: string) => ({
|
||||
token,
|
||||
clientId: 'test-client',
|
||||
scopes: token === VALID_TOKEN ? ['mcp:tools:read'] : ['mcp:tools:call'],
|
||||
expiresAt: Date.now() / 1000 + 3600
|
||||
})
|
||||
};
|
||||
|
||||
await using host = await startExpressMinimal(
|
||||
requireBearerAuth({
|
||||
verifier,
|
||||
requiredScopes: ['mcp:tools:read', 'mcp:tools:call'],
|
||||
resourceMetadataUrl: RESOURCE_METADATA_URL
|
||||
})
|
||||
);
|
||||
|
||||
const res = await fetch(new URL('/mcp', host.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${VALID_TOKEN}`
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
const wwwAuth = res.headers.get('www-authenticate');
|
||||
expect(wwwAuth).toBeTruthy();
|
||||
expect(wwwAuth).toMatch(/^Bearer\b/i);
|
||||
expect(wwwAuth).toContain('error="insufficient_scope"');
|
||||
expect(wwwAuth).toContain('scope="mcp:tools:read mcp:tools:call"');
|
||||
expect(wwwAuth).toContain(`resource_metadata="${RESOURCE_METADATA_URL}"`);
|
||||
|
||||
const body = (await res.json()) as { error?: string };
|
||||
expect(body.error).toBe('insufficient_scope');
|
||||
});
|
||||
|
||||
verifies('hosting:auth:aud-validation', async (_args: TestArgs) => {
|
||||
const SERVER_RESOURCE_ID = 'https://mcp.example.com/api';
|
||||
const WRONG_AUDIENCE = 'https://other.example.com/api';
|
||||
|
||||
const verifier = {
|
||||
verifyAccessToken: async (token: string) => {
|
||||
const aud = token === 'wrong-aud-token' ? WRONG_AUDIENCE : SERVER_RESOURCE_ID;
|
||||
return {
|
||||
token,
|
||||
clientId: 'test-client',
|
||||
scopes: [],
|
||||
expiresAt: Date.now() / 1000 + 3600,
|
||||
resource: new URL(aud)
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(requireBearerAuth({ verifier, resourceMetadataUrl: SERVER_RESOURCE_ID }));
|
||||
app.post('/mcp', (_req, res) => {
|
||||
res.json({ jsonrpc: '2.0', id: 1, result: { ok: true } });
|
||||
});
|
||||
|
||||
await using host = await startExpressMinimal(app);
|
||||
|
||||
const wrongAud = await fetch(new URL('/mcp', host.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: 'Bearer wrong-aud-token'
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
|
||||
});
|
||||
|
||||
expect(wrongAud.status).toBeGreaterThanOrEqual(401);
|
||||
expect(wrongAud.status).toBeLessThanOrEqual(403);
|
||||
|
||||
const wwwAuth = wrongAud.headers.get('www-authenticate');
|
||||
expect(wwwAuth).toBeTruthy();
|
||||
expect(wwwAuth).toMatch(/^Bearer\b/i);
|
||||
expect(wwwAuth).toContain('error=');
|
||||
|
||||
const body = (await wrongAud.json()) as { error?: string };
|
||||
expect(body.error).toBeTruthy();
|
||||
});
|
||||
|
||||
verifies('hosting:auth:metadata-endpoints', async (_args: TestArgs) => {
|
||||
const issuer = new URL('https://auth.example.com');
|
||||
const provider = {
|
||||
authorize: async () => {
|
||||
throw new Error('not needed');
|
||||
},
|
||||
challengeForAuthorizationCode: async () => 'test-challenge',
|
||||
exchangeAuthorizationCode: async () => ({ access_token: 'test-token', token_type: 'Bearer' }),
|
||||
exchangeRefreshToken: async () => ({ access_token: 'test-token', token_type: 'Bearer' }),
|
||||
verifyAccessToken: async () => ({ token: '', clientId: '', scopes: [], expiresAt: 1e12 }),
|
||||
clientsStore: { getClient: async () => undefined }
|
||||
};
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(
|
||||
mcpAuthRouter({
|
||||
provider,
|
||||
issuerUrl: issuer
|
||||
})
|
||||
);
|
||||
|
||||
await using host = await startExpressMinimal(app);
|
||||
|
||||
const asMetadata = await fetch(new URL('/.well-known/oauth-authorization-server', host.baseUrl));
|
||||
expect(asMetadata.status).toBe(200);
|
||||
const asBody = (await asMetadata.json()) as { issuer?: string; authorization_endpoint?: string };
|
||||
expect(asBody.issuer).toBe(issuer.href);
|
||||
expect(asBody.authorization_endpoint).toBeTruthy();
|
||||
|
||||
const prmMetadata = await fetch(new URL('/.well-known/oauth-protected-resource', host.baseUrl));
|
||||
expect(prmMetadata.status).toBe(200);
|
||||
const prmBody = (await prmMetadata.json()) as { resource?: string; authorization_servers?: string[] };
|
||||
expect(prmBody.authorization_servers).toContain(issuer.href);
|
||||
});
|
||||
|
||||
verifies('hosting:auth:prm:authorization-servers-field', async (_args: TestArgs) => {
|
||||
const issuer = new URL('https://auth.example.com');
|
||||
const oauthMetadata = createOAuthMetadata({
|
||||
provider: {
|
||||
authorize: async () => {
|
||||
throw new Error('stub');
|
||||
},
|
||||
challengeForAuthorizationCode: async () => 'test',
|
||||
exchangeAuthorizationCode: async () => ({ access_token: 'test', token_type: 'Bearer' }),
|
||||
exchangeRefreshToken: async () => ({ access_token: 'test', token_type: 'Bearer' }),
|
||||
verifyAccessToken: async () => ({ token: '', clientId: '', scopes: [], expiresAt: 1e12 }),
|
||||
clientsStore: { getClient: async () => undefined }
|
||||
},
|
||||
issuerUrl: issuer
|
||||
});
|
||||
|
||||
const app = express();
|
||||
app.use(
|
||||
mcpAuthMetadataRouter({
|
||||
oauthMetadata,
|
||||
resourceServerUrl: new URL('https://mcp.example.com')
|
||||
})
|
||||
);
|
||||
|
||||
await using host = await startExpressMinimal(app);
|
||||
|
||||
const res = await fetch(new URL('/.well-known/oauth-protected-resource', host.baseUrl));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { authorization_servers?: string[] };
|
||||
expect(body.authorization_servers).toBeInstanceOf(Array);
|
||||
expect(body.authorization_servers?.length).toBeGreaterThan(0);
|
||||
expect(body.authorization_servers).toContain(issuer.href);
|
||||
});
|
||||
|
||||
verifies('hosting:auth:as-router', async (_args: TestArgs) => {
|
||||
const issuer = new URL('https://auth.example.com');
|
||||
const provider: OAuthServerProvider = {
|
||||
authorize: async (_client, _params, res) => {
|
||||
res.redirect(302, 'https://example.com/callback?code=test-code&state=test');
|
||||
},
|
||||
challengeForAuthorizationCode: async () => 'test-challenge',
|
||||
exchangeAuthorizationCode: async () => ({ access_token: 'test-token', token_type: 'Bearer' }),
|
||||
exchangeRefreshToken: async () => ({ access_token: 'test-token', token_type: 'Bearer' }),
|
||||
verifyAccessToken: async (token: string) => ({ token, clientId: 'test', scopes: [], expiresAt: 1e12 }),
|
||||
clientsStore: {
|
||||
getClient: async (id: string) =>
|
||||
id === 'test-client'
|
||||
? ({
|
||||
client_id: 'test-client',
|
||||
client_secret: 'secret',
|
||||
redirect_uris: ['https://example.com/callback']
|
||||
} as OAuthClientInformationFull)
|
||||
: undefined,
|
||||
registerClient: async () =>
|
||||
({
|
||||
client_id: 'new-client',
|
||||
client_secret: 'new-secret',
|
||||
redirect_uris: ['https://example.com/callback']
|
||||
}) as OAuthClientInformationFull
|
||||
},
|
||||
revokeToken: async () => {}
|
||||
};
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(mcpAuthRouter({ provider, issuerUrl: issuer }));
|
||||
|
||||
await using host = await startExpressMinimal(app);
|
||||
|
||||
const authRes = await fetch(new URL('/authorize', host.baseUrl));
|
||||
expect(authRes.status).not.toBe(404);
|
||||
|
||||
const tokenRes = await fetch(new URL('/token', host.baseUrl), { method: 'POST' });
|
||||
expect(tokenRes.status).not.toBe(404);
|
||||
|
||||
const registerRes = await fetch(new URL('/register', host.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ redirect_uris: ['https://example.com/callback'] })
|
||||
});
|
||||
expect(registerRes.status).not.toBe(404);
|
||||
const registerBody = (await registerRes.json()) as { client_id?: string };
|
||||
expect(registerBody.client_id).toBeTruthy();
|
||||
|
||||
const revokeRes = await fetch(new URL('/revoke', host.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'token=old-token&client_id=test-client&client_secret=secret'
|
||||
});
|
||||
expect([200, 400]).toContain(revokeRes.status);
|
||||
});
|
||||
|
||||
verifies('hosting:auth:proxy-provider', async (_args: TestArgs) => {
|
||||
const upstreamRequests: Array<{ url: string; method: string; body?: string }> = [];
|
||||
|
||||
const upstreamAS = express();
|
||||
upstreamAS.use(express.json());
|
||||
upstreamAS.use(express.urlencoded({ extended: true }));
|
||||
upstreamAS.get('/authorize', (req, res) => {
|
||||
upstreamRequests.push({ url: req.url, method: req.method });
|
||||
res.redirect(302, `https://example.com/callback?code=upstream-code&state=${req.query.state ?? ''}`);
|
||||
});
|
||||
upstreamAS.post('/token', (req, res) => {
|
||||
upstreamRequests.push({ url: req.url, method: req.method, body: JSON.stringify(req.body) });
|
||||
res.json({ access_token: 'upstream-token', token_type: 'Bearer' });
|
||||
});
|
||||
upstreamAS.post('/revoke', (req, res) => {
|
||||
upstreamRequests.push({ url: req.url, method: req.method, body: JSON.stringify(req.body) });
|
||||
res.sendStatus(200);
|
||||
});
|
||||
|
||||
await using upstream = await startExpressMinimal(upstreamAS);
|
||||
|
||||
const provider = new ProxyOAuthServerProvider({
|
||||
endpoints: {
|
||||
authorizationUrl: new URL('/authorize', upstream.baseUrl).href,
|
||||
tokenUrl: new URL('/token', upstream.baseUrl).href,
|
||||
revocationUrl: new URL('/revoke', upstream.baseUrl).href
|
||||
},
|
||||
verifyAccessToken: async token => ({ token, clientId: 'proxy-client', scopes: [], expiresAt: 1e12 }),
|
||||
getClient: async (id: string) =>
|
||||
id === 'proxy-client'
|
||||
? ({
|
||||
client_id: 'proxy-client',
|
||||
client_secret: 'proxy-secret',
|
||||
redirect_uris: ['https://example.com/cb']
|
||||
} as OAuthClientInformationFull)
|
||||
: undefined
|
||||
});
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(mcpAuthRouter({ provider, issuerUrl: new URL('https://proxy.example.com') }));
|
||||
|
||||
await using proxy = await startExpressMinimal(app);
|
||||
|
||||
const authRes = await fetch(new URL('/authorize', proxy.baseUrl));
|
||||
expect(authRes.status).not.toBe(404);
|
||||
|
||||
const tokenRes = await fetch(new URL('/token', proxy.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'grant_type=authorization_code&code=test&redirect_uri=https://example.com/cb&client_id=proxy-client&code_verifier=test'
|
||||
});
|
||||
expect(tokenRes.status).not.toBe(404);
|
||||
|
||||
const revokeRes = await fetch(new URL('/revoke', proxy.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'token=old-token&client_id=proxy-client&client_secret=proxy-secret'
|
||||
});
|
||||
expect([200, 400]).toContain(revokeRes.status);
|
||||
});
|
||||
|
||||
verifies('hosting:http:host-validation-middleware', async (_args: TestArgs) => {
|
||||
const handler: RequestHandler = (_req, res) => {
|
||||
res.json({ ok: true });
|
||||
};
|
||||
|
||||
await using host = await startExpressWithHostValidation(['localhost', '127.0.0.1'], handler);
|
||||
|
||||
const good = await fetch(new URL('/test', host.baseUrl));
|
||||
expect(good.status).toBe(200);
|
||||
|
||||
const spoofed = await postWithHost(new URL('/test', host.baseUrl), 'evil.example.com', JSON.stringify({ test: 'data' }));
|
||||
expect(spoofed.status).toBe(403);
|
||||
const body = JSON.parse(spoofed.body) as { error?: { message?: string } };
|
||||
expect(body.error?.message).toMatch(/Invalid Host/i);
|
||||
});
|
||||
|
||||
verifies('hosting:express-app-helper', async (_args: TestArgs) => {
|
||||
const app = createMcpExpressApp();
|
||||
app.post('/mcp', (_req, res) => {
|
||||
res.json({ jsonrpc: '2.0', id: 1, result: { ok: true } });
|
||||
});
|
||||
|
||||
await using host = await startExpressMinimal(app);
|
||||
|
||||
expect(host.baseUrl.hostname).toBe('127.0.0.1');
|
||||
|
||||
const good = await fetch(new URL('/mcp', host.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
|
||||
});
|
||||
expect(good.status).toBe(200);
|
||||
|
||||
const spoofed = await postWithHost(
|
||||
new URL('/mcp', host.baseUrl),
|
||||
'evil.example.com',
|
||||
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
|
||||
);
|
||||
expect(spoofed.status).toBe(403);
|
||||
const body = JSON.parse(spoofed.body) as { error?: { message?: string } };
|
||||
expect(body.error?.message).toMatch(/Invalid Host/i);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bundled authorization-server endpoints (/register, /authorize, /token):
|
||||
// in-memory provider + dynamic registration drive the SDK's own handlers
|
||||
// over real HTTP, mirroring what a real OAuth client would send.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AS_REDIRECT_URI = 'https://client.example.com/callback';
|
||||
|
||||
interface IssuedAuthorizationCode {
|
||||
clientId: string;
|
||||
codeChallenge: string;
|
||||
redirectUri: string;
|
||||
}
|
||||
|
||||
function createAuthorizationServerProvider(): OAuthServerProvider {
|
||||
const clients = new Map<string, OAuthClientInformationFull>();
|
||||
const codes = new Map<string, IssuedAuthorizationCode>();
|
||||
let clientCount = 0;
|
||||
let codeCount = 0;
|
||||
|
||||
return {
|
||||
clientsStore: {
|
||||
getClient: async (clientId: string) => clients.get(clientId),
|
||||
registerClient: async client => {
|
||||
clientCount += 1;
|
||||
const registered: OAuthClientInformationFull = { ...client, client_id: `registered-client-${clientCount}` };
|
||||
clients.set(registered.client_id, registered);
|
||||
return registered;
|
||||
}
|
||||
},
|
||||
authorize: async (client, params, res) => {
|
||||
codeCount += 1;
|
||||
const code = `authorization-code-${codeCount}`;
|
||||
codes.set(code, { clientId: client.client_id, codeChallenge: params.codeChallenge, redirectUri: params.redirectUri });
|
||||
const target = new URL(params.redirectUri);
|
||||
target.searchParams.set('code', code);
|
||||
if (params.state !== undefined) {
|
||||
target.searchParams.set('state', params.state);
|
||||
}
|
||||
res.redirect(302, target.href);
|
||||
},
|
||||
challengeForAuthorizationCode: async (_client, authorizationCode) => {
|
||||
const issued = codes.get(authorizationCode);
|
||||
if (!issued) {
|
||||
throw new InvalidGrantError('authorization code does not exist');
|
||||
}
|
||||
return issued.codeChallenge;
|
||||
},
|
||||
exchangeAuthorizationCode: async (client, authorizationCode, _codeVerifier, redirectUri) => {
|
||||
const issued = codes.get(authorizationCode);
|
||||
if (!issued) {
|
||||
throw new InvalidGrantError('authorization code does not exist');
|
||||
}
|
||||
if (issued.clientId !== client.client_id) {
|
||||
throw new InvalidGrantError('authorization code was issued to a different client');
|
||||
}
|
||||
if (redirectUri !== undefined && redirectUri !== issued.redirectUri) {
|
||||
throw new InvalidGrantError('redirect_uri does not match the one used at authorization');
|
||||
}
|
||||
// Single-use: the first successful exchange consumes the code.
|
||||
codes.delete(authorizationCode);
|
||||
return { access_token: `access-token-for-${authorizationCode}`, token_type: 'Bearer' };
|
||||
},
|
||||
exchangeRefreshToken: async () => {
|
||||
throw new InvalidGrantError('refresh tokens are not issued by this provider');
|
||||
},
|
||||
verifyAccessToken: async (token: string) => ({
|
||||
token,
|
||||
clientId: 'registered-client-1',
|
||||
scopes: [],
|
||||
expiresAt: Date.now() / 1000 + 3600
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
async function startAuthorizationServer(): Promise<ExpressHost> {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(mcpAuthRouter({ provider: createAuthorizationServerProvider(), issuerUrl: new URL('https://auth.example.com') }));
|
||||
return startExpressMinimal(app);
|
||||
}
|
||||
|
||||
function createPkcePair(): { verifier: string; challenge: string } {
|
||||
const verifier = crypto.randomBytes(32).toString('base64url');
|
||||
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
function postRegistration(baseUrl: URL, redirectUris: string[]): Promise<Response> {
|
||||
return fetch(new URL('/register', baseUrl), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ client_name: 'analytics-dashboard', redirect_uris: redirectUris })
|
||||
});
|
||||
}
|
||||
|
||||
async function registerConfidentialClient(baseUrl: URL): Promise<{ clientId: string; clientSecret: string }> {
|
||||
const res = await postRegistration(baseUrl, [AS_REDIRECT_URI]);
|
||||
expect(res.status).toBe(201);
|
||||
const body = (await res.json()) as { client_id?: string; client_secret?: string };
|
||||
if (typeof body.client_id !== 'string' || typeof body.client_secret !== 'string') {
|
||||
throw new Error('registration response did not include client_id and client_secret');
|
||||
}
|
||||
return { clientId: body.client_id, clientSecret: body.client_secret };
|
||||
}
|
||||
|
||||
async function mintAuthorizationCode(baseUrl: URL, clientId: string, codeChallenge: string): Promise<string> {
|
||||
const authorizeUrl = new URL('/authorize', baseUrl);
|
||||
authorizeUrl.searchParams.set('response_type', 'code');
|
||||
authorizeUrl.searchParams.set('client_id', clientId);
|
||||
authorizeUrl.searchParams.set('redirect_uri', AS_REDIRECT_URI);
|
||||
authorizeUrl.searchParams.set('code_challenge', codeChallenge);
|
||||
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
|
||||
authorizeUrl.searchParams.set('state', 'request-state');
|
||||
|
||||
const res = await fetch(authorizeUrl, { redirect: 'manual' });
|
||||
expect(res.status).toBe(302);
|
||||
const location = res.headers.get('location');
|
||||
if (location === null) {
|
||||
throw new Error('authorize response did not include a Location header');
|
||||
}
|
||||
const redirect = new URL(location);
|
||||
expect(redirect.origin + redirect.pathname).toBe(AS_REDIRECT_URI);
|
||||
expect(redirect.searchParams.get('state')).toBe('request-state');
|
||||
const code = redirect.searchParams.get('code');
|
||||
if (code === null) {
|
||||
throw new Error('authorize redirect did not include a code parameter');
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
function postTokenExchange(baseUrl: URL, form: Record<string, string>): Promise<Response> {
|
||||
return fetch(new URL('/token', baseUrl), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(form).toString()
|
||||
});
|
||||
}
|
||||
|
||||
verifies('hosting:auth:as:redirect-uri-scheme', async (_args: TestArgs) => {
|
||||
await using host = await startAuthorizationServer();
|
||||
|
||||
const httpsRegistration = await postRegistration(host.baseUrl, ['https://client.example.com/callback']);
|
||||
expect(httpsRegistration.status).toBe(201);
|
||||
|
||||
const loopbackRegistration = await postRegistration(host.baseUrl, ['http://127.0.0.1:49152/callback']);
|
||||
expect(loopbackRegistration.status).toBe(201);
|
||||
|
||||
const nonLoopbackHttp = await postRegistration(host.baseUrl, ['http://attacker.example.com/callback']);
|
||||
expect(nonLoopbackHttp.status).toBe(400);
|
||||
const body = (await nonLoopbackHttp.json()) as { error?: string };
|
||||
expect(body.error).toBe('invalid_client_metadata');
|
||||
});
|
||||
|
||||
verifies('hosting:auth:as:redirect-uri-binding', async (_args: TestArgs) => {
|
||||
await using host = await startAuthorizationServer();
|
||||
const { clientId, clientSecret } = await registerConfidentialClient(host.baseUrl);
|
||||
const { verifier, challenge } = createPkcePair();
|
||||
|
||||
// Authorize leg: an unregistered redirect_uri gets a direct 400, never a redirect to the attacker URI.
|
||||
const authorizeUrl = new URL('/authorize', host.baseUrl);
|
||||
authorizeUrl.searchParams.set('response_type', 'code');
|
||||
authorizeUrl.searchParams.set('client_id', clientId);
|
||||
authorizeUrl.searchParams.set('redirect_uri', 'https://attacker.example.com/callback');
|
||||
authorizeUrl.searchParams.set('code_challenge', challenge);
|
||||
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
|
||||
|
||||
const authorizeRes = await fetch(authorizeUrl, { redirect: 'manual' });
|
||||
expect(authorizeRes.status).toBe(400);
|
||||
expect(authorizeRes.headers.get('location')).toBeNull();
|
||||
expect(await authorizeRes.json()).toEqual({ error: 'invalid_request', error_description: 'Unregistered redirect_uri' });
|
||||
|
||||
// Token leg: the handler forwards the request's redirect_uri to the provider, which rejects a value differing from authorize.
|
||||
const code = await mintAuthorizationCode(host.baseUrl, clientId, challenge);
|
||||
const tokenRes = await postTokenExchange(host.baseUrl, {
|
||||
grant_type: 'authorization_code',
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
code,
|
||||
code_verifier: verifier,
|
||||
redirect_uri: 'https://client.example.com/callback/other'
|
||||
});
|
||||
expect(tokenRes.status).toBe(400);
|
||||
expect(await tokenRes.json()).toEqual({
|
||||
error: 'invalid_grant',
|
||||
error_description: 'redirect_uri does not match the one used at authorization'
|
||||
});
|
||||
});
|
||||
|
||||
verifies('hosting:auth:query-token-ignored', async (_args: TestArgs) => {
|
||||
const verifier = {
|
||||
verifyAccessToken: async (token: string) => {
|
||||
if (token !== VALID_TOKEN) {
|
||||
throw new InvalidTokenError('Token verification failed');
|
||||
}
|
||||
return { token, clientId: 'analytics-client', scopes: [], expiresAt: Date.now() / 1000 + 3600 };
|
||||
}
|
||||
};
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(requireBearerAuth({ verifier, resourceMetadataUrl: RESOURCE_METADATA_URL }));
|
||||
app.post('/mcp', (_req, res) => {
|
||||
res.json({ jsonrpc: '2.0', id: 1, result: { ok: true } });
|
||||
});
|
||||
|
||||
await using host = await startExpressMinimal(app);
|
||||
|
||||
const queryUrl = new URL('/mcp', host.baseUrl);
|
||||
queryUrl.searchParams.set('access_token', VALID_TOKEN);
|
||||
const queryRes = await fetch(queryUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
|
||||
});
|
||||
|
||||
expect(queryRes.status).toBe(401);
|
||||
const wwwAuth = queryRes.headers.get('www-authenticate');
|
||||
expect(wwwAuth).toBeTruthy();
|
||||
expect(wwwAuth).toMatch(/^Bearer\b/i);
|
||||
expect(wwwAuth).toContain('error="invalid_token"');
|
||||
const queryBody = (await queryRes.json()) as { error?: string };
|
||||
expect(queryBody.error).toBe('invalid_token');
|
||||
|
||||
// Control: the same token in the Authorization header authenticates, proving only the query-string placement was ignored.
|
||||
const headerRes = await fetch(new URL('/mcp', host.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Bearer ${VALID_TOKEN}` },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
|
||||
});
|
||||
expect(headerRes.status).toBe(200);
|
||||
});
|
||||
|
||||
verifies('hosting:auth:as:authorize-requires-pkce', async (_args: TestArgs) => {
|
||||
await using host = await startAuthorizationServer();
|
||||
const { clientId } = await registerConfidentialClient(host.baseUrl);
|
||||
|
||||
const authorizeUrl = new URL('/authorize', host.baseUrl);
|
||||
authorizeUrl.searchParams.set('response_type', 'code');
|
||||
authorizeUrl.searchParams.set('client_id', clientId);
|
||||
authorizeUrl.searchParams.set('redirect_uri', AS_REDIRECT_URI);
|
||||
authorizeUrl.searchParams.set('state', 'pkce-required-state');
|
||||
|
||||
const res = await fetch(authorizeUrl, { redirect: 'manual' });
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
const location = res.headers.get('location');
|
||||
if (location === null) {
|
||||
throw new Error('authorize response did not include a Location header');
|
||||
}
|
||||
const redirect = new URL(location);
|
||||
expect(redirect.origin + redirect.pathname).toBe(AS_REDIRECT_URI);
|
||||
expect(redirect.searchParams.get('error')).toBe('invalid_request');
|
||||
expect(redirect.searchParams.get('error_description')).toContain('code_challenge');
|
||||
expect(redirect.searchParams.get('code')).toBeNull();
|
||||
});
|
||||
|
||||
verifies('hosting:auth:as:verifier-mismatch', async (_args: TestArgs) => {
|
||||
await using host = await startAuthorizationServer();
|
||||
const { clientId, clientSecret } = await registerConfidentialClient(host.baseUrl);
|
||||
|
||||
const { challenge } = createPkcePair();
|
||||
const code = await mintAuthorizationCode(host.baseUrl, clientId, challenge);
|
||||
|
||||
// A well-formed verifier from a different PKCE pair cannot hash to the stored challenge.
|
||||
const { verifier: wrongVerifier } = createPkcePair();
|
||||
const res = await postTokenExchange(host.baseUrl, {
|
||||
grant_type: 'authorization_code',
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
code,
|
||||
code_verifier: wrongVerifier,
|
||||
redirect_uri: AS_REDIRECT_URI
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(await res.json()).toEqual({ error: 'invalid_grant', error_description: 'code_verifier does not match the challenge' });
|
||||
});
|
||||
|
||||
verifies('hosting:auth:as:code-single-use', async (_args: TestArgs) => {
|
||||
await using host = await startAuthorizationServer();
|
||||
const { clientId, clientSecret } = await registerConfidentialClient(host.baseUrl);
|
||||
|
||||
const { verifier, challenge } = createPkcePair();
|
||||
const code = await mintAuthorizationCode(host.baseUrl, clientId, challenge);
|
||||
const form = {
|
||||
grant_type: 'authorization_code',
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
code,
|
||||
code_verifier: verifier,
|
||||
redirect_uri: AS_REDIRECT_URI
|
||||
};
|
||||
|
||||
const first = await postTokenExchange(host.baseUrl, form);
|
||||
expect(first.status).toBe(200);
|
||||
const firstBody = (await first.json()) as { access_token?: string; token_type?: string };
|
||||
expect(firstBody.token_type).toBe('Bearer');
|
||||
expect(firstBody.access_token).toBe(`access-token-for-${code}`);
|
||||
|
||||
const second = await postTokenExchange(host.baseUrl, form);
|
||||
expect(second.status).toBe(400);
|
||||
expect(await second.json()).toEqual({ error: 'invalid_grant', error_description: 'authorization code does not exist' });
|
||||
});
|
||||
1031
test/e2e/scenarios/hosting-http.test.ts
Normal file
1031
test/e2e/scenarios/hosting-http.test.ts
Normal file
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue