Port the end-to-end test suite to v2 (#2179)
This commit is contained in:
parent
5fc42e9be1
commit
1998a186ee
29 changed files with 20108 additions and 1 deletions
1809
test/e2e/scenarios/client-auth.test.ts
Normal file
1809
test/e2e/scenarios/client-auth.test.ts
Normal file
File diff suppressed because it is too large
Load diff
340
test/e2e/scenarios/completion.test.ts
Normal file
340
test/e2e/scenarios/completion.test.ts
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
/**
|
||||
* Self-contained test bodies for the completion surface.
|
||||
*
|
||||
* Completion provides autocompletion for prompt arguments and resource template
|
||||
* variables. The server declares the `completions` capability and handles
|
||||
* `completion/complete` requests, returning up to 100 string suggestions based
|
||||
* on the partial value and optional context (already-resolved variables).
|
||||
*/
|
||||
|
||||
import { Client } from '@modelcontextprotocol/client';
|
||||
import { completable, McpServer, ProtocolError, ProtocolErrorCode, ResourceTemplate, Server } from '@modelcontextprotocol/server';
|
||||
import { expect } from 'vitest';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { wire } from '../helpers/index.js';
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
import type { TestArgs } from '../types.js';
|
||||
|
||||
const COLORS = ['red', 'green', 'blue', 'rebeccapurple'] as const;
|
||||
const FILE_PATHS = ['README.md', 'src/index.ts', 'src/types.ts'] as const;
|
||||
const REPOS_BY_OWNER: Record<string, readonly string[]> = {
|
||||
'acme-corp': ['widget-sdk', 'gadget-sdk', 'docs'],
|
||||
globex: ['frobnicator', 'reticulator']
|
||||
};
|
||||
const MANY_TOTAL = 150;
|
||||
|
||||
const newClient = () => new Client({ name: 'c', version: '0' });
|
||||
|
||||
function colorCompletionServer(): McpServer {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerPrompt(
|
||||
'complete-color',
|
||||
{ argsSchema: z.object({ color: completable(z.string(), value => COLORS.filter(c => c.startsWith(value))) }) },
|
||||
({ color }) => ({ messages: [{ role: 'user', content: { type: 'text', text: `color=${color}` } }] })
|
||||
);
|
||||
return s;
|
||||
}
|
||||
|
||||
verifies('completion:capability:declared', async ({ transport }: TestArgs) => {
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, colorCompletionServer, client);
|
||||
|
||||
const caps = client.getServerCapabilities();
|
||||
expect(caps?.completions).toBeDefined();
|
||||
expect(typeof caps?.completions).toBe('object');
|
||||
|
||||
const result = await client.complete({
|
||||
ref: { type: 'ref/prompt', name: 'complete-color' },
|
||||
argument: { name: 'color', value: '' }
|
||||
});
|
||||
expect(Array.isArray(result.completion.values)).toBe(true);
|
||||
});
|
||||
|
||||
verifies('completion:complete:not-supported', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerPrompt('summarize-code', { argsSchema: z.object({ code: z.string() }) }, ({ code }) => ({
|
||||
messages: [{ role: 'user', content: { type: 'text', text: `Summarize the following code:\n${code}` } }]
|
||||
}));
|
||||
return s;
|
||||
};
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const caps = client.getServerCapabilities();
|
||||
expect(caps).toBeDefined();
|
||||
expect(caps?.completions).toBeUndefined();
|
||||
expect(caps?.prompts).toBeDefined();
|
||||
|
||||
// Raw request bypasses any client-side capability gating, so the rejection observed is the server's own.
|
||||
await expect(
|
||||
client.request({
|
||||
method: 'completion/complete',
|
||||
params: { ref: { type: 'ref/prompt', name: 'summarize-code' }, argument: { name: 'code', value: 'co' } }
|
||||
})
|
||||
).rejects.toMatchObject({ code: ProtocolErrorCode.MethodNotFound });
|
||||
});
|
||||
|
||||
verifies('completion:context-arguments', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerResource(
|
||||
'github-repo',
|
||||
new ResourceTemplate('github://{owner}/{repo}', {
|
||||
list: undefined,
|
||||
complete: {
|
||||
repo: (value, context) => {
|
||||
const owner = context?.arguments?.owner;
|
||||
if (!owner || typeof owner !== 'string') return [];
|
||||
const repos = REPOS_BY_OWNER[owner] ?? [];
|
||||
return repos.filter(r => r.startsWith(value));
|
||||
}
|
||||
}
|
||||
}),
|
||||
{ mimeType: 'text/plain' },
|
||||
(uri, { owner, repo }) => ({ contents: [{ uri: uri.href, mimeType: 'text/plain', text: `${owner}/${repo}` }] })
|
||||
);
|
||||
return s;
|
||||
};
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const acme = await client.complete({
|
||||
ref: { type: 'ref/resource', uri: 'github://{owner}/{repo}' },
|
||||
argument: { name: 'repo', value: '' },
|
||||
context: { arguments: { owner: 'acme-corp' } }
|
||||
});
|
||||
expect(acme.completion.values).toEqual(['widget-sdk', 'gadget-sdk', 'docs']);
|
||||
|
||||
const globex = await client.complete({
|
||||
ref: { type: 'ref/resource', uri: 'github://{owner}/{repo}' },
|
||||
argument: { name: 'repo', value: '' },
|
||||
context: { arguments: { owner: 'globex' } }
|
||||
});
|
||||
expect(globex.completion.values).toEqual(['frobnicator', 'reticulator']);
|
||||
expect(acme.completion.values).not.toEqual(globex.completion.values);
|
||||
|
||||
const bare = await client.complete({
|
||||
ref: { type: 'ref/resource', uri: 'github://{owner}/{repo}' },
|
||||
argument: { name: 'repo', value: '' }
|
||||
});
|
||||
expect(bare.completion.values).toEqual([]);
|
||||
});
|
||||
|
||||
verifies(
|
||||
'completion:error:invalid-ref',
|
||||
async ({ transport }: TestArgs) => {
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, colorCompletionServer, client);
|
||||
|
||||
await expect(
|
||||
client.complete({ ref: { type: 'ref/prompt', name: 'no-such-prompt' }, argument: { name: 'whatever', value: '' } })
|
||||
).rejects.toMatchObject({ code: ProtocolErrorCode.InvalidParams });
|
||||
|
||||
await expect(
|
||||
client.complete({ ref: { type: 'ref/resource', uri: 'nosuchscheme://nowhere/{x}' }, argument: { name: 'x', value: '' } })
|
||||
).rejects.toMatchObject({ code: ProtocolErrorCode.InvalidParams });
|
||||
},
|
||||
{ title: 'mcpserver' }
|
||||
);
|
||||
|
||||
verifies(
|
||||
'completion:error:invalid-ref',
|
||||
async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new Server({ name: 's', version: '0' }, { capabilities: { completions: {} } });
|
||||
s.setRequestHandler('completion/complete', req => {
|
||||
if (req.params.ref.type === 'ref/prompt' && req.params.ref.name === 'known') {
|
||||
return { completion: { values: ['ok'] } };
|
||||
}
|
||||
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `No completion target: ${JSON.stringify(req.params.ref)}`);
|
||||
});
|
||||
return s;
|
||||
};
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const known = await client.complete({ ref: { type: 'ref/prompt', name: 'known' }, argument: { name: 'a', value: '' } });
|
||||
expect(known.completion.values).toEqual(['ok']);
|
||||
|
||||
await expect(
|
||||
client.complete({ ref: { type: 'ref/prompt', name: 'no-such-prompt' }, argument: { name: 'a', value: '' } })
|
||||
).rejects.toMatchObject({ code: ProtocolErrorCode.InvalidParams });
|
||||
},
|
||||
{ title: 'raw server' }
|
||||
);
|
||||
|
||||
verifies('completion:prompt-arg', async ({ transport }: TestArgs) => {
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, colorCompletionServer, client);
|
||||
|
||||
const result = await client.complete({
|
||||
ref: { type: 'ref/prompt', name: 'complete-color' },
|
||||
argument: { name: 'color', value: 're' }
|
||||
});
|
||||
|
||||
const expected = COLORS.filter(c => c.startsWith('re'));
|
||||
expect(result.completion.values).toEqual(expected);
|
||||
expect(result.completion.hasMore ?? false).toBe(false);
|
||||
});
|
||||
|
||||
verifies('completion:resource-template-arg', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerResource(
|
||||
'file-path',
|
||||
new ResourceTemplate('completion://files/{path}', {
|
||||
list: undefined,
|
||||
complete: { path: value => FILE_PATHS.filter(p => p.startsWith(value)) }
|
||||
}),
|
||||
{ mimeType: 'text/plain' },
|
||||
(uri, { path }) => ({ contents: [{ uri: uri.href, mimeType: 'text/plain', text: `path=${path}` }] })
|
||||
);
|
||||
s.registerResource(
|
||||
'github-repo',
|
||||
new ResourceTemplate('github://{owner}/{repo}', {
|
||||
list: undefined,
|
||||
complete: { owner: () => [] }
|
||||
}),
|
||||
{ mimeType: 'text/plain' },
|
||||
(uri, { owner, repo }) => ({ contents: [{ uri: uri.href, mimeType: 'text/plain', text: `${owner}/${repo}` }] })
|
||||
);
|
||||
return s;
|
||||
};
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const result = await client.complete({
|
||||
ref: { type: 'ref/resource', uri: 'completion://files/{path}' },
|
||||
argument: { name: 'path', value: 'src/' }
|
||||
});
|
||||
|
||||
expect(result.completion.values).toEqual(['src/index.ts', 'src/types.ts']);
|
||||
expect(result.completion.hasMore ?? false).toBe(false);
|
||||
|
||||
const other = await client.complete({
|
||||
ref: { type: 'ref/resource', uri: 'github://{owner}/{repo}' },
|
||||
argument: { name: 'path', value: 'src/' }
|
||||
});
|
||||
|
||||
expect(other.completion.values).toEqual([]);
|
||||
});
|
||||
|
||||
verifies('completion:result-shape', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerPrompt(
|
||||
'complete-color',
|
||||
{ argsSchema: z.object({ color: completable(z.string(), value => COLORS.filter(c => c.startsWith(value))) }) },
|
||||
({ color }) => ({ messages: [{ role: 'user', content: { type: 'text', text: `color=${color}` } }] })
|
||||
);
|
||||
const many = Array.from({ length: MANY_TOTAL }, (_, i) => `item-${String(i).padStart(3, '0')}`);
|
||||
s.registerPrompt(
|
||||
'complete-many',
|
||||
{ argsSchema: z.object({ n: completable(z.string(), value => many.filter(s => s.startsWith(value))) }) },
|
||||
({ n }) => ({ messages: [{ role: 'user', content: { type: 'text', text: n } }] })
|
||||
);
|
||||
return s;
|
||||
};
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const small = await client.complete({
|
||||
ref: { type: 'ref/prompt', name: 'complete-color' },
|
||||
argument: { name: 'color', value: 're' }
|
||||
});
|
||||
|
||||
expect(Array.isArray(small.completion.values)).toBe(true);
|
||||
expect(small.completion.values).toEqual(COLORS.filter(c => c.startsWith('re')));
|
||||
expect(small.completion.values.length).toBeLessThanOrEqual(100);
|
||||
expect(small.completion.total).toBe(small.completion.values.length);
|
||||
expect(small.completion.total).toBe(2);
|
||||
expect(small.completion.hasMore).toBe(false);
|
||||
|
||||
const empty = await client.complete({
|
||||
ref: { type: 'ref/prompt', name: 'complete-color' },
|
||||
argument: { name: 'no-such-arg', value: '' }
|
||||
});
|
||||
|
||||
expect(empty.completion.values).toEqual([]);
|
||||
expect(empty.completion.total).toBeUndefined();
|
||||
expect(empty.completion.hasMore).toBe(false);
|
||||
|
||||
const many = await client.complete({
|
||||
ref: { type: 'ref/prompt', name: 'complete-many' },
|
||||
argument: { name: 'n', value: '' }
|
||||
});
|
||||
|
||||
expect(many.completion.values).toHaveLength(100);
|
||||
expect(many.completion.values.every(v => typeof v === 'string')).toBe(true);
|
||||
expect(many.completion.values[0]).toBe('item-000');
|
||||
expect(many.completion.values[99]).toBe('item-099');
|
||||
expect(many.completion.total).toBe(MANY_TOTAL);
|
||||
expect(many.completion.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
verifies(
|
||||
'mcpserver:completion:capability-auto',
|
||||
async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerPrompt('non-completable', { argsSchema: z.object({ arg: z.string() }) }, ({ arg }) => ({
|
||||
messages: [{ role: 'user', content: { type: 'text', text: arg } }]
|
||||
}));
|
||||
s.registerResource(
|
||||
'plain-resource',
|
||||
new ResourceTemplate('plain://resource/{id}', { list: undefined }),
|
||||
{ mimeType: 'text/plain' },
|
||||
(uri, { id }) => ({ contents: [{ uri: uri.href, mimeType: 'text/plain', text: `id=${id}` }] })
|
||||
);
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const caps = client.getServerCapabilities();
|
||||
expect(caps).toBeDefined();
|
||||
expect(caps?.completions).toBeUndefined();
|
||||
expect(caps?.prompts).toBeDefined();
|
||||
expect(caps?.resources).toBeDefined();
|
||||
|
||||
await expect(
|
||||
client.complete({
|
||||
ref: { type: 'ref/prompt', name: 'non-completable' },
|
||||
argument: { name: 'arg', value: '' }
|
||||
})
|
||||
).rejects.toThrow();
|
||||
},
|
||||
{ title: 'mcpserver' }
|
||||
);
|
||||
|
||||
verifies(
|
||||
'mcpserver:completion:capability-auto',
|
||||
async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new Server({ name: 's', version: '0' }, { capabilities: { prompts: {}, resources: {} } });
|
||||
s.setRequestHandler('prompts/list', () => ({ prompts: [{ name: 'plain', arguments: [] }] }));
|
||||
s.setRequestHandler('resources/list', () => ({ resources: [] }));
|
||||
s.setRequestHandler('resources/read', () => ({ contents: [] }));
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const caps = client.getServerCapabilities();
|
||||
expect(caps?.completions).toBeUndefined();
|
||||
expect(caps?.prompts).toBeDefined();
|
||||
expect(caps?.resources).toBeDefined();
|
||||
|
||||
await expect(
|
||||
client.complete({
|
||||
ref: { type: 'ref/prompt', name: 'plain' },
|
||||
argument: { name: 'arg', value: '' }
|
||||
})
|
||||
).rejects.toThrow();
|
||||
},
|
||||
{ title: 'raw server' }
|
||||
);
|
||||
159
test/e2e/scenarios/custom-methods.test.ts
Normal file
159
test/e2e/scenarios/custom-methods.test.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* Self-contained test bodies for custom (non-spec, vendor-prefixed) methods.
|
||||
*
|
||||
* v2 registers these via the schema'd overloads: `setRequestHandler('<vendor>/x',
|
||||
* { params, result }, handler)` and `setNotificationHandler('<vendor>/x',
|
||||
* { params }, handler)`; the requesting side passes the matching result schema to
|
||||
* `request()`. Each test builds its own server (via factory) and client, wires
|
||||
* them with {@link wire} (with `allowCustomMethods` so the wire sniffer permits
|
||||
* the vendor methods), and asserts. Params schemas carry a defaulted field so
|
||||
* the handler observably receives the schema's parse output, not raw wire params.
|
||||
*/
|
||||
|
||||
import { Client } from '@modelcontextprotocol/client';
|
||||
import { McpServer, ProtocolErrorCode, Server } from '@modelcontextprotocol/server';
|
||||
import { expect, vi } from 'vitest';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { wire } from '../helpers/index.js';
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
import type { TestArgs } from '../types.js';
|
||||
|
||||
verifies('custom-methods:server-handler:roundtrip', async ({ transport }: TestArgs) => {
|
||||
const SearchParams = z.object({ query: z.string(), limit: z.number().int().default(5) });
|
||||
const SearchResult = z.object({ hits: z.array(z.string()), total: z.number() });
|
||||
|
||||
const received: Array<z.output<typeof SearchParams>> = [];
|
||||
const makeServer = () => {
|
||||
const s = new Server({ name: 's', version: '0' }, { capabilities: {} });
|
||||
s.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, params => {
|
||||
received.push(params);
|
||||
return { hits: [`doc-1 for ${params.query}`, `doc-2 for ${params.query}`].slice(0, params.limit), total: 2 };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
|
||||
await using _ = await wire(transport, makeServer, client, { allowCustomMethods: true });
|
||||
|
||||
const full = await client.request({ method: 'acme/search', params: { query: 'streamable http' } }, SearchResult);
|
||||
// The defaulted `limit` proves the handler got the schema's parse output, not the raw wire params.
|
||||
expect(received).toEqual([{ query: 'streamable http', limit: 5 }]);
|
||||
expect(full).toEqual({ hits: ['doc-1 for streamable http', 'doc-2 for streamable http'], total: 2 });
|
||||
|
||||
const limited = await client.request({ method: 'acme/search', params: { query: 'sse', limit: 1 } }, SearchResult);
|
||||
expect(received).toEqual([
|
||||
{ query: 'streamable http', limit: 5 },
|
||||
{ query: 'sse', limit: 1 }
|
||||
]);
|
||||
expect(limited).toEqual({ hits: ['doc-1 for sse'], total: 2 });
|
||||
});
|
||||
|
||||
verifies('custom-methods:client-handler:roundtrip', async ({ transport }: TestArgs) => {
|
||||
const LookupParams = z.object({ key: z.string(), scope: z.string().default('workspace') });
|
||||
const LookupResult = z.object({ value: z.string(), found: z.boolean() });
|
||||
|
||||
const received: Array<z.output<typeof LookupParams>> = [];
|
||||
const lookups: Array<z.output<typeof LookupResult>> = [];
|
||||
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('read-config', { inputSchema: z.object({ key: z.string() }) }, async ({ key }, ctx) => {
|
||||
// relatedRequestId keeps the server→client request on the in-flight POST stream for streamableHttp.
|
||||
const lookup = await s.server.request({ method: 'acme/configLookup', params: { key } }, LookupResult, {
|
||||
relatedRequestId: ctx.mcpReq.id
|
||||
});
|
||||
lookups.push(lookup);
|
||||
return { content: [{ type: 'text', text: `${key}=${lookup.value}` }] };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
client.setRequestHandler('acme/configLookup', { params: LookupParams, result: LookupResult }, params => {
|
||||
received.push(params);
|
||||
return { value: `resolved:${params.key}`, found: true };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client, { allowCustomMethods: true });
|
||||
|
||||
const result = await client.callTool({ name: 'read-config', arguments: { key: 'editor.theme' } });
|
||||
|
||||
// The defaulted `scope` proves the client handler got the schema's parse output, not the raw wire params.
|
||||
expect(received).toEqual([{ key: 'editor.theme', scope: 'workspace' }]);
|
||||
expect(lookups).toEqual([{ value: 'resolved:editor.theme', found: true }]);
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'editor.theme=resolved:editor.theme' }]);
|
||||
});
|
||||
|
||||
verifies('custom-methods:params-validation-error', async ({ transport }: TestArgs) => {
|
||||
const ConvertParams = z.object({ celsius: z.number() });
|
||||
const ConvertResult = z.object({ fahrenheit: z.number() });
|
||||
|
||||
const invocations: Array<z.output<typeof ConvertParams>> = [];
|
||||
const makeServer = () => {
|
||||
const s = new Server({ name: 's', version: '0' }, { capabilities: {} });
|
||||
s.setRequestHandler('acme/convertTemperature', { params: ConvertParams, result: ConvertResult }, params => {
|
||||
invocations.push(params);
|
||||
return { fahrenheit: params.celsius * 1.8 + 32 };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
|
||||
await using _ = await wire(transport, makeServer, client, { allowCustomMethods: true });
|
||||
|
||||
// A conforming call first proves the handler is reachable, so the later "not invoked" check is meaningful.
|
||||
await expect(client.request({ method: 'acme/convertTemperature', params: { celsius: 20 } }, ConvertResult)).resolves.toEqual({
|
||||
fahrenheit: 68
|
||||
});
|
||||
expect(invocations).toEqual([{ celsius: 20 }]);
|
||||
|
||||
expect(ProtocolErrorCode.InvalidParams).toBe(-32_602);
|
||||
await expect(client.request({ method: 'acme/convertTemperature', params: { celsius: 'warm' } }, ConvertResult)).rejects.toMatchObject({
|
||||
code: ProtocolErrorCode.InvalidParams,
|
||||
message: expect.stringContaining('Invalid params for acme/convertTemperature')
|
||||
});
|
||||
expect(invocations).toEqual([{ celsius: 20 }]);
|
||||
});
|
||||
|
||||
verifies('custom-methods:notification-handler', async ({ transport }: TestArgs) => {
|
||||
const HeartbeatParams = z.object({ seq: z.number().int(), source: z.string().default('unspecified') });
|
||||
|
||||
const clientReceived: Array<z.output<typeof HeartbeatParams>> = [];
|
||||
const serverReceived: Array<z.output<typeof HeartbeatParams>> = [];
|
||||
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.server.setNotificationHandler('acme/heartbeat', { params: HeartbeatParams }, params => {
|
||||
serverReceived.push(params);
|
||||
});
|
||||
s.registerTool('sync-data', { inputSchema: z.object({ batches: z.number().int() }) }, async ({ batches }, ctx) => {
|
||||
for (let seq = 1; seq <= batches; seq++) {
|
||||
await ctx.mcpReq.notify({ method: 'acme/heartbeat', params: { seq, source: 'sync-job' } });
|
||||
}
|
||||
return { content: [{ type: 'text', text: `synced ${batches} batches` }] };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
client.setNotificationHandler('acme/heartbeat', { params: HeartbeatParams }, params => {
|
||||
clientReceived.push(params);
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client, { allowCustomMethods: true });
|
||||
|
||||
const result = await client.callTool({ name: 'sync-data', arguments: { batches: 2 } });
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'synced 2 batches' }]);
|
||||
|
||||
await vi.waitFor(() => expect(clientReceived).toHaveLength(2));
|
||||
expect(clientReceived).toEqual([
|
||||
{ seq: 1, source: 'sync-job' },
|
||||
{ seq: 2, source: 'sync-job' }
|
||||
]);
|
||||
|
||||
// Same registration works on the server side; `source` is omitted so the applied default proves schema validation ran.
|
||||
await client.notification({ method: 'acme/heartbeat', params: { seq: 3 } });
|
||||
await vi.waitFor(() => expect(serverReceived).toHaveLength(1));
|
||||
expect(serverReceived).toEqual([{ seq: 3, source: 'unspecified' }]);
|
||||
});
|
||||
354
test/e2e/scenarios/dynamic.test.ts
Normal file
354
test/e2e/scenarios/dynamic.test.ts
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
/**
|
||||
* Self-contained test bodies for dynamic (list_changed) behaviors.
|
||||
*
|
||||
* Tests for client-side listChanged handlers (auto-refresh, capability gating,
|
||||
* signal-only mode) and server-side dynamic registration (post-connect,
|
||||
* debounce, enable/disable, low-level handler reach-through).
|
||||
*/
|
||||
|
||||
import { Client } from '@modelcontextprotocol/client';
|
||||
import type { Prompt, RegisteredTool, Resource, Tool } from '@modelcontextprotocol/server';
|
||||
import { McpServer, ProtocolErrorCode, Server } from '@modelcontextprotocol/server';
|
||||
import { expect, vi } from 'vitest';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { wire } from '../helpers/index.js';
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
import type { TestArgs } from '../types.js';
|
||||
|
||||
const newClient = () => new Client({ name: 'c', version: '0' });
|
||||
|
||||
// vi.waitFor only retries on throw, not on falsy return — wrap predicates so a
|
||||
// false result throws and the poll continues.
|
||||
const waitUntil = (pred: () => boolean) => vi.waitFor(() => expect(pred()).toBe(true));
|
||||
|
||||
verifies('client:list-changed:auto-refresh', async ({ transport }: TestArgs) => {
|
||||
const toolCalls: Array<{ error: Error | null; items: Tool[] | null }> = [];
|
||||
const promptCalls: Array<{ error: Error | null; items: Prompt[] | null }> = [];
|
||||
const resourceCalls: Array<{ error: Error | null; items: Resource[] | null }> = [];
|
||||
|
||||
let server!: McpServer;
|
||||
const makeServer = () => {
|
||||
server = new McpServer({ name: 's', version: '0' });
|
||||
server.registerTool('seed', { inputSchema: z.object({}) }, () => ({ content: [] }));
|
||||
server.registerPrompt('seed', { description: '' }, () => ({ messages: [] }));
|
||||
server.registerResource('seed', 'test://seed', {}, () => ({ contents: [] }));
|
||||
return server;
|
||||
};
|
||||
|
||||
const client = new Client(
|
||||
{ name: 'c', version: '0' },
|
||||
{
|
||||
listChanged: {
|
||||
tools: { debounceMs: 0, onChanged: (error, items) => toolCalls.push({ error, items }) },
|
||||
prompts: { debounceMs: 0, onChanged: (error, items) => promptCalls.push({ error, items }) },
|
||||
resources: { debounceMs: 0, onChanged: (error, items) => resourceCalls.push({ error, items }) }
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
server.registerTool('probe-tool', { inputSchema: z.object({}) }, () => ({ content: [] }));
|
||||
await waitUntil(() => toolCalls.some(c => c.items?.some(t => t.name === 'probe-tool')));
|
||||
const toolCall = toolCalls.find(c => c.items?.some(t => t.name === 'probe-tool'));
|
||||
expect(toolCall?.error).toBeNull();
|
||||
expect(toolCall?.items?.map(t => t.name)).toContain('probe-tool');
|
||||
|
||||
server.registerPrompt('probe-prompt', { description: '' }, () => ({ messages: [] }));
|
||||
await waitUntil(() => promptCalls.some(c => c.items?.some(p => p.name === 'probe-prompt')));
|
||||
const promptCall = promptCalls.find(c => c.items?.some(p => p.name === 'probe-prompt'));
|
||||
expect(promptCall?.error).toBeNull();
|
||||
expect(promptCall?.items?.map(p => p.name)).toContain('probe-prompt');
|
||||
|
||||
server.registerResource('probe', 'test://probe', {}, () => ({ contents: [] }));
|
||||
await waitUntil(() => resourceCalls.some(c => c.items?.some(r => r.uri === 'test://probe')));
|
||||
const resourceCall = resourceCalls.find(c => c.items?.some(r => r.uri === 'test://probe'));
|
||||
expect(resourceCall?.error).toBeNull();
|
||||
expect(resourceCall?.items?.map(r => r.uri)).toContain('test://probe');
|
||||
});
|
||||
|
||||
verifies('client:list-changed:capability-gated', async ({ transport }: TestArgs) => {
|
||||
const toolsCalls: unknown[] = [];
|
||||
const promptsCalls: unknown[] = [];
|
||||
const resourcesCalls: unknown[] = [];
|
||||
|
||||
let server!: Server;
|
||||
const makeServer = () => {
|
||||
server = new Server({ name: 's', version: '0' }, { capabilities: { tools: { listChanged: true }, prompts: {}, resources: {} } });
|
||||
server.setRequestHandler('tools/list', () => ({ tools: [] }));
|
||||
server.setRequestHandler('prompts/list', () => ({ prompts: [] }));
|
||||
server.setRequestHandler('resources/list', () => ({ resources: [] }));
|
||||
return server;
|
||||
};
|
||||
|
||||
const client = new Client(
|
||||
{ name: 'c', version: '0' },
|
||||
{
|
||||
listChanged: {
|
||||
tools: { autoRefresh: false, debounceMs: 0, onChanged: (err, items) => toolsCalls.push({ err, items }) },
|
||||
prompts: {
|
||||
autoRefresh: false,
|
||||
debounceMs: 0,
|
||||
onChanged: (err, items) => promptsCalls.push({ err, items })
|
||||
},
|
||||
resources: {
|
||||
autoRefresh: false,
|
||||
debounceMs: 0,
|
||||
onChanged: (err, items) => resourcesCalls.push({ err, items })
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const caps = client.getServerCapabilities();
|
||||
expect(caps?.tools?.listChanged).toBe(true);
|
||||
expect(caps?.prompts?.listChanged).toBeFalsy();
|
||||
expect(caps?.resources?.listChanged).toBeFalsy();
|
||||
|
||||
toolsCalls.length = 0;
|
||||
promptsCalls.length = 0;
|
||||
resourcesCalls.length = 0;
|
||||
|
||||
await server.sendToolListChanged();
|
||||
await server.sendPromptListChanged();
|
||||
await server.sendResourceListChanged();
|
||||
|
||||
await waitUntil(() => toolsCalls.length > 0);
|
||||
expect(toolsCalls[0]).toEqual({ err: null, items: null });
|
||||
|
||||
await client.listTools();
|
||||
expect(promptsCalls).toHaveLength(0);
|
||||
expect(resourcesCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
verifies('client:list-changed:signal-only', async ({ transport }: TestArgs) => {
|
||||
const toolCalls: Array<{ error: Error | null; items: Tool[] | null }> = [];
|
||||
const promptCalls: Array<{ error: Error | null; items: Prompt[] | null }> = [];
|
||||
const resourceCalls: Array<{ error: Error | null; items: Resource[] | null }> = [];
|
||||
|
||||
let server!: McpServer;
|
||||
const makeServer = () => {
|
||||
server = new McpServer({ name: 's', version: '0' });
|
||||
server.registerTool('seed', { inputSchema: z.object({}) }, () => ({ content: [] }));
|
||||
server.registerPrompt('seed', { description: '' }, () => ({ messages: [] }));
|
||||
server.registerResource('seed', 'test://seed', {}, () => ({ contents: [] }));
|
||||
return server;
|
||||
};
|
||||
|
||||
const client = new Client(
|
||||
{ name: 'c', version: '0' },
|
||||
{
|
||||
listChanged: {
|
||||
tools: { autoRefresh: false, debounceMs: 0, onChanged: (error, items) => toolCalls.push({ error, items }) },
|
||||
prompts: {
|
||||
autoRefresh: false,
|
||||
debounceMs: 0,
|
||||
onChanged: (error, items) => promptCalls.push({ error, items })
|
||||
},
|
||||
resources: {
|
||||
autoRefresh: false,
|
||||
debounceMs: 0,
|
||||
onChanged: (error, items) => resourceCalls.push({ error, items })
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const before = await client.listTools();
|
||||
|
||||
toolCalls.length = 0;
|
||||
promptCalls.length = 0;
|
||||
resourceCalls.length = 0;
|
||||
|
||||
server.registerTool('signal-only-tool', { inputSchema: z.object({}) }, () => ({ content: [] }));
|
||||
await waitUntil(() => toolCalls.length > 0);
|
||||
expect(toolCalls[0]).toEqual({ error: null, items: null });
|
||||
|
||||
server.registerPrompt('signal-only-prompt', { description: '' }, () => ({ messages: [] }));
|
||||
await waitUntil(() => promptCalls.length > 0);
|
||||
expect(promptCalls[0]).toEqual({ error: null, items: null });
|
||||
|
||||
server.registerResource('signal-only', 'test://signal-only', {}, () => ({ contents: [] }));
|
||||
await waitUntil(() => resourceCalls.length > 0);
|
||||
expect(resourceCalls[0]).toEqual({ error: null, items: null });
|
||||
|
||||
const after = await client.listTools();
|
||||
expect(after.tools.length).toBe(before.tools.length + 1);
|
||||
});
|
||||
|
||||
verifies('mcpserver:handle:enable-disable', async ({ transport }: TestArgs) => {
|
||||
let handle!: RegisteredTool;
|
||||
let server!: McpServer;
|
||||
|
||||
const makeServer = () => {
|
||||
server = new McpServer({ name: 's', version: '0' });
|
||||
handle = server.registerTool('toggle-probe', { inputSchema: z.object({}) }, () => ({
|
||||
content: [{ type: 'text', text: 'toggle-probe' }]
|
||||
}));
|
||||
return server;
|
||||
};
|
||||
|
||||
let listChanged = 0;
|
||||
const client = newClient();
|
||||
client.setNotificationHandler('notifications/tools/list_changed', () => {
|
||||
listChanged++;
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const initialList = await client.listTools();
|
||||
expect(initialList.tools.map(t => t.name)).toContain('toggle-probe');
|
||||
const baseline = await client.callTool({ name: 'toggle-probe', arguments: {} });
|
||||
expect(baseline.isError).toBeFalsy();
|
||||
expect(baseline.content).toEqual([{ type: 'text', text: 'toggle-probe' }]);
|
||||
|
||||
const beforeDisable = listChanged;
|
||||
handle.disable();
|
||||
await waitUntil(() => listChanged > beforeDisable);
|
||||
|
||||
const disabledList = await client.listTools();
|
||||
expect(disabledList.tools.map(t => t.name)).not.toContain('toggle-probe');
|
||||
// changed in v2: calling a disabled tool surfaces a JSON-RPC InvalidParams error instead of an isError result
|
||||
await expect(client.callTool({ name: 'toggle-probe', arguments: {} })).rejects.toMatchObject({
|
||||
code: ProtocolErrorCode.InvalidParams,
|
||||
message: expect.stringMatching(/disabled/i)
|
||||
});
|
||||
|
||||
const beforeEnable = listChanged;
|
||||
handle.enable();
|
||||
await waitUntil(() => listChanged > beforeEnable);
|
||||
|
||||
const restoredList = await client.listTools();
|
||||
expect(restoredList.tools.map(t => t.name)).toContain('toggle-probe');
|
||||
const restored = await client.callTool({ name: 'toggle-probe', arguments: {} });
|
||||
expect(restored.isError).toBeFalsy();
|
||||
expect(restored.content).toEqual([{ type: 'text', text: 'toggle-probe' }]);
|
||||
});
|
||||
|
||||
verifies('mcpserver:list-changed:debounce', async ({ transport }: TestArgs) => {
|
||||
let server!: McpServer;
|
||||
const makeServer = () => {
|
||||
server = new McpServer(
|
||||
{ name: 's', version: '0' },
|
||||
{
|
||||
debouncedNotificationMethods: [
|
||||
'notifications/tools/list_changed',
|
||||
'notifications/resources/list_changed',
|
||||
'notifications/prompts/list_changed'
|
||||
]
|
||||
}
|
||||
);
|
||||
// Seed one of each so capability registration + request handlers are set
|
||||
// up pre-connect; post-connect registrations would otherwise try to
|
||||
// registerCapabilities and throw.
|
||||
server.registerTool('seed', { inputSchema: z.object({}) }, () => ({ content: [] }));
|
||||
server.registerResource('seed', 'test://seed', {}, () => ({ contents: [] }));
|
||||
server.registerPrompt('seed', { description: '' }, () => ({ messages: [] }));
|
||||
return server;
|
||||
};
|
||||
|
||||
const counts = { tools: 0, resources: 0, prompts: 0 };
|
||||
const client = newClient();
|
||||
client.setNotificationHandler('notifications/tools/list_changed', () => void counts.tools++);
|
||||
client.setNotificationHandler('notifications/resources/list_changed', () => void counts.resources++);
|
||||
client.setNotificationHandler('notifications/prompts/list_changed', () => void counts.prompts++);
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
counts.tools = 0;
|
||||
counts.resources = 0;
|
||||
counts.prompts = 0;
|
||||
|
||||
server.registerTool('a', { inputSchema: z.object({}) }, () => ({ content: [] }));
|
||||
server.registerTool('b', { inputSchema: z.object({}) }, () => ({ content: [] }));
|
||||
server.registerTool('c', { inputSchema: z.object({}) }, () => ({ content: [] }));
|
||||
server.registerResource('a', 'test://debounce/a', {}, () => ({ contents: [] }));
|
||||
server.registerResource('b', 'test://debounce/b', {}, () => ({ contents: [] }));
|
||||
server.registerResource('c', 'test://debounce/c', {}, () => ({ contents: [] }));
|
||||
server.registerPrompt('a', { description: '' }, () => ({ messages: [] }));
|
||||
server.registerPrompt('b', { description: '' }, () => ({ messages: [] }));
|
||||
server.registerPrompt('c', { description: '' }, () => ({ messages: [] }));
|
||||
|
||||
await waitUntil(() => counts.tools >= 1 && counts.resources >= 1 && counts.prompts >= 1);
|
||||
|
||||
await client.listTools();
|
||||
expect(counts.tools).toBe(1);
|
||||
expect(counts.resources).toBe(1);
|
||||
expect(counts.prompts).toBe(1);
|
||||
|
||||
const { tools } = await client.listTools();
|
||||
expect(tools.map(t => t.name)).toEqual(expect.arrayContaining(['a', 'b', 'c']));
|
||||
});
|
||||
|
||||
verifies('mcpserver:register:post-connect', async ({ transport }: TestArgs) => {
|
||||
let server!: McpServer;
|
||||
const makeServer = () => {
|
||||
server = new McpServer({ name: 's', version: '0' });
|
||||
// Seed so handlers + capabilities are wired pre-connect.
|
||||
server.registerTool('seed', { inputSchema: z.object({}) }, () => ({ content: [] }));
|
||||
server.registerResource('seed', 'test://seed', {}, () => ({ contents: [] }));
|
||||
server.registerPrompt('seed', { description: '' }, () => ({ messages: [] }));
|
||||
return server;
|
||||
};
|
||||
|
||||
const seen: string[] = [];
|
||||
const client = newClient();
|
||||
client.setNotificationHandler('notifications/tools/list_changed', () => void seen.push('tools'));
|
||||
client.setNotificationHandler('notifications/resources/list_changed', () => void seen.push('resources'));
|
||||
client.setNotificationHandler('notifications/prompts/list_changed', () => void seen.push('prompts'));
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
seen.length = 0;
|
||||
server.registerTool('post-connect-tool', { inputSchema: z.object({}) }, () => ({ content: [] }));
|
||||
await waitUntil(() => seen.includes('tools'));
|
||||
const { tools } = await client.listTools();
|
||||
expect(tools.map(t => t.name)).toContain('post-connect-tool');
|
||||
|
||||
seen.length = 0;
|
||||
server.registerResource('post-connect', 'test://post-connect', {}, () => ({ contents: [] }));
|
||||
await waitUntil(() => seen.includes('resources'));
|
||||
const { resources } = await client.listResources();
|
||||
expect(resources.map(r => r.uri)).toContain('test://post-connect');
|
||||
|
||||
seen.length = 0;
|
||||
server.registerPrompt('post-connect-prompt', { description: '' }, () => ({ messages: [] }));
|
||||
await waitUntil(() => seen.includes('prompts'));
|
||||
const { prompts } = await client.listPrompts();
|
||||
expect(prompts.map(p => p.name)).toContain('post-connect-prompt');
|
||||
});
|
||||
|
||||
verifies('mcpserver:reach-through:set-request-handler', async ({ transport }: TestArgs) => {
|
||||
const handlerHits: string[] = [];
|
||||
let server!: McpServer;
|
||||
|
||||
const makeServer = () => {
|
||||
server = new McpServer({ name: 's', version: '0' }, { capabilities: { resources: { subscribe: true, listChanged: true } } });
|
||||
server.registerResource('baseline', 'test://baseline', {}, () => ({ contents: [] }));
|
||||
server.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({
|
||||
content: [{ type: 'text', text }]
|
||||
}));
|
||||
return server;
|
||||
};
|
||||
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
server.server.setRequestHandler('resources/subscribe', async request => {
|
||||
handlerHits.push(request.params.uri);
|
||||
return {};
|
||||
});
|
||||
|
||||
await expect(client.subscribeResource({ uri: 'test://reach-through' })).resolves.toEqual({});
|
||||
await waitUntil(() => handlerHits.includes('test://reach-through'));
|
||||
expect(handlerHits).toEqual(['test://reach-through']);
|
||||
|
||||
const echo = await client.callTool({ name: 'echo', arguments: { text: 'still wired' } });
|
||||
expect(echo.isError).toBeFalsy();
|
||||
expect(echo.content).toEqual([{ type: 'text', text: 'still wired' }]);
|
||||
});
|
||||
827
test/e2e/scenarios/elicitation.test.ts
Normal file
827
test/e2e/scenarios/elicitation.test.ts
Normal file
|
|
@ -0,0 +1,827 @@
|
|||
/**
|
||||
* Self-contained test bodies for elicitation (form and URL modes).
|
||||
*
|
||||
* Each export is a {@link TestCase}: it builds its own server, client, wires
|
||||
* them with {@link wire}, and asserts. Elicitation is a server→client request
|
||||
* flow, so tests inline `client.setRequestHandler(ElicitRequestSchema, ...)` to
|
||||
* script the responses the server will receive.
|
||||
*
|
||||
* Function names mirror the requirement id in camelCase.
|
||||
*/
|
||||
|
||||
import { Client } from '@modelcontextprotocol/client';
|
||||
import type { ElicitRequest, ElicitRequestFormParams } from '@modelcontextprotocol/server';
|
||||
import {
|
||||
McpServer,
|
||||
ProtocolError,
|
||||
ProtocolErrorCode,
|
||||
Server,
|
||||
specTypeSchemas,
|
||||
UrlElicitationRequiredError
|
||||
} from '@modelcontextprotocol/server';
|
||||
import { expect } from 'vitest';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { tapWire, wire } from '../helpers/index.js';
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
import type { TestArgs } from '../types.js';
|
||||
|
||||
/** Client with form-mode elicitation support. */
|
||||
const formClient = () => new Client({ name: 'c', version: '0' }, { capabilities: { elicitation: { form: {} } } });
|
||||
|
||||
/** Client with form-mode elicitation AND applyDefaults support. */
|
||||
const formClientWithDefaults = () =>
|
||||
new Client({ name: 'c', version: '0' }, { capabilities: { elicitation: { form: { applyDefaults: true } } } });
|
||||
|
||||
/** Client with URL-mode elicitation support. */
|
||||
const urlClient = () => new Client({ name: 'c', version: '0' }, { capabilities: { elicitation: { url: {} } } });
|
||||
|
||||
verifies('elicitation:form:basic', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('ask-name', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'What is your name?',
|
||||
requestedSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }
|
||||
});
|
||||
const name = ans.action === 'accept' ? String(ans.content?.name) : '<declined>';
|
||||
return { content: [{ type: 'text', text: `Hello, ${name}` }] };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const received: Array<{ method: string; params: unknown }> = [];
|
||||
const client = formClient();
|
||||
client.setRequestHandler('elicitation/create', async req => {
|
||||
received.push({ method: req.method, params: req.params });
|
||||
return { action: 'accept', content: { name: 'Ada' } };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'ask-name', arguments: {} });
|
||||
|
||||
expect(received).toHaveLength(1);
|
||||
const firstRequest = received[0];
|
||||
if (firstRequest === undefined) throw new Error('expected exactly one elicitation request');
|
||||
expect(firstRequest.method).toBe('elicitation/create');
|
||||
expect(firstRequest.params).toMatchObject({
|
||||
mode: 'form',
|
||||
message: 'What is your name?',
|
||||
requestedSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }
|
||||
});
|
||||
|
||||
expect(r.isError).toBeFalsy();
|
||||
expect(r.content).toEqual([{ type: 'text', text: 'Hello, Ada' }]);
|
||||
});
|
||||
|
||||
verifies('elicitation:form:action:accept', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('ask', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Enter name',
|
||||
requestedSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text', text: ans.action === 'accept' ? String(ans.content?.name) : 'none' }]
|
||||
};
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = formClient();
|
||||
client.setRequestHandler('elicitation/create', async () => {
|
||||
return { action: 'accept', content: { name: 'Ada' } };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'ask', arguments: {} });
|
||||
expect(r.content).toEqual([{ type: 'text', text: 'Ada' }]);
|
||||
});
|
||||
|
||||
verifies('elicitation:form:action:cancel', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('ask', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Enter name',
|
||||
requestedSchema: { type: 'object', properties: { name: { type: 'string' } } }
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text', text: `action:${ans.action},hasContent:${'content' in ans && ans.content !== undefined}` }]
|
||||
};
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = formClient();
|
||||
client.setRequestHandler('elicitation/create', async () => {
|
||||
return { action: 'cancel' };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'ask', arguments: {} });
|
||||
expect(r.content).toEqual([{ type: 'text', text: 'action:cancel,hasContent:false' }]);
|
||||
});
|
||||
|
||||
verifies('elicitation:form:action:decline', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('ask', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Enter name',
|
||||
requestedSchema: { type: 'object', properties: { name: { type: 'string' } } }
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text', text: `action:${ans.action},hasContent:${'content' in ans && ans.content !== undefined}` }]
|
||||
};
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = formClient();
|
||||
client.setRequestHandler('elicitation/create', async () => {
|
||||
return { action: 'decline' };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'ask', arguments: {} });
|
||||
expect(r.content).toEqual([{ type: 'text', text: 'action:decline,hasContent:false' }]);
|
||||
});
|
||||
|
||||
verifies('elicitation:form:defaults', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('ask', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Fill form',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', default: 'anon' },
|
||||
age: { type: 'integer', default: 42 },
|
||||
subscribe: { type: 'boolean', default: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
return { content: [], structuredContent: ans.action === 'accept' ? ans.content : undefined };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = formClientWithDefaults();
|
||||
client.setRequestHandler('elicitation/create', async () => ({ action: 'accept', content: { name: 'Ada' } }));
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'ask', arguments: {} });
|
||||
expect(r.structuredContent).toEqual({ name: 'Ada', age: 42, subscribe: true });
|
||||
});
|
||||
|
||||
verifies('elicitation:form:mode-omitted-default', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } });
|
||||
s.setRequestHandler('tools/list', () => ({ tools: [{ name: 'ask', inputSchema: { type: 'object' } }] }));
|
||||
s.setRequestHandler('tools/call', async (_req, ctx) => {
|
||||
try {
|
||||
const ans = await ctx.mcpReq.send(
|
||||
{
|
||||
method: 'elicitation/create',
|
||||
params: {
|
||||
message: 'Enter name',
|
||||
requestedSchema: { type: 'object', properties: { name: { type: 'string' } } }
|
||||
}
|
||||
},
|
||||
specTypeSchemas.ElicitResult
|
||||
);
|
||||
return { content: [{ type: 'text', text: ans.action }] };
|
||||
} catch (error) {
|
||||
if (!(error instanceof ProtocolError)) throw error;
|
||||
return { content: [{ type: 'text', text: `error:${error.code}` }] };
|
||||
}
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const formReceived: ElicitRequest['params'][] = [];
|
||||
const formClientInstance = formClient();
|
||||
formClientInstance.setRequestHandler('elicitation/create', async req => {
|
||||
formReceived.push(req.params);
|
||||
return { action: 'accept', content: { name: 'Test' } };
|
||||
});
|
||||
|
||||
{
|
||||
await using _ = await wire(transport, makeServer, formClientInstance);
|
||||
|
||||
const formResult = await formClientInstance.callTool({ name: 'ask', arguments: {} });
|
||||
|
||||
expect(formReceived).toHaveLength(1);
|
||||
const firstFormRequest = formReceived[0];
|
||||
if (firstFormRequest === undefined) throw new Error('expected exactly one form elicitation request');
|
||||
expect(firstFormRequest.mode).toBeUndefined();
|
||||
expect(formResult.content).toEqual([{ type: 'text', text: 'accept' }]);
|
||||
}
|
||||
|
||||
let urlHandlerInvoked = 0;
|
||||
const urlClientInstance = urlClient();
|
||||
urlClientInstance.setRequestHandler('elicitation/create', async () => {
|
||||
urlHandlerInvoked++;
|
||||
return { action: 'accept' };
|
||||
});
|
||||
|
||||
{
|
||||
await using _ = await wire(transport, makeServer, urlClientInstance);
|
||||
|
||||
const urlResult = await urlClientInstance.callTool({ name: 'ask', arguments: {} });
|
||||
expect(urlResult.content).toEqual([{ type: 'text', text: `error:${ProtocolErrorCode.InvalidParams}` }]);
|
||||
expect(urlHandlerInvoked).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
verifies('elicitation:form:schema:primitives', async ({ transport }: TestArgs) => {
|
||||
const requestedSchema: ElicitRequestFormParams['requestedSchema'] = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
email: { type: 'string', format: 'email' },
|
||||
age: { type: 'integer' },
|
||||
score: { type: 'number' },
|
||||
active: { type: 'boolean' }
|
||||
}
|
||||
};
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('ask', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({ mode: 'form', message: 'Fill form', requestedSchema });
|
||||
return { content: [], structuredContent: ans.action === 'accept' ? ans.content : undefined };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const expected = { name: 'Ada', email: 'ada@example.com', age: 30, score: 95.5, active: true };
|
||||
const received: ElicitRequest['params'][] = [];
|
||||
const client = formClient();
|
||||
client.setRequestHandler('elicitation/create', async req => {
|
||||
received.push(req.params);
|
||||
return { action: 'accept', content: expected };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'ask', arguments: {} });
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0]).toEqual({ mode: 'form', message: 'Fill form', requestedSchema });
|
||||
expect(r.structuredContent).toEqual(expected);
|
||||
});
|
||||
|
||||
verifies('elicitation:form:schema:enum-variants', async ({ transport }: TestArgs) => {
|
||||
const requestedSchema: ElicitRequestFormParams['requestedSchema'] = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
bare: { type: 'string', enum: ['Red', 'Green', 'Blue'] },
|
||||
titled: {
|
||||
type: 'string',
|
||||
oneOf: [
|
||||
{ const: '#FF0000', title: 'Red' },
|
||||
{ const: '#00FF00', title: 'Green' }
|
||||
]
|
||||
},
|
||||
multi: {
|
||||
type: 'array',
|
||||
items: {
|
||||
anyOf: [
|
||||
{ const: '#FF0000', title: 'Red' },
|
||||
{ const: '#0000FF', title: 'Blue' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('ask', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({ mode: 'form', message: 'Pick colors', requestedSchema });
|
||||
return { content: [], structuredContent: ans.action === 'accept' ? ans.content : undefined };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const expected = { bare: 'Red', titled: '#00FF00', multi: ['#FF0000', '#0000FF'] };
|
||||
const received: ElicitRequest['params'][] = [];
|
||||
const client = formClient();
|
||||
client.setRequestHandler('elicitation/create', async req => {
|
||||
received.push(req.params);
|
||||
return { action: 'accept', content: expected };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'ask', arguments: {} });
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0]).toEqual({ mode: 'form', message: 'Pick colors', requestedSchema });
|
||||
expect(r.structuredContent).toEqual(expected);
|
||||
});
|
||||
|
||||
verifies('elicitation:form:response-validation', async ({ transport }: TestArgs) => {
|
||||
const requestedSchema: ElicitRequestFormParams['requestedSchema'] = {
|
||||
type: 'object',
|
||||
properties: { username: { type: 'string' } },
|
||||
required: ['username']
|
||||
};
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('signup', { inputSchema: z.object({}) }, async () => {
|
||||
try {
|
||||
const ans = await s.server.elicitInput({ mode: 'form', message: 'Choose a username', requestedSchema });
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: 'text', text: `SDK accepted invalid content: ${JSON.stringify(ans.content)}` }]
|
||||
};
|
||||
} catch (error) {
|
||||
if (!(error instanceof ProtocolError)) throw error;
|
||||
return { content: [{ type: 'text', text: `rejected:${error.code}:${error.message}` }] };
|
||||
}
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
// Each accepted response violates the requested schema: wrong type for `username`, then missing required `username`.
|
||||
const invalidContents: Array<Record<string, string | number | boolean>> = [{ username: 42 }, {}];
|
||||
let handlerInvoked = 0;
|
||||
const client = formClient();
|
||||
client.setRequestHandler('elicitation/create', async () => {
|
||||
const content = invalidContents[handlerInvoked];
|
||||
handlerInvoked++;
|
||||
return { action: 'accept', content };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const schemaRejection = expect.stringMatching(
|
||||
new RegExp(`^rejected:${ProtocolErrorCode.InvalidParams}:.*does not match requested schema`)
|
||||
);
|
||||
|
||||
const wrongType = await client.callTool({ name: 'signup', arguments: {} });
|
||||
expect(wrongType.isError).toBeFalsy();
|
||||
expect(wrongType.content).toEqual([{ type: 'text', text: schemaRejection }]);
|
||||
|
||||
const missingRequired = await client.callTool({ name: 'signup', arguments: {} });
|
||||
expect(missingRequired.isError).toBeFalsy();
|
||||
expect(missingRequired.content).toEqual([{ type: 'text', text: schemaRejection }]);
|
||||
|
||||
expect(handlerInvoked).toBe(2);
|
||||
});
|
||||
|
||||
verifies('elicitation:form:schema:restricted-subset', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } });
|
||||
s.setRequestHandler('tools/list', () => ({ tools: [{ name: 'profile', inputSchema: { type: 'object' } }] }));
|
||||
s.setRequestHandler('tools/call', async (_req, ctx) => {
|
||||
try {
|
||||
await ctx.mcpReq.send(
|
||||
{
|
||||
method: 'elicitation/create',
|
||||
params: {
|
||||
mode: 'form',
|
||||
message: 'Profile details',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
address: {
|
||||
type: 'object',
|
||||
properties: { street: { type: 'string' }, city: { type: 'string' } }
|
||||
},
|
||||
contacts: {
|
||||
type: 'array',
|
||||
items: { type: 'object', properties: { name: { type: 'string' } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
specTypeSchemas.ElicitResult
|
||||
);
|
||||
return { content: [{ type: 'text', text: 'should-not-reach' }] };
|
||||
} catch (error) {
|
||||
if (!(error instanceof ProtocolError)) throw error;
|
||||
return { content: [{ type: 'text', text: `error:${error.code}` }] };
|
||||
}
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
let handlerInvoked = 0;
|
||||
const client = formClient();
|
||||
client.setRequestHandler('elicitation/create', async () => {
|
||||
handlerInvoked++;
|
||||
return { action: 'accept', content: {} };
|
||||
});
|
||||
|
||||
// strictValidation off: the nested requestedSchema deliberately violates the spec's flat-primitive restriction on the wire.
|
||||
await using _ = await wire(transport, makeServer, client, { strictValidation: false });
|
||||
|
||||
const r = await client.callTool({ name: 'profile', arguments: {} });
|
||||
expect(r.isError).toBeFalsy();
|
||||
expect(r.content).toEqual([{ type: 'text', text: `error:${ProtocolErrorCode.InvalidParams}` }]);
|
||||
expect(handlerInvoked).toBe(0);
|
||||
});
|
||||
|
||||
verifies('elicitation:url:basic', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('auth', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({
|
||||
mode: 'url',
|
||||
message: 'Please sign in',
|
||||
elicitationId: 'url-1',
|
||||
url: 'https://example.com/auth?state=test'
|
||||
});
|
||||
return { content: [{ type: 'text', text: ans.action }] };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const received: ElicitRequest['params'][] = [];
|
||||
const client = urlClient();
|
||||
client.setRequestHandler('elicitation/create', async req => {
|
||||
received.push(req.params);
|
||||
return { action: 'accept' };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'auth', arguments: {} });
|
||||
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0]).toMatchObject({
|
||||
mode: 'url',
|
||||
message: 'Please sign in',
|
||||
elicitationId: 'url-1',
|
||||
url: 'https://example.com/auth?state=test'
|
||||
});
|
||||
expect(r.content).toEqual([{ type: 'text', text: 'accept' }]);
|
||||
});
|
||||
|
||||
verifies('elicitation:url:action:accept-no-content', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('auth', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({
|
||||
mode: 'url',
|
||||
message: 'Please sign in',
|
||||
elicitationId: 'url-2',
|
||||
url: 'https://example.com/auth'
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text', text: `action:${ans.action},hasContent:${'content' in ans && ans.content !== undefined}` }]
|
||||
};
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = urlClient();
|
||||
client.setRequestHandler('elicitation/create', async () => ({ action: 'accept' }));
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'auth', arguments: {} });
|
||||
expect(r.content).toEqual([{ type: 'text', text: 'action:accept,hasContent:false' }]);
|
||||
});
|
||||
|
||||
verifies('elicitation:url:action:cancel', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('auth', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({
|
||||
mode: 'url',
|
||||
message: 'Please sign in',
|
||||
elicitationId: 'url-cancel-1',
|
||||
url: 'https://example.com/auth'
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text', text: `action:${ans.action},hasContent:${'content' in ans && ans.content !== undefined}` }]
|
||||
};
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = urlClient();
|
||||
client.setRequestHandler('elicitation/create', async () => ({ action: 'cancel' }));
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'auth', arguments: {} });
|
||||
expect(r.isError).toBeFalsy();
|
||||
expect(r.content).toEqual([{ type: 'text', text: 'action:cancel,hasContent:false' }]);
|
||||
});
|
||||
|
||||
verifies('elicitation:url:action:decline', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('auth', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({
|
||||
mode: 'url',
|
||||
message: 'Please sign in',
|
||||
elicitationId: 'url-decline-1',
|
||||
url: 'https://example.com/auth'
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text', text: `action:${ans.action},hasContent:${'content' in ans && ans.content !== undefined}` }]
|
||||
};
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = urlClient();
|
||||
client.setRequestHandler('elicitation/create', async () => ({ action: 'decline' }));
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'auth', arguments: {} });
|
||||
expect(r.isError).toBeFalsy();
|
||||
expect(r.content).toEqual([{ type: 'text', text: 'action:decline,hasContent:false' }]);
|
||||
});
|
||||
|
||||
verifies('elicitation:url:complete-notification', async ({ transport }: TestArgs) => {
|
||||
const notifications: Array<{ method: string; params: unknown }> = [];
|
||||
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('auth', { inputSchema: z.object({}) }, async () => {
|
||||
throw new UrlElicitationRequiredError([
|
||||
{
|
||||
mode: 'url',
|
||||
message: 'Sign in required',
|
||||
elicitationId: 'complete-1',
|
||||
url: 'https://example.com/auth'
|
||||
}
|
||||
]);
|
||||
});
|
||||
s.registerTool('complete', { inputSchema: z.object({ elicitationId: z.string() }) }, async ({ elicitationId }, ctx) => {
|
||||
const notify = s.server.createElicitationCompletionNotifier(elicitationId, { relatedRequestId: ctx.mcpReq.id });
|
||||
await notify();
|
||||
return { content: [{ type: 'text', text: 'notified' }] };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = urlClient();
|
||||
client.setNotificationHandler('notifications/elicitation/complete', n => {
|
||||
notifications.push({ method: n.method, params: n.params });
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const err = await client.callTool({ name: 'auth', arguments: {} }).catch(error => error);
|
||||
if (!(err instanceof UrlElicitationRequiredError)) throw new Error(`expected UrlElicitationRequiredError, got ${err}`);
|
||||
const pending = err.elicitations[0];
|
||||
if (pending === undefined) throw new Error('expected one pending elicitation');
|
||||
const elicitationId = pending.elicitationId;
|
||||
|
||||
await client.callTool({ name: 'complete', arguments: { elicitationId } });
|
||||
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0]).toEqual({
|
||||
method: 'notifications/elicitation/complete',
|
||||
params: { elicitationId: 'complete-1' }
|
||||
});
|
||||
});
|
||||
|
||||
verifies('elicitation:url:complete-unknown-ignored', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('auth', { inputSchema: z.object({}) }, () => {
|
||||
throw new UrlElicitationRequiredError([
|
||||
{ mode: 'url', message: 'Sign in required', elicitationId: 'seen-1', url: 'https://example.com/auth' }
|
||||
]);
|
||||
});
|
||||
// Raw send: the server-side capability gate cannot pass on stateless (client capabilities never learned),
|
||||
// and the behavior under test is the client's handling of the notification.
|
||||
s.registerTool('complete', { inputSchema: z.object({ elicitationId: z.string() }) }, async ({ elicitationId }, ctx) => {
|
||||
const tx = s.server.transport;
|
||||
if (!tx) throw new Error('server transport not connected');
|
||||
await tx.send(
|
||||
{ jsonrpc: '2.0', method: 'notifications/elicitation/complete', params: { elicitationId } },
|
||||
{ relatedRequestId: ctx.mcpReq.id }
|
||||
);
|
||||
return { content: [{ type: 'text', text: 'sent' }] };
|
||||
});
|
||||
s.registerTool('noop', { inputSchema: z.object({}) }, () => ({ content: [] }));
|
||||
return s;
|
||||
};
|
||||
|
||||
const notifications: Array<{ method: string; params: unknown }> = [];
|
||||
const errors: Error[] = [];
|
||||
const client = urlClient();
|
||||
client.onerror = e => errors.push(e);
|
||||
client.setNotificationHandler('notifications/elicitation/complete', n => {
|
||||
notifications.push({ method: n.method, params: n.params });
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const err = await client.callTool({ name: 'auth', arguments: {} }).catch(error => error);
|
||||
if (!(err instanceof UrlElicitationRequiredError)) throw new Error(`expected UrlElicitationRequiredError, got ${err}`);
|
||||
const seenPending = err.elicitations[0];
|
||||
if (seenPending === undefined) throw new Error('expected one pending elicitation');
|
||||
const seenId = seenPending.elicitationId;
|
||||
|
||||
await client.callTool({ name: 'complete', arguments: { elicitationId: seenId } });
|
||||
// Already-completed id, then an id the client never saw: both must be ignored without error.
|
||||
await client.callTool({ name: 'complete', arguments: { elicitationId: seenId } });
|
||||
await client.callTool({ name: 'complete', arguments: { elicitationId: 'never-issued' } });
|
||||
|
||||
expect(notifications).toEqual([
|
||||
{ method: 'notifications/elicitation/complete', params: { elicitationId: 'seen-1' } },
|
||||
{ method: 'notifications/elicitation/complete', params: { elicitationId: 'seen-1' } },
|
||||
{ method: 'notifications/elicitation/complete', params: { elicitationId: 'never-issued' } }
|
||||
]);
|
||||
expect(errors).toEqual([]);
|
||||
|
||||
const after = await client.callTool({ name: 'noop', arguments: {} });
|
||||
expect(after.isError).toBeFalsy();
|
||||
expect(after.content).toEqual([]);
|
||||
});
|
||||
|
||||
verifies('elicitation:url:required-error', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('auth-required', { inputSchema: z.object({}) }, () => {
|
||||
throw new UrlElicitationRequiredError([
|
||||
{
|
||||
mode: 'url',
|
||||
message: 'Please authenticate',
|
||||
elicitationId: 'err-1',
|
||||
url: 'https://example.com/oauth'
|
||||
}
|
||||
]);
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = urlClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const err = await client.callTool({ name: 'auth-required', arguments: {} }).catch(error => error);
|
||||
if (!(err instanceof UrlElicitationRequiredError)) throw new Error(`expected UrlElicitationRequiredError, got ${err}`);
|
||||
expect(err.code).toBe(ProtocolErrorCode.UrlElicitationRequired);
|
||||
expect(err.elicitations).toHaveLength(1);
|
||||
expect(err.elicitations[0]).toMatchObject({
|
||||
mode: 'url',
|
||||
message: 'Please authenticate',
|
||||
elicitationId: 'err-1',
|
||||
url: 'https://example.com/oauth'
|
||||
});
|
||||
});
|
||||
|
||||
verifies('elicitation:capability:empty-is-form', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('ask', { inputSchema: z.object({}) }, async () => {
|
||||
const ans = await s.server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Name?',
|
||||
requestedSchema: { type: 'object', properties: { name: { type: 'string' } } }
|
||||
});
|
||||
return { content: [{ type: 'text', text: ans.action }] };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = new Client({ name: 'c', version: '0' }, { capabilities: { elicitation: {} } });
|
||||
client.setRequestHandler('elicitation/create', async () => {
|
||||
return { action: 'accept', content: { name: 'Test' } };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'ask', arguments: {} });
|
||||
expect(r.content).toEqual([{ type: 'text', text: 'accept' }]);
|
||||
});
|
||||
|
||||
verifies('elicitation:capability:mode-mismatch', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } });
|
||||
s.setRequestHandler('tools/list', () => ({ tools: [{ name: 'auth', inputSchema: { type: 'object' } }] }));
|
||||
s.setRequestHandler('tools/call', async (_req, ctx) => {
|
||||
try {
|
||||
await ctx.mcpReq.send(
|
||||
{
|
||||
method: 'elicitation/create',
|
||||
params: { mode: 'url', message: 'Sign in', elicitationId: 'mismatch-1', url: 'https://example.com/auth' }
|
||||
},
|
||||
specTypeSchemas.ElicitResult
|
||||
);
|
||||
return { content: [{ type: 'text', text: 'should-not-reach' }] };
|
||||
} catch (error) {
|
||||
if (!(error instanceof ProtocolError)) throw error;
|
||||
return { content: [{ type: 'text', text: `error:${error.code}` }] };
|
||||
}
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
let handlerInvoked = 0;
|
||||
const client = formClient();
|
||||
client.setRequestHandler('elicitation/create', async () => {
|
||||
handlerInvoked++;
|
||||
return { action: 'accept', content: {} };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'auth', arguments: {} });
|
||||
expect(r.content).toEqual([{ type: 'text', text: `error:${ProtocolErrorCode.InvalidParams}` }]);
|
||||
expect(handlerInvoked).toBe(0);
|
||||
});
|
||||
|
||||
verifies('elicitation:capability:server-respects-mode', async ({ transport }: TestArgs) => {
|
||||
let reachedClient = 0;
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('auth', { inputSchema: z.object({}) }, async () => {
|
||||
try {
|
||||
await s.server.elicitInput({
|
||||
mode: 'url',
|
||||
message: 'Sign in',
|
||||
elicitationId: 'gate-1',
|
||||
url: 'https://example.com/auth'
|
||||
});
|
||||
return { isError: true, content: [{ type: 'text', text: 'SDK let it through' }] };
|
||||
} catch (error) {
|
||||
return { content: [{ type: 'text', text: `refused:${error instanceof Error ? error.message : error}` }] };
|
||||
}
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = formClient();
|
||||
client.setRequestHandler('elicitation/create', async () => {
|
||||
reachedClient++;
|
||||
return { action: 'accept' };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const r = await client.callTool({ name: 'auth', arguments: {} });
|
||||
expect(r.isError).toBeFalsy();
|
||||
expect(r.content).toEqual([{ type: 'text', text: expect.stringMatching(/^refused:.*url elicitation/i) }]);
|
||||
expect(reachedClient).toBe(0);
|
||||
});
|
||||
|
||||
verifies('elicitation:capability:not-declared', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('ask-form', { inputSchema: z.object({}) }, async () => {
|
||||
try {
|
||||
await s.server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'What is your name?',
|
||||
requestedSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }
|
||||
});
|
||||
return { isError: true, content: [{ type: 'text', text: 'SDK let the form elicitation through' }] };
|
||||
} catch (error) {
|
||||
return { content: [{ type: 'text', text: `refused:${error instanceof Error ? error.message : error}` }] };
|
||||
}
|
||||
});
|
||||
s.registerTool('ask-url', { inputSchema: z.object({}) }, async () => {
|
||||
try {
|
||||
await s.server.elicitInput({
|
||||
mode: 'url',
|
||||
message: 'Please sign in',
|
||||
elicitationId: 'no-cap-1',
|
||||
url: 'https://example.com/auth'
|
||||
});
|
||||
return { isError: true, content: [{ type: 'text', text: 'SDK let the url elicitation through' }] };
|
||||
} catch (error) {
|
||||
return { content: [{ type: 'text', text: `refused:${error instanceof Error ? error.message : error}` }] };
|
||||
}
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
// No elicitation capability declared at all — neither mode may reach the wire.
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
const tap = tapWire(client);
|
||||
|
||||
const form = await client.callTool({ name: 'ask-form', arguments: {} });
|
||||
expect(form.isError).toBeFalsy();
|
||||
expect(form.content).toEqual([{ type: 'text', text: 'refused:Client does not support form elicitation.' }]);
|
||||
|
||||
const url = await client.callTool({ name: 'ask-url', arguments: {} });
|
||||
expect(url.isError).toBeFalsy();
|
||||
expect(url.content).toEqual([{ type: 'text', text: 'refused:Client does not support url elicitation.' }]);
|
||||
|
||||
expect(tap.received.filter(m => 'method' in m && m.method === 'elicitation/create')).toEqual([]);
|
||||
});
|
||||
200
test/e2e/scenarios/errors.test.ts
Normal file
200
test/e2e/scenarios/errors.test.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
/**
|
||||
* Error-surface tests: which error class reaches the caller for each failure mode.
|
||||
*
|
||||
* The SDK splits failures into local {@link SdkError}s that never cross the wire
|
||||
* (timeouts, capability gating), wire-level {@link ProtocolError}s carrying the
|
||||
* JSON-RPC error code from an error response, and {@link SdkHttpError}s for HTTP
|
||||
* transport failures exposing the status code. Each test builds its own server
|
||||
* (factory) and client, triggers exactly one failure mode and asserts the class,
|
||||
* code and carried details of the rejection.
|
||||
*/
|
||||
|
||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
import type { JSONRPCMessage } from '@modelcontextprotocol/server';
|
||||
import {
|
||||
isJSONRPCErrorResponse,
|
||||
McpServer,
|
||||
ProtocolError,
|
||||
ProtocolErrorCode,
|
||||
SdkError,
|
||||
SdkErrorCode,
|
||||
SdkHttpError,
|
||||
Server
|
||||
} from '@modelcontextprotocol/server';
|
||||
import { expect } from 'vitest';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { hostPerSession, tapWire, wire } from '../helpers/index.js';
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
import type { TestArgs } from '../types.js';
|
||||
|
||||
const newClient = () => new Client({ name: 'c', version: '0' });
|
||||
|
||||
/** Awaits a promise that must reject and returns the rejection value for exact assertions. */
|
||||
async function rejectionOf(promise: Promise<unknown>): Promise<unknown> {
|
||||
return promise.then(
|
||||
() => {
|
||||
throw new Error('expected the promise to reject');
|
||||
},
|
||||
(error: unknown) => error
|
||||
);
|
||||
}
|
||||
|
||||
verifies('errors:timeout:sdkerror-request-timeout', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool(
|
||||
'generate_report',
|
||||
{ inputSchema: z.object({ source: z.string() }) },
|
||||
async () =>
|
||||
new Promise(() => {
|
||||
/* never resolves: the per-request timeout must fire before any response exists */
|
||||
})
|
||||
);
|
||||
return s;
|
||||
};
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const rejection = await rejectionOf(
|
||||
client.callTool({ name: 'generate_report', arguments: { source: 'sales-q3.csv' } }, { timeout: 100 })
|
||||
);
|
||||
|
||||
expect(rejection).toBeInstanceOf(SdkError);
|
||||
if (!(rejection instanceof SdkError)) throw new Error('rejection is not an SdkError');
|
||||
expect(rejection.code).toBe(SdkErrorCode.RequestTimeout);
|
||||
expect(rejection.message).toMatch(/timed out/i);
|
||||
// The configured timeout value rides on the error, not just a generic message.
|
||||
expect(rejection.data).toEqual({ timeout: 100 });
|
||||
// A timeout is a local SDK error, not a JSON-RPC error response from the server.
|
||||
expect(rejection).not.toBeInstanceOf(ProtocolError);
|
||||
});
|
||||
|
||||
verifies('errors:capability:sdkerror-capability-not-supported', async ({ transport }: TestArgs) => {
|
||||
let server: Server | undefined;
|
||||
const makeServer = () => {
|
||||
// Server-side gating of outbound requests on the client's declared capabilities is opt-in.
|
||||
server = new Server({ name: 's', version: '0' }, { capabilities: {}, enforceStrictCapabilities: true });
|
||||
return server;
|
||||
};
|
||||
// Client declares no sampling capability and registers no sampling handler.
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
if (!server) throw new Error('server not created');
|
||||
const serverTx = server.transport;
|
||||
if (!serverTx) throw new Error('server transport not connected');
|
||||
const serverOutbound: JSONRPCMessage[] = [];
|
||||
const origSend = serverTx.send.bind(serverTx);
|
||||
serverTx.send = async (m, opts) => {
|
||||
serverOutbound.push(m);
|
||||
return origSend(m, opts);
|
||||
};
|
||||
|
||||
const rejection = await rejectionOf(
|
||||
server.createMessage({
|
||||
messages: [{ role: 'user', content: { type: 'text', text: 'Summarize the latest build log.' } }],
|
||||
maxTokens: 100
|
||||
})
|
||||
);
|
||||
|
||||
expect(rejection).toBeInstanceOf(SdkError);
|
||||
// Rejected locally: not a JSON-RPC error that crossed the wire.
|
||||
expect(rejection).not.toBeInstanceOf(ProtocolError);
|
||||
if (!(rejection instanceof SdkError)) throw new Error('rejection is not an SdkError');
|
||||
expect(rejection.code).toBe(SdkErrorCode.CapabilityNotSupported);
|
||||
expect(rejection.message).toMatch(/does not support sampling/i);
|
||||
|
||||
// No sampling/createMessage request was ever handed to the transport.
|
||||
expect(serverOutbound.filter(m => 'method' in m && m.method === 'sampling/createMessage')).toEqual([]);
|
||||
// The session stays healthy after the local rejection.
|
||||
await expect(client.ping()).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
verifies('errors:wire:protocolerror-invalid-params', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool(
|
||||
'get_weather',
|
||||
{ inputSchema: z.object({ latitude: z.number(), longitude: z.number() }) },
|
||||
({ latitude, longitude }) => ({ content: [{ type: 'text', text: `Sunny at ${latitude},${longitude}` }] })
|
||||
);
|
||||
return s;
|
||||
};
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const tap = tapWire(client);
|
||||
|
||||
// Calling a tool the server never registered produces a -32602 error response server-side.
|
||||
const rejection = await rejectionOf(client.callTool({ name: 'get_forecast', arguments: { latitude: 48.1, longitude: 11.6 } }));
|
||||
|
||||
expect(rejection).toBeInstanceOf(ProtocolError);
|
||||
// Wire-level JSON-RPC errors and local SDK errors are distinct hierarchies.
|
||||
expect(rejection).not.toBeInstanceOf(SdkError);
|
||||
if (!(rejection instanceof ProtocolError)) throw new Error('rejection is not a ProtocolError');
|
||||
expect(rejection.code).toBe(ProtocolErrorCode.InvalidParams);
|
||||
expect(rejection.code).toBe(-32_602);
|
||||
expect(rejection.message).toMatch(/tool get_forecast not found/i);
|
||||
|
||||
// The error genuinely arrived as a JSON-RPC error response carrying the same code and message.
|
||||
const errorResponses = tap.received.filter(m => isJSONRPCErrorResponse(m));
|
||||
expect(errorResponses).toHaveLength(1);
|
||||
const wireError = errorResponses[0];
|
||||
if (!wireError) throw new Error('no JSON-RPC error response captured');
|
||||
expect(wireError.error.code).toBe(-32_602);
|
||||
expect(wireError.error.message).toBe(rejection.message);
|
||||
});
|
||||
|
||||
verifies('errors:http:sdkhttperror-status', async (_: TestArgs) => {
|
||||
// HTTP-hosting specific: builds its own StreamableHTTPClientTransport, so the matrix transport arg is unused.
|
||||
|
||||
// An endpoint that is down entirely: the initialize POST gets a plain 500.
|
||||
const downClient = newClient();
|
||||
const downTransport = new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), {
|
||||
fetch: async () => new Response('upstream exploded', { status: 500, statusText: 'Internal Server Error' })
|
||||
});
|
||||
try {
|
||||
const rejection = await rejectionOf(downClient.connect(downTransport));
|
||||
|
||||
expect(rejection).toBeInstanceOf(SdkHttpError);
|
||||
if (!(rejection instanceof SdkHttpError)) throw new Error('rejection is not an SdkHttpError');
|
||||
expect(rejection.status).toBe(500);
|
||||
expect(rejection.statusText).toBe('Internal Server Error');
|
||||
} finally {
|
||||
await downClient.close();
|
||||
}
|
||||
|
||||
// A healthy session whose endpoint then starts answering POSTs with 503.
|
||||
const handle = hostPerSession(() => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({ content: [{ type: 'text', text }] }));
|
||||
return s;
|
||||
});
|
||||
let postFailureStatus: number | undefined;
|
||||
const flakyFetch = async (url: URL | string, init?: RequestInit) => {
|
||||
if (postFailureStatus !== undefined && init?.method === 'POST') {
|
||||
return new Response('Service Unavailable', { status: postFailureStatus, statusText: 'Service Unavailable' });
|
||||
}
|
||||
return handle.handleRequest(new Request(url, init));
|
||||
};
|
||||
const client = newClient();
|
||||
const clientTransport = new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), { fetch: flakyFetch });
|
||||
try {
|
||||
await client.connect(clientTransport);
|
||||
// The session works before the outage.
|
||||
const ok = await client.callTool({ name: 'echo', arguments: { text: 'before outage' } });
|
||||
expect(ok.content).toEqual([{ type: 'text', text: 'before outage' }]);
|
||||
|
||||
postFailureStatus = 503;
|
||||
const rejection = await rejectionOf(client.callTool({ name: 'echo', arguments: { text: 'during outage' } }));
|
||||
|
||||
expect(rejection).toBeInstanceOf(SdkHttpError);
|
||||
if (!(rejection instanceof SdkHttpError)) throw new Error('rejection is not an SdkHttpError');
|
||||
expect(rejection.status).toBe(503);
|
||||
expect(rejection.statusText).toBe('Service Unavailable');
|
||||
} finally {
|
||||
await client.close();
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
864
test/e2e/scenarios/flow.test.ts
Normal file
864
test/e2e/scenarios/flow.test.ts
Normal file
|
|
@ -0,0 +1,864 @@
|
|||
/**
|
||||
* Self-contained test bodies for composite end-to-end flows.
|
||||
*
|
||||
* These are longer journeys that combine multiple SDK features: multi-step
|
||||
* elicitation, OAuth roundtrip, resumption, session management, proxy
|
||||
* forwarding. Each builds whatever in-test pieces it needs (mock AS, minimal
|
||||
* EventStore, secondary Clients, proxy McpServer delegating to an upstream
|
||||
* Client) rather than importing shared fixtures.
|
||||
*/
|
||||
|
||||
import type { OAuthClientProvider } from '@modelcontextprotocol/client';
|
||||
import { Client, StreamableHTTPClientTransport, UnauthorizedError } from '@modelcontextprotocol/client';
|
||||
import type {
|
||||
ElicitRequest,
|
||||
ElicitResult,
|
||||
EventStore,
|
||||
JSONRPCMessage,
|
||||
OAuthClientInformationMixed,
|
||||
OAuthClientMetadata,
|
||||
OAuthTokens,
|
||||
Progress
|
||||
} from '@modelcontextprotocol/server';
|
||||
import { McpServer, ProtocolErrorCode, Server, specTypeSchemas, UrlElicitationRequiredError } from '@modelcontextprotocol/server';
|
||||
import { expect, vi } from 'vitest';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { hostPerSession, hostResumable, wire } from '../helpers/index.js';
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
import type { TestArgs } from '../types.js';
|
||||
|
||||
verifies('flow:elicitation:multi-step-form', async ({ transport }: TestArgs) => {
|
||||
// Server: tool that issues three sequential elicitation/create requests
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('multi-step', { inputSchema: z.object({}) }, async (_args, ctx) => {
|
||||
const step1 = await ctx.mcpReq.send(
|
||||
{
|
||||
method: 'elicitation/create',
|
||||
params: {
|
||||
mode: 'form',
|
||||
message: 'What is your name?',
|
||||
requestedSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }
|
||||
}
|
||||
},
|
||||
specTypeSchemas.ElicitResult
|
||||
);
|
||||
if (step1.action !== 'accept' || typeof step1.content?.name !== 'string') {
|
||||
return { content: [{ type: 'text', text: `aborted at step 1: ${step1.action}` }] };
|
||||
}
|
||||
const name = step1.content.name;
|
||||
|
||||
const step2 = await ctx.mcpReq.send(
|
||||
{
|
||||
method: 'elicitation/create',
|
||||
params: {
|
||||
mode: 'form',
|
||||
message: `Hello ${name}, what is your favorite color?`,
|
||||
requestedSchema: { type: 'object', properties: { color: { type: 'string' } }, required: ['color'] }
|
||||
}
|
||||
},
|
||||
specTypeSchemas.ElicitResult
|
||||
);
|
||||
if (step2.action !== 'accept' || typeof step2.content?.color !== 'string') {
|
||||
return { content: [{ type: 'text', text: `aborted at step 2: ${step2.action}` }] };
|
||||
}
|
||||
const color = step2.content.color;
|
||||
|
||||
const step3 = await ctx.mcpReq.send(
|
||||
{
|
||||
method: 'elicitation/create',
|
||||
params: {
|
||||
mode: 'form',
|
||||
message: `${name}, you picked ${color}. Confirm?`,
|
||||
requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] }
|
||||
}
|
||||
},
|
||||
specTypeSchemas.ElicitResult
|
||||
);
|
||||
if (step3.action !== 'accept') {
|
||||
return { content: [{ type: 'text', text: `aborted at step 3: ${step3.action}` }] };
|
||||
}
|
||||
|
||||
return { content: [{ type: 'text', text: `${name}'s favorite color is ${color}` }] };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
// Client: registers handler for elicitation/create, queues responses
|
||||
const received: ElicitRequest[] = [];
|
||||
const queued: ElicitResult[] = [];
|
||||
const client = new Client({ name: 'c', version: '0' }, { capabilities: { elicitation: { form: {} } } });
|
||||
client.setRequestHandler('elicitation/create', async req => {
|
||||
received.push(req);
|
||||
const resp = queued.shift();
|
||||
if (!resp) throw new Error('no queued elicitation response');
|
||||
return resp;
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
// Happy path: accept all three steps
|
||||
queued.push(
|
||||
{ action: 'accept', content: { name: 'Ada' } },
|
||||
{ action: 'accept', content: { color: 'blue' } },
|
||||
{ action: 'accept', content: { confirm: true } }
|
||||
);
|
||||
|
||||
const ok = await client.callTool({ name: 'multi-step', arguments: {} });
|
||||
expect(ok.isError).toBeFalsy();
|
||||
expect(ok.content).toEqual([{ type: 'text', text: "Ada's favorite color is blue" }]);
|
||||
|
||||
expect(received).toHaveLength(3);
|
||||
const [firstStep, secondStep, thirdStep] = received;
|
||||
if (!firstStep || !secondStep || !thirdStep) throw new Error('expected three elicitation requests');
|
||||
expect(firstStep.params).toMatchObject({ mode: 'form', requestedSchema: { properties: { name: { type: 'string' } } } });
|
||||
expect(secondStep.params.message).toContain('Ada');
|
||||
expect(secondStep.params).toMatchObject({ mode: 'form', requestedSchema: { properties: { color: { type: 'string' } } } });
|
||||
expect(thirdStep.params.message).toContain('Ada');
|
||||
expect(thirdStep.params.message).toContain('blue');
|
||||
expect(thirdStep.params).toMatchObject({ mode: 'form', requestedSchema: { properties: { confirm: { type: 'boolean' } } } });
|
||||
|
||||
// Decline at step 2
|
||||
queued.push({ action: 'accept', content: { name: 'Bob' } }, { action: 'decline' });
|
||||
const declined = await client.callTool({ name: 'multi-step', arguments: {} });
|
||||
expect(declined.isError).toBeFalsy();
|
||||
expect(declined.content).toEqual([{ type: 'text', text: 'aborted at step 2: decline' }]);
|
||||
expect(received).toHaveLength(5);
|
||||
|
||||
// Cancel at step 1
|
||||
queued.push({ action: 'cancel' }, { action: 'accept', content: { color: 'red' } }); // sentinel: must not be consumed
|
||||
const cancelled = await client.callTool({ name: 'multi-step', arguments: {} });
|
||||
expect(cancelled.isError).toBeFalsy();
|
||||
expect(cancelled.content).toEqual([{ type: 'text', text: 'aborted at step 1: cancel' }]);
|
||||
expect(received).toHaveLength(6);
|
||||
expect(queued).toHaveLength(1); // sentinel still queued
|
||||
});
|
||||
|
||||
verifies('flow:elicitation:url-at-session-init', async (_args: TestArgs) => {
|
||||
// Not wire(): a transport.send tap must be installed before connect to prove no client request precedes the unsolicited elicitation.
|
||||
// Server: issues URL-mode elicitation immediately after onsessioninitialized
|
||||
const makeServer = () => {
|
||||
const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } });
|
||||
s.oninitialized = () => {
|
||||
setTimeout(() => {
|
||||
void s
|
||||
.elicitInput({
|
||||
mode: 'url',
|
||||
message: 'Authorize the session',
|
||||
url: 'https://example.com/auth',
|
||||
elicitationId: 'session-init-elicit'
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 0);
|
||||
};
|
||||
s.setRequestHandler('tools/list', () => ({ tools: [] }));
|
||||
return s;
|
||||
};
|
||||
|
||||
const received: ElicitRequest[] = [];
|
||||
const client = new Client({ name: 'c', version: '0' }, { capabilities: { elicitation: { url: {} } } });
|
||||
client.setRequestHandler('elicitation/create', async req => {
|
||||
received.push(req);
|
||||
return { action: 'accept' };
|
||||
});
|
||||
|
||||
const handle = hostPerSession(makeServer);
|
||||
const url = new URL('http://in-process/mcp');
|
||||
const customFetch = (u: URL | string, init?: RequestInit) => handle.handleRequest(new Request(u, init));
|
||||
const transport = new StreamableHTTPClientTransport(url, { fetch: customFetch });
|
||||
|
||||
// Tap wire before connecting
|
||||
const sent: JSONRPCMessage[] = [];
|
||||
const origSend = transport.send.bind(transport);
|
||||
transport.send = async (m, opts) => {
|
||||
if (Array.isArray(m)) {
|
||||
sent.push(...m);
|
||||
} else {
|
||||
sent.push(m);
|
||||
}
|
||||
return origSend(m, opts);
|
||||
};
|
||||
|
||||
await client.connect(transport);
|
||||
|
||||
try {
|
||||
// Wait for the unsolicited server→client elicitation/create
|
||||
await vi.waitFor(() => expect(received.length).toBeGreaterThanOrEqual(1));
|
||||
|
||||
expect(received).toHaveLength(1);
|
||||
const [initElicitation] = received;
|
||||
if (!initElicitation) throw new Error('expected one elicitation request');
|
||||
expect(initElicitation.method).toBe('elicitation/create');
|
||||
const params = initElicitation.params;
|
||||
if (params.mode !== 'url') throw new Error('expected url mode');
|
||||
expect(params.elicitationId).toBe('session-init-elicit');
|
||||
expect(() => new URL(params.url)).not.toThrow();
|
||||
|
||||
// Client has only sent initialize so far (no post-init requests)
|
||||
const requests = sent.filter(m => 'method' in m && 'id' in m);
|
||||
expect(requests.map(r => r.method)).toEqual(['initialize']);
|
||||
|
||||
// Session survived
|
||||
await expect(client.ping()).resolves.toBeDefined();
|
||||
} finally {
|
||||
await Promise.all([client.close(), handle.close()]);
|
||||
}
|
||||
});
|
||||
|
||||
verifies('flow:elicitation:url-required-then-retry', async ({ transport }: TestArgs) => {
|
||||
// Server: tool that throws UrlElicitationRequiredError until elicitation is completed
|
||||
const completed = new Set<string>();
|
||||
let server: McpServer | undefined;
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('url-gated', { inputSchema: z.object({}) }, () => {
|
||||
const elicitationId = 'url-elicit-1';
|
||||
if (!completed.has(elicitationId)) {
|
||||
throw new UrlElicitationRequiredError([
|
||||
{
|
||||
mode: 'url',
|
||||
message: 'Please sign in',
|
||||
elicitationId,
|
||||
url: 'https://example.com/auth'
|
||||
}
|
||||
]);
|
||||
}
|
||||
return { content: [{ type: 'text', text: 'authenticated' }] };
|
||||
});
|
||||
server = s;
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = new Client({ name: 'c', version: '0' }, { capabilities: { elicitation: { url: {} } } });
|
||||
const completionsSeen: string[] = [];
|
||||
client.setNotificationHandler('notifications/elicitation/complete', async n => {
|
||||
completionsSeen.push(n.params.elicitationId);
|
||||
});
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
// Step 1: first call rejects with UrlElicitationRequiredError
|
||||
const err = await client.callTool({ name: 'url-gated', arguments: {} }).catch((error: unknown) => error);
|
||||
expect(err).toBeInstanceOf(UrlElicitationRequiredError);
|
||||
if (!(err instanceof UrlElicitationRequiredError)) throw new Error('expected UrlElicitationRequiredError');
|
||||
expect(err.code).toBe(ProtocolErrorCode.UrlElicitationRequired);
|
||||
expect(err.elicitations).toHaveLength(1);
|
||||
const [elicitation] = err.elicitations;
|
||||
if (!elicitation) throw new Error('expected one required elicitation');
|
||||
expect(elicitation.mode).toBe('url');
|
||||
expect(typeof elicitation.elicitationId).toBe('string');
|
||||
expect(elicitation.url).toMatch(/^https?:\/\//);
|
||||
|
||||
// Step 2: user "opens" the URL (out-of-band, simulated by marking complete)
|
||||
completed.add(elicitation.elicitationId);
|
||||
|
||||
// Step 3: server emits notifications/elicitation/complete and the client receives it for that exact elicitation
|
||||
if (!server) throw new Error('makeServer was never invoked');
|
||||
await server.server.createElicitationCompletionNotifier(elicitation.elicitationId)();
|
||||
await vi.waitFor(() => expect(completionsSeen).toEqual([elicitation.elicitationId]));
|
||||
|
||||
// Step 4: retry succeeds
|
||||
const result = await client.callTool({ name: 'url-gated', arguments: {} });
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'authenticated' }]);
|
||||
});
|
||||
|
||||
verifies('flow:multi-client:stateful-isolation', async (_args: TestArgs) => {
|
||||
// Not wire(): three clients share one host and the test reads each transport's sessionId; wire() binds a single client to its own host.
|
||||
// Server: per-session McpServer with progress tool
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('progress', { inputSchema: z.object({ steps: z.number().int().positive() }) }, async ({ steps }, ctx) => {
|
||||
const token = ctx.mcpReq._meta?.progressToken;
|
||||
if (token !== undefined) {
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
await ctx.mcpReq.notify({
|
||||
method: 'notifications/progress',
|
||||
params: { progressToken: token, progress: i, total: steps, message: `step ${i}/${steps}` }
|
||||
});
|
||||
}
|
||||
}
|
||||
return { content: [{ type: 'text', text: `done after ${steps} steps` }] };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
// Three clients connect to the same per-session host, each with distinct step counts
|
||||
const handle = hostPerSession(makeServer);
|
||||
const url = new URL('http://in-process/mcp');
|
||||
const customFetch = (u: URL | string, init?: RequestInit) => handle.handleRequest(new Request(u, init));
|
||||
|
||||
interface SessionUnderTest {
|
||||
client: Client;
|
||||
transport: StreamableHTTPClientTransport;
|
||||
steps: number;
|
||||
errors: Error[];
|
||||
}
|
||||
const sessions: SessionUnderTest[] = [
|
||||
{ name: 'a', steps: 2 },
|
||||
{ name: 'b', steps: 3 },
|
||||
{ name: 'c', steps: 4 }
|
||||
].map(({ name, steps }) => ({
|
||||
client: new Client({ name, version: '0' }),
|
||||
transport: new StreamableHTTPClientTransport(url, { fetch: customFetch }),
|
||||
steps,
|
||||
errors: []
|
||||
}));
|
||||
|
||||
try {
|
||||
for (const session of sessions) {
|
||||
session.client.onerror = error => session.errors.push(error);
|
||||
await session.client.connect(session.transport);
|
||||
}
|
||||
|
||||
const sessionIds = sessions.map(s => s.transport.sessionId);
|
||||
for (const id of sessionIds) {
|
||||
expect(id).toEqual(expect.any(String));
|
||||
}
|
||||
expect(new Set(sessionIds).size).toBe(3);
|
||||
|
||||
// Concurrent progress calls with distinct step counts
|
||||
const runs = await Promise.all(
|
||||
sessions.map(async ({ client, steps }) => {
|
||||
const received: Progress[] = [];
|
||||
const result = await client.callTool(
|
||||
{ name: 'progress', arguments: { steps } },
|
||||
{ onprogress: p => received.push({ progress: p.progress, total: p.total, message: p.message }) }
|
||||
);
|
||||
return { steps, received, result };
|
||||
})
|
||||
);
|
||||
|
||||
for (const { steps, received, result } of runs) {
|
||||
expect(received).toEqual(
|
||||
Array.from({ length: steps }, (_, k) => ({
|
||||
progress: k + 1,
|
||||
total: steps,
|
||||
message: `step ${k + 1}/${steps}`
|
||||
}))
|
||||
);
|
||||
expect(result.content).toEqual([{ type: 'text', text: `done after ${steps} steps` }]);
|
||||
}
|
||||
|
||||
for (const session of sessions) {
|
||||
expect(session.errors).toEqual([]);
|
||||
}
|
||||
} finally {
|
||||
await Promise.all([...sessions.map(s => s.client.close()), handle.close()]);
|
||||
}
|
||||
});
|
||||
|
||||
verifies('flow:oauth:authorization-code-roundtrip', async (_args: TestArgs) => {
|
||||
// Not wire(): needs an authProvider-equipped client transport plus 401/PRM/AS fetch routing in front of the host, which wire() does not expose.
|
||||
const ACCESS_TOKEN = 'roundtrip-access-token';
|
||||
const AUTH_CODE = 'granted-authorization-code';
|
||||
const AS_ORIGIN = 'https://as.example.com';
|
||||
const PRM_PATH = '/.well-known/oauth-protected-resource';
|
||||
|
||||
// Mock Authorization Server: metadata discovery, dynamic client registration, code exchange.
|
||||
const registerRequests: Array<Record<string, unknown>> = [];
|
||||
const tokenRequests: URLSearchParams[] = [];
|
||||
const mockASFetch = async (url: URL | string, init?: RequestInit): Promise<Response> => {
|
||||
const req = new Request(url, init);
|
||||
const u = new URL(req.url);
|
||||
if (u.pathname === '/.well-known/oauth-authorization-server') {
|
||||
return Response.json(
|
||||
{
|
||||
issuer: AS_ORIGIN,
|
||||
authorization_endpoint: `${AS_ORIGIN}/authorize`,
|
||||
token_endpoint: `${AS_ORIGIN}/token`,
|
||||
registration_endpoint: `${AS_ORIGIN}/register`,
|
||||
grant_types_supported: ['authorization_code'],
|
||||
response_types_supported: ['code'],
|
||||
code_challenge_methods_supported: ['S256']
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
if (u.pathname === '/register' && req.method === 'POST') {
|
||||
const body = z.record(z.string(), z.unknown()).parse(await req.json());
|
||||
registerRequests.push(body);
|
||||
// RFC 7591: the registration response echoes the submitted metadata plus the issued client_id.
|
||||
return Response.json({ ...body, client_id: 'mock-client-id' }, { status: 201 });
|
||||
}
|
||||
if (u.pathname === '/token' && req.method === 'POST') {
|
||||
tokenRequests.push(new URLSearchParams(await req.text()));
|
||||
return Response.json({ access_token: ACCESS_TOKEN, token_type: 'Bearer', expires_in: 3600 }, { status: 200 });
|
||||
}
|
||||
return new Response('Not Found', { status: 404 });
|
||||
};
|
||||
|
||||
// Mock OAuthClientProvider
|
||||
const redirectedTo: string[] = [];
|
||||
const saved: { tokens?: OAuthTokens; clientInfo?: OAuthClientInformationMixed; codeVerifier?: string } = {};
|
||||
const provider: OAuthClientProvider = {
|
||||
get redirectUrl() {
|
||||
return 'http://localhost/callback';
|
||||
},
|
||||
get clientMetadata(): OAuthClientMetadata {
|
||||
return {
|
||||
client_name: 'test-client',
|
||||
client_uri: 'http://localhost',
|
||||
redirect_uris: ['http://localhost/callback']
|
||||
};
|
||||
},
|
||||
clientInformation: () => saved.clientInfo,
|
||||
saveClientInformation: async ci => {
|
||||
saved.clientInfo = ci;
|
||||
},
|
||||
tokens: () => saved.tokens,
|
||||
saveTokens: async t => {
|
||||
saved.tokens = t;
|
||||
},
|
||||
codeVerifier: () => {
|
||||
if (!saved.codeVerifier) throw new Error('No code verifier saved');
|
||||
return saved.codeVerifier;
|
||||
},
|
||||
saveCodeVerifier: async v => {
|
||||
saved.codeVerifier = v;
|
||||
},
|
||||
redirectToAuthorization: async url => {
|
||||
redirectedTo.push(url.toString());
|
||||
}
|
||||
};
|
||||
|
||||
// Server with bearer auth
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({
|
||||
content: [{ type: 'text', text }]
|
||||
}));
|
||||
return s;
|
||||
};
|
||||
|
||||
// Protected host: serves RFC 9728 resource metadata, otherwise requires the issued bearer token.
|
||||
const handle = hostPerSession(makeServer);
|
||||
const url = new URL('http://in-process/mcp');
|
||||
const serverFetch = async (u: URL | string, init?: RequestInit): Promise<Response> => {
|
||||
const req = new Request(u, init);
|
||||
const requestUrl = new URL(req.url);
|
||||
if (requestUrl.pathname.startsWith(PRM_PATH)) {
|
||||
return Response.json({ resource: url.toString(), authorization_servers: [AS_ORIGIN] }, { status: 200 });
|
||||
}
|
||||
const auth = req.headers.get('authorization');
|
||||
if (auth !== `Bearer ${ACCESS_TOKEN}`) {
|
||||
return new Response(null, {
|
||||
status: 401,
|
||||
headers: { 'WWW-Authenticate': `Bearer resource_metadata="http://in-process${PRM_PATH}/mcp"` }
|
||||
});
|
||||
}
|
||||
return handle.handleRequest(req);
|
||||
};
|
||||
|
||||
const combinedFetch = async (u: URL | string, init?: RequestInit): Promise<Response> => {
|
||||
const requestUrl = typeof u === 'string' ? new URL(u) : u;
|
||||
if (requestUrl.hostname === 'as.example.com') return mockASFetch(requestUrl, init);
|
||||
return serverFetch(requestUrl, init);
|
||||
};
|
||||
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
|
||||
try {
|
||||
// Step 1: first connect fails with 401 → discovery + DCR + redirect to the authorization endpoint
|
||||
const transport1 = new StreamableHTTPClientTransport(url, { authProvider: provider, fetch: combinedFetch });
|
||||
await expect(client.connect(transport1)).rejects.toBeInstanceOf(UnauthorizedError);
|
||||
|
||||
expect(redirectedTo).toHaveLength(1);
|
||||
const [authorizationRedirect] = redirectedTo;
|
||||
if (!authorizationRedirect) throw new Error('expected a redirect to the authorization endpoint');
|
||||
const authorizeUrl = new URL(authorizationRedirect);
|
||||
expect(authorizeUrl.origin).toBe(AS_ORIGIN);
|
||||
expect(authorizeUrl.pathname).toBe('/authorize');
|
||||
expect(authorizeUrl.searchParams.get('client_id')).toBe('mock-client-id');
|
||||
expect(authorizeUrl.searchParams.get('code_challenge')).toBeTruthy();
|
||||
expect(registerRequests).toHaveLength(1);
|
||||
expect(saved.tokens).toBeUndefined();
|
||||
|
||||
const codeVerifier = saved.codeVerifier;
|
||||
if (!codeVerifier) throw new Error('No code verifier saved during the redirect step');
|
||||
|
||||
// Step 2: user completes redirect, finishAuth exchanges the code for tokens
|
||||
await transport1.finishAuth(AUTH_CODE);
|
||||
|
||||
expect(tokenRequests).toHaveLength(1);
|
||||
const [tokenRequest] = tokenRequests;
|
||||
if (!tokenRequest) throw new Error('expected one token request');
|
||||
expect(tokenRequest.get('grant_type')).toBe('authorization_code');
|
||||
expect(tokenRequest.get('code')).toBe(AUTH_CODE);
|
||||
expect(tokenRequest.get('code_verifier')).toBe(codeVerifier);
|
||||
expect(tokenRequest.get('redirect_uri')).toBe('http://localhost/callback');
|
||||
expect(saved.tokens?.access_token).toBe(ACCESS_TOKEN);
|
||||
|
||||
// Step 3: second connect with fresh transport succeeds and tools/list works
|
||||
const transport2 = new StreamableHTTPClientTransport(url, { authProvider: provider, fetch: combinedFetch });
|
||||
await client.connect(transport2);
|
||||
|
||||
const { tools } = await client.listTools();
|
||||
expect(tools.some(t => t.name === 'echo')).toBe(true);
|
||||
} finally {
|
||||
await Promise.all([client.close(), handle.close()]);
|
||||
}
|
||||
});
|
||||
|
||||
verifies('flow:resume:tool-call-resumption-token', async (_args: TestArgs) => {
|
||||
// Not wire(): needs an EventStore-backed host and a severable fetch to simulate a mid-stream disconnect, which wire() does not expose.
|
||||
const TOTAL_STEPS = 4;
|
||||
const DELIVERED_BEFORE_DISCONNECT = 2;
|
||||
|
||||
// The tool pauses after the first two progress notifications so the test can sever the call's SSE
|
||||
// stream before the remaining notifications and the result are produced.
|
||||
let releaseRemainingSteps: () => void = () => {
|
||||
throw new Error('remainingStepsReleased resolver not captured yet');
|
||||
};
|
||||
const remainingStepsReleased = new Promise<void>(resolve => {
|
||||
releaseRemainingSteps = resolve;
|
||||
});
|
||||
let toolRuns = 0;
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('import-records', { inputSchema: z.object({ steps: z.number().int().positive() }) }, async ({ steps }, ctx) => {
|
||||
toolRuns++;
|
||||
const token = ctx.mcpReq._meta?.progressToken;
|
||||
if (token === undefined) throw new Error('import-records expects a progressToken');
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
if (i === DELIVERED_BEFORE_DISCONNECT + 1) {
|
||||
await remainingStepsReleased;
|
||||
}
|
||||
await ctx.mcpReq.notify({
|
||||
method: 'notifications/progress',
|
||||
params: { progressToken: token, progress: i, total: steps }
|
||||
});
|
||||
}
|
||||
return { content: [{ type: 'text', text: `imported ${steps} record batches` }] };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
// Insertion-ordered EventStore: replay order (and therefore the exact-sequence assertions below) must not depend on timestamps.
|
||||
const storedEvents: Array<{ eventId: string; streamId: string; message: JSONRPCMessage }> = [];
|
||||
const eventStore: EventStore = {
|
||||
storeEvent: async (streamId, message) => {
|
||||
const eventId = `${streamId}|${storedEvents.length + 1}`;
|
||||
storedEvents.push({ eventId, streamId, message });
|
||||
return eventId;
|
||||
},
|
||||
replayEventsAfter: async (lastEventId, { send }) => {
|
||||
const lastIndex = storedEvents.findIndex(e => e.eventId === lastEventId);
|
||||
const lastEvent = storedEvents[lastIndex];
|
||||
if (!lastEvent) return '';
|
||||
const { streamId } = lastEvent;
|
||||
for (const event of storedEvents.slice(lastIndex + 1)) {
|
||||
if (event.streamId === streamId) await send(event.eventId, event.message);
|
||||
}
|
||||
return streamId;
|
||||
}
|
||||
};
|
||||
|
||||
const handle = hostResumable(makeServer, { eventStore, retryInterval: 0 });
|
||||
const url = new URL('http://in-process/mcp');
|
||||
|
||||
const requests: Array<{ method: string; lastEventId: string | undefined; sessionId: string | undefined; body: string | undefined }> =
|
||||
[];
|
||||
|
||||
// The tools/call response is wrapped so the test can sever the stream the client is reading (a simulated
|
||||
// network drop); the server keeps writing the rest of the stream, which only the EventStore retains.
|
||||
let severToolCallStream: () => void = () => {
|
||||
throw new Error('tools/call SSE stream not opened yet');
|
||||
};
|
||||
let originalStreamFinished: () => void = () => {
|
||||
throw new Error('originalStreamComplete resolver not captured yet');
|
||||
};
|
||||
const originalStreamComplete = new Promise<void>(resolve => {
|
||||
originalStreamFinished = resolve;
|
||||
});
|
||||
|
||||
const customFetch = async (u: URL | string, init?: RequestInit): Promise<Response> => {
|
||||
const request = new Request(u, init);
|
||||
const lastEventId = request.headers.get('last-event-id') ?? undefined;
|
||||
const body = typeof init?.body === 'string' ? init.body : undefined;
|
||||
requests.push({ method: request.method, lastEventId, sessionId: request.headers.get('mcp-session-id') ?? undefined, body });
|
||||
|
||||
// Hold the transport's recovery GET until the server has finished the interrupted stream, so the
|
||||
// replay deterministically contains every message the client missed.
|
||||
if (request.method === 'GET' && lastEventId !== undefined) {
|
||||
await originalStreamComplete;
|
||||
}
|
||||
|
||||
const response = await handle.handleRequest(request);
|
||||
if (request.method !== 'POST' || !body?.includes('"tools/call"') || !response.body) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const upstream = response.body.getReader();
|
||||
let severed = false;
|
||||
const severable = new ReadableStream<Uint8Array>({
|
||||
start: controller => {
|
||||
severToolCallStream = () => {
|
||||
severed = true;
|
||||
controller.error(new Error('simulated mid-stream network drop'));
|
||||
};
|
||||
void (async () => {
|
||||
while (true) {
|
||||
const { value, done } = await upstream.read();
|
||||
if (done) break;
|
||||
if (!severed && value) controller.enqueue(value);
|
||||
}
|
||||
originalStreamFinished();
|
||||
if (!severed) controller.close();
|
||||
})();
|
||||
}
|
||||
});
|
||||
return new Response(severable, { status: response.status, headers: response.headers });
|
||||
};
|
||||
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
const transport = new StreamableHTTPClientTransport(url, {
|
||||
fetch: customFetch,
|
||||
reconnectionOptions: { initialReconnectionDelay: 10, maxReconnectionDelay: 10, reconnectionDelayGrowFactor: 1, maxRetries: 2 }
|
||||
});
|
||||
await client.connect(transport);
|
||||
|
||||
try {
|
||||
const progressSeen: number[] = [];
|
||||
const eventIdsSeen: string[] = [];
|
||||
const callSettled = vi.fn();
|
||||
|
||||
// The one and only tools/call of this test: this same promise must resolve after the disconnect.
|
||||
const call = client.callTool(
|
||||
{ name: 'import-records', arguments: { steps: TOTAL_STEPS } },
|
||||
{
|
||||
onprogress: (p: Progress) => progressSeen.push(p.progress),
|
||||
onresumptiontoken: id => eventIdsSeen.push(id)
|
||||
}
|
||||
);
|
||||
call.then(callSettled, callSettled);
|
||||
|
||||
await vi.waitFor(() => expect(progressSeen).toEqual([1, 2]));
|
||||
const lastEventIdBeforeDisconnect = eventIdsSeen.at(-1);
|
||||
|
||||
// Sever mid-call, then let the server finish steps 3..4 and the result while the client is disconnected.
|
||||
severToolCallStream();
|
||||
expect(callSettled).not.toHaveBeenCalled();
|
||||
releaseRemainingSteps();
|
||||
await originalStreamComplete;
|
||||
expect(progressSeen).toEqual([1, 2]);
|
||||
expect(callSettled).not.toHaveBeenCalled();
|
||||
|
||||
// The original promise resolves with the tool's result; the handler ran exactly once.
|
||||
const result = await call;
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(result.content).toEqual([{ type: 'text', text: `imported ${TOTAL_STEPS} record batches` }]);
|
||||
expect(toolRuns).toBe(1);
|
||||
|
||||
// Recovery delivered only the missed notifications: no duplicates, none missing.
|
||||
expect(progressSeen).toEqual([1, 2, 3, 4]);
|
||||
|
||||
const lastEventBeforeDisconnect = storedEvents.find(e => e.eventId === lastEventIdBeforeDisconnect);
|
||||
if (!lastEventBeforeDisconnect) throw new Error('the last event id delivered before the disconnect was never stored');
|
||||
const toolCallStreamEvents = storedEvents.filter(e => e.streamId === lastEventBeforeDisconnect.streamId);
|
||||
|
||||
// The whole stream was persisted (priming placeholder, four progress notifications, result), and the
|
||||
// client saw each of its events exactly once, in order, across the original stream and the replay.
|
||||
expect(
|
||||
toolCallStreamEvents.map(e => ('method' in e.message ? e.message.method : 'result' in e.message ? 'response' : 'priming'))
|
||||
).toEqual([
|
||||
'priming',
|
||||
'notifications/progress',
|
||||
'notifications/progress',
|
||||
'notifications/progress',
|
||||
'notifications/progress',
|
||||
'response'
|
||||
]);
|
||||
expect(eventIdsSeen).toEqual(toolCallStreamEvents.map(e => e.eventId));
|
||||
// Index DELIVERED_BEFORE_DISCONNECT skips the priming event, landing on the second progress notification.
|
||||
const expectedLastDeliveredEvent = toolCallStreamEvents[DELIVERED_BEFORE_DISCONNECT];
|
||||
if (!expectedLastDeliveredEvent) throw new Error('the tool-call stream did not retain the second progress notification');
|
||||
expect(lastEventIdBeforeDisconnect).toBe(expectedLastDeliveredEvent.eventId);
|
||||
|
||||
// Transparency: the caller never re-issued the call; the transport recovered with a single GET
|
||||
// carrying Last-Event-ID for the last event delivered before the disconnect, on the same session.
|
||||
expect(requests.filter(r => r.method === 'POST' && r.body?.includes('"tools/call"'))).toHaveLength(1);
|
||||
const recoveryGets = requests.filter(r => r.method === 'GET' && r.lastEventId !== undefined);
|
||||
expect(recoveryGets).toHaveLength(1);
|
||||
const [recoveryGet] = recoveryGets;
|
||||
if (!recoveryGet) throw new Error('expected one recovery GET');
|
||||
expect(recoveryGet.lastEventId).toBe(lastEventIdBeforeDisconnect);
|
||||
expect(transport.sessionId).toEqual(expect.any(String));
|
||||
expect(recoveryGet.sessionId).toBe(transport.sessionId);
|
||||
} finally {
|
||||
await Promise.all([client.close(), handle.close()]);
|
||||
}
|
||||
});
|
||||
|
||||
verifies('flow:session:terminate-then-reconnect', async (_args: TestArgs) => {
|
||||
// Not wire(): drives StreamableHTTPClientTransport directly for sessionId/terminateSession() and connects a second transport to the same host.
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({
|
||||
content: [{ type: 'text', text }]
|
||||
}));
|
||||
return s;
|
||||
};
|
||||
|
||||
const handle = hostPerSession(makeServer);
|
||||
const url = new URL('http://in-process/mcp');
|
||||
const customFetch = (u: URL | string, init?: RequestInit) => handle.handleRequest(new Request(u, init));
|
||||
|
||||
const client1 = new Client({ name: 'c', version: '0' });
|
||||
const transport1 = new StreamableHTTPClientTransport(url, { fetch: customFetch });
|
||||
await client1.connect(transport1);
|
||||
|
||||
try {
|
||||
const originalSessionId = transport1.sessionId;
|
||||
expect(originalSessionId).toEqual(expect.any(String));
|
||||
|
||||
// Terminate session
|
||||
await transport1.terminateSession();
|
||||
expect(transport1.sessionId).toBeUndefined();
|
||||
|
||||
// Close first client
|
||||
await client1.close();
|
||||
|
||||
// Fresh client and transport obtain new session
|
||||
const client2 = new Client({ name: 'c', version: '0' });
|
||||
const transport2 = new StreamableHTTPClientTransport(url, { fetch: customFetch });
|
||||
await client2.connect(transport2);
|
||||
|
||||
const newSessionId = transport2.sessionId;
|
||||
expect(newSessionId).toEqual(expect.any(String));
|
||||
expect(newSessionId).not.toBe(originalSessionId);
|
||||
|
||||
// Operations succeed on new session
|
||||
const result = await client2.callTool({ name: 'echo', arguments: { text: 'after-reconnect' } });
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'after-reconnect' }]);
|
||||
|
||||
await client2.close();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('flow:tool-result:resource-link-follow', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('resource-link', { inputSchema: z.object({}) }, () => ({
|
||||
content: [
|
||||
{
|
||||
type: 'resource_link',
|
||||
uri: 'file:///linked.txt',
|
||||
name: 'linked.txt',
|
||||
mimeType: 'text/plain'
|
||||
}
|
||||
]
|
||||
}));
|
||||
s.registerResource('linked.txt', 'file:///linked.txt', { mimeType: 'text/plain' }, async () => ({
|
||||
contents: [{ uri: 'file:///linked.txt', mimeType: 'text/plain', text: 'linked resource contents' }]
|
||||
}));
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
// Call tool and get resource_link
|
||||
const toolResult = await client.callTool({ name: 'resource-link', arguments: {} });
|
||||
expect(toolResult.isError).toBeFalsy();
|
||||
|
||||
const link = toolResult.content.find(c => c.type === 'resource_link');
|
||||
expect(link).toBeDefined();
|
||||
if (link?.type !== 'resource_link') throw new Error('unreachable');
|
||||
expect(link.uri).toBe('file:///linked.txt');
|
||||
|
||||
// Follow the link with resources/read
|
||||
const readResult = await client.readResource({ uri: link.uri });
|
||||
expect(readResult.contents).toHaveLength(1);
|
||||
const [entry] = readResult.contents;
|
||||
if (!entry) throw new Error('expected one resource contents entry');
|
||||
expect(entry.uri).toBe(link.uri);
|
||||
expect(entry.mimeType).toBe('text/plain');
|
||||
if (entry.mimeType?.startsWith('text/')) {
|
||||
expect('text' in entry ? entry.text : '').toBe('linked resource contents');
|
||||
}
|
||||
});
|
||||
|
||||
verifies('flow:proxy:forward-tools-resources', async ({ transport }: TestArgs) => {
|
||||
// Upstream server with tools and resources
|
||||
const upstreamServer = new McpServer({ name: 'upstream', version: '0' });
|
||||
upstreamServer.registerTool('echo', { description: 'Echoes text', inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({
|
||||
content: [{ type: 'text', text }]
|
||||
}));
|
||||
upstreamServer.registerTool(
|
||||
'annotated',
|
||||
{
|
||||
description: 'Annotated tool',
|
||||
inputSchema: z.object({}),
|
||||
annotations: { readOnlyHint: true, idempotentHint: true },
|
||||
_meta: { 'example.com/fixture': true }
|
||||
},
|
||||
() => ({ content: [] })
|
||||
);
|
||||
upstreamServer.registerResource(
|
||||
'annotated',
|
||||
'file:///annotated.md',
|
||||
{
|
||||
mimeType: 'text/markdown',
|
||||
_meta: { 'example.com/fixture': true }
|
||||
},
|
||||
async () => ({
|
||||
contents: [{ uri: 'file:///annotated.md', mimeType: 'text/markdown', text: '# Annotated' }]
|
||||
})
|
||||
);
|
||||
|
||||
// Proxy: low-level Server downstream + Client upstream
|
||||
const upstreamClient = new Client({ name: 'proxy-upstream', version: '0' });
|
||||
const proxyServer = new Server({ name: 'proxy', version: '0' }, { capabilities: { tools: {}, resources: {} } });
|
||||
|
||||
proxyServer.setRequestHandler('tools/list', async req => {
|
||||
const result = await upstreamClient.listTools(req.params);
|
||||
return { tools: result.tools, nextCursor: result.nextCursor };
|
||||
});
|
||||
proxyServer.setRequestHandler('resources/list', async req => {
|
||||
const result = await upstreamClient.listResources(req.params);
|
||||
return { resources: result.resources, nextCursor: result.nextCursor };
|
||||
});
|
||||
proxyServer.setRequestHandler('resources/read', async req => {
|
||||
return upstreamClient.readResource(req.params);
|
||||
});
|
||||
|
||||
// Wire: downstream client → proxy server → upstream client → upstream server
|
||||
const downstreamClient = new Client({ name: 'downstream', version: '0' });
|
||||
|
||||
// Two wires must stay open for the whole body; explicit dispose in finally instead of two `await using` bindings.
|
||||
const upstreamWired = await wire(transport, () => upstreamServer, upstreamClient);
|
||||
try {
|
||||
const proxyWired = await wire(transport, () => proxyServer, downstreamClient);
|
||||
try {
|
||||
// Downstream sees upstream tools
|
||||
const { tools } = await downstreamClient.listTools();
|
||||
const echo = tools.find(t => t.name === 'echo');
|
||||
expect(echo).toBeDefined();
|
||||
if (!echo) throw new Error('unreachable');
|
||||
expect(echo.description).toBe('Echoes text');
|
||||
expect(echo.inputSchema.properties).toMatchObject({ text: { type: 'string' } });
|
||||
|
||||
const annotatedTool = tools.find(t => t.name === 'annotated');
|
||||
expect(annotatedTool).toBeDefined();
|
||||
if (!annotatedTool) throw new Error('unreachable');
|
||||
expect(annotatedTool.annotations).toMatchObject({ readOnlyHint: true, idempotentHint: true });
|
||||
expect(annotatedTool._meta).toEqual({ 'example.com/fixture': true });
|
||||
|
||||
// Downstream sees upstream resources
|
||||
const { resources } = await downstreamClient.listResources();
|
||||
const annotatedRes = resources.find(r => r.uri === 'file:///annotated.md');
|
||||
expect(annotatedRes).toBeDefined();
|
||||
if (!annotatedRes) throw new Error('unreachable');
|
||||
expect(annotatedRes.name).toBe('annotated');
|
||||
expect(annotatedRes._meta).toEqual({ 'example.com/fixture': true });
|
||||
} finally {
|
||||
await proxyWired[Symbol.asyncDispose]();
|
||||
}
|
||||
} finally {
|
||||
await upstreamWired[Symbol.asyncDispose]();
|
||||
}
|
||||
});
|
||||
167
test/e2e/scenarios/handler-context.test.ts
Normal file
167
test/e2e/scenarios/handler-context.test.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
/**
|
||||
* Self-contained test bodies for the ServerContext conveniences handed to
|
||||
* request handlers: `ctx.mcpReq.log()`, `ctx.mcpReq.elicitInput()`,
|
||||
* `ctx.mcpReq.requestSampling()`, and — under HTTP hosting — `ctx.http.req`
|
||||
* exposing the incoming request's Fetch Headers.
|
||||
*
|
||||
* Each body builds its own server (via factory) and client, wires them with
|
||||
* {@link wire} (or hosts directly with {@link hostPerSession} where the HTTP
|
||||
* hosting layer is itself the subject), and asserts.
|
||||
*/
|
||||
|
||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
import type { CreateMessageRequest, ElicitRequest, ElicitRequestFormParams, LoggingLevel } from '@modelcontextprotocol/server';
|
||||
import { McpServer } from '@modelcontextprotocol/server';
|
||||
import { expect, vi } from 'vitest';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { hostPerSession, wire } from '../helpers/index.js';
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
import type { TestArgs } from '../types.js';
|
||||
|
||||
verifies('mcpserver:context:log-from-handler', async ({ transport }: TestArgs) => {
|
||||
let releaseHandler!: () => void;
|
||||
const handlerGate = new Promise<void>(resolve => {
|
||||
releaseHandler = resolve;
|
||||
});
|
||||
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' }, { capabilities: { logging: {} } });
|
||||
s.registerTool('emit-log', { inputSchema: z.object({}) }, async (_args, ctx) => {
|
||||
await ctx.mcpReq.log('info', { msg: 'from-handler' }, 'handler-logger');
|
||||
// Hold the tool call open until the test has observed the notification, so receipt provably happens mid-call.
|
||||
await handlerGate;
|
||||
return { content: [{ type: 'text', text: 'done' }] };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const logs: Array<{ level: LoggingLevel; logger?: string; data: unknown }> = [];
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
client.setNotificationHandler('notifications/message', n => {
|
||||
logs.push(n.params);
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const inFlightCall = client.callTool({ name: 'emit-log', arguments: {} });
|
||||
try {
|
||||
// The handler is parked on the gate, so the tools/call request is still in flight when the log arrives.
|
||||
await vi.waitFor(() => expect(logs).toHaveLength(1));
|
||||
expect(logs).toEqual([{ level: 'info', logger: 'handler-logger', data: { msg: 'from-handler' } }]);
|
||||
} finally {
|
||||
releaseHandler();
|
||||
}
|
||||
|
||||
const result = await inFlightCall;
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'done' }]);
|
||||
});
|
||||
|
||||
verifies('mcpserver:context:elicit-from-handler', async ({ transport }: TestArgs) => {
|
||||
const requestedSchema: ElicitRequestFormParams['requestedSchema'] = {
|
||||
type: 'object',
|
||||
properties: { color: { type: 'string' } },
|
||||
required: ['color']
|
||||
};
|
||||
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('ask-color', { inputSchema: z.object({}) }, async (_args, ctx) => {
|
||||
const ans = await ctx.mcpReq.elicitInput({ mode: 'form', message: 'Favorite color?', requestedSchema });
|
||||
const color = ans.action === 'accept' ? String(ans.content?.color) : '<none>';
|
||||
return { content: [{ type: 'text', text: `${ans.action}:${color}` }] };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const received: ElicitRequest['params'][] = [];
|
||||
const client = new Client({ name: 'c', version: '0' }, { capabilities: { elicitation: { form: {} } } });
|
||||
client.setRequestHandler('elicitation/create', async req => {
|
||||
received.push(req.params);
|
||||
return { action: 'accept', content: { color: 'teal' } };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const result = await client.callTool({ name: 'ask-color', arguments: {} });
|
||||
|
||||
expect(received).toEqual([{ mode: 'form', message: 'Favorite color?', requestedSchema }]);
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'accept:teal' }]);
|
||||
});
|
||||
|
||||
verifies('mcpserver:context:sampling-from-handler', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('summarize', { inputSchema: z.object({ topic: z.string() }) }, async ({ topic }, ctx) => {
|
||||
const result = await ctx.mcpReq.requestSampling({
|
||||
messages: [{ role: 'user', content: { type: 'text', text: `Summarize ${topic}` } }],
|
||||
maxTokens: 50
|
||||
});
|
||||
// Without tools in the request the stub client returns a single text block; arrays would mean a tool-use flow.
|
||||
const text = !Array.isArray(result.content) && result.content.type === 'text' ? result.content.text : '<unexpected>';
|
||||
return { content: [{ type: 'text', text: `${result.model}|${result.role}|${text}` }] };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const received: CreateMessageRequest[] = [];
|
||||
const client = new Client({ name: 'c', version: '0' }, { capabilities: { sampling: {} } });
|
||||
client.setRequestHandler('sampling/createMessage', async req => {
|
||||
received.push(req);
|
||||
return { model: 'stub-model', role: 'assistant', stopReason: 'endTurn', content: { type: 'text', text: 'a short summary' } };
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const result = await client.callTool({ name: 'summarize', arguments: { topic: 'mcp' } });
|
||||
|
||||
expect(received).toHaveLength(1);
|
||||
const samplingRequest = received[0];
|
||||
if (samplingRequest === undefined) throw new Error('expected exactly one sampling request');
|
||||
expect(samplingRequest.method).toBe('sampling/createMessage');
|
||||
expect(samplingRequest.params.messages).toEqual([{ role: 'user', content: { type: 'text', text: 'Summarize mcp' } }]);
|
||||
expect(samplingRequest.params.maxTokens).toBe(50);
|
||||
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'stub-model|assistant|a short summary' }]);
|
||||
});
|
||||
|
||||
verifies('hosting:context:web-request-headers', async (_args: TestArgs) => {
|
||||
const PROBE_HEADER = 'x-e2e-probe';
|
||||
const PROBE_VALUE = 'probe-7d1f';
|
||||
|
||||
const seenByTool: Array<{ isFetchHeaders: boolean; probe: string | null }> = [];
|
||||
const mcpHost = hostPerSession(() => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('read-probe-header', { inputSchema: z.object({}) }, (_toolArgs, ctx) => {
|
||||
const headers = ctx.http?.req?.headers;
|
||||
seenByTool.push({
|
||||
isFetchHeaders: headers instanceof Headers,
|
||||
probe: headers instanceof Headers ? headers.get(PROBE_HEADER) : null
|
||||
});
|
||||
return { content: [{ type: 'text', text: headers?.get(PROBE_HEADER) ?? '<missing>' }] };
|
||||
});
|
||||
return s;
|
||||
});
|
||||
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
const httpTransport = new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), {
|
||||
fetch: (url, init) => mcpHost.handleRequest(new Request(url, init)),
|
||||
requestInit: { headers: { [PROBE_HEADER]: PROBE_VALUE } }
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect(httpTransport);
|
||||
const result = await client.callTool({ name: 'read-probe-header', arguments: {} });
|
||||
|
||||
// The custom header set on the client transport is readable as Fetch Headers inside the handler.
|
||||
expect(seenByTool).toEqual([{ isFetchHeaders: true, probe: PROBE_VALUE }]);
|
||||
expect(result.isError).toBeFalsy();
|
||||
expect(result.content).toEqual([{ type: 'text', text: PROBE_VALUE }]);
|
||||
} finally {
|
||||
await client.close();
|
||||
await mcpHost.close();
|
||||
}
|
||||
});
|
||||
142
test/e2e/scenarios/hosting-auth.test.ts
Normal file
142
test/e2e/scenarios/hosting-auth.test.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
/**
|
||||
* Self-contained test bodies for server-side bearer auth on StreamableHTTP
|
||||
* hosting.
|
||||
*
|
||||
* The SDK ships its bearer-auth enforcement (`requireBearerAuth`), AS router
|
||||
* (`mcpAuthRouter`) and discovery-metadata router (`mcpAuthMetadataRouter`)
|
||||
* as Express `RequestHandler`s only — they consume Express `req`/`res`, so
|
||||
* they cannot be driven in-process with Web-standard Request/Response the way
|
||||
* this suite hosts StreamableHTTP. The requirements those components alone
|
||||
* satisfy (401/403 challenges, audience validation, AS endpoint mounting,
|
||||
* `.well-known` publication) are therefore not covered here: re-implementing
|
||||
* that behavior in a test wrapper and asserting on the wrapper would prove
|
||||
* nothing about the SDK. What this file does cover is the hosting-integration
|
||||
* path the Web-standard transport supports natively: a user-shaped bearer
|
||||
* gate verifies the Authorization header, then delegates to
|
||||
* `WebStandardStreamableHTTPServerTransport.handleRequest(req, { authInfo })`,
|
||||
* and the SDK must surface that verified AuthInfo to tool handlers as
|
||||
* `extra.authInfo`.
|
||||
*
|
||||
* Function names mirror the requirement id in camelCase. Bodies are
|
||||
* streamableHttp-only and host the transport themselves (the gate has to sit
|
||||
* between the client's fetch and the transport), so `TestArgs.transport` is
|
||||
* ignored.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
import type { AuthInfo } from '@modelcontextprotocol/server';
|
||||
import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server';
|
||||
import { expect } from 'vitest';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
import type { TestArgs } from '../types.js';
|
||||
|
||||
const VALID_TOKEN = 'analytics-dashboard-access-token';
|
||||
|
||||
verifies('hosting:auth:authinfo-propagates', async (_args: TestArgs) => {
|
||||
const expiresAt = Math.floor(Date.now() / 1000) + 3600;
|
||||
// What the user's verifier derives from VALID_TOKEN; a fresh object is built
|
||||
// per request so the handler-side assertion checks delivery, not identity.
|
||||
const verifyBearer = (header: string | null): AuthInfo | undefined =>
|
||||
header === `Bearer ${VALID_TOKEN}`
|
||||
? {
|
||||
token: VALID_TOKEN,
|
||||
clientId: 'analytics-dashboard',
|
||||
scopes: ['mcp:tools:read', 'mcp:tools:call'],
|
||||
expiresAt,
|
||||
extra: { userId: 'user-42' }
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Recorders live outside the per-session factory.
|
||||
const seenByTool: Array<AuthInfo | undefined> = [];
|
||||
const postAuthHeaders: Array<string | null> = [];
|
||||
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool(
|
||||
'whoami',
|
||||
{ description: 'Reports the authenticated caller derived from extra.authInfo.', inputSchema: z.object({}) },
|
||||
(_a, ctx) => {
|
||||
seenByTool.push(ctx.http?.authInfo);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: ctx.http?.authInfo
|
||||
? `${ctx.http?.authInfo.clientId} [${ctx.http?.authInfo.scopes.join(' ')}]`
|
||||
: 'no-auth-info'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
);
|
||||
return s;
|
||||
};
|
||||
|
||||
// User-shaped hosting: a bearer gate verifies the Authorization header and
|
||||
// hands the verified AuthInfo to the SDK transport via handleRequest options.
|
||||
const sessions = new Map<string, WebStandardStreamableHTTPServerTransport>();
|
||||
const handleRequest = async (req: Request): Promise<Response> => {
|
||||
if (req.method === 'POST') postAuthHeaders.push(req.headers.get('authorization'));
|
||||
const authInfo = verifyBearer(req.headers.get('authorization'));
|
||||
if (!authInfo) {
|
||||
return Response.json(
|
||||
{ error: 'invalid_token' },
|
||||
{
|
||||
status: 401,
|
||||
headers: { 'content-type': 'application/json', 'www-authenticate': 'Bearer error="invalid_token"' }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const sid = req.headers.get('mcp-session-id') ?? undefined;
|
||||
const existing = sid ? sessions.get(sid) : undefined;
|
||||
if (existing) return existing.handleRequest(req, { authInfo });
|
||||
|
||||
const tx = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: randomUUID,
|
||||
onsessioninitialized: id => void sessions.set(id, tx),
|
||||
onsessionclosed: id => void sessions.delete(id)
|
||||
});
|
||||
await makeServer().connect(tx);
|
||||
return tx.handleRequest(req, { authInfo });
|
||||
};
|
||||
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
await using _ = {
|
||||
[Symbol.asyncDispose]: async () => {
|
||||
await client.close();
|
||||
for (const t of sessions.values()) await t.close();
|
||||
}
|
||||
};
|
||||
|
||||
await client.connect(
|
||||
new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), {
|
||||
fetch: (url, init) => handleRequest(new Request(url, init)),
|
||||
requestInit: { headers: { Authorization: `Bearer ${VALID_TOKEN}` } }
|
||||
})
|
||||
);
|
||||
|
||||
const r = await client.callTool({ name: 'whoami', arguments: {} });
|
||||
expect(r.isError).toBeFalsy();
|
||||
expect(r.content).toEqual([{ type: 'text', text: 'analytics-dashboard [mcp:tools:read mcp:tools:call]' }]);
|
||||
|
||||
// The SDK transport delivered the verified AuthInfo into the tool handler's
|
||||
// RequestHandlerExtra unchanged — not dropped, not replaced by a placeholder.
|
||||
expect(seenByTool).toHaveLength(1);
|
||||
expect(seenByTool[0]).toEqual({
|
||||
token: VALID_TOKEN,
|
||||
clientId: 'analytics-dashboard',
|
||||
scopes: ['mcp:tools:read', 'mcp:tools:call'],
|
||||
expiresAt,
|
||||
extra: { userId: 'user-42' }
|
||||
});
|
||||
|
||||
// Sanity: the bearer was on the wire for every POST the SDK client sent
|
||||
// (initialize, notifications/initialized, tools/call).
|
||||
expect(postAuthHeaders).toEqual([`Bearer ${VALID_TOKEN}`, `Bearer ${VALID_TOKEN}`, `Bearer ${VALID_TOKEN}`]);
|
||||
});
|
||||
491
test/e2e/scenarios/hosting-express.test.ts
Normal file
491
test/e2e/scenarios/hosting-express.test.ts
Normal file
|
|
@ -0,0 +1,491 @@
|
|||
/**
|
||||
* Self-contained test bodies for Express-bound hosting surfaces.
|
||||
*
|
||||
* These tests cover the SDK's Express-bound surface (bearer-token middleware,
|
||||
* OAuth metadata router, host validation, createMcpExpressApp) over real HTTP —
|
||||
* the layer a server operator deploys and remote clients depend on; Client/Server
|
||||
* are not the subject.
|
||||
*
|
||||
* The SDK's requireBearerAuth, mcpAuthMetadataRouter, and host-header validation
|
||||
* middleware are Express RequestHandlers; they cannot be exercised with
|
||||
* in-process Web-standard Request/Response. These tests build real Express apps,
|
||||
* listen on ephemeral ports (127.0.0.1), drive them with fetch(), and assert
|
||||
* exact HTTP status + header + body shapes.
|
||||
*
|
||||
* Function names mirror the requirement id in camelCase. NO casts, exact
|
||||
* assertions, closure recorders outside factories (for stateless compat),
|
||||
* minimal comments, every server closed in finally.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import http from 'node:http';
|
||||
|
||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
import { createMcpExpressApp, mcpAuthMetadataRouter, requireBearerAuth } from '@modelcontextprotocol/express';
|
||||
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
|
||||
import type { OAuthMetadata } from '@modelcontextprotocol/server';
|
||||
import { McpServer, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
|
||||
import type { Express, RequestHandler } from 'express';
|
||||
import express from 'express';
|
||||
import { expect } from 'vitest';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { startExpressMinimal, startExpressWithHostValidation } from '../helpers/express.js';
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
import type { TestArgs } from '../types.js';
|
||||
|
||||
const RESOURCE_METADATA_URL = 'https://mcp.example.com/.well-known/oauth-protected-resource';
|
||||
const VALID_TOKEN = 'analytics-dashboard-token';
|
||||
const EXPIRED_TOKEN = 'expired-access-token';
|
||||
const MALFORMED_TOKEN = 'not-a-valid-jwt';
|
||||
|
||||
/**
|
||||
* POST `body` to `url` via `node:http`, forcing `Host: <host>`.
|
||||
* Unlike undici fetch(), node:http sends caller-supplied Host header verbatim.
|
||||
*/
|
||||
function postWithHost(url: URL, host: string, body: string): Promise<{ status: number; body: string; headers: http.IncomingHttpHeaders }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Host: host,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
'Content-Length': Buffer.byteLength(body)
|
||||
}
|
||||
},
|
||||
res => {
|
||||
let data = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', chunk => (data += chunk));
|
||||
res.on('end', () => resolve({ status: res.statusCode ?? 0, body: data, headers: res.headers }));
|
||||
res.on('error', reject);
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
verifies('hosting:auth:missing-401', async (_args: TestArgs) => {
|
||||
const verifier = { verifyAccessToken: async (_token: string) => ({ token: '', clientId: 'test', scopes: [], expiresAt: 1e12 }) };
|
||||
|
||||
await using host = await startExpressMinimal(requireBearerAuth({ verifier, resourceMetadataUrl: RESOURCE_METADATA_URL }));
|
||||
|
||||
const res = await fetch(new URL('/mcp', host.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
|
||||
const wwwAuth = res.headers.get('www-authenticate');
|
||||
expect(wwwAuth).toBeTruthy();
|
||||
expect(wwwAuth).toMatch(/^Bearer\b/i);
|
||||
expect(wwwAuth).toContain('error="invalid_token"');
|
||||
expect(wwwAuth).toContain(`resource_metadata="${RESOURCE_METADATA_URL}"`);
|
||||
|
||||
const body = (await res.json()) as { error?: string };
|
||||
expect(body.error).toBe('invalid_token');
|
||||
});
|
||||
|
||||
verifies('hosting:auth:invalid-401', async (_args: TestArgs) => {
|
||||
const verifier = {
|
||||
verifyAccessToken: async (token: string) => {
|
||||
if (token === MALFORMED_TOKEN) throw new OAuthError(OAuthErrorCode.InvalidToken, 'Token verification failed');
|
||||
return { token, clientId: 'test', scopes: [], expiresAt: 1e12 };
|
||||
}
|
||||
};
|
||||
|
||||
await using host = await startExpressMinimal(requireBearerAuth({ verifier }));
|
||||
|
||||
const res = await fetch(new URL('/mcp', host.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${MALFORMED_TOKEN}`
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
|
||||
const wwwAuth = res.headers.get('www-authenticate');
|
||||
expect(wwwAuth).toBeTruthy();
|
||||
expect(wwwAuth).toMatch(/^Bearer\b/i);
|
||||
expect(wwwAuth).toContain('error="invalid_token"');
|
||||
});
|
||||
|
||||
verifies('hosting:auth:expired-401', async (_args: TestArgs) => {
|
||||
const PAST_EXPIRY = 1;
|
||||
const verifier = {
|
||||
verifyAccessToken: async (token: string) => ({
|
||||
token,
|
||||
clientId: 'test-client',
|
||||
scopes: [],
|
||||
expiresAt: token === EXPIRED_TOKEN ? PAST_EXPIRY : Date.now() / 1000 + 3600
|
||||
})
|
||||
};
|
||||
|
||||
await using host = await startExpressMinimal(requireBearerAuth({ verifier }));
|
||||
|
||||
const res = await fetch(new URL('/mcp', host.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${EXPIRED_TOKEN}`
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
|
||||
const wwwAuth = res.headers.get('www-authenticate');
|
||||
expect(wwwAuth).toBeTruthy();
|
||||
expect(wwwAuth).toContain('error="invalid_token"');
|
||||
});
|
||||
|
||||
verifies('hosting:auth:scope-403', async (_args: TestArgs) => {
|
||||
const verifier = {
|
||||
verifyAccessToken: async (token: string) => ({
|
||||
token,
|
||||
clientId: 'test-client',
|
||||
scopes: token === VALID_TOKEN ? ['mcp:tools:read'] : ['mcp:tools:call'],
|
||||
expiresAt: Date.now() / 1000 + 3600
|
||||
})
|
||||
};
|
||||
|
||||
await using host = await startExpressMinimal(
|
||||
requireBearerAuth({
|
||||
verifier,
|
||||
requiredScopes: ['mcp:tools:read', 'mcp:tools:call'],
|
||||
resourceMetadataUrl: RESOURCE_METADATA_URL
|
||||
})
|
||||
);
|
||||
|
||||
const res = await fetch(new URL('/mcp', host.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${VALID_TOKEN}`
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
const wwwAuth = res.headers.get('www-authenticate');
|
||||
expect(wwwAuth).toBeTruthy();
|
||||
expect(wwwAuth).toMatch(/^Bearer\b/i);
|
||||
expect(wwwAuth).toContain('error="insufficient_scope"');
|
||||
expect(wwwAuth).toContain('scope="mcp:tools:read mcp:tools:call"');
|
||||
expect(wwwAuth).toContain(`resource_metadata="${RESOURCE_METADATA_URL}"`);
|
||||
|
||||
const body = (await res.json()) as { error?: string };
|
||||
expect(body.error).toBe('insufficient_scope');
|
||||
});
|
||||
|
||||
verifies('hosting:auth:aud-validation', async (_args: TestArgs) => {
|
||||
const SERVER_RESOURCE_ID = 'https://mcp.example.com/api';
|
||||
const WRONG_AUDIENCE = 'https://other.example.com/api';
|
||||
|
||||
const verifier = {
|
||||
verifyAccessToken: async (token: string) => {
|
||||
const aud = token === 'wrong-aud-token' ? WRONG_AUDIENCE : SERVER_RESOURCE_ID;
|
||||
return {
|
||||
token,
|
||||
clientId: 'test-client',
|
||||
scopes: [],
|
||||
expiresAt: Date.now() / 1000 + 3600,
|
||||
resource: new URL(aud)
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(requireBearerAuth({ verifier, resourceMetadataUrl: SERVER_RESOURCE_ID }));
|
||||
app.post('/mcp', (_req, res) => {
|
||||
res.json({ jsonrpc: '2.0', id: 1, result: { ok: true } });
|
||||
});
|
||||
|
||||
await using host = await startExpressMinimal(app);
|
||||
|
||||
const wrongAud = await fetch(new URL('/mcp', host.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: 'Bearer wrong-aud-token'
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
|
||||
});
|
||||
|
||||
expect(wrongAud.status).toBeGreaterThanOrEqual(401);
|
||||
expect(wrongAud.status).toBeLessThanOrEqual(403);
|
||||
|
||||
const wwwAuth = wrongAud.headers.get('www-authenticate');
|
||||
expect(wwwAuth).toBeTruthy();
|
||||
expect(wwwAuth).toMatch(/^Bearer\b/i);
|
||||
expect(wwwAuth).toContain('error=');
|
||||
|
||||
const body = (await wrongAud.json()) as { error?: string };
|
||||
expect(body.error).toBeTruthy();
|
||||
});
|
||||
|
||||
verifies('hosting:auth:metadata-endpoints', async (_args: TestArgs) => {
|
||||
const issuer = new URL('https://auth.example.com');
|
||||
const oauthMetadata: OAuthMetadata = {
|
||||
issuer: issuer.href,
|
||||
authorization_endpoint: new URL('/authorize', issuer).href,
|
||||
token_endpoint: new URL('/token', issuer).href,
|
||||
response_types_supported: ['code']
|
||||
};
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(
|
||||
mcpAuthMetadataRouter({
|
||||
oauthMetadata,
|
||||
resourceServerUrl: new URL('https://mcp.example.com')
|
||||
})
|
||||
);
|
||||
|
||||
await using host = await startExpressMinimal(app);
|
||||
|
||||
const asMetadata = await fetch(new URL('/.well-known/oauth-authorization-server', host.baseUrl));
|
||||
expect(asMetadata.status).toBe(200);
|
||||
const asBody = (await asMetadata.json()) as { issuer?: string; authorization_endpoint?: string };
|
||||
expect(asBody.issuer).toBe(issuer.href);
|
||||
expect(asBody.authorization_endpoint).toBeTruthy();
|
||||
|
||||
const prmMetadata = await fetch(new URL('/.well-known/oauth-protected-resource', host.baseUrl));
|
||||
expect(prmMetadata.status).toBe(200);
|
||||
const prmBody = (await prmMetadata.json()) as { resource?: string; authorization_servers?: string[] };
|
||||
expect(prmBody.authorization_servers).toContain(issuer.href);
|
||||
});
|
||||
|
||||
verifies('hosting:auth:prm:authorization-servers-field', async (_args: TestArgs) => {
|
||||
const issuer = new URL('https://auth.example.com');
|
||||
const oauthMetadata: OAuthMetadata = {
|
||||
issuer: issuer.href,
|
||||
authorization_endpoint: new URL('/authorize', issuer).href,
|
||||
token_endpoint: new URL('/token', issuer).href,
|
||||
response_types_supported: ['code']
|
||||
};
|
||||
|
||||
const app = express();
|
||||
app.use(
|
||||
mcpAuthMetadataRouter({
|
||||
oauthMetadata,
|
||||
resourceServerUrl: new URL('https://mcp.example.com')
|
||||
})
|
||||
);
|
||||
|
||||
await using host = await startExpressMinimal(app);
|
||||
|
||||
const res = await fetch(new URL('/.well-known/oauth-protected-resource', host.baseUrl));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { authorization_servers?: string[] };
|
||||
expect(body.authorization_servers).toBeInstanceOf(Array);
|
||||
expect(body.authorization_servers?.length).toBeGreaterThan(0);
|
||||
expect(body.authorization_servers).toContain(issuer.href);
|
||||
});
|
||||
|
||||
verifies('hosting:http:host-validation-middleware', async (_args: TestArgs) => {
|
||||
const handler: RequestHandler = (_req, res) => {
|
||||
res.json({ ok: true });
|
||||
};
|
||||
|
||||
await using host = await startExpressWithHostValidation(['localhost', '127.0.0.1'], handler);
|
||||
|
||||
const good = await fetch(new URL('/test', host.baseUrl));
|
||||
expect(good.status).toBe(200);
|
||||
|
||||
const spoofed = await postWithHost(new URL('/test', host.baseUrl), 'evil.example.com', JSON.stringify({ test: 'data' }));
|
||||
expect(spoofed.status).toBe(403);
|
||||
const body = JSON.parse(spoofed.body) as { error?: { message?: string } };
|
||||
expect(body.error?.message).toMatch(/Invalid Host/i);
|
||||
});
|
||||
|
||||
verifies('hosting:express-app-helper', async (_args: TestArgs) => {
|
||||
const app = createMcpExpressApp();
|
||||
app.post('/mcp', (_req, res) => {
|
||||
res.json({ jsonrpc: '2.0', id: 1, result: { ok: true } });
|
||||
});
|
||||
|
||||
await using host = await startExpressMinimal(app);
|
||||
|
||||
expect(host.baseUrl.hostname).toBe('127.0.0.1');
|
||||
|
||||
const good = await fetch(new URL('/mcp', host.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
|
||||
});
|
||||
expect(good.status).toBe(200);
|
||||
|
||||
const spoofed = await postWithHost(
|
||||
new URL('/mcp', host.baseUrl),
|
||||
'evil.example.com',
|
||||
JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
|
||||
);
|
||||
expect(spoofed.status).toBe(403);
|
||||
const body = JSON.parse(spoofed.body) as { error?: { message?: string } };
|
||||
expect(body.error?.message).toMatch(/Invalid Host/i);
|
||||
});
|
||||
|
||||
verifies('hosting:auth:query-token-ignored', async (_args: TestArgs) => {
|
||||
const verifier = {
|
||||
verifyAccessToken: async (token: string) => {
|
||||
if (token !== VALID_TOKEN) {
|
||||
throw new OAuthError(OAuthErrorCode.InvalidToken, 'Token verification failed');
|
||||
}
|
||||
return { token, clientId: 'analytics-client', scopes: [], expiresAt: Date.now() / 1000 + 3600 };
|
||||
}
|
||||
};
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(requireBearerAuth({ verifier, resourceMetadataUrl: RESOURCE_METADATA_URL }));
|
||||
app.post('/mcp', (_req, res) => {
|
||||
res.json({ jsonrpc: '2.0', id: 1, result: { ok: true } });
|
||||
});
|
||||
|
||||
await using host = await startExpressMinimal(app);
|
||||
|
||||
const queryUrl = new URL('/mcp', host.baseUrl);
|
||||
queryUrl.searchParams.set('access_token', VALID_TOKEN);
|
||||
const queryRes = await fetch(queryUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
|
||||
});
|
||||
|
||||
expect(queryRes.status).toBe(401);
|
||||
const wwwAuth = queryRes.headers.get('www-authenticate');
|
||||
expect(wwwAuth).toBeTruthy();
|
||||
expect(wwwAuth).toMatch(/^Bearer\b/i);
|
||||
expect(wwwAuth).toContain('error="invalid_token"');
|
||||
const queryBody = (await queryRes.json()) as { error?: string };
|
||||
expect(queryBody.error).toBe('invalid_token');
|
||||
|
||||
// Control: the same token in the Authorization header authenticates, proving only the query-string placement was ignored.
|
||||
const headerRes = await fetch(new URL('/mcp', host.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Bearer ${VALID_TOKEN}` },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
|
||||
});
|
||||
expect(headerRes.status).toBe(200);
|
||||
});
|
||||
|
||||
/** Listen `app` (already fully configured by the adapter under test) on an ephemeral 127.0.0.1 port; callers close() in finally. */
|
||||
function listenExpressApp(app: Express): Promise<{ baseUrl: URL; close: () => Promise<void> }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = app.listen(0, '127.0.0.1', () => {
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === 'string') {
|
||||
reject(new Error(`listen failed: ${String(addr)}`));
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
baseUrl: new URL(`http://127.0.0.1:${addr.port}`),
|
||||
close: () =>
|
||||
new Promise<void>((res, rej) => {
|
||||
server.close(err => (err ? rej(err) : res()));
|
||||
})
|
||||
});
|
||||
});
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
verifies('hosting:express:adapter-basic-flow', async (_args: TestArgs) => {
|
||||
const mcpServer = new McpServer({ name: 'express-adapter-server', version: '1.0.0' });
|
||||
mcpServer.registerTool(
|
||||
'lookup-order-status',
|
||||
{ description: 'Look up the shipping status of an order.', inputSchema: z.object({ orderId: z.string() }) },
|
||||
({ orderId }) => ({ content: [{ type: 'text', text: `Order ${orderId} is in transit.` }] })
|
||||
);
|
||||
|
||||
const serverTransport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
|
||||
await mcpServer.connect(serverTransport);
|
||||
|
||||
const app = createMcpExpressApp();
|
||||
app.post('/mcp', async (req, res) => {
|
||||
await serverTransport.handleRequest(req, res, req.body);
|
||||
});
|
||||
|
||||
const { baseUrl, close } = await listenExpressApp(app);
|
||||
const client = new Client({ name: 'express-adapter-client', version: '1.0.0' });
|
||||
try {
|
||||
await client.connect(new StreamableHTTPClientTransport(new URL('/mcp', baseUrl)));
|
||||
|
||||
// Initialize completed over the Express-hosted POST route: the negotiated server identity is visible on the client.
|
||||
expect(client.getServerVersion()).toEqual({ name: 'express-adapter-server', version: '1.0.0' });
|
||||
|
||||
const result = await client.callTool({ name: 'lookup-order-status', arguments: { orderId: 'ORD-1042' } });
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'Order ORD-1042 is in transit.' }]);
|
||||
} finally {
|
||||
await client.close();
|
||||
await mcpServer.close();
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('hosting:express:adapter-host-header-validation', async ({ protocolVersion }: TestArgs) => {
|
||||
const mcpServer = new McpServer({ name: 'rebind-protected-server', version: '1.0.0' });
|
||||
// JSON response mode keeps the allowed-host control assertable as a plain JSON initialize result.
|
||||
const serverTransport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), enableJsonResponse: true });
|
||||
await mcpServer.connect(serverTransport);
|
||||
|
||||
let mcpRouteHits = 0;
|
||||
const app = createMcpExpressApp();
|
||||
app.post('/mcp', async (req, res) => {
|
||||
mcpRouteHits += 1;
|
||||
await serverTransport.handleRequest(req, res, req.body);
|
||||
});
|
||||
|
||||
const { baseUrl, close } = await listenExpressApp(app);
|
||||
try {
|
||||
const initializeBody = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: { protocolVersion, capabilities: {}, clientInfo: { name: 'rebind-client', version: '1.0.0' } }
|
||||
});
|
||||
|
||||
const spoofed = await postWithHost(new URL('/mcp', baseUrl), 'evil.example.com', initializeBody);
|
||||
expect(spoofed.status).toBe(403);
|
||||
const spoofedJson: unknown = JSON.parse(spoofed.body);
|
||||
expect(spoofedJson).toEqual({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32_000, message: 'Invalid Host: evil.example.com' },
|
||||
id: null
|
||||
});
|
||||
// Rejected by the default localhost DNS-rebinding middleware before the MCP transport route ever ran.
|
||||
expect(mcpRouteHits).toBe(0);
|
||||
|
||||
// Control: the identical request with the real localhost Host reaches the transport and initializes normally.
|
||||
const allowed = await postWithHost(new URL('/mcp', baseUrl), `127.0.0.1:${baseUrl.port}`, initializeBody);
|
||||
expect(allowed.status).toBe(200);
|
||||
const allowedJson: unknown = JSON.parse(allowed.body);
|
||||
expect(allowedJson).toMatchObject({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
result: { protocolVersion, serverInfo: { name: 'rebind-protected-server', version: '1.0.0' } }
|
||||
});
|
||||
expect(mcpRouteHits).toBe(1);
|
||||
} finally {
|
||||
await mcpServer.close();
|
||||
await close();
|
||||
}
|
||||
});
|
||||
201
test/e2e/scenarios/hosting-fastify.test.ts
Normal file
201
test/e2e/scenarios/hosting-fastify.test.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
/**
|
||||
* Self-contained test bodies for the Fastify hosting adapter (@modelcontextprotocol/fastify).
|
||||
*
|
||||
* These tests cover createMcpFastifyApp() over real HTTP: the Fastify instance listens on an
|
||||
* ephemeral 127.0.0.1 port, per-session WebStandardStreamableHTTPServerTransport instances are
|
||||
* mounted on its /mcp routes via a small Node-to-web-standard Request adapter (Fastify parses
|
||||
* JSON bodies natively), and a StreamableHTTPClientTransport (or raw HTTP for hostile-Host
|
||||
* probes) drives it from outside. The hosting adapter is the subject, so the matrix transport
|
||||
* arg is ignored and every listener, transport, and server is closed in finally.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import http from 'node:http';
|
||||
|
||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
import { createMcpFastifyApp } from '@modelcontextprotocol/fastify';
|
||||
import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { expect } from 'vitest';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
import type { TestArgs } from '../types.js';
|
||||
|
||||
function forecastServer(): McpServer {
|
||||
const s = new McpServer({ name: 'forecast-server', version: '0.3.0' });
|
||||
s.registerTool(
|
||||
'get_forecast',
|
||||
{ description: 'Returns tomorrow’s weather forecast for a city.', inputSchema: z.object({ city: z.string() }) },
|
||||
({ city }) => ({ content: [{ type: 'text', text: `Forecast for ${city}: sunny, high of 22C.` }] })
|
||||
);
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Adapt Fastify's request (Node IncomingMessage plus natively parsed JSON body) to the web-standard Request the transport consumes. */
|
||||
function toWebRequest(request: FastifyRequest): Request {
|
||||
const headers = new Headers();
|
||||
for (const [name, value] of Object.entries(request.headers)) {
|
||||
// content-length is dropped because the body below is re-serialized from Fastify's parsed JSON
|
||||
if (name === 'content-length') continue;
|
||||
if (typeof value === 'string') headers.set(name, value);
|
||||
else if (Array.isArray(value)) for (const item of value) headers.append(name, item);
|
||||
}
|
||||
return new Request(new URL(request.url, `http://${request.headers.host ?? '127.0.0.1'}`), {
|
||||
method: request.method,
|
||||
headers,
|
||||
body: request.body === undefined ? undefined : JSON.stringify(request.body)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount per-session WebStandard streamable HTTP transports on the app's /mcp routes,
|
||||
* handing each raw Fastify request to the transport and replying with the web-standard Response.
|
||||
*/
|
||||
function mountMcp(app: FastifyInstance, makeServer: () => McpServer): { close(): Promise<void> } {
|
||||
const sessions = new Map<string, WebStandardStreamableHTTPServerTransport>();
|
||||
const servers: McpServer[] = [];
|
||||
const bySession = (request: FastifyRequest): WebStandardStreamableHTTPServerTransport | undefined => {
|
||||
const sessionId = request.headers['mcp-session-id'];
|
||||
return typeof sessionId === 'string' ? sessions.get(sessionId) : undefined;
|
||||
};
|
||||
|
||||
app.post('/mcp', async (request, reply) => {
|
||||
const existing = bySession(request);
|
||||
if (existing) return reply.send(await existing.handleRequest(toWebRequest(request)));
|
||||
const tx = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: randomUUID,
|
||||
onsessioninitialized: id => void sessions.set(id, tx),
|
||||
onsessionclosed: id => void sessions.delete(id)
|
||||
});
|
||||
const server = makeServer();
|
||||
servers.push(server);
|
||||
await server.connect(tx);
|
||||
return reply.send(await tx.handleRequest(toWebRequest(request)));
|
||||
});
|
||||
app.get('/mcp', async (request, reply) => {
|
||||
const existing = bySession(request);
|
||||
if (existing) return reply.send(await existing.handleRequest(toWebRequest(request)));
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ jsonrpc: '2.0', error: { code: -32_000, message: 'Bad Request: No valid session ID provided' }, id: null });
|
||||
});
|
||||
app.delete('/mcp', async (request, reply) => {
|
||||
const existing = bySession(request);
|
||||
if (existing) return reply.send(await existing.handleRequest(toWebRequest(request)));
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ jsonrpc: '2.0', error: { code: -32_000, message: 'Bad Request: No valid session ID provided' }, id: null });
|
||||
});
|
||||
|
||||
return {
|
||||
close: async () => {
|
||||
for (const server of servers) await server.close();
|
||||
for (const tx of sessions.values()) await tx.close();
|
||||
sessions.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* POST `body` to `url` via `node:http`, forcing `Host: <host>`.
|
||||
* Unlike undici fetch(), node:http sends caller-supplied Host headers verbatim.
|
||||
*/
|
||||
function postWithHost(url: URL, host: string, body: string): Promise<{ status: number; body: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Host: host,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
'Content-Length': Buffer.byteLength(body)
|
||||
}
|
||||
},
|
||||
res => {
|
||||
let data = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', chunk => (data += chunk));
|
||||
res.on('end', () => resolve({ status: res.statusCode ?? 0, body: data }));
|
||||
res.on('error', reject);
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
verifies('hosting:fastify:basic-flow', async (_args: TestArgs) => {
|
||||
const app = createMcpFastifyApp();
|
||||
const mounted = mountMcp(app, forecastServer);
|
||||
const client = new Client({ name: 'fastify-e2e-client', version: '0.1.0' });
|
||||
|
||||
try {
|
||||
const baseUrl = new URL(await app.listen({ port: 0, host: '127.0.0.1' }));
|
||||
const clientTransport = new StreamableHTTPClientTransport(new URL('/mcp', baseUrl));
|
||||
await client.connect(clientTransport);
|
||||
|
||||
expect(client.getServerVersion()).toEqual({ name: 'forecast-server', version: '0.3.0' });
|
||||
expect(clientTransport.sessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
|
||||
|
||||
const { tools } = await client.listTools();
|
||||
expect(tools.map(t => ({ name: t.name, description: t.description }))).toEqual([
|
||||
{ name: 'get_forecast', description: 'Returns tomorrow’s weather forecast for a city.' }
|
||||
]);
|
||||
|
||||
const result = await client.callTool({ name: 'get_forecast', arguments: { city: 'Lisbon' } });
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'Forecast for Lisbon: sunny, high of 22C.' }]);
|
||||
} finally {
|
||||
await client.close();
|
||||
await mounted.close();
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('hosting:fastify:host-header-validation', async ({ protocolVersion }: TestArgs) => {
|
||||
let serversBuilt = 0;
|
||||
const app = createMcpFastifyApp();
|
||||
const mounted = mountMcp(app, () => {
|
||||
serversBuilt += 1;
|
||||
return forecastServer();
|
||||
});
|
||||
const client = new Client({ name: 'fastify-e2e-client', version: '0.1.0' });
|
||||
|
||||
try {
|
||||
const baseUrl = new URL(await app.listen({ port: 0, host: '127.0.0.1' }));
|
||||
|
||||
const rejected = await postWithHost(
|
||||
new URL('/mcp', baseUrl),
|
||||
'evil.example.com',
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: { protocolVersion, capabilities: {}, clientInfo: { name: 'rebinding-probe', version: '0.0.1' } }
|
||||
})
|
||||
);
|
||||
expect(rejected.status).toBe(403);
|
||||
expect(JSON.parse(rejected.body)).toEqual({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32_000, message: 'Invalid Host: evil.example.com' },
|
||||
id: null
|
||||
});
|
||||
// Rejected by the onRequest hook before reaching the MCP layer: no server was ever constructed for the hostile request.
|
||||
expect(serversBuilt).toBe(0);
|
||||
|
||||
// Control: the same endpoint accepts a client whose requests carry the localhost Host header (127.0.0.1).
|
||||
await client.connect(new StreamableHTTPClientTransport(new URL('/mcp', baseUrl)));
|
||||
|
||||
const { tools } = await client.listTools();
|
||||
expect(tools.map(t => t.name)).toEqual(['get_forecast']);
|
||||
expect(serversBuilt).toBe(1);
|
||||
} finally {
|
||||
await client.close();
|
||||
await mounted.close();
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
193
test/e2e/scenarios/hosting-hono.test.ts
Normal file
193
test/e2e/scenarios/hosting-hono.test.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
/**
|
||||
* Self-contained test bodies for the Hono hosting adapter (@modelcontextprotocol/hono).
|
||||
*
|
||||
* These tests cover createMcpHonoApp() over real HTTP: the app is served with
|
||||
* @hono/node-server on an ephemeral 127.0.0.1 port, WebStandardStreamableHTTPServerTransport
|
||||
* instances are mounted on the /mcp routes (the documented Hono hosting pattern), and a
|
||||
* StreamableHTTPClientTransport (or raw HTTP for hostile-Host probes) drives it from outside.
|
||||
* The hosting adapter is the subject, so the matrix transport arg is ignored and every
|
||||
* listener, transport, and server is closed in finally.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import http from 'node:http';
|
||||
|
||||
import { serve } from '@hono/node-server';
|
||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
import { createMcpHonoApp } from '@modelcontextprotocol/hono';
|
||||
import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server';
|
||||
import type { Hono } from 'hono';
|
||||
import { expect } from 'vitest';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
import type { TestArgs } from '../types.js';
|
||||
|
||||
function recipeServer(): McpServer {
|
||||
const s = new McpServer({ name: 'recipe-server', version: '1.2.0' });
|
||||
s.registerTool(
|
||||
'get_recipe',
|
||||
{ description: 'Returns the preparation steps for a named dish.', inputSchema: z.object({ dish: z.string() }) },
|
||||
({ dish }) => ({ content: [{ type: 'text', text: `Steps for ${dish}: mix, bake, serve.` }] })
|
||||
);
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount per-session WebStandard streamable HTTP transports on the app's /mcp routes,
|
||||
* mirroring the documented Hono hosting pattern (handlers pass c.req.raw to the transport).
|
||||
*/
|
||||
function mountMcp(app: Hono, makeServer: () => McpServer): { close(): Promise<void> } {
|
||||
const sessions = new Map<string, WebStandardStreamableHTTPServerTransport>();
|
||||
const servers: McpServer[] = [];
|
||||
const bySession = (sessionId: string | undefined) => (sessionId === undefined ? undefined : sessions.get(sessionId));
|
||||
|
||||
app.post('/mcp', async c => {
|
||||
const existing = bySession(c.req.header('mcp-session-id'));
|
||||
if (existing) return existing.handleRequest(c.req.raw);
|
||||
const tx = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: randomUUID,
|
||||
onsessioninitialized: id => void sessions.set(id, tx),
|
||||
onsessionclosed: id => void sessions.delete(id)
|
||||
});
|
||||
const server = makeServer();
|
||||
servers.push(server);
|
||||
await server.connect(tx);
|
||||
return tx.handleRequest(c.req.raw);
|
||||
});
|
||||
app.get('/mcp', async c => {
|
||||
const existing = bySession(c.req.header('mcp-session-id'));
|
||||
if (existing) return existing.handleRequest(c.req.raw);
|
||||
return c.json({ jsonrpc: '2.0', error: { code: -32_000, message: 'Bad Request: No valid session ID provided' }, id: null }, 400);
|
||||
});
|
||||
app.delete('/mcp', async c => {
|
||||
const existing = bySession(c.req.header('mcp-session-id'));
|
||||
if (existing) return existing.handleRequest(c.req.raw);
|
||||
return c.json({ jsonrpc: '2.0', error: { code: -32_000, message: 'Bad Request: No valid session ID provided' }, id: null }, 400);
|
||||
});
|
||||
|
||||
return {
|
||||
close: async () => {
|
||||
for (const server of servers) await server.close();
|
||||
for (const tx of sessions.values()) await tx.close();
|
||||
sessions.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Serve a Hono app on an ephemeral 127.0.0.1 port via @hono/node-server, resolving once it is listening. */
|
||||
function listenHono(app: Hono): Promise<{ baseUrl: URL; close(): Promise<void> }> {
|
||||
return new Promise(resolve => {
|
||||
const server = serve({ fetch: app.fetch, hostname: '127.0.0.1', port: 0 }, info => {
|
||||
resolve({
|
||||
baseUrl: new URL(`http://127.0.0.1:${info.port}`),
|
||||
close: () =>
|
||||
new Promise<void>((res, rej) => {
|
||||
server.close(err => (err ? rej(err) : res()));
|
||||
})
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST `body` to `url` via `node:http`, forcing `Host: <host>`.
|
||||
* Unlike undici fetch(), node:http sends caller-supplied Host headers verbatim.
|
||||
*/
|
||||
function postWithHost(url: URL, host: string, body: string): Promise<{ status: number; body: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: url.hostname,
|
||||
port: url.port,
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Host: host,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
'Content-Length': Buffer.byteLength(body)
|
||||
}
|
||||
},
|
||||
res => {
|
||||
let data = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', chunk => (data += chunk));
|
||||
res.on('end', () => resolve({ status: res.statusCode ?? 0, body: data }));
|
||||
res.on('error', reject);
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
verifies('hosting:hono:basic-flow', async (_args: TestArgs) => {
|
||||
const app = createMcpHonoApp();
|
||||
const mounted = mountMcp(app, recipeServer);
|
||||
const listener = await listenHono(app);
|
||||
const client = new Client({ name: 'hono-e2e-client', version: '0.1.0' });
|
||||
const clientTransport = new StreamableHTTPClientTransport(new URL('/mcp', listener.baseUrl));
|
||||
|
||||
try {
|
||||
await client.connect(clientTransport);
|
||||
|
||||
expect(client.getServerVersion()).toEqual({ name: 'recipe-server', version: '1.2.0' });
|
||||
expect(clientTransport.sessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
|
||||
|
||||
const { tools } = await client.listTools();
|
||||
expect(tools.map(t => ({ name: t.name, description: t.description }))).toEqual([
|
||||
{ name: 'get_recipe', description: 'Returns the preparation steps for a named dish.' }
|
||||
]);
|
||||
|
||||
const result = await client.callTool({ name: 'get_recipe', arguments: { dish: 'shakshuka' } });
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'Steps for shakshuka: mix, bake, serve.' }]);
|
||||
} finally {
|
||||
await client.close();
|
||||
await mounted.close();
|
||||
await listener.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('hosting:hono:host-header-validation', async (_args: TestArgs) => {
|
||||
let serversBuilt = 0;
|
||||
const app = createMcpHonoApp();
|
||||
const mounted = mountMcp(app, () => {
|
||||
serversBuilt += 1;
|
||||
return recipeServer();
|
||||
});
|
||||
const listener = await listenHono(app);
|
||||
const client = new Client({ name: 'hono-e2e-client', version: '0.1.0' });
|
||||
|
||||
try {
|
||||
const rejected = await postWithHost(
|
||||
new URL('/mcp', listener.baseUrl),
|
||||
'evil.example.com',
|
||||
JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'rebinding-probe', version: '0.0.1' } }
|
||||
})
|
||||
);
|
||||
expect(rejected.status).toBe(403);
|
||||
expect(JSON.parse(rejected.body)).toEqual({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32_000, message: 'Invalid Host: evil.example.com' },
|
||||
id: null
|
||||
});
|
||||
// Rejected before reaching the MCP layer: no server was ever constructed for the hostile request.
|
||||
expect(serversBuilt).toBe(0);
|
||||
|
||||
// Control: the same endpoint accepts a client whose requests carry the localhost Host header (127.0.0.1).
|
||||
await client.connect(new StreamableHTTPClientTransport(new URL('/mcp', listener.baseUrl)));
|
||||
|
||||
const { tools } = await client.listTools();
|
||||
expect(tools.map(t => t.name)).toEqual(['get_recipe']);
|
||||
expect(serversBuilt).toBe(1);
|
||||
} finally {
|
||||
await client.close();
|
||||
await mounted.close();
|
||||
await listener.close();
|
||||
}
|
||||
});
|
||||
1038
test/e2e/scenarios/hosting-http.test.ts
Normal file
1038
test/e2e/scenarios/hosting-http.test.ts
Normal file
File diff suppressed because it is too large
Load diff
856
test/e2e/scenarios/hosting-resume.test.ts
Normal file
856
test/e2e/scenarios/hosting-resume.test.ts
Normal file
|
|
@ -0,0 +1,856 @@
|
|||
/**
|
||||
* Self-contained test bodies for hosting resume (StreamableHTTP resumability/EventStore).
|
||||
*
|
||||
* Each export is a {@link TestCase}: it builds its own server (via a factory),
|
||||
* builds its own client, wires them with {@link wire}, and asserts. SSE stream
|
||||
* parsing and raw GET requests drive resume scenarios.
|
||||
*/
|
||||
|
||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
import type { EventId, EventStore, JSONRPCMessage, StreamId } from '@modelcontextprotocol/server';
|
||||
import { LATEST_PROTOCOL_VERSION, McpServer, parseJSONRPCMessage } from '@modelcontextprotocol/server';
|
||||
import { expect, vi } from 'vitest';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { hostResumable } from '../helpers/index.js';
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
import type { TestArgs } from '../types.js';
|
||||
|
||||
/**
|
||||
* These three tests assert the raw frame sequence of the POST SSE stream itself, so they cannot run their traffic through a connected client.
|
||||
*/
|
||||
async function completeHandshake(
|
||||
url: URL,
|
||||
fetch: (u: URL | string, init?: RequestInit) => Promise<Response>,
|
||||
capabilities: Record<string, unknown> = {}
|
||||
): Promise<{ sessionId: string; baseHeaders: Record<string, string> }> {
|
||||
const initRes = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 0,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: LATEST_PROTOCOL_VERSION,
|
||||
capabilities,
|
||||
clientInfo: { name: 'test', version: '0' }
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!initRes.ok) throw new Error(`Initialize failed: ${initRes.status}`);
|
||||
|
||||
const sessionId = initRes.headers.get('mcp-session-id');
|
||||
if (!sessionId) throw new Error('No session ID in initialize response');
|
||||
|
||||
await initRes.body?.cancel();
|
||||
|
||||
const baseHeaders = { 'mcp-session-id': sessionId, 'mcp-protocol-version': LATEST_PROTOCOL_VERSION };
|
||||
|
||||
const notifRes = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...baseHeaders,
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json, text/event-stream'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'notifications/initialized'
|
||||
})
|
||||
});
|
||||
|
||||
if (!notifRes.ok) throw new Error(`notifications/initialized failed: ${notifRes.status}`);
|
||||
await notifRes.body?.cancel();
|
||||
|
||||
return { sessionId, baseHeaders };
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal in-memory EventStore for tests. Models user implementation.
|
||||
* Ids are `<streamId>_<seq>` for deterministic ordering.
|
||||
*/
|
||||
class InMemoryEventStore implements EventStore {
|
||||
private events: Array<{ id: string; streamId: string; message: JSONRPCMessage }> = [];
|
||||
private seq = 0;
|
||||
|
||||
async storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise<EventId> {
|
||||
const id = `${streamId}_${String(this.seq++).padStart(6, '0')}`;
|
||||
this.events.push({ id, streamId, message });
|
||||
return id;
|
||||
}
|
||||
|
||||
async getStreamIdForEventId(eventId: EventId): Promise<StreamId | undefined> {
|
||||
const found = this.events.find(e => e.id === eventId);
|
||||
return found?.streamId;
|
||||
}
|
||||
|
||||
async replayEventsAfter(
|
||||
lastEventId: EventId,
|
||||
{ send }: { send: (eventId: EventId, message: JSONRPCMessage) => Promise<void> }
|
||||
): Promise<StreamId> {
|
||||
const anchor = this.events.find(e => e.id === lastEventId);
|
||||
if (!anchor) return '';
|
||||
const streamId = anchor.streamId;
|
||||
for (const e of this.events.slice(this.events.indexOf(anchor) + 1)) {
|
||||
if (e.streamId === streamId) await send(e.id, e.message);
|
||||
}
|
||||
return streamId;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.events.length = 0;
|
||||
this.seq = 0;
|
||||
}
|
||||
|
||||
getStoredEvents(): ReadonlyArray<{ id: string; streamId: string; message: JSONRPCMessage }> {
|
||||
return this.events;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse SSE blocks with non-empty data lines from an SSE body. */
|
||||
function parseSseFrames(body: string): Array<{ id: string; data: string }> {
|
||||
const frames: Array<{ id: string; data: string }> = [];
|
||||
for (const block of body.split(/\r?\n\r?\n/)) {
|
||||
const idMatch = block.match(/^id:\s*(.+)$/m);
|
||||
const dataMatch = block.match(/^data:\s*(.+)$/m);
|
||||
if (idMatch?.[1]?.trim() && dataMatch?.[1]) {
|
||||
frames.push({ id: idMatch[1].trim(), data: dataMatch[1] });
|
||||
}
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
/** Narrow a Response's body for SSE reading. */
|
||||
function requireBody(res: Response): ReadableStream<Uint8Array> {
|
||||
if (!res.body) throw new Error('Expected response to have a body');
|
||||
return res.body;
|
||||
}
|
||||
|
||||
/** Read an SSE Response body to completion. */
|
||||
async function readSseBody(body: ReadableStream<Uint8Array>): Promise<string> {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (value) buf += decoder.decode(value, { stream: true });
|
||||
if (done) break;
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {});
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
/** Read SSE frames until we have at least `count` frames, then cancel. */
|
||||
async function readSseFramesUntil(body: ReadableStream<Uint8Array>, count: number, maxIterations = 64): Promise<string> {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = '';
|
||||
try {
|
||||
for (let i = 0; i < maxIterations; i++) {
|
||||
const { value, done } = await reader.read();
|
||||
if (value) buf += decoder.decode(value, { stream: true });
|
||||
const frames = parseSseFrames(buf);
|
||||
if (frames.length >= count) break;
|
||||
if (done) break;
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {});
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
verifies('hosting:resume:event-ids', async (_args: TestArgs) => {
|
||||
const eventStore = new InMemoryEventStore();
|
||||
|
||||
let serverRef: McpServer | undefined;
|
||||
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' }, { capabilities: { logging: {} } });
|
||||
s.registerTool('progress', { inputSchema: z.object({ steps: z.number().int().positive() }) }, async ({ steps }, ctx) => {
|
||||
const token = ctx.mcpReq._meta?.progressToken;
|
||||
if (token !== undefined) {
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
await ctx.mcpReq.notify({
|
||||
method: 'notifications/progress',
|
||||
params: { progressToken: token, progress: i, total: steps }
|
||||
});
|
||||
}
|
||||
}
|
||||
return { content: [{ type: 'text', text: `done ${steps}` }] };
|
||||
});
|
||||
serverRef = s;
|
||||
return s;
|
||||
};
|
||||
|
||||
const handle = hostResumable(makeServer, { eventStore });
|
||||
|
||||
const url = new URL('http://in-process/mcp');
|
||||
const fetch = (u: URL | string, init?: RequestInit) => handle.handleRequest(new Request(u, init));
|
||||
|
||||
try {
|
||||
const { baseHeaders } = await completeHandshake(url, fetch, { logging: {} });
|
||||
|
||||
if (!serverRef) throw new Error('serverRef not set');
|
||||
|
||||
const postRes = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { ...baseHeaders, 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 'evt-1',
|
||||
method: 'tools/call',
|
||||
params: { name: 'progress', arguments: { steps: 3 }, _meta: { progressToken: 'evt-1' } }
|
||||
})
|
||||
});
|
||||
expect(postRes.status).toBe(200);
|
||||
expect(postRes.headers.get('content-type')).toMatch(/text\/event-stream/);
|
||||
|
||||
const postBody = await readSseBody(requireBody(postRes));
|
||||
|
||||
const postBlocks = postBody.split(/\r?\n\r?\n/).filter(b => b.trim().length > 0);
|
||||
expect(postBlocks).toHaveLength(5);
|
||||
const postIds = postBlocks.map(b => b.match(/^id: (\S+)$/m)?.[1]).filter((id): id is string => id !== undefined);
|
||||
expect(postIds).toHaveLength(postBlocks.length);
|
||||
|
||||
const postMessages = postBlocks
|
||||
.map(b => b.match(/^data: (.+)$/m)?.[1])
|
||||
.filter((d): d is string => d !== undefined && d.trim().length > 0)
|
||||
.map(d => parseJSONRPCMessage(JSON.parse(d)));
|
||||
expect(postMessages.filter(m => 'method' in m && m.method === 'notifications/progress')).toHaveLength(3);
|
||||
expect(postMessages.filter(m => 'id' in m && m.id === 'evt-1')).toHaveLength(1);
|
||||
|
||||
const getRes = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { ...baseHeaders, accept: 'application/json, text/event-stream' }
|
||||
});
|
||||
expect(getRes.status).toBe(200);
|
||||
expect(getRes.headers.get('content-type')).toMatch(/text\/event-stream/);
|
||||
|
||||
await serverRef.sendLoggingMessage({ level: 'info', data: 'standalone-1' });
|
||||
await serverRef.sendLoggingMessage({ level: 'warning', data: 'standalone-2' });
|
||||
|
||||
const getBuf = await readSseFramesUntil(requireBody(getRes), 2);
|
||||
|
||||
const getBlocks = getBuf.split(/\r?\n\r?\n/).filter(b => b.trim().length > 0);
|
||||
expect(getBlocks).toHaveLength(2);
|
||||
const getIds = getBlocks.map(b => b.match(/^id: (\S+)$/m)?.[1]).filter((id): id is string => id !== undefined);
|
||||
expect(getIds).toHaveLength(getBlocks.length);
|
||||
|
||||
const storedIds = eventStore.getStoredEvents().map(e => e.id);
|
||||
for (const id of [...postIds, ...getIds]) {
|
||||
expect(storedIds).toContain(id);
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('hosting:resume:replay', async (_args: TestArgs) => {
|
||||
const eventStore = new InMemoryEventStore();
|
||||
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('progress', { inputSchema: z.object({ steps: z.number().int().positive() }) }, async ({ steps }, ctx) => {
|
||||
const token = ctx.mcpReq._meta?.progressToken;
|
||||
if (token !== undefined) {
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
await ctx.mcpReq.notify({
|
||||
method: 'notifications/progress',
|
||||
params: { progressToken: token, progress: i, total: steps }
|
||||
});
|
||||
}
|
||||
}
|
||||
return { content: [{ type: 'text', text: `done ${steps}` }] };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const handle = hostResumable(makeServer, { eventStore });
|
||||
|
||||
const url = new URL('http://in-process/mcp');
|
||||
const fetch = (u: URL | string, init?: RequestInit) => handle.handleRequest(new Request(u, init));
|
||||
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
const transport = new StreamableHTTPClientTransport(url, { fetch });
|
||||
|
||||
try {
|
||||
await client.connect(transport);
|
||||
|
||||
const sessionId = transport.sessionId;
|
||||
if (!sessionId) throw new Error('No session ID after connect');
|
||||
const baseHeaders = { 'mcp-session-id': sessionId };
|
||||
|
||||
const postRes = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { ...baseHeaders, 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 'replay-1',
|
||||
method: 'tools/call',
|
||||
params: { name: 'progress', arguments: { steps: 3 }, _meta: { progressToken: 'replay-1' } }
|
||||
})
|
||||
});
|
||||
expect(postRes.status).toBe(200);
|
||||
const postBody = await readSseBody(requireBody(postRes));
|
||||
const originalFrames = parseSseFrames(postBody);
|
||||
expect(originalFrames.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const [anchorFrame, ...framesAfterAnchor] = originalFrames;
|
||||
if (!anchorFrame) throw new Error('No anchor frame in POST SSE body');
|
||||
const anchorId = anchorFrame.id;
|
||||
const expectedAfterIds = framesAfterAnchor.map(f => f.id);
|
||||
|
||||
const resumeRes = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { ...baseHeaders, accept: 'application/json, text/event-stream', 'last-event-id': anchorId }
|
||||
});
|
||||
expect(resumeRes.status).toBe(200);
|
||||
expect(resumeRes.headers.get('content-type')).toMatch(/text\/event-stream/);
|
||||
|
||||
const resumeBody = await readSseFramesUntil(requireBody(resumeRes), expectedAfterIds.length);
|
||||
const replayedFrames = parseSseFrames(resumeBody);
|
||||
const replayedIds = replayedFrames.map(f => f.id);
|
||||
|
||||
expect(replayedIds).toEqual(expectedAfterIds);
|
||||
expect(replayedIds).not.toContain(anchorId);
|
||||
|
||||
for (const f of replayedFrames) {
|
||||
const msg: unknown = JSON.parse(f.data);
|
||||
expect(msg).toHaveProperty('jsonrpc', '2.0');
|
||||
}
|
||||
} finally {
|
||||
await client.close();
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('hosting:resume:buffered-replay', async (_args: TestArgs) => {
|
||||
const eventStore = new InMemoryEventStore();
|
||||
|
||||
let serverRef: McpServer | undefined;
|
||||
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' }, { capabilities: { logging: {} } });
|
||||
serverRef = s;
|
||||
return s;
|
||||
};
|
||||
|
||||
const handle = hostResumable(makeServer, { eventStore });
|
||||
|
||||
const url = new URL('http://in-process/mcp');
|
||||
const fetch = (u: URL | string, init?: RequestInit) => handle.handleRequest(new Request(u, init));
|
||||
|
||||
try {
|
||||
const initRes = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 'init-1',
|
||||
method: 'initialize',
|
||||
params: { protocolVersion: '2024-11-05', capabilities: { logging: {} }, clientInfo: { name: 'c', version: '0' } }
|
||||
})
|
||||
});
|
||||
expect(initRes.status).toBe(200);
|
||||
const sessionId = initRes.headers.get('mcp-session-id');
|
||||
expect(sessionId).toBeTruthy();
|
||||
if (!sessionId) throw new Error('No session ID in initialize response');
|
||||
|
||||
await readSseBody(requireBody(initRes));
|
||||
|
||||
const baseHeaders = { 'mcp-session-id': sessionId };
|
||||
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { ...baseHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'notifications/initialized',
|
||||
params: {}
|
||||
})
|
||||
});
|
||||
|
||||
if (!serverRef) throw new Error('serverRef not set');
|
||||
|
||||
const getRes = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { ...baseHeaders, accept: 'application/json, text/event-stream' }
|
||||
});
|
||||
expect(getRes.status).toBe(200);
|
||||
|
||||
const reader = requireBody(getRes).getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let anchorBuf = '';
|
||||
|
||||
await serverRef.sendLoggingMessage({ level: 'info', data: 'anchor' });
|
||||
|
||||
try {
|
||||
for (let i = 0; i < 32; i++) {
|
||||
const { value, done } = await reader.read();
|
||||
if (value) anchorBuf += decoder.decode(value, { stream: true });
|
||||
const frames = parseSseFrames(anchorBuf);
|
||||
if (frames.length > 0) break;
|
||||
if (done) break;
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {});
|
||||
}
|
||||
|
||||
const anchorFrames = parseSseFrames(anchorBuf);
|
||||
expect(anchorFrames.length).toBeGreaterThanOrEqual(1);
|
||||
const lastAnchorFrame = anchorFrames.at(-1);
|
||||
if (!lastAnchorFrame) throw new Error('No anchor frame on the standalone GET stream');
|
||||
const anchorId = lastAnchorFrame.id;
|
||||
|
||||
await serverRef.sendLoggingMessage({ level: 'info', data: 'buffered-1' });
|
||||
await serverRef.sendLoggingMessage({ level: 'warning', data: 'buffered-2' });
|
||||
await serverRef.sendLoggingMessage({ level: 'error', data: 'buffered-3' });
|
||||
|
||||
const resumeRes = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { ...baseHeaders, accept: 'application/json, text/event-stream', 'last-event-id': anchorId }
|
||||
});
|
||||
expect(resumeRes.status).toBe(200);
|
||||
|
||||
const resumeBody = await readSseFramesUntil(requireBody(resumeRes), 3);
|
||||
const replayedFrames = parseSseFrames(resumeBody);
|
||||
expect(replayedFrames.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
const logData = replayedFrames
|
||||
.map(f => {
|
||||
const msg = parseJSONRPCMessage(JSON.parse(f.data));
|
||||
if ('method' in msg && msg.method === 'notifications/message') {
|
||||
return msg.params?.data;
|
||||
}
|
||||
return;
|
||||
})
|
||||
.filter(d => d !== undefined);
|
||||
|
||||
expect(logData.slice(0, 3)).toEqual(['buffered-1', 'buffered-2', 'buffered-3']);
|
||||
expect(logData).not.toContain('anchor');
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('typescript:hosting:resume:bad-event-id', async (_args: TestArgs) => {
|
||||
const UNMAPPABLE_EVENT_ID = 'evt-unmappable';
|
||||
const THROWING_EVENT_ID = 'evt-replay-throws';
|
||||
|
||||
const scriptedEventStore: EventStore = {
|
||||
async storeEvent(streamId) {
|
||||
return `${streamId}_stored`;
|
||||
},
|
||||
async getStreamIdForEventId(eventId) {
|
||||
if (eventId === UNMAPPABLE_EVENT_ID) return;
|
||||
if (eventId === THROWING_EVENT_ID) return 'stream-will-throw';
|
||||
return;
|
||||
},
|
||||
async replayEventsAfter(lastEventId) {
|
||||
if (lastEventId === THROWING_EVENT_ID) {
|
||||
throw new Error('boom: replay failed');
|
||||
}
|
||||
return 'unused-stream';
|
||||
}
|
||||
};
|
||||
|
||||
const makeServer = () => new McpServer({ name: 's', version: '0' });
|
||||
|
||||
const handle = hostResumable(makeServer, { eventStore: scriptedEventStore });
|
||||
|
||||
const url = new URL('http://in-process/mcp');
|
||||
const fetch = (u: URL | string, init?: RequestInit) => handle.handleRequest(new Request(u, init));
|
||||
|
||||
const client = new Client({ name: 'c', version: '0' });
|
||||
const transport = new StreamableHTTPClientTransport(url, { fetch });
|
||||
|
||||
try {
|
||||
await client.connect(transport);
|
||||
|
||||
const sessionId = transport.sessionId;
|
||||
if (!sessionId) throw new Error('No session ID after connect');
|
||||
const baseHeaders = { 'mcp-session-id': sessionId };
|
||||
|
||||
const unmappedRes = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { ...baseHeaders, accept: 'application/json, text/event-stream', 'last-event-id': UNMAPPABLE_EVENT_ID }
|
||||
});
|
||||
expect(unmappedRes.status).toBe(400);
|
||||
const unmappedBody: unknown = await unmappedRes.json();
|
||||
expect(unmappedBody).toMatchObject({ error: { code: -32_000 } });
|
||||
|
||||
const failedRes = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { ...baseHeaders, accept: 'application/json, text/event-stream', 'last-event-id': THROWING_EVENT_ID }
|
||||
});
|
||||
expect(failedRes.status).toBe(500);
|
||||
const failedBody: unknown = await failedRes.json();
|
||||
expect(failedBody).toMatchObject({ error: { code: -32_000 } });
|
||||
} finally {
|
||||
await client.close();
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('hosting:resume:priming', async (_args: TestArgs) => {
|
||||
const RETRY_MS = 7331;
|
||||
const eventStore = new InMemoryEventStore();
|
||||
|
||||
const makeServer = () => new McpServer({ name: 's', version: '0' });
|
||||
|
||||
const handle = hostResumable(makeServer, {
|
||||
eventStore,
|
||||
retryInterval: RETRY_MS
|
||||
});
|
||||
|
||||
const url = new URL('http://in-process/mcp');
|
||||
const fetch = (u: URL | string, init?: RequestInit) => handle.handleRequest(new Request(u, init));
|
||||
|
||||
try {
|
||||
const { baseHeaders } = await completeHandshake(url, fetch);
|
||||
|
||||
const postRes = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { ...baseHeaders, 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 'priming-probe',
|
||||
method: 'tools/list',
|
||||
params: {}
|
||||
})
|
||||
});
|
||||
|
||||
expect(postRes.status).toBe(200);
|
||||
expect(postRes.headers.get('content-type')).toMatch(/text\/event-stream/);
|
||||
|
||||
const body = await readSseBody(requireBody(postRes));
|
||||
const events = body.split('\n\n').filter(e => e.trim().length > 0);
|
||||
expect(events.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const priming = events[0];
|
||||
if (priming === undefined) throw new Error('No priming event block in POST SSE body');
|
||||
const primingLines = priming.split('\n');
|
||||
|
||||
const idLine = primingLines.find(l => l.startsWith('id:'));
|
||||
expect(idLine).toBeDefined();
|
||||
if (idLine === undefined) throw new Error('No id line in priming event');
|
||||
expect(idLine.slice('id:'.length).trim().length).toBeGreaterThan(0);
|
||||
|
||||
expect(primingLines).toContain(`retry: ${RETRY_MS}`);
|
||||
|
||||
const dataLine = primingLines.find(l => l.startsWith('data:'));
|
||||
expect(dataLine).toBeDefined();
|
||||
if (dataLine === undefined) throw new Error('No data line in priming event');
|
||||
expect(dataLine.replace(/^data:\s?/, '')).toBe('');
|
||||
|
||||
expect(priming).not.toMatch(/^event: message$/m);
|
||||
|
||||
const responseEvent = events.find(e => /^event: message$/m.test(e));
|
||||
expect(responseEvent).toBeDefined();
|
||||
if (responseEvent === undefined) throw new Error('No response event in POST SSE body');
|
||||
expect(events.indexOf(responseEvent)).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('hosting:resume:stream-scoped', async (_args: TestArgs) => {
|
||||
const eventStore = new InMemoryEventStore();
|
||||
|
||||
let serverRef: McpServer | undefined;
|
||||
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' }, { capabilities: { logging: {} } });
|
||||
s.registerTool('progress', { inputSchema: z.object({ steps: z.number().int().positive() }) }, async ({ steps }, ctx) => {
|
||||
const token = ctx.mcpReq._meta?.progressToken;
|
||||
if (token !== undefined) {
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
await ctx.mcpReq.notify({
|
||||
method: 'notifications/progress',
|
||||
params: { progressToken: token, progress: i, total: steps }
|
||||
});
|
||||
}
|
||||
}
|
||||
return { content: [{ type: 'text', text: `done ${steps}` }] };
|
||||
});
|
||||
serverRef = s;
|
||||
return s;
|
||||
};
|
||||
|
||||
const handle = hostResumable(makeServer, { eventStore });
|
||||
|
||||
const url = new URL('http://in-process/mcp');
|
||||
const fetch = (u: URL | string, init?: RequestInit) => handle.handleRequest(new Request(u, init));
|
||||
|
||||
try {
|
||||
const initRes = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 'init-1',
|
||||
method: 'initialize',
|
||||
params: { protocolVersion: '2024-11-05', capabilities: { logging: {} }, clientInfo: { name: 'c', version: '0' } }
|
||||
})
|
||||
});
|
||||
expect(initRes.status).toBe(200);
|
||||
const sessionId = initRes.headers.get('mcp-session-id');
|
||||
expect(sessionId).toBeTruthy();
|
||||
if (!sessionId) throw new Error('No session ID in initialize response');
|
||||
|
||||
await readSseBody(requireBody(initRes));
|
||||
|
||||
const baseHeaders = { 'mcp-session-id': sessionId };
|
||||
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { ...baseHeaders, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'notifications/initialized',
|
||||
params: {}
|
||||
})
|
||||
});
|
||||
|
||||
const getRes = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { ...baseHeaders, accept: 'application/json, text/event-stream' }
|
||||
});
|
||||
expect(getRes.status).toBe(200);
|
||||
|
||||
const reader = requireBody(getRes).getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let anchorBuf = '';
|
||||
|
||||
if (!serverRef) throw new Error('serverRef not set');
|
||||
await serverRef.sendLoggingMessage({ level: 'info', data: 'anchor' });
|
||||
|
||||
try {
|
||||
for (let i = 0; i < 32; i++) {
|
||||
const { value, done } = await reader.read();
|
||||
if (value) anchorBuf += decoder.decode(value, { stream: true });
|
||||
const frames = parseSseFrames(anchorBuf);
|
||||
if (frames.length > 0) break;
|
||||
if (done) break;
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {});
|
||||
}
|
||||
|
||||
const anchorFrames = parseSseFrames(anchorBuf);
|
||||
expect(anchorFrames.length).toBeGreaterThanOrEqual(1);
|
||||
const lastAnchorFrame = anchorFrames.at(-1);
|
||||
if (!lastAnchorFrame) throw new Error('No anchor frame on the standalone GET stream');
|
||||
const anchorId = lastAnchorFrame.id;
|
||||
const stored = eventStore.getStoredEvents();
|
||||
const anchorEvent = stored.find(e => e.id === anchorId);
|
||||
expect(anchorEvent).toBeDefined();
|
||||
if (!anchorEvent) throw new Error('Anchor event not found in event store');
|
||||
const streamAId = anchorEvent.streamId;
|
||||
|
||||
const postRes = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { ...baseHeaders, 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 'prog-1',
|
||||
method: 'tools/call',
|
||||
params: { name: 'progress', arguments: { steps: 2 }, _meta: { progressToken: 'prog-1' } }
|
||||
})
|
||||
});
|
||||
expect(postRes.status).toBe(200);
|
||||
await readSseBody(requireBody(postRes));
|
||||
|
||||
const storedAfterProgress = eventStore.getStoredEvents();
|
||||
const streamBEvents = storedAfterProgress.filter(e => e.streamId !== streamAId);
|
||||
expect(streamBEvents.length).toBeGreaterThan(0);
|
||||
|
||||
await serverRef.sendLoggingMessage({ level: 'info', data: 'after-1' });
|
||||
await serverRef.sendLoggingMessage({ level: 'warning', data: 'after-2' });
|
||||
|
||||
const resumeRes = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { ...baseHeaders, accept: 'application/json, text/event-stream', 'last-event-id': anchorId }
|
||||
});
|
||||
expect(resumeRes.status).toBe(200);
|
||||
|
||||
const resumeReader = requireBody(resumeRes).getReader();
|
||||
const resumeDecoder = new TextDecoder();
|
||||
let resumeBuf = '';
|
||||
const readResumedUntil = async (haveEnough: (frames: ReturnType<typeof parseSseFrames>) => boolean) => {
|
||||
for (let i = 0; i < 64 && !haveEnough(parseSseFrames(resumeBuf)); i++) {
|
||||
const { value, done } = await resumeReader.read();
|
||||
if (value) resumeBuf += resumeDecoder.decode(value, { stream: true });
|
||||
if (done) break;
|
||||
}
|
||||
return parseSseFrames(resumeBuf);
|
||||
};
|
||||
|
||||
try {
|
||||
const replayedFrames = await readResumedUntil(frames => frames.length >= 2);
|
||||
expect(replayedFrames.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const replayedEvents = replayedFrames.map(f => ({
|
||||
id: f.id,
|
||||
message: parseJSONRPCMessage(JSON.parse(f.data))
|
||||
}));
|
||||
|
||||
const storedFinal = eventStore.getStoredEvents();
|
||||
const eventIdToStream = new Map(storedFinal.map(e => [e.id, e.streamId]));
|
||||
for (const r of replayedEvents) {
|
||||
expect(eventIdToStream.get(r.id)).toBe(streamAId);
|
||||
}
|
||||
|
||||
const replayedMethods = replayedEvents
|
||||
.map(r => ('method' in r.message ? r.message.method : undefined))
|
||||
.filter((m): m is string => m !== undefined);
|
||||
expect(replayedMethods).not.toContain('notifications/progress');
|
||||
|
||||
const logData = replayedEvents
|
||||
.map(r => {
|
||||
if ('method' in r.message && r.message.method === 'notifications/message') {
|
||||
return r.message.params?.data;
|
||||
}
|
||||
return;
|
||||
})
|
||||
.filter(d => d !== undefined);
|
||||
expect(logData.slice(0, 2)).toEqual(['after-1', 'after-2']);
|
||||
expect(logData).not.toContain('anchor');
|
||||
|
||||
const livePostRes = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { ...baseHeaders, 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 'live-1',
|
||||
method: 'tools/call',
|
||||
params: { name: 'progress', arguments: { steps: 2 }, _meta: { progressToken: 'live-1' } }
|
||||
})
|
||||
});
|
||||
expect(livePostRes.status).toBe(200);
|
||||
expect(livePostRes.headers.get('content-type')).toMatch(/text\/event-stream/);
|
||||
|
||||
const livePostBody = await readSseBody(requireBody(livePostRes));
|
||||
const liveMessages = parseSseFrames(livePostBody)
|
||||
.filter(f => f.data.trim().length > 0)
|
||||
.map(f => parseJSONRPCMessage(JSON.parse(f.data)));
|
||||
expect(liveMessages.filter(m => 'method' in m && m.method === 'notifications/progress')).toHaveLength(2);
|
||||
expect(liveMessages.filter(m => 'id' in m && m.id === 'live-1')).toHaveLength(1);
|
||||
|
||||
await serverRef.sendLoggingMessage({ level: 'info', data: 'sentinel' });
|
||||
const resumedFrames = await readResumedUntil(frames =>
|
||||
frames.some(f => {
|
||||
const m = parseJSONRPCMessage(JSON.parse(f.data));
|
||||
return 'method' in m && m.method === 'notifications/message' && m.params?.data === 'sentinel';
|
||||
})
|
||||
);
|
||||
const resumedMessages = resumedFrames.map(f => parseJSONRPCMessage(JSON.parse(f.data)));
|
||||
expect(resumedMessages.some(m => 'method' in m && m.method === 'notifications/message' && m.params?.data === 'sentinel')).toBe(
|
||||
true
|
||||
);
|
||||
expect(resumedMessages.filter(m => 'method' in m && m.method === 'notifications/progress')).toHaveLength(0);
|
||||
expect(resumedMessages.filter(m => 'id' in m && m.id === 'live-1')).toHaveLength(0);
|
||||
} finally {
|
||||
await resumeReader.cancel().catch(() => {});
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('hosting:resume:close-stream', async (_args: TestArgs) => {
|
||||
const eventStore = new InMemoryEventStore();
|
||||
|
||||
const hooksSeen: Array<{ closeRequestStream: boolean; closeStandalone: boolean }> = [];
|
||||
let releaseTool: () => void = () => {};
|
||||
const toolGate = new Promise<void>(resolve => {
|
||||
releaseTool = resolve;
|
||||
});
|
||||
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('generate-report', { inputSchema: z.object({}) }, async (_input, ctx) => {
|
||||
hooksSeen.push({
|
||||
closeRequestStream: typeof ctx.http?.closeSSE === 'function',
|
||||
closeStandalone: typeof ctx.http?.closeStandaloneSSE === 'function'
|
||||
});
|
||||
ctx.http?.closeStandaloneSSE?.();
|
||||
ctx.http?.closeSSE?.();
|
||||
await toolGate;
|
||||
return { content: [{ type: 'text', text: 'report ready' }] };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const handle = hostResumable(makeServer, { eventStore });
|
||||
|
||||
const url = new URL('http://in-process/mcp');
|
||||
const fetch = (u: URL | string, init?: RequestInit) => handle.handleRequest(new Request(u, init));
|
||||
|
||||
try {
|
||||
const { baseHeaders } = await completeHandshake(url, fetch);
|
||||
|
||||
const standaloneRes = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { ...baseHeaders, accept: 'application/json, text/event-stream' }
|
||||
});
|
||||
expect(standaloneRes.status).toBe(200);
|
||||
expect(standaloneRes.headers.get('content-type')).toMatch(/text\/event-stream/);
|
||||
|
||||
const postRes = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { ...baseHeaders, 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 'close-1',
|
||||
method: 'tools/call',
|
||||
params: { name: 'generate-report', arguments: {} }
|
||||
})
|
||||
});
|
||||
expect(postRes.status).toBe(200);
|
||||
expect(postRes.headers.get('content-type')).toMatch(/text\/event-stream/);
|
||||
|
||||
await vi.waitFor(() => expect(hooksSeen).toHaveLength(1));
|
||||
expect(hooksSeen[0]).toEqual({ closeRequestStream: true, closeStandalone: true });
|
||||
|
||||
const postBody = await readSseBody(requireBody(postRes));
|
||||
const standaloneBody = await readSseBody(requireBody(standaloneRes));
|
||||
|
||||
expect(standaloneBody).toBe('');
|
||||
expect(postBody).not.toMatch(/^event: message$/m);
|
||||
|
||||
const primingId = postBody.match(/^id:\s*(.+)$/m)?.[1]?.trim();
|
||||
expect(primingId).toBeTruthy();
|
||||
if (!primingId) throw new Error('No priming event id in POST SSE body');
|
||||
|
||||
const resumeRes = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { ...baseHeaders, accept: 'application/json, text/event-stream', 'last-event-id': primingId }
|
||||
});
|
||||
expect(resumeRes.status).toBe(200);
|
||||
expect(resumeRes.headers.get('content-type')).toMatch(/text\/event-stream/);
|
||||
|
||||
releaseTool();
|
||||
|
||||
const resumeBody = await readSseBody(requireBody(resumeRes));
|
||||
const resumeFrames = parseSseFrames(resumeBody);
|
||||
expect(resumeFrames).toHaveLength(1);
|
||||
|
||||
const resumeFrame = resumeFrames[0];
|
||||
if (!resumeFrame) throw new Error('No frames on the resumed stream');
|
||||
const response: unknown = JSON.parse(resumeFrame.data);
|
||||
expect(response).toMatchObject({
|
||||
jsonrpc: '2.0',
|
||||
id: 'close-1',
|
||||
result: { content: [{ type: 'text', text: 'report ready' }] }
|
||||
});
|
||||
} finally {
|
||||
releaseTool();
|
||||
await handle.close();
|
||||
}
|
||||
});
|
||||
898
test/e2e/scenarios/hosting-session.test.ts
Normal file
898
test/e2e/scenarios/hosting-session.test.ts
Normal file
|
|
@ -0,0 +1,898 @@
|
|||
/**
|
||||
* Self-contained test bodies for the hosting-session surface.
|
||||
*
|
||||
* These tests exercise HTTP server-side semantics: session management
|
||||
* (create/reuse/delete), CORS headers, stateless vs. stateful hosting,
|
||||
* and in-flight request cancellation. Most tests build the hosting layer
|
||||
* directly with `hostPerSession()` or `hostStateless()` from helpers and
|
||||
* drive it with raw HTTP (new Request(...)) to assert status codes and
|
||||
* headers.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
|
||||
import { LATEST_PROTOCOL_VERSION, McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server';
|
||||
import cors from 'cors';
|
||||
import express from 'express';
|
||||
import { expect, vi } from 'vitest';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { startExpressMinimal } from '../helpers/express.js';
|
||||
import { hostPerSession, hostStateless, wire } from '../helpers/index.js';
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
import type { TestArgs } from '../types.js';
|
||||
|
||||
const newClient = () => new Client({ name: 'c', version: '0' });
|
||||
|
||||
function echoServer(): McpServer {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool(
|
||||
'echo',
|
||||
{ description: 'Echoes the input text back as a text content block.', inputSchema: z.object({ text: z.string() }) },
|
||||
({ text }) => ({ content: [{ type: 'text', text }] })
|
||||
);
|
||||
return s;
|
||||
}
|
||||
|
||||
verifies('hosting:session:create', async (_args: TestArgs) => {
|
||||
const initializedSessions: string[] = [];
|
||||
const server = echoServer();
|
||||
const tx = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: randomUUID,
|
||||
onsessioninitialized: id => void initializedSessions.push(id)
|
||||
});
|
||||
await server.connect(tx);
|
||||
const url = new URL('http://in-process/mcp');
|
||||
|
||||
try {
|
||||
const res = await tx.handleRequest(
|
||||
new Request(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'mcp-protocol-version': LATEST_PROTOCOL_VERSION,
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json, text/event-stream'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'c', version: '0' } }
|
||||
})
|
||||
})
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const sessionId = res.headers.get('mcp-session-id');
|
||||
if (sessionId === null) throw new Error('initialize response is missing the mcp-session-id header');
|
||||
expect(sessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
|
||||
expect(initializedSessions).toEqual([sessionId]);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('hosting:session:cors-expose', async (_args: TestArgs) => {
|
||||
const browserOrigin = 'http://dashboard.example.com';
|
||||
const servers: McpServer[] = [];
|
||||
|
||||
// The transport sets no CORS headers itself; the documented hosting layer is cors() with exposedHeaders.
|
||||
const router = express.Router();
|
||||
router.use(cors({ origin: browserOrigin, exposedHeaders: ['Mcp-Session-Id'] }));
|
||||
router.post('/mcp', async (req, res) => {
|
||||
const tx = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: randomUUID });
|
||||
const server = echoServer();
|
||||
await server.connect(tx);
|
||||
servers.push(server);
|
||||
await tx.handleRequest(req, res, req.body);
|
||||
});
|
||||
|
||||
await using host = await startExpressMinimal(router);
|
||||
|
||||
try {
|
||||
const res = await fetch(new URL('/mcp', host.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
origin: browserOrigin,
|
||||
'mcp-protocol-version': LATEST_PROTOCOL_VERSION,
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json, text/event-stream'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'c', version: '0' } }
|
||||
})
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('mcp-session-id')).not.toBeNull();
|
||||
expect(res.headers.get('access-control-allow-origin')).toBe(browserOrigin);
|
||||
|
||||
const exposeHeaders = res.headers.get('access-control-expose-headers');
|
||||
if (exposeHeaders === null) throw new Error('initialize response is missing the access-control-expose-headers header');
|
||||
expect(exposeHeaders.toLowerCase()).toContain('mcp-session-id');
|
||||
|
||||
await res.text();
|
||||
} finally {
|
||||
for (const server of servers) await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('hosting:session:reuse', async (_args: TestArgs) => {
|
||||
const host = hostPerSession(() => echoServer());
|
||||
const url = new URL('http://in-process/mcp');
|
||||
const fetch = (u: URL | string, init?: RequestInit) => host.handleRequest(new Request(u, init));
|
||||
|
||||
const client = newClient();
|
||||
const clientTx = new StreamableHTTPClientTransport(url, { fetch });
|
||||
await client.connect(clientTx);
|
||||
|
||||
try {
|
||||
const sessionId = clientTx.sessionId;
|
||||
expect(sessionId).toBeDefined();
|
||||
expect(typeof sessionId).toBe('string');
|
||||
|
||||
const r1 = await client.listTools();
|
||||
expect(r1.tools.map(t => t.name)).toContain('echo');
|
||||
|
||||
const r2 = await client.callTool({ name: 'echo', arguments: { text: 'reuse-test' } });
|
||||
expect(r2.content).toEqual([{ type: 'text', text: 'reuse-test' }]);
|
||||
|
||||
const stillSameSession = clientTx.sessionId;
|
||||
expect(stillSameSession).toBe(sessionId);
|
||||
} finally {
|
||||
await client.close();
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('hosting:session:unknown-id', async (_args: TestArgs) => {
|
||||
const { handleRequest, close } = hostPerSession(echoServer);
|
||||
try {
|
||||
const init = await handleRequest(
|
||||
new Request('http://in-process/mcp', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream' },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'c', version: '0' } }
|
||||
})
|
||||
})
|
||||
);
|
||||
expect(init.status).toBe(200);
|
||||
|
||||
const unknownId = randomUUID();
|
||||
const headers = {
|
||||
'mcp-session-id': unknownId,
|
||||
'mcp-protocol-version': LATEST_PROTOCOL_VERSION,
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json, text/event-stream'
|
||||
};
|
||||
const post = await handleRequest(
|
||||
new Request('http://in-process/mcp', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} })
|
||||
})
|
||||
);
|
||||
expect(post.status).toBe(404);
|
||||
|
||||
const get = await handleRequest(new Request('http://in-process/mcp', { method: 'GET', headers }));
|
||||
expect(get.status).toBe(404);
|
||||
|
||||
const del = await handleRequest(new Request('http://in-process/mcp', { method: 'DELETE', headers }));
|
||||
expect(del.status).toBe(404);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('hosting:session:missing-id', async (_args: TestArgs) => {
|
||||
// Initialize the transport first so the missing-header branch is hit, not the uninitialized-server branch.
|
||||
const server = echoServer();
|
||||
const tx = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: randomUUID });
|
||||
await server.connect(tx);
|
||||
const url = new URL('http://in-process/mcp');
|
||||
const headers = {
|
||||
'mcp-protocol-version': LATEST_PROTOCOL_VERSION,
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json, text/event-stream'
|
||||
};
|
||||
|
||||
try {
|
||||
const initRes = await tx.handleRequest(
|
||||
new Request(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'c', version: '0' } }
|
||||
})
|
||||
})
|
||||
);
|
||||
expect(initRes.status).toBe(200);
|
||||
expect(initRes.headers.get('mcp-session-id')).not.toBeNull();
|
||||
|
||||
const res = await tx.handleRequest(
|
||||
new Request(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} })
|
||||
})
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
const body: unknown = await res.json();
|
||||
if (typeof body !== 'object' || body === null || !('error' in body)) {
|
||||
throw new Error('400 response body does not contain a JSON-RPC error');
|
||||
}
|
||||
const rpcError = body.error;
|
||||
if (typeof rpcError !== 'object' || rpcError === null || !('code' in rpcError) || !('message' in rpcError)) {
|
||||
throw new Error('400 response error is missing code or message');
|
||||
}
|
||||
expect(rpcError.code).toBe(-32_000);
|
||||
expect(rpcError.message).toBe('Bad Request: Mcp-Session-Id header is required');
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('hosting:session:delete', async (_args: TestArgs) => {
|
||||
// hostPerSession owns its session callbacks, so build the per-session map inline to observe onsessionclosed.
|
||||
const closedSessions: string[] = [];
|
||||
const sessions = new Map<string, WebStandardStreamableHTTPServerTransport>();
|
||||
const handle = async (req: Request): Promise<Response> => {
|
||||
const sid = req.headers.get('mcp-session-id');
|
||||
const existing = sid ? sessions.get(sid) : undefined;
|
||||
if (existing) return existing.handleRequest(req);
|
||||
|
||||
const tx = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: randomUUID,
|
||||
onsessioninitialized: id => void sessions.set(id, tx),
|
||||
onsessionclosed: id => {
|
||||
closedSessions.push(id);
|
||||
sessions.delete(id);
|
||||
}
|
||||
});
|
||||
await echoServer().connect(tx);
|
||||
return tx.handleRequest(req);
|
||||
};
|
||||
|
||||
const url = new URL('http://in-process/mcp');
|
||||
const headers = {
|
||||
'mcp-protocol-version': LATEST_PROTOCOL_VERSION,
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json, text/event-stream'
|
||||
};
|
||||
|
||||
try {
|
||||
const initRes = await handle(
|
||||
new Request(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'c', version: '0' } }
|
||||
})
|
||||
})
|
||||
);
|
||||
expect(initRes.status).toBe(200);
|
||||
const sessionId = initRes.headers.get('mcp-session-id');
|
||||
if (sessionId === null) throw new Error('initialize response is missing the mcp-session-id header');
|
||||
expect([...sessions.keys()]).toEqual([sessionId]);
|
||||
|
||||
const listRes = await handle(
|
||||
new Request(url, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'mcp-session-id': sessionId },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} })
|
||||
})
|
||||
);
|
||||
expect(listRes.status).toBe(200);
|
||||
expect(closedSessions).toEqual([]);
|
||||
|
||||
const deleteRes = await handle(
|
||||
new Request(url, {
|
||||
method: 'DELETE',
|
||||
headers: { 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, 'mcp-session-id': sessionId }
|
||||
})
|
||||
);
|
||||
expect(deleteRes.status).toBe(200);
|
||||
expect(closedSessions).toEqual([sessionId]);
|
||||
expect(sessions.size).toBe(0);
|
||||
|
||||
// The old id no longer routes to a live transport once onsessionclosed removed it from the map.
|
||||
const reuseRes = await handle(
|
||||
new Request(url, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'mcp-session-id': sessionId },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} })
|
||||
})
|
||||
);
|
||||
expect(reuseRes.status).toBeGreaterThanOrEqual(400);
|
||||
} finally {
|
||||
for (const tx of sessions.values()) await tx.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('hosting:session:post-termination-404', async (_args: TestArgs) => {
|
||||
// The documented per-session hosting pattern is the layer that owns session lifetime, so termination is asserted through it.
|
||||
const { handleRequest, close } = hostPerSession(echoServer);
|
||||
const url = new URL('http://in-process/mcp');
|
||||
const headers = {
|
||||
'mcp-protocol-version': LATEST_PROTOCOL_VERSION,
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json, text/event-stream'
|
||||
};
|
||||
|
||||
try {
|
||||
const initRes = await handleRequest(
|
||||
new Request(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'c', version: '0' } }
|
||||
})
|
||||
})
|
||||
);
|
||||
expect(initRes.status).toBe(200);
|
||||
const sessionId = initRes.headers.get('mcp-session-id');
|
||||
if (sessionId === null) throw new Error('initialize response is missing the mcp-session-id header');
|
||||
|
||||
// The session answers a request before termination, so any later 404 is attributable to the DELETE alone.
|
||||
const liveRes = await handleRequest(
|
||||
new Request(url, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'mcp-session-id': sessionId },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} })
|
||||
})
|
||||
);
|
||||
expect(liveRes.status).toBe(200);
|
||||
|
||||
const deleteRes = await handleRequest(
|
||||
new Request(url, {
|
||||
method: 'DELETE',
|
||||
headers: { 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, 'mcp-session-id': sessionId }
|
||||
})
|
||||
);
|
||||
expect(deleteRes.status).toBe(200);
|
||||
|
||||
const staleHeaders = { ...headers, 'mcp-session-id': sessionId };
|
||||
const stalePost = await handleRequest(
|
||||
new Request(url, {
|
||||
method: 'POST',
|
||||
headers: staleHeaders,
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} })
|
||||
})
|
||||
);
|
||||
expect(stalePost.status).toBe(404);
|
||||
|
||||
const staleGet = await handleRequest(new Request(url, { method: 'GET', headers: staleHeaders }));
|
||||
expect(staleGet.status).toBe(404);
|
||||
|
||||
const staleDelete = await handleRequest(new Request(url, { method: 'DELETE', headers: staleHeaders }));
|
||||
expect(staleDelete.status).toBe(404);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('hosting:session:id-charset', async (_args: TestArgs) => {
|
||||
// The SDK has no default generator; its contract is emitting the configured generator's value verbatim in the header.
|
||||
const generatedIds = ['!session-0x21-low-boundary', '~session-0x7E-high-boundary', randomUUID()];
|
||||
const url = new URL('http://in-process/mcp');
|
||||
|
||||
for (const generatedId of generatedIds) {
|
||||
const server = echoServer();
|
||||
const tx = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => generatedId });
|
||||
await server.connect(tx);
|
||||
|
||||
try {
|
||||
const res = await tx.handleRequest(
|
||||
new Request(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'mcp-protocol-version': LATEST_PROTOCOL_VERSION,
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json, text/event-stream'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'c', version: '0' } }
|
||||
})
|
||||
})
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const headerValue = res.headers.get('mcp-session-id');
|
||||
if (headerValue === null) throw new Error('initialize response is missing the mcp-session-id header');
|
||||
expect(headerValue).toBe(generatedId);
|
||||
|
||||
for (const ch of headerValue) {
|
||||
const code = ch.codePointAt(0);
|
||||
if (code === undefined) throw new Error('session id iteration yielded an empty character');
|
||||
expect(code).toBeGreaterThanOrEqual(0x21);
|
||||
expect(code).toBeLessThanOrEqual(0x7e);
|
||||
}
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
verifies('hosting:session:reinitialize', async (_args: TestArgs) => {
|
||||
const host = hostPerSession(() => echoServer());
|
||||
const url = new URL('http://in-process/mcp');
|
||||
|
||||
const initReq = new Request(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'mcp-protocol-version': LATEST_PROTOCOL_VERSION,
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json, text/event-stream'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'c', version: '0' } }
|
||||
})
|
||||
});
|
||||
|
||||
try {
|
||||
const initRes = await host.handleRequest(initReq);
|
||||
expect(initRes.status).toBe(200);
|
||||
const sessionId = initRes.headers.get('mcp-session-id');
|
||||
if (sessionId === null) throw new Error('initialize response is missing the mcp-session-id header');
|
||||
|
||||
const reinitReq = new Request(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'mcp-session-id': sessionId,
|
||||
'mcp-protocol-version': LATEST_PROTOCOL_VERSION,
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json, text/event-stream'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
method: 'initialize',
|
||||
params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'c', version: '0' } }
|
||||
})
|
||||
});
|
||||
|
||||
const reinitRes = await host.handleRequest(reinitReq);
|
||||
expect(reinitRes.status).toBe(400);
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('hosting:session:isolation', async (_args: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
let visits = 0;
|
||||
s.registerTool(
|
||||
'record_visit',
|
||||
{ description: 'Increments and returns the visit counter for this session.', inputSchema: z.object({}) },
|
||||
() => {
|
||||
visits += 1;
|
||||
return { content: [{ type: 'text', text: `visits:${visits}` }] };
|
||||
}
|
||||
);
|
||||
return s;
|
||||
};
|
||||
|
||||
const host = hostPerSession(makeServer);
|
||||
const url = new URL('http://in-process/mcp');
|
||||
const fetch = (u: URL | string, init?: RequestInit) => host.handleRequest(new Request(u, init));
|
||||
|
||||
const clientA = newClient();
|
||||
const txA = new StreamableHTTPClientTransport(url, { fetch });
|
||||
await clientA.connect(txA);
|
||||
|
||||
const clientB = newClient();
|
||||
const txB = new StreamableHTTPClientTransport(url, { fetch });
|
||||
await clientB.connect(txB);
|
||||
|
||||
const sessionIdA = txA.sessionId;
|
||||
const sessionIdB = txB.sessionId;
|
||||
if (sessionIdA === undefined || sessionIdB === undefined) throw new Error('initialize did not assign a session id');
|
||||
expect(sessionIdA).not.toBe(sessionIdB);
|
||||
|
||||
try {
|
||||
const a1 = await clientA.callTool({ name: 'record_visit', arguments: {} });
|
||||
expect(a1.content).toEqual([{ type: 'text', text: 'visits:1' }]);
|
||||
const a2 = await clientA.callTool({ name: 'record_visit', arguments: {} });
|
||||
expect(a2.content).toEqual([{ type: 'text', text: 'visits:2' }]);
|
||||
|
||||
// B starts at 1: counter state accumulated in A's McpServer instance never leaks into B's.
|
||||
const b1 = await clientB.callTool({ name: 'record_visit', arguments: {} });
|
||||
expect(b1.content).toEqual([{ type: 'text', text: 'visits:1' }]);
|
||||
|
||||
await clientB.close();
|
||||
|
||||
const deleteRes = await host.handleRequest(
|
||||
new Request(url, {
|
||||
method: 'DELETE',
|
||||
headers: { 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, 'mcp-session-id': sessionIdB }
|
||||
})
|
||||
);
|
||||
expect(deleteRes.status).toBe(200);
|
||||
|
||||
const a3 = await clientA.callTool({ name: 'record_visit', arguments: {} });
|
||||
expect(a3.content).toEqual([{ type: 'text', text: 'visits:3' }]);
|
||||
|
||||
const reuseRes = await host.handleRequest(
|
||||
new Request(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'mcp-session-id': sessionIdB,
|
||||
'mcp-protocol-version': LATEST_PROTOCOL_VERSION,
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json, text/event-stream'
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 9, method: 'tools/list', params: {} })
|
||||
})
|
||||
);
|
||||
expect(reuseRes.status).toBeGreaterThanOrEqual(400);
|
||||
} finally {
|
||||
await clientA.close();
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('hosting:stateless:no-session-id', async (_args: TestArgs) => {
|
||||
const host = hostStateless(() => echoServer());
|
||||
const url = new URL('http://in-process/mcp');
|
||||
|
||||
const req = new Request(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'mcp-protocol-version': LATEST_PROTOCOL_VERSION,
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json, text/event-stream'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'c', version: '0' } }
|
||||
})
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await host.handleRequest(req);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const sessionId = res.headers.get('mcp-session-id');
|
||||
expect(sessionId).toBeNull();
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('hosting:stateless:concurrent-clients', async (_args: TestArgs) => {
|
||||
const host = hostStateless(() => echoServer());
|
||||
const url = new URL('http://in-process/mcp');
|
||||
const fetch = (u: URL | string, init?: RequestInit) => host.handleRequest(new Request(u, init));
|
||||
|
||||
const client1 = new Client({ name: 'c1', version: '0' });
|
||||
const client2 = new Client({ name: 'c2', version: '0' });
|
||||
const client3 = new Client({ name: 'c3', version: '0' });
|
||||
const clients = [client1, client2, client3];
|
||||
|
||||
try {
|
||||
await Promise.all(clients.map(c => c.connect(new StreamableHTTPClientTransport(url, { fetch }))));
|
||||
|
||||
const [r1, r2, r3] = await Promise.all([
|
||||
client1.callTool({ name: 'echo', arguments: { text: 'client-1' } }),
|
||||
client2.callTool({ name: 'echo', arguments: { text: 'client-2' } }),
|
||||
client3.callTool({ name: 'echo', arguments: { text: 'client-3' } })
|
||||
]);
|
||||
|
||||
expect(r1.content).toEqual([{ type: 'text', text: 'client-1' }]);
|
||||
expect(r2.content).toEqual([{ type: 'text', text: 'client-2' }]);
|
||||
expect(r3.content).toEqual([{ type: 'text', text: 'client-3' }]);
|
||||
} finally {
|
||||
await Promise.all(clients.map(c => c.close()));
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('hosting:stateless:get-delete-405', async (_args: TestArgs) => {
|
||||
const url = new URL('http://in-process/mcp');
|
||||
// hostStateless hand-rolls a 405 for non-POST before the SDK runs; hit the SDK transport directly so its own behavior is asserted.
|
||||
const handleStateless = async (req: Request): Promise<Response> => {
|
||||
const server = echoServer();
|
||||
const tx = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
||||
await server.connect(tx);
|
||||
try {
|
||||
return await tx.handleRequest(req);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
};
|
||||
|
||||
const getRes = await handleStateless(
|
||||
new Request(url, {
|
||||
method: 'GET',
|
||||
headers: { 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, accept: 'text/event-stream' }
|
||||
})
|
||||
);
|
||||
expect(getRes.status).toBe(405);
|
||||
|
||||
const deleteRes = await handleStateless(
|
||||
new Request(url, { method: 'DELETE', headers: { 'mcp-protocol-version': LATEST_PROTOCOL_VERSION } })
|
||||
);
|
||||
expect(deleteRes.status).toBe(405);
|
||||
});
|
||||
|
||||
verifies('hosting:stateless:progress-in-post-stream', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('progress-tool', { inputSchema: z.object({ steps: z.number() }) }, async ({ steps }, ctx) => {
|
||||
const token = ctx.mcpReq._meta?.progressToken;
|
||||
if (token !== undefined) {
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
await ctx.mcpReq.notify({
|
||||
method: 'notifications/progress',
|
||||
params: { progressToken: token, progress: i, total: steps }
|
||||
});
|
||||
}
|
||||
}
|
||||
return { content: [{ type: 'text', text: 'done' }] };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const progressEvents: Array<{ progress: number; total?: number }> = [];
|
||||
let receivedAtResolve = -1;
|
||||
|
||||
const result = await client
|
||||
.callTool(
|
||||
{ name: 'progress-tool', arguments: { steps: 3 } },
|
||||
{ onprogress: p => progressEvents.push({ progress: p.progress, total: p.total }) }
|
||||
)
|
||||
.then(res => {
|
||||
receivedAtResolve = progressEvents.length;
|
||||
return res;
|
||||
});
|
||||
|
||||
expect(receivedAtResolve).toBe(3);
|
||||
expect(progressEvents).toEqual([
|
||||
{ progress: 1, total: 3 },
|
||||
{ progress: 2, total: 3 },
|
||||
{ progress: 3, total: 3 }
|
||||
]);
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'done' }]);
|
||||
});
|
||||
|
||||
verifies('hosting:stateless:no-reuse', async (_args: TestArgs) => {
|
||||
// Build the transport directly: hostStateless creates a fresh transport per request, which would never hit the reuse guard.
|
||||
const server = echoServer();
|
||||
const tx = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
||||
await server.connect(tx);
|
||||
const url = new URL('http://in-process/mcp');
|
||||
|
||||
try {
|
||||
const initRes = await tx.handleRequest(
|
||||
new Request(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'mcp-protocol-version': LATEST_PROTOCOL_VERSION,
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json, text/event-stream'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'c', version: '0' } }
|
||||
})
|
||||
})
|
||||
);
|
||||
expect(initRes.status).toBe(200);
|
||||
|
||||
const secondReq = new Request(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'mcp-protocol-version': LATEST_PROTOCOL_VERSION,
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json, text/event-stream'
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} })
|
||||
});
|
||||
|
||||
await expect(tx.handleRequest(secondReq)).rejects.toThrow(/cannot be reused across requests/);
|
||||
} finally {
|
||||
await server.close();
|
||||
await tx.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('transport:streamable-http:stateless-restrictions', async (_args: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool(
|
||||
'needs-sampling',
|
||||
{ description: 'Asks the client LLM to draft a one-line status update.', inputSchema: z.object({}) },
|
||||
async () => {
|
||||
try {
|
||||
const draft = await s.server.createMessage({
|
||||
messages: [{ role: 'user', content: { type: 'text', text: 'Draft a one-line status update.' } }],
|
||||
maxTokens: 50
|
||||
});
|
||||
return { content: [{ type: 'text', text: JSON.stringify(draft.content) }] };
|
||||
} catch (error) {
|
||||
return { isError: true, content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }] };
|
||||
}
|
||||
}
|
||||
);
|
||||
return s;
|
||||
};
|
||||
|
||||
const host = hostStateless(makeServer);
|
||||
const url = new URL('http://in-process/mcp');
|
||||
const client = new Client({ name: 'c', version: '0' }, { capabilities: { sampling: {} } });
|
||||
// A real sampling handler proves any failure comes from the stateless hosting gap, not a missing client capability.
|
||||
client.setRequestHandler('sampling/createMessage', async () => ({
|
||||
model: 'mock-model',
|
||||
role: 'assistant',
|
||||
content: { type: 'text', text: 'A drafted status update.' }
|
||||
}));
|
||||
|
||||
try {
|
||||
await client.connect(new StreamableHTTPClientTransport(url, { fetch: (u, init) => host.handleRequest(new Request(u, init)) }));
|
||||
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const settled = client.callTool({ name: 'needs-sampling', arguments: {} }).then(
|
||||
value => ({ kind: 'resolved' as const, value }),
|
||||
(error: unknown) => ({ kind: 'rejected' as const, reason: error })
|
||||
);
|
||||
const outcome = await Promise.race([
|
||||
settled,
|
||||
new Promise<{ kind: 'pending' }>(resolve => {
|
||||
timer = setTimeout(() => resolve({ kind: 'pending' }), 1500);
|
||||
})
|
||||
]);
|
||||
clearTimeout(timer);
|
||||
|
||||
if (outcome.kind === 'pending') {
|
||||
throw new Error('tools/call never settled: server.createMessage() hangs in stateless mode instead of rejecting promptly');
|
||||
}
|
||||
if (outcome.kind === 'rejected') {
|
||||
throw new Error(`tools/call rejected instead of returning an isError result: ${String(outcome.reason)}`);
|
||||
}
|
||||
|
||||
const result = outcome.value;
|
||||
expect(result.isError).toBe(true);
|
||||
if (!Array.isArray(result.content)) throw new Error('tools/call result has no content array');
|
||||
expect(result.content).toHaveLength(1);
|
||||
const block: unknown = result.content[0];
|
||||
if (
|
||||
typeof block !== 'object' ||
|
||||
block === null ||
|
||||
!('type' in block) ||
|
||||
block.type !== 'text' ||
|
||||
!('text' in block) ||
|
||||
typeof block.text !== 'string'
|
||||
) {
|
||||
throw new Error('tools/call error content is not a single text block');
|
||||
}
|
||||
expect(block.text.length).toBeGreaterThan(0);
|
||||
expect(block.text).not.toMatch(/does not support sampling|method not found/i);
|
||||
} finally {
|
||||
await client.close();
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
verifies('hosting:session:delete-cancels-inflight', async (_args: TestArgs) => {
|
||||
const started: string[] = [];
|
||||
const aborted: string[] = [];
|
||||
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
// Resolves only when its abort signal fires, so the call stays in flight until DELETE cancels it.
|
||||
s.registerTool(
|
||||
'index_repository',
|
||||
{ description: 'Indexes a source repository for code search.', inputSchema: z.object({ repository: z.string() }) },
|
||||
({ repository }, ctx) =>
|
||||
new Promise(resolve => {
|
||||
started.push(repository);
|
||||
ctx.mcpReq.signal.addEventListener('abort', () => {
|
||||
aborted.push(repository);
|
||||
resolve({ content: [{ type: 'text', text: `${repository} indexing interrupted` }] });
|
||||
});
|
||||
})
|
||||
);
|
||||
return s;
|
||||
};
|
||||
|
||||
const host = hostPerSession(makeServer);
|
||||
const url = new URL('http://in-process/mcp');
|
||||
const headers = {
|
||||
'mcp-protocol-version': LATEST_PROTOCOL_VERSION,
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json, text/event-stream'
|
||||
};
|
||||
|
||||
try {
|
||||
const initRes = await host.handleRequest(
|
||||
new Request(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'c', version: '0' } }
|
||||
})
|
||||
})
|
||||
);
|
||||
expect(initRes.status).toBe(200);
|
||||
const sessionId = initRes.headers.get('mcp-session-id');
|
||||
if (sessionId === null) throw new Error('initialize response is missing the mcp-session-id header');
|
||||
|
||||
const callRequest = (id: number, repository: string) =>
|
||||
host.handleRequest(
|
||||
new Request(url, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'mcp-session-id': sessionId },
|
||||
body: JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
method: 'tools/call',
|
||||
params: { name: 'index_repository', arguments: { repository } }
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
const firstCall = await callRequest(2, 'docs-site');
|
||||
const secondCall = await callRequest(3, 'billing-service');
|
||||
expect(firstCall.status).toBe(200);
|
||||
expect(secondCall.status).toBe(200);
|
||||
expect(firstCall.headers.get('content-type')).toMatch(/text\/event-stream/);
|
||||
expect(secondCall.headers.get('content-type')).toMatch(/text\/event-stream/);
|
||||
|
||||
await vi.waitFor(() => expect(started.toSorted()).toEqual(['billing-service', 'docs-site']));
|
||||
expect(aborted).toEqual([]);
|
||||
|
||||
const deleteRes = await host.handleRequest(
|
||||
new Request(url, {
|
||||
method: 'DELETE',
|
||||
headers: { 'mcp-protocol-version': LATEST_PROTOCOL_VERSION, 'mcp-session-id': sessionId }
|
||||
})
|
||||
);
|
||||
expect(deleteRes.status).toBe(200);
|
||||
|
||||
await vi.waitFor(() => expect(aborted.toSorted()).toEqual(['billing-service', 'docs-site']));
|
||||
|
||||
// text() resolves only once the server ends the stream; no data event means no JSON-RPC response was written.
|
||||
const bodies = await Promise.all([firstCall.text(), secondCall.text()]);
|
||||
for (const body of bodies) {
|
||||
expect(body.split('\n').filter(line => line.startsWith('data:'))).toEqual([]);
|
||||
}
|
||||
} finally {
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
561
test/e2e/scenarios/lifecycle.test.ts
Normal file
561
test/e2e/scenarios/lifecycle.test.ts
Normal file
|
|
@ -0,0 +1,561 @@
|
|||
/**
|
||||
* Self-contained test bodies for the lifecycle surface.
|
||||
*
|
||||
* Lifecycle tests cover the initialize handshake, version negotiation,
|
||||
* the `notifications/initialized` ordering rule, ping, and the metadata
|
||||
* accessors on both ends (`getServerVersion`/`getServerCapabilities` on the
|
||||
* client, `getClientVersion`/`getClientCapabilities` on the server). Each
|
||||
* export is a {@link TestCase}: it builds its own server (via a factory),
|
||||
* builds its own client, wires them with {@link wire}, and asserts. Function
|
||||
* names mirror the requirement id in camelCase.
|
||||
*/
|
||||
|
||||
import { Client } from '@modelcontextprotocol/client';
|
||||
import type {
|
||||
ClientCapabilities,
|
||||
Implementation,
|
||||
InitializeRequest,
|
||||
JSONRPCMessage,
|
||||
ServerCapabilities
|
||||
} from '@modelcontextprotocol/server';
|
||||
import {
|
||||
isJSONRPCNotification,
|
||||
isJSONRPCRequest,
|
||||
isJSONRPCResultResponse,
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
McpServer,
|
||||
SdkError,
|
||||
SdkErrorCode,
|
||||
Server,
|
||||
SUPPORTED_PROTOCOL_VERSIONS
|
||||
} from '@modelcontextprotocol/server';
|
||||
import { expect } from 'vitest';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { tapWire, wire } from '../helpers/index.js';
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
import type { TestArgs } from '../types.js';
|
||||
|
||||
function olderSupportedVersion(): string {
|
||||
const older = SUPPORTED_PROTOCOL_VERSIONS.find(v => v !== LATEST_PROTOCOL_VERSION);
|
||||
if (older === undefined) throw new Error('expected SUPPORTED_PROTOCOL_VERSIONS to include a version other than the latest');
|
||||
return older;
|
||||
}
|
||||
|
||||
const OLDER_SUPPORTED_VERSION = olderSupportedVersion();
|
||||
const BOGUS_VERSION = '1999-01-01';
|
||||
|
||||
const DEFAULT_INSTRUCTIONS = 'This is the default server instruction set for lifecycle tests.';
|
||||
|
||||
function minimalClient() {
|
||||
return new Client({ name: 'minimal-client', version: '0.0.0' });
|
||||
}
|
||||
|
||||
function minimalServer(): McpServer {
|
||||
return new McpServer({ name: 'minimal-server', version: '0.0.0' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw `Server` whose initialize handler echoes the inbound request to
|
||||
* `received` and replies with the given `protocolVersion`. Used by the
|
||||
* version-negotiation tests so both sides of the handshake are observable
|
||||
* via public API only.
|
||||
*/
|
||||
function recordingInitServer(replyVersion: string, received: InitializeRequest['params'][]): Server {
|
||||
const s = new Server({ name: 's', version: '0' }, { capabilities: {} });
|
||||
s.setRequestHandler('initialize', async req => {
|
||||
received.push(req.params);
|
||||
return { protocolVersion: replyVersion, capabilities: {}, serverInfo: { name: 's', version: '0' } };
|
||||
});
|
||||
return s;
|
||||
}
|
||||
|
||||
interface HandshakeLogEntry {
|
||||
direction: 'client-to-server' | 'server-to-client';
|
||||
message: JSONRPCMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch `client.connect` so the transport `wire()` hands it is tapped from the
|
||||
* very first handshake message — `tapWire()` can only attach after connect, too
|
||||
* late to observe initialize. `rewriteOutbound` lets a test alter client→server
|
||||
* messages before they reach the wire (e.g. to request a protocol version the
|
||||
* SDK client would never ask for itself).
|
||||
*/
|
||||
function tapHandshake(client: Client, rewriteOutbound?: (message: JSONRPCMessage) => JSONRPCMessage): HandshakeLogEntry[] {
|
||||
const log: HandshakeLogEntry[] = [];
|
||||
const originalConnect = client.connect.bind(client);
|
||||
client.connect = (clientTransport, options) => {
|
||||
const originalSend = clientTransport.send.bind(clientTransport);
|
||||
clientTransport.send = (message, sendOptions) => {
|
||||
const outbound = rewriteOutbound ? rewriteOutbound(message) : message;
|
||||
log.push({ direction: 'client-to-server', message: outbound });
|
||||
return originalSend(outbound, sendOptions);
|
||||
};
|
||||
// Protocol.connect chains a pre-set onmessage, so inbound messages are logged before the client reacts to them.
|
||||
clientTransport.onmessage = message => {
|
||||
log.push({ direction: 'server-to-client', message });
|
||||
};
|
||||
return originalConnect(clientTransport, options);
|
||||
};
|
||||
return log;
|
||||
}
|
||||
|
||||
verifies('lifecycle:initialize:basic', async ({ transport }: TestArgs) => {
|
||||
const SERVER_CAPS: ServerCapabilities = { tools: { listChanged: true }, logging: {} };
|
||||
|
||||
const initReqs: InitializeRequest['params'][] = [];
|
||||
const makeServer = () => {
|
||||
const s = new Server({ name: 'lifecycle-server', version: '1.2.3' }, { capabilities: SERVER_CAPS });
|
||||
s.setRequestHandler('initialize', async req => {
|
||||
initReqs.push(req.params);
|
||||
return {
|
||||
protocolVersion: req.params.protocolVersion,
|
||||
capabilities: SERVER_CAPS,
|
||||
serverInfo: { name: 'lifecycle-server', version: '1.2.3' }
|
||||
};
|
||||
});
|
||||
return s;
|
||||
};
|
||||
const client = new Client({ name: 'lifecycle-client', version: '0.0.0' }, { capabilities: { roots: {} } });
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
// Server saw an InitializeRequest with all spec-mandated fields populated.
|
||||
expect(initReqs).toHaveLength(1);
|
||||
const initParams = initReqs[0];
|
||||
if (!initParams) throw new Error('expected the server to receive exactly one initialize request');
|
||||
expect(initParams.protocolVersion).toBe(LATEST_PROTOCOL_VERSION);
|
||||
expect(initParams.clientInfo).toEqual({ name: 'lifecycle-client', version: '0.0.0' });
|
||||
expect(initParams.capabilities).toEqual({ roots: {} });
|
||||
|
||||
// Client surfaces what the server returned.
|
||||
expect(client.getServerVersion()).toEqual({ name: 'lifecycle-server', version: '1.2.3' });
|
||||
expect(client.getServerCapabilities()).toEqual(SERVER_CAPS);
|
||||
});
|
||||
|
||||
verifies('lifecycle:initialize:instructions', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => new McpServer({ name: 's', version: '0' }, { instructions: DEFAULT_INSTRUCTIONS });
|
||||
const client = minimalClient();
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
expect(client.getInstructions()).toBe(DEFAULT_INSTRUCTIONS);
|
||||
});
|
||||
|
||||
verifies('lifecycle:initialized-notification', async ({ transport }: TestArgs) => {
|
||||
const order: string[] = [];
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' }, { capabilities: { tools: {} } });
|
||||
s.server.oninitialized = () => order.push('initialized');
|
||||
s.registerTool('marker', { inputSchema: z.object({}) }, () => {
|
||||
order.push('request');
|
||||
return { content: [{ type: 'text', text: 'ok' }] };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
const client = minimalClient();
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
expect(order).toContain('initialized');
|
||||
|
||||
await client.callTool({ name: 'marker', arguments: {} });
|
||||
|
||||
// Wherever a request arrived, the immediately-preceding event in the
|
||||
// server-side order log is the initialized hook firing — i.e., the client
|
||||
// sent notifications/initialized before any other request.
|
||||
const reqIdx = order.indexOf('request');
|
||||
expect(reqIdx).toBeGreaterThan(0);
|
||||
expect(order[reqIdx - 1]).toBe('initialized');
|
||||
});
|
||||
|
||||
verifies('lifecycle:pre-initialization-ordering', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 'weather-server', version: '1.0.0' });
|
||||
s.registerTool('get_weather', { inputSchema: z.object({ city: z.string() }) }, ({ city }) => ({
|
||||
content: [{ type: 'text', text: `Sunny in ${city}` }]
|
||||
}));
|
||||
return s;
|
||||
};
|
||||
const client = minimalClient();
|
||||
const log = tapHandshake(client);
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const result = await client.callTool({ name: 'get_weather', arguments: { city: 'Berlin' } });
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'Sunny in Berlin' }]);
|
||||
|
||||
const requestMethods = new Map<string | number, string>();
|
||||
const summary = log.map(({ direction, message }) => {
|
||||
if (isJSONRPCRequest(message)) {
|
||||
requestMethods.set(message.id, message.method);
|
||||
return `${direction} request ${message.method}`;
|
||||
}
|
||||
if (isJSONRPCNotification(message)) return `${direction} notification ${message.method}`;
|
||||
if (isJSONRPCResultResponse(message)) return `${direction} result for ${requestMethods.get(message.id) ?? 'unknown request'}`;
|
||||
return `${direction} error response`;
|
||||
});
|
||||
|
||||
// Before initialization completes the client sends only initialize and the server only its result; the first feature request follows notifications/initialized.
|
||||
expect(summary).toEqual([
|
||||
'client-to-server request initialize',
|
||||
'server-to-client result for initialize',
|
||||
'client-to-server notification notifications/initialized',
|
||||
'client-to-server request tools/call',
|
||||
'server-to-client result for tools/call'
|
||||
]);
|
||||
});
|
||||
|
||||
verifies('lifecycle:ping', async ({ transport }: TestArgs) => {
|
||||
const client = minimalClient();
|
||||
await using _ = await wire(transport, minimalServer, client);
|
||||
|
||||
const tap = tapWire(client);
|
||||
const result = await client.ping();
|
||||
expect(result).toEqual({});
|
||||
|
||||
const req = tap.sent.find(m => isJSONRPCRequest(m) && m.method === 'ping');
|
||||
expect(req).toBeDefined();
|
||||
if (!req || !isJSONRPCRequest(req)) throw new Error('expected ping request');
|
||||
|
||||
const res = tap.received.find(m => isJSONRPCResultResponse(m) && m.id === req.id);
|
||||
if (!res || !isJSONRPCResultResponse(res)) throw new Error('expected ping result');
|
||||
expect(res.result).toEqual({});
|
||||
});
|
||||
|
||||
verifies('lifecycle:version:match', async ({ transport }: TestArgs) => {
|
||||
const initReqs: InitializeRequest['params'][] = [];
|
||||
const makeServer = () => recordingInitServer(LATEST_PROTOCOL_VERSION, initReqs);
|
||||
const client = minimalClient();
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
expect(initReqs).toHaveLength(1);
|
||||
const initParams = initReqs[0];
|
||||
if (!initParams) throw new Error('expected the server to receive exactly one initialize request');
|
||||
expect(initParams.protocolVersion).toBe(LATEST_PROTOCOL_VERSION);
|
||||
|
||||
// Connect succeeded at the matched version: server state is populated.
|
||||
expect(client.getServerCapabilities()).toEqual({});
|
||||
expect(client.getServerVersion()).toEqual({ name: 's', version: '0' });
|
||||
});
|
||||
|
||||
verifies('lifecycle:version:downgrade', async ({ transport }: TestArgs) => {
|
||||
const initReqs: InitializeRequest['params'][] = [];
|
||||
const makeServer = () => recordingInitServer(OLDER_SUPPORTED_VERSION, initReqs);
|
||||
const client = minimalClient();
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
// Client requested LATEST; server replied with an older supported version;
|
||||
// connect resolved (no throw) and server state is populated, so the client
|
||||
// accepted the downgrade. There is no transport-agnostic SDK getter for the
|
||||
// negotiated version (`client.transport.protocolVersion` is HTTP-only).
|
||||
expect(initReqs).toHaveLength(1);
|
||||
const initParams = initReqs[0];
|
||||
if (!initParams) throw new Error('expected the server to receive exactly one initialize request');
|
||||
expect(initParams.protocolVersion).toBe(LATEST_PROTOCOL_VERSION);
|
||||
expect(OLDER_SUPPORTED_VERSION).not.toBe(LATEST_PROTOCOL_VERSION);
|
||||
expect(client.getServerCapabilities()).toEqual({});
|
||||
expect(client.getServerVersion()).toEqual({ name: 's', version: '0' });
|
||||
});
|
||||
|
||||
verifies('lifecycle:version:server-fallback-latest', async ({ transport }: TestArgs) => {
|
||||
const client = minimalClient();
|
||||
// The SDK client only ever requests the latest version, so the unsupported version is injected into the outbound initialize; the server runs the SDK's default initialize handler.
|
||||
const log = tapHandshake(client, message =>
|
||||
isJSONRPCRequest(message) && message.method === 'initialize'
|
||||
? { ...message, params: { ...message.params, protocolVersion: BOGUS_VERSION } }
|
||||
: message
|
||||
);
|
||||
|
||||
expect(SUPPORTED_PROTOCOL_VERSIONS).not.toContain(BOGUS_VERSION);
|
||||
|
||||
await using _ = await wire(transport, minimalServer, client);
|
||||
|
||||
const initRequest = log.find(
|
||||
e => e.direction === 'client-to-server' && isJSONRPCRequest(e.message) && e.message.method === 'initialize'
|
||||
);
|
||||
if (!initRequest || !isJSONRPCRequest(initRequest.message)) throw new Error('expected an initialize request on the wire');
|
||||
expect(initRequest.message.params?.protocolVersion).toBe(BOGUS_VERSION);
|
||||
const initRequestId = initRequest.message.id;
|
||||
|
||||
const initResponse = log.find(
|
||||
e => e.direction === 'server-to-client' && isJSONRPCResultResponse(e.message) && e.message.id === initRequestId
|
||||
);
|
||||
if (!initResponse || !isJSONRPCResultResponse(initResponse.message)) throw new Error('expected a result for the initialize request');
|
||||
expect(initResponse.message.result.protocolVersion).toBe(LATEST_PROTOCOL_VERSION);
|
||||
|
||||
// The client accepted the fallback version: initialization completed and server state is populated.
|
||||
expect(client.getServerVersion()).toEqual({ name: 'minimal-server', version: '0.0.0' });
|
||||
expect(client.getServerCapabilities()).toEqual({});
|
||||
});
|
||||
|
||||
verifies('lifecycle:version:reject-unsupported', async ({ transport }: TestArgs) => {
|
||||
const initReqs: InitializeRequest['params'][] = [];
|
||||
const makeServer = () => recordingInitServer(BOGUS_VERSION, initReqs);
|
||||
const client = minimalClient();
|
||||
|
||||
await expect(wire(transport, makeServer, client)).rejects.toThrow(/protocol version.*not supported|1999-01-01/i);
|
||||
|
||||
expect(client.transport).toBeUndefined();
|
||||
expect(client.getServerCapabilities()).toBeUndefined();
|
||||
expect(client.getServerVersion()).toBeUndefined();
|
||||
});
|
||||
|
||||
verifies('lifecycle:capability:client-not-declared', async ({ transport }: TestArgs) => {
|
||||
let observedCaps: ClientCapabilities | undefined;
|
||||
const makeServer = () => {
|
||||
const s = minimalServer();
|
||||
s.server.oninitialized = () => {
|
||||
observedCaps = s.server.getClientCapabilities();
|
||||
};
|
||||
return s;
|
||||
};
|
||||
const client = new Client({ name: 'no-caps-client', version: '0.0.0' }, { capabilities: {} });
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
// Client side: cannot send notifications / register handlers for undeclared caps.
|
||||
await expect(client.sendRootsListChanged()).rejects.toThrow(/roots.*list.?changed/i);
|
||||
expect(() =>
|
||||
client.setRequestHandler('sampling/createMessage', async () => ({
|
||||
role: 'assistant',
|
||||
content: { type: 'text', text: 'unreachable' },
|
||||
model: 'stub'
|
||||
}))
|
||||
).toThrow(/sampling/i);
|
||||
expect(() => client.setRequestHandler('elicitation/create', async () => ({ action: 'cancel' }))).toThrow(/elicitation/i);
|
||||
expect(() => client.setRequestHandler('roots/list', async () => ({ roots: [] }))).toThrow(/roots/i);
|
||||
|
||||
// Server side: it sees the empty client capabilities. (Server-side request
|
||||
// gating on these is opt-in via `enforceStrictCapabilities` and is covered
|
||||
// by the elicitation/sampling capability tests, not here.)
|
||||
expect(observedCaps).toEqual({});
|
||||
});
|
||||
|
||||
verifies('lifecycle:capability:server-not-advertised', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => new McpServer({ name: 's', version: '0' }, { capabilities: {} });
|
||||
const client = new Client({ name: 'c', version: '0' }, { enforceStrictCapabilities: true });
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const caps = client.getServerCapabilities();
|
||||
expect(caps).toEqual({});
|
||||
|
||||
const calls: Array<[string, () => Promise<unknown>]> = [
|
||||
['tools', () => client.listTools()],
|
||||
['resources', () => client.listResources()],
|
||||
['resources', () => client.listResourceTemplates()],
|
||||
['prompts', () => client.listPrompts()],
|
||||
['logging', () => client.setLoggingLevel('debug')],
|
||||
['completions', () => client.complete({ ref: { type: 'ref/prompt', name: 'x' }, argument: { name: 'a', value: '' } })]
|
||||
];
|
||||
for (const [cap, call] of calls) {
|
||||
await expect(call(), `${cap} should be gated`).rejects.toThrow(new RegExp(cap, 'i'));
|
||||
}
|
||||
});
|
||||
|
||||
verifies('lifecycle:initialize:capabilities:minimal', async ({ transport }: TestArgs) => {
|
||||
// Bare McpServer: no feature handlers registered and no capabilities option passed.
|
||||
const client = minimalClient();
|
||||
|
||||
await using _ = await wire(transport, minimalServer, client);
|
||||
|
||||
// Exactly empty: no tools, resources, prompts, completions, or logging advertised.
|
||||
expect(client.getServerCapabilities()).toEqual({});
|
||||
});
|
||||
|
||||
verifies('lifecycle:capability:experimental-passthrough', async ({ transport }: TestArgs) => {
|
||||
const SERVER_EXPERIMENTAL = {
|
||||
'x-vendor/streaming': { version: 2, modes: ['delta', 'full'] },
|
||||
'org.example.preview': { nested: { limit: 42 }, enabled: true }
|
||||
};
|
||||
const CLIENT_EXPERIMENTAL = {
|
||||
'x-vendor/telemetry': { level: 'debug', sinks: ['stdout'] }
|
||||
};
|
||||
|
||||
let observedClientCaps: ClientCapabilities | undefined;
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' }, { capabilities: { experimental: SERVER_EXPERIMENTAL } });
|
||||
s.server.oninitialized = () => {
|
||||
observedClientCaps = s.server.getClientCapabilities();
|
||||
};
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = new Client(
|
||||
{ name: 'c', version: '0' },
|
||||
{ capabilities: { roots: { listChanged: true }, experimental: CLIENT_EXPERIMENTAL } }
|
||||
);
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
// Server → client direction.
|
||||
const serverCaps = client.getServerCapabilities();
|
||||
expect(serverCaps?.experimental).toEqual(SERVER_EXPERIMENTAL);
|
||||
expect(serverCaps?.experimental?.['x-vendor/streaming']).toEqual({ version: 2, modes: ['delta', 'full'] });
|
||||
expect(serverCaps?.experimental?.['x-vendor/undeclared']).toBeUndefined();
|
||||
|
||||
// Client → server direction (symmetric).
|
||||
expect(observedClientCaps?.experimental).toEqual(CLIENT_EXPERIMENTAL);
|
||||
expect(observedClientCaps?.experimental?.['x-vendor/undeclared']).toBeUndefined();
|
||||
});
|
||||
|
||||
verifies('lifecycle:initialize:server-info', async ({ transport }: TestArgs) => {
|
||||
const extendedServerInfo: Implementation = {
|
||||
name: 'everything-extended',
|
||||
version: '1.2.3',
|
||||
title: 'Everything Server (Extended)',
|
||||
websiteUrl: 'https://example.com/everything',
|
||||
description: 'Reference everything-server with all optional Implementation fields populated.',
|
||||
icons: [
|
||||
{ src: 'https://example.com/icon-48.png', mimeType: 'image/png', sizes: ['48x48'] },
|
||||
{ src: 'https://example.com/icon.svg', mimeType: 'image/svg+xml', sizes: ['any'] }
|
||||
]
|
||||
};
|
||||
|
||||
const makeServer = () => new McpServer(extendedServerInfo);
|
||||
const client = minimalClient();
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
expect(client.getServerVersion()).toEqual(extendedServerInfo);
|
||||
});
|
||||
|
||||
verifies('lifecycle:initialize:client-info', async ({ transport }: TestArgs) => {
|
||||
let observed: { before: Implementation | undefined; after: Implementation | undefined } | undefined;
|
||||
const makeServer = () => {
|
||||
const s = minimalServer();
|
||||
const before = s.server.getClientVersion();
|
||||
s.server.oninitialized = () => {
|
||||
observed = { before, after: s.server.getClientVersion() };
|
||||
};
|
||||
return s;
|
||||
};
|
||||
const client = minimalClient();
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
expect(observed?.before).toBeUndefined();
|
||||
expect(observed?.after).toEqual({ name: 'minimal-client', version: '0.0.0' });
|
||||
});
|
||||
|
||||
verifies('typescript:server:get-client-capabilities', async ({ transport }: TestArgs) => {
|
||||
const DECLARED = {
|
||||
roots: { listChanged: true },
|
||||
sampling: {},
|
||||
experimental: { 'e2e-cap-marker': {} }
|
||||
};
|
||||
|
||||
let observed:
|
||||
| { before: ClientCapabilities | undefined; after: ClientCapabilities | undefined; second: ClientCapabilities | undefined }
|
||||
| undefined;
|
||||
const makeServer = () => {
|
||||
const s = minimalServer();
|
||||
const before = s.server.getClientCapabilities();
|
||||
s.server.oninitialized = () => {
|
||||
const after = s.server.getClientCapabilities();
|
||||
observed = { before, after, second: s.server.getClientCapabilities() };
|
||||
};
|
||||
return s;
|
||||
};
|
||||
const client = new Client({ name: 'caps-probe-client', version: '0.0.0' }, { capabilities: DECLARED });
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
expect(observed?.before).toBeUndefined();
|
||||
expect(observed?.after).toEqual(DECLARED);
|
||||
expect(observed?.after?.elicitation).toBeUndefined();
|
||||
// Stable reference across calls.
|
||||
expect(observed?.second).toBe(observed?.after);
|
||||
});
|
||||
|
||||
verifies('lifecycle:version:custom-supported-versions', async ({ transport }: TestArgs) => {
|
||||
// The server's first entry is the latest version, so the older negotiated version can only come from honoring the client's request.
|
||||
const makeServer = () =>
|
||||
new McpServer(
|
||||
{ name: 'custom-versions-server', version: '0.0.0' },
|
||||
{ supportedProtocolVersions: [LATEST_PROTOCOL_VERSION, OLDER_SUPPORTED_VERSION] }
|
||||
);
|
||||
const client = new Client(
|
||||
{ name: 'custom-versions-client', version: '0.0.0' },
|
||||
{ supportedProtocolVersions: [OLDER_SUPPORTED_VERSION] }
|
||||
);
|
||||
const log = tapHandshake(client);
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const initRequest = log.find(
|
||||
e => e.direction === 'client-to-server' && isJSONRPCRequest(e.message) && e.message.method === 'initialize'
|
||||
);
|
||||
if (!initRequest || !isJSONRPCRequest(initRequest.message)) throw new Error('expected an initialize request on the wire');
|
||||
// The override drives the requested version: a default client would have asked for the latest.
|
||||
expect(initRequest.message.params?.protocolVersion).toBe(OLDER_SUPPORTED_VERSION);
|
||||
const initRequestId = initRequest.message.id;
|
||||
|
||||
const initResponse = log.find(
|
||||
e => e.direction === 'server-to-client' && isJSONRPCResultResponse(e.message) && e.message.id === initRequestId
|
||||
);
|
||||
if (!initResponse || !isJSONRPCResultResponse(initResponse.message)) throw new Error('expected a result for the initialize request');
|
||||
// The server supports the requested version, so it echoes it back instead of falling back to its first entry.
|
||||
expect(initResponse.message.result.protocolVersion).toBe(OLDER_SUPPORTED_VERSION);
|
||||
|
||||
// After connect both sides settled on the older version: the client reports it and the connection is established.
|
||||
expect(client.getNegotiatedProtocolVersion()).toBe(OLDER_SUPPORTED_VERSION);
|
||||
expect(client.getServerVersion()).toEqual({ name: 'custom-versions-server', version: '0.0.0' });
|
||||
});
|
||||
|
||||
verifies('lifecycle:version:no-overlap-rejects', async ({ transport }: TestArgs) => {
|
||||
// The server only negotiates the latest version while the client only accepts the older one — no overlap.
|
||||
const makeServer = () =>
|
||||
new McpServer({ name: 'no-overlap-server', version: '0.0.0' }, { supportedProtocolVersions: [LATEST_PROTOCOL_VERSION] });
|
||||
const client = new Client({ name: 'no-overlap-client', version: '0.0.0' }, { supportedProtocolVersions: [OLDER_SUPPORTED_VERSION] });
|
||||
|
||||
await expect(wire(transport, makeServer, client)).rejects.toThrow(
|
||||
`Server's protocol version is not supported: ${LATEST_PROTOCOL_VERSION}`
|
||||
);
|
||||
|
||||
// The connection was never established: initialization state stays empty. (Transport closure itself is lifecycle:version:reject-unsupported's contract.)
|
||||
expect(client.getNegotiatedProtocolVersion()).toBeUndefined();
|
||||
expect(client.getServerCapabilities()).toBeUndefined();
|
||||
expect(client.getServerVersion()).toBeUndefined();
|
||||
});
|
||||
|
||||
verifies('lifecycle:capability:list-empty-when-not-advertised', async ({ transport }: TestArgs) => {
|
||||
// Bare McpServer with no registrations advertises no tools/prompts/resources capabilities.
|
||||
const client = minimalClient();
|
||||
|
||||
await using _ = await wire(transport, minimalServer, client);
|
||||
expect(client.getServerCapabilities()).toEqual({});
|
||||
|
||||
const tap = tapWire(client);
|
||||
await expect(client.listTools()).resolves.toEqual({ tools: [] });
|
||||
await expect(client.listPrompts()).resolves.toEqual({ prompts: [] });
|
||||
await expect(client.listResources()).resolves.toEqual({ resources: [] });
|
||||
await expect(client.listResourceTemplates()).resolves.toEqual({ resourceTemplates: [] });
|
||||
|
||||
// None of the four list calls put a request on the wire.
|
||||
expect(tap.sent).toEqual([]);
|
||||
});
|
||||
|
||||
verifies('lifecycle:capability:strict-mode-throws', async ({ transport }: TestArgs) => {
|
||||
const client = new Client({ name: 'strict-client', version: '0.0.0' }, { enforceStrictCapabilities: true });
|
||||
|
||||
await using _ = await wire(transport, minimalServer, client);
|
||||
expect(client.getServerCapabilities()).toEqual({});
|
||||
|
||||
const listCalls: Array<[string, () => Promise<unknown>]> = [
|
||||
['tools', () => client.listTools()],
|
||||
['prompts', () => client.listPrompts()],
|
||||
['resources', () => client.listResources()],
|
||||
['resources', () => client.listResourceTemplates()]
|
||||
];
|
||||
for (const [cap, call] of listCalls) {
|
||||
const pending = call();
|
||||
// Strict mode restores the v1 behavior: a capability error instead of an empty result.
|
||||
await expect(pending).rejects.toBeInstanceOf(SdkError);
|
||||
await expect(pending).rejects.toMatchObject({ code: SdkErrorCode.CapabilityNotSupported });
|
||||
await expect(pending).rejects.toThrow(new RegExp(`Server does not support ${cap}`));
|
||||
}
|
||||
});
|
||||
228
test/e2e/scenarios/logging.test.ts
Normal file
228
test/e2e/scenarios/logging.test.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
/**
|
||||
* Self-contained test bodies for the logging surface.
|
||||
*
|
||||
* Each export is a {@link TestCase}: it builds its own server (via a factory),
|
||||
* builds its own client, wires them with {@link wire}, and asserts. There are
|
||||
* no shared fixture imports; helpers local to multiple bodies live at the top
|
||||
* of this file.
|
||||
*
|
||||
* Function names mirror the requirement id in camelCase; a `Raw` suffix marks
|
||||
* a low-level {@link Server} variant where the behavior under test differs by
|
||||
* tier.
|
||||
*/
|
||||
|
||||
import { Client } from '@modelcontextprotocol/client';
|
||||
import type { LoggingLevel } from '@modelcontextprotocol/server';
|
||||
import { isJSONRPCRequest, McpServer, ProtocolError, ProtocolErrorCode, Server, specTypeSchemas } from '@modelcontextprotocol/server';
|
||||
import { expect, vi } from 'vitest';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { tapWire, wire } from '../helpers/index.js';
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
import type { TestArgs } from '../types.js';
|
||||
|
||||
const ALL_LEVELS: LoggingLevel[] = ['debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency'];
|
||||
|
||||
/** Plain client with no extra capabilities declared. */
|
||||
const newClient = () => new Client({ name: 'c', version: '0' });
|
||||
|
||||
/** McpServer factory that declares logging capability and can emit logs. */
|
||||
function loggingServer(): McpServer {
|
||||
const s = new McpServer({ name: 's', version: '0' }, { capabilities: { logging: {} } });
|
||||
s.registerTool('ping', { inputSchema: z.object({}) }, () => ({ content: [{ type: 'text', text: 'pong' }] }));
|
||||
return s;
|
||||
}
|
||||
|
||||
verifies('logging:capability:declared', async ({ transport }: TestArgs) => {
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, loggingServer, client);
|
||||
|
||||
expect(client.getServerCapabilities()?.logging).toEqual({});
|
||||
});
|
||||
|
||||
verifies('logging:message:fields', async ({ transport }: TestArgs) => {
|
||||
const logs: Array<{ level: LoggingLevel; logger?: string; data: unknown }> = [];
|
||||
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' }, { capabilities: { logging: {} } });
|
||||
s.registerTool('emit-logs', { inputSchema: z.object({ withLogger: z.boolean().optional() }) }, async ({ withLogger }, ctx) => {
|
||||
await (withLogger
|
||||
? ctx.mcpReq.notify({
|
||||
method: 'notifications/message',
|
||||
params: { level: 'info', logger: 'test-logger', data: 'with-logger' }
|
||||
})
|
||||
: ctx.mcpReq.notify({
|
||||
method: 'notifications/message',
|
||||
params: { level: 'warning', data: 'without-logger' }
|
||||
}));
|
||||
for (const level of ALL_LEVELS) {
|
||||
await ctx.mcpReq.notify({
|
||||
method: 'notifications/message',
|
||||
params: { level, logger: 'sweep', data: `level-${level}` }
|
||||
});
|
||||
}
|
||||
return { content: [{ type: 'text', text: 'done' }] };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = newClient();
|
||||
client.setNotificationHandler('notifications/message', n => {
|
||||
logs.push(n.params);
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
await client.setLoggingLevel('debug');
|
||||
await client.callTool({ name: 'emit-logs', arguments: { withLogger: true } });
|
||||
await client.callTool({ name: 'emit-logs', arguments: { withLogger: false } });
|
||||
|
||||
const sweep = ALL_LEVELS.map(level => ({ level, logger: 'sweep', data: `level-${level}` }));
|
||||
expect(logs).toEqual([
|
||||
{ level: 'info', logger: 'test-logger', data: 'with-logger' },
|
||||
...sweep,
|
||||
{ level: 'warning', data: 'without-logger' },
|
||||
...sweep
|
||||
]);
|
||||
expect(logs[1 + ALL_LEVELS.length]).not.toHaveProperty('logger');
|
||||
});
|
||||
|
||||
verifies('logging:message:all-levels', async ({ transport }: TestArgs) => {
|
||||
let server!: McpServer;
|
||||
const makeServer = () => {
|
||||
server = new McpServer({ name: 's', version: '0' }, { capabilities: { logging: {} } });
|
||||
server.registerTool('run-diagnostics', { inputSchema: z.object({}) }, async (_args, ctx) => {
|
||||
for (const level of ALL_LEVELS) {
|
||||
await server.sendLoggingMessage({ level, logger: 'diagnostics', data: `a ${level} event` }, ctx.sessionId);
|
||||
}
|
||||
return { content: [{ type: 'text', text: 'diagnostics complete' }] };
|
||||
});
|
||||
return server;
|
||||
};
|
||||
|
||||
const received: Array<{ level: LoggingLevel; logger?: string; data: unknown }> = [];
|
||||
const client = newClient();
|
||||
client.setNotificationHandler('notifications/message', n => {
|
||||
received.push(n.params);
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
// No logging/setLevel is sent: with no threshold configured, every severity must be deliverable.
|
||||
const result = await client.callTool({ name: 'run-diagnostics', arguments: {} });
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'diagnostics complete' }]);
|
||||
|
||||
await vi.waitFor(() => expect(received).toHaveLength(ALL_LEVELS.length));
|
||||
expect(received).toEqual(ALL_LEVELS.map(level => ({ level, logger: 'diagnostics', data: `a ${level} event` })));
|
||||
});
|
||||
|
||||
verifies(['logging:message:filtered', 'logging:set-level'], async ({ transport }: TestArgs) => {
|
||||
let server!: McpServer;
|
||||
const makeServer = () => {
|
||||
server = new McpServer({ name: 's', version: '0' }, { capabilities: { logging: {} } });
|
||||
server.registerTool('emit-all', { inputSchema: z.object({}) }, async (_args, ctx) => {
|
||||
for (const level of ALL_LEVELS) {
|
||||
await server.sendLoggingMessage({ level, data: level }, ctx.sessionId);
|
||||
}
|
||||
return { content: [{ type: 'text', text: 'done' }] };
|
||||
});
|
||||
return server;
|
||||
};
|
||||
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
for (const threshold of ALL_LEVELS) {
|
||||
const thresholdRank = ALL_LEVELS.indexOf(threshold);
|
||||
const logs: LoggingLevel[] = [];
|
||||
client.setNotificationHandler('notifications/message', n => {
|
||||
logs.push(n.params.level);
|
||||
});
|
||||
|
||||
await client.setLoggingLevel(threshold);
|
||||
await client.callTool({ name: 'emit-all', arguments: {} });
|
||||
|
||||
await vi.waitFor(() => expect(logs).toContain('emergency'));
|
||||
|
||||
expect(logs).toEqual(ALL_LEVELS.slice(thresholdRank));
|
||||
}
|
||||
});
|
||||
|
||||
verifies(
|
||||
'logging:set-level:invalid-level',
|
||||
async ({ transport }: TestArgs) => {
|
||||
const client = newClient();
|
||||
// strictValidation off: the invalid level must actually reach the server for it to reject.
|
||||
await using _ = await wire(transport, loggingServer, client, { strictValidation: false });
|
||||
|
||||
const tap = tapWire(client);
|
||||
|
||||
// @ts-expect-error — sending an invalid enum value is the point of this test.
|
||||
await expect(client.setLoggingLevel('superduper')).rejects.toMatchObject({ code: ProtocolErrorCode.InvalidParams });
|
||||
expect(ProtocolErrorCode.InvalidParams).toBe(-32_602);
|
||||
|
||||
const sent = tap.sent.filter(m => isJSONRPCRequest(m) && m.method === 'logging/setLevel');
|
||||
expect(sent).toHaveLength(1);
|
||||
if (!isJSONRPCRequest(sent[0])) throw new Error('expected request');
|
||||
expect(sent[0].params).toEqual({ level: 'superduper' });
|
||||
},
|
||||
{ title: 'mcpserver' }
|
||||
);
|
||||
|
||||
verifies(
|
||||
'logging:set-level:invalid-level',
|
||||
async ({ transport }: TestArgs) => {
|
||||
// The user-shaped manual implementation of spec-correct invalid-level handling:
|
||||
// a handler registered with a loose params schema so the dispatch-time parse
|
||||
// succeeds, validating the level itself and throwing InvalidParams.
|
||||
const applied: LoggingLevel[] = [];
|
||||
const makeServer = () => {
|
||||
const s = new Server({ name: 's', version: '0' }, { capabilities: { logging: {} } });
|
||||
s.setRequestHandler('logging/setLevel', { params: z.object({ level: z.string() }) }, params => {
|
||||
const parsed = specTypeSchemas.LoggingLevel['~standard'].validate(params.level);
|
||||
if (parsed.issues !== undefined) {
|
||||
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid logging level: ${params.level}`);
|
||||
}
|
||||
applied.push(parsed.value);
|
||||
return {};
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client, { strictValidation: false });
|
||||
|
||||
await client.setLoggingLevel('warning');
|
||||
expect(applied).toEqual(['warning']);
|
||||
|
||||
// @ts-expect-error — sending an invalid enum value is the point of this test.
|
||||
await expect(client.setLoggingLevel('superduper')).rejects.toMatchObject({
|
||||
code: ProtocolErrorCode.InvalidParams,
|
||||
message: expect.stringContaining('superduper')
|
||||
});
|
||||
expect(applied).toEqual(['warning']);
|
||||
},
|
||||
{ title: 'raw server' }
|
||||
);
|
||||
|
||||
verifies('logging:out-of-band:basic', async ({ transport }: TestArgs) => {
|
||||
const received: Array<{ level: LoggingLevel; logger?: string; data: unknown }> = [];
|
||||
let server!: McpServer;
|
||||
const makeServer = () => {
|
||||
server = new McpServer({ name: 's', version: '0' }, { capabilities: { logging: {} } });
|
||||
return server;
|
||||
};
|
||||
|
||||
const client = newClient();
|
||||
client.setNotificationHandler('notifications/message', n => {
|
||||
received.push(n.params);
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
// No request is in flight: this is a server-initiated, out-of-band notification.
|
||||
await server.sendLoggingMessage({ level: 'info', logger: 'job-runner', data: 'nightly index rebuild started' });
|
||||
|
||||
await vi.waitFor(() => expect(received).toHaveLength(1));
|
||||
expect(received).toEqual([{ level: 'info', logger: 'job-runner', data: 'nightly index rebuild started' }]);
|
||||
});
|
||||
115
test/e2e/scenarios/pagination.test.ts
Normal file
115
test/e2e/scenarios/pagination.test.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* Self-contained test bodies for cursor-pagination behaviors that span all
|
||||
* paginated list operations (tools/list, resources/list,
|
||||
* resources/templates/list, prompts/list).
|
||||
*/
|
||||
|
||||
import { Client } from '@modelcontextprotocol/client';
|
||||
import type { Tool } from '@modelcontextprotocol/server';
|
||||
import { isJSONRPCRequest, McpServer, ProtocolError, ProtocolErrorCode, ResourceTemplate, Server } from '@modelcontextprotocol/server';
|
||||
import { expect } from 'vitest';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { tapWire, wire } from '../helpers/index.js';
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
import type { TestArgs } from '../types.js';
|
||||
|
||||
const newClient = () => new Client({ name: 'c', version: '0' });
|
||||
|
||||
verifies('pagination:invalid-cursor', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerTool('echo', { inputSchema: z.object({}) }, () => ({ content: [] }));
|
||||
s.registerResource('static', 'e2e://static', {}, uri => ({
|
||||
contents: [{ uri: uri.href, mimeType: 'text/plain', text: 'hi' }]
|
||||
}));
|
||||
s.registerResource('tpl', new ResourceTemplate('e2e://item/{id}', { list: undefined }), {}, uri => ({
|
||||
contents: [{ uri: uri.href, mimeType: 'text/plain', text: 'hi' }]
|
||||
}));
|
||||
s.registerPrompt('p', {}, () => ({ messages: [{ role: 'user', content: { type: 'text', text: 'hi' } }] }));
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const badCursor = 'not-a-cursor-the-server-ever-issued';
|
||||
const invalidParams = expect.objectContaining({ code: ProtocolErrorCode.InvalidParams });
|
||||
|
||||
await expect(client.listTools({ cursor: badCursor })).rejects.toBeInstanceOf(ProtocolError);
|
||||
await expect(client.listTools({ cursor: badCursor })).rejects.toEqual(invalidParams);
|
||||
await expect(client.listResources({ cursor: badCursor })).rejects.toEqual(invalidParams);
|
||||
await expect(client.listResourceTemplates({ cursor: badCursor })).rejects.toEqual(invalidParams);
|
||||
await expect(client.listPrompts({ cursor: badCursor })).rejects.toEqual(invalidParams);
|
||||
});
|
||||
|
||||
verifies('pagination:client:cursor-handling', async ({ transport }: TestArgs) => {
|
||||
// Cursors are deliberately base64-padded / URL-unsafe / JSON-looking so any client-side parsing or normalization would corrupt them.
|
||||
const cursorToPage2 = 'eyJvZmZzZXQiOjMsInYiOjF9==/page-2?after=get_alerts';
|
||||
const cursorToPage3 = 'YWZ0ZXI+Y29udmVydF91bml0cw==';
|
||||
// Page sizes of 3, 1, 2 prove the client follows nextCursor only and assumes no fixed page size.
|
||||
const pages = new Map<string | undefined, { tools: Tool[]; nextCursor?: string }>([
|
||||
[
|
||||
undefined,
|
||||
{
|
||||
tools: [
|
||||
{ name: 'get_weather', inputSchema: { type: 'object', properties: { city: { type: 'string' } } } },
|
||||
{ name: 'get_forecast', inputSchema: { type: 'object', properties: { city: { type: 'string' } } } },
|
||||
{ name: 'get_alerts', inputSchema: { type: 'object', properties: { region: { type: 'string' } } } }
|
||||
],
|
||||
nextCursor: cursorToPage2
|
||||
}
|
||||
],
|
||||
[
|
||||
cursorToPage2,
|
||||
{
|
||||
tools: [{ name: 'convert_units', inputSchema: { type: 'object', properties: { value: { type: 'number' } } } }],
|
||||
nextCursor: cursorToPage3
|
||||
}
|
||||
],
|
||||
[
|
||||
cursorToPage3,
|
||||
{
|
||||
tools: [
|
||||
{ name: 'list_stations', inputSchema: { type: 'object', properties: {} } },
|
||||
{ name: 'get_station', inputSchema: { type: 'object', properties: { id: { type: 'string' } } } }
|
||||
]
|
||||
}
|
||||
]
|
||||
]);
|
||||
const receivedCursors: Array<string | undefined> = [];
|
||||
|
||||
const makeServer = () => {
|
||||
const s = new Server({ name: 's', version: '0' }, { capabilities: { tools: {} } });
|
||||
s.setRequestHandler('tools/list', req => {
|
||||
const cursor = req.params?.cursor;
|
||||
receivedCursors.push(cursor);
|
||||
const page = pages.get(cursor);
|
||||
if (!page) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Unknown cursor: ${String(cursor)}`);
|
||||
return { tools: page.tools, nextCursor: page.nextCursor };
|
||||
});
|
||||
return s;
|
||||
};
|
||||
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
const tap = tapWire(client);
|
||||
|
||||
const collectedPages: string[][] = [];
|
||||
let result = await client.listTools();
|
||||
collectedPages.push(result.tools.map(t => t.name));
|
||||
while (result.nextCursor !== undefined) {
|
||||
// A run-away loop means the test fixture, not the SDK, is broken — fail fast instead of hitting the suite timeout.
|
||||
if (collectedPages.length >= pages.size) throw new Error('nextCursor still present after the last page');
|
||||
result = await client.listTools({ cursor: result.nextCursor });
|
||||
collectedPages.push(result.tools.map(t => t.name));
|
||||
}
|
||||
|
||||
// The handler got back exactly the cursors it issued, and every page arrived once, in order.
|
||||
expect(receivedCursors).toEqual([undefined, cursorToPage2, cursorToPage3]);
|
||||
expect(collectedPages).toEqual([['get_weather', 'get_forecast', 'get_alerts'], ['convert_units'], ['list_stations', 'get_station']]);
|
||||
|
||||
// The wire requests carried the server-issued strings byte-for-byte — opaque, unparsed, unmodified.
|
||||
const wireListRequests = tap.sent.filter(m => isJSONRPCRequest(m)).filter(m => m.method === 'tools/list');
|
||||
expect(wireListRequests.map(m => m.params?.cursor)).toEqual([undefined, cursorToPage2, cursorToPage3]);
|
||||
});
|
||||
542
test/e2e/scenarios/prompts.test.ts
Normal file
542
test/e2e/scenarios/prompts.test.ts
Normal file
|
|
@ -0,0 +1,542 @@
|
|||
/**
|
||||
* Self-contained test bodies for the prompts surface.
|
||||
*
|
||||
* Each export is a {@link TestCase}: it builds its own server (via a factory),
|
||||
* builds its own client, wires them with {@link wire}, and asserts. Function
|
||||
* names mirror the requirement id in camelCase; a `Raw` suffix marks
|
||||
* a low-level {@link Server} variant where the behavior under test differs by
|
||||
* tier.
|
||||
*/
|
||||
|
||||
import { Client } from '@modelcontextprotocol/client';
|
||||
import type { RegisteredPrompt } from '@modelcontextprotocol/server';
|
||||
import { McpServer, ProtocolError, ProtocolErrorCode, Server } from '@modelcontextprotocol/server';
|
||||
import { expect, vi } from 'vitest';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
import { wire } from '../helpers/index.js';
|
||||
import { verifies } from '../helpers/verifies.js';
|
||||
import type { TestArgs } from '../types.js';
|
||||
|
||||
const TINY_PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
|
||||
const TINY_WAV_BASE64 = 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA=';
|
||||
const TINY_BLOB_BASE64 = 'SGVsbG8sIE1DUCE=';
|
||||
|
||||
const newClient = () => new Client({ name: 'c', version: '0' });
|
||||
|
||||
function summarizeServer(): McpServer {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerPrompt(
|
||||
'summarize',
|
||||
{ description: 'Summarize the provided text.', argsSchema: z.object({ text: z.string() }) },
|
||||
({ text }) => ({
|
||||
messages: [{ role: 'user', content: { type: 'text', text: `Summarize the following text:\n${text}` } }]
|
||||
})
|
||||
);
|
||||
return s;
|
||||
}
|
||||
|
||||
function explainCommitServer(): McpServer {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerPrompt('explain-last-commit', { description: 'Explain the most recent git commit.' }, () => ({
|
||||
messages: [{ role: 'user', content: { type: 'text', text: 'Explain the most recent commit in this repository.' } }]
|
||||
}));
|
||||
return s;
|
||||
}
|
||||
|
||||
verifies('prompts:capability:declared', async ({ transport }: TestArgs) => {
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, explainCommitServer, client);
|
||||
|
||||
const caps = client.getServerCapabilities();
|
||||
expect(caps?.prompts).toBeDefined();
|
||||
expect(caps?.prompts?.listChanged).toBe(true);
|
||||
});
|
||||
|
||||
verifies('prompts:list:basic', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerPrompt(
|
||||
'summarize',
|
||||
{ description: 'Summarize the provided text.', argsSchema: z.object({ text: z.string() }) },
|
||||
({ text }) => ({
|
||||
messages: [{ role: 'user', content: { type: 'text', text: `Summarize the following text:\n${text}` } }]
|
||||
})
|
||||
);
|
||||
s.registerPrompt(
|
||||
'code-review',
|
||||
{
|
||||
description: 'Review a code snippet in the given language, optionally focused on one aspect.',
|
||||
argsSchema: z.object({ language: z.enum(['ts', 'py']), focus: z.string().optional() })
|
||||
},
|
||||
({ language, focus }) => ({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: focus
|
||||
? `Review the following ${language} code, focusing on ${focus}:`
|
||||
: `Review the following ${language} code:`
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
s.registerPrompt('explain-last-commit', { description: 'Explain the most recent git commit.' }, () => ({
|
||||
messages: [{ role: 'user', content: { type: 'text', text: 'Explain the most recent commit in this repository.' } }]
|
||||
}));
|
||||
return s;
|
||||
};
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const result = await client.listPrompts();
|
||||
|
||||
expect(result.prompts).toHaveLength(3);
|
||||
expect(result.prompts.map(p => p.name).toSorted()).toEqual(['code-review', 'explain-last-commit', 'summarize']);
|
||||
|
||||
expect(result.prompts.find(p => p.name === 'summarize')).toMatchObject({
|
||||
name: 'summarize',
|
||||
description: 'Summarize the provided text.',
|
||||
arguments: [{ name: 'text', required: true }]
|
||||
});
|
||||
|
||||
const codeReview = result.prompts.find(p => p.name === 'code-review');
|
||||
expect(codeReview?.description).toBe('Review a code snippet in the given language, optionally focused on one aspect.');
|
||||
expect(codeReview?.arguments).toEqual([
|
||||
{ name: 'language', required: true },
|
||||
{ name: 'focus', required: false }
|
||||
]);
|
||||
|
||||
const explain = result.prompts.find(p => p.name === 'explain-last-commit');
|
||||
expect(explain?.description).toBe('Explain the most recent git commit.');
|
||||
expect(explain?.arguments ?? []).toHaveLength(0);
|
||||
});
|
||||
|
||||
verifies(
|
||||
'prompts:list:pagination',
|
||||
async ({ transport }: TestArgs) => {
|
||||
const TOTAL = 25;
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
for (let i = 0; i < TOTAL; i++) {
|
||||
s.registerPrompt(`bulk_${String(i).padStart(2, '0')}`, {}, () => ({ messages: [] }));
|
||||
}
|
||||
return s;
|
||||
};
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const first = await client.listPrompts();
|
||||
expect(first.prompts.length).toBeLessThan(TOTAL);
|
||||
expect(first.nextCursor).toBeDefined();
|
||||
|
||||
const seen = new Set(first.prompts.map(p => p.name));
|
||||
let result = first;
|
||||
let pages = 1;
|
||||
while (result.nextCursor !== undefined) {
|
||||
result = await client.listPrompts({ cursor: result.nextCursor });
|
||||
for (const p of result.prompts) seen.add(p.name);
|
||||
pages++;
|
||||
expect(pages).toBeLessThan(50);
|
||||
}
|
||||
expect(seen.size).toBe(TOTAL);
|
||||
expect(pages).toBeGreaterThan(1);
|
||||
},
|
||||
{ title: 'mcpserver' }
|
||||
);
|
||||
|
||||
verifies(
|
||||
'prompts:list:pagination',
|
||||
async ({ transport }: TestArgs) => {
|
||||
const TOTAL = 25;
|
||||
const PAGE = 10;
|
||||
const all = Array.from({ length: TOTAL }, (_, i) => `prompt_${String(i).padStart(2, '0')}`);
|
||||
const cursorsReceived: Array<string | undefined> = [];
|
||||
|
||||
const makeServer = () => {
|
||||
const s = new Server({ name: 's', version: '0' }, { capabilities: { prompts: {} } });
|
||||
s.setRequestHandler('prompts/list', req => {
|
||||
cursorsReceived.push(req.params?.cursor);
|
||||
const start = req.params?.cursor === undefined ? 0 : Number.parseInt(req.params.cursor, 10);
|
||||
const slice = all.slice(start, start + PAGE);
|
||||
return {
|
||||
prompts: slice.map(name => ({ name })),
|
||||
nextCursor: start + PAGE < TOTAL ? String(start + PAGE) : undefined
|
||||
};
|
||||
});
|
||||
s.setRequestHandler('prompts/get', req => ({
|
||||
messages: [{ role: 'user', content: { type: 'text', text: req.params.name } }]
|
||||
}));
|
||||
return s;
|
||||
};
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const cursorsSent: string[] = [];
|
||||
let pages = 0;
|
||||
let result = await client.listPrompts();
|
||||
expect(result.nextCursor).toBeDefined();
|
||||
for (;;) {
|
||||
for (const p of result.prompts) {
|
||||
expect(seen.has(p.name)).toBe(false);
|
||||
seen.add(p.name);
|
||||
}
|
||||
pages++;
|
||||
if (result.nextCursor === undefined) break;
|
||||
cursorsSent.push(result.nextCursor);
|
||||
result = await client.listPrompts({ cursor: result.nextCursor });
|
||||
expect(pages).toBeLessThan(50);
|
||||
}
|
||||
|
||||
expect(pages).toBe(3);
|
||||
expect(seen.size).toBe(TOTAL);
|
||||
for (const name of all) expect(seen.has(name)).toBe(true);
|
||||
|
||||
expect(cursorsReceived).toEqual([undefined, '10', '20']);
|
||||
expect(cursorsSent).toEqual(['10', '20']);
|
||||
},
|
||||
{ title: 'raw server' }
|
||||
);
|
||||
|
||||
verifies('prompts:list-changed', async ({ transport }: TestArgs) => {
|
||||
let server: McpServer | undefined;
|
||||
const makeServer = () => {
|
||||
server = new McpServer({ name: 's', version: '0' });
|
||||
server.registerPrompt('seed', {}, () => ({ messages: [] }));
|
||||
return server;
|
||||
};
|
||||
|
||||
let listChanged = 0;
|
||||
const client = newClient();
|
||||
client.setNotificationHandler('notifications/prompts/list_changed', () => {
|
||||
listChanged++;
|
||||
});
|
||||
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
if (server === undefined) throw new Error('wire() did not invoke the server factory');
|
||||
|
||||
const initial = await client.listPrompts();
|
||||
expect(initial.prompts).toHaveLength(1);
|
||||
|
||||
const handle = server.registerPrompt('dynamic-probe', {}, () => ({ messages: [] }));
|
||||
await vi.waitFor(() => expect(listChanged).toBeGreaterThanOrEqual(1));
|
||||
const afterAddList = await client.listPrompts();
|
||||
expect(afterAddList.prompts).toHaveLength(2);
|
||||
const afterAdd = listChanged;
|
||||
|
||||
handle.remove();
|
||||
await vi.waitFor(() => expect(listChanged).toBeGreaterThan(afterAdd));
|
||||
const afterRemoveList = await client.listPrompts();
|
||||
expect(afterRemoveList.prompts).toHaveLength(1);
|
||||
});
|
||||
|
||||
verifies('prompts:get:no-args', async ({ transport }: TestArgs) => {
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, explainCommitServer, client);
|
||||
|
||||
const result = await client.getPrompt({ name: 'explain-last-commit' });
|
||||
|
||||
expect(result.messages).toEqual([
|
||||
{ role: 'user', content: { type: 'text', text: 'Explain the most recent commit in this repository.' } }
|
||||
]);
|
||||
});
|
||||
|
||||
verifies('prompts:get:with-args', async ({ transport }: TestArgs) => {
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, summarizeServer, client);
|
||||
|
||||
const result = await client.getPrompt({
|
||||
name: 'summarize',
|
||||
arguments: { text: 'The quick brown fox jumps over the lazy dog.' }
|
||||
});
|
||||
|
||||
expect(result.messages).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: { type: 'text', text: 'Summarize the following text:\nThe quick brown fox jumps over the lazy dog.' }
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
verifies('prompts:get:multi-message', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerPrompt('geography-quiz', { description: 'A short multi-turn geography quiz.' }, () => ({
|
||||
messages: [
|
||||
{ role: 'user', content: { type: 'text', text: 'What is the capital of France?' } },
|
||||
{ role: 'assistant', content: { type: 'text', text: 'The capital of France is Paris.' } },
|
||||
{ role: 'user', content: { type: 'text', text: 'And of Italy?' } }
|
||||
]
|
||||
}));
|
||||
return s;
|
||||
};
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const result = await client.getPrompt({ name: 'geography-quiz' });
|
||||
|
||||
expect(result.messages).toEqual([
|
||||
{ role: 'user', content: { type: 'text', text: 'What is the capital of France?' } },
|
||||
{ role: 'assistant', content: { type: 'text', text: 'The capital of France is Paris.' } },
|
||||
{ role: 'user', content: { type: 'text', text: 'And of Italy?' } }
|
||||
]);
|
||||
});
|
||||
|
||||
verifies('prompts:get:missing-required-args', async ({ transport }: TestArgs) => {
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, summarizeServer, client);
|
||||
|
||||
await expect(client.getPrompt({ name: 'summarize', arguments: {} })).rejects.toMatchObject({
|
||||
code: ProtocolErrorCode.InvalidParams,
|
||||
message: expect.stringMatching(/text|required|invalid/i)
|
||||
});
|
||||
});
|
||||
|
||||
verifies('prompts:get:unknown-name', async ({ transport }: TestArgs) => {
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, explainCommitServer, client);
|
||||
|
||||
await expect(client.getPrompt({ name: 'no-such-prompt' })).rejects.toMatchObject({
|
||||
code: ProtocolErrorCode.InvalidParams,
|
||||
message: expect.stringMatching(/no-such-prompt|unknown|not found/i)
|
||||
});
|
||||
});
|
||||
|
||||
verifies('prompts:get:content:image', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerPrompt('describe-image', {}, () => ({
|
||||
messages: [
|
||||
{ role: 'user', content: { type: 'text', text: 'Describe what you see in this image.' } },
|
||||
{ role: 'user', content: { type: 'image', data: TINY_PNG_BASE64, mimeType: 'image/png' } }
|
||||
]
|
||||
}));
|
||||
return s;
|
||||
};
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const result = await client.getPrompt({ name: 'describe-image' });
|
||||
|
||||
expect(result.messages).toEqual([
|
||||
{ role: 'user', content: { type: 'text', text: 'Describe what you see in this image.' } },
|
||||
{ role: 'user', content: { type: 'image', data: TINY_PNG_BASE64, mimeType: 'image/png' } }
|
||||
]);
|
||||
});
|
||||
|
||||
verifies('prompts:get:content:audio', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerPrompt('transcribe-audio', {}, () => ({
|
||||
messages: [
|
||||
{ role: 'user', content: { type: 'text', text: 'Transcribe the following audio clip.' } },
|
||||
{ role: 'user', content: { type: 'audio', data: TINY_WAV_BASE64, mimeType: 'audio/wav' } }
|
||||
]
|
||||
}));
|
||||
return s;
|
||||
};
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const result = await client.getPrompt({ name: 'transcribe-audio' });
|
||||
|
||||
expect(result.messages).toEqual([
|
||||
{ role: 'user', content: { type: 'text', text: 'Transcribe the following audio clip.' } },
|
||||
{ role: 'user', content: { type: 'audio', data: TINY_WAV_BASE64, mimeType: 'audio/wav' } }
|
||||
]);
|
||||
});
|
||||
|
||||
verifies('prompts:get:content:embedded-resource', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerPrompt('review-file', { argsSchema: z.object({ kind: z.enum(['text', 'blob']) }) }, ({ kind }) => ({
|
||||
messages: [
|
||||
{ role: 'user', content: { type: 'text', text: 'Review the attached file.' } },
|
||||
{
|
||||
role: 'user',
|
||||
content:
|
||||
kind === 'text'
|
||||
? {
|
||||
type: 'resource',
|
||||
resource: { uri: 'file:///fixture.txt', mimeType: 'text/plain', text: 'embedded fixture text' }
|
||||
}
|
||||
: {
|
||||
type: 'resource',
|
||||
resource: { uri: 'file:///fixture.bin', mimeType: 'application/octet-stream', blob: TINY_BLOB_BASE64 }
|
||||
}
|
||||
}
|
||||
]
|
||||
}));
|
||||
return s;
|
||||
};
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const text = await client.getPrompt({ name: 'review-file', arguments: { kind: 'text' } });
|
||||
expect(text.messages).toEqual([
|
||||
{ role: 'user', content: { type: 'text', text: 'Review the attached file.' } },
|
||||
{
|
||||
role: 'user',
|
||||
content: { type: 'resource', resource: { uri: 'file:///fixture.txt', mimeType: 'text/plain', text: 'embedded fixture text' } }
|
||||
}
|
||||
]);
|
||||
|
||||
const blob = await client.getPrompt({ name: 'review-file', arguments: { kind: 'blob' } });
|
||||
expect(blob.messages).toEqual([
|
||||
{ role: 'user', content: { type: 'text', text: 'Review the attached file.' } },
|
||||
{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'resource',
|
||||
resource: { uri: 'file:///fixture.bin', mimeType: 'application/octet-stream', blob: TINY_BLOB_BASE64 }
|
||||
}
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
verifies('mcpserver:prompt:args-validation', async ({ transport }: TestArgs) => {
|
||||
const handlerCalls = { n: 0 };
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerPrompt(
|
||||
'code-review',
|
||||
{ argsSchema: z.object({ language: z.enum(['ts', 'py']), focus: z.string().optional() }) },
|
||||
({ language }) => {
|
||||
handlerCalls.n++;
|
||||
return { messages: [{ role: 'user', content: { type: 'text', text: `Review the following ${language} code:` } }] };
|
||||
}
|
||||
);
|
||||
return s;
|
||||
};
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const ok = await client.getPrompt({ name: 'code-review', arguments: { language: 'ts' } });
|
||||
expect(ok.messages).toEqual([{ role: 'user', content: { type: 'text', text: 'Review the following ts code:' } }]);
|
||||
expect(handlerCalls.n).toBe(1);
|
||||
|
||||
await expect(client.getPrompt({ name: 'code-review', arguments: { language: 'rust' } })).rejects.toMatchObject({
|
||||
code: ProtocolErrorCode.InvalidParams,
|
||||
message: expect.stringMatching(/invalid arguments.*code-review/i)
|
||||
});
|
||||
expect(handlerCalls.n).toBe(1);
|
||||
|
||||
await expect(client.getPrompt({ name: 'code-review', arguments: {} })).rejects.toMatchObject({
|
||||
code: ProtocolErrorCode.InvalidParams,
|
||||
message: expect.stringMatching(/invalid arguments/i)
|
||||
});
|
||||
expect(handlerCalls.n).toBe(1);
|
||||
});
|
||||
|
||||
verifies('mcpserver:prompt:optional-args', async ({ transport }: TestArgs) => {
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
s.registerPrompt(
|
||||
'summarize',
|
||||
{ argsSchema: z.object({ text: z.string(), max_words: z.string().optional() }) },
|
||||
({ text, max_words }) => ({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: max_words
|
||||
? `Summarize the following text in at most ${max_words} words:\n${text}`
|
||||
: `Summarize the following text:\n${text}`
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
return s;
|
||||
};
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
const withOpt = await client.getPrompt({ name: 'summarize', arguments: { text: 'lorem ipsum', max_words: '10' } });
|
||||
expect(withOpt.messages).toEqual([
|
||||
{ role: 'user', content: { type: 'text', text: 'Summarize the following text in at most 10 words:\nlorem ipsum' } }
|
||||
]);
|
||||
|
||||
const withoutOpt = await client.getPrompt({ name: 'summarize', arguments: { text: 'lorem ipsum' } });
|
||||
expect(withoutOpt.messages).toEqual([{ role: 'user', content: { type: 'text', text: 'Summarize the following text:\nlorem ipsum' } }]);
|
||||
});
|
||||
|
||||
verifies('mcpserver:prompt:duplicate-name', async ({ transport }: TestArgs) => {
|
||||
let dupError: unknown;
|
||||
const makeServer = () => {
|
||||
const s = explainCommitServer();
|
||||
s.registerPrompt('fresh', {}, () => ({ messages: [] }));
|
||||
try {
|
||||
s.registerPrompt('explain-last-commit', {}, () => ({ messages: [] }));
|
||||
} catch (error) {
|
||||
dupError = error;
|
||||
}
|
||||
return s;
|
||||
};
|
||||
const client = newClient();
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
|
||||
expect(dupError).toBeInstanceOf(Error);
|
||||
expect(String(dupError)).toMatch(/already registered/i);
|
||||
|
||||
const { prompts } = await client.listPrompts();
|
||||
expect(prompts.filter(p => p.name === 'explain-last-commit')).toHaveLength(1);
|
||||
expect(prompts.map(p => p.name)).toContain('fresh');
|
||||
const r = await client.getPrompt({ name: 'explain-last-commit' });
|
||||
expect(r.messages).toEqual([{ role: 'user', content: { type: 'text', text: 'Explain the most recent commit in this repository.' } }]);
|
||||
});
|
||||
|
||||
verifies('mcpserver:prompt:handle-update-remove', async ({ transport }: TestArgs) => {
|
||||
let handle: RegisteredPrompt | undefined;
|
||||
const makeServer = () => {
|
||||
const s = new McpServer({ name: 's', version: '0' });
|
||||
handle = s.registerPrompt('probe', { description: 'v1' }, () => ({
|
||||
messages: [{ role: 'user', content: { type: 'text', text: 'v1' } }]
|
||||
}));
|
||||
return s;
|
||||
};
|
||||
|
||||
let listChanged = 0;
|
||||
const client = newClient();
|
||||
client.setNotificationHandler('notifications/prompts/list_changed', () => {
|
||||
listChanged++;
|
||||
});
|
||||
await using _ = await wire(transport, makeServer, client);
|
||||
if (handle === undefined) throw new Error('wire() did not invoke the server factory');
|
||||
|
||||
const initialList = await client.listPrompts();
|
||||
expect(initialList.prompts).toHaveLength(1);
|
||||
const before = initialList.prompts.find(p => p.name === 'probe');
|
||||
expect(before?.description).toBe('v1');
|
||||
const initialGet = await client.getPrompt({ name: 'probe' });
|
||||
expect(initialGet.messages).toEqual([{ role: 'user', content: { type: 'text', text: 'v1' } }]);
|
||||
|
||||
handle.update({
|
||||
description: 'v2',
|
||||
callback: () => ({ messages: [{ role: 'user', content: { type: 'text', text: 'v2' } }] })
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(listChanged).toBeGreaterThanOrEqual(1));
|
||||
const updatedList = await client.listPrompts();
|
||||
expect(updatedList.prompts).toHaveLength(1);
|
||||
|
||||
const after = updatedList.prompts.find(p => p.name === 'probe');
|
||||
expect(after?.description).toBe('v2');
|
||||
|
||||
const r = await client.getPrompt({ name: 'probe' });
|
||||
expect(r.messages).toEqual([{ role: 'user', content: { type: 'text', text: 'v2' } }]);
|
||||
|
||||
const beforeRemove = listChanged;
|
||||
handle.remove();
|
||||
await vi.waitFor(() => expect(listChanged).toBeGreaterThan(beforeRemove));
|
||||
const removedList = await client.listPrompts();
|
||||
expect(removedList.prompts).toHaveLength(0);
|
||||
|
||||
await expect(client.getPrompt({ name: 'probe' })).rejects.toBeInstanceOf(ProtocolError);
|
||||
const finalList = await client.listPrompts();
|
||||
expect(finalList.prompts.find(p => p.name === 'probe')).toBeUndefined();
|
||||
});
|
||||
1462
test/e2e/scenarios/protocol.test.ts
Normal file
1462
test/e2e/scenarios/protocol.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