diff --git a/docs/server.md b/docs/server.md index 8c42f31aa6..b319e6f0fc 100644 --- a/docs/server.md +++ b/docs/server.md @@ -66,6 +66,34 @@ For a minimal “getting started” experience: For more detailed patterns (stateless vs stateful, JSON response mode, CORS, DNS rebind protection), see the examples above and the MCP spec sections on transports. +## DNS rebinding protection + +MCP servers running on localhost are vulnerable to DNS rebinding attacks. Use `createMcpExpressApp()` to create an Express app with DNS rebinding protection enabled by default: + +```typescript +import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/index.js'; + +// Protection auto-enabled (default host is 127.0.0.1) +const app = createMcpExpressApp(); + +// Protection auto-enabled for localhost +const app = createMcpExpressApp({ host: 'localhost' }); + +// No auto protection when binding to all interfaces +const app = createMcpExpressApp({ host: '0.0.0.0' }); +``` + +For custom host validation, use the middleware directly: + +```typescript +import express from 'express'; +import { hostHeaderValidation } from '@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js'; + +const app = express(); +app.use(express.json()); +app.use(hostHeaderValidation(['localhost', '127.0.0.1', 'myhost.local'])); +``` + ## Tools, resources, and prompts ### Tools diff --git a/package-lock.json b/package-lock.json index d551aa61d1..457ae2f836 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.23.0", + "version": "1.24.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.23.0", + "version": "1.24.0", "license": "MIT", "dependencies": { "ajv": "^8.17.1", diff --git a/package.json b/package.json index 521985df10..63d41be7e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.23.0", + "version": "1.24.0", "description": "Model Context Protocol implementation for TypeScript", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index db836d127a..0b979eb999 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -1501,6 +1501,68 @@ describe('StreamableHTTPClientTransport', () => { }); }); + describe('Reconnection Logic with maxRetries 0', () => { + let transport: StreamableHTTPClientTransport; + + // Use fake timers to control setTimeout and make the test instant. + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('should not schedule any reconnection attempts when maxRetries is 0', async () => { + // ARRANGE + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions: { + initialReconnectionDelay: 10, + maxRetries: 0, // This should disable retries completely + maxReconnectionDelay: 1000, + reconnectionDelayGrowFactor: 1 + } + }); + + const errorSpy = vi.fn(); + transport.onerror = errorSpy; + + // ACT - directly call _scheduleReconnection which is the code path the fix affects + transport['_scheduleReconnection']({}); + + // ASSERT - should immediately report max retries exceeded, not schedule a retry + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Maximum reconnection attempts (0) exceeded.' + }) + ); + + // Verify no timeout was scheduled (no reconnection attempt) + expect(transport['_reconnectionTimeout']).toBeUndefined(); + }); + + it('should schedule reconnection when maxRetries is greater than 0', async () => { + // ARRANGE + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { + reconnectionOptions: { + initialReconnectionDelay: 10, + maxRetries: 1, // Allow 1 retry + maxReconnectionDelay: 1000, + reconnectionDelayGrowFactor: 1 + } + }); + + const errorSpy = vi.fn(); + transport.onerror = errorSpy; + + // ACT - call _scheduleReconnection with attemptCount 0 + transport['_scheduleReconnection']({}); + + // ASSERT - should schedule a reconnection, not report error yet + expect(errorSpy).not.toHaveBeenCalled(); + expect(transport['_reconnectionTimeout']).toBeDefined(); + + // Clean up the timeout to avoid test pollution + clearTimeout(transport['_reconnectionTimeout']); + }); + }); + describe('prevent infinite recursion when server returns 401 after successful auth', () => { it('should throw error when server returns 401 after successful auth', async () => { const message: JSONRPCMessage = { diff --git a/src/client/streamableHttp.ts b/src/client/streamableHttp.ts index 9cc4887df1..8146b627ba 100644 --- a/src/client/streamableHttp.ts +++ b/src/client/streamableHttp.ts @@ -279,7 +279,7 @@ export class StreamableHTTPClientTransport implements Transport { const maxRetries = this._reconnectionOptions.maxRetries; // Check if we've exceeded maximum retry attempts - if (maxRetries > 0 && attemptCount >= maxRetries) { + if (attemptCount >= maxRetries) { this.onerror?.(new Error(`Maximum reconnection attempts (${maxRetries}) exceeded.`)); return; } diff --git a/src/examples/server/elicitationFormExample.ts b/src/examples/server/elicitationFormExample.ts index 2e6286b71a..e3ce083d19 100644 --- a/src/examples/server/elicitationFormExample.ts +++ b/src/examples/server/elicitationFormExample.ts @@ -8,11 +8,11 @@ // to collect *sensitive* user input via a browser. import { randomUUID } from 'node:crypto'; -import cors from 'cors'; -import express, { type Request, type Response } from 'express'; +import { type Request, type Response } from 'express'; import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { isInitializeRequest } from '../../types.js'; +import { createMcpExpressApp } from '../../server/index.js'; // Create MCP server - it will automatically use AjvJsonSchemaValidator with sensible defaults // The validator supports format validation (email, date, etc.) if ajv-formats is installed @@ -320,16 +320,7 @@ mcpServer.registerTool( async function main() { const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000; - const app = express(); - app.use(express.json()); - - // Allow CORS for all domains, expose the Mcp-Session-Id header - app.use( - cors({ - origin: '*', - exposedHeaders: ['Mcp-Session-Id'] - }) - ); + const app = createMcpExpressApp(); // Map to store transports by session ID const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; diff --git a/src/examples/server/elicitationUrlExample.ts b/src/examples/server/elicitationUrlExample.ts index af014fdc13..e4d3d2268b 100644 --- a/src/examples/server/elicitationUrlExample.ts +++ b/src/examples/server/elicitationUrlExample.ts @@ -11,6 +11,7 @@ import express, { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; import { z } from 'zod'; import { McpServer } from '../../server/mcp.js'; +import { createMcpExpressApp } from '../../server/index.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../../server/auth/router.js'; import { requireBearerAuth } from '../../server/auth/middleware/bearerAuth.js'; @@ -214,8 +215,7 @@ function completeURLElicitation(elicitationId: string) { const MCP_PORT = process.env.MCP_PORT ? parseInt(process.env.MCP_PORT, 10) : 3000; const AUTH_PORT = process.env.MCP_AUTH_PORT ? parseInt(process.env.MCP_AUTH_PORT, 10) : 3001; -const app = express(); -app.use(express.json()); +const app = createMcpExpressApp(); // Allow CORS all domains, expose the Mcp-Session-Id header app.use( diff --git a/src/examples/server/jsonResponseStreamableHttp.ts b/src/examples/server/jsonResponseStreamableHttp.ts index c1206d8cd7..9be3d7204a 100644 --- a/src/examples/server/jsonResponseStreamableHttp.ts +++ b/src/examples/server/jsonResponseStreamableHttp.ts @@ -1,10 +1,10 @@ -import express, { Request, Response } from 'express'; +import { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import * as z from 'zod/v4'; import { CallToolResult, isInitializeRequest } from '../../types.js'; -import cors from 'cors'; +import { createMcpExpressApp } from '../../server/index.js'; // Create an MCP server with implementation details const getServer = () => { @@ -90,16 +90,7 @@ const getServer = () => { return server; }; -const app = express(); -app.use(express.json()); - -// Configure CORS to expose Mcp-Session-Id header for browser-based clients -app.use( - cors({ - origin: '*', // Allow all origins - adjust as needed for production - exposedHeaders: ['Mcp-Session-Id'] - }) -); +const app = createMcpExpressApp(); // Map to store transports by session ID const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; diff --git a/src/examples/server/simpleSseServer.ts b/src/examples/server/simpleSseServer.ts index e07f36010d..bc6fd2cabb 100644 --- a/src/examples/server/simpleSseServer.ts +++ b/src/examples/server/simpleSseServer.ts @@ -1,8 +1,9 @@ -import express, { Request, Response } from 'express'; +import { Request, Response } from 'express'; import { McpServer } from '../../server/mcp.js'; import { SSEServerTransport } from '../../server/sse.js'; import * as z from 'zod/v4'; import { CallToolResult } from '../../types.js'; +import { createMcpExpressApp } from '../../server/index.js'; /** * This example server demonstrates the deprecated HTTP+SSE transport @@ -75,8 +76,7 @@ const getServer = () => { return server; }; -const app = express(); -app.use(express.json()); +const app = createMcpExpressApp(); // Store transports by session ID const transports: Record = {}; diff --git a/src/examples/server/simpleStatelessStreamableHttp.ts b/src/examples/server/simpleStatelessStreamableHttp.ts index 464ea26233..e2cefffd87 100644 --- a/src/examples/server/simpleStatelessStreamableHttp.ts +++ b/src/examples/server/simpleStatelessStreamableHttp.ts @@ -1,9 +1,9 @@ -import express, { Request, Response } from 'express'; +import { Request, Response } from 'express'; import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import * as z from 'zod/v4'; import { CallToolResult, GetPromptResult, ReadResourceResult } from '../../types.js'; -import cors from 'cors'; +import { createMcpExpressApp } from '../../server/index.js'; const getServer = () => { // Create an MCP server with implementation details @@ -96,16 +96,7 @@ const getServer = () => { return server; }; -const app = express(); -app.use(express.json()); - -// Configure CORS to expose Mcp-Session-Id header for browser-based clients -app.use( - cors({ - origin: '*', // Allow all origins - adjust as needed for production - exposedHeaders: ['Mcp-Session-Id'] - }) -); +const app = createMcpExpressApp(); app.post('/mcp', async (req: Request, res: Response) => { const server = getServer(); diff --git a/src/examples/server/simpleStreamableHttp.ts b/src/examples/server/simpleStreamableHttp.ts index 9d3afda972..3500ac0667 100644 --- a/src/examples/server/simpleStreamableHttp.ts +++ b/src/examples/server/simpleStreamableHttp.ts @@ -1,10 +1,11 @@ -import express, { Request, Response } from 'express'; +import { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; import * as z from 'zod/v4'; import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter } from '../../server/auth/router.js'; import { requireBearerAuth } from '../../server/auth/middleware/bearerAuth.js'; +import { createMcpExpressApp } from '../../server/index.js'; import { CallToolResult, ElicitResultSchema, @@ -20,8 +21,6 @@ import { setupAuthServer } from './demoInMemoryOAuthProvider.js'; import { OAuthMetadata } from '../../shared/auth.js'; import { checkResourceAllowed } from '../../shared/auth-utils.js'; -import cors from 'cors'; - // Check for OAuth flag const useOAuth = process.argv.includes('--oauth'); const strictOAuth = process.argv.includes('--oauth-strict'); @@ -507,16 +506,7 @@ const getServer = () => { const MCP_PORT = process.env.MCP_PORT ? parseInt(process.env.MCP_PORT, 10) : 3000; const AUTH_PORT = process.env.MCP_AUTH_PORT ? parseInt(process.env.MCP_AUTH_PORT, 10) : 3001; -const app = express(); -app.use(express.json()); - -// Allow CORS all domains, expose the Mcp-Session-Id header -app.use( - cors({ - origin: '*', // Allow all origins - exposedHeaders: ['Mcp-Session-Id'] - }) -); +const app = createMcpExpressApp(); // Set up OAuth if enabled let authMiddleware = null; diff --git a/src/examples/server/simpleTaskInteractive.ts b/src/examples/server/simpleTaskInteractive.ts index 3e0e90646d..51e97b7e94 100644 --- a/src/examples/server/simpleTaskInteractive.ts +++ b/src/examples/server/simpleTaskInteractive.ts @@ -9,9 +9,9 @@ * creates a task, and the result is fetched via tasks/result endpoint. */ -import express, { Request, Response } from 'express'; +import { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; -import { Server } from '../../server/index.js'; +import { createMcpExpressApp, Server } from '../../server/index.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { CallToolResult, @@ -630,8 +630,7 @@ const createServer = (): Server => { // Express App Setup // ============================================================================ -const app = express(); -app.use(express.json()); +const app = createMcpExpressApp(); // Map to store transports by session ID const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; diff --git a/src/examples/server/sseAndStreamableHttpCompatibleServer.ts b/src/examples/server/sseAndStreamableHttpCompatibleServer.ts index 8eb3724c3d..317cb2bfe0 100644 --- a/src/examples/server/sseAndStreamableHttpCompatibleServer.ts +++ b/src/examples/server/sseAndStreamableHttpCompatibleServer.ts @@ -1,4 +1,4 @@ -import express, { Request, Response } from 'express'; +import { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; @@ -6,7 +6,7 @@ import { SSEServerTransport } from '../../server/sse.js'; import * as z from 'zod/v4'; import { CallToolResult, isInitializeRequest } from '../../types.js'; import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; -import cors from 'cors'; +import { createMcpExpressApp } from '../../server/index.js'; /** * This example server demonstrates backwards compatibility with both: @@ -71,16 +71,7 @@ const getServer = () => { }; // Create Express application -const app = express(); -app.use(express.json()); - -// Configure CORS to expose Mcp-Session-Id header for browser-based clients -app.use( - cors({ - origin: '*', // Allow all origins - adjust as needed for production - exposedHeaders: ['Mcp-Session-Id'] - }) -); +const app = createMcpExpressApp(); // Store transports by session ID const transports: Record = {}; diff --git a/src/examples/server/ssePollingExample.ts b/src/examples/server/ssePollingExample.ts index ea1d752f07..83ef8e4b15 100644 --- a/src/examples/server/ssePollingExample.ts +++ b/src/examples/server/ssePollingExample.ts @@ -12,9 +12,10 @@ * Run with: npx tsx src/examples/server/ssePollingExample.ts * Test with: curl or the MCP Inspector */ -import express, { Request, Response } from 'express'; +import { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; import { McpServer } from '../../server/mcp.js'; +import { createMcpExpressApp } from '../../server/index.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { CallToolResult } from '../../types.js'; import { InMemoryEventStore } from '../shared/inMemoryEventStore.js'; @@ -103,7 +104,7 @@ server.tool( ); // Set up Express app -const app = express(); +const app = createMcpExpressApp(); app.use(cors()); // Create event store for resumability @@ -112,8 +113,8 @@ const eventStore = new InMemoryEventStore(); // Track transports by session ID for session reuse const transports = new Map(); -// Handle all MCP requests - use express.json() only for this route -app.all('/mcp', express.json(), async (req: Request, res: Response) => { +// Handle all MCP requests +app.all('/mcp', async (req: Request, res: Response) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; // Reuse existing transport or create new one diff --git a/src/examples/server/standaloneSseWithGetStreamableHttp.ts b/src/examples/server/standaloneSseWithGetStreamableHttp.ts index 6229c53a40..33bd73d04d 100644 --- a/src/examples/server/standaloneSseWithGetStreamableHttp.ts +++ b/src/examples/server/standaloneSseWithGetStreamableHttp.ts @@ -1,8 +1,9 @@ -import express, { Request, Response } from 'express'; +import { Request, Response } from 'express'; import { randomUUID } from 'node:crypto'; import { McpServer } from '../../server/mcp.js'; import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; import { isInitializeRequest, ReadResourceResult } from '../../types.js'; +import { createMcpExpressApp } from '../../server/index.js'; // Create an MCP server with implementation details const server = new McpServer({ @@ -34,8 +35,7 @@ const resourceChangeInterval = setInterval(() => { addResource(name, `Content for ${name}`); }, 5000); // Change resources every 5 seconds for testing -const app = express(); -app.use(express.json()); +const app = createMcpExpressApp(); app.post('/mcp', async (req: Request, res: Response) => { console.log('Received MCP request:', req.body); diff --git a/src/examples/server/toolWithSampleServer.ts b/src/examples/server/toolWithSampleServer.ts index c198dc0ecf..e6d7335986 100644 --- a/src/examples/server/toolWithSampleServer.ts +++ b/src/examples/server/toolWithSampleServer.ts @@ -33,12 +33,14 @@ mcpServer.registerTool( maxTokens: 500 }); - const contents = Array.isArray(response.content) ? response.content : [response.content]; + // Since we're not passing tools param to createMessage, response.content is single content return { - content: contents.map(content => ({ - type: 'text', - text: content.type === 'text' ? content.text : 'Unable to generate summary' - })) + content: [ + { + type: 'text', + text: response.content.type === 'text' ? response.content.text : 'Unable to generate summary' + } + ] }; } ); diff --git a/src/server/index.test.ts b/src/server/index.test.ts index 00593bf9c8..c01e638d0e 100644 --- a/src/server/index.test.ts +++ b/src/server/index.test.ts @@ -1,7 +1,9 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ +import supertest from 'supertest'; import { Client } from '../client/index.js'; import { InMemoryTransport } from '../inMemory.js'; import type { Transport } from '../shared/transport.js'; +import { createMcpExpressApp } from './index.js'; import { CreateMessageRequestSchema, CreateMessageResultSchema, @@ -1924,6 +1926,70 @@ describe('createMessage validation', () => { }); }); +describe('createMessage backwards compatibility', () => { + test('createMessage without tools returns single content (backwards compat)', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: {} } }); + + // Mock client returns single text content + client.setRequestHandler(CreateMessageRequestSchema, async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'Hello from LLM' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Call createMessage WITHOUT tools + const result = await server.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], + maxTokens: 100 + }); + + // Backwards compat: result.content should be single (not array) + expect(result.model).toBe('test-model'); + expect(Array.isArray(result.content)).toBe(false); + expect(result.content.type).toBe('text'); + if (result.content.type === 'text') { + expect(result.content.text).toBe('Hello from LLM'); + } + }); + + test('createMessage with tools accepts request and returns result', async () => { + const server = new Server({ name: 'test server', version: '1.0' }, { capabilities: {} }); + + const client = new Client({ name: 'test client', version: '1.0' }, { capabilities: { sampling: { tools: {} } } }); + + // Mock client returns text content (tool_use schema validation is tested in types.test.ts) + client.setRequestHandler(CreateMessageRequestSchema, async () => ({ + model: 'test-model', + role: 'assistant', + content: { type: 'text', text: 'I will use the weather tool' }, + stopReason: 'endTurn' + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + // Call createMessage WITH tools - verifies the overload works + const result = await server.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }], + maxTokens: 100, + tools: [{ name: 'get_weather', inputSchema: { type: 'object' } }] + }); + + // Verify result is returned correctly + expect(result.model).toBe('test-model'); + expect(result.content.type).toBe('text'); + // With tools param, result.content can be array (CreateMessageResultWithTools type) + // This would fail type-check if we used CreateMessageResult which doesn't allow arrays + const contentArray = Array.isArray(result.content) ? result.content : [result.content]; + expect(contentArray.length).toBe(1); + }); +}); + test('should respect log level for transport with sessionId', async () => { const server = new Server( { @@ -1993,6 +2059,167 @@ test('should respect log level for transport with sessionId', async () => { expect(clientTransport.onmessage).toHaveBeenCalled(); }); +describe('createMcpExpressApp', () => { + test('should create an Express app', () => { + const app = createMcpExpressApp(); + expect(app).toBeDefined(); + }); + + test('should parse JSON bodies', async () => { + const app = createMcpExpressApp({ host: '0.0.0.0' }); // Disable host validation for this test + app.post('/test', (req, res) => { + res.json({ received: req.body }); + }); + + const response = await supertest(app).post('/test').send({ hello: 'world' }).set('Content-Type', 'application/json'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ received: { hello: 'world' } }); + }); + + test('should reject requests with invalid Host header by default', async () => { + const app = createMcpExpressApp(); + app.post('/test', (_req, res) => { + res.json({ success: true }); + }); + + const response = await supertest(app).post('/test').set('Host', 'evil.com:3000').send({}); + + expect(response.status).toBe(403); + expect(response.body).toEqual({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Invalid Host: evil.com' + }, + id: null + }); + }); + + test('should allow requests with localhost Host header', async () => { + const app = createMcpExpressApp(); + app.post('/test', (_req, res) => { + res.json({ success: true }); + }); + + const response = await supertest(app).post('/test').set('Host', 'localhost:3000').send({}); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ success: true }); + }); + + test('should allow requests with 127.0.0.1 Host header', async () => { + const app = createMcpExpressApp(); + app.post('/test', (_req, res) => { + res.json({ success: true }); + }); + + const response = await supertest(app).post('/test').set('Host', '127.0.0.1:3000').send({}); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ success: true }); + }); + + test('should not apply host validation when host is 0.0.0.0', async () => { + const app = createMcpExpressApp({ host: '0.0.0.0' }); + app.post('/test', (_req, res) => { + res.json({ success: true }); + }); + + // Should allow any host when bound to 0.0.0.0 + const response = await supertest(app).post('/test').set('Host', 'any-host.com:3000').send({}); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ success: true }); + }); + + test('should apply host validation when host is explicitly localhost', async () => { + const app = createMcpExpressApp({ host: 'localhost' }); + app.post('/test', (_req, res) => { + res.json({ success: true }); + }); + + // Should reject non-localhost hosts + const response = await supertest(app).post('/test').set('Host', 'evil.com:3000').send({}); + + expect(response.status).toBe(403); + }); + + test('should allow requests with IPv6 localhost Host header', async () => { + const app = createMcpExpressApp(); + app.post('/test', (_req, res) => { + res.json({ success: true }); + }); + + const response = await supertest(app).post('/test').set('Host', '[::1]:3000').send({}); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ success: true }); + }); + + test('should apply host validation when host is ::1 (IPv6 localhost)', async () => { + const app = createMcpExpressApp({ host: '::1' }); + app.post('/test', (_req, res) => { + res.json({ success: true }); + }); + + // Should reject non-localhost hosts + const response = await supertest(app).post('/test').set('Host', 'evil.com:3000').send({}); + + expect(response.status).toBe(403); + }); + + test('should warn when binding to 0.0.0.0', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + createMcpExpressApp({ host: '0.0.0.0' }); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('0.0.0.0')); + warnSpy.mockRestore(); + }); + + test('should warn when binding to :: (IPv6 all interfaces)', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + createMcpExpressApp({ host: '::' }); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('::')); + warnSpy.mockRestore(); + }); + + test('should use custom allowedHosts when provided', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['myapp.local', 'localhost'] }); + app.post('/test', (_req, res) => { + res.json({ success: true }); + }); + + // Should not warn when allowedHosts is provided + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + + // Should allow myapp.local + const allowedResponse = await supertest(app).post('/test').set('Host', 'myapp.local:3000').send({}); + expect(allowedResponse.status).toBe(200); + + // Should reject other hosts + const rejectedResponse = await supertest(app).post('/test').set('Host', 'evil.com:3000').send({}); + expect(rejectedResponse.status).toBe(403); + }); + + test('should override default localhost validation when allowedHosts is provided', async () => { + // Even though host is localhost, we're using custom allowedHosts + const app = createMcpExpressApp({ host: 'localhost', allowedHosts: ['custom.local'] }); + app.post('/test', (_req, res) => { + res.json({ success: true }); + }); + + // Should reject localhost since it's not in allowedHosts + const response = await supertest(app).post('/test').set('Host', 'localhost:3000').send({}); + expect(response.status).toBe(403); + + // Should allow custom.local + const allowedResponse = await supertest(app).post('/test').set('Host', 'custom.local:3000').send({}); + expect(allowedResponse.status).toBe(200); + }); +}); + describe('Task-based execution', () => { test('server with TaskStore should handle task-based tool execution', async () => { const taskStore = new InMemoryTaskStore(); diff --git a/src/server/index.ts b/src/server/index.ts index dfbb2a2a32..43aca3b93e 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,8 +1,15 @@ +import express, { Express } from 'express'; import { mergeCapabilities, Protocol, type NotificationOptions, type ProtocolOptions, type RequestOptions } from '../shared/protocol.js'; +import { hostHeaderValidation, localhostHostValidation } from './middleware/hostHeaderValidation.js'; import { type ClientCapabilities, type CreateMessageRequest, + type CreateMessageResult, CreateMessageResultSchema, + type CreateMessageResultWithTools, + CreateMessageResultWithToolsSchema, + type CreateMessageRequestParamsBase, + type CreateMessageRequestParamsWithTools, type ElicitRequestFormParams, type ElicitRequestURLParams, type ElicitResult, @@ -467,7 +474,32 @@ export class Server< return this.request({ method: 'ping' }, EmptyResultSchema); } - async createMessage(params: CreateMessageRequest['params'], options?: RequestOptions) { + /** + * Request LLM sampling from the client (without tools). + * Returns single content block for backwards compatibility. + */ + async createMessage(params: CreateMessageRequestParamsBase, options?: RequestOptions): Promise; + + /** + * Request LLM sampling from the client with tool support. + * Returns content that may be a single block or array (for parallel tool calls). + */ + async createMessage(params: CreateMessageRequestParamsWithTools, options?: RequestOptions): Promise; + + /** + * Request LLM sampling from the client. + * When tools may or may not be present, returns the union type. + */ + async createMessage( + params: CreateMessageRequest['params'], + options?: RequestOptions + ): Promise; + + // Implementation + async createMessage( + params: CreateMessageRequest['params'], + options?: RequestOptions + ): Promise { // Capability check - only required when tools/toolChoice are provided if (params.tools || params.toolChoice) { if (!this._clientCapabilities?.sampling?.tools) { @@ -510,6 +542,10 @@ export class Server< } } + // Use different schemas based on whether tools are provided + if (params.tools) { + return this.request({ method: 'sampling/createMessage', params }, CreateMessageResultWithToolsSchema, options); + } return this.request({ method: 'sampling/createMessage', params }, CreateMessageResultSchema, options); } @@ -633,3 +669,75 @@ export class Server< return this.notification({ method: 'notifications/prompts/list_changed' }); } } + +/** + * Options for creating an MCP Express application. + */ +export interface CreateMcpExpressAppOptions { + /** + * The hostname to bind to. Defaults to '127.0.0.1'. + * When set to '127.0.0.1', 'localhost', or '::1', DNS rebinding protection is automatically enabled. + */ + host?: string; + + /** + * List of allowed hostnames for DNS rebinding protection. + * If provided, host header validation will be applied using this list. + * For IPv6, provide addresses with brackets (e.g., '[::1]'). + * + * This is useful when binding to '0.0.0.0' or '::' but still wanting + * to restrict which hostnames are allowed. + */ + allowedHosts?: string[]; +} + +/** + * Creates an Express application pre-configured for MCP servers. + * + * When the host is '127.0.0.1', 'localhost', or '::1' (the default is '127.0.0.1'), + * DNS rebinding protection middleware is automatically applied to protect against + * DNS rebinding attacks on localhost servers. + * + * @param options - Configuration options + * @returns A configured Express application + * + * @example + * ```typescript + * // Basic usage - defaults to 127.0.0.1 with DNS rebinding protection + * const app = createMcpExpressApp(); + * + * // Custom host - DNS rebinding protection only applied for localhost hosts + * const app = createMcpExpressApp({ host: '0.0.0.0' }); // No automatic DNS rebinding protection + * const app = createMcpExpressApp({ host: 'localhost' }); // DNS rebinding protection enabled + * + * // Custom allowed hosts for non-localhost binding + * const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['myapp.local', 'localhost'] }); + * ``` + */ +export function createMcpExpressApp(options: CreateMcpExpressAppOptions = {}): Express { + const { host = '127.0.0.1', allowedHosts } = options; + + const app = express(); + app.use(express.json()); + + // If allowedHosts is explicitly provided, use that for validation + if (allowedHosts) { + app.use(hostHeaderValidation(allowedHosts)); + } else { + // Apply DNS rebinding protection automatically for localhost hosts + const localhostHosts = ['127.0.0.1', 'localhost', '::1']; + if (localhostHosts.includes(host)) { + app.use(localhostHostValidation()); + } else if (host === '0.0.0.0' || host === '::') { + // Warn when binding to all interfaces without DNS rebinding protection + // eslint-disable-next-line no-console + console.warn( + `Warning: Server is binding to ${host} without DNS rebinding protection. ` + + 'Consider using the allowedHosts option to restrict allowed hosts, ' + + 'or use authentication to protect your server.' + ); + } + } + + return app; +} diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index cfec318afb..981768ec52 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -4765,6 +4765,201 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { }); }); + describe('Tools with transformation schemas', () => { + test('should support z.preprocess() schemas', async () => { + const server = new McpServer({ + name: 'test', + version: '1.0.0' + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + // z.preprocess() allows transforming input before validation + const preprocessSchema = z.preprocess( + input => { + // Normalize input by trimming strings + if (typeof input === 'object' && input !== null) { + const obj = input as Record; + if (typeof obj.name === 'string') { + return { ...obj, name: obj.name.trim() }; + } + } + return input; + }, + z.object({ name: z.string() }) + ); + + server.registerTool('preprocess-test', { inputSchema: preprocessSchema }, async args => { + return { + content: [{ type: 'text' as const, text: `Hello, ${args.name}!` }] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + // Test with input that has leading/trailing whitespace + const result = await client.callTool({ + name: 'preprocess-test', + arguments: { name: ' World ' } + }); + + expect(result.content).toEqual([ + { + type: 'text', + text: 'Hello, World!' + } + ]); + }); + + test('should support z.transform() schemas', async () => { + const server = new McpServer({ + name: 'test', + version: '1.0.0' + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + // z.transform() allows transforming validated output + const transformSchema = z + .object({ + firstName: z.string(), + lastName: z.string() + }) + .transform(data => ({ + ...data, + fullName: `${data.firstName} ${data.lastName}` + })); + + server.registerTool('transform-test', { inputSchema: transformSchema }, async args => { + return { + content: [{ type: 'text' as const, text: `Full name: ${args.fullName}` }] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const result = await client.callTool({ + name: 'transform-test', + arguments: { firstName: 'John', lastName: 'Doe' } + }); + + expect(result.content).toEqual([ + { + type: 'text', + text: 'Full name: John Doe' + } + ]); + }); + + test('should support z.pipe() schemas', async () => { + const server = new McpServer({ + name: 'test', + version: '1.0.0' + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + // z.pipe() chains multiple schemas together + const pipeSchema = z + .object({ value: z.string() }) + .transform(data => ({ ...data, processed: true })) + .pipe(z.object({ value: z.string(), processed: z.boolean() })); + + server.registerTool('pipe-test', { inputSchema: pipeSchema }, async args => { + return { + content: [{ type: 'text' as const, text: `Value: ${args.value}, Processed: ${args.processed}` }] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + const result = await client.callTool({ + name: 'pipe-test', + arguments: { value: 'test' } + }); + + expect(result.content).toEqual([ + { + type: 'text', + text: 'Value: test, Processed: true' + } + ]); + }); + + test('should support nested transformation schemas', async () => { + const server = new McpServer({ + name: 'test', + version: '1.0.0' + }); + + const client = new Client({ + name: 'test-client', + version: '1.0.0' + }); + + // Complex schema with both preprocess and transform + const complexSchema = z.preprocess( + input => { + if (typeof input === 'object' && input !== null) { + const obj = input as Record; + // Convert string numbers to actual numbers + if (typeof obj.count === 'string') { + return { ...obj, count: parseInt(obj.count, 10) }; + } + } + return input; + }, + z + .object({ + name: z.string(), + count: z.number() + }) + .transform(data => ({ + ...data, + doubled: data.count * 2 + })) + ); + + server.registerTool('complex-transform', { inputSchema: complexSchema }, async args => { + return { + content: [{ type: 'text' as const, text: `${args.name}: ${args.count} -> ${args.doubled}` }] + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + // Pass count as string, preprocess will convert it + const result = await client.callTool({ + name: 'complex-transform', + arguments: { name: 'items', count: '5' } + }); + + expect(result.content).toEqual([ + { + type: 'text', + text: 'items: 5 -> 10' + } + ]); + }); + }); + describe('resource()', () => { /*** * Test: Resource Registration with URI and Read Callback diff --git a/src/server/mcp.ts b/src/server/mcp.ts index c097ae5313..1617dc37b1 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -1312,17 +1312,9 @@ const EMPTY_OBJECT_JSON_SCHEMA = { properties: {} }; -// Helper to check if an object is a Zod schema (ZodRawShapeCompat) -function isZodRawShapeCompat(obj: unknown): obj is ZodRawShapeCompat { - if (typeof obj !== 'object' || obj === null) return false; - - const isEmptyObject = Object.keys(obj).length === 0; - - // Check if object is empty or at least one property is a ZodType instance - // Note: use heuristic check to avoid instanceof failure across different Zod versions - return isEmptyObject || Object.values(obj as object).some(isZodTypeLike); -} - +/** + * Checks if a value looks like a Zod schema by checking for parse/safeParse methods. + */ function isZodTypeLike(value: unknown): value is AnySchema { return ( value !== null && @@ -1334,6 +1326,46 @@ function isZodTypeLike(value: unknown): value is AnySchema { ); } +/** + * Checks if an object is a Zod schema instance (v3 or v4). + * + * Zod schemas have internal markers: + * - v3: `_def` property + * - v4: `_zod` property + * + * This includes transformed schemas like z.preprocess(), z.transform(), z.pipe(). + */ +function isZodSchemaInstance(obj: object): boolean { + return '_def' in obj || '_zod' in obj || isZodTypeLike(obj); +} + +/** + * Checks if an object is a "raw shape" - a plain object where values are Zod schemas. + * + * Raw shapes are used as shorthand: `{ name: z.string() }` instead of `z.object({ name: z.string() })`. + * + * IMPORTANT: This must NOT match actual Zod schema instances (like z.preprocess, z.pipe), + * which have internal properties that could be mistaken for schema values. + */ +function isZodRawShapeCompat(obj: unknown): obj is ZodRawShapeCompat { + if (typeof obj !== 'object' || obj === null) { + return false; + } + + // If it's already a Zod schema instance, it's NOT a raw shape + if (isZodSchemaInstance(obj)) { + return false; + } + + // Empty objects are valid raw shapes (tools with no parameters) + if (Object.keys(obj).length === 0) { + return true; + } + + // A raw shape has at least one property that is a Zod schema + return Object.values(obj).some(isZodTypeLike); +} + /** * Converts a provided Zod schema to a Zod object if it is a ZodRawShapeCompat, * otherwise returns the schema as is. diff --git a/src/server/middleware/hostHeaderValidation.ts b/src/server/middleware/hostHeaderValidation.ts new file mode 100644 index 0000000000..165003635b --- /dev/null +++ b/src/server/middleware/hostHeaderValidation.ts @@ -0,0 +1,79 @@ +import { Request, Response, NextFunction, RequestHandler } from 'express'; + +/** + * Express middleware for DNS rebinding protection. + * Validates Host header hostname (port-agnostic) against an allowed list. + * + * This is particularly important for servers without authorization or HTTPS, + * such as localhost servers or development servers. DNS rebinding attacks can + * bypass same-origin policy by manipulating DNS to point a domain to a + * localhost address, allowing malicious websites to access your local server. + * + * @param allowedHostnames - List of allowed hostnames (without ports). + * For IPv6, provide the address with brackets (e.g., '[::1]'). + * @returns Express middleware function + * + * @example + * ```typescript + * const middleware = hostHeaderValidation(['localhost', '127.0.0.1', '[::1]']); + * app.use(middleware); + * ``` + */ +export function hostHeaderValidation(allowedHostnames: string[]): RequestHandler { + return (req: Request, res: Response, next: NextFunction) => { + const hostHeader = req.headers.host; + if (!hostHeader) { + res.status(403).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Missing Host header' + }, + id: null + }); + return; + } + + // Use URL API to parse hostname (handles IPv4, IPv6, and regular hostnames) + let hostname: string; + try { + hostname = new URL(`http://${hostHeader}`).hostname; + } catch { + res.status(403).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: `Invalid Host header: ${hostHeader}` + }, + id: null + }); + return; + } + + if (!allowedHostnames.includes(hostname)) { + res.status(403).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: `Invalid Host: ${hostname}` + }, + id: null + }); + return; + } + next(); + }; +} + +/** + * Convenience middleware for localhost DNS rebinding protection. + * Allows only localhost, 127.0.0.1, and [::1] (IPv6 localhost) hostnames. + * + * @example + * ```typescript + * app.use(localhostHostValidation()); + * ``` + */ +export function localhostHostValidation(): RequestHandler { + return hostHeaderValidation(['localhost', '127.0.0.1', '[::1]']); +} diff --git a/src/server/sse.ts b/src/server/sse.ts index abe969b3ce..270eebc19a 100644 --- a/src/server/sse.ts +++ b/src/server/sse.ts @@ -16,18 +16,24 @@ export interface SSEServerTransportOptions { /** * List of allowed host header values for DNS rebinding protection. * If not specified, host validation is disabled. + * @deprecated Use the `hostHeaderValidation` middleware from `@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js` instead, + * or use `createMcpExpressApp` from `@modelcontextprotocol/sdk/server/index.js` which includes localhost protection by default. */ allowedHosts?: string[]; /** * List of allowed origin header values for DNS rebinding protection. * If not specified, origin validation is disabled. + * @deprecated Use the `hostHeaderValidation` middleware from `@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js` instead, + * or use `createMcpExpressApp` from `@modelcontextprotocol/sdk/server/index.js` which includes localhost protection by default. */ allowedOrigins?: string[]; /** * Enable DNS rebinding protection (requires allowedHosts and/or allowedOrigins to be configured). * Default is false for backwards compatibility. + * @deprecated Use the `hostHeaderValidation` middleware from `@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js` instead, + * or use `createMcpExpressApp` from `@modelcontextprotocol/sdk/server/index.js` which includes localhost protection by default. */ enableDnsRebindingProtection?: boolean; } diff --git a/src/server/streamableHttp.ts b/src/server/streamableHttp.ts index 0473c46453..841d6654d8 100644 --- a/src/server/streamableHttp.ts +++ b/src/server/streamableHttp.ts @@ -104,18 +104,24 @@ export interface StreamableHTTPServerTransportOptions { /** * List of allowed host header values for DNS rebinding protection. * If not specified, host validation is disabled. + * @deprecated Use the `hostHeaderValidation` middleware from `@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js` instead, + * or use `createMcpExpressApp` from `@modelcontextprotocol/sdk/server/index.js` which includes localhost protection by default. */ allowedHosts?: string[]; /** * List of allowed origin header values for DNS rebinding protection. * If not specified, origin validation is disabled. + * @deprecated Use the `hostHeaderValidation` middleware from `@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js` instead, + * or use `createMcpExpressApp` from `@modelcontextprotocol/sdk/server/index.js` which includes localhost protection by default. */ allowedOrigins?: string[]; /** * Enable DNS rebinding protection (requires allowedHosts and/or allowedOrigins to be configured). * Default is false for backwards compatibility. + * @deprecated Use the `hostHeaderValidation` middleware from `@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js` instead, + * or use `createMcpExpressApp` from `@modelcontextprotocol/sdk/server/index.js` which includes localhost protection by default. */ enableDnsRebindingProtection?: boolean; diff --git a/src/spec.types.test.ts b/src/spec.types.test.ts index 14fb039d0a..66e0da207c 100644 --- a/src/spec.types.test.ts +++ b/src/spec.types.test.ts @@ -80,6 +80,21 @@ type FixSpecInitializeRequest = T extends { params: infer P } ? Omit = T extends { params: infer P } ? Omit & { params: FixSpecInitializeRequestParams

} : T; +// Targeted fix: CreateMessageResult in SDK uses single content for v1.x backwards compat. +// The full array-capable type is CreateMessageResultWithTools. +// This will be aligned with schema in v2.0. +// Narrows content from SamplingMessageContentBlock (includes tool types) to basic content types only. +type NarrowToBasicContent = C extends { type: 'text' | 'image' | 'audio' } ? C : never; +type FixSpecCreateMessageResult = T extends { content: infer C; role: infer R; model: infer M } + ? { + _meta?: { [key: string]: unknown }; + model: M; + role: R; + stopReason?: string; + content: C extends (infer U)[] ? NarrowToBasicContent : NarrowToBasicContent; + } + : T; + const sdkTypeChecks = { RequestParams: (sdk: RemovePassthrough, spec: SpecTypes.RequestParams) => { sdk = spec; @@ -369,7 +384,10 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - CreateMessageResult: (sdk: RemovePassthrough, spec: SpecTypes.CreateMessageResult) => { + CreateMessageResult: ( + sdk: RemovePassthrough, + spec: FixSpecCreateMessageResult + ) => { sdk = spec; spec = sdk; }, diff --git a/src/types.test.ts b/src/types.test.ts index e6ea0b6d6b..4570a443af 100644 --- a/src/types.test.ts +++ b/src/types.test.ts @@ -13,21 +13,23 @@ import { SamplingMessageSchema, CreateMessageRequestSchema, CreateMessageResultSchema, + CreateMessageResultWithToolsSchema, ClientCapabilitiesSchema } from './types.js'; describe('Types', () => { test('should have correct latest protocol version', () => { expect(LATEST_PROTOCOL_VERSION).toBeDefined(); - expect(LATEST_PROTOCOL_VERSION).toBe('2025-06-18'); + expect(LATEST_PROTOCOL_VERSION).toBe('2025-11-25'); }); test('should have correct supported protocol versions', () => { expect(SUPPORTED_PROTOCOL_VERSIONS).toBeDefined(); expect(SUPPORTED_PROTOCOL_VERSIONS).toBeInstanceOf(Array); expect(SUPPORTED_PROTOCOL_VERSIONS).toContain(LATEST_PROTOCOL_VERSION); + expect(SUPPORTED_PROTOCOL_VERSIONS).toContain('2025-06-18'); + expect(SUPPORTED_PROTOCOL_VERSIONS).toContain('2025-03-26'); expect(SUPPORTED_PROTOCOL_VERSIONS).toContain('2024-11-05'); expect(SUPPORTED_PROTOCOL_VERSIONS).toContain('2024-10-07'); - expect(SUPPORTED_PROTOCOL_VERSIONS).toContain('2025-03-26'); }); describe('ResourceLink', () => { @@ -787,7 +789,7 @@ describe('Types', () => { } }); - test('should validate result with tool call', () => { + test('should validate result with tool call (using WithTools schema)', () => { const result = { model: 'claude-3-5-sonnet-20241022', role: 'assistant', @@ -800,7 +802,8 @@ describe('Types', () => { stopReason: 'toolUse' }; - const parseResult = CreateMessageResultSchema.safeParse(result); + // Tool call results use CreateMessageResultWithToolsSchema + const parseResult = CreateMessageResultWithToolsSchema.safeParse(result); expect(parseResult.success).toBe(true); if (parseResult.success) { expect(parseResult.data.stopReason).toBe('toolUse'); @@ -810,9 +813,13 @@ describe('Types', () => { expect(content.type).toBe('tool_use'); } } + + // Basic CreateMessageResultSchema should NOT accept tool_use content + const basicResult = CreateMessageResultSchema.safeParse(result); + expect(basicResult.success).toBe(false); }); - test('should validate result with array content', () => { + test('should validate result with array content (using WithTools schema)', () => { const result = { model: 'claude-3-5-sonnet-20241022', role: 'assistant', @@ -828,7 +835,8 @@ describe('Types', () => { stopReason: 'toolUse' }; - const parseResult = CreateMessageResultSchema.safeParse(result); + // Array content uses CreateMessageResultWithToolsSchema + const parseResult = CreateMessageResultWithToolsSchema.safeParse(result); expect(parseResult.success).toBe(true); if (parseResult.success) { expect(parseResult.data.stopReason).toBe('toolUse'); @@ -840,6 +848,10 @@ describe('Types', () => { expect(content[1].type).toBe('tool_use'); } } + + // Basic CreateMessageResultSchema should NOT accept array content + const basicResult = CreateMessageResultSchema.safeParse(result); + expect(basicResult.success).toBe(false); }); test('should validate all new stop reasons', () => { diff --git a/src/types.ts b/src/types.ts index 03acc3e6a3..923da5447f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,9 +1,9 @@ import * as z from 'zod/v4'; import { AuthInfo } from './server/auth/types.js'; -export const LATEST_PROTOCOL_VERSION = '2025-06-18'; +export const LATEST_PROTOCOL_VERSION = '2025-11-25'; export const DEFAULT_NEGOTIATED_PROTOCOL_VERSION = '2025-03-26'; -export const SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION, '2025-03-26', '2024-11-05', '2024-10-07']; +export const SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION, '2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07']; export const RELATED_TASK_META_KEY = 'io.modelcontextprotocol/related-task'; @@ -1495,6 +1495,12 @@ export const ToolResultContentSchema = z }) .passthrough(); +/** + * Basic content types for sampling responses (without tool use). + * Used for backwards-compatible CreateMessageResult when tools are not used. + */ +export const SamplingContentSchema = z.discriminatedUnion('type', [TextContentSchema, ImageContentSchema, AudioContentSchema]); + /** * Content block types allowed in sampling messages. * This includes text, image, audio, tool use requests, and tool results. @@ -1576,9 +1582,38 @@ export const CreateMessageRequestSchema = RequestSchema.extend({ }); /** - * The client's response to a sampling/create_message request from the server. The client should inform the user before returning the sampled message, to allow them to inspect the response (human in the loop) and decide whether to allow the server to see it. + * The client's response to a sampling/create_message request from the server. + * This is the backwards-compatible version that returns single content (no arrays). + * Used when the request does not include tools. */ export const CreateMessageResultSchema = ResultSchema.extend({ + /** + * The name of the model that generated the message. + */ + model: z.string(), + /** + * The reason why sampling stopped, if known. + * + * Standard values: + * - "endTurn": Natural end of the assistant's turn + * - "stopSequence": A stop sequence was encountered + * - "maxTokens": Maximum token limit was reached + * + * This field is an open string to allow for provider-specific stop reasons. + */ + stopReason: z.optional(z.enum(['endTurn', 'stopSequence', 'maxTokens']).or(z.string())), + role: z.enum(['user', 'assistant']), + /** + * Response content. Single content block (text, image, or audio). + */ + content: SamplingContentSchema +}); + +/** + * The client's response to a sampling/create_message request when tools were provided. + * This version supports array content for tool use flows. + */ +export const CreateMessageResultWithToolsSchema = ResultSchema.extend({ /** * The name of the model that generated the message. */ @@ -1597,7 +1632,7 @@ export const CreateMessageResultSchema = ResultSchema.extend({ stopReason: z.optional(z.enum(['endTurn', 'stopSequence', 'maxTokens', 'toolUse']).or(z.string())), role: z.enum(['user', 'assistant']), /** - * Response content. May be ToolUseContent if stopReason is "toolUse". + * Response content. May be a single block or array. May include ToolUseContent if stopReason is "toolUse". */ content: z.union([SamplingMessageContentBlockSchema, z.array(SamplingMessageContentBlockSchema)]) }); @@ -2010,6 +2045,7 @@ export const ClientNotificationSchema = z.union([ export const ClientResultSchema = z.union([ EmptyResultSchema, CreateMessageResultSchema, + CreateMessageResultWithToolsSchema, ElicitResultSchema, ListRootsResultSchema, GetTaskResultSchema, @@ -2285,11 +2321,26 @@ export type LoggingMessageNotification = Infer; export type ModelHint = Infer; export type ModelPreferences = Infer; +export type SamplingContent = Infer; export type SamplingMessageContentBlock = Infer; export type SamplingMessage = Infer; export type CreateMessageRequestParams = Infer; export type CreateMessageRequest = Infer; export type CreateMessageResult = Infer; +export type CreateMessageResultWithTools = Infer; + +/** + * CreateMessageRequestParams without tools - for backwards-compatible overload. + * Excludes tools/toolChoice to indicate they should not be provided. + */ +export type CreateMessageRequestParamsBase = Omit; + +/** + * CreateMessageRequestParams with required tools - for tool-enabled overload. + */ +export interface CreateMessageRequestParamsWithTools extends CreateMessageRequestParams { + tools: Tool[]; +} /* Elicitation */ export type BooleanSchema = Infer;