[v2] Мonorepo, sdk-client, sdk-server, sdk-core package split (#1279)
This commit is contained in:
parent
384311b9b8
commit
71ae3acee0
19 changed files with 12067 additions and 4817 deletions
5
test/integration/eslint.config.mjs
Normal file
5
test/integration/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// @ts-check
|
||||
|
||||
import baseConfig from '@modelcontextprotocol/eslint-config';
|
||||
|
||||
export default baseConfig;
|
||||
46
test/integration/package.json
Normal file
46
test/integration/package.json
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/test-integration",
|
||||
"private": true,
|
||||
"version": "2.0.0-alpha.0",
|
||||
"description": "Model Context Protocol implementation for TypeScript",
|
||||
"license": "MIT",
|
||||
"author": "Anthropic, PBC (https://anthropic.com)",
|
||||
"homepage": "https://modelcontextprotocol.io",
|
||||
"bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20",
|
||||
"pnpm": ">=10.24.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.24.0",
|
||||
"keywords": [
|
||||
"modelcontextprotocol",
|
||||
"mcp"
|
||||
],
|
||||
"scripts": {
|
||||
"lint": "eslint test/ && prettier --ignore-path ../../.prettierignore --check .",
|
||||
"lint:fix": "eslint test/ --fix && prettier --ignore-path ../../.prettierignore --write .",
|
||||
"check": "npm run typecheck && npm run lint",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"start": "npm run server",
|
||||
"server": "tsx watch --clear-screen=false scripts/cli.ts server",
|
||||
"client": "tsx scripts/cli.ts client"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@modelcontextprotocol/core": "workspace:^",
|
||||
"@modelcontextprotocol/client": "workspace:^",
|
||||
"@modelcontextprotocol/server": "workspace:^",
|
||||
"zod": "catalog:runtimeShared",
|
||||
"vitest": "catalog:devTools",
|
||||
"supertest": "catalog:devTools",
|
||||
"@modelcontextprotocol/tsconfig": "workspace:^",
|
||||
"@modelcontextprotocol/vitest-config": "workspace:^",
|
||||
"@modelcontextprotocol/eslint-config": "workspace:^",
|
||||
"@modelcontextprotocol/test-helpers": "workspace:^"
|
||||
}
|
||||
}
|
||||
42
test/integration/test/__fixtures__/serverThatHangs.ts
Normal file
42
test/integration/test/__fixtures__/serverThatHangs.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import process from 'node:process';
|
||||
import { setInterval } from 'node:timers';
|
||||
|
||||
import { McpServer, StdioServerTransport } from '@modelcontextprotocol/server';
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
|
||||
const server = new McpServer(
|
||||
{
|
||||
name: 'server-that-hangs',
|
||||
title: 'Test Server that hangs',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
logging: {}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await server.connect(transport);
|
||||
|
||||
// Keep process alive even after stdin closes
|
||||
const keepAlive = setInterval(() => {}, 60_000);
|
||||
|
||||
// Prevent transport close from exiting
|
||||
transport.onclose = () => {
|
||||
// Intentionally ignore - we want to test the signal handling
|
||||
};
|
||||
|
||||
const doNotExitImmediately = async (signal: NodeJS.Signals) => {
|
||||
await server.sendLoggingMessage({
|
||||
level: 'debug',
|
||||
data: `received signal ${signal}`
|
||||
});
|
||||
// Clear keepalive but delay exit to simulate slow shutdown
|
||||
clearInterval(keepAlive);
|
||||
setInterval(() => {}, 30_000);
|
||||
};
|
||||
|
||||
process.on('SIGINT', doNotExitImmediately);
|
||||
process.on('SIGTERM', doNotExitImmediately);
|
||||
18
test/integration/test/__fixtures__/testServer.ts
Normal file
18
test/integration/test/__fixtures__/testServer.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { McpServer, StdioServerTransport } from '@modelcontextprotocol/server';
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
|
||||
const server = new McpServer({
|
||||
name: 'test-server',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
await server.connect(transport);
|
||||
|
||||
const exit = async () => {
|
||||
await server.close();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGINT', exit);
|
||||
process.on('SIGTERM', exit);
|
||||
4134
test/integration/test/client/client.test.ts
Normal file
4134
test/integration/test/client/client.test.ts
Normal file
File diff suppressed because it is too large
Load diff
129
test/integration/test/experimental/tasks/task-listing.test.ts
Normal file
129
test/integration/test/experimental/tasks/task-listing.test.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { ErrorCode, McpError } from '@modelcontextprotocol/core';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createInMemoryTaskEnvironment } from '../../helpers/mcp.js';
|
||||
|
||||
describe('Task Listing with Pagination', () => {
|
||||
let client: Awaited<ReturnType<typeof createInMemoryTaskEnvironment>>['client'];
|
||||
let server: Awaited<ReturnType<typeof createInMemoryTaskEnvironment>>['server'];
|
||||
let taskStore: Awaited<ReturnType<typeof createInMemoryTaskEnvironment>>['taskStore'];
|
||||
|
||||
beforeEach(async () => {
|
||||
const env = await createInMemoryTaskEnvironment();
|
||||
client = env.client;
|
||||
server = env.server;
|
||||
taskStore = env.taskStore;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
taskStore.cleanup();
|
||||
await client.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
it('should return empty list when no tasks exist', async () => {
|
||||
const result = await client.experimental.tasks.listTasks();
|
||||
|
||||
expect(result.tasks).toEqual([]);
|
||||
expect(result.nextCursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return all tasks when less than page size', async () => {
|
||||
// Create 3 tasks
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await taskStore.createTask({}, i, {
|
||||
method: 'tools/call',
|
||||
params: { name: 'test-tool' }
|
||||
});
|
||||
}
|
||||
|
||||
const result = await client.experimental.tasks.listTasks();
|
||||
|
||||
expect(result.tasks).toHaveLength(3);
|
||||
expect(result.nextCursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should paginate when more than page size exists', async () => {
|
||||
// Create 15 tasks (page size is 10 in InMemoryTaskStore)
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await taskStore.createTask({}, i, {
|
||||
method: 'tools/call',
|
||||
params: { name: 'test-tool' }
|
||||
});
|
||||
}
|
||||
|
||||
// Get first page
|
||||
const page1 = await client.experimental.tasks.listTasks();
|
||||
expect(page1.tasks).toHaveLength(10);
|
||||
expect(page1.nextCursor).toBeDefined();
|
||||
|
||||
// Get second page using cursor
|
||||
const page2 = await client.experimental.tasks.listTasks(page1.nextCursor);
|
||||
expect(page2.tasks).toHaveLength(5);
|
||||
expect(page2.nextCursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should treat cursor as opaque token', async () => {
|
||||
// Create 5 tasks
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await taskStore.createTask({}, i, {
|
||||
method: 'tools/call',
|
||||
params: { name: 'test-tool' }
|
||||
});
|
||||
}
|
||||
|
||||
// Get all tasks to get a valid cursor
|
||||
const allTasks = taskStore.getAllTasks();
|
||||
const validCursor = allTasks[2]!.taskId;
|
||||
|
||||
// Use the cursor - should work even though we don't know its internal structure
|
||||
const result = await client.experimental.tasks.listTasks(validCursor);
|
||||
expect(result.tasks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return error code -32602 for invalid cursor', async () => {
|
||||
await taskStore.createTask({}, 1, {
|
||||
method: 'tools/call',
|
||||
params: { name: 'test-tool' }
|
||||
});
|
||||
|
||||
// Try to use an invalid cursor - should return -32602 (Invalid params) per MCP spec
|
||||
await expect(client.experimental.tasks.listTasks('invalid-cursor')).rejects.toSatisfy((error: McpError) => {
|
||||
expect(error).toBeInstanceOf(McpError);
|
||||
expect(error.code).toBe(ErrorCode.InvalidParams);
|
||||
expect(error.message).toContain('Invalid cursor');
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
it('should ensure tasks accessible via tasks/get are also accessible via tasks/list', async () => {
|
||||
// Create a task
|
||||
const task = await taskStore.createTask({}, 1, {
|
||||
method: 'tools/call',
|
||||
params: { name: 'test-tool' }
|
||||
});
|
||||
|
||||
// Verify it's accessible via tasks/get
|
||||
const getResult = await client.experimental.tasks.getTask(task.taskId);
|
||||
expect(getResult.taskId).toBe(task.taskId);
|
||||
|
||||
// Verify it's also accessible via tasks/list
|
||||
const listResult = await client.experimental.tasks.listTasks();
|
||||
expect(listResult.tasks).toHaveLength(1);
|
||||
expect(listResult.tasks[0]!.taskId).toBe(task.taskId);
|
||||
});
|
||||
|
||||
it('should not include related-task metadata in list response', async () => {
|
||||
// Create a task
|
||||
await taskStore.createTask({}, 1, {
|
||||
method: 'tools/call',
|
||||
params: { name: 'test-tool' }
|
||||
});
|
||||
|
||||
const result = await client.experimental.tasks.listTasks();
|
||||
|
||||
// The response should have _meta but not include related-task metadata
|
||||
expect(result._meta).toBeDefined();
|
||||
expect(result._meta?.['io.modelcontextprotocol/related-task']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
117
test/integration/test/experimental/tasks/task.test.ts
Normal file
117
test/integration/test/experimental/tasks/task.test.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { isTerminal } from '@modelcontextprotocol/core';
|
||||
import type { Task } from '@modelcontextprotocol/server';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('Task utility functions', () => {
|
||||
describe('isTerminal', () => {
|
||||
it('should return true for completed status', () => {
|
||||
expect(isTerminal('completed')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for failed status', () => {
|
||||
expect(isTerminal('failed')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for cancelled status', () => {
|
||||
expect(isTerminal('cancelled')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for working status', () => {
|
||||
expect(isTerminal('working')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for input_required status', () => {
|
||||
expect(isTerminal('input_required')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Task Schema Validation', () => {
|
||||
it('should validate task with ttl field', () => {
|
||||
const createdAt = new Date().toISOString();
|
||||
const task: Task = {
|
||||
taskId: 'test-123',
|
||||
status: 'working',
|
||||
ttl: 60000,
|
||||
createdAt,
|
||||
lastUpdatedAt: createdAt,
|
||||
pollInterval: 1000
|
||||
};
|
||||
|
||||
expect(task.ttl).toBe(60000);
|
||||
expect(task.createdAt).toBeDefined();
|
||||
expect(typeof task.createdAt).toBe('string');
|
||||
});
|
||||
|
||||
it('should validate task with null ttl', () => {
|
||||
const createdAt = new Date().toISOString();
|
||||
const task: Task = {
|
||||
taskId: 'test-456',
|
||||
status: 'completed',
|
||||
ttl: null,
|
||||
createdAt,
|
||||
lastUpdatedAt: createdAt
|
||||
};
|
||||
|
||||
expect(task.ttl).toBeNull();
|
||||
});
|
||||
|
||||
it('should validate task with statusMessage field', () => {
|
||||
const createdAt = new Date().toISOString();
|
||||
const task: Task = {
|
||||
taskId: 'test-789',
|
||||
status: 'failed',
|
||||
ttl: null,
|
||||
createdAt,
|
||||
lastUpdatedAt: createdAt,
|
||||
statusMessage: 'Operation failed due to timeout'
|
||||
};
|
||||
|
||||
expect(task.statusMessage).toBe('Operation failed due to timeout');
|
||||
});
|
||||
|
||||
it('should validate task with createdAt in ISO 8601 format', () => {
|
||||
const now = new Date();
|
||||
const createdAt = now.toISOString();
|
||||
const task: Task = {
|
||||
taskId: 'test-iso',
|
||||
status: 'working',
|
||||
ttl: 30000,
|
||||
createdAt,
|
||||
lastUpdatedAt: createdAt
|
||||
};
|
||||
|
||||
expect(task.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||
expect(new Date(task.createdAt).getTime()).toBe(now.getTime());
|
||||
});
|
||||
|
||||
it('should validate task with lastUpdatedAt in ISO 8601 format', () => {
|
||||
const now = new Date();
|
||||
const createdAt = now.toISOString();
|
||||
const task: Task = {
|
||||
taskId: 'test-iso',
|
||||
status: 'working',
|
||||
ttl: 30000,
|
||||
createdAt,
|
||||
lastUpdatedAt: createdAt
|
||||
};
|
||||
|
||||
expect(task.lastUpdatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
||||
});
|
||||
|
||||
it('should validate all task statuses', () => {
|
||||
const statuses: Task['status'][] = ['working', 'input_required', 'completed', 'failed', 'cancelled'];
|
||||
|
||||
const createdAt = new Date().toISOString();
|
||||
statuses.forEach(status => {
|
||||
const task: Task = {
|
||||
taskId: `test-${status}`,
|
||||
status,
|
||||
ttl: null,
|
||||
createdAt,
|
||||
lastUpdatedAt: createdAt
|
||||
};
|
||||
expect(task.status).toBe(status);
|
||||
});
|
||||
});
|
||||
});
|
||||
70
test/integration/test/helpers/mcp.ts
Normal file
70
test/integration/test/helpers/mcp.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { Client } from '@modelcontextprotocol/client';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/core';
|
||||
import type { ClientCapabilities, ServerCapabilities } from '@modelcontextprotocol/server';
|
||||
import { InMemoryTaskMessageQueue, InMemoryTaskStore, Server } from '@modelcontextprotocol/server';
|
||||
|
||||
export interface InMemoryTaskEnvironment {
|
||||
client: Client;
|
||||
server: Server;
|
||||
taskStore: InMemoryTaskStore;
|
||||
clientTransport: InMemoryTransport;
|
||||
serverTransport: InMemoryTransport;
|
||||
}
|
||||
|
||||
export async function createInMemoryTaskEnvironment(options?: {
|
||||
clientCapabilities?: ClientCapabilities;
|
||||
serverCapabilities?: ServerCapabilities;
|
||||
}): Promise<InMemoryTaskEnvironment> {
|
||||
const taskStore = new InMemoryTaskStore();
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: options?.clientCapabilities ?? {
|
||||
tasks: {
|
||||
list: {},
|
||||
requests: {
|
||||
tools: {
|
||||
call: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const server = new Server(
|
||||
{
|
||||
name: 'test-server',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: options?.serverCapabilities ?? {
|
||||
tasks: {
|
||||
list: {},
|
||||
requests: {
|
||||
tools: {
|
||||
call: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
taskStore,
|
||||
taskMessageQueue: new InMemoryTaskMessageQueue()
|
||||
}
|
||||
);
|
||||
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
return {
|
||||
client,
|
||||
server,
|
||||
taskStore,
|
||||
clientTransport,
|
||||
serverTransport
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* Regression test for https://github.com/modelcontextprotocol/typescript-sdk/issues/1277
|
||||
*
|
||||
* Zod v4 stores `.describe()` descriptions directly on the schema object,
|
||||
* not in `._zod.def.description`. This test verifies that descriptions are
|
||||
* correctly extracted for prompt arguments.
|
||||
*/
|
||||
|
||||
import { Client } from '@modelcontextprotocol/client';
|
||||
import { InMemoryTransport, ListPromptsResultSchema } from '@modelcontextprotocol/core';
|
||||
import { McpServer } from '@modelcontextprotocol/server';
|
||||
import { type ZodMatrixEntry, zodTestMatrix } from '@modelcontextprotocol/test-helpers';
|
||||
|
||||
describe.each(zodTestMatrix)('Issue #1277: $zodVersionLabel', (entry: ZodMatrixEntry) => {
|
||||
const { z } = entry;
|
||||
|
||||
test('should preserve argument descriptions from .describe()', async () => {
|
||||
const mcpServer = new McpServer({
|
||||
name: 'test server',
|
||||
version: '1.0'
|
||||
});
|
||||
const client = new Client({
|
||||
name: 'test client',
|
||||
version: '1.0'
|
||||
});
|
||||
|
||||
mcpServer.prompt(
|
||||
'test',
|
||||
{
|
||||
name: z.string().describe('The user name'),
|
||||
value: z.string().describe('The value to set')
|
||||
},
|
||||
async ({ name, value }) => ({
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: `${name}: ${value}`
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
|
||||
|
||||
const result = await client.request(
|
||||
{
|
||||
method: 'prompts/list'
|
||||
},
|
||||
ListPromptsResultSchema
|
||||
);
|
||||
|
||||
expect(result.prompts).toHaveLength(1);
|
||||
expect(result.prompts[0]!.name).toBe('test');
|
||||
expect(result.prompts[0]!.arguments).toEqual([
|
||||
{ name: 'name', required: true, description: 'The user name' },
|
||||
{ name: 'value', required: true, description: 'The value to set' }
|
||||
]);
|
||||
});
|
||||
});
|
||||
112
test/integration/test/processCleanup.test.ts
Normal file
112
test/integration/test/processCleanup.test.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import path from 'node:path';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
|
||||
import { Client, StdioClientTransport } from '@modelcontextprotocol/client';
|
||||
import { LoggingMessageNotificationSchema, Server, StdioServerTransport } from '@modelcontextprotocol/server';
|
||||
|
||||
// Use the local fixtures directory alongside this test file
|
||||
const FIXTURES_DIR = path.resolve(__dirname, './__fixtures__');
|
||||
|
||||
describe('Process cleanup', () => {
|
||||
vi.setConfig({ testTimeout: 5000 }); // 5 second timeout
|
||||
|
||||
it('server should exit cleanly after closing transport', async () => {
|
||||
const server = new Server(
|
||||
{
|
||||
name: 'test-server',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {}
|
||||
}
|
||||
);
|
||||
|
||||
const mockReadable = new Readable({
|
||||
read() {
|
||||
this.push(null); // signal EOF
|
||||
}
|
||||
}),
|
||||
mockWritable = new Writable({
|
||||
write(chunk, encoding, callback) {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
|
||||
// Attach mock streams to process for the server transport
|
||||
const transport = new StdioServerTransport(mockReadable, mockWritable);
|
||||
await server.connect(transport);
|
||||
|
||||
// Close the transport
|
||||
await transport.close();
|
||||
|
||||
// ensure a proper disposal mock streams
|
||||
mockReadable.destroy();
|
||||
mockWritable.destroy();
|
||||
|
||||
// If we reach here without hanging, the test passes
|
||||
// The test runner will fail if the process hangs
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('onclose should be called exactly once', async () => {
|
||||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command: 'node',
|
||||
args: ['--import', 'tsx', 'testServer.ts'],
|
||||
cwd: FIXTURES_DIR
|
||||
});
|
||||
|
||||
await client.connect(transport);
|
||||
|
||||
let onCloseWasCalled = 0;
|
||||
client.onclose = () => {
|
||||
onCloseWasCalled++;
|
||||
};
|
||||
|
||||
await client.close();
|
||||
|
||||
// A short delay to allow the close event to propagate
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
expect(onCloseWasCalled).toBe(1);
|
||||
});
|
||||
|
||||
it('should exit cleanly for a server that hangs', async () => {
|
||||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command: 'node',
|
||||
args: ['--import', 'tsx', 'serverThatHangs.ts'],
|
||||
cwd: FIXTURES_DIR
|
||||
});
|
||||
|
||||
await client.connect(transport);
|
||||
await client.setLoggingLevel('debug');
|
||||
client.setNotificationHandler(LoggingMessageNotificationSchema, notification => {
|
||||
console.debug('server log: ' + notification.params.data);
|
||||
});
|
||||
const serverPid = transport.pid!;
|
||||
|
||||
await client.close();
|
||||
|
||||
// A short delay to allow the close event to propagate
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
try {
|
||||
process.kill(serverPid, 9);
|
||||
throw new Error('Expected server to be dead but it is alive');
|
||||
} catch (err: unknown) {
|
||||
// 'ESRCH' the process doesn't exist
|
||||
if (err && typeof err === 'object' && 'code' in err && err.code === 'ESRCH') {
|
||||
// success
|
||||
} else throw err;
|
||||
}
|
||||
});
|
||||
});
|
||||
3281
test/integration/test/server.test.ts
Normal file
3281
test/integration/test/server.test.ts
Normal file
File diff suppressed because it is too large
Load diff
986
test/integration/test/server/elicitation.test.ts
Normal file
986
test/integration/test/server/elicitation.test.ts
Normal file
|
|
@ -0,0 +1,986 @@
|
|||
/**
|
||||
* Comprehensive elicitation flow tests with validator integration
|
||||
*
|
||||
* These tests verify the end-to-end elicitation flow from server requesting
|
||||
* input to client responding and validation of the response against schemas.
|
||||
*
|
||||
* Per the MCP spec, elicitation only supports object schemas, not primitives.
|
||||
*/
|
||||
|
||||
import { Client } from '@modelcontextprotocol/client';
|
||||
import type { ElicitRequestFormParams } from '@modelcontextprotocol/core';
|
||||
import { AjvJsonSchemaValidator, CfWorkerJsonSchemaValidator, ElicitRequestSchema, InMemoryTransport } from '@modelcontextprotocol/core';
|
||||
import { Server } from '@modelcontextprotocol/server';
|
||||
|
||||
const ajvProvider = new AjvJsonSchemaValidator();
|
||||
const cfWorkerProvider = new CfWorkerJsonSchemaValidator();
|
||||
|
||||
let server: Server;
|
||||
let client: Client;
|
||||
|
||||
describe('Elicitation Flow', () => {
|
||||
describe('with AJV validator', () => {
|
||||
beforeEach(async () => {
|
||||
server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: ajvProvider
|
||||
}
|
||||
);
|
||||
|
||||
client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
});
|
||||
|
||||
testElicitationFlow(ajvProvider, 'AJV');
|
||||
});
|
||||
|
||||
describe('with CfWorker validator', () => {
|
||||
beforeEach(async () => {
|
||||
server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: cfWorkerProvider
|
||||
}
|
||||
);
|
||||
|
||||
client = new Client({ name: 'test-client', version: '1.0.0' }, { capabilities: { elicitation: {} } });
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
});
|
||||
|
||||
testElicitationFlow(cfWorkerProvider, 'CfWorker');
|
||||
});
|
||||
});
|
||||
|
||||
function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWorkerProvider, validatorName: string) {
|
||||
test(`${validatorName}: should elicit simple object with string field`, async () => {
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { name: 'John Doe' }
|
||||
}));
|
||||
|
||||
const result = await server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'What is your name?',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', minLength: 1 }
|
||||
},
|
||||
required: ['name']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'accept',
|
||||
content: { name: 'John Doe' }
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should elicit object with integer field`, async () => {
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { age: 42 }
|
||||
}));
|
||||
|
||||
const result = await server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'What is your age?',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
age: { type: 'integer', minimum: 0, maximum: 150 }
|
||||
},
|
||||
required: ['age']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'accept',
|
||||
content: { age: 42 }
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should elicit object with boolean field`, async () => {
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { agree: true }
|
||||
}));
|
||||
|
||||
const result = await server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Do you agree?',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
agree: { type: 'boolean' }
|
||||
},
|
||||
required: ['agree']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'accept',
|
||||
content: { agree: true }
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should elicit complex object with multiple fields`, async () => {
|
||||
const userData = {
|
||||
name: 'Jane Smith',
|
||||
email: 'jane@example.com',
|
||||
age: 28,
|
||||
street: '123 Main St',
|
||||
city: 'San Francisco',
|
||||
zipCode: '94105',
|
||||
newsletter: true,
|
||||
notifications: false
|
||||
};
|
||||
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: userData
|
||||
}));
|
||||
|
||||
const formRequestParams: ElicitRequestFormParams = {
|
||||
mode: 'form',
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', minLength: 1 },
|
||||
email: { type: 'string', format: 'email' },
|
||||
age: { type: 'integer', minimum: 0, maximum: 150 },
|
||||
street: { type: 'string' },
|
||||
city: { type: 'string' },
|
||||
// @ts-expect-error - pattern is not a valid property by MCP spec, however it is making use of the Ajv validator
|
||||
zipCode: { type: 'string', pattern: '^[0-9]{5}$' },
|
||||
newsletter: { type: 'boolean' },
|
||||
notifications: { type: 'boolean' }
|
||||
},
|
||||
required: ['name', 'email', 'age', 'street', 'city', 'zipCode']
|
||||
}
|
||||
};
|
||||
const result = await server.elicitInput(formRequestParams);
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'accept',
|
||||
content: userData
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should reject invalid object (missing required field)`, async () => {
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: {
|
||||
email: 'user@example.com'
|
||||
// Missing required 'name' field
|
||||
}
|
||||
}));
|
||||
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
email: { type: 'string' }
|
||||
},
|
||||
required: ['name', 'email']
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(/does not match requested schema/);
|
||||
});
|
||||
|
||||
test(`${validatorName}: should reject invalid field type`, async () => {
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: {
|
||||
name: 'John Doe',
|
||||
age: 'thirty' // Wrong type - should be integer
|
||||
}
|
||||
}));
|
||||
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
age: { type: 'integer' }
|
||||
},
|
||||
required: ['name', 'age']
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(/does not match requested schema/);
|
||||
});
|
||||
|
||||
test(`${validatorName}: should reject invalid string (too short)`, async () => {
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { name: '' } // Too short
|
||||
}));
|
||||
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
message: 'What is your name?',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', minLength: 1 }
|
||||
},
|
||||
required: ['name']
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(/does not match requested schema/);
|
||||
});
|
||||
|
||||
test(`${validatorName}: should reject invalid integer (out of range)`, async () => {
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { age: 200 } // Too high
|
||||
}));
|
||||
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'What is your age?',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
age: { type: 'integer', minimum: 0, maximum: 150 }
|
||||
},
|
||||
required: ['age']
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(/does not match requested schema/);
|
||||
});
|
||||
|
||||
test(`${validatorName}: should reject invalid pattern`, async () => {
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { zipCode: 'ABC123' } // Doesn't match pattern
|
||||
}));
|
||||
|
||||
const formRequestParams: ElicitRequestFormParams = {
|
||||
mode: 'form',
|
||||
message: 'Enter a 5-digit zip code',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
// @ts-expect-error - pattern is not a valid property by MCP spec, however it is making use of the Ajv validator
|
||||
zipCode: { type: 'string', pattern: '^[0-9]{5}$' }
|
||||
},
|
||||
required: ['zipCode']
|
||||
}
|
||||
};
|
||||
|
||||
await expect(server.elicitInput(formRequestParams)).rejects.toThrow(/does not match requested schema/);
|
||||
});
|
||||
|
||||
test(`${validatorName}: should allow decline action without validation`, async () => {
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'decline'
|
||||
}));
|
||||
|
||||
const result = await server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' }
|
||||
},
|
||||
required: ['name']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'decline'
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should allow cancel action without validation`, async () => {
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'cancel'
|
||||
}));
|
||||
|
||||
const result = await server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' }
|
||||
},
|
||||
required: ['name']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'cancel'
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should handle multiple sequential elicitation requests`, async () => {
|
||||
let requestCount = 0;
|
||||
client.setRequestHandler(ElicitRequestSchema, request => {
|
||||
requestCount++;
|
||||
if (request.params.message.includes('name')) {
|
||||
return { action: 'accept', content: { name: 'Alice' } };
|
||||
} else if (request.params.message.includes('age')) {
|
||||
return { action: 'accept', content: { age: 30 } };
|
||||
} else if (request.params.message.includes('city')) {
|
||||
return { action: 'accept', content: { city: 'New York' } };
|
||||
}
|
||||
return { action: 'decline' };
|
||||
});
|
||||
|
||||
const nameResult = await server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'What is your name?',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: { name: { type: 'string', minLength: 1 } },
|
||||
required: ['name']
|
||||
}
|
||||
});
|
||||
|
||||
const ageResult = await server.elicitInput({
|
||||
message: 'What is your age?',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: { age: { type: 'integer', minimum: 0 } },
|
||||
required: ['age']
|
||||
}
|
||||
});
|
||||
|
||||
const cityResult = await server.elicitInput({
|
||||
message: 'What is your city?',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: { city: { type: 'string', minLength: 1 } },
|
||||
required: ['city']
|
||||
}
|
||||
});
|
||||
|
||||
expect(requestCount).toBe(3);
|
||||
expect(nameResult).toEqual({
|
||||
action: 'accept',
|
||||
content: { name: 'Alice' }
|
||||
});
|
||||
expect(ageResult).toEqual({ action: 'accept', content: { age: 30 } });
|
||||
expect(cityResult).toEqual({
|
||||
action: 'accept',
|
||||
content: { city: 'New York' }
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should validate with optional fields present`, async () => {
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { name: 'John', nickname: 'Johnny' }
|
||||
}));
|
||||
|
||||
const result = await server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Enter your name',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', minLength: 1 },
|
||||
nickname: { type: 'string' }
|
||||
},
|
||||
required: ['name']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'accept',
|
||||
content: { name: 'John', nickname: 'Johnny' }
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should validate with optional fields absent`, async () => {
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { name: 'John' }
|
||||
}));
|
||||
|
||||
const result = await server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Enter your name',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', minLength: 1 },
|
||||
nickname: { type: 'string' }
|
||||
},
|
||||
required: ['name']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'accept',
|
||||
content: { name: 'John' }
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should validate email format`, async () => {
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { email: 'user@example.com' }
|
||||
}));
|
||||
|
||||
const result = await server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Enter your email',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email' }
|
||||
},
|
||||
required: ['email']
|
||||
}
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'accept',
|
||||
content: { email: 'user@example.com' }
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should default missing fields from schema defaults`, async () => {
|
||||
const server = new Server(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {},
|
||||
jsonSchemaValidator: validatorProvider
|
||||
}
|
||||
);
|
||||
|
||||
const client = new Client(
|
||||
{ name: 'test-client', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {
|
||||
elicitation: {
|
||||
form: {
|
||||
applyDefaults: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const testSchemaProperties: ElicitRequestFormParams['requestedSchema'] = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
subscribe: { type: 'boolean', default: true },
|
||||
nickname: { type: 'string', default: 'Guest' },
|
||||
age: { type: 'integer', minimum: 0, maximum: 150, default: 18 },
|
||||
color: { type: 'string', enum: ['red', 'green'], default: 'green' },
|
||||
untitledSingleSelectEnum: {
|
||||
type: 'string',
|
||||
title: 'Untitled Single Select Enum',
|
||||
description: 'Choose your favorite color',
|
||||
enum: ['red', 'green', 'blue'],
|
||||
default: 'green'
|
||||
},
|
||||
untitledMultipleSelectEnum: {
|
||||
type: 'array',
|
||||
title: 'Untitled Multiple Select Enum',
|
||||
description: 'Choose your favorite colors',
|
||||
minItems: 1,
|
||||
maxItems: 3,
|
||||
items: { type: 'string', enum: ['red', 'green', 'blue'] },
|
||||
default: ['green', 'blue']
|
||||
},
|
||||
titledSingleSelectEnum: {
|
||||
type: 'string',
|
||||
title: 'Single Select Enum',
|
||||
description: 'Choose your favorite color',
|
||||
oneOf: [
|
||||
{ const: 'red', title: 'Red' },
|
||||
{ const: 'green', title: 'Green' },
|
||||
{ const: 'blue', title: 'Blue' }
|
||||
],
|
||||
default: 'green'
|
||||
},
|
||||
titledMultipleSelectEnum: {
|
||||
type: 'array',
|
||||
title: 'Multiple Select Enum',
|
||||
description: 'Choose your favorite colors',
|
||||
minItems: 1,
|
||||
maxItems: 3,
|
||||
items: {
|
||||
anyOf: [
|
||||
{ const: 'red', title: 'Red' },
|
||||
{ const: 'green', title: 'Green' },
|
||||
{ const: 'blue', title: 'Blue' }
|
||||
]
|
||||
},
|
||||
default: ['green', 'blue']
|
||||
},
|
||||
legacyTitledEnum: {
|
||||
type: 'string',
|
||||
title: 'Legacy Titled Enum',
|
||||
description: 'Choose your favorite color',
|
||||
enum: ['red', 'green', 'blue'],
|
||||
enumNames: ['Red', 'Green', 'Blue'],
|
||||
default: 'green'
|
||||
},
|
||||
optionalWithADefault: { type: 'string', default: 'default value' }
|
||||
},
|
||||
required: [
|
||||
'subscribe',
|
||||
'nickname',
|
||||
'age',
|
||||
'color',
|
||||
'titledSingleSelectEnum',
|
||||
'titledMultipleSelectEnum',
|
||||
'untitledSingleSelectEnum',
|
||||
'untitledMultipleSelectEnum'
|
||||
]
|
||||
};
|
||||
|
||||
// Client returns no values; SDK should apply defaults automatically (and validate)
|
||||
client.setRequestHandler(ElicitRequestSchema, request => {
|
||||
expect(request.params.mode).toEqual('form');
|
||||
expect((request.params as ElicitRequestFormParams).requestedSchema).toEqual(testSchemaProperties);
|
||||
return {
|
||||
action: 'accept',
|
||||
content: {}
|
||||
};
|
||||
});
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
|
||||
|
||||
const result = await server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Provide your preferences',
|
||||
requestedSchema: testSchemaProperties
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
action: 'accept',
|
||||
content: {
|
||||
subscribe: true,
|
||||
nickname: 'Guest',
|
||||
age: 18,
|
||||
color: 'green',
|
||||
untitledSingleSelectEnum: 'green',
|
||||
untitledMultipleSelectEnum: ['green', 'blue'],
|
||||
titledSingleSelectEnum: 'green',
|
||||
titledMultipleSelectEnum: ['green', 'blue'],
|
||||
legacyTitledEnum: 'green',
|
||||
optionalWithADefault: 'default value'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should reject invalid email format`, async () => {
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: { email: 'not-an-email' }
|
||||
}));
|
||||
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Enter your email',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email' }
|
||||
},
|
||||
required: ['email']
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(/does not match requested schema/);
|
||||
});
|
||||
|
||||
// Enums - Valid - Single Select - Untitled / Titled
|
||||
|
||||
test(`${validatorName}: should succeed with valid selection in single-select untitled enum`, async () => {
|
||||
// Set up client to return valid response
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: {
|
||||
color: 'Red'
|
||||
}
|
||||
}));
|
||||
|
||||
// Test with valid response
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
color: {
|
||||
type: 'string',
|
||||
title: 'Color Selection',
|
||||
description: 'Choose your favorite color',
|
||||
enum: ['Red', 'Green', 'Blue'],
|
||||
default: 'Green'
|
||||
}
|
||||
},
|
||||
required: ['color']
|
||||
}
|
||||
})
|
||||
).resolves.toEqual({
|
||||
action: 'accept',
|
||||
content: {
|
||||
color: 'Red'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should succeed with valid selection in single-select titled enum`, async () => {
|
||||
// Set up client to return valid response
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: {
|
||||
color: '#FF0000'
|
||||
}
|
||||
}));
|
||||
|
||||
// Test with valid response
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
color: {
|
||||
type: 'string',
|
||||
title: 'Color Selection',
|
||||
description: 'Choose your favorite color',
|
||||
oneOf: [
|
||||
{ const: '#FF0000', title: 'Red' },
|
||||
{ const: '#00FF00', title: 'Green' },
|
||||
{ const: '#0000FF', title: 'Blue' }
|
||||
],
|
||||
default: '#00FF00'
|
||||
}
|
||||
},
|
||||
required: ['color']
|
||||
}
|
||||
})
|
||||
).resolves.toEqual({
|
||||
action: 'accept',
|
||||
content: {
|
||||
color: '#FF0000'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should succeed with valid selection in single-select titled legacy enum`, async () => {
|
||||
// Set up client to return valid response
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: {
|
||||
color: '#FF0000'
|
||||
}
|
||||
}));
|
||||
|
||||
// Test with valid response
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
color: {
|
||||
type: 'string',
|
||||
title: 'Color Selection',
|
||||
description: 'Choose your favorite color',
|
||||
enum: ['#FF0000', '#00FF00', '#0000FF'],
|
||||
enumNames: ['Red', 'Green', 'Blue'],
|
||||
default: '#00FF00'
|
||||
}
|
||||
},
|
||||
required: ['color']
|
||||
}
|
||||
})
|
||||
).resolves.toEqual({
|
||||
action: 'accept',
|
||||
content: {
|
||||
color: '#FF0000'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Enums - Valid - Multi Select - Untitled / Titled
|
||||
|
||||
test(`${validatorName}: should succeed with valid selection in multi-select untitled enum`, async () => {
|
||||
// Set up client to return valid response
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: {
|
||||
colors: ['Red', 'Blue']
|
||||
}
|
||||
}));
|
||||
|
||||
// Test with valid response
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
colors: {
|
||||
type: 'array',
|
||||
title: 'Color Selection',
|
||||
description: 'Choose your favorite colors',
|
||||
minItems: 1,
|
||||
maxItems: 3,
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: ['Red', 'Green', 'Blue']
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['colors']
|
||||
}
|
||||
})
|
||||
).resolves.toEqual({
|
||||
action: 'accept',
|
||||
content: {
|
||||
colors: ['Red', 'Blue']
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test(`${validatorName}: should succeed with valid selection in multi-select titled enum`, async () => {
|
||||
// Set up client to return valid response
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: {
|
||||
colors: ['#FF0000', '#0000FF']
|
||||
}
|
||||
}));
|
||||
|
||||
// Test with valid response
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
colors: {
|
||||
type: 'array',
|
||||
title: 'Color Selection',
|
||||
description: 'Choose your favorite colors',
|
||||
minItems: 1,
|
||||
maxItems: 3,
|
||||
items: {
|
||||
anyOf: [
|
||||
{ const: '#FF0000', title: 'Red' },
|
||||
{ const: '#00FF00', title: 'Green' },
|
||||
{ const: '#0000FF', title: 'Blue' }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['colors']
|
||||
}
|
||||
})
|
||||
).resolves.toEqual({
|
||||
action: 'accept',
|
||||
content: {
|
||||
colors: ['#FF0000', '#0000FF']
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Enums - Invalid - Single Select - Untitled / Titled
|
||||
|
||||
test(`${validatorName}: should reject invalid selection in single-select untitled enum`, async () => {
|
||||
// Set up client to return valid response
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: {
|
||||
color: 'Black' // Color not in enum list
|
||||
}
|
||||
}));
|
||||
|
||||
// Test with valid response
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
color: {
|
||||
type: 'string',
|
||||
title: 'Color Selection',
|
||||
description: 'Choose your favorite color',
|
||||
enum: ['Red', 'Green', 'Blue'],
|
||||
default: 'Green'
|
||||
}
|
||||
},
|
||||
required: ['color']
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(/^MCP error -32602: Elicitation response content does not match requested schema/);
|
||||
});
|
||||
|
||||
test(`${validatorName}: should reject invalid selection in single-select titled enum`, async () => {
|
||||
// Set up client to return valid response
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: {
|
||||
color: 'Red' // Should be "#FF0000" (const not title)
|
||||
}
|
||||
}));
|
||||
|
||||
// Test with valid response
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
color: {
|
||||
type: 'string',
|
||||
title: 'Color Selection',
|
||||
description: 'Choose your favorite color',
|
||||
oneOf: [
|
||||
{ const: '#FF0000', title: 'Red' },
|
||||
{ const: '#00FF00', title: 'Green' },
|
||||
{ const: '#0000FF', title: 'Blue' }
|
||||
],
|
||||
default: '#00FF00'
|
||||
}
|
||||
},
|
||||
required: ['color']
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(/^MCP error -32602: Elicitation response content does not match requested schema/);
|
||||
});
|
||||
|
||||
test(`${validatorName}: should reject invalid selection in single-select titled legacy enum`, async () => {
|
||||
// Set up client to return valid response
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: {
|
||||
color: 'Red' // Should be "#FF0000" (enum not enumNames)
|
||||
}
|
||||
}));
|
||||
|
||||
// Test with valid response
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
color: {
|
||||
type: 'string',
|
||||
title: 'Color Selection',
|
||||
description: 'Choose your favorite color',
|
||||
enum: ['#FF0000', '#00FF00', '#0000FF'],
|
||||
enumNames: ['Red', 'Green', 'Blue'],
|
||||
default: '#00FF00'
|
||||
}
|
||||
},
|
||||
required: ['color']
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(/^MCP error -32602: Elicitation response content does not match requested schema/);
|
||||
});
|
||||
|
||||
// Enums - Invalid - Multi Select - Untitled / Titled
|
||||
|
||||
test(`${validatorName}: should reject invalid selection in multi-select untitled enum`, async () => {
|
||||
// Set up client to return valid response
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: {
|
||||
color: 'Red' // Should be array, not string
|
||||
}
|
||||
}));
|
||||
|
||||
// Test with valid response
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
color: {
|
||||
type: 'array',
|
||||
title: 'Color Selection',
|
||||
description: 'Choose your favorite colors',
|
||||
minItems: 1,
|
||||
maxItems: 3,
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: ['Red', 'Green', 'Blue']
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['color']
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(/^MCP error -32602: Elicitation response content does not match requested schema/);
|
||||
});
|
||||
|
||||
test(`${validatorName}: should reject invalid selection in multi-select titled enum`, async () => {
|
||||
// Set up client to return valid response
|
||||
client.setRequestHandler(ElicitRequestSchema, _request => ({
|
||||
action: 'accept',
|
||||
content: {
|
||||
colors: ['Red', 'Blue'] // Should be ["#FF0000", "#0000FF"] (const not title)
|
||||
}
|
||||
}));
|
||||
|
||||
// Test with valid response
|
||||
await expect(
|
||||
server.elicitInput({
|
||||
mode: 'form',
|
||||
message: 'Please provide your information',
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
colors: {
|
||||
type: 'array',
|
||||
title: 'Color Selection',
|
||||
description: 'Choose your favorite colors',
|
||||
minItems: 1,
|
||||
maxItems: 3,
|
||||
items: {
|
||||
anyOf: [
|
||||
{ const: '#FF0000', title: 'Red' },
|
||||
{ const: '#00FF00', title: 'Green' },
|
||||
{ const: '#0000FF', title: 'Blue' }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['colors']
|
||||
}
|
||||
})
|
||||
).rejects.toThrow(/^MCP error -32602: Elicitation response content does not match requested schema/);
|
||||
});
|
||||
}
|
||||
6847
test/integration/test/server/mcp.test.ts
Normal file
6847
test/integration/test/server/mcp.test.ts
Normal file
File diff suppressed because it is too large
Load diff
355
test/integration/test/stateManagementStreamableHttp.test.ts
Normal file
355
test/integration/test/stateManagementStreamableHttp.test.ts
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
|
||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
import {
|
||||
CallToolResultSchema,
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
ListPromptsResultSchema,
|
||||
ListResourcesResultSchema,
|
||||
ListToolsResultSchema,
|
||||
McpServer,
|
||||
StreamableHTTPServerTransport
|
||||
} from '@modelcontextprotocol/server';
|
||||
import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers';
|
||||
import { type ZodMatrixEntry, zodTestMatrix } from '@modelcontextprotocol/test-helpers';
|
||||
|
||||
describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
|
||||
const { z } = entry;
|
||||
describe('Streamable HTTP Transport Session Management', () => {
|
||||
// Function to set up the server with optional session management
|
||||
async function setupServer(withSessionManagement: boolean) {
|
||||
const server: Server = createServer();
|
||||
const mcpServer = new McpServer(
|
||||
{ name: 'test-server', version: '1.0.0' },
|
||||
{
|
||||
capabilities: {
|
||||
logging: {},
|
||||
tools: {},
|
||||
resources: {},
|
||||
prompts: {}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Add a simple resource
|
||||
mcpServer.resource('test-resource', '/test', { description: 'A test resource' }, async () => ({
|
||||
contents: [
|
||||
{
|
||||
uri: '/test',
|
||||
text: 'This is a test resource content'
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
mcpServer.prompt('test-prompt', 'A test prompt', async () => ({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: 'This is a test prompt'
|
||||
}
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
mcpServer.tool(
|
||||
'greet',
|
||||
'A simple greeting tool',
|
||||
{
|
||||
name: z.string().describe('Name to greet').default('World')
|
||||
},
|
||||
async ({ name }) => {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Hello, ${name}!` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Create transport with or without session management
|
||||
const serverTransport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: withSessionManagement
|
||||
? () => randomUUID() // With session management, generate UUID
|
||||
: undefined // Without session management, return undefined
|
||||
});
|
||||
|
||||
await mcpServer.connect(serverTransport);
|
||||
|
||||
server.on('request', async (req, res) => {
|
||||
await serverTransport.handleRequest(req, res);
|
||||
});
|
||||
|
||||
// Start the server on a random port
|
||||
const baseUrl = await listenOnRandomPort(server);
|
||||
|
||||
return { server, mcpServer, serverTransport, baseUrl };
|
||||
}
|
||||
|
||||
describe('Stateless Mode', () => {
|
||||
let server: Server;
|
||||
let mcpServer: McpServer;
|
||||
let serverTransport: StreamableHTTPServerTransport;
|
||||
let baseUrl: URL;
|
||||
|
||||
beforeEach(async () => {
|
||||
const setup = await setupServer(false);
|
||||
server = setup.server;
|
||||
mcpServer = setup.mcpServer;
|
||||
serverTransport = setup.serverTransport;
|
||||
baseUrl = setup.baseUrl;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up resources
|
||||
await mcpServer.close().catch(() => {});
|
||||
await serverTransport.close().catch(() => {});
|
||||
server.close();
|
||||
});
|
||||
|
||||
it('should support multiple client connections', async () => {
|
||||
// Create and connect a client
|
||||
const client1 = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport1 = new StreamableHTTPClientTransport(baseUrl);
|
||||
await client1.connect(transport1);
|
||||
|
||||
// Verify that no session ID was set
|
||||
expect(transport1.sessionId).toBeUndefined();
|
||||
|
||||
// List available tools
|
||||
await client1.request(
|
||||
{
|
||||
method: 'tools/list',
|
||||
params: {}
|
||||
},
|
||||
ListToolsResultSchema
|
||||
);
|
||||
|
||||
const client2 = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport2 = new StreamableHTTPClientTransport(baseUrl);
|
||||
await client2.connect(transport2);
|
||||
|
||||
// Verify that no session ID was set
|
||||
expect(transport2.sessionId).toBeUndefined();
|
||||
|
||||
// List available tools
|
||||
await client2.request(
|
||||
{
|
||||
method: 'tools/list',
|
||||
params: {}
|
||||
},
|
||||
ListToolsResultSchema
|
||||
);
|
||||
});
|
||||
it('should operate without session management', async () => {
|
||||
// Create and connect a client
|
||||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(baseUrl);
|
||||
await client.connect(transport);
|
||||
|
||||
// Verify that no session ID was set
|
||||
expect(transport.sessionId).toBeUndefined();
|
||||
|
||||
// List available tools
|
||||
const toolsResult = await client.request(
|
||||
{
|
||||
method: 'tools/list',
|
||||
params: {}
|
||||
},
|
||||
ListToolsResultSchema
|
||||
);
|
||||
|
||||
// Verify tools are accessible
|
||||
expect(toolsResult.tools).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: 'greet'
|
||||
})
|
||||
);
|
||||
|
||||
// List available resources
|
||||
const resourcesResult = await client.request(
|
||||
{
|
||||
method: 'resources/list',
|
||||
params: {}
|
||||
},
|
||||
ListResourcesResultSchema
|
||||
);
|
||||
|
||||
// Verify resources result structure
|
||||
expect(resourcesResult).toHaveProperty('resources');
|
||||
|
||||
// List available prompts
|
||||
const promptsResult = await client.request(
|
||||
{
|
||||
method: 'prompts/list',
|
||||
params: {}
|
||||
},
|
||||
ListPromptsResultSchema
|
||||
);
|
||||
|
||||
// Verify prompts result structure
|
||||
expect(promptsResult).toHaveProperty('prompts');
|
||||
expect(promptsResult.prompts).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: 'test-prompt'
|
||||
})
|
||||
);
|
||||
|
||||
// Call the greeting tool
|
||||
const greetingResult = await client.request(
|
||||
{
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'greet',
|
||||
arguments: {
|
||||
name: 'Stateless Transport'
|
||||
}
|
||||
}
|
||||
},
|
||||
CallToolResultSchema
|
||||
);
|
||||
|
||||
// Verify tool result
|
||||
expect(greetingResult.content).toEqual([{ type: 'text', text: 'Hello, Stateless Transport!' }]);
|
||||
|
||||
// Clean up
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
it('should set protocol version after connecting', async () => {
|
||||
// Create and connect a client
|
||||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(baseUrl);
|
||||
|
||||
// Verify protocol version is not set before connecting
|
||||
expect(transport.protocolVersion).toBeUndefined();
|
||||
|
||||
await client.connect(transport);
|
||||
|
||||
// Verify protocol version is set after connecting
|
||||
expect(transport.protocolVersion).toBe(LATEST_PROTOCOL_VERSION);
|
||||
|
||||
// Clean up
|
||||
await transport.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stateful Mode', () => {
|
||||
let server: Server;
|
||||
let mcpServer: McpServer;
|
||||
let serverTransport: StreamableHTTPServerTransport;
|
||||
let baseUrl: URL;
|
||||
|
||||
beforeEach(async () => {
|
||||
const setup = await setupServer(true);
|
||||
server = setup.server;
|
||||
mcpServer = setup.mcpServer;
|
||||
serverTransport = setup.serverTransport;
|
||||
baseUrl = setup.baseUrl;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up resources
|
||||
await mcpServer.close().catch(() => {});
|
||||
await serverTransport.close().catch(() => {});
|
||||
server.close();
|
||||
});
|
||||
|
||||
it('should operate with session management', async () => {
|
||||
// Create and connect a client
|
||||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(baseUrl);
|
||||
await client.connect(transport);
|
||||
|
||||
// Verify that a session ID was set
|
||||
expect(transport.sessionId).toBeDefined();
|
||||
expect(typeof transport.sessionId).toBe('string');
|
||||
|
||||
// List available tools
|
||||
const toolsResult = await client.request(
|
||||
{
|
||||
method: 'tools/list',
|
||||
params: {}
|
||||
},
|
||||
ListToolsResultSchema
|
||||
);
|
||||
|
||||
// Verify tools are accessible
|
||||
expect(toolsResult.tools).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: 'greet'
|
||||
})
|
||||
);
|
||||
|
||||
// List available resources
|
||||
const resourcesResult = await client.request(
|
||||
{
|
||||
method: 'resources/list',
|
||||
params: {}
|
||||
},
|
||||
ListResourcesResultSchema
|
||||
);
|
||||
|
||||
// Verify resources result structure
|
||||
expect(resourcesResult).toHaveProperty('resources');
|
||||
|
||||
// List available prompts
|
||||
const promptsResult = await client.request(
|
||||
{
|
||||
method: 'prompts/list',
|
||||
params: {}
|
||||
},
|
||||
ListPromptsResultSchema
|
||||
);
|
||||
|
||||
// Verify prompts result structure
|
||||
expect(promptsResult).toHaveProperty('prompts');
|
||||
expect(promptsResult.prompts).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: 'test-prompt'
|
||||
})
|
||||
);
|
||||
|
||||
// Call the greeting tool
|
||||
const greetingResult = await client.request(
|
||||
{
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'greet',
|
||||
arguments: {
|
||||
name: 'Stateful Transport'
|
||||
}
|
||||
}
|
||||
},
|
||||
CallToolResultSchema
|
||||
);
|
||||
|
||||
// Verify tool result
|
||||
expect(greetingResult.content).toEqual([{ type: 'text', text: 'Hello, Stateful Transport!' }]);
|
||||
|
||||
// Clean up
|
||||
await transport.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
1664
test/integration/test/taskLifecycle.test.ts
Normal file
1664
test/integration/test/taskLifecycle.test.ts
Normal file
File diff suppressed because it is too large
Load diff
267
test/integration/test/taskResumability.test.ts
Normal file
267
test/integration/test/taskResumability.test.ts
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
|
||||
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
|
||||
import {
|
||||
CallToolResultSchema,
|
||||
InMemoryEventStore,
|
||||
LoggingMessageNotificationSchema,
|
||||
McpServer,
|
||||
StreamableHTTPServerTransport
|
||||
} from '@modelcontextprotocol/server';
|
||||
import type { ZodMatrixEntry } from '@modelcontextprotocol/test-helpers';
|
||||
import { listenOnRandomPort, zodTestMatrix } from '@modelcontextprotocol/test-helpers';
|
||||
|
||||
describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
|
||||
const { z } = entry;
|
||||
describe('Transport resumability', () => {
|
||||
let server: Server;
|
||||
let mcpServer: McpServer;
|
||||
let serverTransport: StreamableHTTPServerTransport;
|
||||
let baseUrl: URL;
|
||||
let eventStore: InMemoryEventStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create event store for resumability
|
||||
eventStore = new InMemoryEventStore();
|
||||
|
||||
// Create a simple MCP server
|
||||
mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } });
|
||||
|
||||
// Add a simple notification tool that completes quickly
|
||||
mcpServer.tool(
|
||||
'send-notification',
|
||||
'Sends a single notification',
|
||||
{
|
||||
message: z.string().describe('Message to send').default('Test notification')
|
||||
},
|
||||
async ({ message }, { sendNotification }) => {
|
||||
// Send notification immediately
|
||||
await sendNotification({
|
||||
method: 'notifications/message',
|
||||
params: {
|
||||
level: 'info',
|
||||
data: message
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Notification sent' }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Add a long-running tool that sends multiple notifications
|
||||
mcpServer.tool(
|
||||
'run-notifications',
|
||||
'Sends multiple notifications over time',
|
||||
{
|
||||
count: z.number().describe('Number of notifications to send').default(10),
|
||||
interval: z.number().describe('Interval between notifications in ms').default(50)
|
||||
},
|
||||
async ({ count, interval }, { sendNotification }) => {
|
||||
// Send notifications at specified intervals
|
||||
for (let i = 0; i < count; i++) {
|
||||
await sendNotification({
|
||||
method: 'notifications/message',
|
||||
params: {
|
||||
level: 'info',
|
||||
data: `Notification ${i + 1} of ${count}`
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for the specified interval before sending next notification
|
||||
if (i < count - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, interval));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Sent ${count} notifications` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Create a transport with the event store
|
||||
serverTransport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
eventStore
|
||||
});
|
||||
|
||||
// Connect the transport to the MCP server
|
||||
await mcpServer.connect(serverTransport);
|
||||
|
||||
// Create and start an HTTP server
|
||||
server = createServer(async (req, res) => {
|
||||
await serverTransport.handleRequest(req, res);
|
||||
});
|
||||
|
||||
// Start the server on a random port
|
||||
baseUrl = await listenOnRandomPort(server);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up resources
|
||||
await mcpServer.close().catch(() => {});
|
||||
await serverTransport.close().catch(() => {});
|
||||
server.close();
|
||||
});
|
||||
|
||||
it('should store session ID when client connects', async () => {
|
||||
// Create and connect a client
|
||||
const client = new Client({
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(baseUrl);
|
||||
await client.connect(transport);
|
||||
|
||||
// Verify session ID was generated
|
||||
expect(transport.sessionId).toBeDefined();
|
||||
|
||||
// Clean up
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
it('should have session ID functionality', async () => {
|
||||
// The ability to store a session ID when connecting
|
||||
const client = new Client({
|
||||
name: 'test-client-reconnection',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(baseUrl);
|
||||
|
||||
// Make sure the client can connect and get a session ID
|
||||
await client.connect(transport);
|
||||
expect(transport.sessionId).toBeDefined();
|
||||
|
||||
// Clean up
|
||||
await transport.close();
|
||||
});
|
||||
|
||||
// This test demonstrates the capability to resume long-running tools
|
||||
// across client disconnection/reconnection
|
||||
it('should resume long-running notifications with lastEventId', async () => {
|
||||
// Create unique client ID for this test
|
||||
const clientTitle = 'test-client-long-running';
|
||||
const notifications = [];
|
||||
let lastEventId: string | undefined;
|
||||
|
||||
// Create first client
|
||||
const client1 = new Client({
|
||||
title: clientTitle,
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
// Set up notification handler for first client
|
||||
client1.setNotificationHandler(LoggingMessageNotificationSchema, notification => {
|
||||
if (notification.method === 'notifications/message') {
|
||||
notifications.push(notification.params);
|
||||
}
|
||||
});
|
||||
|
||||
// Connect first client
|
||||
const transport1 = new StreamableHTTPClientTransport(baseUrl);
|
||||
await client1.connect(transport1);
|
||||
const sessionId = transport1.sessionId;
|
||||
expect(sessionId).toBeDefined();
|
||||
|
||||
// Start a long-running notification stream with tracking of lastEventId
|
||||
const onLastEventIdUpdate = vi.fn((eventId: string) => {
|
||||
lastEventId = eventId;
|
||||
});
|
||||
expect(lastEventId).toBeUndefined();
|
||||
// Start the notification tool with event tracking using request
|
||||
const toolPromise = client1.request(
|
||||
{
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'run-notifications',
|
||||
arguments: {
|
||||
count: 3,
|
||||
interval: 10
|
||||
}
|
||||
}
|
||||
},
|
||||
CallToolResultSchema,
|
||||
{
|
||||
resumptionToken: lastEventId,
|
||||
onresumptiontoken: onLastEventIdUpdate
|
||||
}
|
||||
);
|
||||
|
||||
// Fix for node 18 test failures, allow some time for notifications to arrive
|
||||
const maxWaitTime = 2000; // 2 seconds max wait
|
||||
const pollInterval = 10; // Check every 10ms
|
||||
const startTime = Date.now();
|
||||
while (notifications.length === 0 && Date.now() - startTime < maxWaitTime) {
|
||||
// Wait for some notifications to arrive (not all) - shorter wait time
|
||||
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
||||
}
|
||||
|
||||
// Verify we received some notifications and lastEventId was updated
|
||||
expect(notifications.length).toBeGreaterThan(0);
|
||||
expect(notifications.length).toBeLessThan(4);
|
||||
expect(onLastEventIdUpdate).toHaveBeenCalled();
|
||||
expect(lastEventId).toBeDefined();
|
||||
|
||||
// Disconnect first client without waiting for completion
|
||||
// When we close the connection, it will cause a ConnectionClosed error for
|
||||
// any in-progress requests, which is expected behavior
|
||||
await transport1.close();
|
||||
// Save the promise so we can catch it after closing
|
||||
const catchPromise = toolPromise.catch(err => {
|
||||
// This error is expected - the connection was intentionally closed
|
||||
if (err?.code !== -32000) {
|
||||
// ConnectionClosed error code
|
||||
console.error('Unexpected error type during transport close:', err);
|
||||
}
|
||||
});
|
||||
|
||||
// Add a short delay to ensure clean disconnect before reconnecting
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
// Wait for the rejection to be handled
|
||||
await catchPromise;
|
||||
|
||||
// Create second client with same client ID
|
||||
const client2 = new Client({
|
||||
title: clientTitle,
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
});
|
||||
|
||||
// Track replayed notifications separately
|
||||
const replayedNotifications: unknown[] = [];
|
||||
client2.setNotificationHandler(LoggingMessageNotificationSchema, notification => {
|
||||
if (notification.method === 'notifications/message') {
|
||||
replayedNotifications.push(notification.params);
|
||||
}
|
||||
});
|
||||
|
||||
// Connect second client with same session ID
|
||||
const transport2 = new StreamableHTTPClientTransport(baseUrl, {
|
||||
sessionId
|
||||
});
|
||||
await client2.connect(transport2);
|
||||
|
||||
// Resume GET SSE stream with Last-Event-ID to replay missed events
|
||||
// Per spec, resumption uses GET with Last-Event-ID header
|
||||
await transport2.resumeStream(lastEventId!, { onresumptiontoken: onLastEventIdUpdate });
|
||||
|
||||
// Wait for replayed events to arrive via SSE
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Verify the test infrastructure worked - we received notifications in first session
|
||||
// and captured the lastEventId for potential replay
|
||||
expect(notifications.length).toBeGreaterThan(0);
|
||||
expect(lastEventId).toBeDefined();
|
||||
|
||||
// Clean up
|
||||
await transport2.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
227
test/integration/test/title.test.ts
Normal file
227
test/integration/test/title.test.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import { Client } from '@modelcontextprotocol/client';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/core';
|
||||
import { McpServer, ResourceTemplate, Server } from '@modelcontextprotocol/server';
|
||||
import { type ZodMatrixEntry, zodTestMatrix } from '@modelcontextprotocol/test-helpers';
|
||||
|
||||
describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
|
||||
const { z } = entry;
|
||||
|
||||
describe('Title field backwards compatibility', () => {
|
||||
it('should work with tools that have title', async () => {
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} });
|
||||
|
||||
// Register tool with title
|
||||
server.registerTool(
|
||||
'test-tool',
|
||||
{
|
||||
title: 'Test Tool Display Name',
|
||||
description: 'A test tool',
|
||||
inputSchema: {
|
||||
value: z.string()
|
||||
}
|
||||
},
|
||||
async () => ({ content: [{ type: 'text', text: 'result' }] })
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' });
|
||||
|
||||
await server.server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const tools = await client.listTools();
|
||||
expect(tools.tools).toHaveLength(1);
|
||||
expect(tools.tools[0]!.name).toBe('test-tool');
|
||||
expect(tools.tools[0]!.title).toBe('Test Tool Display Name');
|
||||
expect(tools.tools[0]!.description).toBe('A test tool');
|
||||
});
|
||||
|
||||
it('should work with tools without title', async () => {
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} });
|
||||
|
||||
// Register tool without title
|
||||
server.tool('test-tool', 'A test tool', { value: z.string() }, async () => ({ content: [{ type: 'text', text: 'result' }] }));
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' });
|
||||
|
||||
await server.server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const tools = await client.listTools();
|
||||
expect(tools.tools).toHaveLength(1);
|
||||
expect(tools.tools[0]!.name).toBe('test-tool');
|
||||
expect(tools.tools[0]!.title).toBeUndefined();
|
||||
expect(tools.tools[0]!.description).toBe('A test tool');
|
||||
});
|
||||
|
||||
it('should work with prompts that have title using update', async () => {
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} });
|
||||
|
||||
// Register prompt with title by updating after creation
|
||||
const prompt = server.prompt('test-prompt', 'A test prompt', async () => ({
|
||||
messages: [{ role: 'user', content: { type: 'text', text: 'test' } }]
|
||||
}));
|
||||
prompt.update({ title: 'Test Prompt Display Name' });
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' });
|
||||
|
||||
await server.server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const prompts = await client.listPrompts();
|
||||
expect(prompts.prompts).toHaveLength(1);
|
||||
expect(prompts.prompts[0]!.name).toBe('test-prompt');
|
||||
expect(prompts.prompts[0]!.title).toBe('Test Prompt Display Name');
|
||||
expect(prompts.prompts[0]!.description).toBe('A test prompt');
|
||||
});
|
||||
|
||||
it('should work with prompts using registerPrompt', async () => {
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} });
|
||||
|
||||
// Register prompt with title using registerPrompt
|
||||
server.registerPrompt(
|
||||
'test-prompt',
|
||||
{
|
||||
title: 'Test Prompt Display Name',
|
||||
description: 'A test prompt',
|
||||
argsSchema: { input: z.string() }
|
||||
},
|
||||
async ({ input }) => ({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: { type: 'text', text: `test: ${input}` }
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' });
|
||||
|
||||
await server.server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const prompts = await client.listPrompts();
|
||||
expect(prompts.prompts).toHaveLength(1);
|
||||
expect(prompts.prompts[0]!.name).toBe('test-prompt');
|
||||
expect(prompts.prompts[0]!.title).toBe('Test Prompt Display Name');
|
||||
expect(prompts.prompts[0]!.description).toBe('A test prompt');
|
||||
expect(prompts.prompts[0]!.arguments).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should work with resources using registerResource', async () => {
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} });
|
||||
|
||||
// Register resource with title using registerResource
|
||||
server.registerResource(
|
||||
'test-resource',
|
||||
'https://example.com/test',
|
||||
{
|
||||
title: 'Test Resource Display Name',
|
||||
description: 'A test resource',
|
||||
mimeType: 'text/plain'
|
||||
},
|
||||
async () => ({
|
||||
contents: [
|
||||
{
|
||||
uri: 'https://example.com/test',
|
||||
text: 'test content'
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' });
|
||||
|
||||
await server.server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const resources = await client.listResources();
|
||||
expect(resources.resources).toHaveLength(1);
|
||||
expect(resources.resources[0]!.name).toBe('test-resource');
|
||||
expect(resources.resources[0]!.title).toBe('Test Resource Display Name');
|
||||
expect(resources.resources[0]!.description).toBe('A test resource');
|
||||
expect(resources.resources[0]!.mimeType).toBe('text/plain');
|
||||
});
|
||||
|
||||
it('should work with dynamic resources using registerResource', async () => {
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} });
|
||||
|
||||
// Register dynamic resource with title using registerResource
|
||||
server.registerResource(
|
||||
'user-profile',
|
||||
new ResourceTemplate('users://{userId}/profile', { list: undefined }),
|
||||
{
|
||||
title: 'User Profile',
|
||||
description: 'User profile information'
|
||||
},
|
||||
async (uri, { userId }, _extra) => ({
|
||||
contents: [
|
||||
{
|
||||
uri: uri.href,
|
||||
text: `Profile data for user ${userId}`
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' });
|
||||
|
||||
await server.server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const resourceTemplates = await client.listResourceTemplates();
|
||||
expect(resourceTemplates.resourceTemplates).toHaveLength(1);
|
||||
expect(resourceTemplates.resourceTemplates[0]!.name).toBe('user-profile');
|
||||
expect(resourceTemplates.resourceTemplates[0]!.title).toBe('User Profile');
|
||||
expect(resourceTemplates.resourceTemplates[0]!.description).toBe('User profile information');
|
||||
expect(resourceTemplates.resourceTemplates[0]!.uriTemplate).toBe('users://{userId}/profile');
|
||||
|
||||
// Test reading the resource
|
||||
const readResult = await client.readResource({ uri: 'users://123/profile' });
|
||||
expect(readResult.contents).toHaveLength(1);
|
||||
expect(readResult.contents).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
text: expect.stringContaining('Profile data for user 123'),
|
||||
uri: 'users://123/profile'
|
||||
}
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('should support serverInfo with title', async () => {
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
const server = new Server(
|
||||
{
|
||||
name: 'test-server',
|
||||
version: '1.0.0',
|
||||
title: 'Test Server Display Name'
|
||||
},
|
||||
{ capabilities: {} }
|
||||
);
|
||||
|
||||
const client = new Client({ name: 'test-client', version: '1.0.0' });
|
||||
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const serverInfo = client.getServerVersion();
|
||||
expect(serverInfo?.name).toBe('test-server');
|
||||
expect(serverInfo?.version).toBe('1.0.0');
|
||||
expect(serverInfo?.title).toBe('Test Server Display Name');
|
||||
});
|
||||
});
|
||||
});
|
||||
15
test/integration/tsconfig.json
Normal file
15
test/integration/tsconfig.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"extends": "@modelcontextprotocol/tsconfig",
|
||||
"include": ["./"],
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"*": ["./*"],
|
||||
"@modelcontextprotocol/core": ["./node_modules/@modelcontextprotocol/core/src/index.ts"],
|
||||
"@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"],
|
||||
"@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"],
|
||||
"@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"],
|
||||
"@modelcontextprotocol/test-helpers": ["./node_modules/@modelcontextprotocol/test-helpers/src/index.ts"]
|
||||
}
|
||||
}
|
||||
}
|
||||
3
test/integration/vitest.config.js
Normal file
3
test/integration/vitest.config.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import baseConfig from '../../common/vitest-config/vitest.config.js';
|
||||
|
||||
export default baseConfig;
|
||||
Loading…
Reference in a new issue