Compare commits

...

14 Commits

Author SHA1 Message Date
samanhappy
687fd6448b fix: update getServerInfos handling to avoid stale references in smart routing service 2026-01-05 13:25:27 +08:00
samanhappy
8008b834ef feat: add progressive disclosure feature for smart routing tools to reduce token usage 2026-01-05 11:15:38 +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
samanhappy
b279a1a62c chore: update mcp sdk dependencies to latest versions (#546) 2026-01-01 22:41:46 +08:00
dependabot[bot]
760cc462b9 chore(deps-dev): bump tsx from 4.20.5 to 4.21.0 (#541)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-01 22:01:13 +08:00
dependabot[bot]
431bc8f6f8 chore(deps-dev): bump next from 15.5.9 to 16.1.1 (#543)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-01 22:00:51 +08:00
dependabot[bot]
fb6af75f5b chore(deps-dev): bump ts-jest from 29.4.1 to 29.4.6 (#540)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-01 22:00:17 +08:00
dependabot[bot]
33b440973f chore(deps): bump axios from 1.13.1 to 1.13.2 (#544)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-01 21:59:57 +08:00
dependabot[bot]
2d248e953e chore(deps): bump dotenv from 16.6.1 to 17.2.3 (#542)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-01 21:59:38 +08:00
samanhappy
d36c6ac5ad fix: rename DATABASE_URL to DB_URL for consistency across configurations (#545) 2026-01-01 21:58:11 +08:00
Zhyim
0be6c36e12 feat: implement pagination for server list with customizable items pe… (#534) 2026-01-01 13:36:09 +08:00
samanhappy
7f2fca9636 feat: add proxy configuration support for STDIO servers on Linux and macOS (#537) 2026-01-01 12:45:50 +08:00
42 changed files with 2175 additions and 772 deletions

View File

@@ -106,7 +106,7 @@ jobs:
# - name: Run integration tests
# run: |
# export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/mcphub_test"
# export DB_URL="postgresql://postgres:postgres@localhost:5432/mcphub_test"
# node test-integration.ts
# env:
# NODE_ENV: test

View File

@@ -106,7 +106,7 @@ services:
- NODE_ENV=production
- PORT=3000
- JWT_SECRET=${JWT_SECRET:-your-jwt-secret}
- DATABASE_URL=postgresql://mcphub:password@postgres:5432/mcphub
- DB_URL=postgresql://mcphub:password@postgres:5432/mcphub
volumes:
- ./mcp_settings.json:/app/mcp_settings.json:ro
- ./servers.json:/app/servers.json:ro
@@ -180,7 +180,7 @@ services:
- PORT=3000
- JWT_SECRET=${JWT_SECRET}
- JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-24h}
- DATABASE_URL=postgresql://mcphub:${POSTGRES_PASSWORD}@postgres:5432/mcphub
- DB_URL=postgresql://mcphub:${POSTGRES_PASSWORD}@postgres:5432/mcphub
- OPENAI_API_KEY=${OPENAI_API_KEY}
- REDIS_URL=redis://redis:6379
volumes:
@@ -293,7 +293,7 @@ services:
environment:
- NODE_ENV=development
- PORT=3000
- DATABASE_URL=postgresql://mcphub:password@postgres:5432/mcphub
- DB_URL=postgresql://mcphub:password@postgres:5432/mcphub
volumes:
- .:/app
- /app/node_modules

View File

@@ -259,6 +259,92 @@ MCPHub supports environment variable substitution using `${VAR_NAME}` syntax:
}
```
### Proxy Configuration (proxychains4)
MCPHub supports routing STDIO server network traffic through a proxy using **proxychains4**. This feature is available on **Linux and macOS only** (Windows is not supported).
<Note>
To use this feature, you must have `proxychains4` installed on your system:
- **Debian/Ubuntu**: `apt install proxychains4`
- **macOS**: `brew install proxychains-ng`
- **Arch Linux**: `pacman -S proxychains-ng`
</Note>
#### Basic Proxy Configuration
```json
{
"mcpServers": {
"fetch-via-proxy": {
"command": "uvx",
"args": ["mcp-server-fetch"],
"proxy": {
"enabled": true,
"type": "socks5",
"host": "127.0.0.1",
"port": 1080
}
}
}
}
```
#### Proxy Configuration Options
| Field | Type | Default | Description |
| ------------ | ------- | --------- | ------------------------------------------------ |
| `enabled` | boolean | `false` | Enable/disable proxy routing |
| `type` | string | `socks5` | Proxy protocol: `socks4`, `socks5`, or `http` |
| `host` | string | - | Proxy server hostname or IP address |
| `port` | number | - | Proxy server port |
| `username` | string | - | Proxy authentication username (optional) |
| `password` | string | - | Proxy authentication password (optional) |
| `configPath` | string | - | Path to custom proxychains4 config file |
#### Proxy with Authentication
```json
{
"mcpServers": {
"secure-server": {
"command": "npx",
"args": ["-y", "@example/mcp-server"],
"proxy": {
"enabled": true,
"type": "http",
"host": "proxy.example.com",
"port": 8080,
"username": "${PROXY_USER}",
"password": "${PROXY_PASSWORD}"
}
}
}
}
```
#### Using Custom proxychains4 Configuration
For advanced use cases, you can provide your own proxychains4 configuration file:
```json
{
"mcpServers": {
"custom-proxy-server": {
"command": "python",
"args": ["-m", "custom_mcp_server"],
"proxy": {
"enabled": true,
"configPath": "/etc/proxychains4/custom.conf"
}
}
}
}
```
<Tip>
When `configPath` is specified, all other proxy settings (`type`, `host`, `port`, etc.) are ignored, and the custom configuration file is used directly.
</Tip>
{/* ### Custom Server Scripts
#### Local Python Server

View File

@@ -47,7 +47,7 @@ PORT=3000
NODE_ENV=development
# Database Configuration
DATABASE_URL=postgresql://username:password@localhost:5432/mcphub
DB_URL=postgresql://username:password@localhost:5432/mcphub
# JWT Configuration
JWT_SECRET=your-secret-key

View File

@@ -69,7 +69,7 @@ Smart Routing requires additional setup compared to basic MCPHub usage:
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://mcphub:password@postgres:5432/mcphub
- DB_URL=postgresql://mcphub:password@postgres:5432/mcphub
- OPENAI_API_KEY=your_openai_api_key
- ENABLE_SMART_ROUTING=true
depends_on:
@@ -114,7 +114,7 @@ Smart Routing requires additional setup compared to basic MCPHub usage:
2. **Set Environment Variables**:
```bash
export DATABASE_URL="postgresql://mcphub:your_password@localhost:5432/mcphub"
export DB_URL="postgresql://mcphub:your_password@localhost:5432/mcphub"
export OPENAI_API_KEY="your_openai_api_key"
export ENABLE_SMART_ROUTING="true"
```
@@ -178,7 +178,7 @@ Smart Routing requires additional setup compared to basic MCPHub usage:
- name: mcphub
image: samanhappy/mcphub:latest
env:
- name: DATABASE_URL
- name: DB_URL
value: "postgresql://mcphub:password@postgres:5432/mcphub"
- name: OPENAI_API_KEY
valueFrom:
@@ -202,7 +202,7 @@ Configure Smart Routing with these environment variables:
```bash
# Required
DATABASE_URL=postgresql://user:password@host:5432/database
DB_URL=postgresql://user:password@host:5432/database
OPENAI_API_KEY=your_openai_api_key
# Optional
@@ -219,10 +219,10 @@ EMBEDDING_BATCH_SIZE=100
<Accordion title="Database Configuration">
```bash
# Full PostgreSQL connection string
DATABASE_URL=postgresql://username:password@host:port/database?schema=public
DB_URL=postgresql://username:password@host:port/database?schema=public
# SSL configuration for cloud databases
DATABASE_URL=postgresql://user:pass@host:5432/db?sslmode=require
DB_URL=postgresql://user:pass@host:5432/db?sslmode=require
# Connection pool settings
DATABASE_POOL_SIZE=20
@@ -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:
@@ -673,11 +796,11 @@ curl -X POST http://localhost:3000/api/smart-routing/feedback \
**Solutions:**
1. Verify PostgreSQL is running
2. Check DATABASE_URL format
2. Check DB_URL format
3. Ensure pgvector extension is installed
4. Test connection manually:
```bash
psql $DATABASE_URL -c "SELECT 1;"
psql $DB_URL -c "SELECT 1;"
```
</Accordion>

View File

@@ -445,7 +445,7 @@ Set the following environment variables:
```bash
# Database connection
DATABASE_URL=postgresql://mcphub:your_password@localhost:5432/mcphub
DB_URL=postgresql://mcphub:your_password@localhost:5432/mcphub
# OpenAI API for embeddings
OPENAI_API_KEY=your_openai_api_key
@@ -563,10 +563,10 @@ curl -X POST http://localhost:3000/mcp \
**Database connection failed:**
```bash
# Test database connection
psql $DATABASE_URL -c "SELECT 1;"
psql $DB_URL -c "SELECT 1;"
# Check if pgvector is installed
psql $DATABASE_URL -c "CREATE EXTENSION IF NOT EXISTS vector;"
psql $DB_URL -c "CREATE EXTENSION IF NOT EXISTS vector;"
```
**Embedding service errors:**

View File

@@ -106,7 +106,7 @@ services:
- NODE_ENV=production
- PORT=3000
- JWT_SECRET=${JWT_SECRET:-your-jwt-secret}
- DATABASE_URL=postgresql://mcphub:password@postgres:5432/mcphub
- DB_URL=postgresql://mcphub:password@postgres:5432/mcphub
volumes:
- ./mcp_settings.json:/app/mcp_settings.json:ro
- ./servers.json:/app/servers.json:ro
@@ -180,7 +180,7 @@ services:
- PORT=3000
- JWT_SECRET=${JWT_SECRET}
- JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-24h}
- DATABASE_URL=postgresql://mcphub:${POSTGRES_PASSWORD}@postgres:5432/mcphub
- DB_URL=postgresql://mcphub:${POSTGRES_PASSWORD}@postgres:5432/mcphub
- OPENAI_API_KEY=${OPENAI_API_KEY}
- REDIS_URL=redis://redis:6379
volumes:
@@ -293,7 +293,7 @@ services:
environment:
- NODE_ENV=development
- PORT=3000
- DATABASE_URL=postgresql://mcphub:password@postgres:5432/mcphub
- DB_URL=postgresql://mcphub:password@postgres:5432/mcphub
volumes:
- .:/app
- /app/node_modules

View File

@@ -290,7 +290,7 @@ MCPHub 支持使用 `${VAR_NAME}` 语法进行环境变量替换:
"command": "python",
"args": ["-m", "db_server"],
"env": {
"DB_URL": "${NODE_ENV:development == 'production' ? DATABASE_URL : DEV_DATABASE_URL}"
"DB_URL": "${NODE_ENV:development == 'production' ? DB_URL : DEV_DB_URL}"
}
}
}

View File

@@ -47,7 +47,7 @@ PORT=3000
NODE_ENV=development
# 数据库配置
DATABASE_URL=postgresql://username:password@localhost:5432/mcphub
DB_URL=postgresql://username:password@localhost:5432/mcphub
# JWT 配置
JWT_SECRET=your-secret-key

View File

@@ -480,7 +480,7 @@ docker run -d \
--name mcphub \
-p 3000:3000 \
-e NODE_ENV=production \
-e DATABASE_URL=postgresql://user:pass@host:5432/mcphub \
-e DB_URL=postgresql://user:pass@host:5432/mcphub \
-e JWT_SECRET=your-secret-key \
mcphub/server:latest
@@ -504,7 +504,7 @@ docker run -d \
--name mcphub \
-p 3000:3000 \
-e NODE_ENV=production \
-e DATABASE_URL=postgresql://user:pass@host:5432/mcphub \
-e DB_URL=postgresql://user:pass@host:5432/mcphub \
-e JWT_SECRET=your-secret-key \
mcphub/server:latest

View File

@@ -159,7 +159,7 @@ services:
- '3000:3000'
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://user:pass@db:5432/mcphub
- DB_URL=postgresql://user:pass@db:5432/mcphub
```
````
@@ -172,7 +172,7 @@ services:
- '3000:3000'
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://user:pass@db:5432/mcphub
- DB_URL=postgresql://user:pass@db:5432/mcphub
```
### 终端命令

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>
{/* ## 性能优化
### 嵌入缓存
@@ -245,11 +409,11 @@ curl -X POST http://localhost:3000/api/smart-routing/feedback \
**解决方案:**
1. 验证 PostgreSQL 是否正在运行
2. 检查 DATABASE_URL 格式
2. 检查 DB_URL 格式
3. 确保安装了 pgvector 扩展
4. 手动测试连接:
```bash
psql $DATABASE_URL -c "SELECT 1;"
psql $DB_URL -c "SELECT 1;"
```
</Accordion>

View File

@@ -420,7 +420,7 @@ description: '各种平台的详细安装说明'
```bash
# 数据库连接
DATABASE_URL=postgresql://mcphub:your_password@localhost:5432/mcphub
DB_URL=postgresql://mcphub:your_password@localhost:5432/mcphub
# 用于嵌入的 OpenAI API
OPENAI_API_KEY=your_openai_api_key
@@ -538,10 +538,10 @@ curl -X POST http://localhost:3000/mcp \
**数据库连接失败:**
```bash
# 测试数据库连接
psql $DATABASE_URL -c "SELECT 1;"
psql $DB_URL -c "SELECT 1;"
# 检查是否安装了 pgvector
psql $DATABASE_URL -c "CREATE EXTENSION IF NOT EXISTS vector;"
psql $DB_URL -c "CREATE EXTENSION IF NOT EXISTS vector;"
```
**嵌入服务错误:**

View File

@@ -28,7 +28,48 @@
"env": {
"API_KEY": "${MY_API_KEY}",
"DEBUG": "${DEBUG_MODE}",
"DATABASE_URL": "${DATABASE_URL}"
"DB_URL": "${DB_URL}"
}
},
"example-stdio-with-proxy": {
"type": "stdio",
"command": "uvx",
"args": [
"mcp-server-fetch"
],
"proxy": {
"enabled": true,
"type": "socks5",
"host": "${PROXY_HOST}",
"port": 1080
}
},
"example-stdio-with-auth-proxy": {
"type": "stdio",
"command": "npx",
"args": [
"-y",
"@example/mcp-server"
],
"proxy": {
"enabled": true,
"type": "http",
"host": "${HTTP_PROXY_HOST}",
"port": 8080,
"username": "${PROXY_USERNAME}",
"password": "${PROXY_PASSWORD}"
}
},
"example-stdio-with-custom-proxy-config": {
"type": "stdio",
"command": "python",
"args": [
"-m",
"custom_mcp_server"
],
"proxy": {
"enabled": true,
"configPath": "/etc/proxychains4/custom.conf"
}
},
"example-openapi-server": {
@@ -55,7 +96,10 @@
"clientId": "${OAUTH_CLIENT_ID}",
"clientSecret": "${OAUTH_CLIENT_SECRET}",
"accessToken": "${OAUTH_ACCESS_TOKEN}",
"scopes": ["read", "write"]
"scopes": [
"read",
"write"
]
}
}
},
@@ -77,4 +121,4 @@
"baseUrl": "${MCPROUTER_BASE_URL}"
}
}
}
}

View File

@@ -1,99 +1,103 @@
import { useState, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Group, Server, IGroupServerConfig } from '@/types'
import { Edit, Trash, Copy, Check, Link, FileCode, DropdownIcon, Wrench } from '@/components/icons/LucideIcons'
import DeleteDialog from '@/components/ui/DeleteDialog'
import { useToast } from '@/contexts/ToastContext'
import { useSettingsData } from '@/hooks/useSettingsData'
import { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Group, Server, IGroupServerConfig } from '@/types';
import {
Edit,
Trash,
Copy,
Check,
Link,
FileCode,
DropdownIcon,
Wrench,
} from '@/components/icons/LucideIcons';
import DeleteDialog from '@/components/ui/DeleteDialog';
import { useToast } from '@/contexts/ToastContext';
import { useSettingsData } from '@/hooks/useSettingsData';
interface GroupCardProps {
group: Group
servers: Server[]
onEdit: (group: Group) => void
onDelete: (groupId: string) => void
group: Group;
servers: Server[];
onEdit: (group: Group) => void;
onDelete: (groupId: string) => void;
}
const GroupCard = ({
group,
servers,
onEdit,
onDelete
}: GroupCardProps) => {
const { t } = useTranslation()
const { showToast } = useToast()
const { installConfig } = useSettingsData()
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [copied, setCopied] = useState(false)
const [showCopyDropdown, setShowCopyDropdown] = useState(false)
const [expandedServer, setExpandedServer] = useState<string | null>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const GroupCard = ({ group, servers, onEdit, onDelete }: GroupCardProps) => {
const { t } = useTranslation();
const { showToast } = useToast();
const { installConfig } = useSettingsData();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [copied, setCopied] = useState(false);
const [showCopyDropdown, setShowCopyDropdown] = useState(false);
const [expandedServer, setExpandedServer] = useState<string | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setShowCopyDropdown(false)
setShowCopyDropdown(false);
}
}
};
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [])
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
const handleEdit = () => {
onEdit(group)
}
onEdit(group);
};
const handleDelete = () => {
setShowDeleteDialog(true)
}
setShowDeleteDialog(true);
};
const handleConfirmDelete = () => {
onDelete(group.id)
setShowDeleteDialog(false)
}
onDelete(group.id);
setShowDeleteDialog(false);
};
const copyToClipboard = (text: string) => {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(() => {
setCopied(true)
setShowCopyDropdown(false)
showToast(t('common.copySuccess'), 'success')
setTimeout(() => setCopied(false), 2000)
})
setCopied(true);
setShowCopyDropdown(false);
showToast(t('common.copySuccess'), 'success');
setTimeout(() => setCopied(false), 2000);
});
} else {
// Fallback for HTTP or unsupported clipboard API
const textArea = document.createElement('textarea')
textArea.value = text
const textArea = document.createElement('textarea');
textArea.value = text;
// Avoid scrolling to bottom
textArea.style.position = 'fixed'
textArea.style.left = '-9999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
textArea.style.position = 'fixed';
textArea.style.left = '-9999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy')
setCopied(true)
setShowCopyDropdown(false)
showToast(t('common.copySuccess'), 'success')
setTimeout(() => setCopied(false), 2000)
document.execCommand('copy');
setCopied(true);
setShowCopyDropdown(false);
showToast(t('common.copySuccess'), 'success');
setTimeout(() => setCopied(false), 2000);
} catch (err) {
showToast(t('common.copyFailed') || 'Copy failed', 'error')
console.error('Copy to clipboard failed:', err)
showToast(t('common.copyFailed') || 'Copy failed', 'error');
console.error('Copy to clipboard failed:', err);
}
document.body.removeChild(textArea)
document.body.removeChild(textArea);
}
}
};
const handleCopyId = () => {
copyToClipboard(group.id)
}
copyToClipboard(group.id);
};
const handleCopyUrl = () => {
copyToClipboard(`${installConfig.baseUrl}/mcp/${group.id}`)
}
copyToClipboard(`${installConfig.baseUrl}/mcp/${group.id}`);
};
const handleCopyJson = () => {
const jsonConfig = {
@@ -101,23 +105,23 @@ const GroupCard = ({
mcphub: {
url: `${installConfig.baseUrl}/mcp/${group.id}`,
headers: {
Authorization: "Bearer <your-access-token>"
}
}
}
}
copyToClipboard(JSON.stringify(jsonConfig, null, 2))
}
Authorization: 'Bearer <your-access-token>',
},
},
},
};
copyToClipboard(JSON.stringify(jsonConfig, null, 2));
};
// Helper function to normalize group servers to get server names
const getServerNames = (servers: string[] | IGroupServerConfig[]): string[] => {
return servers.map(server => typeof server === 'string' ? server : server.name);
return servers.map((server) => (typeof server === 'string' ? server : server.name));
};
// Helper function to get server configuration
const getServerConfig = (serverName: string): IGroupServerConfig | undefined => {
const server = group.servers.find(s =>
typeof s === 'string' ? s === serverName : s.name === serverName
const server = group.servers.find((s) =>
typeof s === 'string' ? s === serverName : s.name === serverName,
);
if (typeof server === 'string') {
return { name: server, tools: 'all' };
@@ -127,11 +131,11 @@ const GroupCard = ({
// Get servers that belong to this group
const serverNames = getServerNames(group.servers);
const groupServers = servers.filter(server => serverNames.includes(server.name));
const groupServers = servers.filter((server) => serverNames.includes(server.name));
return (
<div className="bg-white shadow rounded-lg p-6 ">
<div className="flex justify-between items-center mb-4">
<div className="bg-white shadow rounded-lg p-4">
<div className="flex justify-between items-center">
<div>
<div className="flex items-center">
<h2 className="text-xl font-semibold text-gray-800">{group.name}</h2>
@@ -175,9 +179,7 @@ const GroupCard = ({
</div>
</div>
</div>
{group.description && (
<p className="text-gray-600 text-sm mt-1">{group.description}</p>
)}
{group.description && <p className="text-gray-600 text-sm mt-1">{group.description}</p>}
</div>
<div className="flex items-center space-x-3">
<div className="bg-blue-50 text-blue-700 px-3 py-1 rounded-full text-sm btn-secondary">
@@ -200,17 +202,19 @@ const GroupCard = ({
</div>
</div>
<div className="mt-4">
<div className="">
{groupServers.length === 0 ? (
<p className="text-gray-500 italic">{t('groups.noServers')}</p>
) : (
<div className="flex flex-wrap gap-2">
{groupServers.map(server => {
{groupServers.map((server) => {
const serverConfig = getServerConfig(server.name);
const hasToolRestrictions = serverConfig && serverConfig.tools !== 'all' && Array.isArray(serverConfig.tools);
const toolCount = hasToolRestrictions && Array.isArray(serverConfig?.tools)
? serverConfig.tools.length
: (server.tools?.length || 0); // Show total tool count when all tools are selected
const hasToolRestrictions =
serverConfig && serverConfig.tools !== 'all' && Array.isArray(serverConfig.tools);
const toolCount =
hasToolRestrictions && Array.isArray(serverConfig?.tools)
? serverConfig.tools.length
: server.tools?.length || 0; // Show total tool count when all tools are selected
const isExpanded = expandedServer === server.name;
@@ -219,7 +223,7 @@ const GroupCard = ({
if (hasToolRestrictions && Array.isArray(serverConfig?.tools)) {
return serverConfig.tools;
} else if (server.tools && server.tools.length > 0) {
return server.tools.map(tool => tool.name);
return server.tools.map((tool) => tool.name);
}
return [];
};
@@ -235,9 +239,15 @@ const GroupCard = ({
onClick={handleServerClick}
>
<span className="font-medium text-gray-700 text-sm">{server.name}</span>
<span className={`inline-block h-2 w-2 rounded-full ${server.status === 'connected' ? 'bg-green-500' :
server.status === 'connecting' ? 'bg-yellow-500' : 'bg-red-500'
}`}></span>
<span
className={`inline-block h-2 w-2 rounded-full ${
server.status === 'connected'
? 'bg-green-500'
: server.status === 'connecting'
? 'bg-yellow-500'
: 'bg-red-500'
}`}
></span>
{toolCount > 0 && (
<span className="text-xs text-blue-600 bg-blue-100 px-2 py-0.5 rounded flex items-center gap-1">
<Wrench size={12} />
@@ -278,7 +288,7 @@ const GroupCard = ({
isGroup={true}
/>
</div>
)
}
);
};
export default GroupCard
export default GroupCard;

View File

@@ -18,7 +18,14 @@ interface ServerCardProps {
onReload?: (server: Server) => Promise<boolean>;
}
const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh, onReload }: ServerCardProps) => {
const ServerCard = ({
server,
onRemove,
onEdit,
onToggle,
onRefresh,
onReload,
}: ServerCardProps) => {
const { t } = useTranslation();
const { showToast } = useToast();
const [isExpanded, setIsExpanded] = useState(false);
@@ -232,10 +239,10 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh, onReload }:
return (
<>
<div
className={`bg-white shadow rounded-lg p-6 mb-6 page-card transition-all duration-200 ${server.enabled === false ? 'opacity-60' : ''}`}
className={`bg-white shadow rounded-lg mb-6 page-card transition-all duration-200 ${server.enabled === false ? 'opacity-60' : ''}`}
>
<div
className="flex justify-between items-center cursor-pointer"
className="flex justify-between items-center cursor-pointer p-4"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center space-x-3">
@@ -385,9 +392,9 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh, onReload }:
{isExpanded && (
<>
{server.tools && (
<div className="mt-6">
<div className="px-4">
<h6
className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}
className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-2`}
>
{t('server.tools')}
</h6>
@@ -405,9 +412,9 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh, onReload }:
)}
{server.prompts && (
<div className="mt-6">
<div className="px-4 pb-2">
<h6
className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}
className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'}`}
>
{t('server.prompts')}
</h6>

View File

@@ -1,16 +1,20 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
disabled?: boolean;
}
const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalPages,
onPageChange
onPageChange,
disabled = false
}) => {
const { t } = useTranslation();
// Generate page buttons
const getPageButtons = () => {
const buttons = [];
@@ -95,26 +99,26 @@ const Pagination: React.FC<PaginationProps> = ({
<div className="flex justify-center items-center my-6">
<button
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className={`px-3 py-1 rounded mr-2 ${currentPage === 1
disabled={disabled || currentPage === 1}
className={`px-3 py-1 rounded mr-2 ${disabled || currentPage === 1
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
}`}
>
&laquo; Prev
&laquo; {t('common.previous')}
</button>
<div className="flex">{getPageButtons()}</div>
<button
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className={`px-3 py-1 rounded ml-2 ${currentPage === totalPages
disabled={disabled || currentPage === totalPages}
className={`px-3 py-1 rounded ml-2 ${disabled || currentPage === totalPages
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
}`}
>
Next &raquo;
{t('common.next')} &raquo;
</button>
</div>
);

View File

@@ -171,9 +171,9 @@ const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCar
};
return (
<div className="bg-white border border-gray-200 shadow rounded-lg p-4 mb-4">
<div className="bg-white border border-gray-200 shadow rounded-lg mb-4">
<div
className="flex justify-between items-center cursor-pointer"
className="flex justify-between items-center p-2 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex-1">

View File

@@ -1,19 +1,27 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Tool } from '@/types'
import { ChevronDown, ChevronRight, Play, Loader, Edit, Check, Copy } from '@/components/icons/LucideIcons'
import { callTool, ToolCallResult, updateToolDescription } from '@/services/toolService'
import { useSettingsData } from '@/hooks/useSettingsData'
import { useToast } from '@/contexts/ToastContext'
import { Switch } from './ToggleGroup'
import DynamicForm from './DynamicForm'
import ToolResult from './ToolResult'
import { useState, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Tool } from '@/types';
import {
ChevronDown,
ChevronRight,
Play,
Loader,
Edit,
Check,
Copy,
} from '@/components/icons/LucideIcons';
import { callTool, ToolCallResult, updateToolDescription } from '@/services/toolService';
import { useSettingsData } from '@/hooks/useSettingsData';
import { useToast } from '@/contexts/ToastContext';
import { Switch } from './ToggleGroup';
import DynamicForm from './DynamicForm';
import ToolResult from './ToolResult';
interface ToolCardProps {
server: string
tool: Tool
onToggle?: (toolName: string, enabled: boolean) => void
onDescriptionUpdate?: (toolName: string, description: string) => void
server: string;
tool: Tool;
onToggle?: (toolName: string, enabled: boolean) => void;
onDescriptionUpdate?: (toolName: string, description: string) => void;
}
// Helper to check for "empty" values
@@ -26,165 +34,173 @@ function isEmptyValue(value: any): boolean {
}
const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps) => {
const { t } = useTranslation()
const { showToast } = useToast()
const { nameSeparator } = useSettingsData()
const [isExpanded, setIsExpanded] = useState(false)
const [showRunForm, setShowRunForm] = useState(false)
const [isRunning, setIsRunning] = useState(false)
const [result, setResult] = useState<ToolCallResult | null>(null)
const [isEditingDescription, setIsEditingDescription] = useState(false)
const [customDescription, setCustomDescription] = useState(tool.description || '')
const descriptionInputRef = useRef<HTMLInputElement>(null)
const descriptionTextRef = useRef<HTMLSpanElement>(null)
const [textWidth, setTextWidth] = useState<number>(0)
const [copiedToolName, setCopiedToolName] = useState(false)
const { t } = useTranslation();
const { showToast } = useToast();
const { nameSeparator } = useSettingsData();
const [isExpanded, setIsExpanded] = useState(false);
const [showRunForm, setShowRunForm] = useState(false);
const [isRunning, setIsRunning] = useState(false);
const [result, setResult] = useState<ToolCallResult | null>(null);
const [isEditingDescription, setIsEditingDescription] = useState(false);
const [customDescription, setCustomDescription] = useState(tool.description || '');
const descriptionInputRef = useRef<HTMLInputElement>(null);
const descriptionTextRef = useRef<HTMLSpanElement>(null);
const [textWidth, setTextWidth] = useState<number>(0);
const [copiedToolName, setCopiedToolName] = useState(false);
// Focus the input when editing mode is activated
useEffect(() => {
if (isEditingDescription && descriptionInputRef.current) {
descriptionInputRef.current.focus()
descriptionInputRef.current.focus();
// Set input width to match text width
if (textWidth > 0) {
descriptionInputRef.current.style.width = `${textWidth + 20}px` // Add some padding
descriptionInputRef.current.style.width = `${textWidth + 20}px`; // Add some padding
}
}
}, [isEditingDescription, textWidth])
}, [isEditingDescription, textWidth]);
// Measure text width when not editing
useEffect(() => {
if (!isEditingDescription && descriptionTextRef.current) {
setTextWidth(descriptionTextRef.current.offsetWidth)
setTextWidth(descriptionTextRef.current.offsetWidth);
}
}, [isEditingDescription, customDescription])
}, [isEditingDescription, customDescription]);
// Generate a unique key for localStorage based on tool name and server
const getStorageKey = useCallback(() => {
return `mcphub_tool_form_${server ? `${server}_` : ''}${tool.name}`
}, [tool.name, server])
return `mcphub_tool_form_${server ? `${server}_` : ''}${tool.name}`;
}, [tool.name, server]);
// Clear form data from localStorage
const clearStoredFormData = useCallback(() => {
localStorage.removeItem(getStorageKey())
}, [getStorageKey])
localStorage.removeItem(getStorageKey());
}, [getStorageKey]);
const handleToggle = (enabled: boolean) => {
if (onToggle) {
onToggle(tool.name, enabled)
onToggle(tool.name, enabled);
}
}
};
const handleDescriptionEdit = () => {
setIsEditingDescription(true)
}
setIsEditingDescription(true);
};
const handleDescriptionSave = async () => {
try {
const result = await updateToolDescription(server, tool.name, customDescription)
const result = await updateToolDescription(server, tool.name, customDescription);
if (result.success) {
setIsEditingDescription(false)
setIsEditingDescription(false);
if (onDescriptionUpdate) {
onDescriptionUpdate(tool.name, customDescription)
onDescriptionUpdate(tool.name, customDescription);
}
} else {
// Revert on error
setCustomDescription(tool.description || '')
console.error('Failed to update tool description:', result.error)
setCustomDescription(tool.description || '');
console.error('Failed to update tool description:', result.error);
}
} catch (error) {
console.error('Error updating tool description:', error)
setCustomDescription(tool.description || '')
setIsEditingDescription(false)
console.error('Error updating tool description:', error);
setCustomDescription(tool.description || '');
setIsEditingDescription(false);
}
}
};
const handleDescriptionChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setCustomDescription(e.target.value)
}
setCustomDescription(e.target.value);
};
const handleDescriptionKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleDescriptionSave()
handleDescriptionSave();
} else if (e.key === 'Escape') {
setCustomDescription(tool.description || '')
setIsEditingDescription(false)
setCustomDescription(tool.description || '');
setIsEditingDescription(false);
}
}
};
const handleCopyToolName = async (e: React.MouseEvent) => {
e.stopPropagation()
e.stopPropagation();
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(tool.name)
setCopiedToolName(true)
showToast(t('common.copySuccess'), 'success')
setTimeout(() => setCopiedToolName(false), 2000)
await navigator.clipboard.writeText(tool.name);
setCopiedToolName(true);
showToast(t('common.copySuccess'), 'success');
setTimeout(() => setCopiedToolName(false), 2000);
} else {
// Fallback for HTTP or unsupported clipboard API
const textArea = document.createElement('textarea')
textArea.value = tool.name
textArea.style.position = 'fixed'
textArea.style.left = '-9999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
const textArea = document.createElement('textarea');
textArea.value = tool.name;
textArea.style.position = 'fixed';
textArea.style.left = '-9999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy')
setCopiedToolName(true)
showToast(t('common.copySuccess'), 'success')
setTimeout(() => setCopiedToolName(false), 2000)
document.execCommand('copy');
setCopiedToolName(true);
showToast(t('common.copySuccess'), 'success');
setTimeout(() => setCopiedToolName(false), 2000);
} catch (err) {
showToast(t('common.copyFailed'), 'error')
console.error('Copy to clipboard failed:', err)
showToast(t('common.copyFailed'), 'error');
console.error('Copy to clipboard failed:', err);
}
document.body.removeChild(textArea)
document.body.removeChild(textArea);
}
} catch (error) {
showToast(t('common.copyFailed'), 'error')
console.error('Copy to clipboard failed:', error)
showToast(t('common.copyFailed'), 'error');
console.error('Copy to clipboard failed:', error);
}
}
};
const handleRunTool = async (arguments_: Record<string, any>) => {
setIsRunning(true)
setIsRunning(true);
try {
// filter empty values
arguments_ = Object.fromEntries(Object.entries(arguments_).filter(([_, v]) => !isEmptyValue(v)))
const result = await callTool({
toolName: tool.name,
arguments: arguments_,
}, server)
arguments_ = Object.fromEntries(
Object.entries(arguments_).filter(([_, v]) => !isEmptyValue(v)),
);
const result = await callTool(
{
toolName: tool.name,
arguments: arguments_,
},
server,
);
setResult(result)
setResult(result);
// Clear form data on successful submission
// clearStoredFormData()
} catch (error) {
setResult({
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred',
})
});
} finally {
setIsRunning(false)
setIsRunning(false);
}
}
};
const handleCancelRun = () => {
setShowRunForm(false)
setShowRunForm(false);
// Clear form data when cancelled
clearStoredFormData()
setResult(null)
}
clearStoredFormData();
setResult(null);
};
const handleCloseResult = () => {
setResult(null)
}
setResult(null);
};
return (
<div className="bg-white border border-gray-200 shadow rounded-lg p-4 mb-4">
<div className="bg-white border border-gray-200 shadow rounded-lg mb-4">
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
className="flex justify-between items-center cursor-pointer p-2"
onClick={(e) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
}}
>
<div className="flex-1">
<h3 className="text-lg font-medium text-gray-900 inline-flex items-center">
@@ -194,11 +210,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
onClick={handleCopyToolName}
title={t('common.copy')}
>
{copiedToolName ? (
<Check size={16} className="text-green-500" />
) : (
<Copy size={16} />
)}
{copiedToolName ? <Check size={16} className="text-green-500" /> : <Copy size={16} />}
</button>
<span className="ml-2 text-sm font-normal text-gray-600 inline-flex items-center">
{isEditingDescription ? (
@@ -213,14 +225,14 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
onClick={(e) => e.stopPropagation()}
style={{
minWidth: '100px',
width: textWidth > 0 ? `${textWidth + 20}px` : 'auto'
width: textWidth > 0 ? `${textWidth + 20}px` : 'auto',
}}
/>
<button
className="ml-2 p-1 text-green-600 hover:text-green-800 cursor-pointer transition-colors"
onClick={(e) => {
e.stopPropagation()
handleDescriptionSave()
e.stopPropagation();
handleDescriptionSave();
}}
>
<Check size={16} />
@@ -228,12 +240,14 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
</>
) : (
<>
<span ref={descriptionTextRef}>{customDescription || t('tool.noDescription')}</span>
<span ref={descriptionTextRef}>
{customDescription || t('tool.noDescription')}
</span>
<button
className="ml-2 p-1 text-gray-500 hover:text-blue-600 cursor-pointer transition-colors"
onClick={(e) => {
e.stopPropagation()
handleDescriptionEdit()
e.stopPropagation();
handleDescriptionEdit();
}}
>
<Edit size={14} />
@@ -244,10 +258,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
</h3>
</div>
<div className="flex items-center space-x-2">
<div
className="flex items-center space-x-2"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center space-x-2" onClick={(e) => e.stopPropagation()}>
<Switch
checked={tool.enabled ?? true}
onCheckedChange={handleToggle}
@@ -256,18 +267,14 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
</div>
<button
onClick={(e) => {
e.stopPropagation()
setIsExpanded(true) // Ensure card is expanded when showing run form
setShowRunForm(true)
e.stopPropagation();
setIsExpanded(true); // Ensure card is expanded when showing run form
setShowRunForm(true);
}}
className="flex items-center space-x-1 px-3 py-1 text-sm text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-md transition-colors btn-primary"
disabled={isRunning || !tool.enabled}
>
{isRunning ? (
<Loader size={14} className="animate-spin" />
) : (
<Play size={14} />
)}
{isRunning ? <Loader size={14} className="animate-spin" /> : <Play size={14} />}
<span>{isRunning ? t('tool.running') : t('tool.run')}</span>
</button>
<button className="text-gray-400 hover:text-gray-600">
@@ -297,7 +304,9 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
onCancel={handleCancelRun}
loading={isRunning}
storageKey={getStorageKey()}
title={t('tool.runToolWithName', { name: tool.name.replace(server + nameSeparator, '') })}
title={t('tool.runToolWithName', {
name: tool.name.replace(server + nameSeparator, ''),
})}
/>
{/* Tool Result */}
{result && (
@@ -307,12 +316,10 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
)}
</div>
)}
</div>
)}
</div>
)
}
);
};
export default ToolCard
export default ToolCard;

