Port the end-to-end test suite to v2 (#2179)

This commit is contained in:
Felix Weinberger 2026-05-29 12:04:45 +01:00 • committed by GitHub
commit 1998a186ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 20108 additions and 1 deletions

File diff suppressed because it is too large Load diff

View 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' }
);

View 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' }]);
});

View 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' }]);
});

View 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([]);
});

View 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();
}
});

View file

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

View file

@ -0,0 +1,167 @@
/**
* Self-contained test bodies for the ServerContext conveniences handed to
* request handlers: `ctx.mcpReq.log()`, `ctx.mcpReq.elicitInput()`,
* `ctx.mcpReq.requestSampling()`, and — under HTTP hosting — `ctx.http.req`
* exposing the incoming request's Fetch Headers.
*
* Each body builds its own server (via factory) and client, wires them with
* {@link wire} (or hosts directly with {@link hostPerSession} where the HTTP
* hosting layer is itself the subject), and asserts.
*/
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import type { CreateMessageRequest, ElicitRequest, ElicitRequestFormParams, LoggingLevel } from '@modelcontextprotocol/server';
import { McpServer } from '@modelcontextprotocol/server';
import { expect, vi } from 'vitest';
import { z } from 'zod/v4';
import { hostPerSession, wire } from '../helpers/index.js';
import { verifies } from '../helpers/verifies.js';
import type { TestArgs } from '../types.js';
verifies('mcpserver:context:log-from-handler', async ({ transport }: TestArgs) => {
let releaseHandler!: () => void;
const handlerGate = new Promise<void>(resolve => {
releaseHandler = resolve;
});
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' }, { capabilities: { logging: {} } });
s.registerTool('emit-log', { inputSchema: z.object({}) }, async (_args, ctx) => {
await ctx.mcpReq.log('info', { msg: 'from-handler' }, 'handler-logger');
// Hold the tool call open until the test has observed the notification, so receipt provably happens mid-call.
await handlerGate;
return { content: [{ type: 'text', text: 'done' }] };
});
return s;
};
const logs: Array<{ level: LoggingLevel; logger?: string; data: unknown }> = [];
const client = new Client({ name: 'c', version: '0' });
client.setNotificationHandler('notifications/message', n => {
logs.push(n.params);
});
await using _ = await wire(transport, makeServer, client);
const inFlightCall = client.callTool({ name: 'emit-log', arguments: {} });
try {
// The handler is parked on the gate, so the tools/call request is still in flight when the log arrives.
await vi.waitFor(() => expect(logs).toHaveLength(1));
expect(logs).toEqual([{ level: 'info', logger: 'handler-logger', data: { msg: 'from-handler' } }]);
} finally {
releaseHandler();
}
const result = await inFlightCall;
expect(result.isError).toBeFalsy();
expect(result.content).toEqual([{ type: 'text', text: 'done' }]);
});
verifies('mcpserver:context:elicit-from-handler', async ({ transport }: TestArgs) => {
const requestedSchema: ElicitRequestFormParams['requestedSchema'] = {
type: 'object',
properties: { color: { type: 'string' } },
required: ['color']
};
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('ask-color', { inputSchema: z.object({}) }, async (_args, ctx) => {
const ans = await ctx.mcpReq.elicitInput({ mode: 'form', message: 'Favorite color?', requestedSchema });
const color = ans.action === 'accept' ? String(ans.content?.color) : '<none>';
return { content: [{ type: 'text', text: `${ans.action}:${color}` }] };
});
return s;
};
const received: ElicitRequest['params'][] = [];
const client = new Client({ name: 'c', version: '0' }, { capabilities: { elicitation: { form: {} } } });
client.setRequestHandler('elicitation/create', async req => {
received.push(req.params);
return { action: 'accept', content: { color: 'teal' } };
});
await using _ = await wire(transport, makeServer, client);
const result = await client.callTool({ name: 'ask-color', arguments: {} });
expect(received).toEqual([{ mode: 'form', message: 'Favorite color?', requestedSchema }]);
expect(result.isError).toBeFalsy();
expect(result.content).toEqual([{ type: 'text', text: 'accept:teal' }]);
});
verifies('mcpserver:context:sampling-from-handler', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('summarize', { inputSchema: z.object({ topic: z.string() }) }, async ({ topic }, ctx) => {
const result = await ctx.mcpReq.requestSampling({
messages: [{ role: 'user', content: { type: 'text', text: `Summarize ${topic}` } }],
maxTokens: 50
});
// Without tools in the request the stub client returns a single text block; arrays would mean a tool-use flow.
const text = !Array.isArray(result.content) && result.content.type === 'text' ? result.content.text : '<unexpected>';
return { content: [{ type: 'text', text: `${result.model}|${result.role}|${text}` }] };
});
return s;
};
const received: CreateMessageRequest[] = [];
const client = new Client({ name: 'c', version: '0' }, { capabilities: { sampling: {} } });
client.setRequestHandler('sampling/createMessage', async req => {
received.push(req);
return { model: 'stub-model', role: 'assistant', stopReason: 'endTurn', content: { type: 'text', text: 'a short summary' } };
});
await using _ = await wire(transport, makeServer, client);
const result = await client.callTool({ name: 'summarize', arguments: { topic: 'mcp' } });
expect(received).toHaveLength(1);
const samplingRequest = received[0];
if (samplingRequest === undefined) throw new Error('expected exactly one sampling request');
expect(samplingRequest.method).toBe('sampling/createMessage');
expect(samplingRequest.params.messages).toEqual([{ role: 'user', content: { type: 'text', text: 'Summarize mcp' } }]);
expect(samplingRequest.params.maxTokens).toBe(50);
expect(result.isError).toBeFalsy();
expect(result.content).toEqual([{ type: 'text', text: 'stub-model|assistant|a short summary' }]);
});
verifies('hosting:context:web-request-headers', async (_args: TestArgs) => {
const PROBE_HEADER = 'x-e2e-probe';
const PROBE_VALUE = 'probe-7d1f';
const seenByTool: Array<{ isFetchHeaders: boolean; probe: string | null }> = [];
const mcpHost = hostPerSession(() => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('read-probe-header', { inputSchema: z.object({}) }, (_toolArgs, ctx) => {
const headers = ctx.http?.req?.headers;
seenByTool.push({
isFetchHeaders: headers instanceof Headers,
probe: headers instanceof Headers ? headers.get(PROBE_HEADER) : null
});
return { content: [{ type: 'text', text: headers?.get(PROBE_HEADER) ?? '<missing>' }] };
});
return s;
});
const client = new Client({ name: 'c', version: '0' });
const httpTransport = new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), {
fetch: (url, init) => mcpHost.handleRequest(new Request(url, init)),
requestInit: { headers: { [PROBE_HEADER]: PROBE_VALUE } }
});
try {
await client.connect(httpTransport);
const result = await client.callTool({ name: 'read-probe-header', arguments: {} });
// The custom header set on the client transport is readable as Fetch Headers inside the handler.
expect(seenByTool).toEqual([{ isFetchHeaders: true, probe: PROBE_VALUE }]);
expect(result.isError).toBeFalsy();
expect(result.content).toEqual([{ type: 'text', text: PROBE_VALUE }]);
} finally {
await client.close();
await mcpHost.close();
}
});

View file

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

View file

@ -0,0 +1,491 @@
/**
* Self-contained test bodies for Express-bound hosting surfaces.
*
* These tests cover the SDK's Express-bound surface (bearer-token middleware,
* OAuth metadata router, host validation, createMcpExpressApp) over real HTTP —
* the layer a server operator deploys and remote clients depend on; Client/Server
* are not the subject.
*
* The SDK's requireBearerAuth, mcpAuthMetadataRouter, and host-header validation
* middleware are Express RequestHandlers; they cannot be exercised with
* in-process Web-standard Request/Response. These tests build real Express apps,
* listen on ephemeral ports (127.0.0.1), drive them with fetch(), and assert
* exact HTTP status + header + body shapes.
*
* Function names mirror the requirement id in camelCase. NO casts, exact
* assertions, closure recorders outside factories (for stateless compat),
* minimal comments, every server closed in finally.
*/
import { randomUUID } from 'node:crypto';
import http from 'node:http';
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { createMcpExpressApp, mcpAuthMetadataRouter, requireBearerAuth } from '@modelcontextprotocol/express';
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
import type { OAuthMetadata } from '@modelcontextprotocol/server';
import { McpServer, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
import type { Express, RequestHandler } from 'express';
import express from 'express';
import { expect } from 'vitest';
import { z } from 'zod/v4';
import { startExpressMinimal, startExpressWithHostValidation } from '../helpers/express.js';
import { verifies } from '../helpers/verifies.js';
import type { TestArgs } from '../types.js';
const RESOURCE_METADATA_URL = 'https://mcp.example.com/.well-known/oauth-protected-resource';
const VALID_TOKEN = 'analytics-dashboard-token';
const EXPIRED_TOKEN = 'expired-access-token';
const MALFORMED_TOKEN = 'not-a-valid-jwt';
/**
* POST `body` to `url` via `node:http`, forcing `Host: <host>`.
* Unlike undici fetch(), node:http sends caller-supplied Host header verbatim.
*/
function postWithHost(url: URL, host: string, body: string): Promise<{ status: number; body: string; headers: http.IncomingHttpHeaders }> {
return new Promise((resolve, reject) => {
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: 'POST',
headers: {
Host: host,
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'Content-Length': Buffer.byteLength(body)
}
},
res => {
let data = '';
res.setEncoding('utf8');
res.on('data', chunk => (data += chunk));
res.on('end', () => resolve({ status: res.statusCode ?? 0, body: data, headers: res.headers }));
res.on('error', reject);
}
);
req.on('error', reject);
req.end(body);
});
}
verifies('hosting:auth:missing-401', async (_args: TestArgs) => {
const verifier = { verifyAccessToken: async (_token: string) => ({ token: '', clientId: 'test', scopes: [], expiresAt: 1e12 }) };
await using host = await startExpressMinimal(requireBearerAuth({ verifier, resourceMetadataUrl: RESOURCE_METADATA_URL }));
const res = await fetch(new URL('/mcp', host.baseUrl), {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
});
expect(res.status).toBe(401);
const wwwAuth = res.headers.get('www-authenticate');
expect(wwwAuth).toBeTruthy();
expect(wwwAuth).toMatch(/^Bearer\b/i);
expect(wwwAuth).toContain('error="invalid_token"');
expect(wwwAuth).toContain(`resource_metadata="${RESOURCE_METADATA_URL}"`);
const body = (await res.json()) as { error?: string };
expect(body.error).toBe('invalid_token');
});
verifies('hosting:auth:invalid-401', async (_args: TestArgs) => {
const verifier = {
verifyAccessToken: async (token: string) => {
if (token === MALFORMED_TOKEN) throw new OAuthError(OAuthErrorCode.InvalidToken, 'Token verification failed');
return { token, clientId: 'test', scopes: [], expiresAt: 1e12 };
}
};
await using host = await startExpressMinimal(requireBearerAuth({ verifier }));
const res = await fetch(new URL('/mcp', host.baseUrl), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: `Bearer ${MALFORMED_TOKEN}`
},
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
});
expect(res.status).toBe(401);
const wwwAuth = res.headers.get('www-authenticate');
expect(wwwAuth).toBeTruthy();
expect(wwwAuth).toMatch(/^Bearer\b/i);
expect(wwwAuth).toContain('error="invalid_token"');
});
verifies('hosting:auth:expired-401', async (_args: TestArgs) => {
const PAST_EXPIRY = 1;
const verifier = {
verifyAccessToken: async (token: string) => ({
token,
clientId: 'test-client',
scopes: [],
expiresAt: token === EXPIRED_TOKEN ? PAST_EXPIRY : Date.now() / 1000 + 3600
})
};
await using host = await startExpressMinimal(requireBearerAuth({ verifier }));
const res = await fetch(new URL('/mcp', host.baseUrl), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: `Bearer ${EXPIRED_TOKEN}`
},
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
});
expect(res.status).toBe(401);
const wwwAuth = res.headers.get('www-authenticate');
expect(wwwAuth).toBeTruthy();
expect(wwwAuth).toContain('error="invalid_token"');
});
verifies('hosting:auth:scope-403', async (_args: TestArgs) => {
const verifier = {
verifyAccessToken: async (token: string) => ({
token,
clientId: 'test-client',
scopes: token === VALID_TOKEN ? ['mcp:tools:read'] : ['mcp:tools:call'],
expiresAt: Date.now() / 1000 + 3600
})
};
await using host = await startExpressMinimal(
requireBearerAuth({
verifier,
requiredScopes: ['mcp:tools:read', 'mcp:tools:call'],
resourceMetadataUrl: RESOURCE_METADATA_URL
})
);
const res = await fetch(new URL('/mcp', host.baseUrl), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: `Bearer ${VALID_TOKEN}`
},
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
});
expect(res.status).toBe(403);
const wwwAuth = res.headers.get('www-authenticate');
expect(wwwAuth).toBeTruthy();
expect(wwwAuth).toMatch(/^Bearer\b/i);
expect(wwwAuth).toContain('error="insufficient_scope"');
expect(wwwAuth).toContain('scope="mcp:tools:read mcp:tools:call"');
expect(wwwAuth).toContain(`resource_metadata="${RESOURCE_METADATA_URL}"`);
const body = (await res.json()) as { error?: string };
expect(body.error).toBe('insufficient_scope');
});
verifies('hosting:auth:aud-validation', async (_args: TestArgs) => {
const SERVER_RESOURCE_ID = 'https://mcp.example.com/api';
const WRONG_AUDIENCE = 'https://other.example.com/api';
const verifier = {
verifyAccessToken: async (token: string) => {
const aud = token === 'wrong-aud-token' ? WRONG_AUDIENCE : SERVER_RESOURCE_ID;
return {
token,
clientId: 'test-client',
scopes: [],
expiresAt: Date.now() / 1000 + 3600,
resource: new URL(aud)
};
}
};
const app = express();
app.use(express.json());
app.use(requireBearerAuth({ verifier, resourceMetadataUrl: SERVER_RESOURCE_ID }));
app.post('/mcp', (_req, res) => {
res.json({ jsonrpc: '2.0', id: 1, result: { ok: true } });
});
await using host = await startExpressMinimal(app);
const wrongAud = await fetch(new URL('/mcp', host.baseUrl), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: 'Bearer wrong-aud-token'
},
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
});
expect(wrongAud.status).toBeGreaterThanOrEqual(401);
expect(wrongAud.status).toBeLessThanOrEqual(403);
const wwwAuth = wrongAud.headers.get('www-authenticate');
expect(wwwAuth).toBeTruthy();
expect(wwwAuth).toMatch(/^Bearer\b/i);
expect(wwwAuth).toContain('error=');
const body = (await wrongAud.json()) as { error?: string };
expect(body.error).toBeTruthy();
});
verifies('hosting:auth:metadata-endpoints', async (_args: TestArgs) => {
const issuer = new URL('https://auth.example.com');
const oauthMetadata: OAuthMetadata = {
issuer: issuer.href,
authorization_endpoint: new URL('/authorize', issuer).href,
token_endpoint: new URL('/token', issuer).href,
response_types_supported: ['code']
};
const app = express();
app.use(express.json());
app.use(
mcpAuthMetadataRouter({
oauthMetadata,
resourceServerUrl: new URL('https://mcp.example.com')
})
);
await using host = await startExpressMinimal(app);
const asMetadata = await fetch(new URL('/.well-known/oauth-authorization-server', host.baseUrl));
expect(asMetadata.status).toBe(200);
const asBody = (await asMetadata.json()) as { issuer?: string; authorization_endpoint?: string };
expect(asBody.issuer).toBe(issuer.href);
expect(asBody.authorization_endpoint).toBeTruthy();
const prmMetadata = await fetch(new URL('/.well-known/oauth-protected-resource', host.baseUrl));
expect(prmMetadata.status).toBe(200);
const prmBody = (await prmMetadata.json()) as { resource?: string; authorization_servers?: string[] };
expect(prmBody.authorization_servers).toContain(issuer.href);
});
verifies('hosting:auth:prm:authorization-servers-field', async (_args: TestArgs) => {
const issuer = new URL('https://auth.example.com');
const oauthMetadata: OAuthMetadata = {
issuer: issuer.href,
authorization_endpoint: new URL('/authorize', issuer).href,
token_endpoint: new URL('/token', issuer).href,
response_types_supported: ['code']
};
const app = express();
app.use(
mcpAuthMetadataRouter({
oauthMetadata,
resourceServerUrl: new URL('https://mcp.example.com')
})
);
await using host = await startExpressMinimal(app);
const res = await fetch(new URL('/.well-known/oauth-protected-resource', host.baseUrl));
expect(res.status).toBe(200);
const body = (await res.json()) as { authorization_servers?: string[] };
expect(body.authorization_servers).toBeInstanceOf(Array);
expect(body.authorization_servers?.length).toBeGreaterThan(0);
expect(body.authorization_servers).toContain(issuer.href);
});
verifies('hosting:http:host-validation-middleware', async (_args: TestArgs) => {
const handler: RequestHandler = (_req, res) => {
res.json({ ok: true });
};
await using host = await startExpressWithHostValidation(['localhost', '127.0.0.1'], handler);
const good = await fetch(new URL('/test', host.baseUrl));
expect(good.status).toBe(200);
const spoofed = await postWithHost(new URL('/test', host.baseUrl), 'evil.example.com', JSON.stringify({ test: 'data' }));
expect(spoofed.status).toBe(403);
const body = JSON.parse(spoofed.body) as { error?: { message?: string } };
expect(body.error?.message).toMatch(/Invalid Host/i);
});
verifies('hosting:express-app-helper', async (_args: TestArgs) => {
const app = createMcpExpressApp();
app.post('/mcp', (_req, res) => {
res.json({ jsonrpc: '2.0', id: 1, result: { ok: true } });
});
await using host = await startExpressMinimal(app);
expect(host.baseUrl.hostname).toBe('127.0.0.1');
const good = await fetch(new URL('/mcp', host.baseUrl), {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
});
expect(good.status).toBe(200);
const spoofed = await postWithHost(
new URL('/mcp', host.baseUrl),
'evil.example.com',
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
);
expect(spoofed.status).toBe(403);
const body = JSON.parse(spoofed.body) as { error?: { message?: string } };
expect(body.error?.message).toMatch(/Invalid Host/i);
});
verifies('hosting:auth:query-token-ignored', async (_args: TestArgs) => {
const verifier = {
verifyAccessToken: async (token: string) => {
if (token !== VALID_TOKEN) {
throw new OAuthError(OAuthErrorCode.InvalidToken, 'Token verification failed');
}
return { token, clientId: 'analytics-client', scopes: [], expiresAt: Date.now() / 1000 + 3600 };
}
};
const app = express();
app.use(express.json());
app.use(requireBearerAuth({ verifier, resourceMetadataUrl: RESOURCE_METADATA_URL }));
app.post('/mcp', (_req, res) => {
res.json({ jsonrpc: '2.0', id: 1, result: { ok: true } });
});
await using host = await startExpressMinimal(app);
const queryUrl = new URL('/mcp', host.baseUrl);
queryUrl.searchParams.set('access_token', VALID_TOKEN);
const queryRes = await fetch(queryUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
});
expect(queryRes.status).toBe(401);
const wwwAuth = queryRes.headers.get('www-authenticate');
expect(wwwAuth).toBeTruthy();
expect(wwwAuth).toMatch(/^Bearer\b/i);
expect(wwwAuth).toContain('error="invalid_token"');
const queryBody = (await queryRes.json()) as { error?: string };
expect(queryBody.error).toBe('invalid_token');
// Control: the same token in the Authorization header authenticates, proving only the query-string placement was ignored.
const headerRes = await fetch(new URL('/mcp', host.baseUrl), {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Bearer ${VALID_TOKEN}` },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
});
expect(headerRes.status).toBe(200);
});
/** Listen `app` (already fully configured by the adapter under test) on an ephemeral 127.0.0.1 port; callers close() in finally. */
function listenExpressApp(app: Express): Promise<{ baseUrl: URL; close: () => Promise<void> }> {
return new Promise((resolve, reject) => {
const server = app.listen(0, '127.0.0.1', () => {
const addr = server.address();
if (!addr || typeof addr === 'string') {
reject(new Error(`listen failed: ${String(addr)}`));
return;
}
resolve({
baseUrl: new URL(`http://127.0.0.1:${addr.port}`),
close: () =>
new Promise<void>((res, rej) => {
server.close(err => (err ? rej(err) : res()));
})
});
});
server.on('error', reject);
});
}
verifies('hosting:express:adapter-basic-flow', async (_args: TestArgs) => {
const mcpServer = new McpServer({ name: 'express-adapter-server', version: '1.0.0' });
mcpServer.registerTool(
'lookup-order-status',
{ description: 'Look up the shipping status of an order.', inputSchema: z.object({ orderId: z.string() }) },
({ orderId }) => ({ content: [{ type: 'text', text: `Order ${orderId} is in transit.` }] })
);
const serverTransport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
await mcpServer.connect(serverTransport);
const app = createMcpExpressApp();
app.post('/mcp', async (req, res) => {
await serverTransport.handleRequest(req, res, req.body);
});
const { baseUrl, close } = await listenExpressApp(app);
const client = new Client({ name: 'express-adapter-client', version: '1.0.0' });
try {
await client.connect(new StreamableHTTPClientTransport(new URL('/mcp', baseUrl)));
// Initialize completed over the Express-hosted POST route: the negotiated server identity is visible on the client.
expect(client.getServerVersion()).toEqual({ name: 'express-adapter-server', version: '1.0.0' });
const result = await client.callTool({ name: 'lookup-order-status', arguments: { orderId: 'ORD-1042' } });
expect(result.content).toEqual([{ type: 'text', text: 'Order ORD-1042 is in transit.' }]);
} finally {
await client.close();
await mcpServer.close();
await close();
}
});
verifies('hosting:express:adapter-host-header-validation', async ({ protocolVersion }: TestArgs) => {
const mcpServer = new McpServer({ name: 'rebind-protected-server', version: '1.0.0' });
// JSON response mode keeps the allowed-host control assertable as a plain JSON initialize result.
const serverTransport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), enableJsonResponse: true });
await mcpServer.connect(serverTransport);
let mcpRouteHits = 0;
const app = createMcpExpressApp();
app.post('/mcp', async (req, res) => {
mcpRouteHits += 1;
await serverTransport.handleRequest(req, res, req.body);
});
const { baseUrl, close } = await listenExpressApp(app);
try {
const initializeBody = JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion, capabilities: {}, clientInfo: { name: 'rebind-client', version: '1.0.0' } }
});
const spoofed = await postWithHost(new URL('/mcp', baseUrl), 'evil.example.com', initializeBody);
expect(spoofed.status).toBe(403);
const spoofedJson: unknown = JSON.parse(spoofed.body);
expect(spoofedJson).toEqual({
jsonrpc: '2.0',
error: { code: -32_000, message: 'Invalid Host: evil.example.com' },
id: null
});
// Rejected by the default localhost DNS-rebinding middleware before the MCP transport route ever ran.
expect(mcpRouteHits).toBe(0);
// Control: the identical request with the real localhost Host reaches the transport and initializes normally.
const allowed = await postWithHost(new URL('/mcp', baseUrl), `127.0.0.1:${baseUrl.port}`, initializeBody);
expect(allowed.status).toBe(200);
const allowedJson: unknown = JSON.parse(allowed.body);
expect(allowedJson).toMatchObject({
jsonrpc: '2.0',
id: 1,
result: { protocolVersion, serverInfo: { name: 'rebind-protected-server', version: '1.0.0' } }
});
expect(mcpRouteHits).toBe(1);
} finally {
await mcpServer.close();
await close();
}
});

