Follow up: unify v3 and v4 zod types via describe matrix and a test helper (#1141)

This commit is contained in:
Konstantin Konstantinov 2025-11-20 12:10:15 +02:00 • committed by GitHub
commit 43189335a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 6993 additions and 17620 deletions

View file

@ -2,7 +2,6 @@
/* eslint-disable no-constant-binary-expression */
/* eslint-disable @typescript-eslint/no-unused-expressions */
import { Client, getSupportedElicitationModes } from './index.js';
import * as z from 'zod/v4';
import {
RequestSchema,
NotificationSchema,
@ -21,6 +20,165 @@ import {
import { Transport } from '../shared/transport.js';
import { Server } from '../server/index.js';
import { InMemoryTransport } from '../inMemory.js';
import * as z3 from 'zod/v3';
import * as z4 from 'zod/v4';
describe('Zod v4', () => {
/***
* Test: Type Checking
* Test that custom request/notification/result schemas can be used with the Client class.
*/
test('should typecheck', () => {
const GetWeatherRequestSchema = RequestSchema.extend({
method: z4.literal('weather/get'),
params: z4.object({
city: z4.string()
})
});
const GetForecastRequestSchema = RequestSchema.extend({
method: z4.literal('weather/forecast'),
params: z4.object({
city: z4.string(),
days: z4.number()
})
});
const WeatherForecastNotificationSchema = NotificationSchema.extend({
method: z4.literal('weather/alert'),
params: z4.object({
severity: z4.enum(['warning', 'watch']),
message: z4.string()
})
});
const WeatherRequestSchema = GetWeatherRequestSchema.or(GetForecastRequestSchema);
const WeatherNotificationSchema = WeatherForecastNotificationSchema;
const WeatherResultSchema = ResultSchema.extend({
temperature: z4.number(),
conditions: z4.string()
});
type WeatherRequest = z4.infer<typeof WeatherRequestSchema>;
type WeatherNotification = z4.infer<typeof WeatherNotificationSchema>;
type WeatherResult = z4.infer<typeof WeatherResultSchema>;
// Create a typed Client for weather data
const weatherClient = new Client<WeatherRequest, WeatherNotification, WeatherResult>(
{
name: 'WeatherClient',
version: '1.0.0'
},
{
capabilities: {
sampling: {}
}
}
);
// Typecheck that only valid weather requests/notifications/results are allowed
false &&
weatherClient.request(
{
method: 'weather/get',
params: {
city: 'Seattle'
}
},
WeatherResultSchema
);
false &&
weatherClient.notification({
method: 'weather/alert',
params: {
severity: 'warning',
message: 'Storm approaching'
}
});
});
});
describe('Zod v3', () => {
/***
* Test: Type Checking
* Test that custom request/notification/result schemas can be used with the Client class.
*/
test('should typecheck', () => {
const GetWeatherRequestSchema = z3.object({
...RequestSchema.shape,
method: z3.literal('weather/get'),
params: z3.object({
city: z3.string()
})
});
const GetForecastRequestSchema = z3.object({
...RequestSchema.shape,
method: z3.literal('weather/forecast'),
params: z3.object({
city: z3.string(),
days: z3.number()
})
});
const WeatherForecastNotificationSchema = z3.object({
...NotificationSchema.shape,
method: z3.literal('weather/alert'),
params: z3.object({
severity: z3.enum(['warning', 'watch']),
message: z3.string()
})
});
const WeatherRequestSchema = GetWeatherRequestSchema.or(GetForecastRequestSchema);
const WeatherNotificationSchema = WeatherForecastNotificationSchema;
const WeatherResultSchema = z3.object({
...ResultSchema.shape,
_meta: z3.record(z3.string(), z3.unknown()).optional(),
temperature: z3.number(),
conditions: z3.string()
});
type WeatherRequest = z3.infer<typeof WeatherRequestSchema>;
type WeatherNotification = z3.infer<typeof WeatherNotificationSchema>;
type WeatherResult = z3.infer<typeof WeatherResultSchema>;
// Create a typed Client for weather data
const weatherClient = new Client<WeatherRequest, WeatherNotification, WeatherResult>(
{
name: 'WeatherClient',
version: '1.0.0'
},
{
capabilities: {
sampling: {}
}
}
);
// Typecheck that only valid weather requests/notifications/results are allowed
false &&
weatherClient.request(
{
method: 'weather/get',
params: {
city: 'Seattle'
}
},
WeatherResultSchema
);
false &&
weatherClient.notification({
method: 'weather/alert',
params: {
severity: 'warning',
message: 'Storm approaching'
}
});
});
});
/***
* Test: Initialize with Matching Protocol Version
@ -906,80 +1064,6 @@ test('should apply defaults for form-mode elicitation when applyDefaults is enab
await client.close();
});
/***
* Test: Type Checking
* Test that custom request/notification/result schemas can be used with the Client class.
*/
test('should typecheck', () => {
const GetWeatherRequestSchema = RequestSchema.extend({
method: z.literal('weather/get'),
params: z.object({
city: z.string()
})
});
const GetForecastRequestSchema = RequestSchema.extend({
method: z.literal('weather/forecast'),
params: z.object({
city: z.string(),
days: z.number()
})
});
const WeatherForecastNotificationSchema = NotificationSchema.extend({
method: z.literal('weather/alert'),
params: z.object({
severity: z.enum(['warning', 'watch']),
message: z.string()
})
});
const WeatherRequestSchema = GetWeatherRequestSchema.or(GetForecastRequestSchema);
const WeatherNotificationSchema = WeatherForecastNotificationSchema;
const WeatherResultSchema = ResultSchema.extend({
temperature: z.number(),
conditions: z.string()
});
type WeatherRequest = z.infer<typeof WeatherRequestSchema>;
type WeatherNotification = z.infer<typeof WeatherNotificationSchema>;
type WeatherResult = z.infer<typeof WeatherResultSchema>;
// Create a typed Client for weather data
const weatherClient = new Client<WeatherRequest, WeatherNotification, WeatherResult>(
{
name: 'WeatherClient',
version: '1.0.0'
},
{
capabilities: {
sampling: {}
}
}
);
// Typecheck that only valid weather requests/notifications/results are allowed
false &&
weatherClient.request(
{
method: 'weather/get',
params: {
city: 'Seattle'
}
},
WeatherResultSchema
);
false &&
weatherClient.notification({
method: 'weather/alert',
params: {
severity: 'warning',
message: 'Storm approaching'
}
});
});
/***
* Test: Handle Client Cancelling a Request
*/

File diff suppressed because it is too large Load diff

View file

@ -12,346 +12,349 @@ import {
ListPromptsResultSchema,
LATEST_PROTOCOL_VERSION
} from '../types.js';
import * as z from 'zod/v4';
import { zodTestMatrix, type ZodMatrixEntry } from '../shared/zodTestMatrix.js';
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: [
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' },
{
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'
capabilities: {
logging: {},
tools: {},
resources: {},
prompts: {}
}
}
]
}));
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 new Promise<URL>(resolve => {
server.listen(0, '127.0.0.1', () => {
const addr = server.address() as AddressInfo;
resolve(new URL(`http://127.0.0.1:${addr.port}`));
});
});
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'
});
// Add a simple resource
mcpServer.resource('test-resource', '/test', { description: 'A test resource' }, async () => ({
contents: [
{
uri: '/test',
text: 'This is a test resource content'
}
]
}));
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'
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')
},
CallToolResultSchema
async ({ name }) => {
return {
content: [{ type: 'text', text: `Hello, ${name}!` }]
};
}
);
// 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'
// 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
});
const transport = new StreamableHTTPClientTransport(baseUrl);
await mcpServer.connect(serverTransport);
// 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'
server.on('request', async (req, res) => {
await serverTransport.handleRequest(req, res);
});
const transport = new StreamableHTTPClientTransport(baseUrl);
await client.connect(transport);
// Start the server on a random port
const baseUrl = await new Promise<URL>(resolve => {
server.listen(0, '127.0.0.1', () => {
const addr = server.address() as AddressInfo;
resolve(new URL(`http://127.0.0.1:${addr.port}`));
});
});
// Verify that a session ID was set
expect(transport.sessionId).toBeDefined();
expect(typeof transport.sessionId).toBe('string');
return { server, mcpServer, serverTransport, baseUrl };
}
// List available tools
const toolsResult = await client.request(
{
method: 'tools/list',
params: {}
},
ListToolsResultSchema
);
describe('Stateless Mode', () => {
let server: Server;
let mcpServer: McpServer;
let serverTransport: StreamableHTTPServerTransport;
let baseUrl: URL;
// Verify tools are accessible
expect(toolsResult.tools).toContainEqual(
expect.objectContaining({
name: 'greet'
})
);
beforeEach(async () => {
const setup = await setupServer(false);
server = setup.server;
mcpServer = setup.mcpServer;
serverTransport = setup.serverTransport;
baseUrl = setup.baseUrl;
});
// List available resources
const resourcesResult = await client.request(
{
method: 'resources/list',
params: {}
},
ListResourcesResultSchema
);
afterEach(async () => {
// Clean up resources
await mcpServer.close().catch(() => {});
await serverTransport.close().catch(() => {});
server.close();
});
// Verify resources result structure
expect(resourcesResult).toHaveProperty('resources');
it('should support multiple client connections', async () => {
// Create and connect a client
const client1 = new Client({
name: 'test-client',
version: '1.0.0'
});
// List available prompts
const promptsResult = await client.request(
{
method: 'prompts/list',
params: {}
},
ListPromptsResultSchema
);
const transport1 = new StreamableHTTPClientTransport(baseUrl);
await client1.connect(transport1);
// Verify prompts result structure
expect(promptsResult).toHaveProperty('prompts');
expect(promptsResult.prompts).toContainEqual(
expect.objectContaining({
name: 'test-prompt'
})
);
// Verify that no session ID was set
expect(transport1.sessionId).toBeUndefined();
// Call the greeting tool
const greetingResult = await client.request(
{
method: 'tools/call',
params: {
name: 'greet',
arguments: {
name: 'Stateful Transport'
// 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
);
},
CallToolResultSchema
);
// Verify tool result
expect(greetingResult.content).toEqual([{ type: 'text', text: 'Hello, Stateful Transport!' }]);
// Verify tool result
expect(greetingResult.content).toEqual([{ type: 'text', text: 'Hello, Stateless Transport!' }]);
// Clean up
await transport.close();
// 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();
});
});
});
});

