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

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

@ -0,0 +1,244 @@
/**
* 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 { expect, vi } from 'vitest';
import { z } from 'zod/v4';
import { Client } from '../../../src/client/index.js';
import { Server } from '../../../src/server/index.js';
import { McpServer } from '../../../src/server/mcp.js';
import {
ErrorCode,
isJSONRPCRequest,
type LoggingLevel,
LoggingLevelSchema,
LoggingMessageNotificationSchema,
McpError,
RequestSchema
} from '../../../src/types.js';
import { tapWire, wire } from '../helpers/index.js';
import type { TestArgs } from '../types.js';
import { verifies } from '../helpers/verifies.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 }, extra) => {
if (withLogger) {
await extra.sendNotification({
method: 'notifications/message',
params: { level: 'info', logger: 'test-logger', data: 'with-logger' }
});
} else {
await extra.sendNotification({
method: 'notifications/message',
params: { level: 'warning', data: 'without-logger' }
});
}
for (const level of ALL_LEVELS) {
await extra.sendNotification({
method: 'notifications/message',
params: { level, logger: 'sweep', data: `level-${level}` }
});
}
return { content: [{ type: 'text', text: 'done' }] };
});
return s;
};
const client = newClient();
client.setNotificationHandler(LoggingMessageNotificationSchema, 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, extra) => {
for (const level of ALL_LEVELS) {
await server.sendLoggingMessage({ level, logger: 'diagnostics', data: `a ${level} event` }, extra.sessionId);
}
return { content: [{ type: 'text', text: 'diagnostics complete' }] };
});
return server;
};
const received: Array<{ level: LoggingLevel; logger?: string; data: unknown }> = [];
const client = newClient();
client.setNotificationHandler(LoggingMessageNotificationSchema, 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, extra) => {
for (const level of ALL_LEVELS) {
await server.sendLoggingMessage({ level, data: level }, extra.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(LoggingMessageNotificationSchema, 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();
await using _ = await wire(transport, loggingServer, client);
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: ErrorCode.InvalidParams });
expect(ErrorCode.InvalidParams).toBe(-32602);
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 LooseSetLevelRequestSchema = RequestSchema.extend({
method: z.literal('logging/setLevel'),
params: z.object({ level: z.string() })
});
const applied: LoggingLevel[] = [];
const makeServer = () => {
const s = new Server({ name: 's', version: '0' }, { capabilities: { logging: {} } });
s.setRequestHandler(LooseSetLevelRequestSchema, req => {
const parsed = LoggingLevelSchema.safeParse(req.params.level);
if (!parsed.success) {
throw new McpError(ErrorCode.InvalidParams, `Invalid logging level: ${req.params.level}`);
}
applied.push(parsed.data);
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: ErrorCode.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(LoggingMessageNotificationSchema, 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,117 @@
/**
* Self-contained test bodies for cursor-pagination behaviors that span all
* paginated list operations (tools/list, resources/list,
* resources/templates/list, prompts/list).
*/
import { expect } from 'vitest';
import { z } from 'zod/v4';
import { Client } from '../../../src/client/index.js';
import { Server } from '../../../src/server/index.js';
import { McpServer, ResourceTemplate } from '../../../src/server/mcp.js';
import { ErrorCode, isJSONRPCRequest, ListToolsRequestSchema, McpError, type Tool } from '../../../src/types.js';
import { tapWire, wire } from '../helpers/index.js';
import type { TestArgs } from '../types.js';
import { verifies } from '../helpers/verifies.js';
const newClient = () => new Client({ name: 'c', version: '0' });
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: ErrorCode.InvalidParams });
await expect(client.listTools({ cursor: badCursor })).rejects.toBeInstanceOf(McpError);
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(ListToolsRequestSchema, req => {
const cursor = req.params?.cursor;
receivedCursors.push(cursor);
const page = pages.get(cursor);
if (!page) throw new McpError(ErrorCode.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(isJSONRPCRequest).filter(m => m.method === 'tools/list');
expect(wireListRequests.map(m => m.params?.cursor)).toEqual([undefined, cursorToPage2, cursorToPage3]);
});

View file

@ -0,0 +1,549 @@
/**
* 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 { expect, vi } from 'vitest';
import { z } from 'zod/v4';
import { Client } from '../../../src/client/index.js';
import { Server } from '../../../src/server/index.js';
import { McpServer, type RegisteredPrompt } from '../../../src/server/mcp.js';
import {
ErrorCode,
GetPromptRequestSchema,
ListPromptsRequestSchema,
McpError,
PromptListChangedNotificationSchema
} from '../../../src/types.js';
import { wire } from '../helpers/index.js';
import type { TestArgs } from '../types.js';
import { verifies } from '../helpers/verifies.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: { 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: { 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: { 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).sort()).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(ListPromptsRequestSchema, req => {
cursorsReceived.push(req.params?.cursor);
const start = req.params?.cursor === undefined ? 0 : 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(GetPromptRequestSchema, 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;
const makeServer = () => {
server = new McpServer({ name: 's', version: '0' });
server.registerPrompt('seed', {}, () => ({ messages: [] }));
return server;
};
let listChanged = 0;
const client = newClient();
client.setNotificationHandler(PromptListChangedNotificationSchema, () => {
listChanged++;
});
await using _ = await wire(transport, makeServer, client);
expect((await client.listPrompts()).prompts).toHaveLength(1);
const handle = server.registerPrompt('dynamic-probe', {}, () => ({ messages: [] }));
await vi.waitFor(() => expect(listChanged).toBeGreaterThanOrEqual(1));
expect((await client.listPrompts()).prompts).toHaveLength(2);
const afterAdd = listChanged;
handle.remove();
await vi.waitFor(() => expect(listChanged).toBeGreaterThan(afterAdd));
expect((await client.listPrompts()).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: ErrorCode.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: ErrorCode.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: { 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: { 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: ErrorCode.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: ErrorCode.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: { 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 (e) {
dupError = e;
}
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()).prompts;
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;
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(PromptListChangedNotificationSchema, () => {
listChanged++;
});
await using _ = await wire(transport, makeServer, client);
expect((await client.listPrompts()).prompts).toHaveLength(1);
const before = (await client.listPrompts()).prompts.find(p => p.name === 'probe')!;
expect(before.description).toBe('v1');
expect((await client.getPrompt({ name: 'probe' })).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));
expect((await client.listPrompts()).prompts).toHaveLength(1);
const after = (await client.listPrompts()).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));
expect((await client.listPrompts()).prompts).toHaveLength(0);
await expect(client.getPrompt({ name: 'probe' })).rejects.toBeInstanceOf(McpError);
expect((await client.listPrompts()).prompts.find(p => p.name === 'probe')).toBeUndefined();
});
verifies('mcpserver:prompt:legacy-overload', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.prompt('legacy-prompt', 'Legacy prompt overload (name, description, cb).', () => ({
messages: [{ role: 'user', content: { type: 'text', text: 'Explain the project README.' } }]
}));
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const { prompts } = await client.listPrompts();
const listed = prompts.find(p => p.name === 'legacy-prompt');
expect(listed).toBeDefined();
expect(listed!.description).toBe('Legacy prompt overload (name, description, cb).');
expect(listed!.arguments ?? []).toEqual([]);
const result = await client.getPrompt({ name: 'legacy-prompt' });
expect(result.messages).toEqual([{ role: 'user', content: { type: 'text', text: 'Explain the project README.' } }]);
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,806 @@
/**
* Self-contained test bodies for the resources surface.
*
* Each export is a TestCase: it builds its own server (via a factory), builds
* its own client, wires them with wire(), and asserts. Function names mirror
* the requirement id in camelCase. Bodies that inspect server-side state
* declare the recorder outside the factory so every server instance closes
* over the same array.
*/
import { expect, vi } from 'vitest';
import { Client } from '../../../src/client/index.js';
import { Server } from '../../../src/server/index.js';
import { McpServer, type RegisteredResource, ResourceTemplate } from '../../../src/server/mcp.js';
import {
ErrorCode,
ListResourcesRequestSchema,
ListResourceTemplatesRequestSchema,
McpError,
ReadResourceRequestSchema,
ResourceListChangedNotificationSchema,
ResourceUpdatedNotificationSchema,
SubscribeRequestSchema,
UnsubscribeRequestSchema
} from '../../../src/types.js';
import { wire } from '../helpers/index.js';
import type { TestArgs } from '../types.js';
import { verifies } from '../helpers/verifies.js';
const TINY_PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
const FIXTURE_LAST_MODIFIED = '2024-01-15T10:30:00.000Z';
const newClient = () => new Client({ name: 'c', version: '0' });
verifies('resources:list:basic', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerResource('text', 'file:///fixture.txt', { description: 'A plain-text resource.', mimeType: 'text/plain' }, () => ({
contents: [{ uri: 'file:///fixture.txt', mimeType: 'text/plain', text: 'hello, world' }]
}));
s.registerResource('blob', 'file:///fixture.png', { description: 'A 1×1 PNG.', mimeType: 'image/png' }, () => ({
contents: [{ uri: 'file:///fixture.png', mimeType: 'image/png', blob: TINY_PNG_BASE64 }]
}));
s.registerResource('annotated', 'file:///annotated.md', { description: 'Annotated.', mimeType: 'text/markdown' }, () => ({
contents: [{ uri: 'file:///annotated.md', mimeType: 'text/markdown', text: '# Annotated' }]
}));
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const result = await client.listResources();
expect(result.resources).toHaveLength(3);
expect(result.resources.map(r => r.uri).sort()).toEqual(['file:///annotated.md', 'file:///fixture.png', 'file:///fixture.txt']);
expect(result.resources.find(r => r.uri === 'file:///fixture.txt')).toMatchObject({
uri: 'file:///fixture.txt',
name: 'text',
description: 'A plain-text resource.',
mimeType: 'text/plain'
});
expect(result.resources.find(r => r.uri === 'file:///fixture.png')).toMatchObject({
uri: 'file:///fixture.png',
name: 'blob',
description: 'A 1×1 PNG.',
mimeType: 'image/png'
});
});
verifies(
'resources: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.registerResource(
`bulk_${String(i).padStart(2, '0')}`,
`bulk://item/${String(i).padStart(2, '0')}`,
{ mimeType: 'text/plain' },
uri => ({ contents: [{ uri: uri.href, mimeType: 'text/plain', text: '' }] })
);
}
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const first = await client.listResources();
expect(first.resources.length).toBeLessThan(TOTAL);
expect(first.nextCursor).toBeDefined();
const seen = new Set(first.resources.map(r => r.uri));
let result = first;
let pages = 1;
while (result.nextCursor !== undefined) {
result = await client.listResources({ cursor: result.nextCursor });
for (const r of result.resources) seen.add(r.uri);
pages++;
expect(pages).toBeLessThan(50);
}
expect(seen.size).toBe(TOTAL);
expect(pages).toBeGreaterThan(1);
},
{ title: 'mcpserver' }
);
verifies(
'resources:list:pagination',
async ({ transport }: TestArgs) => {
const TOTAL = 25;
const PAGE = 10;
const all = Array.from({ length: TOTAL }, (_, i) => `bulk://item/${String(i).padStart(2, '0')}`);
const cursorsReceived: Array<string | undefined> = [];
const makeServer = () => {
const s = new Server({ name: 's', version: '0' }, { capabilities: { resources: { listChanged: true } } });
s.setRequestHandler(ListResourcesRequestSchema, req => {
cursorsReceived.push(req.params?.cursor);
const start = req.params?.cursor === undefined ? 0 : parseInt(req.params.cursor, 10);
const slice = all.slice(start, start + PAGE);
return {
resources: slice.map(uri => ({ uri, name: uri, mimeType: 'text/plain' })),
nextCursor: start + PAGE < TOTAL ? String(start + PAGE) : undefined
};
});
s.setRequestHandler(ReadResourceRequestSchema, () => ({ contents: [] }));
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.listResources();
expect(result.nextCursor).toBeDefined();
for (;;) {
for (const r of result.resources) {
expect(seen.has(r.uri)).toBe(false);
seen.add(r.uri);
}
pages++;
if (result.nextCursor === undefined) break;
cursorsSent.push(result.nextCursor);
result = await client.listResources({ cursor: result.nextCursor });
expect(pages).toBeLessThan(50);
}
expect(result.nextCursor).toBeUndefined();
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('resources:read:text', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerResource('text', 'file:///fixture.txt', { mimeType: 'text/plain' }, () => ({
contents: [{ uri: 'file:///fixture.txt', mimeType: 'text/plain', text: 'hello, world' }]
}));
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const result = await client.readResource({ uri: 'file:///fixture.txt' });
expect(result.contents).toHaveLength(1);
const [entry] = result.contents;
expect(entry.uri).toBe('file:///fixture.txt');
expect(entry.mimeType).toBe('text/plain');
expect('text' in entry && entry.text).toBe('hello, world');
expect(entry).not.toHaveProperty('blob');
});
verifies('resources:read:blob', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerResource('blob', 'file:///fixture.png', { mimeType: 'image/png' }, () => ({
contents: [{ uri: 'file:///fixture.png', mimeType: 'image/png', blob: TINY_PNG_BASE64 }]
}));
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const result = await client.readResource({ uri: 'file:///fixture.png' });
expect(result.contents).toHaveLength(1);
const [entry] = result.contents;
expect(entry).toEqual({ uri: 'file:///fixture.png', mimeType: 'image/png', blob: TINY_PNG_BASE64 });
expect(entry).not.toHaveProperty('text');
});
verifies('resources:read:unknown-uri', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerResource('text', 'file:///exists.txt', { mimeType: 'text/plain' }, () => ({
contents: [{ uri: 'file:///exists.txt', mimeType: 'text/plain', text: 'ok' }]
}));
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
await expect(client.readResource({ uri: 'file:///no-such-resource' })).rejects.toMatchObject({
code: -32002,
message: expect.stringMatching(/not found|unknown/i)
});
});
verifies('resources:read:template-vars', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerResource(
'greet',
new ResourceTemplate('greet://hello/{name}', { list: undefined }),
{ mimeType: 'text/plain' },
(uri, { name }) => ({ contents: [{ uri: uri.href, mimeType: 'text/plain', text: `Hello, ${name}!` }] })
);
s.registerResource(
'repo',
new ResourceTemplate('github://{owner}/{repo}', { list: undefined }),
{ 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 greet = await client.readResource({ uri: 'greet://hello/Ada' });
expect(greet.contents).toEqual([{ uri: 'greet://hello/Ada', mimeType: 'text/plain', text: 'Hello, Ada!' }]);
const repo = await client.readResource({ uri: 'github://modelcontextprotocol/typescript-sdk' });
expect(repo.contents).toEqual([
{ uri: 'github://modelcontextprotocol/typescript-sdk', mimeType: 'text/plain', text: 'modelcontextprotocol/typescript-sdk' }
]);
});
verifies('resources:list-changed', async ({ transport }: TestArgs) => {
let server!: McpServer;
const makeServer = () => {
server = new McpServer({ name: 's', version: '0' });
server.registerResource('seed', 'file:///seed.txt', { mimeType: 'text/plain' }, () => ({
contents: [{ uri: 'file:///seed.txt', mimeType: 'text/plain', text: 'seed' }]
}));
return server;
};
let listChanged = 0;
const client = newClient();
client.setNotificationHandler(ResourceListChangedNotificationSchema, () => {
listChanged++;
});
await using _ = await wire(transport, makeServer, client);
expect((await client.listResources()).resources).toHaveLength(1);
const handle = server.registerResource('dynamic', 'file:///probe.txt', { mimeType: 'text/plain' }, () => ({
contents: [{ uri: 'file:///probe.txt', mimeType: 'text/plain', text: 'probe' }]
}));
await vi.waitFor(() => expect(listChanged).toBeGreaterThanOrEqual(1));
expect((await client.listResources()).resources).toHaveLength(2);
const afterAdd = listChanged;
handle.remove();
await vi.waitFor(() => expect(listChanged).toBeGreaterThan(afterAdd));
expect((await client.listResources()).resources).toHaveLength(1);
});
verifies('resources:annotations', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerResource(
'annotated',
'file:///annotated.md',
{
mimeType: 'text/markdown',
annotations: { audience: ['user', 'assistant'], priority: 0.8, lastModified: FIXTURE_LAST_MODIFIED }
},
() => ({ contents: [{ uri: 'file:///annotated.md', mimeType: 'text/markdown', text: '# Annotated' }] })
);
s.registerResource(
'greet',
new ResourceTemplate('greet://hello/{name}', { list: undefined }),
{
mimeType: 'text/plain',
annotations: { audience: ['user'], priority: 0.5, lastModified: FIXTURE_LAST_MODIFIED }
},
(uri, { name }) => ({ contents: [{ uri: uri.href, mimeType: 'text/plain', text: `Hello, ${name}!` }] })
);
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const { resources } = await client.listResources();
const resource = resources.find(r => r.uri === 'file:///annotated.md');
expect(resource).toBeDefined();
expect(resource!.annotations).toEqual({
audience: ['user', 'assistant'],
priority: 0.8,
lastModified: FIXTURE_LAST_MODIFIED
});
const { resourceTemplates } = await client.listResourceTemplates();
const template = resourceTemplates.find(t => t.name === 'greet');
expect(template).toBeDefined();
expect(template!.annotations).toEqual({ audience: ['user'], priority: 0.5, lastModified: FIXTURE_LAST_MODIFIED });
const result = await client.readResource({ uri: 'file:///annotated.md' });
expect(result.contents).toHaveLength(1);
expect(result.contents[0].uri).toBe('file:///annotated.md');
});
verifies('resources:capability:declared', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' }, { capabilities: { resources: {} } });
s.registerResource('echo', 'file:///echo.txt', { mimeType: 'text/plain' }, () => ({
contents: [{ uri: 'file:///echo.txt', mimeType: 'text/plain', text: 'echo' }]
}));
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const caps = client.getServerCapabilities();
expect(caps?.resources).toBeDefined();
expect((await client.listResources()).resources).toHaveLength(1);
expect(caps?.resources?.listChanged).toBe(true);
});
verifies('resources:subscribe:capability-required', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' }, { capabilities: { resources: { listChanged: true } } });
s.registerResource('text', 'file:///fixture.txt', { mimeType: 'text/plain' }, () => ({
contents: [{ uri: 'file:///fixture.txt', mimeType: 'text/plain', text: 'hello' }]
}));
return s;
};
const client = new Client({ name: 'c', version: '0' }, { enforceStrictCapabilities: true });
await using _ = await wire(transport, makeServer, client);
const caps = client.getServerCapabilities();
expect(caps?.resources).toBeDefined();
expect(caps?.resources?.subscribe).toBeFalsy();
await expect(client.subscribeResource({ uri: 'file:///fixture.txt' })).rejects.toThrow(/does not support.*subscri/i);
});
verifies('resources:subscribe:updated', async ({ transport }: TestArgs) => {
let server!: Server;
const subscriptions = new Set<string>();
const makeServer = () => {
server = new Server({ name: 's', version: '0' }, { capabilities: { resources: { listChanged: true, subscribe: true } } });
server.setRequestHandler(ListResourcesRequestSchema, () => ({
resources: [{ uri: 'counter://subscribable', name: 'subscribable', mimeType: 'text/plain' }]
}));
server.setRequestHandler(ReadResourceRequestSchema, () => ({
contents: [{ uri: 'counter://subscribable', mimeType: 'text/plain', text: 'count' }]
}));
server.setRequestHandler(SubscribeRequestSchema, req => {
subscriptions.add(req.params.uri);
return {};
});
return server;
};
const updates: string[] = [];
const client = newClient();
client.setNotificationHandler(ResourceUpdatedNotificationSchema, n => {
updates.push(n.params.uri);
});
await using _ = await wire(transport, makeServer, client);
await client.subscribeResource({ uri: 'counter://subscribable' });
expect(subscriptions.has('counter://subscribable')).toBe(true);
await server.sendResourceUpdated({ uri: 'counter://subscribable' });
await vi.waitFor(() => expect(updates).toContain('counter://subscribable'));
});
verifies('resources:unsubscribe:stops-updates', async ({ transport }: TestArgs) => {
let server!: Server;
const subscriptions = new Set<string>();
const makeServer = () => {
server = new Server({ name: 's', version: '0' }, { capabilities: { resources: { listChanged: true, subscribe: true } } });
server.setRequestHandler(ListResourcesRequestSchema, () => ({
resources: [
{ uri: 'counter://target', name: 'target', mimeType: 'text/plain' },
{ uri: 'counter://sentinel', name: 'sentinel', mimeType: 'text/plain' }
]
}));
server.setRequestHandler(ReadResourceRequestSchema, () => ({ contents: [] }));
server.setRequestHandler(SubscribeRequestSchema, req => {
subscriptions.add(req.params.uri);
return {};
});
server.setRequestHandler(UnsubscribeRequestSchema, req => {
subscriptions.delete(req.params.uri);
return {};
});
return server;
};
const updates: string[] = [];
const client = newClient();
client.setNotificationHandler(ResourceUpdatedNotificationSchema, n => {
updates.push(n.params.uri);
});
await using _ = await wire(transport, makeServer, client);
await client.subscribeResource({ uri: 'counter://target' });
await client.subscribeResource({ uri: 'counter://sentinel' });
await server.sendResourceUpdated({ uri: 'counter://target' });
await vi.waitFor(() => expect(updates.filter(u => u === 'counter://target').length).toBeGreaterThanOrEqual(1));
await client.unsubscribeResource({ uri: 'counter://target' });
expect(subscriptions.has('counter://target')).toBe(false);
updates.length = 0;
// A user-shaped server only emits updates for URIs that are still
// subscribed; the SDK provides the send method, the server author owns the
// subscription set. After unsubscribe, the server should not send for
// 'target' — so we only send for the sentinel and assert nothing for
// 'target' arrived while the sentinel update did.
for (const uri of subscriptions) await server.sendResourceUpdated({ uri });
await vi.waitFor(() => expect(updates.filter(u => u === 'counter://sentinel').length).toBeGreaterThanOrEqual(1));
expect(updates.filter(u => u === 'counter://target')).toHaveLength(0);
});
verifies('resources:templates:list', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerResource(
'greet',
new ResourceTemplate('greet://hello/{name}', { list: undefined }),
{ description: 'Greets {name}.', mimeType: 'text/plain' },
(uri, { name }) => ({ contents: [{ uri: uri.href, mimeType: 'text/plain', text: `Hello, ${name}!` }] })
);
s.registerResource(
'listed',
new ResourceTemplate('listed://items/{id}', {
list: () => ({ resources: [{ uri: 'listed://items/alpha', name: 'alpha' }] })
}),
{ mimeType: 'text/plain' },
(uri, { id }) => ({ contents: [{ uri: uri.href, mimeType: 'text/plain', text: String(id) }] })
);
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const result = await client.listResourceTemplates();
expect(result.resourceTemplates).toHaveLength(2);
expect(result.resourceTemplates.map(t => t.name).sort()).toEqual(['greet', 'listed']);
expect(result.resourceTemplates.find(t => t.name === 'greet')).toMatchObject({
name: 'greet',
uriTemplate: 'greet://hello/{name}',
description: 'Greets {name}.',
mimeType: 'text/plain'
});
expect(result.resourceTemplates.find(t => t.name === 'listed')).toMatchObject({
name: 'listed',
uriTemplate: 'listed://items/{id}'
});
});
verifies(
'resources:templates: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.registerResource(
`tpl_${String(i).padStart(2, '0')}`,
new ResourceTemplate(`bulk-tpl://t${String(i).padStart(2, '0')}/{x}`, { list: undefined }),
{ mimeType: 'text/plain' },
(uri, { x }) => ({ contents: [{ uri: uri.href, mimeType: 'text/plain', text: String(x) }] })
);
}
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const first = await client.listResourceTemplates();
expect(first.resourceTemplates.length).toBeLessThan(TOTAL);
expect(first.nextCursor).toBeDefined();
const seen = new Set(first.resourceTemplates.map(t => t.uriTemplate));
let result = first;
let pages = 1;
while (result.nextCursor !== undefined) {
result = await client.listResourceTemplates({ cursor: result.nextCursor });
for (const t of result.resourceTemplates) seen.add(t.uriTemplate);
pages++;
expect(pages).toBeLessThan(50);
}
expect(seen.size).toBe(TOTAL);
expect(pages).toBeGreaterThan(1);
},
{ title: 'mcpserver' }
);
verifies(
'resources:templates:pagination',
async ({ transport }: TestArgs) => {
const TOTAL = 25;
const PAGE = 10;
const all = Array.from({ length: TOTAL }, (_, i) => `bulk-tpl://t${String(i).padStart(2, '0')}/{x}`);
const makeServer = () => {
const s = new Server({ name: 's', version: '0' }, { capabilities: { resources: { listChanged: true } } });
s.setRequestHandler(ListResourceTemplatesRequestSchema, req => {
const start = req.params?.cursor === undefined ? 0 : parseInt(req.params.cursor, 10);
const slice = all.slice(start, start + PAGE);
return {
resourceTemplates: slice.map(uriTemplate => ({ uriTemplate, name: uriTemplate, mimeType: 'text/plain' })),
nextCursor: start + PAGE < TOTAL ? String(start + PAGE) : undefined
};
});
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const seen = new Set<string>();
let pages = 0;
let result = await client.listResourceTemplates();
expect(result.nextCursor).toBeDefined();
for (;;) {
for (const t of result.resourceTemplates) {
expect(seen.has(t.uriTemplate)).toBe(false);
seen.add(t.uriTemplate);
}
pages++;
if (result.nextCursor === undefined) break;
result = await client.listResourceTemplates({ cursor: result.nextCursor });
expect(pages).toBeLessThan(50);
}
expect(result.nextCursor).toBeUndefined();
expect(pages).toBe(3);
expect(seen.size).toBe(TOTAL);
for (const name of all) expect(seen.has(name)).toBe(true);
},
{ title: 'raw server' }
);
verifies('mcpserver:resource:duplicate-name', async ({ transport }: TestArgs) => {
let server!: McpServer;
const makeServer = () => {
server = new McpServer({ name: 's', version: '0' });
server.registerResource('text', 'file:///fixture.txt', { mimeType: 'text/plain' }, () => ({
contents: [{ uri: 'file:///fixture.txt', mimeType: 'text/plain', text: 'hello, world' }]
}));
return server;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const before = await client.listResources();
expect(before.resources.filter(r => r.uri === 'file:///fixture.txt')).toHaveLength(1);
expect(() => server.registerResource('text', 'file:///fixture.txt', { mimeType: 'text/plain' }, () => ({ contents: [] }))).toThrow(
/already registered/i
);
const after = await client.listResources();
expect(after.resources.filter(r => r.uri === 'file:///fixture.txt')).toHaveLength(1);
const result = await client.readResource({ uri: 'file:///fixture.txt' });
expect(result.contents).toEqual([{ uri: 'file:///fixture.txt', mimeType: 'text/plain', text: 'hello, world' }]);
});
verifies('mcpserver:resource:handle-update-remove', async ({ transport }: TestArgs) => {
let handle!: RegisteredResource;
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
handle = s.registerResource('probe', 'file:///probe.txt', { mimeType: 'text/plain' }, () => ({
contents: [{ uri: 'file:///probe.txt', mimeType: 'text/plain', text: 'v1' }]
}));
return s;
};
let listChanged = 0;
const client = newClient();
client.setNotificationHandler(ResourceListChangedNotificationSchema, () => {
listChanged++;
});
await using _ = await wire(transport, makeServer, client);
expect((await client.listResources()).resources).toHaveLength(1);
const before = (await client.listResources()).resources.find(r => r.uri === 'file:///probe.txt');
expect(before).toBeDefined();
expect(before!.name).toBe('probe');
expect((await client.readResource({ uri: 'file:///probe.txt' })).contents).toEqual([
{ uri: 'file:///probe.txt', mimeType: 'text/plain', text: 'v1' }
]);
const beforeUpdate = listChanged;
handle.update({
name: 'probe (v2)',
callback: () => ({ contents: [{ uri: 'file:///probe.txt', mimeType: 'text/plain', text: 'v2' }] })
});
await vi.waitFor(() => expect(listChanged).toBeGreaterThan(beforeUpdate));
expect((await client.listResources()).resources).toHaveLength(1);
const after = (await client.listResources()).resources.find(r => r.uri === 'file:///probe.txt');
expect(after).toBeDefined();
expect(after!.name).toBe('probe (v2)');
expect((await client.readResource({ uri: 'file:///probe.txt' })).contents).toEqual([
{ uri: 'file:///probe.txt', mimeType: 'text/plain', text: 'v2' }
]);
const beforeRemove = listChanged;
handle.remove();
await vi.waitFor(() => expect(listChanged).toBeGreaterThan(beforeRemove));
expect((await client.listResources()).resources).toHaveLength(0);
await expect(client.readResource({ uri: 'file:///probe.txt' })).rejects.toBeInstanceOf(McpError);
const gone = (await client.listResources()).resources.find(r => r.uri === 'file:///probe.txt');
expect(gone).toBeUndefined();
});
verifies('mcpserver:resource:read-throws-surfaced', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerResource('throws', 'file:///throws', { mimeType: 'text/plain' }, () => {
throw new Error('resource read failed');
});
s.registerResource('ok', 'file:///ok.txt', { mimeType: 'text/plain' }, () => ({
contents: [{ uri: 'file:///ok.txt', mimeType: 'text/plain', text: 'ok' }]
}));
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
await expect(client.readResource({ uri: 'file:///throws' })).rejects.toMatchObject({
code: ErrorCode.InternalError,
message: expect.stringContaining('resource read failed')
});
const ok = await client.readResource({ uri: 'file:///ok.txt' });
expect(ok.contents).toEqual([{ uri: 'file:///ok.txt', mimeType: 'text/plain', text: 'ok' }]);
});
verifies('mcpserver:resource:template-list-callback', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerResource('static', 'file:///static.txt', { mimeType: 'text/plain' }, () => ({
contents: [{ uri: 'file:///static.txt', mimeType: 'text/plain', text: 'static' }]
}));
s.registerResource(
'listed',
new ResourceTemplate('listed://items/{id}', {
list: () => ({
resources: [
{ uri: 'listed://items/alpha', name: 'alpha' },
{ uri: 'listed://items/beta', name: 'beta' },
{ uri: 'listed://items/gamma', name: 'gamma' }
]
})
}),
{ description: 'Template-level description.', mimeType: 'text/plain' },
(uri, { id }) => ({ contents: [{ uri: uri.href, mimeType: 'text/plain', text: String(id) }] })
);
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const { resources } = await client.listResources();
expect(resources.some(r => r.uri === 'file:///static.txt')).toBe(true);
const listed = resources.filter(r => r.uri.startsWith('listed://items/'));
expect(listed.map(r => r.uri).sort()).toEqual(['listed://items/alpha', 'listed://items/beta', 'listed://items/gamma']);
const alpha = listed.find(r => r.uri === 'listed://items/alpha');
expect(alpha).toMatchObject({
uri: 'listed://items/alpha',
name: 'alpha',
description: 'Template-level description.',
mimeType: 'text/plain'
});
});
verifies('mcpserver:resource:metadata-override', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerResource(
'listed',
new ResourceTemplate('listed://items/{id}', {
list: () => ({
resources: [
{ uri: 'listed://items/alpha', name: 'alpha' },
{ uri: 'listed://items/gamma', name: 'gamma', description: 'Per-resource override.', mimeType: 'application/json' }
]
})
}),
{ description: 'Template-level.', mimeType: 'text/plain' },
(uri, { id }) => ({ contents: [{ uri: uri.href, mimeType: 'text/plain', text: String(id) }] })
);
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const { resources } = await client.listResources();
const alpha = resources.find(r => r.uri === 'listed://items/alpha');
const gamma = resources.find(r => r.uri === 'listed://items/gamma');
expect(alpha).toBeDefined();
expect(gamma).toBeDefined();
expect(alpha).toMatchObject({
uri: 'listed://items/alpha',
name: 'alpha',
description: 'Template-level.',
mimeType: 'text/plain'
});
expect(gamma).toMatchObject({
uri: 'listed://items/gamma',
name: 'gamma',
description: 'Per-resource override.',
mimeType: 'application/json'
});
});
verifies('mcpserver:resource:legacy-overload', async ({ transport }: TestArgs) => {
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
// (name, uri, readCallback)
s.resource('plain', 'e2e://legacy/plain', uri => ({
contents: [{ uri: uri.href, text: 'plain' }]
}));
// (name, uri, metadata, readCallback)
s.resource('with-meta', 'e2e://legacy/meta', { description: 'meta', mimeType: 'text/plain' }, uri => ({
contents: [{ uri: uri.href, text: 'meta' }]
}));
// (name, template, readCallback)
s.resource('tmpl', new ResourceTemplate('e2e://legacy/t/{id}', { list: undefined }), (uri, { id }) => ({
contents: [{ uri: uri.href, text: `t:${String(id)}` }]
}));
// (name, template, metadata, readCallback)
s.resource(
'tmpl-meta',
new ResourceTemplate('e2e://legacy/tm/{id}', { list: undefined }),
{ description: 'templated' },
(uri, { id }) => ({ contents: [{ uri: uri.href, text: `tm:${String(id)}` }] })
);
return s;
};
const client = newClient();
await using _ = await wire(transport, makeServer, client);
const list = await client.listResources();
const fixed = list.resources.map(r => r.uri).sort();
expect(fixed).toEqual(['e2e://legacy/meta', 'e2e://legacy/plain']);
expect(list.resources.find(r => r.uri === 'e2e://legacy/meta')).toMatchObject({
description: 'meta',
mimeType: 'text/plain'
});
const templates = await client.listResourceTemplates();
const tmplUris = templates.resourceTemplates.map(t => t.uriTemplate).sort();
expect(tmplUris).toEqual(['e2e://legacy/t/{id}', 'e2e://legacy/tm/{id}']);
expect(templates.resourceTemplates.find(t => t.uriTemplate === 'e2e://legacy/tm/{id}')).toMatchObject({
description: 'templated'
});
expect(await client.readResource({ uri: 'e2e://legacy/plain' })).toMatchObject({
contents: [{ uri: 'e2e://legacy/plain', text: 'plain' }]
});
expect(await client.readResource({ uri: 'e2e://legacy/t/abc' })).toMatchObject({
contents: [{ text: 't:abc' }]
});
expect(await client.readResource({ uri: 'e2e://legacy/tm/xyz' })).toMatchObject({
contents: [{ text: 'tm:xyz' }]
});
});

