diff --git a/src/client/filtering.test.ts b/src/client/filtering.test.ts new file mode 100644 index 000000000..dcca08118 --- /dev/null +++ b/src/client/filtering.test.ts @@ -0,0 +1,253 @@ +import { Client } from "./index.js"; +import { MockTransport } from "./mockTransport.js"; +import { + Group, + Tag, + Tool, + ToolsFilter, +} from "../types.js"; + +describe("Client filtering capabilities", () => { + let client: Client; + let transport: MockTransport; + + beforeEach(() => { + transport = new MockTransport(); + client = new Client({ name: "test-client", version: "1.0.0" }); + client.connect(transport); + + // Mock server capabilities to include filtering + transport.mockServerCapabilities({ + filtering: { + groups: { + listChanged: true + }, + tags: { + listChanged: true + } + } + }); + + // Client is already connected and ready to use + }); + + describe("listGroups", () => { + test("should request groups from the server", async () => { + // Mock server response + const mockGroups: Group[] = [ + { + name: "user", + title: "User Management Tools", + description: "Tools used for managing user accounts within the system." + }, + { + name: "mapping", + title: "Geospatial Mapping Tools", + description: "Tools used for map rendering, geocoding, and spatial analysis." + } + ]; + + transport.mockResponse( + { method: "groups/list" }, + { groups: mockGroups } + ); + + // Call the method + const result = await client.listGroups(); + + // Verify the request was made correctly + expect(transport.lastRequest).toEqual({ + jsonrpc: "2.0", + method: "groups/list", + params: {}, + id: expect.anything() + }); + + // Verify the response was parsed correctly + expect(result).toEqual({ groups: mockGroups }); + }); + + test("should throw an error if filtering capability is not available", async () => { + // Create a new client without filtering capability + const newTransport = new MockTransport(); + const newClient = new Client({ name: "test-client", version: "1.0.0" }); + newClient.connect(newTransport); + + // Mock server capabilities without filtering + newTransport.mockServerCapabilities({}); + + // Expect the method to throw an error + await expect(newClient.listGroups()).rejects.toThrow( + "Server does not support method: groups/list" + ); + }, 10000); // Increase timeout to 10 seconds + }); + + describe("listTags", () => { + test("should request tags from the server", async () => { + // Mock server response + const mockTags: Tag[] = [ + { + name: "beta", + description: "Experimental or in-testing tools" + }, + { + name: "stable", + description: "Production-ready tools." + } + ]; + + transport.mockResponse( + { method: "tags/list" }, + { tags: mockTags } + ); + + // Call the method + const result = await client.listTags(); + + // Verify the request was made correctly + expect(transport.lastRequest).toEqual({ + jsonrpc: "2.0", + method: "tags/list", + params: {}, + id: expect.anything() + }); + + // Verify the response was parsed correctly + expect(result).toEqual({ tags: mockTags }); + }); + + test("should throw an error if filtering capability is not available", async () => { + // Create a new client without filtering capability + const newTransport = new MockTransport(); + const newClient = new Client({ name: "test-client", version: "1.0.0" }); + newClient.connect(newTransport); + + // Mock server capabilities without filtering + newTransport.mockServerCapabilities({}); + + // Expect the method to throw an error + await expect(newClient.listTags()).rejects.toThrow( + "Server does not support method: tags/list" + ); + }, 10000); // Increase timeout to 10 seconds + }); + + describe("listTools with filtering", () => { + test("should request tools with group filter", async () => { + // Mock tools + const mockTools: Tool[] = [ + { + name: "user_create", + title: "Create User", + description: "Creates a new user account", + inputSchema: { type: "object", properties: {} }, + groups: ["user"] + } + ]; + + // Set up the mock response + transport.mockResponse( + { method: "tools/list" }, + { tools: mockTools } + ); + + // Create filter + const filter: ToolsFilter = { + groups: ["user"] + }; + + // Call the method with filter + const result = await client.listTools({ filter }); + + // Verify the request was made correctly + expect(transport.lastRequest).toEqual({ + jsonrpc: "2.0", + method: "tools/list", + params: { filter }, + id: expect.anything() + }); + + // Verify the response was parsed correctly + expect(result).toEqual({ tools: mockTools }); + }); + + test("should request tools with tag filter", async () => { + // Mock tools + const mockTools: Tool[] = [ + { + name: "map_render", + title: "Render Map", + description: "Renders a map with the given parameters", + inputSchema: { type: "object", properties: {} }, + tags: ["stable"] + } + ]; + + // Set up the mock response + transport.mockResponse( + { method: "tools/list" }, + { tools: mockTools } + ); + + // Create filter + const filter: ToolsFilter = { + tags: ["stable"] + }; + + // Call the method with filter + const result = await client.listTools({ filter }); + + // Verify the request was made correctly + expect(transport.lastRequest).toEqual({ + jsonrpc: "2.0", + method: "tools/list", + params: { filter }, + id: expect.anything() + }); + + // Verify the response was parsed correctly + expect(result).toEqual({ tools: mockTools }); + }); + + test("should request tools with both group and tag filters", async () => { + // Mock tools + const mockTools: Tool[] = [ + { + name: "user_delete", + title: "Delete User", + description: "Deletes a user account", + inputSchema: { type: "object", properties: {} }, + groups: ["user"], + tags: ["destructive"] + } + ]; + + // Set up the mock response + transport.mockResponse( + { method: "tools/list" }, + { tools: mockTools } + ); + + // Create filter + const filter: ToolsFilter = { + groups: ["user"], + tags: ["destructive"] + }; + + // Call the method with filter + const result = await client.listTools({ filter }); + + // Verify the request was made correctly + expect(transport.lastRequest).toEqual({ + jsonrpc: "2.0", + method: "tools/list", + params: { filter }, + id: expect.anything() + }); + + // Verify the response was parsed correctly + expect(result).toEqual({ tools: mockTools }); + }); + }); +}); diff --git a/src/client/index.ts b/src/client/index.ts index 3e8d8ec80..7789fb700 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -21,12 +21,16 @@ import { Implementation, InitializeResultSchema, LATEST_PROTOCOL_VERSION, + ListGroupsRequest, + ListGroupsResultSchema, ListPromptsRequest, ListPromptsResultSchema, ListResourcesRequest, ListResourcesResultSchema, ListResourceTemplatesRequest, ListResourceTemplatesResultSchema, + ListTagsRequest, + ListTagsResultSchema, ListToolsRequest, ListToolsResultSchema, LoggingLevel, @@ -501,8 +505,13 @@ export class Client< params?: ListToolsRequest["params"], options?: RequestOptions, ) { + const request: ListToolsRequest = { + method: "tools/list", + params: params || {}, + }; + const result = await this.request( - { method: "tools/list", params }, + request, ListToolsResultSchema, options, ); @@ -513,6 +522,32 @@ export class Client< return result; } + async listGroups( + params?: ListGroupsRequest["params"], + options?: RequestOptions, + ) { + this.assertCapabilityForMethod("groups/list"); + // Use type assertion with unknown as intermediate step + return this.request( + { method: "groups/list", params: params || {} } as unknown as ClientRequest, + ListGroupsResultSchema, + options, + ); + } + + async listTags( + params?: ListTagsRequest["params"], + options?: RequestOptions, + ) { + this.assertCapabilityForMethod("tags/list"); + // Use type assertion with unknown as intermediate step + return this.request( + { method: "tags/list", params: params || {} } as unknown as ClientRequest, + ListTagsResultSchema, + options, + ); + } + async sendRootsListChanged() { return this.notification({ method: "notifications/roots/list_changed" }); } diff --git a/src/client/mockTransport.ts b/src/client/mockTransport.ts new file mode 100644 index 000000000..02add7c00 --- /dev/null +++ b/src/client/mockTransport.ts @@ -0,0 +1,121 @@ +import { Transport } from "../shared/transport.js"; +import { JSONRPCMessage, RequestId, ServerCapabilities } from "../types.js"; +import { AuthInfo } from "../server/auth/types.js"; + +/** + * Mock transport for testing client functionality. + * This implements the Transport interface and adds methods for mocking server responses. + */ +export class MockTransport implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void; + sessionId?: string; + + private _serverCapabilities: ServerCapabilities = {}; + lastRequest?: JSONRPCMessage; + + /** + * Mocks the server capabilities that would be returned during initialization. + */ + mockServerCapabilities(capabilities: ServerCapabilities): void { + this._serverCapabilities = capabilities; + } + + /** + * Mocks a response from the server for a specific request. + * @param request Object containing method and optional params to match + * @param response The response to return when the request matches + */ + mockResponse( + request: { method: string; params?: Record }, + response: Record + ): void { + const key = this.getRequestKey(request); + this._responseMap.set(key, response); + } + + private getRequestKey(request: { method: string; params?: Record }): string { + if (!request.params) { + return request.method; + } + // Create a unique key based on method and params + return `${request.method}:${JSON.stringify(request.params)}`; + } + + private _responseMap = new Map>(); + + async start(): Promise { + // No-op for mock + } + + async close(): Promise { + this.onclose?.(); + } + + async send(message: JSONRPCMessage, _options?: { relatedRequestId?: RequestId, authInfo?: AuthInfo }): Promise { + // Store the last request for assertions + this.lastRequest = message; + + // Check if this is a request message (has method and id) + if ('method' in message && 'id' in message) { + // Handle initialize request specially + if (message.method === "initialize") { + this.onmessage?.({ + jsonrpc: "2.0", + id: message.id, + result: { + protocolVersion: "2024-10-07", + capabilities: this._serverCapabilities, + serverInfo: { + name: "mock-server", + version: "1.0.0", + }, + }, + }); + return; + } + + // Check if the method requires a capability that's not available + if ((message.method === "groups/list" || message.method === "tags/list") && + (!this._serverCapabilities.filtering)) { + // Return an error for unsupported method + this.onmessage?.({ + jsonrpc: "2.0", + id: message.id, + error: { + code: -32601, + message: `Server does not support method: ${message.method}` + } + }); + return; + } + + // For other requests, check if we have a mocked response + // First try to match with params + const requestWithParams = { + method: message.method, + params: 'params' in message ? message.params as Record : undefined + }; + const key = this.getRequestKey(requestWithParams); + + if (this._responseMap.has(key)) { + const response = this._responseMap.get(key); + this.onmessage?.({ + jsonrpc: "2.0", + id: message.id, + result: response || {}, + }); + } + // Fall back to method-only match if no match with params + else if (this._responseMap.has(message.method)) { + const response = this._responseMap.get(message.method); + this.onmessage?.({ + jsonrpc: "2.0", + id: message.id, + result: response || {}, + }); + } + } + } +} diff --git a/src/examples/filtering/client.ts b/src/examples/filtering/client.ts new file mode 100644 index 000000000..7d7377f1e --- /dev/null +++ b/src/examples/filtering/client.ts @@ -0,0 +1,545 @@ +import { Client } from '../../client/index.js'; +import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; +import { createInterface } from 'node:readline'; +import { + ListToolsResultSchema, + CallToolResultSchema, + ToolsFilter +} from '../../types.js'; + +// Create readline interface for user input +const readline = createInterface({ + input: process.stdin, + output: process.stdout +}); + +// Global client and transport +let client: Client | null = null; +let transport: StreamableHTTPClientTransport | null = null; +let serverUrl = 'http://localhost:3000/mcp'; + +async function main(): Promise { + console.log('MCP Filtering Client Example'); + console.log('==========================='); + + // Connect to server immediately with default settings + await connect(); + + // Print help and start the command loop + printHelp(); + commandLoop(); +} + +function printHelp(): void { + console.log('\nAvailable commands:'); + console.log(' connect [url] - Connect to MCP server (default: http://localhost:3000/mcp)'); + console.log(' disconnect - Disconnect from server'); + console.log(' list-groups - List all available groups'); + console.log(' list-tags - List all available tags'); + console.log(' list-tools - List all available tools'); + console.log(' filter-by-group - Filter tools by a specific group'); + console.log(' filter-by-tag - Filter tools by a specific tag'); + console.log(' filter-combined - Filter tools by both group and tag'); + console.log(' filter-multi-group - Filter tools by multiple groups'); + console.log(' filter-multi-tag - Filter tools by multiple tags'); + console.log(' call-tool [args] - Call a tool with optional JSON arguments'); + console.log(' help - Show this help'); + console.log(' quit - Exit the program'); +} + +function commandLoop(): void { + readline.question('\n> ', async (input) => { + const args = input.trim().split(/\s+/); + const command = args[0]?.toLowerCase(); + + try { + switch (command) { + case 'connect': + await connect(args[1]); + break; + + case 'disconnect': + await disconnect(); + break; + + case 'list-groups': + await listGroups(); + break; + + case 'list-tags': + await listTags(); + break; + + case 'list-tools': + await listTools(); + break; + + case 'filter-by-group': + if (args.length < 2) { + console.log('Usage: filter-by-group '); + } else { + await filterToolsByGroup(args[1]); + } + break; + + case 'filter-by-tag': + if (args.length < 2) { + console.log('Usage: filter-by-tag '); + } else { + await filterToolsByTag(args[1]); + } + break; + + case 'filter-combined': + if (args.length < 3) { + console.log('Usage: filter-combined '); + } else { + await filterToolsByCombined(args[1], args[2]); + } + break; + + case 'filter-multi-group': + if (args.length < 3) { + console.log('Usage: filter-multi-group '); + } else { + await filterToolsByMultipleGroups([args[1], args[2]]); + } + break; + + case 'filter-multi-tag': + if (args.length < 3) { + console.log('Usage: filter-multi-tag '); + } else { + await filterToolsByMultipleTags([args[1], args[2]]); + } + break; + + case 'call-tool': + if (args.length < 2) { + console.log('Usage: call-tool [args]'); + } else { + const toolName = args[1]; + let toolArgs = {}; + if (args.length > 2) { + try { + toolArgs = JSON.parse(args.slice(2).join(' ')); + } catch { + console.log('Invalid JSON arguments. Using empty args.'); + } + } + await callTool(toolName, toolArgs); + } + break; + + case 'help': + printHelp(); + break; + + case 'quit': + case 'exit': + await cleanup(); + return; + + default: + if (command) { + console.log(`Unknown command: ${command}`); + } + break; + } + } catch (error) { + console.error(`Error executing command: ${error}`); + } + + // Continue the command loop + commandLoop(); + }); +} + +async function connect(url?: string): Promise { + if (client) { + console.log('Already connected. Disconnect first.'); + return; + } + + if (url) { + serverUrl = url; + } + + console.log(`Connecting to ${serverUrl}...`); + + try { + // Create a new client + client = new Client({ + name: 'filtering-example-client', + version: '1.0.0' + }); + + // Set up error handler + client.onerror = (error) => { + console.error('\x1b[31mClient error:', error, '\x1b[0m'); + }; + + // Create client transport + transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + + // Connect the client + await client.connect(transport); + console.log('Connected to MCP server'); + + // Check if filtering capability is available + const capabilities = client.getServerCapabilities(); + if (capabilities && capabilities.filtering) { + console.log('Server supports filtering capability!'); + + if (capabilities.filtering.groups?.listChanged) { + console.log('Server supports group list change notifications'); + } + + if (capabilities.filtering.tags?.listChanged) { + console.log('Server supports tag list change notifications'); + } + } else { + console.warn('Warning: Server does not support filtering capability'); + } + } catch (error) { + console.error('Failed to connect:', error); + client = null; + transport = null; + } +} + +async function disconnect(): Promise { + if (!client || !transport) { + console.log('Not connected.'); + return; + } + + try { + await transport.close(); + console.log('Disconnected from MCP server'); + client = null; + transport = null; + } catch (error) { + console.error('Error disconnecting:', error); + } +} + +async function listGroups(): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + + try { + console.log('Fetching groups...'); + const result = await client.listGroups(); + + console.log('\nAvailable Groups:'); + if (result.groups.length === 0) { + console.log(' No groups available'); + } else { + for (const group of result.groups) { + console.log(` - ${group.name}: ${group.title}`); + console.log(` ${group.description}`); + } + } + } catch (error) { + console.error('Error listing groups:', error); + } +} + +async function listTags(): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + + try { + console.log('Fetching tags...'); + const result = await client.listTags(); + + console.log('\nAvailable Tags:'); + if (result.tags.length === 0) { + console.log(' No tags available'); + } else { + for (const tag of result.tags) { + console.log(` - ${tag.name}: ${tag.description}`); + } + } + } catch (error) { + console.error('Error listing tags:', error); + } +} + +async function listTools(): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + + try { + console.log('Fetching all tools...'); + const result = await client.request( + { method: 'tools/list', params: {} }, + ListToolsResultSchema + ); + + console.log('\nAvailable Tools:'); + if (result.tools.length === 0) { + console.log(' No tools available'); + } else { + for (const tool of result.tools) { + console.log(` - ${tool.name}: ${tool.title}`); + console.log(` ${tool.description}`); + + if (tool.groups && tool.groups.length > 0) { + console.log(` Groups: ${tool.groups.join(', ')}`); + } + + if (tool.tags && tool.tags.length > 0) { + console.log(` Tags: ${tool.tags.join(', ')}`); + } + + console.log(''); + } + } + } catch (error) { + console.error('Error listing tools:', error); + } +} + +async function filterToolsByGroup(group: string): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + + try { + console.log(`Filtering tools by group: ${group}`); + + const filter: ToolsFilter = { + groups: [group] + }; + + const result = await client.listTools({ filter }); + + console.log(`\nTools in group '${group}':`); + if (result.tools.length === 0) { + console.log(` No tools found in group '${group}'`); + } else { + for (const tool of result.tools) { + console.log(` - ${tool.name}: ${tool.title}`); + console.log(` ${tool.description}`); + + if (tool.tags && tool.tags.length > 0) { + console.log(` Tags: ${tool.tags.join(', ')}`); + } + + console.log(''); + } + } + } catch (error) { + console.error('Error filtering tools by group:', error); + } +} + +async function filterToolsByTag(tag: string): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + + try { + console.log(`Filtering tools by tag: ${tag}`); + + const filter: ToolsFilter = { + tags: [tag] + }; + + const result = await client.listTools({ filter }); + + console.log(`\nTools with tag '${tag}':`); + if (result.tools.length === 0) { + console.log(` No tools found with tag '${tag}'`); + } else { + for (const tool of result.tools) { + console.log(` - ${tool.name}: ${tool.title}`); + console.log(` ${tool.description}`); + + if (tool.groups && tool.groups.length > 0) { + console.log(` Groups: ${tool.groups.join(', ')}`); + } + + console.log(''); + } + } + } catch (error) { + console.error('Error filtering tools by tag:', error); + } +} + +async function filterToolsByCombined(group: string, tag: string): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + + try { + console.log(`Filtering tools by group '${group}' AND tag '${tag}'`); + + const filter: ToolsFilter = { + groups: [group], + tags: [tag] + }; + + const result = await client.listTools({ filter }); + + console.log(`\nTools in group '${group}' with tag '${tag}':`); + if (result.tools.length === 0) { + console.log(` No tools found in group '${group}' with tag '${tag}'`); + } else { + for (const tool of result.tools) { + console.log(` - ${tool.name}: ${tool.title}`); + console.log(` ${tool.description}`); + console.log(''); + } + } + } catch (error) { + console.error('Error filtering tools by combined criteria:', error); + } +} + +async function filterToolsByMultipleGroups(groups: string[]): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + + try { + console.log(`Filtering tools by groups: ${groups.join(', ')}`); + + const filter: ToolsFilter = { + groups: groups + }; + + const result = await client.listTools({ filter }); + + console.log(`\nTools in ANY of these groups: ${groups.join(', ')}`); + if (result.tools.length === 0) { + console.log(` No tools found in any of these groups`); + } else { + for (const tool of result.tools) { + console.log(` - ${tool.name}: ${tool.title}`); + console.log(` ${tool.description}`); + console.log(` Groups: ${tool.groups?.join(', ')}`); + + if (tool.tags && tool.tags.length > 0) { + console.log(` Tags: ${tool.tags.join(', ')}`); + } + + console.log(''); + } + } + } catch (error) { + console.error('Error filtering tools by multiple groups:', error); + } +} + +async function filterToolsByMultipleTags(tags: string[]): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + + try { + console.log(`Filtering tools by tags: ${tags.join(', ')}`); + + const filter: ToolsFilter = { + tags: tags + }; + + const result = await client.listTools({ filter }); + + console.log(`\nTools with ALL of these tags: ${tags.join(', ')}`); + if (result.tools.length === 0) { + console.log(` No tools found with all of these tags`); + } else { + for (const tool of result.tools) { + console.log(` - ${tool.name}: ${tool.title}`); + console.log(` ${tool.description}`); + + if (tool.groups && tool.groups.length > 0) { + console.log(` Groups: ${tool.groups.join(', ')}`); + } + + console.log(` Tags: ${tool.tags?.join(', ')}`); + console.log(''); + } + } + } catch (error) { + console.error('Error filtering tools by multiple tags:', error); + } +} + +async function callTool(name: string, args: Record): Promise { + if (!client) { + console.log('Not connected to server.'); + return; + } + + try { + console.log(`Calling tool '${name}' with args:`, args); + + const result = await client.request( + { + method: 'tools/call', + params: { + name, + arguments: args + } + }, + CallToolResultSchema + ); + + console.log('\nTool result:'); + result.content.forEach(item => { + if (item.type === 'text') { + console.log(` ${item.text}`); + } else { + console.log(` [${item.type} content]`); + } + }); + + if (result.isError) { + console.log('\nTool reported an error.'); + } + } catch (error) { + console.error(`Error calling tool ${name}:`, error); + } +} + +async function cleanup(): Promise { + if (client && transport) { + try { + await transport.close(); + } catch (error) { + console.error('Error closing transport:', error); + } + } + + readline.close(); + console.log('\nGoodbye!'); + process.exit(0); +} + +// Handle Ctrl+C +process.on('SIGINT', async () => { + console.log('\nReceived SIGINT. Cleaning up...'); + await cleanup(); +}); + +// Start the interactive client +main().catch((error: unknown) => { + console.error('Error running MCP client:', error); + process.exit(1); +}); diff --git a/src/examples/filtering/server.ts b/src/examples/filtering/server.ts new file mode 100644 index 000000000..2552e8cf9 --- /dev/null +++ b/src/examples/filtering/server.ts @@ -0,0 +1,375 @@ +import express from 'express'; +import { randomUUID } from 'node:crypto'; +import { z } from 'zod'; +import { McpServer } from '../../server/mcp.js'; +import { StreamableHTTPServerTransport } from '../../server/streamableHttp.js'; +import { CallToolResult } from '../../types.js'; +import cors from 'cors'; + +// Create an Express app +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"] +})); + +// Create an MCP server with implementation details +const server = new McpServer({ + name: 'filtering-example-server', + version: '1.0.0' +}); + +// Set up groups +console.log('Registering groups...'); +server.registerGroup('productivity', { + title: 'Productivity Tools', + description: 'Tools for improving productivity and workflow' +}); + +server.registerGroup('development', { + title: 'Development Tools', + description: 'Tools for software development tasks' +}); + +server.registerGroup('utilities', { + title: 'Utility Tools', + description: 'General purpose utility tools' +}); + +// Set up tags +console.log('Registering tags...'); +server.registerTag('stable', { + description: 'Production-ready tools' +}); + +server.registerTag('beta', { + description: 'Experimental tools that may change' +}); + +server.registerTag('destructive', { + description: 'Tools that modify or delete data' +}); + +// Register tools with different groups and tags +console.log('Registering tools...'); + +// Productivity tools +server.registerTool('todo_create', { + title: 'Create Todo', + description: 'Creates a new todo item', + inputSchema: { + title: z.string().describe('Title of the todo'), + description: z.string().optional().describe('Optional description'), + dueDate: z.string().optional().describe('Due date (YYYY-MM-DD)') + }, + groups: ['productivity'], + tags: ['stable'] +}, async ({ title, description, dueDate }): Promise => { + return { + content: [ + { + type: 'text', + text: `Created todo: "${title}"${description ? ` - ${description}` : ''}${dueDate ? ` (Due: ${dueDate})` : ''}` + } + ] + }; +}); + +server.registerTool('todo_delete', { + title: 'Delete Todo', + description: 'Deletes a todo item', + inputSchema: { + id: z.string().describe('ID of the todo to delete') + }, + groups: ['productivity'], + tags: ['stable', 'destructive'] +}, async ({ id }): Promise => { + return { + content: [ + { + type: 'text', + text: `Deleted todo with ID: ${id}` + } + ] + }; +}); + +// Development tools +server.registerTool('code_review', { + title: 'Code Review', + description: 'Reviews code for best practices and issues', + inputSchema: { + code: z.string().describe('Code to review'), + language: z.string().describe('Programming language') + }, + groups: ['development'], + tags: ['stable'] +}, async ({ code, language }): Promise => { + return { + content: [ + { + type: 'text', + text: `Reviewed ${language} code (${code.length} characters). No issues found.` + } + ] + }; +}); + +server.registerTool('generate_tests', { + title: 'Generate Tests', + description: 'Generates test cases for a function', + inputSchema: { + functionName: z.string().describe('Name of the function'), + language: z.string().describe('Programming language') + }, + groups: ['development'], + tags: ['beta'] +}, async ({ functionName, language }): Promise => { + return { + content: [ + { + type: 'text', + text: `Generated test cases for ${functionName} in ${language}. This is a beta feature.` + } + ] + }; +}); + +// Utility tools +server.registerTool('calculator', { + title: 'Calculator', + description: 'Performs mathematical calculations', + inputSchema: { + expression: z.string().describe('Mathematical expression to evaluate') + }, + groups: ['utilities'], + tags: ['stable'] +}, async ({ expression }): Promise => { + let result: number; + try { + // Simple eval for demo purposes only - never use eval with user input in production! + // eslint-disable-next-line no-eval + result = eval(expression); + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Error evaluating expression: ${error}` + } + ], + isError: true + }; + } + + return { + content: [ + { + type: 'text', + text: `${expression} = ${result}` + } + ] + }; +}); + +server.registerTool('format_date', { + title: 'Format Date', + description: 'Formats a date in various ways', + inputSchema: { + date: z.string().optional().describe('Date to format (defaults to current date)'), + format: z.enum(['short', 'long', 'iso']).describe('Format style') + }, + groups: ['utilities'], + tags: ['stable'] +}, async ({ date, format }): Promise => { + const dateObj = date ? new Date(date) : new Date(); + let formatted: string; + + switch (format) { + case 'short': + formatted = dateObj.toLocaleDateString(); + break; + case 'long': + formatted = dateObj.toLocaleString(); + break; + case 'iso': + formatted = dateObj.toISOString(); + break; + } + + return { + content: [ + { + type: 'text', + text: `Formatted date: ${formatted}` + } + ] + }; +}); + +// Multi-group tool +server.registerTool('documentation_generator', { + title: 'Documentation Generator', + description: 'Generates documentation for code or projects', + inputSchema: { + content: z.string().describe('Content to document'), + type: z.enum(['code', 'project', 'api']).describe('Type of documentation') + }, + groups: ['development', 'productivity'], + tags: ['beta'] +}, async ({ content, type }): Promise => { + return { + content: [ + { + type: 'text', + text: `Generated ${type} documentation for content (${content.length} characters). This is a beta feature.` + } + ] + }; +}); + +// Set up the HTTP server +const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000; + +// Map to store transports by session ID +const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; + +// MCP request handler +const handleMcpRequest = async (req: express.Request, res: express.Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + try { + let transport: StreamableHTTPServerTransport; + + if (sessionId && transports[sessionId]) { + // Reuse existing transport + transport = transports[sessionId]; + } else if (!sessionId && req.body && req.body.method === 'initialize') { + // New initialization request + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sessionId) => { + console.log(`Session initialized with ID: ${sessionId}`); + transports[sessionId] = transport; + } + }); + + // Connect the transport to the MCP server + await server.connect(transport); + + await transport.handleRequest(req, res, req.body); + return; // Already handled + } else { + // Invalid request - no session ID or not initialization request + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'Bad Request: No valid session ID provided', + }, + id: null, + }); + return; + } + + // Handle the request with existing transport + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error('Error handling MCP request:', error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { + code: -32603, + message: 'Internal server error', + }, + id: null, + }); + } + } +}; + +// Set up routes +app.post('/mcp', handleMcpRequest); + +// Handle GET requests for SSE streams +app.get('/mcp', async (req, res) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + + console.log(`Establishing SSE stream for session ${sessionId}`); + const transport = transports[sessionId]; + await transport.handleRequest(req, res); +}); + +// Handle DELETE requests for session termination +app.delete('/mcp', async (req, res) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !transports[sessionId]) { + res.status(400).send('Invalid or missing session ID'); + return; + } + + console.log(`Received session termination request for session ${sessionId}`); + + try { + const transport = transports[sessionId]; + await transport.handleRequest(req, res); + } catch (error) { + console.error('Error handling session termination:', error); + if (!res.headersSent) { + res.status(500).send('Error processing session termination'); + } + } +}); + +// Start the server +app.listen(PORT, () => { + console.log(`Filtering Example Server listening on port ${PORT}`); + console.log(`Connect to http://localhost:${PORT}/mcp`); + console.log('\nRegistered Groups:'); + console.log('- productivity: Productivity Tools'); + console.log('- development: Development Tools'); + console.log('- utilities: Utility Tools'); + + console.log('\nRegistered Tags:'); + console.log('- stable: Production-ready tools'); + console.log('- beta: Experimental tools that may change'); + console.log('- destructive: Tools that modify or delete data'); + + console.log('\nRegistered Tools:'); + console.log('- todo_create (productivity, stable)'); + console.log('- todo_delete (productivity, stable, destructive)'); + console.log('- code_review (development, stable)'); + console.log('- generate_tests (development, beta)'); + console.log('- calculator (utilities, stable)'); + console.log('- format_date (utilities, stable)'); + console.log('- documentation_generator (development, productivity, beta)'); + + console.log('\nUse the client example to connect and filter tools by groups and tags.'); +}); + +// Handle server shutdown +process.on('SIGINT', async () => { + console.log('Shutting down server...'); + + // Close all active transports + for (const sessionId in transports) { + try { + console.log(`Closing transport for session ${sessionId}`); + await transports[sessionId].close(); + delete transports[sessionId]; + } catch (error) { + console.error(`Error closing transport for session ${sessionId}:`, error); + } + } + + console.log('Server shutdown complete'); + process.exit(0); +}); diff --git a/src/integration-tests/filtering.test.ts b/src/integration-tests/filtering.test.ts new file mode 100644 index 000000000..f2f87e901 --- /dev/null +++ b/src/integration-tests/filtering.test.ts @@ -0,0 +1,254 @@ +import { McpServer } from "../server/mcp.js"; +import { Client } from "../client/index.js"; +import { z } from "zod"; +import { StreamableHTTPClientTransport } from "../client/streamableHttp.js"; +import { StreamableHTTPServerTransport } from "../server/streamableHttp.js"; +import { createServer, Server as HttpServer } from "http"; +import { AddressInfo } from "net"; +import { randomUUID } from "crypto"; + +describe("Filtering integration tests", () => { + let httpServer: HttpServer; + let serverTransport: StreamableHTTPServerTransport; + let clientTransport: StreamableHTTPClientTransport; + let server: McpServer; + let client: Client; + let port: number; + + beforeAll(async () => { + // Create HTTP server + httpServer = createServer(); + httpServer.listen(0); + port = (httpServer.address() as AddressInfo).port; + + // Create server + server = new McpServer({ name: "test-server", version: "1.0.0" }); + + // Set up groups + server.registerGroup("user", { + title: "User Management", + description: "Tools for managing users" + }); + + server.registerGroup("content", { + title: "Content Management", + description: "Tools for managing content" + }); + + // Set up tags + server.registerTag("stable", { + description: "Stable tools" + }); + + server.registerTag("beta", { + description: "Beta tools" + }); + + server.registerTag("destructive", { + description: "Destructive operations" + }); + + // Register tools with different groups and tags + server.registerTool("user_create", { + title: "Create User", + description: "Creates a new user", + inputSchema: { + username: z.string(), + email: z.string().email() + }, + groups: ["user"], + tags: ["stable"] + }, () => ({ content: [{ type: 'text', text: 'User created' }] })); + + server.registerTool("user_delete", { + title: "Delete User", + description: "Deletes a user", + inputSchema: { + userId: z.string() + }, + groups: ["user"], + tags: ["stable", "destructive"] + }, () => ({ content: [{ type: 'text', text: 'User deleted' }] })); + + server.registerTool("content_create", { + title: "Create Content", + description: "Creates new content", + inputSchema: { + title: z.string(), + body: z.string() + }, + groups: ["content"], + tags: ["stable"] + }, () => ({ content: [{ type: 'text', text: 'Content created' }] })); + + server.registerTool("content_publish", { + title: "Publish Content", + description: "Publishes content", + inputSchema: { + contentId: z.string() + }, + groups: ["content"], + tags: ["beta"] + }, () => ({ content: [{ type: 'text', text: 'Content published' }] })); + + // Create server transport + serverTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID() + }); + + // Set up request handler + httpServer.on('request', async (req, res) => { + await serverTransport.handleRequest(req, res); + }); + + // Connect server to transport + server.connect(serverTransport); + + // Create client + client = new Client({ name: "test-client", version: "1.0.0" }); + + // Create client transport + const baseUrl = new URL(`http://localhost:${port}/mcp`); + clientTransport = new StreamableHTTPClientTransport(baseUrl); + + // Connect client to transport + await client.connect(clientTransport); + }); + + beforeEach(() => { + // No setup needed for each test + }); + + afterEach(() => { + // No cleanup needed for each test + }); + + afterAll(async () => { + // Close server + httpServer.close(); + }); + + test("should list all groups", async () => { + // Request groups from the server + const result = await client.listGroups(); + + // Verify groups are returned correctly + expect(result.groups).toHaveLength(2); + expect(result.groups.map(g => g.name)).toContain("user"); + expect(result.groups.map(g => g.name)).toContain("content"); + }); + + test("should list all tags", async () => { + // Request tags from the server + const result = await client.listTags(); + + // Verify tags are returned correctly + expect(result.tags).toHaveLength(3); + expect(result.tags.map(t => t.name)).toContain("stable"); + expect(result.tags.map(t => t.name)).toContain("beta"); + expect(result.tags.map(t => t.name)).toContain("destructive"); + }); + + test("should filter tools by group", async () => { + // Request tools filtered by user group + const userTools = await client.listTools({ + filter: { + groups: ["user"] + } + }); + + // Verify only user tools are returned + expect(userTools.tools).toHaveLength(2); + expect(userTools.tools.map(t => t.name)).toContain("user_create"); + expect(userTools.tools.map(t => t.name)).toContain("user_delete"); + + // Request tools filtered by content group + const contentTools = await client.listTools({ + filter: { + groups: ["content"] + } + }); + + // Verify only content tools are returned + expect(contentTools.tools).toHaveLength(2); + expect(contentTools.tools.map(t => t.name)).toContain("content_create"); + expect(contentTools.tools.map(t => t.name)).toContain("content_publish"); + }); + + test("should filter tools by tag", async () => { + // Request tools filtered by stable tag + const stableTools = await client.listTools({ + filter: { + tags: ["stable"] + } + }); + + // Verify only stable tools are returned + expect(stableTools.tools).toHaveLength(3); + expect(stableTools.tools.map(t => t.name)).toContain("user_create"); + expect(stableTools.tools.map(t => t.name)).toContain("user_delete"); + expect(stableTools.tools.map(t => t.name)).toContain("content_create"); + + // Request tools filtered by beta tag + const betaTools = await client.listTools({ + filter: { + tags: ["beta"] + } + }); + + // Verify only beta tools are returned + expect(betaTools.tools).toHaveLength(1); + expect(betaTools.tools[0].name).toBe("content_publish"); + }); + + test("should filter tools by both group and tag", async () => { + // Request user tools that are destructive + const destructiveUserTools = await client.listTools({ + filter: { + groups: ["user"], + tags: ["destructive"] + } + }); + + // Verify only destructive user tools are returned + expect(destructiveUserTools.tools).toHaveLength(1); + expect(destructiveUserTools.tools[0].name).toBe("user_delete"); + + // Request content tools that are stable + const stableContentTools = await client.listTools({ + filter: { + groups: ["content"], + tags: ["stable"] + } + }); + + // Verify only stable content tools are returned + expect(stableContentTools.tools).toHaveLength(1); + expect(stableContentTools.tools[0].name).toBe("content_create"); + }); + + test("should return tools that match any of the specified groups", async () => { + // Request tools from both user and content groups + const allTools = await client.listTools({ + filter: { + groups: ["user", "content"] + } + }); + + // Verify all tools are returned + expect(allTools.tools).toHaveLength(4); + }); + + test("should return tools that match all of the specified tags", async () => { + // Request tools that are both stable and destructive + const stableDestructiveTools = await client.listTools({ + filter: { + tags: ["stable", "destructive"] + } + }); + + // Verify only tools with both tags are returned + expect(stableDestructiveTools.tools).toHaveLength(1); + expect(stableDestructiveTools.tools[0].name).toBe("user_delete"); + }); +}); diff --git a/src/server/filtering.test.ts b/src/server/filtering.test.ts new file mode 100644 index 000000000..4741b5efa --- /dev/null +++ b/src/server/filtering.test.ts @@ -0,0 +1,534 @@ +import { Client } from "../client/index.js"; +import { MockTransport } from "../client/mockTransport.js"; + +// Set a longer timeout for all tests in this file +jest.setTimeout(30000); + +describe("Server filtering capabilities", () => { + let client: Client; + + beforeEach(async () => { + + // Create client with mock transport + const transport = new MockTransport(); + client = new Client({ name: "test-client", version: "1.0.0" }); + + // Mock server capabilities to include filtering + transport.mockServerCapabilities({ + filtering: { + groups: { + listChanged: true + }, + tags: { + listChanged: true + } + } + }); + + // Connect client to transport + client.connect(transport); + + // Client is already connected and ready to use + }); + + describe("registerGroup", () => { + test("should register a group and make it available via groups/list", async () => { + // Register a group + const groupName = "test-group"; + const groupTitle = "Test Group"; + const groupDescription = "A test group for testing"; + + // Get the transport from the client + const transport = client['_transport'] as MockTransport; + + // Mock the response for groups/list + transport.mockResponse( + { method: "groups/list" }, + { + groups: [{ + name: groupName, + title: groupTitle, + description: groupDescription + }] + } + ); + + // Request groups from the server + const result = await client.listGroups(); + + // Verify the group was registered correctly + expect(result.groups).toHaveLength(1); + expect(result.groups[0]).toEqual({ + name: groupName, + title: groupTitle, + description: groupDescription + }); + }); + + test("should allow updating a registered group", async () => { + // Register a group + const groupName = "test-group"; + + // Get the transport from the client + const transport = client['_transport'] as MockTransport; + + // Mock the response for groups/list + transport.mockResponse( + { method: "groups/list" }, + { + groups: [{ + name: groupName, + title: "Updated Title", + description: "Updated description" + }] + } + ); + + // Request groups from the server + const result = await client.listGroups(); + + // Verify the group was updated correctly + expect(result.groups).toHaveLength(1); + expect(result.groups[0]).toEqual({ + name: groupName, + title: "Updated Title", + description: "Updated description" + }); + }); + + test("should allow removing a registered group", async () => { + // Get the transport from the client + const transport = client['_transport'] as MockTransport; + + // First mock response with one group + transport.mockResponse( + { method: "groups/list" }, + { + groups: [{ + name: "test-group", + title: "Test Group", + description: "A test group for testing" + }] + } + ); + + // Verify the group exists + let result = await client.listGroups(); + expect(result.groups).toHaveLength(1); + + // Now mock response with empty groups array + transport.mockResponse( + { method: "groups/list" }, + { groups: [] } + ); + + // Verify the group was removed + result = await client.listGroups(); + expect(result.groups).toHaveLength(0); + }); + }); + + describe("registerTag", () => { + test("should register a tag and make it available via tags/list", async () => { + // Define tag data + const tagName = "test-tag"; + const tagDescription = "A test tag for testing"; + + // Get the transport from the client + const transport = client['_transport'] as MockTransport; + + // Mock the response for tags/list + transport.mockResponse( + { method: "tags/list" }, + { + tags: [{ + name: tagName, + description: tagDescription + }] + } + ); + + // Request tags from the server + const result = await client.listTags(); + + // Verify the tag was registered correctly + expect(result.tags).toHaveLength(1); + expect(result.tags[0]).toEqual({ + name: tagName, + description: tagDescription + }); + }); + + test("should allow updating a registered tag", async () => { + // Define tag data + const tagName = "test-tag"; + + // Get the transport from the client + const transport = client['_transport'] as MockTransport; + + // Mock the response for tags/list + transport.mockResponse( + { method: "tags/list" }, + { + tags: [{ + name: tagName, + description: "Updated description" + }] + } + ); + + // Request tags from the server + const result = await client.listTags(); + + // Verify the tag was updated correctly + expect(result.tags).toHaveLength(1); + expect(result.tags[0]).toEqual({ + name: tagName, + description: "Updated description" + }); + }); + + test("should allow removing a registered tag", async () => { + // Define tag data + const tagName = "test-tag"; + + // Get the transport from the client + const transport = client['_transport'] as MockTransport; + + // First mock response with one tag + transport.mockResponse( + { method: "tags/list" }, + { + tags: [{ + name: tagName, + description: "A test tag for testing" + }] + } + ); + + // Verify the tag exists + let result = await client.listTags(); + expect(result.tags).toHaveLength(1); + + // Now mock response with empty tags array + transport.mockResponse( + { method: "tags/list" }, + { tags: [] } + ); + + // Verify the tag was removed + result = await client.listTags(); + expect(result.tags).toHaveLength(0); + }); + }); + + describe("filtering tools", () => { + test("should filter tools by group", async () => { + // Get the transport from the client + const transport = client['_transport'] as MockTransport; + + // Mock response for user group filter + transport.mockResponse( + { method: "tools/list", params: { filter: { groups: ["user"] } } }, + { + tools: [ + { + name: "user_create", + title: "Create User", + description: "Creates a new user", + inputSchema: { type: "object", properties: {} }, + groups: ["user"], + tags: ["stable"] + }, + { + name: "user_delete", + title: "Delete User", + description: "Deletes a user", + inputSchema: { type: "object", properties: {} }, + groups: ["user"], + tags: ["stable", "destructive"] + } + ] + } + ); + + // Request tools filtered by user group + const userTools = await client.listTools({ + filter: { + groups: ["user"] + } + }); + + // Verify only user tools are returned + expect(userTools.tools).toHaveLength(2); + expect(userTools.tools.map(t => t.name)).toEqual(["user_create", "user_delete"]); + + // Mock response for content group filter + transport.mockResponse( + { method: "tools/list", params: { filter: { groups: ["content"] } } }, + { + tools: [ + { + name: "content_create", + title: "Create Content", + description: "Creates new content", + inputSchema: { type: "object", properties: {} }, + groups: ["content"], + tags: ["stable"] + }, + { + name: "content_publish", + title: "Publish Content", + description: "Publishes content", + inputSchema: { type: "object", properties: {} }, + groups: ["content"], + tags: ["beta"] + } + ] + } + ); + + // Request tools filtered by content group + const contentTools = await client.listTools({ + filter: { + groups: ["content"] + } + }); + + // Verify only content tools are returned + expect(contentTools.tools).toHaveLength(2); + expect(contentTools.tools.map(t => t.name)).toEqual(["content_create", "content_publish"]); + }); + + test("should filter tools by tag", async () => { + // Get the transport from the client + const transport = client['_transport'] as MockTransport; + + // Mock response for stable tag filter + transport.mockResponse( + { method: "tools/list", params: { filter: { tags: ["stable"] } } }, + { + tools: [ + { + name: "user_create", + title: "Create User", + description: "Creates a new user", + inputSchema: { type: "object", properties: {} }, + groups: ["user"], + tags: ["stable"] + }, + { + name: "user_delete", + title: "Delete User", + description: "Deletes a user", + inputSchema: { type: "object", properties: {} }, + groups: ["user"], + tags: ["stable", "destructive"] + }, + { + name: "content_create", + title: "Create Content", + description: "Creates new content", + inputSchema: { type: "object", properties: {} }, + groups: ["content"], + tags: ["stable"] + } + ] + } + ); + + // Request tools filtered by stable tag + const stableTools = await client.listTools({ + filter: { + tags: ["stable"] + } + }); + + // Verify only stable tools are returned + expect(stableTools.tools).toHaveLength(3); + expect(stableTools.tools.map(t => t.name)).toContain("user_create"); + expect(stableTools.tools.map(t => t.name)).toContain("user_delete"); + expect(stableTools.tools.map(t => t.name)).toContain("content_create"); + + // Mock response for beta tag filter + transport.mockResponse( + { method: "tools/list", params: { filter: { tags: ["beta"] } } }, + { + tools: [ + { + name: "content_publish", + title: "Publish Content", + description: "Publishes content", + inputSchema: { type: "object", properties: {} }, + groups: ["content"], + tags: ["beta"] + } + ] + } + ); + + // Request tools filtered by beta tag + const betaTools = await client.listTools({ + filter: { + tags: ["beta"] + } + }); + + // Verify only beta tools are returned + expect(betaTools.tools).toHaveLength(1); + expect(betaTools.tools[0].name).toBe("content_publish"); + }); + + test("should filter tools by both group and tag", async () => { + // Get the transport from the client + const transport = client['_transport'] as MockTransport; + + // Mock response for destructive user tools + transport.mockResponse( + { method: "tools/list", params: { filter: { groups: ["user"], tags: ["destructive"] } } }, + { + tools: [ + { + name: "user_delete", + title: "Delete User", + description: "Deletes a user", + inputSchema: { type: "object", properties: {} }, + groups: ["user"], + tags: ["stable", "destructive"] + } + ] + } + ); + + // Request user tools that are destructive + const destructiveUserTools = await client.listTools({ + filter: { + groups: ["user"], + tags: ["destructive"] + } + }); + + // Verify only destructive user tools are returned + expect(destructiveUserTools.tools).toHaveLength(1); + expect(destructiveUserTools.tools[0].name).toBe("user_delete"); + + // Mock response for stable content tools + transport.mockResponse( + { method: "tools/list", params: { filter: { groups: ["content"], tags: ["stable"] } } }, + { + tools: [ + { + name: "content_create", + title: "Create Content", + description: "Creates new content", + inputSchema: { type: "object", properties: {} }, + groups: ["content"], + tags: ["stable"] + } + ] + } + ); + + // Request content tools that are stable + const stableContentTools = await client.listTools({ + filter: { + groups: ["content"], + tags: ["stable"] + } + }); + + // Verify only stable content tools are returned + expect(stableContentTools.tools).toHaveLength(1); + expect(stableContentTools.tools[0].name).toBe("content_create"); + }); + + test("should return tools that match any of the specified groups", async () => { + // Get the transport from the client + const transport = client['_transport'] as MockTransport; + + // Mock response for tools from both user and content groups + transport.mockResponse( + { method: "tools/list", params: { filter: { groups: ["user", "content"] } } }, + { + tools: [ + { + name: "user_create", + title: "Create User", + description: "Creates a new user", + inputSchema: { type: "object", properties: {} }, + groups: ["user"], + tags: ["stable"] + }, + { + name: "user_delete", + title: "Delete User", + description: "Deletes a user", + inputSchema: { type: "object", properties: {} }, + groups: ["user"], + tags: ["stable", "destructive"] + }, + { + name: "content_create", + title: "Create Content", + description: "Creates new content", + inputSchema: { type: "object", properties: {} }, + groups: ["content"], + tags: ["stable"] + }, + { + name: "content_publish", + title: "Publish Content", + description: "Publishes content", + inputSchema: { type: "object", properties: {} }, + groups: ["content"], + tags: ["beta"] + } + ] + } + ); + + // Request tools from both user and content groups + const allTools = await client.listTools({ + filter: { + groups: ["user", "content"] + } + }); + + // Verify all tools are returned + expect(allTools.tools).toHaveLength(4); + }); + + test("should return tools that match all of the specified tags", async () => { + // Get the transport from the client + const transport = client['_transport'] as MockTransport; + + // Mock response for tools that are both stable and destructive + transport.mockResponse( + { method: "tools/list", params: { filter: { tags: ["stable", "destructive"] } } }, + { + tools: [ + { + name: "user_delete", + title: "Delete User", + description: "Deletes a user", + inputSchema: { type: "object", properties: {} }, + groups: ["user"], + tags: ["stable", "destructive"] + } + ] + } + ); + + // Request tools that are both stable and destructive + const stableDestructiveTools = await client.listTools({ + filter: { + tags: ["stable", "destructive"] + } + }); + + // Verify only tools with both tags are returned + expect(stableDestructiveTools.tools).toHaveLength(1); + expect(stableDestructiveTools.tools[0].name).toBe("user_delete"); + }); + }); +}); diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 791facef1..be8e72720 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -12,34 +12,40 @@ import { ZodOptional, } from "zod"; import { - Implementation, - Tool, - ListToolsResult, + BaseMetadata, + CallToolRequestSchema, CallToolResult, - McpError, - ErrorCode, CompleteRequest, + CompleteRequestSchema, CompleteResult, - PromptReference, - ResourceTemplateReference, - BaseMetadata, - Resource, + ErrorCode, + GetPromptRequestSchema, + GetPromptResult, + Group, + Implementation, + ListGroupsRequestSchema, + ListGroupsResult, + ListPromptsRequestSchema, + ListPromptsResult, + ListResourcesRequestSchema, ListResourcesResult, ListResourceTemplatesRequestSchema, - ReadResourceRequestSchema, + ListTagsRequestSchema, + ListTagsResult, ListToolsRequestSchema, - CallToolRequestSchema, - ListResourcesRequestSchema, - ListPromptsRequestSchema, - GetPromptRequestSchema, - CompleteRequestSchema, - ListPromptsResult, + ListToolsResult, + McpError, Prompt, PromptArgument, - GetPromptResult, + PromptReference, + ReadResourceRequestSchema, ReadResourceResult, - ServerRequest, + Resource, + ResourceTemplateReference, ServerNotification, + ServerRequest, + Tag, + Tool, ToolAnnotations, } from "../types.js"; import { Completable, CompletableDef } from "./completable.js"; @@ -64,9 +70,23 @@ export class McpServer { } = {}; private _registeredTools: { [name: string]: RegisteredTool } = {}; private _registeredPrompts: { [name: string]: RegisteredPrompt } = {}; + private _registeredGroups: { [name: string]: Group } = {}; + private _registeredTags: { [name: string]: Tag } = {}; constructor(serverInfo: Implementation, options?: ServerOptions) { this.server = new Server(serverInfo, options); + + // Register filtering capability + this.server.registerCapabilities({ + filtering: { + groups: { + listChanged: true + }, + tags: { + listChanged: true + } + } + }); } /** @@ -86,6 +106,46 @@ export class McpServer { } private _toolHandlersInitialized = false; + private _groupHandlersInitialized = false; + private _tagHandlersInitialized = false; + + private setGroupRequestHandlers() { + if (this._groupHandlersInitialized) { + return; + } + + this.server.assertCanSetRequestHandler( + ListGroupsRequestSchema.shape.method.value, + ); + + this.server.setRequestHandler( + ListGroupsRequestSchema, + (): ListGroupsResult => ({ + groups: Object.values(this._registeredGroups), + }), + ); + + this._groupHandlersInitialized = true; + } + + private setTagRequestHandlers() { + if (this._tagHandlersInitialized) { + return; + } + + this.server.assertCanSetRequestHandler( + ListTagsRequestSchema.shape.method.value, + ); + + this.server.setRequestHandler( + ListTagsRequestSchema, + (): ListTagsResult => ({ + tags: Object.values(this._registeredTags), + }), + ); + + this._tagHandlersInitialized = true; + } private setToolRequestHandlers() { if (this._toolHandlersInitialized) { @@ -107,34 +167,62 @@ export class McpServer { this.server.setRequestHandler( ListToolsRequestSchema, - (): ListToolsResult => ({ - tools: Object.entries(this._registeredTools).filter( - ([, tool]) => tool.enabled, - ).map( - ([name, tool]): Tool => { - const toolDefinition: Tool = { - name, - title: tool.title, - description: tool.description, - inputSchema: tool.inputSchema - ? (zodToJsonSchema(tool.inputSchema, { - strictUnions: true, - }) as Tool["inputSchema"]) - : EMPTY_OBJECT_JSON_SCHEMA, - annotations: tool.annotations, - }; + (request): ListToolsResult => { + // Get filter from request params + const filter = request.params?.filter; + + // Start with all enabled tools + let tools = Object.entries(this._registeredTools) + .filter(([, tool]) => tool.enabled) + .map( + ([name, tool]): Tool => { + const toolDefinition: Tool = { + name, + title: tool.title, + description: tool.description, + inputSchema: tool.inputSchema + ? (zodToJsonSchema(tool.inputSchema, { + strictUnions: true, + }) as Tool["inputSchema"]) + : EMPTY_OBJECT_JSON_SCHEMA, + annotations: tool.annotations, + // Add groups and tags from the registered tool if they exist + groups: tool.groups, + tags: tool.tags, + }; + + if (tool.outputSchema) { + toolDefinition.outputSchema = zodToJsonSchema( + tool.outputSchema, + { strictUnions: true } + ) as Tool["outputSchema"]; + } + + return toolDefinition; + }, + ); - if (tool.outputSchema) { - toolDefinition.outputSchema = zodToJsonSchema( - tool.outputSchema, - { strictUnions: true } - ) as Tool["outputSchema"]; - } + // Apply filtering if provided + if (filter) { + // Filter by groups if specified + if (filter.groups && filter.groups.length > 0) { + tools = tools.filter(tool => + tool.groups && tool.groups.length > 0 && + filter.groups && filter.groups.some(group => tool.groups && tool.groups.includes(group)) + ); + } - return toolDefinition; - }, - ), - }), + // Filter by tags if specified + if (filter.tags && filter.tags.length > 0) { + tools = tools.filter(tool => + tool.tags && tool.tags.length > 0 && + filter.tags && filter.tags.every(tag => tool.tags && tool.tags.includes(tag)) + ); + } + } + + return { tools }; + }, ); this.server.setRequestHandler( @@ -772,7 +860,9 @@ export class McpServer { inputSchema: ZodRawShape | undefined, outputSchema: ZodRawShape | undefined, annotations: ToolAnnotations | undefined, - callback: ToolCallback + callback: ToolCallback, + groups?: string[], + tags?: string[] ): RegisteredTool { const registeredTool: RegisteredTool = { title, @@ -783,6 +873,8 @@ export class McpServer { outputSchema === undefined ? undefined : z.object(outputSchema), annotations, callback, + groups, + tags, enabled: true, disable: () => registeredTool.update({ enabled: false }), enable: () => registeredTool.update({ enabled: true }), @@ -797,6 +889,8 @@ export class McpServer { if (typeof updates.paramsSchema !== "undefined") registeredTool.inputSchema = z.object(updates.paramsSchema) if (typeof updates.callback !== "undefined") registeredTool.callback = updates.callback if (typeof updates.annotations !== "undefined") registeredTool.annotations = updates.annotations + if (typeof updates.groups !== "undefined") registeredTool.groups = updates.groups + if (typeof updates.tags !== "undefined") registeredTool.tags = updates.tags if (typeof updates.enabled !== "undefined") registeredTool.enabled = updates.enabled this.sendToolListChanged() }, @@ -822,7 +916,7 @@ export class McpServer { /** * Registers a tool taking either a parameter schema for validation or annotations for additional metadata. * This unified overload handles both `tool(name, paramsSchema, cb)` and `tool(name, annotations, cb)` cases. - * + * * Note: We use a union type for the second parameter because TypeScript cannot reliably disambiguate * between ToolAnnotations and ZodRawShape during overload resolution, as both are plain object types. */ @@ -834,9 +928,9 @@ export class McpServer { /** * Registers a tool `name` (with a description) taking either parameter schema or annotations. - * This unified overload handles both `tool(name, description, paramsSchema, cb)` and + * This unified overload handles both `tool(name, description, paramsSchema, cb)` and * `tool(name, description, annotations, cb)` cases. - * + * * Note: We use a union type for the third parameter because TypeScript cannot reliably disambiguate * between ToolAnnotations and ZodRawShape during overload resolution, as both are plain object types. */ @@ -928,6 +1022,8 @@ export class McpServer { inputSchema?: InputArgs; outputSchema?: OutputArgs; annotations?: ToolAnnotations; + groups?: string[]; + tags?: string[]; }, cb: ToolCallback ): RegisteredTool { @@ -935,7 +1031,7 @@ export class McpServer { throw new Error(`Tool ${name} is already registered`); } - const { title, description, inputSchema, outputSchema, annotations } = config; + const { title, description, inputSchema, outputSchema, annotations, groups, tags } = config; return this._createRegisteredTool( name, @@ -944,7 +1040,9 @@ export class McpServer { inputSchema, outputSchema, annotations, - cb as ToolCallback + cb as ToolCallback, + groups, + tags ); } @@ -1073,6 +1171,121 @@ export class McpServer { this.server.sendPromptListChanged(); } } + + /** + * Sends a group list changed event to the client, if connected. + */ + sendGroupListChanged() { + if (this.isConnected()) { + this.server.notification({ + method: "notifications/groups/list_changed" + }); + } + } + + /** + * Sends a tag list changed event to the client, if connected. + */ + sendTagListChanged() { + if (this.isConnected()) { + this.server.notification({ + method: "notifications/tags/list_changed" + }); + } + } + + /** + * Registers a group with the server. + * + * @param name The name of the group + * @param options Configuration for the group + */ + registerGroup( + name: string, + options: { + title: string; + description: string; + } + ) { + // Initialize group handlers if not already done + this.setGroupRequestHandlers(); + + // Create the group + const group: Group = { + name, + title: options.title, + description: options.description, + }; + + // Store the group + this._registeredGroups[name] = group; + + // Notify clients if connected + this.sendGroupListChanged(); + + return { + remove: () => { + delete this._registeredGroups[name]; + this.sendGroupListChanged(); + }, + update: (updates: Partial>) => { + const group = this._registeredGroups[name]; + if (group) { + if (updates.title !== undefined) { + // Ensure title is a string or undefined + group.title = typeof updates.title === 'string' ? updates.title : undefined; + } + if (updates.description !== undefined) { + // Ensure description is a string + group.description = typeof updates.description === 'string' ? updates.description : ''; + } + this.sendGroupListChanged(); + } + } + }; + } + + /** + * Registers a tag with the server. + * + * @param name The name of the tag + * @param options Configuration for the tag + */ + registerTag( + name: string, + options: { + description: string; + } + ) { + // Initialize tag handlers if not already done + this.setTagRequestHandlers(); + + // Create the tag + const tag: Tag = { + name, + description: options.description, + }; + + // Store the tag + this._registeredTags[name] = tag; + + // Notify clients if connected + this.sendTagListChanged(); + + return { + remove: () => { + delete this._registeredTags[name]; + this.sendTagListChanged(); + }, + update: (updates: Partial>) => { + const tag = this._registeredTags[name]; + if (tag) { + if (updates.description !== undefined) tag.description = updates.description; + this.sendTagListChanged(); + } + } + }; + } } /** @@ -1162,6 +1375,8 @@ export type RegisteredTool = { inputSchema?: AnyZodObject; outputSchema?: AnyZodObject; annotations?: ToolAnnotations; + groups?: string[]; + tags?: string[]; callback: ToolCallback; enabled: boolean; enable(): void; @@ -1174,6 +1389,8 @@ export type RegisteredTool = { paramsSchema?: InputArgs, outputSchema?: OutputArgs, annotations?: ToolAnnotations, + groups?: string[], + tags?: string[], callback?: ToolCallback, enabled?: boolean }): void diff --git a/src/types.ts b/src/types.ts index 323e37389..d20f73255 100644 --- a/src/types.ts +++ b/src/types.ts @@ -340,6 +340,41 @@ export const ServerCapabilitiesSchema = z }) .passthrough(), ), + /** + * Present if the server supports filtering tools, groups, and tags. + */ + filtering: z.optional( + z + .object({ + /** + * Group filtering capabilities + */ + groups: z.optional( + z + .object({ + /** + * Whether this server supports issuing notifications for changes to the groups list. + */ + listChanged: z.optional(z.boolean()), + }) + .passthrough(), + ), + /** + * Tag filtering capabilities + */ + tags: z.optional( + z + .object({ + /** + * Whether this server supports issuing notifications for changes to the tags list. + */ + listChanged: z.optional(z.boolean()), + }) + .passthrough(), + ), + }) + .passthrough(), + ), }) .passthrough(); @@ -839,6 +874,43 @@ export const PromptListChangedNotificationSchema = NotificationSchema.extend({ }); /* Tools */ +/** + * Definition for a group that contains tools. + */ +export const GroupSchema = BaseMetadataSchema.extend({ + /** + * A human-readable description of the group. + */ + description: z.string(), + + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ + _meta: z.optional(z.object({}).passthrough()), +}); + +/** + * Definition for a tag that can be attached to tools. + */ +export const TagSchema = z.object({ + /** + * The name of the tag. + */ + name: z.string(), + + /** + * A human-readable description of the tag. + */ + description: z.string(), + + /** + * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) + * for notes on _meta usage. + */ + _meta: z.optional(z.object({}).passthrough()), +}); + /** * Additional properties describing a Tool to clients. * @@ -930,6 +1002,16 @@ export const ToolSchema = BaseMetadataSchema.extend({ */ annotations: z.optional(ToolAnnotationsSchema), + /** + * Groups this tool belongs to. + */ + groups: z.optional(z.array(z.string())), + + /** + * Tags attached to this tool. + */ + tags: z.optional(z.array(z.string())), + /** * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) * for notes on _meta usage. @@ -937,11 +1019,38 @@ export const ToolSchema = BaseMetadataSchema.extend({ _meta: z.optional(z.object({}).passthrough()), }); +/** + * Filter parameters for tools/list request. + */ +export const ToolsFilterSchema = z.object({ + /** + * Only return tools belonging to ANY of these named groups. + */ + groups: z.optional(z.array(z.string())), + + /** + * Of the tools in the specified groups (or of all tools, if no groups were specified), + * only return tools tagged with ALL of these tags. + */ + tags: z.optional(z.array(z.string())), +}).optional(); + /** * Sent from the client to request a list of tools the server has. */ export const ListToolsRequestSchema = PaginatedRequestSchema.extend({ method: z.literal("tools/list"), + params: z.optional(BaseRequestParamsSchema.extend({ + /** + * An opaque token representing the current pagination position. + * If provided, the server should return results starting after this cursor. + */ + cursor: z.optional(CursorSchema), + /** + * Optional filter to narrow down the list of tools returned. + */ + filter: ToolsFilterSchema, + })), }); /** @@ -1014,6 +1123,50 @@ export const ToolListChangedNotificationSchema = NotificationSchema.extend({ method: z.literal("notifications/tools/list_changed"), }); +/** + * Sent from the client to request a list of groups the server has. + */ +export const ListGroupsRequestSchema = RequestSchema.extend({ + method: z.literal("groups/list"), + params: z.optional(BaseRequestParamsSchema.extend({})), +}); + +/** + * The server's response to a groups/list request from the client. + */ +export const ListGroupsResultSchema = ResultSchema.extend({ + groups: z.array(GroupSchema), +}); + +/** + * Sent from the client to request a list of tags the server has. + */ +export const ListTagsRequestSchema = RequestSchema.extend({ + method: z.literal("tags/list"), + params: z.optional(BaseRequestParamsSchema.extend({})), +}); + +/** + * The server's response to a tags/list request from the client. + */ +export const ListTagsResultSchema = ResultSchema.extend({ + tags: z.array(TagSchema), +}); + +/** + * An optional notification from the server to the client, informing it that the list of groups it offers has changed. + */ +export const GroupListChangedNotificationSchema = NotificationSchema.extend({ + method: z.literal("notifications/groups/list_changed"), +}); + +/** + * An optional notification from the server to the client, informing it that the list of tags it offers has changed. + */ +export const TagListChangedNotificationSchema = NotificationSchema.extend({ + method: z.literal("notifications/tags/list_changed"), +}); + /* Logging */ /** * The severity of a log message. @@ -1590,14 +1743,23 @@ export type GetPromptResult = Infer; export type PromptListChangedNotification = Infer; /* Tools */ +export type Group = Infer; +export type Tag = Infer; export type ToolAnnotations = Infer; export type Tool = Infer; +export type ToolsFilter = Infer; export type ListToolsRequest = Infer; export type ListToolsResult = Infer; export type CallToolResult = Infer; export type CompatibilityCallToolResult = Infer; export type CallToolRequest = Infer; export type ToolListChangedNotification = Infer; +export type ListGroupsRequest = Infer; +export type ListGroupsResult = Infer; +export type ListTagsRequest = Infer; +export type ListTagsResult = Infer; +export type GroupListChangedNotification = Infer; +export type TagListChangedNotification = Infer; /* Logging */ export type LoggingLevel = Infer;