mirror of
https://github.com/samanhappy/mcphub.git
synced 2026-01-10 16:47:56 -05:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdb3a6af42 | ||
|
|
7d55d23577 | ||
|
|
e6340e0e1e | ||
|
|
b03eacdf09 | ||
|
|
6b3a077a67 | ||
|
|
3de56b30bd | ||
|
|
6a08f4bc5a | ||
|
|
ef1bc0d305 |
@@ -362,6 +362,129 @@ Smart Routing now supports group-scoped searches, allowing you to limit tool dis
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
### 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.
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="What is Progressive Disclosure?">
|
||||
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
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Enabling Progressive Disclosure">
|
||||
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
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Standard Mode (Default)">
|
||||
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.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Progressive Disclosure Mode">
|
||||
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.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Response Format Comparison">
|
||||
**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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
{/* ### Basic Usage
|
||||
|
||||
Connect your AI client to the Smart Routing endpoint and make natural language requests:
|
||||
|
||||
@@ -64,17 +64,181 @@ description: '使用向量语义搜索的 AI 工具发现系统'
|
||||
<Tabs>
|
||||
<Tab title="HTTP MCP">
|
||||
```
|
||||
# 搜索所有服务器
|
||||
http://localhost:3000/mcp/$smart
|
||||
|
||||
# 在特定分组内搜索
|
||||
http://localhost:3000/mcp/$smart/{group}
|
||||
```
|
||||
</Tab>
|
||||
|
||||
<Tab title="SSE (Legacy)">
|
||||
```
|
||||
# 搜索所有服务器
|
||||
http://localhost:3000/sse/$smart
|
||||
|
||||
# 在特定分组内搜索
|
||||
http://localhost:3000/sse/$smart/{group}
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### 分组范围的智能路由
|
||||
|
||||
智能路由支持分组范围的搜索,允许您将工具发现限制在特定分组内的服务器:
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="使用分组范围的智能路由">
|
||||
将您的 AI 客户端连接到特定分组的智能路由端点:
|
||||
|
||||
```
|
||||
http://localhost:3000/mcp/$smart/production
|
||||
```
|
||||
|
||||
此端点只会搜索属于 "production" 分组的服务器中的工具。
|
||||
|
||||
**优势:**
|
||||
- **聚焦结果**:只返回相关服务器的工具
|
||||
- **更好的性能**:减少搜索空间以加快查询速度
|
||||
- **环境隔离**:将开发、预发布和生产工具分开
|
||||
- **访问控制**:根据用户权限限制工具发现
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="工作原理">
|
||||
当使用 `$smart/{group}` 时:
|
||||
|
||||
1. 系统识别指定的分组
|
||||
2. 获取属于该分组的所有服务器
|
||||
3. 将工具搜索过滤到仅限那些服务器
|
||||
4. 返回限定在该分组服务器范围内的结果
|
||||
|
||||
如果分组不存在或没有服务器,搜索将不返回任何结果。
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
### 渐进式披露模式
|
||||
|
||||
渐进式披露是一个优化功能,可以减少使用智能路由时的 Token 消耗。启用后,工具发现工作流从 2 步变为 3 步。
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="什么是渐进式披露?">
|
||||
默认情况下,智能路由在 `search_tools` 结果中返回完整的工具信息,包括完整的参数模式。当处理具有复杂输入模式的工具时,这会消耗大量 Token。
|
||||
|
||||
**渐进式披露** 改变了这种行为:
|
||||
- `search_tools` 只返回工具名称和描述(最少信息)
|
||||
- 新的 `describe_tool` 端点按需提供完整的参数模式
|
||||
- `call_tool` 像以前一样执行工具
|
||||
|
||||
这种方法特别适用于:
|
||||
- 处理具有复杂模式的大量工具
|
||||
- Token 使用优化很重要的场景
|
||||
- AI 客户端需要浏览多个工具后再选择
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="启用渐进式披露">
|
||||
通过设置页面或环境变量启用渐进式披露:
|
||||
|
||||
**通过设置界面:**
|
||||
1. 导航到 设置 → 智能路由
|
||||
2. 启用"渐进式披露"开关
|
||||
3. 更改立即生效
|
||||
|
||||
**通过环境变量:**
|
||||
```bash
|
||||
SMART_ROUTING_PROGRESSIVE_DISCLOSURE=true
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="标准模式(默认)">
|
||||
当渐进式披露**禁用**(默认)时,智能路由提供两个工具:
|
||||
|
||||
**工作流:** `search_tools` → `call_tool`
|
||||
|
||||
| 工具 | 用途 |
|
||||
|------|------|
|
||||
| `search_tools` | 按查询查找工具,返回包含 `inputSchema` 的完整工具信息 |
|
||||
| `call_tool` | 使用提供的参数执行工具 |
|
||||
|
||||
这种模式更简单,但由于搜索结果中包含完整模式,会使用更多 Token。
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="渐进式披露模式">
|
||||
当渐进式披露**启用**时,智能路由提供三个工具:
|
||||
|
||||
**工作流:** `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 使用。
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="响应格式对比">
|
||||
**标准模式 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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
{/* ## 性能优化
|
||||
|
||||
### 嵌入缓存
|
||||
|
||||
@@ -1,67 +1,67 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useGroupData } from '@/hooks/useGroupData'
|
||||
import { useServerData } from '@/hooks/useServerData'
|
||||
import { GroupFormData, Server, IGroupServerConfig } from '@/types'
|
||||
import { ServerToolConfig } from './ServerToolConfig'
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useGroupData } from '@/hooks/useGroupData';
|
||||
import { useServerData } from '@/hooks/useServerData';
|
||||
import { GroupFormData, Server, IGroupServerConfig } from '@/types';
|
||||
import { ServerToolConfig } from './ServerToolConfig';
|
||||
|
||||
interface AddGroupFormProps {
|
||||
onAdd: () => void
|
||||
onCancel: () => void
|
||||
onAdd: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { createGroup } = useGroupData()
|
||||
const { servers } = useServerData()
|
||||
const [availableServers, setAvailableServers] = useState<Server[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const { t } = useTranslation();
|
||||
const { createGroup } = useGroupData();
|
||||
const { allServers } = useServerData();
|
||||
const [availableServers, setAvailableServers] = useState<Server[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState<GroupFormData>({
|
||||
name: '',
|
||||
description: '',
|
||||
servers: [] as IGroupServerConfig[]
|
||||
})
|
||||
servers: [] as IGroupServerConfig[],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Filter available servers (enabled only)
|
||||
setAvailableServers(servers.filter(server => server.enabled !== false))
|
||||
}, [servers])
|
||||
setAvailableServers(allServers.filter((server) => server.enabled !== false));
|
||||
}, [allServers]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target
|
||||
setFormData(prev => ({
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}))
|
||||
}
|
||||
[name]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (!formData.name.trim()) {
|
||||
setError(t('groups.nameRequired'))
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
setError(t('groups.nameRequired'));
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await createGroup(formData.name, formData.description, formData.servers)
|
||||
const result = await createGroup(formData.name, formData.description, formData.servers);
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('groups.createError'))
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
setError(result?.message || t('groups.createError'));
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
onAdd()
|
||||
onAdd();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
setIsSubmitting(false)
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
@@ -102,7 +102,7 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
|
||||
<ServerToolConfig
|
||||
servers={availableServers}
|
||||
value={formData.servers as IGroupServerConfig[]}
|
||||
onChange={(servers) => setFormData(prev => ({ ...prev, servers }))}
|
||||
onChange={(servers) => setFormData((prev) => ({ ...prev, servers }))}
|
||||
className="border border-gray-200 rounded-lg p-4 bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
@@ -129,7 +129,7 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default AddGroupForm
|
||||
export default AddGroupForm;
|
||||
|
||||
@@ -1,73 +1,73 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Group, GroupFormData, Server, IGroupServerConfig } from '@/types'
|
||||
import { useGroupData } from '@/hooks/useGroupData'
|
||||
import { useServerData } from '@/hooks/useServerData'
|
||||
import { ServerToolConfig } from './ServerToolConfig'
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Group, GroupFormData, Server, IGroupServerConfig } from '@/types';
|
||||
import { useGroupData } from '@/hooks/useGroupData';
|
||||
import { useServerData } from '@/hooks/useServerData';
|
||||
import { ServerToolConfig } from './ServerToolConfig';
|
||||
|
||||
interface EditGroupFormProps {
|
||||
group: Group
|
||||
onEdit: () => void
|
||||
onCancel: () => void
|
||||
group: Group;
|
||||
onEdit: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { updateGroup } = useGroupData()
|
||||
const { servers } = useServerData()
|
||||
const [availableServers, setAvailableServers] = useState<Server[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const { t } = useTranslation();
|
||||
const { updateGroup } = useGroupData();
|
||||
const { allServers } = useServerData();
|
||||
const [availableServers, setAvailableServers] = useState<Server[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState<GroupFormData>({
|
||||
name: group.name,
|
||||
description: group.description || '',
|
||||
servers: group.servers || []
|
||||
})
|
||||
servers: group.servers || [],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Filter available servers (enabled only)
|
||||
setAvailableServers(servers.filter(server => server.enabled !== false))
|
||||
}, [servers])
|
||||
setAvailableServers(allServers.filter((server) => server.enabled !== false));
|
||||
}, [allServers]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target
|
||||
setFormData(prev => ({
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}))
|
||||
}
|
||||
[name]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (!formData.name.trim()) {
|
||||
setError(t('groups.nameRequired'))
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
setError(t('groups.nameRequired'));
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await updateGroup(group.id, {
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
servers: formData.servers
|
||||
})
|
||||
servers: formData.servers,
|
||||
});
|
||||
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('groups.updateError'))
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
setError(result?.message || t('groups.updateError'));
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
onEdit()
|
||||
onEdit();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
setIsSubmitting(false)
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
@@ -108,7 +108,7 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
|
||||
<ServerToolConfig
|
||||
servers={availableServers}
|
||||
value={formData.servers as IGroupServerConfig[]}
|
||||
onChange={(servers) => setFormData(prev => ({ ...prev, servers }))}
|
||||
onChange={(servers) => setFormData((prev) => ({ ...prev, servers }))}
|
||||
className="border border-gray-200 rounded-lg p-4 bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
@@ -135,7 +135,7 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default EditGroupForm
|
||||
export default EditGroupForm;
|
||||
|
||||
@@ -30,6 +30,7 @@ interface PaginationInfo {
|
||||
// Context type definition
|
||||
interface ServerContextType {
|
||||
servers: Server[];
|
||||
allServers: Server[]; // All servers without pagination, for Dashboard, Groups, Settings
|
||||
error: string | null;
|
||||
setError: (error: string | null) => void;
|
||||
isLoading: boolean;
|
||||
@@ -56,6 +57,7 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
const { t } = useTranslation();
|
||||
const { auth } = useAuth();
|
||||
const [servers, setServers] = useState<Server[]>([]);
|
||||
const [allServers, setAllServers] = useState<Server[]>([]); // All servers without pagination
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
@@ -95,29 +97,44 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', currentPage.toString());
|
||||
params.append('limit', serversPerPage.toString());
|
||||
const data = await apiGet(`/servers?${params.toString()}`);
|
||||
|
||||
// Fetch both paginated servers and all servers in parallel
|
||||
const [paginatedData, allData] = await Promise.all([
|
||||
apiGet(`/servers?${params.toString()}`),
|
||||
apiGet('/servers'), // Fetch all servers without pagination
|
||||
]);
|
||||
|
||||
// Update last fetch time
|
||||
lastFetchTimeRef.current = Date.now();
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data);
|
||||
// Handle paginated response
|
||||
if (paginatedData && paginatedData.success && Array.isArray(paginatedData.data)) {
|
||||
setServers(paginatedData.data);
|
||||
// Update pagination info if available
|
||||
if (data.pagination) {
|
||||
setPagination(data.pagination);
|
||||
if (paginatedData.pagination) {
|
||||
setPagination(paginatedData.pagination);
|
||||
} else {
|
||||
setPagination(null);
|
||||
}
|
||||
} else if (data && Array.isArray(data)) {
|
||||
} else if (paginatedData && Array.isArray(paginatedData)) {
|
||||
// Compatibility handling for non-paginated responses
|
||||
setServers(data);
|
||||
setServers(paginatedData);
|
||||
setPagination(null);
|
||||
} else {
|
||||
console.error('Invalid server data format:', data);
|
||||
console.error('Invalid server data format:', paginatedData);
|
||||
setServers([]);
|
||||
setPagination(null);
|
||||
}
|
||||
|
||||
// Handle all servers response
|
||||
if (allData && allData.success && Array.isArray(allData.data)) {
|
||||
setAllServers(allData.data);
|
||||
} else if (allData && Array.isArray(allData)) {
|
||||
setAllServers(allData);
|
||||
} else {
|
||||
setAllServers([]);
|
||||
}
|
||||
|
||||
// Reset error state
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
@@ -159,6 +176,7 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
// When user logs out, clear data and stop polling
|
||||
clearTimer();
|
||||
setServers([]);
|
||||
setAllServers([]);
|
||||
setIsInitialLoading(false);
|
||||
setError(null);
|
||||
}
|
||||
@@ -185,42 +203,49 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
const params = new URLSearchParams();
|
||||
params.append('page', currentPage.toString());
|
||||
params.append('limit', serversPerPage.toString());
|
||||
const data = await apiGet(`/servers?${params.toString()}`);
|
||||
|
||||
// Fetch both paginated servers and all servers in parallel
|
||||
const [paginatedData, allData] = await Promise.all([
|
||||
apiGet(`/servers?${params.toString()}`),
|
||||
apiGet('/servers'), // Fetch all servers without pagination
|
||||
]);
|
||||
|
||||
// Update last fetch time
|
||||
lastFetchTimeRef.current = Date.now();
|
||||
|
||||
// Handle API response wrapper object, extract data field
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data);
|
||||
// Handle paginated API response wrapper object, extract data field
|
||||
if (paginatedData && paginatedData.success && Array.isArray(paginatedData.data)) {
|
||||
setServers(paginatedData.data);
|
||||
// Update pagination info if available
|
||||
if (data.pagination) {
|
||||
setPagination(data.pagination);
|
||||
if (paginatedData.pagination) {
|
||||
setPagination(paginatedData.pagination);
|
||||
} else {
|
||||
setPagination(null);
|
||||
}
|
||||
setIsInitialLoading(false);
|
||||
// Initialization successful, start normal polling (skip immediate to avoid duplicate fetch)
|
||||
startNormalPolling({ immediate: false });
|
||||
return true;
|
||||
} else if (data && Array.isArray(data)) {
|
||||
} else if (paginatedData && Array.isArray(paginatedData)) {
|
||||
// Compatibility handling, if API directly returns array
|
||||
setServers(data);
|
||||
setServers(paginatedData);
|
||||
setPagination(null);
|
||||
setIsInitialLoading(false);
|
||||
// Initialization successful, start normal polling (skip immediate to avoid duplicate fetch)
|
||||
startNormalPolling({ immediate: false });
|
||||
return true;
|
||||
} else {
|
||||
// If data format is not as expected, set to empty array
|
||||
console.error('Invalid server data format:', data);
|
||||
console.error('Invalid server data format:', paginatedData);
|
||||
setServers([]);
|
||||
setPagination(null);
|
||||
setIsInitialLoading(false);
|
||||
// Initialization successful but data is empty, start normal polling (skip immediate)
|
||||
startNormalPolling({ immediate: false });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle all servers response
|
||||
if (allData && allData.success && Array.isArray(allData.data)) {
|
||||
setAllServers(allData.data);
|
||||
} else if (allData && Array.isArray(allData)) {
|
||||
setAllServers(allData);
|
||||
} else {
|
||||
setAllServers([]);
|
||||
}
|
||||
|
||||
setIsInitialLoading(false);
|
||||
// Initialization successful, start normal polling (skip immediate to avoid duplicate fetch)
|
||||
startNormalPolling({ immediate: false });
|
||||
return true;
|
||||
} catch (err) {
|
||||
// Increment attempt count, use ref to avoid triggering effect rerun
|
||||
attemptsRef.current += 1;
|
||||
@@ -439,6 +464,7 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
|
||||
const value: ServerContextType = {
|
||||
servers,
|
||||
allServers,
|
||||
error,
|
||||
setError,
|
||||
isLoading: isInitialLoading,
|
||||
|
||||
@@ -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<SettingsProviderProps> = ({ children })
|
||||
openaiApiBaseUrl: '',
|
||||
openaiApiKey: '',
|
||||
openaiApiEmbeddingModel: '',
|
||||
progressiveDisclosure: false,
|
||||
});
|
||||
|
||||
const [mcpRouterConfig, setMCPRouterConfig] = useState<MCPRouterConfig>({
|
||||
@@ -238,6 +240,7 @@ export const SettingsProvider: React.FC<SettingsProviderProps> = ({ 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) {
|
||||
|
||||
@@ -5,15 +5,15 @@ import { Server } from '@/types';
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { servers, error, setError, isLoading } = useServerData({ refreshOnMount: true });
|
||||
const { allServers, error, setError, isLoading } = useServerData({ refreshOnMount: true });
|
||||
|
||||
// Calculate server statistics
|
||||
// Calculate server statistics using allServers (not paginated)
|
||||
const serverStats = {
|
||||
total: servers.length,
|
||||
online: servers.filter((server: Server) => server.status === 'connected').length,
|
||||
offline: servers.filter((server: Server) => server.status === 'disconnected').length,
|
||||
connecting: servers.filter((server: Server) => server.status === 'connecting').length,
|
||||
oauthRequired: servers.filter((server: Server) => server.status === 'oauth_required').length,
|
||||
total: allServers.length,
|
||||
online: allServers.filter((server: Server) => server.status === 'connected').length,
|
||||
offline: allServers.filter((server: Server) => server.status === 'disconnected').length,
|
||||
connecting: allServers.filter((server: Server) => server.status === 'connecting').length,
|
||||
oauthRequired: allServers.filter((server: Server) => server.status === 'oauth_required').length,
|
||||
};
|
||||
|
||||
// Map status to translation keys
|
||||
@@ -202,7 +202,7 @@ const DashboardPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* Recent activity list */}
|
||||
{servers.length > 0 && !isLoading && (
|
||||
{allServers.length > 0 && !isLoading && (
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">
|
||||
{t('pages.dashboard.recentServers')}
|
||||
@@ -244,7 +244,7 @@ const DashboardPage: React.FC = () => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{servers.slice(0, 5).map((server, index) => (
|
||||
{allServers.slice(0, 5).map((server, index) => (
|
||||
<tr key={index}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{server.name}
|
||||
|
||||
@@ -18,7 +18,7 @@ const GroupsPage: React.FC = () => {
|
||||
deleteGroup,
|
||||
triggerRefresh,
|
||||
} = useGroupData();
|
||||
const { servers } = useServerData({ refreshOnMount: true });
|
||||
const { allServers } = useServerData({ refreshOnMount: true });
|
||||
|
||||
const [editingGroup, setEditingGroup] = useState<Group | null>(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
@@ -140,7 +140,7 @@ const GroupsPage: React.FC = () => {
|
||||
<GroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
servers={servers}
|
||||
servers={allServers}
|
||||
onEdit={handleEditClick}
|
||||
onDelete={handleDeleteGroup}
|
||||
/>
|
||||
|
||||
@@ -378,7 +378,7 @@ const SettingsPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const { servers } = useServerContext();
|
||||
const { allServers: servers } = useServerContext(); // Use allServers for settings (not paginated)
|
||||
const { groups } = useGroupData();
|
||||
|
||||
const [installConfig, setInstallConfig] = useState<{
|
||||
@@ -1425,6 +1425,24 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">
|
||||
{t('settings.progressiveDisclosure')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.progressiveDisclosureDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading || !smartRoutingConfig.enabled}
|
||||
checked={smartRoutingConfig.progressiveDisclosure}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSmartRoutingConfig('progressiveDisclosure', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<button
|
||||
onClick={handleSaveSmartRoutingConfig}
|
||||
|
||||
@@ -607,6 +607,8 @@
|
||||
"openaiApiKeyPlaceholder": "Enter OpenAI API key",
|
||||
"openaiApiEmbeddingModel": "OpenAI Embedding Model",
|
||||
"openaiApiEmbeddingModelPlaceholder": "text-embedding-3-small",
|
||||
"progressiveDisclosure": "Progressive Disclosure",
|
||||
"progressiveDisclosureDescription": "When enabled, search_tools returns only tool names and descriptions. Use describe_tool to get full parameter schema, reducing token usage.",
|
||||
"smartRoutingConfigUpdated": "Smart routing configuration updated successfully",
|
||||
"smartRoutingRequiredFields": "Database URL and OpenAI API Key are required to enable smart routing",
|
||||
"smartRoutingValidationError": "Please fill in the required fields before enabling Smart Routing: {{fields}}",
|
||||
|
||||
@@ -610,6 +610,8 @@
|
||||
"openaiApiKeyPlaceholder": "请输入 OpenAI API 密钥",
|
||||
"openaiApiEmbeddingModel": "OpenAI 嵌入模型",
|
||||
"openaiApiEmbeddingModelPlaceholder": "text-embedding-3-small",
|
||||
"progressiveDisclosure": "渐进式披露",
|
||||
"progressiveDisclosureDescription": "开启后,search_tools 只返回工具名称和描述,通过 describe_tool 获取完整参数定义,可减少 Token 消耗",
|
||||
"smartRoutingConfigUpdated": "智能路由配置更新成功",
|
||||
"smartRoutingRequiredFields": "启用智能路由需要填写数据库连接地址和 OpenAI API 密钥",
|
||||
"smartRoutingValidationError": "启用智能路由前请先填写必要字段:{{fields}}",
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
"react-i18next": "^15.7.2",
|
||||
"react-router-dom": "^7.8.2",
|
||||
"react-router-dom": "^7.12.0",
|
||||
"supertest": "^7.1.4",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwind-scrollbar-hide": "^2.0.0",
|
||||
@@ -136,7 +136,8 @@
|
||||
"brace-expansion@2.0.1": "2.0.2",
|
||||
"glob@10.4.5": "10.5.0",
|
||||
"js-yaml": "4.1.1",
|
||||
"jws@3.2.2": "4.0.1"
|
||||
"jws@3.2.2": "4.0.1",
|
||||
"qs": "6.14.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
87
pnpm-lock.yaml
generated
87
pnpm-lock.yaml
generated
@@ -10,6 +10,7 @@ overrides:
|
||||
glob@10.4.5: 10.5.0
|
||||
js-yaml: 4.1.1
|
||||
jws@3.2.2: 4.0.1
|
||||
qs: 6.14.1
|
||||
|
||||
importers:
|
||||
|
||||
@@ -20,7 +21,7 @@ importers:
|
||||
version: 12.0.0(openapi-types@12.1.3)
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: ^1.25.1
|
||||
version: 1.25.1(hono@4.11.3)(zod@3.25.76)
|
||||
version: 1.25.2(hono@4.11.3)(zod@3.25.76)
|
||||
'@node-oauth/oauth2-server':
|
||||
specifier: ^5.2.1
|
||||
version: 5.2.1
|
||||
@@ -218,8 +219,8 @@ importers:
|
||||
specifier: ^15.7.2
|
||||
version: 15.7.2(i18next@25.6.0(typescript@5.9.2))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.9.2)
|
||||
react-router-dom:
|
||||
specifier: ^7.8.2
|
||||
version: 7.8.2(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||
specifier: ^7.12.0
|
||||
version: 7.12.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||
supertest:
|
||||
specifier: ^7.1.4
|
||||
version: 7.1.4
|
||||
@@ -1118,8 +1119,8 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.9':
|
||||
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
|
||||
|
||||
'@modelcontextprotocol/sdk@1.25.1':
|
||||
resolution: {integrity: sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==}
|
||||
'@modelcontextprotocol/sdk@1.25.2':
|
||||
resolution: {integrity: sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@cfworker/json-schema': ^4.1.1
|
||||
@@ -2176,8 +2177,8 @@ packages:
|
||||
resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==}
|
||||
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
||||
|
||||
body-parser@2.2.1:
|
||||
resolution: {integrity: sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==}
|
||||
body-parser@2.2.2:
|
||||
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
brace-expansion@1.1.12:
|
||||
@@ -2909,8 +2910,8 @@ packages:
|
||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
iconv-lite@0.7.0:
|
||||
resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==}
|
||||
iconv-lite@0.7.1:
|
||||
resolution: {integrity: sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
ieee754@1.2.1:
|
||||
@@ -3849,8 +3850,8 @@ packages:
|
||||
pure-rand@7.0.1:
|
||||
resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==}
|
||||
|
||||
qs@6.14.0:
|
||||
resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==}
|
||||
qs@6.14.1:
|
||||
resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
queue-microtask@1.2.3:
|
||||
@@ -3896,15 +3897,15 @@ packages:
|
||||
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
react-router-dom@7.8.2:
|
||||
resolution: {integrity: sha512-Z4VM5mKDipal2jQ385H6UBhiiEDlnJPx6jyWsTYoZQdl5TrjxEV2a9yl3Fi60NBJxYzOTGTTHXPi0pdizvTwow==}
|
||||
react-router-dom@7.12.0:
|
||||
resolution: {integrity: sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
react: '>=18'
|
||||
react-dom: '>=18'
|
||||
|
||||
react-router@7.8.2:
|
||||
resolution: {integrity: sha512-7M2fR1JbIZ/jFWqelpvSZx+7vd7UlBTfdZqf6OSdF9g6+sfdqJDAWcak6ervbHph200ePlu+7G8LdoiC3ReyAQ==}
|
||||
react-router@7.12.0:
|
||||
resolution: {integrity: sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
react: '>=18'
|
||||
@@ -4023,16 +4024,16 @@ packages:
|
||||
resolution: {integrity: sha512-p4rRk4f23ynFEfcD9LA0xRYngj+IyGiEYyqqOak8kaN0TvNmuxC2dcVeBn62GpCeR2CpWqyHCNScTP91QbAVFg==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
send@1.2.0:
|
||||
resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==}
|
||||
send@1.2.1:
|
||||
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
serve-static@1.16.2:
|
||||
resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
serve-static@2.2.0:
|
||||
resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==}
|
||||
serve-static@2.2.1:
|
||||
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
set-cookie-parser@2.7.1:
|
||||
@@ -4617,8 +4618,8 @@ packages:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
zod-to-json-schema@3.25.0:
|
||||
resolution: {integrity: sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==}
|
||||
zod-to-json-schema@3.25.1:
|
||||
resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==}
|
||||
peerDependencies:
|
||||
zod: ^3.25 || ^4
|
||||
|
||||
@@ -5482,7 +5483,7 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@modelcontextprotocol/sdk@1.25.1(hono@4.11.3)(zod@3.25.76)':
|
||||
'@modelcontextprotocol/sdk@1.25.2(hono@4.11.3)(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@hono/node-server': 1.19.7(hono@4.11.3)
|
||||
ajv: 8.17.1
|
||||
@@ -5499,7 +5500,7 @@ snapshots:
|
||||
pkce-challenge: 5.0.1
|
||||
raw-body: 3.0.2
|
||||
zod: 3.25.76
|
||||
zod-to-json-schema: 3.25.0(zod@3.25.76)
|
||||
zod-to-json-schema: 3.25.1(zod@3.25.76)
|
||||
transitivePeerDependencies:
|
||||
- hono
|
||||
- supports-color
|
||||
@@ -6473,22 +6474,22 @@ snapshots:
|
||||
http-errors: 2.0.1
|
||||
iconv-lite: 0.4.24
|
||||
on-finished: 2.4.1
|
||||
qs: 6.14.0
|
||||
qs: 6.14.1
|
||||
raw-body: 2.5.3
|
||||
type-is: 1.6.18
|
||||
unpipe: 1.0.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
body-parser@2.2.1:
|
||||
body-parser@2.2.2:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
content-type: 1.0.5
|
||||
debug: 4.4.3
|
||||
http-errors: 2.0.1
|
||||
iconv-lite: 0.7.0
|
||||
iconv-lite: 0.7.1
|
||||
on-finished: 2.4.1
|
||||
qs: 6.14.0
|
||||
qs: 6.14.1
|
||||
raw-body: 3.0.2
|
||||
type-is: 2.0.1
|
||||
transitivePeerDependencies:
|
||||
@@ -7013,7 +7014,7 @@ snapshots:
|
||||
parseurl: 1.3.3
|
||||
path-to-regexp: 0.1.12
|
||||
proxy-addr: 2.0.7
|
||||
qs: 6.14.0
|
||||
qs: 6.14.1
|
||||
range-parser: 1.2.1
|
||||
safe-buffer: 5.2.1
|
||||
send: 0.19.1
|
||||
@@ -7029,7 +7030,7 @@ snapshots:
|
||||
express@5.2.1:
|
||||
dependencies:
|
||||
accepts: 2.0.0
|
||||
body-parser: 2.2.1
|
||||
body-parser: 2.2.2
|
||||
content-disposition: 1.0.1
|
||||
content-type: 1.0.5
|
||||
cookie: 0.7.2
|
||||
@@ -7048,11 +7049,11 @@ snapshots:
|
||||
once: 1.4.0
|
||||
parseurl: 1.3.3
|
||||
proxy-addr: 2.0.7
|
||||
qs: 6.14.0
|
||||
qs: 6.14.1
|
||||
range-parser: 1.2.1
|
||||
router: 2.2.0
|
||||
send: 1.2.0
|
||||
serve-static: 2.2.0
|
||||
send: 1.2.1
|
||||
serve-static: 2.2.1
|
||||
statuses: 2.0.2
|
||||
type-is: 2.0.1
|
||||
vary: 1.1.2
|
||||
@@ -7337,7 +7338,7 @@ snapshots:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
iconv-lite@0.7.0:
|
||||
iconv-lite@0.7.1:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
@@ -8357,7 +8358,7 @@ snapshots:
|
||||
|
||||
pure-rand@7.0.1: {}
|
||||
|
||||
qs@6.14.0:
|
||||
qs@6.14.1:
|
||||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
|
||||
@@ -8376,7 +8377,7 @@ snapshots:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
http-errors: 2.0.1
|
||||
iconv-lite: 0.7.0
|
||||
iconv-lite: 0.7.1
|
||||
unpipe: 1.0.0
|
||||
|
||||
react-dom@19.2.1(react@19.2.1):
|
||||
@@ -8398,13 +8399,13 @@ snapshots:
|
||||
|
||||
react-refresh@0.17.0: {}
|
||||
|
||||
react-router-dom@7.8.2(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
|
||||
react-router-dom@7.12.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
|
||||
dependencies:
|
||||
react: 19.2.1
|
||||
react-dom: 19.2.1(react@19.2.1)
|
||||
react-router: 7.8.2(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||
react-router: 7.12.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||
|
||||
react-router@7.8.2(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
|
||||
react-router@7.12.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
|
||||
dependencies:
|
||||
cookie: 1.1.1
|
||||
react: 19.2.1
|
||||
@@ -8557,7 +8558,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
send@1.2.0:
|
||||
send@1.2.1:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
encodeurl: 2.0.0
|
||||
@@ -8582,12 +8583,12 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
serve-static@2.2.0:
|
||||
serve-static@2.2.1:
|
||||
dependencies:
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
parseurl: 1.3.3
|
||||
send: 1.2.0
|
||||
send: 1.2.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -8780,7 +8781,7 @@ snapshots:
|
||||
formidable: 3.5.4
|
||||
methods: 1.1.2
|
||||
mime: 2.6.0
|
||||
qs: 6.14.0
|
||||
qs: 6.14.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -9134,7 +9135,7 @@ snapshots:
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
zod-to-json-schema@3.25.0(zod@3.25.76):
|
||||
zod-to-json-schema@3.25.1(zod@3.25.76):
|
||||
dependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
|
||||
@@ -64,10 +64,10 @@ export const getAllServers = async (req: Request, res: Response): Promise<void>
|
||||
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<v
|
||||
typeof smartRouting.dbUrl === 'string' ||
|
||||
typeof smartRouting.openaiApiBaseUrl === 'string' ||
|
||||
typeof smartRouting.openaiApiKey === 'string' ||
|
||||
typeof smartRouting.openaiApiEmbeddingModel === 'string');
|
||||
typeof smartRouting.openaiApiEmbeddingModel === 'string' ||
|
||||
typeof smartRouting.progressiveDisclosure === 'boolean');
|
||||
|
||||
const hasMcpRouterUpdate =
|
||||
mcpRouter &&
|
||||
@@ -1117,6 +1118,9 @@ export const updateSystemConfig = async (req: Request, res: Response): Promise<v
|
||||
if (typeof smartRouting.openaiApiEmbeddingModel === 'string') {
|
||||
systemConfig.smartRouting.openaiApiEmbeddingModel = smartRouting.openaiApiEmbeddingModel;
|
||||
}
|
||||
if (typeof smartRouting.progressiveDisclosure === 'boolean') {
|
||||
systemConfig.smartRouting.progressiveDisclosure = smartRouting.progressiveDisclosure;
|
||||
}
|
||||
|
||||
// Check if we need to sync embeddings
|
||||
const isNowEnabled = systemConfig.smartRouting.enabled || false;
|
||||
|
||||
@@ -39,7 +39,7 @@ const validateBearerAuth = async (req: Request): Promise<boolean> => {
|
||||
return true;
|
||||
};
|
||||
|
||||
const readonlyAllowPaths = ['/tools/call/'];
|
||||
const readonlyAllowPaths = ['/tools/'];
|
||||
|
||||
const checkReadonly = (req: Request): boolean => {
|
||||
if (!defaultConfig.readonly) {
|
||||
|
||||
@@ -7,15 +7,15 @@ import {
|
||||
MCPRouterListToolsResponse,
|
||||
MCPRouterCallToolResponse,
|
||||
} from '../types/index.js';
|
||||
import { loadOriginalSettings } from '../config/index.js';
|
||||
|
||||
import { getSystemConfigDao } from '../dao/index.js';
|
||||
// MCPRouter API default base URL
|
||||
const DEFAULT_MCPROUTER_API_BASE = 'https://api.mcprouter.to/v1';
|
||||
|
||||
// Get MCPRouter API config from system configuration
|
||||
const getMCPRouterConfig = () => {
|
||||
const settings = loadOriginalSettings();
|
||||
const mcpRouterConfig = settings.systemConfig?.mcpRouter;
|
||||
const getMCPRouterConfig = async () => {
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
const mcpRouterConfig = systemConfig?.mcpRouter;
|
||||
|
||||
return {
|
||||
apiKey: mcpRouterConfig?.apiKey || process.env.MCPROUTER_API_KEY || '',
|
||||
@@ -27,8 +27,8 @@ const getMCPRouterConfig = () => {
|
||||
};
|
||||
|
||||
// Get axios config with MCPRouter headers
|
||||
const getAxiosConfig = (): AxiosRequestConfig => {
|
||||
const mcpRouterConfig = getMCPRouterConfig();
|
||||
const getAxiosConfig = async (): Promise<AxiosRequestConfig> => {
|
||||
const mcpRouterConfig = await getMCPRouterConfig();
|
||||
|
||||
return {
|
||||
headers: {
|
||||
@@ -43,8 +43,8 @@ const getAxiosConfig = (): AxiosRequestConfig => {
|
||||
// List all available cloud servers
|
||||
export const getCloudServers = async (): Promise<CloudServer[]> => {
|
||||
try {
|
||||
const axiosConfig = getAxiosConfig();
|
||||
const mcpRouterConfig = getMCPRouterConfig();
|
||||
const axiosConfig = await getAxiosConfig();
|
||||
const mcpRouterConfig = await getMCPRouterConfig();
|
||||
|
||||
const response = await axios.post<MCPRouterResponse<MCPRouterListServersResponse>>(
|
||||
`${mcpRouterConfig.baseUrl}/list-servers`,
|
||||
@@ -79,8 +79,8 @@ export const getCloudServerByName = async (name: string): Promise<CloudServer |
|
||||
// List tools for a specific cloud server
|
||||
export const getCloudServerTools = async (serverKey: string): Promise<CloudTool[]> => {
|
||||
try {
|
||||
const axiosConfig = getAxiosConfig();
|
||||
const mcpRouterConfig = getMCPRouterConfig();
|
||||
const axiosConfig = await getAxiosConfig();
|
||||
const mcpRouterConfig = await getMCPRouterConfig();
|
||||
|
||||
if (
|
||||
!axiosConfig.headers?.['Authorization'] ||
|
||||
@@ -116,8 +116,8 @@ export const callCloudServerTool = async (
|
||||
args: Record<string, any>,
|
||||
): Promise<MCPRouterCallToolResponse> => {
|
||||
try {
|
||||
const axiosConfig = getAxiosConfig();
|
||||
const mcpRouterConfig = getMCPRouterConfig();
|
||||
const axiosConfig = await getAxiosConfig();
|
||||
const mcpRouterConfig = await getMCPRouterConfig();
|
||||
|
||||
if (
|
||||
!axiosConfig.headers?.['Authorization'] ||
|
||||
|
||||
@@ -22,13 +22,20 @@ import { expandEnvVars, replaceEnvVars, getNameSeparator } from '../config/index
|
||||
import config from '../config/index.js';
|
||||
import { getGroup } from './sseService.js';
|
||||
import { getServersInGroup, getServerConfigInGroup } from './groupService.js';
|
||||
import { saveToolsAsVectorEmbeddings, searchToolsByVector } from './vectorSearchService.js';
|
||||
import { saveToolsAsVectorEmbeddings } from './vectorSearchService.js';
|
||||
import { OpenAPIClient } from '../clients/openapi.js';
|
||||
import { RequestContextService } from './requestContextService.js';
|
||||
import { getDataService } from './services.js';
|
||||
import { getServerDao, getSystemConfigDao, ServerConfigWithName } from '../dao/index.js';
|
||||
import { initializeAllOAuthClients } from './oauthService.js';
|
||||
import { createOAuthProvider } from './mcpOAuthProvider.js';
|
||||
import {
|
||||
initSmartRoutingService,
|
||||
getSmartRoutingTools,
|
||||
handleSearchToolsRequest,
|
||||
handleDescribeToolRequest,
|
||||
isSmartRoutingGroup,
|
||||
} from './smartRoutingService.js';
|
||||
|
||||
const servers: { [sessionId: string]: Server } = {};
|
||||
|
||||
@@ -84,9 +91,7 @@ const generateProxychainsConfig = (
|
||||
if (fs.existsSync(proxyConfig.configPath)) {
|
||||
return proxyConfig.configPath;
|
||||
}
|
||||
console.warn(
|
||||
`[${serverName}] Custom proxychains config not found: ${proxyConfig.configPath}`,
|
||||
);
|
||||
console.warn(`[${serverName}] Custom proxychains config not found: ${proxyConfig.configPath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -97,13 +102,19 @@ const generateProxychainsConfig = (
|
||||
}
|
||||
|
||||
const proxyType = proxyConfig.type || 'socks5';
|
||||
const proxyLine = proxyConfig.username && proxyConfig.password
|
||||
? `${proxyType} ${proxyConfig.host} ${proxyConfig.port} ${proxyConfig.username} ${proxyConfig.password}`
|
||||
: `${proxyType} ${proxyConfig.host} ${proxyConfig.port}`;
|
||||
const proxyLine =
|
||||
proxyConfig.username && proxyConfig.password
|
||||
? `${proxyType} ${proxyConfig.host} ${proxyConfig.port} ${proxyConfig.username} ${proxyConfig.password}`
|
||||
: `${proxyType} ${proxyConfig.host} ${proxyConfig.port}`;
|
||||
|
||||
const configContent = `# Proxychains4 configuration for MCP server: ${serverName}
|
||||
# Generated by MCPHub
|
||||
|
||||
localnet 127.0.0.0/255.0.0.0
|
||||
localnet 10.0.0.0/255.0.0.0
|
||||
localnet 172.16.0.0/255.240.0.0
|
||||
localnet 192.168.0.0/255.255.0.0
|
||||
|
||||
strict_chain
|
||||
proxy_dns
|
||||
remote_dns_subnet 224
|
||||
@@ -184,6 +195,9 @@ export const initUpstreamServers = async (): Promise<void> => {
|
||||
|
||||
// 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 => {
|
||||
@@ -778,7 +792,7 @@ export const getServersInfo = async (
|
||||
user?: any,
|
||||
): Promise<Omit<ServerInfo, 'client' | 'transport'>[]> => {
|
||||
const dataService = getDataService();
|
||||
|
||||
|
||||
// Get paginated or all server configurations from DAO
|
||||
// If pagination is used with a non-admin user, filtering is already done at DAO level
|
||||
const isPaginated = limit !== undefined && page !== undefined;
|
||||
@@ -824,9 +838,10 @@ export const getServersInfo = async (
|
||||
// Apply user filtering only when NOT using pagination (pagination already filtered at DAO level)
|
||||
// Or when no pagination parameters provided (backward compatibility)
|
||||
const shouldApplyUserFilter = !isPaginated;
|
||||
const filterServerInfos: ServerInfo[] = shouldApplyUserFilter && dataService.filterData
|
||||
? dataService.filterData(filteredServerInfos, user)
|
||||
: filteredServerInfos;
|
||||
const filterServerInfos: ServerInfo[] =
|
||||
shouldApplyUserFilter && dataService.filterData
|
||||
? dataService.filterData(filteredServerInfos, user)
|
||||
: filteredServerInfos;
|
||||
|
||||
const infos = filterServerInfos
|
||||
.filter((info) => requestedServerNames.has(info.name)) // Only include requested servers
|
||||
@@ -1076,89 +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'],
|
||||
},
|
||||
},
|
||||
{
|
||||
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'],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
if (isSmartRoutingGroup(group)) {
|
||||
return getSmartRoutingTools(group);
|
||||
}
|
||||
|
||||
// Need to filter servers based on group asynchronously
|
||||
@@ -1210,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
|
||||
|
||||
525
src/services/smartRoutingService.ts
Normal file
525
src/services/smartRoutingService.ts
Normal file
@@ -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<Tool[]>;
|
||||
let filterToolsByGroupFn: (
|
||||
group: string | undefined,
|
||||
serverName: string,
|
||||
tools: Tool[],
|
||||
) => Promise<Tool[]>;
|
||||
|
||||
/**
|
||||
* Initialize the smart routing service with references to mcpService functions
|
||||
*/
|
||||
export const initSmartRoutingService = (
|
||||
getServerInfos: () => ServerInfo[],
|
||||
filterToolsByConfig: (serverName: string, tools: Tool[]) => Promise<Tool[]>,
|
||||
filterToolsByGroup: (
|
||||
group: string | undefined,
|
||||
serverName: string,
|
||||
tools: Tool[],
|
||||
) => Promise<Tool[]>,
|
||||
) => {
|
||||
// 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<any> => {
|
||||
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<any> => {
|
||||
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);
|
||||
};
|
||||
@@ -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<SmartRoutingConfig> {
|
||||
'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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user