View file

@ -6,271 +6,274 @@ import { StreamableHTTPClientTransport } from '../client/streamableHttp.js';
import { McpServer } from '../server/mcp.js';
import { StreamableHTTPServerTransport } from '../server/streamableHttp.js';
import { CallToolResultSchema, LoggingMessageNotificationSchema } from '../types.js';
import * as z from 'zod/v4';
import { InMemoryEventStore } from '../examples/shared/inMemoryEventStore.js';
import { zodTestMatrix, type ZodMatrixEntry } from '../shared/zodTestMatrix.js';
describe('Transport resumability', () => {
let server: Server;
let mcpServer: McpServer;
let serverTransport: StreamableHTTPServerTransport;
let baseUrl: URL;
let eventStore: InMemoryEventStore;
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();
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: {} } });
// 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++) {
// 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: `Notification ${i + 1} of ${count}`
data: message
}
});
// 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: 'Notification sent' }]
};
}
);
return {
content: [{ type: 'text', text: `Sent ${count} notifications` }]
};
}
);
// 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}`
}
});
// Create a transport with the event store
serverTransport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
eventStore
});
// Wait for the specified interval before sending next notification
if (i < count - 1) {
await new Promise(resolve => setTimeout(resolve, interval));
}
}
// Connect the transport to the MCP server
await mcpServer.connect(serverTransport);
return {
content: [{ type: 'text', text: `Sent ${count} notifications` }]
};
}
);
// Create and start an HTTP server
server = createServer(async (req, res) => {
await serverTransport.handleRequest(req, res);
});
// Create a transport with the event store
serverTransport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
eventStore
});
// Start the server on a random port
baseUrl = await new Promise<URL>(resolve => {
server.listen(0, '127.0.0.1', () => {
const addr = server.address() as AddressInfo;
resolve(new URL(`http://127.0.0.1:${addr.port}`));
// 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 new Promise<URL>(resolve => {
server.listen(0, '127.0.0.1', () => {
const addr = server.address() as AddressInfo;
resolve(new URL(`http://127.0.0.1:${addr.port}`));
});
});
});
});
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'
afterEach(async () => {
// Clean up resources
await mcpServer.close().catch(() => {});
await serverTransport.close().catch(() => {});
server.close();
});
const transport = new StreamableHTTPClientTransport(baseUrl);
await client.connect(transport);
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'
});
// Verify session ID was generated
expect(transport.sessionId).toBeDefined();
const transport = new StreamableHTTPClientTransport(baseUrl);
await client.connect(transport);
// Clean up
await transport.close();
});
// Verify session ID was generated
expect(transport.sessionId).toBeDefined();
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'
// Clean up
await transport.close();
});
const transport = new StreamableHTTPClientTransport(baseUrl);
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'
});
// Make sure the client can connect and get a session ID
await client.connect(transport);
expect(transport.sessionId).toBeDefined();
const transport = new StreamableHTTPClientTransport(baseUrl);
// Clean up
await transport.close();
});
// Make sure the client can connect and get a session ID
await client.connect(transport);
expect(transport.sessionId).toBeDefined();
// 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'
// Clean up
await transport.close();
});
// Set up notification handler for first client
client1.setNotificationHandler(LoggingMessageNotificationSchema, notification => {
if (notification.method === 'notifications/message') {
notifications.push(notification.params);
}
});
// 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;
// Connect first client
const transport1 = new StreamableHTTPClientTransport(baseUrl);
await client1.connect(transport1);
const sessionId = transport1.sessionId;
expect(sessionId).toBeDefined();
// Create first client
const client1 = new Client({
title: clientTitle,
name: 'test-client',
version: '1.0.0'
});
// 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
}
// Set up notification handler for first client
client1.setNotificationHandler(LoggingMessageNotificationSchema, notification => {
if (notification.method === 'notifications/message') {
notifications.push(notification.params);
}
},
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));
}
// Connect first client
const transport1 = new StreamableHTTPClientTransport(baseUrl);
await client1.connect(transport1);
const sessionId = transport1.sessionId;
expect(sessionId).toBeDefined();
// 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'
});
// Set up notification handler for second client
client2.setNotificationHandler(LoggingMessageNotificationSchema, notification => {
if (notification.method === 'notifications/message') {
notifications.push(notification.params);
}
});
// Connect second client with same session ID
const transport2 = new StreamableHTTPClientTransport(baseUrl, {
sessionId
});
await client2.connect(transport2);
// Resume the notification stream using lastEventId
// This is the key part - we're resuming the same long-running tool using lastEventId
await client2.request(
{
method: 'tools/call',
params: {
name: 'run-notifications',
arguments: {
count: 1,
interval: 5
// 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
}
},
CallToolResultSchema,
{
resumptionToken: lastEventId, // Pass the lastEventId from the previous session
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 eventually received at leaset a few motifications
expect(notifications.length).toBeGreaterThan(1);
// 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();
// Clean up
await transport2.close();
// 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'
});
// Set up notification handler for second client
client2.setNotificationHandler(LoggingMessageNotificationSchema, notification => {
if (notification.method === 'notifications/message') {
notifications.push(notification.params);
}
});
// Connect second client with same session ID
const transport2 = new StreamableHTTPClientTransport(baseUrl, {
sessionId
});
await client2.connect(transport2);
// Resume the notification stream using lastEventId
// This is the key part - we're resuming the same long-running tool using lastEventId
await client2.request(
{
method: 'tools/call',
params: {
name: 'run-notifications',
arguments: {
count: 1,
interval: 5
}
}
},
CallToolResultSchema,
{
resumptionToken: lastEventId, // Pass the lastEventId from the previous session
onresumptiontoken: onLastEventIdUpdate
}
);
// Verify we eventually received at leaset a few motifications
expect(notifications.length).toBeGreaterThan(1);
// Clean up
await transport2.close();
});
});
});

View file

@ -1,357 +0,0 @@
import { createServer, type Server } from 'node:http';
import { AddressInfo } from 'node:net';
import { randomUUID } from 'node:crypto';
import { Client } from '../../client/index.js';
import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js';
import { McpServer } from '../../server/mcp.js';
import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
import {
CallToolResultSchema,
ListToolsResultSchema,
ListResourcesResultSchema,
ListPromptsResultSchema,
LATEST_PROTOCOL_VERSION
} from '../../types.js';
import * as z from 'zod/v3';
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 new Promise<URL>(resolve => {
server.listen(0, '127.0.0.1', () => {
const addr = server.address() as AddressInfo;
resolve(new URL(`http://127.0.0.1:${addr.port}`));
});
});
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();
});
});
});

