Compare commits

...

41 Commits

Author SHA1 Message Date
samanhappy
2bef1fb0bd fix: update @modelcontextprotocol/sdk dependency to version 1.17.2 (#277) 2025-08-14 15:17:19 +08:00
samanhappy
bdb5b37cf5 Add API documentation (#275) 2025-08-14 14:12:15 +08:00
samanhappy
cbb3b15ba2 fix: standardize naming to MCPHub across documentation and UI (#271)
Co-authored-by: samanhappy@qq.com <my6051199>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-13 21:54:41 +08:00
samanhappy
77b423fbcc Refactor JWT secret management and enhance documentation (#270) 2025-08-11 19:09:33 +08:00
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
samanhappy
9304653c34 feat: enhance GroupCard with copy options for ID, URL, and JSON; update translations (#246)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-07-25 14:30:52 +08:00
dependabot[bot]
b5685b7010 chore(deps): bump axios from 1.10.0 to 1.11.0 (#245)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-24 10:39:17 +08:00
samanhappy
89c37b2f02 Enhance operation name generation in OpenAPIClient (#244) 2025-07-23 19:02:43 +08:00
Oven
c316cb896e fix: create when dxt upload path does not exist (#243) 2025-07-23 13:47:11 +08:00
samanhappy
bc3c8facfa feat: add replaceEnvVarsInArray function and integrate it into server transport configuration (#241)
Co-authored-by: samanhappy@qq.com <my6051199>
2025-07-22 23:24:04 +08:00
dependabot[bot]
69afb865c0 chore(deps): bump brace-expansion from 1.1.11 to 1.1.12 (#231)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-22 23:07:20 +08:00
dependabot[bot]
ba30d88840 chore(deps): bump multer from 2.0.1 to 2.0.2 (#229)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-22 23:06:24 +08:00
samanhappy
6d0d622bd8 feat: add permissions for contents and packages in build workflow (#238) 2025-07-22 10:05:16 +08:00
samanhappy
ab50c7e9eb feat: add conditional check for repository in build and npm publish workflows (#236)
Co-authored-by: samanhappy@qq.com <my6051199>
2025-07-22 08:56:50 +08:00
samanhappy
e507bea2e3 Refactor service registration and revert lazy loading implementation (#234) 2025-07-20 22:30:09 +08:00
samanhappy
0f00ad7200 feat: implement lazy loading for data service and enhance service registration (#233) 2025-07-20 21:37:43 +08:00
samanhappy
b0b0c93337 feat: enable immediate loading of service overrides during registration (#232) 2025-07-20 20:35:00 +08:00
samanhappy
20fd355b87 feat: enhance JSON serialization safety & add dxt upload limit (#230) 2025-07-20 19:18:10 +08:00
dependabot[bot]
4388084704 chore(deps): bump typeorm from 0.3.24 to 0.3.25 (#210)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-07 09:54:52 +08:00
dependabot[bot]
fe2535461d chore(deps-dev): bump @types/react-dom from 19.1.5 to 19.1.6 (#211)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-07 09:39:52 +08:00
dependabot[bot]
985598e529 chore(deps): bump pg from 8.16.0 to 8.16.3 (#212)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-05 15:24:37 +08:00
dependabot[bot]
b2b6d0588b chore(deps-dev): bump tailwindcss from 4.1.8 to 4.1.11 (#213)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-03 14:11:24 +08:00
dependabot[bot]
64628ee3ed chore(deps-dev): bump tsx from 4.19.4 to 4.20.3 (#214)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-02 18:04:25 +08:00
samanhappy
66d4142039 feat: add variable detection and confirmation dialogs in server forms (#205) 2025-06-29 22:23:42 +08:00
samanhappy
cf72295f99 Refactor UI components across multiple pages for improved styling and consistency (#204) 2025-06-29 22:01:00 +08:00
samanhappy
89f85c73ff fix: resolve race conditions in initializeClientsFromSettings (#201) 2025-06-28 22:11:14 +08:00
samanhappy
adabf1d92b feat:support DXT file server installation (#200) 2025-06-27 14:45:24 +08:00
163 changed files with 14910 additions and 8024 deletions

50
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,50 @@
# MCPHub Coding Instructions
## Project Overview
MCPHub is a TypeScript/Node.js MCP server management hub that provides unified access through HTTP endpoints.
**Core Components:**
- **Backend**: Express.js + TypeScript + ESM (`src/server.ts`)
- **Frontend**: React/Vite + Tailwind CSS (`frontend/`)
- **MCP Integration**: Connects multiple MCP servers (`src/services/mcpService.ts`)
## Development Environment
```bash
pnpm install
pnpm dev # Start both backend and frontend
pnpm backend:dev # Backend only
pnpm frontend:dev # Frontend only
```
## Project Conventions
### File Structure
- `src/services/` - Core business logic
- `src/controllers/` - HTTP request handlers
- `src/types/index.ts` - TypeScript type definitions
- `src/config/index.ts` - Configuration management
### Key Notes
- Use ESM modules: Import with `.js` extensions, not `.ts`
- Configuration file: `mcp_settings.json`
- Endpoint formats: `/mcp/{group|server}` and `/mcp/$smart`
- All code comments must be written in English
- Frontend uses i18n with resource files in `locales/` folder
- Server-side code should use appropriate abstraction layers for extensibility and replaceability
## Development Process
- For complex features, implement step by step and wait for confirmation before proceeding to the next step
- After implementing features, no separate summary documentation is needed - update README.md and README.zh.md as appropriate
### Development Entry Points
- **MCP Servers**: Modify `src/services/mcpService.ts`
- **API Endpoints**: Add routes in `src/routes/`, controllers in `src/controllers/`
- **Frontend Features**: Start from `frontend/src/pages/`
- **Testing**: Follow existing patterns in `tests/`

View File

@@ -8,6 +8,9 @@ on:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
matrix:
variant: ${{ startsWith(github.ref, 'refs/tags/') && fromJSON('["base", "full"]') || fromJSON('["base"]') }}
@@ -30,16 +33,27 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
if: endsWith(github.repository, 'mcphub')
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
if: endsWith(github.repository, 'mcphubx')
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: samanhappy/mcphub
images: |
${{ endsWith(github.repository, 'mcphub') && github.repository || '' }}
${{ endsWith(github.repository, 'mcphubx') && format('ghcr.io/{0}', github.repository) || '' }}
tags: |
type=raw,value=edge${{ matrix.variant == 'full' && '-full' || '' }},enable=${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
type=semver,pattern={{version}}${{ matrix.variant == 'full' && '-full' || '' }},enable=${{ startsWith(github.ref, 'refs/tags/') }}
@@ -48,6 +62,7 @@ jobs:
latest=false
- name: Build and Push Docker Image
if: endsWith(github.repository, 'mcphub') || endsWith(github.repository, 'mcphubx')
uses: docker/build-push-action@v5
with:
context: .

View File

@@ -7,6 +7,7 @@ on:
jobs:
publish-npm:
runs-on: ubuntu-latest
if: endsWith(github.repository, 'mcphub')
steps:
- name: Checkout
uses: actions/checkout@v4

2
.gitignore vendored
View File

@@ -24,3 +24,5 @@ yarn-error.log*
.vscode/
*.log
coverage/
data/

View File

@@ -2,12 +2,6 @@ FROM python:3.13-slim-bookworm AS base
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
# 添加 HTTP_PROXY 和 HTTPS_PROXY 环境变量
ARG HTTP_PROXY=""
ARG HTTPS_PROXY=""
ENV HTTP_PROXY=$HTTP_PROXY
ENV HTTPS_PROXY=$HTTPS_PROXY
RUN apt-get update && apt-get install -y curl gnupg git \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y nodejs \
@@ -15,12 +9,6 @@ RUN apt-get update && apt-get install -y curl gnupg git \
RUN npm install -g pnpm
ARG REQUEST_TIMEOUT=60000
ENV REQUEST_TIMEOUT=$REQUEST_TIMEOUT
ARG BASE_PATH=""
ENV BASE_PATH=$BASE_PATH
ENV PNPM_HOME=/usr/local/share/pnpm
ENV PATH=$PNPM_HOME:$PATH
RUN mkdir -p $PNPM_HOME && \

View File

@@ -6,6 +6,11 @@ MCPHub makes it easy to manage and scale multiple MCP (Model Context Protocol) s
![Dashboard Preview](assets/dashboard.png)
## 🌐 Live Demo & Docs
- **Documentation**: [docs.mcphubx.com](https://docs.mcphubx.com/)
- **Demo Environment**: [demo.mcphubx.com](https://demo.mcphubx.com/)
## 🚀 Features
- **Broadened MCP Server Support**: Seamlessly integrate any MCP server with minimal configuration.
@@ -57,7 +62,7 @@ Create a `mcp_settings.json` file to customize your server settings:
**Recommended**: Mount your custom config:
```bash
docker run -p 3000:3000 -v $(pwd)/mcp_settings.json:/app/mcp_settings.json samanhappy/mcphub
docker run -p 3000:3000 -v ./mcp_settings.json:/app/mcp_settings.json -v ./data:/app/data samanhappy/mcphub
```
or run with default settings:

View File

@@ -6,6 +6,11 @@ MCPHub 通过将多个 MCPModel Context Protocol服务器组织为灵活
![控制面板预览](assets/dashboard.zh.png)
## 🌐 在线文档与演示
- **文档地址**: [docs.mcphubx.com](https://docs.mcphubx.com/)
- **演示环境**: [demo.mcphubx.com](https://demo.mcphubx.com/)
## 🚀 功能亮点
- **广泛的 MCP 服务器支持**:无缝集成任何 MCP 服务器,配置简单。
@@ -57,7 +62,7 @@ MCPHub 通过将多个 MCPModel Context Protocol服务器组织为灵活
**推荐**:挂载自定义配置:
```bash
docker run -p 3000:3000 -v $(pwd)/mcp_settings.json:/app/mcp_settings.json samanhappy/mcphub
docker run -p 3000:3000 -v ./mcp_settings.json:/app/mcp_settings.json -v ./data:/app/data samanhappy/mcphub
```
或使用默认配置运行:

View File

@@ -48,11 +48,11 @@ MCPHub 已内置多个常用 MCP 服务如高德地图、GitHub、Slack、Fet
![配置高德地图](../assets/amap-edit.png)
点击保存后MCP Hub 将自动重启高德地图的 MCP 服务,使新配置生效。
点击保存后MCPHub 将自动重启高德地图的 MCP 服务,使新配置生效。
### 配置 MCP Hub SSE
### 配置 MCPHub SSE
MCP Hub 提供了单一聚合的 MCP Server SSE 端点:`http://localhost:3000/sse`,可在任意支持 MCP 的客户端中配置使用。这里我们选择开源的 Cherry Studio 进行演示。
MCPHub 提供了单一聚合的 MCP Server SSE 端点:`http://localhost:3000/sse`,可在任意支持 MCP 的客户端中配置使用。这里我们选择开源的 Cherry Studio 进行演示。
![配置 Cherry Studio](../assets/cherry-mcp.png)

147
docs/api-reference/auth.mdx Normal file
View File

@@ -0,0 +1,147 @@
---
title: "Authentication"
description: "Manage users and authentication."
---
import { Card, Cards } from 'mintlify';
<Card
title="POST /api/auth/login"
href="#login"
>
Log in to get a JWT token.
</Card>
<Card
title="POST /api/auth/register"
href="#register"
>
Register a new user.
</Card>
<Card
title="GET /api/auth/user"
href="#get-current-user"
>
Get the currently authenticated user.
</Card>
<Card
title="POST /api/auth/change-password"
href="#change-password"
>
Change the password for the current user.
</Card>
---
### Login
Authenticates a user and returns a JWT token along with user details.
- **Endpoint**: `/api/auth/login`
- **Method**: `POST`
- **Body**:
- `username` (string, required): The user's username.
- `password` (string, required): The user's password.
- **Request Example**:
```json
{
"username": "admin",
"password": "admin123"
}
```
- **Success Response**:
```json
{
"success": true,
"message": "Login successful",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {
"username": "admin",
"isAdmin": true,
"permissions": { ... }
}
}
```
---
### Register
Registers a new user and returns a JWT token.
- **Endpoint**: `/api/auth/register`
- **Method**: `POST`
- **Body**:
- `username` (string, required): The desired username.
- `password` (string, required): The desired password (must be at least 6 characters).
- `isAdmin` (boolean, optional): Whether the user should have admin privileges.
- **Request Example**:
```json
{
"username": "newuser",
"password": "password123",
"isAdmin": false
}
```
- **Success Response**:
```json
{
"success": true,
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {
"username": "newuser",
"isAdmin": false,
"permissions": { ... }
}
}
```
---
### Get Current User
Retrieves the profile of the currently authenticated user.
- **Endpoint**: `/api/auth/user`
- **Method**: `GET`
- **Authentication**: Bearer Token required.
- **Success Response**:
```json
{
"success": true,
"user": {
"username": "admin",
"isAdmin": true,
"permissions": { ... }
}
}
```
---
### Change Password
Allows the authenticated user to change their password.
- **Endpoint**: `/api/auth/change-password`
- **Method**: `POST`
- **Authentication**: Bearer Token required.
- **Body**:
- `currentPassword` (string, required): The user's current password.
- `newPassword` (string, required): The desired new password (must be at least 6 characters).
- **Request Example**:
```json
{
"currentPassword": "oldpassword",
"newPassword": "newpassword123"
}
```
- **Success Response**:
```json
{
"success": true,
"message": "Password updated successfully"
}
```

View File

@@ -0,0 +1,111 @@
---
title: "Config"
description: "Manage and retrieve system-wide configurations."
---
import { Card, Cards } from 'mintlify';
<Card title="PUT /api/system-config" href="#update-system-config">Update the main system configuration.</Card>
<Card title="GET /api/settings" href="#get-all-settings">Get all system settings, including servers and groups.</Card>
<Card title="GET /config" href="#get-runtime-config">Get public runtime configuration for the frontend.</Card>
<Card title="GET /public-config" href="#get-public-config">Get public configuration to check for auth skip.</Card>
---
### Update System Config
Updates various parts of the system configuration. You only need to provide the keys for the sections you want to update.
- **Endpoint**: `/api/system-config`
- **Method**: `PUT`
- **Body**: A JSON object containing one or more of the following top-level keys: `routing`, `install`, `smartRouting`, `mcpRouter`.
#### Routing Configuration (`routing`)
- `enableGlobalRoute` (boolean): Enable or disable the global `/api/mcp` route.
- `enableGroupNameRoute` (boolean): Enable or disable group-based routing (e.g., `/api/mcp/group/:groupName`).
- `enableBearerAuth` (boolean): Enable bearer token authentication for MCP routes.
- `bearerAuthKey` (string): The secret key to use for bearer authentication.
- `skipAuth` (boolean): If true, skips all authentication, making the instance public.
#### Install Configuration (`install`)
- `pythonIndexUrl` (string): The base URL of the Python Package Index (PyPI) to use for installations.
- `npmRegistry` (string): The URL of the npm registry to use for installations.
- `baseUrl` (string): The public base URL of this MCPHub instance.
#### Smart Routing Configuration (`smartRouting`)
- `enabled` (boolean): Enable or disable the Smart Routing feature.
- `dbUrl` (string): The database connection URL for storing embeddings.
- `openaiApiBaseUrl` (string): The base URL for the OpenAI-compatible API for generating embeddings.
- `openaiApiKey` (string): The API key for the embeddings service.
- `openaiApiEmbeddingModel` (string): The name of the embedding model to use.
#### MCP Router Configuration (`mcpRouter`)
- `apiKey` (string): The API key for the MCP Router service.
- `referer` (string): The referer header to use for MCP Router requests.
- `title` (string): The title to display for this instance on MCP Router.
- `baseUrl` (string): The base URL for the MCP Router API.
- **Request Example**:
```json
{
"routing": {
"skipAuth": true
},
"smartRouting": {
"enabled": true,
"dbUrl": "postgresql://user:pass@host:port/db"
}
}
```
---
### Get All Settings
Retrieves the entire settings object for the instance, including all server configurations, groups, and system settings. This is a comprehensive dump of the `mcp_settings.json` file.
- **Endpoint**: `/api/settings`
- **Method**: `GET`
---
### Get Runtime Config
Retrieves the essential runtime configuration required for the frontend application. This endpoint does not require authentication.
- **Endpoint**: `/config`
- **Method**: `GET`
- **Success Response**:
```json
{
"success": true,
"data": {
"basePath": "",
"version": "1.0.0",
"name": "MCPHub"
}
}
```
---
### Get Public Config
Retrieves public configuration, primarily to check if authentication is skipped. This allows the frontend to adjust its behavior accordingly before a user has logged in. This endpoint does not require authentication.
- **Endpoint**: `/public-config`
- **Method**: `GET`
- **Success Response**:
```json
{
"success": true,
"data": {
"skipAuth": false,
"permissions": {}
}
}
```

View File

@@ -1,4 +0,0 @@
---
title: 'Create Plant'
openapi: 'POST /plants'
---

View File

@@ -1,4 +0,0 @@
---
title: 'Delete Plant'
openapi: 'DELETE /plants/{id}'
---

View File

@@ -1,4 +0,0 @@
---
title: 'Get Plants'
openapi: 'GET /plants'
---

View File

@@ -1,4 +0,0 @@
---
title: 'New Plant'
openapi: 'WEBHOOK /plant/webhook'
---

View File

@@ -0,0 +1,212 @@
---
title: "Groups"
description: "Manage server groups to organize and route requests."
---
import { Card, Cards } from 'mintlify';
<Card title="GET /api/groups" href="#get-all-groups">Get a list of all groups.</Card>
<Card title="POST /api/groups" href="#create-a-new-group">Create a new group.</Card>
<Card title="GET /api/groups/:id" href="#get-a-group">Get details of a specific group.</Card>
<Card title="PUT /api/groups/:id" href="#update-a-group">Update an existing group.</Card>
<Card title="DELETE /api/groups/:id" href="#delete-a-group">Delete a group.</Card>
<Card title="POST /api/groups/:id/servers" href="#add-server-to-group">Add a server to a group.</Card>
<Card title="DELETE /api/groups/:id/servers/:serverName" href="#remove-server-from-group">Remove a server from a group.</Card>
<Card title="PUT /api/groups/:id/servers/batch" href="#batch-update-group-servers">Batch update servers in a group.</Card>
<Card title="GET /api/groups/:id/server-configs" href="#get-group-server-configs">Get detailed server configurations in a group.</Card>
<Card title="PUT /api/groups/:id/server-configs/:serverName/tools" href="#update-group-server-tools">Update tool selection for a server in a group.</Card>
---
### Get All Groups
Retrieves a list of all server groups.
- **Endpoint**: `/api/groups`
- **Method**: `GET`
- **Success Response**:
```json
{
"success": true,
"data": [
{
"id": "group-1",
"name": "My Group",
"description": "A collection of servers.",
"servers": ["server1", "server2"],
"owner": "admin"
}
]
}
```
---
### Create a New Group
Creates a new server group.
- **Endpoint**: `/api/groups`
- **Method**: `POST`
- **Body**:
- `name` (string, required): The name of the group.
- `description` (string, optional): A description for the group.
- `servers` (array of strings, optional): A list of server names to include in the group.
- **Request Example**:
```json
{
"name": "My New Group",
"description": "A description for the new group",
"servers": ["server1", "server2"]
}
```
---
### Get a Group
Retrieves details for a specific group by its ID or name.
- **Endpoint**: `/api/groups/:id`
- **Method**: `GET`
- **Parameters**:
- `:id` (string, required): The ID or name of the group.
---
### Update a Group
Updates an existing group's name, description, or server list.
- **Endpoint**: `/api/groups/:id`
- **Method**: `PUT`
- **Parameters**:
- `:id` (string, required): The ID or name of the group to update.
- **Body**:
- `name` (string, optional): The new name for the group.
- `description` (string, optional): The new description for the group.
- `servers` (array, optional): The new list of servers for the group. See [Batch Update Group Servers](#batch-update-group-servers) for format.
- **Request Example**:
```json
{
"name": "Updated Group Name",
"description": "Updated description"
}
```
---
### Delete a Group
Deletes a group by its ID or name.
- **Endpoint**: `/api/groups/:id`
- **Method**: `DELETE`
- **Parameters**:
- `:id` (string, required): The ID or name of the group to delete.
---
### Add Server to Group
Adds a single server to a group.
- **Endpoint**: `/api/groups/:id/servers`
- **Method**: `POST`
- **Parameters**:
- `:id` (string, required): The ID or name of the group.
- **Body**:
- `serverName` (string, required): The name of the server to add.
- **Request Example**:
```json
{
"serverName": "my-server"
}
```
---
### Remove Server from Group
Removes a single server from a group.
- **Endpoint**: `/api/groups/:id/servers/:serverName`
- **Method**: `DELETE`
- **Parameters**:
- `:id` (string, required): The ID or name of the group.
- `:serverName` (string, required): The name of the server to remove.
---
### Batch Update Group Servers
Replaces all servers in a group with a new list. The list can be simple strings or detailed configuration objects.
- **Endpoint**: `/api/groups/:id/servers/batch`
- **Method**: `PUT`
- **Parameters**:
- `:id` (string, required): The ID or name of the group.
- **Body**:
- `servers` (array, required): An array of server names (strings) or server configuration objects.
- **Request Example (Simple)**:
```json
{
"servers": ["server1", "server2"]
}
```
- **Request Example (Detailed)**:
```json
{
"servers": [
{ "name": "server1", "tools": "all" },
{ "name": "server2", "tools": ["toolA", "toolB"] }
]
}
```
---
### Get Group Server Configs
Retrieves the detailed configuration for all servers within a group, including which tools are enabled.
- **Endpoint**: `/api/groups/:id/server-configs`
- **Method**: `GET`
- **Parameters**:
- `:id` (string, required): The ID or name of the group.
- **Success Response**:
```json
{
"success": true,
"data": [
{
"name": "server1",
"tools": "all"
},
{
"name": "server2",
"tools": ["toolA", "toolB"]
}
]
}
```
---
### Update Group Server Tools
Updates the tool selection for a specific server within a group.
- **Endpoint**: `/api/groups/:id/server-configs/:serverName/tools`
- **Method**: `PUT`
- **Parameters**:
- `:id` (string, required): The ID or name of the group.
- `:serverName` (string, required): The name of the server to update.
- **Body**:
- `tools` (string or array of strings, required): Either the string `"all"` to enable all tools, or an array of tool names to enable specifically.
- **Request Example**:
```json
{
"tools": ["toolA", "toolC"]
}
```

View File

@@ -1,33 +1,13 @@
---
title: 'Introduction'
description: 'Example section for showcasing API endpoints'
title: "Introduction"
description: "Welcome to the MCPHub API documentation."
---
<Note>
If you're not looking to build API reference documentation, you can delete
this section by removing the api-reference folder.
</Note>
The MCPHub API provides a comprehensive set of endpoints to manage your MCP servers, groups, users, and more. The API is divided into two main categories:
## Welcome
- **MCP Endpoints**: These are the primary endpoints for interacting with your MCP servers. They provide a unified interface for sending requests to your servers and receiving responses in real-time.
- **Management API**: These endpoints are used for managing the MCPHub instance itself. This includes managing servers, groups, users, and system settings.
There are two ways to build API documentation: [OpenAPI](https://mintlify.com/docs/api-playground/openapi/setup) and [MDX components](https://mintlify.com/docs/api-playground/mdx/configuration). For the starter kit, we are using the following OpenAPI specification.
All API endpoints are available under the `/api` path. For example, the endpoint to get all servers is `/api/servers`.
<Card
title="Plant Store Endpoints"
icon="leaf"
href="https://github.com/mintlify/starter/blob/main/api-reference/openapi.json"
>
View the OpenAPI specification file
</Card>
## Authentication
All API endpoints are authenticated using Bearer tokens and picked up from the specification file.
```json
"security": [
{
"bearerAuth": []
}
]
```
Authentication is required for most Management API endpoints. See the [Authentication](/api-reference/auth) section for more details.

View File

@@ -0,0 +1,81 @@
---
title: "Logs"
description: "Access and manage server logs."
---
import { Card, Cards } from 'mintlify';
<Card
title="GET /api/logs"
href="#get-all-logs"
>
Get all logs.
</Card>
<Card
title="DELETE /api/logs"
href="#clear-logs"
>
Clear all logs.
</Card>
<Card
title="GET /api/logs/stream"
href="#stream-logs"
>
Stream logs in real-time.
</Card>
---
### Get All Logs
Retrieves all stored logs.
- **Endpoint**: `/api/logs`
- **Method**: `GET`
- **Success Response**:
```json
{
"success": true,
"data": [
{
"timestamp": "2023-10-27T10:00:00.000Z",
"level": "info",
"message": "Server started successfully.",
"service": "system"
}
]
}
```
---
### Clear Logs
Deletes all stored logs.
- **Endpoint**: `/api/logs`
- **Method**: `DELETE`
- **Success Response**:
```json
{
"success": true,
"message": "Logs cleared successfully"
}
```
---
### Stream Logs
Streams logs in real-time using Server-Sent Events (SSE). The connection will remain open, and new log entries will be sent as they occur.
- **Endpoint**: `/api/logs/stream`
- **Method**: `GET`
- **Response Format**: The stream sends events with a `data` field containing a JSON object. The first event has `type: 'initial'` and contains all historical logs. Subsequent events have `type: 'log'` and contain a single new log entry.
- **Example Event**:
```
data: {"type":"log","log":{"timestamp":"2023-10-27T10:00:05.000Z","level":"debug","message":"Processing request for /api/some-endpoint","service":"mcp-server"}}
```

View File

@@ -0,0 +1,33 @@
---
title: "MCP HTTP Endpoints"
description: "Connect to your MCP servers using the unified HTTP endpoint."
---
MCPHub provides a unified streamable HTTP interface for all your MCP servers. This allows you to send requests to any configured MCP server and receive responses in real-time.
### Unified Endpoint
This endpoint provides access to all enabled MCP servers.
- **Endpoint**: `http://localhost:3000/mcp`
- **Method**: `POST`
### Group-Specific Endpoint
For targeted access to specific server groups, use the group-based HTTP endpoint.
- **Endpoint**: `http://localhost:3000/mcp/{group}`
- **Method**: `POST`
- **Parameters**:
- `{group}`: The ID or name of the group.
### Server-Specific Endpoint
For direct access to individual servers, use the server-specific HTTP endpoint.
- **Endpoint**: `http://localhost:3000/mcp/{server}`
- **Method**: `POST`
- **Parameters**:
- `{server}`: The name of the server.
> **Note**: If a server name and group name are the same, the group will take precedence.

View File

@@ -0,0 +1,25 @@
---
title: "MCP SSE Endpoints (Deprecated)"
description: "Connect to your MCP servers using the SSE endpoint."
---
The SSE endpoint is deprecated and will be removed in a future version. Please use the [MCP HTTP Endpoints](/api-reference/mcp-http) instead.
### Unified Endpoint
- **Endpoint**: `http://localhost:3000/sse`
- **Method**: `GET`
### Group-Specific Endpoint
- **Endpoint**: `http://localhost:3000/sse/{group}`
- **Method**: `GET`
- **Parameters**:
- `{group}`: The ID or name of the group.
### Server-Specific Endpoint
- **Endpoint**: `http://localhost:3000/sse/{server}`
- **Method**: `GET`
- **Parameters**:
- `{server}`: The name of the server.

View File

@@ -0,0 +1,209 @@
---
title: "Servers"
description: "Manage your MCP servers."
---
import { Card, Cards } from 'mintlify';
<Card
title="GET /api/servers"
href="#get-all-servers"
>
Get a list of all MCP servers.
</Card>
<Card
title="POST /api/servers"
href="#create-a-new-server"
>
Create a new MCP server.
</Card>
<Card
title="PUT /api/servers/:name"
href="#update-a-server"
>
Update an existing MCP server.
</Card>
<Card
title="DELETE /api/servers/:name"
href="#delete-a-server"
>
Delete an MCP server.
</Card>
<Card
title="POST /api/servers/:name/toggle"
href="#toggle-a-server"
>
Toggle the enabled state of a server.
</Card>
<Card
title="POST /api/servers/:serverName/tools/:toolName/toggle"
href="#toggle-a-tool"
>
Toggle the enabled state of a tool.
</Card>
<Card
title="PUT /api/servers/:serverName/tools/:toolName/description"
href="#update-tool-description"
>
Update the description of a tool.
</Card>
---
### Get All Servers
Retrieves a list of all configured MCP servers, including their status and available tools.
- **Endpoint**: `/api/servers`
- **Method**: `GET`
- **Response**:
```json
{
"success": true,
"data": [
{
"name": "example-server",
"status": "connected",
"tools": [
{
"name": "tool1",
"description": "Description of tool 1"
}
],
"config": {
"type": "stdio",
"command": "node",
"args": ["server.js"]
}
}
]
}
```
---
### Create a New Server
Adds a new MCP server to the configuration.
- **Endpoint**: `/api/servers`
- **Method**: `POST`
- **Body**:
```json
{
"name": "my-new-server",
"config": {
"type": "stdio",
"command": "python",
"args": ["-u", "my_script.py"],
"owner": "admin"
}
}
```
- `name` (string, required): The unique name for the server.
- `config` (object, required): The server configuration object.
- `type` (string): `stdio`, `sse`, `streamable-http`, or `openapi`.
- `command` (string): Command to execute for `stdio` type.
- `args` (array of strings): Arguments for the command.
- `url` (string): URL for `sse`, `streamable-http`, or `openapi` types.
- `openapi` (object): OpenAPI configuration.
- `url` (string): URL to the OpenAPI schema.
- `schema` (object): The OpenAPI schema object itself.
- `headers` (object): Headers to send with requests for `sse`, `streamable-http`, and `openapi` types.
- `keepAliveInterval` (number): Keep-alive interval in milliseconds for `sse` type. Defaults to 60000.
- `owner` (string): The owner of the server. Defaults to the current user or 'admin'.
---
### Update a Server
Updates the configuration of an existing MCP server.
- **Endpoint**: `/api/servers/:name`
- **Method**: `PUT`
- **Parameters**:
- `:name` (string, required): The name of the server to update.
- **Body**:
```json
{
"config": {
"type": "stdio",
"command": "node",
"args": ["new_server.js"]
}
}
```
- `config` (object, required): The updated server configuration object. See "Create a New Server" for details.
---
### Delete a Server
Removes an MCP server from the configuration.
- **Endpoint**: `/api/servers/:name`
- **Method**: `DELETE`
- **Parameters**:
- `:name` (string, required): The name of the server to delete.
---
### Toggle a Server
Enables or disables an MCP server.
- **Endpoint**: `/api/servers/:name/toggle`
- **Method**: `POST`
- **Parameters**:
- `:name` (string, required): The name of the server to toggle.
- **Body**:
```json
{
"enabled": true
}
```
- `enabled` (boolean, required): `true` to enable the server, `false` to disable it.
---
### Toggle a Tool
Enables or disables a specific tool on a server.
- **Endpoint**: `/api/servers/:serverName/tools/:toolName/toggle`
- **Method**: `POST`
- **Parameters**:
- `:serverName` (string, required): The name of the server.
- `:toolName` (string, required): The name of the tool.
- **Body**:
```json
{
"enabled": true
}
```
- `enabled` (boolean, required): `true` to enable the tool, `false` to disable it.
---
### Update Tool Description
Updates the description of a specific tool.
- **Endpoint**: `/api/servers/:serverName/tools/:toolName/description`
- **Method**: `PUT`
- **Parameters**:
- `:serverName` (string, required): The name of the server.
- `:toolName` (string, required): The name of the tool.
- **Body**:
```json
{
"description": "New tool description"
}
```
- `description` (string, required): The new description for the tool.

View File

@@ -0,0 +1,29 @@
---
title: "Smart Routing"
description: "Intelligent tool discovery using vector semantic search."
---
Smart Routing is MCPHub's intelligent tool discovery system that uses vector semantic search to automatically find the most relevant tools for any given task.
### HTTP Endpoint
- **Endpoint**: `http://localhost:3000/mcp/$smart`
- **Method**: `POST`
### SSE Endpoint (Deprecated)
- **Endpoint**: `http://localhost:3000/sse/$smart`
- **Method**: `GET`
### How it Works
1. **Tool Indexing**: All MCP tools are automatically converted to vector embeddings and stored in PostgreSQL with pgvector.
2. **Semantic Search**: User queries are converted to vectors and matched against tool embeddings using cosine similarity.
3. **Intelligent Filtering**: Dynamic thresholds ensure relevant results without noise.
4. **Precise Execution**: Found tools can be directly executed with proper parameter validation.
### Setup Requirements
- PostgreSQL with pgvector extension
- OpenAI API key (or compatible embedding service)
- Enable Smart Routing in MCPHub settings

View File

@@ -11,261 +11,34 @@ MCPHub uses environment variables for configuration. This guide covers all avail
### Server Configuration
| Variable | Default | Description |
| ----------- | ------------- | ------------------------------------------------------------- |
| `PORT` | `3000` | Port number for the HTTP server |
| `HOST` | `0.0.0.0` | Host address to bind the server |
| `NODE_ENV` | `development` | Application environment (`development`, `production`, `test`) |
| `LOG_LEVEL` | `info` | Logging level (`error`, `warn`, `info`, `debug`) |
| Variable | Default | Description |
| --- | --- | --- |
| `PORT` | `3000` | Port number for the HTTP server |
| `INIT_TIMEOUT` | `300000` | Initial timeout for the application |
| `BASE_PATH` | `''` | The base path of the application |
| `READONLY` | `false` | Set to `true` to enable readonly mode |
| `MCPHUB_SETTING_PATH` | | Path to the MCPHub settings |
| `NODE_ENV` | `development` | Application environment (`development`, `production`, `test`) |
```env
PORT=3000
HOST=0.0.0.0
INIT_TIMEOUT=300000
BASE_PATH=/api
READONLY=true
MCPHUB_SETTING_PATH=/path/to/settings
NODE_ENV=production
LOG_LEVEL=info
```
### Database Configuration
| Variable | Default | Description |
| -------------- | ----------- | ---------------------------------- |
| `DATABASE_URL` | - | PostgreSQL connection string |
| `DB_HOST` | `localhost` | Database host |
| `DB_PORT` | `5432` | Database port |
| `DB_NAME` | `mcphub` | Database name |
| `DB_USER` | `mcphub` | Database username |
| `DB_PASSWORD` | - | Database password |
| `DB_SSL` | `false` | Enable SSL for database connection |
| `DB_POOL_MIN` | `2` | Minimum database pool size |
| `DB_POOL_MAX` | `10` | Maximum database pool size |
```env
# Option 1: Full connection string
DATABASE_URL=postgresql://username:password@localhost:5432/mcphub
# Option 2: Individual components
DB_HOST=localhost
DB_PORT=5432
DB_NAME=mcphub
DB_USER=mcphub
DB_PASSWORD=your-password
DB_SSL=false
```
## Authentication & Security
### JWT Configuration
| Variable | Default | Description |
| ------------------------ | ------- | ------------------------------------------- |
| `JWT_SECRET` | - | Secret key for JWT token signing (required) |
| `JWT_EXPIRES_IN` | `24h` | JWT token expiration time |
| `JWT_REFRESH_EXPIRES_IN` | `7d` | Refresh token expiration time |
| `JWT_ALGORITHM` | `HS256` | JWT signing algorithm |
| Variable | Default | Description |
| --- | --- | --- |
| `JWT_SECRET` | - | Secret key for JWT token signing (required) |
```env
JWT_SECRET=your-super-secret-key-change-this-in-production
JWT_EXPIRES_IN=24h
JWT_REFRESH_EXPIRES_IN=7d
```
### Session & Security
| Variable | Default | Description |
| ------------------- | ------- | ------------------------------- |
| `SESSION_SECRET` | - | Session encryption secret |
| `BCRYPT_ROUNDS` | `12` | bcrypt hashing rounds |
| `RATE_LIMIT_WINDOW` | `15` | Rate limiting window in minutes |
| `RATE_LIMIT_MAX` | `100` | Maximum requests per window |
| `CORS_ORIGIN` | `*` | Allowed CORS origins |
```env
SESSION_SECRET=your-session-secret
BCRYPT_ROUNDS=12
RATE_LIMIT_WINDOW=15
RATE_LIMIT_MAX=100
CORS_ORIGIN=https://your-domain.com,https://admin.your-domain.com
```
## External Services
### OpenAI Configuration
| Variable | Default | Description |
| ------------------------ | ------------------------ | -------------------------------- |
| `OPENAI_API_KEY` | - | OpenAI API key for smart routing |
| `OPENAI_MODEL` | `gpt-3.5-turbo` | OpenAI model for embeddings |
| `OPENAI_EMBEDDING_MODEL` | `text-embedding-ada-002` | Model for vector embeddings |
| `OPENAI_MAX_TOKENS` | `1000` | Maximum tokens per request |
| `OPENAI_TEMPERATURE` | `0.1` | Temperature for AI responses |
```env
OPENAI_API_KEY=sk-your-openai-api-key
OPENAI_MODEL=gpt-3.5-turbo
OPENAI_EMBEDDING_MODEL=text-embedding-ada-002
OPENAI_MAX_TOKENS=1000
OPENAI_TEMPERATURE=0.1
```
### Redis Configuration (Optional)
| Variable | Default | Description |
| ---------------- | ----------- | ----------------------- |
| `REDIS_URL` | - | Redis connection string |
| `REDIS_HOST` | `localhost` | Redis host |
| `REDIS_PORT` | `6379` | Redis port |
| `REDIS_PASSWORD` | - | Redis password |
| `REDIS_DB` | `0` | Redis database number |
| `REDIS_PREFIX` | `mcphub:` | Key prefix for Redis |
```env
# Option 1: Full connection string
REDIS_URL=redis://username:password@localhost:6379/0
# Option 2: Individual components
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=your-redis-password
REDIS_DB=0
REDIS_PREFIX=mcphub:
```
## MCP Server Configuration
### Default Settings
| Variable | Default | Description |
| ------------------- | ------------------- | -------------------------------------------- |
| `MCP_SETTINGS_FILE` | `mcp_settings.json` | Path to MCP settings file |
| `MCP_SERVERS_FILE` | `servers.json` | Path to servers configuration |
| `MCP_TIMEOUT` | `30000` | Default timeout for MCP operations (ms) |
| `MCP_MAX_RETRIES` | `3` | Maximum retry attempts for failed operations |
| `MCP_RESTART_DELAY` | `5000` | Delay before restarting failed servers (ms) |
```env
MCP_SETTINGS_FILE=./config/mcp_settings.json
MCP_SERVERS_FILE=./config/servers.json
MCP_TIMEOUT=30000
MCP_MAX_RETRIES=3
MCP_RESTART_DELAY=5000
```
### Smart Routing
| Variable | Default | Description |
| --------------------------- | ------- | -------------------------------- |
| `SMART_ROUTING_ENABLED` | `true` | Enable AI-powered smart routing |
| `SMART_ROUTING_THRESHOLD` | `0.7` | Similarity threshold for routing |
| `SMART_ROUTING_MAX_RESULTS` | `5` | Maximum tools to return |
| `VECTOR_CACHE_TTL` | `3600` | Vector cache TTL in seconds |
```env
SMART_ROUTING_ENABLED=true
SMART_ROUTING_THRESHOLD=0.7
SMART_ROUTING_MAX_RESULTS=5
VECTOR_CACHE_TTL=3600
```
## File Storage & Uploads
| Variable | Default | Description |
| -------------------- | ---------------- | ----------------------------------- |
| `UPLOAD_DIR` | `./uploads` | Directory for file uploads |
| `MAX_FILE_SIZE` | `10485760` | Maximum file size in bytes (10MB) |
| `ALLOWED_FILE_TYPES` | `image/*,text/*` | Allowed MIME types |
| `STORAGE_TYPE` | `local` | Storage type (`local`, `s3`, `gcs`) |
```env
UPLOAD_DIR=./data/uploads
MAX_FILE_SIZE=10485760
ALLOWED_FILE_TYPES=image/*,text/*,application/json
STORAGE_TYPE=local
```
### S3 Storage (Optional)
| Variable | Default | Description |
| ---------------------- | ----------- | ------------------ |
| `S3_BUCKET` | - | S3 bucket name |
| `S3_REGION` | `us-east-1` | S3 region |
| `S3_ACCESS_KEY_ID` | - | S3 access key |
| `S3_SECRET_ACCESS_KEY` | - | S3 secret key |
| `S3_ENDPOINT` | - | Custom S3 endpoint |
```env
S3_BUCKET=mcphub-uploads
S3_REGION=us-east-1
S3_ACCESS_KEY_ID=your-access-key
S3_SECRET_ACCESS_KEY=your-secret-key
```
## Monitoring & Logging
### Application Monitoring
| Variable | Default | Description |
| ------------------------ | ------- | ----------------------------- |
| `METRICS_ENABLED` | `true` | Enable metrics collection |
| `METRICS_PORT` | `9090` | Port for metrics endpoint |
| `HEALTH_CHECK_INTERVAL` | `30000` | Health check interval (ms) |
| `PERFORMANCE_MONITORING` | `false` | Enable performance monitoring |
```env
METRICS_ENABLED=true
METRICS_PORT=9090
HEALTH_CHECK_INTERVAL=30000
PERFORMANCE_MONITORING=true
```
### Logging Configuration
| Variable | Default | Description |
| ------------------ | ------------ | --------------------------------------- |
| `LOG_FORMAT` | `json` | Log format (`json`, `text`) |
| `LOG_FILE` | - | Log file path (if file logging enabled) |
| `LOG_MAX_SIZE` | `10m` | Maximum log file size |
| `LOG_MAX_FILES` | `5` | Maximum number of log files |
| `LOG_DATE_PATTERN` | `YYYY-MM-DD` | Date pattern for log rotation |
```env
LOG_FORMAT=json
LOG_FILE=./logs/mcphub.log
LOG_MAX_SIZE=10m
LOG_MAX_FILES=5
LOG_DATE_PATTERN=YYYY-MM-DD
```
## Development & Debug
| Variable | Default | Description |
| ------------------------ | ------- | ----------------------------------- |
| `DEBUG` | - | Debug namespaces (e.g., `mcphub:*`) |
| `DEV_TOOLS_ENABLED` | `false` | Enable development tools |
| `HOT_RELOAD` | `true` | Enable hot reload in development |
| `MOCK_EXTERNAL_SERVICES` | `false` | Mock external API calls |
```env
DEBUG=mcphub:*
DEV_TOOLS_ENABLED=true
HOT_RELOAD=true
MOCK_EXTERNAL_SERVICES=false
```
## Production Optimization
| Variable | Default | Description |
| ------------------ | ------- | -------------------------------------- |
| `CLUSTER_MODE` | `false` | Enable cluster mode |
| `WORKER_PROCESSES` | `0` | Number of worker processes (0 = auto) |
| `MEMORY_LIMIT` | - | Memory limit per process |
| `CPU_LIMIT` | - | CPU limit per process |
| `GC_OPTIMIZE` | `false` | Enable garbage collection optimization |
```env
CLUSTER_MODE=true
WORKER_PROCESSES=4
MEMORY_LIMIT=512M
GC_OPTIMIZE=true
```
## Configuration Examples
@@ -276,22 +49,9 @@ GC_OPTIMIZE=true
# .env.development
NODE_ENV=development
PORT=3000
LOG_LEVEL=debug
# Database
DATABASE_URL=postgresql://mcphub:password@localhost:5432/mcphub_dev
# Auth
JWT_SECRET=dev-secret-key
JWT_EXPIRES_IN=24h
# OpenAI (optional for development)
# OPENAI_API_KEY=your-dev-key
# Debug
DEBUG=mcphub:*
DEV_TOOLS_ENABLED=true
HOT_RELOAD=true
```
### Production Environment
@@ -300,30 +60,9 @@ HOT_RELOAD=true
# .env.production
NODE_ENV=production
PORT=3000
LOG_LEVEL=info
LOG_FORMAT=json
# Database
DATABASE_URL=postgresql://mcphub:secure-password@db.example.com:5432/mcphub
DB_SSL=true
DB_POOL_MAX=20
# Security
JWT_SECRET=your-super-secure-production-secret
SESSION_SECRET=your-session-secret
BCRYPT_ROUNDS=14
# External Services
OPENAI_API_KEY=your-production-openai-key
REDIS_URL=redis://redis.example.com:6379
# Monitoring
METRICS_ENABLED=true
PERFORMANCE_MONITORING=true
# Optimization
CLUSTER_MODE=true
GC_OPTIMIZE=true
```
### Docker Environment
@@ -331,21 +70,10 @@ GC_OPTIMIZE=true
```env
# .env.docker
NODE_ENV=production
HOST=0.0.0.0
PORT=3000
# Use service names for Docker networking
DATABASE_URL=postgresql://mcphub:password@postgres:5432/mcphub
REDIS_URL=redis://redis:6379
# Security
JWT_SECRET_FILE=/run/secrets/jwt_secret
DB_PASSWORD_FILE=/run/secrets/db_password
# File paths in container
MCP_SETTINGS_FILE=/app/mcp_settings.json
UPLOAD_DIR=/app/data/uploads
LOG_FILE=/app/logs/mcphub.log
```
## Environment Variable Loading
@@ -364,7 +92,6 @@ MCPHub supports variable expansion:
```env
BASE_URL=https://api.example.com
API_ENDPOINT=${BASE_URL}/v1
DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}
```
## Security Best Practices
@@ -375,15 +102,3 @@ DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_N
4. **Use environment-specific files**
5. **Validate all environment variables** at startup
6. **Use Docker secrets** for container deployments
## Validation
MCPHub validates environment variables at startup. Invalid configurations will prevent the application from starting with helpful error messages.
Required variables for production:
- `JWT_SECRET`
- `DATABASE_URL` or individual DB components
- `OPENAI_API_KEY` (if smart routing is enabled)
This comprehensive environment configuration ensures MCPHub can be properly configured for any deployment scenario.

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,9 +27,7 @@
"pages": [
"features/server-management",
"features/group-management",
"features/smart-routing",
"features/authentication",
"features/monitoring"
"features/smart-routing"
]
},
{
@@ -40,14 +38,6 @@
"configuration/docker-setup",
"configuration/nginx"
]
},
{
"group": "Development",
"pages": [
"development/getting-started",
"development/architecture",
"development/contributing"
]
}
]
},
@@ -67,9 +57,7 @@
"pages": [
"zh/features/server-management",
"zh/features/group-management",
"zh/features/smart-routing",
"zh/features/authentication",
"zh/features/monitoring"
"zh/features/smart-routing"
]
},
{
@@ -80,19 +68,11 @@
"zh/configuration/docker-setup",
"zh/configuration/nginx"
]
},
{
"group": "开发指南",
"pages": [
"zh/development/getting-started",
"zh/development/architecture",
"zh/development/contributing"
]
}
]
},
{
"tab": "API Reference",
"tab": "API",
"groups": [
{
"group": "MCP Endpoints",
@@ -104,7 +84,7 @@
]
},
{
"group": "Management API",
"group": "Management Endpoints",
"pages": [
"api-reference/servers",
"api-reference/groups",
@@ -114,26 +94,34 @@
]
}
]
},
{
"tab": "接口",
"groups": [
{
"group": "MCP 端点",
"pages": [
"zh/api-reference/introduction",
"zh/api-reference/mcp-http",
"zh/api-reference/mcp-sse",
"zh/api-reference/smart-routing"
]
},
{
"group": "管理端点",
"pages": [
"zh/api-reference/servers",
"zh/api-reference/groups",
"zh/api-reference/auth",
"zh/api-reference/logs",
"zh/api-reference/config"
]
}
]
}
],
"global": {
"anchors": [
{
"anchor": "GitHub",
"href": "https://github.com/samanhappy/mcphub",
"icon": "github"
},
{
"anchor": "Discord",
"href": "https://discord.gg/qMKNsn5Q",
"icon": "discord"
},
{
"anchor": "Sponsor",
"href": "https://ko-fi.com/samanhappy",
"icon": "heart"
}
]
"anchors": []
}
},
"logo": {
@@ -144,13 +132,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

View File

@@ -0,0 +1,147 @@
---
title: "身份验证"
description: "管理用户和身份验证。"
---
import { Card, Cards } from 'mintlify';
<Card
title="POST /api/auth/login"
href="#login"
>
登录以获取 JWT 令牌。
</Card>
<Card
title="POST /api/auth/register"
href="#register"
>
注册一个新用户。
</Card>
<Card
title="GET /api/auth/user"
href="#get-current-user"
>
获取当前已验证的用户。
</Card>
<Card
title="POST /api/auth/change-password"
href="#change-password"
>
更改当前用户的密码。
</Card>
---
### 登录
验证用户身份并返回 JWT 令牌及用户详细信息。
- **端点**: `/api/auth/login`
- **方法**: `POST`
- **正文**:
- `username` (string, 必填): 用户名。
- `password` (string, 必填): 用户密码。
- **请求示例**:
```json
{
"username": "admin",
"password": "admin123"
}
```
- **成功响应**:
```json
{
"success": true,
"message": "登录成功",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {
"username": "admin",
"isAdmin": true,
"permissions": { ... }
}
}
```
---
### 注册
注册一个新用户并返回 JWT 令牌。
- **端点**: `/api/auth/register`
- **方法**: `POST`
- **正文**:
- `username` (string, 必填): 新的用户名。
- `password` (string, 必填): 新的用户密码 (至少6个字符)。
- `isAdmin` (boolean, 可选): 用户是否应有管理员权限。
- **请求示例**:
```json
{
"username": "newuser",
"password": "password123",
"isAdmin": false
}
```
- **成功响应**:
```json
{
"success": true,
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {
"username": "newuser",
"isAdmin": false,
"permissions": { ... }
}
}
```
---
### 获取当前用户
检索当前通过身份验证的用户的个人资料。
- **端点**: `/api/auth/user`
- **方法**: `GET`
- **身份验证**: 需要承载令牌 (Bearer Token)。
- **成功响应**:
```json
{
"success": true,
"user": {
"username": "admin",
"isAdmin": true,
"permissions": { ... }
}
}
```
---
### 更改密码
允许通过身份验证的用户更改其密码。
- **端点**: `/api/auth/change-password`
- **方法**: `POST`
- **身份验证**: 需要承载令牌 (Bearer Token)。
- **正文**:
- `currentPassword` (string, 必填): 用户的当前密码。
- `newPassword` (string, 必填): 新的密码 (至少6个字符)。
- **请求示例**:
```json
{
"currentPassword": "oldpassword",
"newPassword": "newpassword123"
}
```
- **成功响应**:
```json
{
"success": true,
"message": "密码更新成功"
}
```

View File

@@ -0,0 +1,111 @@
---
title: "配置"
description: "管理和检索系统级配置。"
---
import { Card, Cards } from 'mintlify';
<Card title="PUT /api/system-config" href="#update-system-config">更新主系统配置。</Card>
<Card title="GET /api/settings" href="#get-all-settings">获取所有系统设置,包括服务器和群组。</Card>
<Card title="GET /config" href="#get-runtime-config">获取前端的公共运行时配置。</Card>
<Card title="GET /public-config" href="#get-public-config">获取公共配置以检查是否跳过身份验证。</Card>
---
### 更新系统配置
更新系统配置的各个部分。您只需提供要更新部分的键。
- **端点**: `/api/system-config`
- **方法**: `PUT`
- **正文**: 一个 JSON 对象,包含以下一个或多个顶级键:`routing`、`install`、`smartRouting`、`mcpRouter`。
#### 路由配置 (`routing`)
- `enableGlobalRoute` (boolean): 启用或禁用全局 `/api/mcp` 路由。
- `enableGroupNameRoute` (boolean): 启用或禁用基于群组的路由 (例如 `/api/mcp/group/:groupName`)。
- `enableBearerAuth` (boolean): 为 MCP 路由启用承载令牌身份验证。
- `bearerAuthKey` (string): 用于承载身份验证的密钥。
- `skipAuth` (boolean): 如果为 true则跳过所有身份验证使实例公开。
#### 安装配置 (`install`)
- `pythonIndexUrl` (string): 用于安装的 Python 包索引 (PyPI) 的基础 URL。
- `npmRegistry` (string): 用于安装的 npm 注册表 URL。
- `baseUrl` (string): 此 MCPHub 实例的公共基础 URL。
#### 智能路由配置 (`smartRouting`)
- `enabled` (boolean): 启用或禁用智能路由功能。
- `dbUrl` (string): 用于存储嵌入的数据库连接 URL。
- `openaiApiBaseUrl` (string): 用于生成嵌入的 OpenAI 兼容 API 的基础 URL。
- `openaiApiKey` (string): 嵌入服务的 API 密钥。
- `openaiApiEmbeddingModel` (string): 要使用的嵌入模型的名称。
#### MCP 路由器配置 (`mcpRouter`)
- `apiKey` (string): MCP 路由器服务的 API 密钥。
- `referer` (string): 用于 MCP 路由器请求的 referer 头。
- `title` (string): 在 MCP 路由器上显示的此实例的标题。
- `baseUrl` (string): MCP 路由器 API 的基础 URL。
- **请求示例**:
```json
{
"routing": {
"skipAuth": true
},
"smartRouting": {
"enabled": true,
"dbUrl": "postgresql://user:pass@host:port/db"
}
}
```
---
### 获取所有设置
检索实例的整个设置对象,包括所有服务器配置、群组和系统设置。这是 `mcp_settings.json` 文件的完整转储。
- **端点**: `/api/settings`
- **方法**: `GET`
---
### 获取运行时配置
检索前端应用程序所需的基本运行时配置。此端点不需要身份验证。
- **端点**: `/config`
- **方法**: `GET`
- **成功响应**:
```json
{
"success": true,
"data": {
"basePath": "",
"version": "1.0.0",
"name": "MCPHub"
}
}
```
---
### 获取公共配置
检索公共配置,主要用于检查是否跳过身份验证。这允许前端在用户登录前相应地调整其行为。此端点不需要身份验证。
- **端点**: `/public-config`
- **方法**: `GET`
- **成功响应**:
```json
{
"success": true,
"data": {
"skipAuth": false,
"permissions": {}
}
}
```

View File

@@ -1,572 +0,0 @@
---
title: '创建资源'
description: '创建新的 MCP 服务器、用户和组'
---
## 创建服务器
### 端点
```http
POST /api/servers
```
### 请求
#### 请求头
```http
Content-Type: application/json
Authorization: Bearer YOUR_JWT_TOKEN
```
#### 请求体
```json
{
"name": "文件系统服务器",
"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,
"description": "提供文件系统访问的 MCP 服务器",
"tags": ["filesystem", "production"],
"healthCheck": {
"enabled": true,
"interval": 30000,
"timeout": 5000,
"retries": 3,
"endpoint": "/health"
},
"resources": {
"memory": {
"limit": "512MB",
"warning": "400MB"
},
"cpu": {
"limit": "50%"
}
},
"logging": {
"level": "info",
"file": "/var/log/mcphub/server.log",
"maxSize": "100MB",
"maxFiles": 5
}
}
```
#### 必填字段
- `name` (string): 服务器唯一名称
- `command` (string): 执行命令
- `args` (array): 命令参数数组
#### 可选字段
- `env` (object): 环境变量键值对
- `cwd` (string): 工作目录
- `timeout` (number): 超时时间(毫秒)
- `retries` (number): 重试次数
- `enabled` (boolean): 是否启用(默认 true
- `description` (string): 服务器描述
- `tags` (array): 标签数组
- `healthCheck` (object): 健康检查配置
- `resources` (object): 资源限制配置
- `logging` (object): 日志配置
### 响应
#### 成功响应 (201 Created)
```json
{
"success": true,
"data": {
"id": "server-abc123",
"name": "文件系统服务器",
"status": "stopped",
"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,
"description": "提供文件系统访问的 MCP 服务器",
"tags": ["filesystem", "production"],
"healthCheck": {
"enabled": true,
"interval": 30000,
"timeout": 5000,
"retries": 3,
"endpoint": "/health",
"status": "unknown"
},
"resources": {
"memory": {
"limit": "512MB",
"warning": "400MB",
"current": "0MB"
},
"cpu": {
"limit": "50%",
"current": "0%"
}
},
"logging": {
"level": "info",
"file": "/var/log/mcphub/server.log",
"maxSize": "100MB",
"maxFiles": 5,
"currentSize": "0MB"
},
"createdAt": "2024-01-01T12:00:00Z",
"updatedAt": "2024-01-01T12:00:00Z",
"createdBy": "user123"
},
"message": "服务器创建成功"
}
```
#### 错误响应
**400 Bad Request - 参数错误**
```json
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "请求数据验证失败",
"details": [
{
"field": "name",
"message": "服务器名称不能为空"
},
{
"field": "command",
"message": "执行命令不能为空"
}
]
}
}
```
**409 Conflict - 名称冲突**
```json
{
"success": false,
"error": {
"code": "RESOURCE_CONFLICT",
"message": "服务器名称已存在",
"details": {
"field": "name",
"value": "文件系统服务器",
"conflictingResourceId": "server-xyz789"
}
}
}
```
### 示例
#### cURL
```bash
curl -X POST http://localhost:3000/api/servers \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-d '{
"name": "文件系统服务器",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"],
"env": {
"NODE_ENV": "production"
},
"description": "生产环境文件系统服务器"
}'
```
#### JavaScript
```javascript
const response = await fetch('/api/servers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
name: '文件系统服务器',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-filesystem', '/data'],
env: {
NODE_ENV: 'production',
},
description: '生产环境文件系统服务器',
}),
});
const result = await response.json();
if (result.success) {
console.log('服务器创建成功:', result.data);
} else {
console.error('创建失败:', result.error);
}
```
#### Python
```python
import requests
response = requests.post(
'http://localhost:3000/api/servers',
headers={
'Content-Type': 'application/json',
'Authorization': f'Bearer {token}'
},
json={
'name': '文件系统服务器',
'command': 'npx',
'args': ['-y', '@modelcontextprotocol/server-filesystem', '/data'],
'env': {
'NODE_ENV': 'production'
},
'description': '生产环境文件系统服务器'
}
)
if response.status_code == 201:
result = response.json()
print('服务器创建成功:', result['data'])
else:
error = response.json()
print('创建失败:', error['error'])
```
## 创建用户
### 端点
```http
POST /api/users
```
### 请求体
```json
{
"username": "newuser",
"email": "user@example.com",
"password": "SecurePassword123!",
"role": "user",
"groups": ["dev-team", "qa-team"],
"profile": {
"firstName": "张",
"lastName": "三",
"department": "开发部",
"title": "软件工程师",
"phone": "+86-138-0013-8000",
"location": "北京"
},
"preferences": {
"language": "zh-CN",
"timezone": "Asia/Shanghai",
"notifications": {
"email": true,
"slack": false,
"browser": true
}
},
"enabled": true
}
```
### 响应 (201 Created)
```json
{
"success": true,
"data": {
"id": "user-abc123",
"username": "newuser",
"email": "user@example.com",
"role": "user",
"groups": [
{
"id": "dev-team",
"name": "开发团队",
"role": "member"
}
],
"profile": {
"firstName": "张",
"lastName": "三",
"fullName": "张三",
"department": "开发部",
"title": "软件工程师",
"phone": "+86-138-0013-8000",
"location": "北京",
"avatar": null
},
"preferences": {
"language": "zh-CN",
"timezone": "Asia/Shanghai",
"notifications": {
"email": true,
"slack": false,
"browser": true
}
},
"enabled": true,
"lastLoginAt": null,
"createdAt": "2024-01-01T12:00:00Z",
"updatedAt": "2024-01-01T12:00:00Z"
},
"message": "用户创建成功"
}
```
## 创建组
### 端点
```http
POST /api/groups
```
### 请求体
```json
{
"name": "dev-team",
"displayName": "开发团队",
"description": "前端和后端开发人员",
"parentGroup": null,
"permissions": {
"servers": {
"create": false,
"read": true,
"update": true,
"delete": false,
"execute": true
},
"tools": {
"filesystem": {
"read": true,
"write": true,
"paths": ["/app/data", "/tmp"]
},
"web-search": {
"enabled": true,
"maxQueries": 100
}
},
"monitoring": {
"viewLogs": true,
"viewMetrics": true,
"exportData": false
}
},
"settings": {
"autoAssign": false,
"maxMembers": 50,
"requireApproval": true,
"sessionTimeout": "8h"
},
"quotas": {
"requests": {
"daily": 1000,
"monthly": 30000
},
"storage": {
"maxSize": "10GB"
}
}
}
```
### 响应 (201 Created)
```json
{
"success": true,
"data": {
"id": "group-abc123",
"name": "dev-team",
"displayName": "开发团队",
"description": "前端和后端开发人员",
"parentGroup": null,
"permissions": {
"servers": {
"create": false,
"read": true,
"update": true,
"delete": false,
"execute": true
},
"tools": {
"filesystem": {
"read": true,
"write": true,
"paths": ["/app/data", "/tmp"]
},
"web-search": {
"enabled": true,
"maxQueries": 100
}
},
"monitoring": {
"viewLogs": true,
"viewMetrics": true,
"exportData": false
}
},
"settings": {
"autoAssign": false,
"maxMembers": 50,
"requireApproval": true,
"sessionTimeout": "8h"
},
"quotas": {
"requests": {
"daily": 1000,
"monthly": 30000
},
"storage": {
"maxSize": "10GB"
}
},
"memberCount": 0,
"serverCount": 0,
"createdAt": "2024-01-01T12:00:00Z",
"updatedAt": "2024-01-01T12:00:00Z",
"createdBy": "admin"
},
"message": "组创建成功"
}
```
## 批量创建
### 批量创建服务器
```http
POST /api/servers/bulk
```
#### 请求体
```json
{
"servers": [
{
"name": "dev-server-1",
"command": "python",
"args": ["-m", "mcp_server"],
"env": { "ENV": "development" }
},
{
"name": "dev-server-2",
"command": "node",
"args": ["server.js"],
"env": { "ENV": "development" }
}
],
"options": {
"skipExisting": true,
"validateAll": true,
"startAfterCreate": false
}
}
```
#### 响应 (201 Created)
```json
{
"success": true,
"data": {
"created": [
{
"id": "server-1",
"name": "dev-server-1",
"status": "created"
},
{
"id": "server-2",
"name": "dev-server-2",
"status": "created"
}
],
"skipped": [],
"failed": [],
"summary": {
"total": 2,
"created": 2,
"skipped": 0,
"failed": 0
}
},
"message": "批量创建完成,成功创建 2 个服务器"
}
```
## 验证
### 预验证创建请求
在实际创建资源之前验证请求:
```http
POST /api/servers/validate
```
#### 请求体
```json
{
"name": "test-server",
"command": "invalid-command",
"args": []
}
```
#### 响应
```json
{
"success": false,
"data": {
"valid": false,
"errors": [
{
"field": "command",
"message": "命令 'invalid-command' 不存在或无法执行"
}
],
"warnings": [
{
"field": "args",
"message": "参数数组为空,服务器可能无法正常启动"
}
]
}
}
```
有关更多 API 端点信息,请参阅 [获取资源](/zh/api-reference/endpoint/get)、[删除资源](/zh/api-reference/endpoint/delete) 和 [WebHooks](/zh/api-reference/endpoint/webhook) 文档。

View File

@@ -1,303 +0,0 @@
---
title: 删除资源 API
description: 删除各种资源的 API 端点,包括服务器、组和配置等
---
# 删除资源 API
本文档描述了用于删除各种资源的 API 端点。
## 删除 MCP 服务器
删除指定的 MCP 服务器配置。
### 端点
```http
DELETE /api/servers/{id}
```
### 参数
| 参数名 | 类型 | 位置 | 必需 | 描述 |
| ------ | ------ | ---- | ---- | ------------------ |
| id | string | path | 是 | 服务器的唯一标识符 |
### 请求示例
```bash
curl -X DELETE \
'https://api.mcphub.io/api/servers/mcp-server-123' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Content-Type: application/json'
```
### 响应
#### 成功响应 (204 No Content)
```json
{
"success": true,
"message": "服务器已成功删除",
"data": {
"id": "mcp-server-123",
"deletedAt": "2024-01-15T10:30:00Z"
}
}
```
#### 错误响应
**404 Not Found**
```json
{
"error": {
"code": "SERVER_NOT_FOUND",
"message": "指定的服务器不存在",
"details": {
"serverId": "mcp-server-123"
}
}
}
```
**409 Conflict**
```json
{
"error": {
"code": "SERVER_IN_USE",
"message": "服务器正在使用中,无法删除",
"details": {
"activeConnections": 5,
"associatedGroups": ["group-1", "group-2"]
}
}
}
```
## 删除服务器组
删除指定的服务器组。
### 端点
```http
DELETE /api/groups/{id}
```
### 参数
| 参数名 | 类型 | 位置 | 必需 | 描述 |
| ------ | ------- | ----- | ---- | ------------------------------ |
| id | string | path | 是 | 组的唯一标识符 |
| force | boolean | query | 否 | 是否强制删除(包含服务器的组) |
### 请求示例
```bash
curl -X DELETE \
'https://api.mcphub.io/api/groups/production-group?force=true' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Content-Type: application/json'
```
### 响应
#### 成功响应 (204 No Content)
```json
{
"success": true,
"message": "服务器组已成功删除",
"data": {
"id": "production-group",
"deletedServers": ["server-1", "server-2"],
"deletedAt": "2024-01-15T10:30:00Z"
}
}
```
## 删除配置项
删除指定的配置项。
### 端点
```http
DELETE /api/config/{key}
```
### 参数
| 参数名 | 类型 | 位置 | 必需 | 描述 |
| ------ | ------ | ---- | ---- | -------- |
| key | string | path | 是 | 配置键名 |
### 请求示例
```bash
curl -X DELETE \
'https://api.mcphub.io/api/config/custom-setting' \
-H 'Authorization: Bearer YOUR_API_TOKEN'
```
### 响应
#### 成功响应 (200 OK)
```json
{
"success": true,
"message": "配置项已删除",
"data": {
"key": "custom-setting",
"previousValue": "old-value",
"deletedAt": "2024-01-15T10:30:00Z"
}
}
```
## 批量删除
### 批量删除服务器
删除多个 MCP 服务器。
#### 端点
```http
DELETE /api/servers/batch
```
#### 请求体
```json
{
"serverIds": ["server-1", "server-2", "server-3"],
"force": false
}
```
#### 响应
```json
{
"success": true,
"message": "批量删除完成",
"data": {
"deleted": ["server-1", "server-3"],
"failed": [
{
"id": "server-2",
"reason": "服务器正在使用中"
}
],
"summary": {
"total": 3,
"deleted": 2,
"failed": 1
}
}
}
```
## 软删除 vs 硬删除
### 软删除
默认情况下MCPHub 使用软删除机制:
- 资源被标记为已删除但保留在数据库中
- 可以通过恢复 API 恢复删除的资源
- 删除的资源在列表 API 中默认不显示
### 硬删除
使用 `permanent=true` 参数执行硬删除:
```bash
curl -X DELETE \
'https://api.mcphub.io/api/servers/mcp-server-123?permanent=true' \
-H 'Authorization: Bearer YOUR_API_TOKEN'
```
<Warning>硬删除操作不可逆,请谨慎使用。</Warning>
## 权限要求
| 操作 | 所需权限 |
| ---------- | ------------------------ |
| 删除服务器 | `servers:delete` |
| 删除组 | `groups:delete` |
| 删除配置 | `config:delete` |
| 硬删除 | `admin:permanent_delete` |
## 错误代码
| 错误代码 | HTTP 状态码 | 描述 |
| -------------------------- | ----------- | ---------------- |
| `RESOURCE_NOT_FOUND` | 404 | 资源不存在 |
| `RESOURCE_IN_USE` | 409 | 资源正在使用中 |
| `INSUFFICIENT_PERMISSIONS` | 403 | 权限不足 |
| `VALIDATION_ERROR` | 400 | 请求参数验证失败 |
| `INTERNAL_ERROR` | 500 | 服务器内部错误 |
## 最佳实践
### 1. 删除前检查
在删除资源前,建议先检查资源的使用情况:
```bash
# 检查服务器使用情况
curl -X GET \
'https://api.mcphub.io/api/servers/mcp-server-123/usage' \
-H 'Authorization: Bearer YOUR_API_TOKEN'
```
### 2. 备份重要数据
对于重要资源,建议在删除前进行备份:
```bash
# 导出服务器配置
curl -X GET \
'https://api.mcphub.io/api/servers/mcp-server-123/export' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
> server-backup.json
```
### 3. 使用事务删除
对于复杂的删除操作,使用事务确保数据一致性:
```json
{
"transaction": true,
"operations": [
{
"type": "delete",
"resource": "server",
"id": "server-1"
},
{
"type": "delete",
"resource": "group",
"id": "group-1"
}
]
}
```
## 恢复删除的资源
软删除的资源可以通过恢复 API 恢复:
```bash
curl -X POST \
'https://api.mcphub.io/api/servers/mcp-server-123/restore' \
-H 'Authorization: Bearer YOUR_API_TOKEN'
```

View File

@@ -1,607 +0,0 @@
---
title: '获取资源'
description: '查询和检索 MCP 服务器、用户和组信息'
---
## 获取服务器列表
### 端点
```http
GET /api/servers
```
### 查询参数
| 参数 | 类型 | 描述 | 示例 |
| ---------------- | ------- | ------------------------------- | ---------------------------- |
| `page` | integer | 页码(从 1 开始) | `?page=2` |
| `limit` | integer | 每页记录数(默认 20最大 100 | `?limit=50` |
| `sort` | string | 排序字段 | `?sort=name` |
| `order` | string | 排序顺序asc/desc | `?order=desc` |
| `status` | string | 过滤服务器状态 | `?status=running` |
| `search` | string | 搜索服务器名称或描述 | `?search=python` |
| `group` | string | 过滤所属组 | `?group=dev-team` |
| `tags` | string | 过滤标签(逗号分隔) | `?tags=python,production` |
| `enabled` | boolean | 过滤启用状态 | `?enabled=true` |
| `created_after` | string | 创建时间起始 | `?created_after=2024-01-01` |
| `created_before` | string | 创建时间结束 | `?created_before=2024-01-31` |
### 响应
```json
{
"success": true,
"data": {
"items": [
{
"id": "server-abc123",
"name": "文件系统服务器",
"status": "running",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"],
"env": {
"NODE_ENV": "production"
},
"cwd": "/app",
"pid": 12345,
"uptime": 3600000,
"enabled": true,
"description": "提供文件系统访问的 MCP 服务器",
"tags": ["filesystem", "production"],
"health": {
"status": "healthy",
"lastCheck": "2024-01-01T12:00:00Z",
"responseTime": "45ms"
},
"resources": {
"memory": {
"used": "128MB",
"limit": "512MB",
"percentage": 25
},
"cpu": {
"used": "15%",
"limit": "50%"
}
},
"stats": {
"totalRequests": 1523,
"errorCount": 2,
"avgResponseTime": "234ms"
},
"lastRestart": "2024-01-01T08:00:00Z",
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T12:00:00Z"
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 45,
"pages": 3,
"hasNext": true,
"hasPrev": false
},
"filters": {
"status": "running",
"totalFiltered": 12
}
}
}
```
### 示例
```bash
# 获取运行中的服务器,按名称排序
curl -X GET "http://localhost:3000/api/servers?status=running&sort=name&order=asc" \
-H "Authorization: Bearer $TOKEN"
# 搜索包含 "python" 的服务器
curl -X GET "http://localhost:3000/api/servers?search=python&limit=10" \
-H "Authorization: Bearer $TOKEN"
# 获取开发团队的服务器
curl -X GET "http://localhost:3000/api/servers?group=dev-team" \
-H "Authorization: Bearer $TOKEN"
```
## 获取服务器详情
### 端点
```http
GET /api/servers/{serverId}
```
### 路径参数
- `serverId` (string): 服务器唯一标识符
### 查询参数
| 参数 | 类型 | 描述 |
| --------------- | ------ | ----------------------------------------------- |
| `include` | string | 包含额外信息(逗号分隔):`logs,metrics,events` |
| `metrics_range` | string | 指标时间范围:`1h`, `24h`, `7d` |
### 响应
```json
{
"success": true,
"data": {
"id": "server-abc123",
"name": "文件系统服务器",
"status": "running",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"],
"env": {
"NODE_ENV": "production",
"DEBUG": "mcp:*"
},
"cwd": "/app",
"pid": 12345,
"uptime": 3600000,
"enabled": true,
"description": "提供文件系统访问的 MCP 服务器",
"tags": ["filesystem", "production"],
"healthCheck": {
"enabled": true,
"interval": 30000,
"timeout": 5000,
"retries": 3,
"endpoint": "/health",
"status": "healthy",
"lastCheck": "2024-01-01T12:00:00Z",
"responseTime": "45ms",
"consecutiveFailures": 0
},
"resources": {
"memory": {
"used": "128MB",
"limit": "512MB",
"warning": "400MB",
"percentage": 25
},
"cpu": {
"used": "15%",
"limit": "50%",
"cores": 4
},
"network": {
"bytesIn": "1.2GB",
"bytesOut": "890MB"
}
},
"stats": {
"totalRequests": 1523,
"successfulRequests": 1521,
"errorCount": 2,
"avgResponseTime": "234ms",
"p95ResponseTime": "450ms",
"requestsPerMinute": 25,
"lastError": {
"timestamp": "2024-01-01T11:30:00Z",
"message": "Temporary connection timeout",
"count": 1
}
},
"capabilities": [
{
"type": "tool",
"name": "read_file",
"description": "读取文件内容",
"schema": {
"type": "object",
"properties": {
"path": { "type": "string" }
}
}
},
{
"type": "tool",
"name": "write_file",
"description": "写入文件内容",
"schema": {
"type": "object",
"properties": {
"path": { "type": "string" },
"content": { "type": "string" }
}
}
}
],
"groups": [
{
"id": "dev-team",
"name": "开发团队",
"permissions": ["read", "write", "execute"]
}
],
"events": [
{
"id": "event-123",
"type": "started",
"timestamp": "2024-01-01T08:00:00Z",
"message": "服务器启动成功",
"metadata": {
"pid": 12345,
"startupTime": "2.3s"
}
}
],
"lastRestart": "2024-01-01T08:00:00Z",
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T12:00:00Z",
"createdBy": "admin"
}
}
```
### 示例
```bash
# 获取服务器基本信息
curl -X GET "http://localhost:3000/api/servers/server-abc123" \
-H "Authorization: Bearer $TOKEN"
# 获取服务器详情包含日志和指标
curl -X GET "http://localhost:3000/api/servers/server-abc123?include=logs,metrics&metrics_range=24h" \
-H "Authorization: Bearer $TOKEN"
```
## 获取服务器状态
### 端点
```http
GET /api/servers/{serverId}/status
```
### 响应
```json
{
"success": true,
"data": {
"serverId": "server-abc123",
"status": "running",
"health": "healthy",
"pid": 12345,
"uptime": 3600000,
"startedAt": "2024-01-01T08:00:00Z",
"lastHealthCheck": "2024-01-01T12:00:00Z",
"resources": {
"memory": {
"rss": 134217728,
"heapTotal": 67108864,
"heapUsed": 45088768,
"external": 8388608
},
"cpu": {
"user": 1000000,
"system": 500000,
"percentage": 15.5
}
},
"connections": {
"active": 5,
"total": 127
},
"performance": {
"requestsPerSecond": 12.5,
"avgResponseTime": "234ms",
"errorRate": "0.1%"
}
}
}
```
## 获取服务器日志
### 端点
```http
GET /api/servers/{serverId}/logs
```
### 查询参数
| 参数 | 类型 | 描述 |
| -------- | ------- | ---------------------------------------------- |
| `level` | string | 日志级别过滤:`error`, `warn`, `info`, `debug` |
| `limit` | integer | 返回日志条数(默认 100最大 1000 |
| `since` | string | 开始时间ISO 8601 格式) |
| `until` | string | 结束时间ISO 8601 格式) |
| `follow` | boolean | 实时跟踪日志流 |
| `search` | string | 搜索日志内容 |
### 响应
```json
{
"success": true,
"data": {
"logs": [
{
"id": "log-123",
"timestamp": "2024-01-01T12:00:00Z",
"level": "info",
"message": "处理请求: read_file",
"source": "mcp-server",
"metadata": {
"requestId": "req-456",
"userId": "user-789",
"duration": "45ms"
}
},
{
"id": "log-124",
"timestamp": "2024-01-01T12:00:05Z",
"level": "error",
"message": "文件不存在: /nonexistent/file.txt",
"source": "filesystem",
"metadata": {
"requestId": "req-457",
"path": "/nonexistent/file.txt",
"error": "ENOENT"
}
}
],
"pagination": {
"limit": 100,
"total": 1523,
"hasMore": true,
"nextCursor": "cursor-abc123"
}
}
}
```
### 实时日志流
```bash
# 实时跟踪日志
curl -X GET "http://localhost:3000/api/servers/server-abc123/logs?follow=true" \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: text/event-stream"
```
## 获取服务器指标
### 端点
```http
GET /api/servers/{serverId}/metrics
```
### 查询参数
| 参数 | 类型 | 描述 |
| ------------- | ------ | ------------------------------------------- |
| `timeRange` | string | 时间范围:`1h`, `24h`, `7d`, `30d` |
| `granularity` | string | 数据粒度:`1m`, `5m`, `1h`, `1d` |
| `metrics` | string | 指定指标(逗号分隔):`cpu,memory,requests` |
### 响应
```json
{
"success": true,
"data": {
"timeRange": "1h",
"granularity": "5m",
"metrics": {
"cpu": {
"data": [
{ "timestamp": "2024-01-01T11:00:00Z", "value": 12.5 },
{ "timestamp": "2024-01-01T11:05:00Z", "value": 15.2 }
],
"summary": {
"avg": 13.8,
"min": 8.1,
"max": 18.5,
"current": 15.2
}
},
"memory": {
"data": [
{ "timestamp": "2024-01-01T11:00:00Z", "value": 125 },
{ "timestamp": "2024-01-01T11:05:00Z", "value": 128 }
],
"summary": {
"avg": 126.5,
"min": 120,
"max": 135,
"current": 128
}
},
"requests": {
"data": [
{ "timestamp": "2024-01-01T11:00:00Z", "value": 45 },
{ "timestamp": "2024-01-01T11:05:00Z", "value": 52 }
],
"summary": {
"total": 2847,
"avg": 48.5,
"peak": 67
}
},
"responseTime": {
"data": [
{ "timestamp": "2024-01-01T11:00:00Z", "avg": 230, "p95": 450 },
{ "timestamp": "2024-01-01T11:05:00Z", "avg": 245, "p95": 480 }
],
"summary": {
"avgResponseTime": "237ms",
"p95ResponseTime": "465ms"
}
}
}
}
}
```
## 获取用户列表
### 端点
```http
GET /api/users
```
### 查询参数
| 参数 | 类型 | 描述 |
| ------------------ | ------- | ---------------- |
| `role` | string | 过滤用户角色 |
| `group` | string | 过滤所属组 |
| `enabled` | boolean | 过滤启用状态 |
| `search` | string | 搜索用户名或邮箱 |
| `last_login_after` | string | 最后登录时间起始 |
### 响应
```json
{
"success": true,
"data": {
"items": [
{
"id": "user-abc123",
"username": "zhangsan",
"email": "zhangsan@example.com",
"role": "user",
"enabled": true,
"profile": {
"firstName": "张",
"lastName": "三",
"fullName": "张三",
"department": "开发部",
"title": "软件工程师"
},
"groups": [
{
"id": "dev-team",
"name": "开发团队",
"role": "member"
}
],
"stats": {
"totalSessions": 45,
"totalRequests": 1234,
"lastRequestAt": "2024-01-01T11:30:00Z"
},
"lastLoginAt": "2024-01-01T08:00:00Z",
"createdAt": "2023-12-01T00:00:00Z"
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 89,
"pages": 5
}
}
}
```
## 获取组列表
### 端点
```http
GET /api/groups
```
### 响应
```json
{
"success": true,
"data": {
"items": [
{
"id": "group-abc123",
"name": "dev-team",
"displayName": "开发团队",
"description": "前端和后端开发人员",
"memberCount": 12,
"serverCount": 8,
"parentGroup": null,
"children": [],
"permissions": {
"servers": ["read", "write", "execute"],
"tools": ["read", "execute"]
},
"quotas": {
"requests": {
"used": 750,
"limit": 1000
}
},
"createdAt": "2023-12-01T00:00:00Z"
}
]
}
}
```
## 搜索
### 全局搜索
```http
GET /api/search
```
### 查询参数
| 参数 | 类型 | 描述 |
| ------- | ------- | ---------------------------------------------- |
| `q` | string | 搜索关键词 |
| `type` | string | 资源类型:`servers`, `users`, `groups`, `logs` |
| `limit` | integer | 每种类型的最大结果数 |
### 响应
```json
{
"success": true,
"data": {
"query": "python",
"results": {
"servers": [
{
"id": "server-1",
"name": "Python MCP Server",
"type": "server",
"relevance": 0.95
}
],
"users": [],
"groups": [
{
"id": "python-devs",
"name": "Python 开发者",
"type": "group",
"relevance": 0.8
}
],
"logs": [
{
"id": "log-123",
"message": "Starting Python server...",
"type": "log",
"relevance": 0.7
}
]
},
"total": 3
}
}
```
有关更多信息,请参阅 [创建资源](/zh/api-reference/endpoint/create)、[删除资源](/zh/api-reference/endpoint/delete) 和 [WebHooks](/zh/api-reference/endpoint/webhook) 文档。

View File

@@ -1,615 +0,0 @@
---
title: WebHooks API
description: 配置和管理 WebHook 事件通知的完整指南
---
# WebHooks API
WebHooks 允许 MCPHub 在特定事件发生时向您的应用程序发送实时通知。
## 概述
MCPHub WebHooks 系统支持以下功能:
- 实时事件通知
- 自定义过滤器
- 重试机制
- 签名验证
- 批量事件处理
## 支持的事件类型
| 事件类型 | 描述 |
| ----------------------- | -------------- |
| `server.created` | MCP 服务器创建 |
| `server.updated` | MCP 服务器更新 |
| `server.deleted` | MCP 服务器删除 |
| `server.status_changed` | 服务器状态变更 |
| `group.created` | 服务器组创建 |
| `group.updated` | 服务器组更新 |
| `group.deleted` | 服务器组删除 |
| `user.login` | 用户登录 |
| `user.logout` | 用户登出 |
| `config.changed` | 配置变更 |
| `system.error` | 系统错误 |
## 创建 WebHook
### 端点
```http
POST /api/webhooks
```
### 请求体
```json
{
"url": "https://your-app.com/webhook",
"events": ["server.created", "server.status_changed"],
"secret": "your-webhook-secret",
"active": true,
"config": {
"contentType": "application/json",
"insecureSsl": false,
"retryCount": 3,
"timeout": 30
},
"filters": {
"serverGroups": ["production", "staging"],
"serverTypes": ["ai-assistant", "data-processor"]
}
}
```
### 响应
```json
{
"success": true,
"data": {
"id": "webhook-123",
"url": "https://your-app.com/webhook",
"events": ["server.created", "server.status_changed"],
"active": true,
"secret": "your-webhook-secret",
"config": {
"contentType": "application/json",
"insecureSsl": false,
"retryCount": 3,
"timeout": 30
},
"filters": {
"serverGroups": ["production", "staging"],
"serverTypes": ["ai-assistant", "data-processor"]
},
"createdAt": "2024-01-15T10:30:00Z",
"updatedAt": "2024-01-15T10:30:00Z"
}
}
```
## 获取 WebHook 列表
### 端点
```http
GET /api/webhooks
```
### 查询参数
| 参数名 | 类型 | 描述 |
| ------ | ------- | -------------------- |
| page | integer | 页码默认1 |
| limit | integer | 每页数量默认20 |
| active | boolean | 过滤活跃状态 |
| event | string | 过滤事件类型 |
### 请求示例
```bash
curl -X GET \
'https://api.mcphub.io/api/webhooks?active=true&limit=10' \
-H 'Authorization: Bearer YOUR_API_TOKEN'
```
### 响应
```json
{
"success": true,
"data": {
"webhooks": [
{
"id": "webhook-123",
"url": "https://your-app.com/webhook",
"events": ["server.created", "server.status_changed"],
"active": true,
"lastDelivery": "2024-01-15T09:30:00Z",
"deliveryCount": 145,
"failureCount": 2,
"createdAt": "2024-01-10T10:30:00Z"
}
],
"pagination": {
"page": 1,
"limit": 10,
"total": 25,
"pages": 3
}
}
}
```
## 获取单个 WebHook
### 端点
```http
GET /api/webhooks/{id}
```
### 响应
```json
{
"success": true,
"data": {
"id": "webhook-123",
"url": "https://your-app.com/webhook",
"events": ["server.created", "server.status_changed"],
"active": true,
"secret": "your-webhook-secret",
"config": {
"contentType": "application/json",
"insecureSsl": false,
"retryCount": 3,
"timeout": 30
},
"filters": {
"serverGroups": ["production", "staging"],
"serverTypes": ["ai-assistant", "data-processor"]
},
"stats": {
"totalDeliveries": 145,
"successfulDeliveries": 143,
"failedDeliveries": 2,
"lastDelivery": "2024-01-15T09:30:00Z",
"lastSuccess": "2024-01-15T09:30:00Z",
"lastFailure": "2024-01-14T15:20:00Z"
},
"createdAt": "2024-01-10T10:30:00Z",
"updatedAt": "2024-01-15T10:30:00Z"
}
}
```
## 更新 WebHook
### 端点
```http
PUT /api/webhooks/{id}
```
### 请求体
```json
{
"url": "https://your-app.com/new-webhook",
"events": ["server.created", "server.updated", "server.deleted"],
"active": true,
"config": {
"retryCount": 5,
"timeout": 45
}
}
```
## 删除 WebHook
### 端点
```http
DELETE /api/webhooks/{id}
```
### 响应
```json
{
"success": true,
"message": "WebHook 已成功删除"
}
```
## WebHook 事件格式
### 基本结构
所有 WebHook 事件都遵循以下基本结构:
```json
{
"id": "event-123",
"type": "server.created",
"timestamp": "2024-01-15T10:30:00Z",
"version": "1.0",
"data": {
// 事件特定数据
},
"metadata": {
"source": "mcphub",
"environment": "production",
"triggeredBy": "user-456"
}
}
```
### 服务器事件示例
#### server.created
```json
{
"id": "event-123",
"type": "server.created",
"timestamp": "2024-01-15T10:30:00Z",
"version": "1.0",
"data": {
"server": {
"id": "mcp-server-123",
"name": "AI Assistant Server",
"type": "ai-assistant",
"endpoint": "https://ai-assistant.example.com",
"group": "production",
"status": "active",
"capabilities": ["chat", "completion"],
"createdAt": "2024-01-15T10:30:00Z"
}
},
"metadata": {
"source": "mcphub",
"environment": "production",
"triggeredBy": "user-456"
}
}
```
#### server.status_changed
```json
{
"id": "event-124",
"type": "server.status_changed",
"timestamp": "2024-01-15T11:30:00Z",
"version": "1.0",
"data": {
"server": {
"id": "mcp-server-123",
"name": "AI Assistant Server",
"previousStatus": "active",
"currentStatus": "inactive",
"reason": "Health check failed",
"lastHealthCheck": "2024-01-15T11:25:00Z"
}
},
"metadata": {
"source": "mcphub",
"environment": "production",
"triggeredBy": "system"
}
}
```
## 签名验证
MCPHub 使用 HMAC-SHA256 签名来验证 WebHook 的真实性。
### 签名生成
签名在 `X-MCPHub-Signature-256` 头中发送:
```
X-MCPHub-Signature-256: sha256=5757107ea39eca8e35d1e8...
```
### 验证示例
#### Node.js
```javascript
const crypto = require('crypto');
function verifySignature(payload, signature, secret) {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex');
const actualSignature = signature.replace('sha256=', '');
return crypto.timingSafeEqual(
Buffer.from(expectedSignature, 'hex'),
Buffer.from(actualSignature, 'hex'),
);
}
// Express.js 中间件示例
app.use('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-mcphub-signature-256'];
const payload = req.body;
if (!verifySignature(payload, signature, process.env.WEBHOOK_SECRET)) {
return res.status(401).send('Unauthorized');
}
// 处理 WebHook 事件
const event = JSON.parse(payload);
console.log('收到事件:', event.type);
res.status(200).send('OK');
});
```
#### Python
```python
import hmac
import hashlib
def verify_signature(payload, signature, secret):
expected_signature = hmac.new(
secret.encode('utf-8'),
payload,
hashlib.sha256
).hexdigest()
actual_signature = signature.replace('sha256=', '')
return hmac.compare_digest(expected_signature, actual_signature)
# Flask 示例
from flask import Flask, request, jsonify
import json
app = Flask(__name__)
@app.route('/webhook', methods=['POST'])
def webhook():
signature = request.headers.get('X-MCPHub-Signature-256')
payload = request.get_data()
if not verify_signature(payload, signature, 'your-webhook-secret'):
return jsonify({'error': 'Unauthorized'}), 401
event = json.loads(payload)
print(f'收到事件: {event["type"]}')
return jsonify({'status': 'success'}), 200
```
## 重试机制
MCPHub 对失败的 WebHook 交付实施指数退避重试:
- **重试次数**: 可配置(默认 3 次)
- **重试间隔**: 2^n 秒n 为重试次数)
- **最大间隔**: 300 秒5 分钟)
- **超时设置**: 可配置(默认 30 秒)
### 重试时间表
| 尝试次数 | 延迟时间 |
| -------- | -------- |
| 1 | 立即 |
| 2 | 2 秒 |
| 3 | 4 秒 |
| 4 | 8 秒 |
| 5 | 16 秒 |
## 获取交付历史
### 端点
```http
GET /api/webhooks/{id}/deliveries
```
### 查询参数
| 参数名 | 类型 | 描述 |
| ---------- | ------- | ------------------------------------ |
| page | integer | 页码 |
| limit | integer | 每页数量 |
| status | string | 过滤状态success, failed, pending |
| event_type | string | 过滤事件类型 |
### 响应
```json
{
"success": true,
"data": {
"deliveries": [
{
"id": "delivery-123",
"eventId": "event-123",
"eventType": "server.created",
"url": "https://your-app.com/webhook",
"status": "success",
"responseCode": 200,
"responseTime": 145,
"attempts": 1,
"deliveredAt": "2024-01-15T10:30:15Z",
"nextRetry": null
},
{
"id": "delivery-124",
"eventId": "event-124",
"eventType": "server.status_changed",
"url": "https://your-app.com/webhook",
"status": "failed",
"responseCode": 500,
"responseTime": 30000,
"attempts": 3,
"error": "Connection timeout",
"deliveredAt": null,
"nextRetry": "2024-01-15T11:45:00Z"
}
],
"pagination": {
"page": 1,
"limit": 20,
"total": 145,
"pages": 8
}
}
}
```
## 测试 WebHook
### 端点
```http
POST /api/webhooks/{id}/test
```
### 请求体
```json
{
"eventType": "server.created",
"customData": {
"test": true,
"message": "这是一个测试事件"
}
}
```
### 响应
```json
{
"success": true,
"data": {
"deliveryId": "delivery-test-123",
"status": "delivered",
"responseCode": 200,
"responseTime": 124,
"sentAt": "2024-01-15T10:30:00Z"
}
}
```
## 最佳实践
### 1. 幂等性处理
确保您的 WebHook 端点能够处理重复事件:
```javascript
const processedEvents = new Set();
app.post('/webhook', (req, res) => {
const event = req.body;
// 检查事件是否已处理
if (processedEvents.has(event.id)) {
return res.status(200).send('Already processed');
}
// 处理事件
processEvent(event);
// 记录已处理的事件
processedEvents.add(event.id);
res.status(200).send('OK');
});
```
### 2. 异步处理
对于复杂的处理逻辑,使用异步处理避免阻塞:
```javascript
app.post('/webhook', async (req, res) => {
const event = req.body;
// 立即响应
res.status(200).send('OK');
// 异步处理事件
setImmediate(() => {
processEventAsync(event);
});
});
```
### 3. 错误处理
实施适当的错误处理和日志记录:
```javascript
app.post('/webhook', (req, res) => {
try {
const event = req.body;
processEvent(event);
res.status(200).send('OK');
} catch (error) {
console.error('WebHook 处理错误:', error);
res.status(500).send('Internal Server Error');
}
});
```
### 4. 监控和告警
监控 WebHook 的交付状态:
```bash
# 检查失败的交付
curl -X GET \
'https://api.mcphub.io/api/webhooks/webhook-123/deliveries?status=failed' \
-H 'Authorization: Bearer YOUR_API_TOKEN'
```
## 故障排除
### 常见问题
1. **签名验证失败**
- 检查密钥是否正确
- 确保使用原始请求体进行验证
- 验证 HMAC 计算实现
2. **超时错误**
- 增加 WebHook 超时设置
- 优化端点响应时间
- 使用异步处理
3. **重复事件**
- 实施幂等性检查
- 使用事件 ID 去重
- 记录处理状态
### 调试工具
使用 MCPHub 提供的调试工具:
```bash
# 查看最近的交付日志
curl -X GET \
'https://api.mcphub.io/api/webhooks/webhook-123/deliveries?limit=5' \
-H 'Authorization: Bearer YOUR_API_TOKEN'
# 重新发送失败的事件
curl -X POST \
'https://api.mcphub.io/api/webhooks/delivery-124/redeliver' \
-H 'Authorization: Bearer YOUR_API_TOKEN'
```

View File

@@ -0,0 +1,212 @@
---
title: "群组"
description: "管理服务器群组以组织和路由请求。"
---
import { Card, Cards } from 'mintlify';
<Card title="GET /api/groups" href="#get-all-groups">获取所有群组的列表。</Card>
<Card title="POST /api/groups" href="#create-a-new-group">创建一个新群组。</Card>
<Card title="GET /api/groups/:id" href="#get-a-group">获取特定群组的详细信息。</Card>
<Card title="PUT /api/groups/:id" href="#update-a-group">更新现有群组。</Card>
<Card title="DELETE /api/groups/:id" href="#delete-a-group">删除一个群组。</Card>
<Card title="POST /api/groups/:id/servers" href="#add-server-to-group">将服务器添加到群组。</Card>
<Card title="DELETE /api/groups/:id/servers/:serverName" href="#remove-server-from-group">从群组中删除服务器。</Card>
<Card title="PUT /api/groups/:id/servers/batch" href="#batch-update-group-servers">批量更新群组中的服务器。</Card>
<Card title="GET /api/groups/:id/server-configs" href="#get-group-server-configs">获取群组中详细的服务器配置。</Card>
<Card title="PUT /api/groups/:id/server-configs/:serverName/tools" href="#update-group-server-tools">更新群组中服务器的工具选择。</Card>
---
### 获取所有群组
检索所有服务器群组的列表。
- **端点**: `/api/groups`
- **方法**: `GET`
- **成功响应**:
```json
{
"success": true,
"data": [
{
"id": "group-1",
"name": "我的群组",
"description": "服务器的集合。",
"servers": ["server1", "server2"],
"owner": "admin"
}
]
}
```
---
### 创建一个新群组
创建一个新的服务器群组。
- **端点**: `/api/groups`
- **方法**: `POST`
- **正文**:
- `name` (string, 必填): 群组的名称。
- `description` (string, 可选): 群组的描述。
- `servers` (array of strings, 可选): 要包含在群组中的服务器名称列表。
- **请求示例**:
```json
{
"name": "我的新群组",
"description": "新群组的描述",
"servers": ["server1", "server2"]
}
```
---
### 获取一个群组
通过 ID 或名称检索特定群组的详细信息。
- **端点**: `/api/groups/:id`
- **方法**: `GET`
- **参数**:
- `:id` (string, 必填): 群组的 ID 或名称。
---
### 更新一个群组
更新现有群组的名称、描述或服务器列表。
- **端点**: `/api/groups/:id`
- **方法**: `PUT`
- **参数**:
- `:id` (string, 必填): 要更新的群组的 ID 或名称。
- **正文**:
- `name` (string, 可选): 群组的新名称。
- `description` (string, 可选): 群组的新描述。
- `servers` (array, 可选): 群组的新服务器列表。格式请参阅 [批量更新群组服务器](#batch-update-group-servers)。
- **请求示例**:
```json
{
"name": "更新后的群组名称",
"description": "更新后的描述"
}
```
---
### 删除一个群组
通过 ID 或名称删除一个群组。
- **端点**: `/api/groups/:id`
- **方法**: `DELETE`
- **参数**:
- `:id` (string, 必填): 要删除的群组的 ID 或名称。
---
### 将服务器添加到群组
将单个服务器添加到群组。
- **端点**: `/api/groups/:id/servers`
- **方法**: `POST`
- **参数**:
- `:id` (string, 必填): 群组的 ID 或名称。
- **正文**:
- `serverName` (string, 必填): 要添加的服务器的名称。
- **请求示例**:
```json
{
"serverName": "my-server"
}
```
---
### 从群组中删除服务器
从群组中删除单个服务器。
- **端点**: `/api/groups/:id/servers/:serverName`
- **方法**: `DELETE`
- **参数**:
- `:id` (string, 必填): 群组的 ID 或名称。
- `:serverName` (string, 必填): 要删除的服务器的名称。
---
### 批量更新群组服务器
用新的列表替换群组中的所有服务器。该列表可以是简单的字符串或详细的配置对象。
- **端点**: `/api/groups/:id/servers/batch`
- **方法**: `PUT`
- **参数**:
- `:id` (string, 必填): 群组的 ID 或名称。
- **正文**:
- `servers` (array, 必填): 服务器名称(字符串)或服务器配置对象的数组。
- **请求示例 (简单)**:
```json
{
"servers": ["server1", "server2"]
}
```
- **请求示例 (详细)**:
```json
{
"servers": [
{ "name": "server1", "tools": "all" },
{ "name": "server2", "tools": ["toolA", "toolB"] }
]
}
```
---
### 获取群组服务器配置
检索群组内所有服务器的详细配置,包括启用了哪些工具。
- **端点**: `/api/groups/:id/server-configs`
- **方法**: `GET`
- **参数**:
- `:id` (string, 必填): 群组的 ID 或名称。
- **成功响应**:
```json
{
"success": true,
"data": [
{
"name": "server1",
"tools": "all"
},
{
"name": "server2",
"tools": ["toolA", "toolB"]
}
]
}
```
---
### 更新群组服务器工具
更新群组内特定服务器的工具选择。
- **端点**: `/api/groups/:id/server-configs/:serverName/tools`
- **方法**: `PUT`
- **参数**:
- `:id` (string, 必填): 群组的 ID 或名称。
- `:serverName` (string, 必填): 要更新的服务器的名称。
- **正文**:
- `tools` (string or array of strings, 必填): 字符串 `"all"` 表示启用所有工具,或一个工具名称数组以指定启用哪些工具。
- **请求示例**:
```json
{
"tools": ["toolA", "toolC"]
}
```

View File

@@ -1,717 +1,13 @@
---
title: 'API 参考'
description: 'MCPHub REST API 完整参考文档'
title: "介绍"
description: "欢迎来到 MCPHub API 文档。"
---
## 概述
MCPHub API 提供了一整套端点来管理您的 MCP 服务器、群组、用户等。该 API 分为两个主要类别:
MCPHub 提供全面的 REST API用于管理 MCP 服务器、用户、组和监控。所有 API 端点都需要身份验证,并支持 JSON 格式的请求和响应。
- **MCP 端点**: 这些是与您的 MCP 服务器交互的主要端点。它们提供了一个统一的界面,用于向您的服务器发送请求并实时接收响应。
- **管理 API**: 这些端点用于管理 MCPHub 实例本身。这包括管理服务器、群组、用户和系统设置。
## 基础信息
所有 API 端点都在 `/api` 路径下可用。例如,获取所有服务器的端点是 `/api/servers`。
### 基础 URL
```
https://your-mcphub-instance.com/api
```
### 身份验证
所有 API 请求都需要身份验证。支持以下方法:
#### JWT 令牌认证
```bash
curl -X GET https://api.mcphub.com/servers \
-H "Authorization: Bearer YOUR_JWT_TOKEN"
```
#### API 密钥认证
```bash
curl -X GET https://api.mcphub.com/servers \
-H "X-API-Key: YOUR_API_KEY"
```
### 请求格式
- **Content-Type**: `application/json`
- **Accept**: `application/json`
- **User-Agent**: 建议包含您的应用程序名称和版本
### 响应格式
所有响应都采用 JSON 格式:
```json
{
"success": true,
"data": {
// 响应数据
},
"message": "操作成功",
"timestamp": "2024-01-01T12:00:00Z"
}
```
错误响应格式:
```json
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "请求数据无效",
"details": {
"field": "name",
"reason": "名称不能为空"
}
},
"timestamp": "2024-01-01T12:00:00Z"
}
```
## 状态码
| 状态码 | 说明 |
| ------ | -------------------- |
| 200 | 请求成功 |
| 201 | 资源创建成功 |
| 204 | 请求成功,无返回内容 |
| 400 | 请求参数错误 |
| 401 | 未授权访问 |
| 403 | 权限不足 |
| 404 | 资源不存在 |
| 409 | 资源冲突 |
| 422 | 请求数据验证失败 |
| 429 | 请求频率超限 |
| 500 | 服务器内部错误 |
## 分页
支持分页的端点使用以下参数:
- `page`: 页码(从 1 开始)
- `limit`: 每页记录数(默认 20最大 100
- `sort`: 排序字段
- `order`: 排序顺序(`asc` 或 `desc`
```bash
curl -X GET "https://api.mcphub.com/servers?page=2&limit=50&sort=name&order=asc" \
-H "Authorization: Bearer $TOKEN"
```
分页响应格式:
```json
{
"success": true,
"data": {
"items": [...],
"pagination": {
"page": 2,
"limit": 50,
"total": 234,
"pages": 5,
"hasNext": true,
"hasPrev": true
}
}
}
```
## 过滤和搜索
支持过滤的端点可以使用以下参数:
- `search`: 全文搜索
- `filter[field]`: 字段过滤
- `status`: 状态过滤
- `created_after`: 创建时间筛选
- `created_before`: 创建时间筛选
```bash
curl -X GET "https://api.mcphub.com/servers?search=python&filter[status]=running&created_after=2024-01-01" \
-H "Authorization: Bearer $TOKEN"
```
## API 端点
### 服务器管理
#### 获取服务器列表
```http
GET /api/servers
```
参数:
- `status` (可选): 过滤服务器状态 (`running`, `stopped`, `error`)
- `group` (可选): 过滤所属组
- `search` (可选): 搜索服务器名称或描述
示例响应:
```json
{
"success": true,
"data": {
"items": [
{
"id": "server-1",
"name": "文件系统服务器",
"status": "running",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"],
"env": {
"NODE_ENV": "production"
},
"cwd": "/app",
"pid": 12345,
"uptime": 3600000,
"lastRestart": "2024-01-01T12:00:00Z",
"createdAt": "2024-01-01T10:00:00Z",
"updatedAt": "2024-01-01T12:00:00Z"
}
]
}
}
```
#### 创建服务器
```http
POST /api/servers
```
请求体:
```json
{
"name": "新服务器",
"command": "python",
"args": ["-m", "mcp_server"],
"env": {
"API_KEY": "your-api-key",
"LOG_LEVEL": "INFO"
},
"cwd": "/app/python-server",
"enabled": true,
"description": "Python MCP 服务器",
"tags": ["python", "production"]
}
```
#### 获取服务器详情
```http
GET /api/servers/{serverId}
```
#### 更新服务器
```http
PUT /api/servers/{serverId}
```
#### 删除服务器
```http
DELETE /api/servers/{serverId}
```
#### 启动服务器
```http
POST /api/servers/{serverId}/start
```
#### 停止服务器
```http
POST /api/servers/{serverId}/stop
```
请求体(可选):
```json
{
"graceful": true,
"timeout": 30000
}
```
#### 重启服务器
```http
POST /api/servers/{serverId}/restart
```
#### 获取服务器日志
```http
GET /api/servers/{serverId}/logs
```
参数:
- `level` (可选): 日志级别过滤
- `limit` (可选): 返回日志条数
- `since` (可选): 开始时间
- `follow` (可选): 实时跟踪日志
### 用户管理
#### 获取用户列表
```http
GET /api/users
```
#### 创建用户
```http
POST /api/users
```
请求体:
```json
{
"username": "newuser",
"email": "user@example.com",
"password": "securepassword",
"role": "user",
"groups": ["dev-team"],
"profile": {
"firstName": "张",
"lastName": "三",
"department": "开发部"
}
}
```
#### 获取用户详情
```http
GET /api/users/{userId}
```
#### 更新用户
```http
PUT /api/users/{userId}
```
#### 删除用户
```http
DELETE /api/users/{userId}
```
### 组管理
#### 获取组列表
```http
GET /api/groups
```
#### 创建组
```http
POST /api/groups
```
请求体:
```json
{
"name": "dev-team",
"displayName": "开发团队",
"description": "前端和后端开发人员",
"parentGroup": null,
"permissions": {
"servers": ["read", "write", "execute"],
"tools": ["read", "execute"]
},
"settings": {
"autoAssign": false,
"maxMembers": 50,
"requireApproval": true
}
}
```
#### 添加用户到组
```http
POST /api/groups/{groupId}/members
```
请求体:
```json
{
"userId": "user123",
"role": "member"
}
```
#### 从组中移除用户
```http
DELETE /api/groups/{groupId}/members/{userId}
```
#### 分配服务器到组
```http
POST /api/groups/{groupId}/servers
```
请求体:
```json
{
"serverId": "server-1",
"permissions": ["read", "write", "execute"]
}
```
### 身份验证
#### 登录
```http
POST /api/auth/login
```
请求体:
```json
{
"username": "admin",
"password": "password",
"mfaCode": "123456"
}
```
响应:
```json
{
"success": true,
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "refresh_token_here",
"expiresIn": 86400,
"user": {
"id": "user123",
"username": "admin",
"role": "admin",
"permissions": ["*"]
}
}
}
```
#### 刷新令牌
```http
POST /api/auth/refresh
```
#### 注销
```http
POST /api/auth/logout
```
#### 验证令牌
```http
GET /api/auth/verify
```
### 监控
#### 获取系统状态
```http
GET /api/monitoring/status
```
响应:
```json
{
"success": true,
"data": {
"system": {
"uptime": 86400,
"version": "2.1.0",
"nodeVersion": "18.17.0"
},
"servers": {
"total": 12,
"running": 10,
"stopped": 1,
"error": 1
},
"performance": {
"requestsPerMinute": 85,
"avgResponseTime": "245ms",
"errorRate": "0.3%"
}
}
}
```
#### 获取性能指标
```http
GET /api/monitoring/metrics
```
参数:
- `timeRange`: 时间范围 (`1h`, `24h`, `7d`, `30d`)
- `granularity`: 数据粒度 (`1m`, `5m`, `1h`, `1d`)
- `metrics`: 指定指标名称(逗号分隔)
#### 获取日志
```http
GET /api/monitoring/logs
```
参数:
- `level`: 日志级别
- `source`: 日志源
- `limit`: 返回条数
- `since`: 开始时间
- `until`: 结束时间
### 配置管理
#### 获取系统配置
```http
GET /api/config
```
#### 更新系统配置
```http
PUT /api/config
```
请求体:
```json
{
"smtp": {
"host": "smtp.example.com",
"port": 587,
"secure": false,
"auth": {
"user": "noreply@example.com",
"pass": "password"
}
},
"notifications": {
"email": true,
"slack": true,
"webhook": "https://hooks.example.com/notifications"
}
}
```
## WebSocket API
MCPHub 支持 WebSocket 连接以获取实时更新。
### 连接
```javascript
const ws = new WebSocket('wss://api.mcphub.com/ws');
ws.onopen = function () {
// 发送认证消息
ws.send(
JSON.stringify({
type: 'auth',
token: 'YOUR_JWT_TOKEN',
}),
);
};
```
### 订阅事件
```javascript
// 订阅服务器状态更新
ws.send(
JSON.stringify({
type: 'subscribe',
channel: 'server-status',
filters: {
serverId: 'server-1',
},
}),
);
// 订阅系统监控
ws.send(
JSON.stringify({
type: 'subscribe',
channel: 'monitoring',
metrics: ['cpu', 'memory', 'requests'],
}),
);
```
### 事件类型
- `server-status`: 服务器状态变化
- `server-logs`: 实时日志流
- `monitoring`: 系统监控指标
- `alerts`: 系统警报
- `user-activity`: 用户活动事件
## 错误处理
### 错误代码
| 错误代码 | 描述 |
| ----------------------- | -------------- |
| `INVALID_REQUEST` | 请求格式无效 |
| `AUTHENTICATION_FAILED` | 身份验证失败 |
| `AUTHORIZATION_FAILED` | 权限不足 |
| `RESOURCE_NOT_FOUND` | 资源不存在 |
| `RESOURCE_CONFLICT` | 资源冲突 |
| `VALIDATION_ERROR` | 数据验证失败 |
| `RATE_LIMIT_EXCEEDED` | 请求频率超限 |
| `SERVER_ERROR` | 服务器内部错误 |
### 错误处理示例
```javascript
async function handleApiRequest() {
try {
const response = await fetch('/api/servers', {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
const data = await response.json();
if (!data.success) {
switch (data.error.code) {
case 'AUTHENTICATION_FAILED':
// 重新登录
redirectToLogin();
break;
case 'RATE_LIMIT_EXCEEDED':
// 延迟重试
setTimeout(() => handleApiRequest(), 5000);
break;
default:
// 显示错误消息
showError(data.error.message);
}
return;
}
// 处理成功响应
handleSuccessResponse(data.data);
} catch (error) {
// 处理网络错误
console.error('网络请求失败:', error);
}
}
```
## 速率限制
API 实施速率限制以防止滥用:
- **默认限制**: 每分钟 100 请求
- **认证用户**: 每分钟 1000 请求
- **管理员**: 每分钟 5000 请求
响应头包含速率限制信息:
```
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 999
X-RateLimit-Reset: 1609459200
```
## SDK 和客户端库
### JavaScript/Node.js
```bash
npm install @mcphub/sdk
```
```javascript
import { MCPHubClient } from '@mcphub/sdk';
const client = new MCPHubClient({
baseURL: 'https://api.mcphub.com',
token: 'YOUR_JWT_TOKEN',
});
// 获取服务器列表
const servers = await client.servers.list();
// 创建服务器
const newServer = await client.servers.create({
name: '新服务器',
command: 'python',
args: ['-m', 'mcp_server'],
});
```
### Python
```bash
pip install mcphub-sdk
```
```python
from mcphub_sdk import MCPHubClient
client = MCPHubClient(
base_url='https://api.mcphub.com',
token='YOUR_JWT_TOKEN'
)
# 获取服务器列表
servers = client.servers.list()
# 创建服务器
new_server = client.servers.create(
name='新服务器',
command='python',
args=['-m', 'mcp_server']
)
```
## 最佳实践
1. **使用 HTTPS**: 始终通过 HTTPS 访问 API
2. **安全存储令牌**: 不要在客户端代码中硬编码令牌
3. **处理错误**: 实施适当的错误处理和重试逻辑
4. **遵守速率限制**: 监控速率限制并实施退避策略
5. **使用分页**: 对于大数据集使用分页参数
6. **缓存响应**: 适当缓存 API 响应以减少请求
7. **版本控制**: 使用 API 版本号以确保兼容性
有关更多信息,请参阅我们的 [SDK 文档](https://docs.mcphub.com/sdk) 和 [示例代码](https://github.com/mcphub/examples)。
大多数管理 API 端点都需要身份验证。有关更多详细信息,请参阅[身份验证](/api-reference/auth)部分。

View File

@@ -0,0 +1,81 @@
---
title: "日志"
description: "访问和管理服务器日志。"
---
import { Card, Cards } from 'mintlify';
<Card
title="GET /api/logs"
href="#get-all-logs"
>
获取所有日志。
</Card>
<Card
title="DELETE /api/logs"
href="#clear-logs"
>
清除所有日志。
</Card>
<Card
title="GET /api/logs/stream"
href="#stream-logs"
>
实时流式传输日志。
</Card>
---
### 获取所有日志
检索所有存储的日志。
- **端点**: `/api/logs`
- **方法**: `GET`
- **成功响应**:
```json
{
"success": true,
"data": [
{
"timestamp": "2023-10-27T10:00:00.000Z",
"level": "info",
"message": "服务器成功启动。",
"service": "system"
}
]
}
```
---
### 清除日志
删除所有存储的日志。
- **端点**: `/api/logs`
- **方法**: `DELETE`
- **成功响应**:
```json
{
"success": true,
"message": "日志清除成功"
}
```
---
### 流式传输日志
使用服务器发送事件 (SSE) 实时流式传输日志。连接将保持打开状态,新的日志条目将在发生时发送。
- **端点**: `/api/logs/stream`
- **方法**: `GET`
- **响应格式**: 该流发送带有包含 JSON 对象的 `data` 字段的事件。第一个事件的 `type` 为 `initial`,包含所有历史日志。后续事件的 `type` 为 `log`,包含单个新日志条目。
- **事件示例**:
```
data: {"type":"log","log":{"timestamp":"2023-10-27T10:00:05.000Z","level":"debug","message":"正在处理 /api/some-endpoint 的请求","service":"mcp-server"}}
```

View File

@@ -0,0 +1,33 @@
---
title: "MCP HTTP 端点"
description: "使用统一的 HTTP 端点连接到您的 MCP 服务器。"
---
MCPHub 为您的所有 MCP 服务器提供统一的可流式 HTTP 接口。这使您可以向任何配置的 MCP 服务器发送请求并实时接收响应。
### 统一端点
此端点提供对所有已启用的 MCP 服务器的访问。
- **端点**: `http://localhost:3000/mcp`
- **方法**: `POST`
### 特定群组的端点
要定向访问特定的服务器群组,请使用基于群组的 HTTP 端点。
- **端点**: `http://localhost:3000/mcp/{group}`
- **方法**: `POST`
- **参数**:
- `{group}`: 群组的 ID 或名称。
### 特定服务器的端点
要直接访问单个服务器,请使用特定于服务器的 HTTP 端点。
- **端点**: `http://localhost:3000/mcp/{server}`
- **方法**: `POST`
- **参数**:
- `{server}`: 服务器的名称。
> **注意**: 如果服务器名称和群组名称相同,则群组将优先。

View File

@@ -0,0 +1,25 @@
---
title: "MCP SSE 端点 (已弃用)"
description: "使用 SSE 端点连接到您的 MCP 服务器。"
---
SSE 端点已弃用,并将在未来版本中删除。请改用 [MCP HTTP 端点](/api-reference/mcp-http)。
### 统一端点
- **端点**: `http://localhost:3000/sse`
- **方法**: `GET`
### 特定群组的端点
- **端点**: `http://localhost:3000/sse/{group}`
- **方法**: `GET`
- **参数**:
- `{group}`: 群组的 ID 或名称。
### 特定服务器的端点
- **端点**: `http://localhost:3000/sse/{server}`
- **方法**: `GET`
- **参数**:
- `{server}`: 服务器的名称。

View File

@@ -0,0 +1,209 @@
---
title: "服务器"
description: "管理您的 MCP 服务器。"
---
import { Card, Cards } from 'mintlify';
<Card
title="GET /api/servers"
href="#get-all-servers"
>
获取所有 MCP 服务器的列表。
</Card>
<Card
title="POST /api/servers"
href="#create-a-new-server"
>
创建一个新的 MCP 服务器。
</Card>
<Card
title="PUT /api/servers/:name"
href="#update-a-server"
>
更新现有的 MCP 服务器。
</Card>
<Card
title="DELETE /api/servers/:name"
href="#delete-a-server"
>
删除一个 MCP 服务器。
</Card>
<Card
title="POST /api/servers/:name/toggle"
href="#toggle-a-server"
>
切换服务器的启用状态。
</Card>
<Card
title="POST /api/servers/:serverName/tools/:toolName/toggle"
href="#toggle-a-tool"
>
切换工具的启用状态。
</Card>
<Card
title="PUT /api/servers/:serverName/tools/:toolName/description"
href="#update-tool-description"
>
更新工具的描述。
</Card>
---
### 获取所有服务器
检索所有已配置的 MCP 服务器的列表,包括其状态和可用工具。
- **端点**: `/api/servers`
- **方法**: `GET`
- **响应**:
```json
{
"success": true,
"data": [
{
"name": "example-server",
"status": "connected",
"tools": [
{
"name": "tool1",
"description": "工具1的描述"
}
],
"config": {
"type": "stdio",
"command": "node",
"args": ["server.js"]
}
}
]
}
```
---
### 创建一个新服务器
将一个新的 MCP 服务器添加到配置中。
- **端点**: `/api/servers`
- **方法**: `POST`
- **正文**:
```json
{
"name": "my-new-server",
"config": {
"type": "stdio",
"command": "python",
"args": ["-u", "my_script.py"],
"owner": "admin"
}
}
```
- `name` (string, 必填): 服务器的唯一名称。
- `config` (object, 必填): 服务器配置对象。
- `type` (string): `stdio`、`sse`、`streamable-http` 或 `openapi`。
- `command` (string): `stdio` 类型要执行的命令。
- `args` (array of strings): 命令的参数。
- `url` (string): `sse`、`streamable-http` 或 `openapi` 类型的 URL。
- `openapi` (object): OpenAPI 配置。
- `url` (string): OpenAPI 模式的 URL。
- `schema` (object): OpenAPI 模式对象本身。
- `headers` (object): `sse`、`streamable-http` 和 `openapi` 类型请求要发送的标头。
- `keepAliveInterval` (number): `sse` 类型的保持活动间隔(毫秒)。默认为 60000。
- `owner` (string): 服务器的所有者。默认为当前用户或“admin”。
---
### 更新一个服务器
更新现有 MCP 服务器的配置。
- **端点**: `/api/servers/:name`
- **方法**: `PUT`
- **参数**:
- `:name` (string, 必填): 要更新的服务器的名称。
- **正文**:
```json
{
"config": {
"type": "stdio",
"command": "node",
"args": ["new_server.js"]
}
}
```
- `config` (object, 必填): 更新后的服务器配置对象。详情请参阅“创建一个新服务器”。
---
### 删除一个服务器
从配置中删除一个 MCP 服务器。
- **端点**: `/api/servers/:name`
- **方法**: `DELETE`
- **参数**:
- `:name` (string, 必填): 要删除的服务器的名称。
---
### 切换一个服务器
启用或禁用一个 MCP 服务器。
- **端点**: `/api/servers/:name/toggle`
- **方法**: `POST`
- **参数**:
- `:name` (string, 必填): 要切换的服务器的名称。
- **正文**:
```json
{
"enabled": true
}
```
- `enabled` (boolean, 必填): `true` 启用服务器,`false` 禁用服务器。
---
### 切换一个工具
启用或禁用服务器上的特定工具。
- **端点**: `/api/servers/:serverName/tools/:toolName/toggle`
- **方法**: `POST`
- **参数**:
- `:serverName` (string, 必填): 服务器的名称。
- `:toolName` (string, 必填): 工具的名称。
- **正文**:
```json
{
"enabled": true
}
```
- `enabled` (boolean, 必填): `true` 启用工具,`false` 禁用工具。
---
### 更新工具描述
更新特定工具的描述。
- **端点**: `/api/servers/:serverName/tools/:toolName/description`
- **方法**: `PUT`
- **参数**:
- `:serverName` (string, 必填): 服务器的名称。
- `:toolName` (string, 必填): 工具的名称。
- **正文**:
```json
{
"description": "新的工具描述"
}
```
- `description` (string, 必填): 工具的新描述。

View File

@@ -0,0 +1,29 @@
---
title: "智能路由"
description: "使用向量语义搜索进行智能工具发现。"
---
智能路由是 MCPHub 的智能工具发现系统,它使用向量语义搜索来自动为任何给定任务找到最相关的工具。
### HTTP 端点
- **端点**: `http://localhost:3000/mcp/$smart`
- **方法**: `POST`
### SSE 端点 (已弃用)
- **端点**: `http://localhost:3000/sse/$smart`
- **方法**: `GET`
### 工作原理
1. **工具索引**: 所有 MCP 工具都会自动转换为向量嵌入并存储在带有 pgvector 的 PostgreSQL 中。
2. **语义搜索**: 用户查询被转换为向量,并使用余弦相似度与工具嵌入进行匹配。
3. **智能过滤**: 动态阈值可确保相关结果而无噪音。
4. **精确执行**: 找到的工具可以通过适当的参数验证直接执行。
### 设置要求
- 带有 pgvector 扩展的 PostgreSQL
- OpenAI API 密钥(或兼容的嵌入服务)
- 在 MCPHub 设置中启用智能路由

View File

@@ -1,271 +1,44 @@
---
title: '环境变量配置'
title: '环境变量'
description: '使用环境变量配置 MCPHub'
---
# 环境变量配置
# 环境变量
MCPHub 使用环境变量进行配置。本指南涵盖所有可用变量及其用法。
MCPHub 使用环境变量进行配置。本指南涵盖所有可用变量及其用法。
## 核心应用设置
### 服务器配置
| 变量 | 默认值 | 描述 |
| ----------- | ------------- | ----------------------------------------------- |
| `PORT` | `3000` | HTTP 服务器端口号 |
| `HOST` | `0.0.0.0` | 服务器绑定的主机地址 |
| `NODE_ENV` | `development` | 应用环境(`development`、`production`、`test` |
| `LOG_LEVEL` | `info` | 日志级别(`error`、`warn`、`info`、`debug` |
| 变量 | 默认值 | 描述 |
| --- | --- | --- |
| `PORT` | `3000` | HTTP 服务器端口号 |
| `INIT_TIMEOUT` | `300000` | 应用程序的初始超时时间 |
| `BASE_PATH` | `''` | 应用程序的基本路径 |
| `READONLY` | `false` | 设置为 `true` 以启用只读模式 |
| `MCPHUB_SETTING_PATH` | | MCPHub 设置文件的路径 |
| `NODE_ENV` | `development` | 应用程序环境 (`development`, `production`, `test`) |
```env
PORT=3000
HOST=0.0.0.0
INIT_TIMEOUT=300000
BASE_PATH=/api
READONLY=true
MCPHUB_SETTING_PATH=/path/to/settings
NODE_ENV=production
LOG_LEVEL=info
```
### 数据库配置
| 变量 | 默认值 | 描述 |
| -------------- | ----------- | --------------------- |
| `DATABASE_URL` | - | PostgreSQL 连接字符串 |
| `DB_HOST` | `localhost` | 数据库主机 |
| `DB_PORT` | `5432` | 数据库端口 |
| `DB_NAME` | `mcphub` | 数据库名称 |
| `DB_USER` | `mcphub` | 数据库用户名 |
| `DB_PASSWORD` | - | 数据库密码 |
| `DB_SSL` | `false` | 启用数据库 SSL 连接 |
| `DB_POOL_MIN` | `2` | 最小数据库连接池大小 |
| `DB_POOL_MAX` | `10` | 最大数据库连接池大小 |
```env
# 选项 1完整连接字符串
DATABASE_URL=postgresql://username:password@localhost:5432/mcphub
# 选项 2单独组件
DB_HOST=localhost
DB_PORT=5432
DB_NAME=mcphub
DB_USER=mcphub
DB_PASSWORD=your-password
DB_SSL=false
```
## 认证与安全
### JWT 配置
| 变量 | 默认值 | 描述 |
| ------------------------ | ------- | ------------------------ |
| `JWT_SECRET` | - | JWT 令牌签名密钥必需 |
| `JWT_EXPIRES_IN` | `24h` | JWT 令牌过期时间 |
| `JWT_REFRESH_EXPIRES_IN` | `7d` | 刷新令牌过期时间 |
| `JWT_ALGORITHM` | `HS256` | JWT 签名算法 |
| 变量 | 默认值 | 描述 |
| --- | --- | --- |
| `JWT_SECRET` | - | 用于 JWT 令牌签名密钥 (必需) |
```env
JWT_SECRET=your-super-secret-key-change-this-in-production
JWT_EXPIRES_IN=24h
JWT_REFRESH_EXPIRES_IN=7d
```
### 会话与安全
| 变量 | 默认值 | 描述 |
| ------------------- | ------ | -------------------- |
| `SESSION_SECRET` | - | 会话加密密钥 |
| `BCRYPT_ROUNDS` | `12` | bcrypt 哈希轮数 |
| `RATE_LIMIT_WINDOW` | `15` | 速率限制窗口(分钟) |
| `RATE_LIMIT_MAX` | `100` | 每个窗口最大请求数 |
| `CORS_ORIGIN` | `*` | 允许的 CORS 来源 |
```env
SESSION_SECRET=your-session-secret
BCRYPT_ROUNDS=12
RATE_LIMIT_WINDOW=15
RATE_LIMIT_MAX=100
CORS_ORIGIN=https://your-domain.com,https://admin.your-domain.com
```
## 外部服务
### OpenAI 配置
| 变量 | 默认值 | 描述 |
| ------------------------ | ------------------------ | ------------------------------- |
| `OPENAI_API_KEY` | - | OpenAI API 密钥(用于智能路由) |
| `OPENAI_MODEL` | `gpt-3.5-turbo` | OpenAI 嵌入模型 |
| `OPENAI_EMBEDDING_MODEL` | `text-embedding-ada-002` | 向量嵌入模型 |
| `OPENAI_MAX_TOKENS` | `1000` | 每个请求最大令牌数 |
| `OPENAI_TEMPERATURE` | `0.1` | AI 响应温度 |
```env
OPENAI_API_KEY=sk-your-openai-api-key
OPENAI_MODEL=gpt-3.5-turbo
OPENAI_EMBEDDING_MODEL=text-embedding-ada-002
OPENAI_MAX_TOKENS=1000
OPENAI_TEMPERATURE=0.1
```
### Redis 配置(可选)
| 变量 | 默认值 | 描述 |
| ---------------- | ----------- | ---------------- |
| `REDIS_URL` | - | Redis 连接字符串 |
| `REDIS_HOST` | `localhost` | Redis 主机 |
| `REDIS_PORT` | `6379` | Redis 端口 |
| `REDIS_PASSWORD` | - | Redis 密码 |
| `REDIS_DB` | `0` | Redis 数据库编号 |
| `REDIS_PREFIX` | `mcphub:` | Redis 键前缀 |
```env
# 选项 1完整连接字符串
REDIS_URL=redis://username:password@localhost:6379/0
# 选项 2单独组件
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=your-redis-password
REDIS_DB=0
REDIS_PREFIX=mcphub:
```
## MCP 服务器配置
### 默认设置
| 变量 | 默认值 | 描述 |
| ------------------- | ------------------- | ---------------------------- |
| `MCP_SETTINGS_FILE` | `mcp_settings.json` | MCP 设置文件路径 |
| `MCP_SERVERS_FILE` | `servers.json` | 服务器配置文件路径 |
| `MCP_TIMEOUT` | `30000` | MCP 操作默认超时(毫秒) |
| `MCP_MAX_RETRIES` | `3` | 失败操作最大重试次数 |
| `MCP_RESTART_DELAY` | `5000` | 重启失败服务器的延迟(毫秒) |
```env
MCP_SETTINGS_FILE=./config/mcp_settings.json
MCP_SERVERS_FILE=./config/servers.json
MCP_TIMEOUT=30000
MCP_MAX_RETRIES=3
MCP_RESTART_DELAY=5000
```
### 智能路由
| 变量 | 默认值 | 描述 |
| --------------------------- | ------ | ---------------------- |
| `SMART_ROUTING_ENABLED` | `true` | 启用 AI 驱动的智能路由 |
| `SMART_ROUTING_THRESHOLD` | `0.7` | 路由相似度阈值 |
| `SMART_ROUTING_MAX_RESULTS` | `5` | 返回的最大工具数 |
| `VECTOR_CACHE_TTL` | `3600` | 向量缓存 TTL |
```env
SMART_ROUTING_ENABLED=true
SMART_ROUTING_THRESHOLD=0.7
SMART_ROUTING_MAX_RESULTS=5
VECTOR_CACHE_TTL=3600
```
## 文件存储与上传
| 变量 | 默认值 | 描述 |
| -------------------- | ---------------- | -------------------------------- |
| `UPLOAD_DIR` | `./uploads` | 文件上传目录 |
| `MAX_FILE_SIZE` | `10485760` | 最大文件大小字节10MB |
| `ALLOWED_FILE_TYPES` | `image/*,text/*` | 允许的 MIME 类型 |
| `STORAGE_TYPE` | `local` | 存储类型(`local`、`s3`、`gcs` |
```env
UPLOAD_DIR=./data/uploads
MAX_FILE_SIZE=10485760
ALLOWED_FILE_TYPES=image/*,text/*,application/json
STORAGE_TYPE=local
```
### S3 存储(可选)
| 变量 | 默认值 | 描述 |
| ---------------------- | ----------- | -------------- |
| `S3_BUCKET` | - | S3 存储桶名称 |
| `S3_REGION` | `us-east-1` | S3 区域 |
| `S3_ACCESS_KEY_ID` | - | S3 访问密钥 |
| `S3_SECRET_ACCESS_KEY` | - | S3 密钥 |
| `S3_ENDPOINT` | - | 自定义 S3 端点 |
```env
S3_BUCKET=mcphub-uploads
S3_REGION=us-east-1
S3_ACCESS_KEY_ID=your-access-key
S3_SECRET_ACCESS_KEY=your-secret-key
```
## 监控与日志
### 应用监控
| 变量 | 默认值 | 描述 |
| ------------------------ | ------- | -------------------- |
| `METRICS_ENABLED` | `true` | 启用指标收集 |
| `METRICS_PORT` | `9090` | 指标端点端口 |
| `HEALTH_CHECK_INTERVAL` | `30000` | 健康检查间隔(毫秒) |
| `PERFORMANCE_MONITORING` | `false` | 启用性能监控 |
```env
METRICS_ENABLED=true
METRICS_PORT=9090
HEALTH_CHECK_INTERVAL=30000
PERFORMANCE_MONITORING=true
```
### 日志配置
| 变量 | 默认值 | 描述 |
| ------------------ | ------------ | -------------------------------- |
| `LOG_FORMAT` | `json` | 日志格式(`json`、`text` |
| `LOG_FILE` | - | 日志文件路径(如果启用文件日志) |
| `LOG_MAX_SIZE` | `10m` | 最大日志文件大小 |
| `LOG_MAX_FILES` | `5` | 最大日志文件数 |
| `LOG_DATE_PATTERN` | `YYYY-MM-DD` | 日志轮换日期模式 |
```env
LOG_FORMAT=json
LOG_FILE=./logs/mcphub.log
LOG_MAX_SIZE=10m
LOG_MAX_FILES=5
LOG_DATE_PATTERN=YYYY-MM-DD
```
## 开发与调试
| 变量 | 默认值 | 描述 |
| ------------------------ | ------- | ------------------------------- |
| `DEBUG` | - | 调试命名空间(例如 `mcphub:*` |
| `DEV_TOOLS_ENABLED` | `false` | 启用开发工具 |
| `HOT_RELOAD` | `true` | 在开发中启用热重载 |
| `MOCK_EXTERNAL_SERVICES` | `false` | 模拟外部 API 调用 |
```env
DEBUG=mcphub:*
DEV_TOOLS_ENABLED=true
HOT_RELOAD=true
MOCK_EXTERNAL_SERVICES=false
```
## 生产优化
| 变量 | 默认值 | 描述 |
| ------------------ | ------- | ---------------------- |
| `CLUSTER_MODE` | `false` | 启用集群模式 |
| `WORKER_PROCESSES` | `0` | 工作进程数0 = 自动) |
| `MEMORY_LIMIT` | - | 每个进程内存限制 |
| `CPU_LIMIT` | - | 每个进程 CPU 限制 |
| `GC_OPTIMIZE` | `false` | 启用垃圾回收优化 |
```env
CLUSTER_MODE=true
WORKER_PROCESSES=4
MEMORY_LIMIT=512M
GC_OPTIMIZE=true
```
## 配置示例
@@ -276,22 +49,9 @@ GC_OPTIMIZE=true
# .env.development
NODE_ENV=development
PORT=3000
LOG_LEVEL=debug
# 数据库
DATABASE_URL=postgresql://mcphub:password@localhost:5432/mcphub_dev
# 认证
JWT_SECRET=dev-secret-key
JWT_EXPIRES_IN=24h
# OpenAI开发时可选
# OPENAI_API_KEY=your-dev-key
# 调试
DEBUG=mcphub:*
DEV_TOOLS_ENABLED=true
HOT_RELOAD=true
```
### 生产环境
@@ -300,30 +60,9 @@ HOT_RELOAD=true
# .env.production
NODE_ENV=production
PORT=3000
LOG_LEVEL=info
LOG_FORMAT=json
# 数据库
DATABASE_URL=postgresql://mcphub:secure-password@db.example.com:5432/mcphub
DB_SSL=true
DB_POOL_MAX=20
# 安全
JWT_SECRET=your-super-secure-production-secret
SESSION_SECRET=your-session-secret
BCRYPT_ROUNDS=14
# 外部服务
OPENAI_API_KEY=your-production-openai-key
REDIS_URL=redis://redis.example.com:6379
# 监控
METRICS_ENABLED=true
PERFORMANCE_MONITORING=true
# 优化
CLUSTER_MODE=true
GC_OPTIMIZE=true
```
### Docker 环境
@@ -331,21 +70,10 @@ GC_OPTIMIZE=true
```env
# .env.docker
NODE_ENV=production
HOST=0.0.0.0
PORT=3000
# 使用 Docker 网络的服务名
DATABASE_URL=postgresql://mcphub:password@postgres:5432/mcphub
REDIS_URL=redis://redis:6379
# 安全
JWT_SECRET_FILE=/run/secrets/jwt_secret
DB_PASSWORD_FILE=/run/secrets/db_password
# 容器中的文件路径
MCP_SETTINGS_FILE=/app/mcp_settings.json
UPLOAD_DIR=/app/data/uploads
LOG_FILE=/app/logs/mcphub.log
```
## 环境变量加载
@@ -353,8 +81,8 @@ LOG_FILE=/app/logs/mcphub.log
MCPHub 按以下顺序加载环境变量:
1. 系统环境变量
2. `.env.local`被 git 忽略
3. `.env.{NODE_ENV}`例如 `.env.production`
2. `.env.local` (被 git 忽略)
3. `.env.{NODE_ENV}` (例如, `.env.production`)
4. `.env`
### 使用 dotenv-expand
@@ -364,26 +92,13 @@ MCPHub 支持变量扩展:
```env
BASE_URL=https://api.example.com
API_ENDPOINT=${BASE_URL}/v1
DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}
```
## 安全最佳实践
1. **永远不要提交密钥**到版本控制
2. **为生产使用强唯一密钥**
1. **永远不要将密钥提交**到版本控制
2. **为生产环境使用强大、独特的密钥**
3. **定期轮换密钥**
4. **使用特定于环境的文件**
5. **在启动时验证所有环境变量**
6. **为容器部署使用 Docker 密钥**
## 验证
MCPHub 在启动时验证环境变量。无效配置将阻止应用程序启动并提供有用的错误消息。
生产环境必需变量:
- `JWT_SECRET`
- `DATABASE_URL` 或单独的数据库组件
- `OPENAI_API_KEY`(如果启用智能路由)
这个全面的环境配置确保 MCPHub 可以为任何部署场景正确配置。

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,13 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MCP Hub Dashboard</title>
<title>MCPHub Dashboard</title>
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
</head>
<body class="bg-gray-100">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

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';
@@ -9,11 +9,18 @@ import LoginPage from './pages/LoginPage';
import DashboardPage from './pages/Dashboard';
import ServersPage from './pages/ServersPage';
import GroupsPage from './pages/GroupsPage';
import UsersPage from './pages/UsersPage';
import SettingsPage from './pages/SettingsPage';
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 (
@@ -31,8 +38,15 @@ function App() {
<Route path="/" element={<DashboardPage />} />
<Route path="/servers" element={<ServersPage />} />
<Route path="/groups" element={<GroupsPage />} />
<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"
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"
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"
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,8 @@
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 {
onAdd: () => void
@@ -11,35 +12,34 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => {
const { t } = useTranslation()
const [modalVisible, setModalVisible] = useState(false)
const [error, setError] = useState<string | null>(null)
const [confirmationVisible, setConfirmationVisible] = useState(false)
const [pendingPayload, setPendingPayload] = useState<any>(null)
const [detectedVariables, setDetectedVariables] = useState<string[]>([])
const toggleModal = () => {
setModalVisible(!modalVisible)
setError(null) // Clear any previous errors when toggling modal
setConfirmationVisible(false) // Close confirmation dialog
setPendingPayload(null) // Clear pending payload
}
const handleSubmit = async (payload: any) => {
const handleConfirmSubmit = async () => {
if (pendingPayload) {
await submitServer(pendingPayload)
setConfirmationVisible(false)
setPendingPayload(null)
}
}
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'))
}
@@ -65,11 +65,31 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => {
}
}
const handleSubmit = async (payload: any) => {
try {
// Check for variables in the payload
const variables = detectVariables(payload)
if (variables.length > 0) {
// Show confirmation dialog
setDetectedVariables(variables)
setPendingPayload(payload)
setConfirmationVisible(true)
} else {
// Submit directly if no variables found
await submitServer(payload)
}
} catch (err) {
console.error('Error processing server submission:', err)
setError(t('errors.serverAdd'))
}
}
return (
<div>
<button
onClick={toggleModal}
className="w-full bg-blue-100 text-blue-800 rounded hover:bg-blue-200 py-2 px-4 flex items-center justify-center"
className="w-full bg-blue-100 text-blue-800 rounded hover:bg-blue-200 py-2 px-4 flex items-center justify-center btn-primary"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" />
@@ -87,6 +107,60 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => {
/>
</div>
)}
{confirmationVisible && (
<div className="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center p-4">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
{t('server.confirmVariables')}
</h3>
<p className="text-gray-600 mb-4">
{t('server.variablesDetected')}
</p>
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 mb-4">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-yellow-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">
<h4 className="text-sm font-medium text-yellow-800">
{t('server.detectedVariables')}:
</h4>
<ul className="mt-1 text-sm text-yellow-700">
{detectedVariables.map((variable, index) => (
<li key={index} className="font-mono">
${`{${variable}}`}
</li>
))}
</ul>
</div>
</div>
</div>
<p className="text-gray-600 text-sm mb-6">
{t('server.confirmVariablesMessage')}
</p>
<div className="flex justify-end space-x-3">
<button
onClick={() => {
setConfirmationVisible(false)
setPendingPayload(null)
}}
className="px-4 py-2 text-gray-600 border border-gray-300 rounded hover:bg-gray-50 btn-secondary"
>
{t('common.cancel')}
</button>
<button
onClick={handleConfirmSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 btn-primary"
>
{t('server.confirmAndAdd')}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -31,17 +31,17 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
// Validate passwords match
if (formData.newPassword !== confirmPassword) {
setError(t('auth.passwordsNotMatch'));
return;
}
setIsLoading(true);
try {
const response = await changePassword(formData);
if (response.success) {
setSuccess(true);
if (onSuccess) {
@@ -60,7 +60,7 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
return (
<div className="p-6 bg-white rounded-lg shadow-md">
<h2 className="text-xl font-bold mb-4">{t('auth.changePassword')}</h2>
{success ? (
<div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
{t('auth.changePasswordSuccess')}
@@ -72,7 +72,7 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
{error}
</div>
)}
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="currentPassword">
{t('auth.currentPassword')}
@@ -81,13 +81,13 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
type="password"
id="currentPassword"
name="currentPassword"
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
value={formData.currentPassword}
onChange={handleChange}
required
/>
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="newPassword">
{t('auth.newPassword')}
@@ -96,14 +96,14 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
type="password"
id="newPassword"
name="newPassword"
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
value={formData.newPassword}
onChange={handleChange}
required
minLength={6}
/>
</div>
<div className="mb-6">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="confirmPassword">
{t('auth.confirmPassword')}
@@ -112,14 +112,14 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
type="password"
id="confirmPassword"
name="confirmPassword"
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
value={confirmPassword}
onChange={handleChange}
required
minLength={6}
/>
</div>
<div className="flex justify-end space-x-2">
{onCancel && (
<button
@@ -134,7 +134,7 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
<button
type="submit"
disabled={isLoading}
className="py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
className="py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 btn-primary"
>
{isLoading ? (
<span className="flex items-center">

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

@@ -0,0 +1,394 @@
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';
interface DxtUploadFormProps {
onSuccess: (serverConfig: any) => void;
onCancel: () => void;
}
interface DxtUploadResponse {
success: boolean;
data?: {
manifest: any;
extractDir: string;
};
message?: string;
}
const DxtUploadForm: React.FC<DxtUploadFormProps> = ({ onSuccess, onCancel }) => {
const { t } = useTranslation();
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [showServerForm, setShowServerForm] = useState(false);
const [manifestData, setManifestData] = useState<any>(null);
const [extractDir, setExtractDir] = useState<string>('');
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [pendingServerName, setPendingServerName] = useState<string>('');
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const files = e.dataTransfer.files;
if (files.length > 0) {
const file = files[0];
if (file.name.endsWith('.dxt')) {
setSelectedFile(file);
setError(null);
} else {
setError(t('dxt.invalidFileType'));
}
}
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
const file = files[0];
if (file.name.endsWith('.dxt')) {
setSelectedFile(file);
setError(null);
} else {
setError(t('dxt.invalidFileType'));
}
}
};
const handleUpload = async () => {
if (!selectedFile) {
setError(t('dxt.noFileSelected'));
return;
}
setIsUploading(true);
setError(null);
try {
const formData = new FormData();
formData.append('dxtFile', selectedFile);
const response = await fetchWithInterceptors(getApiUrl('/dxt/upload'), {
method: 'POST',
body: formData,
});
const result: DxtUploadResponse = await response.json();
if (!response.ok) {
throw new Error(result.message || `HTTP error! Status: ${response.status}`);
}
if (result.success && result.data) {
setManifestData(result.data.manifest);
setExtractDir(result.data.extractDir);
setShowServerForm(true);
} else {
throw new Error(result.message || t('dxt.uploadFailed'));
}
} catch (err) {
console.error('DXT upload error:', err);
setError(err instanceof Error ? err.message : t('dxt.uploadFailed'));
} finally {
setIsUploading(false);
}
};
const handleInstallServer = async (serverName: string, forceOverride: boolean = false) => {
setIsUploading(true);
setError(null);
try {
// Convert DXT manifest to MCPHub stdio server configuration
const serverConfig = convertDxtToMcpConfig(manifestData, extractDir, serverName);
// First, check if server exists
if (!forceOverride) {
const checkResult = await apiGet('/servers');
if (checkResult.success) {
const existingServer = checkResult.data?.find((server: any) => server.name === serverName);
if (existingServer) {
// Server exists, show confirmation dialog
setPendingServerName(serverName);
setShowConfirmDialog(true);
setIsUploading(false);
return;
}
}
}
// Install or override the server
let result;
if (forceOverride) {
result = await apiPut(`/servers/${encodeURIComponent(serverName)}`, {
name: serverName,
config: serverConfig,
});
} else {
result = await apiPost('/servers', {
name: serverName,
config: serverConfig,
});
}
if (result.success) {
onSuccess(serverConfig);
} else {
throw new Error(result.message || t('dxt.installFailed'));
}
} catch (err) {
console.error('DXT install error:', err);
setError(err instanceof Error ? err.message : t('dxt.installFailed'));
setIsUploading(false);
}
};
const handleConfirmOverride = () => {
setShowConfirmDialog(false);
if (pendingServerName) {
handleInstallServer(pendingServerName, true);
}
};
const handleCancelOverride = () => {
setShowConfirmDialog(false);
setPendingServerName('');
setIsUploading(false);
};
const convertDxtToMcpConfig = (manifest: any, extractPath: string, _serverName: string) => {
const mcpConfig = manifest.server?.mcp_config || {};
// Convert DXT manifest to MCPHub stdio configuration
const config: any = {
type: 'stdio',
command: mcpConfig.command || 'node',
args: (mcpConfig.args || []).map((arg: string) =>
arg.replace('${__dirname}', extractPath)
),
};
// Add environment variables if they exist
if (mcpConfig.env && Object.keys(mcpConfig.env).length > 0) {
config.env = { ...mcpConfig.env };
// Replace ${__dirname} in environment variables
Object.keys(config.env).forEach(key => {
if (typeof config.env[key] === 'string') {
config.env[key] = config.env[key].replace('${__dirname}', extractPath);
}
});
}
return config;
};
if (showServerForm && manifestData) {
return (
<>
<ConfirmDialog
isOpen={showConfirmDialog}
onClose={handleCancelOverride}
onConfirm={handleConfirmOverride}
title={t('dxt.serverExistsTitle')}
message={t('dxt.serverExistsConfirm', { serverName: pendingServerName })}
confirmText={t('dxt.override')}
cancelText={t('common.cancel')}
variant="warning"
/>
<div className={`fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 ${showConfirmDialog ? 'opacity-50 pointer-events-none' : ''}`}>
<div className="bg-white shadow rounded-lg p-6 w-full max-w-2xl max-h-screen overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold text-gray-900">{t('dxt.installServer')}</h2>
<button onClick={onCancel} className="text-gray-500 hover:text-gray-700">
</button>
</div>
{error && (
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4 rounded">
<p className="text-red-700">{error}</p>
</div>
)}
<div className="space-y-6">
{/* Extension Info */}
<div className="bg-gray-50 p-4 rounded-lg">
<h3 className="font-medium text-gray-900 mb-2">{t('dxt.extensionInfo')}</h3>
<div className="space-y-2 text-sm">
<div><strong>{t('dxt.name')}:</strong> {manifestData.display_name || manifestData.name}</div>
<div><strong>{t('dxt.version')}:</strong> {manifestData.version}</div>
<div><strong>{t('dxt.description')}:</strong> {manifestData.description}</div>
{manifestData.author && (
<div><strong>{t('dxt.author')}:</strong> {manifestData.author.name}</div>
)}
{manifestData.tools && manifestData.tools.length > 0 && (
<div>
<strong>{t('dxt.tools')}:</strong>
<ul className="list-disc list-inside ml-4">
{manifestData.tools.map((tool: any, index: number) => (
<li key={index}>{tool.name} - {tool.description}</li>
))}
</ul>
</div>
)}
</div>
</div>
{/* Server Configuration */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('dxt.serverName')}
</label>
<input
type="text"
id="serverName"
defaultValue={manifestData.name}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
placeholder={t('dxt.serverNamePlaceholder')}
/>
</div>
{/* Action Buttons */}
<div className="flex justify-end space-x-4">
<button
onClick={onCancel}
disabled={isUploading}
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 disabled:opacity-50 btn-secondary"
>
{t('common.cancel')}
</button>
<button
onClick={() => {
const nameInput = document.getElementById('serverName') as HTMLInputElement;
const serverName = nameInput?.value.trim() || manifestData.name;
handleInstallServer(serverName);
}}
disabled={isUploading}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center btn-primary"
>
{isUploading ? (
<>
<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('dxt.installing')}
</>
) : (
t('dxt.install')
)}
</button>
</div>
</div>
</div>
</div>
</>
);
}
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white shadow rounded-lg p-6 w-full max-w-lg">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold text-gray-900">{t('dxt.uploadTitle')}</h2>
<button onClick={onCancel} className="text-gray-500 hover:text-gray-700">
</button>
</div>
{error && (
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4 rounded">
<p className="text-red-700">{error}</p>
</div>
)}
{/* File Drop Zone */}
<div
className={`relative border-2 border-dashed rounded-lg p-8 text-center transition-colors ${isDragging
? 'border-blue-500 bg-blue-50'
: selectedFile
? 'border-gray-500 '
: 'border-gray-300 hover:border-gray-400'
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{selectedFile ? (
<div className="space-y-2">
<svg className="mx-auto h-12 w-12 text-green-200" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-sm text-gray-900 font-medium">{selectedFile.name}</p>
<p className="text-xs text-gray-500">{(selectedFile.size / 1024 / 1024).toFixed(2)} MB</p>
</div>
) : (
<div className="space-y-2">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<div>
<p className="text-sm text-gray-900">{t('dxt.dropFileHere')}</p>
<p className="text-xs text-gray-500">{t('dxt.orClickToSelect')}</p>
</div>
</div>
)}
<input
type="file"
accept=".dxt"
onChange={handleFileSelect}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
</div>
<div className="mt-6 flex justify-end space-x-4">
<button
onClick={onCancel}
disabled={isUploading}
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 disabled:opacity-50 btn-secondary"
>
{t('common.cancel')}
</button>
<button
onClick={handleUpload}
disabled={!selectedFile || isUploading}
className="px-4 py-2 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center btn-primary"
>
{isUploading ? (
<>
<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('dxt.uploading')}
</>
) : (
t('dxt.upload')
)}
</button>
</div>
</div>
</div>
);
};
export default DxtUploadForm;

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
@@ -38,18 +38,6 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
}))
}
const handleServerToggle = (serverName: string) => {
setFormData(prev => {
const isSelected = prev.servers.includes(serverName)
return {
...prev,
servers: isSelected
? prev.servers.filter(name => name !== serverName)
: [...prev.servers, serverName]
}
})
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
@@ -67,9 +55,9 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
description: formData.description,
servers: formData.servers
})
if (!result) {
setError(t('groups.updateError'))
if (!result || !result.success) {
setError(result?.message || t('groups.updateError'))
setIsSubmitting(false)
return
}
@@ -83,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"
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"
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"
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,9 +1,10 @@
import { useState } from 'react'
import { useState, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Group, Server } from '@/types'
import { Edit, Trash, Copy, Check } 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'
interface GroupCardProps {
group: Group
@@ -20,8 +21,26 @@ const GroupCard = ({
}: GroupCardProps) => {
const { t } = useTranslation()
const { showToast } = useToast()
const { installConfig } = useSettingsData()
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [copied, setCopied] = useState(false)
const [showCopyDropdown, setShowCopyDropdown] = useState(false)
const [expandedServer, setExpandedServer] = useState<string | null>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setShowCopyDropdown(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [])
const handleEdit = () => {
onEdit(group)
@@ -36,16 +55,18 @@ const GroupCard = ({
setShowDeleteDialog(false)
}
const copyToClipboard = () => {
const copyToClipboard = (text: string) => {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(group.id).then(() => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true)
setShowCopyDropdown(false)
showToast(t('common.copySuccess'), 'success')
setTimeout(() => setCopied(false), 2000)
})
} else {
// Fallback for HTTP or unsupported clipboard API
const textArea = document.createElement('textarea')
textArea.value = group.id
textArea.value = text
// Avoid scrolling to bottom
textArea.style.position = 'fixed'
textArea.style.left = '-9999px'
@@ -55,6 +76,8 @@ const GroupCard = ({
try {
document.execCommand('copy')
setCopied(true)
setShowCopyDropdown(false)
showToast(t('common.copySuccess'), 'success')
setTimeout(() => setCopied(false), 2000)
} catch (err) {
showToast(t('common.copyFailed') || 'Copy failed', 'error')
@@ -64,24 +87,92 @@ const GroupCard = ({
}
}
const handleCopyId = () => {
copyToClipboard(group.id)
}
const handleCopyUrl = () => {
copyToClipboard(`${installConfig.baseUrl}/mcp/${group.id}`)
}
const handleCopyJson = () => {
const jsonConfig = {
mcpServers: {
mcphub: {
url: `${installConfig.baseUrl}/mcp/${group.id}`,
headers: {
Authorization: "Bearer <your-access-token>"
}
}
}
}
copyToClipboard(JSON.stringify(jsonConfig, null, 2))
}
// Helper function to normalize group servers to get server names
const getServerNames = (servers: string[] | IGroupServerConfig[]): string[] => {
return servers.map(server => typeof server === 'string' ? server : server.name);
};
// 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">
<div className="bg-white shadow rounded-lg p-6 ">
<div className="flex justify-between items-center mb-4">
<div>
<div className="flex items-center">
<h2 className="text-xl font-semibold text-gray-800">{group.name}</h2>
<div className="flex items-center ml-3">
<span className="text-xs text-gray-500 mr-1">{group.id}</span>
<button
onClick={copyToClipboard}
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
title={t('common.copy')}
>
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
</button>
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setShowCopyDropdown(!showCopyDropdown)}
className="p-1 text-gray-400 hover:text-gray-600 transition-colors flex items-center"
title={t('common.copy')}
>
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
<DropdownIcon size={12} className="ml-1" />
</button>
{showCopyDropdown && (
<div className="absolute top-full left-0 mt-1 bg-white shadow-lg rounded-md border border-gray-200 py-1 z-10 min-w-[140px]">
<button
onClick={handleCopyId}
className="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center"
>
<Copy size={12} className="mr-2" />
{t('common.copyId')}
</button>
<button
onClick={handleCopyUrl}
className="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center"
>
<Link size={12} className="mr-2" />
{t('common.copyUrl')}
</button>
<button
onClick={handleCopyJson}
className="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center"
>
<FileCode size={12} className="mr-2" />
{t('common.copyJson')}
</button>
</div>
)}
</div>
</div>
</div>
{group.description && (
@@ -89,7 +180,7 @@ const GroupCard = ({
)}
</div>
<div className="flex items-center space-x-3">
<div className="bg-blue-50 text-blue-700 px-3 py-1 rounded-full text-sm">
<div className="bg-blue-50 text-blue-700 px-3 py-1 rounded-full text-sm btn-secondary">
{t('groups.serverCount', { count: group.servers.length })}
</div>
<button
@@ -113,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

@@ -48,25 +48,26 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
// Get badge color based on log type
const getLogTypeColor = (type: string) => {
switch (type) {
case 'error': return 'bg-red-400';
case 'warn': return 'bg-yellow-400';
case 'debug': return 'bg-purple-400';
default: return 'bg-blue-400';
case 'error': return 'bg-red-400/80 text-white';
case 'warn': return 'bg-yellow-400/80 text-gray-900';
case 'debug': return 'bg-purple-400/80 text-white';
case 'info': return 'bg-blue-400/80 text-white';
default: return 'bg-blue-400/80 text-white';
}
};
// Get badge color based on log source
const getSourceColor = (source: string) => {
switch (source) {
case 'main': return 'bg-green-400';
case 'child': return 'bg-orange-400';
default: return 'bg-gray-400';
case 'main': return 'bg-green-400/80 text-white';
case 'child': return 'bg-orange-400/80 text-white';
default: return 'bg-gray-400/80 text-white';
}
};
return (
<div className="flex flex-col h-full">
<div className="bg-card p-3 rounded-t-md border-b flex flex-wrap items-center justify-between gap-2">
<div className="bg-card p-3 rounded-t-md flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-sm">{t('logs.filters')}:</span>
@@ -74,14 +75,14 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
<input
type="text"
placeholder={t('logs.search')}
className="px-2 py-1 text-sm border rounded"
className="shadow appearance-none border border-gray-200 rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
{/* Log type filters */}
<div className="flex gap-1 items-center">
{(['info', 'error', 'warn', 'debug'] as const).map(type => (
{(['debug', 'info', 'error', 'warn'] as const).map(type => (
<Badge
key={type}
variant={typeFilter.includes(type) ? 'default' : 'outline'}
@@ -134,6 +135,7 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
variant="outline"
size="sm"
onClick={onClear}
className='btn-secondary'
disabled={isLoading || logs.length === 0}
>
{t('logs.clearLogs')}
@@ -164,7 +166,7 @@ const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error =
filteredLogs.map((log, index) => (
<div
key={`${log.timestamp}-${index}`}
className={`py-1 border-b border-gray-100 dark:border-gray-800 ${log.type === 'error' ? 'text-red-500' :
className={`py-1 ${log.type === 'error' ? 'text-red-500' :
log.type === 'warn' ? 'text-yellow-500' : ''
}`}
>

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,36 +10,46 @@ 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) {
return { tagsToShow: [], hasMore: false, moreCount: 0 };
}
// Estimate available width in the card (in characters)
const estimatedAvailableWidth = 28; // Estimated number of characters that can fit in one line
// Calculate the character space needed for tags and plus sign (including # and spacing)
const calculateTagWidth = (tag: string) => tag.length + 3; // +3 for # and spacing
// Loop to determine the maximum number of tags that can be displayed
let totalWidth = 0;
let i = 0;
// First, sort tags by length to prioritize displaying shorter tags
const sortedTags = [...server.tags].sort((a, b) => a.length - b.length);
// Calculate how many tags can fit
for (i = 0; i < sortedTags.length; i++) {
const tagWidth = calculateTagWidth(sortedTags[i]);
// If this tag would make the total width exceed available width, stop adding
if (totalWidth + tagWidth > estimatedAvailableWidth) {
break;
}
totalWidth += tagWidth;
// If this is the last tag but there's still space, no need to show "more"
if (i === sortedTags.length - 1) {
return {
@@ -49,16 +59,16 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
};
}
}
// If there's not enough space to display any tags, show at least one
if (i === 0 && sortedTags.length > 0) {
i = 1;
}
// Calculate space needed for the "more" tag
const moreCount = sortedTags.length - i;
const moreTagWidth = 3 + String(moreCount).length + t('market.moreTags').length;
// If there's enough remaining space to display the "more" tag
if (totalWidth + moreTagWidth <= estimatedAvailableWidth || i < 1) {
return {
@@ -67,7 +77,7 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
moreCount
};
}
// If there's not enough space for even the "more" tag, reduce one tag to make room
return {
tagsToShow: sortedTags.slice(0, Math.max(1, i - 1)),
@@ -79,71 +89,90 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
const { tagsToShow, hasMore, moreCount } = getTagsToDisplay();
return (
<div
className="bg-white rounded-lg shadow-md p-5 hover:shadow-lg transition-shadow cursor-pointer flex flex-col h-full"
<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={() => 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="bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded flex-shrink-0">
{t('market.official')}
</span>
)}
</div>
<p className="text-gray-600 text-sm mb-4 line-clamp-2 min-h-[40px]">{server.description}</p>
{/* 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 rounded whitespace-nowrap"
>
{category}
</span>
))
) : (
<span className="text-xs text-gray-400 py-1">-</span>
)}
</div>
{/* 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"
>
#{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')}
{/* 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-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>
{/* 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 border-t border-gray-100">
<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

@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { MarketServer, MarketServerInstallation } from '@/types';
import ServerForm from './ServerForm';
import { detectVariables } from '../utils/variableDetection';
import { ServerConfig } from '@/types';
@@ -23,6 +24,9 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
const { t } = useTranslation();
const [modalVisible, setModalVisible] = useState(false);
const [error, setError] = useState<string | null>(null);
const [confirmationVisible, setConfirmationVisible] = useState(false);
const [pendingPayload, setPendingPayload] = useState<any>(null);
const [detectedVariables, setDetectedVariables] = useState<string[]>([]);
// Helper function to determine button state
const getButtonProps = () => {
@@ -40,7 +44,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
};
} else {
return {
className: "bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm font-medium text-white",
className: "bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm font-medium text-white btn-primary",
disabled: false,
text: t('market.install')
};
@@ -50,6 +54,27 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
const toggleModal = () => {
setModalVisible(!modalVisible);
setError(null); // Clear any previous errors when toggling modal
setConfirmationVisible(false);
setPendingPayload(null);
};
const handleConfirmInstall = async () => {
if (pendingPayload) {
await proceedWithInstall(pendingPayload);
setConfirmationVisible(false);
setPendingPayload(null);
}
};
const proceedWithInstall = async (payload: any) => {
try {
setError(null);
onInstall(server, payload.config);
setModalVisible(false);
} catch (err) {
console.error('Error installing server:', err);
setError(t('errors.serverInstall'));
}
};
const handleInstall = () => {
@@ -72,24 +97,32 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
} else if (server.installations.default) {
return server.installations.default;
}
// If none of the preferred types are available, get the first available installation type
const installTypes = Object.keys(server.installations);
if (installTypes.length > 0) {
return server.installations[installTypes[0]];
}
return undefined;
};
const handleSubmit = async (payload: any) => {
try {
setError(null);
// Pass the server object and the payload (includes env changes) for installation
onInstall(server, payload.config);
setModalVisible(false);
// Check for variables in the payload
const variables = detectVariables(payload);
if (variables.length > 0) {
// Show confirmation dialog
setDetectedVariables(variables);
setPendingPayload(payload);
setConfirmationVisible(true);
} else {
// Install directly if no variables found
await proceedWithInstall(payload);
}
} catch (err) {
console.error('Error installing server:', err);
console.error('Error processing server installation:', err);
setError(t('errors.serverInstall'));
}
};
@@ -114,15 +147,15 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
<div className="flex justify-between items-start mb-4">
<div>
<h2 className="text-2xl font-bold text-gray-900 flex items-center flex-wrap">
{server.display_name}
{server.display_name}
<span className="text-sm font-normal text-gray-500 ml-2">({server.name})</span>
<span className="text-sm font-normal text-gray-600 ml-4">
{t('market.author')}: {server.author.name} {t('market.license')}: {server.license}
{t('market.author')}: {server.author.name} {t('market.license')}: {server.license}
<a
href={server.repository.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline ml-1"
className="text-blue-500 hover:underline ml-1"
>
{t('market.repository')}
</a>
@@ -132,7 +165,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
<div className="flex items-center">
{server.is_official && (
<span className="bg-blue-100 text-blue-800 text-sm font-medium px-4 py-2 rounded mr-2 flex items-center">
<span className="bg-blue-100 text-blue-800 text-sm font-normal px-4 py-2 rounded mr-2 flex items-center label-primary">
{t('market.official')}
</span>
)}
@@ -169,7 +202,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
<h3 className="text-lg font-semibold mb-3">{t('market.arguments')}</h3>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">
{t('market.argumentName')}
@@ -198,7 +231,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
{arg.required ? (
<span className="text-green-600"></span>
) : (
<span className="text-red-600"></span>
<span className="text-gray-600"></span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
@@ -228,7 +261,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
element.classList.toggle('hidden');
}
}}
className="text-sm text-blue-600 hover:underline focus:outline-none ml-2"
className="text-sm text-blue-500 font-normal hover:underline focus:outline-none ml-2"
>
{t('market.viewSchema')}
</button>
@@ -281,17 +314,71 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
initialData={{
name: server.name,
status: 'disconnected',
config: preferredInstallation
config: preferredInstallation
? {
command: preferredInstallation.command || '',
args: preferredInstallation.args || [],
env: preferredInstallation.env || {}
}
command: preferredInstallation.command || '',
args: preferredInstallation.args || [],
env: preferredInstallation.env || {}
}
: undefined
}}
/>
</div>
)}
{confirmationVisible && (
<div className="fixed inset-0 bg-black/50 z-[60] flex items-center justify-center p-4">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
{t('server.confirmVariables')}
</h3>
<p className="text-gray-600 mb-4">
{t('server.variablesDetected')}
</p>
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 mb-4">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-yellow-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">
<h4 className="text-sm font-medium text-yellow-800">
{t('server.detectedVariables')}:
</h4>
<ul className="mt-1 text-sm text-yellow-700">
{detectedVariables.map((variable, index) => (
<li key={index} className="font-mono">
${`{${variable}}`}
</li>
))}
</ul>
</div>
</div>
</div>
<p className="text-gray-600 text-sm mb-6">
{t('market.confirmVariablesMessage')}
</p>
<div className="flex justify-end space-x-3">
<button
onClick={() => {
setConfirmationVisible(false)
setPendingPayload(null)
}}
className="px-4 py-2 text-gray-600 border border-gray-300 rounded hover:bg-gray-50 btn-secondary"
>
{t('common.cancel')}
</button>
<button
onClick={handleConfirmInstall}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 btn-primary"
>
{t('market.confirmAndInstall')}
</button>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,95 @@
import React from 'react';
import { useAuth } from '../contexts/AuthContext';
interface PermissionCheckerProps {
permissions: string | string[];
fallback?: React.ReactNode;
children: React.ReactNode;
}
/**
* Permission checker component for conditional rendering
* @param permissions Required permissions, supports single permission string or permission array
* @param fallback Content to show when permission is denied, defaults to null
* @param children Content to show when permission is granted
*/
export const PermissionChecker: React.FC<PermissionCheckerProps> = ({
permissions,
fallback = null,
children,
}) => {
const hasPermission = usePermissionCheck(permissions);
return hasPermission ? <>{children}</> : <>{fallback}</>;
};
/**
* Permission check hook
* @param requiredPermissions Permissions to check
* @returns Whether user has permission
*/
export const usePermissionCheck = (requiredPermissions: string | string[]): boolean => {
const { auth } = useAuth();
if (!auth.isAuthenticated || !auth.user) {
return false;
}
const userPermissions = auth.user.permissions || [];
if (requiredPermissions === 'x' && !userPermissions.includes('x')) {
return false;
}
// If user has '*' permission, they have all permissions
if (userPermissions.includes('*')) {
return true;
}
// If user is admin, they have all permissions by default
if (auth.user.isAdmin) {
return true;
}
// Normalize required permissions to array
const permissionsToCheck = Array.isArray(requiredPermissions)
? requiredPermissions
: [requiredPermissions];
// Check if user has any of the required permissions
return permissionsToCheck.some(permission =>
userPermissions.includes(permission)
);
};
/**
* Permission check hook - requires all permissions
* @param requiredPermissions Array of permissions to check
* @returns Whether user has all permissions
*/
export const usePermissionCheckAll = (requiredPermissions: string[]): boolean => {
const { auth } = useAuth();
if (!auth.isAuthenticated || !auth.user) {
return false;
}
const userPermissions = auth.user.permissions || [];
// If user has '*' permission, they have all permissions
if (userPermissions.includes('*')) {
return true;
}
// If user is admin, they have all permissions by default
if (auth.user.isAdmin) {
return true;
}
// Check if user has all required permissions
return requiredPermissions.every(permission =>
userPermissions.includes(permission)
);
};
export default PermissionChecker;

View File

@@ -128,7 +128,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
return (
<>
<div className={`bg-white shadow rounded-lg p-6 mb-6 ${server.enabled === false ? 'opacity-60' : ''}`}>
<div className={`bg-white shadow rounded-lg p-6 mb-6 page-card transition-all duration-200 ${server.enabled === false ? 'opacity-60' : ''}`}>
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
@@ -138,7 +138,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
<StatusBadge status={server.status} />
{/* Tool count display */}
<div className="flex items-center px-2 py-1 bg-blue-50 text-blue-700 rounded-full text-sm">
<div className="flex items-center px-2 py-1 bg-blue-50 text-blue-700 rounded-full text-sm btn-primary">
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<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>
@@ -174,7 +174,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
<h4 className="text-sm font-medium text-red-600">{t('server.errorDetails')}</h4>
<button
onClick={copyToClipboard}
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
className="p-1 text-gray-400 hover:text-gray-600 transition-colors btn-secondary"
title={t('common.copy')}
>
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
@@ -201,7 +201,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
<div className="flex space-x-2">
<button
onClick={handleEdit}
className="px-3 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm"
className="px-3 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary"
>
{t('server.edit')}
</button>
@@ -211,8 +211,8 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
className={`px-3 py-1 text-sm rounded transition-colors ${isToggling
? 'bg-gray-200 text-gray-500'
: server.enabled !== false
? 'bg-green-100 text-green-800 hover:bg-green-200'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
? 'bg-green-100 text-green-800 hover:bg-green-200 btn-secondary'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-primary'
}`}
disabled={isToggling}
>
@@ -226,11 +226,11 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
</div>
<button
onClick={handleRemove}
className="px-3 py-1 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm"
className="px-3 py-1 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm btn-danger"
>
{t('server.delete')}
</button>
<button className="text-gray-400 hover:text-gray-600">
<button className="text-gray-400 hover:text-gray-600 btn-secondary">
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
</button>
</div>

View File

@@ -286,7 +286,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
id="name"
value={formData.name}
onChange={handleInputChange}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
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="e.g.: time-mcp"
required
disabled={isEdit}
@@ -403,7 +403,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
...prev,
openapi: { ...prev.openapi!, url: e.target.value }
}))}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
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="e.g.: https://api.example.com/openapi.json"
required={serverType === 'openapi' && formData.openapi?.inputMode === 'url'}
/>
@@ -462,7 +462,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
url: prev.openapi?.url || ''
}
}))}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
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"
>
<option value="none">{t('server.openapi.securityNone')}</option>
<option value="apiKey">{t('server.openapi.securityApiKey')}</option>
@@ -474,7 +474,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
{/* API Key Configuration */}
{formData.openapi?.securityType === 'apiKey' && (
<div className="mb-4 p-4 border rounded bg-gray-50">
<div className="mb-4 p-4 border border-gray-200 rounded bg-gray-50">
<h4 className="text-sm font-medium text-gray-700 mb-3">{t('server.openapi.apiKeyConfig')}</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
@@ -486,7 +486,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
...prev,
openapi: { ...prev.openapi, apiKeyName: e.target.value, url: prev.openapi?.url || '' }
}))}
className="w-full border rounded px-2 py-1 text-sm"
className="w-full border rounded px-2 py-1 text-sm form-input focus:outline-none"
placeholder="Authorization"
/>
</div>
@@ -498,7 +498,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
...prev,
openapi: { ...prev.openapi, apiKeyIn: e.target.value as any, url: prev.openapi?.url || '' }
}))}
className="w-full border rounded px-2 py-1 text-sm"
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
>
<option value="header">Header</option>
<option value="query">Query</option>
@@ -514,7 +514,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
...prev,
openapi: { ...prev.openapi, apiKeyValue: e.target.value, url: prev.openapi?.url || '' }
}))}
className="w-full border rounded px-2 py-1 text-sm"
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
placeholder="your-api-key"
/>
</div>
@@ -524,7 +524,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
{/* HTTP Authentication Configuration */}
{formData.openapi?.securityType === 'http' && (
<div className="mb-4 p-4 border rounded bg-gray-50">
<div className="mb-4 p-4 border border-gray-200 rounded bg-gray-50">
<h4 className="text-sm font-medium text-gray-700 mb-3">{t('server.openapi.httpAuthConfig')}</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
@@ -535,7 +535,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
...prev,
openapi: { ...prev.openapi, httpScheme: e.target.value as any, url: prev.openapi?.url || '' }
}))}
className="w-full border rounded px-2 py-1 text-sm"
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
>
<option value="basic">Basic</option>
<option value="bearer">Bearer</option>
@@ -551,7 +551,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
...prev,
openapi: { ...prev.openapi, httpCredentials: e.target.value, url: prev.openapi?.url || '' }
}))}
className="w-full border rounded px-2 py-1 text-sm"
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
placeholder={formData.openapi?.httpScheme === 'basic' ? 'base64-encoded-credentials' : 'bearer-token'}
/>
</div>
@@ -561,7 +561,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
{/* OAuth2 Configuration */}
{formData.openapi?.securityType === 'oauth2' && (
<div className="mb-4 p-4 border rounded bg-gray-50">
<div className="mb-4 p-4 border border-gray-200 rounded bg-gray-50">
<h4 className="text-sm font-medium text-gray-700 mb-3">{t('server.openapi.oauth2Config')}</h4>
<div className="grid grid-cols-1 gap-3">
<div>
@@ -573,7 +573,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
...prev,
openapi: { ...prev.openapi, oauth2Token: e.target.value, url: prev.openapi?.url || '' }
}))}
className="w-full border rounded px-2 py-1 text-sm"
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
placeholder="access-token"
/>
</div>
@@ -583,7 +583,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
{/* OpenID Connect Configuration */}
{formData.openapi?.securityType === 'openIdConnect' && (
<div className="mb-4 p-4 border rounded bg-gray-50">
<div className="mb-4 p-4 border border-gray-200 rounded bg-gray-50">
<h4 className="text-sm font-medium text-gray-700 mb-3">{t('server.openapi.openIdConnectConfig')}</h4>
<div className="grid grid-cols-1 gap-3">
<div>
@@ -595,7 +595,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
...prev,
openapi: { ...prev.openapi, openIdConnectUrl: e.target.value, url: prev.openapi?.url || '' }
}))}
className="w-full border rounded px-2 py-1 text-sm"
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
placeholder="https://example.com/.well-known/openid_configuration"
/>
</div>
@@ -608,7 +608,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
...prev,
openapi: { ...prev.openapi, openIdConnectToken: e.target.value, url: prev.openapi?.url || '' }
}))}
className="w-full border rounded px-2 py-1 text-sm"
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
placeholder="id-token"
/>
</div>
@@ -624,9 +624,9 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
<button
type="button"
onClick={addHeaderVar}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center"
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] btn-primary"
>
+ {t('server.add')}
+
</button>
</div>
{headerVars.map((headerVar, index) => (
@@ -636,7 +636,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
type="text"
value={headerVar.key}
onChange={(e) => handleHeaderVarChange(index, 'key', e.target.value)}
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2"
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
placeholder="Authorization"
/>
<span className="flex items-center">:</span>
@@ -644,16 +644,16 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
type="text"
value={headerVar.value}
onChange={(e) => handleHeaderVarChange(index, 'value', e.target.value)}
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2"
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
placeholder="Bearer token..."
/>
</div>
<button
type="button"
onClick={() => removeHeaderVar(index)}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[56px] ml-2"
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] ml-2 btn-danger"
>
- {t('server.remove')}
-
</button>
</div>
))}
@@ -671,7 +671,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
id="url"
value={formData.url}
onChange={handleInputChange}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
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={serverType === 'streamable-http' ? "e.g.: http://localhost:3000/mcp" : "e.g.: http://localhost:3000/sse"}
required={serverType === 'sse' || serverType === 'streamable-http'}
/>
@@ -685,9 +685,9 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
<button
type="button"
onClick={addHeaderVar}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center"
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] btn-primary"
>
+ {t('server.add')}
+
</button>
</div>
{headerVars.map((headerVar, index) => (
@@ -697,7 +697,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
type="text"
value={headerVar.key}
onChange={(e) => handleHeaderVarChange(index, 'key', e.target.value)}
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2"
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
placeholder="Authorization"
/>
<span className="flex items-center">:</span>
@@ -705,16 +705,16 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
type="text"
value={headerVar.value}
onChange={(e) => handleHeaderVarChange(index, 'value', e.target.value)}
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2"
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
placeholder="Bearer token..."
/>
</div>
<button
type="button"
onClick={() => removeHeaderVar(index)}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[56px] ml-2"
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] ml-2 btn-danger"
>
- {t('server.remove')}
-
</button>
</div>
))}
@@ -732,7 +732,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
id="command"
value={formData.command}
onChange={handleInputChange}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
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="e.g.: npx"
required={serverType === 'stdio'}
/>
@@ -747,7 +747,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
id="arguments"
value={formData.arguments}
onChange={(e) => handleArgsChange(e.target.value)}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
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="e.g.: -y time-mcp"
required={serverType === 'stdio'}
/>
@@ -761,9 +761,9 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
<button
type="button"
onClick={addEnvVar}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center"
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] btn-primary"
>
+ {t('server.add')}
+
</button>
</div>
{envVars.map((envVar, index) => (
@@ -773,7 +773,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
type="text"
value={envVar.key}
onChange={(e) => handleEnvVarChange(index, 'key', e.target.value)}
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2"
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
placeholder={t('server.key')}
/>
<span className="flex items-center">:</span>
@@ -781,16 +781,16 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
type="text"
value={envVar.value}
onChange={(e) => handleEnvVarChange(index, 'value', e.target.value)}
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2"
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
placeholder={t('server.value')}
/>
</div>
<button
type="button"
onClick={() => removeEnvVar(index)}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[56px] ml-2"
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] ml-2 btn-danger"
>
- {t('server.remove')}
-
</button>
</div>
))}
@@ -802,7 +802,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
{serverType !== 'openapi' && (
<div className="mb-4">
<div
className="flex items-center justify-between cursor-pointer bg-gray-50 hover:bg-gray-100 p-3 rounded border"
className="flex items-center justify-between cursor-pointer bg-gray-50 hover:bg-gray-100 p-3 rounded border border-gray-200"
onClick={() => setIsRequestOptionsExpanded(!isRequestOptionsExpanded)}
>
<label className="text-gray-700 text-sm font-bold">
@@ -814,7 +814,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
</div>
{isRequestOptionsExpanded && (
<div className="border rounded-b p-4 bg-gray-50 border-t-0">
<div className="border border-gray-200 rounded-b p-4 bg-gray-50 border-t-0">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-gray-600 text-sm font-medium mb-1" htmlFor="timeout">
@@ -825,7 +825,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
id="timeout"
value={formData.options?.timeout || 60000}
onChange={(e) => handleOptionsChange('timeout', parseInt(e.target.value) || 60000)}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
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="30000"
min="1000"
max="300000"
@@ -842,7 +842,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
id="maxTotalTimeout"
value={formData.options?.maxTotalTimeout || ''}
onChange={(e) => handleOptionsChange('maxTotalTimeout', e.target.value ? parseInt(e.target.value) : undefined)}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
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="Optional"
min="1000"
/>
@@ -873,13 +873,13 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
<button
type="button"
onClick={onCancel}
className="bg-gray-300 hover:bg-gray-400 text-gray-800 font-medium py-2 px-4 rounded mr-2"
className="bg-gray-300 hover:bg-gray-400 text-gray-800 font-medium py-2 px-4 rounded mr-2 btn-secondary"
>
{t('server.cancel')}
</button>
<button
type="submit"
className="bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 px-4 rounded"
className="bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 px-4 rounded btn-primary"
>
{isEdit ? t('server.save') : t('server.add')}
</button>

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

@@ -13,7 +13,11 @@ import {
Loader,
CheckCircle,
XCircle,
AlertCircle
AlertCircle,
Link,
FileCode,
ChevronDown as DropdownIcon,
Wrench
} from 'lucide-react'
export {
@@ -31,7 +35,11 @@ export {
Loader,
CheckCircle,
XCircle,
AlertCircle
AlertCircle,
Link,
FileCode,
DropdownIcon,
Wrench
}
const LucideIcons = {
@@ -49,7 +57,10 @@ const LucideIcons = {
Loader,
CheckCircle,
XCircle,
AlertCircle
AlertCircle,
Link,
FileCode,
DropdownIcon
}
export default LucideIcons

View File

@@ -0,0 +1,42 @@
// Permission components unified export
export { PermissionChecker, usePermissionCheck, usePermissionCheckAll } from './PermissionChecker';
export { PERMISSIONS } from '../constants/permissions';
// Convenient permission check Hook
export { useAuth } from '../contexts/AuthContext';
// Permission utility functions
export const hasPermission = (
userPermissions: string[] = [],
requiredPermissions: string | string[],
): boolean => {
if (requiredPermissions === 'x' && !userPermissions.includes('x')) {
return false;
}
// If user has '*' permission, it means they have all permissions
if (userPermissions.includes('*')) {
return true;
}
// Normalize required permissions to array
const permissionsToCheck = Array.isArray(requiredPermissions)
? requiredPermissions
: [requiredPermissions];
// Check if user has any of the required permissions
return permissionsToCheck.some((permission) => userPermissions.includes(permission));
};
export const hasAllPermissions = (
userPermissions: string[] = [],
requiredPermissions: string[],
): boolean => {
// If user has '*' permission, it means they have all permissions
if (userPermissions.includes('*')) {
return true;
}
// Check if user has all required permissions
return requiredPermissions.every((permission) => userPermissions.includes(permission));
};

View File

@@ -1,27 +1,19 @@
import React, { useState } from 'react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '@/contexts/AuthContext';
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 { auth } = useAuth();
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">
<div className="flex justify-between items-center px-4 py-3">
<div className="flex justify-between items-center px-3 py-3">
<div className="flex items-center">
{/* 侧边栏切换按钮 */}
<button
@@ -38,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

@@ -1,6 +1,8 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { NavLink, useLocation } from 'react-router-dom';
import { NavLink } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import { usePermissionCheck } from '../PermissionChecker';
import UserProfileMenu from '@/components/ui/UserProfileMenu';
interface SidebarProps {
@@ -15,11 +17,11 @@ interface MenuItem {
const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
const { t } = useTranslation();
const location = useLocation();
const { auth } = useAuth();
// Application version from package.json (accessed via Vite environment variables)
const appVersion = import.meta.env.PACKAGE_VERSION as string;
// Menu item configuration
const menuItems: MenuItem[] = [
{
@@ -50,6 +52,15 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
</svg>
),
},
...(auth.user?.isAdmin && usePermissionCheck('x') ? [{
path: '/users',
label: t('nav.users'),
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
</svg>
),
}] : []),
{
path: '/market',
label: t('nav.market'),
@@ -71,10 +82,9 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
];
return (
<aside
className={`bg-white dark:bg-gray-800 shadow-sm transition-all duration-300 ease-in-out flex flex-col h-full relative ${
collapsed ? 'w-16' : 'w-64'
}`}
<aside
className={`bg-white dark:bg-gray-800 shadow-sm transition-all duration-300 ease-in-out flex flex-col h-full relative ${collapsed ? 'w-16' : 'w-64'
}`}
>
{/* Scrollable navigation area */}
<div className="overflow-y-auto flex-grow">
@@ -83,12 +93,11 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
`flex items-center px-3 py-2 rounded-md transition-colors ${
isActive
? 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`
className={({ isActive }) =>
`flex items-center px-2.5 py-2 rounded-lg transition-colors duration-200
${isActive
? 'bg-blue-50 text-blue-700'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-100'}`
}
end={item.path === '/'}
>
@@ -98,7 +107,7 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
))}
</nav>
</div>
{/* User profile menu fixed at the bottom */}
<div className="p-3 bg-white dark:bg-gray-800">
<UserProfileMenu collapsed={collapsed} version={appVersion} />

View File

@@ -93,7 +93,7 @@ const AboutDialog: React.FC<AboutDialogProps> = ({ isOpen, onClose, version }) =
<button
onClick={checkForUpdates}
disabled={isChecking}
className={`mt-4 inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium
className={`mt-4 inline-flex items-center px-4 py-2 border border-gray-200 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium btn-secondary
${isChecking
? 'text-gray-400 dark:text-gray-500 bg-gray-100 dark:bg-gray-800'
: 'text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600'

View File

@@ -1,6 +1,5 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { ServerStatus } from '@/types';
import { cn } from '../../utils/cn';
type BadgeVariant = 'default' | 'secondary' | 'outline' | 'destructive';
@@ -19,11 +18,11 @@ const badgeVariants = {
destructive: 'bg-red-500 text-white hover:bg-red-600',
};
export function Badge({
children,
variant = 'default',
className,
onClick
export function Badge({
children,
variant = 'default',
className,
onClick
}: BadgeProps) {
return (
<span
@@ -43,11 +42,11 @@ export function Badge({
// For backward compatibility with existing code
export const StatusBadge = ({ status }: { status: 'connected' | 'disconnected' | 'connecting' }) => {
const { t } = useTranslation();
const colors = {
connecting: 'bg-yellow-100 text-yellow-800',
connected: 'bg-green-100 text-green-800',
disconnected: 'bg-red-100 text-red-800',
connecting: 'status-badge-connecting',
connected: 'status-badge-online',
disconnected: 'status-badge-offline',
};
// Map status to translation keys

View File

@@ -0,0 +1,142 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
interface ConfirmDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title?: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: 'danger' | 'warning' | 'info';
}
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText,
cancelText,
variant = 'warning'
}) => {
const { t } = useTranslation();
if (!isOpen) return null;
const getVariantStyles = () => {
switch (variant) {
case 'danger':
return {
icon: (
<svg className="w-6 h-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
),
confirmClass: 'bg-red-600 hover:bg-red-700 text-white',
};
case 'warning':
return {
icon: (
<svg className="w-6 h-6 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
),
confirmClass: 'bg-yellow-600 hover:bg-yellow-700 text-white',
};
case 'info':
return {
icon: (
<svg className="w-6 h-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
),
confirmClass: 'bg-blue-600 hover:bg-blue-700 text-white',
};
default:
return {
icon: null,
confirmClass: 'bg-blue-600 hover:bg-blue-700 text-white',
};
}
};
const { icon, confirmClass } = getVariantStyles();
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
} else if (e.key === 'Enter') {
onConfirm();
}
};
return (
<div
className="fixed inset-0 bg-black/50 z-[100] flex items-center justify-center p-4"
onClick={handleBackdropClick}
onKeyDown={handleKeyDown}
tabIndex={-1}
>
<div
className="bg-white rounded-lg shadow-xl max-w-md w-full transform transition-all duration-200 ease-out"
role="dialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
aria-describedby="confirm-dialog-message"
>
<div className="p-6">
<div className="flex items-start space-x-3">
{icon && (
<div className="flex-shrink-0">
{icon}
</div>
)}
<div className="flex-1">
{title && (
<h3
id="confirm-dialog-title"
className="text-lg font-medium text-gray-900 mb-2"
>
{title}
</h3>
)}
<p
id="confirm-dialog-message"
className="text-gray-600 leading-relaxed"
>
{message}
</p>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button
onClick={onClose}
className="px-4 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-md transition-colors duration-150 btn-secondary"
autoFocus
>
{cancelText || t('common.cancel')}
</button>
<button
onClick={onConfirm}
className={`px-4 py-2 rounded-md transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 ${confirmClass} ${variant === 'danger' ? 'btn-danger' : variant === 'warning' ? 'btn-warning' : 'btn-primary'}`}
>
{confirmText || t('common.confirm')}
</button>
</div>
</div>
</div>
</div>
);
};
export default ConfirmDialog;

View File

@@ -6,9 +6,10 @@ interface DeleteDialogProps {
onConfirm: () => void
serverName: string
isGroup?: boolean
isUser?: boolean
}
const DeleteDialog = ({ isOpen, onClose, onConfirm, serverName, isGroup = false }: DeleteDialogProps) => {
const DeleteDialog = ({ isOpen, onClose, onConfirm, serverName, isGroup = false, isUser = false }: DeleteDialogProps) => {
const { t } = useTranslation()
if (!isOpen) return null
@@ -18,23 +19,29 @@ const DeleteDialog = ({ isOpen, onClose, onConfirm, serverName, isGroup = false
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
<div className="p-6">
<h3 className="text-lg font-medium text-gray-900 mb-3">
{isGroup ? t('groups.confirmDelete') : t('server.confirmDelete')}
{isUser
? t('users.confirmDelete')
: isGroup
? t('groups.confirmDelete')
: t('server.confirmDelete')}
</h3>
<p className="text-gray-500 mb-6">
{isGroup
? t('groups.deleteWarning', { name: serverName })
: t('server.deleteWarning', { name: serverName })}
{isUser
? t('users.deleteWarning', { username: serverName })
: isGroup
? t('groups.deleteWarning', { name: serverName })
: t('server.deleteWarning', { name: serverName })}
</p>
<div className="flex justify-end space-x-3">
<button
onClick={onClose}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
className="px-4 py-2 text-gray-600 hover:text-gray-800 btn-secondary"
>
{t('common.cancel')}
</button>
<button
onClick={onConfirm}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 btn-danger"
>
{t('common.delete')}
</button>

View File

@@ -18,9 +18,10 @@ interface DynamicFormProps {
onCancel: () => void;
loading?: boolean;
storageKey?: string; // Optional key for localStorage persistence
title?: string; // Optional title to display instead of default parameters title
}
const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, loading = false, storageKey }) => {
const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, loading = false, storageKey, title }) => {
const { t } = useTranslation();
const [formValues, setFormValues] = useState<Record<string, any>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
@@ -284,7 +285,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
type="text"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500"
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500 form-input"
placeholder={schema.description || t('tool.enterKey', { key })}
/>
);
@@ -296,12 +297,12 @@ 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);
}}
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500"
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500 form-input"
/>
);
}
@@ -323,7 +324,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
type="text"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500"
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500 form-input"
placeholder={schema.description || t('tool.enterKey', { key })}
/>
);
@@ -340,7 +341,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
@@ -358,7 +359,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
newArray.splice(index, 1);
handleInputChange(fullPath, newArray);
}}
className="text-red-500 hover:text-red-700 text-sm"
className="text-status-red hover:text-red-700 text-sm"
>
{t('common.remove')}
</button>
@@ -387,7 +388,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={objKey}>
<label className="block text-xs font-medium text-gray-600 mb-1">
{objKey}
{propSchema.items?.required?.includes(objKey) && <span className="text-red-500 ml-1">*</span>}
{propSchema.items?.required?.includes(objKey) && <span className="text-status-red ml-1">*</span>}
</label>
{renderObjectField(objKey, objSchema as JsonSchema, item, (newValue) => {
const newArray = [...arrayValue];
@@ -406,7 +407,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
newArray[index] = e.target.value;
handleInputChange(fullPath, newArray);
}}
className="w-full border rounded-md px-3 py-2 border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500"
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"
placeholder={t('tool.enterValue', { type: propSchema.items?.type || 'value' })}
/>
)}
@@ -425,7 +426,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
</button>
</div>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
} // Handle object type
@@ -436,7 +437,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
@@ -448,7 +449,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
))}
</div>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
} else {
@@ -457,7 +458,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
<span className="text-xs text-gray-500 ml-1">(JSON object)</span>
</label>
{propSchema.description && (
@@ -478,7 +479,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
className={`w-full border rounded-md px-3 py-2 font-mono text-sm ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
rows={4}
/>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
}
@@ -488,7 +489,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
@@ -505,7 +506,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
</option>
))}
</select>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
} else {
@@ -513,7 +514,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
@@ -522,9 +523,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
type="text"
value={value || ''}
onChange={(e) => handleInputChange(fullPath, e.target.value)}
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red' : 'border-gray-200'} focus:outline-none form-input`}
/>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
}
@@ -533,7 +534,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
</label>
{propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
@@ -541,14 +542,14 @@ 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);
}}
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
className={`w-full border rounded-md px-3 py-2 form-input ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
/>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
}
@@ -565,13 +566,13 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
/>
<label className="ml-2 block text-sm text-gray-700">
{key}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
</label>
</div>
{propSchema.description && (
<p className="text-xs text-gray-500 mt-1">{propSchema.description}</p>
)}
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
} // For other types, show as text input with description
@@ -579,7 +580,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
{key}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-red-500 ml-1">*</span>}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
<span className="text-xs text-gray-500 ml-1">({propSchema.type})</span>
</label>
{propSchema.description && (
@@ -590,9 +591,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
value={value || ''}
onChange={(e) => handleInputChange(fullPath, e.target.value)}
placeholder={t('tool.enterValue', { type: propSchema.type })}
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500 form-input`}
/>
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
</div>
);
};
@@ -624,15 +625,15 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
return (
<div className="space-y-4">
{/* Mode Toggle */}
<div className="flex justify-between items-center border-b pb-3">
<h3 className="text-lg font-medium text-gray-900">{t('tool.parameters')}</h3>
<div className="flex justify-between items-center pb-3">
<h6 className="text-md font-medium text-gray-900">{title}</h6>
<div className="flex space-x-2">
<button
type="button"
onClick={switchToFormMode}
className={`px-3 py-1 text-sm rounded-md transition-colors ${!isJsonMode
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
? 'bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary'
: 'text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary'
}`}
>
{t('tool.formMode')}
@@ -641,8 +642,8 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
type="button"
onClick={switchToJsonMode}
className={`px-3 py-1 text-sm rounded-md transition-colors ${isJsonMode
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
? 'px-4 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary'
: 'text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary'
}`}
>
{t('tool.jsonMode')}
@@ -661,17 +662,17 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
value={jsonText}
onChange={(e) => handleJsonTextChange(e.target.value)}
placeholder={`{\n "key": "value"\n}`}
className={`w-full h-64 border rounded-md px-3 py-2 font-mono text-sm resize-y ${jsonError ? 'border-red-500' : 'border-gray-300'
className={`w-full h-64 border rounded-md px-3 py-2 font-mono text-sm resize-y form-input ${jsonError ? 'border-red-500' : 'border-gray-300'
} focus:outline-none focus:ring-2 focus:ring-blue-500`}
/>
{jsonError && <p className="text-red-500 text-xs mt-1">{jsonError}</p>}
{jsonError && <p className="text-status-red text-xs mt-1">{jsonError}</p>}
</div>
<div className="flex justify-end space-x-2 pt-4">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-sm text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200"
className="px-4 py-1 text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary"
>
{t('tool.cancel')}
</button>
@@ -685,7 +686,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
}
}}
disabled={loading || !!jsonError}
className="px-4 py-2 text-sm text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50"
className="px-4 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary"
>
{loading ? t('tool.running') : t('tool.runTool')}
</button>
@@ -702,14 +703,14 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-sm text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200"
className="px-4 py-1 text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary"
>
{t('tool.cancel')}
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-2 text-sm text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50"
className="px-4 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary"
>
{loading ? t('tool.running') : t('tool.runTool')}
</button>

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

@@ -6,34 +6,33 @@ interface PaginationProps {
onPageChange: (page: number) => void;
}
const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalPages,
onPageChange
const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalPages,
onPageChange
}) => {
// Generate page buttons
const getPageButtons = () => {
const buttons = [];
const maxDisplayedPages = 5; // Maximum number of page buttons to display
// Always display first page
buttons.push(
<button
key="first"
onClick={() => onPageChange(1)}
className={`px-3 py-1 mx-1 rounded ${
currentPage === 1
? 'bg-blue-500 text-white'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
}`}
className={`px-3 py-1 mx-1 rounded ${currentPage === 1
? 'bg-blue-500 text-white btn-primary'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
}`}
>
1
</button>
);
// Start range
let startPage = Math.max(2, currentPage - Math.floor(maxDisplayedPages / 2));
const startPage = Math.max(2, currentPage - Math.floor(maxDisplayedPages / 2));
// If we're showing ellipsis after first page
if (startPage > 2) {
buttons.push(
@@ -42,24 +41,23 @@ const Pagination: React.FC<PaginationProps> = ({
</span>
);
}
// Middle pages
for (let i = startPage; i <= Math.min(totalPages - 1, startPage + maxDisplayedPages - 3); i++) {
buttons.push(
<button
key={i}
onClick={() => onPageChange(i)}
className={`px-3 py-1 mx-1 rounded ${
currentPage === i
? 'bg-blue-500 text-white'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
}`}
className={`px-3 py-1 mx-1 rounded ${currentPage === i
? 'bg-blue-500 text-white btn-primary'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
}`}
>
{i}
</button>
);
}
// If we're showing ellipsis before last page
if (startPage + maxDisplayedPages - 3 < totalPages - 1) {
buttons.push(
@@ -68,24 +66,23 @@ const Pagination: React.FC<PaginationProps> = ({
</span>
);
}
// Always display last page if there's more than one page
if (totalPages > 1) {
buttons.push(
<button
key="last"
onClick={() => onPageChange(totalPages)}
className={`px-3 py-1 mx-1 rounded ${
currentPage === totalPages
? 'bg-blue-500 text-white'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
}`}
className={`px-3 py-1 mx-1 rounded ${currentPage === totalPages
? 'bg-blue-500 text-white btn-primary'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
}`}
>
{totalPages}
</button>
);
}
return buttons;
};
@@ -99,25 +96,23 @@ const Pagination: React.FC<PaginationProps> = ({
<button
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className={`px-3 py-1 rounded mr-2 ${
currentPage === 1
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
}`}
className={`px-3 py-1 rounded mr-2 ${currentPage === 1
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
}`}
>
&laquo; Prev
</button>
<div className="flex">{getPageButtons()}</div>
<button
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className={`px-3 py-1 rounded ml-2 ${
currentPage === totalPages
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
}`}
className={`px-3 py-1 rounded ml-2 ${currentPage === totalPages
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
}`}
>
Next &raquo;
</button>

View File

@@ -1,50 +1,25 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useTheme } from '@/contexts/ThemeContext';
import { Sun, Moon, Monitor } from 'lucide-react';
import { Sun, Moon } from 'lucide-react';
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-200 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

@@ -9,7 +9,6 @@ interface ToggleGroupItemProps {
}
export const ToggleGroupItem: React.FC<ToggleGroupItemProps> = ({
value,
isSelected,
onClick,
children
@@ -21,8 +20,8 @@ export const ToggleGroupItem: React.FC<ToggleGroupItemProps> = ({
aria-checked={isSelected}
className={cn(
"flex w-full items-center justify-between p-2 rounded transition-colors cursor-pointer",
isSelected
? "bg-blue-50 text-blue-700 hover:bg-blue-100 border-l-4 border-blue-500"
isSelected
? "bg-blue-50 text-blue-700 hover:bg-blue-100 border-l-4 border-blue-500"
: "hover:bg-gray-50 text-gray-700"
)}
onClick={onClick}
@@ -72,7 +71,7 @@ export const ToggleGroup: React.FC<ToggleGroupProps> = ({
<label className="block text-gray-700 text-sm font-bold mb-2">
{label}
</label>
<div className="border rounded shadow max-h-60 overflow-y-auto">
<div className="border border-gray-200 rounded shadow max-h-60 overflow-y-auto">
{options.length === 0 ? (
<p className="text-gray-500 text-sm p-3">{noOptionsText}</p>
) : (
@@ -118,7 +117,7 @@ export const Switch: React.FC<SwitchProps> = ({
disabled={disabled}
className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500",
checked ? "bg-blue-600" : "bg-gray-200",
checked ? "bg-blue-200" : "bg-gray-100",
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
)}
onClick={() => !disabled && onCheckedChange(!checked)}

View File

@@ -130,7 +130,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
}
return (
<div className="bg-white border border-gray-300 shadow rounded-lg p-4 mb-4">
<div className="bg-white border border-gray-200 shadow rounded-lg p-4 mb-4">
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
@@ -144,7 +144,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
<input
ref={descriptionInputRef}
type="text"
className="px-2 py-1 border border-blue-300 rounded bg-white text-sm"
className="px-2 py-1 border border-blue-300 rounded bg-white text-sm focus:outline-none form-input"
value={customDescription}
onChange={handleDescriptionChange}
onKeyDown={handleDescriptionKeyDown}
@@ -155,7 +155,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
}}
/>
<button
className="ml-2 p-1 text-green-600 hover:text-green-800"
className="ml-2 p-1 text-green-600 hover:text-green-800 cursor-pointer transition-colors"
onClick={(e) => {
e.stopPropagation()
handleDescriptionSave()
@@ -168,7 +168,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
<>
<span ref={descriptionTextRef}>{customDescription || t('tool.noDescription')}</span>
<button
className="ml-2 p-1 text-gray-500 hover:text-blue-600 transition-colors"
className="ml-2 p-1 text-gray-500 hover:text-blue-600 cursor-pointer transition-colors"
onClick={(e) => {
e.stopPropagation()
handleDescriptionEdit()
@@ -198,7 +198,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
setIsExpanded(true) // Ensure card is expanded when showing run form
setShowRunForm(true)
}}
className="flex items-center space-x-1 px-3 py-1 text-sm text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-md transition-colors"
className="flex items-center space-x-1 px-3 py-1 text-sm text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-md transition-colors btn-primary"
disabled={isRunning || !tool.enabled}
>
{isRunning ? (
@@ -228,14 +228,14 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
{/* Run Form */}
{showRunForm && (
<div className="border border-gray-300 rounded-lg p-4 bg-blue-50">
<h4 className="text-sm font-medium text-gray-900 mb-3">{t('tool.runToolWithName', { name: tool.name.replace(server + '-', '') })}</h4>
<div className="border border-gray-300 rounded-lg p-4">
<DynamicForm
schema={tool.inputSchema || { type: 'object' }}
onSubmit={handleRunTool}
onCancel={handleCancelRun}
loading={isRunning}
storageKey={getStorageKey()}
title={t('tool.runToolWithName', { name: tool.name.replace(server + '-', '') })}
/>
{/* Tool Result */}
{result && (

View File

@@ -65,7 +65,6 @@ const ToolResult: React.FC<ToolResultProps> = ({ result, onClose }) => {
// For other structured content, try to parse as JSON
try {
const jsonString = typeof item === 'string' ? item : JSON.stringify(item, null, 2);
const parsed = typeof item === 'string' ? JSON.parse(item) : item;
return (
@@ -97,9 +96,9 @@ const ToolResult: React.FC<ToolResultProps> = ({ result, onClose }) => {
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
{result.success ? (
<CheckCircle size={20} className="text-green-500" />
<CheckCircle size={20} className="text-status-green" />
) : (
<XCircle size={20} className="text-red-500" />
<XCircle size={20} className="text-status-red" />
)}
<div>
<h4 className="text-sm font-medium text-gray-900">

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
@@ -73,7 +90,7 @@ const UserProfileMenu: React.FC<UserProfileMenuProps> = ({ collapsed, version })
}`}
>
<div className="flex-shrink-0 relative">
<div className="w-7 h-7 flex items-center justify-center rounded-full border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700">
<div className="w-5 h-5 flex items-center justify-center rounded-full border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700">
<User className="h-4 w-4 text-gray-700 dark:text-gray-300" />
</div>
{showNewVersionInfo && (
@@ -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-48 bg-white dark:bg-gray-800 shadow-lg rounded-md 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

@@ -0,0 +1,9 @@
// Predefined permission constants
export const PERMISSIONS = {
// Settings page permissions
SETTINGS_SMART_ROUTING: 'settings:smart_routing',
SETTINGS_SKIP_AUTH: 'settings:skip_auth',
SETTINGS_INSTALL_CONFIG: 'settings:install_config',
} as const;
export default PERMISSIONS;

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 {
@@ -16,6 +16,7 @@ interface RoutingConfig {
interface InstallConfig {
pythonIndexUrl: string;
npmRegistry: string;
baseUrl: string;
}
interface SmartRoutingConfig {
@@ -26,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;
};
}
@@ -57,6 +66,7 @@ export const useSettingsData = () => {
const [installConfig, setInstallConfig] = useState<InstallConfig>({
pythonIndexUrl: '',
npmRegistry: '',
baseUrl: 'http://localhost:3000',
});
const [smartRoutingConfig, setSmartRoutingConfig] = useState<SmartRoutingConfig>({
@@ -67,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);
@@ -82,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({
@@ -108,6 +114,7 @@ export const useSettingsData = () => {
setInstallConfig({
pythonIndexUrl: data.data.systemConfig.install.pythonIndexUrl || '',
npmRegistry: data.data.systemConfig.install.npmRegistry || '',
baseUrl: data.data.systemConfig.install.baseUrl || 'http://localhost:3000',
});
}
if (data.success && data.data?.systemConfig?.smartRouting) {
@@ -120,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');
@@ -131,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,
@@ -167,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) {
@@ -186,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,
@@ -214,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) {
@@ -236,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,
@@ -286,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,
@@ -334,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,
@@ -360,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) {
@@ -373,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();
@@ -392,6 +403,7 @@ export const useSettingsData = () => {
setTempRoutingConfig,
installConfig,
smartRoutingConfig,
mcpRouterConfig,
loading,
error,
setError,
@@ -402,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

@@ -1,11 +1,22 @@
/* Use project's custom Tailwind import */
@import "tailwindcss";
@import 'tailwindcss';
/* Add some custom styles to verify CSS is working correctly */
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
font-family:
'Inter',
'PingFang SC',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
'Roboto',
'Oxygen',
'Ubuntu',
'Cantarell',
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@@ -13,7 +24,7 @@ body {
/* Dark mode override styles - these will apply when dark class is on html element */
.dark body {
background-color: #111827;
background-color: #1f2a37;
color: #e5e7eb;
}
@@ -37,30 +48,459 @@ body {
color: #d1d5db !important;
}
.dark .text-gray-500 {
/* .dark .text-gray-500 {
color: #9ca3af !important;
}
} */
.dark .border-gray-300 {
border-color: #4b5563 !important;
border-color: #2f3b4c !important;
}
.dark .border-gray-200 {
border-color: #2f3b4c !important;
}
.dark .divide-gray-200 > :not([hidden]) ~ :not([hidden]) {
border-color: #2f3b4c !important;
}
.dark .bg-gray-100 {
background-color: #374151 !important;
}
/* Specific hover effects for dark mode */
.dark .hover\:bg-gray-100:hover {
background-color: rgba(110, 127, 156, 0.15) !important;
}
.dark .hover\:text-gray-900:hover {
color: rgb(190, 188, 185) !important;
}
.dark .bg-gray-50 {
background-color: #1f2937 !important;
}
.dark .text-blue-700 {
color: white !important;
}
.dark .bg-blue-50 {
background-color: #4b5563 !important;
}
.dark .bg-blue-200 {
background-color: #576476 !important;
}
.dark .shadow {
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.4), 0 1px 2px 0 rgba(0, 0, 0, 0.24) !important;
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.15),
0 2px 6px rgba(0, 0, 0, 0.1) !important;
}
.bg-custom-blue {
background-color: #4a90e2;
background-color: #4a90e2;
}
.text-custom-white {
color: #ffffff;
}
}
.status-badge-online {
background-color: white !important;
color: rgba(129, 199, 132, 0.9) !important;
border: 1px solid #a6d7b7;
}
/* Enhanced status badge styles for dark theme */
.dark .status-badge-online {
background-color: rgba(76, 175, 80, 0.15) !important;
color: rgba(129, 199, 132, 0.9) !important;
border: 1px solid rgba(76, 175, 80, 0.3);
}
.status-badge-offline {
background-color: white !important;
color: rgba(107, 114, 128, 0.9) !important;
border: 1px solid #d1d5db;
}
.dark .status-badge-offline {
background-color: rgba(107, 114, 128, 0.15) !important;
color: rgba(156, 163, 175, 0.9) !important;
border: 1px solid rgba(107, 114, 128, 0.3);
}
.status-badge-connecting {
background-color: white !important;
color: rgba(255, 213, 79, 0.9) !important;
border: 1px solid #ffd57f;
}
.dark .status-badge-connecting {
background-color: rgba(255, 193, 7, 0.15) !important;
color: rgba(255, 213, 79, 0.9) !important;
border: 1px solid rgba(255, 193, 7, 0.3);
}
/* Enhanced status icons for dark theme */
.dark .status-icon-blue {
background-color: rgba(59, 130, 246, 0.15) !important;
color: rgba(96, 165, 250, 0.9) !important;
}
.dark .status-icon-green {
background-color: rgba(76, 175, 80, 0.15) !important;
color: rgba(129, 199, 132, 0.9) !important;
}
.dark .status-icon-red {
background-color: rgba(244, 67, 54, 0.15) !important;
color: rgba(239, 154, 154, 0.9) !important;
}
.dark .status-icon-yellow {
background-color: rgba(255, 193, 7, 0.15) !important;
color: rgba(255, 213, 79, 0.9) !important;
}
/* Enhanced card hover effects */
.dashboard-card {
transition: all 0.3s ease;
border-radius: 12px;
}
.dashboard-card:hover {
transform: translateY(-2px);
box-shadow:
0 8px 25px rgba(0, 0, 0, 0.2),
0 4px 12px rgba(0, 0, 0, 0.15) !important;
}
/* Icon container hover effects */
.icon-container {
transition: all 0.3s ease;
}
.icon-container:hover {
transform: scale(1.05);
filter: brightness(1.1);
}
/* Progress bar enhancements */
.progress-bar-online {
background: linear-gradient(90deg, rgba(76, 175, 80, 0.8), rgba(129, 199, 132, 0.6));
}
.progress-bar-offline {
background: linear-gradient(90deg, rgba(244, 67, 54, 0.8), rgba(239, 154, 154, 0.6));
}
.progress-bar-connecting {
background: linear-gradient(90deg, rgba(255, 193, 7, 0.8), rgba(255, 213, 79, 0.6));
}
/* Table enhancements for dark theme */
.dark .table-container {
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.dark thead {
background-color: #252d3a !important;
}
.dark tbody tr {
border-bottom: 1px solid #2f3b4c;
}
tbody tr:hover {
background-color: var(--color-gray-100) !important;
transition: background-color 0.2s ease;
}
.dark tbody tr:hover {
background-color: rgba(55, 65, 81, 0.5) !important;
transition: background-color 0.2s ease;
}
/* Error box enhancements for dark theme */
.dark .error-box {
background-color: rgba(244, 67, 54, 0.1) !important;
border-color: rgba(244, 67, 54, 0.3) !important;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(244, 67, 54, 0.1);
}
.dark .error-box h3 {
color: rgba(239, 154, 154, 0.9) !important;
}
.dark .error-box p {
color: #d1d5db !important;
}
/* Loading container enhancements */
.loading-container {
border-radius: 12px;
backdrop-filter: blur(10px);
}
.dark .loading-container {
background-color: rgba(31, 41, 55, 0.8) !important;
border: 1px solid #2f3b4c;
}
.label-primary {
background-color: var(--color-blue-50) !important;
color: var(--color-blue-500) !important;
}
.dark .label-primary {
background-color: rgba(59, 130, 246, 0.15) !important;
color: rgba(96, 165, 250, 0.9) !important;
}
.label-secondary {
background-color: var(--color-green-50) !important;
color: var(--color-green-500) !important;
}
.dark .label-secondary {
background-color: rgba(76, 175, 80, 0.15) !important;
color: rgba(129, 199, 132, 0.9) !important;
}
.btn-primary {
background-color: #60a5fa !important;
color: #ffffff !important;
border: none;
border-radius: 8px;
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(96, 165, 250, 0.2);
}
.btn-primary:hover {
background-color: #3b82f6 !important;
color: #ffffff !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
/* Enhanced button styles for dark theme */
.dark .btn-primary {
background-color: rgba(59, 130, 246, 0.15) !important;
color: rgba(96, 165, 250, 0.9) !important;
border: 1px solid rgba(59, 130, 246, 0.3);
transition: all 0.3s ease;
}
.dark .btn-primary:hover {
background-color: rgba(59, 130, 246, 0.25) !important;
color: rgba(96, 165, 250, 1) !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2);
}
.btn-secondary {
background-color: #f9fafb !important;
color: #374151 !important;
border: 1px solid #d1d5db !important;
border-radius: 8px;
font-size: 0.875rem;
}
.btn-secondary:hover {
background-color: #e5e7eb !important;
color: #374151 !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.dark .btn-secondary {
background-color: rgba(107, 114, 128, 0.15) !important;
color: rgba(156, 163, 175, 0.9) !important;
border: 1px solid rgba(107, 114, 128, 0.3) !important;
transition: all 0.3s ease;
}
.dark .btn-secondary:hover {
background-color: rgba(107, 114, 128, 0.25) !important;
color: rgba(156, 163, 175, 1) !important;
transform: translateY(-1px);
}
.btn-warning {
background-color: var(--color-yellow-100) !important;
color: var(--color-yellow-800) !important;
border: none;
border-radius: 8px;
transition: all 0.3s ease;
}
.btn-warning:hover {
background-color: var(--color-yellow-200) !important;
color: var(--color-yellow-800) !important;
}
.dark .btn-warning {
background-color: rgba(234, 179, 8, 0.15) !important;
color: rgba(250, 204, 21, 0.9) !important;
border: 1px solid rgba(234, 179, 8, 0.3);
transition: all 0.3s ease;
}
.dark .btn-warning:hover {
background-color: rgba(234, 179, 8, 0.25) !important;
color: rgba(250, 204, 21, 1) !important;
transform: translateY(-1px);
}
.btn-danger {
background-color: var(--color-red-100) !important;
color: var(--color-red-800) !important;
border: none;
border-radius: 8px;
transition: all 0.3s ease;
}
.btn-danger:hover {
background-color: var(--color-red-200) !important;
color: var(--color-red-800) !important;
}
.dark .btn-danger {
background-color: rgba(244, 67, 54, 0.15) !important;
color: rgba(239, 154, 154, 0.9) !important;
border: 1px solid rgba(244, 67, 54, 0.3);
transition: all 0.3s ease;
}
.dark .btn-danger:hover {
background-color: rgba(244, 67, 54, 0.25) !important;
color: rgba(239, 154, 154, 1) !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(244, 67, 54, 0.2);
}
.form-input {
background-color: #f9fafb !important;
border-color: #d1d5db !important;
color: #374151 !important;
border-radius: 8px;
transition: all 0.3s ease;
}
.form-input:focus {
border-color: rgba(184, 193, 207, 0.5);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* Form input enhancements for dark theme */
.dark .form-input {
background-color: #1f2937 !important;
border-color: #2f3b4c !important;
color: #e5e7eb !important;
border-radius: 8px;
}
.dark .form-input:focus {
border-color: rgba(59, 130, 246, 0.5) !important;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1) !important;
}
.dark .form-input::placeholder {
color: #9ca3af !important;
}
/* Card spacing and layout improvements */
.page-card {
border-radius: 12px;
transition: all 0.3s ease;
}
.page-card:hover {
transform: translateY(-1px);
}
.dark .page-card {
background-color: #1f2937 !important;
border: 1px solid #2f3b4c;
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.15),
0 2px 6px rgba(0, 0, 0, 0.1);
}
/* Custom text color to match status-icon-red */
.text-status-red {
color: #991b1b; /* Tailwind red-800 for light mode */
}
.dark .text-status-red {
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 */
}
.dark .border-red {
border-color: rgba(188, 161, 161, 0.9) !important;
}
.dark .text-status-green {
color: rgba(129, 199, 132, 0.9) !important;
}
/* Empty state styling */
.dark .empty-state {
background-color: #1f2937 !important;
border: 1px solid #2f3b4c;
border-radius: 12px;
text-align: center;
padding: 3rem 2rem;
}
.dark .empty-state p {
color: #9ca3af !important;
}
/* Login page enhancements for dark theme */
.dark .login-container {
background-color: #1f2a37 !important;
}
.dark .login-card {
background-color: #1f2937 !important;
border: 1px solid #2f3b4c;
box-shadow:
0 8px 25px rgba(0, 0, 0, 0.2),
0 4px 12px rgba(0, 0, 0, 0.15);
border-radius: 12px;
}

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

@@ -1,7 +1,6 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useServerData } from '@/hooks/useServerData';
import { ServerStatus } from '@/types';
const DashboardPage: React.FC = () => {
const { t } = useTranslation();
@@ -22,26 +21,20 @@ const DashboardPage: React.FC = () => {
connecting: 'status.connecting'
}
// Calculate percentage for each status (for dashboard display)
const getStatusPercentage = (status: ServerStatus) => {
if (servers.length === 0) return 0;
return Math.round((servers.filter(server => server.status === status).length / servers.length) * 100);
};
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-8">{t('pages.dashboard.title')}</h1>
{error && (
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm">
<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>
<h3 className="text-status-red text-lg font-medium">{t('app.error')}</h3>
<p className="text-gray-600 mt-1">{error}</p>
</div>
<button
onClick={() => setError(null)}
className="ml-4 text-gray-500 hover:text-gray-700"
className="ml-4 text-gray-500 hover:text-gray-700 transition-colors duration-200"
aria-label={t('app.closeButton')}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
@@ -52,8 +45,8 @@ const DashboardPage: React.FC = () => {
</div>
)}
{isLoading ? (
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center">
{isLoading && (
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center loading-container">
<div className="flex flex-col items-center">
<svg className="animate-spin h-10 w-10 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>
@@ -62,12 +55,14 @@ const DashboardPage: React.FC = () => {
<p className="text-gray-600">{t('app.loading')}</p>
</div>
</div>
) : (
)}
{!isLoading && (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
{/* Total servers */}
<div className="bg-white rounded-lg shadow p-6">
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
<div className="flex items-center">
<div className="p-3 rounded-full bg-blue-100 text-blue-800">
<div className="p-3 rounded-full bg-blue-100 text-blue-800 icon-container status-icon-blue">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
@@ -80,9 +75,9 @@ const DashboardPage: React.FC = () => {
</div>
{/* Online servers */}
<div className="bg-white rounded-lg shadow p-6">
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
<div className="flex items-center">
<div className="p-3 rounded-full bg-green-100 text-green-800">
<div className="p-3 rounded-full bg-green-100 text-green-800 icon-container status-icon-green">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
@@ -92,18 +87,12 @@ const DashboardPage: React.FC = () => {
<p className="text-3xl font-bold text-gray-900">{serverStats.online}</p>
</div>
</div>
<div className="mt-4 h-2 bg-gray-200 rounded-full">
<div
className="h-full bg-green-500 rounded-full"
style={{ width: `${getStatusPercentage('connected')}%` }}
></div>
</div>
</div>
{/* Offline servers */}
<div className="bg-white rounded-lg shadow p-6">
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
<div className="flex items-center">
<div className="p-3 rounded-full bg-red-100 text-red-800">
<div className="p-3 rounded-full bg-red-100 text-red-800 icon-container status-icon-red">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
@@ -113,18 +102,12 @@ const DashboardPage: React.FC = () => {
<p className="text-3xl font-bold text-gray-900">{serverStats.offline}</p>
</div>
</div>
<div className="mt-4 h-2 bg-gray-200 rounded-full">
<div
className="h-full bg-red-500 rounded-full"
style={{ width: `${getStatusPercentage('disconnected')}%` }}
></div>
</div>
</div>
{/* Connecting servers */}
<div className="bg-white rounded-lg shadow p-6">
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
<div className="flex items-center">
<div className="p-3 rounded-full bg-yellow-100 text-yellow-800">
<div className="p-3 rounded-full bg-yellow-100 text-yellow-800 icon-container status-icon-yellow">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
@@ -134,12 +117,7 @@ const DashboardPage: React.FC = () => {
<p className="text-3xl font-bold text-gray-900">{serverStats.connecting}</p>
</div>
</div>
<div className="mt-4 h-2 bg-gray-200 rounded-full">
<div
className="h-full bg-yellow-500 rounded-full"
style={{ width: `${getStatusPercentage('connecting')}%` }}
></div>
</div>
</div>
</div>
)}
@@ -148,20 +126,20 @@ const DashboardPage: React.FC = () => {
{servers.length > 0 && !isLoading && (
<div className="mt-8">
<h2 className="text-xl font-semibold text-gray-900 mb-4">{t('pages.dashboard.recentServers')}</h2>
<div className="bg-white shadow rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<div className="bg-white shadow rounded-lg overflow-hidden table-container">
<table className="min-w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('server.name')}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('server.status')}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('server.tools')}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('server.enabled')}
</th>
</tr>
@@ -173,11 +151,11 @@ const DashboardPage: React.FC = () => {
{server.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${server.status === 'connected'
? 'bg-green-100 text-green-800'
: server.status === 'disconnected'
? 'bg-red-100 text-red-800'
: 'bg-yellow-100 text-yellow-800'
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${server.status === 'connected'
? 'status-badge-online'
: server.status === 'disconnected'
? 'status-badge-offline'
: 'status-badge-connecting'
}`}>
{t(statusTranslations[server.status] || server.status)}
</span>
@@ -189,7 +167,7 @@ const DashboardPage: React.FC = () => {
{server.enabled !== false ? (
<span className="text-green-600"></span>
) : (
<span className="text-red-600"></span>
<span className="text-status-red"></span>
)}
</td>
</tr>

View File

@@ -9,16 +9,16 @@ import GroupCard from '@/components/GroupCard';
const GroupsPage: React.FC = () => {
const { t } = useTranslation();
const {
groups,
loading: groupsLoading,
error: groupError,
const {
groups,
loading: groupsLoading,
error: groupError,
setError: setGroupError,
deleteGroup,
triggerRefresh
} = useGroupData();
const { servers } = useServerData();
const [editingGroup, setEditingGroup] = useState<Group | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
@@ -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'));
}
};
@@ -54,7 +54,7 @@ const GroupsPage: React.FC = () => {
<div className="flex space-x-4">
<button
onClick={handleAddGroup}
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center"
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-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 3a1 1 0 00-1 1v5H4a1 1 0 100 2h5v5a1 1 0 102 0v-5h5a1 1 0 100-2h-5V4a1 1 0 00-1-1z" clipRule="evenodd" />
@@ -65,13 +65,13 @@ const GroupsPage: React.FC = () => {
</div>
{groupError && (
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6">
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6 error-box rounded-lg">
<p>{groupError}</p>
</div>
)}
{groupsLoading ? (
<div className="bg-white shadow rounded-lg p-6">
<div className="bg-white shadow rounded-lg p-6 loading-container">
<div className="flex flex-col items-center justify-center">
<svg className="animate-spin h-10 w-10 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>
@@ -81,7 +81,7 @@ const GroupsPage: React.FC = () => {
</div>
</div>
) : groups.length === 0 ? (
<div className="bg-white shadow rounded-lg p-6">
<div className="bg-white shadow rounded-lg p-6 empty-state">
<p className="text-gray-600">{t('groups.noGroups')}</p>
</div>
) : (

Some files were not shown because too many files have changed in this diff Show More