Compare commits

...

32 Commits

Author SHA1 Message Date
samanhappy
6988618c41 fix: update Docker tags for latest and latest-full variants in build … (#19) 2025-04-18 19:18:34 +08:00
samanhappy
964ab4a5d7 docs: add group part (#18) 2025-04-18 17:25:02 +08:00
samanhappy
b59243e410 fix: add scope to cache-to configuration in build workflow (#17) 2025-04-18 16:30:42 +08:00
samanhappy
85c461bbfa fix: enhance clipboard copy functionality with fallback for unsupport… (#15) 2025-04-18 15:01:24 +08:00
samanhappy
f477e1f942 fix: add catch-all route to serve frontend index.html (#14)
Co-authored-by: samanhappy@qq.com <my6051199>
2025-04-18 15:00:52 +08:00
samanhappy
7feb5b2bcb fix: remove scheduled trigger from build workflow (#16) 2025-04-18 15:00:26 +08:00
samanhappy
66d592da1c fix: handle error when sending tool list changed notification (#13) 2025-04-17 19:21:28 +08:00
samanhappy
adef02d3b9 feat: add group management functionality (#12) 2025-04-17 18:55:04 +08:00
samanhappy
6a065ca2f2 fix: add release notes generation to GitHub release workflow (#11)
Co-authored-by: samanhappy@qq.com <my6051199>
2025-04-17 18:52:35 +08:00
samanhappy
d94a58ebca Refactor server management UI (#9) 2025-04-15 19:02:38 +08:00
samanhappy
6af13f85d4 fix: update build workflow to support scheduled and manual triggers (#10) 2025-04-15 15:34:44 +08:00
samanhappy
f026e621a5 Merge pull request #7 from samanhappy/dependabot/npm_and_yarn/vite-5.4.18
chore(deps-dev): bump vite from 5.4.17 to 5.4.18
2025-04-15 13:08:07 +08:00
samanhappy
1f17780000 Merge pull request #8 from samanhappy/toggle
feat: add server toggle functionality
2025-04-15 13:07:42 +08:00
samanhappy
f94acb8bef fix: sort server information by enabled status in getServersInfo 2025-04-15 09:41:20 +08:00
samanhappy
51289b8ca8 fix: improve server card styling and remove unnecessary comment 2025-04-15 09:34:47 +08:00
samanhappy
2c4f9d1c24 fix: update localization strings for clarity and consistency in English and Chinese 2025-04-15 09:27:58 +08:00
samanhappy@qq.com
2e1f73ef64 feat: add server toggle functionality 2025-04-14 22:25:07 +08:00
samanhappy
40d8792294 fix: update Dockerfile to conditionally install Playwright dependencies for Chrome based on architecture 2025-04-14 20:01:24 +08:00
samanhappy
7f887a7031 fix: update Dockerfile to install Playwright dependencies for Chrome instead of Chromium 2025-04-14 19:56:19 +08:00
samanhappy
5a2be4d4fe fix: update Dockerfile to set Playwright browser path and remove unnecessary environment variable 2025-04-14 19:34:07 +08:00
samanhappy
f6c5cde9ce fix: update Dockerfile to set Playwright browser path and environment variables 2025-04-14 19:25:26 +08:00
samanhappy
1e6d10a0c3 fix: update Dockerfile to install Playwright dependencies for Chromium instead of Firefox 2025-04-14 18:56:12 +08:00
samanhappy
1ea8c3c866 fix: update Dockerfile to conditionally install Playwright dependencies based on INSTALL_EXT argument 2025-04-14 17:09:21 +08:00
samanhappy
6b56a9a554 fix: update Playwright installation to use Chrome instead of Chromium 2025-04-14 16:53:32 +08:00
samanhappy
a1d2c679d6 fix: install Chromium for Playwright in Dockerfile 2025-04-13 22:12:20 +08:00
samanhappy
fe10273fc3 fix: update Playwright installation to include default browser 2025-04-13 22:03:47 +08:00
samanhappy
649a454ba8 fix: install Chrome for Playwright in Dockerfile 2025-04-13 21:54:48 +08:00
samanhappy
bc8be7578f fix: set up PNPM environment variables and directory for global installations 2025-04-13 21:35:29 +08:00
samanhappy
c5a8cfd88f fix: change Python base image from alpine to slim-bookworm 2025-04-13 21:31:26 +08:00
samanhappy
8905757c69 fix: update Python base image to 3.13-alpine and streamline package installation 2025-04-13 21:29:36 +08:00
dependabot[bot]
c896fef8ee chore(deps-dev): bump vite from 5.4.17 to 5.4.18
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.17 to 5.4.18.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.18/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.18/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 5.4.18
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-13 08:30:18 +00:00
samanhappy
09b8a478b9 fix: add platforms specification for Docker build in workflow 2025-04-13 16:21:36 +08:00
44 changed files with 3220 additions and 898 deletions

View File

@@ -2,12 +2,15 @@ name: Build
on:
push:
branches: ['main']
tags: ['v*.*.*']
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
variant: ${{ startsWith(github.ref, 'refs/tags/') && fromJSON('["base", "full"]') || fromJSON('["base"]') }}
steps:
- uses: actions/checkout@v4
with:
@@ -28,9 +31,11 @@ jobs:
with:
images: samanhappy/mcphub
tags: |
type=raw,value=edge,enable=${{ github.ref == 'refs/heads/main' }}
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/') }}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/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/') }}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') && matrix.variant == 'base' }}
type=raw,value=latest-full,enable=${{ startsWith(github.ref, 'refs/tags/') && matrix.variant == 'full' }}
- name: Build and Push Docker Image
uses: docker/build-push-action@v5
with:
@@ -39,4 +44,7 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-to: type=gha,mode=max,scope=${{ matrix.variant }}
platforms: linux/amd64,linux/arm64
build-args: |
INSTALL_EXT=${{ matrix.variant == 'full' && 'true' || 'false' }}

View File

@@ -15,3 +15,5 @@ jobs:
uses: actions/checkout@v4
- name: Release
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true

View File

@@ -1,4 +1,4 @@
FROM python:3.12-slim-bookworm AS base
FROM python:3.13-slim-bookworm AS base
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
@@ -12,6 +12,21 @@ RUN npm install -g pnpm
ARG REQUEST_TIMEOUT=60000
ENV REQUEST_TIMEOUT=$REQUEST_TIMEOUT
ENV PNPM_HOME=/usr/local/share/pnpm
ENV PATH=$PNPM_HOME:$PATH
RUN mkdir -p $PNPM_HOME && \
pnpm add -g @amap/amap-maps-mcp-server @playwright/mcp@latest tavily-mcp@latest @modelcontextprotocol/server-github @modelcontextprotocol/server-slack
ARG INSTALL_EXT=false
RUN if [ "$INSTALL_EXT" = "true" ]; then \
ARCH=$(uname -m); \
if [ "$ARCH" = "x86_64" ]; then \
npx -y playwright install --with-deps chrome; \
else \
echo "Skipping Chrome installation on non-amd64 architecture: $ARCH"; \
fi; \
fi
RUN uv tool install mcp-server-fetch
ENV UV_PYTHON_INSTALL_MIRROR="http://mirrors.aliyun.com/pypi/simple/"
@@ -20,8 +35,6 @@ WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install
RUN pnpm install @amap/amap-maps-mcp-server @playwright/mcp@latest tavily-mcp@latest @modelcontextprotocol/server-github @modelcontextprotocol/server-slack
COPY . .
RUN pnpm frontend:build && pnpm build

139
README.md
View File

@@ -1,56 +1,48 @@
# MCPHub: Deploy Your Own MCP Servers in Minutes
# MCPHub: Your Ultimate MCP Server Hub
English | [中文版](README.zh.md)
MCPHub is a unified hub server that consolidates multiple MCP (Model Context Protocol) servers into a single SSE endpoint. It streamlines service management by offering a centralized interface for all your MCP server needs.
MCPHub is a unified management platform that aggregates multiple MCP (Model Context Protocol) servers into separate SSE endpoints for different scenarios by group. It streamlines your AI tool integrations through an intuitive interface and robust protocol handling.
![Dashboard Preview](assets/dashboard.png)
## Features
## 🚀 Features
- **Built-in featured MCP Servers**: Comes with featured MCP servers like `amap-maps`, `playwright`, `slack`, and more.
- **Centralized Management**: Oversee multiple MCP servers from one convenient hub.
- **Broad Protocol Support**: Works seamlessly with both stdio and SSE MCP protocols.
- **Intuitive Dashboard UI**: Monitor server status and manage servers dynamically via a web interface.
- **Flexible Server Management**: Add, remove, or reconfigure MCP servers without restarting the hub.
- **Out-of-the-Box MCP Server Support**: Seamlessly integrate popular servers like `amap-maps`, `playwright`, `fetch`, `slack`, and more.
- **Centralized Dashboard**: Monitor real-time status and performance metrics from one sleek web UI.
- **Flexible Protocol Handling**: Full compatibility with both stdio and SSE MCP protocols.
- **Hot-Swappable Configuration**: Add, remove, or update MCP servers on the fly — no downtime required.
- **Group-Based Access Control**: Organize servers into customizable groups for streamlined permissions management.
- **Secure Authentication**: Built-in user management with role-based access powered by JWT and bcrypt.
- **Docker-Ready**: Deploy instantly with our containerized setup.
## Quick Start
## 🔧 Quick Start
### Configuration (Optional but Recommended)
### Optional Configuration
Create a `mcp_settings.json` file to customize your server settings:
- Customize your MCP server settings by creating the `mcp_settings.json` file. For example:
```json
{
"mcpServers": {
"amap-maps": {
"command": "npx",
"args": [
"-y",
"@amap/amap-maps-mcp-server"
],
"args": ["-y", "@amap/amap-maps-mcp-server"],
"env": {
"AMAP_MAPS_API_KEY": "your-api-key"
}
},
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest",
"--headless"
]
"args": ["@playwright/mcp@latest", "--headless"]
},
"fetch": {
"command": "uvx",
"args": [
"mcp-server-fetch"
]
"args": ["mcp-server-fetch"]
},
"slack": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-slack"
],
"args": ["-y", "@modelcontextprotocol/server-slack"],
"env": {
"SLACK_BOT_TOKEN": "your-bot-token",
"SLACK_TEAM_ID": "your-team-id"
@@ -67,72 +59,83 @@ MCPHub is a unified hub server that consolidates multiple MCP (Model Context Pro
}
```
- The above example includes the `amap-maps`, `playwright`, `fetch`, and `slack` servers. You can add or remove servers as needed.
- The `users` section allows you to set up user authentication. The default root user is `admin` with the password `admin123`. You can change them as needed.
- The password is hashed using bcrypt. You can generate a new password hash using the following command:
> **Note**: Default credentials are `admin` / `admin123`. Passwords are securely hashed with bcrypt. Generate a new hash with:
>
> ```bash
> npx bcryptjs your-password
> ```
```bash
npx bcryptjs your-password
```
### Docker Deployment
### Starting MCPHub with Docker
Run the following command to quickly launch MCPHub with default settings:
**Recommended**: Mount your custom config:
```bash
docker run -p 3000:3000 -v $(pwd)/mcp_settings.json:/app/mcp_settings.json samanhappy/mcphub
```
or run with default settings:
```bash
docker run -p 3000:3000 samanhappy/mcphub
```
Run the following command to launch MCPHub with custom settings:
### Access the Dashboard
```bash
docker run -p 3000:3000 -v ./mcp_settings.json:/app/mcp_settings.json samanhappy/mcphub
```
Open `http://localhost:3000` and log in with your credentials.
> **Note**: Default credentials are `admin` / `admin123`.
### Dashboard Access
Open your web browser and navigate to: `http://localhost:3000`, then login using the credentials you set in the `mcp_settings.json` file.
The default credentials are:
- **Username**: `admin`
- **Password**: `admin123`
The dashboard provides:
- **Real-Time Monitoring**: Keep an eye on the status of all MCP servers.
- **Service Status Indicators**: Quickly see which services are online.
- **Dynamic Server Management**: Add or remove MCP servers on the fly without needing to restart.
**Dashboard Overview**:
- Live status of all MCP servers
- Enable/disable or reconfigure servers
- Group management for organizing servers
- User administration for access control
### SSE Endpoint
Seamlessly connect your host applications (e.g., Claude Desktop, Cursor, Cherry Studio, etc.) to the MCPHub SSE endpoint at: `http://localhost:3000/sse`
Connect AI clients (e.g., Claude Desktop, Cursor, Cherry Studio) via:
```
http://localhost:3000/sse
```
## Local Development
**Group-Specific Endpoints (Recommended)**:
### Clone the Repository
![Group Management](assets/group.png)
Clone MCPHub from GitHub:
For targeted access to specific server groups, use the group-based SSE endpoint:
```
http://localhost:3000/sse/{groupId}
```
Where `{groupId}` is the ID of the group you created in the dashboard. This allows you to:
- Connect to a specific subset of MCP servers organized by use case
- Isolate different AI tools to access only relevant servers
- Implement more granular access control for different environments or teams
## 🧑‍💻 Local Development
```bash
git clone https://github.com/samanhappy/mcphub.git
cd mcphub
pnpm install
pnpm dev
```
### Optional Configuration
This starts both frontend and backend in development mode with hot-reloading.
Customize your MCP server settings by editing the `mcp_settings.json` file.
## 🔍 Tech Stack
### Start the Development Server
- **Backend**: Node.js, Express, TypeScript
- **Frontend**: React, Vite, Tailwind CSS
- **Auth**: JWT & bcrypt
- **Protocol**: Model Context Protocol SDK
Install dependencies and launch MCPHub:
## 👥 Contributing
```bash
cd mcphub && pnpm install && pnpm dev
```
Contributions are welcome!
## Community and Contributions
- New features & optimizations
- Documentation improvements
- Bug reports & fixes
- Translations & suggestions
MCPHub started as a small side project that I developed on a whim, and I'm amazed at the attention it has received. Thank you all for your support!
## 📄 License
Currently, MCPHub still has many areas that need optimization and improvement. Any contributions, whether in the form of code, documentation, or suggestions, are more than welcome.
## License
This project is licensed under the [Apache 2.0 license](LICENSE).
Licensed under the [Apache 2.0 License](LICENSE).

View File

@@ -1,137 +1,145 @@
# MCPHub键部署你的专属 MCP 服务
# MCPHub站式 MCP 服务器聚合平台
[English Version](README.md) | 中文版
MCPHub 是一个集中管理的 MCP 服务器聚合平台,可以将多个 MCPModel Context Protocol)服务整合为一个 SSE 端点。它通过提供一个集中的管理界面来简化服务管理,满足您对 MCP 服务的所有需求
MCPHub 是一个统一的 MCPModel Context Protocol,模型上下文协议)服务器聚合平台,可以根据场景将多个服务器聚合到不同的 SSE 端点。它通过直观的界面和强大的协议处理能力,简化了您的 AI 工具集成流程
![控制面板预览](assets/dashboard.zh.png)
## 功能
## 🚀 功能亮点
- **内置精选 MCP 服务**:默认安装 `amap-maps``playwright``slack` 等热门服务,开箱即用
- **集中管理**:通过单一中心轻松管理多个 MCP 服务
- **协议兼容**同时支持 stdio SSE MCP 协议,确保无缝对接
- **直观控制面板**:通过 Web 界面实时监控服务状态,并动态管理服务
- **灵活配置**:无需重启中心服务即可添加、移除或重新配置 MCP 服务
- **开箱即用的 MCP 服务器支持**:无缝集成 `amap-maps``playwright``fetch``slack` 等常见服务器
- **集中管理控制台**:在一个简洁的 Web UI 中实时监控所有服务器的状态和性能指标
- **灵活的协议兼容**完全支持 stdio SSE 两种 MCP 协议。
- **热插拔式配置**:在运行时动态添加、移除或更新服务器配置,无需停机
- **基于分组的访问控制**:自定义分组并管理服务器访问权限
- **安全认证机制**:内置用户管理,基于 JWT 和 bcrypt实现角色权限控制。
- **Docker 就绪**:提供容器化镜像,快速部署。
## 快速开始
## 🔧 快速开始
### 配置(可选但推荐)
### 可选配置
- 你可以通过创建 `mcp_settings.json` 文件来自定义 MCP 服务器设置,例如
```json
{
"mcpServers": {
"amap-maps": {
"command": "npx",
"args": [
"-y",
"@amap/amap-maps-mcp-server"
],
"env": {
"AMAP_MAPS_API_KEY": "your-api-key"
}
},
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest",
"--headless"
]
},
"fetch": {
"command": "uvx",
"args": [
"mcp-server-fetch"
]
},
"slack": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-slack"
],
"env": {
"SLACK_BOT_TOKEN": "your-bot-token",
"SLACK_TEAM_ID": "your-team-id"
}
通过创建 `mcp_settings.json` 自定义服务器设置:
```json
{
"mcpServers": {
"amap-maps": {
"command": "npx",
"args": ["-y", "@amap/amap-maps-mcp-server"],
"env": {
"AMAP_MAPS_API_KEY": "your-api-key"
}
},
"users": [
{
"username": "admin",
"password": "$2b$10$Vt7krIvjNgyN67LXqly0uOcTpN0LI55cYRbcKC71pUDAP0nJ7RPa.",
"isAdmin": true
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest", "--headless"]
},
"fetch": {
"command": "uvx",
"args": ["mcp-server-fetch"]
},
"slack": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-slack"],
"env": {
"SLACK_BOT_TOKEN": "your-bot-token",
"SLACK_TEAM_ID": "your-team-id"
}
]
}
```
}
},
"users": [
{
"username": "admin",
"password": "$2b$10$Vt7krIvjNgyN67LXqly0uOcTpN0LI55cYRbcKC71pUDAP0nJ7RPa.",
"isAdmin": true
}
]
}
```
- 上述示例中包含 `amap-maps`、`playwright`、`fetch` `slack` 服务器,你可以根据需要增减服务器。
- `users` 部分允许你设置用户认证。默认的 root 用户为 `admin`,密码为 `admin123`,你可以根据需要进行更改。
- 密码使用 bcrypt 进行哈希处理。你可以使用以下命令生成新密码的哈希值:
> **提示**:默认用户名/密码为 `admin` / `admin123`。密码已通过 bcrypt 安全哈希。生成新密码哈希:
>
> ```bash
> npx bcryptjs your-password
> ```
```bash
npx bcryptjs your-password
```
### Docker 部署
### 启动
运行以下命令即可使用默认配置快速启动 MCPHub
**推荐**:挂载自定义配置:
```bash
docker run -p 3000:3000 -v $(pwd)/mcp_settings.json:/app/mcp_settings.json samanhappy/mcphub
```
或使用默认配置运行:
```bash
docker run -p 3000:3000 samanhappy/mcphub
```
运行以下命令可使用自定义配置启动 MCPHub
### 访问控制台
```bash
docker run -p 3000:3000 -v ./mcp_settings.json:/app/mcp_settings.json samanhappy/mcphub
打开 `http://localhost:3000`,使用您的账号登录。
> **提示**:默认用户名/密码为 `admin` / `admin123`。
**控制台功能**
- 实时监控所有 MCP 服务器状态
- 启用/禁用或重新配置服务器
- 分组管理,组织服务器访问
- 用户管理,设定权限
### SSE 端点集成
通过以下地址连接 AI 客户端(如 Claude Desktop、Cursor、Cherry Studio 等):
```
http://localhost:3000/sse
```
### 控制面板访问
**基于分组的 SSE 端点(推荐)**
在浏览器中打开 `http://localhost:3000` 并使用你在 `mcp_settings.json` 文件中设置的凭据登录。默认凭据为:
- **用户名**`admin`
- **密码**`admin123`
![分组](assets/group.zh.png)
控制面板提供以下功能
- **实时监控**:随时查看所有 MCP 服务的运行状态。
- **服务状态指示**:快速识别各服务是否在线。
- **动态管理**:无需重启即可动态添加或移除 MCP 服务。
要针对特定服务器分组进行访问,请使用基于分组的 SSE 端点
```
http://localhost:3000/sse/{groupId}
```
### SSE 端点
其中 `{groupId}` 是您在控制面板中创建的分组 ID。这样做可以
- 连接到按用例组织的特定 MCP 服务器子集
- 隔离不同的 AI 工具,使其只能访问相关服务器
- 为不同环境或团队实现更精细的访问控制
您可以将主机应用(如 Claude Desktop、Cursor、Cherry Studio 等)无缝连接至 MCPHub 的 SSE 端点: `http://localhost:3000/sse`
## 本地开发
### 克隆仓库
从 GitHub 克隆 MCPHub
## 🧑‍💻 本地开发
```bash
git clone https://github.com/samanhappy/mcphub.git
cd mcphub
pnpm install
pnpm dev
```
### 可选配置
此命令将在开发模式下启动前后端,并启用热重载。
通过编辑 `mcp_settings.json` 文件来自定义 MCP 服务器设置。
## 🔍 技术栈
### 启动开发服务器
- **后端**Node.js、Express、TypeScript
- **前端**React、Vite、Tailwind CSS
- **认证**JWT & bcrypt
- **协议**Model Context Protocol SDK
进入项目目录,安装依赖并启动 MCPHub
## 👥 贡献指南
```bash
cd mcphub && pnpm install && pnpm dev
```
期待您的贡献!
## 社区与贡献
- 新功能与优化
- 文档完善
- Bug 报告与修复
- 翻译与建议
MCPHub 只是我一时兴起开发的小项目,没想到竟收获了这么多关注,非常感谢大家的支持!目前 MCPHub 还有不少地方需要优化和完善,我也专门建了个交流群,方便大家交流反馈。如果你也对这个项目感兴趣,欢迎一起参与建设!
欢迎加入企微交流共建群
![微信群](assets/wegroup.jpg)
<img src="assets/wegroup.png" width="500">
## 许可证
## 📄 许可证
本项目采用 [Apache 2.0 许可证](LICENSE)。

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 36 KiB

BIN
assets/group.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
assets/group.zh.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

BIN
assets/wegroup.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -1,369 +1,38 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { BrowserRouter as Router, Route, Routes, Navigate, useNavigate } from 'react-router-dom'
import { Server, ApiResponse } from './types'
import ServerCard from './components/ServerCard'
import AddServerForm from './components/AddServerForm'
import EditServerForm from './components/EditServerForm'
import LoginPage from './pages/LoginPage'
import ChangePasswordPage from './pages/ChangePasswordPage'
import ProtectedRoute from './components/ProtectedRoute'
import { AuthProvider, useAuth } from './contexts/AuthContext'
// 配置选项
const CONFIG = {
// 初始化启动阶段的配置
startup: {
maxAttempts: 60, // 初始化阶段最大尝试次数
pollingInterval: 3000 // 初始阶段轮询间隔 (3秒)
},
// 正常运行阶段的配置
normal: {
pollingInterval: 10000 // 正常运行时的轮询间隔 (10秒)
}
}
// Dashboard component that contains the main application
const Dashboard = () => {
const { t } = useTranslation()
const [servers, setServers] = useState<Server[]>([])
const [error, setError] = useState<string | null>(null)
const [refreshKey, setRefreshKey] = useState(0)
const [editingServer, setEditingServer] = useState<Server | null>(null)
const [isInitialLoading, setIsInitialLoading] = useState(true)
const [fetchAttempts, setFetchAttempts] = useState(0)
const { auth, logout } = useAuth()
const navigate = useNavigate()
// 轮询定时器引用
const intervalRef = useRef<NodeJS.Timeout | null>(null)
// 保存当前尝试次数,避免依赖循环
const attemptsRef = useRef<number>(0)
// 清理定时器
const clearTimer = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}
// 开始正常轮询
const startNormalPolling = useCallback(() => {
// 确保没有其他定时器在运行
clearTimer()
const fetchServers = async () => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/servers', {
headers: {
'x-auth-token': token || ''
}
});
const data = await response.json()
if (data && data.success && Array.isArray(data.data)) {
setServers(data.data)
} else if (data && Array.isArray(data)) {
setServers(data)
} else {
console.error('Invalid server data format:', data)
setServers([])
}
// 重置错误状态
setError(null)
} catch (err) {
console.error('Error fetching servers during normal polling:', err)
// 使用友好的错误消息
if (!navigator.onLine) {
setError(t('errors.network'))
} else if (err instanceof TypeError && (
err.message.includes('NetworkError') ||
err.message.includes('Failed to fetch')
)) {
setError(t('errors.serverConnection'))
} else {
setError(t('errors.serverFetch'))
}
}
}
// 立即执行一次
fetchServers()
// 设置定期轮询
intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval)
}, [t])
useEffect(() => {
// 重置尝试计数
if (refreshKey > 0) {
attemptsRef.current = 0;
setFetchAttempts(0);
}
// 初始化加载阶段的请求函数
const fetchInitialData = async () => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/servers', {
headers: {
'x-auth-token': token || ''
}
});
const data = await response.json()
// 处理API响应中的包装对象提取data字段
if (data && data.success && Array.isArray(data.data)) {
setServers(data.data)
setIsInitialLoading(false)
// 初始化成功,开始正常轮询
startNormalPolling()
return true
} else if (data && Array.isArray(data)) {
// 兼容性处理如果API直接返回数组
setServers(data)
setIsInitialLoading(false)
// 初始化成功,开始正常轮询
startNormalPolling()
return true
} else {
// 如果数据格式不符合预期,设置为空数组
console.error('Invalid server data format:', data)
setServers([])
setIsInitialLoading(false)
// 初始化成功但数据为空,开始正常轮询
startNormalPolling()
return true
}
} catch (err) {
// 增加尝试次数计数,使用 ref 避免触发 effect 重新运行
attemptsRef.current += 1;
console.error(`Initial loading attempt ${attemptsRef.current} failed:`, err)
// 更新状态用于显示
setFetchAttempts(attemptsRef.current)
// 设置适当的错误消息
if (!navigator.onLine) {
setError(t('errors.network'))
} else {
setError(t('errors.initialStartup'))
}
// 如果已超过最大尝试次数,放弃初始化并切换到正常轮询
if (attemptsRef.current >= CONFIG.startup.maxAttempts) {
console.log('Maximum startup attempts reached, switching to normal polling')
setIsInitialLoading(false)
// 清除初始化的轮询
clearTimer()
// 切换到正常轮询模式
startNormalPolling()
}
return false
}
}
// 组件挂载时,根据当前状态设置适当的轮询
if (isInitialLoading) {
// 确保没有其他定时器在运行
clearTimer()
// 立即执行一次初始请求
fetchInitialData()
// 设置初始阶段的轮询间隔
intervalRef.current = setInterval(fetchInitialData, CONFIG.startup.pollingInterval)
console.log(`Started initial polling with interval: ${CONFIG.startup.pollingInterval}ms`)
} else {
// 已经初始化完成,开始正常轮询
startNormalPolling()
}
// 清理函数
return () => {
clearTimer()
}
}, [refreshKey, t, isInitialLoading, startNormalPolling])
// 手动触发刷新
const triggerRefresh = () => {
// 清除当前的定时器
clearTimer()
// 如果在初始化阶段,重置初始化状态
if (isInitialLoading) {
setIsInitialLoading(true)
attemptsRef.current = 0
setFetchAttempts(0)
}
// refreshKey 的改变会触发 useEffect 再次运行
setRefreshKey(prevKey => prevKey + 1)
}
const handleServerAdd = () => {
setRefreshKey(prevKey => prevKey + 1)
}
const handleServerEdit = (server: Server) => {
// Fetch settings to get the full server config before editing
const token = localStorage.getItem('mcphub_token');
fetch(`/api/settings`, {
headers: {
'x-auth-token': token || ''
}
})
.then(response => response.json())
.then((settingsData: ApiResponse<{ mcpServers: Record<string, any> }>) => {
if (
settingsData &&
settingsData.success &&
settingsData.data &&
settingsData.data.mcpServers &&
settingsData.data.mcpServers[server.name]
) {
const serverConfig = settingsData.data.mcpServers[server.name]
const fullServerData = {
name: server.name,
status: server.status,
tools: server.tools || [],
config: serverConfig,
}
console.log('Editing server with config:', fullServerData)
setEditingServer(fullServerData)
} else {
console.error('Failed to get server config from settings:', settingsData)
setError(t('server.invalidConfig', { serverName: server.name }))
}
})
.catch(err => {
console.error('Error fetching server settings:', err)
setError(err instanceof Error ? err.message : String(err))
})
}
const handleEditComplete = () => {
setEditingServer(null)
setRefreshKey(prevKey => prevKey + 1)
}
const handleServerRemove = async (serverName: string) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/servers/${serverName}`, {
method: 'DELETE',
headers: {
'x-auth-token': token || ''
}
})
const result = await response.json()
if (!response.ok) {
setError(result.message || t('server.deleteError', { serverName }))
return
}
setRefreshKey(prevKey => prevKey + 1)
} catch (err) {
setError(t('errors.general') + ': ' + (err instanceof Error ? err.message : String(err)))
}
}
const handleLogout = () => {
logout()
navigate('/login')
}
return (
<div className="min-h-screen bg-gray-100 p-8">
<div className="max-w-3xl mx-auto">
{error && (
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm">
<div className="flex items-center justify-between">
<div>
<h3 className="text-red-600 text-lg font-medium">{t('app.error')}</h3>
<p className="text-gray-600 mt-1">{error}</p>
</div>
<button
onClick={() => setError(null)}
className="ml-4 text-gray-500 hover:text-gray-700"
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">
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 111.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</div>
</div>
)}
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">{t('app.title')}</h1>
<div className="flex items-center">
<AddServerForm onAdd={handleServerAdd} />
<button
onClick={() => navigate('/change-password')}
className="ml-4 bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
>
{t('app.changePassword')}
</button>
<button
onClick={handleLogout}
className="ml-4 bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600"
>
{t('app.logout')}
</button>
</div>
</div>
{servers.length === 0 ? (
<div className="bg-white shadow rounded-lg p-6">
<p className="text-gray-600">{t('app.noServers')}</p>
</div>
) : (
<div className="space-y-6">
{servers.map((server, index) => (
<ServerCard
key={index}
server={server}
onRemove={handleServerRemove}
onEdit={handleServerEdit}
/>
))}
</div>
)}
{editingServer && (
<EditServerForm
server={editingServer}
onEdit={handleEditComplete}
onCancel={() => setEditingServer(null)}
/>
)}
</div>
</div>
)
}
import React from 'react';
import { BrowserRouter as Router, Route, Routes, Navigate } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext';
import MainLayout from './layouts/MainLayout';
import ProtectedRoute from './components/ProtectedRoute';
import LoginPage from './pages/LoginPage';
import DashboardPage from './pages/Dashboard';
import ServersPage from './pages/ServersPage';
import GroupsPage from './pages/GroupsPage';
import SettingsPage from './pages/SettingsPage';
function App() {
return (
<AuthProvider>
<Router>
<Routes>
{/* 公共路由 */}
<Route path="/login" element={<LoginPage />} />
{/* 受保护的路由,使用 MainLayout 作为布局容器 */}
<Route element={<ProtectedRoute />}>
<Route path="/" element={<Dashboard />} />
<Route path="/change-password" element={<ChangePasswordPage />} />
<Route element={<MainLayout />}>
<Route path="/" element={<DashboardPage />} />
<Route path="/servers" element={<ServersPage />} />
<Route path="/groups" element={<GroupsPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
</Route>
{/* 未匹配的路由重定向到首页 */}
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</Router>
</AuthProvider>
)
);
}
export default App
export default App;

View File

@@ -0,0 +1,132 @@
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'
interface AddGroupFormProps {
onAdd: () => void
onCancel: () => void
}
const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
const { t } = useTranslation()
const { createGroup } = useGroupData()
const { servers } = useServerData()
const [availableServers, setAvailableServers] = useState<Server[]>([])
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [formData, setFormData] = useState<GroupFormData>({
name: '',
description: '',
servers: []
})
useEffect(() => {
// Filter available servers (enabled only)
setAvailableServers(servers.filter(server => server.enabled !== false))
}, [servers])
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target
setFormData(prev => ({
...prev,
[name]: value
}))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
setError(null)
try {
if (!formData.name.trim()) {
setError(t('groups.nameRequired'))
setIsSubmitting(false)
return
}
const result = await createGroup(formData.name, formData.description, formData.servers)
if (!result) {
setError(t('groups.createError'))
setIsSubmitting(false)
return
}
onAdd()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
setIsSubmitting(false)
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-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">
<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">
{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>
</div>
</div>
)
}
export default AddGroupForm

View File

@@ -0,0 +1,149 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Group, GroupFormData, Server } from '@/types'
import { useGroupData } from '@/hooks/useGroupData'
import { useServerData } from '@/hooks/useServerData'
import { ToggleGroup } from './ui/ToggleGroup'
interface EditGroupFormProps {
group: Group
onEdit: () => void
onCancel: () => void
}
const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
const { t } = useTranslation()
const { updateGroup } = useGroupData()
const { servers } = useServerData()
const [availableServers, setAvailableServers] = useState<Server[]>([])
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [formData, setFormData] = useState<GroupFormData>({
name: group.name,
description: group.description || '',
servers: group.servers || []
})
useEffect(() => {
// Filter available servers (enabled only)
setAvailableServers(servers.filter(server => server.enabled !== false))
}, [servers])
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target
setFormData(prev => ({
...prev,
[name]: value
}))
}
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)
setError(null)
try {
if (!formData.name.trim()) {
setError(t('groups.nameRequired'))
setIsSubmitting(false)
return
}
const result = await updateGroup(group.id, {
name: formData.name,
description: formData.description,
servers: formData.servers
})
if (!result) {
setError(t('groups.updateError'))
setIsSubmitting(false)
return
}
onEdit()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
setIsSubmitting(false)
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-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">
<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">
{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>
</div>
</div>
)
}
export default EditGroupForm

View File

@@ -0,0 +1,141 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Group, Server } from '@/types'
import { Edit, Trash, Copy, Check } from '@/components/icons/LucideIcons'
import DeleteDialog from '@/components/ui/DeleteDialog'
interface GroupCardProps {
group: Group
servers: Server[]
onEdit: (group: Group) => void
onDelete: (groupId: string) => void
}
const GroupCard = ({
group,
servers,
onEdit,
onDelete
}: GroupCardProps) => {
const { t } = useTranslation()
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [copied, setCopied] = useState(false)
const handleEdit = () => {
onEdit(group)
}
const handleDelete = () => {
setShowDeleteDialog(true)
}
const handleConfirmDelete = () => {
onDelete(group.id)
setShowDeleteDialog(false)
}
const copyToClipboard = () => {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(group.id).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
} else {
// Fallback for HTTP or unsupported clipboard API
const textArea = document.createElement('textarea')
textArea.value = group.id
// Avoid scrolling to bottom
textArea.style.position = 'fixed'
textArea.style.left = '-9999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
try {
document.execCommand('copy')
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
alert(t('common.copyFailed') || 'Copy failed')
console.error('Copy to clipboard failed:', err)
}
document.body.removeChild(textArea)
}
}
// Get servers that belong to this group
const groupServers = servers.filter(server => group.servers.includes(server.name))
return (
<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>
</div>
{group.description && (
<p className="text-gray-600 text-sm mt-1">{group.description}</p>
)}
</div>
<div className="flex items-center space-x-3">
<div className="bg-blue-50 text-blue-700 px-3 py-1 rounded-full text-sm">
{t('groups.serverCount', { count: group.servers.length })}
</div>
<button
onClick={handleEdit}
className="text-gray-500 hover:text-gray-700"
title={t('groups.edit')}
>
<Edit size={18} />
</button>
<button
onClick={handleDelete}
className="text-gray-500 hover:text-red-600"
title={t('groups.delete')}
>
<Trash size={18} />
</button>
</div>
</div>
<div className="mt-4">
{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>
)}
</div>
<DeleteDialog
isOpen={showDeleteDialog}
onClose={() => setShowDeleteDialog(false)}
onConfirm={handleConfirmDelete}
serverName={group.name}
isGroup={true}
/>
</div>
)
}
export default GroupCard

View File

@@ -10,12 +10,14 @@ interface ServerCardProps {
server: Server
onRemove: (serverName: string) => void
onEdit: (server: Server) => void
onToggle?: (server: Server, enabled: boolean) => void
}
const ServerCard = ({ server, onRemove, onEdit }: ServerCardProps) => {
const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) => {
const { t } = useTranslation()
const [isExpanded, setIsExpanded] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isToggling, setIsToggling] = useState(false)
const handleRemove = (e: React.MouseEvent) => {
e.stopPropagation()
@@ -27,38 +29,83 @@ const ServerCard = ({ server, onRemove, onEdit }: ServerCardProps) => {
onEdit(server)
}
const handleToggle = async (e: React.MouseEvent) => {
e.stopPropagation()
if (isToggling || !onToggle) return
setIsToggling(true)
try {
await onToggle(server, !(server.enabled !== false))
} finally {
setIsToggling(false)
}
}
const handleConfirmDelete = () => {
onRemove(server.name)
setShowDeleteDialog(false)
}
return (
<div className="bg-white shadow rounded-lg p-6 mb-6">
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center space-x-3">
<h2 className="text-xl font-semibold text-gray-900">{server.name}</h2>
<Badge status={server.status} />
</div>
<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"
>
{t('server.edit')}
</button>
<button
onClick={handleRemove}
className="px-3 py-1 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm"
>
{t('server.delete')}
</button>
<button className="text-gray-400 hover:text-gray-600">
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
</button>
<>
<div className={`bg-white shadow rounded-lg p-6 mb-6 ${server.enabled === false ? 'opacity-60' : ''}`}>
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center space-x-3">
<h2 className={`text-xl font-semibold ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'}`}>{server.name}</h2>
<Badge status={server.status} />
</div>
<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"
>
{t('server.edit')}
</button>
<div className="flex items-center">
<button
onClick={handleToggle}
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'
}`}
disabled={isToggling}
>
{isToggling
? t('common.processing')
: server.enabled !== false
? t('server.disable')
: t('server.enable')
}
</button>
</div>
<button
onClick={handleRemove}
className="px-3 py-1 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm"
>
{t('server.delete')}
</button>
<button className="text-gray-400 hover:text-gray-600">
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
</button>
</div>
</div>
{isExpanded && server.tools && (
<div className="mt-6">
<h3 className={`text-lg font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}>{t('server.tools')}</h3>
<div className="space-y-4">
{server.tools.map((tool, index) => (
<ToolCard key={index} tool={tool} />
))}
</div>
</div>
)}
</div>
<DeleteDialog
@@ -67,18 +114,7 @@ const ServerCard = ({ server, onRemove, onEdit }: ServerCardProps) => {
onConfirm={handleConfirmDelete}
serverName={server.name}
/>
{isExpanded && server.tools && (
<div className="mt-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">{t('server.tools')}</h3>
<div className="space-y-4">
{server.tools.map((tool, index) => (
<ToolCard key={index} tool={tool} />
))}
</div>
</div>
)}
</div>
</>
)
}

View File

@@ -1,10 +1,14 @@
import { ChevronDown, ChevronRight } from 'lucide-react'
import { ChevronDown, ChevronRight, Edit, Trash, Copy, Check } from 'lucide-react'
export { ChevronDown, ChevronRight }
export { ChevronDown, ChevronRight, Edit, Trash, Copy, Check }
const LucideIcons = {
ChevronDown,
ChevronRight,
Edit,
Trash,
Copy,
Check
}
export default LucideIcons

View File

@@ -0,0 +1,17 @@
import React, { ReactNode } from 'react';
interface ContentProps {
children: ReactNode;
}
const Content: React.FC<ContentProps> = ({ children }) => {
return (
<main className="flex-1 p-6 overflow-auto">
<div className="max-w-5xl mx-auto">
{children}
</div>
</main>
);
};
export default Content;

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
interface HeaderProps {
onToggleSidebar: () => void;
}
const Header: React.FC<HeaderProps> = ({ onToggleSidebar }) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { auth, logout } = useAuth();
const handleLogout = () => {
logout();
navigate('/login');
};
return (
<header className="bg-white shadow-sm z-10">
<div className="flex justify-between items-center px-4 py-3">
<div className="flex items-center">
{/* 侧边栏切换按钮 */}
<button
onClick={onToggleSidebar}
className="p-2 rounded-md text-gray-500 hover:text-gray-900 hover:bg-gray-100 focus:outline-none"
aria-label={t('app.toggleSidebar')}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
{/* 应用标题 */}
<h1 className="ml-4 text-xl font-bold text-gray-900">{t('app.title')}</h1>
</div>
{/* 用户信息和操作 */}
<div className="flex items-center space-x-4">
{auth.user && (
<span className="text-sm text-gray-700">
{t('app.welcomeUser', { username: auth.user.username })}
</span>
)}
<div className="flex space-x-2">
<button
onClick={handleLogout}
className="px-3 py-1.5 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm"
>
{t('app.logout')}
</button>
</div>
</div>
</div>
</header>
);
};
export default Header;

