Port the end-to-end test suite to v2 (#2179)
This commit is contained in:
parent
5fc42e9be1
commit
1998a186ee
46 changed files with 20108 additions and 1 deletions
5
.changeset/add-e2e-test-suite.md
Normal file
5
.changeset/add-e2e-test-suite.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@modelcontextprotocol/test-e2e': patch
|
||||
---
|
||||
|
||||
Add the end-to-end behavior test suite as a workspace package: a requirements manifest covering protocol-visible SDK behavior across the in-memory, stdio, and Streamable HTTP transports, ported from the v1.x branch and extended with coverage for v2 features.
|
||||
28
.github/workflows/main.yml
vendored
28
.github/workflows/main.yml
vendored
|
|
@ -54,7 +54,33 @@ jobs:
|
|||
|
||||
- run: pnpm install
|
||||
|
||||
- run: pnpm test:all
|
||||
# The e2e suite has its own job below; everything else runs here.
|
||||
- run: pnpm -r --filter '!@modelcontextprotocol/test-e2e' test
|
||||
|
||||
test-e2e:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node-version: [20, 22, 24]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
id: pnpm-install
|
||||
with:
|
||||
run_install: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: pnpm
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- run: pnpm install
|
||||
|
||||
- run: pnpm --filter @modelcontextprotocol/test-e2e test
|
||||
|
||||
test-runtimes:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
81
pnpm-lock.yaml
generated
81
pnpm-lock.yaml
generated
|
|
@ -1032,6 +1032,87 @@ importers:
|
|||
specifier: catalog:runtimeShared
|
||||
version: 4.3.6
|
||||
|
||||
test/e2e:
|
||||
devDependencies:
|
||||
'@hono/node-server':
|
||||
specifier: catalog:runtimeServerOnly
|
||||
version: 1.19.11(hono@4.12.9)
|
||||
'@modelcontextprotocol/client':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/client
|
||||
'@modelcontextprotocol/core':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/core
|
||||
'@modelcontextprotocol/eslint-config':
|
||||
specifier: workspace:^
|
||||
version: link:../../common/eslint-config
|
||||
'@modelcontextprotocol/express':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/middleware/express
|
||||
'@modelcontextprotocol/fastify':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/middleware/fastify
|
||||
'@modelcontextprotocol/hono':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/middleware/hono
|
||||
'@modelcontextprotocol/node':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/middleware/node
|
||||
'@modelcontextprotocol/server':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/server
|
||||
'@modelcontextprotocol/test-helpers':
|
||||
specifier: workspace:^
|
||||
version: link:../helpers
|
||||
'@modelcontextprotocol/tsconfig':
|
||||
specifier: workspace:^
|
||||
version: link:../../common/tsconfig
|
||||
'@modelcontextprotocol/vitest-config':
|
||||
specifier: workspace:^
|
||||
version: link:../../common/vitest-config
|
||||
'@types/cors':
|
||||
specifier: catalog:devTools
|
||||
version: 2.8.19
|
||||
'@types/express':
|
||||
specifier: catalog:devTools
|
||||
version: 5.0.6
|
||||
'@valibot/to-json-schema':
|
||||
specifier: catalog:devTools
|
||||
version: 1.6.0(valibot@1.3.1(typescript@5.9.3))
|
||||
arktype:
|
||||
specifier: catalog:devTools
|
||||
version: 2.2.0
|
||||
cors:
|
||||
specifier: catalog:runtimeServerOnly
|
||||
version: 2.8.6
|
||||
express:
|
||||
specifier: catalog:runtimeServerOnly
|
||||
version: 5.2.1
|
||||
fastify:
|
||||
specifier: catalog:runtimeServerOnly
|
||||
version: 5.8.4
|
||||
hono:
|
||||
specifier: catalog:runtimeServerOnly
|
||||
version: 4.12.9
|
||||
jose:
|
||||
specifier: catalog:runtimeClientOnly
|
||||
version: 6.2.2
|
||||
tsx:
|
||||
specifier: catalog:devTools
|
||||
version: 4.21.0
|
||||
typescript:
|
||||
specifier: catalog:devTools
|
||||
version: 5.9.3
|
||||
valibot:
|
||||
specifier: catalog:devTools
|
||||
version: 1.3.1(typescript@5.9.3)
|
||||
vitest:
|
||||
specifier: catalog:devTools
|
||||
version: 4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3))
|
||||
zod:
|
||||
specifier: catalog:runtimeShared
|
||||
version: 4.3.6
|
||||
|
||||
test/helpers:
|
||||
devDependencies:
|
||||
'@modelcontextprotocol/core':
|
||||
|
|
|
|||
74
test/e2e/CLAUDE.md
Normal file
74
test/e2e/CLAUDE.md
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
# E2E test suite
|
||||
|
||||
Conformance-style tests for the SDK's public surface. `requirements.ts` is a pure-data manifest: every behavior the SDK must satisfy, with its spec/source link. Test files in `scenarios/` cite the requirement id(s) they prove via `verifies()` (`helpers/verifies.ts`), which
|
||||
registers one cell per applicable (transport, spec version). `coverage.test.ts` statically checks that every non-deferred requirement is cited and that the manifest is internally consistent.
|
||||
|
||||
## Writing a test
|
||||
|
||||
Add a `verifies()` call with an anonymous async body to `scenarios/<area>.test.ts`:
|
||||
|
||||
```ts
|
||||
verifies('tools:call:content:text', async ({ 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. Pass an array of ids when one body genuinely proves several requirements; pass `{ title: '...' }` as the third argument only when a requirement needs more than one body
|
||||
(the title is how knownFailures target a specific body).
|
||||
|
||||
The corresponding manifest entry is pure data:
|
||||
|
||||
```ts
|
||||
'tools:call:content:text': {
|
||||
source: 'https://modelcontextprotocol.io/...',
|
||||
behavior: 'tools/call returns content[] with type:text...'
|
||||
},
|
||||
```
|
||||
|
||||
## knownFailures, deferred, and transport restrictions
|
||||
|
||||
When a test asserts required behavior the SDK does not satisfy, keep the test exact and record it in the manifest:
|
||||
|
||||
```ts
|
||||
knownFailures: [{ note: 'changed in v2: ...' /* optional: test: '<verifies title>', transport, specVersion */ }];
|
||||
```
|
||||
|
||||
`verifies()` runs matching cells as `test.fails()` — they pass while the SDK misbehaves and fail once it is fixed (then remove the entry). When the behavior cannot be expressed against the public surface at all (e.g. an API removed in v2), mark the requirement
|
||||
`deferred: '<reason>'` instead — deferred ids must not be cited by any `verifies()` call.
|
||||
|
||||
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'
|
||||
```
|
||||
|
||||
`addedInSpecVersion` / `removedInSpecVersion` bound the spec versions a requirement applies to; a behavior changed by a spec release gets a sibling entry linked via `supersedes`.
|
||||
|
||||
## Running
|
||||
|
||||
From the repo root (the suite is the `@modelcontextprotocol/test-e2e` workspace package):
|
||||
|
||||
```bash
|
||||
pnpm --filter @modelcontextprotocol/test-e2e test # all
|
||||
pnpm --filter @modelcontextprotocol/test-e2e exec vitest run scenarios/tools.test.ts # one area
|
||||
pnpm --filter @modelcontextprotocol/test-e2e exec vitest run -t 'tools:' # one requirement-id prefix
|
||||
pnpm --filter @modelcontextprotocol/test-e2e exec vitest run coverage.test.ts # manifest gates
|
||||
pnpm --filter @modelcontextprotocol/test-e2e typecheck
|
||||
pnpm --filter @modelcontextprotocol/test-e2e lint
|
||||
```
|
||||
|
||||
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 path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { REQUIREMENTS } from './requirements.js';
|
||||
|
||||
const E2E_DIR = path.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 = path.join(E2E_DIR, 'scenarios');
|
||||
const files = readdirSync(scenariosDir)
|
||||
.filter(f => f.endsWith('.test.ts'))
|
||||
.toSorted();
|
||||
for (const file of files) {
|
||||
const text = readFileSync(path.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]).filter(id => id !== undefined);
|
||||
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();
|
||||
}
|
||||
}
|
||||
});
|
||||
15
test/e2e/eslint.config.mjs
Normal file
15
test/e2e/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// @ts-check
|
||||
|
||||
import baseConfig from '@modelcontextprotocol/eslint-config';
|
||||
|
||||
export default [
|
||||
...baseConfig,
|
||||
{
|
||||
rules: {
|
||||
// `await using _ = await wire(...)` holds the connection open for the test body; the binding is intentionally unused
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_$' }],
|
||||
// scenario files keep the kebab-case names they share with the v1.x suite
|
||||
'unicorn/filename-case': ['error', { cases: { camelCase: true, kebabCase: true } }]
|
||||
}
|
||||
}
|
||||
];
|
||||
64
test/e2e/fixtures/stdio-server.ts
Normal file
64
test/e2e/fixtures/stdio-server.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/* eslint-disable unicorn/no-process-exit -- standalone spawned executable; exit codes are the behavior under test */
|
||||
|
||||
import { McpServer } from '@modelcontextprotocol/server';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
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).toSorted();
|
||||
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(() => {}, 1000);
|
||||
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');
|
||||
// Valid JSON but not a valid JSON-RPC message: v2 silently skips non-JSON noise, but schema-invalid messages must still surface via onerror.
|
||||
process.stdout.write('{"jsonrpc":"1.0","bogus":true}\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');
|
||||
}
|
||||
64
test/e2e/helpers/express.ts
Normal file
64
test/e2e/helpers/express.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* Express helper for hosting-related e2e tests.
|
||||
*
|
||||
* Builds real Express apps with SDK middleware, listens on ephemeral ports,
|
||||
* closes cleanly. Used by hosting-express.ts scenarios.
|
||||
*/
|
||||
|
||||
import type { Server as HttpServer } from 'node:http';
|
||||
|
||||
import { hostHeaderValidation } from '@modelcontextprotocol/express';
|
||||
import type { Express, RequestHandler } from 'express';
|
||||
import express from 'express';
|
||||
|
||||
export interface ExpressHost extends AsyncDisposable {
|
||||
readonly baseUrl: URL;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
async function listen(app: Express, host = '127.0.0.1'): Promise<{ baseUrl: URL; close: () => Promise<void> }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server: HttpServer = app.listen(0, host, () => {
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === 'string') {
|
||||
reject(new Error(`listen failed: ${addr}`));
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
baseUrl: new URL(`http://${host}:${addr.port}`),
|
||||
close: () =>
|
||||
new Promise((res, rej) => {
|
||||
server.close(err => (err ? rej(err) : res()));
|
||||
})
|
||||
});
|
||||
});
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
export async function startExpressMinimal(handler: RequestHandler): Promise<ExpressHost> {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(handler);
|
||||
|
||||
const { baseUrl, close } = await listen(app);
|
||||
return {
|
||||
baseUrl,
|
||||
close,
|
||||
[Symbol.asyncDispose]: close
|
||||
};
|
||||
}
|
||||
|
||||
export async function startExpressWithHostValidation(allowedHosts: string[], handler: RequestHandler): Promise<ExpressHost> {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(hostHeaderValidation(allowedHosts));
|
||||
app.use(handler);
|
||||
|
||||
const { baseUrl, close } = await listen(app);
|
||||
return {
|
||||
baseUrl,
|
||||
close,
|
||||
[Symbol.asyncDispose]: close
|
||||
};
|
||||
}
|
||||
229
test/e2e/helpers/index.ts
Normal file
229
test/e2e/helpers/index.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
/**
|
||||
* Test wiring helpers.
|
||||
*
|
||||
* `wire(transport, makeServer, client)` connects a server (built per call by
|
||||
* `makeServer`) and a client over the named transport, returning an
|
||||
* `AsyncDisposable` for `await using` teardown. All wiring is in-process —
|
||||
* no real sockets, no child processes.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
import type { Client } from '@modelcontextprotocol/client';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
import type { EventStore, JSONRPCMessage, McpServer, Server } from '@modelcontextprotocol/server';
|
||||
import { InMemoryTransport, ReadBuffer, serializeMessage, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
|
||||
|
||||
import type { Transport } from '../types.js';
|
||||
import type { SnifferOptions } from './wire-sniffer.js';
|
||||
import { sniffTransport } from './wire-sniffer.js';
|
||||
|
||||
export type ServerFactory = () => McpServer | Server;
|
||||
|
||||
export interface Wired extends AsyncDisposable {
|
||||
readonly fetch?: (url: URL | string, init?: RequestInit) => Promise<Response>;
|
||||
readonly url?: URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* The fourth argument controls the wire-format sniffer (see wire-sniffer.ts):
|
||||
* every message the client sends or receives is validated against the SDK's
|
||||
* spec-anchored Zod schemas. Tests that intentionally use vendor-extension
|
||||
* methods pass `{ allowCustomMethods: true }`; tests that deliberately put
|
||||
* malformed MCP on the wire pass `{ strictValidation: false }`.
|
||||
*/
|
||||
export async function wire(transport: Transport, makeServer: ServerFactory, client: Client, sniff: SnifferOptions = {}): Promise<Wired> {
|
||||
switch (transport) {
|
||||
case 'inMemory': {
|
||||
const server = makeServer();
|
||||
const [clientTx, serverTx] = InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTx);
|
||||
await client.connect(sniffTransport(clientTx, 'client', sniff));
|
||||
return { [Symbol.asyncDispose]: () => Promise.all([client.close(), server.close()]).then(() => {}) };
|
||||
}
|
||||
case 'stdio': {
|
||||
const server = makeServer();
|
||||
const c2s = new PassThrough();
|
||||
const s2c = new PassThrough();
|
||||
await server.connect(new StdioServerTransport(c2s, s2c));
|
||||
await client.connect(sniffTransport(stdioClientOverPipes(s2c, c2s), 'client', sniff));
|
||||
return { [Symbol.asyncDispose]: () => Promise.all([client.close(), server.close()]).then(() => {}) };
|
||||
}
|
||||
case 'streamableHttp':
|
||||
case 'streamableHttpStateless': {
|
||||
const handle = transport === 'streamableHttpStateless' ? hostStateless(makeServer) : hostPerSession(makeServer);
|
||||
const url = new URL('http://in-process/mcp');
|
||||
const fetch = (u: URL | string, init?: RequestInit) => handle.handleRequest(new Request(u, init));
|
||||
await client.connect(sniffTransport(new StreamableHTTPClientTransport(url, { fetch }), 'client', sniff));
|
||||
return {
|
||||
fetch,
|
||||
url,
|
||||
[Symbol.asyncDispose]: () => Promise.all([client.close(), handle.close()]).then(() => {})
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tap a connected client's transport so every JSON-RPC message crossing the
|
||||
* wire is recorded. `sent` = client→server, `received` = server→client.
|
||||
* Call after `wire()` so `client.transport` is set. The transport is
|
||||
* monkey-patched in place; teardown via `await using` on `wire()` discards it.
|
||||
*/
|
||||
export function tapWire(client: Client): { sent: JSONRPCMessage[]; received: JSONRPCMessage[] } {
|
||||
const tx = client.transport;
|
||||
if (!tx) throw new Error('tapWire: client not connected');
|
||||
const sent: JSONRPCMessage[] = [];
|
||||
const received: JSONRPCMessage[] = [];
|
||||
const origSend = tx.send.bind(tx);
|
||||
const origOnMessage = tx.onmessage;
|
||||
tx.send = async (m, opts) => {
|
||||
sent.push(m);
|
||||
return origSend(m, opts);
|
||||
};
|
||||
tx.onmessage = (m, extra) => {
|
||||
received.push(m);
|
||||
origOnMessage?.(m, extra);
|
||||
};
|
||||
return { sent, received };
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────────
|
||||
// HTTP hosting (the two production patterns)
|
||||
// ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type HttpHandler = (req: Request) => Promise<Response>;
|
||||
|
||||
export function hostPerSession(makeServer: ServerFactory): { handleRequest: HttpHandler; close(): Promise<void> } {
|
||||
const sessions = new Map<string, WebStandardStreamableHTTPServerTransport>();
|
||||
return {
|
||||
handleRequest: async req => {
|
||||
const sid = req.headers.get('mcp-session-id') ?? undefined;
|
||||
const existing = sid ? sessions.get(sid) : undefined;
|
||||
if (existing) return existing.handleRequest(req);
|
||||
if (sid !== undefined) {
|
||||
// Mirror the SDK's documented hosting pattern: an unrecognized session id is
|
||||
// rejected at the app level, so the transport's own 404 is never reached.
|
||||
return Response.json(
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32_000, message: 'Bad Request: No valid session ID provided' },
|
||||
id: null
|
||||
},
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const tx = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: randomUUID,
|
||||
onsessioninitialized: id => void sessions.set(id, tx),
|
||||
onsessionclosed: id => void sessions.delete(id)
|
||||
});
|
||||
await makeServer().connect(tx);
|
||||
return tx.handleRequest(req);
|
||||
},
|
||||
close: async () => {
|
||||
for (const t of sessions.values()) await t.close();
|
||||
sessions.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export interface ResumeHostOptions {
|
||||
eventStore: EventStore;
|
||||
retryInterval?: number;
|
||||
}
|
||||
|
||||
export function hostResumable(makeServer: ServerFactory, opts: ResumeHostOptions): { handleRequest: HttpHandler; close(): Promise<void> } {
|
||||
const sessions = new Map<string, { tx: WebStandardStreamableHTTPServerTransport; server: McpServer | Server }>();
|
||||
|
||||
return {
|
||||
handleRequest: async req => {
|
||||
const sid = req.headers.get('mcp-session-id') ?? undefined;
|
||||
const existing = sid ? sessions.get(sid) : undefined;
|
||||
if (existing) return existing.tx.handleRequest(req);
|
||||
|
||||
const tx = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: randomUUID,
|
||||
eventStore: opts.eventStore,
|
||||
retryInterval: opts.retryInterval,
|
||||
onsessioninitialized: id => void sessions.set(id, { tx, server }),
|
||||
onsessionclosed: id => void sessions.delete(id)
|
||||
});
|
||||
const server = makeServer();
|
||||
await server.connect(tx);
|
||||
return tx.handleRequest(req);
|
||||
},
|
||||
close: async () => {
|
||||
for (const { tx, server } of sessions.values()) {
|
||||
await server.close();
|
||||
await tx.close();
|
||||
}
|
||||
sessions.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function hostStateless(makeServer: ServerFactory): { handleRequest: HttpHandler; close(): Promise<void> } {
|
||||
const cleanups: Array<() => Promise<void>> = [];
|
||||
return {
|
||||
handleRequest: async req => {
|
||||
if (req.method !== 'POST') {
|
||||
return Response.json(
|
||||
{ jsonrpc: '2.0', error: { code: -32_000, message: 'Method not allowed.' }, id: null },
|
||||
{
|
||||
status: 405,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
);
|
||||
}
|
||||
const server = makeServer();
|
||||
const tx = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
||||
await server.connect(tx);
|
||||
cleanups.push(async () => {
|
||||
await server.close();
|
||||
await tx.close();
|
||||
});
|
||||
return tx.handleRequest(req);
|
||||
},
|
||||
close: async () => {
|
||||
for (const c of cleanups) await c();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────────
|
||||
// In-process stdio client — TEST-ONLY
|
||||
//
|
||||
// Production stdio uses `StdioClientTransport`, which spawns a child process.
|
||||
// This is the in-process equivalent for tests: same newline-framed JSON wire
|
||||
// format (uses the SDK's `serializeMessage`/`ReadBuffer`), but over PassThrough
|
||||
// streams instead of a spawned process. Tests that specifically exercise spawn,
|
||||
// env, signals, or stderr must use the real `StdioClientTransport`.
|
||||
// ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function stdioClientOverPipes(serverStdout: NodeJS.ReadableStream, serverStdin: NodeJS.WritableStream) {
|
||||
const buf = new ReadBuffer();
|
||||
return {
|
||||
onmessage: undefined as ((m: JSONRPCMessage) => void) | undefined,
|
||||
onerror: undefined as ((e: Error) => void) | undefined,
|
||||
onclose: undefined as (() => void) | undefined,
|
||||
async start() {
|
||||
serverStdout.on('data', chunk => {
|
||||
buf.append(chunk);
|
||||
let m: JSONRPCMessage | null;
|
||||
while ((m = buf.readMessage())) this.onmessage?.(m);
|
||||
});
|
||||
serverStdout.on('error', e => this.onerror?.(e));
|
||||
serverStdout.on('close', () => this.onclose?.());
|
||||
},
|
||||
async send(m: JSONRPCMessage) {
|
||||
serverStdin.write(serializeMessage(m));
|
||||
},
|
||||
async close() {
|
||||
serverStdin.end();
|
||||
}
|
||||
};
|
||||
}
|
||||
54
test/e2e/helpers/verifies.ts
Normal file
54
test/e2e/helpers/verifies.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* Inverted manifest linkage: tests declare which requirement(s) they satisfy.
|
||||
* (Staged here in the harness; the restructure codemod copies this file to
|
||||
* sdk/test/e2e/helpers/verifies.ts when the inversion is executed.)
|
||||
*
|
||||
* `verifies(id, fn, opts?)` looks the requirement up in the pure-data manifest
|
||||
* and registers one cell per applicable (transport, spec version) — the same
|
||||
* names and semantics the old matrix runner produced — applying `test.fails`
|
||||
* for knownFailures and the global 15s timeout. Unknown or deferred ids throw
|
||||
* at registration time, so a typo can never silently drop coverage.
|
||||
*
|
||||
* Bodies are anonymous; the registered test title is `opts?.title ?? 'verifies'`.
|
||||
* An explicit `opts.title` is only needed when a requirement is cited by more
|
||||
* than one body (to tell them apart) — a knownFailure with a `test` string only
|
||||
* applies to the body registered with that exact title.
|
||||
*/
|
||||
|
||||
import { describe, test } from 'vitest';
|
||||
|
||||
import { REQUIREMENTS } from '../requirements.js';
|
||||
import type { TestArgs } from '../types.js';
|
||||
import { ALL_SPEC_VERSIONS, ALL_TRANSPORTS } from '../types.js';
|
||||
|
||||
type TestBody = (args: TestArgs) => Promise<void>;
|
||||
|
||||
export function verifies(id: string | readonly string[], fn: TestBody, opts?: { title?: string }): void {
|
||||
const ids = Array.isArray(id) ? id : [id];
|
||||
for (const rid of ids) registerOne(rid, fn, opts);
|
||||
}
|
||||
|
||||
function registerOne(id: string, fn: TestBody, opts?: { title?: string }): void {
|
||||
const req = REQUIREMENTS[id];
|
||||
if (!req) throw new Error(`verifies('${id}'): unknown requirement id`);
|
||||
if (req.deferred) throw new Error(`verifies('${id}'): requirement is deferred — drop the deferral or the test`);
|
||||
|
||||
const transports = req.transports ?? ALL_TRANSPORTS;
|
||||
const versions = ALL_SPEC_VERSIONS.filter(
|
||||
v =>
|
||||
(req.addedInSpecVersion === undefined || v >= req.addedInSpecVersion) &&
|
||||
(req.removedInSpecVersion === undefined || v < req.removedInSpecVersion)
|
||||
);
|
||||
const cells = versions.flatMap(v => transports.map(t => [t, v] as const));
|
||||
|
||||
describe.each(cells)(`${id} [%s %s]`, (transport, protocolVersion) => {
|
||||
const kf = req.knownFailures?.find(
|
||||
k =>
|
||||
(k.test === undefined || k.test === opts?.title) &&
|
||||
(k.transport === undefined || k.transport === transport) &&
|
||||
(k.specVersion === undefined || k.specVersion === protocolVersion)
|
||||
);
|
||||
const run = kf ? test.fails : test;
|
||||
run(opts?.title ?? 'verifies', () => fn({ transport, protocolVersion }), 15_000);
|
||||
});
|
||||
}
|
||||
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 { LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/server';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { assertWireMessage } from './wire-sniffer.js';
|
||||
|
||||
const req = (method: string, params?: unknown, id = 1) => ({
|
||||
jsonrpc: '2.0' as const,
|
||||
id,
|
||||
method,
|
||||
...(params === undefined ? {} : { params })
|
||||
});
|
||||
const notif = (method: string, params?: unknown) => ({ jsonrpc: '2.0' as const, method, ...(params === undefined ? {} : { params }) });
|
||||
const resp = (result: unknown, id = 1) => ({ jsonrpc: '2.0' as const, id, result });
|
||||
|
||||
describe('assertWireMessage', () => {
|
||||
it('accepts a valid client initialize request', () => {
|
||||
expect(() =>
|
||||
assertWireMessage(
|
||||
req('initialize', { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'c', version: '0' } }),
|
||||
'client'
|
||||
)
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts a valid client tools/list result on the server→client return', () => {
|
||||
// server sends the ListToolsResult back
|
||||
expect(() => assertWireMessage(resp({ tools: [] }), 'server')).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts a valid client ping and the empty result', () => {
|
||||
expect(() => assertWireMessage(req('ping'), 'client')).not.toThrow();
|
||||
expect(() => assertWireMessage(resp({}), 'server')).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a non-JSON-RPC envelope', () => {
|
||||
expect(() => assertWireMessage({ jsonrpc: '1.0', id: 1, method: 'ping' }, 'client')).toThrow(/not a JSON-RPC message/);
|
||||
});
|
||||
|
||||
it('rejects an unknown method without allowCustomMethods', () => {
|
||||
expect(() => assertWireMessage(req('x/custom', { a: 1 }), 'client')).toThrow(/non-spec request method 'x\/custom'/);
|
||||
});
|
||||
|
||||
it('accepts an unknown method with allowCustomMethods', () => {
|
||||
expect(() => assertWireMessage(req('x/custom', { a: 1 }), 'client', { allowCustomMethods: true })).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a spec method with malformed params under strict, accepts with strictValidation:false', () => {
|
||||
const bad = notif('notifications/progress', { progressToken: 1, progress: 'not-a-number' });
|
||||
expect(() => assertWireMessage(bad, 'client')).toThrow(/params do not conform/);
|
||||
expect(() => assertWireMessage(bad, 'client', { strictValidation: false })).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a server-only request sent by the client (direction check)', () => {
|
||||
// sampling/createMessage is a Server→Client request; the client must not originate it
|
||||
expect(() => assertWireMessage(req('sampling/createMessage', { messages: [], maxTokens: 1 }), 'client')).toThrow(
|
||||
/non-spec request method 'sampling\/createMessage'/
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts that same request when the server sends it', () => {
|
||||
expect(() => assertWireMessage(req('sampling/createMessage', { messages: [], maxTokens: 1 }), 'server')).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts a JSON-RPC error response for either party', () => {
|
||||
const err = { jsonrpc: '2.0' as const, id: 1, error: { code: -32_601, message: 'Method not found' } };
|
||||
expect(() => assertWireMessage(err, 'server')).not.toThrow();
|
||||
expect(() => assertWireMessage(err, 'client')).not.toThrow();
|
||||
});
|
||||
});
|
||||
135
test/e2e/helpers/wire-sniffer.ts
Normal file
135
test/e2e/helpers/wire-sniffer.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import {
|
||||
ClientNotificationSchema,
|
||||
ClientRequestSchema,
|
||||
ClientResultSchema,
|
||||
ServerNotificationSchema,
|
||||
ServerRequestSchema,
|
||||
ServerResultSchema
|
||||
} from '@modelcontextprotocol/core';
|
||||
import type { Transport } from '@modelcontextprotocol/server';
|
||||
import {
|
||||
isJSONRPCErrorResponse,
|
||||
isJSONRPCNotification,
|
||||
isJSONRPCRequest,
|
||||
isJSONRPCResultResponse,
|
||||
isSpecType
|
||||
} from '@modelcontextprotocol/server';
|
||||
|
||||
export type WireParty = 'client' | 'server';
|
||||
|
||||
export interface SnifferOptions {
|
||||
/** Permit non-spec (vendor-extension) method names. Spec methods stay strict. */
|
||||
allowCustomMethods?: boolean;
|
||||
/** `false` → envelope check only (for tests that deliberately send malformed messages). */
|
||||
strictValidation?: boolean;
|
||||
}
|
||||
|
||||
const OUTBOUND = {
|
||||
client: {
|
||||
request: ClientRequestSchema,
|
||||
notification: ClientNotificationSchema,
|
||||
result: ClientResultSchema
|
||||
},
|
||||
server: {
|
||||
request: ServerRequestSchema,
|
||||
notification: ServerNotificationSchema,
|
||||
result: ServerResultSchema
|
||||
}
|
||||
} as const;
|
||||
|
||||
/** Method names valid as an outbound request/notification for each party. */
|
||||
const SPEC_METHODS: Record<WireParty, { request: Set<string>; notification: Set<string> }> = {
|
||||
client: { request: methodSet(ClientRequestSchema), notification: methodSet(ClientNotificationSchema) },
|
||||
server: { request: methodSet(ServerRequestSchema), notification: methodSet(ServerNotificationSchema) }
|
||||
};
|
||||
|
||||
function methodSet(union: { options?: ReadonlyArray<{ shape?: { method?: { values?: ReadonlySet<unknown> } } }> }): Set<string> {
|
||||
const out = new Set<string>();
|
||||
for (const member of union.options ?? []) {
|
||||
for (const v of member.shape?.method?.values ?? []) {
|
||||
if (typeof v === 'string') out.add(v);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function fail(party: WireParty, reason: string, msg: unknown): never {
|
||||
throw new Error(`[wire] ${party} sent an invalid message: ${reason}\n${JSON.stringify(msg, null, 2)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a single message is valid for the given sending party.
|
||||
* @param msg the raw JSON-RPC message
|
||||
* @param party who put it on the wire (`client` outbound = ClientRequest/Notification/Result)
|
||||
*/
|
||||
export function assertWireMessage(msg: unknown, party: WireParty, opts: SnifferOptions = {}): void {
|
||||
if (!isSpecType.JSONRPCMessage(msg)) {
|
||||
fail(party, 'not a JSON-RPC message', msg);
|
||||
}
|
||||
if (opts.strictValidation === false) return;
|
||||
|
||||
const schemas = OUTBOUND[party];
|
||||
|
||||
if (isJSONRPCRequest(msg) || isJSONRPCNotification(msg)) {
|
||||
const kind = isJSONRPCRequest(msg) ? 'request' : 'notification';
|
||||
const method = (msg as { method: string }).method;
|
||||
if (!SPEC_METHODS[party][kind].has(method)) {
|
||||
if (opts.allowCustomMethods) return;
|
||||
fail(party, `non-spec ${kind} method '${method}' (pass { allowCustomMethods: true } if intentional)`, msg);
|
||||
}
|
||||
const params = (msg as { params?: unknown }).params;
|
||||
const r = schemas[kind].safeParse({ method, params });
|
||||
if (!r.success) {
|
||||
fail(party, `spec method '${method}' params do not conform: ${r.error.message}`, msg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isJSONRPCResultResponse(msg)) {
|
||||
const result = (msg as { result: unknown }).result;
|
||||
const r = schemas.result.safeParse(result);
|
||||
if (!r.success) {
|
||||
// A result for a vendor-extension request legitimately won't match the spec union.
|
||||
if (opts.allowCustomMethods) return;
|
||||
fail(party, `result does not conform to any spec result: ${r.error.message}`, msg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isJSONRPCErrorResponse(msg)) return; // envelope already validated; error bodies are not method-specific
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a transport so every outbound `send` (validated as `party`) and inbound
|
||||
* `onmessage` (validated as the counterpart) is asserted. Returns the same
|
||||
* transport instance (monkey-patched in place).
|
||||
*/
|
||||
export function sniffTransport<T extends Transport>(transport: T, party: WireParty, opts: SnifferOptions = {}): T {
|
||||
const counterpart: WireParty = party === 'client' ? 'server' : 'client';
|
||||
|
||||
const origSend = transport.send.bind(transport);
|
||||
transport.send = (message, sendOpts) => {
|
||||
assertWireMessage(message, party, opts);
|
||||
return origSend(message, sendOpts);
|
||||
};
|
||||
|
||||
// `onmessage` is assigned by Protocol.connect() after we wrap. Intercept via
|
||||
// an accessor so we wrap whatever handler it installs, validating each
|
||||
// inbound message (sent by the counterpart) before passing it through.
|
||||
let handler: Transport['onmessage'];
|
||||
Object.defineProperty(transport, 'onmessage', {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: () => handler,
|
||||
set: next => {
|
||||
handler = next
|
||||
? (message, extra) => {
|
||||
assertWireMessage(message, counterpart, opts);
|
||||
return next(message, extra);
|
||||
}
|
||||
: next;
|
||||
}
|
||||
});
|
||||
|
||||
return transport;
|
||||
}
|
||||
60
test/e2e/package.json
Normal file
60
test/e2e/package.json
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/test-e2e",
|
||||
"private": true,
|
||||
"version": "2.0.0-alpha.0",
|
||||
"description": "Model Context Protocol implementation for TypeScript",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
"homepage": "https://modelcontextprotocol.io",
|
||||
"bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20",
|
||||
"pnpm": ">=10.24.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.24.0",
|
||||
"keywords": [
|
||||
"modelcontextprotocol",
|
||||
"mcp"
|
||||
],
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"lint": "eslint . && prettier --ignore-path ../../.prettierignore --check .",
|
||||
"lint:fix": "eslint . --fix && prettier --ignore-path ../../.prettierignore --write .",
|
||||
"check": "npm run typecheck && npm run lint",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hono/node-server": "catalog:runtimeServerOnly",
|
||||
"@modelcontextprotocol/client": "workspace:^",
|
||||
"@modelcontextprotocol/core": "workspace:^",
|
||||
"@modelcontextprotocol/server": "workspace:^",
|
||||
"@modelcontextprotocol/express": "workspace:^",
|
||||
"@modelcontextprotocol/fastify": "workspace:^",
|
||||
"@modelcontextprotocol/hono": "workspace:^",
|
||||
"@modelcontextprotocol/node": "workspace:^",
|
||||
"@modelcontextprotocol/eslint-config": "workspace:^",
|
||||
"@modelcontextprotocol/test-helpers": "workspace:^",
|
||||
"@modelcontextprotocol/tsconfig": "workspace:^",
|
||||
"@modelcontextprotocol/vitest-config": "workspace:^",
|
||||
"@types/cors": "catalog:devTools",
|
||||
"@types/express": "catalog:devTools",
|
||||
"@valibot/to-json-schema": "catalog:devTools",
|
||||
"arktype": "catalog:devTools",
|
||||
"cors": "catalog:runtimeServerOnly",
|
||||
"express": "catalog:runtimeServerOnly",
|
||||
"fastify": "catalog:runtimeServerOnly",
|
||||
"hono": "catalog:runtimeServerOnly",
|
||||
"jose": "catalog:runtimeClientOnly",
|
||||
"tsx": "catalog:devTools",
|
||||
"typescript": "catalog:devTools",
|
||||
"valibot": "catalog:devTools",
|
||||
"vitest": "catalog:devTools",
|
||||
"zod": "catalog:runtimeShared"
|
||||
}
|
||||
}
|
||||
2584
test/e2e/requirements.ts
Normal file
2584
test/e2e/requirements.ts
Normal file
File diff suppressed because it is too large
Load diff
1809
test/e2e/scenarios/client-auth.test.ts
Normal file
1809
test/e2e/scenarios/client-auth.test.ts
Normal file
File diff suppressed because it is too large
Load diff
340
test/e2e/scenarios/completion.test.ts
Normal file
340
test/e2e/scenarios/completion.test.ts
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
/**
|
||||
* 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 { Client } from '@modelcontextprotocol/client';
|
||||
import { completable, McpServer, ProtocolError, ProtocolErrorCode, ResourceTemplate, Server } from '@modelcontextprotocol/server';
|
||||
import { expect } from 'vitest';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { wire } from '../helpers/index.js';
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
import type { TestArgs } from '../types.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: z.object({ 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: z.object({ 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' } }
|
||||
})
|
||||
).rejects.toMatchObject({ code: ProtocolErrorCode.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: ProtocolErrorCode.InvalidParams });
|
||||
|
||||
await expect(
|
||||
client.complete({ ref: { type: 'ref/resource', uri: 'nosuchscheme://nowhere/{x}' }, argument: { name: 'x', value: '' } })
|
||||
).rejects.toMatchObject({ code: ProtocolErrorCode.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('completion/complete', req => {
|
||||
if (req.params.ref.type === 'ref/prompt' && req.params.ref.name === 'known') {
|
||||
return { completion: { values: ['ok'] } };
|
||||
}
|
||||
throw new ProtocolError(ProtocolErrorCode.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: ProtocolErrorCode.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: z.object({ 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: z.object({ 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: z.object({ 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('prompts/list', () => ({ prompts: [{ name: 'plain', arguments: [] }] }));
|
||||
s.setRequestHandler('resources/list', () => ({ resources: [] }));
|
||||
s.setRequestHandler('resources/read', () => ({ 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' }
|
||||
);
|
||||
159
test/e2e/scenarios/custom-methods.test.ts
Normal file
159
test/e2e/scenarios/custom-methods.test.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* Self-contained test bodies for custom (non-spec, vendor-prefixed) methods.
|
||||
*
|
||||
* v2 registers these via the schema'd overloads: `setRequestHandler('<vendor>/x',
|
||||
* { params, result }, handler)` and `setNotificationHandler('<vendor>/x',
|
||||
* { params }, handler)`; the requesting side passes the matching result schema to
|
||||
* `request()`. Each test builds its own server (via factory) and client, wires
|
||||
* them with {@link wire} (with `allowCustomMethods` so the wire sniffer permits
|
||||
* the vendor methods), and asserts. Params schemas carry a defaulted field so
|
||||
* the handler observably receives the schema's parse output, not raw wire params.
|
||||
*/
|
||||
|
||||
import { Client } from '@modelcontextprotocol/client';
|
||||
import { McpServer, ProtocolErrorCode, Server } from '@modelcontextprotocol/server';
|
||||
import { expect, vi } from 'vitest';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { wire } from '../helpers/index.js';
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
import type { TestArgs } from '../types.js';
|
||||
|
||||
verifies('custom-methods:server-handler:roundtrip', async ({ transport }: TestArgs) => {
|
||||
const SearchParams = z.object({ query: z.string(), limit: z.number().int().default(5) });
|
||||
const SearchResult = z.object({ hits: z.array(z.string()), total: z.number() });
|
||||
|
||||
const received: Array<z.output<typeof SearchParams>> = [];
|
||||
const makeServer = () => {
|
||||
const s = new Server({ name: 's', version: '0' }, { capabilities: {} });
|
||||
s.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, params => {
|
||||
received.push(params);
|
||||
return { hits: [`doc-1 for ${params.query}`, `doc-2 for ${params.query}`].slice(0, params.limit), total: 2 };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
|
||||
await using _ = await wire(transport, makeServer, client, { allowCustomMethods: true });
|
||||
|
||||
const full = await client.request({ method: 'acme/search', params: { query: 'streamable http' } }, SearchResult);
|
||||
// The defaulted `limit` proves the handler got the schema's parse output, not the raw wire params.
|
||||
expect(received).toEqual([{ query: 'streamable http', limit: 5 }]);
|
||||
expect(full).toEqual({ hits: ['doc-1 for streamable http', 'doc-2 for streamable http'], total: 2 });
|
||||
|
||||
const limited = await client.request({ method: 'acme/search', params: { query: 'sse', limit: 1 } }, SearchResult);
|
||||
expect(received).toEqual([
|
||||
{ query: 'streamable http', limit: 5 },
|
||||
{ query: 'sse', limit: 1 }
|
||||
]);
|
||||
expect(limited).toEqual({ hits: ['doc-1 for sse'], total: 2 });
|
||||
});
|
||||
|
||||
verifies('custom-methods:client-handler:roundtrip', async ({ transport }: TestArgs) => {
|
||||
const LookupParams = z.object({ key: z.string(), scope: z.string().default('workspace') });
|
||||
const LookupResult = z.object({ value: z.string(), found: z.boolean() });
|
||||
|
||||
const received: Array<z.output<typeof LookupParams>> = [];
|
||||
const lookups: Array<z.output<typeof LookupResult>> = [];
|
||||
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('read-config', { inputSchema: z.object({ key: z.string() }) }, async ({ key }, ctx) => {
|
||||
// relatedRequestId keeps the server→client request on the in-flight POST stream for streamableHttp.
|
||||
const lookup = await s.server.request({ method: 'acme/configLookup', params: { key } }, LookupResult, {
|
||||
relatedRequestId: ctx.mcpReq.id
|
||||
});
|
||||
lookups.push(lookup);
|
||||
return { content: [{ type: 'text', text: `${key}=${lookup.value}` }] };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
client.setRequestHandler('acme/configLookup', { params: LookupParams, result: LookupResult }, params => {
|
||||
received.push(params);
|
||||
return { value: `resolved:${params.key}`, found: true };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client, { allowCustomMethods: true });
|
||||
|
||||
const result = await client.callTool({ name: 'read-config', arguments: { key: 'editor.theme' } });
|
||||
|
||||
// The defaulted `scope` proves the client handler got the schema's parse output, not the raw wire params.
|
||||
expect(received).toEqual([{ key: 'editor.theme', scope: 'workspace' }]);
|
||||
expect(lookups).toEqual([{ value: 'resolved:editor.theme', found: true }]);
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'editor.theme=resolved:editor.theme' }]);
|
||||
});
|
||||
|
||||
verifies('custom-methods:params-validation-error', async ({ transport }: TestArgs) => {
|
||||
const ConvertParams = z.object({ celsius: z.number() });
|
||||
const ConvertResult = z.object({ fahrenheit: z.number() });
|
||||
|
||||
const invocations: Array<z.output<typeof ConvertParams>> = [];
|
||||
const makeServer = () => {
|
||||
const s = new Server({ name: 's', version: '0' }, { capabilities: {} });
|
||||
s.setRequestHandler('acme/convertTemperature', { params: ConvertParams, result: ConvertResult }, params => {
|
||||
invocations.push(params);
|
||||
return { fahrenheit: params.celsius * 1.8 + 32 };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
|
||||
await using _ = await wire(transport, makeServer, client, { allowCustomMethods: true });
|
||||
|
||||
// A conforming call first proves the handler is reachable, so the later "not invoked" check is meaningful.
|
||||
await expect(client.request({ method: 'acme/convertTemperature', params: { celsius: 20 } }, ConvertResult)).resolves.toEqual({
|
||||
fahrenheit: 68
|
||||
});
|
||||
expect(invocations).toEqual([{ celsius: 20 }]);
|
||||
|
||||
expect(ProtocolErrorCode.InvalidParams).toBe(-32_602);
|
||||
await expect(client.request({ method: 'acme/convertTemperature', params: { celsius: 'warm' } }, ConvertResult)).rejects.toMatchObject({
|
||||
code: ProtocolErrorCode.InvalidParams,
|
||||
message: expect.stringContaining('Invalid params for acme/convertTemperature')
|
||||
});
|
||||
expect(invocations).toEqual([{ celsius: 20 }]);
|
||||
});
|
||||
|
||||
verifies('custom-methods:notification-handler', async ({ transport }: TestArgs) => {
|
||||
const HeartbeatParams = z.object({ seq: z.number().int(), source: z.string().default('unspecified') });
|
||||
|
||||
const clientReceived: Array<z.output<typeof HeartbeatParams>> = [];
|
||||
const serverReceived: Array<z.output<typeof HeartbeatParams>> = [];
|
||||
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.server.setNotificationHandler('acme/heartbeat', { params: HeartbeatParams }, params => {
|
||||
serverReceived.push(params);
|
||||
});
|
||||
s.registerTool('sync-data', { inputSchema: z.object({ batches: z.number().int() }) }, async ({ batches }, ctx) => {
|
||||
for (let seq = 1; seq <= batches; seq++) {
|
||||
await ctx.mcpReq.notify({ method: 'acme/heartbeat', params: { seq, source: 'sync-job' } });
|
||||
}
|
||||
return { content: [{ type: 'text', text: `synced ${batches} batches` }] };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
client.setNotificationHandler('acme/heartbeat', { params: HeartbeatParams }, params => {
|
||||
clientReceived.push(params);
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client, { allowCustomMethods: true });
|
||||
|
||||
const result = await client.callTool({ name: 'sync-data', arguments: { batches: 2 } });
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'synced 2 batches' }]);
|
||||
|
||||
await vi.waitFor(() => expect(clientReceived).toHaveLength(2));
|
||||
expect(clientReceived).toEqual([
|
||||
{ seq: 1, source: 'sync-job' },
|
||||
{ seq: 2, source: 'sync-job' }
|
||||
]);
|
||||
|
||||
// Same registration works on the server side; `source` is omitted so the applied default proves schema validation ran.
|
||||
await client.notification({ method: 'acme/heartbeat', params: { seq: 3 } });
|
||||
await vi.waitFor(() => expect(serverReceived).toHaveLength(1));
|
||||
expect(serverReceived).toEqual([{ seq: 3, source: 'unspecified' }]);
|
||||
});
|
||||
354
test/e2e/scenarios/dynamic.test.ts
Normal file
354
test/e2e/scenarios/dynamic.test.ts
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
/**
|
||||
* 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 { Client } from '@modelcontextprotocol/client';
|
||||
import type { Prompt, RegisteredTool, Resource, Tool } from '@modelcontextprotocol/server';
|
||||
import { McpServer, ProtocolErrorCode, Server } from '@modelcontextprotocol/server';
|
||||
import { expect, vi } from 'vitest';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { wire } from '../helpers/index.js';
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
import type { TestArgs } from '../types.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('tools/list', () => ({ tools: [] }));
|
||||
server.setRequestHandler('prompts/list', () => ({ prompts: [] }));
|
||||
server.setRequestHandler('resources/list', () => ({ 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 > 0);
|
||||
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 > 0);
|
||||
expect(toolCalls[0]).toEqual({ error: null, items: null });
|
||||
|
||||
server.registerPrompt('signal-only-prompt', { description: '' }, () => ({ messages: [] }));
|
||||
await waitUntil(() => promptCalls.length > 0);
|
||||
expect(promptCalls[0]).toEqual({ error: null, items: null });
|
||||
|
||||
server.registerResource('signal-only', 'test://signal-only', {}, () => ({ contents: [] }));
|
||||
await waitUntil(() => resourceCalls.length > 0);
|
||||
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('notifications/tools/list_changed', () => {
|
||||
listChanged++;
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const initialList = await client.listTools();
|
||||
expect(initialList.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);
|
||||
|
||||
const disabledList = await client.listTools();
|
||||
expect(disabledList.tools.map(t => t.name)).not.toContain('toggle-probe');
|
||||
// changed in v2: calling a disabled tool surfaces a JSON-RPC InvalidParams error instead of an isError result
|
||||
await expect(client.callTool({ name: 'toggle-probe', arguments: {} })).rejects.toMatchObject({
|
||||
code: ProtocolErrorCode.InvalidParams,
|
||||
message: expect.stringMatching(/disabled/i)
|
||||
});
|
||||
|
||||
const beforeEnable = listChanged;
|
||||
handle.enable();
|
||||
await waitUntil(() => listChanged > beforeEnable);
|
||||
|
||||
const restoredList = await client.listTools();
|
||||
expect(restoredList.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('notifications/tools/list_changed', () => void counts.tools++);
|
||||
client.setNotificationHandler('notifications/resources/list_changed', () => void counts.resources++);
|
||||
client.setNotificationHandler('notifications/prompts/list_changed', () => 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('notifications/tools/list_changed', () => void seen.push('tools'));
|
||||
client.setNotificationHandler('notifications/resources/list_changed', () => void seen.push('resources'));
|
||||
client.setNotificationHandler('notifications/prompts/list_changed', () => 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'));
|
||||
const { tools } = await client.listTools();
|
||||
expect(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'));
|
||||
const { resources } = await client.listResources();
|
||||
expect(resources.map(r => r.uri)).toContain('test://post-connect');
|
||||
|
||||
seen.length = 0;
|
||||
server.registerPrompt('post-connect-prompt', { description: '' }, () => ({ messages: [] }));
|
||||
await waitUntil(() => seen.includes('prompts'));
|
||||
const { prompts } = await client.listPrompts();
|
||||
expect(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('resources/subscribe', 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' }]);
|
||||
});
|
||||
827
test/e2e/scenarios/elicitation.test.ts
Normal file
827
test/e2e/scenarios/elicitation.test.ts
Normal file
|
|
@ -0,0 +1,827 @@
|
|||
/**
|
||||
* 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 { Client } from '@modelcontextprotocol/client';
|
||||
import type { ElicitRequest, ElicitRequestFormParams } from '@modelcontextprotocol/server';
|
||||
import {
|
||||
McpServer,
|
||||
ProtocolError,
|
||||
ProtocolErrorCode,
|
||||
Server,
|
||||
specTypeSchemas,
|
||||
UrlElicitationRequiredError
|
||||
} from '@modelcontextprotocol/server';
|
||||
import { expect } from 'vitest';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { tapWire, wire } from '../helpers/index.js';
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
import type { TestArgs } from '../types.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('elicitation/create', 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);
|
||||
const firstRequest = received[0];
|
||||
if (firstRequest === undefined) throw new Error('expected exactly one elicitation request');
|
||||
expect(firstRequest.method).toBe('elicitation/create');
|
||||
expect(firstRequest.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('elicitation/create', 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('elicitation/create', 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('elicitation/create', 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('elicitation/create', 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('tools/list', () => ({ tools: [{ name: 'ask', inputSchema: { type: 'object' } }] }));
|
||||
s.setRequestHandler('tools/call', async (_req, ctx) => {
|
||||
try {
|
||||
const ans = await ctx.mcpReq.send(
|
||||
{
|
||||
method: 'elicitation/create',
|
||||
params: {
|
||||
message: 'Enter name',
|
||||
requestedSchema: { type: 'object', properties: { name: { type: 'string' } } }
|
||||
}
|
||||
},
|
||||
specTypeSchemas.ElicitResult
|
||||
);
|
||||
return { content: [{ type: 'text', text: ans.action }] };
|
||||
} catch (error) {
|
||||
if (!(error instanceof ProtocolError)) throw error;
|
||||
return { content: [{ type: 'text', text: `error:${error.code}` }] };
|
||||
}
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const formReceived: ElicitRequest['params'][] = [];
|
||||
const formClientInstance = formClient();
|
||||
formClientInstance.setRequestHandler('elicitation/create', async req => {
|
||||
formReceived.push(req.params);
|
||||
return { action: 'accept', content: { name: 'Test' } };
|
||||
});
|
||||
|
||||
{
|
||||
await using _ = await wire(transport, makeServer, formClientInstance);
|
||||
|
||||
const formResult = await formClientInstance.callTool({ name: 'ask', arguments: {} });
|
||||
|
||||
expect(formReceived).toHaveLength(1);
|
||||
const firstFormRequest = formReceived[0];
|
||||
if (firstFormRequest === undefined) throw new Error('expected exactly one form elicitation request');
|
||||
expect(firstFormRequest.mode).toBeUndefined();
|
||||
expect(formResult.content).toEqual([{ type: 'text', text: 'accept' }]);
|
||||
}
|
||||
|
||||
let urlHandlerInvoked = 0;
|
||||
const urlClientInstance = urlClient();
|
||||
urlClientInstance.setRequestHandler('elicitation/create', async () => {
|
||||
urlHandlerInvoked++;
|
||||
return { action: 'accept' };
|
||||
});
|
||||
|
||||
{
|
||||
await using _ = await wire(transport, makeServer, urlClientInstance);
|
||||
|
||||
const urlResult = await urlClientInstance.callTool({ name: 'ask', arguments: {} });
|
||||
expect(urlResult.content).toEqual([{ type: 'text', text: `error:${ProtocolErrorCode.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('elicitation/create', 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('elicitation/create', 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 (error) {
|
||||
if (!(error instanceof ProtocolError)) throw error;
|
||||
return { content: [{ type: 'text', text: `rejected:${error.code}:${error.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('elicitation/create', async () => {
|
||||
const content = invalidContents[handlerInvoked];
|
||||
handlerInvoked++;
|
||||
return { action: 'accept', content };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const schemaRejection = expect.stringMatching(
|
||||
new RegExp(`^rejected:${ProtocolErrorCode.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('tools/list', () => ({ tools: [{ name: 'profile', inputSchema: { type: 'object' } }] }));
|
||||
s.setRequestHandler('tools/call', async (_req, ctx) => {
|
||||
try {
|
||||
await ctx.mcpReq.send(
|
||||
{
|
||||
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' } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
specTypeSchemas.ElicitResult
|
||||
);
|
||||
return { content: [{ type: 'text', text: 'should-not-reach' }] };
|
||||
} catch (error) {
|
||||
if (!(error instanceof ProtocolError)) throw error;
|
||||
return { content: [{ type: 'text', text: `error:${error.code}` }] };
|
||||
}
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
let handlerInvoked = 0;
|
||||
const client = formClient();
|
||||
client.setRequestHandler('elicitation/create', 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:${ProtocolErrorCode.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('elicitation/create', 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('elicitation/create', 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('elicitation/create', 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('elicitation/create', 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 }, ctx) => {
|
||||
const notify = s.server.createElicitationCompletionNotifier(elicitationId, { relatedRequestId: ctx.mcpReq.id });
|
||||
await notify();
|
||||
return { content: [{ type: 'text', text: 'notified' }] };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = urlClient();
|
||||
client.setNotificationHandler('notifications/elicitation/complete', 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(error => error);
|
||||
if (!(err instanceof UrlElicitationRequiredError)) throw new Error(`expected UrlElicitationRequiredError, got ${err}`);
|
||||
const pending = err.elicitations[0];
|
||||
if (pending === undefined) throw new Error('expected one pending elicitation');
|
||||
const elicitationId = pending.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 }, ctx) => {
|
||||
const tx = s.server.transport;
|
||||
if (!tx) throw new Error('server transport not connected');
|
||||
await tx.send(
|
||||
{ jsonrpc: '2.0', method: 'notifications/elicitation/complete', params: { elicitationId } },
|
||||
{ relatedRequestId: ctx.mcpReq.id }
|
||||
);
|
||||
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('notifications/elicitation/complete', 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(error => error);
|
||||
if (!(err instanceof UrlElicitationRequiredError)) throw new Error(`expected UrlElicitationRequiredError, got ${err}`);
|
||||
const seenPending = err.elicitations[0];
|
||||
if (seenPending === undefined) throw new Error('expected one pending elicitation');
|
||||
const seenId = seenPending.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(error => error);
|
||||
if (!(err instanceof UrlElicitationRequiredError)) throw new Error(`expected UrlElicitationRequiredError, got ${err}`);
|
||||
expect(err.code).toBe(ProtocolErrorCode.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('elicitation/create', 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('tools/list', () => ({ tools: [{ name: 'auth', inputSchema: { type: 'object' } }] }));
|
||||
s.setRequestHandler('tools/call', async (_req, ctx) => {
|
||||
try {
|
||||
await ctx.mcpReq.send(
|
||||
{
|
||||
method: 'elicitation/create',
|
||||
params: { mode: 'url', message: 'Sign in', elicitationId: 'mismatch-1', url: 'https://example.com/auth' }
|
||||
},
|
||||
specTypeSchemas.ElicitResult
|
||||
);
|
||||
return { content: [{ type: 'text', text: 'should-not-reach' }] };
|
||||
} catch (error) {
|
||||
if (!(error instanceof ProtocolError)) throw error;
|
||||
return { content: [{ type: 'text', text: `error:${error.code}` }] };
|
||||
}
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
let handlerInvoked = 0;
|
||||
const client = formClient();
|
||||
client.setRequestHandler('elicitation/create', 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:${ProtocolErrorCode.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 (error) {
|
||||
return { content: [{ type: 'text', text: `refused:${error instanceof Error ? error.message : error}` }] };
|
||||
}
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = formClient();
|
||||
client.setRequestHandler('elicitation/create', 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 (error) {
|
||||
return { content: [{ type: 'text', text: `refused:${error instanceof Error ? error.message : error}` }] };
|
||||
}
|
||||
});
|
||||
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 (error) {
|
||||
return { content: [{ type: 'text', text: `refused:${error instanceof Error ? error.message : error}` }] };
|
||||
}
|
||||
});
|
||||
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([]);
|
||||
});
|
||||
200
test/e2e/scenarios/errors.test.ts
Normal file
200
test/e2e/scenarios/errors.test.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
/**
|
||||
* Error-surface tests: which error class reaches the caller for each failure mode.
|
||||
*
|
||||
* The SDK splits failures into local {@link SdkError}s that never cross the wire
|
||||
* (timeouts, capability gating), wire-level {@link ProtocolError}s carrying the
|
||||
* JSON-RPC error code from an error response, and {@link SdkHttpError}s for HTTP
|
||||
* transport failures exposing the status code. Each test builds its own server
|
||||
* (factory) and client, triggers exactly one failure mode and asserts the class,
|
||||
* code and carried details of the rejection.
|
||||
*/
|
||||
|
||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
import type { JSONRPCMessage } from '@modelcontextprotocol/server';
|
||||
import {
|
||||
isJSONRPCErrorResponse,
|
||||
McpServer,
|
||||
ProtocolError,
|
||||
ProtocolErrorCode,
|
||||
SdkError,
|
||||
SdkErrorCode,
|
||||
SdkHttpError,
|
||||
Server
|
||||
} from '@modelcontextprotocol/server';
|
||||
import { expect } from 'vitest';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { hostPerSession, tapWire, wire } from '../helpers/index.js';
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
import type { TestArgs } from '../types.js';
|
||||
|
||||
const newClient = () => new Client({ name: 'c', version: '0' });
|
||||
|
||||
/** Awaits a promise that must reject and returns the rejection value for exact assertions. */
|
||||
async function rejectionOf(promise: Promise<unknown>): Promise<unknown> {
|
||||
return promise.then(
|
||||
() => {
|
||||
throw new Error('expected the promise to reject');
|
||||
},
|
||||
(error: unknown) => error
|
||||
);
|
||||
}
|
||||
|
||||
verifies('errors:timeout:sdkerror-request-timeout', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool(
|
||||
'generate_report',
|
||||
{ inputSchema: z.object({ source: z.string() }) },
|
||||
async () =>
|
||||
new Promise(() => {
|
||||
/* never resolves: the per-request timeout must fire before any response exists */
|
||||
})
|
||||
);
|
||||
return s;
|
||||
};
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const rejection = await rejectionOf(
|
||||
client.callTool({ name: 'generate_report', arguments: { source: 'sales-q3.csv' } }, { timeout: 100 })
|
||||
);
|
||||
|
||||
expect(rejection).toBeInstanceOf(SdkError);
|
||||
if (!(rejection instanceof SdkError)) throw new Error('rejection is not an SdkError');
|
||||
expect(rejection.code).toBe(SdkErrorCode.RequestTimeout);
|
||||
expect(rejection.message).toMatch(/timed out/i);
|
||||
// The configured timeout value rides on the error, not just a generic message.
|
||||
expect(rejection.data).toEqual({ timeout: 100 });
|
||||
// A timeout is a local SDK error, not a JSON-RPC error response from the server.
|
||||
expect(rejection).not.toBeInstanceOf(ProtocolError);
|
||||
});
|
||||
|
||||
verifies('errors:capability:sdkerror-capability-not-supported', async ({ transport }: TestArgs) => {
|
||||
let server: Server | undefined;
|
||||
const makeServer = () => {
|
||||
// Server-side gating of outbound requests on the client's declared capabilities is opt-in.
|
||||
server = new Server({ name: 's', version: '0' }, { capabilities: {}, enforceStrictCapabilities: true });
|
||||
return server;
|
||||
};
|
||||
// Client declares no sampling capability and registers no sampling handler.
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
if (!server) throw new Error('server not created');
|
||||
const serverTx = server.transport;
|
||||
if (!serverTx) throw new Error('server transport not connected');
|
||||
const serverOutbound: JSONRPCMessage[] = [];
|
||||
const origSend = serverTx.send.bind(serverTx);
|
||||
serverTx.send = async (m, opts) => {
|
||||
serverOutbound.push(m);
|
||||
return origSend(m, opts);
|
||||
};
|
||||
|
||||
const rejection = await rejectionOf(
|
||||
server.createMessage({
|
||||
messages: [{ role: 'user', content: { type: 'text', text: 'Summarize the latest build log.' } }],
|
||||
maxTokens: 100
|
||||
})
|
||||
);
|
||||
|
||||
expect(rejection).toBeInstanceOf(SdkError);
|
||||
// Rejected locally: not a JSON-RPC error that crossed the wire.
|
||||
expect(rejection).not.toBeInstanceOf(ProtocolError);
|
||||
if (!(rejection instanceof SdkError)) throw new Error('rejection is not an SdkError');
|
||||
expect(rejection.code).toBe(SdkErrorCode.CapabilityNotSupported);
|
||||
expect(rejection.message).toMatch(/does not support sampling/i);
|
||||
|
||||
// No sampling/createMessage request was ever handed to the transport.
|
||||
expect(serverOutbound.filter(m => 'method' in m && m.method === 'sampling/createMessage')).toEqual([]);
|
||||
// The session stays healthy after the local rejection.
|
||||
await expect(client.ping()).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
verifies('errors:wire:protocolerror-invalid-params', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool(
|
||||
'get_weather',
|
||||
{ inputSchema: z.object({ latitude: z.number(), longitude: z.number() }) },
|
||||
({ latitude, longitude }) => ({ content: [{ type: 'text', text: `Sunny at ${latitude},${longitude}` }] })
|
||||
);
|
||||
return s;
|
||||
};
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const tap = tapWire(client);
|
||||
|
||||
// Calling a tool the server never registered produces a -32602 error response server-side.
|
||||
const rejection = await rejectionOf(client.callTool({ name: 'get_forecast', arguments: { latitude: 48.1, longitude: 11.6 } }));
|
||||
|
||||
expect(rejection).toBeInstanceOf(ProtocolError);
|
||||
// Wire-level JSON-RPC errors and local SDK errors are distinct hierarchies.
|
||||
expect(rejection).not.toBeInstanceOf(SdkError);
|
||||
if (!(rejection instanceof ProtocolError)) throw new Error('rejection is not a ProtocolError');
|
||||
expect(rejection.code).toBe(ProtocolErrorCode.InvalidParams);
|
||||
expect(rejection.code).toBe(-32_602);
|
||||
expect(rejection.message).toMatch(/tool get_forecast not found/i);
|
||||
|
||||
// The error genuinely arrived as a JSON-RPC error response carrying the same code and message.
|
||||
const errorResponses = tap.received.filter(m => isJSONRPCErrorResponse(m));
|
||||
expect(errorResponses).toHaveLength(1);
|
||||
const wireError = errorResponses[0];
|
||||
if (!wireError) throw new Error('no JSON-RPC error response captured');
|
||||
expect(wireError.error.code).toBe(-32_602);
|
||||
expect(wireError.error.message).toBe(rejection.message);
|
||||
});
|
||||
|
||||
verifies('errors:http:sdkhttperror-status', async (_: TestArgs) => {
|
||||
// HTTP-hosting specific: builds its own StreamableHTTPClientTransport, so the matrix transport arg is unused.
|
||||
|
||||
// An endpoint that is down entirely: the initialize POST gets a plain 500.
|
||||
const downClient = newClient();
|
||||
const downTransport = new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), {
|
||||
fetch: async () => new Response('upstream exploded', { status: 500, statusText: 'Internal Server Error' })
|
||||
});
|
||||
try {
|
||||
const rejection = await rejectionOf(downClient.connect(downTransport));
|
||||
|
||||
expect(rejection).toBeInstanceOf(SdkHttpError);
|
||||
if (!(rejection instanceof SdkHttpError)) throw new Error('rejection is not an SdkHttpError');
|
||||
expect(rejection.status).toBe(500);
|
||||
expect(rejection.statusText).toBe('Internal Server Error');
|
||||
} finally {
|
||||
await downClient.close();
|
||||
}
|
||||
|
||||
// A healthy session whose endpoint then starts answering POSTs with 503.
|
||||
const handle = hostPerSession(() => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({ content: [{ type: 'text', text }] }));
|
||||
return s;
|
||||
});
|
||||
let postFailureStatus: number | undefined;
|
||||
const flakyFetch = async (url: URL | string, init?: RequestInit) => {
|
||||
if (postFailureStatus !== undefined && init?.method === 'POST') {
|
||||
return new Response('Service Unavailable', { status: postFailureStatus, statusText: 'Service Unavailable' });
|
||||
}
|
||||
return handle.handleRequest(new Request(url, init));
|
||||
};
|
||||
const client = newClient();
|
||||
const clientTransport = new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), { fetch: flakyFetch });
|
||||
try {
|
||||
await client.connect(clientTransport);
|
||||
// The session works before the outage.
|
||||
const ok = await client.callTool({ name: 'echo', arguments: { text: 'before outage' } });
|
||||
expect(ok.content).toEqual([{ type: 'text', text: 'before outage' }]);
|
||||
|
||||
postFailureStatus = 503;
|
||||
const rejection = await rejectionOf(client.callTool({ name: 'echo', arguments: { text: 'during outage' } }));
|
||||
|
||||
expect(rejection).toBeInstanceOf(SdkHttpError);
|
||||
if (!(rejection instanceof SdkHttpError)) throw new Error('rejection is not an SdkHttpError');
|
||||
expect(rejection.status).toBe(503);
|
||||
expect(rejection.statusText).toBe('Service Unavailable');
|
||||
} finally {
|
||||
await client.close();
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue