From 6b40eba4d2eb25d5d3c2dcad889665cdd7df91c3 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Wed, 19 Nov 2025 23:29:53 +0200 Subject: [PATCH] unify v3 and v4 zod types via describe matrix and a test helper --- src/client/index.test.ts | 234 +- src/client/v3/index.v3.test.ts | 1591 ---- .../stateManagementStreamableHttp.test.ts | 613 +- .../taskResumability.test.ts | 449 +- .../stateManagementStreamableHttp.v3.test.ts | 357 - .../v3/taskResumability.v3.test.ts | 270 - src/server/completable.test.ts | 5 +- src/server/index.test.ts | 218 +- src/server/mcp.test.ts | 7639 +++++++++-------- src/server/sse.test.ts | 1002 +-- src/server/streamableHttp.test.ts | 3200 +++---- src/server/title.test.ts | 388 +- src/server/v3/completable.v3.test.ts | 54 - src/server/v3/index.v3.test.ts | 964 --- src/server/v3/mcp.v3.test.ts | 4519 ---------- src/server/v3/sse.v3.test.ts | 711 -- src/server/v3/streamableHttp.v3.test.ts | 2151 ----- src/server/v3/title.v3.test.ts | 224 - src/shared/zodTestMatrix.ts | 22 + tsconfig.prod.json | 2 +- 20 files changed, 6993 insertions(+), 17620 deletions(-) delete mode 100644 src/client/v3/index.v3.test.ts delete mode 100644 src/integration-tests/v3/stateManagementStreamableHttp.v3.test.ts delete mode 100644 src/integration-tests/v3/taskResumability.v3.test.ts delete mode 100644 src/server/v3/completable.v3.test.ts delete mode 100644 src/server/v3/index.v3.test.ts delete mode 100644 src/server/v3/mcp.v3.test.ts delete mode 100644 src/server/v3/sse.v3.test.ts delete mode 100644 src/server/v3/streamableHttp.v3.test.ts delete mode 100644 src/server/v3/title.v3.test.ts create mode 100644 src/shared/zodTestMatrix.ts diff --git a/src/client/index.test.ts b/src/client/index.test.ts index 2143f603d..e9e52ea58 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -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; + type WeatherNotification = z4.infer; + type WeatherResult = z4.infer; + + // Create a typed Client for weather data + const weatherClient = new Client( + { + 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; + type WeatherNotification = z3.infer; + type WeatherResult = z3.infer; + + // Create a typed Client for weather data + const weatherClient = new Client( + { + 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; - type WeatherNotification = z.infer; - type WeatherResult = z.infer; - - // Create a typed Client for weather data - const weatherClient = new Client( - { - 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 */ diff --git a/src/client/v3/index.v3.test.ts b/src/client/v3/index.v3.test.ts deleted file mode 100644 index 78a53eea0..000000000 --- a/src/client/v3/index.v3.test.ts +++ /dev/null @@ -1,1591 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -/* eslint-disable no-constant-binary-expression */ -/* eslint-disable @typescript-eslint/no-unused-expressions */ -import { Client, getSupportedElicitationModes } from '../index.js'; -import * as z from 'zod/v3'; -import { - RequestSchema, - NotificationSchema, - ResultSchema, - LATEST_PROTOCOL_VERSION, - SUPPORTED_PROTOCOL_VERSIONS, - InitializeRequestSchema, - ListResourcesRequestSchema, - ListToolsRequestSchema, - CallToolRequestSchema, - CreateMessageRequestSchema, - ElicitRequestSchema, - ListRootsRequestSchema, - ErrorCode -} from '../../types.js'; -import { Transport } from '../../shared/transport.js'; -import { Server } from '../../server/index.js'; -import { InMemoryTransport } from '../../inMemory.js'; - -/*** - * Test: Initialize with Matching Protocol Version - */ -test('should initialize with matching protocol version', async () => { - const clientTransport: Transport = { - start: vi.fn().mockResolvedValue(undefined), - close: vi.fn().mockResolvedValue(undefined), - send: vi.fn().mockImplementation(message => { - if (message.method === 'initialize') { - clientTransport.onmessage?.({ - jsonrpc: '2.0', - id: message.id, - result: { - protocolVersion: LATEST_PROTOCOL_VERSION, - capabilities: {}, - serverInfo: { - name: 'test', - version: '1.0' - }, - instructions: 'test instructions' - } - }); - } - return Promise.resolve(); - }) - }; - - const client = new Client( - { - name: 'test client', - version: '1.0' - }, - { - capabilities: { - sampling: {} - } - } - ); - - await client.connect(clientTransport); - - // Should have sent initialize with latest version - expect(clientTransport.send).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'initialize', - params: expect.objectContaining({ - protocolVersion: LATEST_PROTOCOL_VERSION - }) - }), - expect.objectContaining({ - relatedRequestId: undefined - }) - ); - - // Should have the instructions returned - expect(client.getInstructions()).toEqual('test instructions'); -}); - -/*** - * Test: Initialize with Supported Older Protocol Version - */ -test('should initialize with supported older protocol version', async () => { - const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; - const clientTransport: Transport = { - start: vi.fn().mockResolvedValue(undefined), - close: vi.fn().mockResolvedValue(undefined), - send: vi.fn().mockImplementation(message => { - if (message.method === 'initialize') { - clientTransport.onmessage?.({ - jsonrpc: '2.0', - id: message.id, - result: { - protocolVersion: OLD_VERSION, - capabilities: {}, - serverInfo: { - name: 'test', - version: '1.0' - } - } - }); - } - return Promise.resolve(); - }) - }; - - const client = new Client( - { - name: 'test client', - version: '1.0' - }, - { - capabilities: { - sampling: {} - } - } - ); - - await client.connect(clientTransport); - - // Connection should succeed with the older version - expect(client.getServerVersion()).toEqual({ - name: 'test', - version: '1.0' - }); - - // Expect no instructions - expect(client.getInstructions()).toBeUndefined(); -}); - -/*** - * Test: Reject Unsupported Protocol Version - */ -test('should reject unsupported protocol version', async () => { - const clientTransport: Transport = { - start: vi.fn().mockResolvedValue(undefined), - close: vi.fn().mockResolvedValue(undefined), - send: vi.fn().mockImplementation(message => { - if (message.method === 'initialize') { - clientTransport.onmessage?.({ - jsonrpc: '2.0', - id: message.id, - result: { - protocolVersion: 'invalid-version', - capabilities: {}, - serverInfo: { - name: 'test', - version: '1.0' - } - } - }); - } - return Promise.resolve(); - }) - }; - - const client = new Client( - { - name: 'test client', - version: '1.0' - }, - { - capabilities: { - sampling: {} - } - } - ); - - await expect(client.connect(clientTransport)).rejects.toThrow("Server's protocol version is not supported: invalid-version"); - - expect(clientTransport.close).toHaveBeenCalled(); -}); - -/*** - * Test: Connect New Client to Old Supported Server Version - */ -test('should connect new client to old, supported server version', async () => { - const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; - const server = new Server( - { - name: 'test server', - version: '1.0' - }, - { - capabilities: { - resources: {}, - tools: {} - } - } - ); - - server.setRequestHandler(InitializeRequestSchema, _request => ({ - protocolVersion: OLD_VERSION, - capabilities: { - resources: {}, - tools: {} - }, - serverInfo: { - name: 'old server', - version: '1.0' - } - })); - - server.setRequestHandler(ListResourcesRequestSchema, () => ({ - resources: [] - })); - - server.setRequestHandler(ListToolsRequestSchema, () => ({ - tools: [] - })); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - const client = new Client( - { - name: 'new client', - version: '1.0' - }, - { - capabilities: { - sampling: {} - }, - enforceStrictCapabilities: true - } - ); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - expect(client.getServerVersion()).toEqual({ - name: 'old server', - version: '1.0' - }); -}); - -/*** - * Test: Version Negotiation with Old Client and Newer Server - */ -test('should negotiate version when client is old, and newer server supports its version', async () => { - const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; - const server = new Server( - { - name: 'new server', - version: '1.0' - }, - { - capabilities: { - resources: {}, - tools: {} - } - } - ); - - server.setRequestHandler(InitializeRequestSchema, _request => ({ - protocolVersion: LATEST_PROTOCOL_VERSION, - capabilities: { - resources: {}, - tools: {} - }, - serverInfo: { - name: 'new server', - version: '1.0' - } - })); - - server.setRequestHandler(ListResourcesRequestSchema, () => ({ - resources: [] - })); - - server.setRequestHandler(ListToolsRequestSchema, () => ({ - tools: [] - })); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - const client = new Client( - { - name: 'old client', - version: '1.0' - }, - { - capabilities: { - sampling: {} - }, - enforceStrictCapabilities: true - } - ); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - expect(client.getServerVersion()).toEqual({ - name: 'new server', - version: '1.0' - }); -}); - -/*** - * Test: Throw when Old Client and Server Version Mismatch - */ -test("should throw when client is old, and server doesn't support its version", async () => { - const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; - const FUTURE_VERSION = 'FUTURE_VERSION'; - const server = new Server( - { - name: 'new server', - version: '1.0' - }, - { - capabilities: { - resources: {}, - tools: {} - } - } - ); - - server.setRequestHandler(InitializeRequestSchema, _request => ({ - protocolVersion: FUTURE_VERSION, - capabilities: { - resources: {}, - tools: {} - }, - serverInfo: { - name: 'new server', - version: '1.0' - } - })); - - server.setRequestHandler(ListResourcesRequestSchema, () => ({ - resources: [] - })); - - server.setRequestHandler(ListToolsRequestSchema, () => ({ - tools: [] - })); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - const client = new Client( - { - name: 'old client', - version: '1.0' - }, - { - capabilities: { - sampling: {} - }, - enforceStrictCapabilities: true - } - ); - - await Promise.all([ - expect(client.connect(clientTransport)).rejects.toThrow("Server's protocol version is not supported: FUTURE_VERSION"), - server.connect(serverTransport) - ]); -}); - -/*** - * Test: Respect Server Capabilities - */ -test('should respect server capabilities', async () => { - const server = new Server( - { - name: 'test server', - version: '1.0' - }, - { - capabilities: { - resources: {}, - tools: {} - } - } - ); - - server.setRequestHandler(InitializeRequestSchema, _request => ({ - protocolVersion: LATEST_PROTOCOL_VERSION, - capabilities: { - resources: {}, - tools: {} - }, - serverInfo: { - name: 'test', - version: '1.0' - } - })); - - server.setRequestHandler(ListResourcesRequestSchema, () => ({ - resources: [] - })); - - server.setRequestHandler(ListToolsRequestSchema, () => ({ - tools: [] - })); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - const client = new Client( - { - name: 'test client', - version: '1.0' - }, - { - capabilities: { - sampling: {} - }, - enforceStrictCapabilities: true - } - ); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // Server supports resources and tools, but not prompts - expect(client.getServerCapabilities()).toEqual({ - resources: {}, - tools: {} - }); - - // These should work - await expect(client.listResources()).resolves.not.toThrow(); - await expect(client.listTools()).resolves.not.toThrow(); - - // These should throw because prompts, logging, and completions are not supported - await expect(client.listPrompts()).rejects.toThrow('Server does not support prompts'); - await expect(client.setLoggingLevel('error')).rejects.toThrow('Server does not support logging'); - await expect( - client.complete({ - ref: { type: 'ref/prompt', name: 'test' }, - argument: { name: 'test', value: 'test' } - }) - ).rejects.toThrow('Server does not support completions'); -}); - -/*** - * Test: Respect Client Notification Capabilities - */ -test('should respect client notification capabilities', async () => { - const server = new Server( - { - name: 'test server', - version: '1.0' - }, - { - capabilities: {} - } - ); - - const client = new Client( - { - name: 'test client', - version: '1.0' - }, - { - capabilities: { - roots: { - listChanged: true - } - } - } - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // This should work because the client has the roots.listChanged capability - await expect(client.sendRootsListChanged()).resolves.not.toThrow(); - - // Create a new client without the roots.listChanged capability - const clientWithoutCapability = new Client( - { - name: 'test client without capability', - version: '1.0' - }, - { - capabilities: {}, - enforceStrictCapabilities: true - } - ); - - await clientWithoutCapability.connect(clientTransport); - - // This should throw because the client doesn't have the roots.listChanged capability - await expect(clientWithoutCapability.sendRootsListChanged()).rejects.toThrow(/^Client does not support/); -}); - -/*** - * Test: Respect Server Notification Capabilities - */ -test('should respect server notification capabilities', async () => { - const server = new Server( - { - name: 'test server', - version: '1.0' - }, - { - capabilities: { - logging: {}, - resources: { - listChanged: true - } - } - } - ); - - const client = new Client( - { - name: 'test client', - version: '1.0' - }, - { - capabilities: {} - } - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // These should work because the server has the corresponding capabilities - await expect(server.sendLoggingMessage({ level: 'info', data: 'Test' })).resolves.not.toThrow(); - await expect(server.sendResourceListChanged()).resolves.not.toThrow(); - - // This should throw because the server doesn't have the tools capability - await expect(server.sendToolListChanged()).rejects.toThrow('Server does not support notifying of tool list changes'); -}); - -/*** - * Test: Only Allow setRequestHandler for Declared Capabilities - */ -test('should only allow setRequestHandler for declared capabilities', () => { - const client = new Client( - { - name: 'test client', - version: '1.0' - }, - { - capabilities: { - sampling: {} - } - } - ); - - // This should work because sampling is a declared capability - expect(() => { - client.setRequestHandler(CreateMessageRequestSchema, () => ({ - model: 'test-model', - role: 'assistant', - content: { - type: 'text', - text: 'Test response' - } - })); - }).not.toThrow(); - - // This should throw because roots listing is not a declared capability - expect(() => { - client.setRequestHandler(ListRootsRequestSchema, () => ({})); - }).toThrow('Client does not support roots capability'); -}); - -test('should allow setRequestHandler for declared elicitation capability', () => { - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { - elicitation: {} - } - } - ); - - // This should work because elicitation is a declared capability - expect(() => { - client.setRequestHandler(ElicitRequestSchema, () => ({ - action: 'accept', - content: { - username: 'test-user', - confirmed: true - } - })); - }).not.toThrow(); - - // This should throw because sampling is not a declared capability - expect(() => { - client.setRequestHandler(CreateMessageRequestSchema, () => ({ - model: 'test-model', - role: 'assistant', - content: { - type: 'text', - text: 'Test response' - } - })); - }).toThrow('Client does not support sampling capability'); -}); - -test('should accept form-mode elicitation request when client advertises empty elicitation object (back-compat)', async () => { - const server = new Server( - { - name: 'test server', - version: '1.0' - }, - { - capabilities: { - prompts: {}, - resources: {}, - tools: {}, - logging: {} - } - } - ); - - const client = new Client( - { - name: 'test client', - version: '1.0' - }, - { - capabilities: { - elicitation: {} - } - } - ); - - // Set up client handler for form-mode elicitation - client.setRequestHandler(ElicitRequestSchema, request => { - expect(request.params.mode).toBe('form'); - return { - action: 'accept', - content: { - username: 'test-user', - confirmed: true - } - }; - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // Server should be able to send form-mode elicitation request - // This works because getSupportedElicitationModes defaults to form mode - // when neither form nor url are explicitly declared - const result = await server.elicitInput({ - mode: 'form', - 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'] - } - }); - - expect(result.action).toBe('accept'); - expect(result.content).toEqual({ - username: 'test-user', - confirmed: true - }); -}); - -test('should reject form-mode elicitation when client only supports URL mode', async () => { - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { - elicitation: { - url: {} - } - } - } - ); - - const handler = vi.fn().mockResolvedValue({ - action: 'cancel' - }); - client.setRequestHandler(ElicitRequestSchema, handler); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - let resolveResponse: ((message: unknown) => void) | undefined; - const responsePromise = new Promise(resolve => { - resolveResponse = resolve; - }); - - serverTransport.onmessage = async message => { - if ('method' in message) { - if (message.method === 'initialize') { - if (!('id' in message) || message.id === undefined) { - throw new Error('Expected initialize request to include an id'); - } - const messageId = message.id; - await serverTransport.send({ - jsonrpc: '2.0', - id: messageId, - result: { - protocolVersion: LATEST_PROTOCOL_VERSION, - capabilities: {}, - serverInfo: { - name: 'test-server', - version: '1.0.0' - } - } - }); - } else if (message.method === 'notifications/initialized') { - // ignore - } - } else { - resolveResponse?.(message); - } - }; - - await client.connect(clientTransport); - - // Server shouldn't send this, because the client capabilities - // only advertised URL mode. Test that it's rejected by the client: - const requestId = 1; - await serverTransport.send({ - jsonrpc: '2.0', - id: requestId, - method: 'elicitation/create', - params: { - mode: 'form', - message: 'Provide your username', - requestedSchema: { - type: 'object', - properties: { - username: { - type: 'string' - } - } - } - } - }); - - const response = (await responsePromise) as { id: number; error: { code: number; message: string } }; - - expect(response.id).toBe(requestId); - expect(response.error.code).toBe(ErrorCode.InvalidParams); - expect(response.error.message).toContain('Client does not support form-mode elicitation requests'); - expect(handler).not.toHaveBeenCalled(); - - await client.close(); -}); - -test('should reject URL-mode elicitation when client only supports form mode', async () => { - const client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { - elicitation: { - form: {} - } - } - } - ); - - const handler = vi.fn().mockResolvedValue({ - action: 'cancel' - }); - client.setRequestHandler(ElicitRequestSchema, handler); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - let resolveResponse: ((message: unknown) => void) | undefined; - const responsePromise = new Promise(resolve => { - resolveResponse = resolve; - }); - - serverTransport.onmessage = async message => { - if ('method' in message) { - if (message.method === 'initialize') { - if (!('id' in message) || message.id === undefined) { - throw new Error('Expected initialize request to include an id'); - } - const messageId = message.id; - await serverTransport.send({ - jsonrpc: '2.0', - id: messageId, - result: { - protocolVersion: LATEST_PROTOCOL_VERSION, - capabilities: {}, - serverInfo: { - name: 'test-server', - version: '1.0.0' - } - } - }); - } else if (message.method === 'notifications/initialized') { - // ignore - } - } else { - resolveResponse?.(message); - } - }; - - await client.connect(clientTransport); - - // Server shouldn't send this, because the client capabilities - // only advertised form mode. Test that it's rejected by the client: - const requestId = 2; - await serverTransport.send({ - jsonrpc: '2.0', - id: requestId, - method: 'elicitation/create', - params: { - mode: 'url', - message: 'Open the authorization page', - elicitationId: 'elicitation-123', - url: 'https://example.com/authorize' - } - }); - - const response = (await responsePromise) as { id: number; error: { code: number; message: string } }; - - expect(response.id).toBe(requestId); - expect(response.error.code).toBe(ErrorCode.InvalidParams); - expect(response.error.message).toContain('Client does not support URL-mode elicitation requests'); - expect(handler).not.toHaveBeenCalled(); - - await client.close(); -}); - -test('should apply defaults for form-mode elicitation when applyDefaults is enabled', async () => { - const server = new Server( - { - name: 'test server', - version: '1.0' - }, - { - capabilities: { - prompts: {}, - resources: {}, - tools: {}, - logging: {} - } - } - ); - - const client = new Client( - { - name: 'test client', - version: '1.0' - }, - { - capabilities: { - elicitation: { - form: { - applyDefaults: true - } - } - } - } - ); - - client.setRequestHandler(ElicitRequestSchema, request => { - expect(request.params.mode).toBe('form'); - return { - action: 'accept', - content: {} - }; - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - const result = await server.elicitInput({ - mode: 'form', - message: 'Please confirm your preferences', - requestedSchema: { - type: 'object', - properties: { - confirmed: { - type: 'boolean', - default: true - } - } - } - }); - - expect(result.action).toBe('accept'); - expect(result.content).toEqual({ - confirmed: true - }); - - 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 = z.object({ - ...RequestSchema.shape, - method: z.literal('weather/get'), - params: z.object({ - city: z.string() - }) - }); - - const GetForecastRequestSchema = z.object({ - ...RequestSchema.shape, - method: z.literal('weather/forecast'), - params: z.object({ - city: z.string(), - days: z.number() - }) - }); - - const WeatherForecastNotificationSchema = z.object({ - ...NotificationSchema.shape, - 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 = z.object({ - ...ResultSchema.shape, - _meta: z.record(z.string(), z.unknown()).optional(), - temperature: z.number(), - conditions: z.string() - }); - - type WeatherRequest = z.infer; - type WeatherNotification = z.infer; - type WeatherResult = z.infer; - - // Create a typed Client for weather data - const weatherClient = new Client( - { - 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 - */ -test('should handle client cancelling a request', async () => { - const server = new Server( - { - name: 'test server', - version: '1.0' - }, - { - capabilities: { - resources: {} - } - } - ); - - // Set up server to delay responding to listResources - server.setRequestHandler(ListResourcesRequestSchema, async (request, extra) => { - await new Promise(resolve => setTimeout(resolve, 1000)); - return { - resources: [] - }; - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - const client = new Client( - { - name: 'test client', - version: '1.0' - }, - { - capabilities: {} - } - ); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // Set up abort controller - const controller = new AbortController(); - - // Issue request but cancel it immediately - const listResourcesPromise = client.listResources(undefined, { - signal: controller.signal - }); - controller.abort('Cancelled by test'); - - // Request should be rejected - await expect(listResourcesPromise).rejects.toBe('Cancelled by test'); -}); - -/*** - * Test: Handle Request Timeout - */ -test('should handle request timeout', async () => { - const server = new Server( - { - name: 'test server', - version: '1.0' - }, - { - capabilities: { - resources: {} - } - } - ); - - // Set up server with a delayed response - server.setRequestHandler(ListResourcesRequestSchema, async (_request, extra) => { - const timer = new Promise(resolve => { - const timeout = setTimeout(resolve, 100); - extra.signal.addEventListener('abort', () => clearTimeout(timeout)); - }); - - await timer; - return { - resources: [] - }; - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - const client = new Client( - { - name: 'test client', - version: '1.0' - }, - { - capabilities: {} - } - ); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // Request with 0 msec timeout should fail immediately - await expect(client.listResources(undefined, { timeout: 0 })).rejects.toMatchObject({ - code: ErrorCode.RequestTimeout - }); -}); - -describe('outputSchema validation', () => { - /*** - * Test: Validate structuredContent Against outputSchema - */ - test('should validate structuredContent against outputSchema', async () => { - const server = new Server( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tools: {} - } - } - ); - - // Set up server handlers - server.setRequestHandler(InitializeRequestSchema, async request => ({ - protocolVersion: request.params.protocolVersion, - capabilities: {}, - serverInfo: { - name: 'test-server', - version: '1.0.0' - } - })); - - server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: [ - { - name: 'test-tool', - description: 'A test tool', - inputSchema: { - type: 'object', - properties: {} - }, - outputSchema: { - type: 'object', - properties: { - result: { type: 'string' }, - count: { type: 'number' } - }, - required: ['result', 'count'], - additionalProperties: false - } - } - ] - })); - - server.setRequestHandler(CallToolRequestSchema, async request => { - if (request.params.name === 'test-tool') { - return { - structuredContent: { result: 'success', count: 42 } - }; - } - throw new Error('Unknown tool'); - }); - - const client = new Client({ - name: 'test-client', - version: '1.0.0' - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // List tools to cache the schemas - await client.listTools(); - - // Call the tool - should validate successfully - const result = await client.callTool({ name: 'test-tool' }); - expect(result.structuredContent).toEqual({ result: 'success', count: 42 }); - }); - - /*** - * Test: Throw Error when structuredContent Does Not Match Schema - */ - test('should throw error when structuredContent does not match schema', async () => { - const server = new Server( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tools: {} - } - } - ); - - // Set up server handlers - server.setRequestHandler(InitializeRequestSchema, async request => ({ - protocolVersion: request.params.protocolVersion, - capabilities: {}, - serverInfo: { - name: 'test-server', - version: '1.0.0' - } - })); - - server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: [ - { - name: 'test-tool', - description: 'A test tool', - inputSchema: { - type: 'object', - properties: {} - }, - outputSchema: { - type: 'object', - properties: { - result: { type: 'string' }, - count: { type: 'number' } - }, - required: ['result', 'count'], - additionalProperties: false - } - } - ] - })); - - server.setRequestHandler(CallToolRequestSchema, async request => { - if (request.params.name === 'test-tool') { - // Return invalid structured content (count is string instead of number) - return { - structuredContent: { result: 'success', count: 'not a number' } - }; - } - throw new Error('Unknown tool'); - }); - - const client = new Client({ - name: 'test-client', - version: '1.0.0' - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // List tools to cache the schemas - await client.listTools(); - - // Call the tool - should throw validation error - await expect(client.callTool({ name: 'test-tool' })).rejects.toThrow(/Structured content does not match the tool's output schema/); - }); - - /*** - * Test: Throw Error when Tool with outputSchema Returns No structuredContent - */ - test('should throw error when tool with outputSchema returns no structuredContent', async () => { - const server = new Server( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tools: {} - } - } - ); - - // Set up server handlers - server.setRequestHandler(InitializeRequestSchema, async request => ({ - protocolVersion: request.params.protocolVersion, - capabilities: {}, - serverInfo: { - name: 'test-server', - version: '1.0.0' - } - })); - - server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: [ - { - name: 'test-tool', - description: 'A test tool', - inputSchema: { - type: 'object', - properties: {} - }, - outputSchema: { - type: 'object', - properties: { - result: { type: 'string' } - }, - required: ['result'] - } - } - ] - })); - - server.setRequestHandler(CallToolRequestSchema, async request => { - if (request.params.name === 'test-tool') { - // Return content instead of structuredContent - return { - content: [{ type: 'text', text: 'This should be structured content' }] - }; - } - throw new Error('Unknown tool'); - }); - - const client = new Client({ - name: 'test-client', - version: '1.0.0' - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // List tools to cache the schemas - await client.listTools(); - - // Call the tool - should throw error - await expect(client.callTool({ name: 'test-tool' })).rejects.toThrow( - /Tool test-tool has an output schema but did not return structured content/ - ); - }); - - /*** - * Test: Handle Tools Without outputSchema Normally - */ - test('should handle tools without outputSchema normally', async () => { - const server = new Server( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tools: {} - } - } - ); - - // Set up server handlers - server.setRequestHandler(InitializeRequestSchema, async request => ({ - protocolVersion: request.params.protocolVersion, - capabilities: {}, - serverInfo: { - name: 'test-server', - version: '1.0.0' - } - })); - - server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: [ - { - name: 'test-tool', - description: 'A test tool', - inputSchema: { - type: 'object', - properties: {} - } - // No outputSchema - } - ] - })); - - server.setRequestHandler(CallToolRequestSchema, async request => { - if (request.params.name === 'test-tool') { - // Return regular content - return { - content: [{ type: 'text', text: 'Normal response' }] - }; - } - throw new Error('Unknown tool'); - }); - - const client = new Client({ - name: 'test-client', - version: '1.0.0' - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // List tools to cache the schemas - await client.listTools(); - - // Call the tool - should work normally without validation - const result = await client.callTool({ name: 'test-tool' }); - expect(result.content).toEqual([{ type: 'text', text: 'Normal response' }]); - }); - - /*** - * Test: Handle Complex JSON Schema Validation - */ - test('should handle complex JSON schema validation', async () => { - const server = new Server( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tools: {} - } - } - ); - - // Set up server handlers - server.setRequestHandler(InitializeRequestSchema, async request => ({ - protocolVersion: request.params.protocolVersion, - capabilities: {}, - serverInfo: { - name: 'test-server', - version: '1.0.0' - } - })); - - server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: [ - { - name: 'complex-tool', - description: 'A tool with complex schema', - inputSchema: { - type: 'object', - properties: {} - }, - outputSchema: { - type: 'object', - properties: { - name: { type: 'string', minLength: 3 }, - age: { type: 'integer', minimum: 0, maximum: 120 }, - active: { type: 'boolean' }, - tags: { - type: 'array', - items: { type: 'string' }, - minItems: 1 - }, - metadata: { - type: 'object', - properties: { - created: { type: 'string' } - }, - required: ['created'] - } - }, - required: ['name', 'age', 'active', 'tags', 'metadata'], - additionalProperties: false - } - } - ] - })); - - server.setRequestHandler(CallToolRequestSchema, async request => { - if (request.params.name === 'complex-tool') { - return { - structuredContent: { - name: 'John Doe', - age: 30, - active: true, - tags: ['user', 'admin'], - metadata: { - created: '2023-01-01T00:00:00Z' - } - } - }; - } - throw new Error('Unknown tool'); - }); - - const client = new Client({ - name: 'test-client', - version: '1.0.0' - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // List tools to cache the schemas - await client.listTools(); - - // Call the tool - should validate successfully - const result = await client.callTool({ name: 'complex-tool' }); - expect(result.structuredContent).toBeDefined(); - const structuredContent = result.structuredContent as { name: string; age: number }; - expect(structuredContent.name).toBe('John Doe'); - expect(structuredContent.age).toBe(30); - }); - - /*** - * Test: Fail Validation with Additional Properties When Not Allowed - */ - test('should fail validation with additional properties when not allowed', async () => { - const server = new Server( - { - name: 'test-server', - version: '1.0.0' - }, - { - capabilities: { - tools: {} - } - } - ); - - // Set up server handlers - server.setRequestHandler(InitializeRequestSchema, async request => ({ - protocolVersion: request.params.protocolVersion, - capabilities: {}, - serverInfo: { - name: 'test-server', - version: '1.0.0' - } - })); - - server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: [ - { - name: 'strict-tool', - description: 'A tool with strict schema', - inputSchema: { - type: 'object', - properties: {} - }, - outputSchema: { - type: 'object', - properties: { - name: { type: 'string' } - }, - required: ['name'], - additionalProperties: false - } - } - ] - })); - - server.setRequestHandler(CallToolRequestSchema, async request => { - if (request.params.name === 'strict-tool') { - // Return structured content with extra property - return { - structuredContent: { - name: 'John', - extraField: 'not allowed' - } - }; - } - throw new Error('Unknown tool'); - }); - - const client = new Client({ - name: 'test-client', - version: '1.0.0' - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); - - // List tools to cache the schemas - await client.listTools(); - - // Call the tool - should throw validation error due to additional property - await expect(client.callTool({ name: 'strict-tool' })).rejects.toThrow( - /Structured content does not match the tool's output schema/ - ); - }); -}); - -describe('getSupportedElicitationModes', () => { - test('should support nothing when capabilities are undefined', () => { - const result = getSupportedElicitationModes(undefined); - expect(result.supportsFormMode).toBe(false); - expect(result.supportsUrlMode).toBe(false); - }); - - test('should default to form mode when capabilities are an empty object', () => { - const result = getSupportedElicitationModes({}); - expect(result.supportsFormMode).toBe(true); - expect(result.supportsUrlMode).toBe(false); - }); - - test('should support form mode when form is explicitly declared', () => { - const result = getSupportedElicitationModes({ form: {} }); - expect(result.supportsFormMode).toBe(true); - expect(result.supportsUrlMode).toBe(false); - }); - - test('should support url mode when url is explicitly declared', () => { - const result = getSupportedElicitationModes({ url: {} }); - expect(result.supportsFormMode).toBe(false); - expect(result.supportsUrlMode).toBe(true); - }); - - test('should support both modes when both are explicitly declared', () => { - const result = getSupportedElicitationModes({ form: {}, url: {} }); - expect(result.supportsFormMode).toBe(true); - expect(result.supportsUrlMode).toBe(true); - }); - - test('should support form mode when form declares applyDefaults', () => { - const result = getSupportedElicitationModes({ form: { applyDefaults: true } }); - expect(result.supportsFormMode).toBe(true); - expect(result.supportsUrlMode).toBe(false); - }); -}); diff --git a/src/integration-tests/stateManagementStreamableHttp.test.ts b/src/integration-tests/stateManagementStreamableHttp.test.ts index bd61e6104..3294df4d4 100644 --- a/src/integration-tests/stateManagementStreamableHttp.test.ts +++ b/src/integration-tests/stateManagementStreamableHttp.test.ts @@ -12,346 +12,349 @@ import { ListPromptsResultSchema, LATEST_PROTOCOL_VERSION } from '../types.js'; -import * as z from 'zod/v4'; - -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: [ +import { zodTestMatrix, type ZodMatrixEntry } from '../shared/zodTestMatrix.js'; + +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' }, { - 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(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(); + // 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' + } + } + ] + })); - // List available tools - await client1.request( + mcpServer.tool( + 'greet', + 'A simple greeting tool', { - method: 'tools/list', - params: {} + name: z.string().describe('Name to greet').default('World') }, - ListToolsResultSchema + async ({ name }) => { + return { + content: [{ type: 'text', text: `Hello, ${name}!` }] + }; + } ); - const client2 = 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 transport2 = new StreamableHTTPClientTransport(baseUrl); - await client2.connect(transport2); + await mcpServer.connect(serverTransport); - // 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' + server.on('request', async (req, res) => { + await serverTransport.handleRequest(req, res); }); - 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'); + // Start the server on a random port + const baseUrl = await new Promise(resolve => { + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as AddressInfo; + resolve(new URL(`http://127.0.0.1:${addr.port}`)); + }); + }); - // List available prompts - const promptsResult = await client.request( - { - method: 'prompts/list', - params: {} - }, - ListPromptsResultSchema - ); + 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; + }); - // Verify prompts result structure - expect(promptsResult).toHaveProperty('prompts'); - expect(promptsResult.prompts).toContainEqual( - expect.objectContaining({ - name: 'test-prompt' - }) - ); + afterEach(async () => { + // Clean up resources + await mcpServer.close().catch(() => {}); + await serverTransport.close().catch(() => {}); + server.close(); + }); - // Call the greeting tool - const greetingResult = await client.request( - { - method: 'tools/call', - params: { - name: 'greet', - arguments: { - name: 'Stateless Transport' + 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 - ); + }, + CallToolResultSchema + ); - // Verify tool result - expect(greetingResult.content).toEqual([{ type: 'text', text: 'Hello, Stateless Transport!' }]); + // 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' + // Clean up + await transport.close(); }); - const transport = new StreamableHTTPClientTransport(baseUrl); - - // Verify protocol version is not set before connecting - expect(transport.protocolVersion).toBeUndefined(); + it('should set protocol version after connecting', async () => { + // Create and connect a client + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); - await client.connect(transport); + const transport = new StreamableHTTPClientTransport(baseUrl); - // Verify protocol version is set after connecting - expect(transport.protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + // Verify protocol version is not set before connecting + expect(transport.protocolVersion).toBeUndefined(); - // Clean up - await transport.close(); - }); - }); + await client.connect(transport); - 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; - }); + // Verify protocol version is set after connecting + expect(transport.protocolVersion).toBe(LATEST_PROTOCOL_VERSION); - afterEach(async () => { - // Clean up resources - await mcpServer.close().catch(() => {}); - await serverTransport.close().catch(() => {}); - server.close(); + // Clean up + await transport.close(); + }); }); - it('should operate with session management', async () => { - // Create and connect a client - const client = new Client({ - name: 'test-client', - version: '1.0.0' + 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; }); - 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' - }) - ); + afterEach(async () => { + // Clean up resources + await mcpServer.close().catch(() => {}); + await serverTransport.close().catch(() => {}); + server.close(); + }); - // Call the greeting tool - const greetingResult = await client.request( - { - method: 'tools/call', - params: { - name: 'greet', - arguments: { - name: 'Stateful Transport' + 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 - ); + }, + 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, Stateful Transport!' }]); - // Clean up - await transport.close(); + // Clean up + await transport.close(); + }); }); }); }); diff --git a/src/integration-tests/taskResumability.test.ts b/src/integration-tests/taskResumability.test.ts index d3f54c9d5..5470b3d5f 100644 --- a/src/integration-tests/taskResumability.test.ts +++ b/src/integration-tests/taskResumability.test.ts @@ -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'; - -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++) { +import { zodTestMatrix, type ZodMatrixEntry } from '../shared/zodTestMatrix.js'; + +describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { + const { z } = entry; + describe('Transport resumability', () => { + let server: Server; + let mcpServer: McpServer; + let serverTransport: StreamableHTTPServerTransport; + let baseUrl: URL; + let eventStore: InMemoryEventStore; + + beforeEach(async () => { + // Create event store for resumability + eventStore = new InMemoryEventStore(); + + // Create a simple MCP server + mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } }); + + // Add a simple notification tool that completes quickly + mcpServer.tool( + 'send-notification', + 'Sends a single notification', + { + message: z.string().describe('Message to send').default('Test notification') + }, + async ({ message }, { sendNotification }) => { + // Send notification immediately await sendNotification({ method: 'notifications/message', params: { level: 'info', - data: `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' }] + }; } + ); + + // 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` }] - }; - } - ); + return { + content: [{ type: 'text', text: `Sent ${count} notifications` }] + }; + } + ); - // Create a transport with the event store - serverTransport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - eventStore - }); + // Create a transport with the event store + serverTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore + }); - // Connect the transport to the MCP server - await mcpServer.connect(serverTransport); + // 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); - }); + // 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(resolve => { - server.listen(0, '127.0.0.1', () => { - const addr = server.address() as AddressInfo; - resolve(new URL(`http://127.0.0.1:${addr.port}`)); + // Start the server on a random port + baseUrl = await new Promise(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; + + // Create first client + const client1 = new Client({ + title: clientTitle, + name: 'test-client', + version: '1.0.0' + }); - // Connect first client - const transport1 = new StreamableHTTPClientTransport(baseUrl); - await client1.connect(transport1); - const sessionId = transport1.sessionId; - expect(sessionId).toBeDefined(); + // Set up notification handler for first client + client1.setNotificationHandler(LoggingMessageNotificationSchema, notification => { + if (notification.method === 'notifications/message') { + notifications.push(notification.params); + } + }); - // 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 + // 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 } - }, - 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)); } - ); - - // Fix for node 18 test failures, allow some time for notifications to arrive - const maxWaitTime = 2000; // 2 seconds max wait - const pollInterval = 10; // Check every 10ms - const startTime = Date.now(); - while (notifications.length === 0 && Date.now() - startTime < maxWaitTime) { - // Wait for some notifications to arrive (not all) - shorter wait time - await new Promise(resolve => setTimeout(resolve, pollInterval)); - } - - // Verify we received some notifications and lastEventId was updated - expect(notifications.length).toBeGreaterThan(0); - expect(notifications.length).toBeLessThan(4); - expect(onLastEventIdUpdate).toHaveBeenCalled(); - expect(lastEventId).toBeDefined(); - - // Disconnect first client without waiting for completion - // When we close the connection, it will cause a ConnectionClosed error for - // any in-progress requests, which is expected behavior - await transport1.close(); - // Save the promise so we can catch it after closing - const catchPromise = toolPromise.catch(err => { - // This error is expected - the connection was intentionally closed - if (err?.code !== -32000) { - // ConnectionClosed error code - console.error('Unexpected error type during transport close:', err); - } - }); - // Add a short delay to ensure clean disconnect before reconnecting - await new Promise(resolve => setTimeout(resolve, 10)); + // 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); + } + }); - // Wait for the rejection to be handled - await catchPromise; + // Add a short delay to ensure clean disconnect before reconnecting + await new Promise(resolve => setTimeout(resolve, 10)); - // Create second client with same client ID - const client2 = new Client({ - title: clientTitle, - name: 'test-client', - version: '1.0.0' - }); + // Wait for the rejection to be handled + await catchPromise; - // Set up notification handler for second client - client2.setNotificationHandler(LoggingMessageNotificationSchema, notification => { - if (notification.method === 'notifications/message') { - notifications.push(notification.params); - } - }); + // Create second client with same client ID + const client2 = new Client({ + title: clientTitle, + name: 'test-client', + version: '1.0.0' + }); - // 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 + // 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 } - }, - 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); + // Verify we eventually received at leaset a few motifications + expect(notifications.length).toBeGreaterThan(1); - // Clean up - await transport2.close(); + // Clean up + await transport2.close(); + }); }); }); diff --git a/src/integration-tests/v3/stateManagementStreamableHttp.v3.test.ts b/src/integration-tests/v3/stateManagementStreamableHttp.v3.test.ts deleted file mode 100644 index b47306142..000000000 --- a/src/integration-tests/v3/stateManagementStreamableHttp.v3.test.ts +++ /dev/null @@ -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(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(); - }); - }); -}); diff --git a/src/integration-tests/v3/taskResumability.v3.test.ts b/src/integration-tests/v3/taskResumability.v3.test.ts deleted file mode 100644 index 7c7ea927e..000000000 --- a/src/integration-tests/v3/taskResumability.v3.test.ts +++ /dev/null @@ -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(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(); - }); -}); diff --git a/src/server/completable.test.ts b/src/server/completable.test.ts index fa836fec5..e0d2aba99 100644 --- a/src/server/completable.test.ts +++ b/src/server/completable.test.ts @@ -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, () => []); diff --git a/src/server/index.test.ts b/src/server/index.test.ts index 36665095e..86eaf6d9e 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -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).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).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).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).or( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + GetForecastRequestSchema as unknown as z3.ZodObject + ) as AnyObjectSchema; + const WeatherNotificationSchema = WeatherForecastNotificationSchema as AnyObjectSchema; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const WeatherResultSchema = (ResultSchema as unknown as z3.ZodObject).extend({ + temperature: z3.number(), + conditions: z3.string() + }) as AnyObjectSchema; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + type InferSchema = T extends z3.ZodType ? Output : never; + type WeatherRequest = InferSchema; + type WeatherNotification = InferSchema; + type WeatherResult = InferSchema; + + // Create a typed Server for weather data + const weatherServer = new Server( + { + 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; + type WeatherNotification = z4.infer; + type WeatherResult = z4.infer; + + // Create a typed Server for weather data + const weatherServer = new Server( + { + 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; - type WeatherNotification = z.infer; - type WeatherResult = z.infer; - - // Create a typed Server for weather data - const weatherServer = new Server( - { - 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( { diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 23798c138..776d0a129 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -1,4 +1,3 @@ -import * as z from 'zod/v4'; import { Client } from '../client/index.js'; import { InMemoryTransport } from '../inMemory.js'; import { getDisplayName } from '../shared/metadataUtils.js'; @@ -21,4556 +20,4566 @@ import { } from '../types.js'; import { completable } from './completable.js'; import { McpServer, ResourceTemplate } from './mcp.js'; +import { zodTestMatrix, type ZodMatrixEntry } from '../shared/zodTestMatrix.js'; -describe('McpServer', () => { - /*** - * Test: Basic Server Instance - */ - test('should expose underlying Server instance', () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - - expect(mcpServer.server).toBeDefined(); - }); +describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { + const { z } = entry; - /*** - * Test: Notification Sending via Server - */ - test('should allow sending notifications via Server', async () => { - const mcpServer = new McpServer( - { + describe('McpServer', () => { + /*** + * Test: Basic Server Instance + */ + test('should expose underlying Server instance', () => { + const mcpServer = new McpServer({ name: 'test server', version: '1.0' - }, - { capabilities: { logging: {} } } - ); + }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' + expect(mcpServer.server).toBeDefined(); }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - // This should work because we're using the underlying server - await expect( - mcpServer.server.sendLoggingMessage({ - level: 'info', - data: 'Test log message' - }) - ).resolves.not.toThrow(); - - expect(notifications).toMatchObject([ - { - method: 'notifications/message', - params: { - level: 'info', - data: 'Test log message' - } - } - ]); - }); - /*** - * Test: Progress Notification with Message Field - */ - test('should send progress notifications with message field', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); + /*** + * Test: Notification Sending via Server + */ + test('should allow sending notifications via Server', async () => { + const mcpServer = new McpServer( + { + name: 'test server', + version: '1.0' + }, + { capabilities: { logging: {} } } + ); - // Create a tool that sends progress updates - mcpServer.tool( - 'long-operation', - 'A long running operation with progress updates', - { - steps: z.number().min(1).describe('Number of steps to perform') - }, - async ({ steps }, { sendNotification, _meta }) => { - const progressToken = _meta?.progressToken; - - if (progressToken) { - // Send progress notification for each step - for (let i = 1; i <= steps; i++) { - await sendNotification({ - method: 'notifications/progress', - params: { - progressToken, - progress: i, - total: steps, - message: `Completed step ${i} of ${steps}` - } - }); - } - } + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; - return { - content: [ - { - type: 'text' as const, - text: `Operation completed with ${steps} steps` - } - ] - }; - } - ); - - const progressUpdates: Array<{ - progress: number; - total?: number; - message?: string; - }> = []; - - const client = new Client({ - name: 'test client', - version: '1.0' - }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + // This should work because we're using the underlying server + await expect( + mcpServer.server.sendLoggingMessage({ + level: 'info', + data: 'Test log message' + }) + ).resolves.not.toThrow(); - // Call the tool with progress tracking - await client.request( - { - method: 'tools/call', - params: { - name: 'long-operation', - arguments: { steps: 3 }, - _meta: { - progressToken: 'progress-test-1' + expect(notifications).toMatchObject([ + { + method: 'notifications/message', + params: { + level: 'info', + data: 'Test log message' } } - }, - CallToolResultSchema, - { - onprogress: progress => { - progressUpdates.push(progress); - } - } - ); - - // Verify progress notifications were received with message field - expect(progressUpdates).toHaveLength(3); - expect(progressUpdates[0]).toMatchObject({ - progress: 1, - total: 3, - message: 'Completed step 1 of 3' - }); - expect(progressUpdates[1]).toMatchObject({ - progress: 2, - total: 3, - message: 'Completed step 2 of 3' - }); - expect(progressUpdates[2]).toMatchObject({ - progress: 3, - total: 3, - message: 'Completed step 3 of 3' - }); - }); -}); - -describe('ResourceTemplate', () => { - /*** - * Test: ResourceTemplate Creation with String Pattern - */ - test('should create ResourceTemplate with string pattern', () => { - const template = new ResourceTemplate('test://{category}/{id}', { - list: undefined - }); - expect(template.uriTemplate.toString()).toBe('test://{category}/{id}'); - expect(template.listCallback).toBeUndefined(); - }); - - /*** - * Test: ResourceTemplate Creation with UriTemplate Instance - */ - test('should create ResourceTemplate with UriTemplate', () => { - const uriTemplate = new UriTemplate('test://{category}/{id}'); - const template = new ResourceTemplate(uriTemplate, { list: undefined }); - expect(template.uriTemplate).toBe(uriTemplate); - expect(template.listCallback).toBeUndefined(); - }); - - /*** - * Test: ResourceTemplate with List Callback - */ - test('should create ResourceTemplate with list callback', async () => { - const list = vi.fn().mockResolvedValue({ - resources: [{ name: 'Test', uri: 'test://example' }] + ]); }); - const template = new ResourceTemplate('test://{id}', { list }); - expect(template.listCallback).toBe(list); - - const abortController = new AbortController(); - const result = await template.listCallback?.({ - signal: abortController.signal, - requestId: 'not-implemented', - sendRequest: () => { - throw new Error('Not implemented'); - }, - sendNotification: () => { - throw new Error('Not implemented'); - } - }); - expect(result?.resources).toHaveLength(1); - expect(list).toHaveBeenCalled(); - }); -}); - -describe('tool()', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - /*** - * Test: Zero-Argument Tool Registration - */ - test('should register zero-argument tool', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' - }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; + /*** + * Test: Progress Notification with Message Field + */ + test('should send progress notifications with message field', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - mcpServer.tool('test', async () => ({ - content: [ + // Create a tool that sends progress updates + mcpServer.tool( + 'long-operation', + 'A long running operation with progress updates', { - type: 'text', - text: 'Test response' - } - ] - })); + steps: z.number().min(1).describe('Number of steps to perform') + }, + async ({ steps }, { sendNotification, _meta }) => { + const progressToken = _meta?.progressToken; + + if (progressToken) { + // Send progress notification for each step + for (let i = 1; i <= steps; i++) { + await sendNotification({ + method: 'notifications/progress', + params: { + progressToken, + progress: i, + total: steps, + message: `Completed step ${i} of ${steps}` + } + }); + } + } - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + return { + content: [ + { + type: 'text' as const, + text: `Operation completed with ${steps} steps` + } + ] + }; + } + ); - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + const progressUpdates: Array<{ + progress: number; + total?: number; + message?: string; + }> = []; - const result = await client.request( - { - method: 'tools/list' - }, - ListToolsResultSchema - ); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - expect(result.tools).toHaveLength(1); - expect(result.tools[0].name).toBe('test'); - expect(result.tools[0].inputSchema).toEqual({ - type: 'object', - properties: {} - }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - // Adding the tool before the connection was established means no notification was sent - expect(notifications).toHaveLength(0); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - // Adding another tool triggers the update notification - mcpServer.tool('test2', async () => ({ - content: [ + // Call the tool with progress tracking + await client.request( { - type: 'text', - text: 'Test response' + method: 'tools/call', + params: { + name: 'long-operation', + arguments: { steps: 3 }, + _meta: { + progressToken: 'progress-test-1' + } + } + }, + CallToolResultSchema, + { + onprogress: progress => { + progressUpdates.push(progress); + } } - ] - })); + ); + + // Verify progress notifications were received with message field + expect(progressUpdates).toHaveLength(3); + expect(progressUpdates[0]).toMatchObject({ + progress: 1, + total: 3, + message: 'Completed step 1 of 3' + }); + expect(progressUpdates[1]).toMatchObject({ + progress: 2, + total: 3, + message: 'Completed step 2 of 3' + }); + expect(progressUpdates[2]).toMatchObject({ + progress: 3, + total: 3, + message: 'Completed step 3 of 3' + }); + }); + }); - // Yield event loop to let the notification fly - await new Promise(process.nextTick); + describe('ResourceTemplate', () => { + /*** + * Test: ResourceTemplate Creation with String Pattern + */ + test('should create ResourceTemplate with string pattern', () => { + const template = new ResourceTemplate('test://{category}/{id}', { + list: undefined + }); + expect(template.uriTemplate.toString()).toBe('test://{category}/{id}'); + expect(template.listCallback).toBeUndefined(); + }); + + /*** + * Test: ResourceTemplate Creation with UriTemplate Instance + */ + test('should create ResourceTemplate with UriTemplate', () => { + const uriTemplate = new UriTemplate('test://{category}/{id}'); + const template = new ResourceTemplate(uriTemplate, { list: undefined }); + expect(template.uriTemplate).toBe(uriTemplate); + expect(template.listCallback).toBeUndefined(); + }); + + /*** + * Test: ResourceTemplate with List Callback + */ + test('should create ResourceTemplate with list callback', async () => { + const list = vi.fn().mockResolvedValue({ + resources: [{ name: 'Test', uri: 'test://example' }] + }); - expect(notifications).toMatchObject([ - { - method: 'notifications/tools/list_changed' - } - ]); - }); + const template = new ResourceTemplate('test://{id}', { list }); + expect(template.listCallback).toBe(list); - /*** - * Test: Updating Existing Tool - */ - test('should update existing tool', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + const abortController = new AbortController(); + const result = await template.listCallback?.({ + signal: abortController.signal, + requestId: 'not-implemented', + sendRequest: () => { + throw new Error('Not implemented'); + }, + sendNotification: () => { + throw new Error('Not implemented'); + } + }); + expect(result?.resources).toHaveLength(1); + expect(list).toHaveBeenCalled(); }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' + }); + + describe('tool()', () => { + afterEach(() => { + vi.restoreAllMocks(); }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; - // Register initial tool - const tool = mcpServer.tool('test', async () => ({ - content: [ - { - type: 'text', - text: 'Initial response' - } - ] - })); + /*** + * Test: Zero-Argument Tool Registration + */ + test('should register zero-argument tool', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; - // Update the tool - tool.update({ - callback: async () => ({ + mcpServer.tool('test', async () => ({ content: [ { type: 'text', - text: 'Updated response' + text: 'Test response' } ] - }) - }); + })); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - // Call the tool and verify we get the updated response - const result = await client.request( - { - method: 'tools/call', - params: { - name: 'test' - } - }, - CallToolResultSchema - ); - - expect(result.content).toEqual([ - { - type: 'text', - text: 'Updated response' - } - ]); - - // Update happened before transport was connected, so no notifications should be expected - expect(notifications).toHaveLength(0); - }); + const result = await client.request( + { + method: 'tools/list' + }, + ListToolsResultSchema + ); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0].name).toBe('test'); + expect(result.tools[0].inputSchema).toEqual({ + type: 'object', + properties: {} + }); - /*** - * Test: Updating Tool with Schema - */ - test('should update tool with schema', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' - }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; - - // Register initial tool - const tool = mcpServer.tool( - 'test', - { - name: z.string() - }, - async ({ name }) => ({ + // Adding the tool before the connection was established means no notification was sent + expect(notifications).toHaveLength(0); + + // Adding another tool triggers the update notification + mcpServer.tool('test2', async () => ({ content: [ { type: 'text', - text: `Initial: ${name}` + text: 'Test response' } ] - }) - ); - - // Update the tool with a different schema - tool.update({ - paramsSchema: { - name: z.string(), - value: z.number() - }, - callback: async ({ name, value }) => ({ + })); + + // Yield event loop to let the notification fly + await new Promise(process.nextTick); + + expect(notifications).toMatchObject([ + { + method: 'notifications/tools/list_changed' + } + ]); + }); + + /*** + * Test: Updating Existing Tool + */ + test('should update existing tool', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + // Register initial tool + const tool = mcpServer.tool('test', async () => ({ content: [ { type: 'text', - text: `Updated: ${name}, ${value}` + text: 'Initial response' } ] - }) - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + })); - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + // Update the tool + tool.update({ + callback: async () => ({ + content: [ + { + type: 'text', + text: 'Updated response' + } + ] + }) + }); - // Verify the schema was updated - const listResult = await client.request( - { - method: 'tools/list' - }, - ListToolsResultSchema - ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - expect(listResult.tools[0].inputSchema).toMatchObject({ - properties: { - name: { type: 'string' }, - value: { type: 'number' } - } - }); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - // Call the tool with the new schema - const callResult = await client.request( - { - method: 'tools/call', - params: { - name: 'test', - arguments: { - name: 'test', - value: 42 + // Call the tool and verify we get the updated response + const result = await client.request( + { + method: 'tools/call', + params: { + name: 'test' } - } - }, - CallToolResultSchema - ); - - expect(callResult.content).toEqual([ - { - type: 'text', - text: 'Updated: test, 42' - } - ]); - - // Update happened before transport was connected, so no notifications should be expected - expect(notifications).toHaveLength(0); - }); - - /*** - * Test: Tool List Changed Notifications - */ - test('should send tool list changed notifications when connected', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' - }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; + }, + CallToolResultSchema + ); - // Register initial tool - const tool = mcpServer.tool('test', async () => ({ - content: [ + expect(result.content).toEqual([ { type: 'text', - text: 'Test response' + text: 'Updated response' } - ] - })); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + ]); - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - - expect(notifications).toHaveLength(0); - - // Now update the tool - tool.update({ - callback: async () => ({ - content: [ - { - type: 'text', - text: 'Updated response' - } - ] - }) + // Update happened before transport was connected, so no notifications should be expected + expect(notifications).toHaveLength(0); }); - // Yield event loop to let the notification fly - await new Promise(process.nextTick); + /*** + * Test: Updating Tool with Schema + */ + test('should update tool with schema', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; - expect(notifications).toMatchObject([{ method: 'notifications/tools/list_changed' }]); + // Register initial tool + const tool = mcpServer.tool( + 'test', + { + name: z.string() + }, + async ({ name }) => ({ + content: [ + { + type: 'text', + text: `Initial: ${name}` + } + ] + }) + ); - // Now delete the tool - tool.remove(); + // Update the tool with a different schema + tool.update({ + paramsSchema: { + name: z.string(), + value: z.number() + }, + callback: async ({ name, value }) => ({ + content: [ + { + type: 'text', + text: `Updated: ${name}, ${value}` + } + ] + }) + }); - // Yield event loop to let the notification fly - await new Promise(process.nextTick); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - expect(notifications).toMatchObject([ - { method: 'notifications/tools/list_changed' }, - { method: 'notifications/tools/list_changed' } - ]); - }); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - /*** - * Test: Tool Registration with Parameters - */ - test('should register tool with params', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + // Verify the schema was updated + const listResult = await client.request( + { + method: 'tools/list' + }, + ListToolsResultSchema + ); - // old api - mcpServer.tool( - 'test', - { - name: z.string(), - value: z.number() - }, - async ({ name, value }) => ({ - content: [ - { - type: 'text', - text: `${name}: ${value}` - } - ] - }) - ); - - // new api - mcpServer.registerTool( - 'test (new api)', - { - inputSchema: { name: z.string(), value: z.number() } - }, - async ({ name, value }) => ({ - content: [{ type: 'text', text: `${name}: ${value}` }] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request( - { - method: 'tools/list' - }, - ListToolsResultSchema - ); - - expect(result.tools).toHaveLength(2); - expect(result.tools[0].name).toBe('test'); - expect(result.tools[0].inputSchema).toMatchObject({ - type: 'object', - properties: { - name: { type: 'string' }, - value: { type: 'number' } - } - }); - expect(result.tools[1].name).toBe('test (new api)'); - expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema); - }); + expect(listResult.tools[0].inputSchema).toMatchObject({ + properties: { + name: { type: 'string' }, + value: { type: 'number' } + } + }); - /*** - * Test: Tool Registration with Description - */ - test('should register tool with description', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + // Call the tool with the new schema + const callResult = await client.request( + { + method: 'tools/call', + params: { + name: 'test', + arguments: { + name: 'test', + value: 42 + } + } + }, + CallToolResultSchema + ); - // old api - mcpServer.tool('test', 'Test description', async () => ({ - content: [ + expect(callResult.content).toEqual([ { type: 'text', - text: 'Test response' + text: 'Updated: test, 42' } - ] - })); - - // new api - mcpServer.registerTool( - 'test (new api)', - { - description: 'Test description' - }, - async () => ({ - content: [ - { - type: 'text' as const, - text: 'Test response' - } - ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request( - { - method: 'tools/list' - }, - ListToolsResultSchema - ); - - expect(result.tools).toHaveLength(2); - expect(result.tools[0].name).toBe('test'); - expect(result.tools[0].description).toBe('Test description'); - expect(result.tools[1].name).toBe('test (new api)'); - expect(result.tools[1].description).toBe('Test description'); - }); + ]); - /*** - * Test: Tool Registration with Annotations - */ - test('should register tool with annotations', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' + // Update happened before transport was connected, so no notifications should be expected + expect(notifications).toHaveLength(0); }); - mcpServer.tool('test', { title: 'Test Tool', readOnlyHint: true }, async () => ({ - content: [ - { - type: 'text', - text: 'Test response' - } - ] - })); - - mcpServer.registerTool( - 'test (new api)', - { - annotations: { title: 'Test Tool', readOnlyHint: true } - }, - async () => ({ + /*** + * Test: Tool List Changed Notifications + */ + test('should send tool list changed notifications when connected', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + // Register initial tool + const tool = mcpServer.tool('test', async () => ({ content: [ { - type: 'text' as const, + type: 'text', text: 'Test response' } ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + })); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - const result = await client.request( - { - method: 'tools/list' - }, - ListToolsResultSchema - ); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - expect(result.tools).toHaveLength(2); - expect(result.tools[0].name).toBe('test'); - expect(result.tools[0].annotations).toEqual({ - title: 'Test Tool', - readOnlyHint: true - }); - expect(result.tools[1].name).toBe('test (new api)'); - expect(result.tools[1].annotations).toEqual({ - title: 'Test Tool', - readOnlyHint: true - }); - }); + expect(notifications).toHaveLength(0); - /*** - * Test: Tool Registration with Parameters and Annotations - */ - test('should register tool with params and annotations', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + // Now update the tool + tool.update({ + callback: async () => ({ + content: [ + { + type: 'text', + text: 'Updated response' + } + ] + }) + }); - mcpServer.tool('test', { name: z.string() }, { title: 'Test Tool', readOnlyHint: true }, async ({ name }) => ({ - content: [{ type: 'text', text: `Hello, ${name}!` }] - })); - - mcpServer.registerTool( - 'test (new api)', - { - inputSchema: { name: z.string() }, - annotations: { title: 'Test Tool', readOnlyHint: true } - }, - async ({ name }) => ({ - content: [{ type: 'text', text: `Hello, ${name}!` }] - }) - ); + // Yield event loop to let the notification fly + await new Promise(process.nextTick); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + expect(notifications).toMatchObject([{ method: 'notifications/tools/list_changed' }]); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + // Now delete the tool + tool.remove(); - const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); + // Yield event loop to let the notification fly + await new Promise(process.nextTick); - expect(result.tools).toHaveLength(2); - expect(result.tools[0].name).toBe('test'); - expect(result.tools[0].inputSchema).toMatchObject({ - type: 'object', - properties: { name: { type: 'string' } } - }); - expect(result.tools[0].annotations).toEqual({ - title: 'Test Tool', - readOnlyHint: true + expect(notifications).toMatchObject([ + { method: 'notifications/tools/list_changed' }, + { method: 'notifications/tools/list_changed' } + ]); }); - expect(result.tools[1].name).toBe('test (new api)'); - expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema); - expect(result.tools[1].annotations).toEqual(result.tools[0].annotations); - }); - /*** - * Test: Tool Registration with Description, Parameters, and Annotations - */ - test('should register tool with description, params, and annotations', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + /*** + * Test: Tool Registration with Parameters + */ + test('should register tool with params', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - mcpServer.tool( - 'test', - 'A tool with everything', - { name: z.string() }, - { title: 'Complete Test Tool', readOnlyHint: true, openWorldHint: false }, - async ({ name }) => ({ - content: [{ type: 'text', text: `Hello, ${name}!` }] - }) - ); - - mcpServer.registerTool( - 'test (new api)', - { - description: 'A tool with everything', - inputSchema: { name: z.string() }, - annotations: { - title: 'Complete Test Tool', - readOnlyHint: true, - openWorldHint: false - } - }, - async ({ name }) => ({ - content: [{ type: 'text', text: `Hello, ${name}!` }] - }) - ); + // old api + mcpServer.tool( + 'test', + { + name: z.string(), + value: z.number() + }, + async ({ name, value }) => ({ + content: [ + { + type: 'text', + text: `${name}: ${value}` + } + ] + }) + ); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + // new api + mcpServer.registerTool( + 'test (new api)', + { + inputSchema: { name: z.string(), value: z.number() } + }, + async ({ name, value }) => ({ + content: [{ type: 'text', text: `${name}: ${value}` }] + }) + ); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - expect(result.tools).toHaveLength(2); - expect(result.tools[0].name).toBe('test'); - expect(result.tools[0].description).toBe('A tool with everything'); - expect(result.tools[0].inputSchema).toMatchObject({ - type: 'object', - properties: { name: { type: 'string' } } + const result = await client.request( + { + method: 'tools/list' + }, + ListToolsResultSchema + ); + + expect(result.tools).toHaveLength(2); + expect(result.tools[0].name).toBe('test'); + expect(result.tools[0].inputSchema).toMatchObject({ + type: 'object', + properties: { + name: { type: 'string' }, + value: { type: 'number' } + } + }); + expect(result.tools[1].name).toBe('test (new api)'); + expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema); }); - expect(result.tools[0].annotations).toEqual({ - title: 'Complete Test Tool', - readOnlyHint: true, - openWorldHint: false + + /*** + * Test: Tool Registration with Description + */ + test('should register tool with description', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + // old api + mcpServer.tool('test', 'Test description', async () => ({ + content: [ + { + type: 'text', + text: 'Test response' + } + ] + })); + + // new api + mcpServer.registerTool( + 'test (new api)', + { + description: 'Test description' + }, + async () => ({ + content: [ + { + type: 'text' as const, + text: 'Test response' + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request( + { + method: 'tools/list' + }, + ListToolsResultSchema + ); + + expect(result.tools).toHaveLength(2); + expect(result.tools[0].name).toBe('test'); + expect(result.tools[0].description).toBe('Test description'); + expect(result.tools[1].name).toBe('test (new api)'); + expect(result.tools[1].description).toBe('Test description'); }); - expect(result.tools[1].name).toBe('test (new api)'); - expect(result.tools[1].description).toBe('A tool with everything'); - expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema); - expect(result.tools[1].annotations).toEqual(result.tools[0].annotations); - }); - /*** - * Test: Tool Registration with Description, Empty Parameters, and Annotations - */ - test('should register tool with description, empty params, and annotations', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + /*** + * Test: Tool Registration with Annotations + */ + test('should register tool with annotations', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.tool('test', { title: 'Test Tool', readOnlyHint: true }, async () => ({ + content: [ + { + type: 'text', + text: 'Test response' + } + ] + })); + + mcpServer.registerTool( + 'test (new api)', + { + annotations: { title: 'Test Tool', readOnlyHint: true } + }, + async () => ({ + content: [ + { + type: 'text' as const, + text: 'Test response' + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request( + { + method: 'tools/list' + }, + ListToolsResultSchema + ); + + expect(result.tools).toHaveLength(2); + expect(result.tools[0].name).toBe('test'); + expect(result.tools[0].annotations).toEqual({ + title: 'Test Tool', + readOnlyHint: true + }); + expect(result.tools[1].name).toBe('test (new api)'); + expect(result.tools[1].annotations).toEqual({ + title: 'Test Tool', + readOnlyHint: true + }); }); - const client = new Client({ - name: 'test client', - version: '1.0' + + /*** + * Test: Tool Registration with Parameters and Annotations + */ + test('should register tool with params and annotations', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.tool('test', { name: z.string() }, { title: 'Test Tool', readOnlyHint: true }, async ({ name }) => ({ + content: [{ type: 'text', text: `Hello, ${name}!` }] + })); + + mcpServer.registerTool( + 'test (new api)', + { + inputSchema: { name: z.string() }, + annotations: { title: 'Test Tool', readOnlyHint: true } + }, + async ({ name }) => ({ + content: [{ type: 'text', text: `Hello, ${name}!` }] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); + + expect(result.tools).toHaveLength(2); + expect(result.tools[0].name).toBe('test'); + expect(result.tools[0].inputSchema).toMatchObject({ + type: 'object', + properties: { name: { type: 'string' } } + }); + expect(result.tools[0].annotations).toEqual({ + title: 'Test Tool', + readOnlyHint: true + }); + expect(result.tools[1].name).toBe('test (new api)'); + expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema); + expect(result.tools[1].annotations).toEqual(result.tools[0].annotations); }); - mcpServer.tool( - 'test', - 'A tool with everything but empty params', - {}, - { - title: 'Complete Test Tool with empty params', + /*** + * Test: Tool Registration with Description, Parameters, and Annotations + */ + test('should register tool with description, params, and annotations', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.tool( + 'test', + 'A tool with everything', + { name: z.string() }, + { title: 'Complete Test Tool', readOnlyHint: true, openWorldHint: false }, + async ({ name }) => ({ + content: [{ type: 'text', text: `Hello, ${name}!` }] + }) + ); + + mcpServer.registerTool( + 'test (new api)', + { + description: 'A tool with everything', + inputSchema: { name: z.string() }, + annotations: { + title: 'Complete Test Tool', + readOnlyHint: true, + openWorldHint: false + } + }, + async ({ name }) => ({ + content: [{ type: 'text', text: `Hello, ${name}!` }] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); + + expect(result.tools).toHaveLength(2); + expect(result.tools[0].name).toBe('test'); + expect(result.tools[0].description).toBe('A tool with everything'); + expect(result.tools[0].inputSchema).toMatchObject({ + type: 'object', + properties: { name: { type: 'string' } } + }); + expect(result.tools[0].annotations).toEqual({ + title: 'Complete Test Tool', readOnlyHint: true, openWorldHint: false - }, - async () => ({ - content: [{ type: 'text', text: 'Test response' }] - }) - ); - - mcpServer.registerTool( - 'test (new api)', - { - description: 'A tool with everything but empty params', - inputSchema: {}, - annotations: { + }); + expect(result.tools[1].name).toBe('test (new api)'); + expect(result.tools[1].description).toBe('A tool with everything'); + expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema); + expect(result.tools[1].annotations).toEqual(result.tools[0].annotations); + }); + + /*** + * Test: Tool Registration with Description, Empty Parameters, and Annotations + */ + test('should register tool with description, empty params, and annotations', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.tool( + 'test', + 'A tool with everything but empty params', + {}, + { title: 'Complete Test Tool with empty params', readOnlyHint: true, openWorldHint: false - } - }, - async () => ({ - content: [{ type: 'text' as const, text: 'Test response' }] - }) - ); + }, + async () => ({ + content: [{ type: 'text', text: 'Test response' }] + }) + ); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + mcpServer.registerTool( + 'test (new api)', + { + description: 'A tool with everything but empty params', + inputSchema: {}, + annotations: { + title: 'Complete Test Tool with empty params', + readOnlyHint: true, + openWorldHint: false + } + }, + async () => ({ + content: [{ type: 'text' as const, text: 'Test response' }] + }) + ); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - expect(result.tools).toHaveLength(2); - expect(result.tools[0].name).toBe('test'); - expect(result.tools[0].description).toBe('A tool with everything but empty params'); - expect(result.tools[0].inputSchema).toMatchObject({ - type: 'object', - properties: {} - }); - expect(result.tools[0].annotations).toEqual({ - title: 'Complete Test Tool with empty params', - readOnlyHint: true, - openWorldHint: false - }); - expect(result.tools[1].name).toBe('test (new api)'); - expect(result.tools[1].description).toBe('A tool with everything but empty params'); - expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema); - expect(result.tools[1].annotations).toEqual(result.tools[0].annotations); - }); + const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); - /*** - * Test: Tool Argument Validation - */ - test('should validate tool args', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' + expect(result.tools).toHaveLength(2); + expect(result.tools[0].name).toBe('test'); + expect(result.tools[0].description).toBe('A tool with everything but empty params'); + expect(result.tools[0].inputSchema).toMatchObject({ + type: 'object', + properties: {} + }); + expect(result.tools[0].annotations).toEqual({ + title: 'Complete Test Tool with empty params', + readOnlyHint: true, + openWorldHint: false + }); + expect(result.tools[1].name).toBe('test (new api)'); + expect(result.tools[1].description).toBe('A tool with everything but empty params'); + expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema); + expect(result.tools[1].annotations).toEqual(result.tools[0].annotations); }); - mcpServer.tool( - 'test', - { - name: z.string(), - value: z.number() - }, - async ({ name, value }) => ({ - content: [ - { - type: 'text', - text: `${name}: ${value}` - } - ] - }) - ); + /*** + * Test: Tool Argument Validation + */ + test('should validate tool args', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - mcpServer.registerTool( - 'test (new api)', - { - inputSchema: { + mcpServer.tool( + 'test', + { name: z.string(), value: z.number() - } - }, - async ({ name, value }) => ({ - content: [ - { - type: 'text', - text: `${name}: ${value}` + }, + async ({ name, value }) => ({ + content: [ + { + type: 'text', + text: `${name}: ${value}` + } + ] + }) + ); + + mcpServer.registerTool( + 'test (new api)', + { + inputSchema: { + name: z.string(), + value: z.number() } - ] - }) - ); + }, + async ({ name, value }) => ({ + content: [ + { + type: 'text', + text: `${name}: ${value}` + } + ] + }) + ); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request( - { - method: 'tools/call', - params: { - name: 'test', - arguments: { + const result = await client.request( + { + method: 'tools/call', + params: { name: 'test', - value: 'not a number' + arguments: { + name: 'test', + value: 'not a number' + } } - } - }, - CallToolResultSchema - ); + }, + CallToolResultSchema + ); - expect(result.isError).toBe(true); - expect(result.content).toEqual( - expect.arrayContaining([ - { - type: 'text', - text: expect.stringContaining('Input validation error: Invalid arguments for tool test') - } - ]) - ); - - const result2 = await client.request( - { - method: 'tools/call', - params: { - name: 'test (new api)', - arguments: { - name: 'test', - value: 'not a number' + expect(result.isError).toBe(true); + expect(result.content).toEqual( + expect.arrayContaining([ + { + type: 'text', + text: expect.stringContaining('Input validation error: Invalid arguments for tool test') } - } - }, - CallToolResultSchema - ); + ]) + ); - expect(result2.isError).toBe(true); - expect(result2.content).toEqual( - expect.arrayContaining([ + const result2 = await client.request( { - type: 'text', - text: expect.stringContaining('Input validation error: Invalid arguments for tool test (new api)') - } - ]) - ); - }); + method: 'tools/call', + params: { + name: 'test (new api)', + arguments: { + name: 'test', + value: 'not a number' + } + } + }, + CallToolResultSchema + ); - /*** - * Test: Preventing Duplicate Tool Registration - */ - test('should prevent duplicate tool registration', () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + expect(result2.isError).toBe(true); + expect(result2.content).toEqual( + expect.arrayContaining([ + { + type: 'text', + text: expect.stringContaining('Input validation error: Invalid arguments for tool test (new api)') + } + ]) + ); }); - mcpServer.tool('test', async () => ({ - content: [ - { - type: 'text', - text: 'Test response' - } - ] - })); + /*** + * Test: Preventing Duplicate Tool Registration + */ + test('should prevent duplicate tool registration', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - expect(() => { mcpServer.tool('test', async () => ({ content: [ { type: 'text', - text: 'Test response 2' + text: 'Test response' } ] })); - }).toThrow(/already registered/); - }); - /*** - * Test: Multiple Tool Registration - */ - test('should allow registering multiple tools', () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + expect(() => { + mcpServer.tool('test', async () => ({ + content: [ + { + type: 'text', + text: 'Test response 2' + } + ] + })); + }).toThrow(/already registered/); }); - // This should succeed - mcpServer.tool('tool1', () => ({ content: [] })); + /*** + * Test: Multiple Tool Registration + */ + test('should allow registering multiple tools', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - // This should also succeed and not throw about request handlers - mcpServer.tool('tool2', () => ({ content: [] })); - }); + // This should succeed + mcpServer.tool('tool1', () => ({ content: [] })); - /*** - * Test: Tool with Output Schema and Structured Content - */ - test('should support tool with outputSchema and structuredContent', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + // This should also succeed and not throw about request handlers + mcpServer.tool('tool2', () => ({ content: [] })); }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + /*** + * Test: Tool with Output Schema and Structured Content + */ + test('should support tool with outputSchema and structuredContent', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - // Register a tool with outputSchema - mcpServer.registerTool( - 'test', - { - description: 'Test tool with structured output', - inputSchema: { - input: z.string() + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + // Register a tool with outputSchema + mcpServer.registerTool( + 'test', + { + description: 'Test tool with structured output', + inputSchema: { + input: z.string() + }, + outputSchema: { + processedInput: z.string(), + resultType: z.string(), + timestamp: z.string() + } }, - outputSchema: { - processedInput: z.string(), - resultType: z.string(), - timestamp: z.string() - } - }, - async ({ input }) => ({ - structuredContent: { - processedInput: input, - resultType: 'structured', - timestamp: '2023-01-01T00:00:00Z' + async ({ input }) => ({ + structuredContent: { + processedInput: input, + resultType: 'structured', + timestamp: '2023-01-01T00:00:00Z' + }, + content: [ + { + type: 'text', + text: JSON.stringify({ + processedInput: input, + resultType: 'structured', + timestamp: '2023-01-01T00:00:00Z' + }) + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + // Verify the tool registration includes outputSchema + const listResult = await client.request( + { + method: 'tools/list' }, - content: [ + ListToolsResultSchema + ); + + expect(listResult.tools).toHaveLength(1); + expect(listResult.tools[0].outputSchema).toMatchObject({ + type: 'object', + properties: { + processedInput: { type: 'string' }, + resultType: { type: 'string' }, + timestamp: { type: 'string' } + }, + required: ['processedInput', 'resultType', 'timestamp'] + }); + + // Call the tool and verify it returns valid structuredContent + const result = await client.request( + { + method: 'tools/call', + params: { + name: 'test', + arguments: { + input: 'hello' + } + } + }, + CallToolResultSchema + ); + + expect(result.structuredContent).toBeDefined(); + const structuredContent = result.structuredContent as { + processedInput: string; + resultType: string; + timestamp: string; + }; + expect(structuredContent.processedInput).toBe('hello'); + expect(structuredContent.resultType).toBe('structured'); + expect(structuredContent.timestamp).toBe('2023-01-01T00:00:00Z'); + + // For backward compatibility, content is auto-generated from structuredContent + expect(result.content).toBeDefined(); + expect(result.content!).toHaveLength(1); + expect(result.content![0]).toMatchObject({ type: 'text' }); + const textContent = result.content![0] as TextContent; + expect(JSON.parse(textContent.text)).toEqual(result.structuredContent); + }); + + /*** + * Test: Tool with Output Schema Must Provide Structured Content + */ + test('should throw error when tool with outputSchema returns no structuredContent', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + // Register a tool with outputSchema that returns only content without structuredContent + mcpServer.registerTool( + 'test', + { + description: 'Test tool with output schema but missing structured content', + inputSchema: { + input: z.string() + }, + outputSchema: { + processedInput: z.string(), + resultType: z.string() + } + }, + async ({ input }) => ({ + // Only return content without structuredContent + content: [ + { + type: 'text', + text: `Processed: ${input}` + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + // Call the tool and expect it to throw an error + const result = await client.callTool({ + name: 'test', + arguments: { + input: 'hello' + } + }); + + expect(result.isError).toBe(true); + expect(result.content).toEqual( + expect.arrayContaining([ { type: 'text', - text: JSON.stringify({ - processedInput: input, - resultType: 'structured', - timestamp: '2023-01-01T00:00:00Z' - }) + text: expect.stringContaining( + 'Output validation error: Tool test has an output schema but no structured content was provided' + ) } - ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - // Verify the tool registration includes outputSchema - const listResult = await client.request( - { - method: 'tools/list' - }, - ListToolsResultSchema - ); - - expect(listResult.tools).toHaveLength(1); - expect(listResult.tools[0].outputSchema).toMatchObject({ - type: 'object', - properties: { - processedInput: { type: 'string' }, - resultType: { type: 'string' }, - timestamp: { type: 'string' } - }, - required: ['processedInput', 'resultType', 'timestamp'] + ]) + ); }); + /*** + * Test: Tool with Output Schema Must Provide Structured Content + */ + test('should skip outputSchema validation when isError is true', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerTool( + 'test', + { + description: 'Test tool with output schema but missing structured content', + inputSchema: { + input: z.string() + }, + outputSchema: { + processedInput: z.string(), + resultType: z.string() + } + }, + async ({ input }) => ({ + content: [ + { + type: 'text', + text: `Processed: ${input}` + } + ], + isError: true + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - // Call the tool and verify it returns valid structuredContent - const result = await client.request( - { - method: 'tools/call', - params: { + await expect( + client.callTool({ name: 'test', arguments: { input: 'hello' } - } - }, - CallToolResultSchema - ); - - expect(result.structuredContent).toBeDefined(); - const structuredContent = result.structuredContent as { - processedInput: string; - resultType: string; - timestamp: string; - }; - expect(structuredContent.processedInput).toBe('hello'); - expect(structuredContent.resultType).toBe('structured'); - expect(structuredContent.timestamp).toBe('2023-01-01T00:00:00Z'); - - // For backward compatibility, content is auto-generated from structuredContent - expect(result.content).toBeDefined(); - expect(result.content!).toHaveLength(1); - expect(result.content![0]).toMatchObject({ type: 'text' }); - const textContent = result.content![0] as TextContent; - expect(JSON.parse(textContent.text)).toEqual(result.structuredContent); - }); - - /*** - * Test: Tool with Output Schema Must Provide Structured Content - */ - test('should throw error when tool with outputSchema returns no structuredContent', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + }) + ).resolves.toStrictEqual({ + content: [ + { + type: 'text', + text: `Processed: hello` + } + ], + isError: true + }); }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + /*** + * Test: Schema Validation Failure for Invalid Structured Content + */ + test('should fail schema validation when tool returns invalid structuredContent', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - // Register a tool with outputSchema that returns only content without structuredContent - mcpServer.registerTool( - 'test', - { - description: 'Test tool with output schema but missing structured content', - inputSchema: { - input: z.string() + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + // Register a tool with outputSchema that returns invalid data + mcpServer.registerTool( + 'test', + { + description: 'Test tool with invalid structured output', + inputSchema: { + input: z.string() + }, + outputSchema: { + processedInput: z.string(), + resultType: z.string(), + timestamp: z.string() + } }, - outputSchema: { - processedInput: z.string(), - resultType: z.string() + async ({ input }) => ({ + content: [ + { + type: 'text', + text: JSON.stringify({ + processedInput: input, + resultType: 'structured', + // Missing required 'timestamp' field + someExtraField: 'unexpected' // Extra field not in schema + }) + } + ], + structuredContent: { + processedInput: input, + resultType: 'structured', + // Missing required 'timestamp' field + someExtraField: 'unexpected' // Extra field not in schema + } + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + // Call the tool and expect it to throw a server-side validation error + const result = await client.callTool({ + name: 'test', + arguments: { + input: 'hello' } - }, - async ({ input }) => ({ - // Only return content without structuredContent - content: [ + }); + + expect(result.isError).toBe(true); + expect(result.content).toEqual( + expect.arrayContaining([ { type: 'text', - text: `Processed: ${input}` + text: expect.stringContaining('Output validation error: Invalid structured content for tool test') } - ] - }) - ); + ]) + ); + }); + + /*** + * Test: Pass Session ID to Tool Callback + */ + test('should pass sessionId to tool callback via RequestHandlerExtra', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + let receivedSessionId: string | undefined; + mcpServer.tool('test-tool', async extra => { + receivedSessionId = extra.sessionId; + return { + content: [ + { + type: 'text', + text: 'Test response' + } + ] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + // Set a test sessionId on the server transport + serverTransport.sessionId = 'test-session-123'; - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + await client.request( + { + method: 'tools/call', + params: { + name: 'test-tool' + } + }, + CallToolResultSchema + ); - // Call the tool and expect it to throw an error - const result = await client.callTool({ - name: 'test', - arguments: { - input: 'hello' - } + expect(receivedSessionId).toBe('test-session-123'); }); - expect(result.isError).toBe(true); - expect(result.content).toEqual( - expect.arrayContaining([ + /*** + * Test: Pass Request ID to Tool Callback + */ + test('should pass requestId to tool callback via RequestHandlerExtra', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + let receivedRequestId: string | number | undefined; + mcpServer.tool('request-id-test', async extra => { + receivedRequestId = extra.requestId; + return { + content: [ + { + type: 'text', + text: `Received request ID: ${extra.requestId}` + } + ] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request( { - type: 'text', - text: expect.stringContaining( - 'Output validation error: Tool test has an output schema but no structured content was provided' - ) - } - ]) - ); - }); - /*** - * Test: Tool with Output Schema Must Provide Structured Content - */ - test('should skip outputSchema validation when isError is true', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + method: 'tools/call', + params: { + name: 'request-id-test' + } + }, + CallToolResultSchema + ); + + expect(receivedRequestId).toBeDefined(); + expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); + expect(result.content).toEqual( + expect.arrayContaining([ + { + type: 'text', + text: expect.stringContaining('Received request ID:') + } + ]) + ); }); - const client = new Client({ - name: 'test client', - version: '1.0' + /*** + * Test: Send Notification within Tool Call + */ + test('should provide sendNotification within tool call', async () => { + const mcpServer = new McpServer( + { + name: 'test server', + version: '1.0' + }, + { capabilities: { logging: {} } } + ); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + let receivedLogMessage: string | undefined; + const loggingMessage = 'hello here is log message 1'; + + client.setNotificationHandler(LoggingMessageNotificationSchema, notification => { + receivedLogMessage = notification.params.data as string; + }); + + mcpServer.tool('test-tool', async ({ sendNotification }) => { + await sendNotification({ + method: 'notifications/message', + params: { level: 'debug', data: loggingMessage } + }); + return { + content: [ + { + type: 'text', + text: 'Test response' + } + ] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + await client.request( + { + method: 'tools/call', + params: { + name: 'test-tool' + } + }, + CallToolResultSchema + ); + expect(receivedLogMessage).toBe(loggingMessage); }); - mcpServer.registerTool( - 'test', - { - description: 'Test tool with output schema but missing structured content', - inputSchema: { + /*** + * Test: Client to Server Tool Call + */ + test('should allow client to call server tools', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.tool( + 'test', + 'Test tool', + { input: z.string() }, - outputSchema: { - processedInput: z.string(), - resultType: z.string() - } - }, - async ({ input }) => ({ - content: [ - { - type: 'text', - text: `Processed: ${input}` - } - ], - isError: true - }) - ); + async ({ input }) => ({ + content: [ + { + type: 'text', + text: `Processed: ${input}` + } + ] + }) + ); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - await expect( - client.callTool({ - name: 'test', - arguments: { - input: 'hello' - } - }) - ).resolves.toStrictEqual({ - content: [ + const result = await client.request( + { + method: 'tools/call', + params: { + name: 'test', + arguments: { + input: 'hello' + } + } + }, + CallToolResultSchema + ); + + expect(result.content).toEqual([ { type: 'text', - text: `Processed: hello` + text: 'Processed: hello' } - ], - isError: true + ]); }); - }); - /*** - * Test: Schema Validation Failure for Invalid Structured Content - */ - test('should fail schema validation when tool returns invalid structuredContent', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); + /*** + * Test: Graceful Tool Error Handling + */ + test('should handle server tool errors gracefully', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - // Register a tool with outputSchema that returns invalid data - mcpServer.registerTool( - 'test', - { - description: 'Test tool with invalid structured output', - inputSchema: { - input: z.string() - }, - outputSchema: { - processedInput: z.string(), - resultType: z.string(), - timestamp: z.string() - } - }, - async ({ input }) => ({ - content: [ - { - type: 'text', - text: JSON.stringify({ - processedInput: input, - resultType: 'structured', - // Missing required 'timestamp' field - someExtraField: 'unexpected' // Extra field not in schema - }) - } - ], - structuredContent: { - processedInput: input, - resultType: 'structured', - // Missing required 'timestamp' field - someExtraField: 'unexpected' // Extra field not in schema - } - }) - ); + mcpServer.tool('error-test', async () => { + throw new Error('Tool execution failed'); + }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - // Call the tool and expect it to throw a server-side validation error - const result = await client.callTool({ - name: 'test', - arguments: { - input: 'hello' - } - }); + const result = await client.request( + { + method: 'tools/call', + params: { + name: 'error-test' + } + }, + CallToolResultSchema + ); - expect(result.isError).toBe(true); - expect(result.content).toEqual( - expect.arrayContaining([ + expect(result.isError).toBe(true); + expect(result.content).toEqual([ { type: 'text', - text: expect.stringContaining('Output validation error: Invalid structured content for tool test') + text: 'Tool execution failed' } - ]) - ); - }); - - /*** - * Test: Pass Session ID to Tool Callback - */ - test('should pass sessionId to tool callback via RequestHandlerExtra', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + ]); }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + /*** + * Test: McpError for Invalid Tool Name + */ + test('should throw McpError for invalid tool name', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); - let receivedSessionId: string | undefined; - mcpServer.tool('test-tool', async extra => { - receivedSessionId = extra.sessionId; - return { + mcpServer.tool('test-tool', async () => ({ content: [ { type: 'text', text: 'Test response' } ] - }; - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - // Set a test sessionId on the server transport - serverTransport.sessionId = 'test-session-123'; - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - await client.request( - { - method: 'tools/call', - params: { - name: 'test-tool' - } - }, - CallToolResultSchema - ); + })); - expect(receivedSessionId).toBe('test-session-123'); - }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - /*** - * Test: Pass Request ID to Tool Callback - */ - test('should pass requestId to tool callback via RequestHandlerExtra', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + const result = await client.request( + { + method: 'tools/call', + params: { + name: 'nonexistent-tool' + } + }, + CallToolResultSchema + ); - let receivedRequestId: string | number | undefined; - mcpServer.tool('request-id-test', async extra => { - receivedRequestId = extra.requestId; - return { - content: [ + expect(result.isError).toBe(true); + expect(result.content).toEqual( + expect.arrayContaining([ { type: 'text', - text: `Received request ID: ${extra.requestId}` + text: expect.stringContaining('Tool nonexistent-tool not found') } - ] - }; + ]) + ); }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + /*** + * Test: URL Elicitation Required Error Propagation + */ + test('should propagate UrlElicitationRequiredError to client callers', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - const result = await client.request( - { - method: 'tools/call', - params: { - name: 'request-id-test' - } - }, - CallToolResultSchema - ); - - expect(receivedRequestId).toBeDefined(); - expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); - expect(result.content).toEqual( - expect.arrayContaining([ + const client = new Client( { - type: 'text', - text: expect.stringContaining('Received request ID:') + name: 'test client', + version: '1.0' + }, + { + capabilities: { + elicitation: { + url: {} + } + } } - ]) - ); - }); + ); - /*** - * Test: Send Notification within Tool Call - */ - test('should provide sendNotification within tool call', async () => { - const mcpServer = new McpServer( - { - name: 'test server', - version: '1.0' - }, - { capabilities: { logging: {} } } - ); + const elicitationParams = { + mode: 'url' as const, + elicitationId: 'elicitation-123', + url: 'https://mcp.example.com/connect', + message: 'Authorization required' + }; - const client = new Client({ - name: 'test client', - version: '1.0' - }); + mcpServer.tool('needs-authorization', async () => { + throw new UrlElicitationRequiredError([elicitationParams], 'Confirmation required'); + }); - let receivedLogMessage: string | undefined; - const loggingMessage = 'hello here is log message 1'; + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - client.setNotificationHandler(LoggingMessageNotificationSchema, notification => { - receivedLogMessage = notification.params.data as string; - }); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - mcpServer.tool('test-tool', async ({ sendNotification }) => { - await sendNotification({ - method: 'notifications/message', - params: { level: 'debug', data: loggingMessage } - }); - return { - content: [ - { - type: 'text', - text: 'Test response' + await client + .callTool({ + name: 'needs-authorization' + }) + .then(() => { + throw new Error('Expected callTool to throw UrlElicitationRequiredError'); + }) + .catch(error => { + expect(error).toBeInstanceOf(UrlElicitationRequiredError); + if (error instanceof UrlElicitationRequiredError) { + expect(error.code).toBe(ErrorCode.UrlElicitationRequired); + expect(error.elicitations).toEqual([elicitationParams]); } - ] - }; + }); }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - await client.request( - { - method: 'tools/call', - params: { - name: 'test-tool' - } - }, - CallToolResultSchema - ); - expect(receivedLogMessage).toBe(loggingMessage); - }); - - /*** - * Test: Client to Server Tool Call - */ - test('should allow client to call server tools', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); + /*** + * Test: Tool Registration with _meta field + */ + test('should register tool with _meta field and include it in list response', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + const metaData = { + author: 'test-author', + version: '1.2.3', + category: 'utility', + tags: ['test', 'example'] + }; - mcpServer.tool( - 'test', - 'Test tool', - { - input: z.string() - }, - async ({ input }) => ({ - content: [ - { - type: 'text', - text: `Processed: ${input}` - } - ] - }) - ); + mcpServer.registerTool( + 'test-with-meta', + { + description: 'A tool with _meta field', + inputSchema: { name: z.string() }, + _meta: metaData + }, + async ({ name }) => ({ + content: [{ type: 'text', text: `Hello, ${name}!` }] + }) + ); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request( - { - method: 'tools/call', - params: { - name: 'test', - arguments: { - input: 'hello' - } - } - }, - CallToolResultSchema - ); - - expect(result.content).toEqual([ - { - type: 'text', - text: 'Processed: hello' - } - ]); - }); + const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); - /*** - * Test: Graceful Tool Error Handling - */ - test('should handle server tool errors gracefully', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + expect(result.tools).toHaveLength(1); + expect(result.tools[0].name).toBe('test-with-meta'); + expect(result.tools[0].description).toBe('A tool with _meta field'); + expect(result.tools[0]._meta).toEqual(metaData); }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + /*** + * Test: Tool Registration without _meta field should have undefined _meta + */ + test('should register tool without _meta field and have undefined _meta in response', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - mcpServer.tool('error-test', async () => { - throw new Error('Tool execution failed'); - }); + mcpServer.registerTool( + 'test-without-meta', + { + description: 'A tool without _meta field', + inputSchema: { name: z.string() } + }, + async ({ name }) => ({ + content: [{ type: 'text', text: `Hello, ${name}!` }] + }) + ); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request( - { - method: 'tools/call', - params: { - name: 'error-test' - } - }, - CallToolResultSchema - ); - - expect(result.isError).toBe(true); - expect(result.content).toEqual([ - { - type: 'text', - text: 'Tool execution failed' - } - ]); - }); + const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); - /*** - * Test: McpError for Invalid Tool Name - */ - test('should throw McpError for invalid tool name', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + expect(result.tools).toHaveLength(1); + expect(result.tools[0].name).toBe('test-without-meta'); + expect(result.tools[0]._meta).toBeUndefined(); }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + test('should validate tool names according to SEP specification', () => { + // Create a new server instance for this test + const testServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + // Spy on console.warn to verify warnings are logged + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - mcpServer.tool('test-tool', async () => ({ - content: [ + // Test valid tool names + testServer.registerTool( + 'valid-tool-name', { - type: 'text', - text: 'Test response' - } - ] - })); + description: 'A valid tool name' + }, + async () => ({ content: [{ type: 'text' as const, text: 'Success' }] }) + ); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + // Test tool name with warnings (starts with dash) + testServer.registerTool( + '-warning-tool', + { + description: 'A tool name that generates warnings' + }, + async () => ({ content: [{ type: 'text' as const, text: 'Success' }] }) + ); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + // Test invalid tool name (contains spaces) + testServer.registerTool( + 'invalid tool name', + { + description: 'An invalid tool name' + }, + async () => ({ content: [{ type: 'text' as const, text: 'Success' }] }) + ); - const result = await client.request( - { - method: 'tools/call', - params: { - name: 'nonexistent-tool' - } - }, - CallToolResultSchema - ); + // Verify that warnings were issued (both for warnings and validation failures) + expect(warnSpy).toHaveBeenCalled(); - expect(result.isError).toBe(true); - expect(result.content).toEqual( - expect.arrayContaining([ - { - type: 'text', - text: expect.stringContaining('Tool nonexistent-tool not found') - } - ]) - ); - }); + // Verify specific warning content + const warningCalls = warnSpy.mock.calls.map(call => call.join(' ')); + expect(warningCalls.some(call => call.includes('Tool name starts or ends with a dash'))).toBe(true); + expect(warningCalls.some(call => call.includes('Tool name contains spaces'))).toBe(true); + expect(warningCalls.some(call => call.includes('Tool name contains invalid characters'))).toBe(true); - /*** - * Test: URL Elicitation Required Error Propagation - */ - test('should propagate UrlElicitationRequiredError to client callers', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + // Clean up spies + warnSpy.mockRestore(); }); + }); - const client = new Client( - { + describe('resource()', () => { + /*** + * Test: Resource Registration with URI and Read Callback + */ + test('should register resource with uri and readCallback', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ name: 'test client', version: '1.0' - }, - { - capabilities: { - elicitation: { - url: {} - } - } - } - ); - - const elicitationParams = { - mode: 'url' as const, - elicitationId: 'elicitation-123', - url: 'https://mcp.example.com/connect', - message: 'Authorization required' - }; - - mcpServer.tool('needs-authorization', async () => { - throw new UrlElicitationRequiredError([elicitationParams], 'Confirmation required'); - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - await client - .callTool({ - name: 'needs-authorization' - }) - .then(() => { - throw new Error('Expected callTool to throw UrlElicitationRequiredError'); - }) - .catch(error => { - expect(error).toBeInstanceOf(UrlElicitationRequiredError); - if (error instanceof UrlElicitationRequiredError) { - expect(error.code).toBe(ErrorCode.UrlElicitationRequired); - expect(error.elicitations).toEqual([elicitationParams]); - } }); - }); - /*** - * Test: Tool Registration with _meta field - */ - test('should register tool with _meta field and include it in list response', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + mcpServer.resource('test', 'test://resource', async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Test content' + } + ] + })); - const metaData = { - author: 'test-author', - version: '1.2.3', - category: 'utility', - tags: ['test', 'example'] - }; - - mcpServer.registerTool( - 'test-with-meta', - { - description: 'A tool with _meta field', - inputSchema: { name: z.string() }, - _meta: metaData - }, - async ({ name }) => ({ - content: [{ type: 'text', text: `Hello, ${name}!` }] - }) - ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + const result = await client.request( + { + method: 'resources/list' + }, + ListResourcesResultSchema + ); - const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); + expect(result.resources).toHaveLength(1); + expect(result.resources[0].name).toBe('test'); + expect(result.resources[0].uri).toBe('test://resource'); + }); - expect(result.tools).toHaveLength(1); - expect(result.tools[0].name).toBe('test-with-meta'); - expect(result.tools[0].description).toBe('A tool with _meta field'); - expect(result.tools[0]._meta).toEqual(metaData); - }); + /*** + * Test: Update Resource with URI + */ + test('should update resource with uri', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; - /*** - * Test: Tool Registration without _meta field should have undefined _meta - */ - test('should register tool without _meta field and have undefined _meta in response', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + // Register initial resource + const resource = mcpServer.resource('test', 'test://resource', async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Initial content' + } + ] + })); - mcpServer.registerTool( - 'test-without-meta', - { - description: 'A tool without _meta field', - inputSchema: { name: z.string() } - }, - async ({ name }) => ({ - content: [{ type: 'text', text: `Hello, ${name}!` }] - }) - ); + // Update the resource + resource.update({ + callback: async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Updated content' + } + ] + }) + }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); + // Read the resource and verify we get the updated content + const result = await client.request( + { + method: 'resources/read', + params: { + uri: 'test://resource' + } + }, + ReadResourceResultSchema + ); - expect(result.tools).toHaveLength(1); - expect(result.tools[0].name).toBe('test-without-meta'); - expect(result.tools[0]._meta).toBeUndefined(); - }); + expect(result.contents).toHaveLength(1); + expect(result.contents).toEqual( + expect.arrayContaining([ + { + text: expect.stringContaining('Updated content'), + uri: 'test://resource' + } + ]) + ); - test('should validate tool names according to SEP specification', () => { - // Create a new server instance for this test - const testServer = new McpServer({ - name: 'test server', - version: '1.0' + // Update happened before transport was connected, so no notifications should be expected + expect(notifications).toHaveLength(0); }); - // Spy on console.warn to verify warnings are logged - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - // Test valid tool names - testServer.registerTool( - 'valid-tool-name', - { - description: 'A valid tool name' - }, - async () => ({ content: [{ type: 'text' as const, text: 'Success' }] }) - ); - - // Test tool name with warnings (starts with dash) - testServer.registerTool( - '-warning-tool', - { - description: 'A tool name that generates warnings' - }, - async () => ({ content: [{ type: 'text' as const, text: 'Success' }] }) - ); - - // Test invalid tool name (contains spaces) - testServer.registerTool( - 'invalid tool name', - { - description: 'An invalid tool name' - }, - async () => ({ content: [{ type: 'text' as const, text: 'Success' }] }) - ); - - // Verify that warnings were issued (both for warnings and validation failures) - expect(warnSpy).toHaveBeenCalled(); - - // Verify specific warning content - const warningCalls = warnSpy.mock.calls.map(call => call.join(' ')); - expect(warningCalls.some(call => call.includes('Tool name starts or ends with a dash'))).toBe(true); - expect(warningCalls.some(call => call.includes('Tool name contains spaces'))).toBe(true); - expect(warningCalls.some(call => call.includes('Tool name contains invalid characters'))).toBe(true); - - // Clean up spies - warnSpy.mockRestore(); - }); -}); + /*** + * Test: Update Resource Template + */ + test('should update resource template', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; -describe('resource()', () => { - /*** - * Test: Resource Registration with URI and Read Callback - */ - test('should register resource with uri and readCallback', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + // Register initial resource template + const resourceTemplate = mcpServer.resource( + 'test', + new ResourceTemplate('test://resource/{id}', { list: undefined }), + async uri => ({ + contents: [ + { + uri: uri.href, + text: 'Initial content' + } + ] + }) + ); - mcpServer.resource('test', 'test://resource', async () => ({ - contents: [ - { - uri: 'test://resource', - text: 'Test content' - } - ] - })); + // Update the resource template + resourceTemplate.update({ + callback: async uri => ({ + contents: [ + { + uri: uri.href, + text: 'Updated content' + } + ] + }) + }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - const result = await client.request( - { - method: 'resources/list' - }, - ListResourcesResultSchema - ); + // Read the resource and verify we get the updated content + const result = await client.request( + { + method: 'resources/read', + params: { + uri: 'test://resource/123' + } + }, + ReadResourceResultSchema + ); - expect(result.resources).toHaveLength(1); - expect(result.resources[0].name).toBe('test'); - expect(result.resources[0].uri).toBe('test://resource'); - }); + expect(result.contents).toHaveLength(1); + expect(result.contents).toEqual( + expect.arrayContaining([ + { + text: expect.stringContaining('Updated content'), + uri: 'test://resource/123' + } + ]) + ); - /*** - * Test: Update Resource with URI - */ - test('should update resource with uri', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + // Update happened before transport was connected, so no notifications should be expected + expect(notifications).toHaveLength(0); }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' - }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; - - // Register initial resource - const resource = mcpServer.resource('test', 'test://resource', async () => ({ - contents: [ - { - uri: 'test://resource', - text: 'Initial content' - } - ] - })); - // Update the resource - resource.update({ - callback: async () => ({ + /*** + * Test: Resource List Changed Notification + */ + test('should send resource list changed notification when connected', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + // Register initial resource + const resource = mcpServer.resource('test', 'test://resource', async () => ({ contents: [ { uri: 'test://resource', - text: 'Updated content' + text: 'Test content' } ] - }) - }); + })); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - // Read the resource and verify we get the updated content - const result = await client.request( - { - method: 'resources/read', - params: { - uri: 'test://resource' - } - }, - ReadResourceResultSchema - ); + expect(notifications).toHaveLength(0); - expect(result.contents).toHaveLength(1); - expect(result.contents).toEqual( - expect.arrayContaining([ - { - text: expect.stringContaining('Updated content'), - uri: 'test://resource' - } - ]) - ); + // Now update the resource while connected + resource.update({ + callback: async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Updated content' + } + ] + }) + }); - // Update happened before transport was connected, so no notifications should be expected - expect(notifications).toHaveLength(0); - }); + // Yield event loop to let the notification fly + await new Promise(process.nextTick); - /*** - * Test: Update Resource Template - */ - test('should update resource template', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + expect(notifications).toMatchObject([{ method: 'notifications/resources/list_changed' }]); }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' - }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; - - // Register initial resource template - const resourceTemplate = mcpServer.resource( - 'test', - new ResourceTemplate('test://resource/{id}', { list: undefined }), - async uri => ({ - contents: [ - { - uri: uri.href, - text: 'Initial content' - } - ] - }) - ); - // Update the resource template - resourceTemplate.update({ - callback: async uri => ({ - contents: [ - { - uri: uri.href, - text: 'Updated content' - } - ] - }) - }); + /*** + * Test: Remove Resource and Send Notification + */ + test('should remove resource and send notification when connected', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + // Register initial resources + const resource1 = mcpServer.resource('resource1', 'test://resource1', async () => ({ + contents: [{ uri: 'test://resource1', text: 'Resource 1 content' }] + })); - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + mcpServer.resource('resource2', 'test://resource2', async () => ({ + contents: [{ uri: 'test://resource2', text: 'Resource 2 content' }] + })); - // Read the resource and verify we get the updated content - const result = await client.request( - { - method: 'resources/read', - params: { - uri: 'test://resource/123' - } - }, - ReadResourceResultSchema - ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - expect(result.contents).toHaveLength(1); - expect(result.contents).toEqual( - expect.arrayContaining([ - { - text: expect.stringContaining('Updated content'), - uri: 'test://resource/123' - } - ]) - ); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - // Update happened before transport was connected, so no notifications should be expected - expect(notifications).toHaveLength(0); - }); + // Verify both resources are registered + let result = await client.request({ method: 'resources/list' }, ListResourcesResultSchema); - /*** - * Test: Resource List Changed Notification - */ - test('should send resource list changed notification when connected', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' - }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; + expect(result.resources).toHaveLength(2); - // Register initial resource - const resource = mcpServer.resource('test', 'test://resource', async () => ({ - contents: [ - { - uri: 'test://resource', - text: 'Test content' - } - ] - })); + expect(notifications).toHaveLength(0); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + // Remove a resource + resource1.remove(); - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + // Yield event loop to let the notification fly + await new Promise(process.nextTick); - expect(notifications).toHaveLength(0); + // Should have sent notification + expect(notifications).toMatchObject([{ method: 'notifications/resources/list_changed' }]); - // Now update the resource while connected - resource.update({ - callback: async () => ({ - contents: [ - { - uri: 'test://resource', - text: 'Updated content' - } - ] - }) + // Verify the resource was removed + result = await client.request({ method: 'resources/list' }, ListResourcesResultSchema); + + expect(result.resources).toHaveLength(1); + expect(result.resources[0].uri).toBe('test://resource2'); }); - // Yield event loop to let the notification fly - await new Promise(process.nextTick); + /*** + * Test: Remove Resource Template and Send Notification + */ + test('should remove resource template and send notification when connected', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; - expect(notifications).toMatchObject([{ method: 'notifications/resources/list_changed' }]); - }); + // Register resource template + const resourceTemplate = mcpServer.resource( + 'template', + new ResourceTemplate('test://resource/{id}', { list: undefined }), + async uri => ({ + contents: [ + { + uri: uri.href, + text: 'Template content' + } + ] + }) + ); - /*** - * Test: Remove Resource and Send Notification - */ - test('should remove resource and send notification when connected', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' - }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - // Register initial resources - const resource1 = mcpServer.resource('resource1', 'test://resource1', async () => ({ - contents: [{ uri: 'test://resource1', text: 'Resource 1 content' }] - })); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - mcpServer.resource('resource2', 'test://resource2', async () => ({ - contents: [{ uri: 'test://resource2', text: 'Resource 2 content' }] - })); + // Verify template is registered + const result = await client.request({ method: 'resources/templates/list' }, ListResourceTemplatesResultSchema); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + expect(result.resourceTemplates).toHaveLength(1); + expect(notifications).toHaveLength(0); - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + // Remove the template + resourceTemplate.remove(); - // Verify both resources are registered - let result = await client.request({ method: 'resources/list' }, ListResourcesResultSchema); + // Yield event loop to let the notification fly + await new Promise(process.nextTick); - expect(result.resources).toHaveLength(2); + // Should have sent notification + expect(notifications).toMatchObject([{ method: 'notifications/resources/list_changed' }]); - expect(notifications).toHaveLength(0); + // Verify the template was removed + const result2 = await client.request({ method: 'resources/templates/list' }, ListResourceTemplatesResultSchema); - // Remove a resource - resource1.remove(); + expect(result2.resourceTemplates).toHaveLength(0); + }); + + /*** + * Test: Resource Registration with Metadata + */ + test('should register resource with metadata', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - // Yield event loop to let the notification fly - await new Promise(process.nextTick); + mcpServer.resource( + 'test', + 'test://resource', + { + description: 'Test resource', + mimeType: 'text/plain' + }, + async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Test content' + } + ] + }) + ); - // Should have sent notification - expect(notifications).toMatchObject([{ method: 'notifications/resources/list_changed' }]); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - // Verify the resource was removed - result = await client.request({ method: 'resources/list' }, ListResourcesResultSchema); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - expect(result.resources).toHaveLength(1); - expect(result.resources[0].uri).toBe('test://resource2'); - }); + const result = await client.request( + { + method: 'resources/list' + }, + ListResourcesResultSchema + ); - /*** - * Test: Remove Resource Template and Send Notification - */ - test('should remove resource template and send notification when connected', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' + expect(result.resources).toHaveLength(1); + expect(result.resources[0].description).toBe('Test resource'); + expect(result.resources[0].mimeType).toBe('text/plain'); }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; - - // Register resource template - const resourceTemplate = mcpServer.resource( - 'template', - new ResourceTemplate('test://resource/{id}', { list: undefined }), - async uri => ({ + + /*** + * Test: Resource Template Registration + */ + test('should register resource template', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.resource('test', new ResourceTemplate('test://resource/{id}', { list: undefined }), async () => ({ contents: [ { - uri: uri.href, - text: 'Template content' + uri: 'test://resource/123', + text: 'Test content' } ] - }) - ); + })); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - // Verify template is registered - const result = await client.request({ method: 'resources/templates/list' }, ListResourceTemplatesResultSchema); + const result = await client.request( + { + method: 'resources/templates/list' + }, + ListResourceTemplatesResultSchema + ); - expect(result.resourceTemplates).toHaveLength(1); - expect(notifications).toHaveLength(0); + expect(result.resourceTemplates).toHaveLength(1); + expect(result.resourceTemplates[0].name).toBe('test'); + expect(result.resourceTemplates[0].uriTemplate).toBe('test://resource/{id}'); + }); - // Remove the template - resourceTemplate.remove(); + /*** + * Test: Resource Template with List Callback + */ + test('should register resource template with listCallback', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - // Yield event loop to let the notification fly - await new Promise(process.nextTick); + mcpServer.resource( + 'test', + new ResourceTemplate('test://resource/{id}', { + list: async () => ({ + resources: [ + { + name: 'Resource 1', + uri: 'test://resource/1' + }, + { + name: 'Resource 2', + uri: 'test://resource/2' + } + ] + }) + }), + async uri => ({ + contents: [ + { + uri: uri.href, + text: 'Test content' + } + ] + }) + ); - // Should have sent notification - expect(notifications).toMatchObject([{ method: 'notifications/resources/list_changed' }]); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - // Verify the template was removed - const result2 = await client.request({ method: 'resources/templates/list' }, ListResourceTemplatesResultSchema); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - expect(result2.resourceTemplates).toHaveLength(0); - }); + const result = await client.request( + { + method: 'resources/list' + }, + ListResourcesResultSchema + ); - /*** - * Test: Resource Registration with Metadata - */ - test('should register resource with metadata', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' + expect(result.resources).toHaveLength(2); + expect(result.resources[0].name).toBe('Resource 1'); + expect(result.resources[0].uri).toBe('test://resource/1'); + expect(result.resources[1].name).toBe('Resource 2'); + expect(result.resources[1].uri).toBe('test://resource/2'); }); - mcpServer.resource( - 'test', - 'test://resource', - { - description: 'Test resource', - mimeType: 'text/plain' - }, - async () => ({ - contents: [ - { - uri: 'test://resource', - text: 'Test content' - } - ] - }) - ); + /*** + * Test: Template Variables to Read Callback + */ + test('should pass template variables to readCallback', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + mcpServer.resource( + 'test', + new ResourceTemplate('test://resource/{category}/{id}', { + list: undefined + }), + async (uri, { category, id }) => ({ + contents: [ + { + uri: uri.href, + text: `Category: ${category}, ID: ${id}` + } + ] + }) + ); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - const result = await client.request( - { - method: 'resources/list' - }, - ListResourcesResultSchema - ); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - expect(result.resources).toHaveLength(1); - expect(result.resources[0].description).toBe('Test resource'); - expect(result.resources[0].mimeType).toBe('text/plain'); - }); + const result = await client.request( + { + method: 'resources/read', + params: { + uri: 'test://resource/books/123' + } + }, + ReadResourceResultSchema + ); - /*** - * Test: Resource Template Registration - */ - test('should register resource template', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' + expect(result.contents).toEqual( + expect.arrayContaining([ + { + text: expect.stringContaining('Category: books, ID: 123'), + uri: 'test://resource/books/123' + } + ]) + ); }); - mcpServer.resource('test', new ResourceTemplate('test://resource/{id}', { list: undefined }), async () => ({ - contents: [ - { - uri: 'test://resource/123', - text: 'Test content' - } - ] - })); + /*** + * Test: Preventing Duplicate Resource Registration + */ + test('should prevent duplicate resource registration', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + mcpServer.resource('test', 'test://resource', async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Test content' + } + ] + })); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + expect(() => { + mcpServer.resource('test2', 'test://resource', async () => ({ + contents: [ + { + uri: 'test://resource', + text: 'Test content 2' + } + ] + })); + }).toThrow(/already registered/); + }); - const result = await client.request( - { - method: 'resources/templates/list' - }, - ListResourceTemplatesResultSchema - ); + /*** + * Test: Multiple Resource Registration + */ + test('should allow registering multiple resources', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - expect(result.resourceTemplates).toHaveLength(1); - expect(result.resourceTemplates[0].name).toBe('test'); - expect(result.resourceTemplates[0].uriTemplate).toBe('test://resource/{id}'); - }); + // This should succeed + mcpServer.resource('resource1', 'test://resource1', async () => ({ + contents: [ + { + uri: 'test://resource1', + text: 'Test content 1' + } + ] + })); - /*** - * Test: Resource Template with List Callback - */ - test('should register resource template with listCallback', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' + // This should also succeed and not throw about request handlers + mcpServer.resource('resource2', 'test://resource2', async () => ({ + contents: [ + { + uri: 'test://resource2', + text: 'Test content 2' + } + ] + })); }); - mcpServer.resource( - 'test', - new ResourceTemplate('test://resource/{id}', { - list: async () => ({ - resources: [ - { - name: 'Resource 1', - uri: 'test://resource/1' - }, - { - name: 'Resource 2', - uri: 'test://resource/2' - } - ] - }) - }), - async uri => ({ + /*** + * Test: Preventing Duplicate Resource Template Registration + */ + test('should prevent duplicate resource template registration', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + mcpServer.resource('test', new ResourceTemplate('test://resource/{id}', { list: undefined }), async () => ({ contents: [ { - uri: uri.href, + uri: 'test://resource/123', text: 'Test content' } ] - }) - ); + })); + + expect(() => { + mcpServer.resource('test', new ResourceTemplate('test://resource/{id}', { list: undefined }), async () => ({ + contents: [ + { + uri: 'test://resource/123', + text: 'Test content 2' + } + ] + })); + }).toThrow(/already registered/); + }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + /*** + * Test: Graceful Resource Read Error Handling + */ + test('should handle resource read errors gracefully', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + mcpServer.resource('error-test', 'test://error', async () => { + throw new Error('Resource read failed'); + }); - const result = await client.request( - { - method: 'resources/list' - }, - ListResourcesResultSchema - ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - expect(result.resources).toHaveLength(2); - expect(result.resources[0].name).toBe('Resource 1'); - expect(result.resources[0].uri).toBe('test://resource/1'); - expect(result.resources[1].name).toBe('Resource 2'); - expect(result.resources[1].uri).toBe('test://resource/2'); - }); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - /*** - * Test: Template Variables to Read Callback - */ - test('should pass template variables to readCallback', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' + await expect( + client.request( + { + method: 'resources/read', + params: { + uri: 'test://error' + } + }, + ReadResourceResultSchema + ) + ).rejects.toThrow(/Resource read failed/); }); - mcpServer.resource( - 'test', - new ResourceTemplate('test://resource/{category}/{id}', { - list: undefined - }), - async (uri, { category, id }) => ({ + /*** + * Test: McpError for Invalid Resource URI + */ + test('should throw McpError for invalid resource URI', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.resource('test', 'test://resource', async () => ({ contents: [ { - uri: uri.href, - text: `Category: ${category}, ID: ${id}` + uri: 'test://resource', + text: 'Test content' } ] - }) - ); + })); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request( - { - method: 'resources/read', - params: { - uri: 'test://resource/books/123' - } - }, - ReadResourceResultSchema - ); + await expect( + client.request( + { + method: 'resources/read', + params: { + uri: 'test://nonexistent' + } + }, + ReadResourceResultSchema + ) + ).rejects.toThrow(/Resource test:\/\/nonexistent not found/); + }); - expect(result.contents).toEqual( - expect.arrayContaining([ - { - text: expect.stringContaining('Category: books, ID: 123'), - uri: 'test://resource/books/123' - } - ]) - ); - }); + /*** + * Test: Registering a resource template with a complete callback should update server capabilities to advertise support for completion + */ + test('should advertise support for completion when a resource template with a complete callback is defined', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.resource( + 'test', + new ResourceTemplate('test://resource/{category}', { + list: undefined, + complete: { + category: () => ['books', 'movies', 'music'] + } + }), + async () => ({ + contents: [ + { + uri: 'test://resource/test', + text: 'Test content' + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - /*** - * Test: Preventing Duplicate Resource Registration - */ - test('should prevent duplicate resource registration', () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + expect(client.getServerCapabilities()).toMatchObject({ completions: {} }); }); - mcpServer.resource('test', 'test://resource', async () => ({ - contents: [ - { - uri: 'test://resource', - text: 'Test content' - } - ] - })); + /*** + * Test: Resource Template Parameter Completion + */ + test('should support completion of resource template parameters', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - expect(() => { - mcpServer.resource('test2', 'test://resource', async () => ({ - contents: [ - { - uri: 'test://resource', - text: 'Test content 2' + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.resource( + 'test', + new ResourceTemplate('test://resource/{category}', { + list: undefined, + complete: { + category: () => ['books', 'movies', 'music'] } - ] - })); - }).toThrow(/already registered/); - }); + }), + async () => ({ + contents: [ + { + uri: 'test://resource/test', + text: 'Test content' + } + ] + }) + ); - /*** - * Test: Multiple Resource Registration - */ - test('should allow registering multiple resources', () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - // This should succeed - mcpServer.resource('resource1', 'test://resource1', async () => ({ - contents: [ - { - uri: 'test://resource1', - text: 'Test content 1' - } - ] - })); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - // This should also succeed and not throw about request handlers - mcpServer.resource('resource2', 'test://resource2', async () => ({ - contents: [ + const result = await client.request( { - uri: 'test://resource2', - text: 'Test content 2' - } - ] - })); - }); + method: 'completion/complete', + params: { + ref: { + type: 'ref/resource', + uri: 'test://resource/{category}' + }, + argument: { + name: 'category', + value: '' + } + } + }, + CompleteResultSchema + ); - /*** - * Test: Preventing Duplicate Resource Template Registration - */ - test('should prevent duplicate resource template registration', () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + expect(result.completion.values).toEqual(['books', 'movies', 'music']); + expect(result.completion.total).toBe(3); }); - mcpServer.resource('test', new ResourceTemplate('test://resource/{id}', { list: undefined }), async () => ({ - contents: [ - { - uri: 'test://resource/123', - text: 'Test content' - } - ] - })); - - expect(() => { - mcpServer.resource('test', new ResourceTemplate('test://resource/{id}', { list: undefined }), async () => ({ - contents: [ - { - uri: 'test://resource/123', - text: 'Test content 2' - } - ] - })); - }).toThrow(/already registered/); - }); + /*** + * Test: Filtered Resource Template Parameter Completion + */ + test('should support filtered completion of resource template parameters', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - /*** - * Test: Graceful Resource Read Error Handling - */ - test('should handle resource read errors gracefully', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - mcpServer.resource('error-test', 'test://error', async () => { - throw new Error('Resource read failed'); - }); + mcpServer.resource( + 'test', + new ResourceTemplate('test://resource/{category}', { + list: undefined, + complete: { + category: (test: string) => ['books', 'movies', 'music'].filter(value => value.startsWith(test)) + } + }), + async () => ({ + contents: [ + { + uri: 'test://resource/test', + text: 'Test content' + } + ] + }) + ); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - await expect( - client.request( + const result = await client.request( { - method: 'resources/read', + method: 'completion/complete', params: { - uri: 'test://error' + ref: { + type: 'ref/resource', + uri: 'test://resource/{category}' + }, + argument: { + name: 'category', + value: 'm' + } } }, - ReadResourceResultSchema - ) - ).rejects.toThrow(/Resource read failed/); - }); + CompleteResultSchema + ); - /*** - * Test: McpError for Invalid Resource URI - */ - test('should throw McpError for invalid resource URI', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' + expect(result.completion.values).toEqual(['movies', 'music']); + expect(result.completion.total).toBe(2); }); - mcpServer.resource('test', 'test://resource', async () => ({ - contents: [ - { - uri: 'test://resource', - text: 'Test content' - } - ] - })); + /*** + * Test: Pass Request ID to Resource Callback + */ + test('should pass requestId to resource callback via RequestHandlerExtra', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + let receivedRequestId: string | number | undefined; + mcpServer.resource('request-id-test', 'test://resource', async (_uri, extra) => { + receivedRequestId = extra.requestId; + return { + contents: [ + { + uri: 'test://resource', + text: `Received request ID: ${extra.requestId}` + } + ] + }; + }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - await expect( - client.request( + const result = await client.request( { method: 'resources/read', params: { - uri: 'test://nonexistent' + uri: 'test://resource' } }, ReadResourceResultSchema - ) - ).rejects.toThrow(/Resource test:\/\/nonexistent not found/); - }); - - /*** - * Test: Registering a resource template with a complete callback should update server capabilities to advertise support for completion - */ - test('should advertise support for completion when a resource template with a complete callback is defined', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + ); - mcpServer.resource( - 'test', - new ResourceTemplate('test://resource/{category}', { - list: undefined, - complete: { - category: () => ['books', 'movies', 'music'] - } - }), - async () => ({ - contents: [ + expect(receivedRequestId).toBeDefined(); + expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); + expect(result.contents).toEqual( + expect.arrayContaining([ { - uri: 'test://resource/test', - text: 'Test content' + text: expect.stringContaining(`Received request ID:`), + uri: 'test://resource' } - ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - expect(client.getServerCapabilities()).toMatchObject({ completions: {} }); - }); - - /*** - * Test: Resource Template Parameter Completion - */ - test('should support completion of resource template parameters', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + ]) + ); }); + }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + describe('prompt()', () => { + /*** + * Test: Zero-Argument Prompt Registration + */ + test('should register zero-argument prompt', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - mcpServer.resource( - 'test', - new ResourceTemplate('test://resource/{category}', { - list: undefined, - complete: { - category: () => ['books', 'movies', 'music'] - } - }), - async () => ({ - contents: [ + mcpServer.prompt('test', async () => ({ + messages: [ { - uri: 'test://resource/test', - text: 'Test content' + role: 'assistant', + content: { + type: 'text', + text: 'Test response' + } } ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request( - { - method: 'completion/complete', - params: { - ref: { - type: 'ref/resource', - uri: 'test://resource/{category}' - }, - argument: { - name: 'category', - value: '' - } - } - }, - CompleteResultSchema - ); + })); - expect(result.completion.values).toEqual(['books', 'movies', 'music']); - expect(result.completion.total).toBe(3); - }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - /*** - * Test: Filtered Resource Template Parameter Completion - */ - test('should support filtered completion of resource template parameters', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + const result = await client.request( + { + method: 'prompts/list' + }, + ListPromptsResultSchema + ); + + expect(result.prompts).toHaveLength(1); + expect(result.prompts[0].name).toBe('test'); + expect(result.prompts[0].arguments).toBeUndefined(); + }); + /*** + * Test: Updating Existing Prompt + */ + test('should update existing prompt', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; - mcpServer.resource( - 'test', - new ResourceTemplate('test://resource/{category}', { - list: undefined, - complete: { - category: (test: string) => ['books', 'movies', 'music'].filter(value => value.startsWith(test)) - } - }), - async () => ({ - contents: [ + // Register initial prompt + const prompt = mcpServer.prompt('test', async () => ({ + messages: [ { - uri: 'test://resource/test', - text: 'Test content' + role: 'assistant', + content: { + type: 'text', + text: 'Initial response' + } } ] - }) - ); + })); + + // Update the prompt + prompt.update({ + callback: async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Updated response' + } + } + ] + }) + }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - const result = await client.request( - { - method: 'completion/complete', - params: { - ref: { - type: 'ref/resource', - uri: 'test://resource/{category}' - }, - argument: { - name: 'category', - value: 'm' + // Call the prompt and verify we get the updated response + const result = await client.request( + { + method: 'prompts/get', + params: { + name: 'test' } - } - }, - CompleteResultSchema - ); - - expect(result.completion.values).toEqual(['movies', 'music']); - expect(result.completion.total).toBe(2); - }); + }, + GetPromptResultSchema + ); - /*** - * Test: Pass Request ID to Resource Callback - */ - test('should pass requestId to resource callback via RequestHandlerExtra', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); + expect(result.messages).toHaveLength(1); + expect(result.messages).toEqual( + expect.arrayContaining([ + { + role: 'assistant', + content: { + type: 'text', + text: 'Updated response' + } + } + ]) + ); - const client = new Client({ - name: 'test client', - version: '1.0' + // Update happened before transport was connected, so no notifications should be expected + expect(notifications).toHaveLength(0); }); - let receivedRequestId: string | number | undefined; - mcpServer.resource('request-id-test', 'test://resource', async (_uri, extra) => { - receivedRequestId = extra.requestId; - return { - contents: [ - { - uri: 'test://resource', - text: `Received request ID: ${extra.requestId}` - } - ] + /*** + * Test: Updating Prompt with Schema + */ + test('should update prompt with schema', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); }; - }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + // Register initial prompt + const prompt = mcpServer.prompt( + 'test', + { + name: z.string() + }, + async ({ name }) => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `Initial: ${name}` + } + } + ] + }) + ); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + // Update the prompt with a different schema + prompt.update({ + argsSchema: { + name: z.string(), + value: z.string() + }, + callback: async ({ name, value }) => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `Updated: ${name}, ${value}` + } + } + ] + }) + }); - const result = await client.request( - { - method: 'resources/read', - params: { - uri: 'test://resource' - } - }, - ReadResourceResultSchema - ); - - expect(receivedRequestId).toBeDefined(); - expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); - expect(result.contents).toEqual( - expect.arrayContaining([ - { - text: expect.stringContaining(`Received request ID:`), - uri: 'test://resource' - } - ]) - ); - }); -}); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); -describe('prompt()', () => { - /*** - * Test: Zero-Argument Prompt Registration - */ - test('should register zero-argument prompt', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - mcpServer.prompt('test', async () => ({ - messages: [ + // Verify the schema was updated + const listResult = await client.request( { - role: 'assistant', - content: { - type: 'text', - text: 'Test response' - } - } - ] - })); + method: 'prompts/list' + }, + ListPromptsResultSchema + ); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + expect(listResult.prompts[0].arguments).toHaveLength(2); + expect(listResult.prompts[0].arguments?.map(a => a.name).sort()).toEqual(['name', 'value']); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + // Call the prompt with the new schema + const getResult = await client.request( + { + method: 'prompts/get', + params: { + name: 'test', + arguments: { + name: 'test', + value: 'value' + } + } + }, + GetPromptResultSchema + ); - const result = await client.request( - { - method: 'prompts/list' - }, - ListPromptsResultSchema - ); + expect(getResult.messages).toHaveLength(1); + expect(getResult.messages).toEqual( + expect.arrayContaining([ + { + role: 'assistant', + content: { + type: 'text', + text: 'Updated: test, value' + } + } + ]) + ); - expect(result.prompts).toHaveLength(1); - expect(result.prompts[0].name).toBe('test'); - expect(result.prompts[0].arguments).toBeUndefined(); - }); - /*** - * Test: Updating Existing Prompt - */ - test('should update existing prompt', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + // Update happened before transport was connected, so no notifications should be expected + expect(notifications).toHaveLength(0); }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' - }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; - // Register initial prompt - const prompt = mcpServer.prompt('test', async () => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: 'Initial response' - } - } - ] - })); + /*** + * Test: Prompt List Changed Notification + */ + test('should send prompt list changed notification when connected', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; - // Update the prompt - prompt.update({ - callback: async () => ({ + // Register initial prompt + const prompt = mcpServer.prompt('test', async () => ({ messages: [ { role: 'assistant', content: { type: 'text', - text: 'Updated response' + text: 'Test response' } } ] - }) - }); + })); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - // Call the prompt and verify we get the updated response - const result = await client.request( - { - method: 'prompts/get', - params: { - name: 'test' - } - }, - GetPromptResultSchema - ); + expect(notifications).toHaveLength(0); - expect(result.messages).toHaveLength(1); - expect(result.messages).toEqual( - expect.arrayContaining([ - { - role: 'assistant', - content: { - type: 'text', - text: 'Updated response' - } - } - ]) - ); + // Now update the prompt while connected + prompt.update({ + callback: async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Updated response' + } + } + ] + }) + }); - // Update happened before transport was connected, so no notifications should be expected - expect(notifications).toHaveLength(0); - }); + // Yield event loop to let the notification fly + await new Promise(process.nextTick); - /*** - * Test: Updating Prompt with Schema - */ - test('should update prompt with schema', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' + expect(notifications).toMatchObject([{ method: 'notifications/prompts/list_changed' }]); }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; - - // Register initial prompt - const prompt = mcpServer.prompt( - 'test', - { - name: z.string() - }, - async ({ name }) => ({ + + /*** + * Test: Remove Prompt and Send Notification + */ + test('should remove prompt and send notification when connected', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const notifications: Notification[] = []; + const client = new Client({ + name: 'test client', + version: '1.0' + }); + client.fallbackNotificationHandler = async notification => { + notifications.push(notification); + }; + + // Register initial prompts + const prompt1 = mcpServer.prompt('prompt1', async () => ({ messages: [ { role: 'assistant', content: { type: 'text', - text: `Initial: ${name}` + text: 'Prompt 1 response' } } ] - }) - ); - - // Update the prompt with a different schema - prompt.update({ - argsSchema: { - name: z.string(), - value: z.string() - }, - callback: async ({ name, value }) => ({ + })); + + mcpServer.prompt('prompt2', async () => ({ messages: [ { role: 'assistant', content: { type: 'text', - text: `Updated: ${name}, ${value}` + text: 'Prompt 2 response' } } ] - }) - }); + })); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - // Verify the schema was updated - const listResult = await client.request( - { - method: 'prompts/list' - }, - ListPromptsResultSchema - ); + // Verify both prompts are registered + let result = await client.request({ method: 'prompts/list' }, ListPromptsResultSchema); - expect(listResult.prompts[0].arguments).toHaveLength(2); - expect(listResult.prompts[0].arguments?.map(a => a.name).sort()).toEqual(['name', 'value']); + expect(result.prompts).toHaveLength(2); + expect(result.prompts.map(p => p.name).sort()).toEqual(['prompt1', 'prompt2']); - // Call the prompt with the new schema - const getResult = await client.request( - { - method: 'prompts/get', - params: { - name: 'test', - arguments: { - name: 'test', - value: 'value' - } - } - }, - GetPromptResultSchema - ); + expect(notifications).toHaveLength(0); - expect(getResult.messages).toHaveLength(1); - expect(getResult.messages).toEqual( - expect.arrayContaining([ - { - role: 'assistant', - content: { - type: 'text', - text: 'Updated: test, value' - } - } - ]) - ); + // Remove a prompt + prompt1.remove(); - // Update happened before transport was connected, so no notifications should be expected - expect(notifications).toHaveLength(0); - }); + // Yield event loop to let the notification fly + await new Promise(process.nextTick); - /*** - * Test: Prompt List Changed Notification - */ - test('should send prompt list changed notification when connected', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' + // Should have sent notification + expect(notifications).toMatchObject([{ method: 'notifications/prompts/list_changed' }]); + + // Verify the prompt was removed + result = await client.request({ method: 'prompts/list' }, ListPromptsResultSchema); + + expect(result.prompts).toHaveLength(1); + expect(result.prompts[0].name).toBe('prompt2'); }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; - // Register initial prompt - const prompt = mcpServer.prompt('test', async () => ({ - messages: [ + /*** + * Test: Prompt Registration with Arguments Schema + */ + test('should register prompt with args schema', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.prompt( + 'test', { - role: 'assistant', - content: { - type: 'text', - text: 'Test response' - } - } - ] - })); + name: z.string(), + value: z.string() + }, + async ({ name, value }) => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `${name}: ${value}` + } + } + ] + }) + ); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - expect(notifications).toHaveLength(0); + const result = await client.request( + { + method: 'prompts/list' + }, + ListPromptsResultSchema + ); + + expect(result.prompts).toHaveLength(1); + expect(result.prompts[0].name).toBe('test'); + expect(result.prompts[0].arguments).toEqual([ + { name: 'name', required: true }, + { name: 'value', required: true } + ]); + }); + + /*** + * Test: Prompt Registration with Description + */ + test('should register prompt with description', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - // Now update the prompt while connected - prompt.update({ - callback: async () => ({ + mcpServer.prompt('test', 'Test description', async () => ({ messages: [ { role: 'assistant', content: { type: 'text', - text: 'Updated response' + text: 'Test response' } } ] - }) - }); - - // Yield event loop to let the notification fly - await new Promise(process.nextTick); - - expect(notifications).toMatchObject([{ method: 'notifications/prompts/list_changed' }]); - }); + })); - /*** - * Test: Remove Prompt and Send Notification - */ - test('should remove prompt and send notification when connected', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' - }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - // Register initial prompts - const prompt1 = mcpServer.prompt('prompt1', async () => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: 'Prompt 1 response' - } - } - ] - })); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - mcpServer.prompt('prompt2', async () => ({ - messages: [ + const result = await client.request( { - role: 'assistant', - content: { - type: 'text', - text: 'Prompt 2 response' - } - } - ] - })); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - - // Verify both prompts are registered - let result = await client.request({ method: 'prompts/list' }, ListPromptsResultSchema); - - expect(result.prompts).toHaveLength(2); - expect(result.prompts.map(p => p.name).sort()).toEqual(['prompt1', 'prompt2']); + method: 'prompts/list' + }, + ListPromptsResultSchema + ); - expect(notifications).toHaveLength(0); + expect(result.prompts).toHaveLength(1); + expect(result.prompts[0].name).toBe('test'); + expect(result.prompts[0].description).toBe('Test description'); + }); - // Remove a prompt - prompt1.remove(); + /*** + * Test: Prompt Argument Validation + */ + test('should validate prompt args', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - // Yield event loop to let the notification fly - await new Promise(process.nextTick); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - // Should have sent notification - expect(notifications).toMatchObject([{ method: 'notifications/prompts/list_changed' }]); + mcpServer.prompt( + 'test', + { + name: z.string(), + value: z.string().min(3) + }, + async ({ name, value }) => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `${name}: ${value}` + } + } + ] + }) + ); - // Verify the prompt was removed - result = await client.request({ method: 'prompts/list' }, ListPromptsResultSchema); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - expect(result.prompts).toHaveLength(1); - expect(result.prompts[0].name).toBe('prompt2'); - }); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - /*** - * Test: Prompt Registration with Arguments Schema - */ - test('should register prompt with args schema', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' + await expect( + client.request( + { + method: 'prompts/get', + params: { + name: 'test', + arguments: { + name: 'test', + value: 'ab' // Too short + } + } + }, + GetPromptResultSchema + ) + ).rejects.toThrow(/Invalid arguments/); }); - mcpServer.prompt( - 'test', - { - name: z.string(), - value: z.string() - }, - async ({ name, value }) => ({ + /*** + * Test: Preventing Duplicate Prompt Registration + */ + test('should prevent duplicate prompt registration', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + mcpServer.prompt('test', async () => ({ messages: [ { role: 'assistant', content: { type: 'text', - text: `${name}: ${value}` + text: 'Test response' } } ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request( - { - method: 'prompts/list' - }, - ListPromptsResultSchema - ); - - expect(result.prompts).toHaveLength(1); - expect(result.prompts[0].name).toBe('test'); - expect(result.prompts[0].arguments).toEqual([ - { name: 'name', required: true }, - { name: 'value', required: true } - ]); - }); + })); - /*** - * Test: Prompt Registration with Description - */ - test('should register prompt with description', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' + expect(() => { + mcpServer.prompt('test', async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Test response 2' + } + } + ] + })); + }).toThrow(/already registered/); }); - mcpServer.prompt('test', 'Test description', async () => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: 'Test response' - } - } - ] - })); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request( - { - method: 'prompts/list' - }, - ListPromptsResultSchema - ); + /*** + * Test: Multiple Prompt Registration + */ + test('should allow registering multiple prompts', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - expect(result.prompts).toHaveLength(1); - expect(result.prompts[0].name).toBe('test'); - expect(result.prompts[0].description).toBe('Test description'); - }); + // This should succeed + mcpServer.prompt('prompt1', async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Test response 1' + } + } + ] + })); - /*** - * Test: Prompt Argument Validation - */ - test('should validate prompt args', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + // This should also succeed and not throw about request handlers + mcpServer.prompt('prompt2', async () => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: 'Test response 2' + } + } + ] + })); }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + /*** + * Test: Prompt Registration with Arguments + */ + test('should allow registering prompts with arguments', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - mcpServer.prompt( - 'test', - { - name: z.string(), - value: z.string().min(3) - }, - async ({ name, value }) => ({ + // This should succeed + mcpServer.prompt('echo', { message: z.string() }, ({ message }) => ({ messages: [ { - role: 'assistant', + role: 'user', content: { type: 'text', - text: `${name}: ${value}` + text: `Please process this message: ${message}` } } ] - }) - ); + })); + }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + /*** + * Test: Resources and Prompts with Completion Handlers + */ + test('should allow registering both resources and prompts with completion handlers', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + // Register a resource with completion + mcpServer.resource( + 'test', + new ResourceTemplate('test://resource/{category}', { + list: undefined, + complete: { + category: () => ['books', 'movies', 'music'] + } + }), + async () => ({ + contents: [ + { + uri: 'test://resource/test', + text: 'Test content' + } + ] + }) + ); - await expect( - client.request( - { - method: 'prompts/get', - params: { - name: 'test', - arguments: { - name: 'test', - value: 'ab' // Too short + // Register a prompt with completion + mcpServer.prompt('echo', { message: completable(z.string(), () => ['hello', 'world']) }, ({ message }) => ({ + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `Please process this message: ${message}` } } - }, - GetPromptResultSchema - ) - ).rejects.toThrow(/Invalid arguments/); - }); - - /*** - * Test: Preventing Duplicate Prompt Registration - */ - test('should prevent duplicate prompt registration', () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + ] + })); }); - mcpServer.prompt('test', async () => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: 'Test response' - } - } - ] - })); + /*** + * Test: McpError for Invalid Prompt Name + */ + test('should throw McpError for invalid prompt name', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - expect(() => { - mcpServer.prompt('test', async () => ({ + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.prompt('test-prompt', async () => ({ messages: [ { role: 'assistant', content: { type: 'text', - text: 'Test response 2' + text: 'Test response' } } ] })); - }).toThrow(/already registered/); - }); - /*** - * Test: Multiple Prompt Registration - */ - test('should allow registering multiple prompts', () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + await expect( + client.request( + { + method: 'prompts/get', + params: { + name: 'nonexistent-prompt' + } + }, + GetPromptResultSchema + ) + ).rejects.toThrow(/Prompt nonexistent-prompt not found/); + }); + + /*** + * Test: Registering a prompt with a completable argument should update server capabilities to advertise support for completion + */ + test('should advertise support for completion when a prompt with a completable argument is defined', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.prompt( + 'test-prompt', + { + name: completable(z.string(), () => ['Alice', 'Bob', 'Charlie']) + }, + async ({ name }) => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `Hello ${name}` + } + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + expect(client.getServerCapabilities()).toMatchObject({ completions: {} }); }); - // This should succeed - mcpServer.prompt('prompt1', async () => ({ - messages: [ + /*** + * Test: Prompt Argument Completion + */ + test('should support completion of prompt arguments', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.prompt( + 'test-prompt', { - role: 'assistant', - content: { - type: 'text', - text: 'Test response 1' - } - } - ] - })); + name: completable(z.string(), () => ['Alice', 'Bob', 'Charlie']) + }, + async ({ name }) => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `Hello ${name}` + } + } + ] + }) + ); - // This should also succeed and not throw about request handlers - mcpServer.prompt('prompt2', async () => ({ - messages: [ + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request( { - role: 'assistant', - content: { - type: 'text', - text: 'Test response 2' + method: 'completion/complete', + params: { + ref: { + type: 'ref/prompt', + name: 'test-prompt' + }, + argument: { + name: 'name', + value: '' + } } - } - ] - })); - }); + }, + CompleteResultSchema + ); - /*** - * Test: Prompt Registration with Arguments - */ - test('should allow registering prompts with arguments', () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + expect(result.completion.values).toEqual(['Alice', 'Bob', 'Charlie']); + expect(result.completion.total).toBe(3); }); - // This should succeed - mcpServer.prompt('echo', { message: z.string() }, ({ message }) => ({ - messages: [ + /*** + * Test: Filtered Prompt Argument Completion + */ + test('should support filtered completion of prompt arguments', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.prompt( + 'test-prompt', { - role: 'user', - content: { - type: 'text', - text: `Please process this message: ${message}` - } - } - ] - })); - }); + name: completable(z.string(), test => ['Alice', 'Bob', 'Charlie'].filter(value => value.startsWith(test))) + }, + async ({ name }) => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `Hello ${name}` + } + } + ] + }) + ); - /*** - * Test: Resources and Prompts with Completion Handlers - */ - test('should allow registering both resources and prompts with completion handlers', () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - // Register a resource with completion - mcpServer.resource( - 'test', - new ResourceTemplate('test://resource/{category}', { - list: undefined, - complete: { - category: () => ['books', 'movies', 'music'] - } - }), - async () => ({ - contents: [ - { - uri: 'test://resource/test', - text: 'Test content' - } - ] - }) - ); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - // Register a prompt with completion - mcpServer.prompt('echo', { message: completable(z.string(), () => ['hello', 'world']) }, ({ message }) => ({ - messages: [ + const result = await client.request( { - role: 'user', - content: { - type: 'text', - text: `Please process this message: ${message}` + method: 'completion/complete', + params: { + ref: { + type: 'ref/prompt', + name: 'test-prompt' + }, + argument: { + name: 'name', + value: 'A' + } } - } - ] - })); - }); + }, + CompleteResultSchema + ); - /*** - * Test: McpError for Invalid Prompt Name - */ - test('should throw McpError for invalid prompt name', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + expect(result.completion.values).toEqual(['Alice']); + expect(result.completion.total).toBe(1); }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + /*** + * Test: Pass Request ID to Prompt Callback + */ + test('should pass requestId to prompt callback via RequestHandlerExtra', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - mcpServer.prompt('test-prompt', async () => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: 'Test response' - } - } - ] - })); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + let receivedRequestId: string | number | undefined; + mcpServer.prompt('request-id-test', async extra => { + receivedRequestId = extra.requestId; + return { + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `Received request ID: ${extra.requestId}` + } + } + ] + }; + }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - await expect( - client.request( + const result = await client.request( { method: 'prompts/get', params: { - name: 'nonexistent-prompt' + name: 'request-id-test' } }, GetPromptResultSchema - ) - ).rejects.toThrow(/Prompt nonexistent-prompt not found/); - }); + ); - /*** - * Test: Registering a prompt with a completable argument should update server capabilities to advertise support for completion - */ - test('should advertise support for completion when a prompt with a completable argument is defined', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - mcpServer.prompt( - 'test-prompt', - { - name: completable(z.string(), () => ['Alice', 'Bob', 'Charlie']) - }, - async ({ name }) => ({ - messages: [ + expect(receivedRequestId).toBeDefined(); + expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); + expect(result.messages).toEqual( + expect.arrayContaining([ { role: 'assistant', content: { type: 'text', - text: `Hello ${name}` + text: expect.stringContaining(`Received request ID:`) } } - ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - expect(client.getServerCapabilities()).toMatchObject({ completions: {} }); - }); - - /*** - * Test: Prompt Argument Completion - */ - test('should support completion of prompt arguments', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + ]) + ); }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + /*** + * Test: Resource Template Metadata Priority + */ + test('should prioritize individual resource metadata over template metadata', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - mcpServer.prompt( - 'test-prompt', - { - name: completable(z.string(), () => ['Alice', 'Bob', 'Charlie']) - }, - async ({ name }) => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: `Hello ${name}` + mcpServer.resource( + 'test', + new ResourceTemplate('test://resource/{id}', { + list: async () => ({ + resources: [ + { + name: 'Resource 1', + uri: 'test://resource/1', + description: 'Individual resource description', + mimeType: 'text/plain' + }, + { + name: 'Resource 2', + uri: 'test://resource/2' + // This resource has no description or mimeType + } + ] + }) + }), + { + description: 'Template description', + mimeType: 'application/json' + }, + async uri => ({ + contents: [ + { + uri: uri.href, + text: 'Test content' } - } - ] - }) - ); + ] + }) + ); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request( - { - method: 'completion/complete', - params: { - ref: { - type: 'ref/prompt', - name: 'test-prompt' - }, - argument: { - name: 'name', - value: '' - } - } - }, - CompleteResultSchema - ); + const result = await client.request( + { + method: 'resources/list' + }, + ListResourcesResultSchema + ); - expect(result.completion.values).toEqual(['Alice', 'Bob', 'Charlie']); - expect(result.completion.total).toBe(3); - }); + expect(result.resources).toHaveLength(2); - /*** - * Test: Filtered Prompt Argument Completion - */ - test('should support filtered completion of prompt arguments', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); + // Resource 1 should have its own metadata + expect(result.resources[0].name).toBe('Resource 1'); + expect(result.resources[0].description).toBe('Individual resource description'); + expect(result.resources[0].mimeType).toBe('text/plain'); - const client = new Client({ - name: 'test client', - version: '1.0' + // Resource 2 should inherit template metadata + expect(result.resources[1].name).toBe('Resource 2'); + expect(result.resources[1].description).toBe('Template description'); + expect(result.resources[1].mimeType).toBe('application/json'); }); - mcpServer.prompt( - 'test-prompt', - { - name: completable(z.string(), test => ['Alice', 'Bob', 'Charlie'].filter(value => value.startsWith(test))) - }, - async ({ name }) => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: `Hello ${name}` + /*** + * Test: Resource Template Metadata Overrides All Fields + */ + test('should allow resource to override all template metadata fields', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.resource( + 'test', + new ResourceTemplate('test://resource/{id}', { + list: async () => ({ + resources: [ + { + name: 'Overridden Name', + uri: 'test://resource/1', + description: 'Overridden description', + mimeType: 'text/markdown' + // Add any other metadata fields if they exist + } + ] + }) + }), + { + title: 'Template Name', + description: 'Template description', + mimeType: 'application/json' + }, + async uri => ({ + contents: [ + { + uri: uri.href, + text: 'Test content' } - } - ] - }) - ); + ] + }) + ); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request( - { - method: 'completion/complete', - params: { - ref: { - type: 'ref/prompt', - name: 'test-prompt' - }, - argument: { - name: 'name', - value: 'A' - } - } - }, - CompleteResultSchema - ); + const result = await client.request( + { + method: 'resources/list' + }, + ListResourcesResultSchema + ); - expect(result.completion.values).toEqual(['Alice']); - expect(result.completion.total).toBe(1); - }); + expect(result.resources).toHaveLength(1); - /*** - * Test: Pass Request ID to Prompt Callback - */ - test('should pass requestId to prompt callback via RequestHandlerExtra', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' + // All fields should be from the individual resource, not the template + expect(result.resources[0].name).toBe('Overridden Name'); + expect(result.resources[0].description).toBe('Overridden description'); + expect(result.resources[0].mimeType).toBe('text/markdown'); }); + }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + describe('Tool title precedence', () => { + test('should follow correct title precedence: title → annotations.title → name', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - let receivedRequestId: string | number | undefined; - mcpServer.prompt('request-id-test', async extra => { - receivedRequestId = extra.requestId; - return { - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: `Received request ID: ${extra.requestId}` - } - } - ] - }; - }); + // Tool 1: Only name + mcpServer.tool('tool_name_only', async () => ({ + content: [{ type: 'text', text: 'Response' }] + })); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + // Tool 2: Name and annotations.title + mcpServer.tool( + 'tool_with_annotations_title', + 'Tool with annotations title', + { + title: 'Annotations Title' + }, + async () => ({ + content: [{ type: 'text', text: 'Response' }] + }) + ); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + // Tool 3: Name and title (using registerTool) + mcpServer.registerTool( + 'tool_with_title', + { + title: 'Regular Title', + description: 'Tool with regular title' + }, + async () => ({ + content: [{ type: 'text' as const, text: 'Response' }] + }) + ); - const result = await client.request( - { - method: 'prompts/get', - params: { - name: 'request-id-test' - } - }, - GetPromptResultSchema - ); - - expect(receivedRequestId).toBeDefined(); - expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); - expect(result.messages).toEqual( - expect.arrayContaining([ + // Tool 4: All three - title should win + mcpServer.registerTool( + 'tool_with_all_titles', { - role: 'assistant', - content: { - type: 'text', - text: expect.stringContaining(`Received request ID:`) + title: 'Regular Title Wins', + description: 'Tool with all titles', + annotations: { + title: 'Annotations Title Should Not Show' } - } - ]) - ); - }); - - /*** - * Test: Resource Template Metadata Priority - */ - test('should prioritize individual resource metadata over template metadata', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - mcpServer.resource( - 'test', - new ResourceTemplate('test://resource/{id}', { - list: async () => ({ - resources: [ - { - name: 'Resource 1', - uri: 'test://resource/1', - description: 'Individual resource description', - mimeType: 'text/plain' - }, - { - name: 'Resource 2', - uri: 'test://resource/2' - // This resource has no description or mimeType - } - ] + }, + async () => ({ + content: [{ type: 'text' as const, text: 'Response' }] }) - }), - { - description: 'Template description', - mimeType: 'application/json' - }, - async uri => ({ - contents: [ - { - uri: uri.href, - text: 'Test content' - } - ] - }) - ); + ); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); - const result = await client.request( - { - method: 'resources/list' - }, - ListResourcesResultSchema - ); + expect(result.tools).toHaveLength(4); - expect(result.resources).toHaveLength(2); + // Tool 1: Only name - should display name + const tool1 = result.tools.find(t => t.name === 'tool_name_only'); + expect(tool1).toBeDefined(); + expect(getDisplayName(tool1!)).toBe('tool_name_only'); - // Resource 1 should have its own metadata - expect(result.resources[0].name).toBe('Resource 1'); - expect(result.resources[0].description).toBe('Individual resource description'); - expect(result.resources[0].mimeType).toBe('text/plain'); + // Tool 2: Name and annotations.title - should display annotations.title + const tool2 = result.tools.find(t => t.name === 'tool_with_annotations_title'); + expect(tool2).toBeDefined(); + expect(tool2!.annotations?.title).toBe('Annotations Title'); + expect(getDisplayName(tool2!)).toBe('Annotations Title'); - // Resource 2 should inherit template metadata - expect(result.resources[1].name).toBe('Resource 2'); - expect(result.resources[1].description).toBe('Template description'); - expect(result.resources[1].mimeType).toBe('application/json'); - }); + // Tool 3: Name and title - should display title + const tool3 = result.tools.find(t => t.name === 'tool_with_title'); + expect(tool3).toBeDefined(); + expect(tool3!.title).toBe('Regular Title'); + expect(getDisplayName(tool3!)).toBe('Regular Title'); - /*** - * Test: Resource Template Metadata Overrides All Fields - */ - test('should allow resource to override all template metadata fields', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' + // Tool 4: All three - title should take precedence + const tool4 = result.tools.find(t => t.name === 'tool_with_all_titles'); + expect(tool4).toBeDefined(); + expect(tool4!.title).toBe('Regular Title Wins'); + expect(tool4!.annotations?.title).toBe('Annotations Title Should Not Show'); + expect(getDisplayName(tool4!)).toBe('Regular Title Wins'); }); - mcpServer.resource( - 'test', - new ResourceTemplate('test://resource/{id}', { - list: async () => ({ - resources: [ - { - name: 'Overridden Name', - uri: 'test://resource/1', - description: 'Overridden description', - mimeType: 'text/markdown' - // Add any other metadata fields if they exist - } - ] + test('getDisplayName unit tests for title precedence', () => { + // Test 1: Only name + expect(getDisplayName({ name: 'tool_name' })).toBe('tool_name'); + + // Test 2: Name and title - title wins + expect( + getDisplayName({ + name: 'tool_name', + title: 'Tool Title' }) - }), - { - title: 'Template Name', - description: 'Template description', - mimeType: 'application/json' - }, - async uri => ({ - contents: [ - { - uri: uri.href, - text: 'Test content' - } - ] - }) - ); + ).toBe('Tool Title'); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + // Test 3: Name and annotations.title - annotations.title wins + expect( + getDisplayName({ + name: 'tool_name', + annotations: { title: 'Annotations Title' } + }) + ).toBe('Annotations Title'); + + // Test 4: All three - title wins (correct precedence) + expect( + getDisplayName({ + name: 'tool_name', + title: 'Regular Title', + annotations: { title: 'Annotations Title' } + }) + ).toBe('Regular Title'); + + // Test 5: Empty title should not be used + expect( + getDisplayName({ + name: 'tool_name', + title: '', + annotations: { title: 'Annotations Title' } + }) + ).toBe('Annotations Title'); + + // Test 6: Undefined vs null handling + expect( + getDisplayName({ + name: 'tool_name', + title: undefined, + annotations: { title: 'Annotations Title' } + }) + ).toBe('Annotations Title'); + }); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + test('should support resource template completion with resolved context', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - const result = await client.request( - { - method: 'resources/list' - }, - ListResourcesResultSchema - ); + const client = new Client({ + name: 'test client', + version: '1.0' + }); - expect(result.resources).toHaveLength(1); + mcpServer.registerResource( + 'test', + new ResourceTemplate('github://repos/{owner}/{repo}', { + list: undefined, + complete: { + repo: (value, context) => { + if (context?.arguments?.['owner'] === 'org1') { + return ['project1', 'project2', 'project3'].filter(r => r.startsWith(value)); + } else if (context?.arguments?.['owner'] === 'org2') { + return ['repo1', 'repo2', 'repo3'].filter(r => r.startsWith(value)); + } + return []; + } + } + }), + { + title: 'GitHub Repository', + description: 'Repository information' + }, + async () => ({ + contents: [ + { + uri: 'github://repos/test/test', + text: 'Test content' + } + ] + }) + ); - // All fields should be from the individual resource, not the template - expect(result.resources[0].name).toBe('Overridden Name'); - expect(result.resources[0].description).toBe('Overridden description'); - expect(result.resources[0].mimeType).toBe('text/markdown'); - }); -}); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); -describe('Tool title precedence', () => { - test('should follow correct title precedence: title → annotations.title → name', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - // Tool 1: Only name - mcpServer.tool('tool_name_only', async () => ({ - content: [{ type: 'text', text: 'Response' }] - })); - - // Tool 2: Name and annotations.title - mcpServer.tool( - 'tool_with_annotations_title', - 'Tool with annotations title', - { - title: 'Annotations Title' - }, - async () => ({ - content: [{ type: 'text', text: 'Response' }] - }) - ); - - // Tool 3: Name and title (using registerTool) - mcpServer.registerTool( - 'tool_with_title', - { - title: 'Regular Title', - description: 'Tool with regular title' - }, - async () => ({ - content: [{ type: 'text' as const, text: 'Response' }] - }) - ); - - // Tool 4: All three - title should win - mcpServer.registerTool( - 'tool_with_all_titles', - { - title: 'Regular Title Wins', - description: 'Tool with all titles', - annotations: { - title: 'Annotations Title Should Not Show' - } - }, - async () => ({ - content: [{ type: 'text' as const, text: 'Response' }] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - - const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); - - expect(result.tools).toHaveLength(4); - - // Tool 1: Only name - should display name - const tool1 = result.tools.find(t => t.name === 'tool_name_only'); - expect(tool1).toBeDefined(); - expect(getDisplayName(tool1!)).toBe('tool_name_only'); - - // Tool 2: Name and annotations.title - should display annotations.title - const tool2 = result.tools.find(t => t.name === 'tool_with_annotations_title'); - expect(tool2).toBeDefined(); - expect(tool2!.annotations?.title).toBe('Annotations Title'); - expect(getDisplayName(tool2!)).toBe('Annotations Title'); - - // Tool 3: Name and title - should display title - const tool3 = result.tools.find(t => t.name === 'tool_with_title'); - expect(tool3).toBeDefined(); - expect(tool3!.title).toBe('Regular Title'); - expect(getDisplayName(tool3!)).toBe('Regular Title'); - - // Tool 4: All three - title should take precedence - const tool4 = result.tools.find(t => t.name === 'tool_with_all_titles'); - expect(tool4).toBeDefined(); - expect(tool4!.title).toBe('Regular Title Wins'); - expect(tool4!.annotations?.title).toBe('Annotations Title Should Not Show'); - expect(getDisplayName(tool4!)).toBe('Regular Title Wins'); - }); + // Test with microsoft owner + const result1 = await client.request( + { + method: 'completion/complete', + params: { + ref: { + type: 'ref/resource', + uri: 'github://repos/{owner}/{repo}' + }, + argument: { + name: 'repo', + value: 'p' + }, + context: { + arguments: { + owner: 'org1' + } + } + } + }, + CompleteResultSchema + ); - test('getDisplayName unit tests for title precedence', () => { - // Test 1: Only name - expect(getDisplayName({ name: 'tool_name' })).toBe('tool_name'); - - // Test 2: Name and title - title wins - expect( - getDisplayName({ - name: 'tool_name', - title: 'Tool Title' - }) - ).toBe('Tool Title'); - - // Test 3: Name and annotations.title - annotations.title wins - expect( - getDisplayName({ - name: 'tool_name', - annotations: { title: 'Annotations Title' } - }) - ).toBe('Annotations Title'); - - // Test 4: All three - title wins (correct precedence) - expect( - getDisplayName({ - name: 'tool_name', - title: 'Regular Title', - annotations: { title: 'Annotations Title' } - }) - ).toBe('Regular Title'); - - // Test 5: Empty title should not be used - expect( - getDisplayName({ - name: 'tool_name', - title: '', - annotations: { title: 'Annotations Title' } - }) - ).toBe('Annotations Title'); - - // Test 6: Undefined vs null handling - expect( - getDisplayName({ - name: 'tool_name', - title: undefined, - annotations: { title: 'Annotations Title' } - }) - ).toBe('Annotations Title'); - }); + expect(result1.completion.values).toEqual(['project1', 'project2', 'project3']); + expect(result1.completion.total).toBe(3); - test('should support resource template completion with resolved context', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); + // Test with facebook owner + const result2 = await client.request( + { + method: 'completion/complete', + params: { + ref: { + type: 'ref/resource', + uri: 'github://repos/{owner}/{repo}' + }, + argument: { + name: 'repo', + value: 'r' + }, + context: { + arguments: { + owner: 'org2' + } + } + } + }, + CompleteResultSchema + ); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + expect(result2.completion.values).toEqual(['repo1', 'repo2', 'repo3']); + expect(result2.completion.total).toBe(3); - mcpServer.registerResource( - 'test', - new ResourceTemplate('github://repos/{owner}/{repo}', { - list: undefined, - complete: { - repo: (value, context) => { - if (context?.arguments?.['owner'] === 'org1') { - return ['project1', 'project2', 'project3'].filter(r => r.startsWith(value)); - } else if (context?.arguments?.['owner'] === 'org2') { - return ['repo1', 'repo2', 'repo3'].filter(r => r.startsWith(value)); + // Test with no resolved context + const result3 = await client.request( + { + method: 'completion/complete', + params: { + ref: { + type: 'ref/resource', + uri: 'github://repos/{owner}/{repo}' + }, + argument: { + name: 'repo', + value: 't' } - return []; - } - } - }), - { - title: 'GitHub Repository', - description: 'Repository information' - }, - async () => ({ - contents: [ - { - uri: 'github://repos/test/test', - text: 'Test content' } - ] - }) - ); + }, + CompleteResultSchema + ); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + expect(result3.completion.values).toEqual([]); + expect(result3.completion.total).toBe(0); + }); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + test('should support prompt argument completion with resolved context', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); - // Test with microsoft owner - const result1 = await client.request( - { - method: 'completion/complete', - params: { - ref: { - type: 'ref/resource', - uri: 'github://repos/{owner}/{repo}' - }, - argument: { - name: 'repo', - value: 'p' - }, - context: { - arguments: { - owner: 'org1' - } + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerPrompt( + 'test-prompt', + { + title: 'Team Greeting', + description: 'Generate a greeting for team members', + argsSchema: { + department: completable(z.string(), value => { + return ['engineering', 'sales', 'marketing', 'support'].filter(d => d.startsWith(value)); + }), + name: completable(z.string(), (value, context) => { + const department = context?.arguments?.['department']; + if (department === 'engineering') { + return ['Alice', 'Bob', 'Charlie'].filter(n => n.startsWith(value)); + } else if (department === 'sales') { + return ['David', 'Eve', 'Frank'].filter(n => n.startsWith(value)); + } else if (department === 'marketing') { + return ['Grace', 'Henry', 'Iris'].filter(n => n.startsWith(value)); + } + return ['Guest'].filter(n => n.startsWith(value)); + }) } - } - }, - CompleteResultSchema - ); - - expect(result1.completion.values).toEqual(['project1', 'project2', 'project3']); - expect(result1.completion.total).toBe(3); - - // Test with facebook owner - const result2 = await client.request( - { - method: 'completion/complete', - params: { - ref: { - type: 'ref/resource', - uri: 'github://repos/{owner}/{repo}' - }, - argument: { - name: 'repo', - value: 'r' - }, - context: { - arguments: { - owner: 'org2' + }, + async ({ department, name }) => ({ + messages: [ + { + role: 'assistant', + content: { + type: 'text', + text: `Hello ${name}, welcome to the ${department} team!` + } } - } - } - }, - CompleteResultSchema - ); - - expect(result2.completion.values).toEqual(['repo1', 'repo2', 'repo3']); - expect(result2.completion.total).toBe(3); - - // Test with no resolved context - const result3 = await client.request( - { - method: 'completion/complete', - params: { - ref: { - type: 'ref/resource', - uri: 'github://repos/{owner}/{repo}' - }, - argument: { - name: 'repo', - value: 't' - } - } - }, - CompleteResultSchema - ); - - expect(result3.completion.values).toEqual([]); - expect(result3.completion.total).toBe(0); - }); + ] + }) + ); - test('should support prompt argument completion with resolved context', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - const client = new Client({ - name: 'test client', - version: '1.0' - }); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - mcpServer.registerPrompt( - 'test-prompt', - { - title: 'Team Greeting', - description: 'Generate a greeting for team members', - argsSchema: { - department: completable(z.string(), value => { - return ['engineering', 'sales', 'marketing', 'support'].filter(d => d.startsWith(value)); - }), - name: completable(z.string(), (value, context) => { - const department = context?.arguments?.['department']; - if (department === 'engineering') { - return ['Alice', 'Bob', 'Charlie'].filter(n => n.startsWith(value)); - } else if (department === 'sales') { - return ['David', 'Eve', 'Frank'].filter(n => n.startsWith(value)); - } else if (department === 'marketing') { - return ['Grace', 'Henry', 'Iris'].filter(n => n.startsWith(value)); - } - return ['Guest'].filter(n => n.startsWith(value)); - }) - } - }, - async ({ department, name }) => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: `Hello ${name}, welcome to the ${department} team!` + // Test with engineering department + const result1 = await client.request( + { + method: 'completion/complete', + params: { + ref: { + type: 'ref/prompt', + name: 'test-prompt' + }, + argument: { + name: 'name', + value: 'A' + }, + context: { + arguments: { + department: 'engineering' + } } } - ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + }, + CompleteResultSchema + ); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + expect(result1.completion.values).toEqual(['Alice']); - // Test with engineering department - const result1 = await client.request( - { - method: 'completion/complete', - params: { - ref: { - type: 'ref/prompt', - name: 'test-prompt' - }, - argument: { - name: 'name', - value: 'A' - }, - context: { - arguments: { - department: 'engineering' + // Test with sales department + const result2 = await client.request( + { + method: 'completion/complete', + params: { + ref: { + type: 'ref/prompt', + name: 'test-prompt' + }, + argument: { + name: 'name', + value: 'D' + }, + context: { + arguments: { + department: 'sales' + } } } - } - }, - CompleteResultSchema - ); - - expect(result1.completion.values).toEqual(['Alice']); - - // Test with sales department - const result2 = await client.request( - { - method: 'completion/complete', - params: { - ref: { - type: 'ref/prompt', - name: 'test-prompt' - }, - argument: { - name: 'name', - value: 'D' - }, - context: { - arguments: { - department: 'sales' + }, + CompleteResultSchema + ); + + expect(result2.completion.values).toEqual(['David']); + + // Test with marketing department + const result3 = await client.request( + { + method: 'completion/complete', + params: { + ref: { + type: 'ref/prompt', + name: 'test-prompt' + }, + argument: { + name: 'name', + value: 'G' + }, + context: { + arguments: { + department: 'marketing' + } } } - } - }, - CompleteResultSchema - ); - - expect(result2.completion.values).toEqual(['David']); - - // Test with marketing department - const result3 = await client.request( - { - method: 'completion/complete', - params: { - ref: { - type: 'ref/prompt', - name: 'test-prompt' - }, - argument: { - name: 'name', - value: 'G' - }, - context: { - arguments: { - department: 'marketing' + }, + CompleteResultSchema + ); + + expect(result3.completion.values).toEqual(['Grace']); + + // Test with no resolved context + const result4 = await client.request( + { + method: 'completion/complete', + params: { + ref: { + type: 'ref/prompt', + name: 'test-prompt' + }, + argument: { + name: 'name', + value: 'G' } } - } - }, - CompleteResultSchema - ); - - expect(result3.completion.values).toEqual(['Grace']); - - // Test with no resolved context - const result4 = await client.request( - { - method: 'completion/complete', - params: { - ref: { - type: 'ref/prompt', - name: 'test-prompt' - }, - argument: { - name: 'name', - value: 'G' - } - } - }, - CompleteResultSchema - ); + }, + CompleteResultSchema + ); - expect(result4.completion.values).toEqual(['Guest']); + expect(result4.completion.values).toEqual(['Guest']); + }); }); -}); -describe('elicitInput()', () => { - const checkAvailability = vi.fn().mockResolvedValue(false); - const findAlternatives = vi.fn().mockResolvedValue([]); - const makeBooking = vi.fn().mockResolvedValue('BOOKING-123'); + describe('elicitInput()', () => { + const checkAvailability = vi.fn().mockResolvedValue(false); + const findAlternatives = vi.fn().mockResolvedValue([]); + const makeBooking = vi.fn().mockResolvedValue('BOOKING-123'); - let mcpServer: McpServer; - let client: Client; + let mcpServer: McpServer; + let client: Client; - beforeEach(() => { - vi.clearAllMocks(); + beforeEach(() => { + vi.clearAllMocks(); - // Create server with restaurant booking tool - mcpServer = new McpServer({ - name: 'restaurant-booking-server', - version: '1.0.0' - }); + // Create server with restaurant booking tool + mcpServer = new McpServer({ + name: 'restaurant-booking-server', + version: '1.0.0' + }); - // Register the restaurant booking tool from README example - mcpServer.tool( - 'book-restaurant', - { - restaurant: z.string(), - date: z.string(), - partySize: z.number() - }, - async ({ restaurant, date, partySize }) => { - // Check availability - const available = await checkAvailability(restaurant, date, partySize); - - if (!available) { - // Ask user if they want to try alternative dates - const result = await mcpServer.server.elicitInput({ - mode: 'form', - message: `No tables available at ${restaurant} on ${date}. Would you like to check alternative dates?`, - requestedSchema: { - type: 'object', - properties: { - checkAlternatives: { - type: 'boolean', - title: 'Check alternative dates', - description: 'Would you like me to check other dates?' + // Register the restaurant booking tool from README example + mcpServer.tool( + 'book-restaurant', + { + restaurant: z.string(), + date: z.string(), + partySize: z.number() + }, + async ({ restaurant, date, partySize }) => { + // Check availability + const available = await checkAvailability(restaurant, date, partySize); + + if (!available) { + // Ask user if they want to try alternative dates + const result = await mcpServer.server.elicitInput({ + mode: 'form', + message: `No tables available at ${restaurant} on ${date}. Would you like to check alternative dates?`, + requestedSchema: { + type: 'object', + properties: { + checkAlternatives: { + type: 'boolean', + title: 'Check alternative dates', + description: 'Would you like me to check other dates?' + }, + flexibleDates: { + type: 'string', + title: 'Date flexibility', + description: 'How flexible are your dates?', + enum: ['next_day', 'same_week', 'next_week'], + enumNames: ['Next day', 'Same week', 'Next week'] + } }, - flexibleDates: { - type: 'string', - title: 'Date flexibility', - description: 'How flexible are your dates?', - enum: ['next_day', 'same_week', 'next_week'], - enumNames: ['Next day', 'Same week', 'Next week'] - } - }, - required: ['checkAlternatives'] + required: ['checkAlternatives'] + } + }); + + if (result.action === 'accept' && result.content?.checkAlternatives) { + const alternatives = await findAlternatives( + restaurant, + date, + partySize, + result.content.flexibleDates as string + ); + return { + content: [ + { + type: 'text', + text: `Found these alternatives: ${alternatives.join(', ')}` + } + ] + }; } - }); - if (result.action === 'accept' && result.content?.checkAlternatives) { - const alternatives = await findAlternatives(restaurant, date, partySize, result.content.flexibleDates as string); return { content: [ { type: 'text', - text: `Found these alternatives: ${alternatives.join(', ')}` + text: 'No booking made. Original date not available.' } ] }; } + await makeBooking(restaurant, date, partySize); return { content: [ { type: 'text', - text: 'No booking made. Original date not available.' + text: `Booked table for ${partySize} at ${restaurant} on ${date}` } ] }; } + ); + + // Create client with elicitation capability + client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { + elicitation: {} + } + } + ); + }); + + test('should successfully elicit additional information', async () => { + // Mock availability check to return false + checkAvailability.mockResolvedValue(false); + findAlternatives.mockResolvedValue(['2024-12-26', '2024-12-27', '2024-12-28']); - await makeBooking(restaurant, date, partySize); + // Set up client to accept alternative date checking + client.setRequestHandler(ElicitRequestSchema, async request => { + expect(request.params.message).toContain('No tables available at ABC Restaurant on 2024-12-25'); return { - content: [ - { - type: 'text', - text: `Booked table for ${partySize} at ${restaurant} on ${date}` - } - ] + action: 'accept', + content: { + checkAlternatives: true, + flexibleDates: 'same_week' + } }; - } - ); + }); - // Create client with elicitation capability - client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { - elicitation: {} + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + // Call the tool + const result = await client.callTool({ + name: 'book-restaurant', + arguments: { + restaurant: 'ABC Restaurant', + date: '2024-12-25', + partySize: 2 } - } - ); - }); + }); - test('should successfully elicit additional information', async () => { - // Mock availability check to return false - checkAvailability.mockResolvedValue(false); - findAlternatives.mockResolvedValue(['2024-12-26', '2024-12-27', '2024-12-28']); - - // Set up client to accept alternative date checking - client.setRequestHandler(ElicitRequestSchema, async request => { - expect(request.params.message).toContain('No tables available at ABC Restaurant on 2024-12-25'); - return { - action: 'accept', - content: { - checkAlternatives: true, - flexibleDates: 'same_week' + expect(checkAvailability).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2); + expect(findAlternatives).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2, 'same_week'); + expect(result.content).toEqual([ + { + type: 'text', + text: 'Found these alternatives: 2024-12-26, 2024-12-27, 2024-12-28' } - }; + ]); }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + test('should handle user declining to elicitation request', async () => { + // Mock availability check to return false + checkAvailability.mockResolvedValue(false); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + // Set up client to reject alternative date checking + client.setRequestHandler(ElicitRequestSchema, async () => { + return { + action: 'accept', + content: { + checkAlternatives: false + } + }; + }); - // Call the tool - const result = await client.callTool({ - name: 'book-restaurant', - arguments: { - restaurant: 'ABC Restaurant', - date: '2024-12-25', - partySize: 2 - } - }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - expect(checkAvailability).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2); - expect(findAlternatives).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2, 'same_week'); - expect(result.content).toEqual([ - { - type: 'text', - text: 'Found these alternatives: 2024-12-26, 2024-12-27, 2024-12-28' - } - ]); - }); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - test('should handle user declining to elicitation request', async () => { - // Mock availability check to return false - checkAvailability.mockResolvedValue(false); + // Call the tool + const result = await client.callTool({ + name: 'book-restaurant', + arguments: { + restaurant: 'ABC Restaurant', + date: '2024-12-25', + partySize: 2 + } + }); - // Set up client to reject alternative date checking - client.setRequestHandler(ElicitRequestSchema, async () => { - return { - action: 'accept', - content: { - checkAlternatives: false + expect(checkAvailability).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2); + expect(findAlternatives).not.toHaveBeenCalled(); + expect(result.content).toEqual([ + { + type: 'text', + text: 'No booking made. Original date not available.' } - }; + ]); }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + test('should handle user cancelling the elicitation', async () => { + // Mock availability check to return false + checkAvailability.mockResolvedValue(false); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + // Set up client to cancel the elicitation + client.setRequestHandler(ElicitRequestSchema, async () => { + return { + action: 'cancel' + }; + }); - // Call the tool - const result = await client.callTool({ - name: 'book-restaurant', - arguments: { - restaurant: 'ABC Restaurant', - date: '2024-12-25', - partySize: 2 - } - }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - expect(checkAvailability).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2); - expect(findAlternatives).not.toHaveBeenCalled(); - expect(result.content).toEqual([ - { - type: 'text', - text: 'No booking made. Original date not available.' - } - ]); - }); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - test('should handle user cancelling the elicitation', async () => { - // Mock availability check to return false - checkAvailability.mockResolvedValue(false); + // Call the tool + const result = await client.callTool({ + name: 'book-restaurant', + arguments: { + restaurant: 'ABC Restaurant', + date: '2024-12-25', + partySize: 2 + } + }); - // Set up client to cancel the elicitation - client.setRequestHandler(ElicitRequestSchema, async () => { - return { - action: 'cancel' - }; + expect(checkAvailability).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2); + expect(findAlternatives).not.toHaveBeenCalled(); + expect(result.content).toEqual([ + { + type: 'text', + text: 'No booking made. Original date not available.' + } + ]); }); + }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + describe('Tools with union and intersection schemas', () => { + test('should support union schemas', async () => { + const server = new McpServer({ + name: 'test', + version: '1.0.0' + }); - // Call the tool - const result = await client.callTool({ - name: 'book-restaurant', - arguments: { - restaurant: 'ABC Restaurant', - date: '2024-12-25', - partySize: 2 - } - }); + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); - expect(checkAvailability).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2); - expect(findAlternatives).not.toHaveBeenCalled(); - expect(result.content).toEqual([ - { - type: 'text', - text: 'No booking made. Original date not available.' - } - ]); - }); -}); + const unionSchema = z.union([ + z.object({ type: z.literal('email'), email: z.string().email() }), + z.object({ type: z.literal('phone'), phone: z.string() }) + ]); -describe('Tools with union and intersection schemas', () => { - test('should support union schemas', async () => { - const server = new McpServer({ - name: 'test', - version: '1.0.0' - }); + server.registerTool('contact', { inputSchema: unionSchema }, async args => { + if (args.type === 'email') { + return { + content: [{ type: 'text', text: `Email contact: ${args.email}` }] + }; + } else { + return { + content: [{ type: 'text', text: `Phone contact: ${args.phone}` }] + }; + } + }); - const client = new Client({ - name: 'test-client', - version: '1.0.0' - }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); - const unionSchema = z.union([ - z.object({ type: z.literal('email'), email: z.string().email() }), - z.object({ type: z.literal('phone'), phone: z.string() }) - ]); + const emailResult = await client.callTool({ + name: 'contact', + arguments: { + type: 'email', + email: 'test@example.com' + } + }); - server.registerTool('contact', { inputSchema: unionSchema }, async args => { - if (args.type === 'email') { - return { - content: [{ type: 'text', text: `Email contact: ${args.email}` }] - }; - } else { - return { - content: [{ type: 'text', text: `Phone contact: ${args.phone}` }] - }; - } - }); + expect(emailResult.content).toEqual([ + { + type: 'text', + text: 'Email contact: test@example.com' + } + ]); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await server.connect(serverTransport); - await client.connect(clientTransport); + const phoneResult = await client.callTool({ + name: 'contact', + arguments: { + type: 'phone', + phone: '+1234567890' + } + }); - const emailResult = await client.callTool({ - name: 'contact', - arguments: { - type: 'email', - email: 'test@example.com' - } + expect(phoneResult.content).toEqual([ + { + type: 'text', + text: 'Phone contact: +1234567890' + } + ]); }); - expect(emailResult.content).toEqual([ - { - type: 'text', - text: 'Email contact: test@example.com' - } - ]); - - const phoneResult = await client.callTool({ - name: 'contact', - arguments: { - type: 'phone', - phone: '+1234567890' - } - }); + test('should support intersection schemas', async () => { + const server = new McpServer({ + name: 'test', + version: '1.0.0' + }); - expect(phoneResult.content).toEqual([ - { - type: 'text', - text: 'Phone contact: +1234567890' - } - ]); - }); + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); - test('should support intersection schemas', async () => { - const server = new McpServer({ - name: 'test', - version: '1.0.0' - }); + const baseSchema = z.object({ id: z.string() }); + const extendedSchema = z.object({ name: z.string(), age: z.number() }); + const intersectionSchema = z.intersection(baseSchema, extendedSchema); - const client = new Client({ - name: 'test-client', - version: '1.0.0' - }); + server.registerTool('user', { inputSchema: intersectionSchema }, async args => { + return { + content: [ + { + type: 'text', + text: `User: ${args.id}, ${args.name}, ${args.age} years old` + } + ] + }; + }); - const baseSchema = z.object({ id: z.string() }); - const extendedSchema = z.object({ name: z.string(), age: z.number() }); - const intersectionSchema = z.intersection(baseSchema, extendedSchema); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); - server.registerTool('user', { inputSchema: intersectionSchema }, async args => { - return { - content: [ - { - type: 'text', - text: `User: ${args.id}, ${args.name}, ${args.age} years old` - } - ] - }; - }); + const result = await client.callTool({ + name: 'user', + arguments: { + id: '123', + name: 'John Doe', + age: 30 + } + }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await server.connect(serverTransport); - await client.connect(clientTransport); - - const result = await client.callTool({ - name: 'user', - arguments: { - id: '123', - name: 'John Doe', - age: 30 - } + expect(result.content).toEqual([ + { + type: 'text', + text: 'User: 123, John Doe, 30 years old' + } + ]); }); - expect(result.content).toEqual([ - { - type: 'text', - text: 'User: 123, John Doe, 30 years old' - } - ]); - }); + test('should support complex nested schemas', async () => { + const server = new McpServer({ + name: 'test', + version: '1.0.0' + }); - test('should support complex nested schemas', async () => { - const server = new McpServer({ - name: 'test', - version: '1.0.0' - }); + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); - const client = new Client({ - name: 'test-client', - version: '1.0.0' - }); + const schema = z.object({ + items: z.array( + z.union([ + z.object({ type: z.literal('text'), content: z.string() }), + z.object({ type: z.literal('number'), value: z.number() }) + ]) + ) + }); - const schema = z.object({ - items: z.array( - z.union([ - z.object({ type: z.literal('text'), content: z.string() }), - z.object({ type: z.literal('number'), value: z.number() }) - ]) - ) - }); + server.registerTool('process', { inputSchema: schema }, async args => { + const processed = args.items.map(item => { + if (item.type === 'text') { + return item.content.toUpperCase(); + } else { + return item.value * 2; + } + }); + return { + content: [ + { + type: 'text', + text: `Processed: ${processed.join(', ')}` + } + ] + }; + }); - server.registerTool('process', { inputSchema: schema }, async args => { - const processed = args.items.map(item => { - if (item.type === 'text') { - return item.content.toUpperCase(); - } else { - return item.value * 2; + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const result = await client.callTool({ + name: 'process', + arguments: { + items: [ + { type: 'text', content: 'hello' }, + { type: 'number', value: 5 }, + { type: 'text', content: 'world' } + ] } }); - return { - content: [ - { - type: 'text', - text: `Processed: ${processed.join(', ')}` - } - ] - }; - }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await server.connect(serverTransport); - await client.connect(clientTransport); - - const result = await client.callTool({ - name: 'process', - arguments: { - items: [ - { type: 'text', content: 'hello' }, - { type: 'number', value: 5 }, - { type: 'text', content: 'world' } - ] - } + expect(result.content).toEqual([ + { + type: 'text', + text: 'Processed: HELLO, 10, WORLD' + } + ]); }); - expect(result.content).toEqual([ - { - type: 'text', - text: 'Processed: HELLO, 10, WORLD' - } - ]); - }); + test('should validate union schema inputs correctly', async () => { + const server = new McpServer({ + name: 'test', + version: '1.0.0' + }); - test('should validate union schema inputs correctly', async () => { - const server = new McpServer({ - name: 'test', - version: '1.0.0' - }); + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); - const client = new Client({ - name: 'test-client', - version: '1.0.0' - }); + const unionSchema = z.union([ + z.object({ type: z.literal('a'), value: z.string() }), + z.object({ type: z.literal('b'), value: z.number() }) + ]); - const unionSchema = z.union([ - z.object({ type: z.literal('a'), value: z.string() }), - z.object({ type: z.literal('b'), value: z.number() }) - ]); + server.registerTool('union-test', { inputSchema: unionSchema }, async () => { + return { + content: [{ type: 'text', text: 'Success' }] + }; + }); - server.registerTool('union-test', { inputSchema: unionSchema }, async () => { - return { - content: [{ type: 'text', text: 'Success' }] - }; - }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const invalidTypeResult = await client.callTool({ + name: 'union-test', + arguments: { + type: 'a', + value: 123 + } + }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await server.connect(serverTransport); - await client.connect(clientTransport); + expect(invalidTypeResult.isError).toBe(true); + expect(invalidTypeResult.content).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('Input validation error') + }) + ]) + ); - const invalidTypeResult = await client.callTool({ - name: 'union-test', - arguments: { - type: 'a', - value: 123 - } - }); + const invalidDiscriminatorResult = await client.callTool({ + name: 'union-test', + arguments: { + type: 'c', + value: 'test' + } + }); - expect(invalidTypeResult.isError).toBe(true); - expect(invalidTypeResult.content).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: 'text', - text: expect.stringContaining('Input validation error') - }) - ]) - ); - - const invalidDiscriminatorResult = await client.callTool({ - name: 'union-test', - arguments: { - type: 'c', - value: 'test' - } + expect(invalidDiscriminatorResult.isError).toBe(true); + expect(invalidDiscriminatorResult.content).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('Input validation error') + }) + ]) + ); }); - - expect(invalidDiscriminatorResult.isError).toBe(true); - expect(invalidDiscriminatorResult.content).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: 'text', - text: expect.stringContaining('Input validation error') - }) - ]) - ); }); }); diff --git a/src/server/sse.test.ts b/src/server/sse.test.ts index 34ac071fe..5879b76ea 100644 --- a/src/server/sse.test.ts +++ b/src/server/sse.test.ts @@ -5,8 +5,8 @@ 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/v4'; import { CallToolResult, JSONRPCMessage } from '../types.js'; +import { zodTestMatrix, type ZodMatrixEntry } from '../shared/zodTestMatrix.js'; const createMockResponse = () => { const res = { @@ -49,56 +49,6 @@ const createMockRequest = ({ headers = {}, body }: { headers?: Record { - 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 => { - 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(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 { const reader = response.body?.getReader(); if (!reader) throw new Error('No readable stream'); @@ -148,260 +98,289 @@ async function sendSsePostRequest( }); } -describe('SSEServerTransport', () => { - async function initializeServer(baseUrl: URL): Promise { - const response = await sendSsePostRequest(baseUrl, { - jsonrpc: '2.0', - method: 'initialize', - params: { - clientInfo: { name: 'test-client', version: '1.0' }, - protocolVersion: '2025-03-26', - capabilities: {} - }, +describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { + const { z } = entry; + + /** + * 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 => { + return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; + } + ); + + const endpoint = '/messages'; - id: 'init-1' - } as JSONRPCMessage); + 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(); + } + }); - expect(response.status).toBe(202); + const baseUrl = await new Promise(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 text = await readAllSSEEvents(response); + const port = (server.address() as AddressInfo).port; - expect(text).toHaveLength(1); - expect(text[0]).toBe('Accepted'); + return { server, transport, mcpServer, baseUrl, sessionId, serverPort: port }; } - 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; + describe('SSEServerTransport', () => { + async function initializeServer(baseUrl: URL): Promise { + const response = await sendSsePostRequest(baseUrl, { + jsonrpc: '2.0', + method: 'initialize', + params: { + clientInfo: { name: 'test-client', version: '1.0' }, + protocolVersion: '2025-03-26', + capabilities: {} + }, - await transport.start(); + id: 'init-1' + } as JSONRPCMessage); - 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`); - }); + expect(response.status).toBe(202); - 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; + const text = await readAllSSEEvents(response); - await transport.start(); + expect(text).toHaveLength(1); + expect(text[0]).toBe('Accepted'); + } - 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` - ); - }); + 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; - 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(); - 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`); + }); - 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 existing query parameters', async () => { + const mockRes = createMockResponse(); + const endpoint = '/messages?foo=bar&baz=qux'; + const transport = new SSEServerTransport(endpoint, mockRes); + const expectedSessionId = transport.sessionId; - 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(); - 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` + ); + }); - 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 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; - 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(); - 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`); + }); - 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 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; - 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(); - 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` + ); + }); - 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 the root path endpoint "/"', async () => { + const mockRes = createMockResponse(); + const endpoint = '/'; + const transport = new SSEServerTransport(endpoint, mockRes); + const expectedSessionId = transport.sessionId; - /** - * 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 => { - return { - content: [ - { type: 'text', text: `Hello, ${name}!` }, - { type: 'text', text: `${JSON.stringify(requestInfo)}` } - ] - }; - } - ); + await transport.start(); - const toolCallMessage: JSONRPCMessage = { - jsonrpc: '2.0', - method: 'tools/call', - params: { - name: 'test-request-info', - arguments: { - name: 'Test User' - } - }, - id: 'call-1' - }; + expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); + expect(mockRes.write).toHaveBeenCalledTimes(1); + expect(mockRes.write).toHaveBeenCalledWith(`event: endpoint\ndata: /?sessionId=${expectedSessionId}\n\n`); + }); - const response = await sendSsePostRequest(baseUrl, toolCallMessage, sessionId); + it('should correctly handle an empty string endpoint ""', async () => { + const mockRes = createMockResponse(); + const endpoint = ''; + const transport = new SSEServerTransport(endpoint, mockRes); + const expectedSessionId = transport.sessionId; - expect(response.status).toBe(202); + await transport.start(); - expect(mockRes.write).toHaveBeenCalledWith(`event: endpoint\ndata: /messages?sessionId=${sessionId}\n\n`); + expect(mockRes.writeHead).toHaveBeenCalledWith(200, expect.any(Object)); + expect(mockRes.write).toHaveBeenCalledTimes(1); + expect(mockRes.write).toHaveBeenCalledWith(`event: endpoint\ndata: /?sessionId=${expectedSessionId}\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' - } - }) + /** + * 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 => { + 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' } - ] - }, - jsonrpc: '2.0', - id: 'call-1' - }; - expect(mockRes.write).toHaveBeenCalledWith(`event: message\ndata: ${JSON.stringify(expectedMessage)}\n\n`); - }); - }); + }, + id: 'call-1' + }; - 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); - }); + const response = await sendSsePostRequest(baseUrl, toolCallMessage, sessionId); - 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)); - }); + expect(response.status).toBe(202); - 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 + 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`); }); - 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 + 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); }); - const mockReq = createMockRequest({ - headers: { 'content-type': 'application/json' }, - body: validMessage + + 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}`); }); - 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( - { + + it('should return 202 if message has a valid schema', async () => { + const validMessage = JSON.stringify({ jsonrpc: '2.0', method: 'call', params: { @@ -410,301 +389,326 @@ describe('SSEServerTransport', () => { c: 3 }, id: 1 - }, - { - authInfo: { - token: 'test-token' + }); + 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 }, - requestInfo: { - headers: { - 'content-type': 'application/json' + { + 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('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('Host header validation', () => { - it('should accept requests with allowed host headers', async () => { + describe('send method', () => { + it('should call onsend', async () => { const mockRes = createMockResponse(); - const transport = new SSEServerTransport('/messages', mockRes, { - allowedHosts: ['localhost:3000', 'example.com'], - enableDnsRebindingProtection: true - }); + 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}`)); + }); + }); - const mockReq = createMockRequest({ - headers: { - host: 'localhost:3000', - 'content-type': 'application/json' - } - }); - const mockHandleRes = createMockResponse(); + describe('DNS rebinding protection', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); - await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + 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(); - expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); - expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); - }); + const mockReq = createMockRequest({ + headers: { + host: 'localhost:3000', + 'content-type': 'application/json' + } + }); + const mockHandleRes = createMockResponse(); - 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(); + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); - const mockReq = createMockRequest({ - headers: { - host: 'evil.com', - 'content-type': 'application/json' - } + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); + expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); }); - const mockHandleRes = createMockResponse(); - await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + 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(); - expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403); - expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Host header: evil.com'); - }); + const mockReq = createMockRequest({ + headers: { + host: 'evil.com', + 'content-type': 'application/json' + } + }); + const mockHandleRes = createMockResponse(); - 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(); + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); - const mockReq = createMockRequest({ - headers: { - 'content-type': 'application/json' - } + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403); + expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Host header: evil.com'); }); - const mockHandleRes = createMockResponse(); - await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + 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(); - expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403); - expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Host header: undefined'); - }); - }); + const mockReq = createMockRequest({ + headers: { + 'content-type': 'application/json' + } + }); + const mockHandleRes = createMockResponse(); - 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(); + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); - const mockReq = createMockRequest({ - headers: { - origin: 'http://localhost:3000', - 'content-type': 'application/json' - } + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403); + expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Host header: undefined'); }); - const mockHandleRes = createMockResponse(); + }); - await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + 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(); - expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); - expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); - }); + const mockReq = createMockRequest({ + headers: { + origin: 'http://localhost:3000', + 'content-type': 'application/json' + } + }); + const mockHandleRes = createMockResponse(); - 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(); + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); - const mockReq = createMockRequest({ - headers: { - origin: 'http://evil.com', - 'content-type': 'application/json' - } + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); + expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); }); - const mockHandleRes = createMockResponse(); - await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + 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(); - expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403); - expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Origin header: http://evil.com'); - }); - }); + const mockReq = createMockRequest({ + headers: { + origin: 'http://evil.com', + 'content-type': 'application/json' + } + }); + const mockHandleRes = createMockResponse(); - 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(); + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); - const mockReq = createMockRequest({ - headers: { - 'content-type': 'application/json' - } + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(403); + expect(mockHandleRes.end).toHaveBeenCalledWith('Invalid Origin header: http://evil.com'); }); - const mockHandleRes = createMockResponse(); + }); - await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + 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(); - expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); - expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); - }); + const mockReq = createMockRequest({ + headers: { + 'content-type': 'application/json' + } + }); + const mockHandleRes = createMockResponse(); - it('should accept requests with application/json with charset', async () => { - const mockRes = createMockResponse(); - const transport = new SSEServerTransport('/messages', mockRes); - await transport.start(); + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); - const mockReq = createMockRequest({ - headers: { - 'content-type': 'application/json; charset=utf-8' - } + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); + expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); }); - const mockHandleRes = createMockResponse(); - await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + it('should accept requests with application/json with charset', async () => { + const mockRes = createMockResponse(); + const transport = new SSEServerTransport('/messages', mockRes); + await transport.start(); - expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); - expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); - }); + const mockReq = createMockRequest({ + headers: { + 'content-type': 'application/json; charset=utf-8' + } + }); + const mockHandleRes = createMockResponse(); - 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(); + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); - const mockReq = createMockRequest({ - headers: { - 'content-type': 'text/plain' - } + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(202); + expect(mockHandleRes.end).toHaveBeenCalledWith('Accepted'); }); - const mockHandleRes = createMockResponse(); - await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + 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(); - expect(mockHandleRes.writeHead).toHaveBeenCalledWith(400); - expect(mockHandleRes.end).toHaveBeenCalledWith('Error: Unsupported content-type: text/plain'); - }); - }); + const mockReq = createMockRequest({ + headers: { + 'content-type': 'text/plain' + } + }); + const mockHandleRes = createMockResponse(); - 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(); + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); - const mockReq = createMockRequest({ - headers: { - host: 'evil.com', - origin: 'http://evil.com', - 'content-type': 'text/plain' - } + expect(mockHandleRes.writeHead).toHaveBeenCalledWith(400); + expect(mockHandleRes.end).toHaveBeenCalledWith('Error: Unsupported content-type: text/plain'); }); - const mockHandleRes = createMockResponse(); + }); - await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); + 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(); - // 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'); - }); - }); + await transport.handlePostMessage(mockReq, mockHandleRes, { jsonrpc: '2.0', method: 'test' }); - 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 + // 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'); }); - 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(); + 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' }); + 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'); + 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(); + // 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' }); + await transport.handlePostMessage(mockReq2, mockHandleRes2, { jsonrpc: '2.0', method: 'test' }); - expect(mockHandleRes2.writeHead).toHaveBeenCalledWith(403); - expect(mockHandleRes2.end).toHaveBeenCalledWith('Invalid Host header: evil.com'); + 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(); + // 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' }); + await transport.handlePostMessage(mockReq3, mockHandleRes3, { jsonrpc: '2.0', method: 'test' }); - expect(mockHandleRes3.writeHead).toHaveBeenCalledWith(202); - expect(mockHandleRes3.end).toHaveBeenCalledWith('Accepted'); + expect(mockHandleRes3.writeHead).toHaveBeenCalledWith(202); + expect(mockHandleRes3.end).toHaveBeenCalledWith('Accepted'); + }); }); }); }); diff --git a/src/server/streamableHttp.test.ts b/src/server/streamableHttp.test.ts index b5b169951..c59be4ddd 100644 --- a/src/server/streamableHttp.test.ts +++ b/src/server/streamableHttp.test.ts @@ -4,8 +4,8 @@ import { randomUUID } from 'node:crypto'; import { EventStore, StreamableHTTPServerTransport, EventId, StreamId } from './streamableHttp.js'; import { McpServer } from './mcp.js'; import { CallToolResult, JSONRPCMessage } from '../types.js'; -import * as z from 'zod/v4'; import { AuthInfo } from './auth/types.js'; +import { zodTestMatrix, type ZodMatrixEntry } from '../shared/zodTestMatrix.js'; async function getFreePort() { return new Promise(res => { @@ -33,113 +33,6 @@ interface TestServerConfig { onsessionclosed?: (sessionId: string) => void | Promise; } -/** - * Helper to create and start test HTTP server with MCP setup - */ -async function createTestServer(config: TestServerConfig = { sessionIdGenerator: () => randomUUID() }): Promise<{ - server: Server; - transport: StreamableHTTPServerTransport; - mcpServer: McpServer; - baseUrl: URL; -}> { - 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 => { - return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; - } - ); - - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: config.sessionIdGenerator, - enableJsonResponse: config.enableJsonResponse ?? false, - eventStore: config.eventStore, - onsessioninitialized: config.onsessioninitialized, - onsessionclosed: config.onsessionclosed - }); - - await mcpServer.connect(transport); - - const server = createServer(async (req, res) => { - try { - if (config.customRequestHandler) { - await config.customRequestHandler(req, res); - } else { - await transport.handleRequest(req, res); - } - } catch (error) { - console.error('Error handling request:', error); - if (!res.headersSent) res.writeHead(500).end(); - } - }); - - const baseUrl = await new Promise(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, transport, mcpServer, baseUrl }; -} - -/** - * Helper to create and start authenticated test HTTP server with MCP setup - */ -async function createTestAuthServer(config: TestServerConfig = { sessionIdGenerator: () => randomUUID() }): Promise<{ - server: Server; - transport: StreamableHTTPServerTransport; - mcpServer: McpServer; - baseUrl: URL; -}> { - const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } }); - - mcpServer.tool( - 'profile', - 'A user profile data tool', - { active: z.boolean().describe('Profile status') }, - async ({ active }, { authInfo }): Promise => { - return { content: [{ type: 'text', text: `${active ? 'Active' : 'Inactive'} profile from token: ${authInfo?.token}!` }] }; - } - ); - - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: config.sessionIdGenerator, - enableJsonResponse: config.enableJsonResponse ?? false, - eventStore: config.eventStore, - onsessioninitialized: config.onsessioninitialized, - onsessionclosed: config.onsessionclosed - }); - - await mcpServer.connect(transport); - - const server = createServer(async (req: IncomingMessage & { auth?: AuthInfo }, res) => { - try { - if (config.customRequestHandler) { - await config.customRequestHandler(req, res); - } else { - req.auth = { token: req.headers['authorization']?.split(' ')[1] } as AuthInfo; - await transport.handleRequest(req, res); - } - } catch (error) { - console.error('Error handling request:', error); - if (!res.headersSent) res.writeHead(500).end(); - } - }); - - const baseUrl = await new Promise(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, transport, mcpServer, baseUrl }; -} - /** * Helper to stop test server */ @@ -223,1866 +116,1975 @@ function expectErrorResponse(data: unknown, expectedCode: number, expectedMessag }) }); } +describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { + /** + * Helper to create and start test HTTP server with MCP setup + */ + async function createTestServer(config: TestServerConfig = { sessionIdGenerator: () => randomUUID() }): Promise<{ + server: Server; + transport: StreamableHTTPServerTransport; + mcpServer: McpServer; + baseUrl: URL; + }> { + const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } }); -describe('StreamableHTTPServerTransport', () => { - let server: Server; - let mcpServer: McpServer; - let transport: StreamableHTTPServerTransport; - let baseUrl: URL; - let sessionId: string; - - beforeEach(async () => { - const result = await createTestServer(); - server = result.server; - transport = result.transport; - mcpServer = result.mcpServer; - baseUrl = result.baseUrl; - }); - - afterEach(async () => { - await stopTestServer({ server, transport }); - }); - - async function initializeServer(): Promise { - const response = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); - - expect(response.status).toBe(200); - const newSessionId = response.headers.get('mcp-session-id'); - expect(newSessionId).toBeDefined(); - return newSessionId as string; - } - - it('should initialize server and generate session ID', async () => { - const response = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); - - expect(response.status).toBe(200); - expect(response.headers.get('content-type')).toBe('text/event-stream'); - expect(response.headers.get('mcp-session-id')).toBeDefined(); - }); - - it('should reject second initialization request', async () => { - // First initialize - const sessionId = await initializeServer(); - expect(sessionId).toBeDefined(); - - // Try second initialize - const secondInitMessage = { - ...TEST_MESSAGES.initialize, - id: 'second-init' - }; - - const response = await sendPostRequest(baseUrl, secondInitMessage); - - expect(response.status).toBe(400); - const errorData = await response.json(); - expectErrorResponse(errorData, -32600, /Server already initialized/); - }); - - it('should reject batch initialize request', async () => { - const batchInitMessages: JSONRPCMessage[] = [ - TEST_MESSAGES.initialize, - { - jsonrpc: '2.0', - method: 'initialize', - params: { - clientInfo: { name: 'test-client-2', version: '1.0' }, - protocolVersion: '2025-03-26' - }, - id: 'init-2' + mcpServer.tool( + 'greet', + 'A simple greeting tool', + { name: z.string().describe('Name to greet') }, + async ({ name }): Promise => { + return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; } - ]; - - const response = await sendPostRequest(baseUrl, batchInitMessages); - - expect(response.status).toBe(400); - const errorData = await response.json(); - expectErrorResponse(errorData, -32600, /Only one initialization request is allowed/); - }); - - it('should handle post requests via sse response correctly', async () => { - sessionId = await initializeServer(); - - const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId); - - expect(response.status).toBe(200); - - // Read the SSE stream for the response - const text = await readSSEEvent(response); - - // Parse the SSE event - const eventLines = text.split('\n'); - const dataLine = eventLines.find(line => line.startsWith('data:')); - expect(dataLine).toBeDefined(); + ); - const eventData = JSON.parse(dataLine!.substring(5)); - expect(eventData).toMatchObject({ - jsonrpc: '2.0', - result: expect.objectContaining({ - tools: expect.arrayContaining([ - expect.objectContaining({ - name: 'greet', - description: 'A simple greeting tool' - }) - ]) - }), - id: 'tools-1' + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: config.sessionIdGenerator, + enableJsonResponse: config.enableJsonResponse ?? false, + eventStore: config.eventStore, + onsessioninitialized: config.onsessioninitialized, + onsessionclosed: config.onsessionclosed }); - }); - it('should call a tool and return the result', async () => { - sessionId = await initializeServer(); + await mcpServer.connect(transport); - const toolCallMessage: JSONRPCMessage = { - jsonrpc: '2.0', - method: 'tools/call', - params: { - name: 'greet', - arguments: { - name: 'Test User' + const server = createServer(async (req, res) => { + try { + if (config.customRequestHandler) { + await config.customRequestHandler(req, res); + } else { + await transport.handleRequest(req, res); } - }, - id: 'call-1' - }; + } catch (error) { + console.error('Error handling request:', error); + if (!res.headersSent) res.writeHead(500).end(); + } + }); - const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId); - expect(response.status).toBe(200); - - const text = await readSSEEvent(response); - const eventLines = text.split('\n'); - const dataLine = eventLines.find(line => line.startsWith('data:')); - expect(dataLine).toBeDefined(); - - const eventData = JSON.parse(dataLine!.substring(5)); - expect(eventData).toMatchObject({ - jsonrpc: '2.0', - result: { - content: [ - { - type: 'text', - text: 'Hello, Test User!' - } - ] - }, - id: 'call-1' + const baseUrl = await new Promise(resolve => { + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as AddressInfo; + resolve(new URL(`http://127.0.0.1:${addr.port}`)); + }); }); - }); - /*** - * Test: Tool With Request Info + return { server, transport, mcpServer, baseUrl }; + } + + /** + * Helper to create and start authenticated test HTTP server with MCP setup */ - it('should pass request info to tool callback', async () => { - sessionId = await initializeServer(); + async function createTestAuthServer(config: TestServerConfig = { sessionIdGenerator: () => randomUUID() }): Promise<{ + server: Server; + transport: StreamableHTTPServerTransport; + mcpServer: McpServer; + baseUrl: URL; + }> { + const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } }); mcpServer.tool( - 'test-request-info', - 'A simple test tool with request info', - { name: z.string().describe('Name to greet') }, - async ({ name }, { requestInfo }): Promise => { - return { - content: [ - { type: 'text', text: `Hello, ${name}!` }, - { type: 'text', text: `${JSON.stringify(requestInfo)}` } - ] - }; + 'profile', + 'A user profile data tool', + { active: z.boolean().describe('Profile status') }, + async ({ active }, { authInfo }): Promise => { + return { content: [{ type: 'text', text: `${active ? 'Active' : 'Inactive'} profile from token: ${authInfo?.token}!` }] }; } ); - const toolCallMessage: JSONRPCMessage = { - jsonrpc: '2.0', - method: 'tools/call', - params: { - name: 'test-request-info', - arguments: { - name: 'Test User' - } - }, - id: 'call-1' - }; - - const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId); - expect(response.status).toBe(200); - - const text = await readSSEEvent(response); - const eventLines = text.split('\n'); - const dataLine = eventLines.find(line => line.startsWith('data:')); - expect(dataLine).toBeDefined(); - - const eventData = JSON.parse(dataLine!.substring(5)); - - expect(eventData).toMatchObject({ - jsonrpc: '2.0', - result: { - content: [ - { type: 'text', text: 'Hello, Test User!' }, - { type: 'text', text: expect.any(String) } - ] - }, - id: 'call-1' - }); - - const requestInfo = JSON.parse(eventData.result.content[1].text); - expect(requestInfo).toMatchObject({ - headers: { - 'content-type': 'application/json', - accept: 'application/json, text/event-stream', - connection: 'keep-alive', - 'mcp-session-id': sessionId, - 'accept-language': '*', - 'user-agent': expect.any(String), - 'accept-encoding': expect.any(String), - 'content-length': expect.any(String) - } + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: config.sessionIdGenerator, + enableJsonResponse: config.enableJsonResponse ?? false, + eventStore: config.eventStore, + onsessioninitialized: config.onsessioninitialized, + onsessionclosed: config.onsessionclosed }); - }); - - it('should reject requests without a valid session ID', async () => { - const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList); - - expect(response.status).toBe(400); - const errorData = await response.json(); - expectErrorResponse(errorData, -32000, /Bad Request/); - expect(errorData.id).toBeNull(); - }); - - it('should reject invalid session ID', async () => { - // First initialize to be in valid state - await initializeServer(); - // Now try with invalid session ID - const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, 'invalid-session-id'); + await mcpServer.connect(transport); - expect(response.status).toBe(404); - const errorData = await response.json(); - expectErrorResponse(errorData, -32001, /Session not found/); - }); - - it('should establish standalone SSE stream and receive server-initiated messages', async () => { - // First initialize to get a session ID - sessionId = await initializeServer(); - - // Open a standalone SSE stream - const sseResponse = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'text/event-stream', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2025-03-26' + const server = createServer(async (req: IncomingMessage & { auth?: AuthInfo }, res) => { + try { + if (config.customRequestHandler) { + await config.customRequestHandler(req, res); + } else { + req.auth = { token: req.headers['authorization']?.split(' ')[1] } as AuthInfo; + await transport.handleRequest(req, res); + } + } catch (error) { + console.error('Error handling request:', error); + if (!res.headersSent) res.writeHead(500).end(); } }); - expect(sseResponse.status).toBe(200); - expect(sseResponse.headers.get('content-type')).toBe('text/event-stream'); - - // Send a notification (server-initiated message) that should appear on SSE stream - const notification: JSONRPCMessage = { - jsonrpc: '2.0', - method: 'notifications/message', - params: { level: 'info', data: 'Test notification' } - }; + const baseUrl = await new Promise(resolve => { + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as AddressInfo; + resolve(new URL(`http://127.0.0.1:${addr.port}`)); + }); + }); - // Send the notification via transport - await transport.send(notification); + return { server, transport, mcpServer, baseUrl }; + } - // Read from the stream and verify we got the notification - const text = await readSSEEvent(sseResponse); + const { z } = entry; + describe('StreamableHTTPServerTransport', () => { + let server: Server; + let mcpServer: McpServer; + let transport: StreamableHTTPServerTransport; + let baseUrl: URL; + let sessionId: string; - const eventLines = text.split('\n'); - const dataLine = eventLines.find(line => line.startsWith('data:')); - expect(dataLine).toBeDefined(); + beforeEach(async () => { + const result = await createTestServer(); + server = result.server; + transport = result.transport; + mcpServer = result.mcpServer; + baseUrl = result.baseUrl; + }); - const eventData = JSON.parse(dataLine!.substring(5)); - expect(eventData).toMatchObject({ - jsonrpc: '2.0', - method: 'notifications/message', - params: { level: 'info', data: 'Test notification' } + afterEach(async () => { + await stopTestServer({ server, transport }); }); - }); - it('should not close GET SSE stream after sending multiple server notifications', async () => { - sessionId = await initializeServer(); + async function initializeServer(): Promise { + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); - // Open a standalone SSE stream - const sseResponse = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'text/event-stream', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2025-03-26' - } - }); + expect(response.status).toBe(200); + const newSessionId = response.headers.get('mcp-session-id'); + expect(newSessionId).toBeDefined(); + return newSessionId as string; + } - expect(sseResponse.status).toBe(200); - const reader = sseResponse.body?.getReader(); + it('should initialize server and generate session ID', async () => { + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); - // Send multiple notifications - const notification1: JSONRPCMessage = { - jsonrpc: '2.0', - method: 'notifications/message', - params: { level: 'info', data: 'First notification' } - }; + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('text/event-stream'); + expect(response.headers.get('mcp-session-id')).toBeDefined(); + }); - // Just send one and verify it comes through - then the stream should stay open - await transport.send(notification1); + it('should reject second initialization request', async () => { + // First initialize + const sessionId = await initializeServer(); + expect(sessionId).toBeDefined(); - const { value, done } = await reader!.read(); - const text = new TextDecoder().decode(value); - expect(text).toContain('First notification'); - expect(done).toBe(false); // Stream should still be open - }); + // Try second initialize + const secondInitMessage = { + ...TEST_MESSAGES.initialize, + id: 'second-init' + }; - it('should reject second SSE stream for the same session', async () => { - sessionId = await initializeServer(); + const response = await sendPostRequest(baseUrl, secondInitMessage); - // Open first SSE stream - const firstStream = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'text/event-stream', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2025-03-26' - } - }); + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32600, /Server already initialized/); + }); + + it('should reject batch initialize request', async () => { + const batchInitMessages: JSONRPCMessage[] = [ + TEST_MESSAGES.initialize, + { + jsonrpc: '2.0', + method: 'initialize', + params: { + clientInfo: { name: 'test-client-2', version: '1.0' }, + protocolVersion: '2025-03-26' + }, + id: 'init-2' + } + ]; - expect(firstStream.status).toBe(200); + const response = await sendPostRequest(baseUrl, batchInitMessages); - // Try to open a second SSE stream with the same session ID - const secondStream = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'text/event-stream', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2025-03-26' - } + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32600, /Only one initialization request is allowed/); }); - // Should be rejected - expect(secondStream.status).toBe(409); // Conflict - const errorData = await secondStream.json(); - expectErrorResponse(errorData, -32000, /Only one SSE stream is allowed per session/); - }); + it('should handle post requests via sse response correctly', async () => { + sessionId = await initializeServer(); - it('should reject GET requests without Accept: text/event-stream header', async () => { - sessionId = await initializeServer(); + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId); - // Try GET without proper Accept header - const response = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'application/json', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2025-03-26' - } - }); + expect(response.status).toBe(200); - expect(response.status).toBe(406); - const errorData = await response.json(); - expectErrorResponse(errorData, -32000, /Client must accept text\/event-stream/); - }); + // Read the SSE stream for the response + const text = await readSSEEvent(response); - it('should reject POST requests without proper Accept header', async () => { - sessionId = await initializeServer(); + // Parse the SSE event + const eventLines = text.split('\n'); + const dataLine = eventLines.find(line => line.startsWith('data:')); + expect(dataLine).toBeDefined(); - // Try POST without Accept: text/event-stream - const response = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', // Missing text/event-stream - 'mcp-session-id': sessionId - }, - body: JSON.stringify(TEST_MESSAGES.toolsList) + const eventData = JSON.parse(dataLine!.substring(5)); + expect(eventData).toMatchObject({ + jsonrpc: '2.0', + result: expect.objectContaining({ + tools: expect.arrayContaining([ + expect.objectContaining({ + name: 'greet', + description: 'A simple greeting tool' + }) + ]) + }), + id: 'tools-1' + }); }); - expect(response.status).toBe(406); - const errorData = await response.json(); - expectErrorResponse(errorData, -32000, /Client must accept both application\/json and text\/event-stream/); - }); + it('should call a tool and return the result', async () => { + sessionId = await initializeServer(); - it('should reject unsupported Content-Type', async () => { - sessionId = await initializeServer(); + const toolCallMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'greet', + arguments: { + name: 'Test User' + } + }, + id: 'call-1' + }; - // Try POST with text/plain Content-Type - const response = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'text/plain', - Accept: 'application/json, text/event-stream', - 'mcp-session-id': sessionId - }, - body: 'This is plain text' - }); + const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId); + expect(response.status).toBe(200); - expect(response.status).toBe(415); - const errorData = await response.json(); - expectErrorResponse(errorData, -32000, /Content-Type must be application\/json/); - }); + const text = await readSSEEvent(response); + const eventLines = text.split('\n'); + const dataLine = eventLines.find(line => line.startsWith('data:')); + expect(dataLine).toBeDefined(); - it('should handle JSON-RPC batch notification messages with 202 response', async () => { - sessionId = await initializeServer(); + const eventData = JSON.parse(dataLine!.substring(5)); + expect(eventData).toMatchObject({ + jsonrpc: '2.0', + result: { + content: [ + { + type: 'text', + text: 'Hello, Test User!' + } + ] + }, + id: 'call-1' + }); + }); - // Send batch of notifications (no IDs) - const batchNotifications: JSONRPCMessage[] = [ - { jsonrpc: '2.0', method: 'someNotification1', params: {} }, - { jsonrpc: '2.0', method: 'someNotification2', params: {} } - ]; - const response = await sendPostRequest(baseUrl, batchNotifications, sessionId); + /*** + * Test: Tool With Request Info + */ + it('should pass request info to tool callback', async () => { + sessionId = await initializeServer(); - expect(response.status).toBe(202); - }); + mcpServer.tool( + 'test-request-info', + 'A simple test tool with request info', + { name: z.string().describe('Name to greet') }, + async ({ name }, { requestInfo }): Promise => { + return { + content: [ + { type: 'text', text: `Hello, ${name}!` }, + { type: 'text', text: `${JSON.stringify(requestInfo)}` } + ] + }; + } + ); - it('should handle batch request messages with SSE stream for responses', async () => { - sessionId = await initializeServer(); + const toolCallMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'test-request-info', + arguments: { + name: 'Test User' + } + }, + id: 'call-1' + }; - // Send batch of requests - const batchRequests: JSONRPCMessage[] = [ - { jsonrpc: '2.0', method: 'tools/list', params: {}, id: 'req-1' }, - { jsonrpc: '2.0', method: 'tools/call', params: { name: 'greet', arguments: { name: 'BatchUser' } }, id: 'req-2' } - ]; - const response = await sendPostRequest(baseUrl, batchRequests, sessionId); + const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId); + expect(response.status).toBe(200); - expect(response.status).toBe(200); - expect(response.headers.get('content-type')).toBe('text/event-stream'); + const text = await readSSEEvent(response); + const eventLines = text.split('\n'); + const dataLine = eventLines.find(line => line.startsWith('data:')); + expect(dataLine).toBeDefined(); - const reader = response.body?.getReader(); + const eventData = JSON.parse(dataLine!.substring(5)); - // The responses may come in any order or together in one chunk - const { value } = await reader!.read(); - const text = new TextDecoder().decode(value); + expect(eventData).toMatchObject({ + jsonrpc: '2.0', + result: { + content: [ + { type: 'text', text: 'Hello, Test User!' }, + { type: 'text', text: expect.any(String) } + ] + }, + id: 'call-1' + }); - // Check that both responses were sent on the same stream - expect(text).toContain('"id":"req-1"'); - expect(text).toContain('"tools"'); // tools/list result - expect(text).toContain('"id":"req-2"'); - expect(text).toContain('Hello, BatchUser'); // tools/call result - }); + const requestInfo = JSON.parse(eventData.result.content[1].text); + expect(requestInfo).toMatchObject({ + headers: { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + connection: 'keep-alive', + 'mcp-session-id': sessionId, + 'accept-language': '*', + 'user-agent': expect.any(String), + 'accept-encoding': expect.any(String), + 'content-length': expect.any(String) + } + }); + }); - it('should properly handle invalid JSON data', async () => { - sessionId = await initializeServer(); + it('should reject requests without a valid session ID', async () => { + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList); - // Send invalid JSON - const response = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - 'mcp-session-id': sessionId - }, - body: 'This is not valid JSON' + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Bad Request/); + expect(errorData.id).toBeNull(); }); - expect(response.status).toBe(400); - const errorData = await response.json(); - expectErrorResponse(errorData, -32700, /Parse error/); - }); - - it('should return 400 error for invalid JSON-RPC messages', async () => { - sessionId = await initializeServer(); + it('should reject invalid session ID', async () => { + // First initialize to be in valid state + await initializeServer(); - // Invalid JSON-RPC (missing required jsonrpc version) - const invalidMessage = { method: 'tools/list', params: {}, id: 1 }; // missing jsonrpc version - const response = await sendPostRequest(baseUrl, invalidMessage as JSONRPCMessage, sessionId); + // Now try with invalid session ID + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, 'invalid-session-id'); - expect(response.status).toBe(400); - const errorData = await response.json(); - expect(errorData).toMatchObject({ - jsonrpc: '2.0', - error: expect.anything() + expect(response.status).toBe(404); + const errorData = await response.json(); + expectErrorResponse(errorData, -32001, /Session not found/); }); - }); - it('should reject requests to uninitialized server', async () => { - // Create a new HTTP server and transport without initializing - const { server: uninitializedServer, transport: uninitializedTransport, baseUrl: uninitializedUrl } = await createTestServer(); - // Transport not used in test but needed for cleanup - - // No initialization, just send a request directly - const uninitializedMessage: JSONRPCMessage = { - jsonrpc: '2.0', - method: 'tools/list', - params: {}, - id: 'uninitialized-test' - }; + it('should establish standalone SSE stream and receive server-initiated messages', async () => { + // First initialize to get a session ID + sessionId = await initializeServer(); - // Send a request to uninitialized server - const response = await sendPostRequest(uninitializedUrl, uninitializedMessage, 'any-session-id'); + // Open a standalone SSE stream + const sseResponse = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26' + } + }); - expect(response.status).toBe(400); - const errorData = await response.json(); - expectErrorResponse(errorData, -32000, /Server not initialized/); + expect(sseResponse.status).toBe(200); + expect(sseResponse.headers.get('content-type')).toBe('text/event-stream'); - // Cleanup - await stopTestServer({ server: uninitializedServer, transport: uninitializedTransport }); - }); + // Send a notification (server-initiated message) that should appear on SSE stream + const notification: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'notifications/message', + params: { level: 'info', data: 'Test notification' } + }; - it('should send response messages to the connection that sent the request', async () => { - sessionId = await initializeServer(); + // Send the notification via transport + await transport.send(notification); - const message1: JSONRPCMessage = { - jsonrpc: '2.0', - method: 'tools/list', - params: {}, - id: 'req-1' - }; + // Read from the stream and verify we got the notification + const text = await readSSEEvent(sseResponse); - const message2: JSONRPCMessage = { - jsonrpc: '2.0', - method: 'tools/call', - params: { - name: 'greet', - arguments: { name: 'Connection2' } - }, - id: 'req-2' - }; + const eventLines = text.split('\n'); + const dataLine = eventLines.find(line => line.startsWith('data:')); + expect(dataLine).toBeDefined(); - // Make two concurrent fetch connections for different requests - const req1 = sendPostRequest(baseUrl, message1, sessionId); - const req2 = sendPostRequest(baseUrl, message2, sessionId); - - // Get both responses - const [response1, response2] = await Promise.all([req1, req2]); - const reader1 = response1.body?.getReader(); - const reader2 = response2.body?.getReader(); - - // Read responses from each stream (requires each receives its specific response) - const { value: value1 } = await reader1!.read(); - const text1 = new TextDecoder().decode(value1); - expect(text1).toContain('"id":"req-1"'); - expect(text1).toContain('"tools"'); // tools/list result - - const { value: value2 } = await reader2!.read(); - const text2 = new TextDecoder().decode(value2); - expect(text2).toContain('"id":"req-2"'); - expect(text2).toContain('Hello, Connection2'); // tools/call result - }); + const eventData = JSON.parse(dataLine!.substring(5)); + expect(eventData).toMatchObject({ + jsonrpc: '2.0', + method: 'notifications/message', + params: { level: 'info', data: 'Test notification' } + }); + }); - it('should keep stream open after sending server notifications', async () => { - sessionId = await initializeServer(); + it('should not close GET SSE stream after sending multiple server notifications', async () => { + sessionId = await initializeServer(); - // Open a standalone SSE stream - const sseResponse = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'text/event-stream', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2025-03-26' - } - }); + // Open a standalone SSE stream + const sseResponse = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26' + } + }); - // Send several server-initiated notifications - await transport.send({ - jsonrpc: '2.0', - method: 'notifications/message', - params: { level: 'info', data: 'First notification' } - }); + expect(sseResponse.status).toBe(200); + const reader = sseResponse.body?.getReader(); - await transport.send({ - jsonrpc: '2.0', - method: 'notifications/message', - params: { level: 'info', data: 'Second notification' } - }); + // Send multiple notifications + const notification1: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'notifications/message', + params: { level: 'info', data: 'First notification' } + }; - // Stream should still be open - it should not close after sending notifications - expect(sseResponse.bodyUsed).toBe(false); - }); + // Just send one and verify it comes through - then the stream should stay open + await transport.send(notification1); - // The current implementation will close the entire transport for DELETE - // Creating a temporary transport/server where we don't care if it gets closed - it('should properly handle DELETE requests and close session', async () => { - // Setup a temporary server for this test - const tempResult = await createTestServer(); - const tempServer = tempResult.server; - const tempUrl = tempResult.baseUrl; - - // Initialize to get a session ID - const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); - const tempSessionId = initResponse.headers.get('mcp-session-id'); - - // Now DELETE the session - const deleteResponse = await fetch(tempUrl, { - method: 'DELETE', - headers: { - 'mcp-session-id': tempSessionId || '', - 'mcp-protocol-version': '2025-03-26' - } + const { value, done } = await reader!.read(); + const text = new TextDecoder().decode(value); + expect(text).toContain('First notification'); + expect(done).toBe(false); // Stream should still be open }); - expect(deleteResponse.status).toBe(200); + it('should reject second SSE stream for the same session', async () => { + sessionId = await initializeServer(); - // Clean up - don't wait indefinitely for server close - tempServer.close(); - }); + // Open first SSE stream + const firstStream = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26' + } + }); - it('should reject DELETE requests with invalid session ID', async () => { - // Initialize the server first to activate it - sessionId = await initializeServer(); + expect(firstStream.status).toBe(200); - // Try to delete with invalid session ID - const response = await fetch(baseUrl, { - method: 'DELETE', - headers: { - 'mcp-session-id': 'invalid-session-id', - 'mcp-protocol-version': '2025-03-26' - } - }); + // Try to open a second SSE stream with the same session ID + const secondStream = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26' + } + }); - expect(response.status).toBe(404); - const errorData = await response.json(); - expectErrorResponse(errorData, -32001, /Session not found/); - }); + // Should be rejected + expect(secondStream.status).toBe(409); // Conflict + const errorData = await secondStream.json(); + expectErrorResponse(errorData, -32000, /Only one SSE stream is allowed per session/); + }); - describe('protocol version header validation', () => { - it('should accept requests with matching protocol version', async () => { + it('should reject GET requests without Accept: text/event-stream header', async () => { sessionId = await initializeServer(); - // Send request with matching protocol version - const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId); + // Try GET without proper Accept header + const response = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'application/json', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26' + } + }); - expect(response.status).toBe(200); + expect(response.status).toBe(406); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Client must accept text\/event-stream/); }); - it('should accept requests without protocol version header', async () => { + it('should reject POST requests without proper Accept header', async () => { sessionId = await initializeServer(); - // Send request without protocol version header + // Try POST without Accept: text/event-stream const response = await fetch(baseUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', + Accept: 'application/json', // Missing text/event-stream 'mcp-session-id': sessionId - // No mcp-protocol-version header }, body: JSON.stringify(TEST_MESSAGES.toolsList) }); - expect(response.status).toBe(200); + expect(response.status).toBe(406); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Client must accept both application\/json and text\/event-stream/); }); - it('should reject requests with unsupported protocol version', async () => { + it('should reject unsupported Content-Type', async () => { sessionId = await initializeServer(); - // Send request with unsupported protocol version + // Try POST with text/plain Content-Type const response = await fetch(baseUrl, { method: 'POST', headers: { - 'Content-Type': 'application/json', + 'Content-Type': 'text/plain', Accept: 'application/json, text/event-stream', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': '1999-01-01' // Unsupported version + 'mcp-session-id': sessionId }, - body: JSON.stringify(TEST_MESSAGES.toolsList) + body: 'This is plain text' }); - expect(response.status).toBe(400); + expect(response.status).toBe(415); const errorData = await response.json(); - expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/); + expectErrorResponse(errorData, -32000, /Content-Type must be application\/json/); + }); + + it('should handle JSON-RPC batch notification messages with 202 response', async () => { + sessionId = await initializeServer(); + + // Send batch of notifications (no IDs) + const batchNotifications: JSONRPCMessage[] = [ + { jsonrpc: '2.0', method: 'someNotification1', params: {} }, + { jsonrpc: '2.0', method: 'someNotification2', params: {} } + ]; + const response = await sendPostRequest(baseUrl, batchNotifications, sessionId); + + expect(response.status).toBe(202); }); - it('should accept when protocol version differs from negotiated version', async () => { + it('should handle batch request messages with SSE stream for responses', async () => { sessionId = await initializeServer(); - // Spy on console.warn to verify warning is logged - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + // Send batch of requests + const batchRequests: JSONRPCMessage[] = [ + { jsonrpc: '2.0', method: 'tools/list', params: {}, id: 'req-1' }, + { jsonrpc: '2.0', method: 'tools/call', params: { name: 'greet', arguments: { name: 'BatchUser' } }, id: 'req-2' } + ]; + const response = await sendPostRequest(baseUrl, batchRequests, sessionId); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('text/event-stream'); + + const reader = response.body?.getReader(); + + // The responses may come in any order or together in one chunk + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); - // Send request with different but supported protocol version + // Check that both responses were sent on the same stream + expect(text).toContain('"id":"req-1"'); + expect(text).toContain('"tools"'); // tools/list result + expect(text).toContain('"id":"req-2"'); + expect(text).toContain('Hello, BatchUser'); // tools/call result + }); + + it('should properly handle invalid JSON data', async () => { + sessionId = await initializeServer(); + + // Send invalid JSON const response = await fetch(baseUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2024-11-05' // Different but supported version + 'mcp-session-id': sessionId }, - body: JSON.stringify(TEST_MESSAGES.toolsList) + body: 'This is not valid JSON' }); - // Request should still succeed - expect(response.status).toBe(200); + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32700, /Parse error/); + }); + + it('should return 400 error for invalid JSON-RPC messages', async () => { + sessionId = await initializeServer(); + + // Invalid JSON-RPC (missing required jsonrpc version) + const invalidMessage = { method: 'tools/list', params: {}, id: 1 }; // missing jsonrpc version + const response = await sendPostRequest(baseUrl, invalidMessage as JSONRPCMessage, sessionId); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expect(errorData).toMatchObject({ + jsonrpc: '2.0', + error: expect.anything() + }); + }); + + it('should reject requests to uninitialized server', async () => { + // Create a new HTTP server and transport without initializing + const { server: uninitializedServer, transport: uninitializedTransport, baseUrl: uninitializedUrl } = await createTestServer(); + // Transport not used in test but needed for cleanup + + // No initialization, just send a request directly + const uninitializedMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/list', + params: {}, + id: 'uninitialized-test' + }; - warnSpy.mockRestore(); + // Send a request to uninitialized server + const response = await sendPostRequest(uninitializedUrl, uninitializedMessage, 'any-session-id'); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Server not initialized/); + + // Cleanup + await stopTestServer({ server: uninitializedServer, transport: uninitializedTransport }); }); - it('should handle protocol version validation for GET requests', async () => { + it('should send response messages to the connection that sent the request', async () => { sessionId = await initializeServer(); - // GET request with unsupported protocol version - const response = await fetch(baseUrl, { + const message1: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/list', + params: {}, + id: 'req-1' + }; + + const message2: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'greet', + arguments: { name: 'Connection2' } + }, + id: 'req-2' + }; + + // Make two concurrent fetch connections for different requests + const req1 = sendPostRequest(baseUrl, message1, sessionId); + const req2 = sendPostRequest(baseUrl, message2, sessionId); + + // Get both responses + const [response1, response2] = await Promise.all([req1, req2]); + const reader1 = response1.body?.getReader(); + const reader2 = response2.body?.getReader(); + + // Read responses from each stream (requires each receives its specific response) + const { value: value1 } = await reader1!.read(); + const text1 = new TextDecoder().decode(value1); + expect(text1).toContain('"id":"req-1"'); + expect(text1).toContain('"tools"'); // tools/list result + + const { value: value2 } = await reader2!.read(); + const text2 = new TextDecoder().decode(value2); + expect(text2).toContain('"id":"req-2"'); + expect(text2).toContain('Hello, Connection2'); // tools/call result + }); + + it('should keep stream open after sending server notifications', async () => { + sessionId = await initializeServer(); + + // Open a standalone SSE stream + const sseResponse = await fetch(baseUrl, { method: 'GET', headers: { Accept: 'text/event-stream', 'mcp-session-id': sessionId, - 'mcp-protocol-version': 'invalid-version' + 'mcp-protocol-version': '2025-03-26' } }); - expect(response.status).toBe(400); - const errorData = await response.json(); - expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/); + // Send several server-initiated notifications + await transport.send({ + jsonrpc: '2.0', + method: 'notifications/message', + params: { level: 'info', data: 'First notification' } + }); + + await transport.send({ + jsonrpc: '2.0', + method: 'notifications/message', + params: { level: 'info', data: 'Second notification' } + }); + + // Stream should still be open - it should not close after sending notifications + expect(sseResponse.bodyUsed).toBe(false); + }); + + // The current implementation will close the entire transport for DELETE + // Creating a temporary transport/server where we don't care if it gets closed + it('should properly handle DELETE requests and close session', async () => { + // Setup a temporary server for this test + const tempResult = await createTestServer(); + const tempServer = tempResult.server; + const tempUrl = tempResult.baseUrl; + + // Initialize to get a session ID + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get('mcp-session-id'); + + // Now DELETE the session + const deleteResponse = await fetch(tempUrl, { + method: 'DELETE', + headers: { + 'mcp-session-id': tempSessionId || '', + 'mcp-protocol-version': '2025-03-26' + } + }); + + expect(deleteResponse.status).toBe(200); + + // Clean up - don't wait indefinitely for server close + tempServer.close(); }); - it('should handle protocol version validation for DELETE requests', async () => { + it('should reject DELETE requests with invalid session ID', async () => { + // Initialize the server first to activate it sessionId = await initializeServer(); - // DELETE request with unsupported protocol version + // Try to delete with invalid session ID const response = await fetch(baseUrl, { method: 'DELETE', headers: { - 'mcp-session-id': sessionId, - 'mcp-protocol-version': 'invalid-version' + 'mcp-session-id': 'invalid-session-id', + 'mcp-protocol-version': '2025-03-26' } }); - expect(response.status).toBe(400); + expect(response.status).toBe(404); const errorData = await response.json(); - expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/); + expectErrorResponse(errorData, -32001, /Session not found/); }); - }); -}); -describe('StreamableHTTPServerTransport with AuthInfo', () => { - let server: Server; - let transport: StreamableHTTPServerTransport; - let baseUrl: URL; - let sessionId: string; - - beforeEach(async () => { - const result = await createTestAuthServer(); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; - }); + describe('protocol version header validation', () => { + it('should accept requests with matching protocol version', async () => { + sessionId = await initializeServer(); - afterEach(async () => { - await stopTestServer({ server, transport }); - }); + // Send request with matching protocol version + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId); - async function initializeServer(): Promise { - const response = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + expect(response.status).toBe(200); + }); - expect(response.status).toBe(200); - const newSessionId = response.headers.get('mcp-session-id'); - expect(newSessionId).toBeDefined(); - return newSessionId as string; - } + it('should accept requests without protocol version header', async () => { + sessionId = await initializeServer(); + + // Send request without protocol version header + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId + // No mcp-protocol-version header + }, + body: JSON.stringify(TEST_MESSAGES.toolsList) + }); + + expect(response.status).toBe(200); + }); - it('should call a tool with authInfo', async () => { - sessionId = await initializeServer(); + it('should reject requests with unsupported protocol version', async () => { + sessionId = await initializeServer(); + + // Send request with unsupported protocol version + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '1999-01-01' // Unsupported version + }, + body: JSON.stringify(TEST_MESSAGES.toolsList) + }); + + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/); + }); - const toolCallMessage: JSONRPCMessage = { - jsonrpc: '2.0', - method: 'tools/call', - params: { - name: 'profile', - arguments: { active: true } - }, - id: 'call-1' - }; + it('should accept when protocol version differs from negotiated version', async () => { + sessionId = await initializeServer(); - const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId, { authorization: 'Bearer test-token' }); - expect(response.status).toBe(200); - - const text = await readSSEEvent(response); - const eventLines = text.split('\n'); - const dataLine = eventLines.find(line => line.startsWith('data:')); - expect(dataLine).toBeDefined(); - - const eventData = JSON.parse(dataLine!.substring(5)); - expect(eventData).toMatchObject({ - jsonrpc: '2.0', - result: { - content: [ - { - type: 'text', - text: 'Active profile from token: test-token!' - } - ] - }, - id: 'call-1' - }); - }); + // Spy on console.warn to verify warning is logged + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - it('should calls tool without authInfo when it is optional', async () => { - sessionId = await initializeServer(); + // Send request with different but supported protocol version + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2024-11-05' // Different but supported version + }, + body: JSON.stringify(TEST_MESSAGES.toolsList) + }); - const toolCallMessage: JSONRPCMessage = { - jsonrpc: '2.0', - method: 'tools/call', - params: { - name: 'profile', - arguments: { active: false } - }, - id: 'call-1' - }; + // Request should still succeed + expect(response.status).toBe(200); - const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId); - expect(response.status).toBe(200); - - const text = await readSSEEvent(response); - const eventLines = text.split('\n'); - const dataLine = eventLines.find(line => line.startsWith('data:')); - expect(dataLine).toBeDefined(); - - const eventData = JSON.parse(dataLine!.substring(5)); - expect(eventData).toMatchObject({ - jsonrpc: '2.0', - result: { - content: [ - { - type: 'text', - text: 'Inactive profile from token: undefined!' - } - ] - }, - id: 'call-1' - }); - }); -}); + warnSpy.mockRestore(); + }); + + it('should handle protocol version validation for GET requests', async () => { + sessionId = await initializeServer(); -// Test JSON Response Mode -describe('StreamableHTTPServerTransport with JSON Response Mode', () => { - let server: Server; - let transport: StreamableHTTPServerTransport; - let baseUrl: URL; - let sessionId: string; + // GET request with unsupported protocol version + const response = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': 'invalid-version' + } + }); - beforeEach(async () => { - const result = await createTestServer({ sessionIdGenerator: () => randomUUID(), enableJsonResponse: true }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/); + }); - // Initialize and get session ID - const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + it('should handle protocol version validation for DELETE requests', async () => { + sessionId = await initializeServer(); - sessionId = initResponse.headers.get('mcp-session-id') as string; - }); + // DELETE request with unsupported protocol version + const response = await fetch(baseUrl, { + method: 'DELETE', + headers: { + 'mcp-session-id': sessionId, + 'mcp-protocol-version': 'invalid-version' + } + }); - afterEach(async () => { - await stopTestServer({ server, transport }); + expect(response.status).toBe(400); + const errorData = await response.json(); + expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/); + }); + }); }); - it('should return JSON response for a single request', async () => { - const toolsListMessage: JSONRPCMessage = { - jsonrpc: '2.0', - method: 'tools/list', - params: {}, - id: 'json-req-1' - }; - - const response = await sendPostRequest(baseUrl, toolsListMessage, sessionId); + describe('StreamableHTTPServerTransport with AuthInfo', () => { + let server: Server; + let transport: StreamableHTTPServerTransport; + let baseUrl: URL; + let sessionId: string; - expect(response.status).toBe(200); - expect(response.headers.get('content-type')).toBe('application/json'); + beforeEach(async () => { + const result = await createTestAuthServer(); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + }); - const result = await response.json(); - expect(result).toMatchObject({ - jsonrpc: '2.0', - result: expect.objectContaining({ - tools: expect.arrayContaining([expect.objectContaining({ name: 'greet' })]) - }), - id: 'json-req-1' + afterEach(async () => { + await stopTestServer({ server, transport }); }); - }); - it('should return JSON response for batch requests', async () => { - const batchMessages: JSONRPCMessage[] = [ - { jsonrpc: '2.0', method: 'tools/list', params: {}, id: 'batch-1' }, - { jsonrpc: '2.0', method: 'tools/call', params: { name: 'greet', arguments: { name: 'JSON' } }, id: 'batch-2' } - ]; + async function initializeServer(): Promise { + const response = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); - const response = await sendPostRequest(baseUrl, batchMessages, sessionId); + expect(response.status).toBe(200); + const newSessionId = response.headers.get('mcp-session-id'); + expect(newSessionId).toBeDefined(); + return newSessionId as string; + } - expect(response.status).toBe(200); - expect(response.headers.get('content-type')).toBe('application/json'); + it('should call a tool with authInfo', async () => { + sessionId = await initializeServer(); - const results = await response.json(); - expect(Array.isArray(results)).toBe(true); - expect(results).toHaveLength(2); + const toolCallMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'profile', + arguments: { active: true } + }, + id: 'call-1' + }; - // Batch responses can come in any order - const listResponse = results.find((r: { id?: string }) => r.id === 'batch-1'); - const callResponse = results.find((r: { id?: string }) => r.id === 'batch-2'); + const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId, { authorization: 'Bearer test-token' }); + expect(response.status).toBe(200); - expect(listResponse).toEqual( - expect.objectContaining({ - jsonrpc: '2.0', - id: 'batch-1', - result: expect.objectContaining({ - tools: expect.arrayContaining([expect.objectContaining({ name: 'greet' })]) - }) - }) - ); + const text = await readSSEEvent(response); + const eventLines = text.split('\n'); + const dataLine = eventLines.find(line => line.startsWith('data:')); + expect(dataLine).toBeDefined(); - expect(callResponse).toEqual( - expect.objectContaining({ + const eventData = JSON.parse(dataLine!.substring(5)); + expect(eventData).toMatchObject({ jsonrpc: '2.0', - id: 'batch-2', - result: expect.objectContaining({ - content: expect.arrayContaining([expect.objectContaining({ type: 'text', text: 'Hello, JSON!' })]) - }) - }) - ); - }); -}); - -// Test pre-parsed body handling -describe('StreamableHTTPServerTransport with pre-parsed body', () => { - let server: Server; - let transport: StreamableHTTPServerTransport; - let baseUrl: URL; - let sessionId: string; - let parsedBody: unknown = null; - - beforeEach(async () => { - const result = await createTestServer({ - customRequestHandler: async (req, res) => { - try { - if (parsedBody !== null) { - await transport.handleRequest(req, res, parsedBody); - parsedBody = null; // Reset after use - } else { - await transport.handleRequest(req, res); - } - } catch (error) { - console.error('Error handling request:', error); - if (!res.headersSent) res.writeHead(500).end(); - } - }, - sessionIdGenerator: () => randomUUID() + result: { + content: [ + { + type: 'text', + text: 'Active profile from token: test-token!' + } + ] + }, + id: 'call-1' + }); }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; + it('should calls tool without authInfo when it is optional', async () => { + sessionId = await initializeServer(); - // Initialize and get session ID - const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); - sessionId = initResponse.headers.get('mcp-session-id') as string; - }); + const toolCallMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'profile', + arguments: { active: false } + }, + id: 'call-1' + }; - afterEach(async () => { - await stopTestServer({ server, transport }); - }); + const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId); + expect(response.status).toBe(200); - it('should accept pre-parsed request body', async () => { - // Set up the pre-parsed body - parsedBody = { - jsonrpc: '2.0', - method: 'tools/list', - params: {}, - id: 'preparsed-1' - }; + const text = await readSSEEvent(response); + const eventLines = text.split('\n'); + const dataLine = eventLines.find(line => line.startsWith('data:')); + expect(dataLine).toBeDefined(); - // Send an empty body since we'll use pre-parsed body - const response = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - 'mcp-session-id': sessionId - }, - // Empty body - we're testing pre-parsed body - body: '' + const eventData = JSON.parse(dataLine!.substring(5)); + expect(eventData).toMatchObject({ + jsonrpc: '2.0', + result: { + content: [ + { + type: 'text', + text: 'Inactive profile from token: undefined!' + } + ] + }, + id: 'call-1' + }); }); + }); - expect(response.status).toBe(200); - expect(response.headers.get('content-type')).toBe('text/event-stream'); + // Test JSON Response Mode + describe('StreamableHTTPServerTransport with JSON Response Mode', () => { + let server: Server; + let transport: StreamableHTTPServerTransport; + let baseUrl: URL; + let sessionId: string; - const reader = response.body?.getReader(); - const { value } = await reader!.read(); - const text = new TextDecoder().decode(value); + beforeEach(async () => { + const result = await createTestServer({ sessionIdGenerator: () => randomUUID(), enableJsonResponse: true }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; - // Verify the response used the pre-parsed body - expect(text).toContain('"id":"preparsed-1"'); - expect(text).toContain('"tools"'); - }); + // Initialize and get session ID + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); - it('should handle pre-parsed batch messages', async () => { - parsedBody = [ - { jsonrpc: '2.0', method: 'tools/list', params: {}, id: 'batch-1' }, - { jsonrpc: '2.0', method: 'tools/call', params: { name: 'greet', arguments: { name: 'PreParsed' } }, id: 'batch-2' } - ]; - - const response = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - 'mcp-session-id': sessionId - }, - body: '' // Empty as we're using pre-parsed + sessionId = initResponse.headers.get('mcp-session-id') as string; }); - expect(response.status).toBe(200); + afterEach(async () => { + await stopTestServer({ server, transport }); + }); - const reader = response.body?.getReader(); - const { value } = await reader!.read(); - const text = new TextDecoder().decode(value); + it('should return JSON response for a single request', async () => { + const toolsListMessage: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'tools/list', + params: {}, + id: 'json-req-1' + }; - expect(text).toContain('"id":"batch-1"'); - expect(text).toContain('"tools"'); - }); + const response = await sendPostRequest(baseUrl, toolsListMessage, sessionId); - it('should prefer pre-parsed body over request body', async () => { - // Set pre-parsed to tools/list - parsedBody = { - jsonrpc: '2.0', - method: 'tools/list', - params: {}, - id: 'preparsed-wins' - }; + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('application/json'); - // Send actual body with tools/call - should be ignored - const response = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - 'mcp-session-id': sessionId - }, - body: JSON.stringify({ + const result = await response.json(); + expect(result).toMatchObject({ jsonrpc: '2.0', - method: 'tools/call', - params: { name: 'greet', arguments: { name: 'Ignored' } }, - id: 'ignored-id' - }) + result: expect.objectContaining({ + tools: expect.arrayContaining([expect.objectContaining({ name: 'greet' })]) + }), + id: 'json-req-1' + }); }); - expect(response.status).toBe(200); + it('should return JSON response for batch requests', async () => { + const batchMessages: JSONRPCMessage[] = [ + { jsonrpc: '2.0', method: 'tools/list', params: {}, id: 'batch-1' }, + { jsonrpc: '2.0', method: 'tools/call', params: { name: 'greet', arguments: { name: 'JSON' } }, id: 'batch-2' } + ]; - const reader = response.body?.getReader(); - const { value } = await reader!.read(); - const text = new TextDecoder().decode(value); + const response = await sendPostRequest(baseUrl, batchMessages, sessionId); - // Should have processed the pre-parsed body - expect(text).toContain('"id":"preparsed-wins"'); - expect(text).toContain('"tools"'); - expect(text).not.toContain('"ignored-id"'); - }); -}); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('application/json'); + + const results = await response.json(); + expect(Array.isArray(results)).toBe(true); + expect(results).toHaveLength(2); + + // Batch responses can come in any order + const listResponse = results.find((r: { id?: string }) => r.id === 'batch-1'); + const callResponse = results.find((r: { id?: string }) => r.id === 'batch-2'); + + expect(listResponse).toEqual( + expect.objectContaining({ + jsonrpc: '2.0', + id: 'batch-1', + result: expect.objectContaining({ + tools: expect.arrayContaining([expect.objectContaining({ name: 'greet' })]) + }) + }) + ); + + expect(callResponse).toEqual( + expect.objectContaining({ + jsonrpc: '2.0', + id: 'batch-2', + result: expect.objectContaining({ + content: expect.arrayContaining([expect.objectContaining({ type: 'text', text: 'Hello, JSON!' })]) + }) + }) + ); + }); + }); + + // Test pre-parsed body handling + describe('StreamableHTTPServerTransport with pre-parsed body', () => { + let server: Server; + let transport: StreamableHTTPServerTransport; + let baseUrl: URL; + let sessionId: string; + let parsedBody: unknown = null; + + beforeEach(async () => { + const result = await createTestServer({ + customRequestHandler: async (req, res) => { + try { + if (parsedBody !== null) { + await transport.handleRequest(req, res, parsedBody); + parsedBody = null; // Reset after use + } else { + await transport.handleRequest(req, res); + } + } catch (error) { + console.error('Error handling request:', error); + if (!res.headersSent) res.writeHead(500).end(); + } + }, + sessionIdGenerator: () => randomUUID() + }); -// Test resumability support -describe('StreamableHTTPServerTransport with resumability', () => { - let server: Server; - let transport: StreamableHTTPServerTransport; - let baseUrl: URL; - let sessionId: string; - let mcpServer: McpServer; - const storedEvents: Map = new Map(); - - // Simple implementation of EventStore - const eventStore: EventStore = { - async storeEvent(streamId: string, message: JSONRPCMessage): Promise { - const eventId = `${streamId}_${randomUUID()}`; - storedEvents.set(eventId, { eventId, message }); - return eventId; - }, + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; - async replayEventsAfter( - lastEventId: EventId, - { - send - }: { - send: (eventId: EventId, message: JSONRPCMessage) => Promise; - } - ): Promise { - const streamId = lastEventId.split('_')[0]; - // Extract stream ID from the event ID - // For test simplicity, just return all events with matching streamId that aren't the lastEventId - for (const [eventId, { message }] of storedEvents.entries()) { - if (eventId.startsWith(streamId) && eventId !== lastEventId) { - await send(eventId, message); - } - } - return streamId; - } - }; + // Initialize and get session ID + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + sessionId = initResponse.headers.get('mcp-session-id') as string; + }); - beforeEach(async () => { - storedEvents.clear(); - const result = await createTestServer({ - sessionIdGenerator: () => randomUUID(), - eventStore + afterEach(async () => { + await stopTestServer({ server, transport }); }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; - mcpServer = result.mcpServer; + it('should accept pre-parsed request body', async () => { + // Set up the pre-parsed body + parsedBody = { + jsonrpc: '2.0', + method: 'tools/list', + params: {}, + id: 'preparsed-1' + }; - // Verify resumability is enabled on the transport - expect(transport['_eventStore']).toBeDefined(); + // Send an empty body since we'll use pre-parsed body + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId + }, + // Empty body - we're testing pre-parsed body + body: '' + }); - // Initialize the server - const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); - sessionId = initResponse.headers.get('mcp-session-id') as string; - expect(sessionId).toBeDefined(); - }); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('text/event-stream'); - afterEach(async () => { - await stopTestServer({ server, transport }); - storedEvents.clear(); - }); + const reader = response.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); - it('should store and include event IDs in server SSE messages', async () => { - // Open a standalone SSE stream - const sseResponse = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'text/event-stream', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2025-03-26' - } + // Verify the response used the pre-parsed body + expect(text).toContain('"id":"preparsed-1"'); + expect(text).toContain('"tools"'); }); - expect(sseResponse.status).toBe(200); - expect(sseResponse.headers.get('content-type')).toBe('text/event-stream'); - - // Send a notification that should be stored with an event ID - const notification: JSONRPCMessage = { - jsonrpc: '2.0', - method: 'notifications/message', - params: { level: 'info', data: 'Test notification with event ID' } - }; - - // Send the notification via transport - await transport.send(notification); + it('should handle pre-parsed batch messages', async () => { + parsedBody = [ + { jsonrpc: '2.0', method: 'tools/list', params: {}, id: 'batch-1' }, + { jsonrpc: '2.0', method: 'tools/call', params: { name: 'greet', arguments: { name: 'PreParsed' } }, id: 'batch-2' } + ]; - // Read from the stream and verify we got the notification with an event ID - const reader = sseResponse.body?.getReader(); - const { value } = await reader!.read(); - const text = new TextDecoder().decode(value); - - // The response should contain an event ID - expect(text).toContain('id: '); - expect(text).toContain('"method":"notifications/message"'); + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId + }, + body: '' // Empty as we're using pre-parsed + }); - // Extract the event ID - const idMatch = text.match(/id: ([^\n]+)/); - expect(idMatch).toBeTruthy(); + expect(response.status).toBe(200); - // Verify the event was stored - const eventId = idMatch![1]; - expect(storedEvents.has(eventId)).toBe(true); - const storedEvent = storedEvents.get(eventId); - expect(eventId.startsWith('_GET_stream')).toBe(true); - expect(storedEvent?.message).toMatchObject(notification); - }); + const reader = response.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); - it('should store and replay MCP server tool notifications', async () => { - // Establish a standalone SSE stream - const sseResponse = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'text/event-stream', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2025-03-26' - } + expect(text).toContain('"id":"batch-1"'); + expect(text).toContain('"tools"'); }); - expect(sseResponse.status).toBe(200); - // Send a server notification through the MCP server - await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'First notification from MCP server' }); + it('should prefer pre-parsed body over request body', async () => { + // Set pre-parsed to tools/list + parsedBody = { + jsonrpc: '2.0', + method: 'tools/list', + params: {}, + id: 'preparsed-wins' + }; + + // Send actual body with tools/call - should be ignored + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': sessionId + }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'tools/call', + params: { name: 'greet', arguments: { name: 'Ignored' } }, + id: 'ignored-id' + }) + }); - // Read the notification from the SSE stream - const reader = sseResponse.body?.getReader(); - const { value } = await reader!.read(); - const text = new TextDecoder().decode(value); + expect(response.status).toBe(200); - // Verify the notification was sent with an event ID - expect(text).toContain('id: '); - expect(text).toContain('First notification from MCP server'); + const reader = response.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); - // Extract the event ID - const idMatch = text.match(/id: ([^\n]+)/); - expect(idMatch).toBeTruthy(); - const firstEventId = idMatch![1]; + // Should have processed the pre-parsed body + expect(text).toContain('"id":"preparsed-wins"'); + expect(text).toContain('"tools"'); + expect(text).not.toContain('"ignored-id"'); + }); + }); - // Send a second notification - await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'Second notification from MCP server' }); + // Test resumability support + describe('StreamableHTTPServerTransport with resumability', () => { + let server: Server; + let transport: StreamableHTTPServerTransport; + let baseUrl: URL; + let sessionId: string; + let mcpServer: McpServer; + const storedEvents: Map = new Map(); - // Close the first SSE stream to simulate a disconnect - await reader!.cancel(); + // Simple implementation of EventStore + const eventStore: EventStore = { + async storeEvent(streamId: string, message: JSONRPCMessage): Promise { + const eventId = `${streamId}_${randomUUID()}`; + storedEvents.set(eventId, { eventId, message }); + return eventId; + }, - // Reconnect with the Last-Event-ID to get missed messages - const reconnectResponse = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'text/event-stream', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2025-03-26', - 'last-event-id': firstEventId + async replayEventsAfter( + lastEventId: EventId, + { + send + }: { + send: (eventId: EventId, message: JSONRPCMessage) => Promise; + } + ): Promise { + const streamId = lastEventId.split('_')[0]; + // Extract stream ID from the event ID + // For test simplicity, just return all events with matching streamId that aren't the lastEventId + for (const [eventId, { message }] of storedEvents.entries()) { + if (eventId.startsWith(streamId) && eventId !== lastEventId) { + await send(eventId, message); + } + } + return streamId; } - }); + }; - expect(reconnectResponse.status).toBe(200); + beforeEach(async () => { + storedEvents.clear(); + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + eventStore + }); - // Read the replayed notification - const reconnectReader = reconnectResponse.body?.getReader(); - const reconnectData = await reconnectReader!.read(); - const reconnectText = new TextDecoder().decode(reconnectData.value); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + mcpServer = result.mcpServer; - // Verify we received the second notification that was sent after our stored eventId - expect(reconnectText).toContain('Second notification from MCP server'); - expect(reconnectText).toContain('id: '); - }); -}); + // Verify resumability is enabled on the transport + expect(transport['_eventStore']).toBeDefined(); -// Test stateless mode -describe('StreamableHTTPServerTransport in stateless mode', () => { - let server: Server; - let transport: StreamableHTTPServerTransport; - let baseUrl: URL; - - beforeEach(async () => { - const result = await createTestServer({ sessionIdGenerator: undefined }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; - }); + // Initialize the server + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + sessionId = initResponse.headers.get('mcp-session-id') as string; + expect(sessionId).toBeDefined(); + }); - afterEach(async () => { - await stopTestServer({ server, transport }); - }); + afterEach(async () => { + await stopTestServer({ server, transport }); + storedEvents.clear(); + }); - it('should operate without session ID validation', async () => { - // Initialize the server first - const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + it('should store and include event IDs in server SSE messages', async () => { + // Open a standalone SSE stream + const sseResponse = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26' + } + }); - expect(initResponse.status).toBe(200); - // Should NOT have session ID header in stateless mode - expect(initResponse.headers.get('mcp-session-id')).toBeNull(); + expect(sseResponse.status).toBe(200); + expect(sseResponse.headers.get('content-type')).toBe('text/event-stream'); - // Try request without session ID - should work in stateless mode - const toolsResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList); + // Send a notification that should be stored with an event ID + const notification: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'notifications/message', + params: { level: 'info', data: 'Test notification with event ID' } + }; - expect(toolsResponse.status).toBe(200); - }); + // Send the notification via transport + await transport.send(notification); - it('should handle POST requests with various session IDs in stateless mode', async () => { - await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + // Read from the stream and verify we got the notification with an event ID + const reader = sseResponse.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); - // Try with a random session ID - should be accepted - const response1 = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - 'mcp-session-id': 'random-id-1' - }, - body: JSON.stringify({ jsonrpc: '2.0', method: 'tools/list', params: {}, id: 't1' }) - }); - expect(response1.status).toBe(200); + // The response should contain an event ID + expect(text).toContain('id: '); + expect(text).toContain('"method":"notifications/message"'); - // Try with another random session ID - should also be accepted - const response2 = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - 'mcp-session-id': 'different-id-2' - }, - body: JSON.stringify({ jsonrpc: '2.0', method: 'tools/list', params: {}, id: 't2' }) - }); - expect(response2.status).toBe(200); - }); + // Extract the event ID + const idMatch = text.match(/id: ([^\n]+)/); + expect(idMatch).toBeTruthy(); - it('should reject second SSE stream even in stateless mode', async () => { - // Despite no session ID requirement, the transport still only allows - // one standalone SSE stream at a time + // Verify the event was stored + const eventId = idMatch![1]; + expect(storedEvents.has(eventId)).toBe(true); + const storedEvent = storedEvents.get(eventId); + expect(eventId.startsWith('_GET_stream')).toBe(true); + expect(storedEvent?.message).toMatchObject(notification); + }); - // Initialize the server first - await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + it('should store and replay MCP server tool notifications', async () => { + // Establish a standalone SSE stream + const sseResponse = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26' + } + }); + expect(sseResponse.status).toBe(200); - // Open first SSE stream - const stream1 = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'text/event-stream', - 'mcp-protocol-version': '2025-03-26' - } - }); - expect(stream1.status).toBe(200); + // Send a server notification through the MCP server + await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'First notification from MCP server' }); - // Open second SSE stream - should still be rejected, stateless mode still only allows one - const stream2 = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'text/event-stream', - 'mcp-protocol-version': '2025-03-26' - } - }); - expect(stream2.status).toBe(409); // Conflict - only one stream allowed - }); -}); + // Read the notification from the SSE stream + const reader = sseResponse.body?.getReader(); + const { value } = await reader!.read(); + const text = new TextDecoder().decode(value); -// Test onsessionclosed callback -describe('StreamableHTTPServerTransport onsessionclosed callback', () => { - it('should call onsessionclosed callback when session is closed via DELETE', async () => { - const mockCallback = vi.fn(); + // Verify the notification was sent with an event ID + expect(text).toContain('id: '); + expect(text).toContain('First notification from MCP server'); - // Create server with onsessionclosed callback - const result = await createTestServer({ - sessionIdGenerator: () => randomUUID(), - onsessionclosed: mockCallback - }); + // Extract the event ID + const idMatch = text.match(/id: ([^\n]+)/); + expect(idMatch).toBeTruthy(); + const firstEventId = idMatch![1]; - const tempServer = result.server; - const tempUrl = result.baseUrl; + // Send a second notification + await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'Second notification from MCP server' }); - // Initialize to get a session ID - const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); - const tempSessionId = initResponse.headers.get('mcp-session-id'); - expect(tempSessionId).toBeDefined(); + // Close the first SSE stream to simulate a disconnect + await reader!.cancel(); - // DELETE the session - const deleteResponse = await fetch(tempUrl, { - method: 'DELETE', - headers: { - 'mcp-session-id': tempSessionId || '', - 'mcp-protocol-version': '2025-03-26' - } - }); + // Reconnect with the Last-Event-ID to get missed messages + const reconnectResponse = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-session-id': sessionId, + 'mcp-protocol-version': '2025-03-26', + 'last-event-id': firstEventId + } + }); - expect(deleteResponse.status).toBe(200); - expect(mockCallback).toHaveBeenCalledWith(tempSessionId); - expect(mockCallback).toHaveBeenCalledTimes(1); + expect(reconnectResponse.status).toBe(200); - // Clean up - tempServer.close(); - }); + // Read the replayed notification + const reconnectReader = reconnectResponse.body?.getReader(); + const reconnectData = await reconnectReader!.read(); + const reconnectText = new TextDecoder().decode(reconnectData.value); - it('should not call onsessionclosed callback when not provided', async () => { - // Create server without onsessionclosed callback - const result = await createTestServer({ - sessionIdGenerator: () => randomUUID() + // Verify we received the second notification that was sent after our stored eventId + expect(reconnectText).toContain('Second notification from MCP server'); + expect(reconnectText).toContain('id: '); }); + }); - const tempServer = result.server; - const tempUrl = result.baseUrl; + // Test stateless mode + describe('StreamableHTTPServerTransport in stateless mode', () => { + let server: Server; + let transport: StreamableHTTPServerTransport; + let baseUrl: URL; - // Initialize to get a session ID - const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); - const tempSessionId = initResponse.headers.get('mcp-session-id'); + beforeEach(async () => { + const result = await createTestServer({ sessionIdGenerator: undefined }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + }); - // DELETE the session - should not throw error - const deleteResponse = await fetch(tempUrl, { - method: 'DELETE', - headers: { - 'mcp-session-id': tempSessionId || '', - 'mcp-protocol-version': '2025-03-26' - } + afterEach(async () => { + await stopTestServer({ server, transport }); }); - expect(deleteResponse.status).toBe(200); + it('should operate without session ID validation', async () => { + // Initialize the server first + const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); - // Clean up - tempServer.close(); - }); + expect(initResponse.status).toBe(200); + // Should NOT have session ID header in stateless mode + expect(initResponse.headers.get('mcp-session-id')).toBeNull(); - it('should not call onsessionclosed callback for invalid session DELETE', async () => { - const mockCallback = vi.fn(); + // Try request without session ID - should work in stateless mode + const toolsResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList); - // Create server with onsessionclosed callback - const result = await createTestServer({ - sessionIdGenerator: () => randomUUID(), - onsessionclosed: mockCallback + expect(toolsResponse.status).toBe(200); }); - const tempServer = result.server; - const tempUrl = result.baseUrl; + it('should handle POST requests with various session IDs in stateless mode', async () => { + await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); - // Initialize to get a valid session - await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + // Try with a random session ID - should be accepted + const response1 = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': 'random-id-1' + }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'tools/list', params: {}, id: 't1' }) + }); + expect(response1.status).toBe(200); - // Try to DELETE with invalid session ID - const deleteResponse = await fetch(tempUrl, { - method: 'DELETE', - headers: { - 'mcp-session-id': 'invalid-session-id', - 'mcp-protocol-version': '2025-03-26' - } + // Try with another random session ID - should also be accepted + const response2 = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-session-id': 'different-id-2' + }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'tools/list', params: {}, id: 't2' }) + }); + expect(response2.status).toBe(200); }); - expect(deleteResponse.status).toBe(404); - expect(mockCallback).not.toHaveBeenCalled(); + it('should reject second SSE stream even in stateless mode', async () => { + // Despite no session ID requirement, the transport still only allows + // one standalone SSE stream at a time - // Clean up - tempServer.close(); - }); + // Initialize the server first + await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); - it('should call onsessionclosed callback with correct session ID when multiple sessions exist', async () => { - const mockCallback = vi.fn(); + // Open first SSE stream + const stream1 = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-protocol-version': '2025-03-26' + } + }); + expect(stream1.status).toBe(200); - // Create first server - const result1 = await createTestServer({ - sessionIdGenerator: () => randomUUID(), - onsessionclosed: mockCallback + // Open second SSE stream - should still be rejected, stateless mode still only allows one + const stream2 = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream', + 'mcp-protocol-version': '2025-03-26' + } + }); + expect(stream2.status).toBe(409); // Conflict - only one stream allowed }); + }); - const server1 = result1.server; - const url1 = result1.baseUrl; + // Test onsessionclosed callback + describe('StreamableHTTPServerTransport onsessionclosed callback', () => { + it('should call onsessionclosed callback when session is closed via DELETE', async () => { + const mockCallback = vi.fn(); - // Create second server - const result2 = await createTestServer({ - sessionIdGenerator: () => randomUUID(), - onsessionclosed: mockCallback - }); + // Create server with onsessionclosed callback + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: mockCallback + }); - const server2 = result2.server; - const url2 = result2.baseUrl; + const tempServer = result.server; + const tempUrl = result.baseUrl; - // Initialize both servers - const initResponse1 = await sendPostRequest(url1, TEST_MESSAGES.initialize); - const sessionId1 = initResponse1.headers.get('mcp-session-id'); + // Initialize to get a session ID + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get('mcp-session-id'); + expect(tempSessionId).toBeDefined(); - const initResponse2 = await sendPostRequest(url2, TEST_MESSAGES.initialize); - const sessionId2 = initResponse2.headers.get('mcp-session-id'); + // DELETE the session + const deleteResponse = await fetch(tempUrl, { + method: 'DELETE', + headers: { + 'mcp-session-id': tempSessionId || '', + 'mcp-protocol-version': '2025-03-26' + } + }); - expect(sessionId1).toBeDefined(); - expect(sessionId2).toBeDefined(); - expect(sessionId1).not.toBe(sessionId2); + expect(deleteResponse.status).toBe(200); + expect(mockCallback).toHaveBeenCalledWith(tempSessionId); + expect(mockCallback).toHaveBeenCalledTimes(1); - // DELETE first session - const deleteResponse1 = await fetch(url1, { - method: 'DELETE', - headers: { - 'mcp-session-id': sessionId1 || '', - 'mcp-protocol-version': '2025-03-26' - } + // Clean up + tempServer.close(); }); - expect(deleteResponse1.status).toBe(200); - expect(mockCallback).toHaveBeenCalledWith(sessionId1); - expect(mockCallback).toHaveBeenCalledTimes(1); + it('should not call onsessionclosed callback when not provided', async () => { + // Create server without onsessionclosed callback + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID() + }); - // DELETE second session - const deleteResponse2 = await fetch(url2, { - method: 'DELETE', - headers: { - 'mcp-session-id': sessionId2 || '', - 'mcp-protocol-version': '2025-03-26' - } - }); + const tempServer = result.server; + const tempUrl = result.baseUrl; - expect(deleteResponse2.status).toBe(200); - expect(mockCallback).toHaveBeenCalledWith(sessionId2); - expect(mockCallback).toHaveBeenCalledTimes(2); + // Initialize to get a session ID + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get('mcp-session-id'); - // Clean up - server1.close(); - server2.close(); - }); -}); + // DELETE the session - should not throw error + const deleteResponse = await fetch(tempUrl, { + method: 'DELETE', + headers: { + 'mcp-session-id': tempSessionId || '', + 'mcp-protocol-version': '2025-03-26' + } + }); -// Test async callbacks for onsessioninitialized and onsessionclosed -describe('StreamableHTTPServerTransport async callbacks', () => { - it('should support async onsessioninitialized callback', async () => { - const initializationOrder: string[] = []; - - // Create server with async onsessioninitialized callback - const result = await createTestServer({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: async (sessionId: string) => { - initializationOrder.push('async-start'); - // Simulate async operation - await new Promise(resolve => setTimeout(resolve, 10)); - initializationOrder.push('async-end'); - initializationOrder.push(sessionId); - } + expect(deleteResponse.status).toBe(200); + + // Clean up + tempServer.close(); }); - const tempServer = result.server; - const tempUrl = result.baseUrl; + it('should not call onsessionclosed callback for invalid session DELETE', async () => { + const mockCallback = vi.fn(); - // Initialize to trigger the callback - const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); - const tempSessionId = initResponse.headers.get('mcp-session-id'); + // Create server with onsessionclosed callback + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: mockCallback + }); - // Give time for async callback to complete - await new Promise(resolve => setTimeout(resolve, 50)); + const tempServer = result.server; + const tempUrl = result.baseUrl; - expect(initializationOrder).toEqual(['async-start', 'async-end', tempSessionId]); + // Initialize to get a valid session + await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); - // Clean up - tempServer.close(); - }); + // Try to DELETE with invalid session ID + const deleteResponse = await fetch(tempUrl, { + method: 'DELETE', + headers: { + 'mcp-session-id': 'invalid-session-id', + 'mcp-protocol-version': '2025-03-26' + } + }); - it('should support sync onsessioninitialized callback (backwards compatibility)', async () => { - const capturedSessionId: string[] = []; + expect(deleteResponse.status).toBe(404); + expect(mockCallback).not.toHaveBeenCalled(); - // Create server with sync onsessioninitialized callback - const result = await createTestServer({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: (sessionId: string) => { - capturedSessionId.push(sessionId); - } + // Clean up + tempServer.close(); }); - const tempServer = result.server; - const tempUrl = result.baseUrl; + it('should call onsessionclosed callback with correct session ID when multiple sessions exist', async () => { + const mockCallback = vi.fn(); - // Initialize to trigger the callback - const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); - const tempSessionId = initResponse.headers.get('mcp-session-id'); + // Create first server + const result1 = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: mockCallback + }); - expect(capturedSessionId).toEqual([tempSessionId]); + const server1 = result1.server; + const url1 = result1.baseUrl; - // Clean up - tempServer.close(); - }); + // Create second server + const result2 = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: mockCallback + }); - it('should support async onsessionclosed callback', async () => { - const closureOrder: string[] = []; - - // Create server with async onsessionclosed callback - const result = await createTestServer({ - sessionIdGenerator: () => randomUUID(), - onsessionclosed: async (sessionId: string) => { - closureOrder.push('async-close-start'); - // Simulate async operation - await new Promise(resolve => setTimeout(resolve, 10)); - closureOrder.push('async-close-end'); - closureOrder.push(sessionId); - } - }); + const server2 = result2.server; + const url2 = result2.baseUrl; - const tempServer = result.server; - const tempUrl = result.baseUrl; + // Initialize both servers + const initResponse1 = await sendPostRequest(url1, TEST_MESSAGES.initialize); + const sessionId1 = initResponse1.headers.get('mcp-session-id'); - // Initialize to get a session ID - const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); - const tempSessionId = initResponse.headers.get('mcp-session-id'); - expect(tempSessionId).toBeDefined(); + const initResponse2 = await sendPostRequest(url2, TEST_MESSAGES.initialize); + const sessionId2 = initResponse2.headers.get('mcp-session-id'); - // DELETE the session - const deleteResponse = await fetch(tempUrl, { - method: 'DELETE', - headers: { - 'mcp-session-id': tempSessionId || '', - 'mcp-protocol-version': '2025-03-26' - } - }); + expect(sessionId1).toBeDefined(); + expect(sessionId2).toBeDefined(); + expect(sessionId1).not.toBe(sessionId2); - expect(deleteResponse.status).toBe(200); + // DELETE first session + const deleteResponse1 = await fetch(url1, { + method: 'DELETE', + headers: { + 'mcp-session-id': sessionId1 || '', + 'mcp-protocol-version': '2025-03-26' + } + }); - // Give time for async callback to complete - await new Promise(resolve => setTimeout(resolve, 50)); + expect(deleteResponse1.status).toBe(200); + expect(mockCallback).toHaveBeenCalledWith(sessionId1); + expect(mockCallback).toHaveBeenCalledTimes(1); - expect(closureOrder).toEqual(['async-close-start', 'async-close-end', tempSessionId]); + // DELETE second session + const deleteResponse2 = await fetch(url2, { + method: 'DELETE', + headers: { + 'mcp-session-id': sessionId2 || '', + 'mcp-protocol-version': '2025-03-26' + } + }); + + expect(deleteResponse2.status).toBe(200); + expect(mockCallback).toHaveBeenCalledWith(sessionId2); + expect(mockCallback).toHaveBeenCalledTimes(2); - // Clean up - tempServer.close(); + // Clean up + server1.close(); + server2.close(); + }); }); - it('should propagate errors from async onsessioninitialized callback', async () => { - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + // Test async callbacks for onsessioninitialized and onsessionclosed + describe('StreamableHTTPServerTransport async callbacks', () => { + it('should support async onsessioninitialized callback', async () => { + const initializationOrder: string[] = []; - // Create server with async onsessioninitialized callback that throws - const result = await createTestServer({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: async (_sessionId: string) => { - throw new Error('Async initialization error'); - } - }); + // Create server with async onsessioninitialized callback + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: async (sessionId: string) => { + initializationOrder.push('async-start'); + // Simulate async operation + await new Promise(resolve => setTimeout(resolve, 10)); + initializationOrder.push('async-end'); + initializationOrder.push(sessionId); + } + }); - const tempServer = result.server; - const tempUrl = result.baseUrl; + const tempServer = result.server; + const tempUrl = result.baseUrl; - // Initialize should fail when callback throws - const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); - expect(initResponse.status).toBe(400); + // Initialize to trigger the callback + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get('mcp-session-id'); - // Clean up - consoleErrorSpy.mockRestore(); - tempServer.close(); - }); + // Give time for async callback to complete + await new Promise(resolve => setTimeout(resolve, 50)); - it('should propagate errors from async onsessionclosed callback', async () => { - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + expect(initializationOrder).toEqual(['async-start', 'async-end', tempSessionId]); - // Create server with async onsessionclosed callback that throws - const result = await createTestServer({ - sessionIdGenerator: () => randomUUID(), - onsessionclosed: async (_sessionId: string) => { - throw new Error('Async closure error'); - } + // Clean up + tempServer.close(); }); - const tempServer = result.server; - const tempUrl = result.baseUrl; + it('should support sync onsessioninitialized callback (backwards compatibility)', async () => { + const capturedSessionId: string[] = []; - // Initialize to get a session ID - const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); - const tempSessionId = initResponse.headers.get('mcp-session-id'); - - // DELETE should fail when callback throws - const deleteResponse = await fetch(tempUrl, { - method: 'DELETE', - headers: { - 'mcp-session-id': tempSessionId || '', - 'mcp-protocol-version': '2025-03-26' - } - }); + // Create server with sync onsessioninitialized callback + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sessionId: string) => { + capturedSessionId.push(sessionId); + } + }); - expect(deleteResponse.status).toBe(500); + const tempServer = result.server; + const tempUrl = result.baseUrl; - // Clean up - consoleErrorSpy.mockRestore(); - tempServer.close(); - }); + // Initialize to trigger the callback + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get('mcp-session-id'); - it('should handle both async callbacks together', async () => { - const events: string[] = []; + expect(capturedSessionId).toEqual([tempSessionId]); - // Create server with both async callbacks - const result = await createTestServer({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: async (sessionId: string) => { - await new Promise(resolve => setTimeout(resolve, 5)); - events.push(`initialized:${sessionId}`); - }, - onsessionclosed: async (sessionId: string) => { - await new Promise(resolve => setTimeout(resolve, 5)); - events.push(`closed:${sessionId}`); - } + // Clean up + tempServer.close(); }); - const tempServer = result.server; - const tempUrl = result.baseUrl; + it('should support async onsessionclosed callback', async () => { + const closureOrder: string[] = []; - // Initialize to trigger first callback - const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); - const tempSessionId = initResponse.headers.get('mcp-session-id'); - - // Wait for async callback - await new Promise(resolve => setTimeout(resolve, 20)); + // Create server with async onsessionclosed callback + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: async (sessionId: string) => { + closureOrder.push('async-close-start'); + // Simulate async operation + await new Promise(resolve => setTimeout(resolve, 10)); + closureOrder.push('async-close-end'); + closureOrder.push(sessionId); + } + }); - expect(events).toContain(`initialized:${tempSessionId}`); + const tempServer = result.server; + const tempUrl = result.baseUrl; - // DELETE to trigger second callback - const deleteResponse = await fetch(tempUrl, { - method: 'DELETE', - headers: { - 'mcp-session-id': tempSessionId || '', - 'mcp-protocol-version': '2025-03-26' - } - }); + // Initialize to get a session ID + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get('mcp-session-id'); + expect(tempSessionId).toBeDefined(); - expect(deleteResponse.status).toBe(200); + // DELETE the session + const deleteResponse = await fetch(tempUrl, { + method: 'DELETE', + headers: { + 'mcp-session-id': tempSessionId || '', + 'mcp-protocol-version': '2025-03-26' + } + }); - // Wait for async callback - await new Promise(resolve => setTimeout(resolve, 20)); + expect(deleteResponse.status).toBe(200); - expect(events).toContain(`closed:${tempSessionId}`); - expect(events).toHaveLength(2); + // Give time for async callback to complete + await new Promise(resolve => setTimeout(resolve, 50)); - // Clean up - tempServer.close(); - }); -}); + expect(closureOrder).toEqual(['async-close-start', 'async-close-end', tempSessionId]); -// Test DNS rebinding protection -describe('StreamableHTTPServerTransport DNS rebinding protection', () => { - let server: Server; - let transport: StreamableHTTPServerTransport; - let baseUrl: URL; + // Clean up + tempServer.close(); + }); - afterEach(async () => { - if (server && transport) { - await stopTestServer({ server, transport }); - } - }); + it('should propagate errors from async onsessioninitialized callback', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - describe('Host header validation', () => { - it('should accept requests with allowed host headers', async () => { - const result = await createTestServerWithDnsProtection({ - sessionIdGenerator: undefined, - allowedHosts: ['localhost'], - enableDnsRebindingProtection: true + // Create server with async onsessioninitialized callback that throws + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: async (_sessionId: string) => { + throw new Error('Async initialization error'); + } }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; - // Note: fetch() automatically sets Host header to match the URL - // Since we're connecting to localhost:3001 and that's in allowedHosts, this should work - const response = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream' - }, - body: JSON.stringify(TEST_MESSAGES.initialize) - }); + const tempServer = result.server; + const tempUrl = result.baseUrl; - expect(response.status).toBe(200); + // Initialize should fail when callback throws + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + expect(initResponse.status).toBe(400); + + // Clean up + consoleErrorSpy.mockRestore(); + tempServer.close(); }); - it('should reject requests with disallowed host headers', async () => { - // Test DNS rebinding protection by creating a server that only allows example.com - // but we're connecting via localhost, so it should be rejected - const result = await createTestServerWithDnsProtection({ - sessionIdGenerator: undefined, - allowedHosts: ['example.com:3001'], - enableDnsRebindingProtection: true - }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; + it('should propagate errors from async onsessionclosed callback', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const response = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream' - }, - body: JSON.stringify(TEST_MESSAGES.initialize) + // Create server with async onsessionclosed callback that throws + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessionclosed: async (_sessionId: string) => { + throw new Error('Async closure error'); + } }); - expect(response.status).toBe(403); - const body = await response.json(); - expect(body.error.message).toContain('Invalid Host header:'); - }); + const tempServer = result.server; + const tempUrl = result.baseUrl; - it('should reject GET requests with disallowed host headers', async () => { - const result = await createTestServerWithDnsProtection({ - sessionIdGenerator: undefined, - allowedHosts: ['example.com:3001'], - enableDnsRebindingProtection: true - }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; + // Initialize to get a session ID + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get('mcp-session-id'); - const response = await fetch(baseUrl, { - method: 'GET', + // DELETE should fail when callback throws + const deleteResponse = await fetch(tempUrl, { + method: 'DELETE', headers: { - Accept: 'text/event-stream' + 'mcp-session-id': tempSessionId || '', + 'mcp-protocol-version': '2025-03-26' } }); - expect(response.status).toBe(403); + expect(deleteResponse.status).toBe(500); + + // Clean up + consoleErrorSpy.mockRestore(); + tempServer.close(); }); - }); - describe('Origin header validation', () => { - it('should accept requests with allowed origin headers', async () => { - const result = await createTestServerWithDnsProtection({ - sessionIdGenerator: undefined, - allowedOrigins: ['http://localhost:3000', 'https://example.com'], - enableDnsRebindingProtection: true - }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; + it('should handle both async callbacks together', async () => { + const events: string[] = []; - const response = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - Origin: 'http://localhost:3000' + // Create server with both async callbacks + const result = await createTestServer({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: async (sessionId: string) => { + await new Promise(resolve => setTimeout(resolve, 5)); + events.push(`initialized:${sessionId}`); }, - body: JSON.stringify(TEST_MESSAGES.initialize) + onsessionclosed: async (sessionId: string) => { + await new Promise(resolve => setTimeout(resolve, 5)); + events.push(`closed:${sessionId}`); + } }); - expect(response.status).toBe(200); - }); + const tempServer = result.server; + const tempUrl = result.baseUrl; - it('should reject requests with disallowed origin headers', async () => { - const result = await createTestServerWithDnsProtection({ - sessionIdGenerator: undefined, - allowedOrigins: ['http://localhost:3000'], - enableDnsRebindingProtection: true - }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; + // Initialize to trigger first callback + const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); + const tempSessionId = initResponse.headers.get('mcp-session-id'); - const response = await fetch(baseUrl, { - method: 'POST', + // Wait for async callback + await new Promise(resolve => setTimeout(resolve, 20)); + + expect(events).toContain(`initialized:${tempSessionId}`); + + // DELETE to trigger second callback + const deleteResponse = await fetch(tempUrl, { + method: 'DELETE', headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - Origin: 'http://evil.com' - }, - body: JSON.stringify(TEST_MESSAGES.initialize) + 'mcp-session-id': tempSessionId || '', + 'mcp-protocol-version': '2025-03-26' + } }); - expect(response.status).toBe(403); - const body = await response.json(); - expect(body.error.message).toBe('Invalid Origin header: http://evil.com'); + expect(deleteResponse.status).toBe(200); + + // Wait for async callback + await new Promise(resolve => setTimeout(resolve, 20)); + + expect(events).toContain(`closed:${tempSessionId}`); + expect(events).toHaveLength(2); + + // Clean up + tempServer.close(); }); }); - describe('enableDnsRebindingProtection option', () => { - it('should skip all validations when enableDnsRebindingProtection is false', async () => { - const result = await createTestServerWithDnsProtection({ - sessionIdGenerator: undefined, - allowedHosts: ['localhost'], - allowedOrigins: ['http://localhost:3000'], - enableDnsRebindingProtection: false + // Test DNS rebinding protection + describe('StreamableHTTPServerTransport DNS rebinding protection', () => { + let server: Server; + let transport: StreamableHTTPServerTransport; + let baseUrl: URL; + + afterEach(async () => { + if (server && transport) { + await stopTestServer({ server, transport }); + } + }); + + describe('Host header validation', () => { + it('should accept requests with allowed host headers', async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['localhost'], + enableDnsRebindingProtection: true + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + // Note: fetch() automatically sets Host header to match the URL + // Since we're connecting to localhost:3001 and that's in allowedHosts, this should work + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream' + }, + body: JSON.stringify(TEST_MESSAGES.initialize) + }); + + expect(response.status).toBe(200); }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; - const response = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - Host: 'evil.com', - Origin: 'http://evil.com' - }, - body: JSON.stringify(TEST_MESSAGES.initialize) + it('should reject requests with disallowed host headers', async () => { + // Test DNS rebinding protection by creating a server that only allows example.com + // but we're connecting via localhost, so it should be rejected + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['example.com:3001'], + enableDnsRebindingProtection: true + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream' + }, + body: JSON.stringify(TEST_MESSAGES.initialize) + }); + + expect(response.status).toBe(403); + const body = await response.json(); + expect(body.error.message).toContain('Invalid Host header:'); }); - // Should pass even with invalid headers because protection is disabled - expect(response.status).toBe(200); - }); - }); + it('should reject GET requests with disallowed host headers', async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['example.com:3001'], + enableDnsRebindingProtection: true + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: 'GET', + headers: { + Accept: 'text/event-stream' + } + }); - describe('Combined validations', () => { - it('should validate both host and origin when both are configured', async () => { - const result = await createTestServerWithDnsProtection({ - sessionIdGenerator: undefined, - allowedHosts: ['localhost'], - allowedOrigins: ['http://localhost:3001'], - enableDnsRebindingProtection: true + expect(response.status).toBe(403); }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; + }); - // Test with invalid origin (host will be automatically correct via fetch) - const response1 = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - Origin: 'http://evil.com' - }, - body: JSON.stringify(TEST_MESSAGES.initialize) + describe('Origin header validation', () => { + it('should accept requests with allowed origin headers', async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedOrigins: ['http://localhost:3000', 'https://example.com'], + enableDnsRebindingProtection: true + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + Origin: 'http://localhost:3000' + }, + body: JSON.stringify(TEST_MESSAGES.initialize) + }); + + expect(response.status).toBe(200); }); - expect(response1.status).toBe(403); - const body1 = await response1.json(); - expect(body1.error.message).toBe('Invalid Origin header: http://evil.com'); + it('should reject requests with disallowed origin headers', async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedOrigins: ['http://localhost:3000'], + enableDnsRebindingProtection: true + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + Origin: 'http://evil.com' + }, + body: JSON.stringify(TEST_MESSAGES.initialize) + }); + + expect(response.status).toBe(403); + const body = await response.json(); + expect(body.error.message).toBe('Invalid Origin header: http://evil.com'); + }); + }); - // Test with valid origin - const response2 = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - Origin: 'http://localhost:3001' - }, - body: JSON.stringify(TEST_MESSAGES.initialize) + describe('enableDnsRebindingProtection option', () => { + it('should skip all validations when enableDnsRebindingProtection is false', async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['localhost'], + allowedOrigins: ['http://localhost:3000'], + enableDnsRebindingProtection: false + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + const response = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + Host: 'evil.com', + Origin: 'http://evil.com' + }, + body: JSON.stringify(TEST_MESSAGES.initialize) + }); + + // Should pass even with invalid headers because protection is disabled + expect(response.status).toBe(200); }); + }); - expect(response2.status).toBe(200); + describe('Combined validations', () => { + it('should validate both host and origin when both are configured', async () => { + const result = await createTestServerWithDnsProtection({ + sessionIdGenerator: undefined, + allowedHosts: ['localhost'], + allowedOrigins: ['http://localhost:3001'], + enableDnsRebindingProtection: true + }); + server = result.server; + transport = result.transport; + baseUrl = result.baseUrl; + + // Test with invalid origin (host will be automatically correct via fetch) + const response1 = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + Origin: 'http://evil.com' + }, + body: JSON.stringify(TEST_MESSAGES.initialize) + }); + + expect(response1.status).toBe(403); + const body1 = await response1.json(); + expect(body1.error.message).toBe('Invalid Origin header: http://evil.com'); + + // Test with valid origin + const response2 = await fetch(baseUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + Origin: 'http://localhost:3001' + }, + body: JSON.stringify(TEST_MESSAGES.initialize) + }); + + expect(response2.status).toBe(200); + }); }); }); }); diff --git a/src/server/title.test.ts b/src/server/title.test.ts index 0f588514d..9eb99b992 100644 --- a/src/server/title.test.ts +++ b/src/server/title.test.ts @@ -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(); - - 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'); - }); +describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { + const { z } = entry; - it('should work with tools without title', async () => { - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + 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: {} }); + 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' }] })); + // 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' }); + const client = new Client({ name: 'test-client', version: '1.0.0' }); - await server.server.connect(serverTransport); - await client.connect(clientTransport); + 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'); - }); + 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 prompts that have title using update', async () => { - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + 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: {} }); + 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' }); + // 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' }); + const client = new Client({ name: 'test-client', version: '1.0.0' }); - await server.server.connect(serverTransport); - await client.connect(clientTransport); + 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'); - }); + 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 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}` } - } - ] - }) - ); + it('should work with prompts that have title using update', async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - const client = new Client({ name: 'test-client', version: '1.0.0' }); + const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} }); - await server.server.connect(serverTransport); - await client.connect(clientTransport); + // 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 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); - }); + const client = new Client({ name: 'test-client', version: '1.0.0' }); - 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' - } - ] - }) - ); + await server.server.connect(serverTransport); + await client.connect(clientTransport); - const client = new Client({ name: 'test-client', version: '1.0.0' }); + 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'); + }); - await server.server.connect(serverTransport); - await client.connect(clientTransport); + it('should work with prompts using registerPrompt', async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - 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'); - }); + const server = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: {} }); - 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: [ + // 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([ { - uri: uri.href, - text: `Profile data for user ${userId}` + text: expect.stringContaining('Profile data for user 123'), + uri: 'users://123/profile' } - ] - }) - ); - - 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' }); + it('should support serverInfo with title', async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - 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'); + 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'); + }); }); }); diff --git a/src/server/v3/completable.v3.test.ts b/src/server/v3/completable.v3.test.ts deleted file mode 100644 index 111874e1e..000000000 --- a/src/server/v3/completable.v3.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/src/server/v3/index.v3.test.ts b/src/server/v3/index.v3.test.ts deleted file mode 100644 index bcd05b588..000000000 --- a/src/server/v3/index.v3.test.ts +++ /dev/null @@ -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).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).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).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).or( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - GetForecastRequestSchema as unknown as z.ZodObject - ) as AnyObjectSchema; - const WeatherNotificationSchema = WeatherForecastNotificationSchema as AnyObjectSchema; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const WeatherResultSchema = (ResultSchema as unknown as z.ZodObject).extend({ - temperature: z.number(), - conditions: z.string() - }) as AnyObjectSchema; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - type InferSchema = T extends z.ZodType ? Output : never; - type WeatherRequest = InferSchema; - type WeatherNotification = InferSchema; - type WeatherResult = InferSchema; - - // Create a typed Server for weather data - const weatherServer = new Server( - { - 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(); -}); diff --git a/src/server/v3/mcp.v3.test.ts b/src/server/v3/mcp.v3.test.ts deleted file mode 100644 index 8348906d1..000000000 --- a/src/server/v3/mcp.v3.test.ts +++ /dev/null @@ -1,4519 +0,0 @@ -import * as z from 'zod/v3'; -import { Client } from '../../client/index.js'; -import { InMemoryTransport } from '../../inMemory.js'; -import { getDisplayName } from '../../shared/metadataUtils.js'; -import { UriTemplate } from '../../shared/uriTemplate.js'; -import { - CallToolResultSchema, - CompleteResultSchema, - ElicitRequestSchema, - GetPromptResultSchema, - ListPromptsResultSchema, - ListResourcesResultSchema, - ListResourceTemplatesResultSchema, - ListToolsResultSchema, - LoggingMessageNotificationSchema, - type Notification, - ReadResourceResultSchema, - type TextContent -} from '../../types.js'; -import { completable } from '../completable.js'; -import { McpServer, ResourceTemplate } from '../mcp.js'; - -describe('McpServer', () => { - /*** - * Test: Basic Server Instance - */ - test('should expose underlying Server instance', () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - - expect(mcpServer.server).toBeDefined(); - }); - - /*** - * Test: Notification Sending via Server - */ - test('should allow sending notifications via Server', async () => { - const mcpServer = new McpServer( - { - name: 'test server', - version: '1.0' - }, - { capabilities: { logging: {} } } - ); - - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' - }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - // This should work because we're using the underlying server - await expect( - mcpServer.server.sendLoggingMessage({ - level: 'info', - data: 'Test log message' - }) - ).resolves.not.toThrow(); - - expect(notifications).toMatchObject([ - { - method: 'notifications/message', - params: { - level: 'info', - data: 'Test log message' - } - } - ]); - }); - - /*** - * Test: Progress Notification with Message Field - */ - test('should send progress notifications with message field', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - - // Create a tool that sends progress updates - mcpServer.tool( - 'long-operation', - 'A long running operation with progress updates', - { - steps: z.number().min(1).describe('Number of steps to perform') - }, - async ({ steps }, { sendNotification, _meta }) => { - const progressToken = _meta?.progressToken; - - if (progressToken) { - // Send progress notification for each step - for (let i = 1; i <= steps; i++) { - await sendNotification({ - method: 'notifications/progress', - params: { - progressToken, - progress: i, - total: steps, - message: `Completed step ${i} of ${steps}` - } - }); - } - } - - return { - content: [ - { - type: 'text' as const, - text: `Operation completed with ${steps} steps` - } - ] - }; - } - ); - - const progressUpdates: Array<{ - progress: number; - total?: number; - message?: string; - }> = []; - - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - // Call the tool with progress tracking - await client.request( - { - method: 'tools/call', - params: { - name: 'long-operation', - arguments: { steps: 3 }, - _meta: { - progressToken: 'progress-test-1' - } - } - }, - CallToolResultSchema, - { - onprogress: progress => { - progressUpdates.push(progress); - } - } - ); - - // Verify progress notifications were received with message field - expect(progressUpdates).toHaveLength(3); - expect(progressUpdates[0]).toMatchObject({ - progress: 1, - total: 3, - message: 'Completed step 1 of 3' - }); - expect(progressUpdates[1]).toMatchObject({ - progress: 2, - total: 3, - message: 'Completed step 2 of 3' - }); - expect(progressUpdates[2]).toMatchObject({ - progress: 3, - total: 3, - message: 'Completed step 3 of 3' - }); - }); -}); - -describe('ResourceTemplate', () => { - /*** - * Test: ResourceTemplate Creation with String Pattern - */ - test('should create ResourceTemplate with string pattern', () => { - const template = new ResourceTemplate('test://{category}/{id}', { - list: undefined - }); - expect(template.uriTemplate.toString()).toBe('test://{category}/{id}'); - expect(template.listCallback).toBeUndefined(); - }); - - /*** - * Test: ResourceTemplate Creation with UriTemplate Instance - */ - test('should create ResourceTemplate with UriTemplate', () => { - const uriTemplate = new UriTemplate('test://{category}/{id}'); - const template = new ResourceTemplate(uriTemplate, { list: undefined }); - expect(template.uriTemplate).toBe(uriTemplate); - expect(template.listCallback).toBeUndefined(); - }); - - /*** - * Test: ResourceTemplate with List Callback - */ - test('should create ResourceTemplate with list callback', async () => { - const list = vi.fn().mockResolvedValue({ - resources: [{ name: 'Test', uri: 'test://example' }] - }); - - const template = new ResourceTemplate('test://{id}', { list }); - expect(template.listCallback).toBe(list); - - const abortController = new AbortController(); - const result = await template.listCallback?.({ - signal: abortController.signal, - requestId: 'not-implemented', - sendRequest: () => { - throw new Error('Not implemented'); - }, - sendNotification: () => { - throw new Error('Not implemented'); - } - }); - expect(result?.resources).toHaveLength(1); - expect(list).toHaveBeenCalled(); - }); -}); - -describe('tool()', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - /*** - * Test: Zero-Argument Tool Registration - */ - test('should register zero-argument tool', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' - }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; - - mcpServer.tool('test', async () => ({ - content: [ - { - type: 'text', - text: 'Test response' - } - ] - })); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - - const result = await client.request( - { - method: 'tools/list' - }, - ListToolsResultSchema - ); - - expect(result.tools).toHaveLength(1); - expect(result.tools[0].name).toBe('test'); - expect(result.tools[0].inputSchema).toEqual({ - type: 'object', - properties: {} - }); - - // Adding the tool before the connection was established means no notification was sent - expect(notifications).toHaveLength(0); - - // Adding another tool triggers the update notification - mcpServer.tool('test2', async () => ({ - content: [ - { - type: 'text', - text: 'Test response' - } - ] - })); - - // Yield event loop to let the notification fly - await new Promise(process.nextTick); - - expect(notifications).toMatchObject([ - { - method: 'notifications/tools/list_changed' - } - ]); - }); - - /*** - * Test: Updating Existing Tool - */ - test('should update existing tool', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' - }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; - - // Register initial tool - const tool = mcpServer.tool('test', async () => ({ - content: [ - { - type: 'text', - text: 'Initial response' - } - ] - })); - - // Update the tool - tool.update({ - callback: async () => ({ - content: [ - { - type: 'text', - text: 'Updated response' - } - ] - }) - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - - // Call the tool and verify we get the updated response - const result = await client.request( - { - method: 'tools/call', - params: { - name: 'test' - } - }, - CallToolResultSchema - ); - - expect(result.content).toEqual([ - { - type: 'text', - text: 'Updated response' - } - ]); - - // Update happened before transport was connected, so no notifications should be expected - expect(notifications).toHaveLength(0); - }); - - /*** - * Test: Updating Tool with Schema - */ - test('should update tool with schema', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' - }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; - - // Register initial tool - const tool = mcpServer.tool( - 'test', - { - name: z.string() - }, - async ({ name }) => ({ - content: [ - { - type: 'text', - text: `Initial: ${name}` - } - ] - }) - ); - - // Update the tool with a different schema - tool.update({ - paramsSchema: { - name: z.string(), - value: z.number() - }, - callback: async ({ name, value }) => ({ - content: [ - { - type: 'text', - text: `Updated: ${name}, ${value}` - } - ] - }) - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - - // Verify the schema was updated - const listResult = await client.request( - { - method: 'tools/list' - }, - ListToolsResultSchema - ); - - expect(listResult.tools[0].inputSchema).toMatchObject({ - properties: { - name: { type: 'string' }, - value: { type: 'number' } - } - }); - - // Call the tool with the new schema - const callResult = await client.request( - { - method: 'tools/call', - params: { - name: 'test', - arguments: { - name: 'test', - value: 42 - } - } - }, - CallToolResultSchema - ); - - expect(callResult.content).toEqual([ - { - type: 'text', - text: 'Updated: test, 42' - } - ]); - - // Update happened before transport was connected, so no notifications should be expected - expect(notifications).toHaveLength(0); - }); - - /*** - * Test: Tool List Changed Notifications - */ - test('should send tool list changed notifications when connected', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' - }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; - - // Register initial tool - const tool = mcpServer.tool('test', async () => ({ - content: [ - { - type: 'text', - text: 'Test response' - } - ] - })); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - - expect(notifications).toHaveLength(0); - - // Now update the tool - tool.update({ - callback: async () => ({ - content: [ - { - type: 'text', - text: 'Updated response' - } - ] - }) - }); - - // Yield event loop to let the notification fly - await new Promise(process.nextTick); - - expect(notifications).toMatchObject([{ method: 'notifications/tools/list_changed' }]); - - // Now delete the tool - tool.remove(); - - // Yield event loop to let the notification fly - await new Promise(process.nextTick); - - expect(notifications).toMatchObject([ - { method: 'notifications/tools/list_changed' }, - { method: 'notifications/tools/list_changed' } - ]); - }); - - /*** - * Test: Tool Registration with Parameters - */ - test('should register tool with params', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - // old api - mcpServer.tool( - 'test', - { - name: z.string(), - value: z.number() - }, - async ({ name, value }) => ({ - content: [ - { - type: 'text', - text: `${name}: ${value}` - } - ] - }) - ); - - // new api - mcpServer.registerTool( - 'test (new api)', - { - inputSchema: { name: z.string(), value: z.number() } - }, - async ({ name, value }) => ({ - content: [{ type: 'text', text: `${name}: ${value}` }] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request( - { - method: 'tools/list' - }, - ListToolsResultSchema - ); - - expect(result.tools).toHaveLength(2); - expect(result.tools[0].name).toBe('test'); - expect(result.tools[0].inputSchema).toMatchObject({ - type: 'object', - properties: { - name: { type: 'string' }, - value: { type: 'number' } - } - }); - expect(result.tools[1].name).toBe('test (new api)'); - expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema); - }); - - /*** - * Test: Tool Registration with Description - */ - test('should register tool with description', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - // old api - mcpServer.tool('test', 'Test description', async () => ({ - content: [ - { - type: 'text', - text: 'Test response' - } - ] - })); - - // new api - mcpServer.registerTool( - 'test (new api)', - { - description: 'Test description' - }, - async () => ({ - content: [ - { - type: 'text' as const, - text: 'Test response' - } - ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request( - { - method: 'tools/list' - }, - ListToolsResultSchema - ); - - expect(result.tools).toHaveLength(2); - expect(result.tools[0].name).toBe('test'); - expect(result.tools[0].description).toBe('Test description'); - expect(result.tools[1].name).toBe('test (new api)'); - expect(result.tools[1].description).toBe('Test description'); - }); - - /*** - * Test: Tool Registration with Annotations - */ - test('should register tool with annotations', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - mcpServer.tool('test', { title: 'Test Tool', readOnlyHint: true }, async () => ({ - content: [ - { - type: 'text', - text: 'Test response' - } - ] - })); - - mcpServer.registerTool( - 'test (new api)', - { - annotations: { title: 'Test Tool', readOnlyHint: true } - }, - async () => ({ - content: [ - { - type: 'text' as const, - text: 'Test response' - } - ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request( - { - method: 'tools/list' - }, - ListToolsResultSchema - ); - - expect(result.tools).toHaveLength(2); - expect(result.tools[0].name).toBe('test'); - expect(result.tools[0].annotations).toEqual({ - title: 'Test Tool', - readOnlyHint: true - }); - expect(result.tools[1].name).toBe('test (new api)'); - expect(result.tools[1].annotations).toEqual({ - title: 'Test Tool', - readOnlyHint: true - }); - }); - - /*** - * Test: Tool Registration with Parameters and Annotations - */ - test('should register tool with params and annotations', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - mcpServer.tool('test', { name: z.string() }, { title: 'Test Tool', readOnlyHint: true }, async ({ name }) => ({ - content: [{ type: 'text', text: `Hello, ${name}!` }] - })); - - mcpServer.registerTool( - 'test (new api)', - { - inputSchema: { name: z.string() }, - annotations: { title: 'Test Tool', readOnlyHint: true } - }, - async ({ name }) => ({ - content: [{ type: 'text', text: `Hello, ${name}!` }] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); - - expect(result.tools).toHaveLength(2); - expect(result.tools[0].name).toBe('test'); - expect(result.tools[0].inputSchema).toMatchObject({ - type: 'object', - properties: { name: { type: 'string' } } - }); - expect(result.tools[0].annotations).toEqual({ - title: 'Test Tool', - readOnlyHint: true - }); - expect(result.tools[1].name).toBe('test (new api)'); - expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema); - expect(result.tools[1].annotations).toEqual(result.tools[0].annotations); - }); - - /*** - * Test: Tool Registration with Description, Parameters, and Annotations - */ - test('should register tool with description, params, and annotations', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - mcpServer.tool( - 'test', - 'A tool with everything', - { name: z.string() }, - { title: 'Complete Test Tool', readOnlyHint: true, openWorldHint: false }, - async ({ name }) => ({ - content: [{ type: 'text', text: `Hello, ${name}!` }] - }) - ); - - mcpServer.registerTool( - 'test (new api)', - { - description: 'A tool with everything', - inputSchema: { name: z.string() }, - annotations: { - title: 'Complete Test Tool', - readOnlyHint: true, - openWorldHint: false - } - }, - async ({ name }) => ({ - content: [{ type: 'text', text: `Hello, ${name}!` }] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); - - expect(result.tools).toHaveLength(2); - expect(result.tools[0].name).toBe('test'); - expect(result.tools[0].description).toBe('A tool with everything'); - expect(result.tools[0].inputSchema).toMatchObject({ - type: 'object', - properties: { name: { type: 'string' } } - }); - expect(result.tools[0].annotations).toEqual({ - title: 'Complete Test Tool', - readOnlyHint: true, - openWorldHint: false - }); - expect(result.tools[1].name).toBe('test (new api)'); - expect(result.tools[1].description).toBe('A tool with everything'); - expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema); - expect(result.tools[1].annotations).toEqual(result.tools[0].annotations); - }); - - /*** - * Test: Tool Registration with Description, Empty Parameters, and Annotations - */ - test('should register tool with description, empty params, and annotations', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - mcpServer.tool( - 'test', - 'A tool with everything but empty params', - {}, - { - title: 'Complete Test Tool with empty params', - readOnlyHint: true, - openWorldHint: false - }, - async () => ({ - content: [{ type: 'text', text: 'Test response' }] - }) - ); - - mcpServer.registerTool( - 'test (new api)', - { - description: 'A tool with everything but empty params', - inputSchema: {}, - annotations: { - title: 'Complete Test Tool with empty params', - readOnlyHint: true, - openWorldHint: false - } - }, - async () => ({ - content: [{ type: 'text' as const, text: 'Test response' }] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); - - expect(result.tools).toHaveLength(2); - expect(result.tools[0].name).toBe('test'); - expect(result.tools[0].description).toBe('A tool with everything but empty params'); - expect(result.tools[0].inputSchema).toMatchObject({ - type: 'object', - properties: {} - }); - expect(result.tools[0].annotations).toEqual({ - title: 'Complete Test Tool with empty params', - readOnlyHint: true, - openWorldHint: false - }); - expect(result.tools[1].name).toBe('test (new api)'); - expect(result.tools[1].description).toBe('A tool with everything but empty params'); - expect(result.tools[1].inputSchema).toEqual(result.tools[0].inputSchema); - expect(result.tools[1].annotations).toEqual(result.tools[0].annotations); - }); - - /*** - * Test: Tool Argument Validation - */ - test('should validate tool args', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - mcpServer.tool( - 'test', - { - name: z.string(), - value: z.number() - }, - async ({ name, value }) => ({ - content: [ - { - type: 'text', - text: `${name}: ${value}` - } - ] - }) - ); - - mcpServer.registerTool( - 'test (new api)', - { - inputSchema: { - name: z.string(), - value: z.number() - } - }, - async ({ name, value }) => ({ - content: [ - { - type: 'text', - text: `${name}: ${value}` - } - ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request( - { - method: 'tools/call', - params: { - name: 'test', - arguments: { - name: 'test', - value: 'not a number' - } - } - }, - CallToolResultSchema - ); - - expect(result.isError).toBe(true); - expect(result.content).toEqual( - expect.arrayContaining([ - { - type: 'text', - text: expect.stringContaining('Input validation error: Invalid arguments for tool test') - } - ]) - ); - - const result2 = await client.request( - { - method: 'tools/call', - params: { - name: 'test (new api)', - arguments: { - name: 'test', - value: 'not a number' - } - } - }, - CallToolResultSchema - ); - - expect(result2.isError).toBe(true); - expect(result2.content).toEqual( - expect.arrayContaining([ - { - type: 'text', - text: expect.stringContaining('Input validation error: Invalid arguments for tool test (new api)') - } - ]) - ); - }); - - /*** - * Test: Preventing Duplicate Tool Registration - */ - test('should prevent duplicate tool registration', () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - - mcpServer.tool('test', async () => ({ - content: [ - { - type: 'text', - text: 'Test response' - } - ] - })); - - expect(() => { - mcpServer.tool('test', async () => ({ - content: [ - { - type: 'text', - text: 'Test response 2' - } - ] - })); - }).toThrow(/already registered/); - }); - - /*** - * Test: Multiple Tool Registration - */ - test('should allow registering multiple tools', () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - - // This should succeed - mcpServer.tool('tool1', () => ({ content: [] })); - - // This should also succeed and not throw about request handlers - mcpServer.tool('tool2', () => ({ content: [] })); - }); - - /*** - * Test: Tool with Output Schema and Structured Content - */ - test('should support tool with outputSchema and structuredContent', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - // Register a tool with outputSchema - mcpServer.registerTool( - 'test', - { - description: 'Test tool with structured output', - inputSchema: { - input: z.string() - }, - outputSchema: { - processedInput: z.string(), - resultType: z.string(), - timestamp: z.string() - } - }, - async ({ input }) => ({ - structuredContent: { - processedInput: input, - resultType: 'structured', - timestamp: '2023-01-01T00:00:00Z' - }, - content: [ - { - type: 'text', - text: JSON.stringify({ - processedInput: input, - resultType: 'structured', - timestamp: '2023-01-01T00:00:00Z' - }) - } - ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - // Verify the tool registration includes outputSchema - const listResult = await client.request( - { - method: 'tools/list' - }, - ListToolsResultSchema - ); - - expect(listResult.tools).toHaveLength(1); - expect(listResult.tools[0].outputSchema).toMatchObject({ - type: 'object', - properties: { - processedInput: { type: 'string' }, - resultType: { type: 'string' }, - timestamp: { type: 'string' } - }, - required: ['processedInput', 'resultType', 'timestamp'] - }); - - // Call the tool and verify it returns valid structuredContent - const result = await client.request( - { - method: 'tools/call', - params: { - name: 'test', - arguments: { - input: 'hello' - } - } - }, - CallToolResultSchema - ); - - expect(result.structuredContent).toBeDefined(); - const structuredContent = result.structuredContent as { - processedInput: string; - resultType: string; - timestamp: string; - }; - expect(structuredContent.processedInput).toBe('hello'); - expect(structuredContent.resultType).toBe('structured'); - expect(structuredContent.timestamp).toBe('2023-01-01T00:00:00Z'); - - // For backward compatibility, content is auto-generated from structuredContent - expect(result.content).toBeDefined(); - expect(result.content!).toHaveLength(1); - expect(result.content![0]).toMatchObject({ type: 'text' }); - const textContent = result.content![0] as TextContent; - expect(JSON.parse(textContent.text)).toEqual(result.structuredContent); - }); - - /*** - * Test: Tool with Output Schema Must Provide Structured Content - */ - test('should throw error when tool with outputSchema returns no structuredContent', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - // Register a tool with outputSchema that returns only content without structuredContent - mcpServer.registerTool( - 'test', - { - description: 'Test tool with output schema but missing structured content', - inputSchema: { - input: z.string() - }, - outputSchema: { - processedInput: z.string(), - resultType: z.string() - } - }, - async ({ input }) => ({ - // Only return content without structuredContent - content: [ - { - type: 'text', - text: `Processed: ${input}` - } - ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - // Call the tool and expect it to throw an error - const result = await client.callTool({ - name: 'test', - arguments: { - input: 'hello' - } - }); - - expect(result.isError).toBe(true); - expect(result.content).toEqual( - expect.arrayContaining([ - { - type: 'text', - text: expect.stringContaining( - 'Output validation error: Tool test has an output schema but no structured content was provided' - ) - } - ]) - ); - }); - /*** - * Test: Tool with Output Schema Must Provide Structured Content - */ - test('should skip outputSchema validation when isError is true', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - mcpServer.registerTool( - 'test', - { - description: 'Test tool with output schema but missing structured content', - inputSchema: { - input: z.string() - }, - outputSchema: { - processedInput: z.string(), - resultType: z.string() - } - }, - async ({ input }) => ({ - content: [ - { - type: 'text', - text: `Processed: ${input}` - } - ], - isError: true - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - await expect( - client.callTool({ - name: 'test', - arguments: { - input: 'hello' - } - }) - ).resolves.toStrictEqual({ - content: [ - { - type: 'text', - text: `Processed: hello` - } - ], - isError: true - }); - }); - - /*** - * Test: Schema Validation Failure for Invalid Structured Content - */ - test('should fail schema validation when tool returns invalid structuredContent', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - // Register a tool with outputSchema that returns invalid data - mcpServer.registerTool( - 'test', - { - description: 'Test tool with invalid structured output', - inputSchema: { - input: z.string() - }, - outputSchema: { - processedInput: z.string(), - resultType: z.string(), - timestamp: z.string() - } - }, - async ({ input }) => ({ - content: [ - { - type: 'text', - text: JSON.stringify({ - processedInput: input, - resultType: 'structured', - // Missing required 'timestamp' field - someExtraField: 'unexpected' // Extra field not in schema - }) - } - ], - structuredContent: { - processedInput: input, - resultType: 'structured', - // Missing required 'timestamp' field - someExtraField: 'unexpected' // Extra field not in schema - } - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - // Call the tool and expect it to throw a server-side validation error - const result = await client.callTool({ - name: 'test', - arguments: { - input: 'hello' - } - }); - - expect(result.isError).toBe(true); - expect(result.content).toEqual( - expect.arrayContaining([ - { - type: 'text', - text: expect.stringContaining('Output validation error: Invalid structured content for tool test') - } - ]) - ); - }); - - /*** - * Test: Pass Session ID to Tool Callback - */ - test('should pass sessionId to tool callback via RequestHandlerExtra', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - let receivedSessionId: string | undefined; - mcpServer.tool('test-tool', async extra => { - receivedSessionId = extra.sessionId; - return { - content: [ - { - type: 'text', - text: 'Test response' - } - ] - }; - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - // Set a test sessionId on the server transport - serverTransport.sessionId = 'test-session-123'; - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - await client.request( - { - method: 'tools/call', - params: { - name: 'test-tool' - } - }, - CallToolResultSchema - ); - - expect(receivedSessionId).toBe('test-session-123'); - }); - - /*** - * Test: Pass Request ID to Tool Callback - */ - test('should pass requestId to tool callback via RequestHandlerExtra', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - let receivedRequestId: string | number | undefined; - mcpServer.tool('request-id-test', async extra => { - receivedRequestId = extra.requestId; - return { - content: [ - { - type: 'text', - text: `Received request ID: ${extra.requestId}` - } - ] - }; - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request( - { - method: 'tools/call', - params: { - name: 'request-id-test' - } - }, - CallToolResultSchema - ); - - expect(receivedRequestId).toBeDefined(); - expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); - expect(result.content).toEqual( - expect.arrayContaining([ - { - type: 'text', - text: expect.stringContaining('Received request ID:') - } - ]) - ); - }); - - /*** - * Test: Send Notification within Tool Call - */ - test('should provide sendNotification within tool call', async () => { - const mcpServer = new McpServer( - { - name: 'test server', - version: '1.0' - }, - { capabilities: { logging: {} } } - ); - - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - let receivedLogMessage: string | undefined; - const loggingMessage = 'hello here is log message 1'; - - client.setNotificationHandler(LoggingMessageNotificationSchema, notification => { - receivedLogMessage = notification.params.data as string; - }); - - mcpServer.tool('test-tool', async ({ sendNotification }) => { - await sendNotification({ - method: 'notifications/message', - params: { level: 'debug', data: loggingMessage } - }); - return { - content: [ - { - type: 'text', - text: 'Test response' - } - ] - }; - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - await client.request( - { - method: 'tools/call', - params: { - name: 'test-tool' - } - }, - CallToolResultSchema - ); - expect(receivedLogMessage).toBe(loggingMessage); - }); - - /*** - * Test: Client to Server Tool Call - */ - test('should allow client to call server tools', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - mcpServer.tool( - 'test', - 'Test tool', - { - input: z.string() - }, - async ({ input }) => ({ - content: [ - { - type: 'text', - text: `Processed: ${input}` - } - ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request( - { - method: 'tools/call', - params: { - name: 'test', - arguments: { - input: 'hello' - } - } - }, - CallToolResultSchema - ); - - expect(result.content).toEqual([ - { - type: 'text', - text: 'Processed: hello' - } - ]); - }); - - /*** - * Test: Graceful Tool Error Handling - */ - test('should handle server tool errors gracefully', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - mcpServer.tool('error-test', async () => { - throw new Error('Tool execution failed'); - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request( - { - method: 'tools/call', - params: { - name: 'error-test' - } - }, - CallToolResultSchema - ); - - expect(result.isError).toBe(true); - expect(result.content).toEqual([ - { - type: 'text', - text: 'Tool execution failed' - } - ]); - }); - - /*** - * Test: McpError for Invalid Tool Name - */ - test('should throw McpError for invalid tool name', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - mcpServer.tool('test-tool', async () => ({ - content: [ - { - type: 'text', - text: 'Test response' - } - ] - })); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request( - { - method: 'tools/call', - params: { - name: 'nonexistent-tool' - } - }, - CallToolResultSchema - ); - - expect(result.isError).toBe(true); - expect(result.content).toEqual( - expect.arrayContaining([ - { - type: 'text', - text: expect.stringContaining('Tool nonexistent-tool not found') - } - ]) - ); - }); - - /*** - * Test: Tool Registration with _meta field - */ - test('should register tool with _meta field and include it in list response', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - const metaData = { - author: 'test-author', - version: '1.2.3', - category: 'utility', - tags: ['test', 'example'] - }; - - mcpServer.registerTool( - 'test-with-meta', - { - description: 'A tool with _meta field', - inputSchema: { name: z.string() }, - _meta: metaData - }, - async ({ name }) => ({ - content: [{ type: 'text', text: `Hello, ${name}!` }] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); - - expect(result.tools).toHaveLength(1); - expect(result.tools[0].name).toBe('test-with-meta'); - expect(result.tools[0].description).toBe('A tool with _meta field'); - expect(result.tools[0]._meta).toEqual(metaData); - }); - - /*** - * Test: Tool Registration without _meta field should have undefined _meta - */ - test('should register tool without _meta field and have undefined _meta in response', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - mcpServer.registerTool( - 'test-without-meta', - { - description: 'A tool without _meta field', - inputSchema: { name: z.string() } - }, - async ({ name }) => ({ - content: [{ type: 'text', text: `Hello, ${name}!` }] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); - - expect(result.tools).toHaveLength(1); - expect(result.tools[0].name).toBe('test-without-meta'); - expect(result.tools[0]._meta).toBeUndefined(); - }); - - test('should validate tool names according to SEP specification', () => { - // Create a new server instance for this test - const testServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - - // Spy on console.warn to verify warnings are logged - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - // Test valid tool names - testServer.registerTool( - 'valid-tool-name', - { - description: 'A valid tool name' - }, - async () => ({ content: [{ type: 'text' as const, text: 'Success' }] }) - ); - - // Test tool name with warnings (starts with dash) - testServer.registerTool( - '-warning-tool', - { - description: 'A tool name that generates warnings' - }, - async () => ({ content: [{ type: 'text' as const, text: 'Success' }] }) - ); - - // Test invalid tool name (contains spaces) - testServer.registerTool( - 'invalid tool name', - { - description: 'An invalid tool name' - }, - async () => ({ content: [{ type: 'text' as const, text: 'Success' }] }) - ); - - // Verify that warnings were issued (both for warnings and validation failures) - expect(warnSpy).toHaveBeenCalled(); - - // Verify specific warning content - const warningCalls = warnSpy.mock.calls.map(call => call.join(' ')); - expect(warningCalls.some(call => call.includes('Tool name starts or ends with a dash'))).toBe(true); - expect(warningCalls.some(call => call.includes('Tool name contains spaces'))).toBe(true); - expect(warningCalls.some(call => call.includes('Tool name contains invalid characters'))).toBe(true); - - // Clean up spies - warnSpy.mockRestore(); - }); -}); - -describe('resource()', () => { - /*** - * Test: Resource Registration with URI and Read Callback - */ - test('should register resource with uri and readCallback', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - mcpServer.resource('test', 'test://resource', async () => ({ - contents: [ - { - uri: 'test://resource', - text: 'Test content' - } - ] - })); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request( - { - method: 'resources/list' - }, - ListResourcesResultSchema - ); - - expect(result.resources).toHaveLength(1); - expect(result.resources[0].name).toBe('test'); - expect(result.resources[0].uri).toBe('test://resource'); - }); - - /*** - * Test: Update Resource with URI - */ - test('should update resource with uri', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' - }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; - - // Register initial resource - const resource = mcpServer.resource('test', 'test://resource', async () => ({ - contents: [ - { - uri: 'test://resource', - text: 'Initial content' - } - ] - })); - - // Update the resource - resource.update({ - callback: async () => ({ - contents: [ - { - uri: 'test://resource', - text: 'Updated content' - } - ] - }) - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - - // Read the resource and verify we get the updated content - const result = await client.request( - { - method: 'resources/read', - params: { - uri: 'test://resource' - } - }, - ReadResourceResultSchema - ); - - expect(result.contents).toHaveLength(1); - expect(result.contents).toEqual( - expect.arrayContaining([ - { - text: expect.stringContaining('Updated content'), - uri: 'test://resource' - } - ]) - ); - - // Update happened before transport was connected, so no notifications should be expected - expect(notifications).toHaveLength(0); - }); - - /*** - * Test: Update Resource Template - */ - test('should update resource template', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' - }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; - - // Register initial resource template - const resourceTemplate = mcpServer.resource( - 'test', - new ResourceTemplate('test://resource/{id}', { list: undefined }), - async uri => ({ - contents: [ - { - uri: uri.href, - text: 'Initial content' - } - ] - }) - ); - - // Update the resource template - resourceTemplate.update({ - callback: async uri => ({ - contents: [ - { - uri: uri.href, - text: 'Updated content' - } - ] - }) - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - - // Read the resource and verify we get the updated content - const result = await client.request( - { - method: 'resources/read', - params: { - uri: 'test://resource/123' - } - }, - ReadResourceResultSchema - ); - - expect(result.contents).toHaveLength(1); - expect(result.contents).toEqual( - expect.arrayContaining([ - { - text: expect.stringContaining('Updated content'), - uri: 'test://resource/123' - } - ]) - ); - - // Update happened before transport was connected, so no notifications should be expected - expect(notifications).toHaveLength(0); - }); - - /*** - * Test: Resource List Changed Notification - */ - test('should send resource list changed notification when connected', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' - }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; - - // Register initial resource - const resource = mcpServer.resource('test', 'test://resource', async () => ({ - contents: [ - { - uri: 'test://resource', - text: 'Test content' - } - ] - })); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - - expect(notifications).toHaveLength(0); - - // Now update the resource while connected - resource.update({ - callback: async () => ({ - contents: [ - { - uri: 'test://resource', - text: 'Updated content' - } - ] - }) - }); - - // Yield event loop to let the notification fly - await new Promise(process.nextTick); - - expect(notifications).toMatchObject([{ method: 'notifications/resources/list_changed' }]); - }); - - /*** - * Test: Remove Resource and Send Notification - */ - test('should remove resource and send notification when connected', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' - }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; - - // Register initial resources - const resource1 = mcpServer.resource('resource1', 'test://resource1', async () => ({ - contents: [{ uri: 'test://resource1', text: 'Resource 1 content' }] - })); - - mcpServer.resource('resource2', 'test://resource2', async () => ({ - contents: [{ uri: 'test://resource2', text: 'Resource 2 content' }] - })); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - - // Verify both resources are registered - let result = await client.request({ method: 'resources/list' }, ListResourcesResultSchema); - - expect(result.resources).toHaveLength(2); - - expect(notifications).toHaveLength(0); - - // Remove a resource - resource1.remove(); - - // Yield event loop to let the notification fly - await new Promise(process.nextTick); - - // Should have sent notification - expect(notifications).toMatchObject([{ method: 'notifications/resources/list_changed' }]); - - // Verify the resource was removed - result = await client.request({ method: 'resources/list' }, ListResourcesResultSchema); - - expect(result.resources).toHaveLength(1); - expect(result.resources[0].uri).toBe('test://resource2'); - }); - - /*** - * Test: Remove Resource Template and Send Notification - */ - test('should remove resource template and send notification when connected', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' - }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; - - // Register resource template - const resourceTemplate = mcpServer.resource( - 'template', - new ResourceTemplate('test://resource/{id}', { list: undefined }), - async uri => ({ - contents: [ - { - uri: uri.href, - text: 'Template content' - } - ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - - // Verify template is registered - const result = await client.request({ method: 'resources/templates/list' }, ListResourceTemplatesResultSchema); - - expect(result.resourceTemplates).toHaveLength(1); - expect(notifications).toHaveLength(0); - - // Remove the template - resourceTemplate.remove(); - - // Yield event loop to let the notification fly - await new Promise(process.nextTick); - - // Should have sent notification - expect(notifications).toMatchObject([{ method: 'notifications/resources/list_changed' }]); - - // Verify the template was removed - const result2 = await client.request({ method: 'resources/templates/list' }, ListResourceTemplatesResultSchema); - - expect(result2.resourceTemplates).toHaveLength(0); - }); - - /*** - * Test: Resource Registration with Metadata - */ - test('should register resource with metadata', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - mcpServer.resource( - 'test', - 'test://resource', - { - description: 'Test resource', - mimeType: 'text/plain' - }, - async () => ({ - contents: [ - { - uri: 'test://resource', - text: 'Test content' - } - ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request( - { - method: 'resources/list' - }, - ListResourcesResultSchema - ); - - expect(result.resources).toHaveLength(1); - expect(result.resources[0].description).toBe('Test resource'); - expect(result.resources[0].mimeType).toBe('text/plain'); - }); - - /*** - * Test: Resource Template Registration - */ - test('should register resource template', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - mcpServer.resource('test', new ResourceTemplate('test://resource/{id}', { list: undefined }), async () => ({ - contents: [ - { - uri: 'test://resource/123', - text: 'Test content' - } - ] - })); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request( - { - method: 'resources/templates/list' - }, - ListResourceTemplatesResultSchema - ); - - expect(result.resourceTemplates).toHaveLength(1); - expect(result.resourceTemplates[0].name).toBe('test'); - expect(result.resourceTemplates[0].uriTemplate).toBe('test://resource/{id}'); - }); - - /*** - * Test: Resource Template with List Callback - */ - test('should register resource template with listCallback', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - mcpServer.resource( - 'test', - new ResourceTemplate('test://resource/{id}', { - list: async () => ({ - resources: [ - { - name: 'Resource 1', - uri: 'test://resource/1' - }, - { - name: 'Resource 2', - uri: 'test://resource/2' - } - ] - }) - }), - async uri => ({ - contents: [ - { - uri: uri.href, - text: 'Test content' - } - ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request( - { - method: 'resources/list' - }, - ListResourcesResultSchema - ); - - expect(result.resources).toHaveLength(2); - expect(result.resources[0].name).toBe('Resource 1'); - expect(result.resources[0].uri).toBe('test://resource/1'); - expect(result.resources[1].name).toBe('Resource 2'); - expect(result.resources[1].uri).toBe('test://resource/2'); - }); - - /*** - * Test: Template Variables to Read Callback - */ - test('should pass template variables to readCallback', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - mcpServer.resource( - 'test', - new ResourceTemplate('test://resource/{category}/{id}', { - list: undefined - }), - async (uri, { category, id }) => ({ - contents: [ - { - uri: uri.href, - text: `Category: ${category}, ID: ${id}` - } - ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request( - { - method: 'resources/read', - params: { - uri: 'test://resource/books/123' - } - }, - ReadResourceResultSchema - ); - - expect(result.contents).toEqual( - expect.arrayContaining([ - { - text: expect.stringContaining('Category: books, ID: 123'), - uri: 'test://resource/books/123' - } - ]) - ); - }); - - /*** - * Test: Preventing Duplicate Resource Registration - */ - test('should prevent duplicate resource registration', () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - - mcpServer.resource('test', 'test://resource', async () => ({ - contents: [ - { - uri: 'test://resource', - text: 'Test content' - } - ] - })); - - expect(() => { - mcpServer.resource('test2', 'test://resource', async () => ({ - contents: [ - { - uri: 'test://resource', - text: 'Test content 2' - } - ] - })); - }).toThrow(/already registered/); - }); - - /*** - * Test: Multiple Resource Registration - */ - test('should allow registering multiple resources', () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - - // This should succeed - mcpServer.resource('resource1', 'test://resource1', async () => ({ - contents: [ - { - uri: 'test://resource1', - text: 'Test content 1' - } - ] - })); - - // This should also succeed and not throw about request handlers - mcpServer.resource('resource2', 'test://resource2', async () => ({ - contents: [ - { - uri: 'test://resource2', - text: 'Test content 2' - } - ] - })); - }); - - /*** - * Test: Preventing Duplicate Resource Template Registration - */ - test('should prevent duplicate resource template registration', () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - - mcpServer.resource('test', new ResourceTemplate('test://resource/{id}', { list: undefined }), async () => ({ - contents: [ - { - uri: 'test://resource/123', - text: 'Test content' - } - ] - })); - - expect(() => { - mcpServer.resource('test', new ResourceTemplate('test://resource/{id}', { list: undefined }), async () => ({ - contents: [ - { - uri: 'test://resource/123', - text: 'Test content 2' - } - ] - })); - }).toThrow(/already registered/); - }); - - /*** - * Test: Graceful Resource Read Error Handling - */ - test('should handle resource read errors gracefully', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - mcpServer.resource('error-test', 'test://error', async () => { - throw new Error('Resource read failed'); - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - await expect( - client.request( - { - method: 'resources/read', - params: { - uri: 'test://error' - } - }, - ReadResourceResultSchema - ) - ).rejects.toThrow(/Resource read failed/); - }); - - /*** - * Test: McpError for Invalid Resource URI - */ - test('should throw McpError for invalid resource URI', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - mcpServer.resource('test', 'test://resource', async () => ({ - contents: [ - { - uri: 'test://resource', - text: 'Test content' - } - ] - })); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - await expect( - client.request( - { - method: 'resources/read', - params: { - uri: 'test://nonexistent' - } - }, - ReadResourceResultSchema - ) - ).rejects.toThrow(/Resource test:\/\/nonexistent not found/); - }); - - /*** - * Test: Registering a resource template with a complete callback should update server capabilities to advertise support for completion - */ - test('should advertise support for completion when a resource template with a complete callback is defined', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - mcpServer.resource( - 'test', - new ResourceTemplate('test://resource/{category}', { - list: undefined, - complete: { - category: () => ['books', 'movies', 'music'] - } - }), - async () => ({ - contents: [ - { - uri: 'test://resource/test', - text: 'Test content' - } - ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - expect(client.getServerCapabilities()).toMatchObject({ completions: {} }); - }); - - /*** - * Test: Resource Template Parameter Completion - */ - test('should support completion of resource template parameters', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - mcpServer.resource( - 'test', - new ResourceTemplate('test://resource/{category}', { - list: undefined, - complete: { - category: () => ['books', 'movies', 'music'] - } - }), - async () => ({ - contents: [ - { - uri: 'test://resource/test', - text: 'Test content' - } - ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request( - { - method: 'completion/complete', - params: { - ref: { - type: 'ref/resource', - uri: 'test://resource/{category}' - }, - argument: { - name: 'category', - value: '' - } - } - }, - CompleteResultSchema - ); - - expect(result.completion.values).toEqual(['books', 'movies', 'music']); - expect(result.completion.total).toBe(3); - }); - - /*** - * Test: Filtered Resource Template Parameter Completion - */ - test('should support filtered completion of resource template parameters', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - mcpServer.resource( - 'test', - new ResourceTemplate('test://resource/{category}', { - list: undefined, - complete: { - category: (test: string) => ['books', 'movies', 'music'].filter(value => value.startsWith(test)) - } - }), - async () => ({ - contents: [ - { - uri: 'test://resource/test', - text: 'Test content' - } - ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request( - { - method: 'completion/complete', - params: { - ref: { - type: 'ref/resource', - uri: 'test://resource/{category}' - }, - argument: { - name: 'category', - value: 'm' - } - } - }, - CompleteResultSchema - ); - - expect(result.completion.values).toEqual(['movies', 'music']); - expect(result.completion.total).toBe(2); - }); - - /*** - * Test: Pass Request ID to Resource Callback - */ - test('should pass requestId to resource callback via RequestHandlerExtra', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - let receivedRequestId: string | number | undefined; - mcpServer.resource('request-id-test', 'test://resource', async (_uri, extra) => { - receivedRequestId = extra.requestId; - return { - contents: [ - { - uri: 'test://resource', - text: `Received request ID: ${extra.requestId}` - } - ] - }; - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request( - { - method: 'resources/read', - params: { - uri: 'test://resource' - } - }, - ReadResourceResultSchema - ); - - expect(receivedRequestId).toBeDefined(); - expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); - expect(result.contents).toEqual( - expect.arrayContaining([ - { - text: expect.stringContaining(`Received request ID:`), - uri: 'test://resource' - } - ]) - ); - }); -}); - -describe('prompt()', () => { - /*** - * Test: Zero-Argument Prompt Registration - */ - test('should register zero-argument prompt', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - mcpServer.prompt('test', async () => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: 'Test response' - } - } - ] - })); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request( - { - method: 'prompts/list' - }, - ListPromptsResultSchema - ); - - expect(result.prompts).toHaveLength(1); - expect(result.prompts[0].name).toBe('test'); - expect(result.prompts[0].arguments).toBeUndefined(); - }); - /*** - * Test: Updating Existing Prompt - */ - test('should update existing prompt', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' - }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; - - // Register initial prompt - const prompt = mcpServer.prompt('test', async () => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: 'Initial response' - } - } - ] - })); - - // Update the prompt - prompt.update({ - callback: async () => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: 'Updated response' - } - } - ] - }) - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - - // Call the prompt and verify we get the updated response - const result = await client.request( - { - method: 'prompts/get', - params: { - name: 'test' - } - }, - GetPromptResultSchema - ); - - expect(result.messages).toHaveLength(1); - expect(result.messages).toEqual( - expect.arrayContaining([ - { - role: 'assistant', - content: { - type: 'text', - text: 'Updated response' - } - } - ]) - ); - - // Update happened before transport was connected, so no notifications should be expected - expect(notifications).toHaveLength(0); - }); - - /*** - * Test: Updating Prompt with Schema - */ - test('should update prompt with schema', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' - }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; - - // Register initial prompt - const prompt = mcpServer.prompt( - 'test', - { - name: z.string() - }, - async ({ name }) => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: `Initial: ${name}` - } - } - ] - }) - ); - - // Update the prompt with a different schema - prompt.update({ - argsSchema: { - name: z.string(), - value: z.string() - }, - callback: async ({ name, value }) => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: `Updated: ${name}, ${value}` - } - } - ] - }) - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - - // Verify the schema was updated - const listResult = await client.request( - { - method: 'prompts/list' - }, - ListPromptsResultSchema - ); - - expect(listResult.prompts[0].arguments).toHaveLength(2); - expect(listResult.prompts[0].arguments?.map(a => a.name).sort()).toEqual(['name', 'value']); - - // Call the prompt with the new schema - const getResult = await client.request( - { - method: 'prompts/get', - params: { - name: 'test', - arguments: { - name: 'test', - value: 'value' - } - } - }, - GetPromptResultSchema - ); - - expect(getResult.messages).toHaveLength(1); - expect(getResult.messages).toEqual( - expect.arrayContaining([ - { - role: 'assistant', - content: { - type: 'text', - text: 'Updated: test, value' - } - } - ]) - ); - - // Update happened before transport was connected, so no notifications should be expected - expect(notifications).toHaveLength(0); - }); - - /*** - * Test: Prompt List Changed Notification - */ - test('should send prompt list changed notification when connected', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' - }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; - - // Register initial prompt - const prompt = mcpServer.prompt('test', async () => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: 'Test response' - } - } - ] - })); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - - expect(notifications).toHaveLength(0); - - // Now update the prompt while connected - prompt.update({ - callback: async () => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: 'Updated response' - } - } - ] - }) - }); - - // Yield event loop to let the notification fly - await new Promise(process.nextTick); - - expect(notifications).toMatchObject([{ method: 'notifications/prompts/list_changed' }]); - }); - - /*** - * Test: Remove Prompt and Send Notification - */ - test('should remove prompt and send notification when connected', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const notifications: Notification[] = []; - const client = new Client({ - name: 'test client', - version: '1.0' - }); - client.fallbackNotificationHandler = async notification => { - notifications.push(notification); - }; - - // Register initial prompts - const prompt1 = mcpServer.prompt('prompt1', async () => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: 'Prompt 1 response' - } - } - ] - })); - - mcpServer.prompt('prompt2', async () => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: 'Prompt 2 response' - } - } - ] - })); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - - // Verify both prompts are registered - let result = await client.request({ method: 'prompts/list' }, ListPromptsResultSchema); - - expect(result.prompts).toHaveLength(2); - expect(result.prompts.map(p => p.name).sort()).toEqual(['prompt1', 'prompt2']); - - expect(notifications).toHaveLength(0); - - // Remove a prompt - prompt1.remove(); - - // Yield event loop to let the notification fly - await new Promise(process.nextTick); - - // Should have sent notification - expect(notifications).toMatchObject([{ method: 'notifications/prompts/list_changed' }]); - - // Verify the prompt was removed - result = await client.request({ method: 'prompts/list' }, ListPromptsResultSchema); - - expect(result.prompts).toHaveLength(1); - expect(result.prompts[0].name).toBe('prompt2'); - }); - - /*** - * Test: Prompt Registration with Arguments Schema - */ - test('should register prompt with args schema', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - mcpServer.prompt( - 'test', - { - name: z.string(), - value: z.string() - }, - async ({ name, value }) => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: `${name}: ${value}` - } - } - ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request( - { - method: 'prompts/list' - }, - ListPromptsResultSchema - ); - - expect(result.prompts).toHaveLength(1); - expect(result.prompts[0].name).toBe('test'); - expect(result.prompts[0].arguments).toEqual([ - { name: 'name', required: true }, - { name: 'value', required: true } - ]); - }); - - /*** - * Test: Prompt Registration with Description - */ - test('should register prompt with description', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - mcpServer.prompt('test', 'Test description', async () => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: 'Test response' - } - } - ] - })); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request( - { - method: 'prompts/list' - }, - ListPromptsResultSchema - ); - - expect(result.prompts).toHaveLength(1); - expect(result.prompts[0].name).toBe('test'); - expect(result.prompts[0].description).toBe('Test description'); - }); - - /*** - * Test: Prompt Argument Validation - */ - test('should validate prompt args', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - mcpServer.prompt( - 'test', - { - name: z.string(), - value: z.string().min(3) - }, - async ({ name, value }) => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: `${name}: ${value}` - } - } - ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - await expect( - client.request( - { - method: 'prompts/get', - params: { - name: 'test', - arguments: { - name: 'test', - value: 'ab' // Too short - } - } - }, - GetPromptResultSchema - ) - ).rejects.toThrow(/Invalid arguments/); - }); - - /*** - * Test: Preventing Duplicate Prompt Registration - */ - test('should prevent duplicate prompt registration', () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - - mcpServer.prompt('test', async () => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: 'Test response' - } - } - ] - })); - - expect(() => { - mcpServer.prompt('test', async () => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: 'Test response 2' - } - } - ] - })); - }).toThrow(/already registered/); - }); - - /*** - * Test: Multiple Prompt Registration - */ - test('should allow registering multiple prompts', () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - - // This should succeed - mcpServer.prompt('prompt1', async () => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: 'Test response 1' - } - } - ] - })); - - // This should also succeed and not throw about request handlers - mcpServer.prompt('prompt2', async () => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: 'Test response 2' - } - } - ] - })); - }); - - /*** - * Test: Prompt Registration with Arguments - */ - test('should allow registering prompts with arguments', () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - - // This should succeed - mcpServer.prompt('echo', { message: z.string() }, ({ message }) => ({ - messages: [ - { - role: 'user', - content: { - type: 'text', - text: `Please process this message: ${message}` - } - } - ] - })); - }); - - /*** - * Test: Resources and Prompts with Completion Handlers - */ - test('should allow registering both resources and prompts with completion handlers', () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - - // Register a resource with completion - mcpServer.resource( - 'test', - new ResourceTemplate('test://resource/{category}', { - list: undefined, - complete: { - category: () => ['books', 'movies', 'music'] - } - }), - async () => ({ - contents: [ - { - uri: 'test://resource/test', - text: 'Test content' - } - ] - }) - ); - - // Register a prompt with completion - mcpServer.prompt('echo', { message: completable(z.string(), () => ['hello', 'world']) }, ({ message }) => ({ - messages: [ - { - role: 'user', - content: { - type: 'text', - text: `Please process this message: ${message}` - } - } - ] - })); - }); - - /*** - * Test: McpError for Invalid Prompt Name - */ - test('should throw McpError for invalid prompt name', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - mcpServer.prompt('test-prompt', async () => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: 'Test response' - } - } - ] - })); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - await expect( - client.request( - { - method: 'prompts/get', - params: { - name: 'nonexistent-prompt' - } - }, - GetPromptResultSchema - ) - ).rejects.toThrow(/Prompt nonexistent-prompt not found/); - }); - - /*** - * Test: Registering a prompt with a completable argument should update server capabilities to advertise support for completion - */ - test('should advertise support for completion when a prompt with a completable argument is defined', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - mcpServer.prompt( - 'test-prompt', - { - name: completable(z.string(), () => ['Alice', 'Bob', 'Charlie']) - }, - async ({ name }) => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: `Hello ${name}` - } - } - ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - expect(client.getServerCapabilities()).toMatchObject({ completions: {} }); - }); - - /*** - * Test: Prompt Argument Completion - */ - test('should support completion of prompt arguments', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - mcpServer.prompt( - 'test-prompt', - { - name: completable(z.string(), () => ['Alice', 'Bob', 'Charlie']) - }, - async ({ name }) => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: `Hello ${name}` - } - } - ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request( - { - method: 'completion/complete', - params: { - ref: { - type: 'ref/prompt', - name: 'test-prompt' - }, - argument: { - name: 'name', - value: '' - } - } - }, - CompleteResultSchema - ); - - expect(result.completion.values).toEqual(['Alice', 'Bob', 'Charlie']); - expect(result.completion.total).toBe(3); - }); - - /*** - * Test: Filtered Prompt Argument Completion - */ - test('should support filtered completion of prompt arguments', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - mcpServer.prompt( - 'test-prompt', - { - name: completable(z.string(), test => ['Alice', 'Bob', 'Charlie'].filter(value => value.startsWith(test))) - }, - async ({ name }) => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: `Hello ${name}` - } - } - ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request( - { - method: 'completion/complete', - params: { - ref: { - type: 'ref/prompt', - name: 'test-prompt' - }, - argument: { - name: 'name', - value: 'A' - } - } - }, - CompleteResultSchema - ); - - expect(result.completion.values).toEqual(['Alice']); - expect(result.completion.total).toBe(1); - }); - - /*** - * Test: Pass Request ID to Prompt Callback - */ - test('should pass requestId to prompt callback via RequestHandlerExtra', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - let receivedRequestId: string | number | undefined; - mcpServer.prompt('request-id-test', async extra => { - receivedRequestId = extra.requestId; - return { - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: `Received request ID: ${extra.requestId}` - } - } - ] - }; - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request( - { - method: 'prompts/get', - params: { - name: 'request-id-test' - } - }, - GetPromptResultSchema - ); - - expect(receivedRequestId).toBeDefined(); - expect(typeof receivedRequestId === 'string' || typeof receivedRequestId === 'number').toBe(true); - expect(result.messages).toEqual( - expect.arrayContaining([ - { - role: 'assistant', - content: { - type: 'text', - text: expect.stringContaining(`Received request ID:`) - } - } - ]) - ); - }); - - /*** - * Test: Resource Template Metadata Priority - */ - test('should prioritize individual resource metadata over template metadata', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - mcpServer.resource( - 'test', - new ResourceTemplate('test://resource/{id}', { - list: async () => ({ - resources: [ - { - name: 'Resource 1', - uri: 'test://resource/1', - description: 'Individual resource description', - mimeType: 'text/plain' - }, - { - name: 'Resource 2', - uri: 'test://resource/2' - // This resource has no description or mimeType - } - ] - }) - }), - { - description: 'Template description', - mimeType: 'application/json' - }, - async uri => ({ - contents: [ - { - uri: uri.href, - text: 'Test content' - } - ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request( - { - method: 'resources/list' - }, - ListResourcesResultSchema - ); - - expect(result.resources).toHaveLength(2); - - // Resource 1 should have its own metadata - expect(result.resources[0].name).toBe('Resource 1'); - expect(result.resources[0].description).toBe('Individual resource description'); - expect(result.resources[0].mimeType).toBe('text/plain'); - - // Resource 2 should inherit template metadata - expect(result.resources[1].name).toBe('Resource 2'); - expect(result.resources[1].description).toBe('Template description'); - expect(result.resources[1].mimeType).toBe('application/json'); - }); - - /*** - * Test: Resource Template Metadata Overrides All Fields - */ - test('should allow resource to override all template metadata fields', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - mcpServer.resource( - 'test', - new ResourceTemplate('test://resource/{id}', { - list: async () => ({ - resources: [ - { - name: 'Overridden Name', - uri: 'test://resource/1', - description: 'Overridden description', - mimeType: 'text/markdown' - // Add any other metadata fields if they exist - } - ] - }) - }), - { - title: 'Template Name', - description: 'Template description', - mimeType: 'application/json' - }, - async uri => ({ - contents: [ - { - uri: uri.href, - text: 'Test content' - } - ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - const result = await client.request( - { - method: 'resources/list' - }, - ListResourcesResultSchema - ); - - expect(result.resources).toHaveLength(1); - - // All fields should be from the individual resource, not the template - expect(result.resources[0].name).toBe('Overridden Name'); - expect(result.resources[0].description).toBe('Overridden description'); - expect(result.resources[0].mimeType).toBe('text/markdown'); - }); -}); - -describe('Tool title precedence', () => { - test('should follow correct title precedence: title → annotations.title → name', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - // Tool 1: Only name - mcpServer.tool('tool_name_only', async () => ({ - content: [{ type: 'text', text: 'Response' }] - })); - - // Tool 2: Name and annotations.title - mcpServer.tool( - 'tool_with_annotations_title', - 'Tool with annotations title', - { - title: 'Annotations Title' - }, - async () => ({ - content: [{ type: 'text', text: 'Response' }] - }) - ); - - // Tool 3: Name and title (using registerTool) - mcpServer.registerTool( - 'tool_with_title', - { - title: 'Regular Title', - description: 'Tool with regular title' - }, - async () => ({ - content: [{ type: 'text' as const, text: 'Response' }] - }) - ); - - // Tool 4: All three - title should win - mcpServer.registerTool( - 'tool_with_all_titles', - { - title: 'Regular Title Wins', - description: 'Tool with all titles', - annotations: { - title: 'Annotations Title Should Not Show' - } - }, - async () => ({ - content: [{ type: 'text' as const, text: 'Response' }] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); - - const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); - - expect(result.tools).toHaveLength(4); - - // Tool 1: Only name - should display name - const tool1 = result.tools.find(t => t.name === 'tool_name_only'); - expect(tool1).toBeDefined(); - expect(getDisplayName(tool1!)).toBe('tool_name_only'); - - // Tool 2: Name and annotations.title - should display annotations.title - const tool2 = result.tools.find(t => t.name === 'tool_with_annotations_title'); - expect(tool2).toBeDefined(); - expect(tool2!.annotations?.title).toBe('Annotations Title'); - expect(getDisplayName(tool2!)).toBe('Annotations Title'); - - // Tool 3: Name and title - should display title - const tool3 = result.tools.find(t => t.name === 'tool_with_title'); - expect(tool3).toBeDefined(); - expect(tool3!.title).toBe('Regular Title'); - expect(getDisplayName(tool3!)).toBe('Regular Title'); - - // Tool 4: All three - title should take precedence - const tool4 = result.tools.find(t => t.name === 'tool_with_all_titles'); - expect(tool4).toBeDefined(); - expect(tool4!.title).toBe('Regular Title Wins'); - expect(tool4!.annotations?.title).toBe('Annotations Title Should Not Show'); - expect(getDisplayName(tool4!)).toBe('Regular Title Wins'); - }); - - test('getDisplayName unit tests for title precedence', () => { - // Test 1: Only name - expect(getDisplayName({ name: 'tool_name' })).toBe('tool_name'); - - // Test 2: Name and title - title wins - expect( - getDisplayName({ - name: 'tool_name', - title: 'Tool Title' - }) - ).toBe('Tool Title'); - - // Test 3: Name and annotations.title - annotations.title wins - expect( - getDisplayName({ - name: 'tool_name', - annotations: { title: 'Annotations Title' } - }) - ).toBe('Annotations Title'); - - // Test 4: All three - title wins (correct precedence) - expect( - getDisplayName({ - name: 'tool_name', - title: 'Regular Title', - annotations: { title: 'Annotations Title' } - }) - ).toBe('Regular Title'); - - // Test 5: Empty title should not be used - expect( - getDisplayName({ - name: 'tool_name', - title: '', - annotations: { title: 'Annotations Title' } - }) - ).toBe('Annotations Title'); - - // Test 6: Undefined vs null handling - expect( - getDisplayName({ - name: 'tool_name', - title: undefined, - annotations: { title: 'Annotations Title' } - }) - ).toBe('Annotations Title'); - }); - - test('should support resource template completion with resolved context', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - mcpServer.registerResource( - 'test', - new ResourceTemplate('github://repos/{owner}/{repo}', { - list: undefined, - complete: { - repo: (value, context) => { - if (context?.arguments?.['owner'] === 'org1') { - return ['project1', 'project2', 'project3'].filter(r => r.startsWith(value)); - } else if (context?.arguments?.['owner'] === 'org2') { - return ['repo1', 'repo2', 'repo3'].filter(r => r.startsWith(value)); - } - return []; - } - } - }), - { - title: 'GitHub Repository', - description: 'Repository information' - }, - async () => ({ - contents: [ - { - uri: 'github://repos/test/test', - text: 'Test content' - } - ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - // Test with microsoft owner - const result1 = await client.request( - { - method: 'completion/complete', - params: { - ref: { - type: 'ref/resource', - uri: 'github://repos/{owner}/{repo}' - }, - argument: { - name: 'repo', - value: 'p' - }, - context: { - arguments: { - owner: 'org1' - } - } - } - }, - CompleteResultSchema - ); - - expect(result1.completion.values).toEqual(['project1', 'project2', 'project3']); - expect(result1.completion.total).toBe(3); - - // Test with facebook owner - const result2 = await client.request( - { - method: 'completion/complete', - params: { - ref: { - type: 'ref/resource', - uri: 'github://repos/{owner}/{repo}' - }, - argument: { - name: 'repo', - value: 'r' - }, - context: { - arguments: { - owner: 'org2' - } - } - } - }, - CompleteResultSchema - ); - - expect(result2.completion.values).toEqual(['repo1', 'repo2', 'repo3']); - expect(result2.completion.total).toBe(3); - - // Test with no resolved context - const result3 = await client.request( - { - method: 'completion/complete', - params: { - ref: { - type: 'ref/resource', - uri: 'github://repos/{owner}/{repo}' - }, - argument: { - name: 'repo', - value: 't' - } - } - }, - CompleteResultSchema - ); - - expect(result3.completion.values).toEqual([]); - expect(result3.completion.total).toBe(0); - }); - - test('should support prompt argument completion with resolved context', async () => { - const mcpServer = new McpServer({ - name: 'test server', - version: '1.0' - }); - - const client = new Client({ - name: 'test client', - version: '1.0' - }); - - mcpServer.registerPrompt( - 'test-prompt', - { - title: 'Team Greeting', - description: 'Generate a greeting for team members', - argsSchema: { - department: completable(z.string(), value => { - return ['engineering', 'sales', 'marketing', 'support'].filter(d => d.startsWith(value)); - }), - name: completable(z.string(), (value, context) => { - const department = context?.arguments?.['department']; - if (department === 'engineering') { - return ['Alice', 'Bob', 'Charlie'].filter(n => n.startsWith(value)); - } else if (department === 'sales') { - return ['David', 'Eve', 'Frank'].filter(n => n.startsWith(value)); - } else if (department === 'marketing') { - return ['Grace', 'Henry', 'Iris'].filter(n => n.startsWith(value)); - } - return ['Guest'].filter(n => n.startsWith(value)); - }) - } - }, - async ({ department, name }) => ({ - messages: [ - { - role: 'assistant', - content: { - type: 'text', - text: `Hello ${name}, welcome to the ${department} team!` - } - } - ] - }) - ); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - // Test with engineering department - const result1 = await client.request( - { - method: 'completion/complete', - params: { - ref: { - type: 'ref/prompt', - name: 'test-prompt' - }, - argument: { - name: 'name', - value: 'A' - }, - context: { - arguments: { - department: 'engineering' - } - } - } - }, - CompleteResultSchema - ); - - expect(result1.completion.values).toEqual(['Alice']); - - // Test with sales department - const result2 = await client.request( - { - method: 'completion/complete', - params: { - ref: { - type: 'ref/prompt', - name: 'test-prompt' - }, - argument: { - name: 'name', - value: 'D' - }, - context: { - arguments: { - department: 'sales' - } - } - } - }, - CompleteResultSchema - ); - - expect(result2.completion.values).toEqual(['David']); - - // Test with marketing department - const result3 = await client.request( - { - method: 'completion/complete', - params: { - ref: { - type: 'ref/prompt', - name: 'test-prompt' - }, - argument: { - name: 'name', - value: 'G' - }, - context: { - arguments: { - department: 'marketing' - } - } - } - }, - CompleteResultSchema - ); - - expect(result3.completion.values).toEqual(['Grace']); - - // Test with no resolved context - const result4 = await client.request( - { - method: 'completion/complete', - params: { - ref: { - type: 'ref/prompt', - name: 'test-prompt' - }, - argument: { - name: 'name', - value: 'G' - } - } - }, - CompleteResultSchema - ); - - expect(result4.completion.values).toEqual(['Guest']); - }); -}); - -describe('elicitInput()', () => { - const checkAvailability = vi.fn().mockResolvedValue(false); - const findAlternatives = vi.fn().mockResolvedValue([]); - const makeBooking = vi.fn().mockResolvedValue('BOOKING-123'); - - let mcpServer: McpServer; - let client: Client; - - beforeEach(() => { - vi.clearAllMocks(); - - // Create server with restaurant booking tool - mcpServer = new McpServer({ - name: 'restaurant-booking-server', - version: '1.0.0' - }); - - // Register the restaurant booking tool from README example - mcpServer.tool( - 'book-restaurant', - { - restaurant: z.string(), - date: z.string(), - partySize: z.number() - }, - async ({ restaurant, date, partySize }) => { - // Check availability - const available = await checkAvailability(restaurant, date, partySize); - - if (!available) { - // Ask user if they want to try alternative dates - const result = await mcpServer.server.elicitInput({ - message: `No tables available at ${restaurant} on ${date}. Would you like to check alternative dates?`, - requestedSchema: { - type: 'object', - properties: { - checkAlternatives: { - type: 'boolean', - title: 'Check alternative dates', - description: 'Would you like me to check other dates?' - }, - flexibleDates: { - type: 'string', - title: 'Date flexibility', - description: 'How flexible are your dates?', - enum: ['next_day', 'same_week', 'next_week'], - enumNames: ['Next day', 'Same week', 'Next week'] - } - }, - required: ['checkAlternatives'] - } - }); - - if (result.action === 'accept' && result.content?.checkAlternatives) { - const alternatives = await findAlternatives(restaurant, date, partySize, result.content.flexibleDates as string); - return { - content: [ - { - type: 'text', - text: `Found these alternatives: ${alternatives.join(', ')}` - } - ] - }; - } - - return { - content: [ - { - type: 'text', - text: 'No booking made. Original date not available.' - } - ] - }; - } - - await makeBooking(restaurant, date, partySize); - return { - content: [ - { - type: 'text', - text: `Booked table for ${partySize} at ${restaurant} on ${date}` - } - ] - }; - } - ); - - // Create client with elicitation capability - client = new Client( - { - name: 'test-client', - version: '1.0.0' - }, - { - capabilities: { - elicitation: {} - } - } - ); - }); - - test('should successfully elicit additional information', async () => { - // Mock availability check to return false - checkAvailability.mockResolvedValue(false); - findAlternatives.mockResolvedValue(['2024-12-26', '2024-12-27', '2024-12-28']); - - // Set up client to accept alternative date checking - client.setRequestHandler(ElicitRequestSchema, async request => { - expect(request.params.message).toContain('No tables available at ABC Restaurant on 2024-12-25'); - return { - action: 'accept', - content: { - checkAlternatives: true, - flexibleDates: 'same_week' - } - }; - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - // Call the tool - const result = await client.callTool({ - name: 'book-restaurant', - arguments: { - restaurant: 'ABC Restaurant', - date: '2024-12-25', - partySize: 2 - } - }); - - expect(checkAvailability).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2); - expect(findAlternatives).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2, 'same_week'); - expect(result.content).toEqual([ - { - type: 'text', - text: 'Found these alternatives: 2024-12-26, 2024-12-27, 2024-12-28' - } - ]); - }); - - test('should handle user declining to elicitation request', async () => { - // Mock availability check to return false - checkAvailability.mockResolvedValue(false); - - // Set up client to reject alternative date checking - client.setRequestHandler(ElicitRequestSchema, async () => { - return { - action: 'accept', - content: { - checkAlternatives: false - } - }; - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - // Call the tool - const result = await client.callTool({ - name: 'book-restaurant', - arguments: { - restaurant: 'ABC Restaurant', - date: '2024-12-25', - partySize: 2 - } - }); - - expect(checkAvailability).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2); - expect(findAlternatives).not.toHaveBeenCalled(); - expect(result.content).toEqual([ - { - type: 'text', - text: 'No booking made. Original date not available.' - } - ]); - }); - - test('should handle user cancelling the elicitation', async () => { - // Mock availability check to return false - checkAvailability.mockResolvedValue(false); - - // Set up client to cancel the elicitation - client.setRequestHandler(ElicitRequestSchema, async () => { - return { - action: 'cancel' - }; - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - - await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - - // Call the tool - const result = await client.callTool({ - name: 'book-restaurant', - arguments: { - restaurant: 'ABC Restaurant', - date: '2024-12-25', - partySize: 2 - } - }); - - expect(checkAvailability).toHaveBeenCalledWith('ABC Restaurant', '2024-12-25', 2); - expect(findAlternatives).not.toHaveBeenCalled(); - expect(result.content).toEqual([ - { - type: 'text', - text: 'No booking made. Original date not available.' - } - ]); - }); -}); - -describe('Tools with union and intersection schemas', () => { - test('should support union schemas', async () => { - const server = new McpServer({ - name: 'test', - version: '1.0.0' - }); - - const client = new Client({ - name: 'test-client', - version: '1.0.0' - }); - - const unionSchema = z.union([ - z.object({ type: z.literal('email'), email: z.string().email() }), - z.object({ type: z.literal('phone'), phone: z.string() }) - ]); - - server.registerTool('contact', { inputSchema: unionSchema }, async args => { - if (args.type === 'email') { - return { - content: [{ type: 'text', text: `Email contact: ${args.email}` }] - }; - } else { - return { - content: [{ type: 'text', text: `Phone contact: ${args.phone}` }] - }; - } - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await server.connect(serverTransport); - await client.connect(clientTransport); - - const emailResult = await client.callTool({ - name: 'contact', - arguments: { - type: 'email', - email: 'test@example.com' - } - }); - - expect(emailResult.content).toEqual([ - { - type: 'text', - text: 'Email contact: test@example.com' - } - ]); - - const phoneResult = await client.callTool({ - name: 'contact', - arguments: { - type: 'phone', - phone: '+1234567890' - } - }); - - expect(phoneResult.content).toEqual([ - { - type: 'text', - text: 'Phone contact: +1234567890' - } - ]); - }); - - test('should support intersection schemas', async () => { - const server = new McpServer({ - name: 'test', - version: '1.0.0' - }); - - const client = new Client({ - name: 'test-client', - version: '1.0.0' - }); - - const baseSchema = z.object({ id: z.string() }); - const extendedSchema = z.object({ name: z.string(), age: z.number() }); - const intersectionSchema = z.intersection(baseSchema, extendedSchema); - - server.registerTool('user', { inputSchema: intersectionSchema }, async args => { - return { - content: [ - { - type: 'text', - text: `User: ${args.id}, ${args.name}, ${args.age} years old` - } - ] - }; - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await server.connect(serverTransport); - await client.connect(clientTransport); - - const result = await client.callTool({ - name: 'user', - arguments: { - id: '123', - name: 'John Doe', - age: 30 - } - }); - - expect(result.content).toEqual([ - { - type: 'text', - text: 'User: 123, John Doe, 30 years old' - } - ]); - }); - - test('should support complex nested schemas', async () => { - const server = new McpServer({ - name: 'test', - version: '1.0.0' - }); - - const client = new Client({ - name: 'test-client', - version: '1.0.0' - }); - - const schema = z.object({ - items: z.array( - z.union([ - z.object({ type: z.literal('text'), content: z.string() }), - z.object({ type: z.literal('number'), value: z.number() }) - ]) - ) - }); - - server.registerTool('process', { inputSchema: schema }, async args => { - const processed = args.items.map(item => { - if (item.type === 'text') { - return item.content.toUpperCase(); - } else { - return item.value * 2; - } - }); - return { - content: [ - { - type: 'text', - text: `Processed: ${processed.join(', ')}` - } - ] - }; - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await server.connect(serverTransport); - await client.connect(clientTransport); - - const result = await client.callTool({ - name: 'process', - arguments: { - items: [ - { type: 'text', content: 'hello' }, - { type: 'number', value: 5 }, - { type: 'text', content: 'world' } - ] - } - }); - - expect(result.content).toEqual([ - { - type: 'text', - text: 'Processed: HELLO, 10, WORLD' - } - ]); - }); - - test('should validate union schema inputs correctly', async () => { - const server = new McpServer({ - name: 'test', - version: '1.0.0' - }); - - const client = new Client({ - name: 'test-client', - version: '1.0.0' - }); - - const unionSchema = z.union([ - z.object({ type: z.literal('a'), value: z.string() }), - z.object({ type: z.literal('b'), value: z.number() }) - ]); - - server.registerTool('union-test', { inputSchema: unionSchema }, async () => { - return { - content: [{ type: 'text', text: 'Success' }] - }; - }); - - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await server.connect(serverTransport); - await client.connect(clientTransport); - - const invalidTypeResult = await client.callTool({ - name: 'union-test', - arguments: { - type: 'a', - value: 123 - } - }); - - expect(invalidTypeResult.isError).toBe(true); - expect(invalidTypeResult.content).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: 'text', - text: expect.stringContaining('Input validation error') - }) - ]) - ); - - const invalidDiscriminatorResult = await client.callTool({ - name: 'union-test', - arguments: { - type: 'c', - value: 'test' - } - }); - - expect(invalidDiscriminatorResult.isError).toBe(true); - expect(invalidDiscriminatorResult.content).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: 'text', - text: expect.stringContaining('Input validation error') - }) - ]) - ); - }); -}); diff --git a/src/server/v3/sse.v3.test.ts b/src/server/v3/sse.v3.test.ts deleted file mode 100644 index be19726e8..000000000 --- a/src/server/v3/sse.v3.test.ts +++ /dev/null @@ -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().mockReturnThis(), - write: vi.fn().mockReturnThis(), - on: vi.fn().mockReturnThis(), - end: vi.fn().mockReturnThis() - }; - - return res as unknown as Mocked; -}; - -const createMockRequest = ({ headers = {}, body }: { headers?: Record; body?: string } = {}) => { - const mockReq = { - headers, - body: body ? body : undefined, - auth: { - token: 'test-token' - }, - on: vi.fn().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(), - removeListener: vi.fn() - } 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 => { - 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(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 { - 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 -): Promise { - const headers: Record = { - '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 { - 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 => { - 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'); - }); - }); - }); -}); diff --git a/src/server/v3/streamableHttp.v3.test.ts b/src/server/v3/streamableHttp.v3.test.ts deleted file mode 100644 index 524069080..000000000 --- a/src/server/v3/streamableHttp.v3.test.ts +++ /dev/null @@ -1,2151 +0,0 @@ -import { createServer, type Server, IncomingMessage, ServerResponse } from 'node:http'; -import { createServer as netCreateServer, AddressInfo } from 'node:net'; -import { randomUUID } from 'node:crypto'; -import { EventStore, StreamableHTTPServerTransport, EventId, StreamId } from '../streamableHttp.js'; -import { McpServer } from '../mcp.js'; -import { CallToolResult, JSONRPCMessage } from '../../types.js'; -import * as z from 'zod/v3'; -import { AuthInfo } from '../auth/types.js'; - -async function getFreePort() { - return new Promise(res => { - const srv = netCreateServer(); - srv.listen(0, () => { - const address = srv.address()!; - if (typeof address === 'string') { - throw new Error('Unexpected address type: ' + typeof address); - } - const port = (address as AddressInfo).port; - srv.close(_err => res(port)); - }); - }); -} - -/** - * Test server configuration for StreamableHTTPServerTransport tests - */ -interface TestServerConfig { - sessionIdGenerator: (() => string) | undefined; - enableJsonResponse?: boolean; - customRequestHandler?: (req: IncomingMessage, res: ServerResponse, parsedBody?: unknown) => Promise; - eventStore?: EventStore; - onsessioninitialized?: (sessionId: string) => void | Promise; - onsessionclosed?: (sessionId: string) => void | Promise; -} - -/** - * Helper to create and start test HTTP server with MCP setup - */ -async function createTestServer(config: TestServerConfig = { sessionIdGenerator: () => randomUUID() }): Promise<{ - server: Server; - transport: StreamableHTTPServerTransport; - mcpServer: McpServer; - baseUrl: URL; -}> { - 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 => { - return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; - } - ); - - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: config.sessionIdGenerator, - enableJsonResponse: config.enableJsonResponse ?? false, - eventStore: config.eventStore, - onsessioninitialized: config.onsessioninitialized, - onsessionclosed: config.onsessionclosed - }); - - await mcpServer.connect(transport); - - const server = createServer(async (req, res) => { - try { - if (config.customRequestHandler) { - await config.customRequestHandler(req, res); - } else { - await transport.handleRequest(req, res); - } - } catch (error) { - console.error('Error handling request:', error); - if (!res.headersSent) res.writeHead(500).end(); - } - }); - - const baseUrl = await new Promise(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, transport, mcpServer, baseUrl }; -} - -/** - * Helper to create and start authenticated test HTTP server with MCP setup - */ -async function createTestAuthServer(config: TestServerConfig = { sessionIdGenerator: () => randomUUID() }): Promise<{ - server: Server; - transport: StreamableHTTPServerTransport; - mcpServer: McpServer; - baseUrl: URL; -}> { - const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } }); - - mcpServer.tool( - 'profile', - 'A user profile data tool', - { active: z.boolean().describe('Profile status') }, - async ({ active }, { authInfo }): Promise => { - return { content: [{ type: 'text', text: `${active ? 'Active' : 'Inactive'} profile from token: ${authInfo?.token}!` }] }; - } - ); - - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: config.sessionIdGenerator, - enableJsonResponse: config.enableJsonResponse ?? false, - eventStore: config.eventStore, - onsessioninitialized: config.onsessioninitialized, - onsessionclosed: config.onsessionclosed - }); - - await mcpServer.connect(transport); - - const server = createServer(async (req: IncomingMessage & { auth?: AuthInfo }, res) => { - try { - if (config.customRequestHandler) { - await config.customRequestHandler(req, res); - } else { - req.auth = { token: req.headers['authorization']?.split(' ')[1] } as AuthInfo; - await transport.handleRequest(req, res); - } - } catch (error) { - console.error('Error handling request:', error); - if (!res.headersSent) res.writeHead(500).end(); - } - }); - - const baseUrl = await new Promise(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, transport, mcpServer, baseUrl }; -} - -/** - * Helper to stop test server - */ -async function stopTestServer({ server, transport }: { server: Server; transport: StreamableHTTPServerTransport }): Promise { - // First close the transport to ensure all SSE streams are closed - await transport.close(); - - // Close the server without waiting indefinitely - server.close(); -} - -/** - * Common test messages - */ -const TEST_MESSAGES = { - initialize: { - jsonrpc: '2.0', - method: 'initialize', - params: { - clientInfo: { name: 'test-client', version: '1.0' }, - protocolVersion: '2025-03-26', - capabilities: {} - }, - - id: 'init-1' - } as JSONRPCMessage, - - toolsList: { - jsonrpc: '2.0', - method: 'tools/list', - params: {}, - id: 'tools-1' - } as JSONRPCMessage -}; - -/** - * Helper to extract text from SSE response - * Note: Can only be called once per response stream. For multiple reads, - * get the reader manually and read multiple times. - */ -async function readSSEEvent(response: Response): Promise { - const reader = response.body?.getReader(); - const { value } = await reader!.read(); - return new TextDecoder().decode(value); -} - -/** - * Helper to send JSON-RPC request - */ -async function sendPostRequest( - baseUrl: URL, - message: JSONRPCMessage | JSONRPCMessage[], - sessionId?: string, - extraHeaders?: Record -): Promise { - const headers: Record = { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - ...extraHeaders - }; - - if (sessionId) { - headers['mcp-session-id'] = sessionId; - // After initialization, include the protocol version header - headers['mcp-protocol-version'] = '2025-03-26'; - } - - return fetch(baseUrl, { - method: 'POST', - headers, - body: JSON.stringify(message) - }); -} - -function expectErrorResponse(data: unknown, expectedCode: number, expectedMessagePattern: RegExp): void { - expect(data).toMatchObject({ - jsonrpc: '2.0', - error: expect.objectContaining({ - code: expectedCode, - message: expect.stringMatching(expectedMessagePattern) - }) - }); -} - -describe('StreamableHTTPServerTransport', () => { - let server: Server; - let mcpServer: McpServer; - let transport: StreamableHTTPServerTransport; - let baseUrl: URL; - let sessionId: string; - - beforeEach(async () => { - const result = await createTestServer(); - server = result.server; - transport = result.transport; - mcpServer = result.mcpServer; - baseUrl = result.baseUrl; - }); - - afterEach(async () => { - await stopTestServer({ server, transport }); - }); - - async function initializeServer(): Promise { - const response = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); - - expect(response.status).toBe(200); - const newSessionId = response.headers.get('mcp-session-id'); - expect(newSessionId).toBeDefined(); - return newSessionId as string; - } - - it('should initialize server and generate session ID', async () => { - const response = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); - - expect(response.status).toBe(200); - expect(response.headers.get('content-type')).toBe('text/event-stream'); - expect(response.headers.get('mcp-session-id')).toBeDefined(); - }); - - it('should reject second initialization request', async () => { - // First initialize - const sessionId = await initializeServer(); - expect(sessionId).toBeDefined(); - - // Try second initialize - const secondInitMessage = { - ...TEST_MESSAGES.initialize, - id: 'second-init' - }; - - const response = await sendPostRequest(baseUrl, secondInitMessage); - - expect(response.status).toBe(400); - const errorData = await response.json(); - expectErrorResponse(errorData, -32600, /Server already initialized/); - }); - - it('should reject batch initialize request', async () => { - const batchInitMessages: JSONRPCMessage[] = [ - TEST_MESSAGES.initialize, - { - jsonrpc: '2.0', - method: 'initialize', - params: { - clientInfo: { name: 'test-client-2', version: '1.0' }, - protocolVersion: '2025-03-26' - }, - id: 'init-2' - } - ]; - - const response = await sendPostRequest(baseUrl, batchInitMessages); - - expect(response.status).toBe(400); - const errorData = await response.json(); - expectErrorResponse(errorData, -32600, /Only one initialization request is allowed/); - }); - - it('should handle post requests via sse response correctly', async () => { - sessionId = await initializeServer(); - - const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId); - - expect(response.status).toBe(200); - - // Read the SSE stream for the response - const text = await readSSEEvent(response); - - // Parse the SSE event - const eventLines = text.split('\n'); - const dataLine = eventLines.find(line => line.startsWith('data:')); - expect(dataLine).toBeDefined(); - - const eventData = JSON.parse(dataLine!.substring(5)); - expect(eventData).toMatchObject({ - jsonrpc: '2.0', - result: expect.objectContaining({ - tools: expect.arrayContaining([ - expect.objectContaining({ - name: 'greet', - description: 'A simple greeting tool' - }) - ]) - }), - id: 'tools-1' - }); - }); - - it('should call a tool and return the result', async () => { - sessionId = await initializeServer(); - - const toolCallMessage: JSONRPCMessage = { - jsonrpc: '2.0', - method: 'tools/call', - params: { - name: 'greet', - arguments: { - name: 'Test User' - } - }, - id: 'call-1' - }; - - const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId); - expect(response.status).toBe(200); - - const text = await readSSEEvent(response); - const eventLines = text.split('\n'); - const dataLine = eventLines.find(line => line.startsWith('data:')); - expect(dataLine).toBeDefined(); - - const eventData = JSON.parse(dataLine!.substring(5)); - expect(eventData).toMatchObject({ - jsonrpc: '2.0', - result: { - content: [ - { - type: 'text', - text: 'Hello, Test User!' - } - ] - }, - id: 'call-1' - }); - }); - - /*** - * Test: Tool With Request Info - */ - it('should pass request info to tool callback', async () => { - sessionId = await initializeServer(); - - mcpServer.tool( - 'test-request-info', - 'A simple test tool with request info', - { name: z.string().describe('Name to greet') }, - async ({ name }, { requestInfo }): Promise => { - 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 sendPostRequest(baseUrl, toolCallMessage, sessionId); - expect(response.status).toBe(200); - - const text = await readSSEEvent(response); - const eventLines = text.split('\n'); - const dataLine = eventLines.find(line => line.startsWith('data:')); - expect(dataLine).toBeDefined(); - - const eventData = JSON.parse(dataLine!.substring(5)); - - expect(eventData).toMatchObject({ - jsonrpc: '2.0', - result: { - content: [ - { type: 'text', text: 'Hello, Test User!' }, - { type: 'text', text: expect.any(String) } - ] - }, - id: 'call-1' - }); - - const requestInfo = JSON.parse(eventData.result.content[1].text); - expect(requestInfo).toMatchObject({ - headers: { - 'content-type': 'application/json', - accept: 'application/json, text/event-stream', - connection: 'keep-alive', - 'mcp-session-id': sessionId, - 'accept-language': '*', - 'user-agent': expect.any(String), - 'accept-encoding': expect.any(String), - 'content-length': expect.any(String) - } - }); - }); - - it('should reject requests without a valid session ID', async () => { - const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList); - - expect(response.status).toBe(400); - const errorData = await response.json(); - expectErrorResponse(errorData, -32000, /Bad Request/); - expect(errorData.id).toBeNull(); - }); - - it('should reject invalid session ID', async () => { - // First initialize to be in valid state - await initializeServer(); - - // Now try with invalid session ID - const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, 'invalid-session-id'); - - expect(response.status).toBe(404); - const errorData = await response.json(); - expectErrorResponse(errorData, -32001, /Session not found/); - }); - - it('should establish standalone SSE stream and receive server-initiated messages', async () => { - // First initialize to get a session ID - sessionId = await initializeServer(); - - // Open a standalone SSE stream - const sseResponse = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'text/event-stream', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2025-03-26' - } - }); - - expect(sseResponse.status).toBe(200); - expect(sseResponse.headers.get('content-type')).toBe('text/event-stream'); - - // Send a notification (server-initiated message) that should appear on SSE stream - const notification: JSONRPCMessage = { - jsonrpc: '2.0', - method: 'notifications/message', - params: { level: 'info', data: 'Test notification' } - }; - - // Send the notification via transport - await transport.send(notification); - - // Read from the stream and verify we got the notification - const text = await readSSEEvent(sseResponse); - - const eventLines = text.split('\n'); - const dataLine = eventLines.find(line => line.startsWith('data:')); - expect(dataLine).toBeDefined(); - - const eventData = JSON.parse(dataLine!.substring(5)); - expect(eventData).toMatchObject({ - jsonrpc: '2.0', - method: 'notifications/message', - params: { level: 'info', data: 'Test notification' } - }); - }); - - it('should not close GET SSE stream after sending multiple server notifications', async () => { - sessionId = await initializeServer(); - - // Open a standalone SSE stream - const sseResponse = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'text/event-stream', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2025-03-26' - } - }); - - expect(sseResponse.status).toBe(200); - const reader = sseResponse.body?.getReader(); - - // Send multiple notifications - const notification1: JSONRPCMessage = { - jsonrpc: '2.0', - method: 'notifications/message', - params: { level: 'info', data: 'First notification' } - }; - - // Just send one and verify it comes through - then the stream should stay open - await transport.send(notification1); - - const { value, done } = await reader!.read(); - const text = new TextDecoder().decode(value); - expect(text).toContain('First notification'); - expect(done).toBe(false); // Stream should still be open - }); - - it('should reject second SSE stream for the same session', async () => { - sessionId = await initializeServer(); - - // Open first SSE stream - const firstStream = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'text/event-stream', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2025-03-26' - } - }); - - expect(firstStream.status).toBe(200); - - // Try to open a second SSE stream with the same session ID - const secondStream = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'text/event-stream', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2025-03-26' - } - }); - - // Should be rejected - expect(secondStream.status).toBe(409); // Conflict - const errorData = await secondStream.json(); - expectErrorResponse(errorData, -32000, /Only one SSE stream is allowed per session/); - }); - - it('should reject GET requests without Accept: text/event-stream header', async () => { - sessionId = await initializeServer(); - - // Try GET without proper Accept header - const response = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'application/json', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2025-03-26' - } - }); - - expect(response.status).toBe(406); - const errorData = await response.json(); - expectErrorResponse(errorData, -32000, /Client must accept text\/event-stream/); - }); - - it('should reject POST requests without proper Accept header', async () => { - sessionId = await initializeServer(); - - // Try POST without Accept: text/event-stream - const response = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', // Missing text/event-stream - 'mcp-session-id': sessionId - }, - body: JSON.stringify(TEST_MESSAGES.toolsList) - }); - - expect(response.status).toBe(406); - const errorData = await response.json(); - expectErrorResponse(errorData, -32000, /Client must accept both application\/json and text\/event-stream/); - }); - - it('should reject unsupported Content-Type', async () => { - sessionId = await initializeServer(); - - // Try POST with text/plain Content-Type - const response = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'text/plain', - Accept: 'application/json, text/event-stream', - 'mcp-session-id': sessionId - }, - body: 'This is plain text' - }); - - expect(response.status).toBe(415); - const errorData = await response.json(); - expectErrorResponse(errorData, -32000, /Content-Type must be application\/json/); - }); - - it('should handle JSON-RPC batch notification messages with 202 response', async () => { - sessionId = await initializeServer(); - - // Send batch of notifications (no IDs) - const batchNotifications: JSONRPCMessage[] = [ - { jsonrpc: '2.0', method: 'someNotification1', params: {} }, - { jsonrpc: '2.0', method: 'someNotification2', params: {} } - ]; - const response = await sendPostRequest(baseUrl, batchNotifications, sessionId); - - expect(response.status).toBe(202); - }); - - it('should handle batch request messages with SSE stream for responses', async () => { - sessionId = await initializeServer(); - - // Send batch of requests - const batchRequests: JSONRPCMessage[] = [ - { jsonrpc: '2.0', method: 'tools/list', params: {}, id: 'req-1' }, - { jsonrpc: '2.0', method: 'tools/call', params: { name: 'greet', arguments: { name: 'BatchUser' } }, id: 'req-2' } - ]; - const response = await sendPostRequest(baseUrl, batchRequests, sessionId); - - expect(response.status).toBe(200); - expect(response.headers.get('content-type')).toBe('text/event-stream'); - - const reader = response.body?.getReader(); - - // The responses may come in any order or together in one chunk - const { value } = await reader!.read(); - const text = new TextDecoder().decode(value); - - // Check that both responses were sent on the same stream - expect(text).toContain('"id":"req-1"'); - expect(text).toContain('"tools"'); // tools/list result - expect(text).toContain('"id":"req-2"'); - expect(text).toContain('Hello, BatchUser'); // tools/call result - }); - - it('should properly handle invalid JSON data', async () => { - sessionId = await initializeServer(); - - // Send invalid JSON - const response = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - 'mcp-session-id': sessionId - }, - body: 'This is not valid JSON' - }); - - expect(response.status).toBe(400); - const errorData = await response.json(); - expectErrorResponse(errorData, -32700, /Parse error/); - }); - - it('should return 400 error for invalid JSON-RPC messages', async () => { - sessionId = await initializeServer(); - - // Invalid JSON-RPC (missing required jsonrpc version) - const invalidMessage = { method: 'tools/list', params: {}, id: 1 }; // missing jsonrpc version - const response = await sendPostRequest(baseUrl, invalidMessage as JSONRPCMessage, sessionId); - - expect(response.status).toBe(400); - const errorData = await response.json(); - expect(errorData).toMatchObject({ - jsonrpc: '2.0', - error: expect.anything() - }); - }); - - it('should reject requests to uninitialized server', async () => { - // Create a new HTTP server and transport without initializing - const { server: uninitializedServer, transport: uninitializedTransport, baseUrl: uninitializedUrl } = await createTestServer(); - // Transport not used in test but needed for cleanup - - // No initialization, just send a request directly - const uninitializedMessage: JSONRPCMessage = { - jsonrpc: '2.0', - method: 'tools/list', - params: {}, - id: 'uninitialized-test' - }; - - // Send a request to uninitialized server - const response = await sendPostRequest(uninitializedUrl, uninitializedMessage, 'any-session-id'); - - expect(response.status).toBe(400); - const errorData = await response.json(); - expectErrorResponse(errorData, -32000, /Server not initialized/); - - // Cleanup - await stopTestServer({ server: uninitializedServer, transport: uninitializedTransport }); - }); - - it('should send response messages to the connection that sent the request', async () => { - sessionId = await initializeServer(); - - const message1: JSONRPCMessage = { - jsonrpc: '2.0', - method: 'tools/list', - params: {}, - id: 'req-1' - }; - - const message2: JSONRPCMessage = { - jsonrpc: '2.0', - method: 'tools/call', - params: { - name: 'greet', - arguments: { name: 'Connection2' } - }, - id: 'req-2' - }; - - // Make two concurrent fetch connections for different requests - const req1 = sendPostRequest(baseUrl, message1, sessionId); - const req2 = sendPostRequest(baseUrl, message2, sessionId); - - // Get both responses - const [response1, response2] = await Promise.all([req1, req2]); - const reader1 = response1.body?.getReader(); - const reader2 = response2.body?.getReader(); - - // Read responses from each stream (requires each receives its specific response) - const { value: value1 } = await reader1!.read(); - const text1 = new TextDecoder().decode(value1); - expect(text1).toContain('"id":"req-1"'); - expect(text1).toContain('"tools"'); // tools/list result - - const { value: value2 } = await reader2!.read(); - const text2 = new TextDecoder().decode(value2); - expect(text2).toContain('"id":"req-2"'); - expect(text2).toContain('Hello, Connection2'); // tools/call result - }); - - it('should keep stream open after sending server notifications', async () => { - sessionId = await initializeServer(); - - // Open a standalone SSE stream - const sseResponse = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'text/event-stream', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2025-03-26' - } - }); - - // Send several server-initiated notifications - await transport.send({ - jsonrpc: '2.0', - method: 'notifications/message', - params: { level: 'info', data: 'First notification' } - }); - - await transport.send({ - jsonrpc: '2.0', - method: 'notifications/message', - params: { level: 'info', data: 'Second notification' } - }); - - // Stream should still be open - it should not close after sending notifications - expect(sseResponse.bodyUsed).toBe(false); - }); - - // The current implementation will close the entire transport for DELETE - // Creating a temporary transport/server where we don't care if it gets closed - it('should properly handle DELETE requests and close session', async () => { - // Setup a temporary server for this test - const tempResult = await createTestServer(); - const tempServer = tempResult.server; - const tempUrl = tempResult.baseUrl; - - // Initialize to get a session ID - const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); - const tempSessionId = initResponse.headers.get('mcp-session-id'); - - // Now DELETE the session - const deleteResponse = await fetch(tempUrl, { - method: 'DELETE', - headers: { - 'mcp-session-id': tempSessionId || '', - 'mcp-protocol-version': '2025-03-26' - } - }); - - expect(deleteResponse.status).toBe(200); - - // Clean up - don't wait indefinitely for server close - tempServer.close(); - }); - - it('should reject DELETE requests with invalid session ID', async () => { - // Initialize the server first to activate it - sessionId = await initializeServer(); - - // Try to delete with invalid session ID - const response = await fetch(baseUrl, { - method: 'DELETE', - headers: { - 'mcp-session-id': 'invalid-session-id', - 'mcp-protocol-version': '2025-03-26' - } - }); - - expect(response.status).toBe(404); - const errorData = await response.json(); - expectErrorResponse(errorData, -32001, /Session not found/); - }); - - describe('protocol version header validation', () => { - it('should accept requests with matching protocol version', async () => { - sessionId = await initializeServer(); - - // Send request with matching protocol version - const response = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sessionId); - - expect(response.status).toBe(200); - }); - - it('should accept requests without protocol version header', async () => { - sessionId = await initializeServer(); - - // Send request without protocol version header - const response = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - 'mcp-session-id': sessionId - // No mcp-protocol-version header - }, - body: JSON.stringify(TEST_MESSAGES.toolsList) - }); - - expect(response.status).toBe(200); - }); - - it('should reject requests with unsupported protocol version', async () => { - sessionId = await initializeServer(); - - // Send request with unsupported protocol version - const response = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': '1999-01-01' // Unsupported version - }, - body: JSON.stringify(TEST_MESSAGES.toolsList) - }); - - expect(response.status).toBe(400); - const errorData = await response.json(); - expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/); - }); - - it('should accept when protocol version differs from negotiated version', async () => { - sessionId = await initializeServer(); - - // Spy on console.warn to verify warning is logged - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - // Send request with different but supported protocol version - const response = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2024-11-05' // Different but supported version - }, - body: JSON.stringify(TEST_MESSAGES.toolsList) - }); - - // Request should still succeed - expect(response.status).toBe(200); - - warnSpy.mockRestore(); - }); - - it('should handle protocol version validation for GET requests', async () => { - sessionId = await initializeServer(); - - // GET request with unsupported protocol version - const response = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'text/event-stream', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': 'invalid-version' - } - }); - - expect(response.status).toBe(400); - const errorData = await response.json(); - expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/); - }); - - it('should handle protocol version validation for DELETE requests', async () => { - sessionId = await initializeServer(); - - // DELETE request with unsupported protocol version - const response = await fetch(baseUrl, { - method: 'DELETE', - headers: { - 'mcp-session-id': sessionId, - 'mcp-protocol-version': 'invalid-version' - } - }); - - expect(response.status).toBe(400); - const errorData = await response.json(); - expectErrorResponse(errorData, -32000, /Bad Request: Unsupported protocol version \(supported versions: .+\)/); - }); - }); -}); - -describe('StreamableHTTPServerTransport with AuthInfo', () => { - let server: Server; - let transport: StreamableHTTPServerTransport; - let baseUrl: URL; - let sessionId: string; - - beforeEach(async () => { - const result = await createTestAuthServer(); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; - }); - - afterEach(async () => { - await stopTestServer({ server, transport }); - }); - - async function initializeServer(): Promise { - const response = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); - - expect(response.status).toBe(200); - const newSessionId = response.headers.get('mcp-session-id'); - expect(newSessionId).toBeDefined(); - return newSessionId as string; - } - - it('should call a tool with authInfo', async () => { - sessionId = await initializeServer(); - - const toolCallMessage: JSONRPCMessage = { - jsonrpc: '2.0', - method: 'tools/call', - params: { - name: 'profile', - arguments: { active: true } - }, - id: 'call-1' - }; - - const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId, { authorization: 'Bearer test-token' }); - expect(response.status).toBe(200); - - const text = await readSSEEvent(response); - const eventLines = text.split('\n'); - const dataLine = eventLines.find(line => line.startsWith('data:')); - expect(dataLine).toBeDefined(); - - const eventData = JSON.parse(dataLine!.substring(5)); - expect(eventData).toMatchObject({ - jsonrpc: '2.0', - result: { - content: [ - { - type: 'text', - text: 'Active profile from token: test-token!' - } - ] - }, - id: 'call-1' - }); - }); - - it('should calls tool without authInfo when it is optional', async () => { - sessionId = await initializeServer(); - - const toolCallMessage: JSONRPCMessage = { - jsonrpc: '2.0', - method: 'tools/call', - params: { - name: 'profile', - arguments: { active: false } - }, - id: 'call-1' - }; - - const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId); - expect(response.status).toBe(200); - - const text = await readSSEEvent(response); - const eventLines = text.split('\n'); - const dataLine = eventLines.find(line => line.startsWith('data:')); - expect(dataLine).toBeDefined(); - - const eventData = JSON.parse(dataLine!.substring(5)); - expect(eventData).toMatchObject({ - jsonrpc: '2.0', - result: { - content: [ - { - type: 'text', - text: 'Inactive profile from token: undefined!' - } - ] - }, - id: 'call-1' - }); - }); -}); - -// Test JSON Response Mode -describe('StreamableHTTPServerTransport with JSON Response Mode', () => { - let server: Server; - let transport: StreamableHTTPServerTransport; - let baseUrl: URL; - let sessionId: string; - - beforeEach(async () => { - const result = await createTestServer({ sessionIdGenerator: () => randomUUID(), enableJsonResponse: true }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; - - // Initialize and get session ID - const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); - - sessionId = initResponse.headers.get('mcp-session-id') as string; - }); - - afterEach(async () => { - await stopTestServer({ server, transport }); - }); - - it('should return JSON response for a single request', async () => { - const toolsListMessage: JSONRPCMessage = { - jsonrpc: '2.0', - method: 'tools/list', - params: {}, - id: 'json-req-1' - }; - - const response = await sendPostRequest(baseUrl, toolsListMessage, sessionId); - - expect(response.status).toBe(200); - expect(response.headers.get('content-type')).toBe('application/json'); - - const result = await response.json(); - expect(result).toMatchObject({ - jsonrpc: '2.0', - result: expect.objectContaining({ - tools: expect.arrayContaining([expect.objectContaining({ name: 'greet' })]) - }), - id: 'json-req-1' - }); - }); - - it('should return JSON response for batch requests', async () => { - const batchMessages: JSONRPCMessage[] = [ - { jsonrpc: '2.0', method: 'tools/list', params: {}, id: 'batch-1' }, - { jsonrpc: '2.0', method: 'tools/call', params: { name: 'greet', arguments: { name: 'JSON' } }, id: 'batch-2' } - ]; - - const response = await sendPostRequest(baseUrl, batchMessages, sessionId); - - expect(response.status).toBe(200); - expect(response.headers.get('content-type')).toBe('application/json'); - - const results = await response.json(); - expect(Array.isArray(results)).toBe(true); - expect(results).toHaveLength(2); - - // Batch responses can come in any order - const listResponse = results.find((r: { id?: string }) => r.id === 'batch-1'); - const callResponse = results.find((r: { id?: string }) => r.id === 'batch-2'); - - expect(listResponse).toEqual( - expect.objectContaining({ - jsonrpc: '2.0', - id: 'batch-1', - result: expect.objectContaining({ - tools: expect.arrayContaining([expect.objectContaining({ name: 'greet' })]) - }) - }) - ); - - expect(callResponse).toEqual( - expect.objectContaining({ - jsonrpc: '2.0', - id: 'batch-2', - result: expect.objectContaining({ - content: expect.arrayContaining([expect.objectContaining({ type: 'text', text: 'Hello, JSON!' })]) - }) - }) - ); - }); -}); - -// Test pre-parsed body handling -describe('StreamableHTTPServerTransport with pre-parsed body', () => { - let server: Server; - let transport: StreamableHTTPServerTransport; - let baseUrl: URL; - let sessionId: string; - let parsedBody: unknown = null; - - beforeEach(async () => { - const result = await createTestServer({ - customRequestHandler: async (req, res) => { - try { - if (parsedBody !== null) { - await transport.handleRequest(req, res, parsedBody); - parsedBody = null; // Reset after use - } else { - await transport.handleRequest(req, res); - } - } catch (error) { - console.error('Error handling request:', error); - if (!res.headersSent) res.writeHead(500).end(); - } - }, - sessionIdGenerator: () => randomUUID() - }); - - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; - - // Initialize and get session ID - const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); - sessionId = initResponse.headers.get('mcp-session-id') as string; - }); - - afterEach(async () => { - await stopTestServer({ server, transport }); - }); - - it('should accept pre-parsed request body', async () => { - // Set up the pre-parsed body - parsedBody = { - jsonrpc: '2.0', - method: 'tools/list', - params: {}, - id: 'preparsed-1' - }; - - // Send an empty body since we'll use pre-parsed body - const response = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - 'mcp-session-id': sessionId - }, - // Empty body - we're testing pre-parsed body - body: '' - }); - - expect(response.status).toBe(200); - expect(response.headers.get('content-type')).toBe('text/event-stream'); - - const reader = response.body?.getReader(); - const { value } = await reader!.read(); - const text = new TextDecoder().decode(value); - - // Verify the response used the pre-parsed body - expect(text).toContain('"id":"preparsed-1"'); - expect(text).toContain('"tools"'); - }); - - it('should handle pre-parsed batch messages', async () => { - parsedBody = [ - { jsonrpc: '2.0', method: 'tools/list', params: {}, id: 'batch-1' }, - { jsonrpc: '2.0', method: 'tools/call', params: { name: 'greet', arguments: { name: 'PreParsed' } }, id: 'batch-2' } - ]; - - const response = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - 'mcp-session-id': sessionId - }, - body: '' // Empty as we're using pre-parsed - }); - - expect(response.status).toBe(200); - - const reader = response.body?.getReader(); - const { value } = await reader!.read(); - const text = new TextDecoder().decode(value); - - expect(text).toContain('"id":"batch-1"'); - expect(text).toContain('"tools"'); - }); - - it('should prefer pre-parsed body over request body', async () => { - // Set pre-parsed to tools/list - parsedBody = { - jsonrpc: '2.0', - method: 'tools/list', - params: {}, - id: 'preparsed-wins' - }; - - // Send actual body with tools/call - should be ignored - const response = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - 'mcp-session-id': sessionId - }, - body: JSON.stringify({ - jsonrpc: '2.0', - method: 'tools/call', - params: { name: 'greet', arguments: { name: 'Ignored' } }, - id: 'ignored-id' - }) - }); - - expect(response.status).toBe(200); - - const reader = response.body?.getReader(); - const { value } = await reader!.read(); - const text = new TextDecoder().decode(value); - - // Should have processed the pre-parsed body - expect(text).toContain('"id":"preparsed-wins"'); - expect(text).toContain('"tools"'); - expect(text).not.toContain('"ignored-id"'); - }); -}); - -// Test resumability support -describe('StreamableHTTPServerTransport with resumability', () => { - let server: Server; - let transport: StreamableHTTPServerTransport; - let baseUrl: URL; - let sessionId: string; - let mcpServer: McpServer; - const storedEvents: Map = new Map(); - - // Simple implementation of EventStore - const eventStore: EventStore = { - async storeEvent(streamId: string, message: JSONRPCMessage): Promise { - const eventId = `${streamId}_${randomUUID()}`; - storedEvents.set(eventId, { eventId, message }); - return eventId; - }, - - async replayEventsAfter( - lastEventId: EventId, - { - send - }: { - send: (eventId: EventId, message: JSONRPCMessage) => Promise; - } - ): Promise { - const streamId = lastEventId.split('_')[0]; - // Extract stream ID from the event ID - // For test simplicity, just return all events with matching streamId that aren't the lastEventId - for (const [eventId, { message }] of storedEvents.entries()) { - if (eventId.startsWith(streamId) && eventId !== lastEventId) { - await send(eventId, message); - } - } - return streamId; - } - }; - - beforeEach(async () => { - storedEvents.clear(); - const result = await createTestServer({ - sessionIdGenerator: () => randomUUID(), - eventStore - }); - - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; - mcpServer = result.mcpServer; - - // Verify resumability is enabled on the transport - expect(transport['_eventStore']).toBeDefined(); - - // Initialize the server - const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); - sessionId = initResponse.headers.get('mcp-session-id') as string; - expect(sessionId).toBeDefined(); - }); - - afterEach(async () => { - await stopTestServer({ server, transport }); - storedEvents.clear(); - }); - - it('should store and include event IDs in server SSE messages', async () => { - // Open a standalone SSE stream - const sseResponse = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'text/event-stream', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2025-03-26' - } - }); - - expect(sseResponse.status).toBe(200); - expect(sseResponse.headers.get('content-type')).toBe('text/event-stream'); - - // Send a notification that should be stored with an event ID - const notification: JSONRPCMessage = { - jsonrpc: '2.0', - method: 'notifications/message', - params: { level: 'info', data: 'Test notification with event ID' } - }; - - // Send the notification via transport - await transport.send(notification); - - // Read from the stream and verify we got the notification with an event ID - const reader = sseResponse.body?.getReader(); - const { value } = await reader!.read(); - const text = new TextDecoder().decode(value); - - // The response should contain an event ID - expect(text).toContain('id: '); - expect(text).toContain('"method":"notifications/message"'); - - // Extract the event ID - const idMatch = text.match(/id: ([^\n]+)/); - expect(idMatch).toBeTruthy(); - - // Verify the event was stored - const eventId = idMatch![1]; - expect(storedEvents.has(eventId)).toBe(true); - const storedEvent = storedEvents.get(eventId); - expect(eventId.startsWith('_GET_stream')).toBe(true); - expect(storedEvent?.message).toMatchObject(notification); - }); - - it('should store and replay MCP server tool notifications', async () => { - // Establish a standalone SSE stream - const sseResponse = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'text/event-stream', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2025-03-26' - } - }); - expect(sseResponse.status).toBe(200); - - // Send a server notification through the MCP server - await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'First notification from MCP server' }); - - // Read the notification from the SSE stream - const reader = sseResponse.body?.getReader(); - const { value } = await reader!.read(); - const text = new TextDecoder().decode(value); - - // Verify the notification was sent with an event ID - expect(text).toContain('id: '); - expect(text).toContain('First notification from MCP server'); - - // Extract the event ID - const idMatch = text.match(/id: ([^\n]+)/); - expect(idMatch).toBeTruthy(); - const firstEventId = idMatch![1]; - - // Send a second notification - await mcpServer.server.sendLoggingMessage({ level: 'info', data: 'Second notification from MCP server' }); - - // Close the first SSE stream to simulate a disconnect - await reader!.cancel(); - - // Reconnect with the Last-Event-ID to get missed messages - const reconnectResponse = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'text/event-stream', - 'mcp-session-id': sessionId, - 'mcp-protocol-version': '2025-03-26', - 'last-event-id': firstEventId - } - }); - - expect(reconnectResponse.status).toBe(200); - - // Read the replayed notification - const reconnectReader = reconnectResponse.body?.getReader(); - const reconnectData = await reconnectReader!.read(); - const reconnectText = new TextDecoder().decode(reconnectData.value); - - // Verify we received the second notification that was sent after our stored eventId - expect(reconnectText).toContain('Second notification from MCP server'); - expect(reconnectText).toContain('id: '); - }); -}); - -// Test stateless mode -describe('StreamableHTTPServerTransport in stateless mode', () => { - let server: Server; - let transport: StreamableHTTPServerTransport; - let baseUrl: URL; - - beforeEach(async () => { - const result = await createTestServer({ sessionIdGenerator: undefined }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; - }); - - afterEach(async () => { - await stopTestServer({ server, transport }); - }); - - it('should operate without session ID validation', async () => { - // Initialize the server first - const initResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); - - expect(initResponse.status).toBe(200); - // Should NOT have session ID header in stateless mode - expect(initResponse.headers.get('mcp-session-id')).toBeNull(); - - // Try request without session ID - should work in stateless mode - const toolsResponse = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList); - - expect(toolsResponse.status).toBe(200); - }); - - it('should handle POST requests with various session IDs in stateless mode', async () => { - await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); - - // Try with a random session ID - should be accepted - const response1 = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - 'mcp-session-id': 'random-id-1' - }, - body: JSON.stringify({ jsonrpc: '2.0', method: 'tools/list', params: {}, id: 't1' }) - }); - expect(response1.status).toBe(200); - - // Try with another random session ID - should also be accepted - const response2 = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - 'mcp-session-id': 'different-id-2' - }, - body: JSON.stringify({ jsonrpc: '2.0', method: 'tools/list', params: {}, id: 't2' }) - }); - expect(response2.status).toBe(200); - }); - - it('should reject second SSE stream even in stateless mode', async () => { - // Despite no session ID requirement, the transport still only allows - // one standalone SSE stream at a time - - // Initialize the server first - await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); - - // Open first SSE stream - const stream1 = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'text/event-stream', - 'mcp-protocol-version': '2025-03-26' - } - }); - expect(stream1.status).toBe(200); - - // Open second SSE stream - should still be rejected, stateless mode still only allows one - const stream2 = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'text/event-stream', - 'mcp-protocol-version': '2025-03-26' - } - }); - expect(stream2.status).toBe(409); // Conflict - only one stream allowed - }); -}); - -// Test onsessionclosed callback -describe('StreamableHTTPServerTransport onsessionclosed callback', () => { - it('should call onsessionclosed callback when session is closed via DELETE', async () => { - const mockCallback = vi.fn(); - - // Create server with onsessionclosed callback - const result = await createTestServer({ - sessionIdGenerator: () => randomUUID(), - onsessionclosed: mockCallback - }); - - const tempServer = result.server; - const tempUrl = result.baseUrl; - - // Initialize to get a session ID - const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); - const tempSessionId = initResponse.headers.get('mcp-session-id'); - expect(tempSessionId).toBeDefined(); - - // DELETE the session - const deleteResponse = await fetch(tempUrl, { - method: 'DELETE', - headers: { - 'mcp-session-id': tempSessionId || '', - 'mcp-protocol-version': '2025-03-26' - } - }); - - expect(deleteResponse.status).toBe(200); - expect(mockCallback).toHaveBeenCalledWith(tempSessionId); - expect(mockCallback).toHaveBeenCalledTimes(1); - - // Clean up - tempServer.close(); - }); - - it('should not call onsessionclosed callback when not provided', async () => { - // Create server without onsessionclosed callback - const result = await createTestServer({ - sessionIdGenerator: () => randomUUID() - }); - - const tempServer = result.server; - const tempUrl = result.baseUrl; - - // Initialize to get a session ID - const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); - const tempSessionId = initResponse.headers.get('mcp-session-id'); - - // DELETE the session - should not throw error - const deleteResponse = await fetch(tempUrl, { - method: 'DELETE', - headers: { - 'mcp-session-id': tempSessionId || '', - 'mcp-protocol-version': '2025-03-26' - } - }); - - expect(deleteResponse.status).toBe(200); - - // Clean up - tempServer.close(); - }); - - it('should not call onsessionclosed callback for invalid session DELETE', async () => { - const mockCallback = vi.fn(); - - // Create server with onsessionclosed callback - const result = await createTestServer({ - sessionIdGenerator: () => randomUUID(), - onsessionclosed: mockCallback - }); - - const tempServer = result.server; - const tempUrl = result.baseUrl; - - // Initialize to get a valid session - await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); - - // Try to DELETE with invalid session ID - const deleteResponse = await fetch(tempUrl, { - method: 'DELETE', - headers: { - 'mcp-session-id': 'invalid-session-id', - 'mcp-protocol-version': '2025-03-26' - } - }); - - expect(deleteResponse.status).toBe(404); - expect(mockCallback).not.toHaveBeenCalled(); - - // Clean up - tempServer.close(); - }); - - it('should call onsessionclosed callback with correct session ID when multiple sessions exist', async () => { - const mockCallback = vi.fn(); - - // Create first server - const result1 = await createTestServer({ - sessionIdGenerator: () => randomUUID(), - onsessionclosed: mockCallback - }); - - const server1 = result1.server; - const url1 = result1.baseUrl; - - // Create second server - const result2 = await createTestServer({ - sessionIdGenerator: () => randomUUID(), - onsessionclosed: mockCallback - }); - - const server2 = result2.server; - const url2 = result2.baseUrl; - - // Initialize both servers - const initResponse1 = await sendPostRequest(url1, TEST_MESSAGES.initialize); - const sessionId1 = initResponse1.headers.get('mcp-session-id'); - - const initResponse2 = await sendPostRequest(url2, TEST_MESSAGES.initialize); - const sessionId2 = initResponse2.headers.get('mcp-session-id'); - - expect(sessionId1).toBeDefined(); - expect(sessionId2).toBeDefined(); - expect(sessionId1).not.toBe(sessionId2); - - // DELETE first session - const deleteResponse1 = await fetch(url1, { - method: 'DELETE', - headers: { - 'mcp-session-id': sessionId1 || '', - 'mcp-protocol-version': '2025-03-26' - } - }); - - expect(deleteResponse1.status).toBe(200); - expect(mockCallback).toHaveBeenCalledWith(sessionId1); - expect(mockCallback).toHaveBeenCalledTimes(1); - - // DELETE second session - const deleteResponse2 = await fetch(url2, { - method: 'DELETE', - headers: { - 'mcp-session-id': sessionId2 || '', - 'mcp-protocol-version': '2025-03-26' - } - }); - - expect(deleteResponse2.status).toBe(200); - expect(mockCallback).toHaveBeenCalledWith(sessionId2); - expect(mockCallback).toHaveBeenCalledTimes(2); - - // Clean up - server1.close(); - server2.close(); - }); -}); - -// Test async callbacks for onsessioninitialized and onsessionclosed -describe('StreamableHTTPServerTransport async callbacks', () => { - it('should support async onsessioninitialized callback', async () => { - const initializationOrder: string[] = []; - - // Create server with async onsessioninitialized callback - const result = await createTestServer({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: async (sessionId: string) => { - initializationOrder.push('async-start'); - // Simulate async operation - await new Promise(resolve => setTimeout(resolve, 10)); - initializationOrder.push('async-end'); - initializationOrder.push(sessionId); - } - }); - - const tempServer = result.server; - const tempUrl = result.baseUrl; - - // Initialize to trigger the callback - const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); - const tempSessionId = initResponse.headers.get('mcp-session-id'); - - // Give time for async callback to complete - await new Promise(resolve => setTimeout(resolve, 50)); - - expect(initializationOrder).toEqual(['async-start', 'async-end', tempSessionId]); - - // Clean up - tempServer.close(); - }); - - it('should support sync onsessioninitialized callback (backwards compatibility)', async () => { - const capturedSessionId: string[] = []; - - // Create server with sync onsessioninitialized callback - const result = await createTestServer({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: (sessionId: string) => { - capturedSessionId.push(sessionId); - } - }); - - const tempServer = result.server; - const tempUrl = result.baseUrl; - - // Initialize to trigger the callback - const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); - const tempSessionId = initResponse.headers.get('mcp-session-id'); - - expect(capturedSessionId).toEqual([tempSessionId]); - - // Clean up - tempServer.close(); - }); - - it('should support async onsessionclosed callback', async () => { - const closureOrder: string[] = []; - - // Create server with async onsessionclosed callback - const result = await createTestServer({ - sessionIdGenerator: () => randomUUID(), - onsessionclosed: async (sessionId: string) => { - closureOrder.push('async-close-start'); - // Simulate async operation - await new Promise(resolve => setTimeout(resolve, 10)); - closureOrder.push('async-close-end'); - closureOrder.push(sessionId); - } - }); - - const tempServer = result.server; - const tempUrl = result.baseUrl; - - // Initialize to get a session ID - const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); - const tempSessionId = initResponse.headers.get('mcp-session-id'); - expect(tempSessionId).toBeDefined(); - - // DELETE the session - const deleteResponse = await fetch(tempUrl, { - method: 'DELETE', - headers: { - 'mcp-session-id': tempSessionId || '', - 'mcp-protocol-version': '2025-03-26' - } - }); - - expect(deleteResponse.status).toBe(200); - - // Give time for async callback to complete - await new Promise(resolve => setTimeout(resolve, 50)); - - expect(closureOrder).toEqual(['async-close-start', 'async-close-end', tempSessionId]); - - // Clean up - tempServer.close(); - }); - - it('should propagate errors from async onsessioninitialized callback', async () => { - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - // Create server with async onsessioninitialized callback that throws - const result = await createTestServer({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: async (_sessionId: string) => { - throw new Error('Async initialization error'); - } - }); - - const tempServer = result.server; - const tempUrl = result.baseUrl; - - // Initialize should fail when callback throws - const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); - expect(initResponse.status).toBe(400); - - // Clean up - consoleErrorSpy.mockRestore(); - tempServer.close(); - }); - - it('should propagate errors from async onsessionclosed callback', async () => { - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - // Create server with async onsessionclosed callback that throws - const result = await createTestServer({ - sessionIdGenerator: () => randomUUID(), - onsessionclosed: async (_sessionId: string) => { - throw new Error('Async closure error'); - } - }); - - const tempServer = result.server; - const tempUrl = result.baseUrl; - - // Initialize to get a session ID - const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); - const tempSessionId = initResponse.headers.get('mcp-session-id'); - - // DELETE should fail when callback throws - const deleteResponse = await fetch(tempUrl, { - method: 'DELETE', - headers: { - 'mcp-session-id': tempSessionId || '', - 'mcp-protocol-version': '2025-03-26' - } - }); - - expect(deleteResponse.status).toBe(500); - - // Clean up - consoleErrorSpy.mockRestore(); - tempServer.close(); - }); - - it('should handle both async callbacks together', async () => { - const events: string[] = []; - - // Create server with both async callbacks - const result = await createTestServer({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: async (sessionId: string) => { - await new Promise(resolve => setTimeout(resolve, 5)); - events.push(`initialized:${sessionId}`); - }, - onsessionclosed: async (sessionId: string) => { - await new Promise(resolve => setTimeout(resolve, 5)); - events.push(`closed:${sessionId}`); - } - }); - - const tempServer = result.server; - const tempUrl = result.baseUrl; - - // Initialize to trigger first callback - const initResponse = await sendPostRequest(tempUrl, TEST_MESSAGES.initialize); - const tempSessionId = initResponse.headers.get('mcp-session-id'); - - // Wait for async callback - await new Promise(resolve => setTimeout(resolve, 20)); - - expect(events).toContain(`initialized:${tempSessionId}`); - - // DELETE to trigger second callback - const deleteResponse = await fetch(tempUrl, { - method: 'DELETE', - headers: { - 'mcp-session-id': tempSessionId || '', - 'mcp-protocol-version': '2025-03-26' - } - }); - - expect(deleteResponse.status).toBe(200); - - // Wait for async callback - await new Promise(resolve => setTimeout(resolve, 20)); - - expect(events).toContain(`closed:${tempSessionId}`); - expect(events).toHaveLength(2); - - // Clean up - tempServer.close(); - }); -}); - -// Test DNS rebinding protection -describe('StreamableHTTPServerTransport DNS rebinding protection', () => { - let server: Server; - let transport: StreamableHTTPServerTransport; - let baseUrl: URL; - - afterEach(async () => { - if (server && transport) { - await stopTestServer({ server, transport }); - } - }); - - describe('Host header validation', () => { - it('should accept requests with allowed host headers', async () => { - const result = await createTestServerWithDnsProtection({ - sessionIdGenerator: undefined, - allowedHosts: ['localhost'], - enableDnsRebindingProtection: true - }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; - - // Note: fetch() automatically sets Host header to match the URL - // Since we're connecting to localhost:3001 and that's in allowedHosts, this should work - const response = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream' - }, - body: JSON.stringify(TEST_MESSAGES.initialize) - }); - - expect(response.status).toBe(200); - }); - - it('should reject requests with disallowed host headers', async () => { - // Test DNS rebinding protection by creating a server that only allows example.com - // but we're connecting via localhost, so it should be rejected - const result = await createTestServerWithDnsProtection({ - sessionIdGenerator: undefined, - allowedHosts: ['example.com:3001'], - enableDnsRebindingProtection: true - }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; - - const response = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream' - }, - body: JSON.stringify(TEST_MESSAGES.initialize) - }); - - expect(response.status).toBe(403); - const body = await response.json(); - expect(body.error.message).toContain('Invalid Host header:'); - }); - - it('should reject GET requests with disallowed host headers', async () => { - const result = await createTestServerWithDnsProtection({ - sessionIdGenerator: undefined, - allowedHosts: ['example.com:3001'], - enableDnsRebindingProtection: true - }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; - - const response = await fetch(baseUrl, { - method: 'GET', - headers: { - Accept: 'text/event-stream' - } - }); - - expect(response.status).toBe(403); - }); - }); - - describe('Origin header validation', () => { - it('should accept requests with allowed origin headers', async () => { - const result = await createTestServerWithDnsProtection({ - sessionIdGenerator: undefined, - allowedOrigins: ['http://localhost:3000', 'https://example.com'], - enableDnsRebindingProtection: true - }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; - - const response = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - Origin: 'http://localhost:3000' - }, - body: JSON.stringify(TEST_MESSAGES.initialize) - }); - - expect(response.status).toBe(200); - }); - - it('should reject requests with disallowed origin headers', async () => { - const result = await createTestServerWithDnsProtection({ - sessionIdGenerator: undefined, - allowedOrigins: ['http://localhost:3000'], - enableDnsRebindingProtection: true - }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; - - const response = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - Origin: 'http://evil.com' - }, - body: JSON.stringify(TEST_MESSAGES.initialize) - }); - - expect(response.status).toBe(403); - const body = await response.json(); - expect(body.error.message).toBe('Invalid Origin header: http://evil.com'); - }); - }); - - describe('enableDnsRebindingProtection option', () => { - it('should skip all validations when enableDnsRebindingProtection is false', async () => { - const result = await createTestServerWithDnsProtection({ - sessionIdGenerator: undefined, - allowedHosts: ['localhost'], - allowedOrigins: ['http://localhost:3000'], - enableDnsRebindingProtection: false - }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; - - const response = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - Host: 'evil.com', - Origin: 'http://evil.com' - }, - body: JSON.stringify(TEST_MESSAGES.initialize) - }); - - // Should pass even with invalid headers because protection is disabled - expect(response.status).toBe(200); - }); - }); - - describe('Combined validations', () => { - it('should validate both host and origin when both are configured', async () => { - const result = await createTestServerWithDnsProtection({ - sessionIdGenerator: undefined, - allowedHosts: ['localhost'], - allowedOrigins: ['http://localhost:3001'], - enableDnsRebindingProtection: true - }); - server = result.server; - transport = result.transport; - baseUrl = result.baseUrl; - - // Test with invalid origin (host will be automatically correct via fetch) - const response1 = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - Origin: 'http://evil.com' - }, - body: JSON.stringify(TEST_MESSAGES.initialize) - }); - - expect(response1.status).toBe(403); - const body1 = await response1.json(); - expect(body1.error.message).toBe('Invalid Origin header: http://evil.com'); - - // Test with valid origin - const response2 = await fetch(baseUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - Origin: 'http://localhost:3001' - }, - body: JSON.stringify(TEST_MESSAGES.initialize) - }); - - expect(response2.status).toBe(200); - }); - }); -}); - -/** - * Helper to create test server with DNS rebinding protection options - */ -async function createTestServerWithDnsProtection(config: { - sessionIdGenerator: (() => string) | undefined; - allowedHosts?: string[]; - allowedOrigins?: string[]; - enableDnsRebindingProtection?: boolean; -}): Promise<{ - server: Server; - transport: StreamableHTTPServerTransport; - mcpServer: McpServer; - baseUrl: URL; -}> { - const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } }); - - const port = await getFreePort(); - - if (config.allowedHosts) { - config.allowedHosts = config.allowedHosts.map(host => { - if (host.includes(':')) { - return host; - } - return `localhost:${port}`; - }); - } - - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: config.sessionIdGenerator, - allowedHosts: config.allowedHosts, - allowedOrigins: config.allowedOrigins, - enableDnsRebindingProtection: config.enableDnsRebindingProtection - }); - - await mcpServer.connect(transport); - - const httpServer = createServer(async (req, res) => { - if (req.method === 'POST') { - let body = ''; - req.on('data', chunk => (body += chunk)); - req.on('end', async () => { - const parsedBody = JSON.parse(body); - await transport.handleRequest(req as IncomingMessage & { auth?: AuthInfo }, res, parsedBody); - }); - } else { - await transport.handleRequest(req as IncomingMessage & { auth?: AuthInfo }, res); - } - }); - - await new Promise(resolve => { - httpServer.listen(port, () => resolve()); - }); - - const serverUrl = new URL(`http://localhost:${port}/`); - - return { - server: httpServer, - transport, - mcpServer, - baseUrl: serverUrl - }; -} diff --git a/src/server/v3/title.v3.test.ts b/src/server/v3/title.v3.test.ts deleted file mode 100644 index 2d99d5316..000000000 --- a/src/server/v3/title.v3.test.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/src/shared/zodTestMatrix.ts b/src/shared/zodTestMatrix.ts new file mode 100644 index 000000000..fc4ee63db --- /dev/null +++ b/src/shared/zodTestMatrix.ts @@ -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]; diff --git a/tsconfig.prod.json b/tsconfig.prod.json index 8fc00fcd4..fcf2e951c 100644 --- a/tsconfig.prod.json +++ b/tsconfig.prod.json @@ -3,5 +3,5 @@ "compilerOptions": { "outDir": "./dist/esm" }, - "exclude": ["**/*.test.ts", "src/__mocks__/**/*"] + "exclude": ["**/*.test.ts", "src/__mocks__/**/*", "src/server/zodTestMatrix.ts"] }