View file

@ -0,0 +1,201 @@
/**
* Self-contained test bodies for the Fastify hosting adapter (@modelcontextprotocol/fastify).
*
* These tests cover createMcpFastifyApp() over real HTTP: the Fastify instance listens on an
* ephemeral 127.0.0.1 port, per-session WebStandardStreamableHTTPServerTransport instances are
* mounted on its /mcp routes via a small Node-to-web-standard Request adapter (Fastify parses
* JSON bodies natively), and a StreamableHTTPClientTransport (or raw HTTP for hostile-Host
* probes) drives it from outside. The hosting adapter is the subject, so the matrix transport
* arg is ignored and every listener, transport, and server is closed in finally.
*/
import { randomUUID } from 'node:crypto';
import http from 'node:http';
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { createMcpFastifyApp } from '@modelcontextprotocol/fastify';
import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { expect } from 'vitest';
import { z } from 'zod/v4';
import { verifies } from '../helpers/verifies.js';
import type { TestArgs } from '../types.js';
function forecastServer(): McpServer {
const s = new McpServer({ name: 'forecast-server', version: '0.3.0' });
s.registerTool(
'get_forecast',
{ description: 'Returns tomorrow’s weather forecast for a city.', inputSchema: z.object({ city: z.string() }) },
({ city }) => ({ content: [{ type: 'text', text: `Forecast for ${city}: sunny, high of 22C.` }] })
);
return s;
}
/** Adapt Fastify's request (Node IncomingMessage plus natively parsed JSON body) to the web-standard Request the transport consumes. */
function toWebRequest(request: FastifyRequest): Request {
const headers = new Headers();
for (const [name, value] of Object.entries(request.headers)) {
// content-length is dropped because the body below is re-serialized from Fastify's parsed JSON
if (name === 'content-length') continue;
if (typeof value === 'string') headers.set(name, value);
else if (Array.isArray(value)) for (const item of value) headers.append(name, item);
}
return new Request(new URL(request.url, `http://${request.headers.host ?? '127.0.0.1'}`), {
method: request.method,
headers,
body: request.body === undefined ? undefined : JSON.stringify(request.body)
});
}
/**
* Mount per-session WebStandard streamable HTTP transports on the app's /mcp routes,
* handing each raw Fastify request to the transport and replying with the web-standard Response.
*/
function mountMcp(app: FastifyInstance, makeServer: () => McpServer): { close(): Promise<void> } {
const sessions = new Map<string, WebStandardStreamableHTTPServerTransport>();
const servers: McpServer[] = [];
const bySession = (request: FastifyRequest): WebStandardStreamableHTTPServerTransport | undefined => {
const sessionId = request.headers['mcp-session-id'];
return typeof sessionId === 'string' ? sessions.get(sessionId) : undefined;
};
app.post('/mcp', async (request, reply) => {
const existing = bySession(request);
if (existing) return reply.send(await existing.handleRequest(toWebRequest(request)));
const tx = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: randomUUID,
onsessioninitialized: id => void sessions.set(id, tx),
onsessionclosed: id => void sessions.delete(id)
});
const server = makeServer();
servers.push(server);
await server.connect(tx);
return reply.send(await tx.handleRequest(toWebRequest(request)));
});
app.get('/mcp', async (request, reply) => {
const existing = bySession(request);
if (existing) return reply.send(await existing.handleRequest(toWebRequest(request)));
return reply
.code(400)
.send({ jsonrpc: '2.0', error: { code: -32_000, message: 'Bad Request: No valid session ID provided' }, id: null });
});
app.delete('/mcp', async (request, reply) => {
const existing = bySession(request);
if (existing) return reply.send(await existing.handleRequest(toWebRequest(request)));
return reply
.code(400)
.send({ jsonrpc: '2.0', error: { code: -32_000, message: 'Bad Request: No valid session ID provided' }, id: null });
});
return {
close: async () => {
for (const server of servers) await server.close();
for (const tx of sessions.values()) await tx.close();
sessions.clear();
}
};
}
/**
* POST `body` to `url` via `node:http`, forcing `Host: <host>`.
* Unlike undici fetch(), node:http sends caller-supplied Host headers verbatim.
*/
function postWithHost(url: URL, host: string, body: string): Promise<{ status: number; body: string }> {
return new Promise((resolve, reject) => {
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: 'POST',
headers: {
Host: host,
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'Content-Length': Buffer.byteLength(body)
}
},
res => {
let data = '';
res.setEncoding('utf8');
res.on('data', chunk => (data += chunk));
res.on('end', () => resolve({ status: res.statusCode ?? 0, body: data }));
res.on('error', reject);
}
);
req.on('error', reject);
req.end(body);
});
}
verifies('hosting:fastify:basic-flow', async (_args: TestArgs) => {
const app = createMcpFastifyApp();
const mounted = mountMcp(app, forecastServer);
const client = new Client({ name: 'fastify-e2e-client', version: '0.1.0' });
try {
const baseUrl = new URL(await app.listen({ port: 0, host: '127.0.0.1' }));
const clientTransport = new StreamableHTTPClientTransport(new URL('/mcp', baseUrl));
await client.connect(clientTransport);
expect(client.getServerVersion()).toEqual({ name: 'forecast-server', version: '0.3.0' });
expect(clientTransport.sessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
const { tools } = await client.listTools();
expect(tools.map(t => ({ name: t.name, description: t.description }))).toEqual([
{ name: 'get_forecast', description: 'Returns tomorrow’s weather forecast for a city.' }
]);
const result = await client.callTool({ name: 'get_forecast', arguments: { city: 'Lisbon' } });
expect(result.content).toEqual([{ type: 'text', text: 'Forecast for Lisbon: sunny, high of 22C.' }]);
} finally {
await client.close();
await mounted.close();
await app.close();
}
});
verifies('hosting:fastify:host-header-validation', async ({ protocolVersion }: TestArgs) => {
let serversBuilt = 0;
const app = createMcpFastifyApp();
const mounted = mountMcp(app, () => {
serversBuilt += 1;
return forecastServer();
});
const client = new Client({ name: 'fastify-e2e-client', version: '0.1.0' });
try {
const baseUrl = new URL(await app.listen({ port: 0, host: '127.0.0.1' }));
const rejected = await postWithHost(
new URL('/mcp', baseUrl),
'evil.example.com',
JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion, capabilities: {}, clientInfo: { name: 'rebinding-probe', version: '0.0.1' } }
})
);
expect(rejected.status).toBe(403);
expect(JSON.parse(rejected.body)).toEqual({
jsonrpc: '2.0',
error: { code: -32_000, message: 'Invalid Host: evil.example.com' },
id: null
});
// Rejected by the onRequest hook before reaching the MCP layer: no server was ever constructed for the hostile request.
expect(serversBuilt).toBe(0);
// Control: the same endpoint accepts a client whose requests carry the localhost Host header (127.0.0.1).
await client.connect(new StreamableHTTPClientTransport(new URL('/mcp', baseUrl)));
const { tools } = await client.listTools();
expect(tools.map(t => t.name)).toEqual(['get_forecast']);
expect(serversBuilt).toBe(1);
} finally {
await client.close();
await mounted.close();
await app.close();
}
});