View File

@@ -0,0 +1,89 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { NavLink, useLocation } from 'react-router-dom';
interface SidebarProps {
collapsed: boolean;
}
interface MenuItem {
path: string;
label: string;
icon: React.ReactNode;
}
const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
const { t } = useTranslation();
const location = useLocation();
// 菜单项配置
const menuItems: MenuItem[] = [
{
path: '/',
label: t('nav.dashboard'),
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M2 10a8 8 0 018-8v8h8a8 8 0 11-16 0z" />
<path d="M12 2.252A8.014 8.014 0 0117.748 8H12V2.252z" />
</svg>
),
},
{
path: '/servers',
label: t('nav.servers'),
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M2 5a2 2 0 012-2h12a2 2 0 012 2v2a2 2 0 01-2 2H4a2 2 0 01-2-2V5zm14 1a1 1 0 11-2 0 1 1 0 012 0zM2 13a2 2 0 012-2h12a2 2 0 012 2v2a2 2 0 01-2 2H4a2 2 0 01-2-2v-2zm14 1a1 1 0 11-2 0 1 1 0 012 0z" clipRule="evenodd" />
</svg>
),
},
{
path: '/groups',
label: t('nav.groups'),
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z" />
</svg>
),
},
{
path: '/settings',
label: t('nav.settings'),
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" />
</svg>
),
},
];
return (
<aside
className={`bg-white shadow-sm transition-all duration-300 ease-in-out ${
collapsed ? 'w-16' : 'w-64'
}`}
>
<nav className="p-3 space-y-1">
{menuItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
`flex items-center px-3 py-2 rounded-md transition-colors ${
isActive
? 'bg-blue-100 text-blue-800'
: 'text-gray-700 hover:bg-gray-100'
}`
}
end={item.path === '/'}
>
<span className="flex-shrink-0">{item.icon}</span>
{!collapsed && <span className="ml-3">{item.label}</span>}
</NavLink>
))}
</nav>
</aside>
);
};
export default Sidebar;

View File

@@ -5,33 +5,40 @@ interface DeleteDialogProps {
onClose: () => void
onConfirm: () => void
serverName: string
isGroup?: boolean
}
const DeleteDialog = ({ isOpen, onClose, onConfirm, serverName }: DeleteDialogProps) => {
const DeleteDialog = ({ isOpen, onClose, onConfirm, serverName, isGroup = false }: DeleteDialogProps) => {
const { t } = useTranslation()
if (!isOpen) return null
return (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div className="bg-white shadow rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-medium text-gray-900 mb-4">{t('server.delete')}</h3>
<p className="text-gray-700">
{t('server.confirmDelete')} <strong>{serverName}</strong>
</p>
<div className="flex justify-end mt-6">
<button
onClick={onClose}
className="bg-gray-300 hover:bg-gray-400 text-gray-800 font-medium py-2 px-4 rounded mr-2"
>
{t('server.cancel')}
</button>
<button
onClick={onConfirm}
className="bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded"
>
{t('server.delete')}
</button>
<div className="fixed inset-0 bg-black bg-opacity-30 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">
<h3 className="text-lg font-medium text-gray-900 mb-3">
{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 })}
</p>
<div className="flex justify-end space-x-3">
<button
onClick={onClose}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
>
{t('common.cancel')}
</button>
<button
onClick={onConfirm}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
>
{t('common.delete')}
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,100 @@
import React, { ReactNode } from 'react';
import { cn } from '@/utils/cn';
interface ToggleGroupItemProps {
value: string;
isSelected: boolean;
onClick: () => void;
children: ReactNode;
}
export const ToggleGroupItem: React.FC<ToggleGroupItemProps> = ({
value,
isSelected,
onClick,
children
}) => {
return (
<button
type="button"
role="checkbox"
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"
: "hover:bg-gray-50 text-gray-700"
)}
onClick={onClick}
>
<span className="flex items-center">
{children}
</span>
{isSelected && (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-5 h-5 text-blue-500">
<path fillRule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clipRule="evenodd" />
</svg>
)}
</button>
);
};
interface ToggleGroupProps {
label: string;
helpText?: string;
noOptionsText?: string;
values: string[];
options: { value: string; label: string }[];
onChange: (values: string[]) => void;
className?: string;
}
export const ToggleGroup: React.FC<ToggleGroupProps> = ({
label,
helpText,
noOptionsText = "No options available",
values,
options,
onChange,
className
}) => {
const handleToggle = (value: string) => {
const isSelected = values.includes(value);
if (isSelected) {
onChange(values.filter(v => v !== value));
} else {
onChange([...values, value]);
}
};
return (
<div className={className}>
<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">
{options.length === 0 ? (
<p className="text-gray-500 text-sm p-3">{noOptionsText}</p>
) : (
<div className="space-y-1 p-1">
{options.map(option => (
<ToggleGroupItem
key={option.value}
value={option.value}
isSelected={values.includes(option.value)}
onClick={() => handleToggle(option.value)}
>
{option.label}
</ToggleGroupItem>
))}
</div>
)}
</div>
{helpText && (
<p className="text-xs text-gray-500 mt-1">
{helpText}
</p>
)}
</div>
);
};

View File

@@ -0,0 +1,232 @@
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Group, ApiResponse } from '@/types';
export const useGroupData = () => {
const { t } = useTranslation();
const [groups, setGroups] = useState<Group[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const fetchGroups = useCallback(async () => {
try {
setLoading(true);
const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/groups', {
headers: {
'x-auth-token': token || ''
}
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<Group[]> = await response.json();
if (data && data.success && Array.isArray(data.data)) {
setGroups(data.data);
} else {
console.error('Invalid group data format:', data);
setGroups([]);
}
setError(null);
} catch (err) {
console.error('Error fetching groups:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch groups');
setGroups([]);
} finally {
setLoading(false);
}
}, []);
// Trigger a refresh of the groups data
const triggerRefresh = useCallback(() => {
setRefreshKey(prev => prev + 1);
}, []);
// Create a new group with server associations
const createGroup = async (name: string, description?: string, servers: string[] = []) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/groups', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || ''
},
body: JSON.stringify({ name, description, servers }),
});
const result: ApiResponse<Group> = await response.json();
if (!response.ok) {
setError(result.message || t('groups.createError'));
return null;
}
triggerRefresh();
return result.data || null;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create group');
return null;
}
};
// Update an existing group with server associations
const updateGroup = async (id: string, data: { name?: string; description?: string; servers?: string[] }) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/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;
}
triggerRefresh();
return result.data || null;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update group');
return null;
}
};
// Update servers in a group (for batch updates)
const updateGroupServers = async (groupId: string, servers: string[]) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/groups/${groupId}/servers/batch`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || ''
},
body: JSON.stringify({ servers }),
});
const result: ApiResponse<Group> = await response.json();
if (!response.ok) {
setError(result.message || t('groups.updateError'));
return null;
}
triggerRefresh();
return result.data || null;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update group servers');
return null;
}
};
// Delete a group
const deleteGroup = async (id: string) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/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;
}
triggerRefresh();
return true;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete group');
return false;
}
};
// Add server to a group
const addServerToGroup = async (groupId: string, serverName: string) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/groups/${groupId}/servers`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || ''
},
body: JSON.stringify({ serverName }),
});
const result: ApiResponse<Group> = await response.json();
if (!response.ok) {
setError(result.message || t('groups.serverAddError'));
return null;
}
triggerRefresh();
return result.data || null;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to add server to group');
return null;
}
};
// Remove server from group
const removeServerFromGroup = async (groupId: string, serverName: string) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/groups/${groupId}/servers/${serverName}`, {
method: 'DELETE',
headers: {
'x-auth-token': token || ''
}
});
const result: ApiResponse<Group> = await response.json();
if (!response.ok) {
setError(result.message || t('groups.serverRemoveError'));
return null;
}
triggerRefresh();
return result.data || null;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to remove server from group');
return null;
}
};
// Fetch groups when the component mounts or refreshKey changes
useEffect(() => {
fetchGroups();
}, [fetchGroups, refreshKey]);
return {
groups,
loading,
error,
setError,
triggerRefresh,
createGroup,
updateGroup,
updateGroupServers,
deleteGroup,
addServerToGroup,
removeServerFromGroup
};
};

View File

@@ -0,0 +1,306 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Server, ApiResponse } from '@/types';
// 配置选项
const CONFIG = {
// 初始化启动阶段的配置
startup: {
maxAttempts: 60, // 初始化阶段最大尝试次数
pollingInterval: 3000 // 初始阶段轮询间隔 (3秒)
},
// 正常运行阶段的配置
normal: {
pollingInterval: 10000 // 正常运行时的轮询间隔 (10秒)
}
};
export const useServerData = () => {
const { t } = useTranslation();
const [servers, setServers] = useState<Server[]>([]);
const [error, setError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [fetchAttempts, setFetchAttempts] = useState(0);
// 轮询定时器引用
const intervalRef = useRef<NodeJS.Timeout | null>(null);
// 保存当前尝试次数,避免依赖循环
const attemptsRef = useRef<number>(0);
// 清理定时器
const clearTimer = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
// 开始正常轮询
const startNormalPolling = useCallback(() => {
// 确保没有其他定时器在运行
clearTimer();
const fetchServers = async () => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/servers', {
headers: {
'x-auth-token': token || ''
}
});
const data = await response.json();
if (data && data.success && Array.isArray(data.data)) {
setServers(data.data);
} else if (data && Array.isArray(data)) {
setServers(data);
} else {
console.error('Invalid server data format:', data);
setServers([]);
}
// 重置错误状态
setError(null);
} catch (err) {
console.error('Error fetching servers during normal polling:', err);
// 使用友好的错误消息
if (!navigator.onLine) {
setError(t('errors.network'));
} else if (err instanceof TypeError && (
err.message.includes('NetworkError') ||
err.message.includes('Failed to fetch')
)) {
setError(t('errors.serverConnection'));
} else {
setError(t('errors.serverFetch'));
}
}
};
// 立即执行一次
fetchServers();
// 设置定期轮询
intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval);
}, [t]);
useEffect(() => {
// 重置尝试计数
if (refreshKey > 0) {
attemptsRef.current = 0;
setFetchAttempts(0);
}
// 初始化加载阶段的请求函数
const fetchInitialData = async () => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/servers', {
headers: {
'x-auth-token': token || ''
}
});
const data = await response.json();
// 处理API响应中的包装对象提取data字段
if (data && data.success && Array.isArray(data.data)) {
setServers(data.data);
setIsInitialLoading(false);
// 初始化成功,开始正常轮询
startNormalPolling();
return true;
} else if (data && Array.isArray(data)) {
// 兼容性处理如果API直接返回数组
setServers(data);
setIsInitialLoading(false);
// 初始化成功,开始正常轮询
startNormalPolling();
return true;
} else {
// 如果数据格式不符合预期,设置为空数组
console.error('Invalid server data format:', data);
setServers([]);
setIsInitialLoading(false);
// 初始化成功但数据为空,开始正常轮询
startNormalPolling();
return true;
}
} catch (err) {
// 增加尝试次数计数,使用 ref 避免触发 effect 重新运行
attemptsRef.current += 1;
console.error(`Initial loading attempt ${attemptsRef.current} failed:`, err);
// 更新状态用于显示
setFetchAttempts(attemptsRef.current);
// 设置适当的错误消息
if (!navigator.onLine) {
setError(t('errors.network'));
} else {
setError(t('errors.initialStartup'));
}
// 如果已超过最大尝试次数,放弃初始化并切换到正常轮询
if (attemptsRef.current >= CONFIG.startup.maxAttempts) {
console.log('Maximum startup attempts reached, switching to normal polling');
setIsInitialLoading(false);
// 清除初始化的轮询
clearTimer();
// 切换到正常轮询模式
startNormalPolling();
}
return false;
}
};
// 组件挂载时,根据当前状态设置适当的轮询
if (isInitialLoading) {
// 确保没有其他定时器在运行
clearTimer();
// 立即执行一次初始请求
fetchInitialData();
// 设置初始阶段的轮询间隔
intervalRef.current = setInterval(fetchInitialData, CONFIG.startup.pollingInterval);
console.log(`Started initial polling with interval: ${CONFIG.startup.pollingInterval}ms`);
} else {
// 已经初始化完成,开始正常轮询
startNormalPolling();
}
// 清理函数
return () => {
clearTimer();
};
}, [refreshKey, t, isInitialLoading, startNormalPolling]);
// 手动触发刷新
const triggerRefresh = () => {
// 清除当前的定时器
clearTimer();
// 如果在初始化阶段,重置初始化状态
if (isInitialLoading) {
setIsInitialLoading(true);
attemptsRef.current = 0;
setFetchAttempts(0);
}
// refreshKey 的改变会触发 useEffect 再次运行
setRefreshKey(prevKey => prevKey + 1);
};
// 服务器相关操作
const handleServerAdd = () => {
setRefreshKey(prevKey => prevKey + 1);
};
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(`/api/settings`, {
headers: {
'x-auth-token': token || ''
}
});
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> = await response.json();
if (
settingsData &&
settingsData.success &&
settingsData.data &&
settingsData.data.mcpServers &&
settingsData.data.mcpServers[server.name]
) {
const serverConfig = settingsData.data.mcpServers[server.name];
return {
name: server.name,
status: server.status,
tools: server.tools || [],
config: serverConfig,
};
} else {
console.error('Failed to get server config from settings:', settingsData);
setError(t('server.invalidConfig', { serverName: server.name }));
return null;
}
} catch (err) {
console.error('Error fetching server settings:', err);
setError(err instanceof Error ? err.message : String(err));
return null;
}
};
const handleServerRemove = async (serverName: string) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/servers/${serverName}`, {
method: 'DELETE',
headers: {
'x-auth-token': token || ''
}
});
const result = await response.json();
if (!response.ok) {
setError(result.message || t('server.deleteError', { serverName }));
return false;
}
setRefreshKey(prevKey => prevKey + 1);
return true;
} catch (err) {
setError(t('errors.general') + ': ' + (err instanceof Error ? err.message : String(err)));
return false;
}
};
const handleServerToggle = async (server: Server, enabled: boolean) => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/servers/${server.name}/toggle`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || ''
},
body: JSON.stringify({ enabled }),
});
const result = await response.json();
if (!response.ok) {
console.error('Failed to toggle server:', result);
setError(t('server.toggleError', { serverName: server.name }));
return false;
}
// Update the UI immediately to reflect the change
setRefreshKey(prevKey => prevKey + 1);
return true;
} catch (err) {
console.error('Error toggling server:', err);
setError(err instanceof Error ? err.message : String(err));
return false;
}
};
return {
servers,
error,
setError,
isLoading: isInitialLoading,
fetchAttempts,
triggerRefresh,
handleServerAdd,
handleServerEdit,
handleServerRemove,
handleServerToggle
};
};

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import Header from '@/components/layout/Header';
import Sidebar from '@/components/layout/Sidebar';
import Content from '@/components/layout/Content';
const MainLayout: React.FC = () => {
// 控制侧边栏展开/折叠状态
const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false);
const toggleSidebar = () => {
setSidebarCollapsed(!sidebarCollapsed);
};
return (
<div className="flex flex-col min-h-screen bg-gray-100">
{/* 顶部导航 */}
<Header onToggleSidebar={toggleSidebar} />
<div className="flex flex-1 overflow-hidden">
{/* 侧边导航 */}
<Sidebar collapsed={sidebarCollapsed} />
{/* 主内容区域 */}
<Content>
<Outlet />
</Content>
</div>
</div>
);
};
export default MainLayout;

View File

@@ -7,7 +7,9 @@
"loading": "Loading...",
"logout": "Logout",
"profile": "Profile",
"changePassword": "Change Password"
"changePassword": "Change Password",
"toggleSidebar": "Toggle Sidebar",
"welcomeUser": "Welcome, {{username}}"
},
"auth": {
"login": "Login",
@@ -32,6 +34,7 @@
"edit": "Edit",
"delete": "Delete",
"confirmDelete": "Are you sure you want to delete this server?",
"deleteWarning": "Deleting server '{{name}}' will remove it and all its data. This action cannot be undone.",
"status": "Status",
"tools": "Tools",
"name": "Server Name",
@@ -51,7 +54,14 @@
"envVars": "Environment Variables",
"key": "key",
"value": "value",
"remove": "Remove"
"enabled": "Enabled",
"enable": "Enable",
"disable": "Disable",
"remove": "Remove",
"toggleError": "Failed to toggle server {{serverName}}",
"alreadyExists": "Server {{serverName}} already exists",
"invalidData": "Invalid server data provided",
"notFound": "Server {{serverName}} not found"
},
"status": {
"online": "Online",
@@ -68,7 +78,65 @@
"initialStartup": "The server might be starting up. Please wait a moment as this process can take some time on first launch..."
},
"common": {
"processing": "Processing...",
"save": "Save",
"cancel": "Cancel"
"cancel": "Cancel",
"refresh": "Refresh",
"create": "Create",
"submitting": "Submitting...",
"delete": "Delete"
},
"nav": {
"dashboard": "Dashboard",
"servers": "Servers",
"groups": "Groups",
"settings": "Settings",
"changePassword": "Change Password"
},
"pages": {
"dashboard": {
"title": "Dashboard",
"totalServers": "Total Servers",
"onlineServers": "Online Servers",
"offlineServers": "Offline Servers",
"connectingServers": "Connecting Servers",
"recentServers": "Recent Servers"
},
"servers": {
"title": "Servers Management"
},
"groups": {
"title": "Group Management"
},
"settings": {
"title": "Settings",
"language": "Language"
}
},
"groups": {
"add": "Add",
"addNew": "Add New Group",
"edit": "Edit Group",
"delete": "Delete",
"confirmDelete": "Are you sure you want to delete this group?",
"deleteWarning": "Deleting group '{{name}}' will remove it and all its server associations. This action cannot be undone.",
"name": "Group Name",
"namePlaceholder": "Enter group name",
"nameRequired": "Group name is required",
"description": "Description",
"descriptionPlaceholder": "Enter group description (optional)",
"createError": "Failed to create group",
"updateError": "Failed to update group",
"deleteError": "Failed to delete group",
"serverAddError": "Failed to add server to group",
"serverRemoveError": "Failed to remove server from group",
"addServer": "Add Server to Group",
"selectServer": "Select a server to add",
"servers": "Servers in Group",
"remove": "Remove",
"noGroups": "No groups available. Create a new group to get started.",
"noServers": "No servers in this group.",
"noServerOptions": "No servers available",
"serverCount": "{{count}} Servers"
}
}

View File

@@ -7,7 +7,9 @@
"loading": "加载中...",
"logout": "退出登录",
"profile": "个人资料",
"changePassword": "修改密码"
"changePassword": "修改密码",
"toggleSidebar": "切换侧边栏",
"welcomeUser": "欢迎, {{username}}"
},
"auth": {
"login": "登录",
@@ -32,6 +34,7 @@
"edit": "编辑",
"delete": "删除",
"confirmDelete": "您确定要删除此服务器吗?",
"deleteWarning": "删除服务器 '{{name}}' 将会移除该服务器及其所有数据。此操作无法撤销。",
"status": "状态",
"tools": "工具",
"name": "服务器名称",
@@ -51,7 +54,14 @@
"envVars": "环境变量",
"key": "键",
"value": "值",
"remove": "移除"
"enabled": "已启用",
"enable": "启用",
"disable": "禁用",
"remove": "移除",
"toggleError": "切换服务器 {{serverName}} 状态失败",
"alreadyExists": "服务器 {{serverName}} 已经存在",
"invalidData": "提供的服务器数据无效",
"notFound": "找不到服务器 {{serverName}}"
},
"status": {
"online": "在线",
@@ -68,7 +78,65 @@
"initialStartup": "服务器可能正在启动中。首次启动可能需要一些时间,请耐心等候..."
},
"common": {
"processing": "处理中...",
"save": "保存",
"cancel": "取消"
"cancel": "取消",
"refresh": "刷新",
"create": "创建",
"submitting": "提交中...",
"delete": "删除"
},
"nav": {
"dashboard": "仪表盘",
"servers": "服务器",
"settings": "设置",
"changePassword": "修改密码",
"groups": "分组"
},
"pages": {
"dashboard": {
"title": "仪表盘",
"totalServers": "服务器总数",
"onlineServers": "在线服务器",
"offlineServers": "离线服务器",
"connectingServers": "连接中服务",
"recentServers": "最近的服务器"
},
"servers": {
"title": "服务器管理"
},
"settings": {
"title": "设置",
"language": "语言"
},
"groups": {
"title": "分组管理"
}
},
"groups": {
"add": "添加",
"addNew": "添加新分组",
"edit": "编辑分组",
"delete": "删除",
"confirmDelete": "您确定要删除此分组吗?",
"deleteWarning": "删除分组 '{{name}}' 将会移除该分组及其所有服务器关联。此操作无法撤销。",
"name": "分组名称",
"namePlaceholder": "请输入分组名称",
"nameRequired": "分组名称不能为空",
"description": "描述",
"descriptionPlaceholder": "请输入分组描述(可选)",
"createError": "创建分组失败",
"updateError": "更新分组失败",
"deleteError": "删除分组失败",
"serverAddError": "向分组添加服务器失败",
"serverRemoveError": "从分组移除服务器失败",
"addServer": "添加服务器到分组",
"selectServer": "选择要添加的服务器",
"servers": "分组中的服务器",
"remove": "移除",
"noGroups": "暂无可用分组。创建一个新分组以开始使用。",
"noServers": "此分组中没有服务器。",
"noServerOptions": "没有可用的服务器",
"serverCount": "{{count}} 台服务器"
}
}

View File

@@ -1,30 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import ChangePasswordForm from '../components/ChangePasswordForm';
const ChangePasswordPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const handleSuccess = () => {
setTimeout(() => {
navigate('/');
}, 2000);
};
const handleCancel = () => {
navigate('/');
};
return (
<div className="container mx-auto px-4 py-8">
<div className="max-w-md mx-auto">
<h1 className="text-2xl font-bold mb-6">{t('auth.changePassword')}</h1>
<ChangePasswordForm onSuccess={handleSuccess} onCancel={handleCancel} />
</div>
</div>
);
};
export default ChangePasswordPage;

View File

@@ -0,0 +1,206 @@
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();
const { servers, error, setError, isLoading } = useServerData();
// 计算服务器统计信息
const serverStats = {
total: servers.length,
online: servers.filter(server => server.status === 'connected').length,
offline: servers.filter(server => server.status === 'disconnected').length,
connecting: servers.filter(server => server.status === 'connecting').length
};
// Map status to translation keys
const statusTranslations = {
connected: 'status.online',
disconnected: 'status.offline',
connecting: 'status.connecting'
}
// 计算各状态百分比(用于仪表板展示)
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="flex items-center justify-between">
<div>
<h3 className="text-red-600 text-lg font-medium">{t('app.error')}</h3>
<p className="text-gray-600 mt-1">{error}</p>
</div>
<button
onClick={() => setError(null)}
className="ml-4 text-gray-500 hover:text-gray-700"
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">
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 111.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</div>
</div>
)}
{isLoading ? (
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center">
<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>
<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">{t('app.loading')}</p>
</div>
</div>
) : (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
{/* 服务器总数 */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="p-3 rounded-full bg-blue-100 text-blue-800">
<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>
</div>
<div className="ml-4">
<h2 className="text-xl font-semibold text-gray-700">{t('pages.dashboard.totalServers')}</h2>
<p className="text-3xl font-bold text-gray-900">{serverStats.total}</p>
</div>
</div>
</div>
{/* 在线服务器 */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="p-3 rounded-full bg-green-100 text-green-800">
<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>
</div>
<div className="ml-4">
<h2 className="text-xl font-semibold text-gray-700">{t('pages.dashboard.onlineServers')}</h2>
<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>
{/* 离线服务器 */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="p-3 rounded-full bg-red-100 text-red-800">
<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>
</div>
<div className="ml-4">
<h2 className="text-xl font-semibold text-gray-700">{t('pages.dashboard.offlineServers')}</h2>
<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>
{/* 连接中服务器 */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="p-3 rounded-full bg-yellow-100 text-yellow-800">
<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>
</div>
<div className="ml-4">
<h2 className="text-xl font-semibold text-gray-700">{t('pages.dashboard.connectingServers')}</h2>
<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>
)}
{/* 最近活动列表 */}
{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">
<tr>
<th scope="col" className="px-6 py-3 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">
{t('server.status')}
</th>
<th scope="col" className="px-6 py-3 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">
{t('server.enabled')}
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{servers.slice(0, 5).map((server, index) => (
<tr key={index}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{server.name}
</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'
}`}>
{t(statusTranslations[server.status] || server.status)}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{server.tools?.length || 0}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{server.enabled !== false ? (
<span className="text-green-600"></span>
) : (
<span className="text-red-600"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
};
export default DashboardPage;

View File

@@ -0,0 +1,116 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Group } from '@/types';
import { useGroupData } from '@/hooks/useGroupData';
import { useServerData } from '@/hooks/useServerData';
import AddGroupForm from '@/components/AddGroupForm';
import EditGroupForm from '@/components/EditGroupForm';
import GroupCard from '@/components/GroupCard';
const GroupsPage: React.FC = () => {
const { t } = useTranslation();
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);
const handleEditClick = (group: Group) => {
setEditingGroup(group);
};
const handleEditComplete = () => {
setEditingGroup(null);
triggerRefresh(); // Refresh the groups list after editing
};
const handleDeleteGroup = async (groupId: string) => {
const success = await deleteGroup(groupId);
if (!success) {
setGroupError(t('groups.deleteError'));
}
};
const handleAddGroup = () => {
setShowAddForm(true);
};
const handleAddComplete = () => {
setShowAddForm(false);
triggerRefresh(); // Refresh the groups list after adding
};
return (
<div>
<div className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold text-gray-900">{t('pages.groups.title')}</h1>
<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"
>
<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" />
</svg>
{t('groups.add')}
</button>
</div>
</div>
{groupError && (
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6">
<p>{groupError}</p>
</div>
)}
{groupsLoading ? (
<div className="bg-white shadow rounded-lg p-6">
<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>
<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">{t('app.loading')}</p>
</div>
</div>
) : groups.length === 0 ? (
<div className="bg-white shadow rounded-lg p-6">
<p className="text-gray-600">{t('groups.noGroups')}</p>
</div>
) : (
<div className="space-y-6">
{groups.map((group) => (
<GroupCard
key={group.id}
group={group}
servers={servers}
onEdit={handleEditClick}
onDelete={handleDeleteGroup}
/>
))}
</div>
)}
{showAddForm && (
<AddGroupForm onAdd={handleAddComplete} onCancel={handleAddComplete} />
)}
{editingGroup && (
<EditGroupForm
group={editingGroup}
onEdit={handleEditComplete}
onCancel={() => setEditingGroup(null)}
/>
)}
</div>
);
};
export default GroupsPage;

