Compare commits

..

8 Commits

Author SHA1 Message Date
samanhappy
fdb3a6af42 fix: update react-router-dom to version 7.12.0 and add qs dependency (#558) 2026-01-09 21:35:27 +08:00
samanhappy
7d55d23577 Add progressive disclosure feature for smart routing tools (#551) 2026-01-09 21:25:54 +08:00
samanhappy
e6340e0e1e fix: use config dao instead of load settings (#557) 2026-01-09 21:11:56 +08:00
dependabot[bot]
b03eacdf09 chore(deps): bump @modelcontextprotocol/sdk from 1.25.1 to 1.25.2 (#554)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-08 09:00:50 +08:00
samanhappy
6b3a077a67 Enhance server data handling with allServers (#553) 2026-01-07 22:10:44 +08:00
samanhappy
3de56b30bd fix: update readonlyAllowPaths for correct authorization handling (#550) 2026-01-04 13:06:27 +08:00
Bryan Thompson
6a08f4bc5a feat: Add tool annotations for improved LLM tool understanding (#549)
Co-authored-by: triepod-ai <199543909+triepod-ai@users.noreply.github.com>
2026-01-03 10:20:17 +08:00
samanhappy
ef1bc0d305 fix: add localnet configuration for Proxychains4 (#547) 2026-01-02 13:42:00 +03:00
20 changed files with 1172 additions and 460 deletions

View File

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

View File

@@ -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>
{/* ## 性能优化
### 嵌入缓存

View File

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

View File

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

View File

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

View File

@@ -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) {

View File

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

View File

@@ -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}
/>

View File

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

View File

@@ -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}}",

View File

@@ -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}}",

View File

@@ -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
View File

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

View File

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

View File

@@ -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) {

View File

@@ -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'] ||

View File

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

View 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);
};

View File

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

View File

@@ -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',