View file

@ -1,270 +0,0 @@
import { createServer, type Server } from 'node:http';
import { AddressInfo } from 'node:net';
import { randomUUID } from 'node:crypto';
import { Client } from '../../client/index.js';
import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js';
import { McpServer } from '../../server/mcp.js';
import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
import { CallToolResultSchema, LoggingMessageNotificationSchema } from '../../types.js';
import * as z from 'zod/v3';
import { InMemoryEventStore } from '../../examples/shared/inMemoryEventStore.js';
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 new Promise<URL>(resolve => {
server.listen(0, '127.0.0.1', () => {
const addr = server.address() as AddressInfo;
resolve(new URL(`http://127.0.0.1:${addr.port}`));
});
});
});
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
}
);
// Wait for some notifications to arrive (not all) - shorter wait time
await new Promise(resolve => setTimeout(resolve, 20));
// 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'
});
// Set up notification handler for second client
client2.setNotificationHandler(LoggingMessageNotificationSchema, notification => {
if (notification.method === 'notifications/message') {
notifications.push(notification.params);
}
});
// Connect second client with same session ID
const transport2 = new StreamableHTTPClientTransport(baseUrl, {
sessionId
});
await client2.connect(transport2);
// Resume the notification stream using lastEventId
// This is the key part - we're resuming the same long-running tool using lastEventId
await client2.request(
{
method: 'tools/call',
params: {
name: 'run-notifications',
arguments: {
count: 1,
interval: 5
}
}
},
CallToolResultSchema,
{
resumptionToken: lastEventId, // Pass the lastEventId from the previous session
onresumptiontoken: onLastEventIdUpdate
}
);
// Verify we eventually received at leaset a few motifications
expect(notifications.length).toBeGreaterThan(1);
// Clean up
await transport2.close();
});
});

View file

