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>
This commit is contained in:
Copilot
2025-10-26 20:51:55 +08:00
committed by GitHub
parent 9105507722
commit ff797b4ab9
5 changed files with 401 additions and 16 deletions

View File

@@ -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:

View File

@@ -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 端点:

View File

@@ -276,17 +276,92 @@ Access Smart Routing through the special `$smart` endpoint:
<Tabs>
<Tab title="HTTP MCP">
```
# Search across all servers
http://localhost:3000/mcp/$smart
# Search within a specific group
http://localhost:3000/mcp/$smart/{group}
```
</Tab>
<Tab title="SSE (Legacy)">
```
# Search across all servers
http://localhost:3000/sse/$smart
# Search within a specific group
http://localhost:3000/sse/$smart/{group}
```
</Tab>
</Tabs>
### Group-Scoped Smart Routing
Smart Routing now supports group-scoped searches, allowing you to limit tool discovery to servers within a specific group:
<AccordionGroup>
<Accordion title="Using Group-Scoped Smart Routing">
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
</Accordion>
<Accordion title="Example: Environment-Based Groups">
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.
</Accordion>
<Accordion title="Example: Team-Based Groups">
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.
</Accordion>
<Accordion title="How It Works">
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.
</Accordion>
</AccordionGroup>
{/* ### Basic Usage
Connect your AI client to the Smart Routing endpoint and make natural language requests:

View File

@@ -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)}`);

View File

@@ -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');
});
});
});