View file

@ -0,0 +1,193 @@
/**
* Self-contained test bodies for the Hono hosting adapter (@modelcontextprotocol/hono).
*
* These tests cover createMcpHonoApp() over real HTTP: the app is served with
* @hono/node-server on an ephemeral 127.0.0.1 port, WebStandardStreamableHTTPServerTransport
* instances are mounted on the /mcp routes (the documented Hono hosting pattern), and a
* StreamableHTTPClientTransport (or raw HTTP for hostile-Host probes) drives it from outside.
* The hosting adapter is the subject, so the matrix transport arg is ignored and every
* listener, transport, and server is closed in finally.
*/
import { randomUUID } from 'node:crypto';
import http from 'node:http';
import { serve } from '@hono/node-server';
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { createMcpHonoApp } from '@modelcontextprotocol/hono';
import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server';
import type { Hono } from 'hono';
import { expect } from 'vitest';
import { z } from 'zod/v4';
import { verifies } from '../helpers/verifies.js';
import type { TestArgs } from '../types.js';
function recipeServer(): McpServer {
const s = new McpServer({ name: 'recipe-server', version: '1.2.0' });
s.registerTool(
'get_recipe',
{ description: 'Returns the preparation steps for a named dish.', inputSchema: z.object({ dish: z.string() }) },
({ dish }) => ({ content: [{ type: 'text', text: `Steps for ${dish}: mix, bake, serve.` }] })
);
return s;
}
/**
* Mount per-session WebStandard streamable HTTP transports on the app's /mcp routes,
* mirroring the documented Hono hosting pattern (handlers pass c.req.raw to the transport).
*/
function mountMcp(app: Hono, makeServer: () => McpServer): { close(): Promise<void> } {
const sessions = new Map<string, WebStandardStreamableHTTPServerTransport>();
const servers: McpServer[] = [];
const bySession = (sessionId: string | undefined) => (sessionId === undefined ? undefined : sessions.get(sessionId));
app.post('/mcp', async c => {
const existing = bySession(c.req.header('mcp-session-id'));
if (existing) return existing.handleRequest(c.req.raw);
const tx = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: randomUUID,
onsessioninitialized: id => void sessions.set(id, tx),
onsessionclosed: id => void sessions.delete(id)
});
const server = makeServer();
servers.push(server);
await server.connect(tx);
return tx.handleRequest(c.req.raw);
});
app.get('/mcp', async c => {
const existing = bySession(c.req.header('mcp-session-id'));
if (existing) return existing.handleRequest(c.req.raw);
return c.json({ jsonrpc: '2.0', error: { code: -32_000, message: 'Bad Request: No valid session ID provided' }, id: null }, 400);
});
app.delete('/mcp', async c => {
const existing = bySession(c.req.header('mcp-session-id'));
if (existing) return existing.handleRequest(c.req.raw);
return c.json({ jsonrpc: '2.0', error: { code: -32_000, message: 'Bad Request: No valid session ID provided' }, id: null }, 400);
});
return {
close: async () => {
for (const server of servers) await server.close();
for (const tx of sessions.values()) await tx.close();
sessions.clear();
}
};
}
/** Serve a Hono app on an ephemeral 127.0.0.1 port via @hono/node-server, resolving once it is listening. */
function listenHono(app: Hono): Promise<{ baseUrl: URL; close(): Promise<void> }> {
return new Promise(resolve => {
const server = serve({ fetch: app.fetch, hostname: '127.0.0.1', port: 0 }, info => {
resolve({
baseUrl: new URL(`http://127.0.0.1:${info.port}`),
close: () =>
new Promise<void>((res, rej) => {
server.close(err => (err ? rej(err) : res()));
})
});
});
});
}
/**
* POST `body` to `url` via `node:http`, forcing `Host: <host>`.
* Unlike undici fetch(), node:http sends caller-supplied Host headers verbatim.
*/
function postWithHost(url: URL, host: string, body: string): Promise<{ status: number; body: string }> {
return new Promise((resolve, reject) => {
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: 'POST',
headers: {
Host: host,
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'Content-Length': Buffer.byteLength(body)
}
},
res => {
let data = '';
res.setEncoding('utf8');
res.on('data', chunk => (data += chunk));
res.on('end', () => resolve({ status: res.statusCode ?? 0, body: data }));
res.on('error', reject);
}
);
req.on('error', reject);
req.end(body);
});
}
verifies('hosting:hono:basic-flow', async (_args: TestArgs) => {
const app = createMcpHonoApp();
const mounted = mountMcp(app, recipeServer);
const listener = await listenHono(app);
const client = new Client({ name: 'hono-e2e-client', version: '0.1.0' });
const clientTransport = new StreamableHTTPClientTransport(new URL('/mcp', listener.baseUrl));
try {
await client.connect(clientTransport);
expect(client.getServerVersion()).toEqual({ name: 'recipe-server', version: '1.2.0' });
expect(clientTransport.sessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
const { tools } = await client.listTools();
expect(tools.map(t => ({ name: t.name, description: t.description }))).toEqual([
{ name: 'get_recipe', description: 'Returns the preparation steps for a named dish.' }
]);
const result = await client.callTool({ name: 'get_recipe', arguments: { dish: 'shakshuka' } });
expect(result.content).toEqual([{ type: 'text', text: 'Steps for shakshuka: mix, bake, serve.' }]);
} finally {
await client.close();
await mounted.close();
await listener.close();
}
});
verifies('hosting:hono:host-header-validation', async (_args: TestArgs) => {
let serversBuilt = 0;
const app = createMcpHonoApp();
const mounted = mountMcp(app, () => {
serversBuilt += 1;
return recipeServer();
});
const listener = await listenHono(app);
const client = new Client({ name: 'hono-e2e-client', version: '0.1.0' });
try {
const rejected = await postWithHost(
new URL('/mcp', listener.baseUrl),
'evil.example.com',
JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'rebinding-probe', version: '0.0.1' } }
})
);
expect(rejected.status).toBe(403);
expect(JSON.parse(rejected.body)).toEqual({
jsonrpc: '2.0',
error: { code: -32_000, message: 'Invalid Host: evil.example.com' },
id: null
});
// Rejected before reaching the MCP layer: no server was ever constructed for the hostile request.
expect(serversBuilt).toBe(0);
// Control: the same endpoint accepts a client whose requests carry the localhost Host header (127.0.0.1).
await client.connect(new StreamableHTTPClientTransport(new URL('/mcp', listener.baseUrl)));
const { tools } = await client.listTools();
expect(tools.map(t => t.name)).toEqual(['get_recipe']);
expect(serversBuilt).toBe(1);
} finally {
await client.close();
await mounted.close();
await listener.close();
}
});

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

@ -0,0 +1,228 @@
/**
* Self-contained test bodies for the logging surface.
*
* Each export is a {@link TestCase}: it builds its own server (via a factory),
* builds its own client, wires them with {@link wire}, and asserts. There are
* no shared fixture imports; helpers local to multiple bodies live at the top
* of this file.
*
* Function names mirror the requirement id in camelCase; a `Raw` suffix marks
* a low-level {@link Server} variant where the behavior under test differs by
* tier.
*/
import { Client } from '@modelcontextprotocol/client';
import type { LoggingLevel } from '@modelcontextprotocol/server';
import { isJSONRPCRequest, McpServer, ProtocolError, ProtocolErrorCode, Server, specTypeSchemas } from '@modelcontextprotocol/server';
import { expect, vi } 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';
const ALL_LEVELS: LoggingLevel[] = ['debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency'];
/** Plain client with no extra capabilities declared. */
const newClient = () => new Client({ name: 'c', version: '0' });
/** McpServer factory that declares logging capability and can emit logs. */
function loggingServer(): McpServer {
const s = new McpServer({ name: 's', version: '0' }, { capabilities: { logging: {} } });
s.registerTool('ping', { inputSchema: z.object({}) }, () => ({ content: [{ type: 'text', text: 'pong' }] }));
return s;
}
verifies('logging:capability:declared', async ({ transport }: TestArgs) => {
const client = newClient();
await using _ = await wire(transport, loggingServer, client);
expect(client.getServerCapabilities()?.logging).toEqual({});
});
verifies('logging:message:fields', async ({ transport }: TestArgs) => {
const logs: Array<{ level: LoggingLevel; logger?: string; data: unknown }> = [];
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' }, { capabilities: { logging: {} } });
s.registerTool('emit-logs', { inputSchema: z.object({ withLogger: z.boolean().optional() }) }, async ({ withLogger }, ctx) => {
await (withLogger
? ctx.mcpReq.notify({
method: 'notifications/message',
params: { level: 'info', logger: 'test-logger', data: 'with-logger' }
})
: ctx.mcpReq.notify({
method: 'notifications/message',
params: { level: 'warning', data: 'without-logger' }
}));
for (const level of ALL_LEVELS) {
await ctx.mcpReq.notify({
method: 'notifications/message',
params: { level, logger: 'sweep', data: `level-${level}` }
});
}
return { content: [{ type: 'text', text: 'done' }] };
});
return s;
};
const client = newClient();
client.setNotificationHandler('notifications/message', n => {
logs.push(n.params);
});
await using _ = await wire(transport, makeServer, client);
await client.setLoggingLevel('debug');
await client.callTool({ name: 'emit-logs', arguments: { withLogger: true } });
await client.callTool({ name: 'emit-logs', arguments: { withLogger: false } });
const sweep = ALL_LEVELS.map(level => ({ level, logger: 'sweep', data: `level-${level}` }));
expect(logs).toEqual([
{ level: 'info', logger: 'test-logger', data: 'with-logger' },
...sweep,
{ level: 'warning', data: 'without-logger' },
...sweep
]);
expect(logs[1 + ALL_LEVELS.length]).not.toHaveProperty('logger');
});
verifies('logging:message:all-levels', async ({ transport }: TestArgs) => {
let server!: McpServer;
const makeServer = () => {
server = new McpServer({ name: 's', version: '0' }, { capabilities: { logging: {} } });
server.registerTool('run-diagnostics', { inputSchema: z.object({}) }, async (_args, ctx) => {
for (const level of ALL_LEVELS) {
await server.sendLoggingMessage({ level, logger: 'diagnostics', data: `a ${level} event` }, ctx.sessionId);
}
return { content: [{ type: 'text', text: 'diagnostics complete' }] };
});
return server;
};
const received: Array<{ level: LoggingLevel; logger?: string; data: unknown }> = [];
const client = newClient();
client.setNotificationHandler('notifications/message', n => {
received.push(n.params);
});
await using _ = await wire(transport, makeServer, client);
// No logging/setLevel is sent: with no threshold configured, every severity must be deliverable.
const result = await client.callTool({ name: 'run-diagnostics', arguments: {} });
expect(result.content).toEqual([{ type: 'text', text: 'diagnostics complete' }]);
await vi.waitFor(() => expect(received).toHaveLength(ALL_LEVELS.length));
expect(received).toEqual(ALL_LEVELS.map(level => ({ level, logger: 'diagnostics', data: `a ${level} event` })));
});
verifies(['logging:message:filtered', 'logging:set-level'], async ({ transport }: TestArgs) => {
let server!: McpServer;
const makeServer = () => {
server = new McpServer({ name: 's', version: '0' }, { capabilities: { logging: {} } });
server.registerTool('emit-all', { inputSchema: z.object({}) }, async (_args, ctx) => {
for (const level of ALL_LEVELS) {
await server.sendLoggingMessage({ level, data: level }, ctx.sessionId);
}
return { content: [{ type: 'text', text: 'done' }] };
});
return server;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
for (const threshold of ALL_LEVELS) {
const thresholdRank = ALL_LEVELS.indexOf(threshold);
const logs: LoggingLevel[] = [];
client.setNotificationHandler('notifications/message', n => {
logs.push(n.params.level);
});
await client.setLoggingLevel(threshold);
await client.callTool({ name: 'emit-all', arguments: {} });
await vi.waitFor(() => expect(logs).toContain('emergency'));
expect(logs).toEqual(ALL_LEVELS.slice(thresholdRank));
}
});
verifies(
'logging:set-level:invalid-level',
async ({ transport }: TestArgs) => {
const client = newClient();
// strictValidation off: the invalid level must actually reach the server for it to reject.
await using _ = await wire(transport, loggingServer, client, { strictValidation: false });
const tap = tapWire(client);
// @ts-expect-error — sending an invalid enum value is the point of this test.
await expect(client.setLoggingLevel('superduper')).rejects.toMatchObject({ code: ProtocolErrorCode.InvalidParams });
expect(ProtocolErrorCode.InvalidParams).toBe(-32_602);
const sent = tap.sent.filter(m => isJSONRPCRequest(m) && m.method === 'logging/setLevel');
expect(sent).toHaveLength(1);
if (!isJSONRPCRequest(sent[0])) throw new Error('expected request');
expect(sent[0].params).toEqual({ level: 'superduper' });
},
{ title: 'mcpserver' }
);
verifies(
'logging:set-level:invalid-level',
async ({ transport }: TestArgs) => {
// The user-shaped manual implementation of spec-correct invalid-level handling:
// a handler registered with a loose params schema so the dispatch-time parse
// succeeds, validating the level itself and throwing InvalidParams.
const applied: LoggingLevel[] = [];
const makeServer = () => {
const s = new Server({ name: 's', version: '0' }, { capabilities: { logging: {} } });
s.setRequestHandler('logging/setLevel', { params: z.object({ level: z.string() }) }, params => {
const parsed = specTypeSchemas.LoggingLevel['~standard'].validate(params.level);
if (parsed.issues !== undefined) {
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid logging level: ${params.level}`);
}
applied.push(parsed.value);
return {};
});
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client, { strictValidation: false });
await client.setLoggingLevel('warning');
expect(applied).toEqual(['warning']);
// @ts-expect-error — sending an invalid enum value is the point of this test.
await expect(client.setLoggingLevel('superduper')).rejects.toMatchObject({
code: ProtocolErrorCode.InvalidParams,
message: expect.stringContaining('superduper')
});
expect(applied).toEqual(['warning']);
},
{ title: 'raw server' }
);
verifies('logging:out-of-band:basic', async ({ transport }: TestArgs) => {
const received: Array<{ level: LoggingLevel; logger?: string; data: unknown }> = [];
let server!: McpServer;
const makeServer = () => {
server = new McpServer({ name: 's', version: '0' }, { capabilities: { logging: {} } });
return server;
};
const client = newClient();
client.setNotificationHandler('notifications/message', n => {
received.push(n.params);
});
await using _ = await wire(transport, makeServer, client);
// No request is in flight: this is a server-initiated, out-of-band notification.
await server.sendLoggingMessage({ level: 'info', logger: 'job-runner', data: 'nightly index rebuild started' });
await vi.waitFor(() => expect(received).toHaveLength(1));
expect(received).toEqual([{ level: 'info', logger: 'job-runner', data: 'nightly index rebuild started' }]);
});

View file

@ -0,0 +1,115 @@
/**
* Self-contained test bodies for cursor-pagination behaviors that span all
* paginated list operations (tools/list, resources/list,
* resources/templates/list, prompts/list).
*/
import { Client } from '@modelcontextprotocol/client';
import type { Tool } from '@modelcontextprotocol/server';
import { isJSONRPCRequest, McpServer, ProtocolError, ProtocolErrorCode, ResourceTemplate, Server } 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';
const newClient = () => new Client({ name: 'c', version: '0' });
verifies('pagination:invalid-cursor', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('echo', { inputSchema: z.object({}) }, () => ({ content: [] }));
s.registerResource('static', 'e2e://static', {}, uri => ({
contents: [{ uri: uri.href, mimeType: 'text/plain', text: 'hi' }]
}));
s.registerResource('tpl', new ResourceTemplate('e2e://item/{id}', { list: undefined }), {}, uri => ({
contents: [{ uri: uri.href, mimeType: 'text/plain', text: 'hi' }]
}));
s.registerPrompt('p', {}, () => ({ messages: [{ role: 'user', content: { type: 'text', text: 'hi' } }] }));
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const badCursor = 'not-a-cursor-the-server-ever-issued';
const invalidParams = expect.objectContaining({ code: ProtocolErrorCode.InvalidParams });
await expect(client.listTools({ cursor: badCursor })).rejects.toBeInstanceOf(ProtocolError);
await expect(client.listTools({ cursor: badCursor })).rejects.toEqual(invalidParams);
await expect(client.listResources({ cursor: badCursor })).rejects.toEqual(invalidParams);
await expect(client.listResourceTemplates({ cursor: badCursor })).rejects.toEqual(invalidParams);
await expect(client.listPrompts({ cursor: badCursor })).rejects.toEqual(invalidParams);
});
verifies('pagination:client:cursor-handling', async ({ transport }: TestArgs) => {
// Cursors are deliberately base64-padded / URL-unsafe / JSON-looking so any client-side parsing or normalization would corrupt them.
const cursorToPage2 = 'eyJvZmZzZXQiOjMsInYiOjF9==/page-2?after=get_alerts';
const cursorToPage3 = 'YWZ0ZXI+Y29udmVydF91bml0cw==';
// Page sizes of 3, 1, 2 prove the client follows nextCursor only and assumes no fixed page size.
const pages = new Map<string | undefined, { tools: Tool[]; nextCursor?: string }>([
[
undefined,
{
tools: [
{ name: 'get_weather', inputSchema: { type: 'object', properties: { city: { type: 'string' } } } },
{ name: 'get_forecast', inputSchema: { type: 'object', properties: { city: { type: 'string' } } } },
{ name: 'get_alerts', inputSchema: { type: 'object', properties: { region: { type: 'string' } } } }
],
nextCursor: cursorToPage2
}
],
[
cursorToPage2,
{
tools: [{ name: 'convert_units', inputSchema: { type: 'object', properties: { value: { type: 'number' } } } }],
nextCursor: cursorToPage3
}
],
[
cursorToPage3,
{
tools: [
{ name: 'list_stations', inputSchema: { type: 'object', properties: {} } },
{ name: 'get_station', inputSchema: { type: 'object', properties: { id: { type: 'string' } } } }
]
}
]
]);
const receivedCursors: Array<string | undefined> = [];
const makeServer = () => {
const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } });
s.setRequestHandler('tools/list', req => {
const cursor = req.params?.cursor;
receivedCursors.push(cursor);
const page = pages.get(cursor);
if (!page) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Unknown cursor: ${String(cursor)}`);
return { tools: page.tools, nextCursor: page.nextCursor };
});
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const tap = tapWire(client);
const collectedPages: string[][] = [];
let result = await client.listTools();
collectedPages.push(result.tools.map(t => t.name));
while (result.nextCursor !== undefined) {
// A run-away loop means the test fixture, not the SDK, is broken — fail fast instead of hitting the suite timeout.
if (collectedPages.length >= pages.size) throw new Error('nextCursor still present after the last page');
result = await client.listTools({ cursor: result.nextCursor });
collectedPages.push(result.tools.map(t => t.name));
}
// The handler got back exactly the cursors it issued, and every page arrived once, in order.
expect(receivedCursors).toEqual([undefined, cursorToPage2, cursorToPage3]);
expect(collectedPages).toEqual([['get_weather', 'get_forecast', 'get_alerts'], ['convert_units'], ['list_stations', 'get_station']]);
// The wire requests carried the server-issued strings byte-for-byte — opaque, unparsed, unmodified.
const wireListRequests = tap.sent.filter(m => isJSONRPCRequest(m)).filter(m => m.method === 'tools/list');
expect(wireListRequests.map(m => m.params?.cursor)).toEqual([undefined, cursorToPage2, cursorToPage3]);
});

View file

@ -0,0 +1,542 @@
/**
* Self-contained test bodies for the prompts surface.
*
* Each export is a {@link TestCase}: it builds its own server (via a factory),
* builds its own client, wires them with {@link wire}, and asserts. Function
* names mirror the requirement id in camelCase; a `Raw` suffix marks
* a low-level {@link Server} variant where the behavior under test differs by
* tier.
*/
import { Client } from '@modelcontextprotocol/client';
import type { RegisteredPrompt } from '@modelcontextprotocol/server';
import { McpServer, ProtocolError, 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 TINY_PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
const TINY_WAV_BASE64 = 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA=';
const TINY_BLOB_BASE64 = 'SGVsbG8sIE1DUCE=';
const newClient = () => new Client({ name: 'c', version: '0' });
function summarizeServer(): McpServer {
const s = new McpServer({ name: 's', version: '0' });
s.registerPrompt(
'summarize',
{ description: 'Summarize the provided text.', argsSchema: z.object({ text: z.string() }) },
({ text }) => ({
messages: [{ role: 'user', content: { type: 'text', text: `Summarize the following text:\n${text}` } }]
})
);
return s;
}
function explainCommitServer(): McpServer {
const s = new McpServer({ name: 's', version: '0' });
s.registerPrompt('explain-last-commit', { description: 'Explain the most recent git commit.' }, () => ({
messages: [{ role: 'user', content: { type: 'text', text: 'Explain the most recent commit in this repository.' } }]
}));
return s;
}
verifies('prompts:capability:declared', async ({ transport }: TestArgs) => {
const client = newClient();
await using _ = await wire(transport, explainCommitServer, client);
const caps = client.getServerCapabilities();
expect(caps?.prompts).toBeDefined();
expect(caps?.prompts?.listChanged).toBe(true);
});
verifies('prompts:list:basic', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerPrompt(
'summarize',
{ description: 'Summarize the provided text.', argsSchema: z.object({ text: z.string() }) },
({ text }) => ({
messages: [{ role: 'user', content: { type: 'text', text: `Summarize the following text:\n${text}` } }]
})
);
s.registerPrompt(
'code-review',
{
description: 'Review a code snippet in the given language, optionally focused on one aspect.',
argsSchema: z.object({ language: z.enum(['ts', 'py']), focus: z.string().optional() })
},
({ language, focus }) => ({
messages: [
{
role: 'user',
content: {
type: 'text',
text: focus
? `Review the following ${language} code, focusing on ${focus}:`
: `Review the following ${language} code:`
}
}
]
})
);
s.registerPrompt('explain-last-commit', { description: 'Explain the most recent git commit.' }, () => ({
messages: [{ role: 'user', content: { type: 'text', text: 'Explain the most recent commit in this repository.' } }]
}));
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const result = await client.listPrompts();
expect(result.prompts).toHaveLength(3);
expect(result.prompts.map(p => p.name).toSorted()).toEqual(['code-review', 'explain-last-commit', 'summarize']);
expect(result.prompts.find(p => p.name === 'summarize')).toMatchObject({
name: 'summarize',
description: 'Summarize the provided text.',
arguments: [{ name: 'text', required: true }]
});
const codeReview = result.prompts.find(p => p.name === 'code-review');
expect(codeReview?.description).toBe('Review a code snippet in the given language, optionally focused on one aspect.');
expect(codeReview?.arguments).toEqual([
{ name: 'language', required: true },
{ name: 'focus', required: false }
]);
const explain = result.prompts.find(p => p.name === 'explain-last-commit');
expect(explain?.description).toBe('Explain the most recent git commit.');
expect(explain?.arguments ?? []).toHaveLength(0);
});
verifies(
'prompts:list:pagination',
async ({ transport }: TestArgs) => {
const TOTAL = 25;
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
for (let i = 0; i < TOTAL; i++) {
s.registerPrompt(`bulk_${String(i).padStart(2, '0')}`, {}, () => ({ messages: [] }));
}
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const first = await client.listPrompts();
expect(first.prompts.length).toBeLessThan(TOTAL);
expect(first.nextCursor).toBeDefined();
const seen = new Set(first.prompts.map(p => p.name));
let result = first;
let pages = 1;
while (result.nextCursor !== undefined) {
result = await client.listPrompts({ cursor: result.nextCursor });
for (const p of result.prompts) seen.add(p.name);
pages++;
expect(pages).toBeLessThan(50);
}
expect(seen.size).toBe(TOTAL);
expect(pages).toBeGreaterThan(1);
},
{ title: 'mcpserver' }
);
verifies(
'prompts:list:pagination',
async ({ transport }: TestArgs) => {
const TOTAL = 25;
const PAGE = 10;
const all = Array.from({ length: TOTAL }, (_, i) => `prompt_${String(i).padStart(2, '0')}`);
const cursorsReceived: Array<string | undefined> = [];
const makeServer = () => {
const s = new Server({ name: 's', version: '0' }, { capabilities: { prompts: {} } });
s.setRequestHandler('prompts/list', req => {
cursorsReceived.push(req.params?.cursor);
const start = req.params?.cursor === undefined ? 0 : Number.parseInt(req.params.cursor, 10);
const slice = all.slice(start, start + PAGE);
return {
prompts: slice.map(name => ({ name })),
nextCursor: start + PAGE < TOTAL ? String(start + PAGE) : undefined
};
});
s.setRequestHandler('prompts/get', req => ({
messages: [{ role: 'user', content: { type: 'text', text: req.params.name } }]
}));
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const seen = new Set<string>();
const cursorsSent: string[] = [];
let pages = 0;
let result = await client.listPrompts();
expect(result.nextCursor).toBeDefined();
for (;;) {
for (const p of result.prompts) {
expect(seen.has(p.name)).toBe(false);
seen.add(p.name);
}
pages++;
if (result.nextCursor === undefined) break;
cursorsSent.push(result.nextCursor);
result = await client.listPrompts({ cursor: result.nextCursor });
expect(pages).toBeLessThan(50);
}
expect(pages).toBe(3);
expect(seen.size).toBe(TOTAL);
for (const name of all) expect(seen.has(name)).toBe(true);
expect(cursorsReceived).toEqual([undefined, '10', '20']);
expect(cursorsSent).toEqual(['10', '20']);
},
{ title: 'raw server' }
);
verifies('prompts:list-changed', async ({ transport }: TestArgs) => {
let server: McpServer | undefined;
const makeServer = () => {
server = new McpServer({ name: 's', version: '0' });
server.registerPrompt('seed', {}, () => ({ messages: [] }));
return server;
};
let listChanged = 0;
const client = newClient();
client.setNotificationHandler('notifications/prompts/list_changed', () => {
listChanged++;
});
await using _ = await wire(transport, makeServer, client);
if (server === undefined) throw new Error('wire() did not invoke the server factory');
const initial = await client.listPrompts();
expect(initial.prompts).toHaveLength(1);
const handle = server.registerPrompt('dynamic-probe', {}, () => ({ messages: [] }));
await vi.waitFor(() => expect(listChanged).toBeGreaterThanOrEqual(1));
const afterAddList = await client.listPrompts();
expect(afterAddList.prompts).toHaveLength(2);
const afterAdd = listChanged;
handle.remove();
await vi.waitFor(() => expect(listChanged).toBeGreaterThan(afterAdd));
const afterRemoveList = await client.listPrompts();
expect(afterRemoveList.prompts).toHaveLength(1);
});
verifies('prompts:get:no-args', async ({ transport }: TestArgs) => {
const client = newClient();
await using _ = await wire(transport, explainCommitServer, client);
const result = await client.getPrompt({ name: 'explain-last-commit' });
expect(result.messages).toEqual([
{ role: 'user', content: { type: 'text', text: 'Explain the most recent commit in this repository.' } }
]);
});
verifies('prompts:get:with-args', async ({ transport }: TestArgs) => {
const client = newClient();
await using _ = await wire(transport, summarizeServer, client);
const result = await client.getPrompt({
name: 'summarize',
arguments: { text: 'The quick brown fox jumps over the lazy dog.' }
});
expect(result.messages).toEqual([
{
role: 'user',
content: { type: 'text', text: 'Summarize the following text:\nThe quick brown fox jumps over the lazy dog.' }
}
]);
});
verifies('prompts:get:multi-message', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerPrompt('geography-quiz', { description: 'A short multi-turn geography quiz.' }, () => ({
messages: [
{ role: 'user', content: { type: 'text', text: 'What is the capital of France?' } },
{ role: 'assistant', content: { type: 'text', text: 'The capital of France is Paris.' } },
{ role: 'user', content: { type: 'text', text: 'And of Italy?' } }
]
}));
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const result = await client.getPrompt({ name: 'geography-quiz' });
expect(result.messages).toEqual([
{ role: 'user', content: { type: 'text', text: 'What is the capital of France?' } },
{ role: 'assistant', content: { type: 'text', text: 'The capital of France is Paris.' } },
{ role: 'user', content: { type: 'text', text: 'And of Italy?' } }
]);
});
verifies('prompts:get:missing-required-args', async ({ transport }: TestArgs) => {
const client = newClient();
await using _ = await wire(transport, summarizeServer, client);
await expect(client.getPrompt({ name: 'summarize', arguments: {} })).rejects.toMatchObject({
code: ProtocolErrorCode.InvalidParams,
message: expect.stringMatching(/text|required|invalid/i)
});
});
verifies('prompts:get:unknown-name', async ({ transport }: TestArgs) => {
const client = newClient();
await using _ = await wire(transport, explainCommitServer, client);
await expect(client.getPrompt({ name: 'no-such-prompt' })).rejects.toMatchObject({
code: ProtocolErrorCode.InvalidParams,
message: expect.stringMatching(/no-such-prompt|unknown|not found/i)
});
});
verifies('prompts:get:content:image', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerPrompt('describe-image', {}, () => ({
messages: [
{ role: 'user', content: { type: 'text', text: 'Describe what you see in this image.' } },
{ role: 'user', content: { type: 'image', data: TINY_PNG_BASE64, mimeType: 'image/png' } }
]
}));
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const result = await client.getPrompt({ name: 'describe-image' });
expect(result.messages).toEqual([
{ role: 'user', content: { type: 'text', text: 'Describe what you see in this image.' } },
{ role: 'user', content: { type: 'image', data: TINY_PNG_BASE64, mimeType: 'image/png' } }
]);
});
verifies('prompts:get:content:audio', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerPrompt('transcribe-audio', {}, () => ({
messages: [
{ role: 'user', content: { type: 'text', text: 'Transcribe the following audio clip.' } },
{ role: 'user', content: { type: 'audio', data: TINY_WAV_BASE64, mimeType: 'audio/wav' } }
]
}));
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const result = await client.getPrompt({ name: 'transcribe-audio' });
expect(result.messages).toEqual([
{ role: 'user', content: { type: 'text', text: 'Transcribe the following audio clip.' } },
{ role: 'user', content: { type: 'audio', data: TINY_WAV_BASE64, mimeType: 'audio/wav' } }
]);
});
verifies('prompts:get:content:embedded-resource', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerPrompt('review-file', { argsSchema: z.object({ kind: z.enum(['text', 'blob']) }) }, ({ kind }) => ({
messages: [
{ role: 'user', content: { type: 'text', text: 'Review the attached file.' } },
{
role: 'user',
content:
kind === 'text'
? {
type: 'resource',
resource: { uri: 'file:///fixture.txt', mimeType: 'text/plain', text: 'embedded fixture text' }
}
: {
type: 'resource',
resource: { uri: 'file:///fixture.bin', mimeType: 'application/octet-stream', blob: TINY_BLOB_BASE64 }
}
}
]
}));
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const text = await client.getPrompt({ name: 'review-file', arguments: { kind: 'text' } });
expect(text.messages).toEqual([
{ role: 'user', content: { type: 'text', text: 'Review the attached file.' } },
{
role: 'user',
content: { type: 'resource', resource: { uri: 'file:///fixture.txt', mimeType: 'text/plain', text: 'embedded fixture text' } }
}
]);
const blob = await client.getPrompt({ name: 'review-file', arguments: { kind: 'blob' } });
expect(blob.messages).toEqual([
{ role: 'user', content: { type: 'text', text: 'Review the attached file.' } },
{
role: 'user',
content: {
type: 'resource',
resource: { uri: 'file:///fixture.bin', mimeType: 'application/octet-stream', blob: TINY_BLOB_BASE64 }
}
}
]);
});
verifies('mcpserver:prompt:args-validation', async ({ transport }: TestArgs) => {
const handlerCalls = { n: 0 };
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerPrompt(
'code-review',
{ argsSchema: z.object({ language: z.enum(['ts', 'py']), focus: z.string().optional() }) },
({ language }) => {
handlerCalls.n++;
return { messages: [{ role: 'user', content: { type: 'text', text: `Review the following ${language} code:` } }] };
}
);
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const ok = await client.getPrompt({ name: 'code-review', arguments: { language: 'ts' } });
expect(ok.messages).toEqual([{ role: 'user', content: { type: 'text', text: 'Review the following ts code:' } }]);
expect(handlerCalls.n).toBe(1);
await expect(client.getPrompt({ name: 'code-review', arguments: { language: 'rust' } })).rejects.toMatchObject({
code: ProtocolErrorCode.InvalidParams,
message: expect.stringMatching(/invalid arguments.*code-review/i)
});
expect(handlerCalls.n).toBe(1);
await expect(client.getPrompt({ name: 'code-review', arguments: {} })).rejects.toMatchObject({
code: ProtocolErrorCode.InvalidParams,
message: expect.stringMatching(/invalid arguments/i)
});
expect(handlerCalls.n).toBe(1);
});
verifies('mcpserver:prompt:optional-args', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerPrompt(
'summarize',
{ argsSchema: z.object({ text: z.string(), max_words: z.string().optional() }) },
({ text, max_words }) => ({
messages: [
{
role: 'user',
content: {
type: 'text',
text: max_words
? `Summarize the following text in at most ${max_words} words:\n${text}`
: `Summarize the following text:\n${text}`
}
}
]
})
);
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const withOpt = await client.getPrompt({ name: 'summarize', arguments: { text: 'lorem ipsum', max_words: '10' } });
expect(withOpt.messages).toEqual([
{ role: 'user', content: { type: 'text', text: 'Summarize the following text in at most 10 words:\nlorem ipsum' } }
]);
const withoutOpt = await client.getPrompt({ name: 'summarize', arguments: { text: 'lorem ipsum' } });
expect(withoutOpt.messages).toEqual([{ role: 'user', content: { type: 'text', text: 'Summarize the following text:\nlorem ipsum' } }]);
});
verifies('mcpserver:prompt:duplicate-name', async ({ transport }: TestArgs) => {
let dupError: unknown;
const makeServer = () => {
const s = explainCommitServer();
s.registerPrompt('fresh', {}, () => ({ messages: [] }));
try {
s.registerPrompt('explain-last-commit', {}, () => ({ messages: [] }));
} catch (error) {
dupError = error;
}
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
expect(dupError).toBeInstanceOf(Error);
expect(String(dupError)).toMatch(/already registered/i);
const { prompts } = await client.listPrompts();
expect(prompts.filter(p => p.name === 'explain-last-commit')).toHaveLength(1);
expect(prompts.map(p => p.name)).toContain('fresh');
const r = await client.getPrompt({ name: 'explain-last-commit' });
expect(r.messages).toEqual([{ role: 'user', content: { type: 'text', text: 'Explain the most recent commit in this repository.' } }]);
});
verifies('mcpserver:prompt:handle-update-remove', async ({ transport }: TestArgs) => {
let handle: RegisteredPrompt | undefined;
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
handle = s.registerPrompt('probe', { description: 'v1' }, () => ({
messages: [{ role: 'user', content: { type: 'text', text: 'v1' } }]
}));
return s;
};
let listChanged = 0;
const client = newClient();
client.setNotificationHandler('notifications/prompts/list_changed', () => {
listChanged++;
});
await using _ = await wire(transport, makeServer, client);
if (handle === undefined) throw new Error('wire() did not invoke the server factory');
const initialList = await client.listPrompts();
expect(initialList.prompts).toHaveLength(1);
const before = initialList.prompts.find(p => p.name === 'probe');
expect(before?.description).toBe('v1');
const initialGet = await client.getPrompt({ name: 'probe' });
expect(initialGet.messages).toEqual([{ role: 'user', content: { type: 'text', text: 'v1' } }]);
handle.update({
description: 'v2',
callback: () => ({ messages: [{ role: 'user', content: { type: 'text', text: 'v2' } }] })
});
await vi.waitFor(() => expect(listChanged).toBeGreaterThanOrEqual(1));
const updatedList = await client.listPrompts();
expect(updatedList.prompts).toHaveLength(1);
const after = updatedList.prompts.find(p => p.name === 'probe');
expect(after?.description).toBe('v2');
const r = await client.getPrompt({ name: 'probe' });
expect(r.messages).toEqual([{ role: 'user', content: { type: 'text', text: 'v2' } }]);
const beforeRemove = listChanged;
handle.remove();
await vi.waitFor(() => expect(listChanged).toBeGreaterThan(beforeRemove));
const removedList = await client.listPrompts();
expect(removedList.prompts).toHaveLength(0);
await expect(client.getPrompt({ name: 'probe' })).rejects.toBeInstanceOf(ProtocolError);
const finalList = await client.listPrompts();
expect(finalList.prompts.find(p => p.name === 'probe')).toBeUndefined();
});

File diff suppressed because it is too large Load diff

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