View File

@@ -17,6 +17,16 @@ const CONFIG = {
},
};
// Pagination info type
interface PaginationInfo {
page: number;
limit: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPrevPage: boolean;
}
// Context type definition
interface ServerContextType {
servers: Server[];
@@ -24,6 +34,11 @@ interface ServerContextType {
setError: (error: string | null) => void;
isLoading: boolean;
fetchAttempts: number;
pagination: PaginationInfo | null;
currentPage: number;
serversPerPage: number;
setCurrentPage: (page: number) => void;
setServersPerPage: (limit: number) => void;
triggerRefresh: () => void;
refreshIfNeeded: () => void; // Smart refresh with debounce
handleServerAdd: () => void;
@@ -45,6 +60,9 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
const [refreshKey, setRefreshKey] = useState(0);
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [fetchAttempts, setFetchAttempts] = useState(0);
const [pagination, setPagination] = useState<PaginationInfo | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [serversPerPage, setServersPerPage] = useState(10);
// Timer reference for polling
const intervalRef = useRef<NodeJS.Timeout | null>(null);
@@ -73,18 +91,31 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
const fetchServers = async () => {
try {
console.log('[ServerContext] Fetching servers from API...');
const data = await apiGet('/servers');
// Build query parameters for pagination
const params = new URLSearchParams();
params.append('page', currentPage.toString());
params.append('limit', serversPerPage.toString());
const data = await apiGet(`/servers?${params.toString()}`);
// Update last fetch time
lastFetchTimeRef.current = Date.now();
if (data && data.success && Array.isArray(data.data)) {
setServers(data.data);
// Update pagination info if available
if (data.pagination) {
setPagination(data.pagination);
} else {
setPagination(null);
}
} else if (data && Array.isArray(data)) {
// Compatibility handling for non-paginated responses
setServers(data);
setPagination(null);
} else {
console.error('Invalid server data format:', data);
setServers([]);
setPagination(null);
}
// Reset error state
@@ -114,7 +145,7 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
// Set up regular polling
intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval);
},
[t],
[t, currentPage, serversPerPage],
);
// Watch for authentication status changes
@@ -150,7 +181,11 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
const fetchInitialData = async () => {
try {
console.log('[ServerContext] Initial fetch - attempt', attemptsRef.current + 1);
const data = await apiGet('/servers');
// Build query parameters for pagination
const params = new URLSearchParams();
params.append('page', currentPage.toString());
params.append('limit', serversPerPage.toString());
const data = await apiGet(`/servers?${params.toString()}`);
// Update last fetch time
lastFetchTimeRef.current = Date.now();
@@ -158,6 +193,12 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
// Handle API response wrapper object, extract data field
if (data && data.success && Array.isArray(data.data)) {
setServers(data.data);
// Update pagination info if available
if (data.pagination) {
setPagination(data.pagination);
} else {
setPagination(null);
}
setIsInitialLoading(false);
// Initialization successful, start normal polling (skip immediate to avoid duplicate fetch)
startNormalPolling({ immediate: false });
@@ -165,6 +206,7 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
} else if (data && Array.isArray(data)) {
// Compatibility handling, if API directly returns array
setServers(data);
setPagination(null);
setIsInitialLoading(false);
// Initialization successful, start normal polling (skip immediate to avoid duplicate fetch)
startNormalPolling({ immediate: false });
@@ -173,6 +215,7 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
// If data format is not as expected, set to empty array
console.error('Invalid server data format:', data);
setServers([]);
setPagination(null);
setIsInitialLoading(false);
// Initialization successful but data is empty, start normal polling (skip immediate)
startNormalPolling({ immediate: false });
@@ -227,7 +270,7 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
return () => {
clearTimer();
};
}, [refreshKey, t, isInitialLoading, startNormalPolling]);
}, [refreshKey, t, isInitialLoading, startNormalPolling, currentPage, serversPerPage]);
// Manually trigger refresh (always refreshes)
const triggerRefresh = useCallback(() => {
@@ -383,12 +426,28 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
[t, triggerRefresh],
);
// Handle page change
const handlePageChange = useCallback((page: number) => {
setCurrentPage(page);
}, []);
// Handle servers per page change
const handleServersPerPageChange = useCallback((limit: number) => {
setServersPerPage(limit);
setCurrentPage(1); // Reset to first page when changing page size
}, []);
const value: ServerContextType = {
servers,
error,
setError,
isLoading: isInitialLoading,
fetchAttempts,
pagination,
currentPage,
serversPerPage,
setCurrentPage: handlePageChange,
setServersPerPage: handleServersPerPageChange,
triggerRefresh,
refreshIfNeeded,
handleServerAdd,

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

@@ -8,6 +8,7 @@ import EditServerForm from '@/components/EditServerForm';
import { useServerData } from '@/hooks/useServerData';
import DxtUploadForm from '@/components/DxtUploadForm';
import JSONImportForm from '@/components/JSONImportForm';
import Pagination from '@/components/ui/Pagination';
const ServersPage: React.FC = () => {
const { t } = useTranslation();
@@ -17,6 +18,11 @@ const ServersPage: React.FC = () => {
error,
setError,
isLoading,
pagination,
currentPage,
serversPerPage,
setCurrentPage,
setServersPerPage,
handleServerAdd,
handleServerEdit,
handleServerRemove,
@@ -151,19 +157,66 @@ const ServersPage: React.FC = () => {
<p className="text-gray-600">{t('app.noServers')}</p>
</div>
) : (
<div className="space-y-6">
{servers.map((server, index) => (
<ServerCard
key={index}
server={server}
onRemove={handleServerRemove}
onEdit={handleEditClick}
onToggle={handleServerToggle}
onRefresh={triggerRefresh}
onReload={handleServerReload}
/>
))}
</div>
<>
<div className="space-y-6">
{servers.map((server, index) => (
<ServerCard
key={index}
server={server}
onRemove={handleServerRemove}
onEdit={handleEditClick}
onToggle={handleServerToggle}
onRefresh={triggerRefresh}
onReload={handleServerReload}
/>
))}
</div>
<div className="flex items-center mb-4">
<div className="flex-[2] text-sm text-gray-500">
{pagination ? (
t('common.showing', {
start: (pagination.page - 1) * pagination.limit + 1,
end: Math.min(pagination.page * pagination.limit, pagination.total),
total: pagination.total
})
) : (
t('common.showing', {
start: 1,
end: servers.length,
total: servers.length
})
)}
</div>
<div className="flex-[4] flex justify-center">
{pagination && pagination.totalPages > 1 && (
<Pagination
currentPage={currentPage}
totalPages={pagination.totalPages}
onPageChange={setCurrentPage}
disabled={isLoading}
/>
)}
</div>
<div className="flex-[2] flex items-center justify-end space-x-2">
<label htmlFor="perPage" className="text-sm text-gray-600">
{t('common.itemsPerPage')}:
</label>
<select
id="perPage"
value={serversPerPage}
onChange={(e) => setServersPerPage(Number(e.target.value))}
disabled={isLoading}
className="border rounded p-1 text-sm btn-secondary outline-none disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value={5}>5</option>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
</select>
</div>
</div>
</>
)}
{editingServer && (

View File

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

@@ -105,6 +105,17 @@ export interface Prompt {
enabled?: boolean;
}
// Proxychains4 configuration for STDIO servers (Linux/macOS only)
export interface ProxychainsConfig {
enabled?: boolean; // Enable/disable proxychains4 proxy routing
type?: 'socks4' | 'socks5' | 'http'; // Proxy protocol type
host?: string; // Proxy server hostname or IP address
port?: number; // Proxy server port
username?: string; // Proxy authentication username (optional)
password?: string; // Proxy authentication password (optional)
configPath?: string; // Path to custom proxychains4 configuration file (optional)
}
// Server config types
export interface ServerConfig {
type?: 'stdio' | 'sse' | 'streamable-http' | 'openapi';
@@ -123,6 +134,8 @@ export interface ServerConfig {
resetTimeoutOnProgress?: boolean; // Reset timeout on progress notifications
maxTotalTimeout?: number; // Maximum total timeout in milliseconds
}; // MCP request options configuration
// Proxychains4 proxy configuration for STDIO servers (Linux/macOS only, Windows not supported)
proxy?: ProxychainsConfig;
// OAuth authentication for upstream MCP servers
oauth?: {
clientId?: string; // OAuth client ID

View File

@@ -248,6 +248,10 @@
"wechat": "WeChat",
"discord": "Discord",
"required": "Required",
"itemsPerPage": "Items per page",
"showing": "Showing {{start}}-{{end}} of {{total}}",
"previous": "Previous",
"next": "Next",
"secret": "Secret",
"default": "Default",
"value": "Value",
@@ -603,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

@@ -248,6 +248,10 @@
"github": "GitHub",
"wechat": "WeChat",
"discord": "Discord",
"itemsPerPage": "Éléments par page",
"showing": "Affichage de {{start}}-{{end}} sur {{total}}",
"previous": "Précédent",
"next": "Suivant",
"required": "Requis",
"secret": "Secret",
"default": "Défaut",

View File

@@ -248,6 +248,10 @@
"github": "GitHub",
"wechat": "WeChat",
"discord": "Discord",
"itemsPerPage": "Sayfa başına öğe",
"showing": "{{total}} öğeden {{start}}-{{end}} gösteriliyor",
"previous": "Önceki",
"next": "Sonraki",
"required": "Gerekli",
"secret": "Gizli",
"default": "Varsayılan",

View File

@@ -248,6 +248,10 @@
"dismiss": "忽略",
"github": "GitHub",
"wechat": "微信",
"itemsPerPage": "每页显示",
"showing": "显示第 {{start}}-{{end}} 条,共 {{total}} 条",
"previous": "上一页",
"next": "下一页",
"discord": "Discord",
"required": "必填",
"secret": "敏感",
@@ -606,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

@@ -46,7 +46,7 @@
"license": "ISC",
"dependencies": {
"@apidevtools/swagger-parser": "^12.0.0",
"@modelcontextprotocol/sdk": "^1.20.2",
"@modelcontextprotocol/sdk": "^1.25.1",
"@node-oauth/oauth2-server": "^5.2.1",
"@types/adm-zip": "^0.5.7",
"@types/bcrypt": "^6.0.0",
@@ -57,7 +57,7 @@
"bcrypt": "^6.0.0",
"bcryptjs": "^3.0.2",
"cors": "^2.8.5",
"dotenv": "^16.6.1",
"dotenv": "^17.2.3",
"dotenv-expand": "^12.0.2",
"express": "^4.21.2",
"express-validator": "^7.3.1",
@@ -108,7 +108,7 @@
"jest-environment-node": "^30.0.5",
"jest-mock-extended": "4.0.0",
"lucide-react": "^0.552.0",
"next": "^15.5.0",
"next": "^16.1.1",
"postcss": "^8.5.6",
"prettier": "^3.6.2",
"react": "19.2.1",

425
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@ import {
BatchCreateServersResponse,
BatchServerResult,
ServerConfig,
ServerInfo,
} from '../types/index.js';
import {
getServersInfo,
@@ -24,13 +25,66 @@ import { createSafeJSON } from '../utils/serialization.js';
import { cloneDefaultOAuthServerConfig } from '../constants/oauthServerDefaults.js';
import { getServerDao, getGroupDao, getSystemConfigDao } from '../dao/DaoFactory.js';
import { getBearerKeyDao } from '../dao/DaoFactory.js';
import { UserContextService } from '../services/userContextService.js';
export const getAllServers = async (_: Request, res: Response): Promise<void> => {
export const getAllServers = async (req: Request, res: Response): Promise<void> => {
try {
const serversInfo = await getServersInfo();
// Parse pagination parameters from query string
const page = req.query.page ? parseInt(req.query.page as string, 10) : 1;
const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : undefined;
// Validate pagination parameters
if (page < 1) {
res.status(400).json({
success: false,
message: 'Page number must be greater than 0',
});
return;
}
if (limit !== undefined && (limit < 1 || limit > 1000)) {
res.status(400).json({
success: false,
message: 'Limit must be between 1 and 1000',
});
return;
}
// Get current user for filtering
const currentUser = UserContextService.getInstance().getCurrentUser();
const isAdmin = !currentUser || currentUser.isAdmin;
// Get servers info with pagination if limit is specified
let serversInfo: Omit<ServerInfo, 'client' | 'transport'>[];
let pagination = undefined;
if (limit !== undefined) {
// Use DAO layer pagination with proper filtering
const serverDao = getServerDao();
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,
total: paginatedResult.total,
totalPages: paginatedResult.totalPages,
hasNextPage: paginatedResult.page < paginatedResult.totalPages,
hasPrevPage: paginatedResult.page > 1,
};
} else {
// No pagination, get all servers (will be filtered by mcpService)
serversInfo = await getServersInfo();
}
const response: ApiResponse = {
success: true,
data: createSafeJSON(serversInfo),
...(pagination && { pagination }),
};
res.json(response);
} catch (error) {
@@ -564,10 +618,9 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
});
}
} catch (error) {
console.error('Failed to update server:', error);
res.status(500).json({
success: false,
message: error instanceof Error ? error.message : 'Internal server error',
message: 'Internal server error',
});
}
};
@@ -853,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 &&
@@ -1064,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

@@ -2,10 +2,31 @@ import { ServerConfig } from '../types/index.js';
import { BaseDao } from './base/BaseDao.js';
import { JsonFileBaseDao } from './base/JsonFileBaseDao.js';
/**
* Pagination result interface
*/
export interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
/**
* Server DAO interface with server-specific operations
*/
export interface ServerDao extends BaseDao<ServerConfigWithName, string> {
/**
* Find all servers with pagination
*/
findAllPaginated(page: number, limit: number): Promise<PaginatedResult<ServerConfigWithName>>;
/**
* Find servers by owner with pagination
*/
findByOwnerPaginated(owner: string, page: number, limit: number): Promise<PaginatedResult<ServerConfigWithName>>;
/**
* Find servers by owner
*/
@@ -176,6 +197,61 @@ export class ServerDaoImpl extends JsonFileBaseDao implements ServerDao {
return servers.length;
}
async findAllPaginated(page: number, limit: number): Promise<PaginatedResult<ServerConfigWithName>> {
const allServers = await this.getAll();
// Sort: enabled servers first, then by creation time
const sortedServers = allServers.sort((a, b) => {
const aEnabled = a.enabled !== false;
const bEnabled = b.enabled !== false;
if (aEnabled !== bEnabled) {
return aEnabled ? -1 : 1;
}
return 0; // Keep original order for same enabled status
});
const total = sortedServers.length;
const totalPages = Math.ceil(total / limit);
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const data = sortedServers.slice(startIndex, endIndex);
return {
data,
total,
page,
limit,
totalPages,
};
}
async findByOwnerPaginated(owner: string, page: number, limit: number): Promise<PaginatedResult<ServerConfigWithName>> {
const allServers = await this.getAll();
const filteredServers = allServers.filter((server) => server.owner === owner);
// Sort: enabled servers first, then by creation time
const sortedServers = filteredServers.sort((a, b) => {
const aEnabled = a.enabled !== false;
const bEnabled = b.enabled !== false;
if (aEnabled !== bEnabled) {
return aEnabled ? -1 : 1;
}
return 0; // Keep original order for same enabled status
});
const total = sortedServers.length;
const totalPages = Math.ceil(total / limit);
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const data = sortedServers.slice(startIndex, endIndex);
return {
data,
total,
page,
limit,
totalPages,
};
}
async findByOwner(owner: string): Promise<ServerConfigWithName[]> {
const servers = await this.getAll();
return servers.filter((server) => server.owner === owner);

View File

@@ -1,4 +1,4 @@
import { ServerDao, ServerConfigWithName } from './index.js';
import { ServerDao, ServerConfigWithName, PaginatedResult } from './index.js';
import { ServerRepository } from '../db/repositories/ServerRepository.js';
/**
@@ -16,6 +16,32 @@ export class ServerDaoDbImpl implements ServerDao {
return servers.map((s) => this.mapToServerConfig(s));
}
async findAllPaginated(page: number, limit: number): Promise<PaginatedResult<ServerConfigWithName>> {
const { data, total } = await this.repository.findAllPaginated(page, limit);
const totalPages = Math.ceil(total / limit);
return {
data: data.map((s) => this.mapToServerConfig(s)),
total,
page,
limit,
totalPages,
};
}
async findByOwnerPaginated(owner: string, page: number, limit: number): Promise<PaginatedResult<ServerConfigWithName>> {
const { data, total } = await this.repository.findByOwnerPaginated(owner, page, limit);
const totalPages = Math.ceil(total / limit);
return {
data: data.map((s) => this.mapToServerConfig(s)),
total,
page,
limit,
totalPages,
};
}
async findById(name: string): Promise<ServerConfigWithName | null> {
const server = await this.repository.findByName(name);
return server ? this.mapToServerConfig(server) : null;
@@ -38,6 +64,7 @@ export class ServerDaoDbImpl implements ServerDao {
prompts: entity.prompts,
options: entity.options,
oauth: entity.oauth,
proxy: entity.proxy,
openapi: entity.openapi,
});
return this.mapToServerConfig(server);
@@ -62,6 +89,7 @@ export class ServerDaoDbImpl implements ServerDao {
prompts: entity.prompts,
options: entity.options,
oauth: entity.oauth,
proxy: entity.proxy,
openapi: entity.openapi,
});
return server ? this.mapToServerConfig(server) : null;
@@ -140,6 +168,7 @@ export class ServerDaoDbImpl implements ServerDao {
prompts?: Record<string, { enabled: boolean; description?: string }>;
options?: Record<string, any>;
oauth?: Record<string, any>;
proxy?: Record<string, any>;
openapi?: Record<string, any>;
}): ServerConfigWithName {
return {
@@ -158,6 +187,7 @@ export class ServerDaoDbImpl implements ServerDao {
prompts: server.prompts,
options: server.options,
oauth: server.oauth,
proxy: server.proxy,
openapi: server.openapi,
};
}

View File

@@ -59,6 +59,9 @@ export class Server {
@Column({ type: 'simple-json', nullable: true })
oauth?: Record<string, any>;
@Column({ type: 'simple-json', nullable: true })
proxy?: Record<string, any>;
@Column({ type: 'simple-json', nullable: true })
openapi?: Record<string, any>;

View File

@@ -69,6 +69,41 @@ export class ServerRepository {
return await this.repository.count();
}
/**
* Find servers with pagination
*/
async findAllPaginated(page: number, limit: number): Promise<{ data: Server[]; total: number }> {
const skip = (page - 1) * limit;
const [data, total] = await this.repository.findAndCount({
order: {
enabled: 'DESC', // Enabled servers first
createdAt: 'ASC' // Then by creation time
},
skip,
take: limit,
});
return { data, total };
}
/**
* Find servers by owner with pagination
*/
async findByOwnerPaginated(owner: string, page: number, limit: number): Promise<{ data: Server[]; total: number }> {
const skip = (page - 1) * limit;
const [data, total] = await this.repository.findAndCount({
where: { owner },
order: {
enabled: 'DESC', // Enabled servers first
createdAt: 'ASC' // Then by creation time
},
skip,
take: limit,
});
return { data, total };
}
/**
* Find servers by owner
*/

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

@@ -1,4 +1,6 @@
import os from 'os';
import path from 'path';
import fs from 'fs';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import {
CallToolRequestSchema,
@@ -15,29 +17,187 @@ import {
StreamableHTTPClientTransportOptions,
} from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { createFetchWithProxy, getProxyConfigFromEnv } from './proxy.js';
import { ServerInfo, ServerConfig, Tool } from '../types/index.js';
import { ServerInfo, ServerConfig, Tool, ProxychainsConfig } from '../types/index.js';
import { expandEnvVars, replaceEnvVars, getNameSeparator } from '../config/index.js';
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 } = {};
import { setupClientKeepAlive } from './keepAliveService.js';
/**
* Check if proxychains4 is available on the system (Linux/macOS only).
* Returns the path to proxychains4 if found, null otherwise.
*/
const findProxychains4 = (): string | null => {
// Windows is not supported
if (process.platform === 'win32') {
return null;
}
// Common proxychains4 binary paths
const possiblePaths = [
'/usr/bin/proxychains4',
'/usr/local/bin/proxychains4',
'/opt/homebrew/bin/proxychains4', // macOS Homebrew ARM
'/usr/local/Cellar/proxychains-ng/*/bin/proxychains4', // macOS Homebrew Intel
];
for (const p of possiblePaths) {
if (fs.existsSync(p)) {
return p;
}
}
// Try to find in PATH
const pathEnv = process.env.PATH || '';
const pathDirs = pathEnv.split(path.delimiter);
for (const dir of pathDirs) {
const fullPath = path.join(dir, 'proxychains4');
if (fs.existsSync(fullPath)) {
return fullPath;
}
}
return null;
};
/**
* Generate a temporary proxychains4 configuration file.
* Returns the path to the generated config file.
*/
const generateProxychainsConfig = (
serverName: string,
proxyConfig: ProxychainsConfig,
): string | null => {
// If a custom config path is provided, use it directly
if (proxyConfig.configPath) {
if (fs.existsSync(proxyConfig.configPath)) {
return proxyConfig.configPath;
}
console.warn(`[${serverName}] Custom proxychains config not found: ${proxyConfig.configPath}`);
return null;
}
// Validate required fields
if (!proxyConfig.host || !proxyConfig.port) {
console.warn(`[${serverName}] Proxy host and port are required for proxychains4`);
return null;
}
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 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
tcp_read_time_out 15000
tcp_connect_time_out 8000
[ProxyList]
${proxyLine}
`;
// Create temp directory if needed
const tempDir = path.join(os.tmpdir(), 'mcphub-proxychains');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
// Write config file
const configPath = path.join(tempDir, `${serverName.replace(/[^a-zA-Z0-9-_]/g, '_')}.conf`);
fs.writeFileSync(configPath, configContent, 'utf-8');
console.log(`[${serverName}] Generated proxychains4 config: ${configPath}`);
return configPath;
};
/**
* Wrap a command with proxychains4 if proxy is configured and available.
* Returns modified command and args if proxychains4 is used, original values otherwise.
*/
const wrapWithProxychains = (
serverName: string,
command: string,
args: string[],
proxyConfig?: ProxychainsConfig,
): { command: string; args: string[] } => {
// Skip if proxy is not enabled or not configured
if (!proxyConfig?.enabled) {
return { command, args };
}
// Check platform - Windows is not supported
if (process.platform === 'win32') {
console.warn(
`[${serverName}] proxychains4 proxy is not supported on Windows, ignoring proxy configuration`,
);
return { command, args };
}
// Find proxychains4 binary
const proxychains4Path = findProxychains4();
if (!proxychains4Path) {
console.warn(
`[${serverName}] proxychains4 not found on system, install it with: apt install proxychains4 (Debian/Ubuntu) or brew install proxychains-ng (macOS)`,
);
return { command, args };
}
// Generate or get config file
const configPath = generateProxychainsConfig(serverName, proxyConfig);
if (!configPath) {
console.warn(`[${serverName}] Failed to setup proxychains4 configuration, skipping proxy`);
return { command, args };
}
// Wrap command with proxychains4
console.log(
`[${serverName}] Using proxychains4 proxy: ${proxyConfig.type || 'socks5'}://${proxyConfig.host}:${proxyConfig.port}`,
);
return {
command: proxychains4Path,
args: ['-f', configPath, command, ...args],
};
};
export const initUpstreamServers = async (): Promise<void> => {
// Initialize OAuth clients for servers with dynamic registration
await initializeAllOAuthClients();
// 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 => {
@@ -209,11 +369,19 @@ export const createTransportFromConfig = async (name: string, conf: ServerConfig
env['npm_config_registry'] = systemConfig.install.npmRegistry;
}
// Expand environment variables in command
// Apply proxychains4 wrapper if proxy is configured (Linux/macOS only)
const { command: finalCommand, args: finalArgs } = wrapWithProxychains(
name,
conf.command,
replaceEnvVars(conf.args) as string[],
conf.proxy,
);
// Create STDIO transport with potentially wrapped command
transport = new StdioClientTransport({
cwd: os.homedir(),
command: conf.command,
args: replaceEnvVars(conf.args) as string[],
command: finalCommand,
args: finalArgs,
env: env,
stderr: 'pipe',
});
@@ -618,10 +786,20 @@ export const registerAllTools = async (isInit: boolean, serverName?: string): Pr
};
// Get all server information
export const getServersInfo = async (): Promise<Omit<ServerInfo, 'client' | 'transport'>[]> => {
const allServers: ServerConfigWithName[] = await getServerDao().findAll();
export const getServersInfo = async (
page?: number,
limit?: number,
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;
const allServers: ServerConfigWithName[] = isPaginated
? (await getServerDao().findAllPaginated(page, limit)).data
: await getServerDao().findAll();
// Ensure that servers recently added via DAO but not yet initialized in serverInfos
// are still visible in the servers list. This avoids a race condition where
// a POST /api/servers immediately followed by GET /api/servers would not
@@ -629,10 +807,19 @@ export const getServersInfo = async (): Promise<Omit<ServerInfo, 'client' | 'tra
const combinedServerInfos: ServerInfo[] = [...serverInfos];
const existingNames = new Set(combinedServerInfos.map((s) => s.name));
// Create a set of server names we're interested in (for pagination)
const requestedServerNames = new Set(allServers.map((s) => s.name));
// Filter serverInfos to only include requested servers if pagination is used
const filteredServerInfos = isPaginated
? combinedServerInfos.filter((s) => requestedServerNames.has(s.name))
: combinedServerInfos;
// Add servers from DAO that don't have runtime info yet
for (const server of allServers) {
if (!existingNames.has(server.name)) {
const isEnabled = server.enabled === undefined ? true : server.enabled;
combinedServerInfos.push({
filteredServerInfos.push({
name: server.name,
owner: server.owner,
// Newly created servers that are enabled should appear as "connecting"
@@ -648,12 +835,17 @@ export const getServersInfo = async (): Promise<Omit<ServerInfo, 'client' | 'tra
}
}
const filterServerInfos: ServerInfo[] = dataService.filterData
? dataService.filterData(combinedServerInfos)
: combinedServerInfos;
// 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 infos = filterServerInfos.map(
({ name, status, tools, prompts, createTime, error, oauth }) => {
const infos = filterServerInfos
.filter((info) => requestedServerNames.has(info.name)) // Only include requested servers
.map(({ name, status, tools, prompts, createTime, error, oauth }) => {
const serverConfig = allServers.find((server) => server.name === name);
const enabled = serverConfig ? serverConfig.enabled !== false : true;
@@ -692,12 +884,8 @@ export const getServersInfo = async (): Promise<Omit<ServerInfo, 'client' | 'tra
}
: undefined,
};
},
);
infos.sort((a, b) => {
if (a.enabled === b.enabled) return 0;
return a.enabled ? -1 : 1;
});
});
// Sorting is now handled at DAO layer for consistent pagination results
return infos;
};
@@ -903,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
@@ -1037,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

@@ -270,6 +270,17 @@ export interface McpSettings {
bearerKeys?: BearerKey[]; // Bearer authentication keys (multi-key configuration)
}
// Proxychains4 configuration for STDIO servers (Linux/macOS only)
export interface ProxychainsConfig {
enabled?: boolean; // Enable/disable proxychains4 proxy routing
type?: 'socks4' | 'socks5' | 'http'; // Proxy protocol type
host?: string; // Proxy server hostname or IP address
port?: number; // Proxy server port
username?: string; // Proxy authentication username (optional)
password?: string; // Proxy authentication password (optional)
configPath?: string; // Path to custom proxychains4 configuration file (optional, overrides above settings)
}
// Configuration details for an individual server
export interface ServerConfig {
type?: 'stdio' | 'sse' | 'streamable-http' | 'openapi'; // Type of server
@@ -285,6 +296,8 @@ export interface ServerConfig {
tools?: Record<string, { enabled: boolean; description?: string }>; // Tool-specific configurations with enable/disable state and custom descriptions
prompts?: Record<string, { enabled: boolean; description?: string }>; // Prompt-specific configurations with enable/disable state and custom descriptions
options?: Partial<Pick<RequestOptions, 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout'>>; // MCP request options configuration
// Proxychains4 proxy configuration for STDIO servers (Linux/macOS only, Windows not supported)
proxy?: ProxychainsConfig;
// OAuth authentication for upstream MCP servers
oauth?: {
// Static client configuration (traditional OAuth flow)

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;
}
/**
@@ -17,7 +24,7 @@ export interface SmartRoutingConfig {
*
* Priority order for each setting:
* 1. Specific environment variables (ENABLE_SMART_ROUTING, SMART_ROUTING_ENABLED, etc.)
* 2. Generic environment variables (OPENAI_API_KEY, DATABASE_URL, etc.)
* 2. Generic environment variables (OPENAI_API_KEY, DB_URL, etc.)
* 3. Settings configuration (systemConfig.smartRouting)
* 4. Default values
*
@@ -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',

View File

@@ -5,7 +5,7 @@ import 'reflect-metadata';
Object.assign(process.env, {
NODE_ENV: 'test',
JWT_SECRET: 'test-jwt-secret-key',
DATABASE_URL: 'sqlite::memory:',
DB_URL: 'sqlite::memory:',
});
// Mock moduleDir to avoid import.meta parsing issues in Jest
@@ -40,7 +40,7 @@ expect.extend({
};
}
},
toBeValidUUID(received: any) {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const pass = typeof received === 'string' && uuidRegex.test(received);