View File

@@ -0,0 +1,111 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Server } from '@/types';
import ServerCard from '@/components/ServerCard';
import AddServerForm from '@/components/AddServerForm';
import EditServerForm from '@/components/EditServerForm';
import { useServerData } from '@/hooks/useServerData';
const ServersPage: React.FC = () => {
const { t } = useTranslation();
const {
servers,
error,
setError,
isLoading,
handleServerAdd,
handleServerEdit,
handleServerRemove,
handleServerToggle
} = useServerData();
const [editingServer, setEditingServer] = useState<Server | null>(null);
const handleEditClick = async (server: Server) => {
const fullServerData = await handleServerEdit(server);
if (fullServerData) {
setEditingServer(fullServerData);
}
};
const handleEditComplete = () => {
setEditingServer(null);
};
return (
<div>
<div className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold text-gray-900">{t('pages.servers.title')}</h1>
<div className="flex space-x-4">
<AddServerForm onAdd={handleServerAdd} />
<button
onClick={() => handleServerAdd()}
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center"
>
<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="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clipRule="evenodd" />
</svg>
{t('common.refresh')}
</button>
</div>
</div>
{error && (
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm">
<div className="flex items-center justify-between">
<div>
<h3 className="text-red-600 text-lg font-medium">{t('app.error')}</h3>
<p className="text-gray-600 mt-1">{error}</p>
</div>
<button
onClick={() => setError(null)}
className="ml-4 text-gray-500 hover:text-gray-700"
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">
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 111.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</div>
</div>
)}
{isLoading ? (
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center">
<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>
<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">{t('app.loading')}</p>
</div>
</div>
) : servers.length === 0 ? (
<div className="bg-white shadow rounded-lg p-6">
<p className="text-gray-600">{t('app.noServers')}</p>
</div>
) : (
<div className="space-y-6">
{servers.map((server, index) => (
<ServerCard
key={index}
server={server}
onRemove={handleServerRemove}
onEdit={handleEditClick}
onToggle={handleServerToggle}
/>
))}
</div>
)}
{editingServer && (
<EditServerForm
server={editingServer}
onEdit={handleEditComplete}
onCancel={() => setEditingServer(null)}
/>
)}
</div>
);
};
export default ServersPage;

View File

@@ -0,0 +1,55 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import ChangePasswordForm from '@/components/ChangePasswordForm';
const SettingsPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const handlePasswordChangeSuccess = () => {
setTimeout(() => {
navigate('/');
}, 2000);
};
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-8">{t('pages.settings.title')}</h1>
<div className="bg-white shadow rounded-lg p-6">
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('auth.changePassword')}</h2>
<div className="max-w-lg">
<ChangePasswordForm onSuccess={handlePasswordChangeSuccess} />
</div>
</div>
{/* 其他设置可以在这里添加 */}
<div className="bg-white shadow rounded-lg p-6 mt-6">
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('pages.settings.language')}</h2>
<div className="flex space-x-4">
<button
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200"
onClick={() => {
localStorage.setItem('i18nextLng', 'en');
window.location.reload();
}}
>
English
</button>
<button
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200"
onClick={() => {
localStorage.setItem('i18nextLng', 'zh');
window.location.reload();
}}
>
</button>
</div>
</div>
</div>
);
};
export default SettingsPage;

View File

