Compare commits

...

15 Commits

Author SHA1 Message Date
samanhappy
604fe4f71d fix: remove registration endpoint from authentication bypass in middleware (#267) 2025-08-11 14:18:28 +08:00
samanhappy
907bca8aac Refactor cloud and market pages for improved functionality and UI consistency (#265) 2025-08-10 17:39:34 +08:00
samanhappy
8c58200dcc feat: add health check endpoint to monitor MCP server status (#264) 2025-08-10 16:46:22 +08:00
samanhappy
0b4dc453a5 fix: reinitialize mcp server connection on update (#263) 2025-08-10 16:20:36 +08:00
samanhappy
35012f99fc refine docs 2025-08-10 12:52:54 +08:00
samanhappy
22ad4f83f6 fix: handle undefined and null values for number inputs in DynamicForm (#261)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-10 12:52:20 +08:00
samanhappy
26720d9e49 feat: introduce cloud server market (#260) 2025-08-09 21:14:26 +08:00
samanhappy
a9aa4a9a08 feat: Update registerService to handle environment-specific service overrides (#257) 2025-08-05 14:48:48 +08:00
samanhappy
48bcf9f5f0 feat: Add cleanInputSchema function to remove $schema field from inputSchema (#255) 2025-08-05 13:45:52 +08:00
samanhappy
f63f06d879 feat: Enhance authentication flow by integrating permissions retrieval and updating related services (#256) 2025-08-05 13:45:31 +08:00
samanhappy
63b356b8d7 Add Chinese localization support and i18n middleware (#253) 2025-08-03 11:53:04 +08:00
samanhappy
a6cea2ad3f feat: Enhance group management with server tool configuration (#250) 2025-07-29 17:31:05 +08:00
samanhappy
5bb2715094 refactor: simplify LanguageSwitch component by removing unnecessary language count checks and enhancing dropdown behavior (#249) 2025-07-27 20:44:50 +08:00
samanhappy
9b40f7e101 feat: enhance LanguageSwitch component with language toggle functionality and improve dropdown behavior; update UserProfileMenu styles (#248) 2025-07-26 22:58:01 +08:00
samanhappy
df872823c1 Implement language and theme switchers in Header (#247) 2025-07-26 21:46:14 +08:00
85 changed files with 6740 additions and 3183 deletions

View File

@@ -21,6 +21,9 @@ ENV REQUEST_TIMEOUT=$REQUEST_TIMEOUT
ARG BASE_PATH=""
ENV BASE_PATH=$BASE_PATH
ARG READONLY=false
ENV READONLY=$READONLY
ENV PNPM_HOME=/usr/local/share/pnpm
ENV PATH=$PNPM_HOME:$PATH
RUN mkdir -p $PNPM_HOME && \

View File

@@ -27,10 +27,7 @@ MCPHub uses several configuration files:
"args": ["arg1", "arg2"],
"env": {
"ENV_VAR": "value"
},
"cwd": "/working/directory",
"timeout": 30000,
"restart": true
}
}
}
}
@@ -50,8 +47,7 @@ MCPHub uses several configuration files:
},
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest", "--headless"],
"timeout": 60000
"args": ["@playwright/mcp@latest", "--headless"]
},
"slack": {
"command": "npx",
@@ -79,12 +75,6 @@ MCPHub uses several configuration files:
| Field | Type | Default | Description |
| -------------- | ------- | --------------- | --------------------------- |
| `env` | object | `{}` | Environment variables |
| `cwd` | string | `process.cwd()` | Working directory |
| `timeout` | number | `30000` | Startup timeout (ms) |
| `restart` | boolean | `true` | Auto-restart on failure |
| `maxRestarts` | number | `5` | Maximum restart attempts |
| `restartDelay` | number | `5000` | Delay between restarts (ms) |
| `stdio` | string | `pipe` | stdio configuration |
## Common MCP Server Examples
@@ -262,42 +252,14 @@ MCPHub supports environment variable substitution using `${VAR_NAME}` syntax:
"args": ["-m", "api_server"],
"env": {
"API_KEY": "${API_KEY}",
"API_URL": "${API_BASE_URL}/v1",
"DEBUG": "${NODE_ENV:development}"
"API_URL": "${API_BASE_URL}/v1"
}
}
}
}
```
Default values can be specified with `${VAR_NAME:default}`:
```json
{
"timeout": "${MCP_TIMEOUT:30000}",
"maxRestarts": "${MCP_MAX_RESTARTS:5}"
}
```
### Conditional Configuration
Use different configurations based on environment:
```json
{
"mcpServers": {
"database": {
"command": "python",
"args": ["-m", "db_server"],
"env": {
"DB_URL": "${NODE_ENV:development == 'production' ? DATABASE_URL : DEV_DATABASE_URL}"
}
}
}
}
```
### Custom Server Scripts
{/* ### Custom Server Scripts
#### Local Python Server
@@ -373,7 +335,7 @@ Complement `mcp_settings.json` with server metadata:
}
}
}
```
``` */}
## Group Management
@@ -385,25 +347,18 @@ Complement `mcp_settings.json` with server metadata:
"production": {
"name": "Production Tools",
"description": "Stable production servers",
"servers": ["fetch", "slack", "github"],
"access": "authenticated",
"rateLimit": {
"requestsPerMinute": 100,
"burstLimit": 20
}
"servers": ["fetch", "slack", "github"]
},
"experimental": {
"name": "Experimental Features",
"description": "Beta and experimental servers",
"servers": ["experimental-ai", "beta-search"],
"access": "admin",
"enabled": false
"servers": ["experimental-ai", "beta-search"]
}
}
}
```
### Access Control
{/* ### Access Control
| Access Level | Description |
| --------------- | -------------------------- |
@@ -422,9 +377,9 @@ MCPHub supports hot reloading of configurations:
# Reload configurations without restart
curl -X POST http://localhost:3000/api/admin/reload-config \
-H "Authorization: Bearer your-admin-token"
```
``` */}
### Configuration Validation
{/* ### Configuration Validation
MCPHub validates configurations on startup and reload:
@@ -436,7 +391,7 @@ MCPHub validates configurations on startup and reload:
"requireDocumentation": true
}
}
```
``` */}
## Best Practices
@@ -453,7 +408,7 @@ MCPHub validates configurations on startup and reload:
}
```
2. **Limit server permissions**:
{/* 2. **Limit server permissions**:
```json
{
"filesystem": {
@@ -464,9 +419,9 @@ MCPHub validates configurations on startup and reload:
}
}
}
```
``` */}
### Performance
{/* ### Performance
1. **Set appropriate timeouts**:
@@ -486,9 +441,9 @@ MCPHub validates configurations on startup and reload:
"MEMORY_LIMIT": "512MB"
}
}
```
``` */}
### Monitoring
{/* ### Monitoring
1. **Enable health checks**:
@@ -510,9 +465,9 @@ MCPHub validates configurations on startup and reload:
"LOG_FORMAT": "json"
}
}
```
``` */}
## Troubleshooting
{/* ## Troubleshooting
### Common Issues
@@ -521,9 +476,9 @@ MCPHub validates configurations on startup and reload:
```bash
# Test command manually
uvx mcp-server-fetch
```
``` */}
**Environment variables not found**: Verify `.env` file
{/* **Environment variables not found**: Verify `.env` file
```bash
# Check environment
@@ -535,9 +490,9 @@ printenv | grep API_KEY
```bash
# Verify executable permissions
ls -la /path/to/server
```
``` */}
### Debug Configuration
{/* ### Debug Configuration
Enable debug mode for detailed logging:
@@ -550,8 +505,8 @@ Enable debug mode for detailed logging:
"logStartup": true
}
}
```
``` */}
{/*
### Validation Errors
Common validation errors and solutions:
@@ -559,6 +514,6 @@ Common validation errors and solutions:
1. **Missing required fields**: Add `command` and `args`
2. **Invalid timeout**: Use number, not string
3. **Environment variable not found**: Check `.env` file
4. **Command not found**: Verify installation and PATH
4. **Command not found**: Verify installation and PATH */}
This comprehensive guide covers all aspects of configuring MCP servers in MCPHub for various use cases and environments.

View File

@@ -27,27 +27,16 @@
"pages": [
"features/server-management",
"features/group-management",
"features/smart-routing",
"features/authentication",
"features/monitoring"
"features/smart-routing"
]
},
{
"group": "Configuration",
"pages": [
"configuration/mcp-settings",
"configuration/environment-variables",
"configuration/docker-setup",
"configuration/nginx"
]
},
{
"group": "Development",
"pages": [
"development/getting-started",
"development/architecture",
"development/contributing"
]
}
]
},
@@ -67,51 +56,16 @@
"pages": [
"zh/features/server-management",
"zh/features/group-management",
"zh/features/smart-routing",
"zh/features/authentication",
"zh/features/monitoring"
"zh/features/smart-routing"
]
},
{
"group": "配置指南",
"pages": [
"zh/configuration/mcp-settings",
"zh/configuration/environment-variables",
"zh/configuration/docker-setup",
"zh/configuration/nginx"
]
},
{
"group": "开发指南",
"pages": [
"zh/development/getting-started",
"zh/development/architecture",
"zh/development/contributing"
]
}
]
},
{
"tab": "API Reference",
"groups": [
{
"group": "MCP Endpoints",
"pages": [
"api-reference/introduction",
"api-reference/mcp-http",
"api-reference/mcp-sse",
"api-reference/smart-routing"
]
},
{
"group": "Management API",
"pages": [
"api-reference/servers",
"api-reference/groups",
"api-reference/auth",
"api-reference/logs",
"api-reference/config"
]
}
]
}
@@ -144,13 +98,13 @@
"links": [
{
"label": "Demo",
"href": "http://localhost:3000"
"href": "https://demo.mcphubx.com"
}
],
"primary": {
"type": "button",
"label": "Get Started",
"href": "https://docs.hubmcp.dev/quickstart"
"href": "https://docs.mcphubx.com/quickstart"
}
},
"footer": {

View File

@@ -30,9 +30,6 @@ Groups are named collections of MCP servers that can be accessed through dedicat
3. **Fill Group Details**:
- **Name**: Unique identifier for the group
- **Display Name**: Human-readable name
- **Description**: Purpose and contents of the group
- **Access Level**: Public, Private, or Restricted
4. **Add Servers**: Select servers to include in the group
@@ -46,14 +43,11 @@ curl -X POST http://localhost:3000/api/groups \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-d '{
"name": "web-automation",
"displayName": "Web Automation Tools",
"description": "Browser automation and web scraping tools",
"servers": ["playwright", "fetch"],
"accessLevel": "public"
"servers": ["playwright", "fetch"]
}'
```
### Via Configuration File
{/* ### Via Configuration File
Define groups in your `mcp_settings.json`:
@@ -66,20 +60,16 @@ Define groups in your `mcp_settings.json`:
},
"groups": {
"web-tools": {
"displayName": "Web Tools",
"description": "Web scraping and browser automation",
"name": "web",
"servers": ["fetch", "playwright"],
"accessLevel": "public"
},
"communication": {
"displayName": "Communication Tools",
"description": "Messaging and collaboration tools",
"name": "communication",
"servers": ["slack"],
"accessLevel": "private"
}
}
}
```
``` */}
## Group Types and Use Cases
@@ -177,7 +167,7 @@ Define groups in your `mcp_settings.json`:
</Accordion>
</AccordionGroup>
## Group Access Control
{/* ## Group Access Control
### Access Levels
@@ -254,7 +244,7 @@ curl -X DELETE http://localhost:3000/api/groups/web-tools/members/user123 \
# List group members
curl http://localhost:3000/api/groups/web-tools/members \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
```
``` */}
## Group Endpoints
@@ -346,7 +336,7 @@ Response will only include tools from `fetch` and `playwright` servers.
</Tab>
</Tabs>
### Batch Server Updates
{/* ### Batch Server Updates
Update multiple servers at once:
@@ -357,9 +347,9 @@ curl -X PUT http://localhost:3000/api/groups/web-tools/servers \
-d '{
"servers": ["fetch", "playwright", "selenium"]
}'
```
``` */}
## Group Monitoring
{/* ## Group Monitoring
### Group Status
@@ -393,9 +383,9 @@ Metrics include:
- Request count by tool
- Response times
- Error rates
- User activity
- User activity */}
## Advanced Group Features
{/* ## Advanced Group Features
### Nested Groups
@@ -474,7 +464,7 @@ Define policies for group behavior:
}
}
}
```
``` */}
## Best Practices
@@ -494,7 +484,7 @@ Define policies for group behavior:
**Use Descriptive Names**: Choose names that clearly indicate the group's purpose and contents.
</Tip>
### Security Considerations
{/* ### Security Considerations
<Warning>
**Principle of Least Privilege**: Only give users access to groups they actually need.
@@ -507,7 +497,7 @@ Define policies for group behavior:
<Warning>
**Regular Access Reviews**: Periodically review group memberships and remove unnecessary access.
</Warning>
</Warning> */}
### Performance Optimization

View File

@@ -311,7 +311,7 @@ Servers can use environment variables for configuration:
- `${VAR_NAME:-default}`: Uses default if variable not set
- `${VAR_NAME:+value}`: Uses value if variable is set
### Working Directory
{/* ### Working Directory
Set the working directory for server execution:
@@ -323,7 +323,7 @@ Set the working directory for server execution:
"cwd": "/path/to/server/directory"
}
}
```
``` */}
### Command Variations
@@ -352,7 +352,7 @@ Different ways to specify server commands:
```
</Tab>
<Tab title="Direct Python">
{/* <Tab title="Direct Python">
```json
{
"direct-python": {
@@ -373,7 +373,7 @@ Different ways to specify server commands:
}
}
```
</Tab>
</Tab> */}
</Tabs>
## Advanced Features
@@ -382,12 +382,12 @@ Different ways to specify server commands:
MCPHub supports hot reloading of server configurations:
1. **Config File Changes**: Automatically detects changes to `mcp_settings.json`
2. **Dashboard Updates**: Immediately applies changes made through the web interface
3. **API Updates**: Real-time updates via REST API calls
4. **Zero Downtime**: Graceful server restarts without affecting other servers
{/* 1. **Config File Changes**: Automatically detects changes to `mcp_settings.json` */}
1. **Dashboard Updates**: Immediately applies changes made through the web interface
2. **API Updates**: Real-time updates via REST API calls
3. **Zero Downtime**: Graceful server restarts without affecting other servers
### Resource Limits
{/* ### Resource Limits
Control server resource usage:
@@ -403,9 +403,9 @@ Control server resource usage:
}
}
}
```
``` */}
### Dependency Management
{/* ### Dependency Management
Handle server dependencies:
@@ -439,7 +439,7 @@ Handle server dependencies:
```
</Accordion>
</AccordionGroup>
</AccordionGroup> */}
## Troubleshooting

View File

@@ -55,7 +55,7 @@ Smart Routing requires additional setup compared to basic MCPHub usage:
2. **Embedding Service**: OpenAI API or compatible service
3. **Environment Configuration**: Proper configuration variables
### Quick Setup
{/* ### Quick Setup
<Tabs>
<Tab title="Docker Compose">
@@ -265,7 +265,7 @@ EMBEDDING_BATCH_SIZE=100
```
</Accordion>
</AccordionGroup>
</AccordionGroup> */}
## Using Smart Routing
@@ -287,7 +287,7 @@ Access Smart Routing through the special `$smart` endpoint:
</Tab>
</Tabs>
### Basic Usage
{/* ### Basic Usage
Connect your AI client to the Smart Routing endpoint and make natural language requests:
@@ -330,9 +330,9 @@ Response:
]
}
}
```
``` */}
### Advanced Queries
{/* ### Advanced Queries
Smart Routing supports various query types:
@@ -405,9 +405,9 @@ Smart Routing supports various query types:
}'
```
</Accordion>
</AccordionGroup>
</AccordionGroup> */}
### Tool Execution
{/* ### Tool Execution
Once Smart Routing finds relevant tools, you can execute them directly:
@@ -426,9 +426,9 @@ curl -X POST http://localhost:3000/mcp/$smart \
}
}
}'
```
``` */}
## Performance Optimization
{/* ## Performance Optimization
### Embedding Cache
@@ -585,7 +585,7 @@ curl -X POST http://localhost:3000/api/smart-routing/feedback \
"successful": true,
"comments": "Perfect tool for the task"
}'
```
``` */}
## Troubleshooting

View File

@@ -1,10 +1,10 @@
---
title: MCPHub Documentation
title: MCPHub
description: 'The Unified Hub for Model Context Protocol (MCP) Servers'
---
<img className="block dark:hidden" src="/images/hero-light.png" alt="Hero Light" />
<img className="hidden dark:block" src="/images/hero-dark.png" alt="Hero Dark" />
{/* <img className="block dark:hidden" src="/images/hero-light.png" alt="Hero Light" />
<img className="hidden dark:block" src="/images/hero-dark.png" alt="Hero Dark" /> */}
# Welcome to MCPHub
@@ -16,12 +16,12 @@ MCPHub makes it easy to manage and scale multiple MCP (Model Context Protocol) s
<Card title="Unified Management" icon="server" href="/features/server-management">
Centrally manage multiple MCP servers with hot-swappable configuration
</Card>
<Card title="Smart Routing" icon="route" href="/features/smart-routing">
AI-powered tool discovery using vector semantic search
</Card>
<Card title="Group Management" icon="users" href="/features/group-management">
Organize servers into logical groups for streamlined access control
</Card>
<Card title="Smart Routing" icon="route" href="/features/smart-routing">
AI-powered tool discovery using vector semantic search
</Card>
<Card title="Real-time Monitoring" icon="chart-line" href="/features/monitoring">
Monitor server status and performance from a unified dashboard
</Card>

View File

@@ -72,7 +72,6 @@ Optional for Smart Routing:
-p 3000:3000 \
-e PORT=3000 \
-e BASE_PATH="" \
-e REQUEST_TIMEOUT=60000 \
samanhappy/mcphub:latest
```
@@ -144,12 +143,9 @@ Optional for Smart Routing:
# Run with custom port
PORT=8080 mcphub
# Run with custom config path
MCP_SETTINGS_PATH=/path/to/mcp_settings.json mcphub
```
#### 3. Local Installation
{/* #### 3. Local Installation
You can also install MCPHub locally in a project:
@@ -170,8 +166,7 @@ Optional for Smart Routing:
# Run MCPHub
./start.sh
```
``` */}
</Tab>
<Tab title="Local Development">
@@ -419,7 +414,7 @@ Smart Routing provides AI-powered tool discovery using vector semantic search.
</Tab>
</Tabs>
### Environment Configuration
{/* ### Environment Configuration
Set the following environment variables:
@@ -435,13 +430,13 @@ EMBEDDING_MODEL=text-embedding-3-small
# Optional: Enable smart routing
ENABLE_SMART_ROUTING=true
```
``` */}
## Verification
After installation, verify MCPHub is working:
### 1. Health Check
{/* ### 1. Health Check
```bash
curl http://localhost:3000/api/health
@@ -455,9 +450,9 @@ Expected response:
"version": "x.x.x",
"uptime": 123
}
```
``` */}
### 2. Dashboard Access
### Dashboard Access
Open your browser and navigate to:
@@ -465,7 +460,7 @@ Open your browser and navigate to:
http://localhost:3000
```
### 3. API Test
{/* ### 3. API Test
```bash
curl -X POST http://localhost:3000/mcp \
@@ -476,7 +471,7 @@ curl -X POST http://localhost:3000/mcp \
"method": "tools/list",
"params": {}
}'
```
``` */}
## Troubleshooting

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -106,12 +106,10 @@ Once your servers are configured, connect your AI clients using MCPHub endpoints
Access all configured MCP servers: ``` http://localhost:3000/mcp ```
</Tab>
<Tab title="Specific Group">
Access servers in a specific group: ``` http://localhost:3000/mcp/{group - name}
```
Access servers in a specific group: ``` http://localhost:3000/mcp/{groupName} ```
</Tab>
<Tab title="Individual Server">
Access a single server: ``` http://localhost:3000/mcp/{server - name}
```
Access a single server: ``` http://localhost:3000/mcp/{serverName} ```
</Tab>
<Tab title="Smart Routing">
Use AI-powered tool discovery: ``` http://localhost:3000/mcp/$smart ```
@@ -172,7 +170,7 @@ Here are some popular MCP servers you can add:
</Accordion>
</AccordionGroup>
## Verification
{/* ## Verification
Test your setup by making a simple request:
@@ -187,7 +185,7 @@ curl -X POST http://localhost:3000/mcp \
}'
```
You should receive a list of available tools from your configured MCP servers.
You should receive a list of available tools from your configured MCP servers. */}
## Next Steps

File diff suppressed because it is too large Load Diff

View File

@@ -49,448 +49,369 @@ curl -X POST http://localhost:3000/api/servers \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-d '{
"name": "my-server",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/files"],
"env": {
"NODE_ENV": "production"
},
"cwd": "/app"
"name": "fetch-server",
"command": "uvx",
"args": ["mcp-server-fetch"],
"env": {}
}'
```
## 服务器配置
## 流行的 MCP 服务器示例
### 通用配置选项
<AccordionGroup>
<Accordion title="Web 抓取服务器">
提供网页抓取和 HTTP 请求功能:
```json
{
"name": "filesystem-server",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/allowed/path"],
"env": {
"NODE_ENV": "production",
"DEBUG": "mcp:*",
"MAX_FILES": "1000"
},
"cwd": "/app/workspace",
"timeout": 30000,
"retries": 3,
"enabled": true
}
```
```json
{
"fetch": {
"command": "uvx",
"args": ["mcp-server-fetch"]
}
}
```
### Python 服务器示例
**可用工具:**
- `fetch`: 发起 HTTP 请求
- `fetch_html`: 抓取网页
- `fetch_json`: 从 API 获取 JSON 数据
```json
{
"name": "python-server",
"command": "python",
"args": ["-m", "mcp_server", "--config", "config.json"],
"env": {
"PYTHONPATH": "/app/python",
"API_KEY": "${API_KEY}",
"LOG_LEVEL": "INFO"
},
"cwd": "/app/python-server"
}
```
</Accordion>
### Node.js 服务器示例
<Accordion title="Playwright 浏览器自动化">
用于网页交互的浏览器自动化:
```json
{
"name": "node-server",
"command": "node",
"args": ["server.js", "--port", "3001"],
"env": {
"NODE_ENV": "production",
"PORT": "3001",
"DATABASE_URL": "${DATABASE_URL}"
},
"cwd": "/app/node-server"
}
```
```json
{
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest", "--headless"]
}
}
```
**可用工具:**
- `playwright_navigate`: 导航到网页
- `playwright_screenshot`: 截取屏幕截图
- `playwright_click`: 点击元素
- `playwright_fill`: 填写表单
</Accordion>
<Accordion title="文件系统操作">
文件和目录管理:
```json
{
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/allowed/path"]
}
}
```
**可用工具:**
- `read_file`: 读取文件内容
- `write_file`: 写入文件
- `create_directory`: 创建目录
- `list_directory`: 列出目录内容
</Accordion>
<Accordion title="SQLite 数据库">
数据库操作:
```json
{
"sqlite": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-sqlite", "/path/to/database.db"]
}
}
```
**可用工具:**
- `execute_query`: 执行 SQL 查询
- `describe_tables`: 获取表结构
- `create_table`: 创建新表
</Accordion>
<Accordion title="Slack 集成">
Slack 工作空间集成:
```json
{
"slack": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-slack"],
"env": {
"SLACK_BOT_TOKEN": "xoxb-your-bot-token",
"SLACK_TEAM_ID": "T1234567890"
}
}
}
```
**可用工具:**
- `send_slack_message`: 发送消息到频道
- `list_slack_channels`: 列出可用频道
- `get_slack_thread`: 获取线程消息
</Accordion>
<Accordion title="GitHub 集成">
GitHub 仓库操作:
```json
{
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_your_token"
}
}
}
```
**可用工具:**
- `create_or_update_file`: 创建/更新仓库文件
- `search_repositories`: 搜索 GitHub 仓库
- `create_issue`: 创建问题
- `create_pull_request`: 创建拉取请求
</Accordion>
<Accordion title="Google Drive">
Google Drive 文件操作:
```json
{
"gdrive": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-gdrive"],
"env": {
"GDRIVE_CLIENT_ID": "your-client-id",
"GDRIVE_CLIENT_SECRET": "your-client-secret",
"GDRIVE_REDIRECT_URI": "your-redirect-uri"
}
}
}
```
**可用工具:**
- `gdrive_search`: 搜索文件和文件夹
- `gdrive_read`: 读取文件内容
- `gdrive_create`: 创建新文件
</Accordion>
<Accordion title="高德地图(中国)">
中国地图和位置服务:
```json
{
"amap": {
"command": "npx",
"args": ["-y", "@amap/amap-maps-mcp-server"],
"env": {
"AMAP_MAPS_API_KEY": "your-api-key"
}
}
}
```
**可用工具:**
- `search_location`: 搜索位置
- `get_directions`: 获取路线指引
- `reverse_geocode`: 将坐标转换为地址
</Accordion>
</AccordionGroup>
## 服务器生命周期管理
### 启动服务器
```bash
# 启动特定服务器
curl -X POST http://localhost:3000/api/servers/my-server/start \
-H "Authorization: Bearer $TOKEN"
服务器会在以下情况下自动启动:
# 启动所有服务器
curl -X POST http://localhost:3000/api/servers/start-all \
-H "Authorization: Bearer $TOKEN"
```
- MCPHub 启动时
- 通过仪表板或 API 添加服务器时
- 服务器配置更新时
- 手动重启已停止的服务器时
### 停止服务器
```bash
# 停止特定服务器
curl -X POST http://localhost:3000/api/servers/my-server/stop \
-H "Authorization: Bearer $TOKEN"
您可以通过以下方式停止服务器:
# 优雅停止(等待当前请求完成)
curl -X POST http://localhost:3000/api/servers/my-server/stop \
-H "Authorization: Bearer $TOKEN" \
-d '{"graceful": true, "timeout": 30000}'
```
- **通过仪表板**: 切换服务器状态开关
- **通过 API**: 发送 POST 请求到 `/api/servers/{name}/toggle`
- **自动停止**: 服务器崩溃或遇到错误时会自动停止
### 重启服务器
```bash
# 重启服务器
curl -X POST http://localhost:3000/api/servers/my-server/restart \
-H "Authorization: Bearer $TOKEN"
```
服务器会在以下情况下自动重启:
## 热配置重载
### 更新服务器配置
无需重启即可更新配置:
```bash
curl -X PUT http://localhost:3000/api/servers/my-server/config \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"env": {
"DEBUG": "mcp:verbose",
"NEW_SETTING": "value"
},
"args": ["--verbose", "--new-flag"]
}'
```
### 批量配置更新
```bash
curl -X PUT http://localhost:3000/api/servers/bulk-update \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"servers": ["server1", "server2"],
"config": {
"env": {
"LOG_LEVEL": "DEBUG"
}
}
}'
```
- 配置更改时
- 环境变量更新后
- 通过仪表板或 API 手动触发时
## 服务器状态监控
### 检查服务器状态
### 状态指示器
```bash
# 获取所有服务器状态
curl -X GET http://localhost:3000/api/servers/status \
-H "Authorization: Bearer $TOKEN"
每个服务器都显示状态指示器:
# 获取特定服务器状态
curl -X GET http://localhost:3000/api/servers/my-server/status \
-H "Authorization: Bearer $TOKEN"
```
- 🟢 **运行中**: 服务器处于活动状态并响应
- 🟡 **启动中**: 服务器正在初始化
- 🔴 **已停止**: 服务器未运行
- ⚠️ **错误**: 服务器遇到错误
响应示例:
### 实时日志
```json
{
"name": "my-server",
"status": "running",
"pid": 12345,
"uptime": 3600000,
"memory": {
"rss": 123456789,
"heapTotal": 98765432,
"heapUsed": 87654321
},
"cpu": {
"user": 1000000,
"system": 500000
},
"lastRestart": "2024-01-01T12:00:00.000Z"
}
```
实时查看服务器日志:
1. **仪表板日志**: 点击服务器查看其日志
2. **API 日志**: 通过 `/api/logs` 端点访问日志
3. **流式日志**: 通过 WebSocket 订阅日志流
### 健康检查
配置自动健康检查:
MCPHub 自动执行健康检查:
- **初始化检查**: 验证服务器成功启动
- **工具发现**: 确认检测到可用工具
- **响应检查**: 测试服务器响应性
- **资源监控**: 跟踪 CPU 和内存使用情况
## 配置管理
### 环境变量
服务器可以使用环境变量进行配置:
```json
{
"name": "my-server",
"command": "node",
"args": ["server.js"],
"healthCheck": {
"enabled": true,
"interval": 30000,
"timeout": 5000,
"retries": 3,
"endpoint": "/health",
"expectedStatus": 200
}
}
```
## 负载均衡
### 配置多实例
```json
{
"name": "load-balanced-server",
"instances": 3,
"command": "node",
"args": ["server.js"],
"loadBalancer": {
"strategy": "round-robin",
"healthCheck": true,
"stickySession": false
},
"env": {
"PORT": "${PORT}"
}
}
```
### 负载均衡策略
- **round-robin**: 轮询分发请求
- **least-connections**: 分发到连接数最少的实例
- **weighted**: 基于权重分发
- **ip-hash**: 基于客户端 IP 的一致性哈希
## 资源限制
### 设置资源限制
```json
{
"name": "resource-limited-server",
"command": "python",
"args": ["server.py"],
"resources": {
"memory": {
"limit": "512MB",
"warning": "400MB"
},
"cpu": {
"limit": "50%",
"priority": "normal"
},
"processes": {
"max": 10
"server-name": {
"command": "python",
"args": ["server.py"],
"env": {
"API_KEY": "${YOUR_API_KEY}",
"DEBUG": "true",
"MAX_CONNECTIONS": "10"
}
}
}
```
### 监控资源使用
**环境变量展开:**
```bash
# 获取资源使用统计
curl -X GET http://localhost:3000/api/servers/my-server/resources \
-H "Authorization: Bearer $TOKEN"
```
- `${VAR_NAME}`: 展开为环境变量值
- `${VAR_NAME:-default}`: 如果变量未设置则使用默认值
- `${VAR_NAME:+value}`: 如果变量已设置则使用指定值
## 日志管理
### 命令变体
### 配置日志记录
指定服务器命令的不同方式:
```json
{
"name": "my-server",
"command": "node",
"args": ["server.js"],
"logging": {
"level": "info",
"file": "/var/log/mcphub/my-server.log",
"maxSize": "100MB",
"maxFiles": 5,
"rotate": true,
"format": "json"
}
}
```
### 查看日志
```bash
# 获取实时日志
curl -X GET http://localhost:3000/api/servers/my-server/logs \
-H "Authorization: Bearer $TOKEN"
# 获取带过滤器的日志
curl -X GET "http://localhost:3000/api/servers/my-server/logs?level=error&limit=100" \
-H "Authorization: Bearer $TOKEN"
```
## 环境变量管理
### 动态环境变量
```json
{
"name": "dynamic-server",
"command": "python",
"args": ["server.py"],
"env": {
"API_KEY": "${secrets:api_key}",
"DATABASE_URL": "${vault:db_url}",
"CURRENT_TIME": "${time:iso}",
"SERVER_ID": "${server:id}",
"HOSTNAME": "${system:hostname}"
}
}
```
### 环境变量模板
支持的模板变量:
- `${secrets:key}`: 从密钥存储获取
- `${vault:path}`: 从 Vault 获取
- `${env:VAR}`: 从系统环境变量获取
- `${time:format}`: 当前时间戳
- `${server:property}`: 服务器属性
- `${system:property}`: 系统属性
## 服务发现
### 自动服务发现
```json
{
"serviceDiscovery": {
"enabled": true,
"provider": "consul",
"config": {
"host": "localhost",
"port": 8500,
"serviceName": "mcp-server",
"tags": ["mcp", "ai", "api"]
}
}
}
```
### 注册服务
```bash
# 手动注册服务
curl -X POST http://localhost:3000/api/servers/my-server/register \
-H "Authorization: Bearer $TOKEN" \
-d '{
"service": {
"name": "my-mcp-service",
"tags": ["mcp", "production"],
"port": 3001,
"check": {
"http": "http://localhost:3001/health",
"interval": "30s"
<Tabs>
<Tab title="npm/npx">
```json
{
"npm-server": {
"command": "npx",
"args": ["-y", "package-name", "--option", "value"]
}
}
}'
```
```
</Tab>
<Tab title="Python/uvx">
```json
{
"python-server": {
"command": "uvx",
"args": ["package-name", "--config", "config.json"]
}
}
```
</Tab>
</Tabs>
## 高级功能
### 热重载
MCPHub 支持服务器配置的热重载:
1. **仪表板更新**: 立即应用通过 Web 界面进行的更改
2. **API 更新**: 通过 REST API 调用进行实时更新
3. **零停机时间**: 优雅的服务器重启,不影响其他服务器
## 故障排除
### 常见问题
<AccordionGroup>
<Accordion title="服务器无法启动">
**检查以下项目:**
- 命令在 PATH 中可用
- 已设置所有必需的环境变量
- 工作目录存在且可访问
- 网络端口未被阻塞
- 依赖项已安装
1. **服务器启动失败**
**调试步骤:**
1. 在仪表板中检查服务器日志
2. 在终端中手动测试命令
3. 验证环境变量展开
4. 检查文件权限
```bash
# 检查服务器日志
curl -X GET http://localhost:3000/api/servers/my-server/logs?level=error \
-H "Authorization: Bearer $TOKEN"
```
</Accordion>
2. **配置无效**
<Accordion title="服务器持续崩溃">
**常见原因:**
- 无效的配置参数
- 缺少 API 密钥或凭据
- 超出资源限制
- 依赖项冲突
```bash
# 验证配置
curl -X POST http://localhost:3000/api/servers/validate \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d @server-config.json
```
**解决方案:**
1. 查看服务器日志中的错误消息
2. 使用最小配置进行测试
3. 验证所有凭据和 API 密钥
4. 检查系统资源可用性
3. **性能问题**
```bash
# 获取性能指标
curl -X GET http://localhost:3000/api/servers/my-server/metrics \
-H "Authorization: Bearer $TOKEN"
```
</Accordion>
### 调试模式
<Accordion title="工具未显示">
**可能的问题:**
- 服务器未完全初始化
- 工具发现超时
- 通信协议不匹配
- 服务器报告错误
启用详细调试:
**调试步骤:**
1. 等待服务器初始化完成
2. 检查服务器日志中的工具注册消息
3. 测试与服务器的直接通信
4. 验证 MCP 协议兼容性
```json
{
"name": "debug-server",
"command": "node",
"args": ["--inspect=0.0.0.0:9229", "server.js"],
"env": {
"DEBUG": "*",
"LOG_LEVEL": "debug",
"NODE_ENV": "development"
},
"debugging": {
"enabled": true,
"port": 9229,
"breakOnStart": false
}
}
```
</Accordion>
</AccordionGroup>
## 高级配置
## 下一步
### 自定义钩子
```json
{
"name": "hooked-server",
"command": "node",
"args": ["server.js"],
"hooks": {
"beforeStart": ["./scripts/setup.sh"],
"afterStart": ["./scripts/notify.sh"],
"beforeStop": ["./scripts/cleanup.sh"],
"onError": ["./scripts/alert.sh"]
}
}
```
### 配置模板
```json
{
"templates": {
"python-server": {
"command": "python",
"args": ["-m", "mcp_server"],
"env": {
"PYTHONPATH": "/app/python",
"LOG_LEVEL": "INFO"
}
}
},
"servers": {
"my-python-server": {
"extends": "python-server",
"args": ["-m", "mcp_server", "--config", "custom.json"],
"env": {
"API_KEY": "custom-key"
}
}
}
}
```
有关更多配置选项,请参阅 [MCP 设置配置](/zh/configuration/mcp-settings) 和 [环境变量](/zh/configuration/environment-variables) 文档。
<CardGroup cols={2}>
<Card title="分组管理" icon="users" href="/zh/features/group-management">
将服务器组织成逻辑分组
</Card>
<Card title="智能路由" icon="route" href="/zh/features/smart-routing">
设置 AI 驱动的工具发现
</Card>
<Card title="API 参考" icon="code" href="/zh/api-reference/servers">
服务器管理 API 文档
</Card>
<Card title="配置指南" icon="cog" href="/zh/configuration/mcp-settings">
详细配置选项
</Card>
</CardGroup>

View File

@@ -1,691 +1,367 @@
---
title: '智能路由'
description: '自动负载均衡和请求路由到最佳的 MCP 服务器实例'
description: '使用向量语义搜索的 AI 工具发现系统'
---
## 概述
MCPHub 的智能路由系统自动将传入请求路由到最适合的 MCP 服务器实例。系统考虑服务器负载、响应时间、功能可用性和业务规则来做出路由决策
智能路由是 MCPHub 的智能工具发现系统它使用向量语义搜索来自动找到与任何给定任务最相关的工具。AI 客户端无需手动指定使用哪些工具,只需描述他们想要完成的任务,智能路由就会识别并提供对最合适工具的访问
## 路由策略
## 智能路由的工作原理
### 轮询路由
### 1. 工具索引
最简单的路由策略,按顺序分发请求
当服务器启动时,智能路由会自动
```json
{
"routing": {
"strategy": "round-robin",
"targets": [
{
"serverId": "server-1",
"weight": 1,
"enabled": true
},
{
"serverId": "server-2",
"weight": 1,
"enabled": true
},
{
"serverId": "server-3",
"weight": 1,
"enabled": true
}
]
}
}
```
- 从 MCP 服务器发现所有可用工具
- 提取工具元数据(名称、描述、参数)
- 将工具信息转换为向量嵌入
- 使用 pgvector 将嵌入存储在 PostgreSQL 中
### 加权轮询
### 2. 语义搜索
基于服务器容量分配不同权重
当进行查询时
```json
{
"routing": {
"strategy": "weighted-round-robin",
"targets": [
{
"serverId": "high-performance-server",
"weight": 3,
"specs": {
"cpu": "8 cores",
"memory": "32GB"
}
},
{
"serverId": "standard-server-1",
"weight": 2,
"specs": {
"cpu": "4 cores",
"memory": "16GB"
}
},
{
"serverId": "standard-server-2",
"weight": 1,
"specs": {
"cpu": "2 cores",
"memory": "8GB"
}
}
]
}
}
```
- 用户查询被转换为向量嵌入
- 相似性搜索使用余弦相似度找到匹配的工具
- 动态阈值过滤掉不相关的结果
- 结果按相关性得分排序
### 最少连接数
### 3. 智能过滤
将请求路由到当前连接数最少的服务器:
智能路由应用多个过滤器:
```json
{
"routing": {
"strategy": "least-connections",
"balancingMode": "dynamic",
"healthCheck": {
"enabled": true,
"interval": 10000
}
}
}
```
- **相关性阈值**:只返回高于相似性阈值的工具
- **上下文感知**:考虑对话上下文
- **工具可用性**:确保工具当前可访问
- **权限过滤**:尊重用户访问权限
### 基于响应时间
### 4. 工具执行
路由到响应时间最短的服务器
找到的工具可以直接执行
```json
{
"routing": {
"strategy": "fastest-response",
"metrics": {
"measurementWindow": "5m",
"sampleSize": 100,
"excludeSlowRequests": true,
"slowRequestThreshold": "5s"
}
}
}
```
- 参数验证确保正确的工具使用
- 错误处理提供有用的反馈
- 响应格式保持一致性
- 日志记录跟踪工具使用情况进行分析
## 基于功能的路由
## 前置条件
### 工具特定路由
智能路由需要比基础 MCPHub 使用更多的设置:
根据请求的工具类型路由到专门的服务器:
### 必需组件
```json
{
"routing": {
"strategy": "capability-based",
"rules": [
{
"condition": {
"tool": "filesystem"
},
"targets": ["filesystem-server-1", "filesystem-server-2"],
"strategy": "least-connections"
},
{
"condition": {
"tool": "web-search"
},
"targets": ["search-server-1", "search-server-2"],
"strategy": "round-robin"
},
{
"condition": {
"tool": "database"
},
"targets": ["db-server"],
"strategy": "single"
}
],
"fallback": {
"targets": ["general-server-1", "general-server-2"],
"strategy": "round-robin"
}
}
}
```
1. **带有 pgvector 的 PostgreSQL**:用于嵌入存储的向量数据库
2. **嵌入服务**OpenAI API 或兼容服务
3. **环境配置**:正确的配置变量
### 内容感知路由
## 使用智能路由
基于请求内容进行智能路由
### 智能路由端点
```json
{
"routing": {
"strategy": "content-aware",
"rules": [
{
"condition": {
"content.language": "python"
},
"targets": ["python-specialized-server"],
"reason": "Python代码分析专用服务器"
},
{
"condition": {
"content.size": "> 1MB"
},
"targets": ["high-memory-server"],
"reason": "大文件处理专用服务器"
},
{
"condition": {
"content.type": "image"
},
"targets": ["image-processing-server"],
"reason": "图像处理专用服务器"
}
]
}
}
```
通过特殊的 `$smart` 端点访问智能路由:
## 地理位置路由
<Tabs>
<Tab title="HTTP MCP">
```
http://localhost:3000/mcp/$smart
```
</Tab>
### 基于客户端位置
<Tab title="SSE (Legacy)">
```
http://localhost:3000/sse/$smart
```
</Tab>
</Tabs>
根据客户端地理位置路由到最近的服务器:
{/* ## 性能优化
```json
{
"routing": {
"strategy": "geo-location",
"regions": [
{
"name": "北美",
"countries": ["US", "CA", "MX"],
"servers": ["us-east-1", "us-west-1", "ca-central-1"],
"strategy": "least-latency"
},
{
"name": "欧洲",
"countries": ["DE", "FR", "UK", "NL"],
"servers": ["eu-west-1", "eu-central-1"],
"strategy": "round-robin"
},
{
"name": "亚太",
"countries": ["CN", "JP", "KR", "SG"],
"servers": ["ap-southeast-1", "ap-northeast-1"],
"strategy": "fastest-response"
}
],
"fallback": {
"servers": ["global-server-1"],
"strategy": "single"
}
}
}
```
### 嵌入缓存
### 延迟优化
智能路由缓存嵌入以提高性能:
```bash
# 配置延迟监控
curl -X PUT http://localhost:3000/api/routing/latency-config \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
# 配置缓存设置
EMBEDDING_CACHE_TTL=3600 # 缓存 1 小时
EMBEDDING_CACHE_SIZE=10000 # 最多缓存 10k 个嵌入
EMBEDDING_CACHE_CLEANUP=300 # 每 5 分钟清理一次
```
### 批处理
工具批量索引以提高效率:
```bash
# 嵌入生成的批大小
EMBEDDING_BATCH_SIZE=100
# 并发嵌入请求
EMBEDDING_CONCURRENCY=5
# 索引更新频率
INDEX_UPDATE_INTERVAL=3600 # 每小时重新索引
```
### 数据库优化
为向量操作优化 PostgreSQL
```sql
-- 创建索引以获得更好的性能
CREATE INDEX ON tool_embeddings USING hnsw (embedding vector_cosine_ops);
-- 调整 PostgreSQL 设置
ALTER SYSTEM SET shared_preload_libraries = 'vector';
ALTER SYSTEM SET max_connections = 200;
ALTER SYSTEM SET shared_buffers = '256MB';
ALTER SYSTEM SET effective_cache_size = '1GB';
```
## 监控和分析
### 智能路由指标
监控智能路由性能:
```bash
# 获取智能路由统计信息
curl http://localhost:3000/api/smart-routing/stats \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
```
响应包括:
- 查询计数和频率
- 平均响应时间
- 嵌入缓存命中率
- 最受欢迎的工具
- 查询模式
### 工具使用分析
跟踪哪些工具被发现和使用:
```bash
# 获取工具使用分析
curl http://localhost:3000/api/smart-routing/analytics \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
```
指标包括:
- 工具发现率
- 执行成功率
- 用户满意度评分
- 查询到执行的转换率
### 性能监控
监控系统性能:
```bash
# 数据库性能
curl http://localhost:3000/api/smart-routing/db-stats \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
# 嵌入服务状态
curl http://localhost:3000/api/smart-routing/embedding-stats \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
```
## 高级功能
### 自定义嵌入
使用自定义嵌入模型:
```bash
# Hugging Face 模型
EMBEDDING_SERVICE=huggingface
HUGGINGFACE_MODEL=sentence-transformers/all-MiniLM-L6-v2
HUGGINGFACE_API_KEY=your_api_key
# 本地嵌入服务
EMBEDDING_SERVICE=local
EMBEDDING_SERVICE_URL=http://localhost:8080/embeddings
```
### 查询增强
增强查询以获得更好的结果:
```json
{
"queryEnhancement": {
"enabled": true,
"measurementInterval": 30000,
"regions": [
{"id": "us-east", "endpoint": "ping.us-east.example.com"},
{"id": "eu-west", "endpoint": "ping.eu-west.example.com"},
{"id": "ap-southeast", "endpoint": "ping.ap-southeast.example.com"}
],
"routing": {
"preferLowLatency": true,
"maxLatencyThreshold": "200ms",
"fallbackOnTimeout": true
}
}'
```
## 负载感知路由
### 实时负载监控
```json
{
"routing": {
"strategy": "load-aware",
"loadMetrics": {
"cpu": {
"threshold": 80,
"weight": 0.4
},
"memory": {
"threshold": 85,
"weight": 0.3
},
"connections": {
"threshold": 1000,
"weight": 0.2
},
"responseTime": {
"threshold": "2s",
"weight": 0.1
}
},
"adaptation": {
"enabled": true,
"adjustmentInterval": 60000,
"emergencyThreshold": 95
}
"expandAcronyms": true,
"addSynonyms": true,
"contextualExpansion": true
}
}
```
### 预测性负载均衡
### 结果过滤
基于条件过滤结果:
```json
{
"routing": {
"strategy": "predictive",
"prediction": {
"algorithm": "linear-regression",
"trainingWindow": "7d",
"predictionHorizon": "1h",
"factors": ["historical_load", "time_of_day", "day_of_week", "seasonal_patterns"]
},
"adaptation": {
"preemptiveScaling": true,
"scaleUpThreshold": 70,
"scaleDownThreshold": 30
}
"resultFiltering": {
"minRelevanceScore": 0.7,
"maxResults": 10,
"preferredServers": ["fetch", "playwright"],
"excludeServers": ["deprecated-server"]
}
}
```
## 故障转移和恢复
### 反馈学习
### 自动故障转移
```json
{
"routing": {
"strategy": "high-availability",
"failover": {
"enabled": true,
"detection": {
"healthCheckFailures": 3,
"timeoutThreshold": "10s",
"checkInterval": 5000
},
"recovery": {
"automaticRecovery": true,
"recoveryChecks": 5,
"recoveryInterval": 30000
}
},
"clusters": [
{
"name": "primary",
"servers": ["server-1", "server-2"],
"priority": 1
},
{
"name": "secondary",
"servers": ["backup-server-1", "backup-server-2"],
"priority": 2
}
]
}
}
```
### 断路器模式
```json
{
"routing": {
"circuitBreaker": {
"enabled": true,
"failureThreshold": 10,
"timeWindow": 60000,
"halfOpenRetries": 3,
"fallback": {
"type": "cached-response",
"ttl": 300000
}
}
}
}
```
## 会话亲和性
### 粘性会话
保持用户会话与特定服务器的关联:
```json
{
"routing": {
"strategy": "session-affinity",
"affinity": {
"type": "cookie",
"cookieName": "mcphub-server-id",
"ttl": 3600000,
"fallbackOnUnavailable": true
},
"sessionStore": {
"type": "redis",
"config": {
"host": "localhost",
"port": 6379,
"db": 1
}
}
}
}
```
### 基于用户 ID 的路由
```json
{
"routing": {
"strategy": "user-based",
"userRouting": {
"algorithm": "consistent-hashing",
"hashFunction": "sha256",
"virtualNodes": 100,
"replicationFactor": 2
}
}
}
```
## 动态路由配置
### 运行时配置更新
基于用户反馈改进结果:
```bash
# 更新路由配置
curl -X PUT http://localhost:3000/api/routing/config \
# 对搜索结果提供反馈
curl -X POST http://localhost:3000/api/smart-routing/feedback \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-d '{
"strategy": "weighted-round-robin",
"weights": {
"server-1": 3,
"server-2": 2,
"server-3": 1
},
"applyImmediately": true
"queryId": "search-123",
"toolName": "fetch_html",
"rating": 5,
"successful": true,
"comments": "完美适合这个任务的工具"
}'
```
### A/B 测试路由
```json
{
"routing": {
"strategy": "ab-testing",
"experiments": [
{
"name": "new-algorithm-test",
"enabled": true,
"trafficSplit": {
"control": 70,
"variant": 30
},
"rules": {
"control": {
"strategy": "round-robin",
"servers": ["stable-server-1", "stable-server-2"]
},
"variant": {
"strategy": "ai-optimized",
"servers": ["experimental-server-1"]
}
},
"metrics": ["response_time", "error_rate", "user_satisfaction"]
}
]
}
}
```
## 路由分析和监控
### 实时路由指标
```bash
# 获取路由统计
curl -X GET http://localhost:3000/api/routing/metrics \
-H "Authorization: Bearer $TOKEN"
```
响应示例:
```json
{
"timestamp": "2024-01-01T12:00:00Z",
"totalRequests": 15420,
"routingDistribution": {
"server-1": { "requests": 6168, "percentage": 40 },
"server-2": { "requests": 4626, "percentage": 30 },
"server-3": { "requests": 3084, "percentage": 20 },
"backup-server": { "requests": 1542, "percentage": 10 }
},
"performance": {
"avgResponseTime": "245ms",
"p95ResponseTime": "580ms",
"errorRate": "0.3%"
},
"failovers": {
"total": 2,
"byServer": {
"server-2": 1,
"server-3": 1
}
}
}
```
### 路由决策日志
```bash
# 启用路由决策日志
curl -X PUT http://localhost:3000/api/routing/logging \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"enabled": true,
"level": "info",
"includeDecisionFactors": true,
"sampleRate": 0.1
}'
```
## 自定义路由规则
### 基于业务逻辑的路由
```json
{
"routing": {
"strategy": "custom-rules",
"rules": [
{
"name": "premium-users",
"priority": 1,
"condition": "user.tier === 'premium'",
"action": {
"targetServers": ["premium-server-1", "premium-server-2"],
"strategy": "least-connections",
"qos": {
"maxResponseTime": "1s",
"priority": "high"
}
}
},
{
"name": "high-volume-requests",
"priority": 2,
"condition": "request.size > 10MB",
"action": {
"targetServers": ["high-capacity-server"],
"strategy": "single",
"timeout": "60s"
}
},
{
"name": "batch-processing",
"priority": 3,
"condition": "request.type === 'batch'",
"action": {
"targetServers": ["batch-server-1", "batch-server-2"],
"strategy": "queue-based",
"queueConfig": {
"maxSize": 1000,
"timeout": "5m"
}
}
}
]
}
}
```
### JavaScript 路由函数
```javascript
// 自定义路由函数
function customRouting(request, servers, metrics) {
const { user, content, timestamp } = request;
// 工作时间优先使用高性能服务器
const isBusinessHours =
new Date(timestamp).getHours() >= 9 && new Date(timestamp).getHours() <= 17;
if (isBusinessHours && user.priority === 'high') {
return servers.filter((s) => s.tags.includes('high-performance'));
}
// 基于内容类型的特殊路由
if (content.type === 'code-analysis') {
return servers.filter((s) => s.capabilities.includes('code-analysis'));
}
// 默认负载均衡
return servers.sort((a, b) => a.currentLoad - b.currentLoad);
}
```
## 路由优化
### 机器学习优化
```json
{
"routing": {
"strategy": "ml-optimized",
"mlConfig": {
"algorithm": "reinforcement-learning",
"rewardFunction": "response_time_weighted",
"trainingData": {
"features": [
"server_load",
"response_time_history",
"request_complexity",
"user_pattern",
"time_of_day"
],
"targetMetric": "overall_satisfaction"
},
"updateFrequency": "hourly",
"explorationRate": 0.1
}
}
}
```
### 缓存感知路由
```json
{
"routing": {
"strategy": "cache-aware",
"caching": {
"enabled": true,
"levels": [
{
"type": "local",
"ttl": 300,
"maxSize": "100MB"
},
{
"type": "distributed",
"provider": "redis",
"ttl": 3600,
"maxSize": "1GB"
}
],
"routing": {
"preferCachedServers": true,
"cacheHitBonus": 0.3,
"cacheMissThreshold": 0.8
}
}
}
}
```
``` */}
## 故障排除
### 路由调试
<AccordionGroup>
<Accordion title="数据库连接问题">
**症状:**
- 智能路由不可用
- 数据库连接错误
- 嵌入存储失败
```bash
# 调试特定请求的路由决策
curl -X POST http://localhost:3000/api/routing/debug \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"request": {
"userId": "user123",
"tool": "filesystem",
"content": {"type": "read", "path": "/data/file.txt"}
},
"traceRoute": true
}'
```
**解决方案:**
1. 验证 PostgreSQL 是否正在运行
2. 检查 DATABASE_URL 格式
3. 确保安装了 pgvector 扩展
4. 手动测试连接:
```bash
psql $DATABASE_URL -c "SELECT 1;"
```
### 路由性能分析
</Accordion>
```bash
# 获取路由性能报告
curl -X GET http://localhost:3000/api/routing/performance \
-H "Authorization: Bearer $TOKEN" \
-G -d "timeRange=1h" -d "detailed=true"
```
<Accordion title="嵌入服务问题">
**症状:**
- 工具索引失败
- 查询处理错误
- API 速率限制错误
### 常见问题
**解决方案:**
1. 验证 API 密钥有效性
2. 检查网络连接
3. 监控速率限制
4. 测试嵌入服务:
```bash
curl -X POST https://api.openai.com/v1/embeddings \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"input": "test", "model": "text-embedding-3-small"}'
```
1. **不均匀的负载分布**
</Accordion>
- 检查服务器权重配置
- 验证健康检查设置
- 分析请求模式
<Accordion title="搜索结果不佳">
**症状:**
- 返回不相关的工具
- 相关性得分低
- 缺少预期的工具
2. **频繁的故障转移**
**解决方案:**
1. 调整相似性阈值
2. 使用更好的描述重新索引工具
3. 使用更具体的查询
4. 检查工具元数据质量
```bash
# 重新索引所有工具
curl -X POST http://localhost:3000/api/smart-routing/reindex \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
```
- 调整健康检查阈值
- 检查网络连接稳定性
- 优化服务器资源
</Accordion>
3. **路由延迟过高**
- 简化路由规则
- 优化路由算法
- 使用缓存加速决策
<Accordion title="性能问题">
**症状:**
- 查询响应缓慢
- 数据库负载高
- 内存使用激增
有关更多信息,请参阅 [监控](/zh/features/monitoring) 和 [服务器管理](/zh/features/server-management) 文档。
**解决方案:**
1. 优化数据库配置
2. 增加缓存大小
3. 减少批处理大小
4. 监控系统资源
```bash
# 检查系统性能
curl http://localhost:3000/api/smart-routing/performance \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
```
</Accordion>
</AccordionGroup>
## 最佳实践
### 查询编写
<Tip>
**要具体描述**:在查询中使用具体、描述性的语言以获得更好的工具匹配。
</Tip>
<Tip>
**包含上下文**:提供有关您的任务或领域的相关上下文以获得更准确的结果。
</Tip>
<Tip>**使用自然语言**:像向人类描述任务一样编写查询。</Tip>
### 工具描述
<Warning>
**质量元数据**:确保 MCP 服务器提供高质量的工具描述和元数据。
</Warning>
<Warning>**定期更新**:随着功能的发展保持工具描述的最新状态。</Warning>
<Warning>
**一致的命名**:在工具和服务器中使用一致的命名约定。
</Warning>
### 系统维护
<Info>**定期重新索引**:定期重新索引工具以确保嵌入质量。</Info>
<Info>**监控性能**:跟踪查询模式并根据使用情况进行优化。</Info>
<Info>
**更新模型**:随着新嵌入模型的出现,考虑更新到更新的模型。
</Info>
## 下一步
<CardGroup cols={2}>
<Card title="身份验证" icon="shield" href="/zh/features/authentication">
用户管理和访问控制
</Card>
<Card title="监控" icon="chart-line" href="/zh/features/monitoring">
系统监控和分析
</Card>
<Card title="API 参考" icon="code" href="/zh/api-reference/smart-routing">
完整的智能路由 API 文档
</Card>
<Card title="配置" icon="cog" href="/zh/configuration/environment-variables">
高级配置选项
</Card>
</CardGroup>

View File

@@ -1,23 +1,21 @@
---
title: '欢迎使用 MCPHub'
description: 'MCPHub 是一个强大的 Model Context Protocol (MCP) 服务器管理平台,提供智能路由、负载均衡和实时监控功能'
title: '欢迎使用'
description: 'MCPHub 是一个强大的 Model Context Protocol (MCP) 服务器管理平台,提供分组管理、智能路由和实时监控功能'
---
<img className="block dark:hidden" src="/images/hero-light.png" alt="MCPHub Hero Light" />
<img className="hidden dark:block" src="/images/hero-dark.png" alt="MCPHub Hero Dark" />
{/* <img className="block dark:hidden" src="/images/hero-light.png" alt="MCPHub Hero Light" />
<img className="hidden dark:block" src="/images/hero-dark.png" alt="MCPHub Hero Dark" /> */}
## 什么是 MCPHub
MCPHub 是一个现代化的 Model Context Protocol (MCP) 服务器管理平台,旨在简化 AI 模型服务的部署、管理和监控。通过智能路由和负载均衡技术MCPHub 帮助您构建高可用、可扩展的 AI 服务架构。
MCPHub 是一个现代化的 Model Context Protocol (MCP) 服务器管理平台,旨在简化 AI 模型服务的部署、管理和监控。通过分组管理和智能路由技术MCPHub 帮助您构建高可用、可扩展的 AI 服务架构。
### 核心功能
- **🚀 智能路由** - 基于负载、延迟和健康状态的智能请求分发
- **⚖️ 负载均衡** - 多种负载均衡策略,确保最优性能
- **🏗️ 分组管理** - 灵活的服务器分组和配置管理
- **🚀 智能路由** - 基于语义检索的智能路由分发
- **📊 实时监控** - 全面的性能指标和健康检查
- **🔐 安全认证** - 企业级身份认证和访问控制
- **🏗️ 服务器组管理** - 灵活的服务器分组和配置管理
- **🔄 故障转移** - 自动故障检测和流量切换
- **🔐 安全认证** - 身份认证和访问控制
## 快速开始

570
docs/zh/installation.mdx Normal file
View File

@@ -0,0 +1,570 @@
---
title: '安装指南'
description: '各种平台的详细安装说明'
---
## 先决条件
在安装 MCPHub 之前,请确保您具备以下先决条件:
- **Node.js** 18+ (用于本地开发)
- **Docker** (推荐用于生产环境)
- **pnpm** (用于本地开发)
智能路由的可选要求:
- **PostgreSQL** 带 pgvector 扩展
- **OpenAI API Key** 或兼容的嵌入服务
## 安装方法
<Tabs>
<Tab title="Docker (推荐)">
### Docker 安装
Docker 是在生产环境中部署 MCPHub 的推荐方式。
#### 1. 基础安装
```bash
# 拉取最新镜像
docker pull samanhappy/mcphub:latest
# 使用默认设置运行
docker run -d \
--name mcphub \
-p 3000:3000 \
samanhappy/mcphub:latest
```
#### 2. 使用自定义配置
```bash
# 创建您的配置文件
cat > mcp_settings.json << 'EOF'
{
"mcpServers": {
"fetch": {
"command": "uvx",
"args": ["mcp-server-fetch"]
},
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest", "--headless"]
}
}
}
EOF
# 使用挂载的配置运行
docker run -d \
--name mcphub \
-p 3000:3000 \
-v $(pwd)/mcp_settings.json:/app/mcp_settings.json \
samanhappy/mcphub:latest
```
#### 3. 使用环境变量
```bash
docker run -d \
--name mcphub \
-p 3000:3000 \
-e PORT=3000 \
-e BASE_PATH="" \
samanhappy/mcphub:latest
```
#### 4. Docker Compose
创建 `docker-compose.yml` 文件:
```yaml
version: '3.8'
services:
mcphub:
image: samanhappy/mcphub:latest
ports:
- "3000:3000"
volumes:
- ./mcp_settings.json:/app/mcp_settings.json
environment:
- PORT=3000
- BASE_PATH=""
- REQUEST_TIMEOUT=60000
restart: unless-stopped
# 可选:用于智能路由的 PostgreSQL
postgres:
image: pgvector/pgvector:pg16
environment:
POSTGRES_DB: mcphub
POSTGRES_USER: mcphub
POSTGRES_PASSWORD: mcphub_password
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
volumes:
postgres_data:
```
运行命令:
```bash
docker-compose up -d
```
</Tab>
<Tab title="npm 包">
### npm 包安装
将 MCPHub 安装为全局 npm 包:
#### 1. 全局安装
```bash
# 全局安装
npm install -g @samanhappy/mcphub
# 或使用 yarn
yarn global add @samanhappy/mcphub
# 或使用 pnpm
pnpm add -g @samanhappy/mcphub
```
#### 2. 运行 MCPHub
```bash
# 使用默认设置运行
mcphub
# 使用自定义端口运行
PORT=8080 mcphub
```
{/* #### 3. 本地安装
您也可以在项目中本地安装 MCPHub
```bash
# 创建新目录
mkdir my-mcphub
cd my-mcphub
# 初始化 package.json
npm init -y
# 本地安装 MCPHub
npm install @samanhappy/mcphub
# 创建启动脚本
echo '#!/bin/bash\nnpx mcphub' > start.sh
chmod +x start.sh
# 运行 MCPHub
./start.sh
``` */}
</Tab>
<Tab title="本地开发">
### 本地开发环境设置
用于开发、自定义或贡献:
#### 1. 克隆仓库
```bash
# 克隆仓库
git clone https://github.com/samanhappy/mcphub.git
cd mcphub
```
#### 2. 安装依赖
```bash
# 使用 pnpm 安装依赖(推荐)
pnpm install
# 或使用 npm
npm install
# 或使用 yarn
yarn install
```
#### 3. 开发模式
```bash
# 在开发模式下同时启动后端和前端
pnpm dev
# 这将启动:
# - 后端在 http://localhost:3001
# - 前端在 http://localhost:5173
# - 前端代理 API 调用到后端
```
#### 4. 生产构建
```bash
# 构建后端和前端
pnpm build
# 启动生产服务器
pnpm start
```
#### 5. 开发脚本
```bash
# 仅后端(用于 API 开发)
pnpm backend:dev
# 仅前端(当后端单独运行时)
pnpm frontend:dev
# 运行测试
pnpm test
# 代码检查
pnpm lint
# 代码格式化
pnpm format
```
<Note>
在 Windows 上,您可能需要分别运行后端和前端:
```bash
# 终端 1后端
pnpm backend:dev
# 终端 2前端
pnpm frontend:dev
```
</Note>
</Tab>
<Tab title="Kubernetes">
### Kubernetes 部署
使用这些清单在 Kubernetes 上部署 MCPHub
#### 1. 设置的 ConfigMap
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: mcphub-config
data:
mcp_settings.json: |
{
"mcpServers": {
"fetch": {
"command": "uvx",
"args": ["mcp-server-fetch"]
},
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest", "--headless"]
}
}
}
```
#### 2. 部署
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: mcphub
spec:
replicas: 1
selector:
matchLabels:
app: mcphub
template:
metadata:
labels:
app: mcphub
spec:
containers:
- name: mcphub
image: samanhappy/mcphub:latest
ports:
- containerPort: 3000
env:
- name: PORT
value: "3000"
volumeMounts:
- name: config
mountPath: /app/mcp_settings.json
subPath: mcp_settings.json
volumes:
- name: config
configMap:
name: mcphub-config
```
#### 3. 服务
```yaml
apiVersion: v1
kind: Service
metadata:
name: mcphub-service
spec:
selector:
app: mcphub
ports:
- port: 80
targetPort: 3000
type: ClusterIP
```
#### 4. Ingress (可选)
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: mcphub-ingress
annotations:
nginx.ingress.kubernetes.io/proxy-buffering: "off"
spec:
rules:
- host: mcphub.yourdomain.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: mcphub-service
port:
number: 80
```
部署命令:
```bash
kubectl apply -f mcphub-configmap.yaml
kubectl apply -f mcphub-deployment.yaml
kubectl apply -f mcphub-service.yaml
kubectl apply -f mcphub-ingress.yaml
```
</Tab>
</Tabs>
## 智能路由设置 (可选)
智能路由使用向量语义搜索提供 AI 驱动的工具发现。
### 先决条件
1. **PostgreSQL 带 pgvector 扩展**
2. **OpenAI API Key** (或兼容的嵌入服务)
### 数据库设置
<Tabs>
<Tab title="Docker PostgreSQL">
```bash
# 运行带 pgvector 的 PostgreSQL
docker run -d \
--name mcphub-postgres \
-e POSTGRES_DB=mcphub \
-e POSTGRES_USER=mcphub \
-e POSTGRES_PASSWORD=your_password \
-p 5432:5432 \
pgvector/pgvector:pg16
```
</Tab>
<Tab title="现有 PostgreSQL">
如果您有现有的 PostgreSQL 实例:
```sql
-- 连接到您的 PostgreSQL 实例
-- 创建数据库
CREATE DATABASE mcphub;
-- 连接到 mcphub 数据库
\c mcphub;
-- 启用 pgvector 扩展
CREATE EXTENSION IF NOT EXISTS vector;
```
</Tab>
<Tab title="云 PostgreSQL">
对于云提供商AWS RDS、Google Cloud SQL 等):
1. 在您的云提供商控制台中启用 pgvector 扩展
2. 创建名为 `mcphub` 的数据库
3. 记下连接详细信息
</Tab>
</Tabs>
{/* ### 环境配置
设置以下环境变量:
```bash
# 数据库连接
DATABASE_URL=postgresql://mcphub:your_password@localhost:5432/mcphub
# 用于嵌入的 OpenAI API
OPENAI_API_KEY=your_openai_api_key
# 可选:自定义嵌入模型
EMBEDDING_MODEL=text-embedding-3-small
# 可选:启用智能路由
ENABLE_SMART_ROUTING=true
``` */}
## 验证
安装后,验证 MCPHub 是否正常工作:
{/* ### 1. 健康检查
```bash
curl http://localhost:3000/api/health
```
预期响应:
```json
{
"status": "ok",
"version": "x.x.x",
"uptime": 123
}
``` */}
### 控制台访问
打开浏览器并导航到:
```
http://localhost:3000
```
{/* ### 3. API 测试
```bash
curl -X POST http://localhost:3000/mcp \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {}
}'
``` */}
## 故障排除
<AccordionGroup>
<Accordion title="Docker 问题">
**端口已被使用:**
```bash
# 检查是什么在使用端口 3000
lsof -i :3000
# 使用不同的端口
docker run -p 8080:3000 samanhappy/mcphub
```
**容器无法启动:**
```bash
# 检查容器日志
docker logs mcphub
# 交互式运行以进行调试
docker run -it --rm samanhappy/mcphub /bin/bash
```
</Accordion>
<Accordion title="npm 安装问题">
**权限错误:**
```bash
# 使用 npx 而不是全局安装
npx @samanhappy/mcphub
# 或修复 npm 权限
npm config set prefix ~/.npm-global
export PATH=~/.npm-global/bin:$PATH
```
**Node 版本问题:**
```bash
# 检查 Node 版本
node --version
# 使用 nvm 安装 Node 18+
nvm install 18
nvm use 18
```
</Accordion>
<Accordion title="网络问题">
**无法访问控制台:**
- 检查 MCPHub 是否在运行:`ps aux | grep mcphub`
- 验证端口绑定:`netstat -tlnp | grep 3000`
- 检查防火墙设置
- 尝试通过 `127.0.0.1:3000` 而不是 `localhost:3000` 访问
**AI 客户端无法连接:**
- 确保端点 URL 正确
- 检查 MCPHub 是否在代理后面
- 验证 Kubernetes/Docker 环境中的网络策略
</Accordion>
<Accordion title="智能路由问题">
**数据库连接失败:**
```bash
# 测试数据库连接
psql $DATABASE_URL -c "SELECT 1;"
# 检查是否安装了 pgvector
psql $DATABASE_URL -c "CREATE EXTENSION IF NOT EXISTS vector;"
```
**嵌入服务错误:**
- 验证 OpenAI API 密钥是否有效
- 检查互联网连接
- 监控速率限制
</Accordion>
</AccordionGroup>
## 下一步
<CardGroup cols={2}>
<Card title="配置" icon="cog" href="/zh/configuration/mcp-settings">
配置您的 MCP 服务器和设置
</Card>
<Card title="快速开始" icon="rocket" href="/zh/quickstart">
5分钟内启动并运行
</Card>
<Card title="服务器管理" icon="server" href="/zh/features/server-management">
了解如何管理您的 MCP 服务器
</Card>
<Card title="API 参考" icon="code" href="/zh/api-reference/introduction">
探索完整的 API 文档
</Card>
</CardGroup>

View File

@@ -1,304 +1,212 @@
---
title: '快速开始'
description: '5 分钟内部署 MCPHub 并连接您的第一个 MCP 服务器'
title: '快速开始指南'
description: '5 分钟内运行 MCPHub'
---
## 欢迎使用 MCPHub
## 安装
本指南将帮助您在 5 分钟内完成 MCPHub 的部署和配置,并连接您的第一个 MCP 服务器。
## 前提条件
在开始之前,请确保您的系统满足以下要求:
<AccordionGroup>
<Accordion icon="desktop" title="系统要求">
- **操作系统**: Linux、macOS 或 Windows
- **内存**: 最少 2GB RAM推荐 4GB+
- **存储**: 至少 1GB 可用空间
- **网络**: 稳定的互联网连接
</Accordion>
<Accordion icon="code" title="软件依赖">
- **Node.js**: 18.0+ 版本
- **Docker**: 最新版本(可选,用于容器化部署)
- **Git**: 用于代码管理
检查版本:
```bash
node --version # 应该 >= 18.0.0
npm --version # 应该 >= 8.0.0
docker --version # 可选
```
</Accordion>
</AccordionGroup>
## 安装 MCPHub
### 方式一:使用 npm推荐
<AccordionGroup>
<Accordion icon="download" title="安装 MCPHub CLI">
首先安装 MCPHub 命令行工具:
<Tabs>
<Tab title="Docker推荐">
使用 Docker 是最快的开始方式:
```bash
npm install -g @mcphub/cli
# 使用默认配置运行
docker run -p 3000:3000 samanhappy/mcphub
```
验证安装
或者挂载自定义配置
```bash
mcphub --version
# 使用自定义 MCP 设置运行
docker run -p 3000:3000 \
-v $(pwd)/mcp_settings.json:/app/mcp_settings.json \
samanhappy/mcphub
```
</Accordion>
<Accordion icon="folder-plus" title="创建新项目">
创建一个新的 MCPHub 项目:
</Tab>
<Tab title="本地开发">
用于开发或自定义:
```bash
# 创建项目
mcphub init my-mcphub-project
cd my-mcphub-project
# 克隆仓库
git clone https://github.com/samanhappy/mcphub.git
cd mcphub
# 安装依赖
npm install
pnpm install
# 启动开发服务器
pnpm dev
```
</Accordion>
这会同时启动后端(端口 3001和前端端口 5173的开发模式。
<Accordion icon="gear" title="配置环境">
复制并编辑环境变量文件:
</Tab>
<Tab title="npm 包">
将 MCPHub 安装为全局包:
```bash
cp .env.example .env
# 全局安装
npm install -g @samanhappy/mcphub
# 运行 MCPHub
mcphub
```
编辑 `.env` 文件,设置基本配置:
```bash
# 服务器配置
PORT=3000
NODE_ENV=development
</Tab>
</Tabs>
# 数据库配置(使用内置 SQLite
DATABASE_URL=sqlite:./data/mcphub.db
## 初始设置
# JWT 密钥(请更改为安全的随机字符串)
JWT_SECRET=your-super-secret-jwt-key-change-me
### 1. 访问控制面板
# 管理员账户
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=admin123
```
打开浏览器并导航到:
</Accordion>
</AccordionGroup>
### 方式二:使用 Docker
<AccordionGroup>
<Accordion icon="docker" title="Docker 快速部署">
使用 Docker Compose 一键部署:
```bash
# 下载配置文件
curl -O https://raw.githubusercontent.com/mcphub/mcphub/main/docker-compose.yml
# 启动服务
docker-compose up -d
```
或者直接运行 Docker 容器:
```bash
docker run -d \
--name mcphub \
-p 3000:3000 \
-e NODE_ENV=production \
-e JWT_SECRET=your-secret-key \
mcphub/server:latest
```
</Accordion>
</AccordionGroup>
## 启动 MCPHub
### 开发模式启动
```bash
# 初始化数据库
npm run db:setup
# 启动开发服务器
npm run dev
```
http://localhost:3000
```
### 生产模式启动
### 2. 登录
```bash
# 构建应用
npm run build
使用默认凭据:
# 启动生产服务器
npm start
```
<Note>开发模式下MCPHub 会在 `http://localhost:3000` 启动,并具有热重载功能。</Note>
## 首次访问和配置
### 1. 访问管理界面
打开浏览器,访问 `http://localhost:3000`,您将看到 MCPHub 的欢迎页面。
### 2. 登录管理员账户
使用您在 `.env` 文件中设置的管理员凭据登录:
- **邮箱**: `admin@example.com`
- **用户名**: `admin`
- **密码**: `admin123`
<Warning>首次登录后,请立即更改默认密码以确保安全!</Warning>
<Warning>为了安全起见,请在首次登录后立即更改这些默认凭据。</Warning>
### 3. 完成初始配置
### 3. 配置您的第一个 MCP 服务器
登录后,系统会引导您完成初始配置:
1. 在控制面板中点击 **"添加服务器"**
2. 输入服务器详细信息:
- **名称**: 唯一标识符(例如 `fetch`
- **命令**: 可执行命令(`uvx`
- **参数**: 命令参数(`["mcp-server-fetch"]`
- **环境**: 任何所需的环境变量
1. **更改管理员密码**
2. **设置组织信息**
3. **配置基本设置**
fetch 服务器的示例配置:
## 添加您的第一个 MCP 服务器
### 1. 准备 MCP 服务器
如果您还没有 MCP 服务器,可以使用我们的示例服务器进行测试:
```bash
# 克隆示例服务器
git clone https://github.com/mcphub/example-mcp-server.git
cd example-mcp-server
# 安装依赖并启动
npm install
npm start
```json
{
"name": "fetch",
"command": "uvx",
"args": ["mcp-server-fetch"],
"env": {}
}
```
示例服务器将在 `http://localhost:3001` 启动。
## 基本使用
### 2. 在 MCPHub 中添加服务器
### 连接 AI 客户端
在 MCPHub 管理界面中
一旦配置了服务器,使用 MCPHub 端点连接您的 AI 客户端
1. 点击 **"添加服务器"** 按钮
2. 填写服务器信息:
```
名称: Example MCP Server
端点: http://localhost:3001
描述: 示例 MCP 服务器用于测试
```
3. 选择功能类型chat、completion、analysis
4. 点击 **"测试连接"** 验证服务器可达性
5. 点击 **"保存"** 完成添加
<Tabs>
<Tab title="所有服务器">
访问所有已配置的 MCP 服务器:``` http://localhost:3000/mcp ```
</Tab>
<Tab title="特定组">
访问特定组中的服务器:``` http://localhost:3000/mcp/{groupName} ```
</Tab>
<Tab title="单个服务器">
访问单个服务器:``` http://localhost:3000/mcp/{serverName} ```
</Tab>
<Tab title="智能路由">
使用 AI 驱动的工具发现:``` http://localhost:3000/mcp/$smart ```
<Info>智能路由需要使用 pgvector 的 PostgreSQL 和 OpenAI API 密钥。</Info>
</Tab>
</Tabs>
### 3. 验证服务器状态
### 示例:添加热门 MCP 服务器
添加成功后,您应该能在服务器列表中看到新添加的服务器,状态显示为 **"活跃"**(绿色)。
以下是一些您可以添加的热门 MCP 服务器:
## 测试路由功能
### 发送测试请求
使用 cURL 或其他 HTTP 客户端测试路由功能:
```bash
# 发送聊天请求
curl -X POST http://localhost:3000/api/chat \
-H "Content-Type: application/json" \
-d '{
"messages": [
{
"role": "user",
"content": "Hello, this is a test message!"
<AccordionGroup>
<Accordion title="Web Fetch 服务器">
```json
{
"name": "fetch",
"command": "uvx",
"args": ["mcp-server-fetch"]
}
```
</Accordion>
<Accordion title="Playwright 浏览器自动化">
```json
{
"name": "playwright",
"command": "npx",
"args": ["@playwright/mcp@latest", "--headless"]
}
```
</Accordion>
<Accordion title="高德地图(需要 API 密钥)">
```json
{
"name": "amap",
"command": "npx",
"args": ["-y", "@amap/amap-maps-mcp-server"],
"env": {
"AMAP_MAPS_API_KEY": "your-api-key-here"
}
]
}'
```
### 查看请求日志
在 MCPHub 管理界面的 **"监控"** 页面中,您可以实时查看:
- 请求数量和响应时间
- 服务器健康状态
- 错误日志和统计
}
```
</Accordion>
<Accordion title="Slack 集成">
```json
{
"name": "slack",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-slack"],
"env": {
"SLACK_BOT_TOKEN": "your-bot-token",
"SLACK_TEAM_ID": "your-team-id"
}
}
```
</Accordion>
</AccordionGroup>
## 后续步骤
恭喜!您已经成功部署了 MCPHub 并添加了第一个 MCP 服务器。接下来您可以:
<CardGroup cols={2}>
<Card title="配置负载均衡" icon="balance-scale" href="/zh/features/smart-routing">
学习如何配置智能路由和负载均衡策略
<Card title="服务器管理" icon="server" href="/zh/features/server-management">
学习高级服务器配置和管理
</Card>
<Card title="添加更多服务器" icon="plus" href="/zh/features/server-management">
了解服务器管理的高级功能
<Card title="组管理" icon="users" href="/zh/features/group-management">
服务器组织成逻辑组
</Card>
<Card title="设置监控告警" icon="bell" href="/zh/features/monitoring">
配置性能监控和告警通知
<Card title="智能路由" icon="route" href="/zh/features/smart-routing">
设置 AI 驱动的工具发现
</Card>
<Card title="API 集成" icon="code" href="/zh/api-reference/introduction">
将 MCPHub 集成到您的应用程序中
<Card title="API 参考" icon="code" href="/zh/api-reference/introduction">
探索完整的 API 文档
</Card>
</CardGroup>
## 常见问题
## 故障排除
<AccordionGroup>
<Accordion icon="question" title="无法连接到 MCP 服务器">
**可能原因**
- 服务器地址错误或服务器未启动
- 防火墙阻止连接
- 网络配置问题
**解决方案**
1. 验证服务器是否正在运行:`curl http://localhost:3001/health`
2. 检查防火墙设置
3. 确认网络连接正常
<Accordion title="服务器无法启动">
- 检查 MCP 服务器命令是否在您的 PATH 中可访问
- 验证环境变量是否正确设置
- 检查 MCPHub 日志以获取详细错误信息
</Accordion>
<Accordion icon="question" title="服务器状态显示为离线">
**可能原因**
- 健康检查失败
- 服务器响应超时
- 服务器崩溃或重启
**解决方案**
1. 检查服务器日志
2. 调整健康检查间隔
3. 重启服务器进程
<Accordion title="无法从 AI 客户端连接">
- 确保 MCPHub 在正确的端口上运行
- 检查防火墙设置
- 验证端点 URL 格式
</Accordion>
<Accordion icon="question" title="忘记管理员密码">
**解决方案**
```bash
# 重置管理员密码
npm run reset-admin-password
```
或者删除数据库文件重新初始化:
```bash
rm data/mcphub.db
npm run db:setup
```
<Accordion title="身份验证问题">
- 验证凭据是否正确
- 检查 JWT 令牌是否有效
- 尝试清除浏览器缓存和 cookie
</Accordion>
</AccordionGroup>
## 获取帮助
如果您在设置过程中遇到问题:
- 📖 查看 [完整文档](/zh/development/getting-started)
- 🐛 在 [GitHub](https://github.com/mcphub/mcphub/issues) 上报告问题
- 💬 加入 [Discord 社区](https://discord.gg/mcphub) 获取实时帮助
- 📧 发送邮件至 support@mcphub.io
需要更多帮助?加入我们的 [Discord 社区](https://discord.gg/qMKNsn5Q) 获取支持!

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { BrowserRouter as Router, Route, Routes, Navigate } from 'react-router-dom';
import { BrowserRouter as Router, Route, Routes, Navigate, useParams } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext';
import { ToastProvider } from './contexts/ToastContext';
import { ThemeProvider } from './contexts/ThemeContext';
@@ -15,6 +15,12 @@ import MarketPage from './pages/MarketPage';
import LogsPage from './pages/LogsPage';
import { getBasePath } from './utils/runtime';
// Helper component to redirect cloud server routes to market
const CloudRedirect: React.FC = () => {
const { serverName } = useParams<{ serverName: string }>();
return <Navigate to={`/market/${serverName}?tab=cloud`} replace />;
};
function App() {
const basename = getBasePath();
return (
@@ -35,6 +41,12 @@ function App() {
<Route path="/users" element={<UsersPage />} />
<Route path="/market" element={<MarketPage />} />
<Route path="/market/:serverName" element={<MarketPage />} />
{/* Legacy cloud routes redirect to market with cloud tab */}
<Route path="/cloud" element={<Navigate to="/market?tab=cloud" replace />} />
<Route
path="/cloud/:serverName"
element={<CloudRedirect />}
/>
<Route path="/logs" element={<LogsPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>

View File

@@ -2,8 +2,8 @@ import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useGroupData } from '@/hooks/useGroupData'
import { useServerData } from '@/hooks/useServerData'
import { GroupFormData, Server } from '@/types'
import { ToggleGroup } from './ui/ToggleGroup'
import { GroupFormData, Server, IGroupServerConfig } from '@/types'
import { ServerToolConfig } from './ServerToolConfig'
interface AddGroupFormProps {
onAdd: () => void
@@ -21,7 +21,7 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
const [formData, setFormData] = useState<GroupFormData>({
name: '',
description: '',
servers: []
servers: [] as IGroupServerConfig[]
})
useEffect(() => {
@@ -50,9 +50,8 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
}
const result = await createGroup(formData.name, formData.description, formData.servers)
if (!result) {
setError(t('groups.createError'))
if (!result || !result.success) {
setError(result?.message || t('groups.createError'))
setIsSubmitting(false)
return
}
@@ -66,64 +65,68 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
<div className="p-6">
<div className="bg-white rounded-lg shadow-lg max-w-2xl w-full max-h-[90vh] flex flex-col">
<div className="p-6 flex-shrink-0">
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('groups.addNew')}</h2>
{error && (
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded-md border border-gray-200">
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="name">
{t('groups.name')} *
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder={t('groups.namePlaceholder')}
required
/>
</div>
<ToggleGroup
className="mb-6"
label={t('groups.servers')}
noOptionsText={t('groups.noServerOptions')}
values={formData.servers}
options={availableServers.map(server => ({
value: server.name,
label: server.name
}))}
onChange={(servers) => setFormData(prev => ({ ...prev, servers }))}
/>
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-gray-600 hover:text-gray-800 btn-secondary"
disabled={isSubmitting}
>
{t('common.cancel')}
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 btn-primary"
disabled={isSubmitting}
>
{isSubmitting ? t('common.submitting') : t('common.create')}
</button>
</div>
</form>
</div>
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
<div className="flex-1 overflow-y-auto px-6">
<div className="space-y-4">
<div>
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="name">
{t('groups.name')} *
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder={t('groups.namePlaceholder')}
required
/>
</div>
<div>
<label className="block text-gray-700 text-sm font-bold mb-2">
{t('groups.configureTools')}
</label>
<ServerToolConfig
servers={availableServers}
value={formData.servers as IGroupServerConfig[]}
onChange={(servers) => setFormData(prev => ({ ...prev, servers }))}
className="border border-gray-200 rounded-lg p-4 bg-gray-50"
/>
</div>
</div>
</div>
<div className="flex justify-end space-x-3 p-6 pt-4 border-t border-gray-200 flex-shrink-0">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-gray-600 hover:text-gray-800 border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
disabled={isSubmitting}
>
{t('common.cancel')}
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 disabled:opacity-50 transition-colors"
disabled={isSubmitting}
>
{isSubmitting ? t('common.submitting') : t('common.create')}
</button>
</div>
</form>
</div>
</div>
)

View File

@@ -1,7 +1,7 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import ServerForm from './ServerForm'
import { getApiUrl } from '../utils/runtime'
import { apiPost } from '../utils/fetchInterceptor'
import { detectVariables } from '../utils/variableDetection'
interface AddServerFormProps {
@@ -34,26 +34,12 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => {
const submitServer = async (payload: any) => {
try {
setError(null)
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/servers'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || ''
},
body: JSON.stringify(payload),
})
const result = await apiPost('/servers', payload)
const result = await response.json()
if (!response.ok) {
if (!result.success) {
// Use specific error message from the response if available
if (result && result.message) {
setError(result.message)
} else if (response.status === 400) {
setError(t('server.invalidData'))
} else if (response.status === 409) {
setError(t('server.alreadyExists', { serverName: payload.name }))
} else {
setError(t('server.addError'))
}

View File

@@ -0,0 +1,144 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { CloudServer } from '@/types';
interface CloudServerCardProps {
server: CloudServer;
onClick: (server: CloudServer) => void;
}
const CloudServerCard: React.FC<CloudServerCardProps> = ({ server, onClick }) => {
const { t } = useTranslation();
const handleClick = () => {
onClick(server);
};
// Extract a brief description from content if description is too long
const getDisplayDescription = () => {
if (server.description && server.description.length <= 150) {
return server.description;
}
// Try to extract a summary from content
if (server.content) {
const lines = server.content.split('\n').filter(line => line.trim());
for (const line of lines) {
if (line.length > 50 && line.length <= 150) {
return line;
}
}
}
return server.description ?
server.description.slice(0, 150) + '...' :
t('cloud.noDescription');
};
// Format date for display
const formatDate = (dateString: string) => {
try {
const date = new Date(dateString);
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
return `${year}/${month}/${day}`;
} catch {
return '';
}
};
// Get initials for avatar
const getAuthorInitials = (name: string) => {
return name
.split(' ')
.map(word => word.charAt(0))
.join('')
.toUpperCase()
.slice(0, 2);
};
return (
<div
className="bg-white border border-gray-200 rounded-xl p-4 hover:shadow-lg hover:border-blue-400 hover:-translate-y-1 transition-all duration-300 cursor-pointer group relative overflow-hidden h-full flex flex-col"
onClick={handleClick}
>
{/* Background gradient overlay on hover */}
<div className="absolute inset-0 bg-gradient-to-br from-blue-50/0 to-purple-50/0 group-hover:from-blue-50/30 group-hover:to-purple-50/30 transition-all duration-300 pointer-events-none" />
{/* Server Header */}
<div className="relative z-10 flex-1 flex flex-col">
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h3 className="text-lg font-bold text-gray-900 group-hover:text-blue-600 transition-colors duration-200 mb-2 line-clamp-2">
{server.title || server.name}
</h3>
{/* Author Section */}
<div className="flex items-center space-x-2 mb-2">
<div className="w-7 h-7 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center text-white text-xs font-semibold">
{getAuthorInitials(server.author_name)}
</div>
<div>
<p className="text-sm font-medium text-gray-700">{server.author_name}</p>
{server.updated_at && (
<p className="text-xs text-gray-500">
{t('cloud.updated')} {formatDate(server.updated_at)}
</p>
)}
</div>
</div>
</div>
{/* Server Type Badge */}
<div className="flex flex-col items-end space-y-2">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
MCP Server
</span>
</div>
</div>
{/* Description */}
<div className="mb-3 flex-1">
<p className="text-gray-600 text-sm leading-relaxed line-clamp-2">
{getDisplayDescription()}
</p>
</div>
{/* Tools Info */}
{server.tools && server.tools.length > 0 && (
<div className="mb-3">
<div className="flex items-center space-x-2">
<svg className="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span className="text-sm text-gray-600 font-medium">
{server.tools.length} {server.tools.length === 1 ? t('cloud.tool') : t('cloud.tools')}
</span>
</div>
</div>
)}
{/* Footer - 固定在底部 */}
<div className="flex items-center justify-between pt-3 border-t border-gray-100 mt-auto">
<div className="flex items-center space-x-2 text-xs text-gray-500">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" clipRule="evenodd" />
</svg>
<span>{formatDate(server.created_at)}</span>
</div>
<div className="flex items-center text-blue-600 text-sm font-medium group-hover:text-blue-700 transition-colors">
<span>{t('cloud.viewDetails')}</span>
<svg className="w-4 h-4 ml-1 transform group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
</div>
</div>
);
};
export default CloudServerCard;

View File

@@ -0,0 +1,573 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { CloudServer, CloudServerTool, ServerConfig } from '@/types';
import { apiGet } from '@/utils/fetchInterceptor';
import { useSettingsData } from '@/hooks/useSettingsData';
import MCPRouterApiKeyError from './MCPRouterApiKeyError';
import ServerForm from './ServerForm';
interface CloudServerDetailProps {
serverName: string;
onBack: () => void;
onCallTool?: (serverName: string, toolName: string, args: Record<string, any>) => Promise<any>;
fetchServerTools?: (serverName: string) => Promise<CloudServerTool[]>;
onInstall?: (server: CloudServer, config: ServerConfig) => void;
installing?: boolean;
isInstalled?: boolean;
}
const CloudServerDetail: React.FC<CloudServerDetailProps> = ({
serverName,
onBack,
onCallTool,
fetchServerTools,
onInstall,
installing = false,
isInstalled = false
}) => {
const { t } = useTranslation();
const { mcpRouterConfig } = useSettingsData();
const [server, setServer] = useState<CloudServer | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tools, setTools] = useState<CloudServerTool[]>([]);
const [loadingTools, setLoadingTools] = useState(false);
const [toolsApiKeyError, setToolsApiKeyError] = useState(false);
const [toolCallLoading, setToolCallLoading] = useState<string | null>(null);
const [toolCallResults, setToolCallResults] = useState<Record<string, any>>({});
const [toolArgs, setToolArgs] = useState<Record<string, Record<string, any>>>({});
const [expandedSchemas, setExpandedSchemas] = useState<Record<string, boolean>>({});
const [modalVisible, setModalVisible] = useState(false);
const [installError, setInstallError] = useState<string | null>(null);
// Helper function to check if error is MCPRouter API key not configured
const isMCPRouterApiKeyError = (errorMessage: string) => {
console.error('Checking for MCPRouter API key error:', errorMessage);
return errorMessage === 'MCPROUTER_API_KEY_NOT_CONFIGURED' ||
errorMessage.toLowerCase().includes('mcprouter api key not configured');
};
// Helper function to determine button state for install
const getInstallButtonProps = () => {
if (isInstalled) {
return {
className: "bg-green-600 cursor-default px-4 py-2 rounded text-sm font-medium text-white",
disabled: true,
text: t('market.installed')
};
} else if (installing) {
return {
className: "bg-gray-400 cursor-not-allowed px-4 py-2 rounded text-sm font-medium text-white",
disabled: true,
text: t('market.installing')
};
} else {
return {
className: "bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm font-medium text-white transition-colors",
disabled: false,
text: t('market.install')
};
}
};
// Handle install button click
const handleInstall = () => {
if (!isInstalled && onInstall) {
setModalVisible(true);
setInstallError(null);
}
};
// Handle modal close
const handleModalClose = () => {
setModalVisible(false);
setInstallError(null);
};
// Handle install form submission
const handleInstallSubmit = async (payload: any) => {
try {
if (!server || !onInstall) return;
setInstallError(null);
onInstall(server, payload.config);
setModalVisible(false);
} catch (err) {
console.error('Error installing server:', err);
setInstallError(t('errors.serverInstall'));
}
};
// Load server details
useEffect(() => {
const loadServerDetails = async () => {
try {
setLoading(true);
setError(null);
const response = await apiGet(`/cloud/servers/${serverName}`);
if (response && response.success && response.data) {
setServer(response.data);
setTools(response.data.tools || []);
} else {
setError(t('cloud.serverNotFound'));
}
} catch (err) {
console.error('Failed to load server details:', err);
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
};
loadServerDetails();
}, [serverName, t]);
// Load tools if not already loaded
useEffect(() => {
const loadTools = async () => {
if (server && (!server.tools || server.tools.length === 0) && fetchServerTools) {
setLoadingTools(true);
setToolsApiKeyError(false);
try {
const fetchedTools = await fetchServerTools(server.name);
setTools(fetchedTools);
} catch (error) {
console.error('Failed to load tools:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
if (isMCPRouterApiKeyError(errorMessage)) {
setToolsApiKeyError(true);
}
} finally {
setLoadingTools(false);
}
}
};
loadTools();
}, [server?.name, server?.tools, fetchServerTools]);
// Format creation date
const formatDate = (dateStr: string) => {
try {
const date = new Date(dateStr);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
} catch {
return dateStr;
}
};
// Handle tool argument changes
const handleArgChange = (toolName: string, argName: string, value: any) => {
setToolArgs(prev => ({
...prev,
[toolName]: {
...prev[toolName],
[argName]: value
}
}));
};
// Handle tool call
const handleCallTool = async (toolName: string) => {
if (!onCallTool || !server) return;
setToolCallLoading(toolName);
try {
const args = toolArgs[toolName] || {};
const result = await onCallTool(server.server_key, toolName, args);
setToolCallResults(prev => ({
...prev,
[toolName]: result
}));
} catch (error) {
console.error('Tool call failed:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
setToolCallResults(prev => ({
...prev,
[toolName]: { error: errorMessage }
}));
} finally {
setToolCallLoading(null);
}
};
// Toggle schema visibility
const toggleSchema = (toolName: string) => {
setExpandedSchemas(prev => ({
...prev,
[toolName]: !prev[toolName]
}));
};
// Render tool input field based on schema
const renderToolInput = (tool: CloudServerTool, propName: string, propSchema: any) => {
const currentValue = toolArgs[tool.name]?.[propName] || '';
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
let value: any = e.target.value;
// Convert value based on schema type
if (propSchema.type === 'number' || propSchema.type === 'integer') {
value = value === '' ? undefined : Number(value);
} else if (propSchema.type === 'boolean') {
value = e.target.value === 'true';
}
handleArgChange(tool.name, propName, value);
};
if (propSchema.type === 'boolean') {
return (
<select
value={currentValue === true ? 'true' : currentValue === false ? 'false' : ''}
onChange={handleChange}
className="w-full border rounded-md px-3 py-2 border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
>
<option value=""></option>
<option value="true">True</option>
<option value="false">False</option>
</select>
);
} else if (propSchema.type === 'number' || propSchema.type === 'integer') {
return (
<input
type="number"
step={propSchema.type === 'integer' ? '1' : 'any'}
value={currentValue || ''}
onChange={handleChange}
className="w-full border rounded-md px-3 py-2 border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
/>
);
} else {
return (
<input
type="text"
value={currentValue || ''}
onChange={handleChange}
className="w-full border rounded-md px-3 py-2 border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
/>
);
}
};
return (
<div className="bg-white rounded-lg shadow-md p-6">
<div className="mb-6">
<button
onClick={onBack}
className="inline-flex items-center text-gray-600 hover:text-gray-900 transition-colors group"
>
<svg className="h-5 w-5 mr-2 transform group-hover:-translate-x-1 transition-transform" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
</svg>
{t('cloud.backToList')}
</button>
</div>
{loading ? (
<div className="bg-white rounded-xl shadow-sm p-12">
<div className="flex flex-col items-center">
<svg className="animate-spin h-12 w-12 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p className="text-gray-600 text-lg">{t('app.loading')}</p>
</div>
</div>
) : error && !isMCPRouterApiKeyError(error) ? (
<div className="bg-white rounded-xl shadow-sm p-6">
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center">
<svg className="h-5 w-5 text-red-400 mr-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
<p className="text-red-700">{error}</p>
</div>
</div>
</div>
) : !server ? (
<div className="bg-white rounded-xl shadow-sm p-12">
<div className="text-center">
<svg className="h-12 w-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9.172 16.172a4 4 0 015.656 0M9 12h6m-6-4h6m2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<p className="text-gray-600 text-lg">{t('cloud.serverNotFound')}</p>
</div>
</div>
) : (
<div className="space-y-6">
{/* Server Header Card */}
<div className="bg-white rounded-xl shadow-sm overflow-hidden">
<div className="bg-gradient-to-r from-gray-100 to-gray-200 px-6 py-4">
<div className="flex justify-between items-end">
<div className="flex-1">
<h1 className="text-2xl font-bold text-gray-800 mb-2">
{server.title || server.name}
</h1>
<div className="flex flex-wrap items-center gap-4 text-gray-600">
<span className="text-sm bg-white/60 text-gray-700 px-3 py-1 rounded-full">
{server.name}
</span>
<div className="flex items-center">
<svg className="h-4 w-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clipRule="evenodd" />
</svg>
{t('cloud.by')} {server.author_name}
</div>
</div>
</div>
<div className="text-right flex flex-col items-end gap-3">
<div className="text-xs text-gray-500">
{t('cloud.updated')}: {formatDate(server.updated_at)}
</div>
{onInstall && !isMCPRouterApiKeyError(error || '') && !toolsApiKeyError && (
<button
onClick={handleInstall}
disabled={getInstallButtonProps().disabled}
className={getInstallButtonProps().className}
>
{getInstallButtonProps().text}
</button>
)}
</div>
</div>
</div>
</div>
{/* Description Card */}
<div className="bg-white rounded-xl shadow-sm p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center">
<svg className="h-5 w-5 text-gray-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{t('cloud.description')}
</h2>
<p className="text-gray-700 leading-relaxed">{server.description}</p>
</div>
{/* Content Card */}
{server.content && (
<div className="bg-white rounded-xl shadow-sm p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center">
<svg className="h-5 w-5 text-gray-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
{t('cloud.details')}
</h2>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 overflow-auto">
<pre className="text-sm text-gray-800 whitespace-pre-wrap">{server.content}</pre>
</div>
</div>
)}
{/* Tools Card */}
<div className="bg-white rounded-xl shadow-sm p-6">
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center">
<svg className="h-5 w-5 text-gray-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{t('cloud.tools')}
{tools.length > 0 && (
<span className="ml-2 bg-blue-100 text-blue-800 text-sm font-medium px-2.5 py-0.5 rounded-full">
{tools.length}
</span>
)}
</h2>
{/* Check for API key error */}
{toolsApiKeyError && (
<MCPRouterApiKeyError />
)}
{loadingTools ? (
<div className="flex items-center justify-center py-12">
<svg className="animate-spin h-8 w-8 text-blue-500 mr-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span className="text-gray-600">{t('cloud.loadingTools')}</span>
</div>
) : tools.length === 0 && !toolsApiKeyError ? (
<div className="text-center py-12">
<svg className="h-12 w-12 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
<p className="text-gray-600">{t('cloud.noTools')}</p>
</div>
) : tools.length > 0 ? (
<div className="space-y-4">
{tools.map((tool, index) => (
<div key={index} className="border border-gray-200 rounded-lg p-6 hover:border-gray-300 transition-colors">
<div className="flex justify-between items-start mb-4">
<div className="flex-1">
<h3 className="text-lg font-medium text-gray-900 mb-2 flex items-center">
<span className="bg-blue-100 text-blue-800 text-xs font-medium px-2 py-1 rounded mr-3">
TOOL
</span>
{tool.name}
</h3>
<p className="text-gray-600 leading-relaxed whitespace-pre-wrap">{tool.description}</p>
</div>
{onCallTool && (
<button
onClick={() => handleCallTool(tool.name)}
disabled={toolCallLoading === tool.name}
className="ml-4 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center min-w-[100px] justify-center"
>
{toolCallLoading === tool.name ? (
<>
<svg className="animate-spin h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{t('cloud.calling')}
</>
) : (
<>
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.828 14.828a4 4 0 01-5.656 0M9 10h6m2 8l4-4H7l4 4z" />
</svg>
{t('cloud.callTool')}
</>
)}
</button>
)}
</div>
{/* Tool inputs */}
{tool.inputSchema && tool.inputSchema.properties && Object.keys(tool.inputSchema.properties).length > 0 && (
<div className="border-t border-gray-100 pt-4">
<div className="flex items-center gap-3 mb-4">
<h4 className="text-sm font-medium text-gray-700">{t('cloud.parameters')}</h4>
<button
onClick={() => toggleSchema(tool.name)}
className="text-sm text-blue-600 hover:text-blue-800 focus:outline-none flex items-center gap-1 transition-colors"
>
{t('cloud.viewSchema')}
<svg
className={`h-3 w-3 transition-transform duration-200 ${expandedSchemas[tool.name] ? 'rotate-90' : 'rotate-0'}`}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg>
</button>
</div>
{/* Schema content */}
{expandedSchemas[tool.name] && (
<div className="mb-4">
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3 overflow-auto">
<pre className="text-sm text-gray-800">
{JSON.stringify(tool.inputSchema, null, 2)}
</pre>
</div>
</div>
)}
<div className="space-y-4">
{Object.entries(tool.inputSchema.properties).map(([propName, propSchema]: [string, any]) => (
<div key={propName} className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
{propName}
{tool.inputSchema.required?.includes(propName) && (
<span className="text-red-500 ml-1">*</span>
)}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500">{propSchema.description}</p>
)}
{renderToolInput(tool, propName, propSchema)}
</div>
))}
</div>
</div>
)}
{/* Tool call result */}
{toolCallResults[tool.name] && (
<div className="border-t border-gray-100 pt-4 mt-4">
{toolCallResults[tool.name].error ? (
<>
{isMCPRouterApiKeyError(toolCallResults[tool.name].error) ? (
<MCPRouterApiKeyError />
) : (
<>
<h4 className="text-sm font-medium text-red-600 mb-3 flex items-center">
<svg className="h-4 w-4 text-red-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
{t('cloud.error')}
</h4>
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<pre className="text-sm text-red-800 whitespace-pre-wrap overflow-auto">
{toolCallResults[tool.name].error}
</pre>
</div>
</>
)}
</>
) : (
<>
<h4 className="text-sm font-medium text-gray-700 mb-3 flex items-center">
<svg className="h-4 w-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
{t('cloud.result')}
</h4>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<pre className="text-sm text-gray-800 whitespace-pre-wrap overflow-auto">
{JSON.stringify(toolCallResults[tool.name], null, 2)}
</pre>
</div>
</>
)}
</div>
)}
</div>
))}
</div>
) : null}
</div>
</div>
)}
{/* Install Modal */}
{modalVisible && server && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<ServerForm
onSubmit={handleInstallSubmit}
onCancel={handleModalClose}
modalTitle={t('cloud.installServer', { name: server.title || server.name })}
formError={installError}
initialData={{
name: server.name,
status: 'disconnected',
config: {
type: 'streamable-http',
url: server.server_url,
headers: {
'Authorization': `Bearer ${mcpRouterConfig.apiKey || '<MCPROUTER_API_KEY>'}`,
'HTTP-Referer': mcpRouterConfig.referer || '<YOUR_APP_URL>',
'X-Title': mcpRouterConfig.title || '<YOUR_APP_NAME>'
}
}
}}
/>
</div>
)}
</div>
);
};
export default CloudServerDetail;

View File

@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { apiPost, apiGet, apiPut, fetchWithInterceptors } from '@/utils/fetchInterceptor';
import { getApiUrl } from '@/utils/runtime';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
@@ -81,12 +82,8 @@ const DxtUploadForm: React.FC<DxtUploadFormProps> = ({ onSuccess, onCancel }) =>
const formData = new FormData();
formData.append('dxtFile', selectedFile);
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/dxt/upload'), {
const response = await fetchWithInterceptors(getApiUrl('/dxt/upload'), {
method: 'POST',
headers: {
'x-auth-token': token || '',
},
body: formData,
});
@@ -119,19 +116,11 @@ const DxtUploadForm: React.FC<DxtUploadFormProps> = ({ onSuccess, onCancel }) =>
// Convert DXT manifest to MCPHub stdio server configuration
const serverConfig = convertDxtToMcpConfig(manifestData, extractDir, serverName);
const token = localStorage.getItem('mcphub_token');
// First, check if server exists
if (!forceOverride) {
const checkResponse = await fetch(getApiUrl('/servers'), {
method: 'GET',
headers: {
'x-auth-token': token || '',
},
});
const checkResult = await apiGet('/servers');
if (checkResponse.ok) {
const checkResult = await checkResponse.json();
if (checkResult.success) {
const existingServer = checkResult.data?.find((server: any) => server.name === serverName);
if (existingServer) {
@@ -145,25 +134,17 @@ const DxtUploadForm: React.FC<DxtUploadFormProps> = ({ onSuccess, onCancel }) =>
}
// Install or override the server
const method = forceOverride ? 'PUT' : 'POST';
const url = forceOverride ? getApiUrl(`/servers/${encodeURIComponent(serverName)}`) : getApiUrl('/servers');
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify({
let result;
if (forceOverride) {
result = await apiPut(`/servers/${encodeURIComponent(serverName)}`, {
name: serverName,
config: serverConfig,
}),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || `HTTP error! Status: ${response.status}`);
});
} else {
result = await apiPost('/servers', {
name: serverName,
config: serverConfig,
});
}
if (result.success) {

View File

@@ -1,9 +1,9 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Group, GroupFormData, Server } from '@/types'
import { Group, GroupFormData, Server, IGroupServerConfig } from '@/types'
import { useGroupData } from '@/hooks/useGroupData'
import { useServerData } from '@/hooks/useServerData'
import { ToggleGroup } from './ui/ToggleGroup'
import { ServerToolConfig } from './ServerToolConfig'
interface EditGroupFormProps {
group: Group
@@ -56,8 +56,8 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
servers: formData.servers
})
if (!result) {
setError(t('groups.updateError'))
if (!result || !result.success) {
setError(result?.message || t('groups.updateError'))
setIsSubmitting(false)
return
}
@@ -71,64 +71,68 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
<div className="p-6">
<div className="bg-white rounded-lg shadow-lg max-w-2xl w-full max-h-[90vh] flex flex-col">
<div className="p-6 flex-shrink-0">
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('groups.edit')}</h2>
{error && (
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded-md border border-gray-200">
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="name">
{t('groups.name')} *
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder={t('groups.namePlaceholder')}
required
/>
</div>
<ToggleGroup
className="mb-6"
label={t('groups.servers')}
noOptionsText={t('groups.noServerOptions')}
values={formData.servers}
options={availableServers.map(server => ({
value: server.name,
label: server.name
}))}
onChange={(servers) => setFormData(prev => ({ ...prev, servers }))}
/>
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-gray-600 hover:text-gray-800 btn-secondary"
disabled={isSubmitting}
>
{t('common.cancel')}
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50 btn-primary"
disabled={isSubmitting}
>
{isSubmitting ? t('common.submitting') : t('common.save')}
</button>
</div>
</form>
</div>
<form onSubmit={handleSubmit} className="flex flex-col flex-1 min-h-0">
<div className="flex-1 overflow-y-auto px-6">
<div className="space-y-4">
<div>
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="name">
{t('groups.name')} *
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder={t('groups.namePlaceholder')}
required
/>
</div>
<div>
<label className="block text-gray-700 text-sm font-bold mb-2">
{t('groups.configureTools')}
</label>
<ServerToolConfig
servers={availableServers}
value={formData.servers as IGroupServerConfig[]}
onChange={(servers) => setFormData(prev => ({ ...prev, servers }))}
className="border border-gray-200 rounded-lg p-4 bg-gray-50"
/>
</div>
</div>
</div>
<div className="flex justify-end space-x-3 p-6 pt-4 border-t border-gray-200 flex-shrink-0">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-gray-600 hover:text-gray-800 border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
disabled={isSubmitting}
>
{t('common.cancel')}
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 disabled:opacity-50 transition-colors"
disabled={isSubmitting}
>
{isSubmitting ? t('common.submitting') : t('common.save')}
</button>
</div>
</form>
</div>
</div>
)

View File

@@ -1,7 +1,7 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Server } from '@/types'
import { getApiUrl } from '../utils/runtime'
import { apiPut } from '../utils/fetchInterceptor'
import ServerForm from './ServerForm'
interface EditServerFormProps {
@@ -17,26 +17,12 @@ const EditServerForm = ({ server, onEdit, onCancel }: EditServerFormProps) => {
const handleSubmit = async (payload: any) => {
try {
setError(null)
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl(`/servers/${server.name}`), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || ''
},
body: JSON.stringify(payload),
})
const result = await apiPut(`/servers/${server.name}`, payload)
const result = await response.json()
if (!response.ok) {
if (!result.success) {
// Use specific error message from the response if available
if (result && result.message) {
setError(result.message)
} else if (response.status === 404) {
setError(t('server.notFound', { serverName: server.name }))
} else if (response.status === 400) {
setError(t('server.invalidData'))
} else {
setError(t('server.updateError', { serverName: server.name }))
}

View File

@@ -1,7 +1,7 @@
import { useState, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Group, Server } from '@/types'
import { Edit, Trash, Copy, Check, Link, FileCode, DropdownIcon } from '@/components/icons/LucideIcons'
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'
@@ -25,6 +25,7 @@ const GroupCard = ({
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
@@ -108,8 +109,25 @@ const GroupCard = ({
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);
};
// 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
);
if (typeof server === 'string') {
return { name: server, tools: 'all' };
}
return server;
};
// Get servers that belong to this group
const groupServers = servers.filter(server => group.servers.includes(server.name))
const serverNames = getServerNames(group.servers);
const groupServers = servers.filter(server => serverNames.includes(server.name));
return (
<div className="bg-white shadow rounded-lg p-6 ">
@@ -186,18 +204,68 @@ const GroupCard = ({
{groupServers.length === 0 ? (
<p className="text-gray-500 italic">{t('groups.noServers')}</p>
) : (
<div className="flex flex-wrap gap-2 mt-2">
{groupServers.map(server => (
<div
key={server.name}
className="inline-flex items-center px-3 py-1 bg-gray-50 rounded"
>
<span className="font-medium text-gray-700 text-sm">{server.name}</span>
<span className={`ml-2 inline-block h-2 w-2 rounded-full ${server.status === 'connected' ? 'bg-green-500' :
server.status === 'connecting' ? 'bg-yellow-500' : 'bg-red-500'
}`}></span>
</div>
))}
<div className="flex flex-wrap gap-2">
{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 isExpanded = expandedServer === server.name;
// Get tools list for display
const getToolsList = () => {
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 [];
};
const handleServerClick = () => {
setExpandedServer(isExpanded ? null : server.name);
};
return (
<div key={server.name} className="relative">
<div
className="flex items-center space-x-2 bg-gray-50 rounded-lg px-3 py-2 cursor-pointer hover:bg-gray-100 transition-colors"
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>
{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} />
{toolCount}
</span>
)}
</div>
{isExpanded && (
<div className="absolute top-full left-0 mt-1 bg-white shadow-lg rounded-md border border-gray-200 p-3 z-10 min-w-[300px] max-w-[400px]">
<div className="text-gray-600 text-xs mb-2">
{hasToolRestrictions ? t('groups.selectedTools') : t('groups.allTools')}:
</div>
<div className="flex flex-wrap gap-1">
{getToolsList().map((toolName, index) => (
<span
key={index}
className="inline-block bg-gray-100 text-gray-700 px-2 py-1 rounded text-xs"
>
{toolName}
</span>
))}
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>

View File

@@ -0,0 +1,92 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
const MCPRouterApiKeyError: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const handleConfigureSettings = () => {
navigate('/settings');
};
const handleGetApiKey = () => {
window.open('https://mcprouter.co', '_blank', 'noopener,noreferrer');
};
return (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-6 mb-6">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg
className="h-5 w-5 text-amber-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-3 flex-1">
<h3 className="text-sm font-medium text-amber-800">
{t('cloud.apiKeyNotConfigured')}
</h3>
<div className="mt-2 text-sm text-amber-700">
<p>{t('cloud.apiKeyNotConfiguredDescription')}</p>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<button
onClick={handleGetApiKey}
className="inline-flex items-center px-3 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors duration-200"
>
<svg
className="w-4 h-4 mr-1.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
{t('cloud.getApiKey')}
</button>
<button
onClick={handleConfigureSettings}
className="inline-flex items-center px-3 py-2 text-sm font-medium text-amber-800 bg-amber-100 border border-amber-300 rounded-md hover:bg-amber-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-amber-500 transition-colors duration-200"
>
<svg
className="w-4 h-4 mr-1.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
{t('cloud.configureInSettings')}
</button>
</div>
</div>
</div>
</div>
);
};
export default MCPRouterApiKeyError;

View File

@@ -10,6 +10,16 @@ interface MarketServerCardProps {
const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick }) => {
const { t } = useTranslation();
// Get initials for avatar
const getAuthorInitials = (name: string) => {
return name
.split(' ')
.map(word => word.charAt(0))
.join('')
.toUpperCase()
.slice(0, 2);
};
// Intelligently calculate how many tags to display to ensure they fit in a single line
const getTagsToDisplay = () => {
if (!server.tags || server.tags.length === 0) {
@@ -80,70 +90,89 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
return (
<div
className="bg-white rounded-lg shadow-md p-5 hover:shadow-lg transition-all duration-200 cursor-pointer flex flex-col h-full page-card"
className="bg-white border border-gray-200 rounded-xl p-4 hover:shadow-lg hover:border-blue-400 hover:-translate-y-1 transition-all duration-300 cursor-pointer group relative overflow-hidden h-full flex flex-col"
onClick={() => onClick(server)}
>
<div className="flex justify-between items-start mb-3">
<h3 className="text-lg font-semibold text-gray-900 line-clamp-1 mr-2">{server.display_name}</h3>
{server.is_official && (
<span className="text-xs font-medium px-2.5 py-0.5 rounded flex-shrink-0 label-primary">
{t('market.official')}
</span>
)}
</div>
<p className="text-gray-600 text-sm mb-4 line-clamp-2 min-h-[40px]">{server.description}</p>
{/* Background gradient overlay on hover */}
<div className="absolute inset-0 bg-gradient-to-br from-blue-50/0 to-purple-50/0 group-hover:from-blue-50/30 group-hover:to-purple-50/30 transition-all duration-300 pointer-events-none" />
{/* Categories */}
<div className="flex flex-wrap gap-1 mb-2 min-h-[28px]">
{server.categories?.length > 0 ? (
server.categories.map((category, index) => (
<span
key={index}
className="bg-gray-100 text-gray-800 text-xs px-2 py-1.5 rounded whitespace-nowrap"
>
{category}
</span>
))
) : (
<span className="text-xs text-gray-400 py-1">-</span>
)}
</div>
{/* Server Header */}
<div className="relative z-10 flex-1 flex flex-col">
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<h3 className="text-lg font-bold text-gray-900 group-hover:text-blue-600 transition-colors duration-200 mb-1 line-clamp-1 mr-2">
{server.display_name}
</h3>
{/* Tags */}
<div className="relative mb-3 min-h-[28px] overflow-x-auto">
{server.tags?.length > 0 ? (
<div className="flex gap-1 items-center whitespace-nowrap">
{tagsToShow.map((tag, index) => (
<span
key={index}
className="bg-green-50 text-green-700 text-xs px-2 py-1 rounded flex-shrink-0 label-secondary"
>
#{tag}
</span>
))}
{hasMore && (
<span className="bg-gray-100 text-gray-600 text-xs px-1.5 py-1 rounded flex-shrink-0">
+{moreCount} {t('market.moreTags')}
{/* Author Section */}
<div className="flex items-center space-x-2 mb-1">
<div className="w-6 h-6 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center text-white text-xs font-semibold">
{getAuthorInitials(server.author?.name || t('market.unknown'))}
</div>
<div>
<p className="text-xs font-medium text-gray-700">{server.author?.name || t('market.unknown')}</p>
</div>
</div>
</div>
{/* Server Type Badge */}
<div className="flex flex-col items-end space-y-2">
{server.is_official && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{t('market.official')}
</span>
)}
</div>
) : (
<span className="text-xs text-gray-400 py-1">-</span>
)}
</div>
<div className="flex justify-between items-center mt-auto pt-2 text-xs text-gray-500">
<div className="overflow-hidden">
<span className="whitespace-nowrap">{t('market.by')} </span>
<span className="font-medium whitespace-nowrap overflow-hidden text-ellipsis max-w-[120px] inline-block align-bottom">
{server.author?.name || t('market.unknown')}
</span>
</div>
<div className="flex items-center flex-shrink-0">
<svg className="h-4 w-4 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
</svg>
<span>{server.tools?.length || 0} {t('market.tools')}</span>
{/* Description */}
<div className="mb-2 flex-1">
<p className="text-gray-600 text-sm leading-relaxed line-clamp-2 min-h-[36px]">
{server.description}
</p>
</div>
{/* Categories */}
<div className="mb-2">
<div className="flex flex-wrap gap-1 min-h-[24px]">
{server.categories?.length > 0 ? (
server.categories.map((category, index) => (
<span
key={index}
className="bg-gray-100 text-gray-800 text-xs px-2 py-1 rounded whitespace-nowrap"
>
{category}
</span>
))
) : (
<span className="text-xs text-gray-400 py-1">-</span>
)}
</div>
</div>
{/* Tags */}
<div className="mb-2">
<div className="relative min-h-[24px] overflow-x-auto">
{server.tags?.length > 0 ? (
<div className="flex gap-1 items-center whitespace-nowrap">
{tagsToShow.map((tag, index) => (
<span
key={index}
className="bg-green-50 text-green-700 text-xs px-2 py-1 rounded flex-shrink-0"
>
#{tag}
</span>
))}
{hasMore && (
<span className="bg-gray-100 text-gray-600 text-xs px-1.5 py-1 rounded flex-shrink-0">
+{moreCount} {t('market.moreTags')}
</span>
)}
</div>
) : (
<span className="text-xs text-gray-400 py-1">-</span>
)}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,317 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { IGroupServerConfig, Server, Tool } from '@/types';
import { cn } from '@/utils/cn';
interface ServerToolConfigProps {
servers: Server[];
value: string[] | IGroupServerConfig[];
onChange: (value: IGroupServerConfig[]) => void;
className?: string;
}
export const ServerToolConfig: React.FC<ServerToolConfigProps> = ({
servers,
value,
onChange,
className
}) => {
const { t } = useTranslation();
const [expandedServers, setExpandedServers] = useState<Set<string>>(new Set());
// Normalize current value to IGroupServerConfig[] format
const normalizedValue: IGroupServerConfig[] = React.useMemo(() => {
return value.map(item => {
if (typeof item === 'string') {
return { name: item, tools: 'all' as const };
}
return { ...item, tools: item.tools || 'all' as const };
});
}, [value]);
// Get available servers (enabled only)
const availableServers = React.useMemo(() =>
servers.filter(server => server.enabled !== false),
[servers]
);
// Clean up expanded servers when servers are removed from configuration
// But keep servers that were explicitly expanded even if they have no configuration
React.useEffect(() => {
const configuredServerNames = new Set(normalizedValue.map(config => config.name));
const availableServerNames = new Set(availableServers.map(server => server.name));
setExpandedServers(prev => {
const newSet = new Set<string>();
prev.forEach(serverName => {
// Keep expanded if server is configured OR if server exists and user manually expanded it
if (configuredServerNames.has(serverName) || availableServerNames.has(serverName)) {
newSet.add(serverName);
}
});
return newSet;
});
}, [normalizedValue, availableServers]);
const toggleServer = (serverName: string) => {
const existingIndex = normalizedValue.findIndex(config => config.name === serverName);
if (existingIndex >= 0) {
// Remove server - this will also remove all its tools
const newValue = normalizedValue.filter(config => config.name !== serverName);
onChange(newValue);
// Don't auto-collapse the server when it's unchecked - let user control expansion manually
} else {
// Add server with all tools by default
const newValue = [...normalizedValue, { name: serverName, tools: 'all' as const }];
onChange(newValue);
// Don't auto-expand the server when it's checked - let user control expansion manually
}
};
const toggleServerExpanded = (serverName: string) => {
setExpandedServers(prev => {
const newSet = new Set(prev);
if (newSet.has(serverName)) {
newSet.delete(serverName);
} else {
newSet.add(serverName);
}
return newSet;
});
};
const updateServerTools = (serverName: string, tools: string[] | 'all', keepExpanded = false) => {
if (Array.isArray(tools) && tools.length === 0) {
// If no tools are selected, remove the server entirely
const newValue = normalizedValue.filter(config => config.name !== serverName);
onChange(newValue);
// Only collapse the server if not explicitly asked to keep it expanded
if (!keepExpanded) {
setExpandedServers(prev => {
const newSet = new Set(prev);
newSet.delete(serverName);
return newSet;
});
}
} else {
// Update server tools or add server if it doesn't exist
const existingServerIndex = normalizedValue.findIndex(config => config.name === serverName);
if (existingServerIndex >= 0) {
// Update existing server
const newValue = normalizedValue.map(config =>
config.name === serverName ? { ...config, tools } : config
);
onChange(newValue);
} else {
// Add new server with specified tools
const newValue = [...normalizedValue, { name: serverName, tools }];
onChange(newValue);
}
}
};
const toggleTool = (serverName: string, toolName: string) => {
const server = availableServers.find(s => s.name === serverName);
if (!server) return;
const allToolNames = server.tools?.map(tool => tool.name.replace(`${serverName}-`, '')) || [];
const serverConfig = normalizedValue.find(config => config.name === serverName);
if (!serverConfig) {
// Server not selected yet, add it with only this tool
const newValue = [...normalizedValue, { name: serverName, tools: [toolName] }];
onChange(newValue);
// Don't auto-expand - let user control expansion manually
return;
}
if (serverConfig.tools === 'all') {
// Switch from 'all' to specific tools, excluding the toggled tool
const newTools = allToolNames.filter(name => name !== toolName);
updateServerTools(serverName, newTools);
// If all tools are deselected, the server will be removed and collapsed in updateServerTools
} else if (Array.isArray(serverConfig.tools)) {
const currentTools = serverConfig.tools;
if (currentTools.includes(toolName)) {
// Remove tool
const newTools = currentTools.filter(name => name !== toolName);
updateServerTools(serverName, newTools);
// If all tools are deselected, the server will be removed and collapsed in updateServerTools
} else {
// Add tool
const newTools = [...currentTools, toolName];
// If all tools are selected, switch to 'all'
if (newTools.length === allToolNames.length) {
updateServerTools(serverName, 'all');
} else {
updateServerTools(serverName, newTools);
}
}
}
};
const isServerSelected = (serverName: string) => {
const serverConfig = normalizedValue.find(config => config.name === serverName);
if (!serverConfig) return false;
// Server is considered "fully selected" if tools is 'all'
return serverConfig.tools === 'all';
};
const isServerPartiallySelected = (serverName: string) => {
const serverConfig = normalizedValue.find(config => config.name === serverName);
if (!serverConfig) return false;
// Server is partially selected if it has specific tools selected (not 'all' and not empty)
return Array.isArray(serverConfig.tools) && serverConfig.tools.length > 0;
};
const isToolSelected = (serverName: string, toolName: string) => {
const serverConfig = normalizedValue.find(config => config.name === serverName);
if (!serverConfig) return false;
if (serverConfig.tools === 'all') return true;
if (Array.isArray(serverConfig.tools)) {
return serverConfig.tools.includes(toolName);
}
return false;
};
const getServerTools = (serverName: string): Tool[] => {
const server = availableServers.find(s => s.name === serverName);
return server?.tools || [];
};
return (
<div className={cn("space-y-4", className)}>
<div className="space-y-3">
{availableServers.map(server => {
const isSelected = isServerSelected(server.name);
const isPartiallySelected = isServerPartiallySelected(server.name);
const isExpanded = expandedServers.has(server.name);
const serverTools = getServerTools(server.name);
const serverConfig = normalizedValue.find(config => config.name === server.name);
return (
<div key={server.name} className="border border-gray-200 rounded-lg hover:border-gray-300 hover:bg-gray-50 transition-colors">
<div
className="flex items-center justify-between p-3 cursor-pointer rounded-lg transition-colors"
onClick={() => toggleServerExpanded(server.name)}
>
<div
className="flex items-center space-x-3"
onClick={(e) => {
e.stopPropagation();
toggleServer(server.name);
}}
>
<input
type="checkbox"
checked={isSelected || isPartiallySelected}
onChange={() => toggleServer(server.name)}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
/>
<span className="font-medium text-gray-900 cursor-pointer select-none">
{server.name}
</span>
</div>
<div className="flex items-center space-x-3">
{serverConfig && serverConfig.tools !== 'all' && Array.isArray(serverConfig.tools) && (
<span className="text-sm text-green-600">
({t('groups.toolsSelected')} {serverConfig.tools.length}/{serverTools.length})
</span>
)}
{serverConfig && serverConfig.tools === 'all' && (
<span className="text-sm text-green-600">
({t('groups.allTools')} {serverTools.length}/{serverTools.length})
</span>
)}
{serverTools.length > 0 && (
<button
type="button"
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
>
<svg
className={cn("w-5 h-5 transition-transform", isExpanded && "rotate-180")}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
)}
</div>
</div>
{isExpanded && serverTools.length > 0 && (
<div className="border-t border-gray-200 bg-gray-50 p-3">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-gray-700">
{t('groups.toolSelection')}
</span>
<button
type="button"
onClick={() => {
const isAllSelected = serverConfig?.tools === 'all';
if (isAllSelected || (Array.isArray(serverConfig?.tools) && serverConfig.tools.length === serverTools.length)) {
// If all tools are selected, deselect all (remove server) but keep expanded
updateServerTools(server.name, [], true);
} else {
// Select all tools (add server if not present)
updateServerTools(server.name, 'all');
// Don't auto-expand - let user control expansion manually
}
}}
className="text-sm text-blue-600 hover:text-blue-800 transition-colors"
>
{(serverConfig?.tools === 'all' ||
(Array.isArray(serverConfig?.tools) && serverConfig.tools.length === serverTools.length))
? t('groups.selectNone')
: t('groups.selectAll')}
</button>
</div>
<div className="grid grid-cols-1 gap-2 max-h-32 overflow-y-auto">
{serverTools.map(tool => {
const toolName = tool.name.replace(`${server.name}-`, '');
const isToolChecked = isToolSelected(server.name, toolName);
return (
<label key={tool.name} className="flex items-center space-x-2 text-sm">
<input
type="checkbox"
checked={isToolChecked}
onChange={() => toggleTool(server.name, toolName)}
className="w-3 h-3 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
/>
<span className="text-gray-700">
{toolName}
</span>
{tool.description && (
<span className="text-gray-400 text-xs truncate">
{tool.description}
</span>
)}
</label>
);
})}
</div>
</div>
)}
</div>
);
})}
</div>
{availableServers.length === 0 && (
<p className="text-gray-500 text-sm">{t('groups.noServerOptions')}</p>
)}
</div>
);
};

View File

@@ -0,0 +1,27 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
export const LanguageIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
const { t } = useTranslation();
return (
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
stroke="currentColor"
strokeWidth={2}
{...props}
>
<title>{t('common.language')}</title>
<circle cx="12" cy="12" r="10" />
<path strokeLinecap="round" strokeLinejoin="round" d="M2 12h20" />
<path strokeLinecap="round" strokeLinejoin="round" d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z" />
</svg>
);
};
export default LanguageIcon;

View File

@@ -16,7 +16,8 @@ import {
AlertCircle,
Link,
FileCode,
ChevronDown as DropdownIcon
ChevronDown as DropdownIcon,
Wrench
} from 'lucide-react'
export {
@@ -37,7 +38,8 @@ export {
AlertCircle,
Link,
FileCode,
DropdownIcon
DropdownIcon,
Wrench
}
const LucideIcons = {

View File

@@ -1,21 +1,15 @@
import React, { useState } from 'react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import ThemeSwitch from '@/components/ui/ThemeSwitch';
import LanguageSwitch from '@/components/ui/LanguageSwitch';
import GitHubIcon from '@/components/icons/GitHubIcon';
import SponsorIcon from '@/components/icons/SponsorIcon';
import WeChatIcon from '@/components/icons/WeChatIcon';
import DiscordIcon from '@/components/icons/DiscordIcon';
import SponsorDialog from '@/components/ui/SponsorDialog';
import WeChatDialog from '@/components/ui/WeChatDialog';
interface HeaderProps {
onToggleSidebar: () => void;
}
const Header: React.FC<HeaderProps> = ({ onToggleSidebar }) => {
const { t, i18n } = useTranslation();
const [sponsorDialogOpen, setSponsorDialogOpen] = useState(false);
const [wechatDialogOpen, setWechatDialogOpen] = useState(false);
const { t } = useTranslation();
return (
<header className="bg-white dark:bg-gray-800 shadow-sm z-10">
@@ -36,53 +30,27 @@ const Header: React.FC<HeaderProps> = ({ onToggleSidebar }) => {
<h1 className="ml-4 text-xl font-bold text-gray-900 dark:text-white">{t('app.title')}</h1>
</div>
{/* Theme Switch and Version */}
<div className="flex items-center space-x-4">
<span className="text-sm text-gray-500 dark:text-gray-400">
{/* Theme Switch and Language Switcher and Version */}
<div className="flex items-center space-x-1">
<span className="text-sm text-gray-500 dark:text-gray-400 mr-2">
{import.meta.env.PACKAGE_VERSION === 'dev'
? import.meta.env.PACKAGE_VERSION
: `v${import.meta.env.PACKAGE_VERSION}`}
</span>
<a
href="https://github.com/samanhappy/mcphub"
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
className="p-2 rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
aria-label="GitHub Repository"
>
<GitHubIcon className="h-5 w-5" />
</a>
{i18n.language === 'zh' ? (
<button
onClick={() => setWechatDialogOpen(true)}
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 focus:outline-none"
aria-label={t('wechat.label')}
>
<WeChatIcon className="h-5 w-5" />
</button>
) : (
<a
href="https://discord.gg/qMKNsn5Q"
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200"
aria-label={t('discord.label')}
>
<DiscordIcon className="h-5 w-5" />
</a>
)}
<button
onClick={() => setSponsorDialogOpen(true)}
className="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 focus:outline-none"
aria-label={t('sponsor.label')}
>
<SponsorIcon className="h-5 w-5" />
</button>
<ThemeSwitch />
<LanguageSwitch />
</div>
</div>
<SponsorDialog open={sponsorDialogOpen} onOpenChange={setSponsorDialogOpen} />
<WeChatDialog open={wechatDialogOpen} onOpenChange={setWechatDialogOpen} />
</header>
);
};

View File

@@ -297,7 +297,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<input
type="number"
step={schema.type === 'integer' ? '1' : 'any'}
value={value || ''}
value={value ?? ''}
onChange={(e) => {
const val = e.target.value === '' ? '' : schema.type === 'integer' ? parseInt(e.target.value) : parseFloat(e.target.value);
onChange(val);
@@ -542,7 +542,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<input
type="number"
step={propSchema.type === 'integer' ? '1' : 'any'}
value={value || ''}
value={value !== undefined && value !== null ? value : ''}
onChange={(e) => {
const val = e.target.value === '' ? '' : propSchema.type === 'integer' ? parseInt(e.target.value) : parseFloat(e.target.value);
handleInputChange(fullPath, val);

View File

@@ -0,0 +1,83 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import LanguageIcon from '@/components/icons/LanguageIcon';
const LanguageSwitch: React.FC = () => {
const { i18n } = useTranslation();
const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false);
const [currentLanguage, setCurrentLanguage] = useState(i18n.language);
// Available languages
const availableLanguages = [
{ code: 'en', label: 'English' },
{ code: 'zh', label: '中文' }
];
// Update current language when it changes
useEffect(() => {
setCurrentLanguage(i18n.language);
}, [i18n.language]);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (!target.closest('.language-dropdown')) {
setLanguageDropdownOpen(false);
}
};
if (languageDropdownOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [languageDropdownOpen]);
const handleLanguageChange = (lang: string) => {
localStorage.setItem('i18nextLng', lang);
setLanguageDropdownOpen(false);
window.location.reload();
};
// Always show dropdown for language selection
const handleLanguageToggle = () => {
setLanguageDropdownOpen(!languageDropdownOpen);
};
return (
<div className="relative language-dropdown">
<button
onClick={handleLanguageToggle}
className="p-2 rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none"
aria-label="Language Switcher"
>
<LanguageIcon className="h-5 w-5" />
</button>
{/* Show dropdown when opened */}
{languageDropdownOpen && (
<div className="absolute right-0 mt-2 w-24 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-50">
<div>
{availableLanguages.map((lang) => (
<button
key={lang.code}
onClick={() => handleLanguageChange(lang.code)}
className={`flex items-center w-full px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${currentLanguage.startsWith(lang.code)
? 'bg-blue-50 text-blue-700'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-100'
}`}
>
{lang.label}
</button>
))}
</div>
</div>
)}
</div>
);
};
export default LanguageSwitch;

View File

@@ -7,44 +7,19 @@ const ThemeSwitch: React.FC = () => {
const { t } = useTranslation();
const { theme, setTheme } = useTheme();
const toggleTheme = () => {
setTheme(theme === 'light' ? 'dark' : 'light');
};
return (
<div className="flex items-center space-x-2">
<div className="flex bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
<button
onClick={() => setTheme('light')}
className={`flex items-center justify-center rounded-md p-1.5 ${theme === 'light'
? 'bg-white text-yellow-600 shadow'
: 'text-black dark:text-gray-300 hover:text-yellow-600 dark:hover:text-yellow-500'
}`}
title={t('theme.light')}
aria-label={t('theme.light')}
>
<Sun size={18} />
</button>
<button
onClick={() => setTheme('dark')}
className={`flex items-center justify-center rounded-md p-1.5 ${theme === 'dark'
? 'bg-gray-800 text-blue-400 shadow'
: 'text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400'
}`}
title={t('theme.dark')}
aria-label={t('theme.dark')}
>
<Moon size={18} />
</button>
{/* <button
onClick={() => setTheme('system')}
className={`flex items-center justify-center rounded-md p-1.5 ${theme === 'system'
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow'
: 'text-black dark:text-gray-300 hover:text-green-600 dark:hover:text-green-400'
}`}
title={t('theme.system')}
aria-label={t('theme.system')}
>
<Monitor size={18} />
</button> */}
</div>
</div>
<button
onClick={toggleTheme}
className="p-2 rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none"
title={theme === 'light' ? t('theme.dark') : t('theme.light')}
aria-label={theme === 'light' ? t('theme.dark') : t('theme.light')}
>
{theme === 'light' ? <Moon className="h-5 w-5" /> : <Sun className="h-5 w-5" />}
</button>
);
};

View File

@@ -4,6 +4,11 @@ import { useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import { User, Settings, LogOut, Info } from 'lucide-react';
import AboutDialog from './AboutDialog';
import SponsorDialog from './SponsorDialog';
import WeChatDialog from './WeChatDialog';
import WeChatIcon from '@/components/icons/WeChatIcon';
import DiscordIcon from '@/components/icons/DiscordIcon';
import SponsorIcon from '@/components/icons/SponsorIcon';
import { checkLatestVersion, compareVersions } from '@/utils/version';
interface UserProfileMenuProps {
@@ -12,12 +17,14 @@ interface UserProfileMenuProps {
}
const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version }) => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const { auth, logout } = useAuth();
const [isOpen, setIsOpen] = useState(false);
const [showNewVersionInfo, setShowNewVersionInfo] = useState(false);
const [showAboutDialog, setShowAboutDialog] = useState(false);
const [sponsorDialogOpen, setSponsorDialogOpen] = useState(false);
const [wechatDialogOpen, setWechatDialogOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
// Check for new version on login and component mount
@@ -65,6 +72,16 @@ const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version })
setIsOpen(false);
};
const handleSponsorClick = () => {
setSponsorDialogOpen(true);
setIsOpen(false);
};
const handleWeChatClick = () => {
setWechatDialogOpen(true);
setIsOpen(false);
};
return (
<div ref={menuRef} className="relative">
<button
@@ -90,7 +107,35 @@ const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version })
</button>
{isOpen && (
<div className="absolute top-0 transform -translate-y-full left-0 w-full min-w-max bg-white border border-gray-200 dark:bg-gray-800 py-1 z-50">
<div className="absolute top-0 transform -translate-y-full left-0 w-full min-w-max bg-white border border-gray-200 dark:bg-gray-800 z-50">
<button
onClick={handleSponsorClick}
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<SponsorIcon className="h-4 w-4 mr-2" />
{t('sponsor.label')}
</button>
{i18n.language === 'zh' ? (
<button
onClick={handleWeChatClick}
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<WeChatIcon className="h-4 w-4 mr-2" />
{t('wechat.label')}
</button>
) : (
<a
href="https://discord.gg/qMKNsn5Q"
target="_blank"
rel="noopener noreferrer"
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<DiscordIcon className="h-4 w-4 mr-2" />
{t('discord.label')}
</a>
)}
<button
onClick={handleSettingsClick}
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
@@ -108,6 +153,9 @@ const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version })
<span className="absolute top-2 right-4 block w-2 h-2 bg-red-500 rounded-full"></span>
)}
</button>
<div className="border-t border-gray-200 dark:border-gray-600"></div>
<button
onClick={handleLogoutClick}
className="flex items-center w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
@@ -124,6 +172,12 @@ const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version })
onClose={() => setShowAboutDialog(false)}
version={version}
/>
{/* Sponsor dialog */}
<SponsorDialog open={sponsorDialogOpen} onOpenChange={setSponsorDialogOpen} />
{/* WeChat dialog */}
<WeChatDialog open={wechatDialogOpen} onOpenChange={setWechatDialogOpen} />
</div>
);
};

View File

@@ -1,7 +1,7 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { AuthState } from '../types';
import * as authService from '../services/authService';
import { shouldSkipAuth } from '../services/configService';
import { getPublicConfig } from '../services/configService';
// Initial auth state
const initialState: AuthState = {
@@ -32,7 +32,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
useEffect(() => {
const loadUser = async () => {
// First check if authentication should be skipped
const skipAuth = await shouldSkipAuth();
const { skipAuth, permissions } = await getPublicConfig();
if (skipAuth) {
// If authentication is disabled, set user as authenticated with a dummy user
@@ -42,6 +42,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
user: {
username: 'guest',
isAdmin: true,
permissions,
},
error: null,
});

View File

@@ -0,0 +1,350 @@
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { CloudServer, ApiResponse, CloudServerTool } from '@/types';
import { apiGet, apiPost } from '../utils/fetchInterceptor';
export const useCloudData = () => {
const { t } = useTranslation();
const [servers, setServers] = useState<CloudServer[]>([]);
const [allServers, setAllServers] = useState<CloudServer[]>([]);
const [categories, setCategories] = useState<string[]>([]);
const [tags, setTags] = useState<string[]>([]);
const [selectedCategory, setSelectedCategory] = useState<string>('');
const [selectedTag, setSelectedTag] = useState<string>('');
const [searchQuery, setSearchQuery] = useState<string>('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentServer, setCurrentServer] = useState<CloudServer | null>(null);
// Pagination states
const [currentPage, setCurrentPage] = useState(1);
const [serversPerPage, setServersPerPage] = useState(9);
const [totalPages, setTotalPages] = useState(1);
// Fetch all cloud market servers
const fetchCloudServers = useCallback(async () => {
try {
setLoading(true);
const data: ApiResponse<CloudServer[]> = await apiGet('/cloud/servers');
if (data && data.success && Array.isArray(data.data)) {
setAllServers(data.data);
// Apply pagination to the fetched data
applyPagination(data.data, currentPage);
} else {
console.error('Invalid cloud market servers data format:', data);
setError(t('cloud.fetchError'));
}
} catch (err) {
console.error('Error fetching cloud market servers:', err);
const errorMessage = err instanceof Error ? err.message : String(err);
// Keep the original error message for API key errors
if (
errorMessage === 'MCPROUTER_API_KEY_NOT_CONFIGURED' ||
errorMessage.toLowerCase().includes('mcprouter api key not configured')
) {
setError(errorMessage);
} else {
setError(errorMessage);
}
} finally {
setLoading(false);
}
}, [t]);
// Apply pagination to data
const applyPagination = useCallback(
(data: CloudServer[], page: number, itemsPerPage = serversPerPage) => {
const totalItems = data.length;
const calculatedTotalPages = Math.ceil(totalItems / itemsPerPage);
setTotalPages(calculatedTotalPages);
// Ensure current page is valid
const validPage = Math.max(1, Math.min(page, calculatedTotalPages));
if (validPage !== page) {
setCurrentPage(validPage);
}
const startIndex = (validPage - 1) * itemsPerPage;
const paginatedServers = data.slice(startIndex, startIndex + itemsPerPage);
setServers(paginatedServers);
},
[serversPerPage],
);
// Change page
const changePage = useCallback(
(page: number) => {
setCurrentPage(page);
applyPagination(allServers, page, serversPerPage);
},
[allServers, applyPagination, serversPerPage],
);
// Fetch all categories
const fetchCategories = useCallback(async () => {
try {
const data: ApiResponse<string[]> = await apiGet('/cloud/categories');
if (data && data.success && Array.isArray(data.data)) {
setCategories(data.data);
} else {
console.error('Invalid cloud market categories data format:', data);
}
} catch (err) {
console.error('Error fetching cloud market categories:', err);
}
}, []);
// Fetch all tags
const fetchTags = useCallback(async () => {
try {
const data: ApiResponse<string[]> = await apiGet('/cloud/tags');
if (data && data.success && Array.isArray(data.data)) {
setTags(data.data);
} else {
console.error('Invalid cloud market tags data format:', data);
}
} catch (err) {
console.error('Error fetching cloud market tags:', err);
}
}, []);
// Fetch server by name
const fetchServerByName = useCallback(
async (name: string) => {
try {
setLoading(true);
const data: ApiResponse<CloudServer> = await apiGet(`/cloud/servers/${name}`);
if (data && data.success && data.data) {
setCurrentServer(data.data);
return data.data;
} else {
console.error('Invalid cloud server data format:', data);
setError(t('cloud.serverNotFound'));
return null;
}
} catch (err) {
console.error(`Error fetching cloud server ${name}:`, err);
const errorMessage = err instanceof Error ? err.message : String(err);
// Keep the original error message for API key errors
if (
errorMessage === 'MCPROUTER_API_KEY_NOT_CONFIGURED' ||
errorMessage.toLowerCase().includes('mcprouter api key not configured')
) {
setError(errorMessage);
} else {
setError(errorMessage);
}
return null;
} finally {
setLoading(false);
}
},
[t],
);
// Search servers by query
const searchServers = useCallback(
async (query: string) => {
try {
setLoading(true);
setSearchQuery(query);
if (!query.trim()) {
// Fetch fresh data from server instead of just applying pagination
fetchCloudServers();
return;
}
const data: ApiResponse<CloudServer[]> = await apiGet(
`/cloud/servers/search?query=${encodeURIComponent(query)}`,
);
if (data && data.success && Array.isArray(data.data)) {
setAllServers(data.data);
setCurrentPage(1);
applyPagination(data.data, 1);
} else {
console.error('Invalid cloud search results format:', data);
setError(t('cloud.searchError'));
}
} catch (err) {
console.error('Error searching cloud servers:', err);
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
},
[t, allServers, applyPagination, fetchCloudServers],
);
// Filter servers by category
const filterByCategory = useCallback(
async (category: string) => {
try {
setLoading(true);
setSelectedCategory(category);
setSelectedTag(''); // Reset tag filter when filtering by category
if (!category) {
fetchCloudServers();
return;
}
const data: ApiResponse<CloudServer[]> = await apiGet(
`/cloud/categories/${encodeURIComponent(category)}`,
);
if (data && data.success && Array.isArray(data.data)) {
setAllServers(data.data);
setCurrentPage(1);
applyPagination(data.data, 1);
} else {
console.error('Invalid cloud category filter results format:', data);
setError(t('cloud.filterError'));
}
} catch (err) {
console.error('Error filtering cloud servers by category:', err);
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
},
[t, fetchCloudServers, applyPagination],
);
// Filter servers by tag
const filterByTag = useCallback(
async (tag: string) => {
try {
setLoading(true);
setSelectedTag(tag);
setSelectedCategory(''); // Reset category filter when filtering by tag
if (!tag) {
fetchCloudServers();
return;
}
const data: ApiResponse<CloudServer[]> = await apiGet(
`/cloud/tags/${encodeURIComponent(tag)}`,
);
if (data && data.success && Array.isArray(data.data)) {
setAllServers(data.data);
setCurrentPage(1);
applyPagination(data.data, 1);
} else {
console.error('Invalid cloud tag filter results format:', data);
setError(t('cloud.tagFilterError'));
}
} catch (err) {
console.error('Error filtering cloud servers by tag:', err);
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
},
[t, fetchCloudServers, applyPagination],
);
// Fetch tools for a specific server
const fetchServerTools = useCallback(async (serverName: string) => {
try {
const data: ApiResponse<CloudServerTool[]> = await apiGet(
`/cloud/servers/${serverName}/tools`,
);
if (!data.success) {
console.error('Failed to fetch cloud server tools:', data);
throw new Error(data.message || 'Failed to fetch cloud server tools');
}
if (data && data.success && Array.isArray(data.data)) {
return data.data;
} else {
console.error('Invalid cloud server tools data format:', data);
return [];
}
} catch (err) {
console.error(`Error fetching tools for cloud server ${serverName}:`, err);
const errorMessage = err instanceof Error ? err.message : String(err);
// Re-throw API key errors so they can be handled by the component
if (
errorMessage === 'MCPROUTER_API_KEY_NOT_CONFIGURED' ||
errorMessage.toLowerCase().includes('mcprouter api key not configured')
) {
throw err;
}
return [];
}
}, []);
// Call a tool on a cloud server
const callServerTool = useCallback(
async (serverName: string, toolName: string, args: Record<string, any>) => {
try {
const data = await apiPost(`/cloud/servers/${serverName}/tools/${toolName}/call`, {
arguments: args,
});
if (data && data.success) {
return data.data;
} else {
throw new Error(data.message || 'Failed to call tool');
}
} catch (err) {
console.error(`Error calling tool ${toolName} on cloud server ${serverName}:`, err);
throw err;
}
},
[],
);
// Change servers per page
const changeServersPerPage = useCallback(
(perPage: number) => {
setServersPerPage(perPage);
setCurrentPage(1);
applyPagination(allServers, 1, perPage);
},
[allServers, applyPagination],
);
// Load initial data
useEffect(() => {
fetchCloudServers();
fetchCategories();
fetchTags();
}, [fetchCloudServers, fetchCategories, fetchTags]);
return {
servers,
allServers,
categories,
tags,
selectedCategory,
selectedTag,
searchQuery,
loading,
error,
setError,
currentServer,
fetchCloudServers: fetchCloudServers,
fetchServerByName,
searchServers,
filterByCategory,
filterByTag,
fetchServerTools,
callServerTool,
// Pagination properties and methods
currentPage,
totalPages,
serversPerPage,
changePage,
changeServersPerPage,
};
};

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Group, ApiResponse } from '@/types';
import { getApiUrl } from '../utils/runtime';
import { Group, ApiResponse, IGroupServerConfig } from '@/types';
import { apiGet, apiPost, apiPut, apiDelete } from '../utils/fetchInterceptor';
export const useGroupData = () => {
const { t } = useTranslation();
@@ -13,18 +13,7 @@ export const useGroupData = () => {
const fetchGroups = useCallback(async () => {
try {
setLoading(true);
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/groups'), {
headers: {
'x-auth-token': token || '',
},
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<Group[]> = await response.json();
const data: ApiResponse<Group[]> = await apiGet('/groups');
if (data && data.success && Array.isArray(data.data)) {
setGroups(data.data);
@@ -49,27 +38,22 @@ export const useGroupData = () => {
}, []);
// Create a new group with server associations
const createGroup = async (name: string, description?: string, servers: string[] = []) => {
const createGroup = async (
name: string,
description?: string,
servers: string[] | IGroupServerConfig[] = [],
) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/groups'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify({ name, description, servers }),
});
const result: ApiResponse<Group> = await apiPost('/groups', { name, description, servers });
console.log('Group created successfully:', result);
const result: ApiResponse<Group> = await response.json();
if (!response.ok) {
setError(result.message || t('groups.createError'));
return null;
if (!result || !result.success) {
setError(result?.message || t('groups.createError'));
return result;
}
triggerRefresh();
return result.data || null;
return result || null;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create group');
return null;
@@ -79,28 +63,17 @@ export const useGroupData = () => {
// Update an existing group with server associations
const updateGroup = async (
id: string,
data: { name?: string; description?: string; servers?: string[] },
data: { name?: string; description?: string; servers?: string[] | IGroupServerConfig[] },
) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl(`/groups/${id}`), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify(data),
});
const result: ApiResponse<Group> = await response.json();
if (!response.ok) {
setError(result.message || t('groups.updateError'));
return null;
const result: ApiResponse<Group> = await apiPut(`/groups/${id}`, data);
if (!result || !result.success) {
setError(result?.message || t('groups.updateError'));
return result;
}
triggerRefresh();
return result.data || null;
return result || null;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update group');
return null;
@@ -108,22 +81,14 @@ export const useGroupData = () => {
};
// Update servers in a group (for batch updates)
const updateGroupServers = async (groupId: string, servers: string[]) => {
const updateGroupServers = async (groupId: string, servers: string[] | IGroupServerConfig[]) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl(`/groups/${groupId}/servers/batch`), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify({ servers }),
const result: ApiResponse<Group> = await apiPut(`/groups/${groupId}/servers/batch`, {
servers,
});
const result: ApiResponse<Group> = await response.json();
if (!response.ok) {
setError(result.message || t('groups.updateError'));
if (!result || !result.success) {
setError(result?.message || t('groups.updateError'));
return null;
}
@@ -138,46 +103,29 @@ export const useGroupData = () => {
// Delete a group
const deleteGroup = async (id: string) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl(`/groups/${id}`), {
method: 'DELETE',
headers: {
'x-auth-token': token || '',
},
});
const result = await response.json();
if (!response.ok) {
setError(result.message || t('groups.deleteError'));
return false;
const result = await apiDelete(`/groups/${id}`);
if (!result || !result.success) {
setError(result?.message || t('groups.deleteError'));
return result;
}
triggerRefresh();
return true;
return result;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete group');
return false;
return null;
}
};
// Add server to a group
const addServerToGroup = async (groupId: string, serverName: string) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl(`/groups/${groupId}/servers`), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify({ serverName }),
const result: ApiResponse<Group> = await apiPost(`/groups/${groupId}/servers`, {
serverName,
});
const result: ApiResponse<Group> = await response.json();
if (!response.ok) {
setError(result.message || t('groups.serverAddError'));
if (!result || !result.success) {
setError(result?.message || t('groups.serverAddError'));
return null;
}
@@ -192,18 +140,12 @@ export const useGroupData = () => {
// Remove server from group
const removeServerFromGroup = async (groupId: string, serverName: string) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl(`/groups/${groupId}/servers/${serverName}`), {
method: 'DELETE',
headers: {
'x-auth-token': token || '',
},
});
const result: ApiResponse<Group> = await apiDelete(
`/groups/${groupId}/servers/${serverName}`,
);
const result: ApiResponse<Group> = await response.json();
if (!response.ok) {
setError(result.message || t('groups.serverRemoveError'));
if (!result || !result.success) {
setError(result?.message || t('groups.serverRemoveError'));
return null;
}

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { MarketServer, ApiResponse, ServerConfig } from '@/types';
import { getApiUrl } from '../utils/runtime';
import { apiGet, apiPost } from '../utils/fetchInterceptor';
export const useMarketData = () => {
const { t } = useTranslation();
@@ -26,18 +26,7 @@ export const useMarketData = () => {
const fetchMarketServers = useCallback(async () => {
try {
setLoading(true);
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/market/servers'), {
headers: {
'x-auth-token': token || '',
},
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<MarketServer[]> = await response.json();
const data: ApiResponse<MarketServer[]> = await apiGet('/market/servers');
if (data && data.success && Array.isArray(data.data)) {
setAllServers(data.data);
@@ -87,18 +76,7 @@ export const useMarketData = () => {
// Fetch all categories
const fetchCategories = useCallback(async () => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/market/categories'), {
headers: {
'x-auth-token': token || '',
},
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<string[]> = await response.json();
const data: ApiResponse<string[]> = await apiGet('/market/categories');
if (data && data.success && Array.isArray(data.data)) {
setCategories(data.data);
@@ -113,18 +91,7 @@ export const useMarketData = () => {
// Fetch all tags
const fetchTags = useCallback(async () => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/market/tags'), {
headers: {
'x-auth-token': token || '',
},
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<string[]> = await response.json();
const data: ApiResponse<string[]> = await apiGet('/market/tags');
if (data && data.success && Array.isArray(data.data)) {
setTags(data.data);
@@ -141,18 +108,7 @@ export const useMarketData = () => {
async (name: string) => {
try {
setLoading(true);
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl(`/market/servers/${name}`), {
headers: {
'x-auth-token': token || '',
},
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<MarketServer> = await response.json();
const data: ApiResponse<MarketServer> = await apiGet(`/market/servers/${name}`);
if (data && data.success && data.data) {
setCurrentServer(data.data);
@@ -186,22 +142,10 @@ export const useMarketData = () => {
return;
}
const token = localStorage.getItem('mcphub_token');
const response = await fetch(
getApiUrl(`/market/servers/search?query=${encodeURIComponent(query)}`),
{
headers: {
'x-auth-token': token || '',
},
},
const data: ApiResponse<MarketServer[]> = await apiGet(
`/market/servers/search?query=${encodeURIComponent(query)}`,
);
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<MarketServer[]> = await response.json();
if (data && data.success && Array.isArray(data.data)) {
setAllServers(data.data);
setCurrentPage(1);
@@ -233,22 +177,10 @@ export const useMarketData = () => {
return;
}
const token = localStorage.getItem('mcphub_token');
const response = await fetch(
getApiUrl(`/market/categories/${encodeURIComponent(category)}`),
{
headers: {
'x-auth-token': token || '',
},
},
const data: ApiResponse<MarketServer[]> = await apiGet(
`/market/categories/${encodeURIComponent(category)}`,
);
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<MarketServer[]> = await response.json();
if (data && data.success && Array.isArray(data.data)) {
setAllServers(data.data);
setCurrentPage(1);
@@ -280,18 +212,9 @@ export const useMarketData = () => {
return;
}
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl(`/market/tags/${encodeURIComponent(tag)}`), {
headers: {
'x-auth-token': token || '',
},
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<MarketServer[]> = await response.json();
const data: ApiResponse<MarketServer[]> = await apiGet(
`/market/tags/${encodeURIComponent(tag)}`,
);
if (data && data.success && Array.isArray(data.data)) {
setAllServers(data.data);
@@ -314,18 +237,7 @@ export const useMarketData = () => {
// Fetch installed servers
const fetchInstalledServers = useCallback(async () => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/servers'), {
headers: {
'x-auth-token': token || '',
},
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data = await response.json();
const data = await apiGet<{ success: boolean; data: any[] }>('/servers');
if (data && data.success && Array.isArray(data.data)) {
// Extract server names
@@ -365,27 +277,24 @@ export const useMarketData = () => {
// Prepare server configuration, merging with customConfig
const serverConfig = {
name: server.name,
config: customConfig.type === 'stdio' ? {
command: customConfig.command || installation.command || '',
args: customConfig.args || installation.args || [],
env: { ...installation.env, ...customConfig.env },
} : customConfig
config:
customConfig.type === 'stdio'
? {
command: customConfig.command || installation.command || '',
args: customConfig.args || installation.args || [],
env: { ...installation.env, ...customConfig.env },
}
: customConfig,
};
// Call the createServer API
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/servers'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify(serverConfig),
});
const result = await apiPost<{ success: boolean; message?: string }>(
'/servers',
serverConfig,
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `Status: ${response.status}`);
if (!result.success) {
throw new Error(result.message || 'Failed to install server');
}
// Update installed servers list after successful installation

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Server, ApiResponse } from '@/types';
import { getApiUrl } from '../utils/runtime';
import { apiGet, apiPost, apiDelete } from '../utils/fetchInterceptor';
// Configuration options
const CONFIG = {
@@ -44,13 +44,7 @@ export const useServerData = () => {
const fetchServers = async () => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/servers'), {
headers: {
'x-auth-token': token || '',
},
});
const data = await response.json();
const data = await apiGet('/servers');
if (data && data.success && Array.isArray(data.data)) {
setServers(data.data);
@@ -97,13 +91,7 @@ export const useServerData = () => {
// Initialization phase request function
const fetchInitialData = async () => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/servers'), {
headers: {
'x-auth-token': token || '',
},
});
const data = await response.json();
const data = await apiGet('/servers');
// Handle API response wrapper object, extract data field
if (data && data.success && Array.isArray(data.data)) {
@@ -203,14 +191,8 @@ export const useServerData = () => {
const handleServerEdit = async (server: Server) => {
try {
// Fetch settings to get the full server config before editing
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/settings'), {
headers: {
'x-auth-token': token || '',
},
});
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> = await response.json();
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> =
await apiGet('/settings');
if (
settingsData &&
@@ -240,17 +222,10 @@ export const useServerData = () => {
const handleServerRemove = async (serverName: string) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl(`/servers/${serverName}`), {
method: 'DELETE',
headers: {
'x-auth-token': token || '',
},
});
const result = await response.json();
const result = await apiDelete(`/servers/${serverName}`);
if (!response.ok) {
setError(result.message || t('server.deleteError', { serverName }));
if (!result || !result.success) {
setError(result?.message || t('server.deleteError', { serverName }));
return false;
}
@@ -264,21 +239,11 @@ export const useServerData = () => {
const handleServerToggle = async (server: Server, enabled: boolean) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl(`/servers/${server.name}/toggle`), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify({ enabled }),
});
const result = await apiPost(`/servers/${server.name}/toggle`, { enabled });
const result = await response.json();
if (!response.ok) {
if (!result || !result.success) {
console.error('Failed to toggle server:', result);
setError(t('server.toggleError', { serverName: server.name }));
setError(result?.message || t('server.toggleError', { serverName: server.name }));
return false;
}

View File

@@ -2,7 +2,7 @@ import { useState, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { ApiResponse } from '@/types';
import { useToast } from '@/contexts/ToastContext';
import { getApiUrl } from '../utils/runtime';
import { apiGet, apiPut } from '../utils/fetchInterceptor';
// Define types for the settings data
interface RoutingConfig {
@@ -27,11 +27,19 @@ interface SmartRoutingConfig {
openaiApiEmbeddingModel: string;
}
interface MCPRouterConfig {
apiKey: string;
referer: string;
title: string;
baseUrl: string;
}
interface SystemSettings {
systemConfig?: {
routing?: RoutingConfig;
install?: InstallConfig;
smartRouting?: SmartRoutingConfig;
mcpRouter?: MCPRouterConfig;
};
}
@@ -69,6 +77,13 @@ export const useSettingsData = () => {
openaiApiEmbeddingModel: '',
});
const [mcpRouterConfig, setMCPRouterConfig] = useState<MCPRouterConfig>({
apiKey: '',
referer: 'https://mcphub.app',
title: 'MCPHub',
baseUrl: 'https://api.mcprouter.to/v1',
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
@@ -84,18 +99,7 @@ export const useSettingsData = () => {
setError(null);
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/settings'), {
headers: {
'x-auth-token': token || '',
},
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data: ApiResponse<SystemSettings> = await response.json();
const data: ApiResponse<SystemSettings> = await apiGet('/settings');
if (data.success && data.data?.systemConfig?.routing) {
setRoutingConfig({
@@ -123,6 +127,14 @@ export const useSettingsData = () => {
data.data.systemConfig.smartRouting.openaiApiEmbeddingModel || '',
});
}
if (data.success && data.data?.systemConfig?.mcpRouter) {
setMCPRouterConfig({
apiKey: data.data.systemConfig.mcpRouter.apiKey || '',
referer: data.data.systemConfig.mcpRouter.referer || 'https://mcphub.app',
title: data.data.systemConfig.mcpRouter.title || 'MCPHub',
baseUrl: data.data.systemConfig.mcpRouter.baseUrl || 'https://api.mcprouter.to/v1',
});
}
} catch (error) {
console.error('Failed to fetch settings:', error);
setError(error instanceof Error ? error.message : 'Failed to fetch settings');
@@ -134,34 +146,17 @@ export const useSettingsData = () => {
}, [t]); // 移除 showToast 依赖
// Update routing configuration
const updateRoutingConfig = async <T extends keyof RoutingConfig>(
key: T,
value: RoutingConfig[T],
) => {
const updateRoutingConfig = async (key: keyof RoutingConfig, value: any) => {
setLoading(true);
setError(null);
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/system-config'), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
const data = await apiPut('/system-config', {
routing: {
[key]: value,
},
body: JSON.stringify({
routing: {
[key]: value,
},
}),
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
if (data.success) {
setRoutingConfig({
...routingConfig,
@@ -170,7 +165,7 @@ export const useSettingsData = () => {
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(t('errors.failedToUpdateRouteConfig'));
showToast(data.message || t('errors.failedToUpdateRouteConfig'));
return false;
}
} catch (error) {
@@ -189,26 +184,12 @@ export const useSettingsData = () => {
setError(null);
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/system-config'), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
const data = await apiPut('/system-config', {
install: {
[key]: value,
},
body: JSON.stringify({
install: {
[key]: value,
},
}),
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
if (data.success) {
setInstallConfig({
...installConfig,
@@ -217,7 +198,7 @@ export const useSettingsData = () => {
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(t('errors.failedToUpdateSystemConfig'));
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
return false;
}
} catch (error) {
@@ -239,27 +220,12 @@ export const useSettingsData = () => {
setError(null);
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/system-config'), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
const data = await apiPut('/system-config', {
smartRouting: {
[key]: value,
},
body: JSON.stringify({
smartRouting: {
[key]: value,
},
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `HTTP error! Status: ${response.status}`);
}
const data = await response.json();
if (data.success) {
setSmartRoutingConfig({
...smartRoutingConfig,
@@ -289,25 +255,10 @@ export const useSettingsData = () => {
setError(null);
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/system-config'), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify({
smartRouting: updates,
}),
const data = await apiPut('/system-config', {
smartRouting: updates,
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `HTTP error! Status: ${response.status}`);
}
const data = await response.json();
if (data.success) {
setSmartRoutingConfig({
...smartRoutingConfig,
@@ -337,24 +288,10 @@ export const useSettingsData = () => {
setError(null);
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(getApiUrl('/system-config'), {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify({
routing: updates,
}),
const data = await apiPut('/system-config', {
routing: updates,
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
if (data.success) {
setRoutingConfig({
...routingConfig,
@@ -363,7 +300,7 @@ export const useSettingsData = () => {
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(t('errors.failedToUpdateRouteConfig'));
showToast(data.message || t('errors.failedToUpdateRouteConfig'));
return false;
}
} catch (error) {
@@ -376,6 +313,77 @@ export const useSettingsData = () => {
}
};
// Update MCPRouter configuration
const updateMCPRouterConfig = async <T extends keyof MCPRouterConfig>(
key: T,
value: MCPRouterConfig[T],
) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
mcpRouter: {
[key]: value,
},
});
if (data.success) {
setMCPRouterConfig({
...mcpRouterConfig,
[key]: value,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
return false;
}
} catch (error) {
console.error('Failed to update MCPRouter config:', error);
const errorMessage =
error instanceof Error ? error.message : 'Failed to update MCPRouter config';
setError(errorMessage);
showToast(errorMessage);
return false;
} finally {
setLoading(false);
}
};
// Update multiple MCPRouter configuration fields at once
const updateMCPRouterConfigBatch = async (updates: Partial<MCPRouterConfig>) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
mcpRouter: updates,
});
if (data.success) {
setMCPRouterConfig({
...mcpRouterConfig,
...updates,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
return false;
}
} catch (error) {
console.error('Failed to update MCPRouter config:', error);
const errorMessage =
error instanceof Error ? error.message : 'Failed to update MCPRouter config';
setError(errorMessage);
showToast(errorMessage);
return false;
} finally {
setLoading(false);
}
};
// Fetch settings when the component mounts or refreshKey changes
useEffect(() => {
fetchSettings();
@@ -395,6 +403,7 @@ export const useSettingsData = () => {
setTempRoutingConfig,
installConfig,
smartRoutingConfig,
mcpRouterConfig,
loading,
error,
setError,
@@ -405,5 +414,7 @@ export const useSettingsData = () => {
updateSmartRoutingConfig,
updateSmartRoutingConfigBatch,
updateRoutingConfigBatch,
updateMCPRouterConfig,
updateMCPRouterConfigBatch,
};
};

View File

@@ -2,9 +2,9 @@ import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
// Import translations
import enTranslation from './locales/en.json';
import zhTranslation from './locales/zh.json';
// Import shared translations from root locales directory
import enTranslation from '../../locales/en.json';
import zhTranslation from '../../locales/zh.json';
i18n
// Detect user language
@@ -15,18 +15,18 @@ i18n
.init({
resources: {
en: {
translation: enTranslation
translation: enTranslation,
},
zh: {
translation: zhTranslation
}
translation: zhTranslation,
},
},
fallbackLng: 'en',
debug: process.env.NODE_ENV === 'development',
// Common namespace used for all translations
defaultNS: 'translation',
interpolation: {
escapeValue: false, // React already safe from XSS
},
@@ -36,7 +36,7 @@ i18n
order: ['localStorage', 'cookie', 'htmlTag', 'navigator'],
// Cache the language in localStorage
caches: ['localStorage', 'cookie'],
}
},
});
export default i18n;
export default i18n;

View File

@@ -442,6 +442,30 @@ tbody tr:hover {
color: rgba(239, 154, 154, 0.9) !important;
}
/* External link styles */
.external-link {
color: #2563eb !important; /* Blue-600 for light mode */
text-decoration: none;
border-bottom: 1px solid transparent;
transition: all 0.2s ease-in-out;
cursor: pointer;
}
.external-link:hover {
color: #1d4ed8 !important; /* Blue-700 for light mode */
border-bottom-color: #1d4ed8;
text-decoration: none;
}
.dark .external-link {
color: #60a5fa !important; /* Blue-400 for dark mode */
}
.dark .external-link:hover {
color: #93c5fd !important; /* Blue-300 for dark mode */
border-bottom-color: #93c5fd;
}
.border-red {
border-color: #937d7d; /* Tailwind red-800 for light mode */
}

View File

@@ -4,6 +4,8 @@ import App from './App';
import './index.css';
// Import the i18n configuration
import './i18n';
// Setup fetch interceptors
import './utils/setupInterceptors';
import { loadRuntimeConfig } from './utils/runtime';
// Load runtime configuration before starting the app

View File

@@ -32,9 +32,9 @@ const GroupsPage: React.FC = () => {
};
const handleDeleteGroup = async (groupId: string) => {
const success = await deleteGroup(groupId);
if (!success) {
setGroupError(t('groups.deleteError'));
const result = await deleteGroup(groupId);
if (!result || !result.success) {
setGroupError(result?.message || t('groups.deleteError'));
}
};

View File

@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../contexts/AuthContext';
import ThemeSwitch from '@/components/ui/ThemeSwitch';
import LanguageSwitch from '@/components/ui/LanguageSwitch';
const LoginPage: React.FC = () => {
const { t } = useTranslation();
@@ -41,8 +42,9 @@ const LoginPage: React.FC = () => {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8 login-container">
<div className="absolute top-4 right-4">
<div className="absolute top-4 right-4 w-full max-w-xs flex justify-end">
<ThemeSwitch />
<LanguageSwitch />
</div>
<div className="max-w-md w-full space-y-8 login-card p-8">
<div>

View File

@@ -1,11 +1,16 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams } from 'react-router-dom';
import { MarketServer, ServerConfig } from '@/types';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { MarketServer, CloudServer, ServerConfig } from '@/types';
import { useMarketData } from '@/hooks/useMarketData';
import { useCloudData } from '@/hooks/useCloudData';
import { useToast } from '@/contexts/ToastContext';
import { apiPost } from '@/utils/fetchInterceptor';
import MarketServerCard from '@/components/MarketServerCard';
import MarketServerDetail from '@/components/MarketServerDetail';
import CloudServerCard from '@/components/CloudServerCard';
import CloudServerDetail from '@/components/CloudServerDetail';
import MCPRouterApiKeyError from '@/components/MCPRouterApiKeyError';
import Pagination from '@/components/ui/Pagination';
const MarketPage: React.FC = () => {
@@ -14,82 +19,140 @@ const MarketPage: React.FC = () => {
const { serverName } = useParams<{ serverName?: string }>();
const { showToast } = useToast();
// Get tab from URL search params, default to cloud market
const [searchParams, setSearchParams] = useSearchParams();
const currentTab = searchParams.get('tab') || 'cloud';
// Local market data
const {
servers,
allServers,
categories,
loading,
error,
setError,
searchServers,
filterByCategory,
filterByTag,
selectedCategory,
selectedTag,
installServer,
fetchServerByName,
servers: localServers,
allServers: allLocalServers,
categories: localCategories,
loading: localLoading,
error: localError,
setError: setLocalError,
searchServers: searchLocalServers,
filterByCategory: filterLocalByCategory,
filterByTag: filterLocalByTag,
selectedCategory: selectedLocalCategory,
selectedTag: selectedLocalTag,
installServer: installLocalServer,
fetchServerByName: fetchLocalServerByName,
isServerInstalled,
// Pagination
currentPage,
totalPages,
changePage,
serversPerPage,
changeServersPerPage
currentPage: localCurrentPage,
totalPages: localTotalPages,
changePage: changeLocalPage,
serversPerPage: localServersPerPage,
changeServersPerPage: changeLocalServersPerPage
} = useMarketData();
// Cloud market data
const {
servers: cloudServers,
allServers: allCloudServers,
loading: cloudLoading,
error: cloudError,
setError: setCloudError,
fetchServerTools,
callServerTool,
// Pagination
currentPage: cloudCurrentPage,
totalPages: cloudTotalPages,
changePage: changeCloudPage,
serversPerPage: cloudServersPerPage,
changeServersPerPage: changeCloudServersPerPage
} = useCloudData();
const [selectedServer, setSelectedServer] = useState<MarketServer | null>(null);
const [selectedCloudServer, setSelectedCloudServer] = useState<CloudServer | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [installing, setInstalling] = useState(false);
const [installedCloudServers, setInstalledCloudServers] = useState<Set<string>>(new Set());
// Load server details if a server name is in the URL
useEffect(() => {
const loadServerDetails = async () => {
if (serverName) {
const server = await fetchServerByName(serverName);
if (server) {
setSelectedServer(server);
// Determine if it's a cloud or local server based on the current tab
if (currentTab === 'cloud') {
// Try to find the server in cloud servers
const server = cloudServers.find(s => s.name === serverName);
if (server) {
setSelectedCloudServer(server);
} else {
// If server not found, navigate back to market page
navigate('/market?tab=cloud');
}
} else {
// If server not found, navigate back to market page
navigate('/market');
// Local market
const server = await fetchLocalServerByName(serverName);
if (server) {
setSelectedServer(server);
} else {
// If server not found, navigate back to market page
navigate('/market?tab=local');
}
}
} else {
setSelectedServer(null);
setSelectedCloudServer(null);
}
};
loadServerDetails();
}, [serverName, fetchServerByName, navigate]);
}, [serverName, currentTab, cloudServers, fetchLocalServerByName, navigate]);
// Tab switching handler
const switchTab = (tab: 'local' | 'cloud') => {
const newSearchParams = new URLSearchParams(searchParams);
newSearchParams.set('tab', tab);
setSearchParams(newSearchParams);
// Clear any selected server when switching tabs
if (serverName) {
navigate('/market?' + newSearchParams.toString());
}
};
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
searchServers(searchQuery);
if (currentTab === 'local') {
searchLocalServers(searchQuery);
}
// Cloud search is not implemented in the original cloud page
};
const handleCategoryClick = (category: string) => {
filterByCategory(category);
if (currentTab === 'local') {
filterLocalByCategory(category);
}
};
const handleClearFilters = () => {
setSearchQuery('');
filterByCategory('');
filterByTag('');
if (currentTab === 'local') {
filterLocalByCategory('');
filterLocalByTag('');
}
};
const handleServerClick = (server: MarketServer) => {
navigate(`/market/${server.name}`);
const handleServerClick = (server: MarketServer | CloudServer) => {
if (currentTab === 'cloud') {
navigate(`/market/${server.name}?tab=cloud`);
} else {
navigate(`/market/${server.name}?tab=local`);
}
};
const handleBackToList = () => {
navigate('/market');
navigate(`/market?tab=${currentTab}`);
};
const handleInstall = async (server: MarketServer, config: ServerConfig) => {
const handleLocalInstall = async (server: MarketServer, config: ServerConfig) => {
try {
setInstalling(true);
// Pass the server object and the config to the installServer function
const success = await installServer(server, config);
const success = await installLocalServer(server, config);
if (success) {
// Show success message using toast instead of alert
showToast(t('market.installSuccess', { serverName: server.display_name }), 'success');
}
} finally {
@@ -97,15 +160,75 @@ const MarketPage: React.FC = () => {
}
};
// Handle cloud server installation
const handleCloudInstall = async (server: CloudServer, config: ServerConfig) => {
try {
setInstalling(true);
const payload = {
name: server.name,
config: config
};
const result = await apiPost('/servers', payload);
if (!result.success) {
const errorMessage = result?.message || t('server.addError');
showToast(errorMessage, 'error');
return;
}
// Update installed servers set
setInstalledCloudServers(prev => new Set(prev).add(server.name));
showToast(t('cloud.installSuccess', { name: server.title || server.name }), 'success');
} catch (error) {
console.error('Error installing cloud server:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
showToast(t('cloud.installError', { error: errorMessage }), 'error');
} finally {
setInstalling(false);
}
};
const handleCallTool = async (serverName: string, toolName: string, args: Record<string, any>) => {
try {
const result = await callServerTool(serverName, toolName, args);
showToast(t('cloud.toolCallSuccess', { toolName }), 'success');
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
// Don't show toast for API key errors, let the component handle it
if (!isMCPRouterApiKeyError(errorMessage)) {
showToast(t('cloud.toolCallError', { toolName, error: errorMessage }), 'error');
}
throw error;
}
};
// Helper function to check if error is MCPRouter API key not configured
const isMCPRouterApiKeyError = (errorMessage: string) => {
return errorMessage === 'MCPROUTER_API_KEY_NOT_CONFIGURED' ||
errorMessage.toLowerCase().includes('mcprouter api key not configured');
};
const handlePageChange = (page: number) => {
changePage(page);
if (currentTab === 'local') {
changeLocalPage(page);
} else {
changeCloudPage(page);
}
// Scroll to top of page when changing pages
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleChangeItemsPerPage = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newValue = parseInt(e.target.value, 10);
changeServersPerPage(newValue);
if (currentTab === 'local') {
changeLocalServersPerPage(newValue);
} else {
changeCloudServersPerPage(newValue);
}
};
// Render detailed view if a server is selected
@@ -114,164 +237,201 @@ const MarketPage: React.FC = () => {
<MarketServerDetail
server={selectedServer}
onBack={handleBackToList}
onInstall={handleInstall}
onInstall={handleLocalInstall}
installing={installing}
isInstalled={isServerInstalled(selectedServer.name)}
/>
);
}
// Render cloud server detail if selected
if (selectedCloudServer) {
return (
<CloudServerDetail
serverName={selectedCloudServer.name}
onBack={handleBackToList}
onCallTool={handleCallTool}
fetchServerTools={fetchServerTools}
onInstall={handleCloudInstall}
installing={installing}
isInstalled={installedCloudServers.has(selectedCloudServer.name)}
/>
);
}
// Get current data based on active tab
const isLocalTab = currentTab === 'local';
const servers = isLocalTab ? localServers : cloudServers;
const allServers = isLocalTab ? allLocalServers : allCloudServers;
const categories = isLocalTab ? localCategories : [];
const loading = isLocalTab ? localLoading : cloudLoading;
const error = isLocalTab ? localError : cloudError;
const setError = isLocalTab ? setLocalError : setCloudError;
const selectedCategory = isLocalTab ? selectedLocalCategory : '';
const selectedTag = isLocalTab ? selectedLocalTag : '';
const currentPage = isLocalTab ? localCurrentPage : cloudCurrentPage;
const totalPages = isLocalTab ? localTotalPages : cloudTotalPages;
const serversPerPage = isLocalTab ? localServersPerPage : cloudServersPerPage;
return (
<div>
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center">
{t('market.title')}
<span className="text-sm text-gray-500 font-normal ml-2">{t('pages.market.title').split(' - ')[1]}</span>
</h1>
{/* Tab Navigation */}
<div className="mb-6">
<div className="border-b border-gray-200">
<nav className="-mb-px flex space-x-3">
<button
onClick={() => switchTab('cloud')}
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${!isLocalTab
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{t('cloud.title')}
<span className="text-xs text-gray-400 font-normal ml-1">(
<a
href="https://mcprouter.co"
target="_blank"
rel="noopener noreferrer"
className="external-link"
>
MCPRouter
</a>
)
</span>
</button>
<button
onClick={() => switchTab('local')}
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${isLocalTab
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{t('market.title')}
<span className="text-xs text-gray-400 font-normal ml-1">(
<a
href="https://mcpm.sh"
target="_blank"
rel="noopener noreferrer"
className="external-link"
>
MCPM
</a>
)
</span>
</button>
</nav>
</div>
</div>
{error && (
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6 error-box rounded-lg">
<div className="flex items-center justify-between">
<p>{error}</p>
<>
{!isLocalTab && isMCPRouterApiKeyError(error) ? (
<MCPRouterApiKeyError />
) : (
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6 error-box rounded-lg">
<div className="flex items-center justify-between">
<p>{error}</p>
<button
onClick={() => setError(null)}
className="text-red-700 hover:text-red-900 transition-colors duration-200"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 01.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</div>
</div>
)}
</>
)}
{/* Search bar for local market only */}
{isLocalTab && (
<div className="bg-white shadow rounded-lg p-6 mb-6 page-card">
<form onSubmit={handleSearch} className="flex space-x-4 mb-0">
<div className="flex-grow">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('market.searchPlaceholder')}
className="shadow appearance-none border border-gray-200 rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
/>
</div>
<button
onClick={() => setError(null)}
className="text-red-700 hover:text-red-900 transition-colors duration-200"
type="submit"
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 01.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z" clipRule="evenodd" />
</svg>
{t('market.search')}
</button>
</div>
{(searchQuery || selectedCategory || selectedTag) && (
<button
type="button"
onClick={handleClearFilters}
className="border border-gray-300 text-gray-700 font-medium py-2 px-4 rounded hover:bg-gray-50 btn-secondary transition-all duration-200"
>
{t('market.clearFilters')}
</button>
)}
</form>
</div>
)}
{/* Search bar at the top */}
<div className="bg-white shadow rounded-lg p-6 mb-6 page-card">
<form onSubmit={handleSearch} className="flex space-x-4 mb-0">
<div className="flex-grow">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('market.searchPlaceholder')}
className="shadow appearance-none border border-gray-200 rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
/>
</div>
<button
type="submit"
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
>
{t('market.search')}
</button>
{(searchQuery || selectedCategory || selectedTag) && (
<button
type="button"
onClick={handleClearFilters}
className="border border-gray-300 text-gray-700 font-medium py-2 px-4 rounded hover:bg-gray-50 btn-secondary transition-all duration-200"
>
{t('market.clearFilters')}
</button>
)}
</form>
</div>
<div className="flex flex-col md:flex-row gap-6">
{/* Left sidebar for filters (without search) */}
<div className="md:w-48 flex-shrink-0">
<div className="bg-white shadow rounded-lg p-4 mb-6 sticky top-4 page-card">
{/* Categories */}
{categories.length > 0 ? (
<div className="mb-6">
<div className="flex justify-between items-center mb-3">
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
{selectedCategory && (
<span className="text-xs text-blue-600 cursor-pointer hover:underline transition-colors duration-200" onClick={() => filterByCategory('')}>
{t('market.clearCategoryFilter')}
</span>
)}
</div>
<div className="flex flex-col gap-2">
{categories.map((category) => (
<button
key={category}
onClick={() => handleCategoryClick(category)}
className={`px-3 py-2 rounded text-sm text-left transition-all duration-200 ${selectedCategory === category
? 'bg-blue-100 text-blue-800 font-medium btn-primary'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-secondary'
}`}
>
{category}
</button>
))}
</div>
</div>
) : loading ? (
<div className="mb-6">
<div className="mb-3">
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
</div>
<div className="flex flex-col gap-2 items-center py-4 loading-container">
<svg className="animate-spin h-6 w-6 text-blue-500 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p className="text-sm text-gray-600">{t('app.loading')}</p>
</div>
</div>
) : (
<div className="mb-6">
<div className="mb-3">
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
</div>
<p className="text-sm text-gray-600 py-2">{t('market.noCategories')}</p>
</div>
)}
{/* Tags */}
{/* {tags.length > 0 && (
<div className="mb-4">
<div className="flex justify-between items-center mb-3">
<div className="flex items-center">
<h3 className="font-medium text-gray-900">{t('market.tags')}</h3>
<button
onClick={toggleTagsVisibility}
className="ml-2 p-1 text-gray-600 hover:text-blue-600 hover:bg-gray-100 rounded-full"
aria-label={showTags ? t('market.hideTags') : t('market.showTags')}
>
<svg xmlns="http://www.w3.org/2000/svg" className={`h-5 w-5 transition-transform ${showTags ? 'rotate-180' : ''}`} viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M5.293 7.293a1 1 011.414 0L10 10.586l3.293-3.293a1 1 011.414 1.414l-4 4a1 1 01-1.414 0l-4-4a1 1 010-1.414z" clipRule="evenodd" />
</svg>
</button>
{/* Left sidebar for filters (local market only) */}
{isLocalTab && (
<div className="md:w-48 flex-shrink-0">
<div className="bg-white shadow rounded-lg p-4 mb-6 sticky top-4 page-card">
{/* Categories */}
{categories.length > 0 ? (
<div className="mb-6">
<div className="flex justify-between items-center mb-3">
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
{selectedCategory && (
<span className="text-xs text-blue-600 cursor-pointer hover:underline transition-colors duration-200" onClick={() => filterLocalByCategory('')}>
{t('market.clearCategoryFilter')}
</span>
)}
</div>
{selectedTag && (
<span className="text-xs text-blue-600 cursor-pointer hover:underline" onClick={() => filterByTag('')}>
{t('market.clearTagFilter')}
</span>
)}
</div>
{showTags && (
<div className="flex flex-wrap gap-2 max-h-48 overflow-y-auto pr-2">
{tags.map((tag) => (
<div className="flex flex-col gap-2">
{categories.map((category) => (
<button
key={tag}
onClick={() => handleTagClick(tag)}
className={`px-2 py-1 rounded text-xs ${selectedTag === tag
? 'bg-green-100 text-green-800 font-medium'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
key={category}
onClick={() => handleCategoryClick(category)}
className={`px-3 py-2 rounded text-sm text-left transition-all duration-200 ${selectedCategory === category
? 'bg-blue-100 text-blue-800 font-medium btn-primary'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-secondary'
}`}
>
#{tag}
{category}
</button>
))}
</div>
)}
</div>
)} */}
</div>
) : loading ? (
<div className="mb-6">
<div className="mb-3">
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
</div>
<div className="flex flex-col gap-2 items-center py-4 loading-container">
<svg className="animate-spin h-6 w-6 text-blue-500 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p className="text-sm text-gray-600">{t('app.loading')}</p>
</div>
</div>
) : (
<div className="mb-6">
<div className="mb-3">
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
</div>
<p className="text-sm text-gray-600 py-2">{t('market.noCategories')}</p>
</div>
)}
</div>
</div>
</div>
)}
{/* Main content area */}
<div className="flex-grow">
@@ -287,27 +447,43 @@ const MarketPage: React.FC = () => {
</div>
) : servers.length === 0 ? (
<div className="bg-white shadow rounded-lg p-6">
<p className="text-gray-600">{t('market.noServers')}</p>
<p className="text-gray-600">{isLocalTab ? t('market.noServers') : t('cloud.noServers')}</p>
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-6">
{servers.map((server, index) => (
<MarketServerCard
key={index}
server={server}
onClick={handleServerClick}
/>
isLocalTab ? (
<MarketServerCard
key={index}
server={server as MarketServer}
onClick={handleServerClick}
/>
) : (
<CloudServerCard
key={index}
server={server as CloudServer}
onClick={handleServerClick}
/>
)
))}
</div>
<div className="flex justify-between items-center mb-4">
<div className="text-sm text-gray-500">
{t('market.showing', {
from: (currentPage - 1) * serversPerPage + 1,
to: Math.min(currentPage * serversPerPage, allServers.length),
total: allServers.length
})}
{isLocalTab ? (
t('market.showing', {
from: (currentPage - 1) * serversPerPage + 1,
to: Math.min(currentPage * serversPerPage, allServers.length),
total: allServers.length
})
) : (
t('cloud.showing', {
from: (currentPage - 1) * serversPerPage + 1,
to: Math.min(currentPage * serversPerPage, allServers.length),
total: allServers.length
})
)}
</div>
<Pagination
currentPage={currentPage}
@@ -316,7 +492,7 @@ const MarketPage: React.FC = () => {
/>
<div className="flex items-center space-x-2">
<label htmlFor="perPage" className="text-sm text-gray-600">
{t('market.perPage')}:
{isLocalTab ? t('market.perPage') : t('cloud.perPage')}:
</label>
<select
id="perPage"
@@ -333,7 +509,6 @@ const MarketPage: React.FC = () => {
</div>
<div className="mt-6">
</div>
</>
)}

View File

@@ -103,7 +103,6 @@ const ServersPage: React.FC = () => {
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm error-box">
<div className="flex items-center justify-between">
<div>
<h3 className="text-red-600 text-lg font-medium">{t('app.error')}</h3>
<p className="text-gray-600 mt-1">{error}</p>
</div>
<button

View File

@@ -10,15 +10,9 @@ import { PermissionChecker } from '@/components/PermissionChecker';
import { PERMISSIONS } from '@/constants/permissions';
const SettingsPage: React.FC = () => {
const { t, i18n } = useTranslation();
const { t } = useTranslation();
const navigate = useNavigate();
const { showToast } = useToast();
const [currentLanguage, setCurrentLanguage] = useState(i18n.language);
// Update current language when it changes
useEffect(() => {
setCurrentLanguage(i18n.language);
}, [i18n.language]);
const [installConfig, setInstallConfig] = useState<{
pythonIndexUrl: string;
@@ -42,18 +36,32 @@ const SettingsPage: React.FC = () => {
openaiApiEmbeddingModel: '',
});
const [tempMCPRouterConfig, setTempMCPRouterConfig] = useState<{
apiKey: string;
referer: string;
title: string;
baseUrl: string;
}>({
apiKey: '',
referer: 'https://mcphub.app',
title: 'MCPHub',
baseUrl: 'https://api.mcprouter.to/v1',
});
const {
routingConfig,
tempRoutingConfig,
setTempRoutingConfig,
installConfig: savedInstallConfig,
smartRoutingConfig,
mcpRouterConfig,
loading,
updateRoutingConfig,
updateRoutingConfigBatch,
updateInstallConfig,
updateSmartRoutingConfig,
updateSmartRoutingConfigBatch
updateSmartRoutingConfigBatch,
updateMCPRouterConfig
} = useSettingsData();
// Update local installConfig when savedInstallConfig changes
@@ -75,14 +83,27 @@ const SettingsPage: React.FC = () => {
}
}, [smartRoutingConfig]);
// Update local tempMCPRouterConfig when mcpRouterConfig changes
useEffect(() => {
if (mcpRouterConfig) {
setTempMCPRouterConfig({
apiKey: mcpRouterConfig.apiKey || '',
referer: mcpRouterConfig.referer || 'https://mcphub.app',
title: mcpRouterConfig.title || 'MCPHub',
baseUrl: mcpRouterConfig.baseUrl || 'https://api.mcprouter.to/v1',
});
}
}, [mcpRouterConfig]);
const [sectionsVisible, setSectionsVisible] = useState({
routingConfig: false,
installConfig: false,
smartRoutingConfig: false,
mcpRouterConfig: false,
password: false
});
const toggleSection = (section: 'routingConfig' | 'installConfig' | 'smartRoutingConfig' | 'password') => {
const toggleSection = (section: 'routingConfig' | 'installConfig' | 'smartRoutingConfig' | 'mcpRouterConfig' | 'password') => {
setSectionsVisible(prev => ({
...prev,
[section]: !prev[section]
@@ -149,6 +170,17 @@ const SettingsPage: React.FC = () => {
await updateSmartRoutingConfig(key, tempSmartRoutingConfig[key]);
};
const handleMCPRouterConfigChange = (key: 'apiKey' | 'referer' | 'title' | 'baseUrl', value: string) => {
setTempMCPRouterConfig({
...tempMCPRouterConfig,
[key]: value
});
};
const saveMCPRouterConfig = async (key: 'apiKey' | 'referer' | 'title' | 'baseUrl') => {
await updateMCPRouterConfig(key, tempMCPRouterConfig[key]);
};
const handleSmartRoutingEnabledChange = async (value: boolean) => {
// If enabling Smart Routing, validate required fields and save any unsaved changes
if (value) {
@@ -197,45 +229,13 @@ const SettingsPage: React.FC = () => {
}, 2000);
};
const handleLanguageChange = (lang: string) => {
localStorage.setItem('i18nextLng', lang);
window.location.reload();
};
return (
<div className="container mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-8">{t('pages.settings.title')}</h1>
{/* Language Settings */}
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 page-card">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-gray-800">{t('pages.settings.language')}</h2>
<div className="flex space-x-3">
<button
className={`px-3 py-1.5 rounded-md transition-all duration-200 text-sm ${currentLanguage.startsWith('en')
? 'bg-blue-500 text-white btn-primary'
: 'bg-blue-100 text-blue-800 hover:bg-blue-200 btn-secondary'
}`}
onClick={() => handleLanguageChange('en')}
>
English
</button>
<button
className={`px-3 py-1.5 rounded-md transition-all duration-200 text-sm ${currentLanguage.startsWith('zh')
? 'bg-blue-500 text-white btn-primary'
: 'bg-blue-100 text-blue-800 hover:bg-blue-200 btn-secondary'
}`}
onClick={() => handleLanguageChange('zh')}
>
</button>
</div>
</div>
</div>
{/* Smart Routing Configuration Settings */}
<PermissionChecker permissions={PERMISSIONS.SETTINGS_SMART_ROUTING}>
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 page-card">
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 page-card dashboard-card">
<div
className="flex justify-between items-center cursor-pointer transition-colors duration-200 hover:text-blue-600"
onClick={() => toggleSection('smartRoutingConfig')}
@@ -360,8 +360,123 @@ const SettingsPage: React.FC = () => {
</div>
</PermissionChecker>
{/* MCPRouter Configuration Settings */}
<PermissionChecker permissions={PERMISSIONS.SETTINGS_INSTALL_CONFIG}>
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 page-card dashboard-card">
<div
className="flex justify-between items-center cursor-pointer transition-colors duration-200 hover:text-blue-600"
onClick={() => toggleSection('mcpRouterConfig')}
>
<h2 className="font-semibold text-gray-800">{t('settings.mcpRouterConfig')}</h2>
<span className="text-gray-500 transition-transform duration-200">
{sectionsVisible.mcpRouterConfig ? '▼' : '►'}
</span>
</div>
{sectionsVisible.mcpRouterConfig && (
<div className="space-y-4 mt-4">
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">{t('settings.mcpRouterApiKey')}</h3>
<p className="text-sm text-gray-500">{t('settings.mcpRouterApiKeyDescription')}</p>
</div>
<div className="flex items-center gap-3">
<input
type="password"
value={tempMCPRouterConfig.apiKey}
onChange={(e) => handleMCPRouterConfigChange('apiKey', e.target.value)}
placeholder={t('settings.mcpRouterApiKeyPlaceholder')}
className="flex-1 mt-1 block w-full py-2 px-3 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300 form-input"
disabled={loading}
/>
<button
onClick={() => saveMCPRouterConfig('apiKey')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
>
{t('common.save')}
</button>
</div>
</div>
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">{t('settings.mcpRouterReferer')}</h3>
<p className="text-sm text-gray-500">{t('settings.mcpRouterRefererDescription')}</p>
</div>
<div className="flex items-center gap-3">
<input
type="text"
value={tempMCPRouterConfig.referer}
onChange={(e) => handleMCPRouterConfigChange('referer', e.target.value)}
placeholder={t('settings.mcpRouterRefererPlaceholder')}
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
disabled={loading}
/>
<button
onClick={() => saveMCPRouterConfig('referer')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
>
{t('common.save')}
</button>
</div>
</div>
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">{t('settings.mcpRouterTitle')}</h3>
<p className="text-sm text-gray-500">{t('settings.mcpRouterTitleDescription')}</p>
</div>
<div className="flex items-center gap-3">
<input
type="text"
value={tempMCPRouterConfig.title}
onChange={(e) => handleMCPRouterConfigChange('title', e.target.value)}
placeholder={t('settings.mcpRouterTitlePlaceholder')}
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
disabled={loading}
/>
<button
onClick={() => saveMCPRouterConfig('title')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
>
{t('common.save')}
</button>
</div>
</div>
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">{t('settings.mcpRouterBaseUrl')}</h3>
<p className="text-sm text-gray-500">{t('settings.mcpRouterBaseUrlDescription')}</p>
</div>
<div className="flex items-center gap-3">
<input
type="text"
value={tempMCPRouterConfig.baseUrl}
onChange={(e) => handleMCPRouterConfigChange('baseUrl', e.target.value)}
placeholder={t('settings.mcpRouterBaseUrlPlaceholder')}
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
disabled={loading}
/>
<button
onClick={() => saveMCPRouterConfig('baseUrl')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
>
{t('common.save')}
</button>
</div>
</div>
</div>
)}
</div>
</PermissionChecker>
{/* Route Configuration Settings */}
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card">
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => toggleSection('routingConfig')}
@@ -456,7 +571,7 @@ const SettingsPage: React.FC = () => {
{/* Installation Configuration Settings */}
<PermissionChecker permissions={PERMISSIONS.SETTINGS_INSTALL_CONFIG}>
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card">
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => toggleSection('installConfig')}
@@ -546,7 +661,7 @@ const SettingsPage: React.FC = () => {
</PermissionChecker>
{/* Change Password */}
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card">
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => toggleSection('password')}

View File

@@ -4,45 +4,27 @@ import {
RegisterCredentials,
ChangePasswordCredentials,
} from '../types';
import { getApiUrl } from '../utils/runtime';
import { apiPost, apiGet } from '../utils/fetchInterceptor';
import { getToken, setToken, removeToken } from '../utils/interceptors';
// Token key in localStorage
const TOKEN_KEY = 'mcphub_token';
// Get token from localStorage
export const getToken = (): string | null => {
return localStorage.getItem(TOKEN_KEY);
};
// Set token in localStorage
export const setToken = (token: string): void => {
localStorage.setItem(TOKEN_KEY, token);
};
// Remove token from localStorage
export const removeToken = (): void => {
localStorage.removeItem(TOKEN_KEY);
};
// Export token management functions
export { getToken, setToken, removeToken };
// Login user
export const login = async (credentials: LoginCredentials): Promise<AuthResponse> => {
try {
console.log(getApiUrl('/auth/login'));
const response = await fetch(getApiUrl('/auth/login'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(credentials),
});
const response = await apiPost<AuthResponse>('/auth/login', credentials);
const data: AuthResponse = await response.json();
if (data.success && data.token) {
setToken(data.token);
// The auth API returns data directly, not wrapped in a data field
if (response.success && response.token) {
setToken(response.token);
return response;
}
return data;
return {
success: false,
message: response.message || 'Login failed',
};
} catch (error) {
console.error('Login error:', error);
return {
@@ -55,21 +37,17 @@ export const login = async (credentials: LoginCredentials): Promise<AuthResponse
// Register user
export const register = async (credentials: RegisterCredentials): Promise<AuthResponse> => {
try {
const response = await fetch(getApiUrl('/auth/register'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(credentials),
});
const response = await apiPost<AuthResponse>('/auth/register', credentials);
const data: AuthResponse = await response.json();
if (data.success && data.token) {
setToken(data.token);
if (response.success && response.token) {
setToken(response.token);
return response;
}
return data;
return {
success: false,
message: response.message || 'Registration failed',
};
} catch (error) {
console.error('Register error:', error);
return {
@@ -91,14 +69,8 @@ export const getCurrentUser = async (): Promise<AuthResponse> => {
}
try {
const response = await fetch(getApiUrl('/auth/user'), {
method: 'GET',
headers: {
'x-auth-token': token,
},
});
return await response.json();
const response = await apiGet<AuthResponse>('/auth/user');
return response;
} catch (error) {
console.error('Get current user error:', error);
return {
@@ -122,16 +94,8 @@ export const changePassword = async (
}
try {
const response = await fetch(getApiUrl('/auth/change-password'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token,
},
body: JSON.stringify(credentials),
});
return await response.json();
const response = await apiPost<AuthResponse>('/auth/change-password', credentials);
return response;
} catch (error) {
console.error('Change password error:', error);
return {

View File

@@ -1,4 +1,5 @@
import { getApiUrl, getBasePath } from '../utils/runtime';
import { apiGet, fetchWithInterceptors } from '../utils/fetchInterceptor';
import { getBasePath } from '../utils/runtime';
export interface SystemConfig {
routing?: {
@@ -25,6 +26,7 @@ export interface PublicConfigResponse {
success: boolean;
data?: {
skipAuth?: boolean;
permissions?: any;
};
message?: string;
}
@@ -40,10 +42,10 @@ export interface SystemConfigResponse {
/**
* Get public configuration (skipAuth setting) without authentication
*/
export const getPublicConfig = async (): Promise<{ skipAuth: boolean }> => {
export const getPublicConfig = async (): Promise<{ skipAuth: boolean; permissions?: any }> => {
try {
const basePath = getBasePath();
const response = await fetch(`${basePath}/public-config`, {
const response = await fetchWithInterceptors(`${basePath}/public-config`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
@@ -52,7 +54,7 @@ export const getPublicConfig = async (): Promise<{ skipAuth: boolean }> => {
if (response.ok) {
const data: PublicConfigResponse = await response.json();
return { skipAuth: data.data?.skipAuth === true };
return { skipAuth: data.data?.skipAuth === true, permissions: data.data?.permissions || {} };
}
return { skipAuth: false };
@@ -69,16 +71,10 @@ export const getPublicConfig = async (): Promise<{ skipAuth: boolean }> => {
*/
export const getSystemConfigPublic = async (): Promise<SystemConfig | null> => {
try {
const response = await fetch(getApiUrl('/settings'), {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
const response = await apiGet<SystemConfigResponse>('/settings');
if (response.ok) {
const data: SystemConfigResponse = await response.json();
return data.data?.systemConfig || null;
if (response.success) {
return response.data?.systemConfig || null;
}
return null;

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { getToken } from './authService'; // Import getToken function
import { apiGet, apiDelete } from '../utils/fetchInterceptor';
import { getApiUrl } from '../utils/runtime';
import { getToken } from '../utils/interceptors';
export interface LogEntry {
timestamp: number;
@@ -13,21 +14,13 @@ export interface LogEntry {
// Fetch all logs
export const fetchLogs = async (): Promise<LogEntry[]> => {
try {
// Get authentication token
const token = getToken();
const response = await fetch(getApiUrl('/logs'), {
headers: {
'x-auth-token': token || '',
},
});
const response = await apiGet<{ success: boolean; data: LogEntry[]; error?: string }>('/logs');
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Failed to fetch logs');
if (!response.success) {
throw new Error(response.error || 'Failed to fetch logs');
}
return result.data;
return response.data;
} catch (error) {
console.error('Error fetching logs:', error);
throw error;
@@ -37,19 +30,10 @@ export const fetchLogs = async (): Promise<LogEntry[]> => {
// Clear all logs
export const clearLogs = async (): Promise<void> => {
try {
// Get authentication token
const token = getToken();
const response = await fetch(getApiUrl('/logs'), {
method: 'DELETE',
headers: {
'x-auth-token': token || '',
},
});
const response = await apiDelete<{ success: boolean; error?: string }>('/logs');
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Failed to clear logs');
if (!response.success) {
throw new Error(response.error || 'Failed to clear logs');
}
} catch (error) {
console.error('Error clearing logs:', error);

View File

@@ -1,5 +1,4 @@
import { getApiUrl } from '../utils/runtime';
import { getToken } from './authService';
import { apiPost, apiPut } from '../utils/fetchInterceptor';
export interface ToolCallRequest {
toolName: string;
@@ -25,38 +24,32 @@ export const callTool = async (
server?: string,
): Promise<ToolCallResult> => {
try {
const token = getToken();
// Construct the URL with optional server parameter
const url = server ? `/tools/call/${server}` : '/tools/call';
const response = await fetch(getApiUrl(url), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '', // Include token for authentication
Authorization: `Bearer ${token}`, // Add bearer auth for MCP routing
},
body: JSON.stringify({
const response = await apiPost<any>(
url,
{
toolName: request.toolName,
arguments: request.arguments,
}),
});
},
{
headers: {
Authorization: `Bearer ${localStorage.getItem('mcphub_token')}`, // Add bearer auth for MCP routing
},
},
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (!data.success) {
if (!response.success) {
return {
success: false,
error: data.message || 'Tool call failed',
error: response.message || 'Tool call failed',
};
}
return {
success: true,
content: data.data.content || [],
content: response.data?.content || [],
};
} catch (error) {
console.error('Error calling tool:', error);
@@ -76,25 +69,19 @@ export const toggleTool = async (
enabled: boolean,
): Promise<{ success: boolean; error?: string }> => {
try {
const token = getToken();
const response = await fetch(getApiUrl(`/servers/${serverName}/tools/${toolName}/toggle`), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
Authorization: `Bearer ${token}`,
const response = await apiPost<any>(
`/servers/${serverName}/tools/${toolName}/toggle`,
{ enabled },
{
headers: {
Authorization: `Bearer ${localStorage.getItem('mcphub_token')}`,
},
},
body: JSON.stringify({ enabled }),
});
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
return {
success: data.success,
error: data.success ? undefined : data.message,
success: response.success,
error: response.success ? undefined : response.message,
};
} catch (error) {
console.error('Error toggling tool:', error);
@@ -114,28 +101,19 @@ export const updateToolDescription = async (
description: string,
): Promise<{ success: boolean; error?: string }> => {
try {
const token = getToken();
const response = await fetch(
getApiUrl(`/servers/${serverName}/tools/${toolName}/description`),
const response = await apiPut<any>(
`/servers/${serverName}/tools/${toolName}/description`,
{ description },
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
Authorization: `Bearer ${token || ''}`,
Authorization: `Bearer ${localStorage.getItem('mcphub_token')}`,
},
body: JSON.stringify({ description }),
},
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
return {
success: data.success,
error: data.success ? undefined : data.message,
success: response.success,
error: response.success ? undefined : response.message,
};
} catch (error) {
console.error('Error updating tool description:', error);

View File

@@ -55,6 +55,27 @@ export interface MarketServer {
is_official?: boolean;
}
// Cloud Server types (for MCPRouter API)
export interface CloudServer {
created_at: string;
updated_at: string;
name: string;
author_name: string;
title: string;
description: string;
content: string;
server_key: string;
config_name: string;
server_url: string;
tools?: CloudServerTool[];
}
export interface CloudServerTool {
name: string;
description: string;
inputSchema: Record<string, any>;
}
// Tool input schema types
export interface ToolInputSchema {
type: string;
@@ -137,11 +158,17 @@ export interface Server {
}
// Group types
// Group server configuration - supports tool selection
export interface IGroupServerConfig {
name: string; // Server name
tools?: string[] | 'all'; // Array of specific tool names to include, or 'all' for all tools (default: 'all')
}
export interface Group {
id: string;
name: string;
description?: string;
servers: string[];
servers: string[] | IGroupServerConfig[]; // Supports both old and new format
}
// Environment variable types
@@ -196,7 +223,7 @@ export interface ServerFormData {
export interface GroupFormData {
name: string;
description: string;
servers: string[]; // Added servers array to include in form data
servers: string[] | IGroupServerConfig[]; // Updated to support new format
}
// API response types

View File

@@ -0,0 +1,174 @@
import { getApiUrl } from './runtime';
// Define the interceptor interface
export interface FetchInterceptor {
request?: (url: string, config: RequestInit) => Promise<{ url: string; config: RequestInit }>;
response?: (response: Response) => Promise<Response>;
error?: (error: Error) => Promise<Error>;
}
// Define the enhanced fetch response interface
export interface ApiResponse<T = any> {
success: boolean;
data?: T;
message?: string;
error?: string;
}
// Global interceptors store
const interceptors: FetchInterceptor[] = [];
// Add an interceptor
export const addInterceptor = (interceptor: FetchInterceptor): void => {
interceptors.push(interceptor);
};
// Remove an interceptor
export const removeInterceptor = (interceptor: FetchInterceptor): void => {
const index = interceptors.indexOf(interceptor);
if (index > -1) {
interceptors.splice(index, 1);
}
};
// Clear all interceptors
export const clearInterceptors = (): void => {
interceptors.length = 0;
};
// Enhanced fetch function with interceptors
export const fetchWithInterceptors = async (
input: string | URL | Request,
init: RequestInit = {},
): Promise<Response> => {
let url = input.toString();
let config = { ...init };
try {
// Apply request interceptors
for (const interceptor of interceptors) {
if (interceptor.request) {
const result = await interceptor.request(url, config);
url = result.url;
config = result.config;
}
}
// Make the actual fetch request
let response = await fetch(url, config);
// Apply response interceptors
for (const interceptor of interceptors) {
if (interceptor.response) {
response = await interceptor.response(response);
}
}
return response;
} catch (error) {
let processedError = error as Error;
// Apply error interceptors
for (const interceptor of interceptors) {
if (interceptor.error) {
processedError = await interceptor.error(processedError);
}
}
throw processedError;
}
};
// Convenience function for API calls with automatic URL construction
export const apiRequest = async <T = any>(endpoint: string, init: RequestInit = {}): Promise<T> => {
try {
const url = getApiUrl(endpoint);
const response = await fetchWithInterceptors(url, init);
// Try to parse JSON response
let data: T;
try {
data = await response.json();
} catch (parseError) {
// If JSON parsing fails, create a generic response
const genericResponse = {
success: response.ok,
message: response.ok
? 'Request successful'
: `HTTP ${response.status}: ${response.statusText}`,
};
data = genericResponse as T;
}
// If response is not ok, but no explicit error in parsed data
if (!response.ok && typeof data === 'object' && data !== null) {
const responseObj = data as any;
if (responseObj.success !== false) {
responseObj.success = false;
responseObj.message =
responseObj.message || `HTTP ${response.status}: ${response.statusText}`;
}
}
return data;
} catch (error) {
console.error('API request error:', error);
const errorResponse = {
success: false,
message: error instanceof Error ? error.message : 'An unknown error occurred',
};
return errorResponse as T;
}
};
// Convenience methods for common HTTP methods
export const apiGet = <T = any>(endpoint: string, init: Omit<RequestInit, 'method'> = {}) =>
apiRequest<T>(endpoint, { ...init, method: 'GET' });
export const apiPost = <T = any>(
endpoint: string,
data?: any,
init: Omit<RequestInit, 'method' | 'body'> = {},
) =>
apiRequest<T>(endpoint, {
...init,
method: 'POST',
headers: {
'Content-Type': 'application/json',
...init.headers,
},
body: data ? JSON.stringify(data) : undefined,
});
export const apiPut = <T = any>(
endpoint: string,
data?: any,
init: Omit<RequestInit, 'method' | 'body'> = {},
) =>
apiRequest<T>(endpoint, {
...init,
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...init.headers,
},
body: data ? JSON.stringify(data) : undefined,
});
export const apiDelete = <T = any>(endpoint: string, init: Omit<RequestInit, 'method'> = {}) =>
apiRequest<T>(endpoint, { ...init, method: 'DELETE' });
export const apiPatch = <T = any>(
endpoint: string,
data?: any,
init: Omit<RequestInit, 'method' | 'body'> = {},
) =>
apiRequest<T>(endpoint, {
...init,
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...init.headers,
},
body: data ? JSON.stringify(data) : undefined,
});

View File

@@ -0,0 +1,99 @@
import { addInterceptor, removeInterceptor, type FetchInterceptor } from './fetchInterceptor';
// Token key in localStorage
const TOKEN_KEY = 'mcphub_token';
// Get token from localStorage
export const getToken = (): string | null => {
return localStorage.getItem(TOKEN_KEY);
};
// Set token in localStorage
export const setToken = (token: string): void => {
localStorage.setItem(TOKEN_KEY, token);
};
// Remove token from localStorage
export const removeToken = (): void => {
localStorage.removeItem(TOKEN_KEY);
};
// Auth interceptor for automatically adding authorization headers
export const authInterceptor: FetchInterceptor = {
request: async (url: string, config: RequestInit) => {
const headers = new Headers(config.headers);
const language = localStorage.getItem('i18nextLng') || 'en';
headers.set('Accept-Language', language);
const token = getToken();
if (token) {
headers.set('x-auth-token', token);
}
return {
url,
config: {
...config,
headers,
},
};
},
response: async (response: Response) => {
// Handle unauthorized responses
if (response.status === 401) {
// Token might be expired or invalid, remove it
removeToken();
// You could also trigger a redirect to login page here
// window.location.href = '/login';
}
return response;
},
error: async (error: Error) => {
console.error('Auth interceptor error:', error);
return error;
},
};
// Install the auth interceptor
export const installAuthInterceptor = (): void => {
addInterceptor(authInterceptor);
};
// Uninstall the auth interceptor
export const uninstallAuthInterceptor = (): void => {
removeInterceptor(authInterceptor);
};
// Logging interceptor for development
export const loggingInterceptor: FetchInterceptor = {
request: async (url: string, config: RequestInit) => {
console.log(`🚀 [${config.method || 'GET'}] ${url}`, config);
return { url, config };
},
response: async (response: Response) => {
console.log(`✅ [${response.status}] ${response.url}`);
return response;
},
error: async (error: Error) => {
console.error(`❌ Fetch error:`, error);
return error;
},
};
// Install the logging interceptor (only in development)
export const installLoggingInterceptor = (): void => {
if (process.env.NODE_ENV === 'development') {
addInterceptor(loggingInterceptor);
}
};
// Uninstall the logging interceptor
export const uninstallLoggingInterceptor = (): void => {
removeInterceptor(loggingInterceptor);
};

View File

@@ -0,0 +1,19 @@
import { installAuthInterceptor, installLoggingInterceptor } from './interceptors';
/**
* Setup all default interceptors for the application
* This should be called once when the app initializes
*/
export const setupInterceptors = (): void => {
// Install auth interceptor for automatic token handling
installAuthInterceptor();
// Install logging interceptor in development mode
installLoggingInterceptor();
};
/**
* Initialize interceptors automatically when this module is imported
* This ensures interceptors are set up as early as possible
*/
setupInterceptors();

View File

@@ -1,12 +1,9 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
darkMode: 'class', // Use class strategy for dark mode
theme: {
extend: {},
},
plugins: [],
}
plugins: [require('@tailwindcss/line-clamp')],
};

View File

@@ -186,7 +186,8 @@
"copySuccess": "Copied to clipboard",
"copyFailed": "Copy failed",
"close": "Close",
"confirm": "Confirm"
"confirm": "Confirm",
"language": "Language"
},
"nav": {
"dashboard": "Dashboard",
@@ -196,6 +197,7 @@
"settings": "Settings",
"changePassword": "Change Password",
"market": "Market",
"cloud": "Cloud Market",
"logs": "Logs"
},
"pages": {
@@ -227,7 +229,7 @@
"smartRouting": "Smart Routing"
},
"market": {
"title": "Server Market - (Data from mcpm.sh)"
"title": "Market Hub - Local and Cloud Markets"
},
"logs": {
"title": "System Logs"
@@ -270,10 +272,17 @@
"noGroups": "No groups available. Create a new group to get started.",
"noServers": "No servers in this group.",
"noServerOptions": "No servers available",
"serverCount": "{{count}} Servers"
"serverCount": "{{count}} Servers",
"toolSelection": "Tool Selection",
"toolsSelected": "Selected",
"allTools": "All",
"selectedTools": "Selected tools",
"selectAll": "Select All",
"selectNone": "Select None",
"configureTools": "Configure Tools"
},
"market": {
"title": "Server Market",
"title": "Local Installation",
"official": "Official",
"by": "By",
"unknown": "Unknown",
@@ -316,6 +325,58 @@
"confirmVariablesMessage": "Please ensure these variables are properly defined in your runtime environment. Continue installing server?",
"confirmAndInstall": "Confirm and Install"
},
"cloud": {
"title": "Cloud Support",
"subtitle": "Powered by MCPRouter",
"by": "By",
"server": "Server",
"config": "Config",
"created": "Created",
"updated": "Updated",
"available": "Available",
"description": "Description",
"details": "Details",
"tools": "Tools",
"tool": "tool",
"toolsAvailable": "{{count}} tool available||{{count}} tools available",
"loadingTools": "Loading tools...",
"noTools": "No tools available for this server",
"noDescription": "No description available",
"viewDetails": "View Details",
"parameters": "Parameters",
"result": "Result",
"error": "Error",
"callTool": "Call",
"calling": "Calling...",
"toolCallSuccess": "Tool {{toolName}} executed successfully",
"toolCallError": "Failed to call tool {{toolName}}: {{error}}",
"viewSchema": "View Schema",
"backToList": "Back to Cloud Market",
"search": "Search",
"searchPlaceholder": "Search cloud servers by name, title, or author",
"clearFilters": "Clear Filters",
"clearCategoryFilter": "Clear",
"clearTagFilter": "Clear",
"categories": "Categories",
"tags": "Tags",
"noCategories": "No categories found",
"noTags": "No tags found",
"noServers": "No cloud servers found",
"fetchError": "Error fetching cloud servers",
"serverNotFound": "Cloud server not found",
"searchError": "Error searching cloud servers",
"filterError": "Error filtering cloud servers by category",
"tagFilterError": "Error filtering cloud servers by tag",
"showing": "Showing {{from}}-{{to}} of {{total}} cloud servers",
"perPage": "Per page",
"apiKeyNotConfigured": "MCPRouter API key not configured",
"apiKeyNotConfiguredDescription": "To use cloud servers, you need to configure your MCPRouter API key.",
"getApiKey": "Get API Key",
"configureInSettings": "Configure in Settings",
"installServer": "Install {{name}}",
"installSuccess": "Server {{name}} installed successfully",
"installError": "Failed to install server: {{error}}"
},
"tool": {
"run": "Run",
"running": "Running...",
@@ -386,7 +447,20 @@
"openaiApiEmbeddingModelPlaceholder": "text-embedding-3-small",
"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}}"
"smartRoutingValidationError": "Please fill in the required fields before enabling Smart Routing: {{fields}}",
"mcpRouterConfig": "Cloud Market",
"mcpRouterApiKey": "MCPRouter API Key",
"mcpRouterApiKeyDescription": "API key for accessing MCPRouter cloud market services",
"mcpRouterApiKeyPlaceholder": "Enter MCPRouter API key",
"mcpRouterReferer": "Referer",
"mcpRouterRefererDescription": "Referer header for MCPRouter API requests",
"mcpRouterRefererPlaceholder": "https://mcphub.app",
"mcpRouterTitle": "Title",
"mcpRouterTitleDescription": "Title header for MCPRouter API requests",
"mcpRouterTitlePlaceholder": "MCPHub",
"mcpRouterBaseUrl": "Base URL",
"mcpRouterBaseUrlDescription": "Base URL for MCPRouter API",
"mcpRouterBaseUrlPlaceholder": "https://api.mcprouter.to/v1"
},
"dxt": {
"upload": "Upload",
@@ -448,5 +522,63 @@
"deleteConfirmation": "Are you sure you want to delete user '{{username}}'? This action cannot be undone.",
"confirmDelete": "Delete User",
"deleteWarning": "Are you sure you want to delete user '{{username}}'? This action cannot be undone."
},
"api": {
"errors": {
"readonly": "Readonly for demo environment",
"serverNameRequired": "Server name is required",
"serverConfigRequired": "Server configuration is required",
"serverConfigInvalid": "Server configuration must include either a URL, OpenAPI specification URL or schema, or command with arguments",
"serverTypeInvalid": "Server type must be one of: stdio, sse, streamable-http, openapi",
"urlRequiredForType": "URL is required for {{type}} server type",
"openapiSpecRequired": "OpenAPI specification URL or schema is required for openapi server type",
"headersInvalidFormat": "Headers must be an object",
"headersNotSupportedForStdio": "Headers are not supported for stdio server type",
"serverNotFound": "Server not found",
"failedToRemoveServer": "Server not found or failed to remove",
"internalServerError": "Internal server error",
"failedToGetServers": "Failed to get servers information",
"failedToGetServerSettings": "Failed to get server settings",
"failedToGetServerConfig": "Failed to get server configuration",
"failedToSaveSettings": "Failed to save settings",
"toolNameRequired": "Server name and tool name are required",
"descriptionMustBeString": "Description must be a string",
"groupIdRequired": "Group ID is required",
"groupNameRequired": "Group name is required",
"groupNotFound": "Group not found",
"groupIdAndServerNameRequired": "Group ID and server name are required",
"groupOrServerNotFound": "Group or server not found",
"toolsMustBeAllOrArray": "Tools must be \"all\" or an array of strings",
"serverNameAndToolNameRequired": "Server name and tool name are required",
"usernameRequired": "Username is required",
"userNotFound": "User not found",
"failedToGetUsers": "Failed to get users information",
"failedToGetUserInfo": "Failed to get user information",
"failedToGetUserStats": "Failed to get user statistics",
"marketServerNameRequired": "Server name is required",
"marketServerNotFound": "Market server not found",
"failedToGetMarketServers": "Failed to get market servers information",
"failedToGetMarketServer": "Failed to get market server information",
"failedToGetMarketCategories": "Failed to get market categories",
"failedToGetMarketTags": "Failed to get market tags",
"failedToSearchMarketServers": "Failed to search market servers",
"failedToFilterMarketServers": "Failed to filter market servers",
"failedToProcessDxtFile": "Failed to process DXT file"
},
"success": {
"serverCreated": "Server created successfully",
"serverUpdated": "Server updated successfully",
"serverRemoved": "Server removed successfully",
"serverToggled": "Server status toggled successfully",
"toolToggled": "Tool {{name}} {{action}} successfully",
"toolDescriptionUpdated": "Tool {{name}} description updated successfully",
"systemConfigUpdated": "System configuration updated successfully",
"groupCreated": "Group created successfully",
"groupUpdated": "Group updated successfully",
"groupDeleted": "Group deleted successfully",
"serverAddedToGroup": "Server added to group successfully",
"serverRemovedFromGroup": "Server removed from group successfully",
"serverToolsUpdated": "Server tools updated successfully"
}
}
}

View File

@@ -187,7 +187,8 @@
"copySuccess": "已复制到剪贴板",
"copyFailed": "复制失败",
"close": "关闭",
"confirm": "确认"
"confirm": "确认",
"language": "语言"
},
"nav": {
"dashboard": "仪表盘",
@@ -197,6 +198,7 @@
"groups": "分组",
"users": "用户",
"market": "市场",
"cloud": "云端市场",
"logs": "日志"
},
"pages": {
@@ -228,7 +230,7 @@
"title": "用户管理"
},
"market": {
"title": "服务器市场 - (数据来源于 mcpm.sh"
"title": "市场中心 - 本地市场和云端市场"
},
"logs": {
"title": "系统日志"
@@ -271,10 +273,17 @@
"noGroups": "暂无可用分组。创建一个新分组以开始使用。",
"noServers": "此分组中没有服务器。",
"noServerOptions": "没有可用的服务器",
"serverCount": "{{count}} 台服务器"
"serverCount": "{{count}} 台服务器",
"toolSelection": "工具选择",
"toolsSelected": "选择",
"allTools": "全部",
"selectedTools": "选中的工具",
"selectAll": "全选",
"selectNone": "全不选",
"configureTools": "配置工具"
},
"market": {
"title": "服务器市场",
"title": "本地安装",
"official": "官方",
"by": "作者",
"unknown": "未知",
@@ -306,7 +315,7 @@
"required": "必填",
"example": "示例",
"viewSchema": "查看结构",
"fetchError": "获取服务器市场数据失败",
"fetchError": "获取本地市场服务器数据失败",
"serverNotFound": "未找到服务器",
"searchError": "搜索服务器失败",
"filterError": "按分类筛选服务器失败",
@@ -317,6 +326,58 @@
"confirmVariablesMessage": "请确保这些变量在运行环境中已正确定义。是否继续安装服务器?",
"confirmAndInstall": "确认并安装"
},
"cloud": {
"title": "云端支持",
"subtitle": "由 MCPRouter 提供支持",
"by": "作者",
"server": "服务器",
"config": "配置",
"created": "创建时间",
"updated": "更新时间",
"available": "可用",
"description": "描述",
"details": "详细信息",
"tools": "工具",
"tool": "个工具",
"toolsAvailable": "{{count}} 个工具可用",
"loadingTools": "加载工具中...",
"noTools": "该服务器没有可用工具",
"noDescription": "无描述信息",
"viewDetails": "查看详情",
"parameters": "参数",
"result": "结果",
"error": "错误",
"callTool": "调用",
"calling": "调用中...",
"toolCallSuccess": "工具 {{toolName}} 执行成功",
"toolCallError": "调用工具 {{toolName}} 失败:{{error}}",
"viewSchema": "查看结构",
"backToList": "返回云端市场",
"search": "搜索",
"searchPlaceholder": "搜索云端服务器名称、标题或作者",
"clearFilters": "清除筛选",
"clearCategoryFilter": "清除",
"clearTagFilter": "清除",
"categories": "分类",
"tags": "标签",
"noCategories": "未找到分类",
"noTags": "未找到标签",
"noServers": "未找到云端服务器",
"fetchError": "获取云端服务器失败",
"serverNotFound": "未找到云端服务器",
"searchError": "搜索云端服务器失败",
"filterError": "按分类筛选云端服务器失败",
"tagFilterError": "按标签筛选云端服务器失败",
"showing": "显示 {{from}}-{{to}}/{{total}} 个云端服务器",
"perPage": "每页显示",
"apiKeyNotConfigured": "MCPRouter API 密钥未配置",
"apiKeyNotConfiguredDescription": "要使用云端服务器,您需要配置 MCPRouter API 密钥。",
"getApiKey": "获取 API 密钥",
"configureInSettings": "在设置中配置",
"installServer": "安装 {{name}}",
"installSuccess": "服务器 {{name}} 安装成功",
"installError": "安装服务器失败:{{error}}"
},
"tool": {
"run": "运行",
"running": "运行中...",
@@ -388,7 +449,20 @@
"openaiApiEmbeddingModelPlaceholder": "text-embedding-3-small",
"smartRoutingConfigUpdated": "智能路由配置更新成功",
"smartRoutingRequiredFields": "启用智能路由需要填写数据库连接地址和 OpenAI API 密钥",
"smartRoutingValidationError": "启用智能路由前请先填写必要字段:{{fields}}"
"smartRoutingValidationError": "启用智能路由前请先填写必要字段:{{fields}}",
"mcpRouterConfig": "云端市场",
"mcpRouterApiKey": "MCPRouter API 密钥",
"mcpRouterApiKeyDescription": "用于访问 MCPRouter 云端市场服务的 API 密钥",
"mcpRouterApiKeyPlaceholder": "请输入 MCPRouter API 密钥",
"mcpRouterReferer": "引用地址",
"mcpRouterRefererDescription": "MCPRouter API 请求的引用地址头",
"mcpRouterRefererPlaceholder": "https://mcphub.app",
"mcpRouterTitle": "标题",
"mcpRouterTitleDescription": "MCPRouter API 请求的标题头",
"mcpRouterTitlePlaceholder": "MCPHub",
"mcpRouterBaseUrl": "基础地址",
"mcpRouterBaseUrlDescription": "MCPRouter API 的基础地址",
"mcpRouterBaseUrlPlaceholder": "https://api.mcprouter.to/v1"
},
"dxt": {
"upload": "上传",
@@ -421,7 +495,7 @@
"edit": "编辑用户",
"delete": "删除用户",
"create": "创建",
"update": "用户",
"update": "更新",
"username": "用户名",
"password": "密码",
"newPassword": "新密码",
@@ -450,5 +524,63 @@
"deleteConfirmation": "您确定要删除用户 '{{username}}' 吗?此操作无法撤消。",
"confirmDelete": "删除用户",
"deleteWarning": "您确定要删除用户 '{{username}}' 吗?此操作无法撤消。"
},
"api": {
"errors": {
"readonly": "演示环境无法修改数据",
"serverNameRequired": "服务器名称是必需的",
"serverConfigRequired": "服务器配置是必需的",
"serverConfigInvalid": "服务器配置必须包含 URL、OpenAPI 规范 URL 或模式,或者带参数的命令",
"serverTypeInvalid": "服务器类型必须是以下之一stdio、sse、streamable-http、openapi",
"urlRequiredForType": "{{type}} 服务器类型需要 URL",
"openapiSpecRequired": "openapi 服务器类型需要 OpenAPI 规范 URL 或模式",
"headersInvalidFormat": "请求头必须是对象格式",
"headersNotSupportedForStdio": "stdio 服务器类型不支持请求头",
"serverNotFound": "找不到服务器",
"failedToRemoveServer": "找不到服务器或删除失败",
"internalServerError": "服务器内部错误",
"failedToGetServers": "获取服务器信息失败",
"failedToGetServerSettings": "获取服务器设置失败",
"failedToGetServerConfig": "获取服务器配置失败",
"failedToSaveSettings": "保存设置失败",
"toolNameRequired": "服务器名称和工具名称是必需的",
"descriptionMustBeString": "描述必须是字符串",
"groupIdRequired": "分组 ID 是必需的",
"groupNameRequired": "分组名称是必需的",
"groupNotFound": "找不到分组",
"groupIdAndServerNameRequired": "分组 ID 和服务器名称是必需的",
"groupOrServerNotFound": "找不到分组或服务器",
"toolsMustBeAllOrArray": "工具必须是 \"all\" 或字符串数组",
"serverNameAndToolNameRequired": "服务器名称和工具名称是必需的",
"usernameRequired": "用户名是必需的",
"userNotFound": "找不到用户",
"failedToGetUsers": "获取用户信息失败",
"failedToGetUserInfo": "获取用户信息失败",
"failedToGetUserStats": "获取用户统计信息失败",
"marketServerNameRequired": "服务器名称是必需的",
"marketServerNotFound": "找不到市场服务器",
"failedToGetMarketServers": "获取市场服务器信息失败",
"failedToGetMarketServer": "获取市场服务器信息失败",
"failedToGetMarketCategories": "获取市场类别失败",
"failedToGetMarketTags": "获取市场标签失败",
"failedToSearchMarketServers": "搜索市场服务器失败",
"failedToFilterMarketServers": "过滤市场服务器失败",
"failedToProcessDxtFile": "处理 DXT 文件失败"
},
"success": {
"serverCreated": "服务器创建成功",
"serverUpdated": "服务器更新成功",
"serverRemoved": "服务器删除成功",
"serverToggled": "服务器状态切换成功",
"toolToggled": "工具 {{name}} {{action}} 成功",
"toolDescriptionUpdated": "工具 {{name}} 描述更新成功",
"systemConfigUpdated": "系统配置更新成功",
"groupCreated": "分组创建成功",
"groupUpdated": "分组更新成功",
"groupDeleted": "分组删除成功",
"serverAddedToGroup": "服务器添加到分组成功",
"serverRemovedFromGroup": "服务器从分组移除成功",
"serverToolsUpdated": "服务器工具更新成功"
}
}
}

View File

@@ -57,6 +57,7 @@
"dotenv-expand": "^12.0.2",
"express": "^4.21.2",
"express-validator": "^7.2.1",
"i18next-fs-backend": "^2.6.0",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.1",
"openai": "^4.103.0",
@@ -74,6 +75,7 @@
"@shadcn/ui": "^0.0.4",
"@swc/core": "^1.13.0",
"@swc/jest": "^0.2.39",
"@tailwindcss/line-clamp": "^0.4.4",
"@tailwindcss/postcss": "^4.1.3",
"@tailwindcss/vite": "^4.1.7",
"@types/bcryptjs": "^3.0.0",

20
pnpm-lock.yaml generated
View File

@@ -44,6 +44,9 @@ importers:
express-validator:
specifier: ^7.2.1
version: 7.2.1
i18next-fs-backend:
specifier: ^2.6.0
version: 2.6.0
jsonwebtoken:
specifier: ^9.0.2
version: 9.0.2
@@ -90,6 +93,9 @@ importers:
'@swc/jest':
specifier: ^0.2.39
version: 0.2.39(@swc/core@1.13.0)
'@tailwindcss/line-clamp':
specifier: ^0.4.4
version: 0.4.4(tailwindcss@4.1.11)
'@tailwindcss/postcss':
specifier: ^4.1.3
version: 4.1.11
@@ -1442,6 +1448,11 @@ packages:
'@swc/types@0.1.23':
resolution: {integrity: sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw==}
'@tailwindcss/line-clamp@0.4.4':
resolution: {integrity: sha512-5U6SY5z8N42VtrCrKlsTAA35gy2VSyYtHWCsg1H87NU1SXnEfekTVlrga9fzUDrrHcGi2Lb5KenUWb4lRQT5/g==}
peerDependencies:
tailwindcss: '>=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1'
'@tailwindcss/node@4.1.11':
resolution: {integrity: sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==}
@@ -2669,6 +2680,9 @@ packages:
i18next-browser-languagedetector@8.2.0:
resolution: {integrity: sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==}
i18next-fs-backend@2.6.0:
resolution: {integrity: sha512-3ZlhNoF9yxnM8pa8bWp5120/Ob6t4lVl1l/tbLmkml/ei3ud8IWySCHt2lrY5xWRlSU5D9IV2sm5bEbGuTqwTw==}
i18next@24.2.3:
resolution: {integrity: sha512-lfbf80OzkocvX7nmZtu7nSTNbrTYR52sLWxPtlXX1zAhVw8WEnFk4puUkCR4B1dNQwbSpEHHHemcZu//7EcB7A==}
peerDependencies:
@@ -5480,6 +5494,10 @@ snapshots:
dependencies:
'@swc/counter': 0.1.3
'@tailwindcss/line-clamp@0.4.4(tailwindcss@4.1.11)':
dependencies:
tailwindcss: 4.1.11
'@tailwindcss/node@4.1.11':
dependencies:
'@ampproject/remapping': 2.3.0
@@ -6902,6 +6920,8 @@ snapshots:
dependencies:
'@babel/runtime': 7.27.0
i18next-fs-backend@2.6.0: {}
i18next@24.2.3(typescript@5.8.3):
dependencies:
'@babel/runtime': 7.27.0

View File

@@ -1,6 +1,6 @@
import dotenv from 'dotenv';
import fs from 'fs';
import { McpSettings } from '../types/index.js';
import { McpSettings, IUser } from '../types/index.js';
import { getConfigFilePath } from '../utils/path.js';
import { getPackageVersion } from '../utils/version.js';
import { getDataService } from '../services/services.js';
@@ -13,6 +13,7 @@ const defaultConfig = {
initTimeout: process.env.INIT_TIMEOUT || 300000,
timeout: process.env.REQUEST_TIMEOUT || 60000,
basePath: process.env.BASE_PATH || '',
readonly: 'true' === process.env.READONLY || false,
mcpHubName: 'mcphub',
mcpHubVersion: getPackageVersion(),
};
@@ -53,14 +54,14 @@ export const loadOriginalSettings = (): McpSettings => {
}
};
export const loadSettings = (): McpSettings => {
return dataService.filterSettings!(loadOriginalSettings());
export const loadSettings = (user?: IUser): McpSettings => {
return dataService.filterSettings!(loadOriginalSettings(), user);
};
export const saveSettings = (settings: McpSettings): boolean => {
export const saveSettings = (settings: McpSettings, user?: IUser): boolean => {
const settingsPath = getSettingsPath();
try {
const mergedSettings = dataService.mergeSettings!(loadOriginalSettings(), settings);
const mergedSettings = dataService.mergeSettings!(loadOriginalSettings(), settings, user);
fs.writeFileSync(settingsPath, JSON.stringify(mergedSettings, null, 2), 'utf8');
// Update cache after successful save

View File

@@ -18,10 +18,17 @@ const TOKEN_EXPIRY = '24h';
// Login user
export const login = async (req: Request, res: Response): Promise<void> => {
// Get translation function from request
const t = (req as any).t;
// Validate request
const errors = validationResult(req);
if (!errors.isEmpty()) {
res.status(400).json({ success: false, errors: errors.array() });
res.status(400).json({
success: false,
message: t('api.errors.validation_failed'),
errors: errors.array(),
});
return;
}
@@ -32,7 +39,10 @@ export const login = async (req: Request, res: Response): Promise<void> => {
const user = findUserByUsername(username);
if (!user) {
res.status(401).json({ success: false, message: 'Invalid credentials' });
res.status(401).json({
success: false,
message: t('api.errors.invalid_credentials'),
});
return;
}
@@ -40,7 +50,10 @@ export const login = async (req: Request, res: Response): Promise<void> => {
const isPasswordValid = await verifyPassword(password, user.password);
if (!isPasswordValid) {
res.status(401).json({ success: false, message: 'Invalid credentials' });
res.status(401).json({
success: false,
message: t('api.errors.invalid_credentials'),
});
return;
}
@@ -56,6 +69,7 @@ export const login = async (req: Request, res: Response): Promise<void> => {
if (err) throw err;
res.json({
success: true,
message: t('api.success.login_successful'),
token,
user: {
username: user.username,
@@ -66,16 +80,26 @@ export const login = async (req: Request, res: Response): Promise<void> => {
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({ success: false, message: 'Server error' });
res.status(500).json({
success: false,
message: t('api.errors.server_error'),
});
}
};
// Register new user
export const register = async (req: Request, res: Response): Promise<void> => {
// Get translation function from request
const t = (req as any).t;
// Validate request
const errors = validationResult(req);
if (!errors.isEmpty()) {
res.status(400).json({ success: false, errors: errors.array() });
res.status(400).json({
success: false,
message: t('api.errors.validation_failed'),
errors: errors.array(),
});
return;
}

View File

@@ -0,0 +1,273 @@
import { Request, Response } from 'express';
import { ApiResponse, CloudServer, CloudTool } from '../types/index.js';
import {
getCloudServers,
getCloudServerByName,
getCloudServerTools,
callCloudServerTool,
getCloudCategories,
getCloudTags,
searchCloudServers,
filterCloudServersByCategory,
filterCloudServersByTag,
} from '../services/cloudService.js';
// Get all cloud market servers
export const getAllCloudServers = async (_: Request, res: Response): Promise<void> => {
try {
const cloudServers = await getCloudServers();
const response: ApiResponse<CloudServer[]> = {
success: true,
data: cloudServers,
};
res.json(response);
} catch (error) {
console.error('Error getting cloud market servers:', error);
const errorMessage =
error instanceof Error ? error.message : 'Failed to get cloud market servers';
res.status(500).json({
success: false,
message: errorMessage,
});
}
};
// Get a specific cloud market server by name
export const getCloudServer = async (req: Request, res: Response): Promise<void> => {
try {
const { name } = req.params;
if (!name) {
res.status(400).json({
success: false,
message: 'Server name is required',
});
return;
}
const server = await getCloudServerByName(name);
if (!server) {
res.status(404).json({
success: false,
message: 'Cloud server not found',
});
return;
}
// Fetch tools for this server
try {
const tools = await getCloudServerTools(server.server_key);
server.tools = tools;
} catch (toolError) {
console.warn(`Failed to fetch tools for server ${server.name}:`, toolError);
// Continue without tools
}
const response: ApiResponse<CloudServer> = {
success: true,
data: server,
};
res.json(response);
} catch (error) {
console.error('Error getting cloud market server:', error);
const errorMessage =
error instanceof Error ? error.message : 'Failed to get cloud market server';
res.status(500).json({
success: false,
message: errorMessage,
});
}
};
// Get all cloud market categories
export const getAllCloudCategories = async (_: Request, res: Response): Promise<void> => {
try {
const categories = await getCloudCategories();
const response: ApiResponse<string[]> = {
success: true,
data: categories,
};
res.json(response);
} catch (error) {
console.error('Error getting cloud market categories:', error);
const errorMessage =
error instanceof Error ? error.message : 'Failed to get cloud market categories';
res.status(500).json({
success: false,
message: errorMessage,
});
}
};
// Get all cloud market tags
export const getAllCloudTags = async (_: Request, res: Response): Promise<void> => {
try {
const tags = await getCloudTags();
const response: ApiResponse<string[]> = {
success: true,
data: tags,
};
res.json(response);
} catch (error) {
console.error('Error getting cloud market tags:', error);
const errorMessage = error instanceof Error ? error.message : 'Failed to get cloud market tags';
res.status(500).json({
success: false,
message: errorMessage,
});
}
};
// Search cloud market servers
export const searchCloudServersByQuery = async (req: Request, res: Response): Promise<void> => {
try {
const query = req.query.query as string;
if (!query || query.trim() === '') {
res.status(400).json({
success: false,
message: 'Search query is required',
});
return;
}
const servers = await searchCloudServers(query);
const response: ApiResponse<CloudServer[]> = {
success: true,
data: servers,
};
res.json(response);
} catch (error) {
console.error('Error searching cloud market servers:', error);
const errorMessage =
error instanceof Error ? error.message : 'Failed to search cloud market servers';
res.status(500).json({
success: false,
message: errorMessage,
});
}
};
// Get cloud market servers by category
export const getCloudServersByCategory = async (req: Request, res: Response): Promise<void> => {
try {
const { category } = req.params;
if (!category) {
res.status(400).json({
success: false,
message: 'Category is required',
});
return;
}
const servers = await filterCloudServersByCategory(category);
const response: ApiResponse<CloudServer[]> = {
success: true,
data: servers,
};
res.json(response);
} catch (error) {
console.error('Error getting cloud market servers by category:', error);
const errorMessage =
error instanceof Error ? error.message : 'Failed to get cloud market servers by category';
res.status(500).json({
success: false,
message: errorMessage,
});
}
};
// Get cloud market servers by tag
export const getCloudServersByTag = async (req: Request, res: Response): Promise<void> => {
try {
const { tag } = req.params;
if (!tag) {
res.status(400).json({
success: false,
message: 'Tag is required',
});
return;
}
const servers = await filterCloudServersByTag(tag);
const response: ApiResponse<CloudServer[]> = {
success: true,
data: servers,
};
res.json(response);
} catch (error) {
console.error('Error getting cloud market servers by tag:', error);
const errorMessage =
error instanceof Error ? error.message : 'Failed to get cloud market servers by tag';
res.status(500).json({
success: false,
message: errorMessage,
});
}
};
// Get tools for a specific cloud server
export const getCloudServerToolsList = async (req: Request, res: Response): Promise<void> => {
try {
const { serverName } = req.params;
if (!serverName) {
res.status(400).json({
success: false,
message: 'Server name is required',
});
return;
}
const tools = await getCloudServerTools(serverName);
const response: ApiResponse<CloudTool[]> = {
success: true,
data: tools,
};
res.json(response);
} catch (error) {
console.error('Error getting cloud server tools:', error);
const errorMessage =
error instanceof Error ? error.message : 'Failed to get cloud server tools';
res.status(500).json({
success: false,
message: errorMessage,
});
}
};
// Call a tool on a cloud server
export const callCloudTool = async (req: Request, res: Response): Promise<void> => {
try {
const { serverName, toolName } = req.params;
const { arguments: args } = req.body;
if (!serverName) {
res.status(400).json({
success: false,
message: 'Server name is required',
});
return;
}
if (!toolName) {
res.status(400).json({
success: false,
message: 'Tool name is required',
});
return;
}
const result = await callCloudServerTool(serverName, toolName, args || {});
const response: ApiResponse = {
success: true,
data: result,
};
res.json(response);
} catch (error) {
console.error('Error calling cloud server tool:', error);
const errorMessage =
error instanceof Error ? error.message : 'Failed to call cloud server tool';
res.status(500).json({
success: false,
message: errorMessage,
});
}
};

View File

@@ -1,6 +1,11 @@
import { Request, Response } from 'express';
import config from '../config/index.js';
import { loadSettings } from '../config/index.js';
import { getDataService } from '../services/services.js';
import { DataService } from '../services/dataService.js';
import { IUser } from '../types/index.js';
const dataService: DataService = getDataService();
/**
* Get runtime configuration for frontend
@@ -38,6 +43,15 @@ export const getPublicConfig = (req: Request, res: Response): void => {
try {
const settings = loadSettings();
const skipAuth = settings.systemConfig?.routing?.skipAuth || false;
let permissions = {};
if (skipAuth) {
const user: IUser = {
username: 'guest',
password: '',
isAdmin: true,
};
permissions = dataService.getPermissions(user);
}
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
@@ -47,6 +61,7 @@ export const getPublicConfig = (req: Request, res: Response): void => {
success: true,
data: {
skipAuth,
permissions,
},
});
} catch (error) {

View File

@@ -9,6 +9,9 @@ import {
deleteGroup,
addServerToGroup,
removeServerFromGroup,
getServerConfigInGroup,
getServerConfigsInGroup,
updateServerToolsInGroup,
} from '../services/groupService.js';
// Get all groups
@@ -153,7 +156,7 @@ export const updateExistingGroup = (req: Request, res: Response): void => {
}
};
// Update servers in a group (batch update)
// Update servers in a group (batch update) - supports both string[] and server config format
export const updateGroupServersBatch = (req: Request, res: Response): void => {
try {
const { id } = req.params;
@@ -170,11 +173,36 @@ export const updateGroupServersBatch = (req: Request, res: Response): void => {
if (!Array.isArray(servers)) {
res.status(400).json({
success: false,
message: 'Servers must be an array of server names',
message: 'Servers must be an array of server names or server configurations',
});
return;
}
// Validate server configurations if provided in new format
for (const server of servers) {
if (typeof server === 'object' && server !== null) {
if (!server.name || typeof server.name !== 'string') {
res.status(400).json({
success: false,
message: 'Each server configuration must have a valid name',
});
return;
}
if (
server.tools &&
server.tools !== 'all' &&
(!Array.isArray(server.tools) ||
!server.tools.every((tool: any) => typeof tool === 'string'))
) {
res.status(400).json({
success: false,
message: 'Tools must be "all" or an array of strings',
});
return;
}
}
}
const updatedGroup = updateGroupServers(id, servers);
if (!updatedGroup) {
res.status(404).json({
@@ -343,3 +371,112 @@ export const getGroupServers = (req: Request, res: Response): void => {
});
}
};
// Get server configurations in a group (including tool selections)
export const getGroupServerConfigs = (req: Request, res: Response): void => {
try {
const { id } = req.params;
if (!id) {
res.status(400).json({
success: false,
message: 'Group ID is required',
});
return;
}
const serverConfigs = getServerConfigsInGroup(id);
const response: ApiResponse = {
success: true,
data: serverConfigs,
};
res.json(response);
} catch (error) {
res.status(500).json({
success: false,
message: 'Failed to get group server configurations',
});
}
};
// Get specific server configuration in a group
export const getGroupServerConfig = (req: Request, res: Response): void => {
try {
const { id, serverName } = req.params;
if (!id || !serverName) {
res.status(400).json({
success: false,
message: 'Group ID and server name are required',
});
return;
}
const serverConfig = getServerConfigInGroup(id, serverName);
if (!serverConfig) {
res.status(404).json({
success: false,
message: 'Server not found in group',
});
return;
}
const response: ApiResponse = {
success: true,
data: serverConfig,
};
res.json(response);
} catch (error) {
res.status(500).json({
success: false,
message: 'Failed to get server configuration',
});
}
};
// Update tools for a specific server in a group
export const updateGroupServerTools = (req: Request, res: Response): void => {
try {
const { id, serverName } = req.params;
const { tools } = req.body;
if (!id || !serverName) {
res.status(400).json({
success: false,
message: 'Group ID and server name are required',
});
return;
}
// Validate tools parameter
if (
tools !== 'all' &&
(!Array.isArray(tools) || !tools.every((tool) => typeof tool === 'string'))
) {
res.status(400).json({
success: false,
message: 'Tools must be "all" or an array of strings',
});
return;
}
const updatedGroup = updateServerToolsInGroup(id, serverName, tools);
if (!updatedGroup) {
res.status(404).json({
success: false,
message: 'Group or server not found',
});
return;
}
const response: ApiResponse = {
success: true,
data: updatedGroup,
message: 'Server tools updated successfully',
};
res.json(response);
} catch (error) {
res.status(500).json({
success: false,
message: 'Internal server error',
});
}
};

View File

@@ -0,0 +1,33 @@
import { Request, Response } from 'express';
import { connected } from '../services/mcpService.js';
/**
* Health check endpoint
* Returns 200 OK when all MCPs are loaded and connected
* Returns 503 Service Unavailable when MCPs are not ready
*/
export const healthCheck = (_req: Request, res: Response): void => {
try {
const allConnected = connected();
if (allConnected) {
res.status(200).json({
status: 'healthy',
message: 'All enabled MCP servers are ready',
timestamp: new Date().toISOString(),
});
} else {
res.status(503).json({
status: 'unhealthy',
message: 'Not all enabled MCP servers are ready',
timestamp: new Date().toISOString(),
});
}
} catch (error) {
console.error('Health check error:', error);
res.status(500).json({
status: 'error',
message: 'Internal server error during health check',
timestamp: new Date().toISOString(),
});
}
};

View File

@@ -280,7 +280,7 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
const result = await addOrUpdateServer(name, config, true); // Allow override for updates
if (result.success) {
notifyToolChanged();
notifyToolChanged(name);
res.json({
success: true,
message: 'Server updated successfully',
@@ -505,7 +505,8 @@ export const updateToolDescription = async (req: Request, res: Response): Promis
export const updateSystemConfig = (req: Request, res: Response): void => {
try {
const { routing, install, smartRouting } = req.body;
const { routing, install, smartRouting, mcpRouter } = req.body;
const currentUser = (req as any).user;
if (
(!routing ||
@@ -523,7 +524,12 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
typeof smartRouting.dbUrl !== 'string' &&
typeof smartRouting.openaiApiBaseUrl !== 'string' &&
typeof smartRouting.openaiApiKey !== 'string' &&
typeof smartRouting.openaiApiEmbeddingModel !== 'string'))
typeof smartRouting.openaiApiEmbeddingModel !== 'string')) &&
(!mcpRouter ||
(typeof mcpRouter.apiKey !== 'string' &&
typeof mcpRouter.referer !== 'string' &&
typeof mcpRouter.title !== 'string' &&
typeof mcpRouter.baseUrl !== 'string'))
) {
res.status(400).json({
success: false,
@@ -554,6 +560,12 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
openaiApiKey: '',
openaiApiEmbeddingModel: '',
},
mcpRouter: {
apiKey: '',
referer: 'https://mcphub.app',
title: 'MCPHub',
baseUrl: 'https://api.mcprouter.to/v1',
},
};
}
@@ -585,6 +597,15 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
};
}
if (!settings.systemConfig.mcpRouter) {
settings.systemConfig.mcpRouter = {
apiKey: '',
referer: 'https://mcphub.app',
title: 'MCPHub',
baseUrl: 'https://api.mcprouter.to/v1',
};
}
if (routing) {
if (typeof routing.enableGlobalRoute === 'boolean') {
settings.systemConfig.routing.enableGlobalRoute = routing.enableGlobalRoute;
@@ -675,7 +696,22 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
needsSync = (!wasSmartRoutingEnabled && isNowEnabled) || (isNowEnabled && hasConfigChanged);
}
if (saveSettings(settings)) {
if (mcpRouter) {
if (typeof mcpRouter.apiKey === 'string') {
settings.systemConfig.mcpRouter.apiKey = mcpRouter.apiKey;
}
if (typeof mcpRouter.referer === 'string') {
settings.systemConfig.mcpRouter.referer = mcpRouter.referer;
}
if (typeof mcpRouter.title === 'string') {
settings.systemConfig.mcpRouter.title = mcpRouter.title;
}
if (typeof mcpRouter.baseUrl === 'string') {
settings.systemConfig.mcpRouter.baseUrl = mcpRouter.baseUrl;
}
}
if (saveSettings(settings, currentUser)) {
res.json({
success: true,
data: settings.systemConfig,

View File

@@ -9,9 +9,15 @@ import {
getUserCount,
getAdminCount,
} from '../services/userService.js';
import { loadSettings } from '../config/index.js';
// Admin permission check middleware function
const requireAdmin = (req: Request, res: Response): boolean => {
const settings = loadSettings();
if (settings.systemConfig?.routing?.skipAuth) {
return true;
}
const user = (req as any).user;
if (!user || !user.isAdmin) {
res.status(403).json({

View File

@@ -1,6 +1,7 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { loadSettings } from '../config/index.js';
import defaultConfig from '../config/index.js';
// Default secret key - in production, use an environment variable
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this';
@@ -18,8 +19,30 @@ const validateBearerAuth = (req: Request, routingConfig: any): boolean => {
return authHeader.substring(7) === routingConfig.bearerAuthKey;
};
const readonlyAllowPaths = ['/tools/call/'];
const checkReadonly = (req: Request): boolean => {
if (!defaultConfig.readonly) {
return true;
}
for (const path of readonlyAllowPaths) {
if (req.path.startsWith(defaultConfig.basePath + path)) {
return true;
}
}
return req.method === 'GET';
};
// Middleware to authenticate JWT token
export const auth = (req: Request, res: Response, next: NextFunction): void => {
const t = (req as any).t;
if (!checkReadonly(req)) {
res.status(403).json({ success: false, message: t('api.errors.readonly') });
return;
}
// Check if authentication is disabled globally
const routingConfig = loadSettings().systemConfig?.routing || {
enableGlobalRoute: true,

41
src/middlewares/i18n.ts Normal file
View File

@@ -0,0 +1,41 @@
import { Request, Response, NextFunction } from 'express';
import { getT } from '../utils/i18n.js';
/**
* i18n middleware to detect user language and attach translation function to request
*/
export const i18nMiddleware = (req: Request, res: Response, next: NextFunction) => {
// Detect language from various sources (prioritized)
const acceptLanguage = req.headers['accept-language'];
const customLanguageHeader = req.headers['x-language'] as string;
const languageFromQuery = req.query.lang as string;
// Default to English
let detectedLanguage = 'en';
// Priority order: query parameter > custom header > accept-language header
if (languageFromQuery) {
detectedLanguage = languageFromQuery;
} else if (customLanguageHeader) {
detectedLanguage = customLanguageHeader;
} else if (acceptLanguage) {
// Parse accept-language header and get primary language
const primaryLanguage = acceptLanguage.split(',')[0].split('-')[0].trim();
detectedLanguage = primaryLanguage;
}
// Normalize language code (ensure we support it)
const supportedLanguages = ['en', 'zh'];
if (!supportedLanguages.includes(detectedLanguage)) {
detectedLanguage = 'en'; // fallback to English
}
// Set language in request (using any type to avoid TypeScript issues)
(req as any).language = detectedLanguage;
// Get translation function for the detected language
const t = getT(detectedLanguage);
(req as any).t = t;
next();
};

View File

@@ -1,6 +1,7 @@
import express, { Request, Response, NextFunction } from 'express';
import { auth } from './auth.js';
import { userContextMiddleware } from './userContext.js';
import { i18nMiddleware } from './i18n.js';
import { initializeDefaultUser } from '../models/User.js';
import config from '../config/index.js';
@@ -18,6 +19,9 @@ export const errorHandler = (
};
export const initMiddlewares = (app: express.Application): void => {
// Apply i18n middleware first to detect language for all requests
app.use(i18nMiddleware);
// Serve static files from the dynamically determined frontend path
// Note: Static files will be handled by the server directly, not here
@@ -49,8 +53,8 @@ export const initMiddlewares = (app: express.Application): void => {
// Protect API routes with authentication middleware, but exclude auth endpoints
app.use(`${config.basePath}/api`, (req, res, next) => {
// Skip authentication for login and register endpoints
if (req.path === '/auth/login' || req.path === '/auth/register') {
// Skip authentication for login endpoint
if (req.path === '/auth/login') {
next();
} else {
// Apply authentication middleware first

View File

@@ -22,6 +22,9 @@ import {
removeServerFromExistingGroup,
getGroupServers,
updateGroupServersBatch,
getGroupServerConfigs,
getGroupServerConfig,
updateGroupServerTools,
} from '../controllers/groupController.js';
import {
getUsers,
@@ -40,16 +43,31 @@ import {
getMarketServersByCategory,
getMarketServersByTag,
} from '../controllers/marketController.js';
import {
getAllCloudServers,
getCloudServer,
getAllCloudCategories,
getAllCloudTags,
searchCloudServersByQuery,
getCloudServersByCategory,
getCloudServersByTag,
getCloudServerToolsList,
callCloudTool,
} from '../controllers/cloudController.js';
import { login, register, getCurrentUser, changePassword } from '../controllers/authController.js';
import { getAllLogs, clearLogs, streamLogs } from '../controllers/logController.js';
import { getRuntimeConfig, getPublicConfig } from '../controllers/configController.js';
import { callTool } from '../controllers/toolController.js';
import { uploadDxtFile, uploadMiddleware } from '../controllers/dxtController.js';
import { healthCheck } from '../controllers/healthController.js';
import { auth } from '../middlewares/auth.js';
const router = express.Router();
export const initRoutes = (app: express.Application): void => {
// Health check endpoint (no auth required, accessible at /health)
app.get('/health', healthCheck);
// API routes protected by auth middleware in middlewares/index.ts
router.get('/servers', getAllServers);
router.get('/settings', getAllSettings);
@@ -72,6 +90,10 @@ export const initRoutes = (app: express.Application): void => {
router.get('/groups/:id/servers', getGroupServers);
// New route for batch updating servers in a group
router.put('/groups/:id/servers/batch', updateGroupServersBatch);
// New routes for server configurations and tool management in groups
router.get('/groups/:id/server-configs', getGroupServerConfigs);
router.get('/groups/:id/server-configs/:serverName', getGroupServerConfig);
router.put('/groups/:id/server-configs/:serverName/tools', updateGroupServerTools);
// User management routes (admin only)
router.get('/users', getUsers);
@@ -96,6 +118,17 @@ export const initRoutes = (app: express.Application): void => {
router.get('/market/tags', getAllMarketTags);
router.get('/market/tags/:tag', getMarketServersByTag);
// Cloud Market routes
router.get('/cloud/servers', getAllCloudServers);
router.get('/cloud/servers/search', searchCloudServersByQuery);
router.get('/cloud/servers/:name', getCloudServer);
router.get('/cloud/categories', getAllCloudCategories);
router.get('/cloud/categories/:category', getCloudServersByCategory);
router.get('/cloud/tags', getAllCloudTags);
router.get('/cloud/tags/:tag', getCloudServersByTag);
router.get('/cloud/servers/:serverName/tools', getCloudServerToolsList);
router.post('/cloud/servers/:serverName/tools/:toolName/call', callCloudTool);
// Log routes
router.get('/logs', getAllLogs);
router.delete('/logs', clearLogs);

View File

@@ -5,6 +5,7 @@ import fs from 'fs';
import { initUpstreamServers, connected } from './services/mcpService.js';
import { initMiddlewares } from './middlewares/index.js';
import { initRoutes } from './routes/index.js';
import { initI18n } from './utils/i18n.js';
import {
handleSseConnection,
handleSseMessage,
@@ -31,6 +32,10 @@ export class AppServer {
async initialize(): Promise<void> {
try {
// Initialize i18n before other components
await initI18n();
console.log('i18n initialized successfully');
// Initialize default admin user if no users exist
await initializeDefaultUser();

View File

@@ -0,0 +1,102 @@
describe('Schema Cleanup Tests', () => {
describe('cleanInputSchema functionality', () => {
// Helper function to simulate the cleanInputSchema behavior
const cleanInputSchema = (schema: any): any => {
if (!schema || typeof schema !== 'object') {
return schema;
}
const cleanedSchema = { ...schema };
delete cleanedSchema.$schema;
return cleanedSchema;
};
test('should remove $schema field from inputSchema', () => {
const schemaWithDollarSchema = {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object',
properties: {
name: {
type: 'string',
description: 'Test property',
},
},
required: ['name'],
};
const cleanedSchema = cleanInputSchema(schemaWithDollarSchema);
expect(cleanedSchema).not.toHaveProperty('$schema');
expect(cleanedSchema.type).toBe('object');
expect(cleanedSchema.properties).toEqual({
name: {
type: 'string',
description: 'Test property',
},
});
expect(cleanedSchema.required).toEqual(['name']);
});
test('should handle null and undefined schemas', () => {
expect(cleanInputSchema(null)).toBe(null);
expect(cleanInputSchema(undefined)).toBe(undefined);
});
test('should handle non-object schemas', () => {
expect(cleanInputSchema('string')).toBe('string');
expect(cleanInputSchema(42)).toBe(42);
expect(cleanInputSchema(true)).toBe(true);
});
test('should preserve other properties while removing $schema', () => {
const complexSchema = {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object',
title: 'Test Schema',
description: 'A test schema',
properties: {
name: { type: 'string' },
age: { type: 'number' },
},
required: ['name'],
additionalProperties: false,
};
const cleanedSchema = cleanInputSchema(complexSchema);
expect(cleanedSchema).not.toHaveProperty('$schema');
expect(cleanedSchema.type).toBe('object');
expect(cleanedSchema.title).toBe('Test Schema');
expect(cleanedSchema.description).toBe('A test schema');
expect(cleanedSchema.properties).toEqual({
name: { type: 'string' },
age: { type: 'number' },
});
expect(cleanedSchema.required).toEqual(['name']);
expect(cleanedSchema.additionalProperties).toBe(false);
});
test('should handle schemas without $schema field', () => {
const schemaWithoutDollarSchema = {
type: 'object',
properties: {
name: { type: 'string' },
},
};
const cleanedSchema = cleanInputSchema(schemaWithoutDollarSchema);
expect(cleanedSchema).toEqual(schemaWithoutDollarSchema);
expect(cleanedSchema).not.toHaveProperty('$schema');
});
test('should handle empty objects', () => {
const emptySchema = {};
const cleanedSchema = cleanInputSchema(emptySchema);
expect(cleanedSchema).toEqual({});
expect(cleanedSchema).not.toHaveProperty('$schema');
});
});
});

View File

@@ -0,0 +1,273 @@
import axios, { AxiosRequestConfig } from 'axios';
import {
CloudServer,
CloudTool,
MCPRouterResponse,
MCPRouterListServersResponse,
MCPRouterListToolsResponse,
MCPRouterCallToolResponse,
} from '../types/index.js';
import { loadOriginalSettings } from '../config/index.js';
// MCPRouter API default base URL
const DEFAULT_MCPROUTER_API_BASE = 'https://api.mcprouter.to/v1';
// Get MCPRouter API config from system configuration
const getMCPRouterConfig = () => {
const settings = loadOriginalSettings();
const mcpRouterConfig = settings.systemConfig?.mcpRouter;
return {
apiKey: mcpRouterConfig?.apiKey || process.env.MCPROUTER_API_KEY || '',
referer: mcpRouterConfig?.referer || process.env.MCPROUTER_REFERER || 'https://mcphub.app',
title: mcpRouterConfig?.title || process.env.MCPROUTER_TITLE || 'MCPHub',
baseUrl:
mcpRouterConfig?.baseUrl || process.env.MCPROUTER_API_BASE || DEFAULT_MCPROUTER_API_BASE,
};
};
// Get axios config with MCPRouter headers
const getAxiosConfig = (): AxiosRequestConfig => {
const mcpRouterConfig = getMCPRouterConfig();
return {
headers: {
Authorization: mcpRouterConfig.apiKey ? `Bearer ${mcpRouterConfig.apiKey}` : '',
'HTTP-Referer': mcpRouterConfig.referer || 'https://mcphub.app',
'X-Title': mcpRouterConfig.title || 'MCPHub',
'Content-Type': 'application/json',
},
};
};
// List all available cloud servers
export const getCloudServers = async (): Promise<CloudServer[]> => {
try {
const axiosConfig = getAxiosConfig();
const mcpRouterConfig = getMCPRouterConfig();
const response = await axios.post<MCPRouterResponse<MCPRouterListServersResponse>>(
`${mcpRouterConfig.baseUrl}/list-servers`,
{},
axiosConfig,
);
const data = response.data;
if (data.code !== 0) {
throw new Error(data.message || 'Failed to fetch servers');
}
return data.data.servers || [];
} catch (error) {
console.error('Error fetching cloud market servers:', error);
throw error;
}
};
// Get a specific cloud server by name
export const getCloudServerByName = async (name: string): Promise<CloudServer | null> => {
try {
const servers = await getCloudServers();
return servers.find((server) => server.name === name || server.config_name === name) || null;
} catch (error) {
console.error(`Error fetching cloud server ${name}:`, error);
throw error;
}
};
// List tools for a specific cloud server
export const getCloudServerTools = async (serverKey: string): Promise<CloudTool[]> => {
try {
const axiosConfig = getAxiosConfig();
const mcpRouterConfig = getMCPRouterConfig();
if (
!axiosConfig.headers?.['Authorization'] ||
axiosConfig.headers['Authorization'] === 'Bearer '
) {
throw new Error('MCPROUTER_API_KEY_NOT_CONFIGURED');
}
const response = await axios.post<MCPRouterResponse<MCPRouterListToolsResponse>>(
`${mcpRouterConfig.baseUrl}/list-tools`,
{
server: serverKey,
},
axiosConfig,
);
const data = response.data;
if (data.code !== 0) {
throw new Error(data.message || 'Failed to fetch tools');
}
return data.data.tools || [];
} catch (error) {
console.error(`Error fetching tools for server ${serverKey}:`, error);
throw error;
}
};
// Call a tool on a cloud server
export const callCloudServerTool = async (
serverName: string,
toolName: string,
args: Record<string, any>,
): Promise<MCPRouterCallToolResponse> => {
try {
const axiosConfig = getAxiosConfig();
const mcpRouterConfig = getMCPRouterConfig();
if (
!axiosConfig.headers?.['Authorization'] ||
axiosConfig.headers['Authorization'] === 'Bearer '
) {
throw new Error('MCPROUTER_API_KEY_NOT_CONFIGURED');
}
const response = await axios.post<MCPRouterResponse<MCPRouterCallToolResponse>>(
`${mcpRouterConfig.baseUrl}/call-tool`,
{
server: serverName,
name: toolName,
arguments: args,
},
axiosConfig,
);
const data = response.data;
if (data.code !== 0) {
throw new Error(data.message || 'Failed to call tool');
}
return data.data;
} catch (error) {
console.error(`Error calling tool ${toolName} on server ${serverName}:`, error);
throw error;
}
};
// Get all categories from cloud servers
export const getCloudCategories = async (): Promise<string[]> => {
try {
const servers = await getCloudServers();
const categories = new Set<string>();
servers.forEach((server) => {
// Extract categories from content or description
// This is a simple implementation, you might want to parse the content more sophisticatedly
if (server.content) {
const categoryMatches = server.content.match(/category[:\s]*([^,\n]+)/gi);
if (categoryMatches) {
categoryMatches.forEach((match) => {
const category = match.replace(/category[:\s]*/i, '').trim();
if (category) categories.add(category);
});
}
}
});
return Array.from(categories).sort();
} catch (error) {
console.error('Error fetching cloud market categories:', error);
throw error;
}
};
// Get all tags from cloud servers
export const getCloudTags = async (): Promise<string[]> => {
try {
const servers = await getCloudServers();
const tags = new Set<string>();
servers.forEach((server) => {
// Extract tags from content or description
if (server.content) {
const tagMatches = server.content.match(/tag[s]?[:\s]*([^,\n]+)/gi);
if (tagMatches) {
tagMatches.forEach((match) => {
const tag = match.replace(/tag[s]?[:\s]*/i, '').trim();
if (tag) tags.add(tag);
});
}
}
});
return Array.from(tags).sort();
} catch (error) {
console.error('Error fetching cloud market tags:', error);
throw error;
}
};
// Search cloud servers by query
export const searchCloudServers = async (query: string): Promise<CloudServer[]> => {
try {
const servers = await getCloudServers();
const searchTerms = query
.toLowerCase()
.split(' ')
.filter((term) => term.length > 0);
if (searchTerms.length === 0) {
return servers;
}
return servers.filter((server) => {
const searchText = [
server.name,
server.title,
server.description,
server.content,
server.author_name,
]
.join(' ')
.toLowerCase();
return searchTerms.some((term) => searchText.includes(term));
});
} catch (error) {
console.error('Error searching cloud market servers:', error);
throw error;
}
};
// Filter cloud servers by category
export const filterCloudServersByCategory = async (category: string): Promise<CloudServer[]> => {
try {
const servers = await getCloudServers();
if (!category) {
return servers;
}
return servers.filter((server) => {
const content = (server.content || '').toLowerCase();
return content.includes(category.toLowerCase());
});
} catch (error) {
console.error('Error filtering cloud market servers by category:', error);
throw error;
}
};
// Filter cloud servers by tag
export const filterCloudServersByTag = async (tag: string): Promise<CloudServer[]> => {
try {
const servers = await getCloudServers();
if (!tag) {
return servers;
}
return servers.filter((server) => {
const content = (server.content || '').toLowerCase();
return content.includes(tag.toLowerCase());
});
} catch (error) {
console.error('Error filtering cloud market servers by tag:', error);
throw error;
}
};

View File

@@ -2,9 +2,9 @@ import { IUser, McpSettings } from '../types/index.js';
export interface DataService {
foo(): void;
filterData(data: any[]): any[];
filterSettings(settings: McpSettings): McpSettings;
mergeSettings(all: McpSettings, newSettings: McpSettings): McpSettings;
filterData(data: any[], user?: IUser): any[];
filterSettings(settings: McpSettings, user?: IUser): McpSettings;
mergeSettings(all: McpSettings, newSettings: McpSettings, user?: IUser): McpSettings;
getPermissions(user: IUser): string[];
}
@@ -13,15 +13,15 @@ export class DataServiceImpl implements DataService {
console.log('default implementation');
}
filterData(data: any[]): any[] {
filterData(data: any[], _user?: IUser): any[] {
return data;
}
filterSettings(settings: McpSettings): McpSettings {
filterSettings(settings: McpSettings, _user?: IUser): McpSettings {
return settings;
}
mergeSettings(all: McpSettings, newSettings: McpSettings): McpSettings {
mergeSettings(all: McpSettings, newSettings: McpSettings, _user?: IUser): McpSettings {
return newSettings;
}

View File

@@ -1,9 +1,21 @@
import { v4 as uuidv4 } from 'uuid';
import { IGroup } from '../types/index.js';
import { IGroup, IGroupServerConfig } from '../types/index.js';
import { loadSettings, saveSettings } from '../config/index.js';
import { notifyToolChanged } from './mcpService.js';
import { getDataService } from './services.js';
// Helper function to normalize group servers configuration
const normalizeGroupServers = (servers: string[] | IGroupServerConfig[]): IGroupServerConfig[] => {
return servers.map((server) => {
if (typeof server === 'string') {
// Backward compatibility: string format means all tools
return { name: server, tools: 'all' };
}
// New format: ensure tools defaults to 'all' if not specified
return { name: server.name, tools: server.tools || 'all' };
});
};
// Get all groups
export const getAllGroups = (): IGroup[] => {
const settings = loadSettings();
@@ -32,7 +44,7 @@ export const getGroupByIdOrName = (key: string): IGroup | undefined => {
export const createGroup = (
name: string,
description?: string,
servers: string[] = [],
servers: string[] | IGroupServerConfig[] = [],
owner?: string,
): IGroup | null => {
try {
@@ -44,8 +56,11 @@ export const createGroup = (
return null;
}
// Filter out non-existent servers
const validServers = servers.filter((serverName) => settings.mcpServers[serverName]);
// Normalize servers configuration and filter out non-existent servers
const normalizedServers = normalizeGroupServers(servers);
const validServers: IGroupServerConfig[] = normalizedServers.filter(
(serverConfig) => settings.mcpServers[serverConfig.name],
);
const newGroup: IGroup = {
id: uuidv4(),
@@ -91,9 +106,12 @@ export const updateGroup = (id: string, data: Partial<IGroup>): IGroup | null =>
return null;
}
// If servers array is provided, validate server existence
// If servers array is provided, validate server existence and normalize format
if (data.servers) {
data.servers = data.servers.filter((serverName) => settings.mcpServers[serverName]);
const normalizedServers = normalizeGroupServers(data.servers);
data.servers = normalizedServers.filter(
(serverConfig) => settings.mcpServers[serverConfig.name],
);
}
const updatedGroup = {
@@ -116,7 +134,11 @@ export const updateGroup = (id: string, data: Partial<IGroup>): IGroup | null =>
};
// Update servers in a group (batch update)
export const updateGroupServers = (groupId: string, servers: string[]): IGroup | null => {
// Update group servers (maintaining backward compatibility)
export const updateGroupServers = (
groupId: string,
servers: string[] | IGroupServerConfig[],
): IGroup | null => {
try {
const settings = loadSettings();
if (!settings.groups) {
@@ -128,8 +150,11 @@ export const updateGroupServers = (groupId: string, servers: string[]): IGroup |
return null;
}
// Filter out non-existent servers
const validServers = servers.filter((serverName) => settings.mcpServers[serverName]);
// Normalize and filter out non-existent servers
const normalizedServers = normalizeGroupServers(servers);
const validServers = normalizedServers.filter(
(serverConfig) => settings.mcpServers[serverConfig.name],
);
settings.groups[groupIndex].servers = validServers;
@@ -186,10 +211,12 @@ export const addServerToGroup = (groupId: string, serverName: string): IGroup |
}
const group = settings.groups[groupIndex];
const normalizedServers = normalizeGroupServers(group.servers);
// Add server to group if not already in it
if (!group.servers.includes(serverName)) {
group.servers.push(serverName);
if (!normalizedServers.some((server) => server.name === serverName)) {
normalizedServers.push({ name: serverName, tools: 'all' });
group.servers = normalizedServers;
if (!saveSettings(settings)) {
return null;
@@ -218,7 +245,8 @@ export const removeServerFromGroup = (groupId: string, serverName: string): IGro
}
const group = settings.groups[groupIndex];
group.servers = group.servers.filter((name) => name !== serverName);
const normalizedServers = normalizeGroupServers(group.servers);
group.servers = normalizedServers.filter((server) => server.name !== serverName);
if (!saveSettings(settings)) {
return null;
@@ -234,5 +262,71 @@ export const removeServerFromGroup = (groupId: string, serverName: string): IGro
// Get all servers in a group
export const getServersInGroup = (groupId: string): string[] => {
const group = getGroupByIdOrName(groupId);
return group ? group.servers : [];
if (!group) return [];
const normalizedServers = normalizeGroupServers(group.servers);
return normalizedServers.map((server) => server.name);
};
// Get server configuration from group (including tool selection)
export const getServerConfigInGroup = (
groupId: string,
serverName: string,
): IGroupServerConfig | undefined => {
const group = getGroupByIdOrName(groupId);
if (!group) return undefined;
const normalizedServers = normalizeGroupServers(group.servers);
return normalizedServers.find((server) => server.name === serverName);
};
// Get all server configurations in a group
export const getServerConfigsInGroup = (groupId: string): IGroupServerConfig[] => {
const group = getGroupByIdOrName(groupId);
if (!group) return [];
return normalizeGroupServers(group.servers);
};
// Update tools selection for a specific server in a group
export const updateServerToolsInGroup = (
groupId: string,
serverName: string,
tools: string[] | 'all',
): IGroup | null => {
try {
const settings = loadSettings();
if (!settings.groups) {
return null;
}
const groupIndex = settings.groups.findIndex((group) => group.id === groupId);
if (groupIndex === -1) {
return null;
}
// Verify server exists
if (!settings.mcpServers[serverName]) {
return null;
}
const group = settings.groups[groupIndex];
const normalizedServers = normalizeGroupServers(group.servers);
const serverIndex = normalizedServers.findIndex((server) => server.name === serverName);
if (serverIndex === -1) {
return null; // Server not in group
}
// Update the tools configuration for the server
normalizedServers[serverIndex].tools = tools;
group.servers = normalizedServers;
if (!saveSettings(settings)) {
return null;
}
notifyToolChanged();
return group;
} catch (error) {
console.error(`Failed to update tools for server ${serverName} in group ${groupId}:`, error);
return null;
}
};

View File

@@ -8,7 +8,7 @@ import { ServerInfo, ServerConfig, ToolInfo } from '../types/index.js';
import { loadSettings, saveSettings, expandEnvVars, replaceEnvVars } from '../config/index.js';
import config from '../config/index.js';
import { getGroup } from './sseService.js';
import { getServersInGroup } from './groupService.js';
import { getServersInGroup, getServerConfigInGroup } from './groupService.js';
import { saveToolsAsVectorEmbeddings, searchToolsByVector } from './vectorSearchService.js';
import { OpenAPIClient } from '../clients/openapi.js';
import { getDataService } from './services.js';
@@ -70,8 +70,8 @@ export const deleteMcpServer = (sessionId: string): void => {
delete servers[sessionId];
};
export const notifyToolChanged = async () => {
await registerAllTools(false);
export const notifyToolChanged = async (name?: string) => {
await registerAllTools(false, name);
Object.values(servers).forEach((server) => {
server
.sendToolListChanged()
@@ -99,12 +99,26 @@ export const syncToolEmbedding = async (serverName: string, toolName: string) =>
saveToolsAsVectorEmbeddings(serverName, [tool]);
};
// 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;
};
// Store all server information
let serverInfos: ServerInfo[] = [];
// Returns true if all servers are connected
// Returns true if all enabled servers are connected
export const connected = (): boolean => {
return serverInfos.every((serverInfo) => serverInfo.status === 'connected');
return serverInfos
.filter((serverInfo) => serverInfo.enabled !== false)
.every((serverInfo) => serverInfo.status === 'connected');
};
// Global cleanup function to close all connections
@@ -272,7 +286,7 @@ const callToolWithReconnect = async (
serverInfo.tools = tools.tools.map((tool) => ({
name: `${serverInfo.name}-${tool.name}`,
description: tool.description || '',
inputSchema: tool.inputSchema || {},
inputSchema: cleanInputSchema(tool.inputSchema || {}),
}));
// Save tools as vector embeddings for search
@@ -311,7 +325,10 @@ const callToolWithReconnect = async (
};
// Initialize MCP server clients
export const initializeClientsFromSettings = async (isInit: boolean): Promise<ServerInfo[]> => {
export const initializeClientsFromSettings = async (
isInit: boolean,
serverName?: string,
): Promise<ServerInfo[]> => {
const settings = loadSettings();
const existingServerInfos = serverInfos;
serverInfos = [];
@@ -336,7 +353,7 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
const existingServer = existingServerInfos.find(
(s) => s.name === name && s.status === 'connected',
);
if (existingServer) {
if (existingServer && (!serverName || serverName !== name)) {
serverInfos.push({
...existingServer,
enabled: conf.enabled === undefined ? true : conf.enabled,
@@ -347,7 +364,6 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
let transport;
let openApiClient;
if (conf.type === 'openapi') {
// Handle OpenAPI type servers
if (!conf.openapi?.url && !conf.openapi?.schema) {
@@ -391,7 +407,7 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
const mcpTools: ToolInfo[] = openApiTools.map((tool) => ({
name: `${name}-${tool.name}`,
description: tool.description,
inputSchema: tool.inputSchema,
inputSchema: cleanInputSchema(tool.inputSchema),
}));
// Update server info with successful initialization
@@ -472,7 +488,7 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
serverInfo.tools = tools.tools.map((tool) => ({
name: `${name}-${tool.name}`,
description: tool.description || '',
inputSchema: tool.inputSchema || {},
inputSchema: cleanInputSchema(tool.inputSchema || {}),
}));
serverInfo.status = 'connected';
serverInfo.error = null;
@@ -505,8 +521,8 @@ export const initializeClientsFromSettings = async (isInit: boolean): Promise<Se
};
// Register all MCP tools
export const registerAllTools = async (isInit: boolean): Promise<void> => {
await initializeClientsFromSettings(isInit);
export const registerAllTools = async (isInit: boolean, serverName?: string): Promise<void> => {
await initializeClientsFromSettings(isInit, serverName);
};
// Get all server information
@@ -823,10 +839,22 @@ Available servers: ${serversList}`;
const allTools = [];
for (const serverInfo of allServerInfos) {
if (serverInfo.tools && serverInfo.tools.length > 0) {
// Filter tools based on server configuration and apply custom descriptions
const enabledTools = filterToolsByConfig(serverInfo.name, serverInfo.tools);
// Filter tools based on server configuration
let enabledTools = filterToolsByConfig(serverInfo.name, serverInfo.tools);
// Apply custom descriptions from configuration
// If this is a group request, apply group-level tool filtering
if (group) {
const serverConfig = getServerConfigInGroup(group, serverInfo.name);
if (serverConfig && serverConfig.tools !== 'all' && Array.isArray(serverConfig.tools)) {
// Filter tools based on group configuration
const allowedToolNames = serverConfig.tools.map(
(toolName) => `${serverInfo.name}-${toolName}`,
);
enabledTools = enabledTools.filter((tool) => allowedToolNames.includes(tool.name));
}
}
// Apply custom descriptions from server configuration
const settings = loadSettings();
const serverConfig = settings.mcpServers[serverInfo.name];
const toolsWithCustomDescriptions = enabledTools.map((tool) => {
@@ -912,7 +940,7 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
return {
name: result.toolName,
description: result.description || '',
inputSchema: result.inputSchema || {},
inputSchema: cleanInputSchema(result.inputSchema || {}),
};
})
.filter((tool) => {

View File

@@ -13,18 +13,37 @@ const instances = new Map<string, unknown>();
export function registerService<T>(key: string, entry: Service<T>) {
// Try to load override immediately during registration
const overridePath = join(process.cwd(), 'src', 'services', key + 'x.ts');
try {
const require = createRequire(process.cwd());
const mod = require(overridePath);
const override = mod[key.charAt(0).toUpperCase() + key.slice(1) + 'x'];
if (typeof override === 'function') {
entry.override = override;
// Try multiple paths and file extensions in order
const serviceDirs = ['src/services', 'dist/services'];
const fileExts = ['.ts', '.js'];
const overrideFileName = key + 'x';
for (const serviceDir of serviceDirs) {
for (const fileExt of fileExts) {
const overridePath = join(process.cwd(), serviceDir, overrideFileName + fileExt);
try {
// Use createRequire with a stable path reference
const require = createRequire(join(process.cwd(), 'package.json'));
const mod = require(overridePath);
const override = mod[key.charAt(0).toUpperCase() + key.slice(1) + 'x'];
if (typeof override === 'function') {
entry.override = override;
break; // Found override, exit both loops
}
} catch (error) {
// Continue trying next path/extension combination
continue;
}
}
// If override was found, break out of outer loop too
if (entry.override) {
break;
}
} catch (error) {
// Silently ignore if override doesn't exist
}
console.log(`Service registered: ${key} with entry:`, entry);
registry.set(key, entry);
}

5
src/types/express.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
// Custom types for Express Request
export interface I18nRequest extends Request {
language?: string;
t: (key: string, options?: any) => string;
}

View File

@@ -17,10 +17,16 @@ export interface IGroup {
id: string; // Unique UUID for the group
name: string; // Display name of the group
description?: string; // Optional description of the group
servers: string[]; // Array of server names that belong to this group
servers: string[] | IGroupServerConfig[]; // Array of server names or server configurations that belong to this group
owner?: string; // Owner of the group, defaults to 'admin' user
}
// Server configuration within a group - supports tool selection
export interface IGroupServerConfig {
name: string; // Server name
tools?: string[] | 'all'; // Array of specific tool names to include, or 'all' for all tools (default: 'all')
}
// Market server types
export interface MarketServerRepository {
type: string;
@@ -75,6 +81,49 @@ export interface MarketServer {
is_official?: boolean;
}
// Cloud Market Server types (for MCPRouter API)
export interface CloudServer {
created_at: string;
updated_at: string;
name: string;
author_name: string;
title: string;
description: string;
content: string;
server_key: string;
config_name: string;
tools?: CloudTool[];
}
export interface CloudTool {
name: string;
description: string;
inputSchema: Record<string, any>;
}
// MCPRouter API Response types
export interface MCPRouterResponse<T = any> {
code: number;
message: string;
data: T;
}
export interface MCPRouterListServersResponse {
servers: CloudServer[];
}
export interface MCPRouterListToolsResponse {
tools: CloudTool[];
}
export interface MCPRouterCallToolResponse {
content: Array<{
type: string;
text: string;
}>;
isError: boolean;
}
export interface SystemConfig {
routing?: {
enableGlobalRoute?: boolean; // Controls whether the /sse endpoint without group is enabled
@@ -89,6 +138,12 @@ export interface SystemConfig {
baseUrl?: string; // Base URL for group card copy operations
};
smartRouting?: SmartRoutingConfig;
mcpRouter?: {
apiKey?: string; // MCPRouter API key for authentication
referer?: string; // Referer header for MCPRouter API requests
title?: string; // Title header for MCPRouter API requests
baseUrl?: string; // Base URL for MCPRouter API (default: https://api.mcprouter.to/v1)
};
}
export interface UserConfig {

41
src/utils/i18n.ts Normal file
View File

@@ -0,0 +1,41 @@
import i18n from 'i18next';
import Backend from 'i18next-fs-backend';
import path from 'path';
// Initialize i18n for backend
const initI18n = async () => {
return i18n.use(Backend).init({
lng: 'en', // default language
fallbackLng: 'en',
backend: {
// Path to translation files
loadPath: path.join(process.cwd(), 'locales', '{{lng}}.json'),
},
interpolation: {
escapeValue: false, // not needed for server side
},
// Enable debug mode for development
debug: false,
// Preload languages
preload: ['en', 'zh'],
// Use sync mode for server
initImmediate: false,
});
};
// Get translation function for a specific language
export const getT = (language?: string) => {
if (language && language !== i18n.language) {
i18n.changeLanguage(language);
}
return i18n.t.bind(i18n);
};
// Initialize and export
export { initI18n };
export default i18n;