From adef02d3b9612852ec81575211ed11127c228481 Mon Sep 17 00:00:00 2001 From: samanhappy Date: Thu, 17 Apr 2025 18:55:04 +0800 Subject: [PATCH] feat: add group management functionality (#12) --- README.md | 129 +++---- README.zh.md | 190 +++++----- frontend/src/App.tsx | 2 + frontend/src/components/AddGroupForm.tsx | 132 +++++++ frontend/src/components/EditGroupForm.tsx | 149 ++++++++ frontend/src/components/GroupCard.tsx | 121 +++++++ frontend/src/components/ServerCard.tsx | 108 +++--- frontend/src/components/icons/LucideIcons.tsx | 8 +- frontend/src/components/layout/Sidebar.tsx | 9 + frontend/src/components/ui/DeleteDialog.tsx | 49 +-- frontend/src/components/ui/ToggleGroup.tsx | 100 +++++ frontend/src/hooks/useGroupData.ts | 232 ++++++++++++ frontend/src/locales/en.json | 36 +- frontend/src/locales/zh.json | 38 +- frontend/src/pages/GroupsPage.tsx | 116 ++++++ frontend/src/types/index.ts | 49 ++- frontend/src/utils/cn.ts | 10 + package.json | 2 + pnpm-lock.yaml | 17 + src/controllers/groupController.ts | 341 ++++++++++++++++++ src/controllers/serverController.ts | 17 +- src/routes/index.ts | 23 ++ src/server.ts | 21 +- src/services/groupService.ts | 223 ++++++++++++ src/services/mcpService.ts | 253 +++++-------- src/services/sseService.ts | 15 +- src/types/index.ts | 9 + 27 files changed, 1953 insertions(+), 446 deletions(-) create mode 100644 frontend/src/components/AddGroupForm.tsx create mode 100644 frontend/src/components/EditGroupForm.tsx create mode 100644 frontend/src/components/GroupCard.tsx create mode 100644 frontend/src/components/ui/ToggleGroup.tsx create mode 100644 frontend/src/hooks/useGroupData.ts create mode 100644 frontend/src/pages/GroupsPage.tsx create mode 100644 src/controllers/groupController.ts create mode 100644 src/services/groupService.ts diff --git a/README.md b/README.md index 1f41fb6..493e14b 100644 --- a/README.md +++ b/README.md @@ -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,69 @@ 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 - -### Clone the Repository - -Clone MCPHub from GitHub: +## 🧑‍💻 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). diff --git a/README.zh.md b/README.zh.md index 517932d..fbc3c6b 100644 --- a/README.zh.md +++ b/README.zh.md @@ -1,137 +1,127 @@ -# MCPHub:一键部署你的专属 MCP 服务 +# MCPHub:一站式 MCP 服务器聚合平台 [English Version](README.md) | 中文版 -MCPHub 是一个集中管理的 MCP 服务器聚合平台,可以将多个 MCP(Model Context Protocol)服务整合为一个 SSE 端点。它通过提供一个集中的管理界面来简化服务管理,满足您对 MCP 服务的所有需求。 +MCPHub 是一个统一的 MCP(Model 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 ``` -### 控制面板访问 - -在浏览器中打开 `http://localhost:3000` 并使用你在 `mcp_settings.json` 文件中设置的凭据登录。默认凭据为: -- **用户名**:`admin` -- **密码**:`admin123` - -控制面板提供以下功能: -- **实时监控**:随时查看所有 MCP 服务的运行状态。 -- **服务状态指示**:快速识别各服务是否在线。 -- **动态管理**:无需重启即可动态添加或移除 MCP 服务。 - -### SSE 端点 - -您可以将主机应用(如 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) - -## 许可证 +## 📄 许可证 本项目采用 [Apache 2.0 许可证](LICENSE)。 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 71eedf7..d76bdcb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { }> } /> } /> + } /> } /> diff --git a/frontend/src/components/AddGroupForm.tsx b/frontend/src/components/AddGroupForm.tsx new file mode 100644 index 0000000..dc94c24 --- /dev/null +++ b/frontend/src/components/AddGroupForm.tsx @@ -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([]) + const [error, setError] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + + const [formData, setFormData] = useState({ + name: '', + description: '', + servers: [] + }) + + useEffect(() => { + // Filter available servers (enabled only) + setAvailableServers(servers.filter(server => server.enabled !== false)) + }, [servers]) + + const handleChange = (e: React.ChangeEvent) => { + 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 ( +
+
+
+

{t('groups.addNew')}

+ + {error && ( +
+ {error} +
+ )} + +
+
+ + +
+ + ({ + value: server.name, + label: server.name + }))} + onChange={(servers) => setFormData(prev => ({ ...prev, servers }))} + /> + +
+ + +
+ +
+
+
+ ) +} + +export default AddGroupForm \ No newline at end of file diff --git a/frontend/src/components/EditGroupForm.tsx b/frontend/src/components/EditGroupForm.tsx new file mode 100644 index 0000000..847ac28 --- /dev/null +++ b/frontend/src/components/EditGroupForm.tsx @@ -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([]) + const [error, setError] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + + const [formData, setFormData] = useState({ + 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) => { + 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 ( +
+
+
+

{t('groups.edit')}

+ + {error && ( +
+ {error} +
+ )} + +
+
+ + +
+ + ({ + value: server.name, + label: server.name + }))} + onChange={(servers) => setFormData(prev => ({ ...prev, servers }))} + /> + +
+ + +
+ +
+
+
+ ) +} + +export default EditGroupForm \ No newline at end of file diff --git a/frontend/src/components/GroupCard.tsx b/frontend/src/components/GroupCard.tsx new file mode 100644 index 0000000..2a4f07d --- /dev/null +++ b/frontend/src/components/GroupCard.tsx @@ -0,0 +1,121 @@ +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 = () => { + navigator.clipboard.writeText(group.id).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }) + } + + // Get servers that belong to this group + const groupServers = servers.filter(server => group.servers.includes(server.name)) + + return ( +
+
+
+
+

