feat: zod v4 with backwards compatibility for v3.25+ (#1040)
This commit is contained in:
parent
7fca1f2b15
commit
418b7f9b85
39 changed files with 14723 additions and 1002 deletions
|
|
@ -2,7 +2,7 @@
|
|||
/* eslint-disable no-constant-binary-expression */
|
||||
/* eslint-disable @typescript-eslint/no-unused-expressions */
|
||||
import { Client, getSupportedElicitationModes } from './index.js';
|
||||
import { z } from 'zod';
|
||||
import * as z from 'zod/v4';
|
||||
import {
|
||||
RequestSchema,
|
||||
NotificationSchema,
|
||||
|
|
|
|||
|
|
@ -42,7 +42,15 @@ import {
|
|||
} from '../types.js';
|
||||
import { AjvJsonSchemaValidator } from '../validation/ajv-provider.js';
|
||||
import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from '../validation/types.js';
|
||||
import { ZodLiteral, ZodObject, z } from 'zod';
|
||||
import {
|
||||
AnyObjectSchema,
|
||||
SchemaOutput,
|
||||
getObjectShape,
|
||||
isZ4Schema,
|
||||
safeParse,
|
||||
type ZodV3Internal,
|
||||
type ZodV4Internal
|
||||
} from '../server/zod-compat.js';
|
||||
import type { RequestHandlerExtra } from '../shared/protocol.js';
|
||||
|
||||
/**
|
||||
|
|
@ -216,26 +224,46 @@ export class Client<
|
|||
/**
|
||||
* Override request handler registration to enforce client-side validation for elicitation.
|
||||
*/
|
||||
public override setRequestHandler<
|
||||
T extends ZodObject<{
|
||||
method: ZodLiteral<string>;
|
||||
}>
|
||||
>(
|
||||
public override setRequestHandler<T extends AnyObjectSchema>(
|
||||
requestSchema: T,
|
||||
handler: (
|
||||
request: z.infer<T>,
|
||||
request: SchemaOutput<T>,
|
||||
extra: RequestHandlerExtra<ClientRequest | RequestT, ClientNotification | NotificationT>
|
||||
) => ClientResult | ResultT | Promise<ClientResult | ResultT>
|
||||
): void {
|
||||
const method = requestSchema.shape.method.value;
|
||||
const shape = getObjectShape(requestSchema);
|
||||
const methodSchema = shape?.method;
|
||||
if (!methodSchema) {
|
||||
throw new Error('Schema is missing a method literal');
|
||||
}
|
||||
|
||||
// Extract literal value using type-safe property access
|
||||
let methodValue: unknown;
|
||||
if (isZ4Schema(methodSchema)) {
|
||||
const v4Schema = methodSchema as unknown as ZodV4Internal;
|
||||
const v4Def = v4Schema._zod?.def;
|
||||
methodValue = v4Def?.value ?? v4Schema.value;
|
||||
} else {
|
||||
const v3Schema = methodSchema as unknown as ZodV3Internal;
|
||||
const legacyDef = v3Schema._def;
|
||||
methodValue = legacyDef?.value ?? v3Schema.value;
|
||||
}
|
||||
|
||||
if (typeof methodValue !== 'string') {
|
||||
throw new Error('Schema method literal must be a string');
|
||||
}
|
||||
const method = methodValue;
|
||||
if (method === 'elicitation/create') {
|
||||
const wrappedHandler = async (
|
||||
request: z.infer<T>,
|
||||
request: SchemaOutput<T>,
|
||||
extra: RequestHandlerExtra<ClientRequest | RequestT, ClientNotification | NotificationT>
|
||||
): Promise<ClientResult | ResultT> => {
|
||||
const validatedRequest = ElicitRequestSchema.safeParse(request);
|
||||
const validatedRequest = safeParse(ElicitRequestSchema, request);
|
||||
if (!validatedRequest.success) {
|
||||
throw new McpError(ErrorCode.InvalidParams, `Invalid elicitation request: ${validatedRequest.error.message}`);
|
||||
// Type guard: if success is false, error is guaranteed to exist
|
||||
const errorMessage =
|
||||
validatedRequest.error instanceof Error ? validatedRequest.error.message : String(validatedRequest.error);
|
||||
throw new McpError(ErrorCode.InvalidParams, `Invalid elicitation request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
const { params } = validatedRequest.data;
|
||||
|
|
@ -251,9 +279,12 @@ export class Client<
|
|||
|
||||
const result = await Promise.resolve(handler(request, extra));
|
||||
|
||||
const validationResult = ElicitResultSchema.safeParse(result);
|
||||
const validationResult = safeParse(ElicitResultSchema, result);
|
||||
if (!validationResult.success) {
|
||||
throw new McpError(ErrorCode.InvalidParams, `Invalid elicitation result: ${validationResult.error.message}`);
|
||||
// Type guard: if success is false, error is guaranteed to exist
|
||||
const errorMessage =
|
||||
validationResult.error instanceof Error ? validationResult.error.message : String(validationResult.error);
|
||||
throw new McpError(ErrorCode.InvalidParams, `Invalid elicitation result: ${errorMessage}`);
|
||||
}
|
||||
|
||||
const validatedResult = validationResult.data;
|
||||
|
|
|
|||
1591
src/client/v3/index.v3.test.ts
Normal file
1591
src/client/v3/index.v3.test.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -2,7 +2,7 @@ import express, { Request, Response } from 'express';
|
|||
import { randomUUID } from 'node:crypto';
|
||||
import { McpServer } from '../../server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
|
||||
import { z } from 'zod';
|
||||
import * as z from 'zod/v4';
|
||||
import { CallToolResult, isInitializeRequest } from '../../types.js';
|
||||
import cors from 'cors';
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import { McpServer } from '../../server/mcp.js';
|
||||
import { StdioServerTransport } from '../../server/stdio.js';
|
||||
import { z } from 'zod';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
const server = new McpServer({
|
||||
name: 'mcp-output-schema-high-level-example',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import express, { Request, Response } from 'express';
|
||||
import { McpServer } from '../../server/mcp.js';
|
||||
import { SSEServerTransport } from '../../server/sse.js';
|
||||
import { z } from 'zod';
|
||||
import * as z from 'zod/v4';
|
||||
import { CallToolResult } from '../../types.js';
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import express, { Request, Response } from 'express';
|
||||
import { McpServer } from '../../server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
|
||||
import { z } from 'zod';
|
||||
import * as z from 'zod/v4';
|
||||
import { CallToolResult, GetPromptResult, ReadResourceResult } from '../../types.js';
|
||||
import cors from 'cors';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import express, { Request, Response } from 'express';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
import * as z from 'zod/v4';
|
||||
import { McpServer } from '../../server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
|
||||
import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../../server/auth/router.js';
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto';
|
|||
import { McpServer } from '../../server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js';
|
||||
import { SSEServerTransport } from '../../server/sse.js';
|
||||
import { z } from 'zod';
|
||||
import * as z from 'zod/v4';
|
||||
import { CallToolResult, isInitializeRequest } from '../../types.js';
|
||||
import { InMemoryEventStore } from '../shared/inMemoryEventStore.js';
|
||||
import cors from 'cors';
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { McpServer } from '../../server/mcp.js';
|
||||
import { StdioServerTransport } from '../../server/stdio.js';
|
||||
import { z } from 'zod';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
const mcpServer = new McpServer({
|
||||
name: 'tools-with-sample-server',
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
ListPromptsResultSchema,
|
||||
LATEST_PROTOCOL_VERSION
|
||||
} from '../types.js';
|
||||
import { z } from 'zod';
|
||||
import * as z from 'zod/v4';
|
||||
|
||||
describe('Streamable HTTP Transport Session Management', () => {
|
||||
// Function to set up the server with optional session management
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ 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 { z } from 'zod';
|
||||
import * as z from 'zod/v4';
|
||||
import { InMemoryEventStore } from '../examples/shared/inMemoryEventStore.js';
|
||||
|
||||
describe('Transport resumability', () => {
|
||||
|
|
@ -193,8 +193,14 @@ describe('Transport resumability', () => {
|
|||
}
|
||||
);
|
||||
|
||||
// Wait for some notifications to arrive (not all) - shorter wait time
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
// Fix for node 18 test failures, allow some time for notifications to arrive
|
||||
const maxWaitTime = 2000; // 2 seconds max wait
|
||||
const pollInterval = 10; // Check every 10ms
|
||||
const startTime = Date.now();
|
||||
while (notifications.length === 0 && Date.now() - startTime < maxWaitTime) {
|
||||
// Wait for some notifications to arrive (not all) - shorter wait time
|
||||
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
||||
}
|
||||
|
||||
// Verify we received some notifications and lastEventId was updated
|
||||
expect(notifications.length).toBeGreaterThan(0);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,357 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
270
src/integration-tests/v3/taskResumability.v3.test.ts
Normal file
270
src/integration-tests/v3/taskResumability.v3.test.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { RequestHandler } from 'express';
|
||||
import { z } from 'zod';
|
||||
import * as z from 'zod/v4';
|
||||
import express from 'express';
|
||||
import { OAuthServerProvider } from '../provider.js';
|
||||
import { rateLimit, Options as RateLimitOptions } from 'express-rate-limit';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { z } from 'zod';
|
||||
import * as z from 'zod/v4';
|
||||
import express, { RequestHandler } from 'express';
|
||||
import { OAuthServerProvider } from '../provider.js';
|
||||
import cors from 'cors';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { z } from 'zod';
|
||||
import * as z from 'zod/v4';
|
||||
import { RequestHandler } from 'express';
|
||||
import { OAuthRegisteredClientsStore } from '../clients.js';
|
||||
import { OAuthClientInformationFull } from '../../../shared/auth.js';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { z } from 'zod';
|
||||
import { completable } from './completable.js';
|
||||
import * as z from 'zod/v4';
|
||||
import { completable, getCompleter } from './completable.js';
|
||||
|
||||
describe('completable', () => {
|
||||
it('preserves types and values of underlying schema', () => {
|
||||
|
|
@ -14,27 +14,35 @@ describe('completable', () => {
|
|||
const completions = ['foo', 'bar', 'baz'];
|
||||
const schema = completable(z.string(), () => completions);
|
||||
|
||||
expect(await schema._def.complete('')).toEqual(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);
|
||||
|
||||
expect(await schema._def.complete('')).toEqual(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 + '!']);
|
||||
|
||||
expect(await schema._def.complete('test')).toEqual(['test!']);
|
||||
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);
|
||||
expect(await schema._def.complete(0)).toEqual([1, 2, 3]);
|
||||
const completer = getCompleter(schema);
|
||||
expect(completer).toBeDefined();
|
||||
expect(await completer!(0)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('preserves schema description', () => {
|
||||
|
|
|
|||
|
|
@ -1,79 +1,67 @@
|
|||
import { ZodTypeAny, ZodTypeDef, ZodType, ParseInput, ParseReturnType, RawCreateParams, ZodErrorMap, ProcessedCreateParams } from 'zod';
|
||||
import { AnySchema, SchemaInput } from './zod-compat.js';
|
||||
|
||||
export const COMPLETABLE_SYMBOL: unique symbol = Symbol.for('mcp.completable');
|
||||
|
||||
export type CompleteCallback<T extends AnySchema = AnySchema> = (
|
||||
value: SchemaInput<T>,
|
||||
context?: {
|
||||
arguments?: Record<string, string>;
|
||||
}
|
||||
) => SchemaInput<T>[] | Promise<SchemaInput<T>[]>;
|
||||
|
||||
export type CompletableMeta<T extends AnySchema = AnySchema> = {
|
||||
complete: CompleteCallback<T>;
|
||||
};
|
||||
|
||||
export type CompletableSchema<T extends AnySchema> = T & {
|
||||
[COMPLETABLE_SYMBOL]: CompletableMeta<T>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps a Zod type to provide autocompletion capabilities. Useful for, e.g., prompt arguments in MCP.
|
||||
* Works with both Zod v3 and v4 schemas.
|
||||
*/
|
||||
export function completable<T extends AnySchema>(schema: T, complete: CompleteCallback<T>): CompletableSchema<T> {
|
||||
Object.defineProperty(schema as object, COMPLETABLE_SYMBOL, {
|
||||
value: { complete } as CompletableMeta<T>,
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
configurable: false
|
||||
});
|
||||
return schema as CompletableSchema<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a schema is completable (has completion metadata).
|
||||
*/
|
||||
export function isCompletable(schema: unknown): schema is CompletableSchema<AnySchema> {
|
||||
return !!schema && typeof schema === 'object' && COMPLETABLE_SYMBOL in (schema as object);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the completer callback from a completable schema, if it exists.
|
||||
*/
|
||||
export function getCompleter<T extends AnySchema>(schema: T): CompleteCallback<T> | undefined {
|
||||
const meta = (schema as unknown as { [COMPLETABLE_SYMBOL]?: CompletableMeta<T> })[COMPLETABLE_SYMBOL];
|
||||
return meta?.complete as CompleteCallback<T> | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwraps a completable schema to get the underlying schema.
|
||||
* For backward compatibility with code that called `.unwrap()`.
|
||||
*/
|
||||
export function unwrapCompletable<T extends AnySchema>(schema: CompletableSchema<T>): T {
|
||||
return schema;
|
||||
}
|
||||
|
||||
// Legacy exports for backward compatibility
|
||||
// These types are deprecated but kept for existing code
|
||||
export enum McpZodTypeKind {
|
||||
Completable = 'McpCompletable'
|
||||
}
|
||||
|
||||
export type CompleteCallback<T extends ZodTypeAny = ZodTypeAny> = (
|
||||
value: T['_input'],
|
||||
context?: {
|
||||
arguments?: Record<string, string>;
|
||||
}
|
||||
) => T['_input'][] | Promise<T['_input'][]>;
|
||||
|
||||
export interface CompletableDef<T extends ZodTypeAny = ZodTypeAny> extends ZodTypeDef {
|
||||
export interface CompletableDef<T extends AnySchema = AnySchema> {
|
||||
type: T;
|
||||
complete: CompleteCallback<T>;
|
||||
typeName: McpZodTypeKind.Completable;
|
||||
}
|
||||
|
||||
export class Completable<T extends ZodTypeAny> extends ZodType<T['_output'], CompletableDef<T>, T['_input']> {
|
||||
_parse(input: ParseInput): ParseReturnType<this['_output']> {
|
||||
const { ctx } = this._processInputParams(input);
|
||||
const data = ctx.data;
|
||||
return this._def.type._parse({
|
||||
data,
|
||||
path: ctx.path,
|
||||
parent: ctx
|
||||
});
|
||||
}
|
||||
|
||||
unwrap() {
|
||||
return this._def.type;
|
||||
}
|
||||
|
||||
static create = <T extends ZodTypeAny>(
|
||||
type: T,
|
||||
params: RawCreateParams & {
|
||||
complete: CompleteCallback<T>;
|
||||
}
|
||||
): Completable<T> => {
|
||||
return new Completable({
|
||||
type,
|
||||
typeName: McpZodTypeKind.Completable,
|
||||
complete: params.complete,
|
||||
...processCreateParams(params)
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a Zod type to provide autocompletion capabilities. Useful for, e.g., prompt arguments in MCP.
|
||||
*/
|
||||
export function completable<T extends ZodTypeAny>(schema: T, complete: CompleteCallback<T>): Completable<T> {
|
||||
return Completable.create(schema, { ...schema._def, complete });
|
||||
}
|
||||
|
||||
// Not sure why this isn't exported from Zod:
|
||||
// https://github.com/colinhacks/zod/blob/f7ad26147ba291cb3fb257545972a8e00e767470/src/types.ts#L130
|
||||
function processCreateParams(params: RawCreateParams): ProcessedCreateParams {
|
||||
if (!params) return {};
|
||||
const { errorMap, invalid_type_error, required_error, description } = params;
|
||||
if (errorMap && (invalid_type_error || required_error)) {
|
||||
throw new Error(`Can't use "invalid_type_error" or "required_error" in conjunction with custom error map.`);
|
||||
}
|
||||
if (errorMap) return { errorMap: errorMap, description };
|
||||
const customMap: ZodErrorMap = (iss, ctx) => {
|
||||
const { message } = params;
|
||||
|
||||
if (iss.code === 'invalid_enum_value') {
|
||||
return { message: message ?? ctx.defaultError };
|
||||
}
|
||||
if (typeof ctx.data === 'undefined') {
|
||||
return { message: message ?? required_error ?? ctx.defaultError };
|
||||
}
|
||||
if (iss.code !== 'invalid_type') return { message: ctx.defaultError };
|
||||
return { message: message ?? invalid_type_error ?? ctx.defaultError };
|
||||
};
|
||||
return { errorMap: customMap, description };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { z } from 'zod';
|
||||
import * as z from 'zod/v4';
|
||||
import { Client } from '../client/index.js';
|
||||
import { InMemoryTransport } from '../inMemory.js';
|
||||
import type { Transport } from '../shared/transport.js';
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue