diff --git a/docs/features/smart-routing.mdx b/docs/features/smart-routing.mdx index f92de11..f85294d 100644 --- a/docs/features/smart-routing.mdx +++ b/docs/features/smart-routing.mdx @@ -362,6 +362,129 @@ Smart Routing now supports group-scoped searches, allowing you to limit tool dis +### Progressive Disclosure Mode + +Progressive Disclosure is an optimization feature that reduces token usage when working with Smart Routing. When enabled, the tool discovery workflow changes from a 2-step to a 3-step process. + + + + By default, Smart Routing returns full tool information including complete parameter schemas in `search_tools` results. This can consume significant tokens when dealing with tools that have complex input schemas. + + **Progressive Disclosure** changes this behavior: + - `search_tools` returns only tool names and descriptions (minimal info) + - A new `describe_tool` endpoint provides full parameter schema on demand + - `call_tool` executes the tool as before + + This approach is particularly useful when: + - Working with many tools with complex schemas + - Token usage optimization is important + - AI clients need to browse many tools before selecting one + + + + Enable Progressive Disclosure through the Settings page or environment variable: + + **Via Settings UI:** + 1. Navigate to Settings → Smart Routing + 2. Enable the "Progressive Disclosure" toggle + 3. The change takes effect immediately + + **Via Environment Variable:** + ```bash + SMART_ROUTING_PROGRESSIVE_DISCLOSURE=true + ``` + + + + When Progressive Disclosure is **disabled** (default), Smart Routing provides two tools: + + **Workflow:** `search_tools` → `call_tool` + + | Tool | Purpose | + |------|---------| + | `search_tools` | Find tools by query, returns full tool info including `inputSchema` | + | `call_tool` | Execute a tool with the provided arguments | + + This mode is simpler but uses more tokens due to full schemas in search results. + + + + When Progressive Disclosure is **enabled**, Smart Routing provides three tools: + + **Workflow:** `search_tools` → `describe_tool` → `call_tool` + + | Tool | Purpose | + |------|---------| + | `search_tools` | Find tools by query, returns only name and description | + | `describe_tool` | Get full schema for a specific tool (new) | + | `call_tool` | Execute a tool with the provided arguments | + + **Example workflow:** + 1. AI calls `search_tools` with query "file operations" + 2. Results show tool names and descriptions (minimal tokens) + 3. AI calls `describe_tool` for a specific tool to get full `inputSchema` + 4. AI calls `call_tool` with the correct arguments + + This mode reduces token usage by only fetching full schemas when needed. + + + + **Standard Mode search_tools response:** + ```json + { + "tools": [ + { + "name": "read_file", + "description": "Read contents of a file", + "inputSchema": { + "type": "object", + "properties": { + "path": { "type": "string", "description": "File path to read" }, + "encoding": { "type": "string", "default": "utf-8" } + }, + "required": ["path"] + } + } + ] + } + ``` + + **Progressive Disclosure Mode search_tools response:** + ```json + { + "tools": [ + { + "name": "read_file", + "description": "Read contents of a file" + } + ], + "metadata": { + "progressiveDisclosure": true, + "guideline": "Use describe_tool to get the full parameter schema before calling." + } + } + ``` + + **describe_tool response:** + ```json + { + "tool": { + "name": "read_file", + "description": "Read contents of a file", + "inputSchema": { + "type": "object", + "properties": { + "path": { "type": "string", "description": "File path to read" }, + "encoding": { "type": "string", "default": "utf-8" } + }, + "required": ["path"] + } + } + } + ``` + + + {/* ### Basic Usage Connect your AI client to the Smart Routing endpoint and make natural language requests: diff --git a/docs/zh/features/smart-routing.mdx b/docs/zh/features/smart-routing.mdx index 40e9b64..2de0a7b 100644 --- a/docs/zh/features/smart-routing.mdx +++ b/docs/zh/features/smart-routing.mdx @@ -64,17 +64,181 @@ description: '使用向量语义搜索的 AI 工具发现系统' ``` + # 搜索所有服务器 http://localhost:3000/mcp/$smart + + # 在特定分组内搜索 + http://localhost:3000/mcp/$smart/{group} ``` ``` + # 搜索所有服务器 http://localhost:3000/sse/$smart + + # 在特定分组内搜索 + http://localhost:3000/sse/$smart/{group} ``` +### 分组范围的智能路由 + +智能路由支持分组范围的搜索,允许您将工具发现限制在特定分组内的服务器: + + + + 将您的 AI 客户端连接到特定分组的智能路由端点: + + ``` + http://localhost:3000/mcp/$smart/production + ``` + + 此端点只会搜索属于 "production" 分组的服务器中的工具。 + + **优势:** + - **聚焦结果**:只返回相关服务器的工具 + - **更好的性能**:减少搜索空间以加快查询速度 + - **环境隔离**:将开发、预发布和生产工具分开 + - **访问控制**:根据用户权限限制工具发现 + + + + 当使用 `$smart/{group}` 时: + + 1. 系统识别指定的分组 + 2. 获取属于该分组的所有服务器 + 3. 将工具搜索过滤到仅限那些服务器 + 4. 返回限定在该分组服务器范围内的结果 + + 如果分组不存在或没有服务器,搜索将不返回任何结果。 + + + +### 渐进式披露模式 + +渐进式披露是一个优化功能,可以减少使用智能路由时的 Token 消耗。启用后,工具发现工作流从 2 步变为 3 步。 + + + + 默认情况下,智能路由在 `search_tools` 结果中返回完整的工具信息,包括完整的参数模式。当处理具有复杂输入模式的工具时,这会消耗大量 Token。 + + **渐进式披露** 改变了这种行为: + - `search_tools` 只返回工具名称和描述(最少信息) + - 新的 `describe_tool` 端点按需提供完整的参数模式 + - `call_tool` 像以前一样执行工具 + + 这种方法特别适用于: + - 处理具有复杂模式的大量工具 + - Token 使用优化很重要的场景 + - AI 客户端需要浏览多个工具后再选择 + + + + 通过设置页面或环境变量启用渐进式披露: + + **通过设置界面:** + 1. 导航到 设置 → 智能路由 + 2. 启用"渐进式披露"开关 + 3. 更改立即生效 + + **通过环境变量:** + ```bash + SMART_ROUTING_PROGRESSIVE_DISCLOSURE=true + ``` + + + + 当渐进式披露**禁用**(默认)时,智能路由提供两个工具: + + **工作流:** `search_tools` → `call_tool` + + | 工具 | 用途 | + |------|------| + | `search_tools` | 按查询查找工具,返回包含 `inputSchema` 的完整工具信息 | + | `call_tool` | 使用提供的参数执行工具 | + + 这种模式更简单,但由于搜索结果中包含完整模式,会使用更多 Token。 + + + + 当渐进式披露**启用**时,智能路由提供三个工具: + + **工作流:** `search_tools` → `describe_tool` → `call_tool` + + | 工具 | 用途 | + |------|------| + | `search_tools` | 按查询查找工具,只返回名称和描述 | + | `describe_tool` | 获取特定工具的完整模式(新增) | + | `call_tool` | 使用提供的参数执行工具 | + + **示例工作流:** + 1. AI 使用查询 "文件操作" 调用 `search_tools` + 2. 结果显示工具名称和描述(最少 Token) + 3. AI 为特定工具调用 `describe_tool` 获取完整的 `inputSchema` + 4. AI 使用正确的参数调用 `call_tool` + + 这种模式通过仅在需要时获取完整模式来减少 Token 使用。 + + + + **标准模式 search_tools 响应:** + ```json + { + "tools": [ + { + "name": "read_file", + "description": "读取文件内容", + "inputSchema": { + "type": "object", + "properties": { + "path": { "type": "string", "description": "要读取的文件路径" }, + "encoding": { "type": "string", "default": "utf-8" } + }, + "required": ["path"] + } + } + ] + } + ``` + + **渐进式披露模式 search_tools 响应:** + ```json + { + "tools": [ + { + "name": "read_file", + "description": "读取文件内容" + } + ], + "metadata": { + "progressiveDisclosure": true, + "guideline": "使用 describe_tool 获取完整的参数模式后再调用。" + } + } + ``` + + **describe_tool 响应:** + ```json + { + "tool": { + "name": "read_file", + "description": "读取文件内容", + "inputSchema": { + "type": "object", + "properties": { + "path": { "type": "string", "description": "要读取的文件路径" }, + "encoding": { "type": "string", "default": "utf-8" } + }, + "required": ["path"] + } + } + } + ``` + + + {/* ## 性能优化 ### 嵌入缓存 diff --git a/frontend/src/contexts/SettingsContext.tsx b/frontend/src/contexts/SettingsContext.tsx index 49153ca..0f26686 100644 --- a/frontend/src/contexts/SettingsContext.tsx +++ b/frontend/src/contexts/SettingsContext.tsx @@ -33,6 +33,7 @@ interface SmartRoutingConfig { openaiApiBaseUrl: string; openaiApiKey: string; openaiApiEmbeddingModel: string; + progressiveDisclosure: boolean; } interface MCPRouterConfig { @@ -180,6 +181,7 @@ export const SettingsProvider: React.FC = ({ children }) openaiApiBaseUrl: '', openaiApiKey: '', openaiApiEmbeddingModel: '', + progressiveDisclosure: false, }); const [mcpRouterConfig, setMCPRouterConfig] = useState({ @@ -238,6 +240,7 @@ export const SettingsProvider: React.FC = ({ children }) openaiApiKey: data.data.systemConfig.smartRouting.openaiApiKey || '', openaiApiEmbeddingModel: data.data.systemConfig.smartRouting.openaiApiEmbeddingModel || '', + progressiveDisclosure: data.data.systemConfig.smartRouting.progressiveDisclosure ?? false, }); } if (data.success && data.data?.systemConfig?.mcpRouter) { diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index c7acaeb..f294757 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -1425,6 +1425,24 @@ const SettingsPage: React.FC = () => { + + + + {t('settings.progressiveDisclosure')} + + + {t('settings.progressiveDisclosureDescription')} + + + + updateSmartRoutingConfig('progressiveDisclosure', checked) + } + /> + + const paginatedResult = isAdmin ? await serverDao.findAllPaginated(page, limit) : await serverDao.findByOwnerPaginated(currentUser!.username, page, limit); - + // Get runtime info for paginated servers serversInfo = await getServersInfo(page, limit, currentUser); - + pagination = { page: paginatedResult.page, limit: paginatedResult.limit, @@ -906,7 +906,8 @@ export const updateSystemConfig = async (req: Request, res: Response): Promise => { // Register all tools from upstream servers await registerAllTools(true); + + // Initialize smart routing service with references to mcpService functions + initSmartRoutingService(() => serverInfos, filterToolsByConfig, filterToolsByGroup); }; export const getMcpServer = (sessionId?: string, group?: string): Server => { @@ -1081,97 +1091,10 @@ export const handleListToolsRequest = async (_: any, extra: any) => { const group = getGroup(sessionId); console.log(`Handling ListToolsRequest for group: ${group}`); - // Special handling for $smart group to return special tools + // Special handling for $smart group to return smart routing tools // 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 = await 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: `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}`, - inputSchema: { - type: 'object', - properties: { - query: { - type: 'string', - description: - 'The search query to find relevant tools. Be specific and descriptive about the task you want to accomplish.', - }, - limit: { - type: 'integer', - description: - 'Maximum number of results to return. Use higher values (20-30) for broad searches and lower values (5-10) for specific searches.', - default: 10, - }, - }, - required: ['query'], - }, - annotations: { - title: 'Search Tools', - readOnlyHint: true, - }, - }, - { - name: 'call_tool', - description: - "STEP 2 of 2: Use this tool AFTER search_tools to actually execute/invoke any tool you found. This is the execution step - search_tools finds tools, call_tool runs them.\n\nWorkflow: search_tools → examine results → call_tool with the chosen tool name and required arguments.\n\nIMPORTANT: Always check the tool's inputSchema from search_tools results before invoking to ensure you provide the correct arguments. The search results will show you exactly what parameters each tool expects.", - inputSchema: { - type: 'object', - properties: { - toolName: { - type: 'string', - description: 'The exact name of the tool to invoke (from search_tools results)', - }, - arguments: { - type: 'object', - description: - 'The arguments to pass to the tool based on its inputSchema (optional if tool requires no arguments)', - }, - }, - required: ['toolName'], - }, - annotations: { - title: 'Call Tool', - openWorldHint: true, - }, - }, - ], - }; + if (isSmartRoutingGroup(group)) { + return getSmartRoutingTools(group); } // Need to filter servers based on group asynchronously @@ -1223,146 +1146,18 @@ Available servers: ${serversList}`, export const handleCallToolRequest = async (request: any, extra: any) => { console.log(`Handling CallToolRequest for tool: ${JSON.stringify(request.params)}`); try { - // Special handling for agent group tools + // Special handling for smart routing tools if (request.params.name === 'search_tools') { const { query, limit = 10 } = request.params.arguments || {}; - - if (!query || typeof query !== 'string') { - throw new Error('Query parameter is required and must be a string'); - } - - const limitNum = Math.min(Math.max(parseInt(String(limit)) || 10, 1), 100); - - // Dynamically adjust threshold based on query characteristics - let thresholdNum = 0.3; // Default threshold - - // For more general queries, use a lower threshold to get more diverse results - if (query.length < 10 || query.split(' ').length <= 2) { - thresholdNum = 0.2; - } - - // For very specific queries, use a higher threshold for more precise results - if (query.length > 30 || query.includes('specific') || query.includes('exact')) { - thresholdNum = 0.4; - } - - console.log(`Using similarity threshold: ${thresholdNum} for query: "${query}"`); - - // Determine server filtering based on group const sessionId = extra.sessionId || ''; - let group = getGroup(sessionId); - let servers: string[] | undefined = undefined; // No server filtering by default + return await handleSearchToolsRequest(query, limit, sessionId); + } - // If group is in format $smart/{group}, filter servers to that group - if (group?.startsWith('$smart/')) { - const targetGroup = group.substring(7); - if (targetGroup) { - group = targetGroup; - } - const serversInGroup = await getServersInGroup(targetGroup); - if (serversInGroup !== undefined && serversInGroup !== null) { - servers = serversInGroup; - if (servers && 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)}`); - // Find actual tool information from serverInfos by serverName and toolName - // First resolve all tool promises - const resolvedTools = await Promise.all( - searchResults.map(async (result) => { - // Find the server in serverInfos - const server = serverInfos.find( - (serverInfo) => - serverInfo.name === result.serverName && - serverInfo.status === 'connected' && - serverInfo.enabled !== false, - ); - if (server && server.tools && server.tools.length > 0) { - // Find the tool in server.tools - const actualTool = server.tools.find((tool) => tool.name === result.toolName); - if (actualTool) { - // Check if the tool is enabled in configuration - const tools = await filterToolsByConfig(server.name, [actualTool]); - if (tools.length > 0) { - // Apply custom description from configuration - const serverConfig = await getServerDao().findById(server.name); - const toolConfig = serverConfig?.tools?.[actualTool.name]; - - // Return the actual tool info from serverInfos with custom description - return { - ...actualTool, - description: toolConfig?.description || actualTool.description, - serverName: result.serverName, // Add serverName for filtering - }; - } - } - } - - // Fallback to search result if server or tool not found or disabled - return { - name: result.toolName, - description: result.description || '', - inputSchema: cleanInputSchema(result.inputSchema || {}), - serverName: result.serverName, // Add serverName for filtering - }; - }), - ); - - // Now filter the resolved tools - const filterResults = await Promise.all( - resolvedTools.map(async (tool) => { - if (tool.name) { - const serverName = tool.serverName; - if (serverName) { - let tools = await filterToolsByConfig(serverName, [tool as Tool]); - if (tools.length === 0) { - return false; - } - - tools = await filterToolsByGroup(group, serverName, tools); - return tools.length > 0; - } - } - return true; - }), - ); - const tools = resolvedTools.filter((_, i) => filterResults[i]); - - // Add usage guidance to the response - const response = { - tools, - metadata: { - query: query, - threshold: thresholdNum, - totalResults: tools.length, - guideline: - tools.length > 0 - ? "Found relevant tools. If these tools don't match exactly what you need, try another search with more specific keywords." - : 'No tools found. Try broadening your search or using different keywords.', - nextSteps: - tools.length > 0 - ? 'To use a tool, call call_tool with the toolName and required arguments.' - : 'Consider searching for related capabilities or more general terms.', - }, - }; - - // Return in the same format as handleListToolsRequest - return { - content: [ - { - type: 'text', - text: JSON.stringify(response), - }, - ], - }; + // Special handling for describe_tool (progressive disclosure mode) + if (request.params.name === 'describe_tool') { + const { toolName } = request.params.arguments || {}; + const sessionId = extra.sessionId || ''; + return await handleDescribeToolRequest(toolName, sessionId); } // Special handling for call_tool diff --git a/src/services/smartRoutingService.ts b/src/services/smartRoutingService.ts new file mode 100644 index 0000000..6b4b1d0 --- /dev/null +++ b/src/services/smartRoutingService.ts @@ -0,0 +1,525 @@ +/** + * Smart Routing Service + * + * This service handles the $smart routing functionality, which provides + * AI-powered tool discovery using vector semantic search. + */ + +import { Tool, ServerInfo } from '../types/index.js'; +import { getServersInGroup } from './groupService.js'; +import { searchToolsByVector } from './vectorSearchService.js'; +import { getSmartRoutingConfig } from '../utils/smartRouting.js'; +import { getServerDao } from '../dao/index.js'; +import { getGroup } from './sseService.js'; + +// Reference to serverInfos from mcpService - will be set via init +let serverInfosRef: ServerInfo[] = []; +let getServerInfosFn: () => ServerInfo[] = () => serverInfosRef; +let filterToolsByConfigFn: (serverName: string, tools: Tool[]) => Promise; +let filterToolsByGroupFn: ( + group: string | undefined, + serverName: string, + tools: Tool[], +) => Promise; + +/** + * Initialize the smart routing service with references to mcpService functions + */ +export const initSmartRoutingService = ( + getServerInfos: () => ServerInfo[], + filterToolsByConfig: (serverName: string, tools: Tool[]) => Promise, + filterToolsByGroup: ( + group: string | undefined, + serverName: string, + tools: Tool[], + ) => Promise, +) => { + // Store the getter to avoid stale references while staying ESM-safe + getServerInfosFn = getServerInfos; + serverInfosRef = getServerInfos(); + filterToolsByConfigFn = filterToolsByConfig; + filterToolsByGroupFn = filterToolsByGroup; +}; + +/** + * Get current server infos (refreshed each call) + */ +const getServerInfos = (): ServerInfo[] => { + return getServerInfosFn(); +}; + +/** + * Helper function to clean $schema field from inputSchema + */ +const cleanInputSchema = (schema: any): any => { + if (!schema || typeof schema !== 'object') { + return schema; + } + + const cleanedSchema = { ...schema }; + delete cleanedSchema.$schema; + + return cleanedSchema; +}; + +/** + * Generate the list of smart routing tools based on configuration + */ +export const getSmartRoutingTools = async ( + group: string | undefined, +): Promise<{ tools: any[] }> => { + // Extract target group if pattern is $smart/{group} + const targetGroup = group?.startsWith('$smart/') ? group.substring(7) : undefined; + + // Get smart routing config to check progressive disclosure setting + const smartRoutingConfig = await getSmartRoutingConfig(); + const progressiveDisclosure = smartRoutingConfig.progressiveDisclosure ?? false; + + // Get info about available servers, filtered by target group if specified + let availableServers = getServerInfos().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 = await 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'; + + // Base tools that are always available + const tools: any[] = []; + + if (progressiveDisclosure) { + // Progressive disclosure mode: search_tools returns minimal info, + // describe_tool provides full schema + tools.push( + { + name: 'search_tools', + description: `STEP 1 of 3: Use this tool FIRST to discover and search for relevant tools across ${scopeDescription}. Returns tool names and descriptions only - use describe_tool to get full parameter details before calling. + +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. + +Workflow: search_tools → describe_tool (for parameter details) → call_tool (to execute) + +Available servers: ${serversList}`, + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: + 'The search query to find relevant tools. Be specific and descriptive about the task you want to accomplish.', + }, + limit: { + type: 'integer', + description: + 'Maximum number of results to return. Use higher values (20-30) for broad searches and lower values (5-10) for specific searches.', + default: 10, + }, + }, + required: ['query'], + }, + annotations: { + title: 'Search Tools', + readOnlyHint: true, + }, + }, + { + name: 'describe_tool', + description: + 'STEP 2 of 3: Use this tool AFTER search_tools to get the full parameter schema for a specific tool. This provides the complete inputSchema needed to correctly invoke the tool with call_tool.\n\nWorkflow: search_tools → describe_tool → call_tool', + inputSchema: { + type: 'object', + properties: { + toolName: { + type: 'string', + description: 'The exact name of the tool to describe (from search_tools results)', + }, + }, + required: ['toolName'], + }, + annotations: { + title: 'Describe Tool', + readOnlyHint: true, + }, + }, + { + name: 'call_tool', + description: + "STEP 3 of 3: Use this tool AFTER describe_tool to actually execute/invoke any tool you found. This is the execution step.\n\nWorkflow: search_tools → describe_tool → call_tool with the chosen tool name and required arguments.\n\nIMPORTANT: Always use describe_tool first to get the tool's inputSchema before invoking to ensure you provide the correct arguments.", + inputSchema: { + type: 'object', + properties: { + toolName: { + type: 'string', + description: 'The exact name of the tool to invoke (from search_tools results)', + }, + arguments: { + type: 'object', + description: + 'The arguments to pass to the tool based on its inputSchema from describe_tool (optional if tool requires no arguments)', + }, + }, + required: ['toolName'], + }, + annotations: { + title: 'Call Tool', + openWorldHint: true, + }, + }, + ); + } else { + // Standard mode: search_tools returns full schema + tools.push( + { + name: 'search_tools', + 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}`, + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: + 'The search query to find relevant tools. Be specific and descriptive about the task you want to accomplish.', + }, + limit: { + type: 'integer', + description: + 'Maximum number of results to return. Use higher values (20-30) for broad searches and lower values (5-10) for specific searches.', + default: 10, + }, + }, + required: ['query'], + }, + annotations: { + title: 'Search Tools', + readOnlyHint: true, + }, + }, + { + name: 'call_tool', + description: + "STEP 2 of 2: Use this tool AFTER search_tools to actually execute/invoke any tool you found. This is the execution step - search_tools finds tools, call_tool runs them.\n\nWorkflow: search_tools → examine results → call_tool with the chosen tool name and required arguments.\n\nIMPORTANT: Always check the tool's inputSchema from search_tools results before invoking to ensure you provide the correct arguments. The search results will show you exactly what parameters each tool expects.", + inputSchema: { + type: 'object', + properties: { + toolName: { + type: 'string', + description: 'The exact name of the tool to invoke (from search_tools results)', + }, + arguments: { + type: 'object', + description: + 'The arguments to pass to the tool based on its inputSchema (optional if tool requires no arguments)', + }, + }, + required: ['toolName'], + }, + annotations: { + title: 'Call Tool', + openWorldHint: true, + }, + }, + ); + } + + return { tools }; +}; + +/** + * Handle the search_tools request for smart routing + */ +export const handleSearchToolsRequest = async ( + query: string, + limit: number, + sessionId: string, +): Promise => { + if (!query || typeof query !== 'string') { + throw new Error('Query parameter is required and must be a string'); + } + + const limitNum = Math.min(Math.max(parseInt(String(limit)) || 10, 1), 100); + + // Dynamically adjust threshold based on query characteristics + let thresholdNum = 0.3; // Default threshold + + // For more general queries, use a lower threshold to get more diverse results + if (query.length < 10 || query.split(' ').length <= 2) { + thresholdNum = 0.2; + } + + // For very specific queries, use a higher threshold for more precise results + if (query.length > 30 || query.includes('specific') || query.includes('exact')) { + thresholdNum = 0.4; + } + + console.log(`Using similarity threshold: ${thresholdNum} for query: "${query}"`); + + // Determine server filtering based on group + let 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); + if (targetGroup) { + group = targetGroup; + } + const serversInGroup = await getServersInGroup(targetGroup); + if (serversInGroup !== undefined && serversInGroup !== null) { + servers = serversInGroup; + if (servers && 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)}`); + + // Get smart routing config to check progressive disclosure setting + const smartRoutingConfig = await getSmartRoutingConfig(); + const progressiveDisclosure = smartRoutingConfig.progressiveDisclosure ?? false; + + // Find actual tool information from serverInfos by serverName and toolName + const resolvedTools = await Promise.all( + searchResults.map(async (result) => { + // Find the server in serverInfos + const server = getServerInfos().find( + (serverInfo) => + serverInfo.name === result.serverName && + serverInfo.status === 'connected' && + serverInfo.enabled !== false, + ); + if (server && server.tools && server.tools.length > 0) { + // Find the tool in server.tools + const actualTool = server.tools.find((tool) => tool.name === result.toolName); + if (actualTool) { + // Check if the tool is enabled in configuration + const tools = await filterToolsByConfigFn(server.name, [actualTool]); + if (tools.length > 0) { + // Apply custom description from configuration + const serverConfig = await getServerDao().findById(server.name); + const toolConfig = serverConfig?.tools?.[actualTool.name]; + + // Return the actual tool info from serverInfos with custom description + if (progressiveDisclosure) { + // Progressive disclosure: return only name and description + return { + name: actualTool.name, + description: toolConfig?.description || actualTool.description, + serverName: result.serverName, + }; + } else { + // Standard mode: return full tool info + return { + ...actualTool, + description: toolConfig?.description || actualTool.description, + serverName: result.serverName, + }; + } + } + } + } + + // Fallback to search result if server or tool not found or disabled + if (progressiveDisclosure) { + return { + name: result.toolName, + description: result.description || '', + serverName: result.serverName, + }; + } else { + return { + name: result.toolName, + description: result.description || '', + inputSchema: cleanInputSchema(result.inputSchema || {}), + serverName: result.serverName, + }; + } + }), + ); + + // Filter the resolved tools + const filterResults = await Promise.all( + resolvedTools.map(async (tool) => { + if (tool.name) { + const serverName = tool.serverName; + if (serverName) { + let tools = await filterToolsByConfigFn(serverName, [tool as Tool]); + if (tools.length === 0) { + return false; + } + + tools = await filterToolsByGroupFn(group, serverName, tools); + return tools.length > 0; + } + } + return true; + }), + ); + const tools = resolvedTools.filter((_, i) => filterResults[i]); + + // Build response based on mode + let guideline: string; + let nextSteps: string; + + if (progressiveDisclosure) { + guideline = + tools.length > 0 + ? "Found relevant tools. Use describe_tool to get the full parameter schema before calling. If these tools don't match exactly what you need, try another search with more specific keywords." + : 'No tools found. Try broadening your search or using different keywords.'; + nextSteps = + tools.length > 0 + ? 'Use describe_tool with the toolName to get the full inputSchema, then use call_tool to execute.' + : 'Consider searching for related capabilities or more general terms.'; + } else { + guideline = + tools.length > 0 + ? "Found relevant tools. If these tools don't match exactly what you need, try another search with more specific keywords." + : 'No tools found. Try broadening your search or using different keywords.'; + nextSteps = + tools.length > 0 + ? 'To use a tool, call call_tool with the toolName and required arguments.' + : 'Consider searching for related capabilities or more general terms.'; + } + + const response = { + tools, + metadata: { + query: query, + threshold: thresholdNum, + totalResults: tools.length, + progressiveDisclosure, + guideline, + nextSteps, + }, + }; + + return { + content: [ + { + type: 'text', + text: JSON.stringify(response), + }, + ], + }; +}; + +/** + * Handle the describe_tool request for smart routing (progressive disclosure mode) + */ +export const handleDescribeToolRequest = async ( + toolName: string, + sessionId: string, +): Promise => { + if (!toolName || typeof toolName !== 'string') { + throw new Error('toolName parameter is required and must be a string'); + } + + console.log(`Handling describe_tool request for: ${toolName}`); + + // Determine group filtering + let group = getGroup(sessionId); + if (group?.startsWith('$smart/')) { + group = group.substring(7); + } + + // Find the tool across all connected servers + for (const serverInfo of getServerInfos()) { + if (serverInfo.status !== 'connected' || serverInfo.enabled === false) { + continue; + } + + // Check if this server has the tool + const tool = serverInfo.tools?.find((t) => t.name === toolName); + if (!tool) { + continue; + } + + // Check if the tool is enabled in configuration + const tools = await filterToolsByConfigFn(serverInfo.name, [tool]); + if (tools.length === 0) { + continue; + } + + // Apply group filtering if applicable + if (group) { + const filteredTools = await filterToolsByGroupFn(group, serverInfo.name, tools); + if (filteredTools.length === 0) { + continue; + } + } + + // Get custom description from configuration + const serverConfig = await getServerDao().findById(serverInfo.name); + const toolConfig = serverConfig?.tools?.[tool.name]; + + // Return full tool information + const toolInfo = { + name: tool.name, + description: toolConfig?.description || tool.description, + inputSchema: cleanInputSchema(tool.inputSchema), + serverName: serverInfo.name, + }; + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + tool: toolInfo, + metadata: { + message: `Full schema for tool '${toolName}'. Use call_tool with the toolName and arguments based on the inputSchema.`, + }, + }), + }, + ], + }; + } + + // Tool not found + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + error: `Tool '${toolName}' not found or not available`, + metadata: { + message: + 'The specified tool was not found. Use search_tools to discover available tools.', + }, + }), + }, + ], + isError: true, + }; +}; + +/** + * Check if the given group is a smart routing group + */ +export const isSmartRoutingGroup = (group: string | undefined): boolean => { + return group === '$smart' || (group?.startsWith('$smart/') ?? false); +}; diff --git a/src/utils/smartRouting.ts b/src/utils/smartRouting.ts index e0340fb..5a385d7 100644 --- a/src/utils/smartRouting.ts +++ b/src/utils/smartRouting.ts @@ -10,6 +10,13 @@ export interface SmartRoutingConfig { openaiApiBaseUrl: string; openaiApiKey: string; openaiApiEmbeddingModel: string; + /** + * When enabled, search_tools returns only tool name and description (without full inputSchema). + * A new describe_tool endpoint is provided to get the full tool schema on demand. + * This reduces token usage for AI clients that don't need all tool parameters upfront. + * Default: false (returns full tool schemas in search_tools for backward compatibility) + */ + progressiveDisclosure?: boolean; } /** @@ -62,6 +69,15 @@ export async function getSmartRoutingConfig(): Promise { 'text-embedding-3-small', expandEnvVars, ), + + // Progressive disclosure - when enabled, search_tools returns minimal info + // and describe_tool is used to get full schema + progressiveDisclosure: getConfigValue( + [process.env.SMART_ROUTING_PROGRESSIVE_DISCLOSURE], + smartRoutingSettings.progressiveDisclosure, + false, + parseBooleanEnvVar, + ), }; } diff --git a/tests/services/mcpService-smart-routing-group.test.ts b/tests/services/mcpService-smart-routing-group.test.ts index f0a4317..44fa2e2 100644 --- a/tests/services/mcpService-smart-routing-group.test.ts +++ b/tests/services/mcpService-smart-routing-group.test.ts @@ -48,6 +48,44 @@ jest.mock('../../src/services/services.js', () => ({ })), })); +// Mock smartRoutingService to initialize with test functions +const mockHandleSearchToolsRequest = jest.fn(); +jest.mock('../../src/services/smartRoutingService.js', () => ({ + initSmartRoutingService: jest.fn(), + handleSearchToolsRequest: mockHandleSearchToolsRequest, + handleDescribeToolRequest: jest.fn(), + isSmartRoutingGroup: jest.fn((group: string) => group?.startsWith('$smart')), + getSmartRoutingTools: jest.fn(async (group: string) => { + const targetGroup = group?.startsWith('$smart/') ? group.substring(7) : undefined; + const scopeDescription = targetGroup + ? `servers in the "${targetGroup}" group` + : 'all available servers'; + + return { + tools: [ + { + name: 'search_tools', + description: `Search for relevant tools across ${scopeDescription}.`, + inputSchema: { + type: 'object', + properties: { query: { type: 'string' }, limit: { type: 'integer' } }, + required: ['query'], + }, + }, + { + name: 'call_tool', + description: 'Execute a tool by name', + inputSchema: { + type: 'object', + properties: { toolName: { type: 'string' } }, + required: ['toolName'], + }, + }, + ], + }; + }), +})); + jest.mock('../../src/services/vectorSearchService.js', () => ({ searchToolsByVector: jest.fn(), saveToolsAsVectorEmbeddings: jest.fn(), @@ -66,13 +104,21 @@ jest.mock('../../src/config/index.js', () => ({ // 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'; +import { handleSearchToolsRequest } from '../../src/services/smartRoutingService.js'; describe('MCP Service - Smart Routing with Group Support', () => { beforeEach(() => { jest.clearAllMocks(); + // Setup mock return for handleSearchToolsRequest + mockHandleSearchToolsRequest.mockResolvedValue({ + content: [ + { + type: 'text', + text: JSON.stringify({ tools: [], guideline: 'test', nextSteps: 'test' }), + }, + ], + }); }); describe('handleListToolsRequest', () => { @@ -89,7 +135,7 @@ describe('MCP Service - Smart Routing with Group Support', () => { const result = await handleListToolsRequest({}, { sessionId: 'session-smart-group' }); expect(getGroup).toHaveBeenCalledWith('session-smart-group'); - expect(getServersInGroup).toHaveBeenCalledWith('test-group'); + // Note: getServersInGroup is now called inside the mocked getSmartRoutingTools expect(result.tools).toHaveLength(2); expect(result.tools[0].name).toBe('search_tools'); @@ -101,7 +147,7 @@ describe('MCP Service - Smart Routing with Group Support', () => { const result = await handleListToolsRequest({}, { sessionId: 'session-smart-empty' }); expect(getGroup).toHaveBeenCalledWith('session-smart-empty'); - expect(getServersInGroup).toHaveBeenCalledWith('empty-group'); + // Note: getServersInGroup is now called inside the mocked getSmartRoutingTools expect(result.tools).toHaveLength(2); expect(result.tools[0].name).toBe('search_tools'); @@ -113,16 +159,6 @@ describe('MCP Service - Smart Routing with Group Support', () => { 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', @@ -135,25 +171,11 @@ describe('MCP Service - Smart Routing with Group Support', () => { await handleCallToolRequest(request, { sessionId: 'session-smart' }); - expect(searchToolsByVector).toHaveBeenCalledWith( - 'test query', - 10, - expect.any(Number), - undefined, // No server filtering - ); + // handleSearchToolsRequest should be called with the query, limit, and sessionId + expect(handleSearchToolsRequest).toHaveBeenCalledWith('test query', 10, 'session-smart'); }); 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', @@ -166,20 +188,16 @@ describe('MCP Service - Smart Routing with Group Support', () => { await handleCallToolRequest(request, { sessionId: 'session-smart-group' }); - expect(getGroup).toHaveBeenCalledWith('session-smart-group'); - expect(getServersInGroup).toHaveBeenCalledWith('test-group'); - expect(searchToolsByVector).toHaveBeenCalledWith( + // handleSearchToolsRequest should be called with the sessionId that contains group info + // The group filtering happens inside handleSearchToolsRequest, not in handleCallToolRequest + expect(handleSearchToolsRequest).toHaveBeenCalledWith( 'test query', 10, - expect.any(Number), - ['server1', 'server2'], // Filtered to group servers + 'session-smart-group', ); }); it('should handle empty group in $smart/{group}', async () => { - const mockSearchResults: any[] = []; - (searchToolsByVector as jest.Mock).mockResolvedValue(mockSearchResults); - const request = { params: { name: 'search_tools', @@ -192,18 +210,19 @@ describe('MCP Service - Smart Routing with Group Support', () => { 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( + expect(handleSearchToolsRequest).toHaveBeenCalledWith( 'test query', 10, - expect.any(Number), - [], // Empty group + 'session-smart-empty', ); }); it('should validate query parameter', async () => { + // Mock handleSearchToolsRequest to return an error result when query is missing + mockHandleSearchToolsRequest.mockImplementationOnce(() => { + return Promise.reject(new Error('Query parameter is required and must be a string')); + }); + const request = { params: { name: 'search_tools',
+ {t('settings.progressiveDisclosureDescription')} +