{group.name}

+
+ {group.id} + +
+
+ {group.description && ( +

{group.description}

+ )} +
+
+
+ {t('groups.serverCount', { count: group.servers.length })} +
+ + +
+
+ +
+ {groupServers.length === 0 ? ( +

{t('groups.noServers')}

+ ) : ( +
+ {groupServers.map(server => ( +
+ {server.name} + +
+ ))} +
+ )} +
+ + setShowDeleteDialog(false)} + onConfirm={handleConfirmDelete} + serverName={group.name} + isGroup={true} + /> +
+ ) +} + +export default GroupCard \ No newline at end of file diff --git a/frontend/src/components/ServerCard.tsx b/frontend/src/components/ServerCard.tsx index 6cc2a49..226cbca 100644 --- a/frontend/src/components/ServerCard.tsx +++ b/frontend/src/components/ServerCard.tsx @@ -47,52 +47,65 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) => } return ( -
-
setIsExpanded(!isExpanded)} - > -
-

{server.name}

- -
-
- -
+ <> +
+
setIsExpanded(!isExpanded)} + > +
+

{server.name}

+ +
+
+
+ +
+ +
- -
+ + {isExpanded && server.tools && ( +
+

{t('server.tools')}

+
+ {server.tools.map((tool, index) => ( + + ))} +
+
+ )}
onConfirm={handleConfirmDelete} serverName={server.name} /> - - {isExpanded && server.tools && ( -
-

{t('server.tools')}

-
- {server.tools.map((tool, index) => ( - - ))} -
-
- )} -
+ ) } diff --git a/frontend/src/components/icons/LucideIcons.tsx b/frontend/src/components/icons/LucideIcons.tsx index 83759df..e086ec9 100644 --- a/frontend/src/components/icons/LucideIcons.tsx +++ b/frontend/src/components/icons/LucideIcons.tsx @@ -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 \ No newline at end of file diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 1d227c6..61e161d 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -37,6 +37,15 @@ const Sidebar: React.FC = ({ collapsed }) => { ), }, + { + path: '/groups', + label: t('nav.groups'), + icon: ( + + + + ), + }, { path: '/settings', label: t('nav.settings'), diff --git a/frontend/src/components/ui/DeleteDialog.tsx b/frontend/src/components/ui/DeleteDialog.tsx index ca20a01..8e3177e 100644 --- a/frontend/src/components/ui/DeleteDialog.tsx +++ b/frontend/src/components/ui/DeleteDialog.tsx @@ -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 ( -
-
-

{t('server.delete')}

-

- {t('server.confirmDelete')} {serverName} -

-
- - +
+
+
+

+ {isGroup ? t('groups.confirmDelete') : t('server.confirmDelete')} +

+

+ {isGroup + ? t('groups.deleteWarning', { name: serverName }) + : t('server.deleteWarning', { name: serverName })} +

+
+ + +
diff --git a/frontend/src/components/ui/ToggleGroup.tsx b/frontend/src/components/ui/ToggleGroup.tsx new file mode 100644 index 0000000..2982286 --- /dev/null +++ b/frontend/src/components/ui/ToggleGroup.tsx @@ -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 = ({ + value, + isSelected, + onClick, + children +}) => { + return ( + + ); +}; + +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 = ({ + 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 ( +
+ +
+ {options.length === 0 ? ( +

{noOptionsText}

+ ) : ( +
+ {options.map(option => ( + handleToggle(option.value)} + > + {option.label} + + ))} +
+ )} +
+ {helpText && ( +

+ {helpText} +

+ )} +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/hooks/useGroupData.ts b/frontend/src/hooks/useGroupData.ts new file mode 100644 index 0000000..4f28286 --- /dev/null +++ b/frontend/src/hooks/useGroupData.ts @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 = 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 = 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 = 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 = 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 = 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 = 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 + }; +}; \ No newline at end of file diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 79e6fe7..45b762c 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -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" } } \ No newline at end of file diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index 2a5e158..2210bce 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -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}} 台服务器" } } \ No newline at end of file diff --git a/frontend/src/pages/GroupsPage.tsx b/frontend/src/pages/GroupsPage.tsx new file mode 100644 index 0000000..40d4513 --- /dev/null +++ b/frontend/src/pages/GroupsPage.tsx @@ -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(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 ( +
+
+

{t('pages.groups.title')}

+
+ +
+
+ + {groupError && ( +
+

{groupError}

+
+ )} + + {groupsLoading ? ( +
+
+ + + + +

{t('app.loading')}

+
+
+ ) : groups.length === 0 ? ( +
+

{t('groups.noGroups')}

+
+ ) : ( +
+ {groups.map((group) => ( + + ))} +
+ )} + + {showAddForm && ( + + )} + + {editingGroup && ( + setEditingGroup(null)} + /> + )} +
+ ); +}; + +export default GroupsPage; \ No newline at end of file diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index c5129fd..43951a6 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,30 +1,30 @@ -// 服务器状态类型 +// Server status types export type ServerStatus = 'connecting' | 'connected' | 'disconnected'; -// 工具输入模式类型 +// Tool input schema types export interface ToolInputSchema { type: string; - properties?: Record; + properties?: Record; 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; + 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 { +// 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 { 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 }>; } \ No newline at end of file diff --git a/frontend/src/utils/cn.ts b/frontend/src/utils/cn.ts index e69de29..dcb15b8 100644 --- a/frontend/src/utils/cn.ts +++ b/frontend/src/utils/cn.ts @@ -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)); +} \ No newline at end of file diff --git a/package.json b/package.json index fcf9799..bc7be8c 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9c3a78..241923e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/src/controllers/groupController.ts b/src/controllers/groupController.ts new file mode 100644 index 0000000..1888f9f --- /dev/null +++ b/src/controllers/groupController.ts @@ -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', + }); + } +}; \ No newline at end of file diff --git a/src/controllers/serverController.ts b/src/controllers/serverController.ts index 552d03c..13dca23 100644 --- a/src/controllers/serverController.ts +++ b/src/controllers/serverController.ts @@ -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 = 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 = export const deleteServer = async (req: Request, res: Response): Promise => { 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 = } 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 = 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 = 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 = 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 = } const result = await toggleServerStatus(name, enabled); - if (result.success) { - recreateMcpServer(); + notifyToolChanged(); res.json({ success: true, message: result.message || `Server ${enabled ? 'enabled' : 'disabled'} successfully`, diff --git a/src/routes/index.ts b/src/routes/index.ts index 5e1c6ec..ade6197 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -8,6 +8,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 +38,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(), diff --git a/src/server.ts b/src/server.ts index 22eae56..fcbe6c9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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; diff --git a/src/services/groupService.ts b/src/services/groupService.ts new file mode 100644 index 0000000..35466a4 --- /dev/null +++ b/src/services/groupService.ts @@ -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 | 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 : []; +}; diff --git a/src/services/mcpService.ts b/src/services/mcpService.ts index fad4ecb..b626f18 100644 --- a/src/services/mcpService.ts +++ b/src/services/mcpService.ts @@ -1,37 +1,34 @@ -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 => { + 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(); + console.log('Tool list changed notification sent'); }; // Store all server information @@ -52,11 +49,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 +61,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 +104,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 +124,7 @@ export const initializeClientsFromSettings = (): ServerInfo[] => { }; // Register all MCP tools -export const registerAllTools = async (server: McpServer, forceInit: boolean): Promise => { +export const registerAllTools = async (server: Server, forceInit: boolean): Promise => { initializeClientsFromSettings(); for (const serverInfo of serverInfos) { if (serverInfo.status === 'connected' && !forceInit) continue; @@ -136,35 +133,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) => { - 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 +156,7 @@ export const getServersInfo = (): Omit[] => 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 +172,16 @@ export const getServersInfo = (): Omit[] => 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 +198,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 +210,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 +244,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 +254,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 +278,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 +306,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; - 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; -} diff --git a/src/services/sseService.ts b/src/services/sseService.ts index fb10230..067e903 100644 --- a/src/services/sseService.ts +++ b/src/services/sseService.ts @@ -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 => { 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 => { 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 { diff --git a/src/types/index.ts b/src/types/index.ts index 47ec1c6..113806d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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