From ff797b4ab9867a55114c4ce4d7562d1326c37903 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 26 Oct 2025 20:51:55 +0800 Subject: [PATCH] Add group-scoped smart routing via $smart/{group} pattern (#388) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com> --- README.md | 26 ++ README.zh.md | 26 ++ docs/features/smart-routing.mdx | 75 ++++++ src/services/mcpService.ts | 68 ++++-- .../mcpService-smart-routing-group.test.ts | 222 ++++++++++++++++++ 5 files changed, 401 insertions(+), 16 deletions(-) create mode 100644 tests/services/mcpService-smart-routing-group.test.ts diff --git a/README.md b/README.md index ed09975..b1017df 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,11 @@ This endpoint provides a unified streamable HTTP interface for all your MCP serv Smart Routing is MCPHub's intelligent tool discovery system that uses vector semantic search to automatically find the most relevant tools for any given task. ``` +# Search across all servers http://localhost:3000/mcp/$smart + +# Search within a specific group +http://localhost:3000/mcp/$smart/{group} ``` **How it Works:** @@ -156,6 +160,7 @@ http://localhost:3000/mcp/$smart 2. **Semantic Search**: User queries are converted to vectors and matched against tool embeddings using cosine similarity 3. **Intelligent Filtering**: Dynamic thresholds ensure relevant results without noise 4. **Precise Execution**: Found tools can be directly executed with proper parameter validation +5. **Group Scoping**: Optionally limit searches to servers within a specific group for focused results **Setup Requirements:** @@ -167,6 +172,23 @@ To enable Smart Routing, you need: - OpenAI API key (or compatible embedding service) - Enable Smart Routing in MCPHub settings +**Group-Scoped Smart Routing**: + +You can combine Smart Routing with group filtering to search only within specific server groups: + +``` +# Search only within production servers +http://localhost:3000/mcp/$smart/production + +# Search only within development servers +http://localhost:3000/mcp/$smart/development +``` + +This enables: +- **Focused Discovery**: Find tools only from relevant servers +- **Environment Isolation**: Separate tool discovery by environment (dev, staging, prod) +- **Team-Based Access**: Limit tool search to team-specific server groups + **Group-Specific Endpoints (Recommended)**: ![Group Management](assets/group.png) @@ -205,7 +227,11 @@ http://localhost:3000/sse For smart routing, use: ``` +# Search across all servers http://localhost:3000/sse/$smart + +# Search within a specific group +http://localhost:3000/sse/$smart/{group} ``` For targeted access to specific server groups, use the group-based SSE endpoint: diff --git a/README.zh.md b/README.zh.md index 3efe652..7327401 100644 --- a/README.zh.md +++ b/README.zh.md @@ -145,7 +145,11 @@ http://localhost:3000/mcp 智能路由是 MCPHub 的智能工具发现系统,使用向量语义搜索自动为任何给定任务找到最相关的工具。 ``` +# 在所有服务器中搜索 http://localhost:3000/mcp/$smart + +# 在特定分组中搜索 +http://localhost:3000/mcp/$smart/{group} ``` **工作原理:** @@ -154,6 +158,7 @@ http://localhost:3000/mcp/$smart 2. **语义搜索**:用户查询转换为向量并使用余弦相似度与工具嵌入匹配 3. **智能筛选**:动态阈值确保相关结果且无噪声 4. **精确执行**:找到的工具可以直接执行并进行适当的参数验证 +5. **分组限定**:可选择将搜索限制在特定分组的服务器内以获得更精确的结果 **设置要求:** @@ -165,6 +170,23 @@ http://localhost:3000/mcp/$smart - OpenAI API 密钥(或兼容的嵌入服务) - 在 MCPHub 设置中启用智能路由 +**分组限定的智能路由**: + +您可以将智能路由与分组筛选结合使用,仅在特定服务器分组内搜索: + +``` +# 仅在生产服务器中搜索 +http://localhost:3000/mcp/$smart/production + +# 仅在开发服务器中搜索 +http://localhost:3000/mcp/$smart/development +``` + +这样可以实现: +- **精准发现**:仅从相关服务器查找工具 +- **环境隔离**:按环境(开发、测试、生产)分离工具发现 +- **基于团队的访问**:将工具搜索限制在特定团队的服务器分组 + **基于分组的 HTTP 端点(推荐)**: ![分组](assets/group.zh.png) 要针对特定服务器分组进行访问,请使用基于分组的 HTTP 端点: @@ -203,7 +225,11 @@ http://localhost:3000/sse 要启用智能路由,请使用: ``` +# 在所有服务器中搜索 http://localhost:3000/sse/$smart + +# 在特定分组中搜索 +http://localhost:3000/sse/$smart/{group} ``` 要针对特定服务器分组进行访问,请使用基于分组的 SSE 端点: diff --git a/docs/features/smart-routing.mdx b/docs/features/smart-routing.mdx index 702893c..4eea7bc 100644 --- a/docs/features/smart-routing.mdx +++ b/docs/features/smart-routing.mdx @@ -276,17 +276,92 @@ Access Smart Routing through the special `$smart` endpoint: ``` + # Search across all servers http://localhost:3000/mcp/$smart + + # Search within a specific group + http://localhost:3000/mcp/$smart/{group} ``` ``` + # Search across all servers http://localhost:3000/sse/$smart + + # Search within a specific group + http://localhost:3000/sse/$smart/{group} ``` +### Group-Scoped Smart Routing + +Smart Routing now supports group-scoped searches, allowing you to limit tool discovery to servers within a specific group: + + + + Connect your AI client to a group-specific Smart Routing endpoint: + + ``` + http://localhost:3000/mcp/$smart/production + ``` + + This endpoint will only search for tools within servers that belong to the "production" group. + + **Benefits:** + - **Focused Results**: Only tools from relevant servers are returned + - **Better Performance**: Reduced search space for faster queries + - **Environment Isolation**: Keep development, staging, and production tools separate + - **Access Control**: Limit tool discovery based on user permissions + + + + Create groups for different environments: + + ```bash + # Development environment + http://localhost:3000/mcp/$smart/development + + # Staging environment + http://localhost:3000/mcp/$smart/staging + + # Production environment + http://localhost:3000/mcp/$smart/production + ``` + + Each endpoint will only return tools from servers in that specific environment group. + + + + Organize tools by team or department: + + ```bash + # Backend team tools + http://localhost:3000/mcp/$smart/backend-team + + # Frontend team tools + http://localhost:3000/mcp/$smart/frontend-team + + # DevOps team tools + http://localhost:3000/mcp/$smart/devops-team + ``` + + This enables teams to have focused access to their relevant toolsets. + + + + When using `$smart/{group}`: + + 1. The system identifies the specified group + 2. Retrieves all servers belonging to that group + 3. Filters the tool search to only those servers + 4. Returns results scoped to the group's servers + + If the group doesn't exist or has no servers, the search will return no results. + + + {/* ### Basic Usage Connect your AI client to the Smart Routing endpoint and make natural language requests: diff --git a/src/services/mcpService.ts b/src/services/mcpService.ts index 3510a60..85a6729 100644 --- a/src/services/mcpService.ts +++ b/src/services/mcpService.ts @@ -889,30 +889,48 @@ export const handleListToolsRequest = async (_: any, extra: any) => { console.log(`Handling ListToolsRequest for group: ${group}`); // Special handling for $smart group to return special tools - if (group === '$smart') { + // Support both $smart and $smart/{group} patterns + if (group === '$smart' || group?.startsWith('$smart/')) { + // Extract target group if pattern is $smart/{group} + const targetGroup = group?.startsWith('$smart/') ? group.substring(7) : undefined; + + // Get info about available servers, filtered by target group if specified + let availableServers = serverInfos.filter( + (server) => server.status === 'connected' && server.enabled !== false, + ); + + // If a target group is specified, filter servers to only those in the group + if (targetGroup) { + const serversInGroup = getServersInGroup(targetGroup); + if (serversInGroup && serversInGroup.length > 0) { + availableServers = availableServers.filter((server) => + serversInGroup.includes(server.name), + ); + } + } + + // Create simple server information with only server names + const serversList = availableServers + .map((server) => { + return `${server.name}`; + }) + .join(', '); + + const scopeDescription = targetGroup + ? `servers in the "${targetGroup}" group` + : 'all available servers'; + return { tools: [ { name: 'search_tools', - description: (() => { - // Get info about available servers - const availableServers = serverInfos.filter( - (server) => server.status === 'connected' && server.enabled !== false, - ); - // Create simple server information with only server names - const serversList = availableServers - .map((server) => { - return `${server.name}`; - }) - .join(', '); - return `STEP 1 of 2: Use this tool FIRST to discover and search for relevant tools across all available servers. This tool and call_tool work together as a two-step process: 1) search_tools to find what you need, 2) call_tool to execute it. + description: `STEP 1 of 2: Use this tool FIRST to discover and search for relevant tools across ${scopeDescription}. This tool and call_tool work together as a two-step process: 1) search_tools to find what you need, 2) call_tool to execute it. For optimal results, use specific queries matching your exact needs. Call this tool multiple times with different queries for different parts of complex tasks. Example queries: "image generation tools", "code review tools", "data analysis", "translation capabilities", etc. Results are sorted by relevance using vector similarity. After finding relevant tools, you MUST use the call_tool to actually execute them. The search_tools only finds tools - it doesn't execute them. -Available servers: ${serversList}`; - })(), +Available servers: ${serversList}`, inputSchema: { type: 'object', properties: { @@ -1029,7 +1047,25 @@ export const handleCallToolRequest = async (request: any, extra: any) => { } console.log(`Using similarity threshold: ${thresholdNum} for query: "${query}"`); - const servers = undefined; // No server filtering + + // Determine server filtering based on group + const sessionId = extra.sessionId || ''; + const group = getGroup(sessionId); + let servers: string[] | undefined = undefined; // No server filtering by default + + // If group is in format $smart/{group}, filter servers to that group + if (group?.startsWith('$smart/')) { + const targetGroup = group.substring(7); + const serversInGroup = getServersInGroup(targetGroup); + if (serversInGroup !== undefined && serversInGroup !== null) { + servers = serversInGroup; + if (servers.length > 0) { + console.log(`Filtering search to servers in group "${targetGroup}": ${servers.join(', ')}`); + } else { + console.log(`Group "${targetGroup}" has no servers, search will return no results`); + } + } + } const searchResults = await searchToolsByVector(query, limitNum, thresholdNum, servers); console.log(`Search results: ${JSON.stringify(searchResults)}`); diff --git a/tests/services/mcpService-smart-routing-group.test.ts b/tests/services/mcpService-smart-routing-group.test.ts new file mode 100644 index 0000000..f0a4317 --- /dev/null +++ b/tests/services/mcpService-smart-routing-group.test.ts @@ -0,0 +1,222 @@ +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; + +// Mock dependencies before importing mcpService +jest.mock('../../src/services/oauthService.js', () => ({ + initializeAllOAuthClients: jest.fn(), +})); + +jest.mock('../../src/services/oauthClientRegistration.js', () => ({ + registerOAuthClient: jest.fn(), +})); + +jest.mock('../../src/services/mcpOAuthProvider.js', () => ({ + createOAuthProvider: jest.fn(), +})); + +jest.mock('../../src/services/groupService.js', () => ({ + getServersInGroup: jest.fn((groupId: string) => { + if (groupId === 'test-group') { + return ['server1', 'server2']; + } + if (groupId === 'empty-group') { + return []; + } + return undefined; + }), + getServerConfigInGroup: jest.fn(), +})); + +jest.mock('../../src/services/sseService.js', () => ({ + getGroup: jest.fn((sessionId: string) => { + if (sessionId === 'session-smart') return '$smart'; + if (sessionId === 'session-smart-group') return '$smart/test-group'; + if (sessionId === 'session-smart-empty') return '$smart/empty-group'; + return ''; + }), +})); + +jest.mock('../../src/dao/index.js', () => ({ + getServerDao: jest.fn(() => ({ + findById: jest.fn(), + findAll: jest.fn(() => Promise.resolve([])), + })), +})); + +jest.mock('../../src/services/services.js', () => ({ + getDataService: jest.fn(() => ({ + filterData: (data: any) => data, + })), +})); + +jest.mock('../../src/services/vectorSearchService.js', () => ({ + searchToolsByVector: jest.fn(), + saveToolsAsVectorEmbeddings: jest.fn(), +})); + +jest.mock('../../src/config/index.js', () => ({ + loadSettings: jest.fn(), + expandEnvVars: jest.fn((val: string) => val), + replaceEnvVars: jest.fn((val: any) => val), + getNameSeparator: jest.fn(() => '::'), + default: { + mcpHubName: 'test-hub', + mcpHubVersion: '1.0.0', + }, +})); + +// Import after mocks are set up +import { handleListToolsRequest, handleCallToolRequest } from '../../src/services/mcpService.js'; +import { getServersInGroup } from '../../src/services/groupService.js'; +import { getGroup } from '../../src/services/sseService.js'; +import { searchToolsByVector } from '../../src/services/vectorSearchService.js'; + +describe('MCP Service - Smart Routing with Group Support', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('handleListToolsRequest', () => { + it('should return search_tools and call_tool for $smart group', async () => { + const result = await handleListToolsRequest({}, { sessionId: 'session-smart' }); + + expect(result.tools).toHaveLength(2); + expect(result.tools[0].name).toBe('search_tools'); + expect(result.tools[1].name).toBe('call_tool'); + expect(result.tools[0].description).toContain('all available servers'); + }); + + it('should return filtered tools for $smart/{group} pattern', async () => { + const result = await handleListToolsRequest({}, { sessionId: 'session-smart-group' }); + + expect(getGroup).toHaveBeenCalledWith('session-smart-group'); + expect(getServersInGroup).toHaveBeenCalledWith('test-group'); + + expect(result.tools).toHaveLength(2); + expect(result.tools[0].name).toBe('search_tools'); + expect(result.tools[1].name).toBe('call_tool'); + expect(result.tools[0].description).toContain('servers in the "test-group" group'); + }); + + it('should handle $smart with empty group', async () => { + const result = await handleListToolsRequest({}, { sessionId: 'session-smart-empty' }); + + expect(getGroup).toHaveBeenCalledWith('session-smart-empty'); + expect(getServersInGroup).toHaveBeenCalledWith('empty-group'); + + expect(result.tools).toHaveLength(2); + expect(result.tools[0].name).toBe('search_tools'); + expect(result.tools[1].name).toBe('call_tool'); + // Should still show group-scoped message even if group is empty + expect(result.tools[0].description).toContain('servers in the "empty-group" group'); + }); + }); + + describe('handleCallToolRequest - search_tools', () => { + it('should search across all servers when using $smart', async () => { + const mockSearchResults = [ + { + serverName: 'server1', + toolName: 'server1::tool1', + description: 'Test tool 1', + inputSchema: {}, + }, + ]; + (searchToolsByVector as jest.Mock).mockResolvedValue(mockSearchResults); + + const request = { + params: { + name: 'search_tools', + arguments: { + query: 'test query', + limit: 10, + }, + }, + }; + + await handleCallToolRequest(request, { sessionId: 'session-smart' }); + + expect(searchToolsByVector).toHaveBeenCalledWith( + 'test query', + 10, + expect.any(Number), + undefined, // No server filtering + ); + }); + + it('should filter servers when using $smart/{group}', async () => { + const mockSearchResults = [ + { + serverName: 'server1', + toolName: 'server1::tool1', + description: 'Test tool 1', + inputSchema: {}, + }, + ]; + (searchToolsByVector as jest.Mock).mockResolvedValue(mockSearchResults); + + const request = { + params: { + name: 'search_tools', + arguments: { + query: 'test query', + limit: 10, + }, + }, + }; + + await handleCallToolRequest(request, { sessionId: 'session-smart-group' }); + + expect(getGroup).toHaveBeenCalledWith('session-smart-group'); + expect(getServersInGroup).toHaveBeenCalledWith('test-group'); + expect(searchToolsByVector).toHaveBeenCalledWith( + 'test query', + 10, + expect.any(Number), + ['server1', 'server2'], // Filtered to group servers + ); + }); + + it('should handle empty group in $smart/{group}', async () => { + const mockSearchResults: any[] = []; + (searchToolsByVector as jest.Mock).mockResolvedValue(mockSearchResults); + + const request = { + params: { + name: 'search_tools', + arguments: { + query: 'test query', + limit: 10, + }, + }, + }; + + await handleCallToolRequest(request, { sessionId: 'session-smart-empty' }); + + expect(getGroup).toHaveBeenCalledWith('session-smart-empty'); + expect(getServersInGroup).toHaveBeenCalledWith('empty-group'); + // Empty group returns empty array, which should still be passed to search + expect(searchToolsByVector).toHaveBeenCalledWith( + 'test query', + 10, + expect.any(Number), + [], // Empty group + ); + }); + + it('should validate query parameter', async () => { + const request = { + params: { + name: 'search_tools', + arguments: { + limit: 10, + }, + }, + }; + + const result = await handleCallToolRequest(request, { sessionId: 'session-smart' }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Query parameter is required'); + }); + }); +});