Compare commits

...

8 Commits

Author SHA1 Message Date
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
35 changed files with 2011 additions and 441 deletions

View File

@@ -3,8 +3,6 @@ name: Build
on:
push:
tags: ['v*.*.*']
schedule:
- cron: '0 23 * * *'
workflow_dispatch:
jobs:
@@ -45,7 +43,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

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: 35 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 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

@@ -6,6 +6,7 @@ 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() {
@@ -21,6 +22,7 @@ function App() {
<Route element={<MainLayout />}>
<Route path="/" element={<DashboardPage />} />
<Route path="/servers" element={<ServersPage />} />
<Route path="/groups" element={<GroupsPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
</Route>

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

@@ -47,52 +47,65 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
}
return (
<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">
<>
<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={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}
onClick={handleEdit}
className="px-3 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm"
>
{isToggling
? t('common.processing')
: server.enabled !== false
? t('server.disable')
: t('server.enable')
}
{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>
<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>
{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
@@ -101,18 +114,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
onConfirm={handleConfirmDelete}
serverName={server.name}
/>
{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>
</>
)
}

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

@@ -37,6 +37,15 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
</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'),

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

@@ -34,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",
@@ -80,11 +81,15 @@
"processing": "Processing...",
"save": "Save",
"cancel": "Cancel",
"refresh": "Refresh"
"refresh": "Refresh",
"create": "Create",
"submitting": "Submitting...",
"delete": "Delete"
},
"nav": {
"dashboard": "Dashboard",
"servers": "Servers",
"groups": "Groups",
"settings": "Settings",
"changePassword": "Change Password"
},
@@ -100,9 +105,38 @@
"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

@@ -34,6 +34,7 @@
"edit": "编辑",
"delete": "删除",
"confirmDelete": "您确定要删除此服务器吗?",
"deleteWarning": "删除服务器 '{{name}}' 将会移除该服务器及其所有数据。此操作无法撤销。",
"status": "状态",
"tools": "工具",
"name": "服务器名称",
@@ -80,13 +81,17 @@
"processing": "处理中...",
"save": "保存",
"cancel": "取消",
"refresh": "刷新"
"refresh": "刷新",
"create": "创建",
"submitting": "提交中...",
"delete": "删除"
},
"nav": {
"dashboard": "仪表盘",
"servers": "服务器",
"settings": "设置",
"changePassword": "修改密码"
"changePassword": "修改密码",
"groups": "分组"
},
"pages": {
"dashboard": {
@@ -103,6 +108,35 @@
"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

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

@@ -1,30 +1,30 @@
// 服务器状态类型
// 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;
@@ -33,26 +33,41 @@ export interface Server {
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
@@ -62,10 +77,9 @@ export interface IUser {
}
export interface AuthState {
token: string | null;
isAuthenticated: boolean;
loading: boolean;
user: IUser | null;
loading: boolean;
error: string | null;
}
@@ -88,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": {

17
pnpm-lock.yaml generated
View File

@@ -29,6 +29,9 @@ importers:
'@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
@@ -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==}
@@ -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==}
@@ -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':
@@ -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:

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,7 @@ import {
addServer,
removeServer,
updateMcpServer,
recreateMcpServer,
notifyToolChanged,
toggleServerStatus,
} from '../services/mcpService.js';
import { loadSettings } from '../config/index.js';
@@ -71,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',
@@ -93,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,
@@ -103,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',
@@ -128,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,
@@ -155,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',
@@ -178,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,
@@ -189,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: {
@@ -213,7 +208,6 @@ 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,
@@ -231,9 +225,8 @@ export const toggleServer = async (req: Request, res: Response): Promise<void> =
}
const result = await toggleServerStatus(name, enabled);
if (result.success) {
recreateMcpServer();
notifyToolChanged();
res.json({
success: true,
message: result.message || `Server ${enabled ? 'enabled' : 'disabled'} successfully`,

View File

@@ -1,5 +1,6 @@
import express from 'express';
import { check } from 'express-validator';
import path from 'path';
import {
getAllServers,
getAllSettings,
@@ -8,6 +9,17 @@ import {
deleteServer,
toggleServer,
} from '../controllers/serverController.js';
import {
getGroups,
getGroup,
createNewGroup,
updateExistingGroup,
deleteExistingGroup,
addServerToExistingGroup,
removeServerFromExistingGroup,
getGroupServers,
updateGroupServersBatch
} from '../controllers/groupController.js';
import {
login,
register,
@@ -27,6 +39,18 @@ export const initRoutes = (app: express.Application): void => {
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', [
check('username', 'Username is required').not().isEmpty(),
@@ -48,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
@@ -52,11 +55,11 @@ export const initializeClientsFromSettings = (): ServerInfo[] => {
status: 'disconnected',
tools: [],
createTime: Date.now(),
enabled: false
enabled: false,
});
continue;
}
// Check if server is already connected
const existingServer = existingServerInfos.find(
(s) => s.name === name && s.status === 'connected',
@@ -64,7 +67,7 @@ export const initializeClientsFromSettings = (): ServerInfo[] => {
if (existingServer) {
serverInfos.push({
...existingServer,
enabled: conf.enabled === undefined ? true : conf.enabled
enabled: conf.enabled === undefined ? true : conf.enabled,
});
console.log(`Server '${name}' is already connected.`);
continue;
@@ -107,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';
}
@@ -127,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;
@@ -136,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}`,
@@ -179,7 +162,7 @@ export const getServersInfo = (): Omit<ServerInfo, 'client' | 'transport'>[] =>
const settings = loadSettings();
const infos = serverInfos.map(({ name, status, tools, createTime }) => {
const serverConfig = settings.mcpServers[name];
const enabled = serverConfig ? (serverConfig.enabled !== false) : true;
const enabled = serverConfig ? serverConfig.enabled !== false : true;
return {
name,
status,
@@ -195,11 +178,16 @@ export const getServersInfo = (): Omit<ServerInfo, 'client' | 'transport'>[] =>
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,
@@ -216,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);
@@ -228,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' };
}
@@ -263,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' };
@@ -279,10 +260,21 @@ export const updateMcpServer = async (
}
};
// 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
enabled: boolean,
): Promise<{ success: boolean; message?: string }> => {
try {
const settings = loadSettings();
@@ -292,22 +284,17 @@ export const toggleServerStatus = async (
// 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) {
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: ${name}`);
}
closeServer(name);
// Update the server info to show as disconnected and disabled
const index = serverInfos.findIndex(s => s.name === name);
const index = serverInfos.findIndex((s) => s.name === name);
if (index !== -1) {
serverInfos[index] = {
...serverInfos[index],
@@ -325,92 +312,52 @@ export const toggleServerStatus = async (
};
// Create McpServer instance
export const createMcpServer = (name: string, version: string): McpServer => {
return new McpServer({ name, version });
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 allTools = [];
for (const serverInfo of allServerInfos) {
if (serverInfo.tools && serverInfo.tools.length > 0) {
allTools.push(...serverInfo.tools);
}
}
return {
tools: allTools,
};
});
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}` };
}
});
return server;
};
// 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');
}
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();
}
}
if (prop.description) {
zodType = zodType.describe(prop.description);
}
if (prop.default !== undefined) {
zodType = zodType.default(prop.default);
}
required = Array.isArray(required) ? required : [];
if (Array.isArray(required) && required.includes(key)) {
processedSchema[key] = zodType;
} else {
processedSchema[key] = zodType.optional();
}
}
return processedSchema;
}

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