@@ -1,57 +1,73 @@
// 服务器状态类型
// Server status types
export type ServerStatus = 'connecting' | 'connected' | 'disconnected';
// 工具输入模式类型
// Tool input schema types
export interface ToolInputSchema {
type: string;
properties?: Record<string, any>;
properties?: Record<string, unknown>;
required?: string[];
[key: string]: any;
}
// 工具类型
// Tool types
export interface Tool {
name: string;
description?: string;
description: string;
inputSchema: ToolInputSchema;
}
// 服务器配置类型
// Server config types
export interface ServerConfig {
url?: string;
command?: string;
args?: string[] | string;
args?: string[];
env?: Record<string, string>;
enabled?: boolean;
}
// 服务器类型
// Server types
export interface Server {
name: string;
status: ServerStatus;
tools?: Tool[];
config?: ServerConfig;
enabled?: boolean;
}
// 环境变量类型
// Group types
export interface Group {
id: string;
name: string;
description?: string;
servers: string[];
}
// Environment variable types
export interface EnvVar {
key: string;
value: string;
}
// 表单数据类型
// Form data types
export interface ServerFormData {
name: string;
url: string;
command: string;
arguments: string;
args: string[];
env: EnvVar[];
}
// API响应类型
export interface ApiResponse<T> {
// Group form data types
export interface GroupFormData {
name: string;
description: string;
servers: string[]; // Added servers array to include in form data
}
// API response types
export interface ApiResponse<T = any> {
success: boolean;
data: T;
message?: string;
data?: T;
}
// Auth types
@@ -61,10 +77,9 @@ export interface IUser {
}
export interface AuthState {
token: string | null;
isAuthenticated: boolean;
loading: boolean;
user: IUser | null;
loading: boolean;
error: string | null;
}
@@ -87,5 +102,4 @@ export interface AuthResponse {
token?: string;
user?: IUser;
message?: string;
errors?: Array<{ msg: string }>;
}

View File

@@ -0,0 +1,10 @@
import { ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
/**
* Combines multiple class names and deduplicates Tailwind CSS classes
* This is a utility function for conditionally joining class names together
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -30,6 +30,7 @@
"@tailwindcss/vite": "^4.1.3",
"@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4",
"@types/uuid": "^10.0.0",
"autoprefixer": "^10.4.21",
"bcryptjs": "^3.0.2",
"class-variance-authority": "^0.7.1",
@@ -50,6 +51,7 @@
"tailwind-merge": "^3.1.0",
"tailwind-scrollbar-hide": "^2.0.0",
"tailwindcss": "^4.0.17",
"uuid": "^11.1.0",
"zod": "^3.24.2"
},
"devDependencies": {
@@ -70,6 +72,6 @@
"ts-node-dev": "^2.0.0",
"tsx": "^4.7.0",
"typescript": "^5.2.2",
"vite": "^5.2.6"
"vite": "^5.4.18"
}
}

207
pnpm-lock.yaml generated
View File

@@ -22,13 +22,16 @@ importers:
version: 0.0.4
'@tailwindcss/vite':
specifier: ^4.1.3
version: 4.1.3(vite@5.4.17(@types/node@20.17.28)(lightningcss@1.29.2))
version: 4.1.3(vite@5.4.18(@types/node@20.17.28)(lightningcss@1.29.2))
'@types/react':
specifier: ^19.0.12
version: 19.0.12
'@types/react-dom':
specifier: ^19.0.4
version: 19.0.4(@types/react@19.0.12)
'@types/uuid':
specifier: ^10.0.0
version: 10.0.0
autoprefixer:
specifier: ^10.4.21
version: 10.4.21(postcss@8.5.3)
@@ -89,6 +92,9 @@ importers:
tailwindcss:
specifier: ^4.0.17
version: 4.0.17
uuid:
specifier: ^11.1.0
version: 11.1.0
zod:
specifier: ^3.24.2
version: 3.24.2
@@ -119,7 +125,7 @@ importers:
version: 6.21.0(eslint@8.57.1)(typescript@5.8.2)
'@vitejs/plugin-react':
specifier: ^4.2.1
version: 4.3.4(vite@5.4.17(@types/node@20.17.28)(lightningcss@1.29.2))
version: 4.3.4(vite@5.4.18(@types/node@20.17.28)(lightningcss@1.29.2))
concurrently:
specifier: ^8.2.2
version: 8.2.2
@@ -145,8 +151,8 @@ importers:
specifier: ^5.2.2
version: 5.8.2
vite:
specifier: ^5.2.6
version: 5.4.17(@types/node@20.17.28)(lightningcss@1.29.2)
specifier: ^5.4.18
version: 5.4.18(@types/node@20.17.28)(lightningcss@1.29.2)
packages:
@@ -1068,103 +1074,103 @@ packages:
'@types/react':
optional: true
'@rollup/rollup-android-arm-eabi@4.39.0':
resolution: {integrity: sha512-lGVys55Qb00Wvh8DMAocp5kIcaNzEFTmGhfFd88LfaogYTRKrdxgtlO5H6S49v2Nd8R2C6wLOal0qv6/kCkOwA==}
'@rollup/rollup-android-arm-eabi@4.40.0':
resolution: {integrity: sha512-+Fbls/diZ0RDerhE8kyC6hjADCXA1K4yVNlH0EYfd2XjyH0UGgzaQ8MlT0pCXAThfxv3QUAczHaL+qSv1E4/Cg==}
cpu: [arm]
os: [android]
'@rollup/rollup-android-arm64@4.39.0':
resolution: {integrity: sha512-It9+M1zE31KWfqh/0cJLrrsCPiF72PoJjIChLX+rEcujVRCb4NLQ5QzFkzIZW8Kn8FTbvGQBY5TkKBau3S8cCQ==}
'@rollup/rollup-android-arm64@4.40.0':
resolution: {integrity: sha512-PPA6aEEsTPRz+/4xxAmaoWDqh67N7wFbgFUJGMnanCFs0TV99M0M8QhhaSCks+n6EbQoFvLQgYOGXxlMGQe/6w==}
cpu: [arm64]
os: [android]
'@rollup/rollup-darwin-arm64@4.39.0':
resolution: {integrity: sha512-lXQnhpFDOKDXiGxsU9/l8UEGGM65comrQuZ+lDcGUx+9YQ9dKpF3rSEGepyeR5AHZ0b5RgiligsBhWZfSSQh8Q==}
'@rollup/rollup-darwin-arm64@4.40.0':
resolution: {integrity: sha512-GwYOcOakYHdfnjjKwqpTGgn5a6cUX7+Ra2HeNj/GdXvO2VJOOXCiYYlRFU4CubFM67EhbmzLOmACKEfvp3J1kQ==}
cpu: [arm64]
os: [darwin]
'@rollup/rollup-darwin-x64@4.39.0':
resolution: {integrity: sha512-mKXpNZLvtEbgu6WCkNij7CGycdw9cJi2k9v0noMb++Vab12GZjFgUXD69ilAbBh034Zwn95c2PNSz9xM7KYEAQ==}
'@rollup/rollup-darwin-x64@4.40.0':
resolution: {integrity: sha512-CoLEGJ+2eheqD9KBSxmma6ld01czS52Iw0e2qMZNpPDlf7Z9mj8xmMemxEucinev4LgHalDPczMyxzbq+Q+EtA==}
cpu: [x64]
os: [darwin]
'@rollup/rollup-freebsd-arm64@4.39.0':
resolution: {integrity: sha512-jivRRlh2Lod/KvDZx2zUR+I4iBfHcu2V/BA2vasUtdtTN2Uk3jfcZczLa81ESHZHPHy4ih3T/W5rPFZ/hX7RtQ==}
'@rollup/rollup-freebsd-arm64@4.40.0':
resolution: {integrity: sha512-r7yGiS4HN/kibvESzmrOB/PxKMhPTlz+FcGvoUIKYoTyGd5toHp48g1uZy1o1xQvybwwpqpe010JrcGG2s5nkg==}
cpu: [arm64]
os: [freebsd]
'@rollup/rollup-freebsd-x64@4.39.0':
resolution: {integrity: sha512-8RXIWvYIRK9nO+bhVz8DwLBepcptw633gv/QT4015CpJ0Ht8punmoHU/DuEd3iw9Hr8UwUV+t+VNNuZIWYeY7Q==}
'@rollup/rollup-freebsd-x64@4.40.0':
resolution: {integrity: sha512-mVDxzlf0oLzV3oZOr0SMJ0lSDd3xC4CmnWJ8Val8isp9jRGl5Dq//LLDSPFrasS7pSm6m5xAcKaw3sHXhBjoRw==}
cpu: [x64]
os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.39.0':
resolution: {integrity: sha512-mz5POx5Zu58f2xAG5RaRRhp3IZDK7zXGk5sdEDj4o96HeaXhlUwmLFzNlc4hCQi5sGdR12VDgEUqVSHer0lI9g==}
'@rollup/rollup-linux-arm-gnueabihf@4.40.0':
resolution: {integrity: sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA==}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm-musleabihf@4.39.0':
resolution: {integrity: sha512-+YDwhM6gUAyakl0CD+bMFpdmwIoRDzZYaTWV3SDRBGkMU/VpIBYXXEvkEcTagw/7VVkL2vA29zU4UVy1mP0/Yw==}
'@rollup/rollup-linux-arm-musleabihf@4.40.0':
resolution: {integrity: sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg==}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm64-gnu@4.39.0':
resolution: {integrity: sha512-EKf7iF7aK36eEChvlgxGnk7pdJfzfQbNvGV/+l98iiMwU23MwvmV0Ty3pJ0p5WQfm3JRHOytSIqD9LB7Bq7xdQ==}
'@rollup/rollup-linux-arm64-gnu@4.40.0':
resolution: {integrity: sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg==}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-arm64-musl@4.39.0':
resolution: {integrity: sha512-vYanR6MtqC7Z2SNr8gzVnzUul09Wi1kZqJaek3KcIlI/wq5Xtq4ZPIZ0Mr/st/sv/NnaPwy/D4yXg5x0B3aUUA==}
'@rollup/rollup-linux-arm64-musl@4.40.0':
resolution: {integrity: sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ==}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-loongarch64-gnu@4.39.0':
resolution: {integrity: sha512-NMRUT40+h0FBa5fb+cpxtZoGAggRem16ocVKIv5gDB5uLDgBIwrIsXlGqYbLwW8YyO3WVTk1FkFDjMETYlDqiw==}
'@rollup/rollup-linux-loongarch64-gnu@4.40.0':
resolution: {integrity: sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg==}
cpu: [loong64]
os: [linux]
'@rollup/rollup-linux-powerpc64le-gnu@4.39.0':
resolution: {integrity: sha512-0pCNnmxgduJ3YRt+D+kJ6Ai/r+TaePu9ZLENl+ZDV/CdVczXl95CbIiwwswu4L+K7uOIGf6tMo2vm8uadRaICQ==}
'@rollup/rollup-linux-powerpc64le-gnu@4.40.0':
resolution: {integrity: sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw==}
cpu: [ppc64]
os: [linux]
'@rollup/rollup-linux-riscv64-gnu@4.39.0':
resolution: {integrity: sha512-t7j5Zhr7S4bBtksT73bO6c3Qa2AV/HqiGlj9+KB3gNF5upcVkx+HLgxTm8DK4OkzsOYqbdqbLKwvGMhylJCPhQ==}
'@rollup/rollup-linux-riscv64-gnu@4.40.0':
resolution: {integrity: sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA==}
cpu: [riscv64]
os: [linux]
'@rollup/rollup-linux-riscv64-musl@4.39.0':
resolution: {integrity: sha512-m6cwI86IvQ7M93MQ2RF5SP8tUjD39Y7rjb1qjHgYh28uAPVU8+k/xYWvxRO3/tBN2pZkSMa5RjnPuUIbrwVxeA==}
'@rollup/rollup-linux-riscv64-musl@4.40.0':
resolution: {integrity: sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ==}
cpu: [riscv64]
os: [linux]
'@rollup/rollup-linux-s390x-gnu@4.39.0':
resolution: {integrity: sha512-iRDJd2ebMunnk2rsSBYlsptCyuINvxUfGwOUldjv5M4tpa93K8tFMeYGpNk2+Nxl+OBJnBzy2/JCscGeO507kA==}
'@rollup/rollup-linux-s390x-gnu@4.40.0':
resolution: {integrity: sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw==}
cpu: [s390x]
os: [linux]
'@rollup/rollup-linux-x64-gnu@4.39.0':
resolution: {integrity: sha512-t9jqYw27R6Lx0XKfEFe5vUeEJ5pF3SGIM6gTfONSMb7DuG6z6wfj2yjcoZxHg129veTqU7+wOhY6GX8wmf90dA==}
'@rollup/rollup-linux-x64-gnu@4.40.0':
resolution: {integrity: sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ==}
cpu: [x64]
os: [linux]
'@rollup/rollup-linux-x64-musl@4.39.0':
resolution: {integrity: sha512-ThFdkrFDP55AIsIZDKSBWEt/JcWlCzydbZHinZ0F/r1h83qbGeenCt/G/wG2O0reuENDD2tawfAj2s8VK7Bugg==}
'@rollup/rollup-linux-x64-musl@4.40.0':
resolution: {integrity: sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw==}
cpu: [x64]
os: [linux]
'@rollup/rollup-win32-arm64-msvc@4.39.0':
resolution: {integrity: sha512-jDrLm6yUtbOg2TYB3sBF3acUnAwsIksEYjLeHL+TJv9jg+TmTwdyjnDex27jqEMakNKf3RwwPahDIt7QXCSqRQ==}
'@rollup/rollup-win32-arm64-msvc@4.40.0':
resolution: {integrity: sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ==}
cpu: [arm64]
os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.39.0':
resolution: {integrity: sha512-6w9uMuza+LbLCVoNKL5FSLE7yvYkq9laSd09bwS0tMjkwXrmib/4KmoJcrKhLWHvw19mwU+33ndC69T7weNNjQ==}
'@rollup/rollup-win32-ia32-msvc@4.40.0':
resolution: {integrity: sha512-+m03kvI2f5syIqHXCZLPVYplP8pQch9JHyXKZ3AGMKlg8dCyr2PKHjwRLiW53LTrN/Nc3EqHOKxUxzoSPdKddA==}
cpu: [ia32]
os: [win32]
'@rollup/rollup-win32-x64-msvc@4.39.0':
resolution: {integrity: sha512-yAkUOkIKZlK5dl7u6dg897doBgLXmUHhIINM2c+sND3DZwnrdQkkSiDh7N75Ll4mM4dxSkYfXqU9fW3lLkMFug==}
'@rollup/rollup-win32-x64-msvc@4.40.0':
resolution: {integrity: sha512-lpPE1cLfP5oPzVjKMx10pgBmKELQnFJXHgvtHCtuJWOv8MxqdEIMNtgHgBFf7Ea2/7EuVwa9fodWUfXAlXZLZQ==}
cpu: [x64]
os: [win32]
@@ -1379,6 +1385,9 @@ packages:
'@types/strip-json-comments@0.0.30':
resolution: {integrity: sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==}
'@types/uuid@10.0.0':
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
'@types/yargs-parser@21.0.3':
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==}
@@ -3100,8 +3109,8 @@ packages:
deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true
rollup@4.39.0:
resolution: {integrity: sha512-thI8kNc02yNvnmJp8dr3fNWJ9tCONDhp6TV35X6HkKGGs9E6q7YWCHbe5vKiTa7TAiNcFEmXKj3X/pG2b3ci0g==}
rollup@4.40.0:
resolution: {integrity: sha512-Noe455xmA96nnqH5piFtLobsGbCij7Tu+tb3c1vYjNbTkfzGqXqQXG3wJaYXkRZuQ0vEYN4bhwg7QnIrqB5B+w==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
@@ -3473,6 +3482,10 @@ packages:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
uuid@11.1.0:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
v8-compile-cache-lib@3.0.1:
resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==}
@@ -3488,8 +3501,8 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
vite@5.4.17:
resolution: {integrity: sha512-5+VqZryDj4wgCs55o9Lp+p8GE78TLVg0lasCH5xFZ4jacZjtqZa6JUw9/p0WeAojaOfncSM6v77InkFPGnvPvg==}
vite@5.4.18:
resolution: {integrity: sha512-1oDcnEp3lVyHCuQ2YFelM4Alm2o91xNoMncRm1U7S+JdYfYOvbiGZ3/CxGttrOu2M/KcGz7cRC2DoNUA6urmMA==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:
@@ -4425,64 +4438,64 @@ snapshots:
optionalDependencies:
'@types/react': 19.0.12
'@rollup/rollup-android-arm-eabi@4.39.0':
'@rollup/rollup-android-arm-eabi@4.40.0':
optional: true
'@rollup/rollup-android-arm64@4.39.0':
'@rollup/rollup-android-arm64@4.40.0':
optional: true
'@rollup/rollup-darwin-arm64@4.39.0':
'@rollup/rollup-darwin-arm64@4.40.0':
optional: true
'@rollup/rollup-darwin-x64@4.39.0':
'@rollup/rollup-darwin-x64@4.40.0':
optional: true
'@rollup/rollup-freebsd-arm64@4.39.0':
'@rollup/rollup-freebsd-arm64@4.40.0':
optional: true
'@rollup/rollup-freebsd-x64@4.39.0':
'@rollup/rollup-freebsd-x64@4.40.0':
optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.39.0':
'@rollup/rollup-linux-arm-gnueabihf@4.40.0':
optional: true
'@rollup/rollup-linux-arm-musleabihf@4.39.0':
'@rollup/rollup-linux-arm-musleabihf@4.40.0':
optional: true
'@rollup/rollup-linux-arm64-gnu@4.39.0':
'@rollup/rollup-linux-arm64-gnu@4.40.0':
optional: true
'@rollup/rollup-linux-arm64-musl@4.39.0':
'@rollup/rollup-linux-arm64-musl@4.40.0':
optional: true
'@rollup/rollup-linux-loongarch64-gnu@4.39.0':
'@rollup/rollup-linux-loongarch64-gnu@4.40.0':
optional: true
'@rollup/rollup-linux-powerpc64le-gnu@4.39.0':
'@rollup/rollup-linux-powerpc64le-gnu@4.40.0':
optional: true
'@rollup/rollup-linux-riscv64-gnu@4.39.0':
'@rollup/rollup-linux-riscv64-gnu@4.40.0':
optional: true
'@rollup/rollup-linux-riscv64-musl@4.39.0':
'@rollup/rollup-linux-riscv64-musl@4.40.0':
optional: true
'@rollup/rollup-linux-s390x-gnu@4.39.0':
'@rollup/rollup-linux-s390x-gnu@4.40.0':
optional: true
'@rollup/rollup-linux-x64-gnu@4.39.0':
'@rollup/rollup-linux-x64-gnu@4.40.0':
optional: true
'@rollup/rollup-linux-x64-musl@4.39.0':
'@rollup/rollup-linux-x64-musl@4.40.0':
optional: true
'@rollup/rollup-win32-arm64-msvc@4.39.0':
'@rollup/rollup-win32-arm64-msvc@4.40.0':
optional: true
'@rollup/rollup-win32-ia32-msvc@4.39.0':
'@rollup/rollup-win32-ia32-msvc@4.40.0':
optional: true
'@rollup/rollup-win32-x64-msvc@4.39.0':
'@rollup/rollup-win32-x64-msvc@4.40.0':
optional: true
'@shadcn/ui@0.0.4':
@@ -4574,12 +4587,12 @@ snapshots:
postcss: 8.5.3
tailwindcss: 4.1.3
'@tailwindcss/vite@4.1.3(vite@5.4.17(@types/node@20.17.28)(lightningcss@1.29.2))':
'@tailwindcss/vite@4.1.3(vite@5.4.18(@types/node@20.17.28)(lightningcss@1.29.2))':
dependencies:
'@tailwindcss/node': 4.1.3
'@tailwindcss/oxide': 4.1.3
tailwindcss: 4.1.3
vite: 5.4.17(@types/node@20.17.28)(lightningcss@1.29.2)
vite: 5.4.18(@types/node@20.17.28)(lightningcss@1.29.2)
'@tsconfig/node10@1.0.11': {}
@@ -4708,6 +4721,8 @@ snapshots:
'@types/strip-json-comments@0.0.30': {}
'@types/uuid@10.0.0': {}
'@types/yargs-parser@21.0.3': {}
'@types/yargs@17.0.33':
@@ -4802,14 +4817,14 @@ snapshots:
'@ungap/structured-clone@1.3.0': {}
'@vitejs/plugin-react@4.3.4(vite@5.4.17(@types/node@20.17.28)(lightningcss@1.29.2))':
'@vitejs/plugin-react@4.3.4(vite@5.4.18(@types/node@20.17.28)(lightningcss@1.29.2))':
dependencies:
'@babel/core': 7.26.10
'@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.26.10)
'@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.10)
'@types/babel__core': 7.20.5
react-refresh: 0.14.2
vite: 5.4.17(@types/node@20.17.28)(lightningcss@1.29.2)
vite: 5.4.18(@types/node@20.17.28)(lightningcss@1.29.2)
transitivePeerDependencies:
- supports-color
@@ -6701,30 +6716,30 @@ snapshots:
dependencies:
glob: 7.2.3
rollup@4.39.0:
rollup@4.40.0:
dependencies:
'@types/estree': 1.0.7
optionalDependencies:
'@rollup/rollup-android-arm-eabi': 4.39.0
'@rollup/rollup-android-arm64': 4.39.0
'@rollup/rollup-darwin-arm64': 4.39.0
'@rollup/rollup-darwin-x64': 4.39.0
'@rollup/rollup-freebsd-arm64': 4.39.0
'@rollup/rollup-freebsd-x64': 4.39.0
'@rollup/rollup-linux-arm-gnueabihf': 4.39.0
'@rollup/rollup-linux-arm-musleabihf': 4.39.0
'@rollup/rollup-linux-arm64-gnu': 4.39.0
'@rollup/rollup-linux-arm64-musl': 4.39.0
'@rollup/rollup-linux-loongarch64-gnu': 4.39.0
'@rollup/rollup-linux-powerpc64le-gnu': 4.39.0
'@rollup/rollup-linux-riscv64-gnu': 4.39.0
'@rollup/rollup-linux-riscv64-musl': 4.39.0
'@rollup/rollup-linux-s390x-gnu': 4.39.0
'@rollup/rollup-linux-x64-gnu': 4.39.0
'@rollup/rollup-linux-x64-musl': 4.39.0
'@rollup/rollup-win32-arm64-msvc': 4.39.0
'@rollup/rollup-win32-ia32-msvc': 4.39.0
'@rollup/rollup-win32-x64-msvc': 4.39.0
'@rollup/rollup-android-arm-eabi': 4.40.0
'@rollup/rollup-android-arm64': 4.40.0
'@rollup/rollup-darwin-arm64': 4.40.0
'@rollup/rollup-darwin-x64': 4.40.0
'@rollup/rollup-freebsd-arm64': 4.40.0
'@rollup/rollup-freebsd-x64': 4.40.0
'@rollup/rollup-linux-arm-gnueabihf': 4.40.0
'@rollup/rollup-linux-arm-musleabihf': 4.40.0
'@rollup/rollup-linux-arm64-gnu': 4.40.0
'@rollup/rollup-linux-arm64-musl': 4.40.0
'@rollup/rollup-linux-loongarch64-gnu': 4.40.0
'@rollup/rollup-linux-powerpc64le-gnu': 4.40.0
'@rollup/rollup-linux-riscv64-gnu': 4.40.0
'@rollup/rollup-linux-riscv64-musl': 4.40.0
'@rollup/rollup-linux-s390x-gnu': 4.40.0
'@rollup/rollup-linux-x64-gnu': 4.40.0
'@rollup/rollup-linux-x64-musl': 4.40.0
'@rollup/rollup-win32-arm64-msvc': 4.40.0
'@rollup/rollup-win32-ia32-msvc': 4.40.0
'@rollup/rollup-win32-x64-msvc': 4.40.0
fsevents: 2.3.3
router@2.2.0:
@@ -7120,6 +7135,8 @@ snapshots:
utils-merge@1.0.1: {}
uuid@11.1.0: {}
v8-compile-cache-lib@3.0.1: {}
v8-to-istanbul@9.3.0:
@@ -7132,11 +7149,11 @@ snapshots:
vary@1.1.2: {}
vite@5.4.17(@types/node@20.17.28)(lightningcss@1.29.2):
vite@5.4.18(@types/node@20.17.28)(lightningcss@1.29.2):
dependencies:
esbuild: 0.21.5
postcss: 8.5.3
rollup: 4.39.0
rollup: 4.40.0
optionalDependencies:
'@types/node': 20.17.28
fsevents: 2.3.3

View File

@@ -0,0 +1,341 @@
import { Request, Response } from 'express';
import { ApiResponse } from '../types/index.js';
import {
getAllGroups,
getGroupById,
createGroup,
updateGroup,
updateGroupServers,
deleteGroup,
addServerToGroup,
removeServerFromGroup,
getServersInGroup
} from '../services/groupService.js';
// Get all groups
export const getGroups = (_: Request, res: Response): void => {
try {
const groups = getAllGroups();
const response: ApiResponse = {
success: true,
data: groups,
};
res.json(response);
} catch (error) {
res.status(500).json({
success: false,
message: 'Failed to get groups information',
});
}
};
// Get a specific group by ID
export const getGroup = (req: Request, res: Response): void => {
try {
const { id } = req.params;
if (!id) {
res.status(400).json({
success: false,
message: 'Group ID is required',
});
return;
}
const group = getGroupById(id);
if (!group) {
res.status(404).json({
success: false,
message: 'Group not found',
});
return;
}
const response: ApiResponse = {
success: true,
data: group,
};
res.json(response);
} catch (error) {
res.status(500).json({
success: false,
message: 'Failed to get group information',
});
}
};
// Create a new group
export const createNewGroup = (req: Request, res: Response): void => {
try {
const { name, description, servers } = req.body;
if (!name) {
res.status(400).json({
success: false,
message: 'Group name is required',
});
return;
}
const serverList = Array.isArray(servers) ? servers : [];
const newGroup = createGroup(name, description, serverList);
if (!newGroup) {
res.status(400).json({
success: false,
message: 'Failed to create group or group name already exists',
});
return;
}
const response: ApiResponse = {
success: true,
data: newGroup,
message: 'Group created successfully',
};
res.status(201).json(response);
} catch (error) {
res.status(500).json({
success: false,
message: 'Internal server error',
});
}
};
// Update an existing group
export const updateExistingGroup = (req: Request, res: Response): void => {
try {
const { id } = req.params;
const { name, description, servers } = req.body;
if (!id) {
res.status(400).json({
success: false,
message: 'Group ID is required',
});
return;
}
// Allow updating servers along with other fields
const updateData: any = {};
if (name !== undefined) updateData.name = name;
if (description !== undefined) updateData.description = description;
if (servers !== undefined) updateData.servers = servers;
if (Object.keys(updateData).length === 0) {
res.status(400).json({
success: false,
message: 'At least one field (name, description, or servers) is required to update',
});
return;
}
const updatedGroup = updateGroup(id, updateData);
if (!updatedGroup) {
res.status(404).json({
success: false,
message: 'Group not found or name already exists',
});
return;
}
const response: ApiResponse = {
success: true,
data: updatedGroup,
message: 'Group updated successfully',
};
res.json(response);
} catch (error) {
res.status(500).json({
success: false,
message: 'Internal server error',
});
}
};
// Update servers in a group (batch update)
export const updateGroupServersBatch = (req: Request, res: Response): void => {
try {
const { id } = req.params;
const { servers } = req.body;
if (!id) {
res.status(400).json({
success: false,
message: 'Group ID is required',
});
return;
}
if (!Array.isArray(servers)) {
res.status(400).json({
success: false,
message: 'Servers must be an array of server names',
});
return;
}
const updatedGroup = updateGroupServers(id, servers);
if (!updatedGroup) {
res.status(404).json({
success: false,
message: 'Group not found',
});
return;
}
const response: ApiResponse = {
success: true,
data: updatedGroup,
message: 'Group servers updated successfully',
};
res.json(response);
} catch (error) {
res.status(500).json({
success: false,
message: 'Internal server error',
});
}
};
// Delete a group
export const deleteExistingGroup = (req: Request, res: Response): void => {
try {
const { id } = req.params;
if (!id) {
res.status(400).json({
success: false,
message: 'Group ID is required',
});
return;
}
const success = deleteGroup(id);
if (!success) {
res.status(404).json({
success: false,
message: 'Group not found or failed to delete',
});
return;
}
res.json({
success: true,
message: 'Group deleted successfully',
});
} catch (error) {
res.status(500).json({
success: false,
message: 'Internal server error',
});
}
};
// Add server to a group
export const addServerToExistingGroup = (req: Request, res: Response): void => {
try {
const { id } = req.params;
const { serverName } = req.body;
if (!id) {
res.status(400).json({
success: false,
message: 'Group ID is required',
});
return;
}
if (!serverName) {
res.status(400).json({
success: false,
message: 'Server name is required',
});
return;
}
const updatedGroup = addServerToGroup(id, serverName);
if (!updatedGroup) {
res.status(404).json({
success: false,
message: 'Group or server not found',
});
return;
}
const response: ApiResponse = {
success: true,
data: updatedGroup,
message: 'Server added to group successfully',
};
res.json(response);
} catch (error) {
res.status(500).json({
success: false,
message: 'Internal server error',
});
}
};
// Remove server from a group
export const removeServerFromExistingGroup = (req: Request, res: Response): void => {
try {
const { id, serverName } = req.params;
if (!id || !serverName) {
res.status(400).json({
success: false,
message: 'Group ID and server name are required',
});
return;
}
const updatedGroup = removeServerFromGroup(id, serverName);
if (!updatedGroup) {
res.status(404).json({
success: false,
message: 'Group not found',
});
return;
}
const response: ApiResponse = {
success: true,
data: updatedGroup,
message: 'Server removed from group successfully',
};
res.json(response);
} catch (error) {
res.status(500).json({
success: false,
message: 'Internal server error',
});
}
};
// Get servers in a group
export const getGroupServers = (req: Request, res: Response): void => {
try {
const { id } = req.params;
if (!id) {
res.status(400).json({
success: false,
message: 'Group ID is required',
});
return;
}
const group = getGroupById(id);
if (!group) {
res.status(404).json({
success: false,
message: 'Group not found',
});
return;
}
const response: ApiResponse = {
success: true,
data: group.servers,
};
res.json(response);
} catch (error) {
res.status(500).json({
success: false,
message: 'Failed to get group servers',
});
}
};

View File

@@ -5,7 +5,8 @@ import {
addServer,
removeServer,
updateMcpServer,
recreateMcpServer,
notifyToolChanged,
toggleServerStatus,
} from '../services/mcpService.js';
import { loadSettings } from '../config/index.js';
@@ -70,7 +71,7 @@ export const createServer = async (req: Request, res: Response): Promise<void> =
const result = await addServer(name, config);
if (result.success) {
recreateMcpServer();
notifyToolChanged();
res.json({
success: true,
message: 'Server added successfully',
@@ -92,7 +93,6 @@ export const createServer = async (req: Request, res: Response): Promise<void> =
export const deleteServer = async (req: Request, res: Response): Promise<void> => {
try {
const { name } = req.params;
if (!name) {
res.status(400).json({
success: false,
@@ -102,9 +102,8 @@ export const deleteServer = async (req: Request, res: Response): Promise<void> =
}
const result = removeServer(name);
if (result.success) {
recreateMcpServer();
notifyToolChanged();
res.json({
success: true,
message: 'Server removed successfully',
@@ -127,7 +126,6 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
try {
const { name } = req.params;
const { config } = req.body;
if (!name) {
res.status(400).json({
success: false,
@@ -154,7 +152,7 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
const result = await updateMcpServer(name, config);
if (result.success) {
recreateMcpServer();
notifyToolChanged();
res.json({
success: true,
message: 'Server updated successfully',
@@ -177,7 +175,6 @@ export const getServerConfig = (req: Request, res: Response): void => {
try {
const { name } = req.params;
const settings = loadSettings();
if (!settings.mcpServers || !settings.mcpServers[name]) {
res.status(404).json({
success: false,
@@ -188,7 +185,6 @@ export const getServerConfig = (req: Request, res: Response): void => {
const serverInfo = getServersInfo().find((s) => s.name === name);
const serverConfig = settings.mcpServers[name];
const response: ApiResponse = {
success: true,
data: {
@@ -207,3 +203,44 @@ export const getServerConfig = (req: Request, res: Response): void => {
});
}
};
export const toggleServer = async (req: Request, res: Response): Promise<void> => {
try {
const { name } = req.params;
const { enabled } = req.body;
if (!name) {
res.status(400).json({
success: false,
message: 'Server name is required',
});
return;
}
if (typeof enabled !== 'boolean') {
res.status(400).json({
success: false,
message: 'Enabled status must be a boolean',
});
return;
}
const result = await toggleServerStatus(name, enabled);
if (result.success) {
notifyToolChanged();
res.json({
success: true,
message: result.message || `Server ${enabled ? 'enabled' : 'disabled'} successfully`,
});
} else {
res.status(404).json({
success: false,
message: result.message || 'Server not found or failed to toggle status',
});
}
} catch (error) {
res.status(500).json({
success: false,
message: 'Internal server error',
});
}
};

View File

@@ -1,12 +1,25 @@
import express from 'express';
import { check } from 'express-validator';
import path from 'path';
import {
getAllServers,
getAllSettings,
createServer,
updateServer,
deleteServer,
toggleServer,
} from '../controllers/serverController.js';
import {
getGroups,
getGroup,
createNewGroup,
updateExistingGroup,
deleteExistingGroup,
addServerToExistingGroup,
removeServerFromExistingGroup,
getGroupServers,
updateGroupServersBatch
} from '../controllers/groupController.js';
import {
login,
register,
@@ -24,6 +37,19 @@ export const initRoutes = (app: express.Application): void => {
router.post('/servers', createServer);
router.put('/servers/:name', updateServer);
router.delete('/servers/:name', deleteServer);
router.post('/servers/:name/toggle', toggleServer);
// Group management routes
router.get('/groups', getGroups);
router.get('/groups/:id', getGroup);
router.post('/groups', createNewGroup);
router.put('/groups/:id', updateExistingGroup);
router.delete('/groups/:id', deleteExistingGroup);
router.post('/groups/:id/servers', addServerToExistingGroup);
router.delete('/groups/:id/servers/:serverName', removeServerFromExistingGroup);
router.get('/groups/:id/servers', getGroupServers);
// New route for batch updating servers in a group
router.put('/groups/:id/servers/batch', updateGroupServersBatch);
// Auth routes (these will NOT be protected by auth middleware)
app.post('/auth/login', [
@@ -46,6 +72,10 @@ export const initRoutes = (app: express.Application): void => {
], changePassword);
app.use('/api', router);
app.get('*', (_req, res) => {
res.sendFile(path.join(process.cwd(), 'frontend', 'dist', 'index.html'));
});
};
export default router;

View File

@@ -1,6 +1,6 @@
import express from 'express';
import config from './config/index.js';
import { initMcpServer, registerAllTools } from './services/mcpService.js';
import { initMcpServer } from './services/mcpService.js';
import { initMiddlewares } from './middlewares/index.js';
import { initRoutes } from './routes/index.js';
import { handleSseConnection, handleSseMessage } from './services/sseService.js';
@@ -20,17 +20,24 @@ export class AppServer {
try {
// Migrate user data from users.json to mcp_settings.json if needed
migrateUserData();
// Initialize default admin user if no users exist
await initializeDefaultUser();
const mcpServer = await initMcpServer(config.mcpHubName, config.mcpHubVersion);
await registerAllTools(mcpServer, true);
initMiddlewares(this.app);
initRoutes(this.app);
this.app.get('/sse', (req, res) => handleSseConnection(req, res));
this.app.post('/messages', handleSseMessage);
console.log('Server initialized successfully');
initMcpServer(config.mcpHubName, config.mcpHubVersion)
.then(() => {
console.log('MCP server initialized successfully');
this.app.get('/sse/:groupId?', (req, res) => handleSseConnection(req, res));
this.app.post('/messages', handleSseMessage);
})
.catch((error) => {
console.error('Error initializing MCP server:', error);
throw error;
});
} catch (error) {
console.error('Error initializing server:', error);
throw error;

View File

@@ -0,0 +1,223 @@
import { v4 as uuidv4 } from 'uuid';
import { IGroup, McpSettings } from '../types/index.js';
import { loadSettings, saveSettings } from '../config/index.js';
import { notifyToolChanged } from './mcpService.js';
// Get all groups
export const getAllGroups = (): IGroup[] => {
const settings = loadSettings();
return settings.groups || [];
};
// Get group by ID
export const getGroupById = (id: string): IGroup | undefined => {
const groups = getAllGroups();
return groups.find((group) => group.id === id);
};
// Create a new group
export const createGroup = (
name: string,
description?: string,
servers: string[] = [],
): IGroup | null => {
try {
const settings = loadSettings();
const groups = settings.groups || [];
// Check if group with same name already exists
if (groups.some((group) => group.name === name)) {
return null;
}
// Filter out non-existent servers
const validServers = servers.filter((serverName) => settings.mcpServers[serverName]);
const newGroup: IGroup = {
id: uuidv4(),
name,
description,
servers: validServers,
};
// Initialize groups array if it doesn't exist
if (!settings.groups) {
settings.groups = [];
}
settings.groups.push(newGroup);
if (!saveSettings(settings)) {
return null;
}
return newGroup;
} catch (error) {
console.error('Failed to create group:', error);
return null;
}
};
// Update an existing group
export const updateGroup = (id: string, data: Partial<IGroup>): IGroup | null => {
try {
const settings = loadSettings();
if (!settings.groups) {
return null;
}
const groupIndex = settings.groups.findIndex((group) => group.id === id);
if (groupIndex === -1) {
return null;
}
// Check for name uniqueness if name is being updated
if (data.name && settings.groups.some((g) => g.name === data.name && g.id !== id)) {
return null;
}
// If servers array is provided, validate server existence
if (data.servers) {
data.servers = data.servers.filter((serverName) => settings.mcpServers[serverName]);
}
const updatedGroup = {
...settings.groups[groupIndex],
...data,
};
settings.groups[groupIndex] = updatedGroup;
if (!saveSettings(settings)) {
return null;
}
notifyToolChanged();
return updatedGroup;
} catch (error) {
console.error(`Failed to update group ${id}:`, error);
return null;
}
};
// Update servers in a group (batch update)
export const updateGroupServers = (groupId: string, servers: string[]): IGroup | null => {
try {
const settings = loadSettings();
if (!settings.groups) {
return null;
}
const groupIndex = settings.groups.findIndex((group) => group.id === groupId);
if (groupIndex === -1) {
return null;
}
// Filter out non-existent servers
const validServers = servers.filter((serverName) => settings.mcpServers[serverName]);
settings.groups[groupIndex].servers = validServers;
if (!saveSettings(settings)) {
return null;
}
notifyToolChanged();
return settings.groups[groupIndex];
} catch (error) {
console.error(`Failed to update servers for group ${groupId}:`, error);
return null;
}
};
// Delete a group
export const deleteGroup = (id: string): boolean => {
try {
const settings = loadSettings();
if (!settings.groups) {
return false;
}
const initialLength = settings.groups.length;
settings.groups = settings.groups.filter((group) => group.id !== id);
if (settings.groups.length === initialLength) {
return false;
}
return saveSettings(settings);
} catch (error) {
console.error(`Failed to delete group ${id}:`, error);
return false;
}
};
// Add server to group
export const addServerToGroup = (groupId: string, serverName: string): IGroup | null => {
try {
const settings = loadSettings();
if (!settings.groups) {
return null;
}
// Verify server exists
if (!settings.mcpServers[serverName]) {
return null;
}
const groupIndex = settings.groups.findIndex((group) => group.id === groupId);
if (groupIndex === -1) {
return null;
}
const group = settings.groups[groupIndex];
// Add server to group if not already in it
if (!group.servers.includes(serverName)) {
group.servers.push(serverName);
if (!saveSettings(settings)) {
return null;
}
}
notifyToolChanged();
return group;
} catch (error) {
console.error(`Failed to add server ${serverName} to group ${groupId}:`, error);
return null;
}
};
// Remove server from group
export const removeServerFromGroup = (groupId: string, serverName: string): IGroup | null => {
try {
const settings = loadSettings();
if (!settings.groups) {
return null;
}
const groupIndex = settings.groups.findIndex((group) => group.id === groupId);
if (groupIndex === -1) {
return null;
}
const group = settings.groups[groupIndex];
group.servers = group.servers.filter((name) => name !== serverName);
if (!saveSettings(settings)) {
return null;
}
return group;
} catch (error) {
console.error(`Failed to remove server ${serverName} from group ${groupId}:`, error);
return null;
}
};
// Get all servers in a group
export const getServersInGroup = (groupId: string): string[] => {
const group = getGroupById(groupId);
return group ? group.servers : [];
};

View File

@@ -1,37 +1,40 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import * as z from 'zod';
import { ZodType, ZodRawShape } from 'zod';
import { ServerInfo, ServerConfig } from '../types/index.js';
import { loadSettings, saveSettings, expandEnvVars } from '../config/index.js';
import config from '../config/index.js';
import { get } from 'http';
import { getGroupId } from './sseService.js';
import { getServersInGroup } from './groupService.js';
let mcpServer: McpServer;
let currentServer: Server;
export const initMcpServer = (name: string, version: string): McpServer => {
mcpServer = new McpServer({ name, version });
return mcpServer;
export const initMcpServer = async (name: string, version: string): Promise<void> => {
currentServer = createMcpServer(name, version);
await registerAllTools(currentServer, true);
};
export const setMcpServer = (server: McpServer): void => {
mcpServer = server;
export const setMcpServer = (server: Server): void => {
currentServer = server;
};
export const getMcpServer = (): McpServer => {
return mcpServer;
export const getMcpServer = (): Server => {
return currentServer;
};
export const recreateMcpServer = async () => {
console.log('Re-creating McpServer instance');
const newServer = createMcpServer(config.mcpHubName, config.mcpHubVersion);
await registerAllTools(newServer, true);
const oldServer = getMcpServer();
setMcpServer(newServer);
oldServer.close();
console.log('McpServer instance successfully re-created');
export const notifyToolChanged = async () => {
await registerAllTools(currentServer, true);
currentServer
.sendToolListChanged()
.catch((error) => {
console.error('Failed to send tool list changed notification:', error);
})
.then(() => {
console.log('Tool list changed notification sent successfully');
});
};
// Store all server information
@@ -44,12 +47,28 @@ export const initializeClientsFromSettings = (): ServerInfo[] => {
serverInfos = [];
for (const [name, conf] of Object.entries(settings.mcpServers)) {
// Skip disabled servers
if (conf.enabled === false) {
console.log(`Skipping disabled server: ${name}`);
serverInfos.push({
name,
status: 'disconnected',
tools: [],
createTime: Date.now(),
enabled: false,
});
continue;
}
// Check if server is already connected
const existingServer = existingServerInfos.find(
(s) => s.name === name && s.status === 'connected',
);
if (existingServer) {
serverInfos.push(existingServer);
serverInfos.push({
...existingServer,
enabled: conf.enabled === undefined ? true : conf.enabled,
});
console.log(`Server '${name}' is already connected.`);
continue;
}
@@ -91,7 +110,7 @@ export const initializeClientsFromSettings = (): ServerInfo[] => {
);
client.connect(transport, { timeout: Number(config.timeout) }).catch((error) => {
console.error(`Failed to connect client for server ${name} by error: ${error}`);
const serverInfo = getServerInfoByName(name);
const serverInfo = getServerByName(name);
if (serverInfo) {
serverInfo.status = 'disconnected';
}
@@ -111,7 +130,7 @@ export const initializeClientsFromSettings = (): ServerInfo[] => {
};
// Register all MCP tools
export const registerAllTools = async (server: McpServer, forceInit: boolean): Promise<void> => {
export const registerAllTools = async (server: Server, forceInit: boolean): Promise<void> => {
initializeClientsFromSettings();
for (const serverInfo of serverInfos) {
if (serverInfo.status === 'connected' && !forceInit) continue;
@@ -120,35 +139,15 @@ export const registerAllTools = async (server: McpServer, forceInit: boolean): P
try {
serverInfo.status = 'connecting';
console.log(`Connecting to server: ${serverInfo.name}...`);
const tools = await serverInfo.client.listTools({}, { timeout: Number(config.timeout) });
serverInfo.tools = tools.tools.map((tool) => ({
name: tool.name,
description: tool.description || '',
inputSchema: tool.inputSchema.properties || {},
inputSchema: tool.inputSchema || {},
}));
serverInfo.status = 'connected';
console.log(`Successfully connected to server: ${serverInfo.name}`);
for (const tool of tools.tools) {
console.log(`Registering tool: ${JSON.stringify(tool)}`);
await server.tool(
tool.name,
tool.description || '',
json2zod(tool.inputSchema.properties, tool.inputSchema.required),
async (params: Record<string, unknown>) => {
const currentServer = getServerInfoByName(serverInfo.name)!;
console.log(`Calling tool: ${tool.name} with params: ${JSON.stringify(params)}`);
const result = await currentServer.client!.callTool({
name: tool.name,
arguments: params,
});
console.log(`Tool call result: ${JSON.stringify(result)}`);
return result as CallToolResult;
},
);
}
} catch (error) {
console.error(
`Failed to connect to server for client: ${serverInfo.name} by error: ${error}`,
@@ -160,19 +159,35 @@ export const registerAllTools = async (server: McpServer, forceInit: boolean): P
// Get all server information
export const getServersInfo = (): Omit<ServerInfo, 'client' | 'transport'>[] => {
return serverInfos.map(({ name, status, tools, createTime }) => ({
name,
status,
tools,
createTime,
}));
const settings = loadSettings();
const infos = serverInfos.map(({ name, status, tools, createTime }) => {
const serverConfig = settings.mcpServers[name];
const enabled = serverConfig ? serverConfig.enabled !== false : true;
return {
name,
status,
tools,
createTime,
enabled,
};
});
infos.sort((a, b) => {
if (a.enabled === b.enabled) return 0;
return a.enabled ? -1 : 1;
});
return infos;
};
// Get server information by name
const getServerInfoByName = (name: string): ServerInfo | undefined => {
// Get server by name
const getServerByName = (name: string): ServerInfo | undefined => {
return serverInfos.find((serverInfo) => serverInfo.name === name);
};
// Get server by tool name
const getServerByTool = (toolName: string): ServerInfo | undefined => {
return serverInfos.find((serverInfo) => serverInfo.tools.some((tool) => tool.name === toolName));
};
// Add new server
export const addServer = async (
name: string,
@@ -189,7 +204,7 @@ export const addServer = async (
return { success: false, message: 'Failed to save settings' };
}
registerAllTools(mcpServer, false);
registerAllTools(currentServer, false);
return { success: true, message: 'Server added successfully' };
} catch (error) {
console.error(`Failed to add server: ${name}`, error);
@@ -201,7 +216,6 @@ export const addServer = async (
export const removeServer = (name: string): { success: boolean; message?: string } => {
try {
const settings = loadSettings();
if (!settings.mcpServers[name]) {
return { success: false, message: 'Server not found' };
}
@@ -236,13 +250,7 @@ export const updateMcpServer = async (
return { success: false, message: 'Failed to save settings' };
}
const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name);
if (serverInfo) {
serverInfo.client!.close();
serverInfo.transport!.close();
console.log(`Closed client and transport for server: ${name}`);
// TODO kill process
}
closeServer(name);
serverInfos = serverInfos.filter((serverInfo) => serverInfo.name !== name);
return { success: true, message: 'Server updated successfully' };
@@ -252,93 +260,104 @@ export const updateMcpServer = async (
}
};
// Create McpServer instance
export const createMcpServer = (name: string, version: string): McpServer => {
return new McpServer({ name, version });
// Close server client and transport
function closeServer(name: string) {
const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name);
if (serverInfo && serverInfo.client && serverInfo.transport) {
serverInfo.client.close();
serverInfo.transport.close();
console.log(`Closed client and transport for server: ${serverInfo.name}`);
// TODO kill process
}
}
// Toggle server enabled status
export const toggleServerStatus = async (
name: string,
enabled: boolean,
): Promise<{ success: boolean; message?: string }> => {
try {
const settings = loadSettings();
if (!settings.mcpServers[name]) {
return { success: false, message: 'Server not found' };
}
// Update the enabled status in settings
settings.mcpServers[name].enabled = enabled;
if (!saveSettings(settings)) {
return { success: false, message: 'Failed to save settings' };
}
// If disabling, disconnect the server and remove from active servers
if (!enabled) {
closeServer(name);
// Update the server info to show as disconnected and disabled
const index = serverInfos.findIndex((s) => s.name === name);
if (index !== -1) {
serverInfos[index] = {
...serverInfos[index],
status: 'disconnected',
enabled: false,
};
}
}
return { success: true, message: `Server ${enabled ? 'enabled' : 'disabled'} successfully` };
} catch (error) {
console.error(`Failed to toggle server status: ${name}`, error);
return { success: false, message: 'Failed to toggle server status' };
}
};
// Helper function: Convert JSON Schema to Zod Schema
function json2zod(inputSchema: unknown, required: unknown): ZodRawShape {
if (typeof inputSchema !== 'object' || inputSchema === null) {
throw new Error('Invalid input schema');
}
// Create McpServer instance
export const createMcpServer = (name: string, version: string): Server => {
const server = new Server({ name, version }, { capabilities: { tools: {} } });
server.setRequestHandler(ListToolsRequestSchema, async (_, extra) => {
const sessionId = extra.sessionId || '';
const groupId = getGroupId(sessionId);
console.log(`Handling ListToolsRequest for groupId: ${groupId}`);
const allServerInfos = serverInfos.filter((serverInfo) => {
if (serverInfo.enabled === false) return false;
if (!groupId) return true;
const serversInGroup = getServersInGroup(groupId);
return serversInGroup.includes(serverInfo.name);
});
const properties = inputSchema as Record<string, any>;
const processedSchema: ZodRawShape = {};
for (const key in properties) {
const prop = properties[key];
if (prop instanceof ZodType) {
processedSchema[key] = prop;
continue;
}
if (typeof prop !== 'object' || prop === null) {
throw new Error(`Invalid property definition for ${key}`);
}
let zodType: ZodType;
if (prop.type === 'array' && prop.items) {
if (prop.items.type === 'string') {
zodType = z.array(z.string());
} else if (prop.items.type === 'number') {
zodType = z.array(z.number());
} else if (prop.items.type === 'integer') {
zodType = z.array(z.number().int());
} else if (prop.items.type === 'boolean') {
zodType = z.array(z.boolean());
} else if (prop.items.type === 'object' && prop.items.properties) {
zodType = z.array(z.object(json2zod(prop.items.properties, prop.items.required)));
} else {
zodType = z.array(z.any());
}
} else {
switch (prop.type) {
case 'string':
if (prop.enum && Array.isArray(prop.enum)) {
zodType = z.enum(prop.enum as [string, ...string[]]);
} else {
zodType = z.string();
}
break;
case 'number':
zodType = z.number();
break;
case 'boolean':
zodType = z.boolean();
break;
case 'integer':
zodType = z.number().int();
break;
case 'object':
if (prop.properties) {
zodType = z.object(json2zod(prop.properties, prop.required));
} else {
zodType = z.record(z.any());
}
break;
default:
zodType = z.any();
const allTools = [];
for (const serverInfo of allServerInfos) {
if (serverInfo.tools && serverInfo.tools.length > 0) {
allTools.push(...serverInfo.tools);
}
}
if (prop.description) {
zodType = zodType.describe(prop.description);
}
return {
tools: allTools,
};
});
if (prop.default !== undefined) {
zodType = zodType.default(prop.default);
server.setRequestHandler(CallToolRequestSchema, async (request, _) => {
console.log(`Handling CallToolRequest for tool: ${request.params.name}`);
try {
if (!request.params.arguments) {
throw new Error('Arguments are required');
}
const serverInfo = getServerByTool(request.params.name);
if (!serverInfo) {
throw new Error(`Server not found: ${request.params.name}`);
}
const client = serverInfo.client;
if (!client) {
throw new Error(`Client not found for server: ${request.params.name}`);
}
const result = await client.callTool(request.params);
console.log(`Tool call result: ${JSON.stringify(result)}`);
return result;
} catch (error) {
console.error(`Error handling CallToolRequest: ${error}`);
return { error: `Failed to call tool: ${error}` };
}
required = Array.isArray(required) ? required : [];
if (Array.isArray(required) && required.includes(key)) {
processedSchema[key] = zodType;
} else {
processedSchema[key] = zodType.optional();
}
}
return processedSchema;
}
});
return server;
};

View File

@@ -2,11 +2,16 @@ import { Request, Response } from 'express';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { getMcpServer } from './mcpService.js';
const transports: { [sessionId: string]: SSEServerTransport } = {};
const transports: { [sessionId: string]: { transport: SSEServerTransport; groupId: string } } = {};
export const getGroupId = (sessionId: string): string => {
return transports[sessionId]?.groupId || '';
};
export const handleSseConnection = async (req: Request, res: Response): Promise<void> => {
const transport = new SSEServerTransport('/messages', res);
transports[transport.sessionId] = transport;
const groupId = req.params.groupId;
transports[transport.sessionId] = { transport, groupId };
res.on('close', () => {
delete transports[transport.sessionId];
@@ -19,8 +24,10 @@ export const handleSseConnection = async (req: Request, res: Response): Promise<
export const handleSseMessage = async (req: Request, res: Response): Promise<void> => {
const sessionId = req.query.sessionId as string;
const transport = transports[sessionId];
const { transport, groupId } = transports[sessionId];
req.params.groupId = groupId;
req.query.groupId = groupId;
console.log(`Received message for sessionId: ${sessionId} in groupId: ${groupId}`);
if (transport) {
await transport.handlePostMessage(req, res);
} else {

View File

@@ -9,12 +9,21 @@ export interface IUser {
isAdmin?: boolean;
}
// Group interface for server grouping
export interface IGroup {
id: string; // Unique UUID for the group
name: string; // Display name of the group
description?: string; // Optional description of the group
servers: string[]; // Array of server names that belong to this group
}
// Represents the settings for MCP servers
export interface McpSettings {
users?: IUser[]; // Array of user credentials and permissions
mcpServers: {
[key: string]: ServerConfig; // Key-value pairs of server names and their configurations
};
groups?: IGroup[]; // Array of server groups
}
// Configuration details for an individual server
@@ -23,6 +32,7 @@ export interface ServerConfig {
command?: string; // Command to execute for stdio-based servers
args?: string[]; // Arguments for the command
env?: Record<string, string>; // Environment variables
enabled?: boolean; // Flag to enable/disable the server
}
// Information about a server's status and tools
@@ -33,6 +43,7 @@ export interface ServerInfo {
client?: Client; // Client instance for communication
transport?: SSEClientTransport | StdioClientTransport; // Transport mechanism used
createTime: number; // Timestamp of when the server was created
enabled?: boolean; // Flag to indicate if the server is enabled
}
// Details about a tool available on the server