View file

@ -0,0 +1,180 @@
/**
* Self-contained test bodies for the roots surface.
*
* Roots are a client capability: the client exposes filesystem roots to the
* server. The server can request them via `roots/list`, and the client notifies
* the server when roots change via `notifications/roots/list_changed`.
*/
import { expect, vi } from 'vitest';
import { z } from 'zod/v4';
import { Client } from '../../../src/client/index.js';
import { McpServer } from '../../../src/server/mcp.js';
import {
ErrorCode,
type ListRootsResult,
ListRootsRequestSchema,
McpError,
RootsListChangedNotificationSchema
} from '../../../src/types.js';
import { wire } from '../helpers/index.js';
import type { TestArgs } from '../types.js';
import { verifies } from '../helpers/verifies.js';
verifies('roots:list:basic', async ({ transport }: TestArgs) => {
const received: Array<{ method: string }> = [];
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
// Drive the server→client call via the typed Server.listRoots() helper —
// this is the user-facing API and exercises the capability check.
s.registerTool('list-roots', { inputSchema: z.object({}) }, async () => {
const result = await s.server.listRoots();
return { structuredContent: { ok: true, result }, content: [] };
});
return s;
};
const client = new Client({ name: 'c', version: '0' }, { capabilities: { roots: { listChanged: true } } });
client.setRequestHandler(ListRootsRequestSchema, async req => {
received.push({ method: req.method });
return {
roots: [{ uri: 'file:///home/user/projects/myproject', name: 'My Project' }, { uri: 'file:///home/user/repos/backend' }]
};
});
await using _ = await wire(transport, makeServer, client);
const result = await client.callTool({ name: 'list-roots', arguments: {} });
expect(received).toHaveLength(1);
expect(received[0].method).toBe('roots/list');
expect(result.isError).toBeFalsy();
expect(result.structuredContent).toMatchObject({
ok: true,
result: {
roots: [{ uri: 'file:///home/user/projects/myproject', name: 'My Project' }, { uri: 'file:///home/user/repos/backend' }]
}
});
});
verifies('roots:list:empty', async ({ transport }: TestArgs) => {
const results: ListRootsResult[] = [];
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('list-roots', { inputSchema: z.object({}) }, async () => {
results.push(await s.server.listRoots());
return { content: [{ type: 'text', text: 'ok' }] };
});
return s;
};
// The client supports roots but currently has none to offer.
const client = new Client({ name: 'c', version: '0' }, { capabilities: { roots: {} } });
client.setRequestHandler(ListRootsRequestSchema, async () => ({ roots: [] }));
await using _ = await wire(transport, makeServer, client);
const result = await client.callTool({ name: 'list-roots', arguments: {} });
expect(result.isError).toBeFalsy();
expect(results).toHaveLength(1);
expect(results[0].roots).toEqual([]);
});
verifies('roots:list:client-error', async ({ transport }: TestArgs) => {
const failures: McpError[] = [];
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('list-roots', { inputSchema: z.object({}) }, async () => {
try {
await s.server.listRoots();
return { content: [{ type: 'text', text: 'unexpected success' }] };
} catch (e) {
if (e instanceof McpError) failures.push(e);
return { content: [{ type: 'text', text: 'rejected' }] };
}
});
return s;
};
const client = new Client({ name: 'c', version: '0' }, { capabilities: { roots: {} } });
client.setRequestHandler(ListRootsRequestSchema, async () => {
throw new McpError(ErrorCode.InternalError, 'roots provider crashed');
});
await using _ = await wire(transport, makeServer, client);
const result = await client.callTool({ name: 'list-roots', arguments: {} });
// The handler observed a rejection (not a hang or a malformed result), and it was an McpError.
expect(result.content).toEqual([{ type: 'text', text: 'rejected' }]);
expect(failures).toHaveLength(1);
expect(failures[0].code).toBe(ErrorCode.InternalError);
expect(failures[0].message).toMatch(/roots provider crashed/);
});
verifies('roots:list:not-supported', async ({ transport }: TestArgs) => {
const failures: McpError[] = [];
const makeServer = () => {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('list-roots', { inputSchema: z.object({}) }, async () => {
try {
await s.server.listRoots();
return { content: [{ type: 'text', text: 'unexpected success' }] };
} catch (e) {
if (e instanceof McpError) failures.push(e);
return { content: [{ type: 'text', text: 'rejected' }] };
}
});
return s;
};
// The client deliberately declares no roots capability and registers no roots/list handler.
const client = new Client({ name: 'c', version: '0' });
await using _ = await wire(transport, makeServer, client);
const result = await client.callTool({ name: 'list-roots', arguments: {} });
expect(result.content).toEqual([{ type: 'text', text: 'rejected' }]);
expect(failures).toHaveLength(1);
expect(failures[0].code).toBe(ErrorCode.MethodNotFound);
expect(failures[0].message).toMatch(/Method not found/);
});
verifies('roots:list-changed', async ({ transport }: TestArgs) => {
const refetched: ListRootsResult[] = [];
let server!: McpServer;
const makeServer = () => {
server = new McpServer({ name: 's', version: '0' });
server.server.setNotificationHandler(RootsListChangedNotificationSchema, async () => {
refetched.push(await server.server.listRoots());
});
return server;
};
let roots = [{ uri: 'file:///home/user/projects/a', name: 'A' }];
const client = new Client({ name: 'c', version: '0' }, { capabilities: { roots: { listChanged: true } } });
client.setRequestHandler(ListRootsRequestSchema, async () => ({ roots }));
await using _ = await wire(transport, makeServer, client);
// Change roots, signal the server, and observe the server's re-request
// returning the *new* roots.
roots = [
{ uri: 'file:///home/user/projects/a', name: 'A' },
{ uri: 'file:///home/user/projects/b', name: 'B' }
];
await client.sendRootsListChanged();
await vi.waitFor(() => expect(refetched).toHaveLength(1));
expect(refetched[0].roots).toEqual(roots);
roots = [{ uri: 'file:///home/user/projects/b', name: 'B' }];
await client.sendRootsListChanged();
await vi.waitFor(() => expect(refetched).toHaveLength(2));
expect(refetched[1].roots).toEqual(roots);
});