@ -1,7 +1,8 @@
import * as z from 'zod/v4';
import { completable, getCompleter } from './completable.js';
import { zodTestMatrix, type ZodMatrixEntry } from '../shared/zodTestMatrix.js';
describe('completable', () => {
describe.each(zodTestMatrix)('completable with $zodVersionLabel', (entry: ZodMatrixEntry) => {
const { z } = entry;
it('preserves types and values of underlying schema', () => {
const baseSchema = z.string();
const schema = completable(baseSchema, () => []);

View file

@ -1,5 +1,4 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import * as z from 'zod/v4';
import { Client } from '../client/index.js';
import { InMemoryTransport } from '../inMemory.js';
import type { Transport } from '../shared/transport.js';
@ -21,6 +20,156 @@ import {
} from '../types.js';
import { Server } from './index.js';
import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from '../validation/types.js';
import type { AnyObjectSchema } from './zod-compat.js';
import * as z3 from 'zod/v3';
import * as z4 from 'zod/v4';
describe('Zod v3', () => {
/*
Test that custom request/notification/result schemas can be used with the Server class.
*/
test('should typecheck', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const GetWeatherRequestSchema = (RequestSchema as unknown as z3.ZodObject<any>).extend({
method: z3.literal('weather/get'),
params: z3.object({
city: z3.string()
})
}) as AnyObjectSchema;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const GetForecastRequestSchema = (RequestSchema as unknown as z3.ZodObject<any>).extend({
method: z3.literal('weather/forecast'),
params: z3.object({
city: z3.string(),
days: z3.number()
})
}) as AnyObjectSchema;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const WeatherForecastNotificationSchema = (NotificationSchema as unknown as z3.ZodObject<any>).extend({
method: z3.literal('weather/alert'),
params: z3.object({
severity: z3.enum(['warning', 'watch']),
message: z3.string()
})
}) as AnyObjectSchema;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const WeatherRequestSchema = (GetWeatherRequestSchema as unknown as z3.ZodObject<any>).or(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
GetForecastRequestSchema as unknown as z3.ZodObject<any>
) as AnyObjectSchema;
const WeatherNotificationSchema = WeatherForecastNotificationSchema as AnyObjectSchema;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const WeatherResultSchema = (ResultSchema as unknown as z3.ZodObject<any>).extend({
temperature: z3.number(),
conditions: z3.string()
}) as AnyObjectSchema;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type InferSchema<T> = T extends z3.ZodType<infer Output, any, any> ? Output : never;
type WeatherRequest = InferSchema<typeof WeatherRequestSchema>;
type WeatherNotification = InferSchema<typeof WeatherNotificationSchema>;
type WeatherResult = InferSchema<typeof WeatherResultSchema>;
// Create a typed Server for weather data
const weatherServer = new Server<WeatherRequest, WeatherNotification, WeatherResult>(
{
name: 'WeatherServer',
version: '1.0.0'
},
{
capabilities: {
prompts: {},
resources: {},
tools: {},
logging: {}
}
}
);
// Typecheck that only valid weather requests/notifications/results are allowed
weatherServer.setRequestHandler(GetWeatherRequestSchema, _request => {
return {
temperature: 72,
conditions: 'sunny'
};
});
weatherServer.setNotificationHandler(WeatherForecastNotificationSchema, notification => {
// Type assertion needed for v3/v4 schema mixing
const params = notification.params as { message: string; severity: 'warning' | 'watch' };
console.log(`Weather alert: ${params.message}`);
});
});
});
describe('Zod v4', () => {
test('should typecheck', () => {
const GetWeatherRequestSchema = RequestSchema.extend({
method: z4.literal('weather/get'),
params: z4.object({
city: z4.string()
})
});
const GetForecastRequestSchema = RequestSchema.extend({
method: z4.literal('weather/forecast'),
params: z4.object({
city: z4.string(),
days: z4.number()
})
});
const WeatherForecastNotificationSchema = NotificationSchema.extend({
method: z4.literal('weather/alert'),
params: z4.object({
severity: z4.enum(['warning', 'watch']),
message: z4.string()
})
});
const WeatherRequestSchema = GetWeatherRequestSchema.or(GetForecastRequestSchema);
const WeatherNotificationSchema = WeatherForecastNotificationSchema;
const WeatherResultSchema = ResultSchema.extend({
temperature: z4.number(),
conditions: z4.string()
});
type WeatherRequest = z4.infer<typeof WeatherRequestSchema>;
type WeatherNotification = z4.infer<typeof WeatherNotificationSchema>;
type WeatherResult = z4.infer<typeof WeatherResultSchema>;
// Create a typed Server for weather data
const weatherServer = new Server<WeatherRequest, WeatherNotification, WeatherResult>(
{
name: 'WeatherServer',
version: '1.0.0'
},
{
capabilities: {
prompts: {},
resources: {},
tools: {},
logging: {}
}
}
);
// Typecheck that only valid weather requests/notifications/results are allowed
weatherServer.setRequestHandler(GetWeatherRequestSchema, _request => {
return {
temperature: 72,
conditions: 'sunny'
};
});
weatherServer.setNotificationHandler(WeatherForecastNotificationSchema, notification => {
console.log(`Weather alert: ${notification.params.message}`);
});
});
});
test('should accept latest protocol version', async () => {
let sendPromiseResolve: (value: unknown) => void;
@ -1247,73 +1396,6 @@ test('should only allow setRequestHandler for declared capabilities', () => {
}).toThrow(/^Server does not support logging/);
});
/*
Test that custom request/notification/result schemas can be used with the Server class.
*/
test('should typecheck', () => {
const GetWeatherRequestSchema = RequestSchema.extend({
method: z.literal('weather/get'),
params: z.object({
city: z.string()
})
});
const GetForecastRequestSchema = RequestSchema.extend({
method: z.literal('weather/forecast'),
params: z.object({
city: z.string(),
days: z.number()
})
});
const WeatherForecastNotificationSchema = NotificationSchema.extend({
method: z.literal('weather/alert'),
params: z.object({
severity: z.enum(['warning', 'watch']),
message: z.string()
})
});
const WeatherRequestSchema = GetWeatherRequestSchema.or(GetForecastRequestSchema);
const WeatherNotificationSchema = WeatherForecastNotificationSchema;
const WeatherResultSchema = ResultSchema.extend({
temperature: z.number(),
conditions: z.string()
});
type WeatherRequest = z.infer<typeof WeatherRequestSchema>;
type WeatherNotification = z.infer<typeof WeatherNotificationSchema>;
type WeatherResult = z.infer<typeof WeatherResultSchema>;
// Create a typed Server for weather data
const weatherServer = new Server<WeatherRequest, WeatherNotification, WeatherResult>(
{
name: 'WeatherServer',
version: '1.0.0'
},
{
capabilities: {
prompts: {},
resources: {},
tools: {},
logging: {}
}
}
);
// Typecheck that only valid weather requests/notifications/results are allowed
weatherServer.setRequestHandler(GetWeatherRequestSchema, _request => {
return {
temperature: 72,
conditions: 'sunny'
};
});
weatherServer.setNotificationHandler(WeatherForecastNotificationSchema, notification => {
console.log(`Weather alert: ${notification.params.message}`);
});
});
test('should handle server cancelling a request', async () => {
const server = new Server(
{

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,224 +1,228 @@
import { Server } from './index.js';
import { Client } from '../client/index.js';
import { InMemoryTransport } from '../inMemory.js';
import * as z from 'zod/v4';
import { McpServer, ResourceTemplate } from './mcp.js';
import { zodTestMatrix, type ZodMatrixEntry } from '../shared/zodTestMatrix.js';
describe('Title field backwards compatibility', () => {
it('should work with tools that have title', async () => {
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
const { z } = entry;
const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} });
describe('Title field backwards compatibility', () => {
it('should work with tools that have title', async () => {
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
// 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 server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} });
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([
// Register tool with title
server.registerTool(
'test-tool',
{
text: expect.stringContaining('Profile data for user 123'),
uri: 'users://123/profile'
}
])
);
});
title: 'Test Tool Display Name',
description: 'A test tool',
inputSchema: {
value: z.string()
}
},
async () => ({ content: [{ type: 'text', text: 'result' }] })
);
it('should support serverInfo with title', async () => {
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: 'test-client', version: '1.0.0' });
const server = new Server(
{
name: 'test-server',
version: '1.0.0',
title: 'Test Server Display Name'
},
{ capabilities: {} }
);
await server.server.connect(serverTransport);
await client.connect(clientTransport);
const client = new Client({ name: 'test-client', version: '1.0.0' });
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');
});
await server.connect(serverTransport);
await client.connect(clientTransport);
it('should work with tools without title', async () => {
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
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');
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

@ -1,54 +0,0 @@
import * as z from 'zod/v3';
import { completable, getCompleter } from '../completable.js';
describe('completable', () => {
it('preserves types and values of underlying schema', () => {
const baseSchema = z.string();
const schema = completable(baseSchema, () => []);
expect(schema.parse('test')).toBe('test');
expect(() => schema.parse(123)).toThrow();
});
it('provides access to completion function', async () => {
const completions = ['foo', 'bar', 'baz'];
const schema = completable(z.string(), () => completions);
const completer = getCompleter(schema);
expect(completer).toBeDefined();
expect(await completer!('')).toEqual(completions);
});
it('allows async completion functions', async () => {
const completions = ['foo', 'bar', 'baz'];
const schema = completable(z.string(), async () => completions);
const completer = getCompleter(schema);
expect(completer).toBeDefined();
expect(await completer!('')).toEqual(completions);
});
it('passes current value to completion function', async () => {
const schema = completable(z.string(), value => [value + '!']);
const completer = getCompleter(schema);
expect(completer).toBeDefined();
expect(await completer!('test')).toEqual(['test!']);
});
it('works with number schemas', async () => {
const schema = completable(z.number(), () => [1, 2, 3]);
expect(schema.parse(1)).toBe(1);
const completer = getCompleter(schema);
expect(completer).toBeDefined();
expect(await completer!(0)).toEqual([1, 2, 3]);
});
it('preserves schema description', () => {
const desc = 'test description';
const schema = completable(z.string().describe(desc), () => []);
expect(schema.description).toBe(desc);
});
});

View file

@ -1,964 +0,0 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import * as z from 'zod/v3';
import { Client } from '../../client/index.js';
import { InMemoryTransport } from '../../inMemory.js';
import type { Transport } from '../../shared/transport.js';
import {
CreateMessageRequestSchema,
ElicitRequestSchema,
ErrorCode,
LATEST_PROTOCOL_VERSION,
ListPromptsRequestSchema,
ListResourcesRequestSchema,
ListToolsRequestSchema,
type LoggingMessageNotification,
NotificationSchema,
RequestSchema,
ResultSchema,
SetLevelRequestSchema,
SUPPORTED_PROTOCOL_VERSIONS
} from '../../types.js';
import { Server } from '../index.js';
import { AnyObjectSchema } from '../zod-compat.js';
test('should accept latest protocol version', async () => {
let sendPromiseResolve: (value: unknown) => void;
const sendPromise = new Promise(resolve => {
sendPromiseResolve = resolve;
});
const serverTransport: Transport = {
start: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
send: vi.fn().mockImplementation(message => {
if (message.id === 1 && message.result) {
expect(message.result).toEqual({
protocolVersion: LATEST_PROTOCOL_VERSION,
capabilities: expect.any(Object),
serverInfo: {
name: 'test server',
version: '1.0'
},
instructions: 'Test instructions'
});
sendPromiseResolve(undefined);
}
return Promise.resolve();
})
};
const server = new Server(
{
name: 'test server',
version: '1.0'
},
{
capabilities: {
prompts: {},
resources: {},
tools: {},
logging: {}
},
instructions: 'Test instructions'
}
);
await server.connect(serverTransport);
// Simulate initialize request with latest version
serverTransport.onmessage?.({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: LATEST_PROTOCOL_VERSION,
capabilities: {},
clientInfo: {
name: 'test client',
version: '1.0'
}
}
});
await expect(sendPromise).resolves.toBeUndefined();
});
test('should accept supported older protocol version', async () => {
const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1];
let sendPromiseResolve: (value: unknown) => void;
const sendPromise = new Promise(resolve => {
sendPromiseResolve = resolve;
});
const serverTransport: Transport = {
start: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
send: vi.fn().mockImplementation(message => {
if (message.id === 1 && message.result) {
expect(message.result).toEqual({
protocolVersion: OLD_VERSION,
capabilities: expect.any(Object),
serverInfo: {
name: 'test server',
version: '1.0'
}
});
sendPromiseResolve(undefined);
}
return Promise.resolve();
})
};
const server = new Server(
{
name: 'test server',
version: '1.0'
},
{
capabilities: {
prompts: {},
resources: {},
tools: {},
logging: {}
}
}
);
await server.connect(serverTransport);
// Simulate initialize request with older version
serverTransport.onmessage?.({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: OLD_VERSION,
capabilities: {},
clientInfo: {
name: 'test client',
version: '1.0'
}
}
});
await expect(sendPromise).resolves.toBeUndefined();
});
test('should handle unsupported protocol version', async () => {
let sendPromiseResolve: (value: unknown) => void;
const sendPromise = new Promise(resolve => {
sendPromiseResolve = resolve;
});
const serverTransport: Transport = {
start: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
send: vi.fn().mockImplementation(message => {
if (message.id === 1 && message.result) {
expect(message.result).toEqual({
protocolVersion: LATEST_PROTOCOL_VERSION,
capabilities: expect.any(Object),
serverInfo: {
name: 'test server',
version: '1.0'
}
});
sendPromiseResolve(undefined);
}
return Promise.resolve();
})
};
const server = new Server(
{
name: 'test server',
version: '1.0'
},
{
capabilities: {
prompts: {},
resources: {},
tools: {},
logging: {}
}
}
);
await server.connect(serverTransport);
// Simulate initialize request with unsupported version
serverTransport.onmessage?.({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: 'invalid-version',
capabilities: {},
clientInfo: {
name: 'test client',
version: '1.0'
}
}
});
await expect(sendPromise).resolves.toBeUndefined();
});
test('should respect client capabilities', async () => {
const server = new Server(
{
name: 'test server',
version: '1.0'
},
{
capabilities: {
prompts: {},
resources: {},
tools: {},
logging: {}
},
enforceStrictCapabilities: true
}
);
const client = new Client(
{
name: 'test client',
version: '1.0'
},
{
capabilities: {
sampling: {}
}
}
);
// Implement request handler for sampling/createMessage
client.setRequestHandler(CreateMessageRequestSchema, async _request => {
// Mock implementation of createMessage
return {
model: 'test-model',
role: 'assistant',
content: {
type: 'text',
text: 'This is a test response'
}
};
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
expect(server.getClientCapabilities()).toEqual({ sampling: {} });
// This should work because sampling is supported by the client
await expect(
server.createMessage({
messages: [],
maxTokens: 10
})
).resolves.not.toThrow();
// This should still throw because roots are not supported by the client
await expect(server.listRoots()).rejects.toThrow(/^Client does not support/);
});
test('should respect client elicitation capabilities', async () => {
const server = new Server(
{
name: 'test server',
version: '1.0'
},
{
capabilities: {
prompts: {},
resources: {},
tools: {},
logging: {}
},
enforceStrictCapabilities: true
}
);
const client = new Client(
{
name: 'test client',
version: '1.0'
},
{
capabilities: {
elicitation: {}
}
}
);
client.setRequestHandler(ElicitRequestSchema, params => ({
action: 'accept',
content: {
username: params.params.message.includes('username') ? 'test-user' : undefined,
confirmed: true
}
}));
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
expect(server.getClientCapabilities()).toEqual({ elicitation: { form: {} } });
// This should work because elicitation is supported by the client
await expect(
server.elicitInput({
message: 'Please provide your username',
requestedSchema: {
type: 'object',
properties: {
username: {
type: 'string',
title: 'Username',
description: 'Your username'
},
confirmed: {
type: 'boolean',
title: 'Confirm',
description: 'Please confirm',
default: false
}
},
required: ['username']
}
})
).resolves.toEqual({
action: 'accept',
content: {
username: 'test-user',
confirmed: true
}
});
// This should still throw because sampling is not supported by the client
await expect(
server.createMessage({
messages: [],
maxTokens: 10
})
).rejects.toThrow(/^Client does not support/);
});
test('should validate elicitation response against requested schema', async () => {
const server = new Server(
{
name: 'test server',
version: '1.0'
},
{
capabilities: {
prompts: {},
resources: {},
tools: {},
logging: {}
},
enforceStrictCapabilities: true
}
);
const client = new Client(
{
name: 'test client',
version: '1.0'
},
{
capabilities: {
elicitation: {}
}
}
);
// Set up client to return valid response
client.setRequestHandler(ElicitRequestSchema, _request => ({
action: 'accept',
content: {
name: 'John Doe',
email: 'john@example.com',
age: 30
}
}));
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
// Test with valid response
await expect(
server.elicitInput({
message: 'Please provide your information',
requestedSchema: {
type: 'object',
properties: {
name: {
type: 'string',
minLength: 1
},
email: {
type: 'string',
minLength: 1
},
age: {
type: 'integer',
minimum: 0,
maximum: 150
}
},
required: ['name', 'email']
}
})
).resolves.toEqual({
action: 'accept',
content: {
name: 'John Doe',
email: 'john@example.com',
age: 30
}
});
});
test('should reject elicitation response with invalid data', async () => {
const server = new Server(
{
name: 'test server',
version: '1.0'
},
{
capabilities: {
prompts: {},
resources: {},
tools: {},
logging: {}
},
enforceStrictCapabilities: true
}
);
const client = new Client(
{
name: 'test client',
version: '1.0'
},
{
capabilities: {
elicitation: {}
}
}
);
// Set up client to return invalid response (missing required field, invalid age)
client.setRequestHandler(ElicitRequestSchema, _request => ({
action: 'accept',
content: {
email: '', // Invalid - too short
age: -5 // Invalid age
}
}));
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
// Test with invalid response
await expect(
server.elicitInput({
message: 'Please provide your information',
requestedSchema: {
type: 'object',
properties: {
name: {
type: 'string',
minLength: 1
},
email: {
type: 'string',
minLength: 1
},
age: {
type: 'integer',
minimum: 0,
maximum: 150
}
},
required: ['name', 'email']
}
})
).rejects.toThrow(/does not match requested schema/);
});
test('should allow elicitation reject and cancel without validation', async () => {
const server = new Server(
{
name: 'test server',
version: '1.0'
},
{
capabilities: {
prompts: {},
resources: {},
tools: {},
logging: {}
},
enforceStrictCapabilities: true
}
);
const client = new Client(
{
name: 'test client',
version: '1.0'
},
{
capabilities: {
elicitation: {}
}
}
);
let requestCount = 0;
client.setRequestHandler(ElicitRequestSchema, _request => {
requestCount++;
if (requestCount === 1) {
return { action: 'decline' };
} else {
return { action: 'cancel' };
}
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
const schema = {
type: 'object' as const,
properties: {
name: { type: 'string' as const }
},
required: ['name']
};
// Test reject - should not validate
await expect(
server.elicitInput({
message: 'Please provide your name',
requestedSchema: schema
})
).resolves.toEqual({
action: 'decline'
});
// Test cancel - should not validate
await expect(
server.elicitInput({
message: 'Please provide your name',
requestedSchema: schema
})
).resolves.toEqual({
action: 'cancel'
});
});
test('should respect server notification capabilities', async () => {
const server = new Server(
{
name: 'test server',
version: '1.0'
},
{
capabilities: {
logging: {}
},
enforceStrictCapabilities: true
}
);
const [_clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
// This should work because logging is supported by the server
await expect(
server.sendLoggingMessage({
level: 'info',
data: 'Test log message'
})
).resolves.not.toThrow();
// This should throw because resource notificaitons are not supported by the server
await expect(server.sendResourceUpdated({ uri: 'test://resource' })).rejects.toThrow(/^Server does not support/);
});
test('should only allow setRequestHandler for declared capabilities', () => {
const server = new Server(
{
name: 'test server',
version: '1.0'
},
{
capabilities: {
prompts: {},
resources: {}
}
}
);
// These should work because the capabilities are declared
expect(() => {
server.setRequestHandler(ListPromptsRequestSchema, () => ({ prompts: [] }));
}).not.toThrow();
expect(() => {
server.setRequestHandler(ListResourcesRequestSchema, () => ({
resources: []
}));
}).not.toThrow();
// These should throw because the capabilities are not declared
expect(() => {
server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: [] }));
}).toThrow(/^Server does not support tools/);
expect(() => {
server.setRequestHandler(SetLevelRequestSchema, () => ({}));
}).toThrow(/^Server does not support logging/);
});
/*
Test that custom request/notification/result schemas can be used with the Server class.
*/
test('should typecheck', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const GetWeatherRequestSchema = (RequestSchema as unknown as z.ZodObject<any>).extend({
method: z.literal('weather/get'),
params: z.object({
city: z.string()
})
}) as AnyObjectSchema;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const GetForecastRequestSchema = (RequestSchema as unknown as z.ZodObject<any>).extend({
method: z.literal('weather/forecast'),
params: z.object({
city: z.string(),
days: z.number()
})
}) as AnyObjectSchema;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const WeatherForecastNotificationSchema = (NotificationSchema as unknown as z.ZodObject<any>).extend({
method: z.literal('weather/alert'),
params: z.object({
severity: z.enum(['warning', 'watch']),
message: z.string()
})
}) as AnyObjectSchema;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const WeatherRequestSchema = (GetWeatherRequestSchema as unknown as z.ZodObject<any>).or(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
GetForecastRequestSchema as unknown as z.ZodObject<any>
) as AnyObjectSchema;
const WeatherNotificationSchema = WeatherForecastNotificationSchema as AnyObjectSchema;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const WeatherResultSchema = (ResultSchema as unknown as z.ZodObject<any>).extend({
temperature: z.number(),
conditions: z.string()
}) as AnyObjectSchema;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type InferSchema<T> = T extends z.ZodType<infer Output, any, any> ? Output : never;
type WeatherRequest = InferSchema<typeof WeatherRequestSchema>;
type WeatherNotification = InferSchema<typeof WeatherNotificationSchema>;
type WeatherResult = InferSchema<typeof WeatherResultSchema>;
// Create a typed Server for weather data
const weatherServer = new Server<WeatherRequest, WeatherNotification, WeatherResult>(
{
name: 'WeatherServer',
version: '1.0.0'
},
{
capabilities: {
prompts: {},
resources: {},
tools: {},
logging: {}
}
}
);
// Typecheck that only valid weather requests/notifications/results are allowed
weatherServer.setRequestHandler(GetWeatherRequestSchema, _request => {
return {
temperature: 72,
conditions: 'sunny'
};
});
weatherServer.setNotificationHandler(WeatherForecastNotificationSchema, notification => {
// Type assertion needed for v3/v4 schema mixing
const params = notification.params as { message: string; severity: 'warning' | 'watch' };
console.log(`Weather alert: ${params.message}`);
});
});
test('should handle server cancelling a request', async () => {
const server = new Server(
{
name: 'test server',
version: '1.0'
},
{
capabilities: {}
}
);
const client = new Client(
{
name: 'test client',
version: '1.0'
},
{
capabilities: {
sampling: {}
}
}
);
// Set up client to delay responding to createMessage
client.setRequestHandler(CreateMessageRequestSchema, async (_request, _extra) => {
await new Promise(resolve => setTimeout(resolve, 1000));
return {
model: 'test',
role: 'assistant',
content: {
type: 'text',
text: 'Test response'
}
};
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
// Set up abort controller
const controller = new AbortController();
// Issue request but cancel it immediately
const createMessagePromise = server.createMessage(
{
messages: [],
maxTokens: 10
},
{
signal: controller.signal
}
);
controller.abort('Cancelled by test');
// Request should be rejected
await expect(createMessagePromise).rejects.toBe('Cancelled by test');
});
test('should handle request timeout', async () => {
const server = new Server(
{
name: 'test server',
version: '1.0'
},
{
capabilities: {}
}
);
// Set up client that delays responses
const client = new Client(
{
name: 'test client',
version: '1.0'
},
{
capabilities: {
sampling: {}
}
}
);
client.setRequestHandler(CreateMessageRequestSchema, async (_request, extra) => {
await new Promise((resolve, reject) => {
const timeout = setTimeout(resolve, 100);
extra.signal.addEventListener('abort', () => {
clearTimeout(timeout);
reject(extra.signal.reason);
});
});
return {
model: 'test',
role: 'assistant',
content: {
type: 'text',
text: 'Test response'
}
};
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
// Request with 0 msec timeout should fail immediately
await expect(
server.createMessage(
{
messages: [],
maxTokens: 10
},
{ timeout: 0 }
)
).rejects.toMatchObject({
code: ErrorCode.RequestTimeout
});
});
/*
Test automatic log level handling for transports with and without sessionId
*/
test('should respect log level for transport without sessionId', async () => {
const server = new Server(
{
name: 'test server',
version: '1.0'
},
{
capabilities: {
prompts: {},
resources: {},
tools: {},
logging: {}
},
enforceStrictCapabilities: true
}
);
const client = new Client({
name: 'test client',
version: '1.0'
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
expect(clientTransport.sessionId).toEqual(undefined);
// Client sets logging level to warning
await client.setLoggingLevel('warning');
// This one will make it through
const warningParams: LoggingMessageNotification['params'] = {
level: 'warning',
logger: 'test server',
data: 'Warning message'
};
// This one will not
const debugParams: LoggingMessageNotification['params'] = {
level: 'debug',
logger: 'test server',
data: 'Debug message'
};
// Test the one that makes it through
clientTransport.onmessage = vi.fn().mockImplementation(message => {
expect(message).toEqual({
jsonrpc: '2.0',
method: 'notifications/message',
params: warningParams
});
});
// This one will not make it through
await server.sendLoggingMessage(debugParams);
expect(clientTransport.onmessage).not.toHaveBeenCalled();
// This one will, triggering the above test in clientTransport.onmessage
await server.sendLoggingMessage(warningParams);
expect(clientTransport.onmessage).toHaveBeenCalled();
});
test('should respect log level for transport with sessionId', async () => {
const server = new Server(
{
name: 'test server',
version: '1.0'
},
{
capabilities: {
prompts: {},
resources: {},
tools: {},
logging: {}
},
enforceStrictCapabilities: true
}
);
const client = new Client({
name: 'test client',
version: '1.0'
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
// Add a session id to the transports
const SESSION_ID = 'test-session-id';
clientTransport.sessionId = SESSION_ID;
serverTransport.sessionId = SESSION_ID;
expect(clientTransport.sessionId).toBeDefined();
expect(serverTransport.sessionId).toBeDefined();
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
// Client sets logging level to warning
await client.setLoggingLevel('warning');
// This one will make it through
const warningParams: LoggingMessageNotification['params'] = {
level: 'warning',
logger: 'test server',
data: 'Warning message'
};
// This one will not
const debugParams: LoggingMessageNotification['params'] = {
level: 'debug',
logger: 'test server',
data: 'Debug message'
};
// Test the one that makes it through
clientTransport.onmessage = vi.fn().mockImplementation(message => {
expect(message).toEqual({
jsonrpc: '2.0',
method: 'notifications/message',
params: warningParams
});
});
// This one will not make it through
await server.sendLoggingMessage(debugParams, SESSION_ID);
expect(clientTransport.onmessage).not.toHaveBeenCalled();
// This one will, triggering the above test in clientTransport.onmessage
await server.sendLoggingMessage(warningParams, SESSION_ID);
expect(clientTransport.onmessage).toHaveBeenCalled();
});

File diff suppressed because it is too large Load diff

View file

@ -1,711 +0,0 @@
import http from 'http';
import { type Mocked } from 'vitest';
import { SSEServerTransport } from '../sse.js';
import { McpServer } from '../mcp.js';
import { createServer, type Server } from 'node:http';
import { AddressInfo } from 'node:net';
import * as z from 'zod/v3';
import { CallToolResult, JSONRPCMessage } from '../../types.js';
const createMockResponse = () => {
const res = {
writeHead: vi.fn<http.ServerResponse['writeHead']>().mockReturnThis(),
write: vi.fn<http.ServerResponse['write']>().mockReturnThis(),
on: vi.fn<http.ServerResponse['on']>().mockReturnThis(),
end: vi.fn<http.ServerResponse['end']>().mockReturnThis()
};
return res as unknown as Mocked<http.ServerResponse>;
};
const createMockRequest = ({ headers = {}, body }: { headers?: Record<string, string>; body?: string } = {}) => {
const mockReq = {
headers,
body: body ? body : undefined,
auth: {
token: 'test-token'
},
on: vi.fn<http.IncomingMessage['on']>().mockImplementation((event, listener) => {
const mockListener = listener as unknown as (...args: unknown[]) => void;
if (event === 'data') {
mockListener(Buffer.from(body || '') as unknown as Error);
}
if (event === 'error') {
mockListener(new Error('test'));
}
if (event === 'end') {
mockListener();
}
if (event === 'close') {
setTimeout(listener, 100);
}
return mockReq;
}),
listeners: vi.fn<http.IncomingMessage['listeners']>(),
removeListener: vi.fn<http.IncomingMessage['removeListener']>()
} as unknown as http.IncomingMessage;
return mockReq;
};
/**
* Helper to create and start test HTTP server with MCP setup
*/
async function createTestServerWithSse(args: { mockRes: http.ServerResponse }): Promise<{
server: Server;
transport: SSEServerTransport;
mcpServer: McpServer;
baseUrl: URL;
sessionId: string;
serverPort: number;
}> {
const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } });
mcpServer.tool(
'greet',
'A simple greeting tool',
{ name: z.string().describe('Name to greet') },
async ({ name }): Promise<CallToolResult> => {
return { content: [{ type: 'text', text: `Hello, ${name}!` }] };
}
);
const endpoint = '/messages';
const transport = new SSEServerTransport(endpoint, args.mockRes);
const sessionId = transport.sessionId;
await mcpServer.connect(transport);
const server = createServer(async (req, res) => {
try {
await transport.handlePostMessage(req, res);
} catch (error) {
console.error('Error handling request:', error);
if (!res.headersSent) res.writeHead(500).end();
}
});
const baseUrl = await new Promise<URL>(resolve => {
server.listen(0, '127.0.0.1', () => {
const addr = server.address() as AddressInfo;
resolve(new URL(`http://127.0.0.1:${addr.port}`));
});
});
const port = (server.address() as AddressInfo).port;
return { server, transport, mcpServer, baseUrl, sessionId, serverPort: port };
}
async function readAllSSEEvents(response: Response): Promise<string[]> {
const reader = response.body?.getReader();
if (!reader) throw new Error('No readable stream');
const events: string[] = [];
const decoder = new TextDecoder();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
events.push(decoder.decode(value));
}
}
} finally {
reader.releaseLock();
}
return events;
}
/**
* Helper to send JSON-RPC request
*/
async function sendSsePostRequest(
baseUrl: URL,
message: JSONRPCMessage | JSONRPCMessage[],
sessionId?: string,
extraHeaders?: Record<string, string>
): Promise<Response> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
...extraHeaders
};
if (sessionId) {
baseUrl.searchParams.set('sessionId', sessionId);
}
return fetch(baseUrl, {
method: 'POST',
headers,
body: JSON.stringify(message)
});
}
describe('SSEServerTransport', () => {
async function initializeServer(baseUrl: URL): Promise<void> {
const response = await sendSsePostRequest(baseUrl, {
jsonrpc: '2.0',
method: 'initialize',
params: {
clientInfo: { name: 'test-client', version: '1.0' },
protocolVersion: '2025-03-26',
capabilities: {}
},
id: 'init-1'
} as JSONRPCMessage);
expect(response.status).toBe(202);
const text = await readAllSSEEvents(response);
expect(text).toHaveLength(1);
expect(text[0]).toBe('Accepted');
}
describe('start method', () => {
it('should correctly append sessionId to a simple relative endpoint', async () => {
const mockRes = createMockResponse();
const endpoint = '/messages';
const transport = new SSEServerTransport(endpoint, mockRes);
const expectedSessionId = transport.sessionId;
await transport.start();
expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
expect(mockRes.write).toHaveBeenCalledTimes(1);
expect(mockRes.write).toHaveBeenCalledWith(`event: endpoint\ndata: /messages?sessionId=${expectedSessionId}\n\n`);
});
it('should correctly append sessionId to an endpoint with existing query parameters', async () => {
const mockRes = createMockResponse();
const endpoint = '/messages?foo=bar&baz=qux';
const transport = new SSEServerTransport(endpoint, mockRes);
const expectedSessionId = transport.sessionId;
await transport.start();
expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
expect(mockRes.write).toHaveBeenCalledTimes(1);
expect(mockRes.write).toHaveBeenCalledWith(
`event: endpoint\ndata: /messages?foo=bar&baz=qux&sessionId=${expectedSessionId}\n\n`
);
});
it('should correctly append sessionId to an endpoint with a hash fragment', async () => {
const mockRes = createMockResponse();
const endpoint = '/messages#section1';
const transport = new SSEServerTransport(endpoint, mockRes);
const expectedSessionId = transport.sessionId;
await transport.start();
expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
expect(mockRes.write).toHaveBeenCalledTimes(1);
expect(mockRes.write).toHaveBeenCalledWith(`event: endpoint\ndata: /messages?sessionId=${expectedSessionId}#section1\n\n`);
});
it('should correctly append sessionId to an endpoint with query parameters and a hash fragment', async () => {
const mockRes = createMockResponse();
const endpoint = '/messages?key=value#section2';
const transport = new SSEServerTransport(endpoint, mockRes);
const expectedSessionId = transport.sessionId;
await transport.start();
expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
expect(mockRes.write).toHaveBeenCalledTimes(1);
expect(mockRes.write).toHaveBeenCalledWith(
`event: endpoint\ndata: /messages?key=value&sessionId=${expectedSessionId}#section2\n\n`
);
});
it('should correctly handle the root path endpoint "/"', async () => {
const mockRes = createMockResponse();
const endpoint = '/';
const transport = new SSEServerTransport(endpoint, mockRes);
const expectedSessionId = transport.sessionId;
await transport.start();
expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
expect(mockRes.write).toHaveBeenCalledTimes(1);
expect(mockRes.write).toHaveBeenCalledWith(`event: endpoint\ndata: /?sessionId=${expectedSessionId}\n\n`);
});
it('should correctly handle an empty string endpoint ""', async () => {
const mockRes = createMockResponse();
const endpoint = '';
const transport = new SSEServerTransport(endpoint, mockRes);
const expectedSessionId = transport.sessionId;
await transport.start();
expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object));
expect(mockRes.write).toHaveBeenCalledTimes(1);
expect(mockRes.write).toHaveBeenCalledWith(`event: endpoint\ndata: /?sessionId=${expectedSessionId}\n\n`);
});
/**
* Test: Tool With Request Info
*/
it('should pass request info to tool callback', async () => {
const mockRes = createMockResponse();
const { mcpServer, baseUrl, sessionId, serverPort } = await createTestServerWithSse({ mockRes });
await initializeServer(baseUrl);
mcpServer.tool(
'test-request-info',
'A simple test tool with request info',
{ name: z.string().describe('Name to greet') },
async ({ name }, { requestInfo }): Promise<CallToolResult> => {
return {
content: [
{ type: 'text', text: `Hello, ${name}!` },
{ type: 'text', text: `${JSON.stringify(requestInfo)}` }
]
};
}
);
const toolCallMessage: JSONRPCMessage = {
jsonrpc: '2.0',
method: 'tools/call',
params: {
name: 'test-request-info',
arguments: {
name: 'Test User'
}
},
id: 'call-1'
};
const response = await sendSsePostRequest(baseUrl, toolCallMessage, sessionId);
expect(response.status).toBe(202);
expect(mockRes.write).toHaveBeenCalledWith(`event: endpoint\ndata: /messages?sessionId=${sessionId}\n\n`);
const expectedMessage = {
result: {
content: [
{
type: 'text',
text: 'Hello, Test User!'
},
{
type: 'text',
text: JSON.stringify({
headers: {
host: `127.0.0.1:${serverPort}`,
connection: 'keep-alive',
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
'accept-language': '*',
'sec-fetch-mode': 'cors',
'user-agent': 'node',
'accept-encoding': 'gzip, deflate',
'content-length': '124'
}
})
}
]
},
jsonrpc: '2.0',
id: 'call-1'
};
expect(mockRes.write).toHaveBeenCalledWith(`event: message\ndata: ${JSON.stringify(expectedMessage)}\n\n`);
});
});
describe('handlePostMessage method', () => {
it('should return 500 if server has not started', async () => {
const mockReq = createMockRequest();
const mockRes = createMockResponse();
const endpoint = '/messages';
const transport = new SSEServerTransport(endpoint, mockRes);
const error = 'SSE connection not established';
await expect(transport.handlePostMessage(mockReq, mockRes)).rejects.toThrow(error);
expect(mockRes.writeHead).toHaveBeenCalledWith(500);
expect(mockRes.end).toHaveBeenCalledWith(error);
});
it('should return 400 if content-type is not application/json', async () => {
const mockReq = createMockRequest({ headers: { 'content-type': 'text/plain' } });
const mockRes = createMockResponse();
const endpoint = '/messages';
const transport = new SSEServerTransport(endpoint, mockRes);
await transport.start();
transport.onerror = vi.fn();
const error = 'Unsupported content-type: text/plain';
await expect(transport.handlePostMessage(mockReq, mockRes)).resolves.toBe(undefined);
expect(mockRes.writeHead).toHaveBeenCalledWith(400);
expect(mockRes.end).toHaveBeenCalledWith(expect.stringContaining(error));
expect(transport.onerror).toHaveBeenCalledWith(new Error(error));
});
it('should return 400 if message has not a valid schema', async () => {
const invalidMessage = JSON.stringify({
// missing jsonrpc field
method: 'call',
params: [1, 2, 3],
id: 1
});
const mockReq = createMockRequest({
headers: { 'content-type': 'application/json' },
body: invalidMessage
});
const mockRes = createMockResponse();
const endpoint = '/messages';
const transport = new SSEServerTransport(endpoint, mockRes);
await transport.start();
transport.onmessage = vi.fn();
await transport.handlePostMessage(mockReq, mockRes);
expect(mockRes.writeHead).toHaveBeenCalledWith(400);
expect(transport.onmessage).not.toHaveBeenCalled();
expect(mockRes.end).toHaveBeenCalledWith(`Invalid message: ${invalidMessage}`);
});
it('should return 202 if message has a valid schema', async () => {
const validMessage = JSON.stringify({
jsonrpc: '2.0',
method: 'call',
params: {
a: 1,
b: 2,
c: 3
},
id: 1
});
const mockReq = createMockRequest({
headers: { 'content-type': 'application/json' },
body: validMessage
});
const mockRes = createMockResponse();
const endpoint = '/messages';
const transport = new SSEServerTransport(endpoint, mockRes);
await transport.start();
transport.onmessage = vi.fn();
await transport.handlePostMessage(mockReq, mockRes);
expect(mockRes.writeHead).toHaveBeenCalledWith(202);
expect(mockRes.end).toHaveBeenCalledWith('Accepted');
expect(transport.onmessage).toHaveBeenCalledWith(
{
jsonrpc: '2.0',
method: 'call',
params: {
a: 1,
b: 2,
c: 3
},
id: 1
},
{
authInfo: {
token: 'test-token'
},
requestInfo: {
headers: {
'content-type': 'application/json'
}
}
}
);
});
});
describe('close method', () => {
it('should call onclose', async () => {
const mockRes = createMockResponse();
const endpoint = '/messages';
const transport = new SSEServerTransport(endpoint, mockRes);
await transport.start();
transport.onclose = vi.fn();
await transport.close();
expect(transport.onclose).toHaveBeenCalled();
});
});
describe('send method', () => {
it('should call onsend', async () => {
const mockRes = createMockResponse();
const endpoint = '/messages';
const transport = new SSEServerTransport(endpoint, mockRes);
await transport.start();
expect(mockRes.write).toHaveBeenCalledTimes(1);
expect(mockRes.write).toHaveBeenCalledWith(expect.stringContaining('event: endpoint'));
expect(mockRes.write).toHaveBeenCalledWith(expect.stringContaining(`data: /messages?sessionId=${transport.sessionId}`));
});
});
describe('DNS rebinding protection', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('Host header validation', () => {
it('should accept requests with allowed host headers', async () => {
const mockRes = createMockResponse();
const transport = new SSEServerTransport('/messages', mockRes, {
allowedHosts: ['localhost:3000', 'example.com'],
enableDnsRebindingProtection: true
});
await transport.start();
const mockReq = createMockRequest({
headers: {
host: 'localhost:3000',
'content-type': 'application/json'
}
});
const mockHandleRes = createMockResponse();
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202);
expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted');
});
it('should reject requests with disallowed host headers', async () => {
const mockRes = createMockResponse();
const transport = new SSEServerTransport('/messages', mockRes, {
allowedHosts: ['localhost:3000'],
enableDnsRebindingProtection: true
});
await transport.start();
const mockReq = createMockRequest({
headers: {
host: 'evil.com',
'content-type': 'application/json'
}
});
const mockHandleRes = createMockResponse();
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403);
expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Host header: evil.com');
});
it('should reject requests without host header when allowedHosts is configured', async () => {
const mockRes = createMockResponse();
const transport = new SSEServerTransport('/messages', mockRes, {
allowedHosts: ['localhost:3000'],
enableDnsRebindingProtection: true
});
await transport.start();
const mockReq = createMockRequest({
headers: {
'content-type': 'application/json'
}
});
const mockHandleRes = createMockResponse();
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403);
expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Host header: undefined');
});
});
describe('Origin header validation', () => {
it('should accept requests with allowed origin headers', async () => {
const mockRes = createMockResponse();
const transport = new SSEServerTransport('/messages', mockRes, {
allowedOrigins: ['http://localhost:3000', 'https://example.com'],
enableDnsRebindingProtection: true
});
await transport.start();
const mockReq = createMockRequest({
headers: {
origin: 'http://localhost:3000',
'content-type': 'application/json'
}
});
const mockHandleRes = createMockResponse();
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202);
expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted');
});
it('should reject requests with disallowed origin headers', async () => {
const mockRes = createMockResponse();
const transport = new SSEServerTransport('/messages', mockRes, {
allowedOrigins: ['http://localhost:3000'],
enableDnsRebindingProtection: true
});
await transport.start();
const mockReq = createMockRequest({
headers: {
origin: 'http://evil.com',
'content-type': 'application/json'
}
});
const mockHandleRes = createMockResponse();
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403);
expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Origin header: http://evil.com');
});
});
describe('Content-Type validation', () => {
it('should accept requests with application/json content-type', async () => {
const mockRes = createMockResponse();
const transport = new SSEServerTransport('/messages', mockRes);
await transport.start();
const mockReq = createMockRequest({
headers: {
'content-type': 'application/json'
}
});
const mockHandleRes = createMockResponse();
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202);
expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted');
});
it('should accept requests with application/json with charset', async () => {
const mockRes = createMockResponse();
const transport = new SSEServerTransport('/messages', mockRes);
await transport.start();
const mockReq = createMockRequest({
headers: {
'content-type': 'application/json; charset=utf-8'
}
});
const mockHandleRes = createMockResponse();
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202);
expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted');
});
it('should reject requests with non-application/json content-type when protection is enabled', async () => {
const mockRes = createMockResponse();
const transport = new SSEServerTransport('/messages', mockRes);
await transport.start();
const mockReq = createMockRequest({
headers: {
'content-type': 'text/plain'
}
});
const mockHandleRes = createMockResponse();
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(400);
expect(mockHandleRes.end).toHaveBeenCalledWith('Error: Unsupported content-type: text/plain');
});
});
describe('enableDnsRebindingProtection option', () => {
it('should skip all validations when enableDnsRebindingProtection is false', async () => {
const mockRes = createMockResponse();
const transport = new SSEServerTransport('/messages', mockRes, {
allowedHosts: ['localhost:3000'],
allowedOrigins: ['http://localhost:3000'],
enableDnsRebindingProtection: false
});
await transport.start();
const mockReq = createMockRequest({
headers: {
host: 'evil.com',
origin: 'http://evil.com',
'content-type': 'text/plain'
}
});
const mockHandleRes = createMockResponse();
await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' });
// Should pass even with invalid headers because protection is disabled
expect(mockHandleRes.writeHead).toHaveBeenCalledWith(400);
// The error should be from content-type parsing, not DNS rebinding protection
expect(mockHandleRes.end).toHaveBeenCalledWith('Error: Unsupported content-type: text/plain');
});
});
describe('Combined validations', () => {
it('should validate both host and origin when both are configured', async () => {
const mockRes = createMockResponse();
const transport = new SSEServerTransport('/messages', mockRes, {
allowedHosts: ['localhost:3000'],
allowedOrigins: ['http://localhost:3000'],
enableDnsRebindingProtection: true
});
await transport.start();
// Valid host, invalid origin
const mockReq1 = createMockRequest({
headers: {
host: 'localhost:3000',
origin: 'http://evil.com',
'content-type': 'application/json'
}
});
const mockHandleRes1 = createMockResponse();
await transport.handlePostMessage(mockReq1, mockHandleRes1, { jsonrpc: '2.0', method: 'test' });
expect(mockHandleRes1.writeHead).toHaveBeenCalledWith(403);
expect(mockHandleRes1.end).toHaveBeenCalledWith('Invalid Origin header: http://evil.com');
// Invalid host, valid origin
const mockReq2 = createMockRequest({
headers: {
host: 'evil.com',
origin: 'http://localhost:3000',
'content-type': 'application/json'
}
});
const mockHandleRes2 = createMockResponse();
await transport.handlePostMessage(mockReq2, mockHandleRes2, { jsonrpc: '2.0', method: 'test' });
expect(mockHandleRes2.writeHead).toHaveBeenCalledWith(403);
expect(mockHandleRes2.end).toHaveBeenCalledWith('Invalid Host header: evil.com');
// Both valid
const mockReq3 = createMockRequest({
headers: {
host: 'localhost:3000',
origin: 'http://localhost:3000',
'content-type': 'application/json'
}
});
const mockHandleRes3 = createMockResponse();
await transport.handlePostMessage(mockReq3, mockHandleRes3, { jsonrpc: '2.0', method: 'test' });
expect(mockHandleRes3.writeHead).toHaveBeenCalledWith(202);
expect(mockHandleRes3.end).toHaveBeenCalledWith('Accepted');
});
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -1,224 +0,0 @@
import { Server } from '../index.js';
import { Client } from '../../client/index.js';
import { InMemoryTransport } from '../../inMemory.js';
import * as z from 'zod/v3';
import { McpServer, ResourceTemplate } from '../mcp.js';
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,22 @@
import * as z3 from 'zod/v3';
import * as z4 from 'zod/v4';
// Shared Zod namespace type that exposes the common surface area used in tests.
export type ZNamespace = typeof z3 & typeof z4;
export const zodTestMatrix = [
{
zodVersionLabel: 'Zod v3',
z: z3 as ZNamespace,
isV3: true as const,
isV4: false as const
},
{
zodVersionLabel: 'Zod v4',
z: z4 as ZNamespace,
isV3: false as const,
isV4: true as const
}
] as const;
export type ZodMatrixEntry = (typeof zodTestMatrix)[number];

View file

@ -3,5 +3,5 @@
"compilerOptions": {
"outDir": "./dist/esm"
},
"exclude": ["**/*.test.ts", "src/__mocks__/**/*"]
"exclude": ["**/*.test.ts", "src/__mocks__/**/*", "src/server/zodTestMatrix.ts"]
}