diff --git a/README.md b/README.md index 92f56786f..09e64c0e7 100644 --- a/README.md +++ b/README.md @@ -1401,6 +1401,56 @@ This setup allows you to: ### Backwards Compatibility +#### Tool Aliases + +If you need to rename a tool but maintain backwards compatibility with clients using the old name, you can register an alias. Aliases resolve to the canonical tool at call time but are not included in the `tools/list` response, keeping your tool list clean while supporting legacy +names. + +```typescript +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; + +const server = new McpServer({ + name: 'example-server', + version: '1.0.0' +}); + +// Register the tool with its new name +server.registerTool( + 'get_weather', + { + title: 'Get Weather', + description: 'Get current weather information for a location', + inputSchema: { location: z.string() }, + outputSchema: { temperature: z.number(), conditions: z.string() } + }, + async ({ location }) => { + const output = { temperature: 72, conditions: `Sunny in ${location}` }; + return { + content: [{ type: 'text', text: JSON.stringify(output) }], + structuredContent: output + }; + } +); + +// Create an alias for backwards compatibility with the old tool name +server.aliasTool('get_temperature', 'get_weather'); + +// Now clients can call either 'get_weather' or 'get_temperature' +// Both will execute the same tool handler +// However, only 'get_weather' appears in tools/list +``` + +Key features: + +- Aliases resolve to the canonical tool at call time +- Multiple aliases can point to the same tool +- Aliases are not listed in `tools/list` responses +- Aliases respect the enabled/disabled state of the canonical tool +- Input validation is applied based on the canonical tool's schema + +#### Transport Backwards Compatibility + Clients and servers with StreamableHttp transport can maintain [backwards compatibility](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#backwards-compatibility) with the deprecated HTTP+SSE transport (from protocol version 2024-11-05) as follows #### Client-Side Compatibility diff --git a/src/server/mcp.test.ts b/src/server/mcp.test.ts index 4bb42d7fc..9ff3a7715 100644 --- a/src/server/mcp.test.ts +++ b/src/server/mcp.test.ts @@ -1611,6 +1611,363 @@ describe('tool()', () => { }); }); +describe('aliasTool()', () => { + /*** + * Test: Calling alias name executes canonical tool + */ + test('should execute canonical tool when alias is called', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + // Register tool with new name + mcpServer.registerTool( + 'get_weather', + { + title: 'Get Weather', + description: 'Get weather information', + inputSchema: { location: z.string() }, + outputSchema: { temperature: z.number(), conditions: z.string() } + }, + async ({ location }) => { + const output = { temperature: 72, conditions: `Sunny in ${location}` }; + return { + content: [{ type: 'text', text: JSON.stringify(output) }], + structuredContent: output + }; + } + ); + + mcpServer.aliasTool('get_temperature', 'get_weather'); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + // Call using alias name + const result = await client.request( + { + method: 'tools/call', + params: { + name: 'get_temperature', + arguments: { location: 'San Francisco' } + } + }, + CallToolResultSchema + ); + + expect(result.content).toEqual([ + { + type: 'text', + text: JSON.stringify({ temperature: 72, conditions: 'Sunny in San Francisco' }) + } + ]); + expect(result.structuredContent).toEqual({ temperature: 72, conditions: 'Sunny in San Francisco' }); + }); + + /*** + * Test: tools/list does not include alias + */ + test('should not include aliases in tools/list', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + // Register tool with new name + mcpServer.registerTool( + 'get_weather', + { + title: 'Get Weather', + description: 'Get weather information' + }, + async () => ({ + content: [{ type: 'text', text: 'result' }] + }) + ); + + // Register alias for backwards compatibility + mcpServer.aliasTool('get_temperature', 'get_weather'); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + // List tools + const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); + + // Should only have the new tool name, not the alias + expect(result.tools).toHaveLength(1); + expect(result.tools[0].name).toBe('get_weather'); + expect(result.tools.find(t => t.name === 'get_temperature')).toBeUndefined(); + }); + + /*** + * Test: Unknown name (no alias) throws error + */ + test('should throw error for unknown tool name', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + // Register tool + mcpServer.registerTool( + 'get_weather', + { + title: 'Get Weather', + description: 'Get weather information' + }, + async () => ({ + content: [{ type: 'text', text: 'result' }] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + // Call using unknown name + await expect( + client.request( + { + method: 'tools/call', + params: { + name: 'unknown_tool', + arguments: {} + } + }, + CallToolResultSchema + ) + ).rejects.toThrow(); + }); + + /*** + * Test: Error when aliasing unknown tool + */ + test('should throw error when aliasing non-existent tool', () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + // Try to alias a tool that doesn't exist + expect(() => { + mcpServer.aliasTool('new_alias', 'non_existent_tool'); + }).toThrow('Unknown tool: non_existent_tool'); + }); + + /*** + * Test: Multiple aliases for same tool + */ + test('should support multiple aliases for the same tool', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + // Register tool with current name + mcpServer.registerTool( + 'get_weather', + { + title: 'Get Weather', + description: 'Get weather information', + inputSchema: { location: z.string() }, + outputSchema: { temperature: z.number(), conditions: z.string() } + }, + async ({ location }) => { + const output = { temperature: 72, conditions: `Sunny in ${location}` }; + return { + content: [{ type: 'text', text: JSON.stringify(output) }], + structuredContent: output + }; + } + ); + + // Register multiple aliases for backwards compatibility with historical names + mcpServer.aliasTool('get_temperature', 'get_weather'); + mcpServer.aliasTool('check_temperature', 'get_weather'); + mcpServer.aliasTool('fetch_temp', 'get_weather'); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + // Call using each alias + const result1 = await client.request( + { + method: 'tools/call', + params: { + name: 'get_temperature', + arguments: { location: 'Boston' } + } + }, + CallToolResultSchema + ); + expect(result1.structuredContent).toEqual({ temperature: 72, conditions: 'Sunny in Boston' }); + + const result2 = await client.request( + { + method: 'tools/call', + params: { + name: 'check_temperature', + arguments: { location: 'Seattle' } + } + }, + CallToolResultSchema + ); + expect(result2.structuredContent).toEqual({ temperature: 72, conditions: 'Sunny in Seattle' }); + + const result3 = await client.request( + { + method: 'tools/call', + params: { + name: 'fetch_temp', + arguments: { location: 'Austin' } + } + }, + CallToolResultSchema + ); + expect(result3.structuredContent).toEqual({ temperature: 72, conditions: 'Sunny in Austin' }); + + // Verify tools/list only shows the current tool name + const listResult = await client.request({ method: 'tools/list' }, ListToolsResultSchema); + expect(listResult.tools).toHaveLength(1); + expect(listResult.tools[0].name).toBe('get_weather'); + }); + + /*** + * Test: Alias respects tool enabled/disabled state + */ + test('should respect disabled state when calling via alias', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + // Register tool + const tool = mcpServer.registerTool( + 'get_weather', + { + title: 'Get Weather', + description: 'Get weather information' + }, + async () => ({ + content: [{ type: 'text', text: 'result' }] + }) + ); + + // Register alias for backwards compatibility + mcpServer.aliasTool('get_temperature', 'get_weather'); + + // Disable the tool + tool.disable(); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + // Try to call using alias - should fail because tool is disabled + await expect( + client.request( + { + method: 'tools/call', + params: { + name: 'get_temperature', + arguments: {} + } + }, + CallToolResultSchema + ) + ).rejects.toThrow('Tool get_temperature disabled'); + }); + + /*** + * Test: Alias works with tool that has validation + */ + test('should validate arguments when calling via alias', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + // Register tool with validation + mcpServer.registerTool( + 'get_weather', + { + title: 'Get Weather', + description: 'Get weather information', + inputSchema: { + location: z.string(), + units: z.enum(['celsius', 'fahrenheit']) + }, + outputSchema: { temperature: z.number(), conditions: z.string() } + }, + async ({ location, units }) => { + const temp = units === 'celsius' ? 22 : 72; + const output = { temperature: temp, conditions: `Sunny in ${location}` }; + return { + content: [{ type: 'text', text: JSON.stringify(output) }], + structuredContent: output + }; + } + ); + + // Register alias for backwards compatibility + mcpServer.aliasTool('get_temperature', 'get_weather'); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); + + // Valid call via alias + const result = await client.request( + { + method: 'tools/call', + params: { + name: 'get_temperature', + arguments: { location: 'Paris', units: 'celsius' } + } + }, + CallToolResultSchema + ); + expect(result.structuredContent).toEqual({ temperature: 22, conditions: 'Sunny in Paris' }); + + // Invalid call via alias - should fail validation (invalid enum value) + await expect( + client.request( + { + method: 'tools/call', + params: { + name: 'get_temperature', + arguments: { location: 'Paris', units: 'kelvin' } + } + }, + CallToolResultSchema + ) + ).rejects.toThrow(); + }); +}); + describe('resource()', () => { /*** * Test: Resource Registration with URI and Read Callback diff --git a/src/server/mcp.ts b/src/server/mcp.ts index cef1722d6..9fcb65d40 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -54,6 +54,7 @@ export class McpServer { [name: string]: RegisteredResourceTemplate; } = {}; private _registeredTools: { [name: string]: RegisteredTool } = {}; + private _toolAliases: Map = new Map(); // alias -> canonical private _registeredPrompts: { [name: string]: RegisteredPrompt } = {}; constructor(serverInfo: Implementation, options?: ServerOptions) { @@ -123,7 +124,16 @@ export class McpServer { ); this.server.setRequestHandler(CallToolRequestSchema, async (request, extra): Promise => { - const tool = this._registeredTools[request.params.name]; + let tool = this._registeredTools[request.params.name]; + + // If not found by name, check if it's an alias + if (!tool) { + const toolAlias = this._toolAliases.get(request.params.name); + if (toolAlias) { + tool = this._registeredTools[toolAlias]; + } + } + if (!tool) { throw new McpError(ErrorCode.InvalidParams, `Tool ${request.params.name} not found`); } @@ -816,6 +826,23 @@ export class McpServer { ); } + /** + * Registers an alias for an existing tool. The alias will resolve to the canonical tool at call time, + * but will not be included in the tools/list response. + * + * This is useful for maintaining backwards compatibility when renaming tools. + * + * @param aliasName - The alias name to register + * @param targetName - The canonical tool name to resolve to + * @throws Error if the canonical tool does not exist + */ + aliasTool(aliasName: string, targetName: string): void { + if (!this._registeredTools[targetName]) { + throw new Error(`Unknown tool: ${targetName}`); + } + this._toolAliases.set(aliasName, targetName); + } + /** * Registers a zero-argument prompt `name`, which will run the given function when the client calls it. */