View file

@ -0,0 +1,582 @@
/**
* Self-contained test bodies for the sampling surface.
*
* Sampling is bidirectional: servers request LLM completions from clients by
* sending `sampling/createMessage`. Each test builds its own server (via
* factory) and client, wires them with {@link wire}, and asserts. Clients
* declare the `sampling` capability and register a handler via
* `setRequestHandler(CreateMessageRequestSchema, ...)`.
*/
import { expect } from 'vitest';
import { z } from 'zod/v4';
import { Client } from '../../../src/client/index.js';
import type { ServerOptions } from '../../../src/server/index.js';
import { McpServer } from '../../../src/server/mcp.js';
import {
type CreateMessageRequest,
CreateMessageRequestSchema,
type CreateMessageResultWithTools,
ErrorCode,
McpError,
type SamplingMessage
} from '../../../src/types.js';
import { tapWire, wire } from '../helpers/index.js';
import type { TestArgs } from '../types.js';
import { verifies } from '../helpers/verifies.js';
const newClient = (capabilities: { sampling?: object } = { sampling: {} }) => new Client({ name: 'c', version: '0' }, { capabilities });
function samplingServer() {
const s = new McpServer({ name: 's', version: '0' });
s.registerTool('ask-llm', { inputSchema: z.object({ prompt: z.string() }) }, async ({ prompt }) => {
const result = await s.server.createMessage({
messages: [{ role: 'user', content: { type: 'text', text: prompt } }],
maxTokens: 100
});
const text = Array.isArray(result.content)
? ((result.content[0] as { type: string; text?: string }).text ?? '')
: ((result.content as { type: string; text?: string }).text ?? '');
return { content: [{ type: 'text', text: `model said: ${text}` }] };
});
return s;
}
function passthroughServer(options?: ServerOptions) {
const s = new McpServer({ name: 's', version: '0' }, options);
s.registerTool(
'sampling-passthrough',
{
inputSchema: z.any(),
outputSchema: z.object({
ok: z.boolean(),
result: z.any().optional(),
code: z.number().optional(),
message: z.string().optional()
})
},
async args => {
try {
const result = await s.server.createMessage(args as unknown as CreateMessageRequest['params']);
return { structuredContent: { ok: true, result }, content: [{ type: 'text', text: JSON.stringify(result) }] };
} catch (e: unknown) {
const err = e as { code?: number; message?: string };
const message = err.message ?? String(e);
const code = err.code ?? (e instanceof Error ? undefined : (e as { error?: { code?: number } }).error?.code);
return { structuredContent: { ok: false, code, message }, content: [{ type: 'text', text: message }] };
}
}
);
return s;
}
verifies('sampling:capability:declare', async ({ transport }: TestArgs) => {
const received: CreateMessageRequest[] = [];
const client = newClient({ sampling: {} });
client.setRequestHandler(CreateMessageRequestSchema, async req => {
received.push(req);
return { model: 'stub', role: 'assistant', stopReason: 'endTurn', content: { type: 'text', text: 'ok' } };
});
await using _ = await wire(transport, samplingServer, client);
await client.callTool({ name: 'ask-llm', arguments: { prompt: 'hi' } });
expect(received).toHaveLength(1);
});
verifies('sampling:create:basic', async ({ transport }: TestArgs) => {
const received: CreateMessageRequest[] = [];
const client = newClient();
client.setRequestHandler(CreateMessageRequestSchema, async req => {
received.push(req);
return { model: 'test-model', role: 'assistant', stopReason: 'endTurn', content: { type: 'text', text: 'Paris' } };
});
await using _ = await wire(transport, passthroughServer, client);
const r = await client.callTool({
name: 'sampling-passthrough',
arguments: { messages: [{ role: 'user', content: { type: 'text', text: 'capital?' } }], maxTokens: 50 }
});
expect(received).toHaveLength(1);
expect(received[0].params.messages).toEqual([{ role: 'user', content: { type: 'text', text: 'capital?' } }]);
expect(received[0].params.maxTokens).toBe(50);
expect(r.structuredContent).toEqual({
ok: true,
result: { role: 'assistant', content: { type: 'text', text: 'Paris' }, model: 'test-model', stopReason: 'endTurn' }
});
});
verifies('sampling:create:include-context', async ({ transport }: TestArgs) => {
const received: CreateMessageRequest[] = [];
const client = newClient();
client.setRequestHandler(CreateMessageRequestSchema, async req => {
received.push(req);
return { model: 'm', role: 'assistant', stopReason: 'endTurn', content: { type: 'text', text: 'ok' } };
});
await using _ = await wire(transport, passthroughServer, client);
for (const val of ['none', 'thisServer', 'allServers'] as const) {
await client.callTool({ name: 'sampling-passthrough', arguments: { messages: [], maxTokens: 10, includeContext: val } });
}
await client.callTool({ name: 'sampling-passthrough', arguments: { messages: [], maxTokens: 10 } });
expect(received).toHaveLength(4);
expect(received[0].params.includeContext).toBe('none');
expect(received[1].params.includeContext).toBe('thisServer');
expect(received[2].params.includeContext).toBe('allServers');
expect(received[3].params.includeContext).toBeUndefined();
});
verifies('sampling:create:model-preferences', async ({ transport }: TestArgs) => {
const received: CreateMessageRequest[] = [];
const client = newClient();
client.setRequestHandler(CreateMessageRequestSchema, async req => {
received.push(req);
return { model: 'm', role: 'assistant', stopReason: 'endTurn', content: { type: 'text', text: 'ok' } };
});
await using _ = await wire(transport, passthroughServer, client);
const prefs = { hints: [{ name: 'sonnet' }], costPriority: 0.3, speedPriority: 0.7, intelligencePriority: 0.5 };
await client.callTool({ name: 'sampling-passthrough', arguments: { messages: [], maxTokens: 10, modelPreferences: prefs } });
expect(received).toHaveLength(1);
expect(received[0].params.modelPreferences).toEqual(prefs);
});
verifies('sampling:create:system-prompt', async ({ transport }: TestArgs) => {
const received: CreateMessageRequest[] = [];
const client = newClient();
client.setRequestHandler(CreateMessageRequestSchema, async req => {
received.push(req);
return { model: 'm', role: 'assistant', stopReason: 'endTurn', content: { type: 'text', text: 'ok' } };
});
await using _ = await wire(transport, passthroughServer, client);
await client.callTool({ name: 'sampling-passthrough', arguments: { messages: [], maxTokens: 10, systemPrompt: 'Be helpful' } });
expect(received).toHaveLength(1);
expect(received[0].params.systemPrompt).toBe('Be helpful');
});
verifies('sampling:create:tools', async ({ transport }: TestArgs) => {
const received: CreateMessageRequest[] = [];
const client = newClient({ sampling: { tools: {} } });
client.setRequestHandler(CreateMessageRequestSchema, async req => {
received.push(req);
const mode = req.params.toolChoice?.mode;
if (mode === 'auto' || mode === 'required') {
return {
model: 'm',
role: 'assistant',
stopReason: 'toolUse',
content: [{ type: 'tool_use', id: 'c1', name: 'weather', input: { city: 'SF' } }]
} satisfies CreateMessageResultWithTools;
}
return { model: 'm', role: 'assistant', stopReason: 'endTurn', content: { type: 'text', text: 'sunny' } };
});
await using _ = await wire(transport, passthroughServer, client);
const tools = [
{ name: 'weather', description: 'Get weather', inputSchema: { type: 'object' as const, properties: { city: { type: 'string' } } } }
];
for (const mode of ['auto', 'required', 'none'] as const) {
await client.callTool({ name: 'sampling-passthrough', arguments: { messages: [], maxTokens: 10, tools, toolChoice: { mode } } });
}
expect(received).toHaveLength(3);
for (let i = 0; i < 3; i++) {
expect(received[i].params.tools).toHaveLength(1);
expect(received[i].params.tools![0].name).toBe('weather');
expect(received[i].params.toolChoice?.mode).toBe(['auto', 'required', 'none'][i]);
}
});
verifies('sampling:error:user-rejected', async ({ transport }: TestArgs) => {
const client = newClient();
client.setRequestHandler(CreateMessageRequestSchema, async () => {
throw new McpError(-1, 'User rejected sampling request');
});
await using _ = await wire(transport, passthroughServer, client);
const r = await client.callTool({ name: 'sampling-passthrough', arguments: { messages: [], maxTokens: 10 } });
expect(r.structuredContent).toMatchObject({ ok: false, code: -1 });
expect((r.structuredContent as { message?: string }).message).toMatch(/User rejected sampling request/);
});
verifies('sampling:message:content-cardinality', async ({ transport }: TestArgs) => {
const received: CreateMessageRequest[] = [];
const client = newClient();
client.setRequestHandler(CreateMessageRequestSchema, async req => {
received.push(req);
return { model: 'm', role: 'assistant', stopReason: 'endTurn', content: { type: 'text', text: 'ok' } };
});
await using _ = await wire(transport, samplingServer, client);
await client.callTool({ name: 'ask-llm', arguments: { prompt: 'one' } });
expect(received).toHaveLength(1);
const msg = received[0].params.messages[0] as SamplingMessage;
expect(Array.isArray(msg.content)).toBe(false);
expect(msg.content).toEqual({ type: 'text', text: 'one' });
received.length = 0;
const client2 = newClient();
client2.setRequestHandler(CreateMessageRequestSchema, async req => {
received.push(req);
return { model: 'm', role: 'assistant', stopReason: 'endTurn', content: { type: 'text', text: 'ok' } };
});
await using _2 = await wire(transport, passthroughServer, client2);
await client2.callTool({
name: 'sampling-passthrough',
arguments: {
messages: [
{
role: 'user',
content: [
{ type: 'text', text: 'a' },
{ type: 'text', text: 'b' }
]
}
],
maxTokens: 10
}
});
expect(received).toHaveLength(1);
const msg2 = received[0].params.messages[0] as SamplingMessage;
expect(Array.isArray(msg2.content)).toBe(true);
expect(msg2.content).toEqual([
{ type: 'text', text: 'a' },
{ type: 'text', text: 'b' }
]);
});
verifies('sampling:result:no-tools-single-content', async ({ transport }: TestArgs) => {
const client = newClient();
// No tools/toolChoice in the request, so the client's wrapped sampling handler validates against CreateMessageResultSchema and rejects array content with -32602.
client.setRequestHandler(CreateMessageRequestSchema, async () => ({
model: 'm',
role: 'assistant',
stopReason: 'endTurn',
content: [{ type: 'text', text: 'array-content' }]
}));
await using _ = await wire(transport, samplingServer, client);
const r = await client.callTool({ name: 'ask-llm', arguments: { prompt: 'hi' } });
expect(r.isError).toBe(true);
expect(r.content).toEqual([{ type: 'text', text: expect.stringContaining('Invalid sampling result') }]);
// The handler's text must never reach the tool result: the client rejects before any sampling result is returned.
expect((r.content as [{ text: string }])[0].text).not.toContain('array-content');
});
verifies('sampling:result:with-tools-array-content', async ({ transport }: TestArgs) => {
const client = newClient({ sampling: { tools: {} } });
client.setRequestHandler(CreateMessageRequestSchema, async () => {
return {
model: 'm',
role: 'assistant',
stopReason: 'toolUse',
content: [
{ type: 'text', text: 'using' },
{ type: 'tool_use', id: 'c1', name: 'noop', input: {} }
]
} satisfies CreateMessageResultWithTools;
});
await using _ = await wire(transport, passthroughServer, client);
const r = await client.callTool({
name: 'sampling-passthrough',
arguments: { messages: [], maxTokens: 10, tools: [{ name: 'noop', inputSchema: { type: 'object' as const } }] }
});
expect(r.structuredContent).toMatchObject({
ok: true,
result: {
stopReason: 'toolUse',
content: [
{ type: 'text', text: 'using' },
{ type: 'tool_use', id: 'c1', name: 'noop', input: {} }
]
}
});
});
verifies('sampling:tool-result:no-mixed-content', async ({ transport }: TestArgs) => {
const client = newClient();
client.setRequestHandler(CreateMessageRequestSchema, async () => {
return { model: 'm', role: 'assistant', stopReason: 'endTurn', content: { type: 'text', text: 'unreachable' } };
});
await using _ = await wire(transport, passthroughServer, client);
const r = await client.callTool({
name: 'sampling-passthrough',
arguments: {
messages: [
{ role: 'assistant', content: [{ type: 'tool_use', id: 'c1', name: 'n', input: {} }] },
{
role: 'user',
content: [
{ type: 'tool_result', toolUseId: 'c1', content: [] },
{ type: 'text', text: 'mixed' }
]
}
],
maxTokens: 10
}
});
expect(r.structuredContent).toMatchObject({ ok: false, code: ErrorCode.InvalidParams });
expect((r.structuredContent as { message?: string }).message).toMatch(/tool.?result/i);
});
verifies('sampling:tool-use:result-balance', async ({ transport }: TestArgs) => {
const received: CreateMessageRequest[] = [];
const client = newClient({ sampling: { tools: {} } });
client.setRequestHandler(CreateMessageRequestSchema, async req => {
received.push(req);
return { model: 'm', role: 'assistant', stopReason: 'endTurn', content: { type: 'text', text: 'ok' } };
});
await using _ = await wire(transport, passthroughServer, client);
const tools = [{ name: 'weather', inputSchema: { type: 'object' as const } }];
const balanced = await client.callTool({
name: 'sampling-passthrough',
arguments: {
messages: [
{ role: 'user', content: { type: 'text', text: 'hi' } },
{ role: 'assistant', content: [{ type: 'tool_use', id: 'c1', name: 'weather', input: {} }] },
{ role: 'user', content: [{ type: 'tool_result', toolUseId: 'c1', content: [] }] }
],
tools,
maxTokens: 10
}
});
expect(balanced.structuredContent).toMatchObject({ ok: true });
expect(received).toHaveLength(1);
const missing = await client.callTool({
name: 'sampling-passthrough',
arguments: {
messages: [
{ role: 'user', content: { type: 'text', text: 'hi' } },
{
role: 'assistant',
content: [
{ type: 'tool_use', id: 'a', name: 'weather', input: {} },
{ type: 'tool_use', id: 'b', name: 'weather', input: {} }
]
},
{ role: 'user', content: [{ type: 'tool_result', toolUseId: 'a', content: [] }] }
],
tools,
maxTokens: 10
}
});
expect(missing.structuredContent).toMatchObject({ ok: false, code: ErrorCode.InvalidParams });
expect(received).toHaveLength(1);
const trailing = await client.callTool({
name: 'sampling-passthrough',
arguments: {
messages: [
{ role: 'user', content: { type: 'text', text: 'hi' } },
{ role: 'assistant', content: [{ type: 'tool_use', id: 'c', name: 'weather', input: {} }] }
],
tools,
maxTokens: 10
}
});
expect(trailing.structuredContent).toMatchObject({ ok: false, code: ErrorCode.InvalidParams });
expect(received).toHaveLength(1);
});
verifies('sampling:tools:server-gated-by-capability', async ({ transport }: TestArgs) => {
const received: CreateMessageRequest[] = [];
const client = newClient({ sampling: {} });
client.setRequestHandler(CreateMessageRequestSchema, async req => {
received.push(req);
return { model: 'm', role: 'assistant', stopReason: 'endTurn', content: { type: 'text', text: 'unreachable' } };
});
await using _ = await wire(transport, passthroughServer, client);
const withTools = await client.callTool({
name: 'sampling-passthrough',
arguments: { messages: [], maxTokens: 10, tools: [{ name: 'n', inputSchema: { type: 'object' as const } }] }
});
expect(withTools.structuredContent).toMatchObject({ ok: false });
expect((withTools.structuredContent as { message?: string }).message).toMatch(/sampling.*tools/i);
expect(received).toHaveLength(0);
const withChoice = await client.callTool({
name: 'sampling-passthrough',
arguments: { messages: [], maxTokens: 10, toolChoice: { mode: 'auto' } }
});
expect(withChoice.structuredContent).toMatchObject({ ok: false });
expect((withChoice.structuredContent as { message?: string }).message).toMatch(/sampling.*tools/i);
expect(received).toHaveLength(0);
const empty = await client.callTool({
name: 'sampling-passthrough',
arguments: { messages: [], maxTokens: 10, tools: [], toolChoice: { mode: 'required' } }
});
expect(empty.structuredContent).toMatchObject({ ok: false });
expect((empty.structuredContent as { message?: string }).message).toMatch(/sampling.*tools/i);
expect(received).toHaveLength(0);
});
verifies('sampling:create:image-content', async ({ transport }: TestArgs) => {
const received: CreateMessageRequest[] = [];
const client = newClient();
client.setRequestHandler(CreateMessageRequestSchema, async req => {
received.push(req);
return {
model: 'mock-vision-1',
role: 'assistant',
stopReason: 'endTurn',
content: { type: 'image', data: 'Y2F0', mimeType: 'image/png' }
};
});
await using _ = await wire(transport, passthroughServer, client);
const r = await client.callTool({
name: 'sampling-passthrough',
arguments: {
messages: [{ role: 'user', content: { type: 'image', data: 'aW1n', mimeType: 'image/jpeg' } }],
maxTokens: 100
}
});
expect(received).toHaveLength(1);
expect(received[0].params.messages).toEqual([{ role: 'user', content: { type: 'image', data: 'aW1n', mimeType: 'image/jpeg' } }]);
expect(received[0].params.maxTokens).toBe(100);
expect(r.structuredContent).toEqual({
ok: true,
result: {
role: 'assistant',
content: { type: 'image', data: 'Y2F0', mimeType: 'image/png' },
model: 'mock-vision-1',
stopReason: 'endTurn'
}
});
});
verifies('sampling:create:audio-content', async ({ transport }: TestArgs) => {
const received: CreateMessageRequest[] = [];
const client = newClient();
client.setRequestHandler(CreateMessageRequestSchema, async req => {
received.push(req);
return {
model: 'mock-audio-1',
role: 'assistant',
stopReason: 'endTurn',
content: { type: 'audio', data: 'aGVsbG8=', mimeType: 'audio/wav' }
};
});
await using _ = await wire(transport, passthroughServer, client);
const r = await client.callTool({
name: 'sampling-passthrough',
arguments: {
messages: [{ role: 'user', content: { type: 'audio', data: 'c25k', mimeType: 'audio/mpeg' } }],
maxTokens: 100
}
});
expect(received).toHaveLength(1);
expect(received[0].params.messages).toEqual([{ role: 'user', content: { type: 'audio', data: 'c25k', mimeType: 'audio/mpeg' } }]);
expect(received[0].params.maxTokens).toBe(100);
expect(r.structuredContent).toEqual({
ok: true,
result: {
role: 'assistant',
content: { type: 'audio', data: 'aGVsbG8=', mimeType: 'audio/wav' },
model: 'mock-audio-1',
stopReason: 'endTurn'
}
});
});
verifies('sampling:context:server-gated-by-capability', async ({ transport }: TestArgs) => {
const received: CreateMessageRequest[] = [];
// Client declares plain sampling but not the sampling.context sub-capability.
const client = newClient({ sampling: {} });
client.setRequestHandler(CreateMessageRequestSchema, async req => {
received.push(req);
return { model: 'm', role: 'assistant', stopReason: 'endTurn', content: { type: 'text', text: 'ok' } };
});
await using _ = await wire(transport, passthroughServer, client);
const none = await client.callTool({
name: 'sampling-passthrough',
arguments: { messages: [], maxTokens: 10, includeContext: 'none' }
});
expect(none.structuredContent).toMatchObject({ ok: true });
expect(received).toHaveLength(1);
const thisServer = await client.callTool({
name: 'sampling-passthrough',
arguments: { messages: [], maxTokens: 10, includeContext: 'thisServer' }
});
expect(thisServer.structuredContent).toMatchObject({ ok: false, message: expect.stringMatching(/context/i) });
expect(received).toHaveLength(1);
const allServers = await client.callTool({
name: 'sampling-passthrough',
arguments: { messages: [], maxTokens: 10, includeContext: 'allServers' }
});
expect(allServers.structuredContent).toMatchObject({ ok: false, message: expect.stringMatching(/context/i) });
expect(received).toHaveLength(1);
});
verifies('sampling:create:not-supported', async ({ transport }: TestArgs) => {
// Client declares no sampling capability at all and registers no sampling handler.
const client = newClient({});
await using _ = await wire(transport, () => passthroughServer({ enforceStrictCapabilities: true }), client);
const tap = tapWire(client);
const r = await client.callTool({
name: 'sampling-passthrough',
arguments: { messages: [{ role: 'user', content: { type: 'text', text: 'Say hello.' } }], maxTokens: 100 }
});
expect(r.structuredContent).toMatchObject({ ok: false, message: expect.stringMatching(/does not support sampling/i) });
// The refusal happens server-side: no sampling/createMessage request ever reaches the client.
expect(tap.received.filter(m => 'method' in m && m.method === 'sampling/createMessage')).toEqual([]);
});

View file

@ -0,0 +1,417 @@
/**
* Self-contained test bodies for the real spawned-process stdio transport.
*
* Unlike other scenario areas these do not use `wire()`: every body spawns the
* fixture server in `fixtures/stdio-server.ts` as a real child process via
* {@link StdioClientTransport}, so the matrix `transport` arg is ignored and
* the requirements should list `transports: ['stdio']` only. Each body closes
* the transport in a `finally` so no child process outlives the test.
*
* Function names mirror the requirement id in camelCase.
*/
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { expect, vi } from 'vitest';
import { Client } from '../../../src/client/index.js';
import { StdioClientTransport } from '../../../src/client/stdio.js';
import { JSONRPCMessageSchema } from '../../../src/types.js';
import type { TestArgs } from '../types.js';
import { verifies } from '../helpers/verifies.js';
/** Absolute path to the runnable fixture server (executed with tsx). */
const FIXTURE_PATH = fileURLToPath(new URL('../fixtures/stdio-server.ts', import.meta.url));
/** Repo root — used as the spawn cwd so `npx`/node resolve the workspace-local `tsx`. */
const REPO_ROOT = fileURLToPath(new URL('../../../', import.meta.url));
/** Plain client with no extra capabilities declared. */
const newClient = () => new Client({ name: 'c', version: '0' });
/** True while `pid` refers to a live process (signal 0 probes existence only). */
function processAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
/** Asserts a raw wire capture is one JSON-RPC message per line (newline count == message count) and returns the lines. */
function expectOneMessagePerLine(raw: string): string[] {
expect(raw.endsWith('\n')).toBe(true);
const lines = raw.slice(0, -1).split('\n');
const messages = lines.map(line => JSONRPCMessageSchema.parse(JSON.parse(line)));
const newlineCount = raw.split('\n').length - 1;
expect(messages).toHaveLength(newlineCount);
return lines;
}
verifies('transport:stdio:clean-shutdown', async (_args: TestArgs) => {
// Direct spawn (not npx) so transport.pid IS the server, with a thin wrapper
// logging stderr markers: the exit path is observed (stdin EOF seen, no
// SIGTERM delivered) instead of inferred from a wall-clock bound.
const wrapperScript = [
`process.on('SIGTERM', () => { process.stderr.write('[wrapper] sigterm received\\n'); process.exit(143); });`,
`process.stdin.on('end', () => { process.stderr.write('[wrapper] stdin eof\\n'); });`,
`await import(${JSON.stringify(pathToFileURL(FIXTURE_PATH).href)});`
].join('\n');
const transport = new StdioClientTransport({
command: process.execPath,
args: ['--import', 'tsx', '--input-type=module', '-e', wrapperScript],
cwd: REPO_ROOT,
stderr: 'pipe'
});
const stderr = transport.stderr;
if (stderr === null) throw new Error('expected transport.stderr when spawned with stderr: "pipe"');
let captured = '';
stderr.on('data', (chunk: Buffer) => {
captured += chunk.toString();
});
const client = newClient();
let sawClose = false;
client.onclose = () => {
sawClose = true;
};
try {
await client.connect(transport);
const childPid = transport.pid;
if (childPid === null) throw new Error('expected a child pid after connect');
expect(processAlive(childPid)).toBe(true);
const echoed = await client.callTool({ name: 'echo', arguments: { text: 'shutting down soon' } });
expect(echoed.isError).toBeFalsy();
expect(echoed.content).toEqual([{ type: 'text', text: 'shutting down soon' }]);
// close() ends the child's stdin; a well-behaved server exits on EOF alone.
await client.close();
expect(sawClose).toBe(true);
expect(processAlive(childPid)).toBe(false);
await vi.waitFor(() => expect(captured).toContain('[wrapper] stdin eof'), { timeout: 2_000, interval: 25 });
// Exit came from the EOF path, not the SIGTERM/SIGKILL escalation ladder.
expect(captured).not.toContain('[wrapper] sigterm received');
} finally {
await transport.close();
}
});
verifies('transport:stdio:no-embedded-newlines', async (_args: TestArgs) => {
const payload = [
'first line',
'second line with a carriage return\r',
'',
'{"jsonrpc":"2.0","id":99,"method":"tools/call","params":{}}',
'last line',
''
].join('\n');
// Tee wrapper between client and fixture: appends each direction's raw bytes
// to a capture file (synchronously, before forwarding) so the serialized wire
// framing itself is observable, not just the content round-trip.
const captureDir = await mkdtemp(join(tmpdir(), 'stdio-wire-'));
const clientToServerPath = join(captureDir, 'client-to-server.jsonl');
const serverToClientPath = join(captureDir, 'server-to-client.jsonl');
const teeScript = [
`import { spawn } from 'node:child_process';`,
`import { appendFileSync } from 'node:fs';`,
`const child = spawn(process.execPath, ['--import', 'tsx', ${JSON.stringify(FIXTURE_PATH)}], { stdio: ['pipe', 'pipe', 'inherit'] });`,
`process.stdin.on('data', c => { appendFileSync(${JSON.stringify(clientToServerPath)}, c); child.stdin.write(c); });`,
`process.stdin.on('end', () => child.stdin.end());`,
`child.stdout.on('data', c => { appendFileSync(${JSON.stringify(serverToClientPath)}, c); process.stdout.write(c); });`,
`child.on('exit', code => process.exit(code === null ? 1 : code));`
].join('\n');
const transport = new StdioClientTransport({
command: process.execPath,
args: ['--input-type=module', '-e', teeScript],
cwd: REPO_ROOT
});
const client = newClient();
try {
await client.connect(transport);
// Round-trip sanity check: the multi-line payload survives intact.
const echoed = await client.callTool({ name: 'echo', arguments: { text: payload } });
expect(echoed.isError).toBeFalsy();
expect(echoed.content).toEqual([{ type: 'text', text: payload }]);
const after = await client.callTool({ name: 'echo', arguments: { text: 'still framed' } });
expect(after.content).toEqual([{ type: 'text', text: 'still framed' }]);
// Captures are complete once the calls above resolved (bytes are written
// to disk before being forwarded), so read them before close().
const clientLines = expectOneMessagePerLine(await readFile(clientToServerPath, 'utf8'));
const serverLines = expectOneMessagePerLine(await readFile(serverToClientPath, 'utf8'));
// initialize, notifications/initialized, two tools/call ↔ three responses.
expect(clientLines).toHaveLength(4);
expect(serverLines).toHaveLength(3);
// The newline-riddled payload crossed each direction inside exactly one
// line, escaped — never split across lines.
const escapedPayload = JSON.stringify(payload);
expect(clientLines.filter(line => line.includes(escapedPayload))).toHaveLength(1);
expect(serverLines.filter(line => line.includes(escapedPayload))).toHaveLength(1);
} finally {
await transport.close();
await rm(captureDir, { recursive: true, force: true });
}
});
verifies('transport:stdio:shutdown-escalation', async (_args: TestArgs) => {
// Spawned directly (`node --import tsx`) instead of via `npx tsx`: close()
// signals the process the transport spawned, and behind npx the SIGTERM/
// SIGKILL escalation would only ever reach the npx wrapper while the real
// server — which ignores SIGTERM — survived as an orphan. Direct spawn makes
// the spawned process BE the server so the full ladder is observable.
const transport = new StdioClientTransport({
command: process.execPath,
args: ['--import', 'tsx', FIXTURE_PATH],
cwd: REPO_ROOT,
env: { E2E_IGNORE_SIGTERM: '1' },
stderr: 'pipe'
});
const stderr = transport.stderr;
if (stderr === null) throw new Error('expected transport.stderr when spawned with stderr: "pipe"');
let captured = '';
stderr.on('data', (chunk: Buffer) => {
captured += chunk.toString();
});
const client = newClient();
let childPid: number | null = null;
try {
await client.connect(transport);
childPid = transport.pid;
if (childPid === null) throw new Error('expected a child pid after connect');
const pid = childPid;
expect(processAlive(pid)).toBe(true);
const echoed = await client.callTool({ name: 'echo', arguments: { text: 'about to ignore shutdown' } });
expect(echoed.content).toEqual([{ type: 'text', text: 'about to ignore shutdown' }]);
// The fixture survives stdin EOF and swallows SIGTERM, so close() must
// walk the whole escalation ladder (two grace periods, ~4s wall clock).
await client.close();
// SIGTERM really was delivered and ignored — surviving rung 1 (stdin
// EOF) and rung 2 (SIGTERM) is proven by the marker, so the child's
// termination below can only have come from the SIGKILL escalation.
await vi.waitFor(() => expect(captured).toContain('[stdio-server] sigterm ignored'), { timeout: 1_000, interval: 25 });
await vi.waitFor(() => expect(processAlive(pid)).toBe(false), { timeout: 2_000, interval: 25 });
} finally {
await transport.close();
// Belt and braces: if an assertion threw mid-ladder the stubborn child
// may still be alive and must not outlive the test run.
if (childPid !== null && processAlive(childPid)) {
process.kill(childPid, 'SIGKILL');
}
}
});
verifies('transport:stdio:stderr-passthrough', async (_args: TestArgs) => {
const transport = new StdioClientTransport({ command: 'npx', args: ['tsx', FIXTURE_PATH], cwd: REPO_ROOT, stderr: 'pipe' });
// With stderr: 'pipe' the stream is available before start(), so listeners
// attached here cannot miss output written while the server boots.
const stderr = transport.stderr;
if (stderr === null) throw new Error('expected transport.stderr when spawned with stderr: "pipe"');
let captured = '';
stderr.on('data', (chunk: Buffer) => {
captured += chunk.toString();
});
const client = newClient();
try {
await client.connect(transport);
// The startup marker the server wrote to stderr is observable on
// transport.stderr — the transport neither swallows it nor mixes it
// into the stdout JSON-RPC channel.
await vi.waitFor(() => expect(captured).toContain('[stdio-server] ready'), { timeout: 2_000, interval: 25 });
// ...and stderr output does not disturb the JSON-RPC channel itself.
const echoed = await client.callTool({ name: 'echo', arguments: { text: 'hello over stdio' } });
expect(echoed.isError).toBeFalsy();
expect(echoed.content).toEqual([{ type: 'text', text: 'hello over stdio' }]);
} finally {
await transport.close();
}
});
verifies('lifecycle:connect:onerror-pre-handshake', async (_args: TestArgs) => {
// Requirement: 'Transport errors emitted after transport.start() but before
// client.connect() resolves are delivered to a client.onerror handler set
// prior to connect (Protocol wires transport.onerror before start() and
// before the initialize handshake).'
//
// Spawn the fixture with E2E_GARBAGE_STDOUT=1: it writes non-JSON garbage to
// stdout immediately (before the handshake) and exits. The garbage fails to
// parse, triggering transport.onerror. Assert that client.onerror fires and
// that connect() does not hang or falsely succeed.
const transport = new StdioClientTransport({
command: 'npx',
args: ['tsx', FIXTURE_PATH],
cwd: REPO_ROOT,
env: { E2E_GARBAGE_STDOUT: '1' }
});
const client = newClient();
const errors: Error[] = [];
client.onerror = (error: Error) => {
errors.push(error);
};
try {
// connect() should either reject (because the handshake never completes)
// or hang until timeout. We bound the whole test well under 10s by racing
// with a short timeout, ensuring we don't wait forever if the SDK
// incorrectly succeeds or hangs.
const connectPromise = client.connect(transport);
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('connect timed out (expected behavior)')), 8_000)
);
await expect(Promise.race([connectPromise, timeoutPromise])).rejects.toThrow();
// At this point, client.onerror should have fired at least once due to
// the garbage JSON parse failures.
expect(errors.length).toBeGreaterThan(0);
// At least one error should relate to JSON parsing of the garbage lines.
const hasJsonError = errors.some(
e => e.message.includes('JSON') || e.message.includes('parse') || e.message.includes('Unexpected token')
);
expect(hasJsonError).toBe(true);
} finally {
await transport.close();
}
});
verifies('transport:stdio:default-env-safelist', async (_args: TestArgs) => {
// Requirement: 'StdioClientTransport spawned with no `env` option passes
// only DEFAULT_INHERITED_ENV_VARS (PATH, HOME, USER, …) to the child;
// arbitrary parent process.env entries (secrets) are not inherited.
// getDefaultEnvironment() is the public helper that produces this safelist.'
//
// Set a sentinel env var in the parent for the duration of this test, spawn
// the fixture WITHOUT passing env (so getDefaultEnvironment() is applied),
// call the env-report tool to see which variable NAMES reached the child,
// then assert the sentinel is NOT present while expected safelisted vars are.
const SENTINEL_KEY = 'E2E_SECRET_SENTINEL';
const originalValue = process.env[SENTINEL_KEY];
process.env[SENTINEL_KEY] = 'should-not-reach-child';
const transport = new StdioClientTransport({
command: 'npx',
args: ['tsx', FIXTURE_PATH],
cwd: REPO_ROOT
// Deliberately omit `env` option, so getDefaultEnvironment() applies.
});
const client = newClient();
try {
await client.connect(transport);
// Call env-report tool: returns JSON.stringify(Object.keys(process.env).sort()).
const result = await client.callTool({ name: 'env-report', arguments: {} });
expect(result.isError).toBeFalsy();
if (!result.content || !Array.isArray(result.content)) {
throw new Error('expected content array from env-report');
}
expect(result.content).toHaveLength(1);
const firstContent = result.content[0];
if (!firstContent || firstContent.type !== 'text') {
throw new Error('expected text content from env-report');
}
const childEnvKeys: string[] = JSON.parse(firstContent.text);
// The sentinel must NOT be present in the child.
expect(childEnvKeys).not.toContain(SENTINEL_KEY);
// At least one safelisted variable (from DEFAULT_INHERITED_ENV_VARS)
// should be present. We pick variables that are very likely to exist
// on any platform the test runs on.
const expectedKeys = process.platform === 'win32' ? ['PATH', 'SYSTEMROOT'] : ['PATH', 'HOME'];
for (const key of expectedKeys) {
// Only assert presence if the parent has it (some minimal containers might lack HOME).
if (process.env[key] !== undefined) {
expect(childEnvKeys).toContain(key);
}
}
} finally {
await transport.close();
// Restore original env state.
if (originalValue === undefined) {
delete process.env[SENTINEL_KEY];
} else {
process.env[SENTINEL_KEY] = originalValue;
}
}
});
verifies('transport:stdio:pre-started-tolerated', async (_args: TestArgs) => {
// Real consumers call transport.start() themselves (to capture early stderr) before handing the transport to connect().
const transport = new StdioClientTransport({
command: process.execPath,
args: ['--import', 'tsx', FIXTURE_PATH],
cwd: REPO_ROOT,
stderr: 'pipe'
});
const stderr = transport.stderr;
if (stderr === null) throw new Error('expected transport.stderr when spawned with stderr: "pipe"');
let captured = '';
stderr.on('data', (chunk: Buffer) => {
captured += chunk.toString();
});
// Counts fixture boots observed on stderr — more than one means connect() double-spawned the server.
const readyCount = () => captured.split('[stdio-server] ready').length - 1;
const client = newClient();
try {
await transport.start();
const preStartedPid = transport.pid;
if (preStartedPid === null) throw new Error('expected a child pid after manual start()');
expect(processAlive(preStartedPid)).toBe(true);
await vi.waitFor(() => expect(readyCount()).toBe(1), { timeout: 5_000, interval: 25 });
// The contract is disjunctive: connect() over a pre-started transport either completes the handshake or rejects with the recognizable already-started error.
let connectOutcome: { ok: true } | { ok: false; error: unknown } = { ok: true };
try {
await client.connect(transport);
} catch (error) {
connectOutcome = { ok: false, error };
}
if (connectOutcome.ok) {
// Tolerated branch: the handshake ran over the manually started child, so tool calls work...
const echoed = await client.callTool({ name: 'echo', arguments: { text: 'pre-started transport' } });
expect(echoed.isError).toBeFalsy();
expect(echoed.content).toEqual([{ type: 'text', text: 'pre-started transport' }]);
// ...and connect() did not spawn a second server process behind the consumer's back.
expect(transport.pid).toBe(preStartedPid);
expect(readyCount()).toBe(1);
} else {
if (!(connectOutcome.error instanceof Error)) throw new Error('expected connect() to reject with an Error');
// Recognizable: the message names the already-started condition consumers branch on.
expect(connectOutcome.error.message).toContain('StdioClientTransport already started');
// Ignorable: the rejection left the manually started child alive, still owned by the transport, and not double-spawned.
expect(processAlive(preStartedPid)).toBe(true);
expect(transport.pid).toBe(preStartedPid);
expect(readyCount()).toBe(1);
}
} finally {
await transport.close();
}
});

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