[v2] Мonorepo, sdk-client, sdk-server, sdk-core package split (#1279)

This commit is contained in:
Konstantin Konstantinov 2025-12-19 15:19:02 +02:00 • committed by GitHub
commit 71ae3acee0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 12067 additions and 4817 deletions

View file

@ -0,0 +1,5 @@
// @ts-check
import baseConfig from '@modelcontextprotocol/eslint-config';
export default baseConfig;

View 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:^"
}
}

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

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

File diff suppressed because it is too large Load diff

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

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

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

View file

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

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

File diff suppressed because it is too large Load diff

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

File diff suppressed because it is too large Load diff

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

File diff suppressed because it is too large Load diff

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

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

View 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"]
}
}
}

View file

@ -0,0 +1,3 @@
import baseConfig from '../../common/vitest-config/vitest.config.js';
export default baseConfig;