Add end-to-end test suite (#2167)
This commit is contained in:
parent
bf1e022bd2
commit
c36e1ef5bb
23 changed files with 19371 additions and 1 deletions
1747
test/e2e/scenarios/client-auth.test.ts
Normal file
1747
test/e2e/scenarios/client-auth.test.ts
Normal file
File diff suppressed because it is too large
Load diff
355
test/e2e/scenarios/completion.test.ts
Normal file
355
test/e2e/scenarios/completion.test.ts
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
/**
|
||||
* Self-contained test bodies for the completion surface.
|
||||
*
|
||||
* Completion provides autocompletion for prompt arguments and resource template
|
||||
* variables. The server declares the `completions` capability and handles
|
||||
* `completion/complete` requests, returning up to 100 string suggestions based
|
||||
* on the partial value and optional context (already-resolved variables).
|
||||
*/
|
||||
|
||||
import { expect } from 'vitest';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { Client } from '../../../src/client/index.js';
|
||||
import { Server } from '../../../src/server/index.js';
|
||||
import { completable } from '../../../src/server/completable.js';
|
||||
import { McpServer, ResourceTemplate } from '../../../src/server/mcp.js';
|
||||
import {
|
||||
CompleteRequestSchema,
|
||||
CompleteResultSchema,
|
||||
ErrorCode,
|
||||
ListPromptsRequestSchema,
|
||||
ListResourcesRequestSchema,
|
||||
McpError,
|
||||
ReadResourceRequestSchema
|
||||
} from '../../../src/types.js';
|
||||
|
||||
import { wire } from '../helpers/index.js';
|
||||
import type { TestArgs } from '../types.js';
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
|
||||
const COLORS = ['red', 'green', 'blue', 'rebeccapurple'] as const;
|
||||
const FILE_PATHS = ['README.md', 'src/index.ts', 'src/types.ts'] as const;
|
||||
const REPOS_BY_OWNER: Record<string, readonly string[]> = {
|
||||
'acme-corp': ['widget-sdk', 'gadget-sdk', 'docs'],
|
||||
globex: ['frobnicator', 'reticulator']
|
||||
};
|
||||
const MANY_TOTAL = 150;
|
||||
|
||||
const newClient = () => new Client({ name: 'c', version: '0' });
|
||||
|
||||
function colorCompletionServer(): McpServer {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerPrompt(
|
||||
'complete-color',
|
||||
{ argsSchema: { color: completable(z.string(), value => COLORS.filter(c => c.startsWith(value))) } },
|
||||
({ color }) => ({ messages: [{ role: 'user', content: { type: 'text', text: `color=${color}` } }] })
|
||||
);
|
||||
return s;
|
||||
}
|
||||
|
||||
verifies('completion:capability:declared', async ({ transport }: TestArgs) => {
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, colorCompletionServer, client);
|
||||
|
||||
const caps = client.getServerCapabilities();
|
||||
expect(caps?.completions).toBeDefined();
|
||||
expect(typeof caps?.completions).toBe('object');
|
||||
|
||||
const result = await client.complete({
|
||||
ref: { type: 'ref/prompt', name: 'complete-color' },
|
||||
argument: { name: 'color', value: '' }
|
||||
});
|
||||
expect(Array.isArray(result.completion.values)).toBe(true);
|
||||
});
|
||||
|
||||
verifies('completion:complete:not-supported', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerPrompt('summarize-code', { argsSchema: { code: z.string() } }, ({ code }) => ({
|
||||
messages: [{ role: 'user', content: { type: 'text', text: `Summarize the following code:\n${code}` } }]
|
||||
}));
|
||||
return s;
|
||||
};
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const caps = client.getServerCapabilities();
|
||||
expect(caps).toBeDefined();
|
||||
expect(caps?.completions).toBeUndefined();
|
||||
expect(caps?.prompts).toBeDefined();
|
||||
|
||||
// Raw request bypasses any client-side capability gating, so the rejection observed is the server's own.
|
||||
await expect(
|
||||
client.request(
|
||||
{
|
||||
method: 'completion/complete',
|
||||
params: { ref: { type: 'ref/prompt', name: 'summarize-code' }, argument: { name: 'code', value: 'co' } }
|
||||
},
|
||||
CompleteResultSchema
|
||||
)
|
||||
).rejects.toMatchObject({ code: ErrorCode.MethodNotFound });
|
||||
});
|
||||
|
||||
verifies('completion:context-arguments', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerResource(
|
||||
'github-repo',
|
||||
new ResourceTemplate('github://{owner}/{repo}', {
|
||||
list: undefined,
|
||||
complete: {
|
||||
repo: (value, context) => {
|
||||
const owner = context?.arguments?.owner;
|
||||
if (!owner || typeof owner !== 'string') return [];
|
||||
const repos = REPOS_BY_OWNER[owner] ?? [];
|
||||
return repos.filter(r => r.startsWith(value));
|
||||
}
|
||||
}
|
||||
}),
|
||||
{ mimeType: 'text/plain' },
|
||||
(uri, { owner, repo }) => ({ contents: [{ uri: uri.href, mimeType: 'text/plain', text: `${owner}/${repo}` }] })
|
||||
);
|
||||
return s;
|
||||
};
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const acme = await client.complete({
|
||||
ref: { type: 'ref/resource', uri: 'github://{owner}/{repo}' },
|
||||
argument: { name: 'repo', value: '' },
|
||||
context: { arguments: { owner: 'acme-corp' } }
|
||||
});
|
||||
expect(acme.completion.values).toEqual(['widget-sdk', 'gadget-sdk', 'docs']);
|
||||
|
||||
const globex = await client.complete({
|
||||
ref: { type: 'ref/resource', uri: 'github://{owner}/{repo}' },
|
||||
argument: { name: 'repo', value: '' },
|
||||
context: { arguments: { owner: 'globex' } }
|
||||
});
|
||||
expect(globex.completion.values).toEqual(['frobnicator', 'reticulator']);
|
||||
expect(acme.completion.values).not.toEqual(globex.completion.values);
|
||||
|
||||
const bare = await client.complete({
|
||||
ref: { type: 'ref/resource', uri: 'github://{owner}/{repo}' },
|
||||
argument: { name: 'repo', value: '' }
|
||||
});
|
||||
expect(bare.completion.values).toEqual([]);
|
||||
});
|
||||
|
||||
verifies(
|
||||
'completion:error:invalid-ref',
|
||||
async ({ transport }: TestArgs) => {
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, colorCompletionServer, client);
|
||||
|
||||
await expect(
|
||||
client.complete({ ref: { type: 'ref/prompt', name: 'no-such-prompt' }, argument: { name: 'whatever', value: '' } })
|
||||
).rejects.toMatchObject({ code: ErrorCode.InvalidParams });
|
||||
|
||||
await expect(
|
||||
client.complete({ ref: { type: 'ref/resource', uri: 'nosuchscheme://nowhere/{x}' }, argument: { name: 'x', value: '' } })
|
||||
).rejects.toMatchObject({ code: ErrorCode.InvalidParams });
|
||||
},
|
||||
{ title: 'mcpserver' }
|
||||
);
|
||||
|
||||
verifies(
|
||||
'completion:error:invalid-ref',
|
||||
async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new Server({ name: 's', version: '0' }, { capabilities: { completions: {} } });
|
||||
s.setRequestHandler(CompleteRequestSchema, req => {
|
||||
if (req.params.ref.type === 'ref/prompt' && req.params.ref.name === 'known') {
|
||||
return { completion: { values: ['ok'] } };
|
||||
}
|
||||
throw new McpError(ErrorCode.InvalidParams, `No completion target: ${JSON.stringify(req.params.ref)}`);
|
||||
});
|
||||
return s;
|
||||
};
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const known = await client.complete({ ref: { type: 'ref/prompt', name: 'known' }, argument: { name: 'a', value: '' } });
|
||||
expect(known.completion.values).toEqual(['ok']);
|
||||
|
||||
await expect(
|
||||
client.complete({ ref: { type: 'ref/prompt', name: 'no-such-prompt' }, argument: { name: 'a', value: '' } })
|
||||
).rejects.toMatchObject({ code: ErrorCode.InvalidParams });
|
||||
},
|
||||
{ title: 'raw server' }
|
||||
);
|
||||
|
||||
verifies('completion:prompt-arg', async ({ transport }: TestArgs) => {
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, colorCompletionServer, client);
|
||||
|
||||
const result = await client.complete({
|
||||
ref: { type: 'ref/prompt', name: 'complete-color' },
|
||||
argument: { name: 'color', value: 're' }
|
||||
});
|
||||
|
||||
const expected = COLORS.filter(c => c.startsWith('re'));
|
||||
expect(result.completion.values).toEqual(expected);
|
||||
expect(result.completion.hasMore ?? false).toBe(false);
|
||||
});
|
||||
|
||||
verifies('completion:resource-template-arg', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerResource(
|
||||
'file-path',
|
||||
new ResourceTemplate('completion://files/{path}', {
|
||||
list: undefined,
|
||||
complete: { path: value => FILE_PATHS.filter(p => p.startsWith(value)) }
|
||||
}),
|
||||
{ mimeType: 'text/plain' },
|
||||
(uri, { path }) => ({ contents: [{ uri: uri.href, mimeType: 'text/plain', text: `path=${path}` }] })
|
||||
);
|
||||
s.registerResource(
|
||||
'github-repo',
|
||||
new ResourceTemplate('github://{owner}/{repo}', {
|
||||
list: undefined,
|
||||
complete: { owner: () => [] }
|
||||
}),
|
||||
{ mimeType: 'text/plain' },
|
||||
(uri, { owner, repo }) => ({ contents: [{ uri: uri.href, mimeType: 'text/plain', text: `${owner}/${repo}` }] })
|
||||
);
|
||||
return s;
|
||||
};
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const result = await client.complete({
|
||||
ref: { type: 'ref/resource', uri: 'completion://files/{path}' },
|
||||
argument: { name: 'path', value: 'src/' }
|
||||
});
|
||||
|
||||
expect(result.completion.values).toEqual(['src/index.ts', 'src/types.ts']);
|
||||
expect(result.completion.hasMore ?? false).toBe(false);
|
||||
|
||||
const other = await client.complete({
|
||||
ref: { type: 'ref/resource', uri: 'github://{owner}/{repo}' },
|
||||
argument: { name: 'path', value: 'src/' }
|
||||
});
|
||||
|
||||
expect(other.completion.values).toEqual([]);
|
||||
});
|
||||
|
||||
verifies('completion:result-shape', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerPrompt(
|
||||
'complete-color',
|
||||
{ argsSchema: { color: completable(z.string(), value => COLORS.filter(c => c.startsWith(value))) } },
|
||||
({ color }) => ({ messages: [{ role: 'user', content: { type: 'text', text: `color=${color}` } }] })
|
||||
);
|
||||
const many = Array.from({ length: MANY_TOTAL }, (_, i) => `item-${String(i).padStart(3, '0')}`);
|
||||
s.registerPrompt(
|
||||
'complete-many',
|
||||
{ argsSchema: { n: completable(z.string(), value => many.filter(s => s.startsWith(value))) } },
|
||||
({ n }) => ({ messages: [{ role: 'user', content: { type: 'text', text: n } }] })
|
||||
);
|
||||
return s;
|
||||
};
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const small = await client.complete({
|
||||
ref: { type: 'ref/prompt', name: 'complete-color' },
|
||||
argument: { name: 'color', value: 're' }
|
||||
});
|
||||
|
||||
expect(Array.isArray(small.completion.values)).toBe(true);
|
||||
expect(small.completion.values).toEqual(COLORS.filter(c => c.startsWith('re')));
|
||||
expect(small.completion.values.length).toBeLessThanOrEqual(100);
|
||||
expect(small.completion.total).toBe(small.completion.values.length);
|
||||
expect(small.completion.total).toBe(2);
|
||||
expect(small.completion.hasMore).toBe(false);
|
||||
|
||||
const empty = await client.complete({
|
||||
ref: { type: 'ref/prompt', name: 'complete-color' },
|
||||
argument: { name: 'no-such-arg', value: '' }
|
||||
});
|
||||
|
||||
expect(empty.completion.values).toEqual([]);
|
||||
expect(empty.completion.total).toBeUndefined();
|
||||
expect(empty.completion.hasMore).toBe(false);
|
||||
|
||||
const many = await client.complete({
|
||||
ref: { type: 'ref/prompt', name: 'complete-many' },
|
||||
argument: { name: 'n', value: '' }
|
||||
});
|
||||
|
||||
expect(many.completion.values).toHaveLength(100);
|
||||
expect(many.completion.values.every(v => typeof v === 'string')).toBe(true);
|
||||
expect(many.completion.values[0]).toBe('item-000');
|
||||
expect(many.completion.values[99]).toBe('item-099');
|
||||
expect(many.completion.total).toBe(MANY_TOTAL);
|
||||
expect(many.completion.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
verifies(
|
||||
'mcpserver:completion:capability-auto',
|
||||
async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerPrompt('non-completable', { argsSchema: { arg: z.string() } }, ({ arg }) => ({
|
||||
messages: [{ role: 'user', content: { type: 'text', text: arg } }]
|
||||
}));
|
||||
s.registerResource(
|
||||
'plain-resource',
|
||||
new ResourceTemplate('plain://resource/{id}', { list: undefined }),
|
||||
{ mimeType: 'text/plain' },
|
||||
(uri, { id }) => ({ contents: [{ uri: uri.href, mimeType: 'text/plain', text: `id=${id}` }] })
|
||||
);
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const caps = client.getServerCapabilities();
|
||||
expect(caps).toBeDefined();
|
||||
expect(caps?.completions).toBeUndefined();
|
||||
expect(caps?.prompts).toBeDefined();
|
||||
expect(caps?.resources).toBeDefined();
|
||||
|
||||
await expect(
|
||||
client.complete({
|
||||
ref: { type: 'ref/prompt', name: 'non-completable' },
|
||||
argument: { name: 'arg', value: '' }
|
||||
})
|
||||
).rejects.toThrow();
|
||||
},
|
||||
{ title: 'mcpserver' }
|
||||
);
|
||||
|
||||
verifies(
|
||||
'mcpserver:completion:capability-auto',
|
||||
async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new Server({ name: 's', version: '0' }, { capabilities: { prompts: {}, resources: {} } });
|
||||
s.setRequestHandler(ListPromptsRequestSchema, () => ({ prompts: [{ name: 'plain', arguments: [] }] }));
|
||||
s.setRequestHandler(ListResourcesRequestSchema, () => ({ resources: [] }));
|
||||
s.setRequestHandler(ReadResourceRequestSchema, () => ({ contents: [] }));
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const caps = client.getServerCapabilities();
|
||||
expect(caps?.completions).toBeUndefined();
|
||||
expect(caps?.prompts).toBeDefined();
|
||||
expect(caps?.resources).toBeDefined();
|
||||
|
||||
await expect(
|
||||
client.complete({
|
||||
ref: { type: 'ref/prompt', name: 'plain' },
|
||||
argument: { name: 'arg', value: '' }
|
||||
})
|
||||
).rejects.toThrow();
|
||||
},
|
||||
{ title: 'raw server' }
|
||||
);
|
||||
359
test/e2e/scenarios/dynamic.test.ts
Normal file
359
test/e2e/scenarios/dynamic.test.ts
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
/**
|
||||
* Self-contained test bodies for dynamic (list_changed) behaviors.
|
||||
*
|
||||
* Tests for client-side listChanged handlers (auto-refresh, capability gating,
|
||||
* signal-only mode) and server-side dynamic registration (post-connect,
|
||||
* debounce, enable/disable, low-level handler reach-through).
|
||||
*/
|
||||
|
||||
import { expect, vi } from 'vitest';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { Client } from '../../../src/client/index.js';
|
||||
import { McpServer, type RegisteredTool } from '../../../src/server/mcp.js';
|
||||
import { Server } from '../../../src/server/index.js';
|
||||
import {
|
||||
ListPromptsRequestSchema,
|
||||
ListResourcesRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
PromptListChangedNotificationSchema,
|
||||
ResourceListChangedNotificationSchema,
|
||||
SubscribeRequestSchema,
|
||||
ToolListChangedNotificationSchema,
|
||||
type Prompt,
|
||||
type Resource,
|
||||
type Tool
|
||||
} from '../../../src/types.js';
|
||||
|
||||
import { wire } from '../helpers/index.js';
|
||||
import type { TestArgs } from '../types.js';
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
|
||||
const newClient = () => new Client({ name: 'c', version: '0' });
|
||||
|
||||
// vi.waitFor only retries on throw, not on falsy return — wrap predicates so a
|
||||
// false result throws and the poll continues.
|
||||
const waitUntil = (pred: () => boolean) => vi.waitFor(() => expect(pred()).toBe(true));
|
||||
|
||||
verifies('client:list-changed:auto-refresh', async ({ transport }: TestArgs) => {
|
||||
const toolCalls: Array<{ error: Error | null; items: Tool[] | null }> = [];
|
||||
const promptCalls: Array<{ error: Error | null; items: Prompt[] | null }> = [];
|
||||
const resourceCalls: Array<{ error: Error | null; items: Resource[] | null }> = [];
|
||||
|
||||
let server!: McpServer;
|
||||
const makeServer = () => {
|
||||
server = new McpServer({ name: 's', version: '0' });
|
||||
server.registerTool('seed', { inputSchema: z.object({}) }, () => ({ content: [] }));
|
||||
server.registerPrompt('seed', { description: '' }, () => ({ messages: [] }));
|
||||
server.registerResource('seed', 'test://seed', {}, () => ({ contents: [] }));
|
||||
return server;
|
||||
};
|
||||
|
||||
const client = new Client(
|
||||
{ name: 'c', version: '0' },
|
||||
{
|
||||
listChanged: {
|
||||
tools: { debounceMs: 0, onChanged: (error, items) => toolCalls.push({ error, items }) },
|
||||
prompts: { debounceMs: 0, onChanged: (error, items) => promptCalls.push({ error, items }) },
|
||||
resources: { debounceMs: 0, onChanged: (error, items) => resourceCalls.push({ error, items }) }
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
server.registerTool('probe-tool', { inputSchema: z.object({}) }, () => ({ content: [] }));
|
||||
await waitUntil(() => toolCalls.some(c => c.items?.some(t => t.name === 'probe-tool')));
|
||||
const toolCall = toolCalls.find(c => c.items?.some(t => t.name === 'probe-tool'));
|
||||
expect(toolCall?.error).toBeNull();
|
||||
expect(toolCall?.items?.map(t => t.name)).toContain('probe-tool');
|
||||
|
||||
server.registerPrompt('probe-prompt', { description: '' }, () => ({ messages: [] }));
|
||||
await waitUntil(() => promptCalls.some(c => c.items?.some(p => p.name === 'probe-prompt')));
|
||||
const promptCall = promptCalls.find(c => c.items?.some(p => p.name === 'probe-prompt'));
|
||||
expect(promptCall?.error).toBeNull();
|
||||
expect(promptCall?.items?.map(p => p.name)).toContain('probe-prompt');
|
||||
|
||||
server.registerResource('probe', 'test://probe', {}, () => ({ contents: [] }));
|
||||
await waitUntil(() => resourceCalls.some(c => c.items?.some(r => r.uri === 'test://probe')));
|
||||
const resourceCall = resourceCalls.find(c => c.items?.some(r => r.uri === 'test://probe'));
|
||||
expect(resourceCall?.error).toBeNull();
|
||||
expect(resourceCall?.items?.map(r => r.uri)).toContain('test://probe');
|
||||
});
|
||||
|
||||
verifies('client:list-changed:capability-gated', async ({ transport }: TestArgs) => {
|
||||
const toolsCalls: unknown[] = [];
|
||||
const promptsCalls: unknown[] = [];
|
||||
const resourcesCalls: unknown[] = [];
|
||||
|
||||
let server!: Server;
|
||||
const makeServer = () => {
|
||||
server = new Server({ name: 's', version: '0' }, { capabilities: { tools: { listChanged: true }, prompts: {}, resources: {} } });
|
||||
server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: [] }));
|
||||
server.setRequestHandler(ListPromptsRequestSchema, () => ({ prompts: [] }));
|
||||
server.setRequestHandler(ListResourcesRequestSchema, () => ({ resources: [] }));
|
||||
return server;
|
||||
};
|
||||
|
||||
const client = new Client(
|
||||
{ name: 'c', version: '0' },
|
||||
{
|
||||
listChanged: {
|
||||
tools: { autoRefresh: false, debounceMs: 0, onChanged: (err, items) => toolsCalls.push({ err, items }) },
|
||||
prompts: {
|
||||
autoRefresh: false,
|
||||
debounceMs: 0,
|
||||
onChanged: (err, items) => promptsCalls.push({ err, items })
|
||||
},
|
||||
resources: {
|
||||
autoRefresh: false,
|
||||
debounceMs: 0,
|
||||
onChanged: (err, items) => resourcesCalls.push({ err, items })
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const caps = client.getServerCapabilities();
|
||||
expect(caps?.tools?.listChanged).toBe(true);
|
||||
expect(caps?.prompts?.listChanged).toBeFalsy();
|
||||
expect(caps?.resources?.listChanged).toBeFalsy();
|
||||
|
||||
toolsCalls.length = 0;
|
||||
promptsCalls.length = 0;
|
||||
resourcesCalls.length = 0;
|
||||
|
||||
await server.sendToolListChanged();
|
||||
await server.sendPromptListChanged();
|
||||
await server.sendResourceListChanged();
|
||||
|
||||
await waitUntil(() => toolsCalls.length >= 1);
|
||||
expect(toolsCalls[0]).toEqual({ err: null, items: null });
|
||||
|
||||
await client.listTools();
|
||||
expect(promptsCalls).toHaveLength(0);
|
||||
expect(resourcesCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
verifies('client:list-changed:signal-only', async ({ transport }: TestArgs) => {
|
||||
const toolCalls: Array<{ error: Error | null; items: Tool[] | null }> = [];
|
||||
const promptCalls: Array<{ error: Error | null; items: Prompt[] | null }> = [];
|
||||
const resourceCalls: Array<{ error: Error | null; items: Resource[] | null }> = [];
|
||||
|
||||
let server!: McpServer;
|
||||
const makeServer = () => {
|
||||
server = new McpServer({ name: 's', version: '0' });
|
||||
server.registerTool('seed', { inputSchema: z.object({}) }, () => ({ content: [] }));
|
||||
server.registerPrompt('seed', { description: '' }, () => ({ messages: [] }));
|
||||
server.registerResource('seed', 'test://seed', {}, () => ({ contents: [] }));
|
||||
return server;
|
||||
};
|
||||
|
||||
const client = new Client(
|
||||
{ name: 'c', version: '0' },
|
||||
{
|
||||
listChanged: {
|
||||
tools: { autoRefresh: false, debounceMs: 0, onChanged: (error, items) => toolCalls.push({ error, items }) },
|
||||
prompts: {
|
||||
autoRefresh: false,
|
||||
debounceMs: 0,
|
||||
onChanged: (error, items) => promptCalls.push({ error, items })
|
||||
},
|
||||
resources: {
|
||||
autoRefresh: false,
|
||||
debounceMs: 0,
|
||||
onChanged: (error, items) => resourceCalls.push({ error, items })
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const before = await client.listTools();
|
||||
|
||||
toolCalls.length = 0;
|
||||
promptCalls.length = 0;
|
||||
resourceCalls.length = 0;
|
||||
|
||||
server.registerTool('signal-only-tool', { inputSchema: z.object({}) }, () => ({ content: [] }));
|
||||
await waitUntil(() => toolCalls.length >= 1);
|
||||
expect(toolCalls[0]).toEqual({ error: null, items: null });
|
||||
|
||||
server.registerPrompt('signal-only-prompt', { description: '' }, () => ({ messages: [] }));
|
||||
await waitUntil(() => promptCalls.length >= 1);
|
||||
expect(promptCalls[0]).toEqual({ error: null, items: null });
|
||||
|
||||
server.registerResource('signal-only', 'test://signal-only', {}, () => ({ contents: [] }));
|
||||
await waitUntil(() => resourceCalls.length >= 1);
|
||||
expect(resourceCalls[0]).toEqual({ error: null, items: null });
|
||||
|
||||
const after = await client.listTools();
|
||||
expect(after.tools.length).toBe(before.tools.length + 1);
|
||||
});
|
||||
|
||||
verifies('mcpserver:handle:enable-disable', async ({ transport }: TestArgs) => {
|
||||
let handle!: RegisteredTool;
|
||||
let server!: McpServer;
|
||||
|
||||
const makeServer = () => {
|
||||
server = new McpServer({ name: 's', version: '0' });
|
||||
handle = server.registerTool('toggle-probe', { inputSchema: z.object({}) }, () => ({
|
||||
content: [{ type: 'text', text: 'toggle-probe' }]
|
||||
}));
|
||||
return server;
|
||||
};
|
||||
|
||||
let listChanged = 0;
|
||||
const client = newClient();
|
||||
client.setNotificationHandler(ToolListChangedNotificationSchema, () => {
|
||||
listChanged++;
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
expect((await client.listTools()).tools.map(t => t.name)).toContain('toggle-probe');
|
||||
const baseline = await client.callTool({ name: 'toggle-probe', arguments: {} });
|
||||
expect(baseline.isError).toBeFalsy();
|
||||
expect(baseline.content).toEqual([{ type: 'text', text: 'toggle-probe' }]);
|
||||
|
||||
const beforeDisable = listChanged;
|
||||
handle.disable();
|
||||
await waitUntil(() => listChanged > beforeDisable);
|
||||
|
||||
expect((await client.listTools()).tools.map(t => t.name)).not.toContain('toggle-probe');
|
||||
const disabledCall = await client.callTool({ name: 'toggle-probe', arguments: {} });
|
||||
expect(disabledCall.isError).toBe(true);
|
||||
expect(disabledCall.content).toEqual([{ type: 'text', text: expect.stringMatching(/disabled/i) }]);
|
||||
|
||||
const beforeEnable = listChanged;
|
||||
handle.enable();
|
||||
await waitUntil(() => listChanged > beforeEnable);
|
||||
|
||||
expect((await client.listTools()).tools.map(t => t.name)).toContain('toggle-probe');
|
||||
const restored = await client.callTool({ name: 'toggle-probe', arguments: {} });
|
||||
expect(restored.isError).toBeFalsy();
|
||||
expect(restored.content).toEqual([{ type: 'text', text: 'toggle-probe' }]);
|
||||
});
|
||||
|
||||
verifies('mcpserver:list-changed:debounce', async ({ transport }: TestArgs) => {
|
||||
let server!: McpServer;
|
||||
const makeServer = () => {
|
||||
server = new McpServer(
|
||||
{ name: 's', version: '0' },
|
||||
{
|
||||
debouncedNotificationMethods: [
|
||||
'notifications/tools/list_changed',
|
||||
'notifications/resources/list_changed',
|
||||
'notifications/prompts/list_changed'
|
||||
]
|
||||
}
|
||||
);
|
||||
// Seed one of each so capability registration + request handlers are set
|
||||
// up pre-connect; post-connect registrations would otherwise try to
|
||||
// registerCapabilities and throw.
|
||||
server.registerTool('seed', { inputSchema: z.object({}) }, () => ({ content: [] }));
|
||||
server.registerResource('seed', 'test://seed', {}, () => ({ contents: [] }));
|
||||
server.registerPrompt('seed', { description: '' }, () => ({ messages: [] }));
|
||||
return server;
|
||||
};
|
||||
|
||||
const counts = { tools: 0, resources: 0, prompts: 0 };
|
||||
const client = newClient();
|
||||
client.setNotificationHandler(ToolListChangedNotificationSchema, () => void counts.tools++);
|
||||
client.setNotificationHandler(ResourceListChangedNotificationSchema, () => void counts.resources++);
|
||||
client.setNotificationHandler(PromptListChangedNotificationSchema, () => void counts.prompts++);
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
counts.tools = 0;
|
||||
counts.resources = 0;
|
||||
counts.prompts = 0;
|
||||
|
||||
server.registerTool('a', { inputSchema: z.object({}) }, () => ({ content: [] }));
|
||||
server.registerTool('b', { inputSchema: z.object({}) }, () => ({ content: [] }));
|
||||
server.registerTool('c', { inputSchema: z.object({}) }, () => ({ content: [] }));
|
||||
server.registerResource('a', 'test://debounce/a', {}, () => ({ contents: [] }));
|
||||
server.registerResource('b', 'test://debounce/b', {}, () => ({ contents: [] }));
|
||||
server.registerResource('c', 'test://debounce/c', {}, () => ({ contents: [] }));
|
||||
server.registerPrompt('a', { description: '' }, () => ({ messages: [] }));
|
||||
server.registerPrompt('b', { description: '' }, () => ({ messages: [] }));
|
||||
server.registerPrompt('c', { description: '' }, () => ({ messages: [] }));
|
||||
|
||||
await waitUntil(() => counts.tools >= 1 && counts.resources >= 1 && counts.prompts >= 1);
|
||||
|
||||
await client.listTools();
|
||||
expect(counts.tools).toBe(1);
|
||||
expect(counts.resources).toBe(1);
|
||||
expect(counts.prompts).toBe(1);
|
||||
|
||||
const { tools } = await client.listTools();
|
||||
expect(tools.map(t => t.name)).toEqual(expect.arrayContaining(['a', 'b', 'c']));
|
||||
});
|
||||
|
||||
verifies('mcpserver:register:post-connect', async ({ transport }: TestArgs) => {
|
||||
let server!: McpServer;
|
||||
const makeServer = () => {
|
||||
server = new McpServer({ name: 's', version: '0' });
|
||||
// Seed so handlers + capabilities are wired pre-connect.
|
||||
server.registerTool('seed', { inputSchema: z.object({}) }, () => ({ content: [] }));
|
||||
server.registerResource('seed', 'test://seed', {}, () => ({ contents: [] }));
|
||||
server.registerPrompt('seed', { description: '' }, () => ({ messages: [] }));
|
||||
return server;
|
||||
};
|
||||
|
||||
const seen: string[] = [];
|
||||
const client = newClient();
|
||||
client.setNotificationHandler(ToolListChangedNotificationSchema, () => void seen.push('tools'));
|
||||
client.setNotificationHandler(ResourceListChangedNotificationSchema, () => void seen.push('resources'));
|
||||
client.setNotificationHandler(PromptListChangedNotificationSchema, () => void seen.push('prompts'));
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
seen.length = 0;
|
||||
server.registerTool('post-connect-tool', { inputSchema: z.object({}) }, () => ({ content: [] }));
|
||||
await waitUntil(() => seen.includes('tools'));
|
||||
expect((await client.listTools()).tools.map(t => t.name)).toContain('post-connect-tool');
|
||||
|
||||
seen.length = 0;
|
||||
server.registerResource('post-connect', 'test://post-connect', {}, () => ({ contents: [] }));
|
||||
await waitUntil(() => seen.includes('resources'));
|
||||
expect((await client.listResources()).resources.map(r => r.uri)).toContain('test://post-connect');
|
||||
|
||||
seen.length = 0;
|
||||
server.registerPrompt('post-connect-prompt', { description: '' }, () => ({ messages: [] }));
|
||||
await waitUntil(() => seen.includes('prompts'));
|
||||
expect((await client.listPrompts()).prompts.map(p => p.name)).toContain('post-connect-prompt');
|
||||
});
|
||||
|
||||
verifies('mcpserver:reach-through:set-request-handler', async ({ transport }: TestArgs) => {
|
||||
const handlerHits: string[] = [];
|
||||
let server!: McpServer;
|
||||
|
||||
const makeServer = () => {
|
||||
server = new McpServer({ name: 's', version: '0' }, { capabilities: { resources: { subscribe: true, listChanged: true } } });
|
||||
server.registerResource('baseline', 'test://baseline', {}, () => ({ contents: [] }));
|
||||
server.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({
|
||||
content: [{ type: 'text', text }]
|
||||
}));
|
||||
return server;
|
||||
};
|
||||
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
server.server.setRequestHandler(SubscribeRequestSchema, async request => {
|
||||
handlerHits.push(request.params.uri);
|
||||
return {};
|
||||
});
|
||||
|
||||
await expect(client.subscribeResource({ uri: 'test://reach-through' })).resolves.toEqual({});
|
||||
await waitUntil(() => handlerHits.includes('test://reach-through'));
|
||||
expect(handlerHits).toEqual(['test://reach-through']);
|
||||
|
||||
const echo = await client.callTool({ name: 'echo', arguments: { text: 'still wired' } });
|
||||
expect(echo.isError).toBeFalsy();
|
||||
expect(echo.content).toEqual([{ type: 'text', text: 'still wired' }]);
|
||||
});
|
||||
817
test/e2e/scenarios/elicitation.test.ts
Normal file
817
test/e2e/scenarios/elicitation.test.ts
Normal file
|
|
@ -0,0 +1,817 @@
|
|||
/**
|
||||
* Self-contained test bodies for elicitation (form and URL modes).
|
||||
*
|
||||
* Each export is a {@link TestCase}: it builds its own server, client, wires
|
||||
* them with {@link wire}, and asserts. Elicitation is a server→client request
|
||||
* flow, so tests inline `client.setRequestHandler(ElicitRequestSchema, ...)` to
|
||||
* script the responses the server will receive.
|
||||
*
|
||||
* Function names mirror the requirement id in camelCase.
|
||||
*/
|
||||
|
||||
import { expect } from 'vitest';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { Client } from '../../../src/client/index.js';
|
||||
import { Server } from '../../../src/server/index.js';
|
||||
import { McpServer } from '../../../src/server/mcp.js';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ElicitationCompleteNotificationSchema,
|
||||
type ElicitRequest,
|
||||
type ElicitRequestFormParams,
|
||||
ElicitRequestSchema,
|
||||
ElicitResultSchema,
|
||||
ErrorCode,
|
||||
ListToolsRequestSchema,
|
||||
McpError,
|
||||
UrlElicitationRequiredError
|
||||
} from '../../../src/types.js';
|
||||
|
||||
import { tapWire, wire } from '../helpers/index.js';
|
||||
import type { TestArgs } from '../types.js';
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
|
||||
/** Client with form-mode elicitation support. */
|
||||
const formClient = () => new Client({ name: 'c', version: '0' }, { capabilities: { elicitation: { form: {} } } });
|
||||
|
||||
/** Client with form-mode elicitation AND applyDefaults support. */
|
||||
const formClientWithDefaults = () =>
|
||||
new Client({ name: 'c', version: '0' }, { capabilities: { elicitation: { form: { applyDefaults: true } } } });
|
||||
|
||||
/** Client with URL-mode elicitation support. */
|
||||
const urlClient = () => new Client({ name: 'c', version: '0' }, { capabilities: { elicitation: { url: {} } } });
|
||||
|
||||
verifies('elicitation:form:basic', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('ask-name', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'What is your name?',
|
||||
requestedSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }
|
||||
});
|
||||
const name = ans.action === 'accept' ? String(ans.content?.name) : '<declined>';
|
||||
return { content: [{ type: 'text', text: `Hello, ${name}` }] };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const received: Array<{ method: string; params: unknown }> = [];
|
||||
const client = formClient();
|
||||
client.setRequestHandler(ElicitRequestSchema, async req => {
|
||||
received.push({ method: req.method, params: req.params });
|
||||
return { action: 'accept', content: { name: 'Ada' } };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'ask-name', arguments: {} });
|
||||
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0].method).toBe('elicitation/create');
|
||||
expect(received[0].params).toMatchObject({
|
||||
mode: 'form',
|
||||
message: 'What is your name?',
|
||||
requestedSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }
|
||||
});
|
||||
|
||||
expect(r.isError).toBeFalsy();
|
||||
expect(r.content).toEqual([{ type: 'text', text: 'Hello, Ada' }]);
|
||||
});
|
||||
|
||||
verifies('elicitation:form:action:accept', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('ask', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Enter name',
|
||||
requestedSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text', text: ans.action === 'accept' ? String(ans.content?.name) : 'none' }]
|
||||
};
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = formClient();
|
||||
client.setRequestHandler(ElicitRequestSchema, async () => {
|
||||
return { action: 'accept', content: { name: 'Ada' } };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'ask', arguments: {} });
|
||||
expect(r.content).toEqual([{ type: 'text', text: 'Ada' }]);
|
||||
});
|
||||
|
||||
verifies('elicitation:form:action:cancel', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('ask', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Enter name',
|
||||
requestedSchema: { type: 'object', properties: { name: { type: 'string' } } }
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text', text: `action:${ans.action},hasContent:${'content' in ans && ans.content !== undefined}` }]
|
||||
};
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = formClient();
|
||||
client.setRequestHandler(ElicitRequestSchema, async () => {
|
||||
return { action: 'cancel' };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'ask', arguments: {} });
|
||||
expect(r.content).toEqual([{ type: 'text', text: 'action:cancel,hasContent:false' }]);
|
||||
});
|
||||
|
||||
verifies('elicitation:form:action:decline', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('ask', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Enter name',
|
||||
requestedSchema: { type: 'object', properties: { name: { type: 'string' } } }
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text', text: `action:${ans.action},hasContent:${'content' in ans && ans.content !== undefined}` }]
|
||||
};
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = formClient();
|
||||
client.setRequestHandler(ElicitRequestSchema, async () => {
|
||||
return { action: 'decline' };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'ask', arguments: {} });
|
||||
expect(r.content).toEqual([{ type: 'text', text: 'action:decline,hasContent:false' }]);
|
||||
});
|
||||
|
||||
verifies('elicitation:form:defaults', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('ask', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Fill form',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', default: 'anon' },
|
||||
age: { type: 'integer', default: 42 },
|
||||
subscribe: { type: 'boolean', default: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
return { content: [], structuredContent: ans.action === 'accept' ? ans.content : undefined };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = formClientWithDefaults();
|
||||
client.setRequestHandler(ElicitRequestSchema, async () => ({ action: 'accept', content: { name: 'Ada' } }));
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'ask', arguments: {} });
|
||||
expect(r.structuredContent).toEqual({ name: 'Ada', age: 42, subscribe: true });
|
||||
});
|
||||
|
||||
verifies('elicitation:form:mode-omitted-default', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } });
|
||||
s.setRequestHandler(ListToolsRequestSchema, () => ({ tools: [{ name: 'ask', inputSchema: { type: 'object' } }] }));
|
||||
s.setRequestHandler(CallToolRequestSchema, async (_req, extra) => {
|
||||
try {
|
||||
const ans = await extra.sendRequest(
|
||||
{
|
||||
method: 'elicitation/create',
|
||||
params: {
|
||||
message: 'Enter name',
|
||||
requestedSchema: { type: 'object', properties: { name: { type: 'string' } } }
|
||||
}
|
||||
},
|
||||
ElicitResultSchema
|
||||
);
|
||||
return { content: [{ type: 'text', text: ans.action }] };
|
||||
} catch (e) {
|
||||
if (!(e instanceof McpError)) throw e;
|
||||
return { content: [{ type: 'text', text: `error:${e.code}` }] };
|
||||
}
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const formReceived: ElicitRequest['params'][] = [];
|
||||
const formClientInstance = formClient();
|
||||
formClientInstance.setRequestHandler(ElicitRequestSchema, async req => {
|
||||
formReceived.push(req.params);
|
||||
return { action: 'accept', content: { name: 'Test' } };
|
||||
});
|
||||
|
||||
await using _1 = await wire(transport, makeServer, formClientInstance);
|
||||
|
||||
const formResult = await formClientInstance.callTool({ name: 'ask', arguments: {} });
|
||||
|
||||
expect(formReceived).toHaveLength(1);
|
||||
expect(formReceived[0].mode).toBeUndefined();
|
||||
expect(formResult.content).toEqual([{ type: 'text', text: 'accept' }]);
|
||||
|
||||
let urlHandlerInvoked = 0;
|
||||
const urlClientInstance = urlClient();
|
||||
urlClientInstance.setRequestHandler(ElicitRequestSchema, async () => {
|
||||
urlHandlerInvoked++;
|
||||
return { action: 'accept' };
|
||||
});
|
||||
|
||||
await using _2 = await wire(transport, makeServer, urlClientInstance);
|
||||
|
||||
const urlResult = await urlClientInstance.callTool({ name: 'ask', arguments: {} });
|
||||
expect(urlResult.content).toEqual([{ type: 'text', text: `error:${ErrorCode.InvalidParams}` }]);
|
||||
expect(urlHandlerInvoked).toBe(0);
|
||||
});
|
||||
|
||||
verifies('elicitation:form:schema:primitives', async ({ transport }: TestArgs) => {
|
||||
const requestedSchema: ElicitRequestFormParams['requestedSchema'] = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
email: { type: 'string', format: 'email' },
|
||||
age: { type: 'integer' },
|
||||
score: { type: 'number' },
|
||||
active: { type: 'boolean' }
|
||||
}
|
||||
};
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('ask', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({ mode: 'form', message: 'Fill form', requestedSchema });
|
||||
return { content: [], structuredContent: ans.action === 'accept' ? ans.content : undefined };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const expected = { name: 'Ada', email: 'ada@example.com', age: 30, score: 95.5, active: true };
|
||||
const received: ElicitRequest['params'][] = [];
|
||||
const client = formClient();
|
||||
client.setRequestHandler(ElicitRequestSchema, async req => {
|
||||
received.push(req.params);
|
||||
return { action: 'accept', content: expected };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'ask', arguments: {} });
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0]).toEqual({ mode: 'form', message: 'Fill form', requestedSchema });
|
||||
expect(r.structuredContent).toEqual(expected);
|
||||
});
|
||||
|
||||
verifies('elicitation:form:schema:enum-variants', async ({ transport }: TestArgs) => {
|
||||
const requestedSchema: ElicitRequestFormParams['requestedSchema'] = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
bare: { type: 'string', enum: ['Red', 'Green', 'Blue'] },
|
||||
titled: {
|
||||
type: 'string',
|
||||
oneOf: [
|
||||
{ const: '#FF0000', title: 'Red' },
|
||||
{ const: '#00FF00', title: 'Green' }
|
||||
]
|
||||
},
|
||||
multi: {
|
||||
type: 'array',
|
||||
items: {
|
||||
anyOf: [
|
||||
{ const: '#FF0000', title: 'Red' },
|
||||
{ const: '#0000FF', title: 'Blue' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('ask', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({ mode: 'form', message: 'Pick colors', requestedSchema });
|
||||
return { content: [], structuredContent: ans.action === 'accept' ? ans.content : undefined };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const expected = { bare: 'Red', titled: '#00FF00', multi: ['#FF0000', '#0000FF'] };
|
||||
const received: ElicitRequest['params'][] = [];
|
||||
const client = formClient();
|
||||
client.setRequestHandler(ElicitRequestSchema, async req => {
|
||||
received.push(req.params);
|
||||
return { action: 'accept', content: expected };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'ask', arguments: {} });
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0]).toEqual({ mode: 'form', message: 'Pick colors', requestedSchema });
|
||||
expect(r.structuredContent).toEqual(expected);
|
||||
});
|
||||
|
||||
verifies('elicitation:form:response-validation', async ({ transport }: TestArgs) => {
|
||||
const requestedSchema: ElicitRequestFormParams['requestedSchema'] = {
|
||||
type: 'object',
|
||||
properties: { username: { type: 'string' } },
|
||||
required: ['username']
|
||||
};
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('signup', { inputSchema: z.object({}) }, async () => {
|
||||
try {
|
||||
const ans = await s.server.elicitInput({ mode: 'form', message: 'Choose a username', requestedSchema });
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: 'text', text: `SDK accepted invalid content: ${JSON.stringify(ans.content)}` }]
|
||||
};
|
||||
} catch (e) {
|
||||
if (!(e instanceof McpError)) throw e;
|
||||
return { content: [{ type: 'text', text: `rejected:${e.code}:${e.message}` }] };
|
||||
}
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
// Each accepted response violates the requested schema: wrong type for `username`, then missing required `username`.
|
||||
const invalidContents: Array<Record<string, string | number | boolean>> = [{ username: 42 }, {}];
|
||||
let handlerInvoked = 0;
|
||||
const client = formClient();
|
||||
client.setRequestHandler(ElicitRequestSchema, async () => {
|
||||
const content = invalidContents[handlerInvoked];
|
||||
handlerInvoked++;
|
||||
return { action: 'accept', content };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const schemaRejection = expect.stringMatching(new RegExp(`^rejected:${ErrorCode.InvalidParams}:.*does not match requested schema`));
|
||||
|
||||
const wrongType = await client.callTool({ name: 'signup', arguments: {} });
|
||||
expect(wrongType.isError).toBeFalsy();
|
||||
expect(wrongType.content).toEqual([{ type: 'text', text: schemaRejection }]);
|
||||
|
||||
const missingRequired = await client.callTool({ name: 'signup', arguments: {} });
|
||||
expect(missingRequired.isError).toBeFalsy();
|
||||
expect(missingRequired.content).toEqual([{ type: 'text', text: schemaRejection }]);
|
||||
|
||||
expect(handlerInvoked).toBe(2);
|
||||
});
|
||||
|
||||
verifies('elicitation:form:schema:restricted-subset', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } });
|
||||
s.setRequestHandler(ListToolsRequestSchema, () => ({ tools: [{ name: 'profile', inputSchema: { type: 'object' } }] }));
|
||||
s.setRequestHandler(CallToolRequestSchema, async (_req, extra) => {
|
||||
try {
|
||||
await extra.sendRequest(
|
||||
{
|
||||
method: 'elicitation/create',
|
||||
params: {
|
||||
mode: 'form',
|
||||
message: 'Profile details',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
address: {
|
||||
type: 'object',
|
||||
properties: { street: { type: 'string' }, city: { type: 'string' } }
|
||||
},
|
||||
contacts: {
|
||||
type: 'array',
|
||||
items: { type: 'object', properties: { name: { type: 'string' } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
ElicitResultSchema
|
||||
);
|
||||
return { content: [{ type: 'text', text: 'should-not-reach' }] };
|
||||
} catch (e) {
|
||||
if (!(e instanceof McpError)) throw e;
|
||||
return { content: [{ type: 'text', text: `error:${e.code}` }] };
|
||||
}
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
let handlerInvoked = 0;
|
||||
const client = formClient();
|
||||
client.setRequestHandler(ElicitRequestSchema, async () => {
|
||||
handlerInvoked++;
|
||||
return { action: 'accept', content: {} };
|
||||
});
|
||||
|
||||
// strictValidation off: the nested requestedSchema deliberately violates the spec's flat-primitive restriction on the wire.
|
||||
await using _ = await wire(transport, makeServer, client, { strictValidation: false });
|
||||
|
||||
const r = await client.callTool({ name: 'profile', arguments: {} });
|
||||
expect(r.isError).toBeFalsy();
|
||||
expect(r.content).toEqual([{ type: 'text', text: `error:${ErrorCode.InvalidParams}` }]);
|
||||
expect(handlerInvoked).toBe(0);
|
||||
});
|
||||
|
||||
verifies('elicitation:url:basic', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('auth', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({
|
||||
mode: 'url',
|
||||
message: 'Please sign in',
|
||||
elicitationId: 'url-1',
|
||||
url: 'https://example.com/auth?state=test'
|
||||
});
|
||||
return { content: [{ type: 'text', text: ans.action }] };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const received: ElicitRequest['params'][] = [];
|
||||
const client = urlClient();
|
||||
client.setRequestHandler(ElicitRequestSchema, async req => {
|
||||
received.push(req.params);
|
||||
return { action: 'accept' };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'auth', arguments: {} });
|
||||
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0]).toMatchObject({
|
||||
mode: 'url',
|
||||
message: 'Please sign in',
|
||||
elicitationId: 'url-1',
|
||||
url: 'https://example.com/auth?state=test'
|
||||
});
|
||||
expect(r.content).toEqual([{ type: 'text', text: 'accept' }]);
|
||||
});
|
||||
|
||||
verifies('elicitation:url:action:accept-no-content', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('auth', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({
|
||||
mode: 'url',
|
||||
message: 'Please sign in',
|
||||
elicitationId: 'url-2',
|
||||
url: 'https://example.com/auth'
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text', text: `action:${ans.action},hasContent:${'content' in ans && ans.content !== undefined}` }]
|
||||
};
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = urlClient();
|
||||
client.setRequestHandler(ElicitRequestSchema, async () => ({ action: 'accept' }));
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'auth', arguments: {} });
|
||||
expect(r.content).toEqual([{ type: 'text', text: 'action:accept,hasContent:false' }]);
|
||||
});
|
||||
|
||||
verifies('elicitation:url:action:cancel', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('auth', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({
|
||||
mode: 'url',
|
||||
message: 'Please sign in',
|
||||
elicitationId: 'url-cancel-1',
|
||||
url: 'https://example.com/auth'
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text', text: `action:${ans.action},hasContent:${'content' in ans && ans.content !== undefined}` }]
|
||||
};
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = urlClient();
|
||||
client.setRequestHandler(ElicitRequestSchema, async () => ({ action: 'cancel' }));
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'auth', arguments: {} });
|
||||
expect(r.isError).toBeFalsy();
|
||||
expect(r.content).toEqual([{ type: 'text', text: 'action:cancel,hasContent:false' }]);
|
||||
});
|
||||
|
||||
verifies('elicitation:url:action:decline', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('auth', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({
|
||||
mode: 'url',
|
||||
message: 'Please sign in',
|
||||
elicitationId: 'url-decline-1',
|
||||
url: 'https://example.com/auth'
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text', text: `action:${ans.action},hasContent:${'content' in ans && ans.content !== undefined}` }]
|
||||
};
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = urlClient();
|
||||
client.setRequestHandler(ElicitRequestSchema, async () => ({ action: 'decline' }));
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'auth', arguments: {} });
|
||||
expect(r.isError).toBeFalsy();
|
||||
expect(r.content).toEqual([{ type: 'text', text: 'action:decline,hasContent:false' }]);
|
||||
});
|
||||
|
||||
verifies('elicitation:url:complete-notification', async ({ transport }: TestArgs) => {
|
||||
const notifications: Array<{ method: string; params: unknown }> = [];
|
||||
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('auth', { inputSchema: z.object({}) }, async () => {
|
||||
throw new UrlElicitationRequiredError([
|
||||
{
|
||||
mode: 'url',
|
||||
message: 'Sign in required',
|
||||
elicitationId: 'complete-1',
|
||||
url: 'https://example.com/auth'
|
||||
}
|
||||
]);
|
||||
});
|
||||
s.registerTool('complete', { inputSchema: z.object({ elicitationId: z.string() }) }, async ({ elicitationId }, extra) => {
|
||||
const notify = s.server.createElicitationCompletionNotifier(elicitationId, { relatedRequestId: extra.requestId });
|
||||
await notify();
|
||||
return { content: [{ type: 'text', text: 'notified' }] };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = urlClient();
|
||||
client.setNotificationHandler(ElicitationCompleteNotificationSchema, n => {
|
||||
notifications.push({ method: n.method, params: n.params });
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const err = await client.callTool({ name: 'auth', arguments: {} }).catch(e => e);
|
||||
if (!(err instanceof UrlElicitationRequiredError)) throw new Error(`expected UrlElicitationRequiredError, got ${err}`);
|
||||
const elicitationId = err.elicitations[0].elicitationId;
|
||||
|
||||
await client.callTool({ name: 'complete', arguments: { elicitationId } });
|
||||
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0]).toEqual({
|
||||
method: 'notifications/elicitation/complete',
|
||||
params: { elicitationId: 'complete-1' }
|
||||
});
|
||||
});
|
||||
|
||||
verifies('elicitation:url:complete-unknown-ignored', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('auth', { inputSchema: z.object({}) }, () => {
|
||||
throw new UrlElicitationRequiredError([
|
||||
{ mode: 'url', message: 'Sign in required', elicitationId: 'seen-1', url: 'https://example.com/auth' }
|
||||
]);
|
||||
});
|
||||
// Raw send: the server-side capability gate cannot pass on stateless (client capabilities never learned),
|
||||
// and the behavior under test is the client's handling of the notification.
|
||||
s.registerTool('complete', { inputSchema: z.object({ elicitationId: z.string() }) }, async ({ elicitationId }, extra) => {
|
||||
await s.server.transport!.send(
|
||||
{ jsonrpc: '2.0', method: 'notifications/elicitation/complete', params: { elicitationId } },
|
||||
{ relatedRequestId: extra.requestId }
|
||||
);
|
||||
return { content: [{ type: 'text', text: 'sent' }] };
|
||||
});
|
||||
s.registerTool('noop', { inputSchema: z.object({}) }, () => ({ content: [] }));
|
||||
return s;
|
||||
};
|
||||
|
||||
const notifications: Array<{ method: string; params: unknown }> = [];
|
||||
const errors: Error[] = [];
|
||||
const client = urlClient();
|
||||
client.onerror = e => errors.push(e);
|
||||
client.setNotificationHandler(ElicitationCompleteNotificationSchema, n => {
|
||||
notifications.push({ method: n.method, params: n.params });
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const err = await client.callTool({ name: 'auth', arguments: {} }).catch(e => e);
|
||||
if (!(err instanceof UrlElicitationRequiredError)) throw new Error(`expected UrlElicitationRequiredError, got ${err}`);
|
||||
const seenId = err.elicitations[0].elicitationId;
|
||||
|
||||
await client.callTool({ name: 'complete', arguments: { elicitationId: seenId } });
|
||||
// Already-completed id, then an id the client never saw: both must be ignored without error.
|
||||
await client.callTool({ name: 'complete', arguments: { elicitationId: seenId } });
|
||||
await client.callTool({ name: 'complete', arguments: { elicitationId: 'never-issued' } });
|
||||
|
||||
expect(notifications).toEqual([
|
||||
{ method: 'notifications/elicitation/complete', params: { elicitationId: 'seen-1' } },
|
||||
{ method: 'notifications/elicitation/complete', params: { elicitationId: 'seen-1' } },
|
||||
{ method: 'notifications/elicitation/complete', params: { elicitationId: 'never-issued' } }
|
||||
]);
|
||||
expect(errors).toEqual([]);
|
||||
|
||||
const after = await client.callTool({ name: 'noop', arguments: {} });
|
||||
expect(after.isError).toBeFalsy();
|
||||
expect(after.content).toEqual([]);
|
||||
});
|
||||
|
||||
verifies('elicitation:url:required-error', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('auth-required', { inputSchema: z.object({}) }, () => {
|
||||
throw new UrlElicitationRequiredError([
|
||||
{
|
||||
mode: 'url',
|
||||
message: 'Please authenticate',
|
||||
elicitationId: 'err-1',
|
||||
url: 'https://example.com/oauth'
|
||||
}
|
||||
]);
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = urlClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const err = await client.callTool({ name: 'auth-required', arguments: {} }).catch(e => e);
|
||||
if (!(err instanceof UrlElicitationRequiredError)) throw new Error(`expected UrlElicitationRequiredError, got ${err}`);
|
||||
expect(err.code).toBe(ErrorCode.UrlElicitationRequired);
|
||||
expect(err.elicitations).toHaveLength(1);
|
||||
expect(err.elicitations[0]).toMatchObject({
|
||||
mode: 'url',
|
||||
message: 'Please authenticate',
|
||||
elicitationId: 'err-1',
|
||||
url: 'https://example.com/oauth'
|
||||
});
|
||||
});
|
||||
|
||||
verifies('elicitation:capability:empty-is-form', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('ask', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Name?',
|
||||
requestedSchema: { type: 'object', properties: { name: { type: 'string' } } }
|
||||
});
|
||||
return { content: [{ type: 'text', text: ans.action }] };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = new Client({ name: 'c', version: '0' }, { capabilities: { elicitation: {} } });
|
||||
client.setRequestHandler(ElicitRequestSchema, async () => {
|
||||
return { action: 'accept', content: { name: 'Test' } };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'ask', arguments: {} });
|
||||
expect(r.content).toEqual([{ type: 'text', text: 'accept' }]);
|
||||
});
|
||||
|
||||
verifies('elicitation:capability:mode-mismatch', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } });
|
||||
s.setRequestHandler(ListToolsRequestSchema, () => ({ tools: [{ name: 'auth', inputSchema: { type: 'object' } }] }));
|
||||
s.setRequestHandler(CallToolRequestSchema, async (_req, extra) => {
|
||||
try {
|
||||
await extra.sendRequest(
|
||||
{
|
||||
method: 'elicitation/create',
|
||||
params: { mode: 'url', message: 'Sign in', elicitationId: 'mismatch-1', url: 'https://example.com/auth' }
|
||||
},
|
||||
ElicitResultSchema
|
||||
);
|
||||
return { content: [{ type: 'text', text: 'should-not-reach' }] };
|
||||
} catch (e) {
|
||||
if (!(e instanceof McpError)) throw e;
|
||||
return { content: [{ type: 'text', text: `error:${e.code}` }] };
|
||||
}
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
let handlerInvoked = 0;
|
||||
const client = formClient();
|
||||
client.setRequestHandler(ElicitRequestSchema, async () => {
|
||||
handlerInvoked++;
|
||||
return { action: 'accept', content: {} };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'auth', arguments: {} });
|
||||
expect(r.content).toEqual([{ type: 'text', text: `error:${ErrorCode.InvalidParams}` }]);
|
||||
expect(handlerInvoked).toBe(0);
|
||||
});
|
||||
|
||||
verifies('elicitation:capability:server-respects-mode', async ({ transport }: TestArgs) => {
|
||||
let reachedClient = 0;
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('auth', { inputSchema: z.object({}) }, async () => {
|
||||
try {
|
||||
await s.server.elicitInput({
|
||||
mode: 'url',
|
||||
message: 'Sign in',
|
||||
elicitationId: 'gate-1',
|
||||
url: 'https://example.com/auth'
|
||||
});
|
||||
return { isError: true, content: [{ type: 'text', text: 'SDK let it through' }] };
|
||||
} catch (e) {
|
||||
return { content: [{ type: 'text', text: `refused:${e instanceof Error ? e.message : e}` }] };
|
||||
}
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = formClient();
|
||||
client.setRequestHandler(ElicitRequestSchema, async () => {
|
||||
reachedClient++;
|
||||
return { action: 'accept' };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'auth', arguments: {} });
|
||||
expect(r.isError).toBeFalsy();
|
||||
expect(r.content).toEqual([{ type: 'text', text: expect.stringMatching(/^refused:.*url elicitation/i) }]);
|
||||
expect(reachedClient).toBe(0);
|
||||
});
|
||||
|
||||
verifies('elicitation:capability:not-declared', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('ask-form', { inputSchema: z.object({}) }, async () => {
|
||||
try {
|
||||
await s.server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'What is your name?',
|
||||
requestedSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }
|
||||
});
|
||||
return { isError: true, content: [{ type: 'text', text: 'SDK let the form elicitation through' }] };
|
||||
} catch (e) {
|
||||
return { content: [{ type: 'text', text: `refused:${e instanceof Error ? e.message : e}` }] };
|
||||
}
|
||||
});
|
||||
s.registerTool('ask-url', { inputSchema: z.object({}) }, async () => {
|
||||
try {
|
||||
await s.server.elicitInput({
|
||||
mode: 'url',
|
||||
message: 'Please sign in',
|
||||
elicitationId: 'no-cap-1',
|
||||
url: 'https://example.com/auth'
|
||||
});
|
||||
return { isError: true, content: [{ type: 'text', text: 'SDK let the url elicitation through' }] };
|
||||
} catch (e) {
|
||||
return { content: [{ type: 'text', text: `refused:${e instanceof Error ? e.message : e}` }] };
|
||||
}
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
// No elicitation capability declared at all — neither mode may reach the wire.
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
const tap = tapWire(client);
|
||||
|
||||
const form = await client.callTool({ name: 'ask-form', arguments: {} });
|
||||
expect(form.isError).toBeFalsy();
|
||||
expect(form.content).toEqual([{ type: 'text', text: 'refused:Client does not support form elicitation.' }]);
|
||||
|
||||
const url = await client.callTool({ name: 'ask-url', arguments: {} });
|
||||
expect(url.isError).toBeFalsy();
|
||||
expect(url.content).toEqual([{ type: 'text', text: 'refused:Client does not support url elicitation.' }]);
|
||||
|
||||
expect(tap.received.filter(m => 'method' in m && m.method === 'elicitation/create')).toEqual([]);
|
||||
});
|
||||
840
test/e2e/scenarios/flow.test.ts
Normal file
840
test/e2e/scenarios/flow.test.ts
Normal file
|
|
@ -0,0 +1,840 @@
|
|||
/**
|
||||
* Self-contained test bodies for composite end-to-end flows.
|
||||
*
|
||||
* These are longer journeys that combine multiple SDK features: multi-step
|
||||
* elicitation, OAuth roundtrip, resumption, session management, proxy
|
||||
* forwarding. Each builds whatever in-test pieces it needs (mock AS, minimal
|
||||
* EventStore, secondary Clients, proxy McpServer delegating to an upstream
|
||||
* Client) rather than importing shared fixtures.
|
||||
*/
|
||||
|
||||
import { expect, vi } from 'vitest';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { Client } from '../../../src/client/index.js';
|
||||
import { type OAuthClientProvider, UnauthorizedError } from '../../../src/client/auth.js';
|
||||
import { StreamableHTTPClientTransport } from '../../../src/client/streamableHttp.js';
|
||||
import { Server } from '../../../src/server/index.js';
|
||||
import { McpServer } from '../../../src/server/mcp.js';
|
||||
import type { EventStore } from '../../../src/server/webStandardStreamableHttp.js';
|
||||
import { type OAuthTokens, type OAuthClientMetadata, type OAuthClientInformationMixed } from '../../../src/shared/auth.js';
|
||||
import {
|
||||
type CallToolResult,
|
||||
ElicitationCompleteNotificationSchema,
|
||||
type ElicitRequest,
|
||||
type ElicitResult,
|
||||
ElicitRequestSchema,
|
||||
ElicitResultSchema,
|
||||
ErrorCode,
|
||||
type JSONRPCMessage,
|
||||
ListResourcesRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
type Progress,
|
||||
ReadResourceRequestSchema,
|
||||
UrlElicitationRequiredError
|
||||
} from '../../../src/types.js';
|
||||
|
||||
import { hostPerSession, hostResumable, wire } from '../helpers/index.js';
|
||||
import type { TestArgs } from '../types.js';
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
|
||||
verifies('flow:elicitation:multi-step-form', async ({ transport }: TestArgs) => {
|
||||
// Server: tool that issues three sequential elicitation/create requests
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('multi-step', { inputSchema: z.object({}) }, async (_args, extra) => {
|
||||
const step1 = await extra.sendRequest(
|
||||
{
|
||||
method: 'elicitation/create',
|
||||
params: {
|
||||
mode: 'form',
|
||||
message: 'What is your name?',
|
||||
requestedSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }
|
||||
}
|
||||
},
|
||||
ElicitResultSchema
|
||||
);
|
||||
if (step1.action !== 'accept' || typeof step1.content?.name !== 'string') {
|
||||
return { content: [{ type: 'text', text: `aborted at step 1: ${step1.action}` }] };
|
||||
}
|
||||
const name = step1.content.name;
|
||||
|
||||
const step2 = await extra.sendRequest(
|
||||
{
|
||||
method: 'elicitation/create',
|
||||
params: {
|
||||
mode: 'form',
|
||||
message: `Hello ${name}, what is your favorite color?`,
|
||||
requestedSchema: { type: 'object', properties: { color: { type: 'string' } }, required: ['color'] }
|
||||
}
|
||||
},
|
||||
ElicitResultSchema
|
||||
);
|
||||
if (step2.action !== 'accept' || typeof step2.content?.color !== 'string') {
|
||||
return { content: [{ type: 'text', text: `aborted at step 2: ${step2.action}` }] };
|
||||
}
|
||||
const color = step2.content.color;
|
||||
|
||||
const step3 = await extra.sendRequest(
|
||||
{
|
||||
method: 'elicitation/create',
|
||||
params: {
|
||||
mode: 'form',
|
||||
message: `${name}, you picked ${color}. Confirm?`,
|
||||
requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] }
|
||||
}
|
||||
},
|
||||
ElicitResultSchema
|
||||
);
|
||||
if (step3.action !== 'accept') {
|
||||
return { content: [{ type: 'text', text: `aborted at step 3: ${step3.action}` }] };
|
||||
}
|
||||
|
||||
return { content: [{ type: 'text', text: `${name}'s favorite color is ${color}` }] };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
// Client: registers handler for elicitation/create, queues responses
|
||||
const received: ElicitRequest[] = [];
|
||||
const queued: ElicitResult[] = [];
|
||||
const client = new Client({ name: 'c', version: '0' }, { capabilities: { elicitation: { form: {} } } });
|
||||
client.setRequestHandler(ElicitRequestSchema, async req => {
|
||||
received.push(req);
|
||||
const resp = queued.shift();
|
||||
if (!resp) throw new Error('no queued elicitation response');
|
||||
return resp;
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
// Happy path: accept all three steps
|
||||
queued.push({ action: 'accept', content: { name: 'Ada' } });
|
||||
queued.push({ action: 'accept', content: { color: 'blue' } });
|
||||
queued.push({ action: 'accept', content: { confirm: true } });
|
||||
|
||||
const ok = await client.callTool({ name: 'multi-step', arguments: {} });
|
||||
expect(ok.isError).toBeFalsy();
|
||||
expect(ok.content).toEqual([{ type: 'text', text: "Ada's favorite color is blue" }]);
|
||||
|
||||
expect(received).toHaveLength(3);
|
||||
expect(received[0].params).toMatchObject({ mode: 'form', requestedSchema: { properties: { name: { type: 'string' } } } });
|
||||
expect(received[1].params.message).toContain('Ada');
|
||||
expect(received[1].params).toMatchObject({ mode: 'form', requestedSchema: { properties: { color: { type: 'string' } } } });
|
||||
expect(received[2].params.message).toContain('Ada');
|
||||
expect(received[2].params.message).toContain('blue');
|
||||
expect(received[2].params).toMatchObject({ mode: 'form', requestedSchema: { properties: { confirm: { type: 'boolean' } } } });
|
||||
|
||||
// Decline at step 2
|
||||
queued.push({ action: 'accept', content: { name: 'Bob' } });
|
||||
queued.push({ action: 'decline' });
|
||||
const declined = await client.callTool({ name: 'multi-step', arguments: {} });
|
||||
expect(declined.isError).toBeFalsy();
|
||||
expect(declined.content).toEqual([{ type: 'text', text: 'aborted at step 2: decline' }]);
|
||||
expect(received).toHaveLength(5);
|
||||
|
||||
// Cancel at step 1
|
||||
queued.push({ action: 'cancel' });
|
||||
queued.push({ action: 'accept', content: { color: 'red' } }); // sentinel: must not be consumed
|
||||
const cancelled = await client.callTool({ name: 'multi-step', arguments: {} });
|
||||
expect(cancelled.isError).toBeFalsy();
|
||||
expect(cancelled.content).toEqual([{ type: 'text', text: 'aborted at step 1: cancel' }]);
|
||||
expect(received).toHaveLength(6);
|
||||
expect(queued).toHaveLength(1); // sentinel still queued
|
||||
});
|
||||
|
||||
verifies('flow:elicitation:url-at-session-init', async (_args: TestArgs) => {
|
||||
// Not wire(): a transport.send tap must be installed before connect to prove no client request precedes the unsolicited elicitation.
|
||||
// Server: issues URL-mode elicitation immediately after onsessioninitialized
|
||||
const makeServer = () => {
|
||||
const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } });
|
||||
s.oninitialized = () => {
|
||||
setTimeout(() => {
|
||||
void s
|
||||
.elicitInput({
|
||||
mode: 'url',
|
||||
message: 'Authorize the session',
|
||||
url: 'https://example.com/auth',
|
||||
elicitationId: 'session-init-elicit'
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 0);
|
||||
};
|
||||
s.setRequestHandler(ListToolsRequestSchema, () => ({ tools: [] }));
|
||||
return s;
|
||||
};
|
||||
|
||||
const received: ElicitRequest[] = [];
|
||||
const client = new Client({ name: 'c', version: '0' }, { capabilities: { elicitation: { url: {} } } });
|
||||
client.setRequestHandler(ElicitRequestSchema, async req => {
|
||||
received.push(req);
|
||||
return { action: 'accept' };
|
||||
});
|
||||
|
||||
const handle = hostPerSession(makeServer);
|
||||
const url = new URL('http://in-process/mcp');
|
||||
const customFetch = (u: URL | string, init?: RequestInit) => handle.handleRequest(new Request(u, init));
|
||||
const transport = new StreamableHTTPClientTransport(url, { fetch: customFetch });
|
||||
|
||||
// Tap wire before connecting
|
||||
const sent: Array<{ method?: string }> = [];
|
||||
const origSend = transport.send.bind(transport);
|
||||
transport.send = async (m, opts) => {
|
||||
sent.push(m as { method?: string });
|
||||
return origSend(m, opts);
|
||||
};
|
||||
|
||||
await client.connect(transport);
|
||||
|
||||
try {
|
||||
// Wait for the unsolicited server→client elicitation/create
|
||||
await vi.waitFor(() => expect(received.length).toBeGreaterThanOrEqual(1));
|
||||
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0].method).toBe('elicitation/create');
|
||||
const params = received[0].params;
|
||||
if (params.mode !== 'url') throw new Error('expected url mode');
|
||||
expect(params.elicitationId).toBe('session-init-elicit');
|
||||
expect(() => new URL(params.url)).not.toThrow();
|
||||
|
||||
// Client has only sent initialize so far (no post-init requests)
|
||||
const requests = sent.filter(m => 'method' in m && 'id' in m);
|
||||
expect(requests.map(r => r.method)).toEqual(['initialize']);
|
||||
|
||||
// Session survived
|
||||
await expect(client.ping()).resolves.toBeDefined();
|
||||
} finally {
|
||||
await Promise.all([client.close(), handle.close()]);
|
||||
}
|
||||
});
|
||||
|
||||
verifies('flow:elicitation:url-required-then-retry', async ({ transport }: TestArgs) => {
|
||||
// Server: tool that throws UrlElicitationRequiredError until elicitation is completed
|
||||
const completed = new Set<string>();
|
||||
let server!: McpServer;
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('url-gated', { inputSchema: z.object({}) }, () => {
|
||||
const elicitationId = 'url-elicit-1';
|
||||
if (!completed.has(elicitationId)) {
|
||||
throw new UrlElicitationRequiredError([
|
||||
{
|
||||
mode: 'url',
|
||||
message: 'Please sign in',
|
||||
elicitationId,
|
||||
url: 'https://example.com/auth'
|
||||
}
|
||||
]);
|
||||
}
|
||||
return { content: [{ type: 'text', text: 'authenticated' }] };
|
||||
});
|
||||
server = s;
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = new Client({ name: 'c', version: '0' }, { capabilities: { elicitation: { url: {} } } });
|
||||
const completionsSeen: string[] = [];
|
||||
client.setNotificationHandler(ElicitationCompleteNotificationSchema, async n => {
|
||||
completionsSeen.push(n.params.elicitationId);
|
||||
});
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
// Step 1: first call rejects with UrlElicitationRequiredError
|
||||
const err = await client.callTool({ name: 'url-gated', arguments: {} }).catch(e => e);
|
||||
expect(err).toBeInstanceOf(UrlElicitationRequiredError);
|
||||
const required = err as UrlElicitationRequiredError;
|
||||
expect(required.code).toBe(ErrorCode.UrlElicitationRequired);
|
||||
expect(required.elicitations).toHaveLength(1);
|
||||
const elicitation = required.elicitations[0];
|
||||
expect(elicitation.mode).toBe('url');
|
||||
expect(typeof elicitation.elicitationId).toBe('string');
|
||||
expect(elicitation.url).toMatch(/^https?:\/\//);
|
||||
|
||||
// Step 2: user "opens" the URL (out-of-band, simulated by marking complete)
|
||||
completed.add(elicitation.elicitationId);
|
||||
|
||||
// Step 3: server emits notifications/elicitation/complete and the client receives it for that exact elicitation
|
||||
await server.server.createElicitationCompletionNotifier(elicitation.elicitationId)();
|
||||
await vi.waitFor(() => expect(completionsSeen).toEqual([elicitation.elicitationId]));
|
||||
|
||||
// Step 4: retry succeeds
|
||||
const result = await client.callTool({ name: 'url-gated', arguments: {} });
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'authenticated' }]);
|
||||
});
|
||||
|
||||
verifies('flow:multi-client:stateful-isolation', async (_args: TestArgs) => {
|
||||
// Not wire(): three clients share one host and the test reads each transport's sessionId; wire() binds a single client to its own host.
|
||||
// Server: per-session McpServer with progress tool
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('progress', { inputSchema: z.object({ steps: z.number().int().positive() }) }, async ({ steps }, extra) => {
|
||||
const token = extra._meta?.progressToken;
|
||||
if (token !== undefined) {
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
await extra.sendNotification({
|
||||
method: 'notifications/progress',
|
||||
params: { progressToken: token, progress: i, total: steps, message: `step ${i}/${steps}` }
|
||||
});
|
||||
}
|
||||
}
|
||||
return { content: [{ type: 'text', text: `done after ${steps} steps` }] };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
// Three clients connect to the same per-session host
|
||||
const clientA = new Client({ name: 'a', version: '0' });
|
||||
const clientB = new Client({ name: 'b', version: '0' });
|
||||
const clientC = new Client({ name: 'c', version: '0' });
|
||||
|
||||
const handle = hostPerSession(makeServer);
|
||||
const url = new URL('http://in-process/mcp');
|
||||
const customFetch = (u: URL | string, init?: RequestInit) => handle.handleRequest(new Request(u, init));
|
||||
|
||||
await clientA.connect(new StreamableHTTPClientTransport(url, { fetch: customFetch }));
|
||||
await clientB.connect(new StreamableHTTPClientTransport(url, { fetch: customFetch }));
|
||||
await clientC.connect(new StreamableHTTPClientTransport(url, { fetch: customFetch }));
|
||||
|
||||
try {
|
||||
const clients = [clientA, clientB, clientC] as const;
|
||||
const sessionIds = clients.map(c => (c.transport as StreamableHTTPClientTransport).sessionId);
|
||||
|
||||
for (const id of sessionIds) {
|
||||
expect(id).toEqual(expect.any(String));
|
||||
}
|
||||
expect(new Set(sessionIds).size).toBe(3);
|
||||
|
||||
// Concurrent progress calls with distinct step counts
|
||||
const STEPS = [2, 3, 4] as const;
|
||||
const errors: Error[][] = [[], [], []];
|
||||
clients.forEach((cl, i) => {
|
||||
cl.onerror = e => errors[i].push(e);
|
||||
});
|
||||
|
||||
const runs = await Promise.all(
|
||||
clients.map(async (cl, i) => {
|
||||
const received: Progress[] = [];
|
||||
const result = await cl.callTool({ name: 'progress', arguments: { steps: STEPS[i] } }, undefined, {
|
||||
onprogress: p => received.push({ progress: p.progress, total: p.total, message: p.message })
|
||||
});
|
||||
return { received, result };
|
||||
})
|
||||
);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const steps = STEPS[i];
|
||||
const { received, result } = runs[i];
|
||||
expect(received).toEqual(
|
||||
Array.from({ length: steps }, (_, k) => ({
|
||||
progress: k + 1,
|
||||
total: steps,
|
||||
message: `step ${k + 1}/${steps}`
|
||||
}))
|
||||
);
|
||||
expect(result.content).toEqual([{ type: 'text', text: `done after ${steps} steps` }]);
|
||||
}
|
||||
|
||||
for (const errs of errors) {
|
||||
expect(errs).toEqual([]);
|
||||
}
|
||||
} finally {
|
||||
await Promise.all([clientA.close(), clientB.close(), clientC.close(), handle.close()]);
|
||||
}
|
||||
});
|
||||
|
||||
verifies('flow:oauth:authorization-code-roundtrip', async (_args: TestArgs) => {
|
||||
// Not wire(): needs an authProvider-equipped client transport plus 401/PRM/AS fetch routing in front of the host, which wire() does not expose.
|
||||
const ACCESS_TOKEN = 'roundtrip-access-token';
|
||||
const AUTH_CODE = 'granted-authorization-code';
|
||||
const AS_ORIGIN = 'https://as.example.com';
|
||||
const PRM_PATH = '/.well-known/oauth-protected-resource';
|
||||
|
||||
// Mock Authorization Server: metadata discovery, dynamic client registration, code exchange.
|
||||
const registerRequests: Array<Record<string, unknown>> = [];
|
||||
const tokenRequests: URLSearchParams[] = [];
|
||||
const mockASFetch = async (url: URL | string, init?: RequestInit): Promise<Response> => {
|
||||
const req = new Request(url, init);
|
||||
const u = new URL(req.url);
|
||||
if (u.pathname === '/.well-known/oauth-authorization-server') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
issuer: AS_ORIGIN,
|
||||
authorization_endpoint: `${AS_ORIGIN}/authorize`,
|
||||
token_endpoint: `${AS_ORIGIN}/token`,
|
||||
registration_endpoint: `${AS_ORIGIN}/register`,
|
||||
grant_types_supported: ['authorization_code'],
|
||||
response_types_supported: ['code'],
|
||||
code_challenge_methods_supported: ['S256']
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
if (u.pathname === '/register' && req.method === 'POST') {
|
||||
const body: Record<string, unknown> = await req.json();
|
||||
registerRequests.push(body);
|
||||
// RFC 7591: the registration response echoes the submitted metadata plus the issued client_id.
|
||||
return new Response(JSON.stringify({ ...body, client_id: 'mock-client-id' }), {
|
||||
status: 201,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
if (u.pathname === '/token' && req.method === 'POST') {
|
||||
tokenRequests.push(new URLSearchParams(await req.text()));
|
||||
return new Response(JSON.stringify({ access_token: ACCESS_TOKEN, token_type: 'Bearer', expires_in: 3600 }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
return new Response('Not Found', { status: 404 });
|
||||
};
|
||||
|
||||
// Mock OAuthClientProvider
|
||||
const redirectedTo: string[] = [];
|
||||
const saved: { tokens?: OAuthTokens; clientInfo?: OAuthClientInformationMixed; codeVerifier?: string } = {};
|
||||
const provider: OAuthClientProvider = {
|
||||
get redirectUrl() {
|
||||
return 'http://localhost/callback';
|
||||
},
|
||||
get clientMetadata(): OAuthClientMetadata {
|
||||
return {
|
||||
client_name: 'test-client',
|
||||
client_uri: 'http://localhost',
|
||||
redirect_uris: ['http://localhost/callback']
|
||||
};
|
||||
},
|
||||
clientInformation: () => saved.clientInfo,
|
||||
saveClientInformation: async ci => {
|
||||
saved.clientInfo = ci;
|
||||
},
|
||||
tokens: () => saved.tokens,
|
||||
saveTokens: async t => {
|
||||
saved.tokens = t;
|
||||
},
|
||||
codeVerifier: () => {
|
||||
if (!saved.codeVerifier) throw new Error('No code verifier saved');
|
||||
return saved.codeVerifier;
|
||||
},
|
||||
saveCodeVerifier: async v => {
|
||||
saved.codeVerifier = v;
|
||||
},
|
||||
redirectToAuthorization: async url => {
|
||||
redirectedTo.push(url.toString());
|
||||
}
|
||||
};
|
||||
|
||||
// Server with bearer auth
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({
|
||||
content: [{ type: 'text', text }]
|
||||
}));
|
||||
return s;
|
||||
};
|
||||
|
||||
// Protected host: serves RFC 9728 resource metadata, otherwise requires the issued bearer token.
|
||||
const handle = hostPerSession(makeServer);
|
||||
const url = new URL('http://in-process/mcp');
|
||||
const serverFetch = async (u: URL | string, init?: RequestInit): Promise<Response> => {
|
||||
const req = new Request(u, init);
|
||||
const requestUrl = new URL(req.url);
|
||||
if (requestUrl.pathname.startsWith(PRM_PATH)) {
|
||||
return new Response(JSON.stringify({ resource: url.toString(), authorization_servers: [AS_ORIGIN] }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
const auth = req.headers.get('authorization');
|
||||
if (auth !== `Bearer ${ACCESS_TOKEN}`) {
|
||||
return new Response(null, {
|
||||
status: 401,
|
||||
headers: { 'WWW-Authenticate': `Bearer resource_metadata="http://in-process${PRM_PATH}/mcp"` }
|
||||
});
|
||||
}
|
||||
return handle.handleRequest(req);
|
||||
};
|
||||
|
||||
const combinedFetch = async (u: URL | string, init?: RequestInit): Promise<Response> => {
|
||||
const requestUrl = typeof u === 'string' ? new URL(u) : u;
|
||||
if (requestUrl.hostname === 'as.example.com') return mockASFetch(requestUrl, init);
|
||||
return serverFetch(requestUrl, init);
|
||||
};
|
||||
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
|
||||
try {
|
||||
// Step 1: first connect fails with 401 → discovery + DCR + redirect to the authorization endpoint
|
||||
const transport1 = new StreamableHTTPClientTransport(url, { authProvider: provider, fetch: combinedFetch });
|
||||
await expect(client.connect(transport1)).rejects.toBeInstanceOf(UnauthorizedError);
|
||||
|
||||
expect(redirectedTo).toHaveLength(1);
|
||||
const authorizeUrl = new URL(redirectedTo[0]);
|
||||
expect(authorizeUrl.origin).toBe(AS_ORIGIN);
|
||||
expect(authorizeUrl.pathname).toBe('/authorize');
|
||||
expect(authorizeUrl.searchParams.get('client_id')).toBe('mock-client-id');
|
||||
expect(authorizeUrl.searchParams.get('code_challenge')).toBeTruthy();
|
||||
expect(registerRequests).toHaveLength(1);
|
||||
expect(saved.tokens).toBeUndefined();
|
||||
|
||||
const codeVerifier = saved.codeVerifier;
|
||||
if (!codeVerifier) throw new Error('No code verifier saved during the redirect step');
|
||||
|
||||
// Step 2: user completes redirect, finishAuth exchanges the code for tokens
|
||||
await transport1.finishAuth(AUTH_CODE);
|
||||
|
||||
expect(tokenRequests).toHaveLength(1);
|
||||
expect(tokenRequests[0].get('grant_type')).toBe('authorization_code');
|
||||
expect(tokenRequests[0].get('code')).toBe(AUTH_CODE);
|
||||
expect(tokenRequests[0].get('code_verifier')).toBe(codeVerifier);
|
||||
expect(tokenRequests[0].get('redirect_uri')).toBe('http://localhost/callback');
|
||||
expect(saved.tokens?.access_token).toBe(ACCESS_TOKEN);
|
||||
|
||||
// Step 3: second connect with fresh transport succeeds and tools/list works
|
||||
const transport2 = new StreamableHTTPClientTransport(url, { authProvider: provider, fetch: combinedFetch });
|
||||
await client.connect(transport2);
|
||||
|
||||
const { tools } = await client.listTools();
|
||||
expect(tools.some(t => t.name === 'echo')).toBe(true);
|
||||
} finally {
|
||||
await Promise.all([client.close(), handle.close()]);
|
||||
}
|
||||
});
|
||||
|
||||
verifies('flow:resume:tool-call-resumption-token', async (_args: TestArgs) => {
|
||||
// Not wire(): needs an EventStore-backed host and a severable fetch to simulate a mid-stream disconnect, which wire() does not expose.
|
||||
const TOTAL_STEPS = 4;
|
||||
const DELIVERED_BEFORE_DISCONNECT = 2;
|
||||
|
||||
// The tool pauses after the first two progress notifications so the test can sever the call's SSE
|
||||
// stream before the remaining notifications and the result are produced.
|
||||
let releaseRemainingSteps!: () => void;
|
||||
const remainingStepsReleased = new Promise<void>(resolve => {
|
||||
releaseRemainingSteps = resolve;
|
||||
});
|
||||
let toolRuns = 0;
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('import-records', { inputSchema: z.object({ steps: z.number().int().positive() }) }, async ({ steps }, extra) => {
|
||||
toolRuns++;
|
||||
const token = extra._meta?.progressToken;
|
||||
if (token === undefined) throw new Error('import-records expects a progressToken');
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
if (i === DELIVERED_BEFORE_DISCONNECT + 1) {
|
||||
await remainingStepsReleased;
|
||||
}
|
||||
await extra.sendNotification({
|
||||
method: 'notifications/progress',
|
||||
params: { progressToken: token, progress: i, total: steps }
|
||||
});
|
||||
}
|
||||
return { content: [{ type: 'text', text: `imported ${steps} record batches` }] };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
// Insertion-ordered EventStore: replay order (and therefore the exact-sequence assertions below) must not depend on timestamps.
|
||||
const storedEvents: Array<{ eventId: string; streamId: string; message: JSONRPCMessage }> = [];
|
||||
const eventStore: EventStore = {
|
||||
storeEvent: async (streamId, message) => {
|
||||
const eventId = `${streamId}|${storedEvents.length + 1}`;
|
||||
storedEvents.push({ eventId, streamId, message });
|
||||
return eventId;
|
||||
},
|
||||
replayEventsAfter: async (lastEventId, { send }) => {
|
||||
const lastIndex = storedEvents.findIndex(e => e.eventId === lastEventId);
|
||||
if (lastIndex === -1) return '';
|
||||
const { streamId } = storedEvents[lastIndex];
|
||||
for (const event of storedEvents.slice(lastIndex + 1)) {
|
||||
if (event.streamId === streamId) await send(event.eventId, event.message);
|
||||
}
|
||||
return streamId;
|
||||
}
|
||||
};
|
||||
|
||||
const handle = hostResumable(makeServer, { eventStore, retryInterval: 0 });
|
||||
const url = new URL('http://in-process/mcp');
|
||||
|
||||
const requests: Array<{ method: string; lastEventId: string | undefined; sessionId: string | undefined; body: string | undefined }> =
|
||||
[];
|
||||
|
||||
// The tools/call response is wrapped so the test can sever the stream the client is reading (a simulated
|
||||
// network drop); the server keeps writing the rest of the stream, which only the EventStore retains.
|
||||
let severToolCallStream: () => void = () => {
|
||||
throw new Error('tools/call SSE stream not opened yet');
|
||||
};
|
||||
let originalStreamFinished!: () => void;
|
||||
const originalStreamComplete = new Promise<void>(resolve => {
|
||||
originalStreamFinished = resolve;
|
||||
});
|
||||
|
||||
const customFetch = async (u: URL | string, init?: RequestInit): Promise<Response> => {
|
||||
const request = new Request(u, init);
|
||||
const lastEventId = request.headers.get('last-event-id') ?? undefined;
|
||||
const body = typeof init?.body === 'string' ? init.body : undefined;
|
||||
requests.push({ method: request.method, lastEventId, sessionId: request.headers.get('mcp-session-id') ?? undefined, body });
|
||||
|
||||
// Hold the transport's recovery GET until the server has finished the interrupted stream, so the
|
||||
// replay deterministically contains every message the client missed.
|
||||
if (request.method === 'GET' && lastEventId !== undefined) {
|
||||
await originalStreamComplete;
|
||||
}
|
||||
|
||||
const response = await handle.handleRequest(request);
|
||||
if (request.method !== 'POST' || !body?.includes('"tools/call"') || !response.body) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const upstream = response.body.getReader();
|
||||
let severed = false;
|
||||
const severable = new ReadableStream<Uint8Array>({
|
||||
start: controller => {
|
||||
severToolCallStream = () => {
|
||||
severed = true;
|
||||
controller.error(new Error('simulated mid-stream network drop'));
|
||||
};
|
||||
void (async () => {
|
||||
while (true) {
|
||||
const { value, done } = await upstream.read();
|
||||
if (done) break;
|
||||
if (!severed && value) controller.enqueue(value);
|
||||
}
|
||||
originalStreamFinished();
|
||||
if (!severed) controller.close();
|
||||
})();
|
||||
}
|
||||
});
|
||||
return new Response(severable, { status: response.status, headers: response.headers });
|
||||
};
|
||||
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
const transport = new StreamableHTTPClientTransport(url, {
|
||||
fetch: customFetch,
|
||||
reconnectionOptions: { initialReconnectionDelay: 10, maxReconnectionDelay: 10, reconnectionDelayGrowFactor: 1, maxRetries: 2 }
|
||||
});
|
||||
await client.connect(transport);
|
||||
|
||||
try {
|
||||
const progressSeen: number[] = [];
|
||||
const eventIdsSeen: string[] = [];
|
||||
const callSettled = vi.fn();
|
||||
|
||||
// The one and only tools/call of this test: this same promise must resolve after the disconnect.
|
||||
const call = client.callTool({ name: 'import-records', arguments: { steps: TOTAL_STEPS } }, undefined, {
|
||||
onprogress: (p: Progress) => progressSeen.push(p.progress),
|
||||
onresumptiontoken: id => eventIdsSeen.push(id)
|
||||
});
|
||||
call.then(callSettled, callSettled);
|
||||
|
||||
await vi.waitFor(() => expect(progressSeen).toEqual([1, 2]));
|
||||
const lastEventIdBeforeDisconnect = eventIdsSeen[eventIdsSeen.length - 1];
|
||||
|
||||
// Sever mid-call, then let the server finish steps 3..4 and the result while the client is disconnected.
|
||||
severToolCallStream();
|
||||
expect(callSettled).not.toHaveBeenCalled();
|
||||
releaseRemainingSteps();
|
||||
await originalStreamComplete;
|
||||
expect(progressSeen).toEqual([1, 2]);
|
||||
expect(callSettled).not.toHaveBeenCalled();
|
||||
|
||||
// The original promise resolves with the tool's result; the handler ran exactly once.
|
||||
const result = await call;
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(result.content).toEqual([{ type: 'text', text: `imported ${TOTAL_STEPS} record batches` }]);
|
||||
expect(toolRuns).toBe(1);
|
||||
|
||||
// Recovery delivered only the missed notifications: no duplicates, none missing.
|
||||
expect(progressSeen).toEqual([1, 2, 3, 4]);
|
||||
|
||||
const lastEventBeforeDisconnect = storedEvents.find(e => e.eventId === lastEventIdBeforeDisconnect);
|
||||
if (!lastEventBeforeDisconnect) throw new Error('the last event id delivered before the disconnect was never stored');
|
||||
const toolCallStreamEvents = storedEvents.filter(e => e.streamId === lastEventBeforeDisconnect.streamId);
|
||||
|
||||
// The whole stream was persisted (priming placeholder, four progress notifications, result), and the
|
||||
// client saw each of its events exactly once, in order, across the original stream and the replay.
|
||||
expect(
|
||||
toolCallStreamEvents.map(e => ('method' in e.message ? e.message.method : 'result' in e.message ? 'response' : 'priming'))
|
||||
).toEqual([
|
||||
'priming',
|
||||
'notifications/progress',
|
||||
'notifications/progress',
|
||||
'notifications/progress',
|
||||
'notifications/progress',
|
||||
'response'
|
||||
]);
|
||||
expect(eventIdsSeen).toEqual(toolCallStreamEvents.map(e => e.eventId));
|
||||
// Index DELIVERED_BEFORE_DISCONNECT skips the priming event, landing on the second progress notification.
|
||||
expect(lastEventIdBeforeDisconnect).toBe(toolCallStreamEvents[DELIVERED_BEFORE_DISCONNECT].eventId);
|
||||
|
||||
// Transparency: the caller never re-issued the call; the transport recovered with a single GET
|
||||
// carrying Last-Event-ID for the last event delivered before the disconnect, on the same session.
|
||||
expect(requests.filter(r => r.method === 'POST' && r.body?.includes('"tools/call"'))).toHaveLength(1);
|
||||
const recoveryGets = requests.filter(r => r.method === 'GET' && r.lastEventId !== undefined);
|
||||
expect(recoveryGets).toHaveLength(1);
|
||||
expect(recoveryGets[0].lastEventId).toBe(lastEventIdBeforeDisconnect);
|
||||
expect(transport.sessionId).toEqual(expect.any(String));
|
||||
expect(recoveryGets[0].sessionId).toBe(transport.sessionId);
|
||||
} finally {
|
||||
await Promise.all([client.close(), handle.close()]);
|
||||
}
|
||||
});
|
||||
|
||||
verifies('flow:session:terminate-then-reconnect', async (_args: TestArgs) => {
|
||||
// Not wire(): drives StreamableHTTPClientTransport directly for sessionId/terminateSession() and connects a second transport to the same host.
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({
|
||||
content: [{ type: 'text', text }]
|
||||
}));
|
||||
return s;
|
||||
};
|
||||
|
||||
const handle = hostPerSession(makeServer);
|
||||
const url = new URL('http://in-process/mcp');
|
||||
const customFetch = (u: URL | string, init?: RequestInit) => handle.handleRequest(new Request(u, init));
|
||||
|
||||
const client1 = new Client({ name: 'c', version: '0' });
|
||||
const transport1 = new StreamableHTTPClientTransport(url, { fetch: customFetch });
|
||||
await client1.connect(transport1);
|
||||
|
||||
try {
|
||||
const originalSessionId = transport1.sessionId;
|
||||
expect(originalSessionId).toEqual(expect.any(String));
|
||||
|
||||
// Terminate session
|
||||
await transport1.terminateSession();
|
||||
expect(transport1.sessionId).toBeUndefined();
|
||||
|
||||
// Close first client
|
||||
await client1.close();
|
||||
|
||||
// Fresh client and transport obtain new session
|
||||
const client2 = new Client({ name: 'c', version: '0' });
|
||||
const transport2 = new StreamableHTTPClientTransport(url, { fetch: customFetch });
|
||||
await client2.connect(transport2);
|
||||
|
||||
const newSessionId = transport2.sessionId;
|
||||
expect(newSessionId).toEqual(expect.any(String));
|
||||
expect(newSessionId).not.toBe(originalSessionId);
|
||||
|
||||
// Operations succeed on new session
|
||||
const result = await client2.callTool({ name: 'echo', arguments: { text: 'after-reconnect' } });
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'after-reconnect' }]);
|
||||
|
||||
await client2.close();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('flow:tool-result:resource-link-follow', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('resource-link', { inputSchema: z.object({}) }, () => ({
|
||||
content: [
|
||||
{
|
||||
type: 'resource_link',
|
||||
uri: 'file:///linked.txt',
|
||||
name: 'linked.txt',
|
||||
mimeType: 'text/plain'
|
||||
}
|
||||
]
|
||||
}));
|
||||
s.registerResource('linked.txt', 'file:///linked.txt', { mimeType: 'text/plain' }, async () => ({
|
||||
contents: [{ uri: 'file:///linked.txt', mimeType: 'text/plain', text: 'linked resource contents' }]
|
||||
}));
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
// Call tool and get resource_link
|
||||
const toolResult = (await client.callTool({ name: 'resource-link', arguments: {} })) as CallToolResult;
|
||||
expect(toolResult.isError).toBeFalsy();
|
||||
|
||||
const link = toolResult.content.find(c => c.type === 'resource_link');
|
||||
expect(link).toBeDefined();
|
||||
if (link?.type !== 'resource_link') throw new Error('unreachable');
|
||||
expect(link.uri).toBe('file:///linked.txt');
|
||||
|
||||
// Follow the link with resources/read
|
||||
const readResult = await client.readResource({ uri: link.uri });
|
||||
expect(readResult.contents).toHaveLength(1);
|
||||
const [entry] = readResult.contents;
|
||||
expect(entry.uri).toBe(link.uri);
|
||||
expect(entry.mimeType).toBe('text/plain');
|
||||
if (entry.mimeType?.startsWith('text/')) {
|
||||
expect('text' in entry ? entry.text : '').toBe('linked resource contents');
|
||||
}
|
||||
});
|
||||
|
||||
verifies('flow:proxy:forward-tools-resources', async ({ transport }: TestArgs) => {
|
||||
// Upstream server with tools and resources
|
||||
const upstreamServer = new McpServer({ name: 'upstream', version: '0' });
|
||||
upstreamServer.registerTool('echo', { description: 'Echoes text', inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({
|
||||
content: [{ type: 'text', text }]
|
||||
}));
|
||||
upstreamServer.registerTool(
|
||||
'annotated',
|
||||
{
|
||||
description: 'Annotated tool',
|
||||
inputSchema: z.object({}),
|
||||
annotations: { readOnlyHint: true, idempotentHint: true },
|
||||
_meta: { 'example.com/fixture': true }
|
||||
},
|
||||
() => ({ content: [] })
|
||||
);
|
||||
upstreamServer.registerResource(
|
||||
'annotated',
|
||||
'file:///annotated.md',
|
||||
{
|
||||
mimeType: 'text/markdown',
|
||||
_meta: { 'example.com/fixture': true }
|
||||
},
|
||||
async () => ({
|
||||
contents: [{ uri: 'file:///annotated.md', mimeType: 'text/markdown', text: '# Annotated' }]
|
||||
})
|
||||
);
|
||||
|
||||
// Proxy: low-level Server downstream + Client upstream
|
||||
const upstreamClient = new Client({ name: 'proxy-upstream', version: '0' });
|
||||
const proxyServer = new Server({ name: 'proxy', version: '0' }, { capabilities: { tools: {}, resources: {} } });
|
||||
|
||||
proxyServer.setRequestHandler(ListToolsRequestSchema, async req => {
|
||||
const result = await upstreamClient.listTools(req.params);
|
||||
return { tools: result.tools, nextCursor: result.nextCursor };
|
||||
});
|
||||
proxyServer.setRequestHandler(ListResourcesRequestSchema, async req => {
|
||||
const result = await upstreamClient.listResources(req.params);
|
||||
return { resources: result.resources, nextCursor: result.nextCursor };
|
||||
});
|
||||
proxyServer.setRequestHandler(ReadResourceRequestSchema, async req => {
|
||||
return upstreamClient.readResource(req.params);
|
||||
});
|
||||
|
||||
// Wire: downstream client → proxy server → upstream client → upstream server
|
||||
const downstreamClient = new Client({ name: 'downstream', version: '0' });
|
||||
|
||||
await using _upstreamW = await wire(transport, () => upstreamServer, upstreamClient);
|
||||
await using _proxyW = await wire(transport, () => proxyServer, downstreamClient);
|
||||
|
||||
// Downstream sees upstream tools
|
||||
const { tools } = await downstreamClient.listTools();
|
||||
const echo = tools.find(t => t.name === 'echo');
|
||||
expect(echo).toBeDefined();
|
||||
expect(echo!.description).toBe('Echoes text');
|
||||
expect(echo!.inputSchema.properties).toMatchObject({ text: { type: 'string' } });
|
||||
|
||||
const annotatedTool = tools.find(t => t.name === 'annotated');
|
||||
expect(annotatedTool).toBeDefined();
|
||||
expect(annotatedTool!.annotations).toMatchObject({ readOnlyHint: true, idempotentHint: true });
|
||||
expect(annotatedTool!._meta).toEqual({ 'example.com/fixture': true });
|
||||
|
||||
// Downstream sees upstream resources
|
||||
const { resources } = await downstreamClient.listResources();
|
||||
const annotatedRes = resources.find(r => r.uri === 'file:///annotated.md');
|
||||
expect(annotatedRes).toBeDefined();
|
||||
expect(annotatedRes!.name).toBe('annotated');
|
||||
expect(annotatedRes!._meta).toEqual({ 'example.com/fixture': true });
|
||||
});
|
||||
140
test/e2e/scenarios/hosting-auth.test.ts
Normal file
140
test/e2e/scenarios/hosting-auth.test.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
/**
|
||||
* Self-contained test bodies for server-side bearer auth on StreamableHTTP
|
||||
* hosting.
|
||||
*
|
||||
* The SDK ships its bearer-auth enforcement (`requireBearerAuth`), AS router
|
||||
* (`mcpAuthRouter`) and discovery-metadata router (`mcpAuthMetadataRouter`)
|
||||
* as Express `RequestHandler`s only — they consume Express `req`/`res`, so
|
||||
* they cannot be driven in-process with Web-standard Request/Response the way
|
||||
* this suite hosts StreamableHTTP. The requirements those components alone
|
||||
* satisfy (401/403 challenges, audience validation, AS endpoint mounting,
|
||||
* `.well-known` publication) are therefore not covered here: re-implementing
|
||||
* that behavior in a test wrapper and asserting on the wrapper would prove
|
||||
* nothing about the SDK. What this file does cover is the hosting-integration
|
||||
* path the Web-standard transport supports natively: a user-shaped bearer
|
||||
* gate verifies the Authorization header, then delegates to
|
||||
* `WebStandardStreamableHTTPServerTransport.handleRequest(req, { authInfo })`,
|
||||
* and the SDK must surface that verified AuthInfo to tool handlers as
|
||||
* `extra.authInfo`.
|
||||
*
|
||||
* Function names mirror the requirement id in camelCase. Bodies are
|
||||
* streamableHttp-only and host the transport themselves (the gate has to sit
|
||||
* between the client's fetch and the transport), so `TestArgs.transport` is
|
||||
* ignored.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { expect } from 'vitest';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { Client } from '../../../src/client/index.js';
|
||||
import { StreamableHTTPClientTransport } from '../../../src/client/streamableHttp.js';
|
||||
import type { AuthInfo } from '../../../src/server/auth/types.js';
|
||||
import { McpServer } from '../../../src/server/mcp.js';
|
||||
import { WebStandardStreamableHTTPServerTransport } from '../../../src/server/webStandardStreamableHttp.js';
|
||||
|
||||
import type { TestArgs } from '../types.js';
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
|
||||
const VALID_TOKEN = 'analytics-dashboard-access-token';
|
||||
|
||||
verifies('hosting:auth:authinfo-propagates', async (_args: TestArgs) => {
|
||||
const expiresAt = Math.floor(Date.now() / 1000) + 3600;
|
||||
// What the user's verifier derives from VALID_TOKEN; a fresh object is built
|
||||
// per request so the handler-side assertion checks delivery, not identity.
|
||||
const verifyBearer = (header: string | null): AuthInfo | undefined =>
|
||||
header === `Bearer ${VALID_TOKEN}`
|
||||
? {
|
||||
token: VALID_TOKEN,
|
||||
clientId: 'analytics-dashboard',
|
||||
scopes: ['mcp:tools:read', 'mcp:tools:call'],
|
||||
expiresAt,
|
||||
extra: { userId: 'user-42' }
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Recorders live outside the per-session factory.
|
||||
const seenByTool: Array<AuthInfo | undefined> = [];
|
||||
const postAuthHeaders: Array<string | null> = [];
|
||||
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool(
|
||||
'whoami',
|
||||
{ description: 'Reports the authenticated caller derived from extra.authInfo.', inputSchema: z.object({}) },
|
||||
(_a, extra) => {
|
||||
seenByTool.push(extra.authInfo);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: extra.authInfo ? `${extra.authInfo.clientId} [${extra.authInfo.scopes.join(' ')}]` : 'no-auth-info'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
);
|
||||
return s;
|
||||
};
|
||||
|
||||
// User-shaped hosting: a bearer gate verifies the Authorization header and
|
||||
// hands the verified AuthInfo to the SDK transport via handleRequest options.
|
||||
const sessions = new Map<string, WebStandardStreamableHTTPServerTransport>();
|
||||
const handleRequest = async (req: Request): Promise<Response> => {
|
||||
if (req.method === 'POST') postAuthHeaders.push(req.headers.get('authorization'));
|
||||
const authInfo = verifyBearer(req.headers.get('authorization'));
|
||||
if (!authInfo) {
|
||||
return new Response(JSON.stringify({ error: 'invalid_token' }), {
|
||||
status: 401,
|
||||
headers: { 'content-type': 'application/json', 'www-authenticate': 'Bearer error="invalid_token"' }
|
||||
});
|
||||
}
|
||||
|
||||
const sid = req.headers.get('mcp-session-id') ?? undefined;
|
||||
const existing = sid ? sessions.get(sid) : undefined;
|
||||
if (existing) return existing.handleRequest(req, { authInfo });
|
||||
|
||||
const tx = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: randomUUID,
|
||||
onsessioninitialized: id => void sessions.set(id, tx),
|
||||
onsessionclosed: id => void sessions.delete(id)
|
||||
});
|
||||
await makeServer().connect(tx);
|
||||
return tx.handleRequest(req, { authInfo });
|
||||
};
|
||||
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
await using _ = {
|
||||
[Symbol.asyncDispose]: async () => {
|
||||
await client.close();
|
||||
for (const t of sessions.values()) await t.close();
|
||||
}
|
||||
};
|
||||
|
||||
await client.connect(
|
||||
new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), {
|
||||
fetch: (url, init) => handleRequest(new Request(url, init)),
|
||||
requestInit: { headers: { Authorization: `Bearer ${VALID_TOKEN}` } }
|
||||
})
|
||||
);
|
||||
|
||||
const r = await client.callTool({ name: 'whoami', arguments: {} });
|
||||
expect(r.isError).toBeFalsy();
|
||||
expect(r.content).toEqual([{ type: 'text', text: 'analytics-dashboard [mcp:tools:read mcp:tools:call]' }]);
|
||||
|
||||
// The SDK transport delivered the verified AuthInfo into the tool handler's
|
||||
// RequestHandlerExtra unchanged — not dropped, not replaced by a placeholder.
|
||||
expect(seenByTool).toHaveLength(1);
|
||||
expect(seenByTool[0]).toEqual({
|
||||
token: VALID_TOKEN,
|
||||
clientId: 'analytics-dashboard',
|
||||
scopes: ['mcp:tools:read', 'mcp:tools:call'],
|
||||
expiresAt,
|
||||
extra: { userId: 'user-42' }
|
||||
});
|
||||
|
||||
// Sanity: the bearer was on the wire for every POST the SDK client sent
|
||||
// (initialize, notifications/initialized, tools/call).
|
||||
expect(postAuthHeaders).toEqual([`Bearer ${VALID_TOKEN}`, `Bearer ${VALID_TOKEN}`, `Bearer ${VALID_TOKEN}`]);
|
||||
});
|
||||
780
test/e2e/scenarios/hosting-express.test.ts
Normal file
780
test/e2e/scenarios/hosting-express.test.ts
Normal file
|
|
@ -0,0 +1,780 @@
|
|||
/**
|
||||
* Self-contained test bodies for Express-bound hosting surfaces.
|
||||
*
|
||||
* These tests cover the SDK's Express-bound surface (auth middleware/routers, host
|
||||
* validation, createMcpExpressApp) over real HTTP — the layer a server operator
|
||||
* deploys and remote clients depend on; Client/Server are not the subject.
|
||||
*
|
||||
* The SDK's requireBearerAuth, mcpAuthRouter, mcpAuthMetadataRouter, and host-
|
||||
* header validation middleware are Express RequestHandlers; they cannot be
|
||||
* exercised with in-process Web-standard Request/Response. These tests build
|
||||
* real Express apps, listen on ephemeral ports (127.0.0.1), drive them with
|
||||
* fetch(), and assert exact HTTP status + header + body shapes.
|
||||
*
|
||||
* Function names mirror the requirement id in camelCase. NO casts, exact
|
||||
* assertions, closure recorders outside factories (for stateless compat),
|
||||
* minimal comments, every server closed in finally.
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import http from 'node:http';
|
||||
|
||||
import express, { type RequestHandler } from 'express';
|
||||
import { expect } from 'vitest';
|
||||
|
||||
import { requireBearerAuth } from '../../../src/server/auth/middleware/bearerAuth.js';
|
||||
import { mcpAuthRouter, mcpAuthMetadataRouter, createOAuthMetadata } from '../../../src/server/auth/router.js';
|
||||
import { ProxyOAuthServerProvider } from '../../../src/server/auth/providers/proxyProvider.js';
|
||||
import type { OAuthServerProvider } from '../../../src/server/auth/provider.js';
|
||||
import { InvalidGrantError, InvalidTokenError } from '../../../src/server/auth/errors.js';
|
||||
import { createMcpExpressApp } from '../../../src/server/express.js';
|
||||
import type { OAuthClientInformationFull } from '../../../src/shared/auth.js';
|
||||
|
||||
import type { TestArgs } from '../types.js';
|
||||
import { startExpressMinimal, startExpressWithHostValidation, type ExpressHost } from '../helpers/express.js';
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
|
||||
const RESOURCE_METADATA_URL = 'https://mcp.example.com/.well-known/oauth-protected-resource';
|
||||
const VALID_TOKEN = 'analytics-dashboard-token';
|
||||
const EXPIRED_TOKEN = 'expired-access-token';
|
||||
const MALFORMED_TOKEN = 'not-a-valid-jwt';
|
||||
|
||||
/**
|
||||
* POST `body` to `url` via `node:http`, forcing `Host: <host>`.
|
||||
* Unlike undici fetch(), node:http sends caller-supplied Host header verbatim.
|
||||
*/
|
||||
function postWithHost(url: URL, host: string, body: string): Promise<{ status: number; body: string; headers: http.IncomingHttpHeaders }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Host: host,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
'Content-Length': Buffer.byteLength(body)
|
||||
}
|
||||
},
|
||||
res => {
|
||||
let data = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', chunk => (data += chunk));
|
||||
res.on('end', () => resolve({ status: res.statusCode ?? 0, body: data, headers: res.headers }));
|
||||
res.on('error', reject);
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
verifies('hosting:auth:missing-401', async (_args: TestArgs) => {
|
||||
const verifier = { verifyAccessToken: async (_token: string) => ({ token: '', clientId: 'test', scopes: [], expiresAt: 1e12 }) };
|
||||
|
||||
await using host = await startExpressMinimal(requireBearerAuth({ verifier, resourceMetadataUrl: RESOURCE_METADATA_URL }));
|
||||
|
||||
const res = await fetch(new URL('/mcp', host.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
|
||||
const wwwAuth = res.headers.get('www-authenticate');
|
||||
expect(wwwAuth).toBeTruthy();
|
||||
expect(wwwAuth).toMatch(/^Bearer\b/i);
|
||||
expect(wwwAuth).toContain('error="invalid_token"');
|
||||
expect(wwwAuth).toContain(`resource_metadata="${RESOURCE_METADATA_URL}"`);
|
||||
|
||||
const body = (await res.json()) as { error?: string };
|
||||
expect(body.error).toBe('invalid_token');
|
||||
});
|
||||
|
||||
verifies('hosting:auth:invalid-401', async (_args: TestArgs) => {
|
||||
const verifier = {
|
||||
verifyAccessToken: async (token: string) => {
|
||||
if (token === MALFORMED_TOKEN) throw new InvalidTokenError('Token verification failed');
|
||||
return { token, clientId: 'test', scopes: [], expiresAt: 1e12 };
|
||||
}
|
||||
};
|
||||
|
||||
await using host = await startExpressMinimal(requireBearerAuth({ verifier }));
|
||||
|
||||
const res = await fetch(new URL('/mcp', host.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${MALFORMED_TOKEN}`
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
|
||||
const wwwAuth = res.headers.get('www-authenticate');
|
||||
expect(wwwAuth).toBeTruthy();
|
||||
expect(wwwAuth).toMatch(/^Bearer\b/i);
|
||||
expect(wwwAuth).toContain('error="invalid_token"');
|
||||
});
|
||||
|
||||
verifies('hosting:auth:expired-401', async (_args: TestArgs) => {
|
||||
const PAST_EXPIRY = 1;
|
||||
const verifier = {
|
||||
verifyAccessToken: async (token: string) => ({
|
||||
token,
|
||||
clientId: 'test-client',
|
||||
scopes: [],
|
||||
expiresAt: token === EXPIRED_TOKEN ? PAST_EXPIRY : Date.now() / 1000 + 3600
|
||||
})
|
||||
};
|
||||
|
||||
await using host = await startExpressMinimal(requireBearerAuth({ verifier }));
|
||||
|
||||
const res = await fetch(new URL('/mcp', host.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${EXPIRED_TOKEN}`
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
|
||||
const wwwAuth = res.headers.get('www-authenticate');
|
||||
expect(wwwAuth).toBeTruthy();
|
||||
expect(wwwAuth).toContain('error="invalid_token"');
|
||||
});
|
||||
|
||||
verifies('hosting:auth:scope-403', async (_args: TestArgs) => {
|
||||
const verifier = {
|
||||
verifyAccessToken: async (token: string) => ({
|
||||
token,
|
||||
clientId: 'test-client',
|
||||
scopes: token === VALID_TOKEN ? ['mcp:tools:read'] : ['mcp:tools:call'],
|
||||
expiresAt: Date.now() / 1000 + 3600
|
||||
})
|
||||
};
|
||||
|
||||
await using host = await startExpressMinimal(
|
||||
requireBearerAuth({
|
||||
verifier,
|
||||
requiredScopes: ['mcp:tools:read', 'mcp:tools:call'],
|
||||
resourceMetadataUrl: RESOURCE_METADATA_URL
|
||||
})
|
||||
);
|
||||
|
||||
const res = await fetch(new URL('/mcp', host.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${VALID_TOKEN}`
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
const wwwAuth = res.headers.get('www-authenticate');
|
||||
expect(wwwAuth).toBeTruthy();
|
||||
expect(wwwAuth).toMatch(/^Bearer\b/i);
|
||||
expect(wwwAuth).toContain('error="insufficient_scope"');
|
||||
expect(wwwAuth).toContain('scope="mcp:tools:read mcp:tools:call"');
|
||||
expect(wwwAuth).toContain(`resource_metadata="${RESOURCE_METADATA_URL}"`);
|
||||
|
||||
const body = (await res.json()) as { error?: string };
|
||||
expect(body.error).toBe('insufficient_scope');
|
||||
});
|
||||
|
||||
verifies('hosting:auth:aud-validation', async (_args: TestArgs) => {
|
||||
const SERVER_RESOURCE_ID = 'https://mcp.example.com/api';
|
||||
const WRONG_AUDIENCE = 'https://other.example.com/api';
|
||||
|
||||
const verifier = {
|
||||
verifyAccessToken: async (token: string) => {
|
||||
const aud = token === 'wrong-aud-token' ? WRONG_AUDIENCE : SERVER_RESOURCE_ID;
|
||||
return {
|
||||
token,
|
||||
clientId: 'test-client',
|
||||
scopes: [],
|
||||
expiresAt: Date.now() / 1000 + 3600,
|
||||
resource: new URL(aud)
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(requireBearerAuth({ verifier, resourceMetadataUrl: SERVER_RESOURCE_ID }));
|
||||
app.post('/mcp', (_req, res) => {
|
||||
res.json({ jsonrpc: '2.0', id: 1, result: { ok: true } });
|
||||
});
|
||||
|
||||
await using host = await startExpressMinimal(app);
|
||||
|
||||
const wrongAud = await fetch(new URL('/mcp', host.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: 'Bearer wrong-aud-token'
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
|
||||
});
|
||||
|
||||
expect(wrongAud.status).toBeGreaterThanOrEqual(401);
|
||||
expect(wrongAud.status).toBeLessThanOrEqual(403);
|
||||
|
||||
const wwwAuth = wrongAud.headers.get('www-authenticate');
|
||||
expect(wwwAuth).toBeTruthy();
|
||||
expect(wwwAuth).toMatch(/^Bearer\b/i);
|
||||
expect(wwwAuth).toContain('error=');
|
||||
|
||||
const body = (await wrongAud.json()) as { error?: string };
|
||||
expect(body.error).toBeTruthy();
|
||||
});
|
||||
|
||||
verifies('hosting:auth:metadata-endpoints', async (_args: TestArgs) => {
|
||||
const issuer = new URL('https://auth.example.com');
|
||||
const provider = {
|
||||
authorize: async () => {
|
||||
throw new Error('not needed');
|
||||
},
|
||||
challengeForAuthorizationCode: async () => 'test-challenge',
|
||||
exchangeAuthorizationCode: async () => ({ access_token: 'test-token', token_type: 'Bearer' }),
|
||||
exchangeRefreshToken: async () => ({ access_token: 'test-token', token_type: 'Bearer' }),
|
||||
verifyAccessToken: async () => ({ token: '', clientId: '', scopes: [], expiresAt: 1e12 }),
|
||||
clientsStore: { getClient: async () => undefined }
|
||||
};
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(
|
||||
mcpAuthRouter({
|
||||
provider,
|
||||
issuerUrl: issuer
|
||||
})
|
||||
);
|
||||
|
||||
await using host = await startExpressMinimal(app);
|
||||
|
||||
const asMetadata = await fetch(new URL('/.well-known/oauth-authorization-server', host.baseUrl));
|
||||
expect(asMetadata.status).toBe(200);
|
||||
const asBody = (await asMetadata.json()) as { issuer?: string; authorization_endpoint?: string };
|
||||
expect(asBody.issuer).toBe(issuer.href);
|
||||
expect(asBody.authorization_endpoint).toBeTruthy();
|
||||
|
||||
const prmMetadata = await fetch(new URL('/.well-known/oauth-protected-resource', host.baseUrl));
|
||||
expect(prmMetadata.status).toBe(200);
|
||||
const prmBody = (await prmMetadata.json()) as { resource?: string; authorization_servers?: string[] };
|
||||
expect(prmBody.authorization_servers).toContain(issuer.href);
|
||||
});
|
||||
|
||||
verifies('hosting:auth:prm:authorization-servers-field', async (_args: TestArgs) => {
|
||||
const issuer = new URL('https://auth.example.com');
|
||||
const oauthMetadata = createOAuthMetadata({
|
||||
provider: {
|
||||
authorize: async () => {
|
||||
throw new Error('stub');
|
||||
},
|
||||
challengeForAuthorizationCode: async () => 'test',
|
||||
exchangeAuthorizationCode: async () => ({ access_token: 'test', token_type: 'Bearer' }),
|
||||
exchangeRefreshToken: async () => ({ access_token: 'test', token_type: 'Bearer' }),
|
||||
verifyAccessToken: async () => ({ token: '', clientId: '', scopes: [], expiresAt: 1e12 }),
|
||||
clientsStore: { getClient: async () => undefined }
|
||||
},
|
||||
issuerUrl: issuer
|
||||
});
|
||||
|
||||
const app = express();
|
||||
app.use(
|
||||
mcpAuthMetadataRouter({
|
||||
oauthMetadata,
|
||||
resourceServerUrl: new URL('https://mcp.example.com')
|
||||
})
|
||||
);
|
||||
|
||||
await using host = await startExpressMinimal(app);
|
||||
|
||||
const res = await fetch(new URL('/.well-known/oauth-protected-resource', host.baseUrl));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { authorization_servers?: string[] };
|
||||
expect(body.authorization_servers).toBeInstanceOf(Array);
|
||||
expect(body.authorization_servers?.length).toBeGreaterThan(0);
|
||||
expect(body.authorization_servers).toContain(issuer.href);
|
||||
});
|
||||
|
||||
verifies('hosting:auth:as-router', async (_args: TestArgs) => {
|
||||
const issuer = new URL('https://auth.example.com');
|
||||
const provider: OAuthServerProvider = {
|
||||
authorize: async (_client, _params, res) => {
|
||||
res.redirect(302, 'https://example.com/callback?code=test-code&state=test');
|
||||
},
|
||||
challengeForAuthorizationCode: async () => 'test-challenge',
|
||||
exchangeAuthorizationCode: async () => ({ access_token: 'test-token', token_type: 'Bearer' }),
|
||||
exchangeRefreshToken: async () => ({ access_token: 'test-token', token_type: 'Bearer' }),
|
||||
verifyAccessToken: async (token: string) => ({ token, clientId: 'test', scopes: [], expiresAt: 1e12 }),
|
||||
clientsStore: {
|
||||
getClient: async (id: string) =>
|
||||
id === 'test-client'
|
||||
? ({
|
||||
client_id: 'test-client',
|
||||
client_secret: 'secret',
|
||||
redirect_uris: ['https://example.com/callback']
|
||||
} as OAuthClientInformationFull)
|
||||
: undefined,
|
||||
registerClient: async () =>
|
||||
({
|
||||
client_id: 'new-client',
|
||||
client_secret: 'new-secret',
|
||||
redirect_uris: ['https://example.com/callback']
|
||||
}) as OAuthClientInformationFull
|
||||
},
|
||||
revokeToken: async () => {}
|
||||
};
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(mcpAuthRouter({ provider, issuerUrl: issuer }));
|
||||
|
||||
await using host = await startExpressMinimal(app);
|
||||
|
||||
const authRes = await fetch(new URL('/authorize', host.baseUrl));
|
||||
expect(authRes.status).not.toBe(404);
|
||||
|
||||
const tokenRes = await fetch(new URL('/token', host.baseUrl), { method: 'POST' });
|
||||
expect(tokenRes.status).not.toBe(404);
|
||||
|
||||
const registerRes = await fetch(new URL('/register', host.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ redirect_uris: ['https://example.com/callback'] })
|
||||
});
|
||||
expect(registerRes.status).not.toBe(404);
|
||||
const registerBody = (await registerRes.json()) as { client_id?: string };
|
||||
expect(registerBody.client_id).toBeTruthy();
|
||||
|
||||
const revokeRes = await fetch(new URL('/revoke', host.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'token=old-token&client_id=test-client&client_secret=secret'
|
||||
});
|
||||
expect([200, 400]).toContain(revokeRes.status);
|
||||
});
|
||||
|
||||
verifies('hosting:auth:proxy-provider', async (_args: TestArgs) => {
|
||||
const upstreamRequests: Array<{ url: string; method: string; body?: string }> = [];
|
||||
|
||||
const upstreamAS = express();
|
||||
upstreamAS.use(express.json());
|
||||
upstreamAS.use(express.urlencoded({ extended: true }));
|
||||
upstreamAS.get('/authorize', (req, res) => {
|
||||
upstreamRequests.push({ url: req.url, method: req.method });
|
||||
res.redirect(302, `https://example.com/callback?code=upstream-code&state=${req.query.state ?? ''}`);
|
||||
});
|
||||
upstreamAS.post('/token', (req, res) => {
|
||||
upstreamRequests.push({ url: req.url, method: req.method, body: JSON.stringify(req.body) });
|
||||
res.json({ access_token: 'upstream-token', token_type: 'Bearer' });
|
||||
});
|
||||
upstreamAS.post('/revoke', (req, res) => {
|
||||
upstreamRequests.push({ url: req.url, method: req.method, body: JSON.stringify(req.body) });
|
||||
res.sendStatus(200);
|
||||
});
|
||||
|
||||
await using upstream = await startExpressMinimal(upstreamAS);
|
||||
|
||||
const provider = new ProxyOAuthServerProvider({
|
||||
endpoints: {
|
||||
authorizationUrl: new URL('/authorize', upstream.baseUrl).href,
|
||||
tokenUrl: new URL('/token', upstream.baseUrl).href,
|
||||
revocationUrl: new URL('/revoke', upstream.baseUrl).href
|
||||
},
|
||||
verifyAccessToken: async token => ({ token, clientId: 'proxy-client', scopes: [], expiresAt: 1e12 }),
|
||||
getClient: async (id: string) =>
|
||||
id === 'proxy-client'
|
||||
? ({
|
||||
client_id: 'proxy-client',
|
||||
client_secret: 'proxy-secret',
|
||||
redirect_uris: ['https://example.com/cb']
|
||||
} as OAuthClientInformationFull)
|
||||
: undefined
|
||||
});
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(mcpAuthRouter({ provider, issuerUrl: new URL('https://proxy.example.com') }));
|
||||
|
||||
await using proxy = await startExpressMinimal(app);
|
||||
|
||||
const authRes = await fetch(new URL('/authorize', proxy.baseUrl));
|
||||
expect(authRes.status).not.toBe(404);
|
||||
|
||||
const tokenRes = await fetch(new URL('/token', proxy.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'grant_type=authorization_code&code=test&redirect_uri=https://example.com/cb&client_id=proxy-client&code_verifier=test'
|
||||
});
|
||||
expect(tokenRes.status).not.toBe(404);
|
||||
|
||||
const revokeRes = await fetch(new URL('/revoke', proxy.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'token=old-token&client_id=proxy-client&client_secret=proxy-secret'
|
||||
});
|
||||
expect([200, 400]).toContain(revokeRes.status);
|
||||
});
|
||||
|
||||
verifies('hosting:http:host-validation-middleware', async (_args: TestArgs) => {
|
||||
const handler: RequestHandler = (_req, res) => {
|
||||
res.json({ ok: true });
|
||||
};
|
||||
|
||||
await using host = await startExpressWithHostValidation(['localhost', '127.0.0.1'], handler);
|
||||
|
||||
const good = await fetch(new URL('/test', host.baseUrl));
|
||||
expect(good.status).toBe(200);
|
||||
|
||||
const spoofed = await postWithHost(new URL('/test', host.baseUrl), 'evil.example.com', JSON.stringify({ test: 'data' }));
|
||||
expect(spoofed.status).toBe(403);
|
||||
const body = JSON.parse(spoofed.body) as { error?: { message?: string } };
|
||||
expect(body.error?.message).toMatch(/Invalid Host/i);
|
||||
});
|
||||
|
||||
verifies('hosting:express-app-helper', async (_args: TestArgs) => {
|
||||
const app = createMcpExpressApp();
|
||||
app.post('/mcp', (_req, res) => {
|
||||
res.json({ jsonrpc: '2.0', id: 1, result: { ok: true } });
|
||||
});
|
||||
|
||||
await using host = await startExpressMinimal(app);
|
||||
|
||||
expect(host.baseUrl.hostname).toBe('127.0.0.1');
|
||||
|
||||
const good = await fetch(new URL('/mcp', host.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
|
||||
});
|
||||
expect(good.status).toBe(200);
|
||||
|
||||
const spoofed = await postWithHost(
|
||||
new URL('/mcp', host.baseUrl),
|
||||
'evil.example.com',
|
||||
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
|
||||
);
|
||||
expect(spoofed.status).toBe(403);
|
||||
const body = JSON.parse(spoofed.body) as { error?: { message?: string } };
|
||||
expect(body.error?.message).toMatch(/Invalid Host/i);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bundled authorization-server endpoints (/register, /authorize, /token):
|
||||
// in-memory provider + dynamic registration drive the SDK's own handlers
|
||||
// over real HTTP, mirroring what a real OAuth client would send.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AS_REDIRECT_URI = 'https://client.example.com/callback';
|
||||
|
||||
interface IssuedAuthorizationCode {
|
||||
clientId: string;
|
||||
codeChallenge: string;
|
||||
redirectUri: string;
|
||||
}
|
||||
|
||||
function createAuthorizationServerProvider(): OAuthServerProvider {
|
||||
const clients = new Map<string, OAuthClientInformationFull>();
|
||||
const codes = new Map<string, IssuedAuthorizationCode>();
|
||||
let clientCount = 0;
|
||||
let codeCount = 0;
|
||||
|
||||
return {
|
||||
clientsStore: {
|
||||
getClient: async (clientId: string) => clients.get(clientId),
|
||||
registerClient: async client => {
|
||||
clientCount += 1;
|
||||
const registered: OAuthClientInformationFull = { ...client, client_id: `registered-client-${clientCount}` };
|
||||
clients.set(registered.client_id, registered);
|
||||
return registered;
|
||||
}
|
||||
},
|
||||
authorize: async (client, params, res) => {
|
||||
codeCount += 1;
|
||||
const code = `authorization-code-${codeCount}`;
|
||||
codes.set(code, { clientId: client.client_id, codeChallenge: params.codeChallenge, redirectUri: params.redirectUri });
|
||||
const target = new URL(params.redirectUri);
|
||||
target.searchParams.set('code', code);
|
||||
if (params.state !== undefined) {
|
||||
target.searchParams.set('state', params.state);
|
||||
}
|
||||
res.redirect(302, target.href);
|
||||
},
|
||||
challengeForAuthorizationCode: async (_client, authorizationCode) => {
|
||||
const issued = codes.get(authorizationCode);
|
||||
if (!issued) {
|
||||
throw new InvalidGrantError('authorization code does not exist');
|
||||
}
|
||||
return issued.codeChallenge;
|
||||
},
|
||||
exchangeAuthorizationCode: async (client, authorizationCode, _codeVerifier, redirectUri) => {
|
||||
const issued = codes.get(authorizationCode);
|
||||
if (!issued) {
|
||||
throw new InvalidGrantError('authorization code does not exist');
|
||||
}
|
||||
if (issued.clientId !== client.client_id) {
|
||||
throw new InvalidGrantError('authorization code was issued to a different client');
|
||||
}
|
||||
if (redirectUri !== undefined && redirectUri !== issued.redirectUri) {
|
||||
throw new InvalidGrantError('redirect_uri does not match the one used at authorization');
|
||||
}
|
||||
// Single-use: the first successful exchange consumes the code.
|
||||
codes.delete(authorizationCode);
|
||||
return { access_token: `access-token-for-${authorizationCode}`, token_type: 'Bearer' };
|
||||
},
|
||||
exchangeRefreshToken: async () => {
|
||||
throw new InvalidGrantError('refresh tokens are not issued by this provider');
|
||||
},
|
||||
verifyAccessToken: async (token: string) => ({
|
||||
token,
|
||||
clientId: 'registered-client-1',
|
||||
scopes: [],
|
||||
expiresAt: Date.now() / 1000 + 3600
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
async function startAuthorizationServer(): Promise<ExpressHost> {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(mcpAuthRouter({ provider: createAuthorizationServerProvider(), issuerUrl: new URL('https://auth.example.com') }));
|
||||
return startExpressMinimal(app);
|
||||
}
|
||||
|
||||
function createPkcePair(): { verifier: string; challenge: string } {
|
||||
const verifier = crypto.randomBytes(32).toString('base64url');
|
||||
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
function postRegistration(baseUrl: URL, redirectUris: string[]): Promise<Response> {
|
||||
return fetch(new URL('/register', baseUrl), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ client_name: 'analytics-dashboard', redirect_uris: redirectUris })
|
||||
});
|
||||
}
|
||||
|
||||
async function registerConfidentialClient(baseUrl: URL): Promise<{ clientId: string; clientSecret: string }> {
|
||||
const res = await postRegistration(baseUrl, [AS_REDIRECT_URI]);
|
||||
expect(res.status).toBe(201);
|
||||
const body = (await res.json()) as { client_id?: string; client_secret?: string };
|
||||
if (typeof body.client_id !== 'string' || typeof body.client_secret !== 'string') {
|
||||
throw new Error('registration response did not include client_id and client_secret');
|
||||
}
|
||||
return { clientId: body.client_id, clientSecret: body.client_secret };
|
||||
}
|
||||
|
||||
async function mintAuthorizationCode(baseUrl: URL, clientId: string, codeChallenge: string): Promise<string> {
|
||||
const authorizeUrl = new URL('/authorize', baseUrl);
|
||||
authorizeUrl.searchParams.set('response_type', 'code');
|
||||
authorizeUrl.searchParams.set('client_id', clientId);
|
||||
authorizeUrl.searchParams.set('redirect_uri', AS_REDIRECT_URI);
|
||||
authorizeUrl.searchParams.set('code_challenge', codeChallenge);
|
||||
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
|
||||
authorizeUrl.searchParams.set('state', 'request-state');
|
||||
|
||||
const res = await fetch(authorizeUrl, { redirect: 'manual' });
|
||||
expect(res.status).toBe(302);
|
||||
const location = res.headers.get('location');
|
||||
if (location === null) {
|
||||
throw new Error('authorize response did not include a Location header');
|
||||
}
|
||||
const redirect = new URL(location);
|
||||
expect(redirect.origin + redirect.pathname).toBe(AS_REDIRECT_URI);
|
||||
expect(redirect.searchParams.get('state')).toBe('request-state');
|
||||
const code = redirect.searchParams.get('code');
|
||||
if (code === null) {
|
||||
throw new Error('authorize redirect did not include a code parameter');
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
function postTokenExchange(baseUrl: URL, form: Record<string, string>): Promise<Response> {
|
||||
return fetch(new URL('/token', baseUrl), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(form).toString()
|
||||
});
|
||||
}
|
||||
|
||||
verifies('hosting:auth:as:redirect-uri-scheme', async (_args: TestArgs) => {
|
||||
await using host = await startAuthorizationServer();
|
||||
|
||||
const httpsRegistration = await postRegistration(host.baseUrl, ['https://client.example.com/callback']);
|
||||
expect(httpsRegistration.status).toBe(201);
|
||||
|
||||
const loopbackRegistration = await postRegistration(host.baseUrl, ['http://127.0.0.1:49152/callback']);
|
||||
expect(loopbackRegistration.status).toBe(201);
|
||||
|
||||
const nonLoopbackHttp = await postRegistration(host.baseUrl, ['http://attacker.example.com/callback']);
|
||||
expect(nonLoopbackHttp.status).toBe(400);
|
||||
const body = (await nonLoopbackHttp.json()) as { error?: string };
|
||||
expect(body.error).toBe('invalid_client_metadata');
|
||||
});
|
||||
|
||||
verifies('hosting:auth:as:redirect-uri-binding', async (_args: TestArgs) => {
|
||||
await using host = await startAuthorizationServer();
|
||||
const { clientId, clientSecret } = await registerConfidentialClient(host.baseUrl);
|
||||
const { verifier, challenge } = createPkcePair();
|
||||
|
||||
// Authorize leg: an unregistered redirect_uri gets a direct 400, never a redirect to the attacker URI.
|
||||
const authorizeUrl = new URL('/authorize', host.baseUrl);
|
||||
authorizeUrl.searchParams.set('response_type', 'code');
|
||||
authorizeUrl.searchParams.set('client_id', clientId);
|
||||
authorizeUrl.searchParams.set('redirect_uri', 'https://attacker.example.com/callback');
|
||||
authorizeUrl.searchParams.set('code_challenge', challenge);
|
||||
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
|
||||
|
||||
const authorizeRes = await fetch(authorizeUrl, { redirect: 'manual' });
|
||||
expect(authorizeRes.status).toBe(400);
|
||||
expect(authorizeRes.headers.get('location')).toBeNull();
|
||||
expect(await authorizeRes.json()).toEqual({ error: 'invalid_request', error_description: 'Unregistered redirect_uri' });
|
||||
|
||||
// Token leg: the handler forwards the request's redirect_uri to the provider, which rejects a value differing from authorize.
|
||||
const code = await mintAuthorizationCode(host.baseUrl, clientId, challenge);
|
||||
const tokenRes = await postTokenExchange(host.baseUrl, {
|
||||
grant_type: 'authorization_code',
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
code,
|
||||
code_verifier: verifier,
|
||||
redirect_uri: 'https://client.example.com/callback/other'
|
||||
});
|
||||
expect(tokenRes.status).toBe(400);
|
||||
expect(await tokenRes.json()).toEqual({
|
||||
error: 'invalid_grant',
|
||||
error_description: 'redirect_uri does not match the one used at authorization'
|
||||
});
|
||||
});
|
||||
|
||||
verifies('hosting:auth:query-token-ignored', async (_args: TestArgs) => {
|
||||
const verifier = {
|
||||
verifyAccessToken: async (token: string) => {
|
||||
if (token !== VALID_TOKEN) {
|
||||
throw new InvalidTokenError('Token verification failed');
|
||||
}
|
||||
return { token, clientId: 'analytics-client', scopes: [], expiresAt: Date.now() / 1000 + 3600 };
|
||||
}
|
||||
};
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(requireBearerAuth({ verifier, resourceMetadataUrl: RESOURCE_METADATA_URL }));
|
||||
app.post('/mcp', (_req, res) => {
|
||||
res.json({ jsonrpc: '2.0', id: 1, result: { ok: true } });
|
||||
});
|
||||
|
||||
await using host = await startExpressMinimal(app);
|
||||
|
||||
const queryUrl = new URL('/mcp', host.baseUrl);
|
||||
queryUrl.searchParams.set('access_token', VALID_TOKEN);
|
||||
const queryRes = await fetch(queryUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
|
||||
});
|
||||
|
||||
expect(queryRes.status).toBe(401);
|
||||
const wwwAuth = queryRes.headers.get('www-authenticate');
|
||||
expect(wwwAuth).toBeTruthy();
|
||||
expect(wwwAuth).toMatch(/^Bearer\b/i);
|
||||
expect(wwwAuth).toContain('error="invalid_token"');
|
||||
const queryBody = (await queryRes.json()) as { error?: string };
|
||||
expect(queryBody.error).toBe('invalid_token');
|
||||
|
||||
// Control: the same token in the Authorization header authenticates, proving only the query-string placement was ignored.
|
||||
const headerRes = await fetch(new URL('/mcp', host.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Bearer ${VALID_TOKEN}` },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
|
||||
});
|
||||
expect(headerRes.status).toBe(200);
|
||||
});
|
||||
|
||||
verifies('hosting:auth:as:authorize-requires-pkce', async (_args: TestArgs) => {
|
||||
await using host = await startAuthorizationServer();
|
||||
const { clientId } = await registerConfidentialClient(host.baseUrl);
|
||||
|
||||
const authorizeUrl = new URL('/authorize', host.baseUrl);
|
||||
authorizeUrl.searchParams.set('response_type', 'code');
|
||||
authorizeUrl.searchParams.set('client_id', clientId);
|
||||
authorizeUrl.searchParams.set('redirect_uri', AS_REDIRECT_URI);
|
||||
authorizeUrl.searchParams.set('state', 'pkce-required-state');
|
||||
|
||||
const res = await fetch(authorizeUrl, { redirect: 'manual' });
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
const location = res.headers.get('location');
|
||||
if (location === null) {
|
||||
throw new Error('authorize response did not include a Location header');
|
||||
}
|
||||
const redirect = new URL(location);
|
||||
expect(redirect.origin + redirect.pathname).toBe(AS_REDIRECT_URI);
|
||||
expect(redirect.searchParams.get('error')).toBe('invalid_request');
|
||||
expect(redirect.searchParams.get('error_description')).toContain('code_challenge');
|
||||
expect(redirect.searchParams.get('code')).toBeNull();
|
||||
});
|
||||
|
||||
verifies('hosting:auth:as:verifier-mismatch', async (_args: TestArgs) => {
|
||||
await using host = await startAuthorizationServer();
|
||||
const { clientId, clientSecret } = await registerConfidentialClient(host.baseUrl);
|
||||
|
||||
const { challenge } = createPkcePair();
|
||||
const code = await mintAuthorizationCode(host.baseUrl, clientId, challenge);
|
||||
|
||||
// A well-formed verifier from a different PKCE pair cannot hash to the stored challenge.
|
||||
const { verifier: wrongVerifier } = createPkcePair();
|
||||
const res = await postTokenExchange(host.baseUrl, {
|
||||
grant_type: 'authorization_code',
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
code,
|
||||
code_verifier: wrongVerifier,
|
||||
redirect_uri: AS_REDIRECT_URI
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(await res.json()).toEqual({ error: 'invalid_grant', error_description: 'code_verifier does not match the challenge' });
|
||||
});
|
||||
|
||||
verifies('hosting:auth:as:code-single-use', async (_args: TestArgs) => {
|
||||
await using host = await startAuthorizationServer();
|
||||
const { clientId, clientSecret } = await registerConfidentialClient(host.baseUrl);
|
||||
|
||||
const { verifier, challenge } = createPkcePair();
|
||||
const code = await mintAuthorizationCode(host.baseUrl, clientId, challenge);
|
||||
const form = {
|
||||
grant_type: 'authorization_code',
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
code,
|
||||
code_verifier: verifier,
|
||||
redirect_uri: AS_REDIRECT_URI
|
||||
};
|
||||
|
||||
const first = await postTokenExchange(host.baseUrl, form);
|
||||
expect(first.status).toBe(200);
|
||||
const firstBody = (await first.json()) as { access_token?: string; token_type?: string };
|
||||
expect(firstBody.token_type).toBe('Bearer');
|
||||
expect(firstBody.access_token).toBe(`access-token-for-${code}`);
|
||||
|
||||
const second = await postTokenExchange(host.baseUrl, form);
|
||||
expect(second.status).toBe(400);
|
||||
expect(await second.json()).toEqual({ error: 'invalid_grant', error_description: 'authorization code does not exist' });
|
||||
});
|
||||
1031
test/e2e/scenarios/hosting-http.test.ts
Normal file
1031
test/e2e/scenarios/hosting-http.test.ts
Normal file
File diff suppressed because it is too large
Load diff
840
test/e2e/scenarios/hosting-resume.test.ts
Normal file
840
test/e2e/scenarios/hosting-resume.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
942
test/e2e/scenarios/hosting-session.test.ts
Normal file
942
test/e2e/scenarios/hosting-session.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
461
test/e2e/scenarios/lifecycle.test.ts
Normal file
461
test/e2e/scenarios/lifecycle.test.ts
Normal 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);
|
||||
});
|
||||
244
test/e2e/scenarios/logging.test.ts
Normal file
244
test/e2e/scenarios/logging.test.ts
Normal 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' }]);
|
||||
});
|
||||
117
test/e2e/scenarios/pagination.test.ts
Normal file
117
test/e2e/scenarios/pagination.test.ts
Normal 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]);
|
||||
});
|
||||
549
test/e2e/scenarios/prompts.test.ts
Normal file
549
test/e2e/scenarios/prompts.test.ts
Normal 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.' } }]);
|
||||
});
|
||||
1634
test/e2e/scenarios/protocol.test.ts
Normal file
1634
test/e2e/scenarios/protocol.test.ts
Normal file
File diff suppressed because it is too large
Load diff
806
test/e2e/scenarios/resources.test.ts
Normal file
806
test/e2e/scenarios/resources.test.ts
Normal 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' }]
|
||||
});
|
||||
});
|
||||
180
test/e2e/scenarios/roots.test.ts
Normal file
180
test/e2e/scenarios/roots.test.ts
Normal 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);
|
||||
});
|
||||
582
test/e2e/scenarios/sampling.test.ts
Normal file
582
test/e2e/scenarios/sampling.test.ts
Normal 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([]);
|
||||
});
|
||||
417
test/e2e/scenarios/stdio.test.ts
Normal file
417
test/e2e/scenarios/stdio.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
1389
test/e2e/scenarios/tools.test.ts
Normal file
1389
test/e2e/scenarios/tools.test.ts
Normal file
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue