Compare commits

..

12 Commits

49 changed files with 77735 additions and 210 deletions

View File

@@ -31,17 +31,11 @@ jobs:
with:
images: samanhappy/mcphub
tags: |
# edge 变体
type=raw,value=edge,enable=${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
type=raw,value=edge-full,enable=${{ (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && matrix.variant == 'full' }}
# semver
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/') && matrix.variant == 'base' }}
type=semver,pattern={{version}}-full,enable=${{ startsWith(github.ref, 'refs/tags/') && matrix.variant == 'full' }}
# latest
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') && matrix.variant == 'base' }}
type=raw,value=latest-full,enable=${{ startsWith(github.ref, 'refs/tags/') && matrix.variant == 'full' }}
type=raw,value=edge${{ matrix.variant == 'full' && '-full' || '' }},enable=${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
type=semver,pattern={{version}}${{ matrix.variant == 'full' && '-full' || '' }},enable=${{ startsWith(github.ref, 'refs/tags/') }}
type=raw,value=latest${{ matrix.variant == 'full' && '-full' || '' }},enable=${{ startsWith(github.ref, 'refs/tags/') }}
flavor: |
latest=false
- name: Build and Push Docker Image
uses: docker/build-push-action@v5

View File

@@ -2,7 +2,7 @@ FROM python:3.13-slim-bookworm AS base
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
RUN apt-get update && apt-get install -y curl gnupg \
RUN apt-get update && apt-get install -y curl gnupg git \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y nodejs \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
@@ -37,6 +37,9 @@ RUN pnpm install
COPY . .
# Download the latest servers.json from mcpm.sh and replace the existing file
RUN curl -s -f --connect-timeout 10 https://mcpm.sh/api/servers.json -o servers.json || echo "Failed to download servers.json, using bundled version"
RUN pnpm frontend:build && pnpm build
COPY entrypoint.sh /usr/local/bin/entrypoint.sh

View File

@@ -25,7 +25,7 @@ Create a `mcp_settings.json` file to customize your server settings:
```json
{
"mcpServers": {
"amap-maps": {
"amap": {
"command": "npx",
"args": ["-y", "@amap/amap-maps-mcp-server"],
"env": {

View File

@@ -25,7 +25,7 @@ MCPHub 是一个统一的 MCPModel Context Protocol模型上下文协议
```json
{
"mcpServers": {
"amap-maps": {
"amap": {
"command": "npx",
"args": ["-y", "@amap/amap-maps-mcp-server"],
"env": {
@@ -136,9 +136,9 @@ pnpm dev
- Bug 报告与修复
- 翻译与建议
欢迎加入企微交流共建群
欢迎加入企微交流共建群,由于群人数限制,有兴趣的同学可以扫码添加管理员为好友后拉入群聊。
<img src="assets/wegroup.png" width="500">
<img src="assets/wexin.png" width="350">
## 📄 许可证

BIN
assets/cursor-mcp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
assets/cursor-query.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
assets/cursor-tools.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

BIN
assets/market.zh.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
assets/wegroup.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
assets/wexin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

232
doc/intro2.md Normal file
View File

@@ -0,0 +1,232 @@
# 本地部署、一键安装、分组路由MCPHub 重塑 MCP 服务器体验
## 概述
现代 AI 应用场景中将大模型LLM与各种数据源和工具无缝对接往往需要手动编写大量胶水代码并且无法快速复用。MCPModel Context Protocol协议由 Anthropic 在 2024 年开源旨在提供类似“USBC”接口般的标准化通信方式简化 AI 助手与内容仓库、业务系统等的集成流程。然而MCP 服务器部署常常需要大量环境依赖、手动配置及持续运行开发者常因安装和配置耗费大量时间和精力。MCPHub 作为一款开源的一站式聚合平台,通过直观的 Web UI、Docker 镜像和热插拔配置,实现本地或容器里的“一键安装”与“分组路由”,大幅降低 MCP 服务器的使用门槛和运维成本​。
## MCPHub 是什么
### MCP 协议简介
Model Context ProtocolMCP是一种开放标准类似“USBC”接口为 AI 助手与内容仓库、业务系统和第三方服务之间提供统一通信协议。它支持 stdio 与 SSE最新协议中被 Streamable HTTP 取代两种通信方式既能满足实时流式数据交换也可用于批量任务。2024 年由 Anthropic 团队开源发布后MCP 已在各类 AI 客户端(如 Claude Desktop中得到应用成功实现与 GitHub、Slack、网页自动化工具等的无缝对接。
### MCPHub 项目概览
MCPHub 是一个统一的 MCP 服务器聚合平台,内置 MCP 服务器市场实现一键安装。前端基于 React、Vite 和 Tailwind CSS 构建,后端兼容任意使用 npx 或 uvx 命令启动的 MCP 服务器。它通过一个集中式 Dashboard 实时展示各服务器的运行状态,并支持在运行时热插拔增删改服务器配置,无需停机维护。支持分组式访问控制,可以通过独立的 SSE 端点访问不同的 MCP 服务器组合,管理员可灵活定义不同团队或环境的权限策略。官方提供 Docker 镜像,仅需一条命令即可快速启动本地或云端服务。
![MCPHub 控制面板](../assets/dashboard.zh.png)
## 为什么要使用 MCPHub
### 1. 复杂的环境依赖与配置
- MCP 服务器常依赖 Node.js、Python 等多种运行时,需手动维护大量命令、参数和环境变量。
- MCPHub 内置 MCP 服务器市场,包含众多常用 MCP 服务器,支持一键安装和自动配置,简化了环境搭建过程。
- 通过 Docker 部署MCPHub 可在任何支持 Docker 的平台上运行,避免了环境不一致的问题。
![MCPHub 市场](../assets/market.zh.png)
### 2. 持续运行的服务压力
- MCP 要求长连接服务常驻内存,重启或升级时需要人工干预,缺乏弹性。
- 借助 Docker 容器化部署MCPHub 可快速重建环境,享受容器带来的弹性与隔离优势。
### 3. 路由与分组管理缺乏统一视图
- 传统方式下,很难可视化地将不同 MCP 服务按场景分类,容易造成 token 浪费和工具选择精度下降。
- MCPHub 支持动态创建分组(如“地图检索”、“网页自动化”、“聊天”等),为每个分组生成独立的 SSE 端点,实现各类用例的隔离与优化。
![MCPHub 分组](../assets/group.zh.png)
## 如何使用 MCPHub
### 快速部署
```bash
docker run -p 3000:3000 samanhappy/mcphub
```
一条命令就可以在本地快速启动 MCPHub默认监听 3000 端口。
MCPHub 使用`mcp_settings.json`保存所有服务器、分组和用户的配置。你可以创建一个 `mcp_settings.json` 文件,并将其挂载到 Docker 容器中,以便在重启时保留配置。
```json
{
"mcpServers": {
"amap": {
"command": "npx",
"args": ["-y", "@amap/amap-maps-mcp-server"],
"env": {
"AMAP_MAPS_API_KEY": "your-api-key"
}
},
"time-mcp": {
"command": "npx",
"args": [
"-y",
"time-mcp"
]
},
"sequential-thinking": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-sequential-thinking"
]
}
}
}
```
然后挂载配置文件启动:
```bash
docker run -p 3000:3000 -v $(pwd)/mcp_settings.json:/app/mcp_settings.json samanhappy/mcphub
```
> 注意首次运行时MCPHub 会自动下载并安装所需的依赖包,可能需要一些时间。
### 访问控制台
启动后访问 `http://localhost:3000` 即可进入控制台。
> 默认登录用户名和密码为 `admin`/`admin123`,登录后可以修改密码以确保安全。
控制台提供了服务器管理、分组管理和市场管理等功能,你可以在这里查看所有已安装的 MCP 服务器、创建新的分组、添加或删除服务器等。
### 分组路由 & SSE 端点
#### 全局 SSE 端点
```
http://localhost:3000/sse
```
通过全局 SSE 端点可以访问所有已启用的 MCP 服务器。
#### 基于分组的 SSE 端点
除了全局 SSE 端点MCPHub 还支持基于分组的 SSE 端点。你可以为每个分组创建独立的 SSE 端点,以便更好地管理和路由请求。
分组的 SSE 端点格式如下:
```
http://localhost:3000/sse/{groupId}
```
其中 `{groupId}` 是分组的唯一标识符,可以从控制台获取。比如我在上面的截图中创建了一个名为 `map` 的分组,选择了 `amap``sequential-thinking` 两个服务器,那么可以通过以下 URL 访问这个分组的 SSE 端点:
```
http://localhost:3000/sse/a800bef7-c4c1-4460-9557-5f4404cdd0bd
```
#### 使用分组路由
创建了路由分组后,我们就可以在任意支持 MCP 的客户端中使用这个分组的 SSE 端点了。比如在 `Cursor` 中,我们可以在配置文件中指定分组的 SSE 端点:
```json
{
"mcpServers": {
"mcphub": {
"url": "http://localhost:3000/sse/a800bef7-c4c1-4460-9557-5f4404cdd0bd"
}
}
}
```
配置完成后,可以从 `Cursor` 中看到所有可用的 MCP 服务器工具列表。
![Cursor 中的 MCP 配置](../assets/cursor-mcp.png)
然后,我们可以测试一下,比如输入:深度思考一下,帮我制定一个五一假期从南京出发的自驾行出游计划,要求避开拥堵路线,结合天气情况,并且可以体验到不同的自然风光。
![Cursor 中的测试输入](../assets/cursor-query.png)
接着可以看到,`Cursor` 在运行过程中调用了多个工具。
![Cursor 中的工具调用](../assets/cursor-tools.png)
最终生成结果如下:
```markdown
基于对路线和天气的全面分析,我为你制定了一个四天的五一自驾游行程方案:
### 第一天4月29日南京→黄山
- 出发时间建议早上8点出发
- 行驶路线G42沪蓉高速 → S11巢黄高速
- 行驶时间约4.5小时282公里
- 住宿地点:黄山风景区附近
- 特别提醒:
* 路线多隧道,注意安全
* 天气较好,适合出行
* 建议提前订好黄山景区附近酒店
### 第二天4月30日黄山→婺源
- 游览时间:上午游览黄山景区
- 下午出发前往婺源
- 行驶路线G3京台高速 → G56杭瑞高速
- 行驶时间约2.5小时139公里
- 住宿地点:婺源县城或特色古村落
- 特别提醒:
* 注意天气可能有阵雨
* 建议游览篁岭、李坑等著名古村落
### 第三天5月1日婺源→景德镇
- 上午游览婺源景点
- 中午后出发前往景德镇
- 行驶路线G56杭瑞高速
- 行驶时间约1.5小时77公里
- 住宿地点:景德镇市区
- 特别提醒:
* 可能遇到阵雨天气
* 建议参观陶瓷博物馆和古窑址
### 第四天5月2日景德镇→南京
- 建议上午游览景德镇景点
- 中午后出发返回南京
- 行驶路线G35济广高速 → G4211宁芜高速
- 行驶时间约5小时429公里
- 特别提醒:
* 路程较长,建议做好休息安排
* 注意途中加油站分布
* 建议错峰返程,避免拥堵
### 整体建议:
1. 避堵建议:
- 选择错峰出行时间
- 提前查看实时路况
- 准备备选绕行路线
2. 安全建议:
- 全程多雨,注意雨天行车安全
- 保持车距,谨慎驾驶
- 途经隧道较多,注意灯光使用
3. 行程准备:
- 提前预订住宿
- 准备雨具和应急用品
- 建议下载离线地图
- 检查车况,特别是雨刮和轮胎
4. 景点安排:
- 黄山:建议游览迎客松、光明顶等
- 婺源:篁岭、李坑、江岭等
- 景德镇:陶瓷博物馆、古窑民俗博览区
这条路线可以让你体验到:
- 黄山的壮丽山景
- 婺源的田园风光
- 景德镇的人文历史
既避开了主要拥堵路段,又能欣赏到不同的自然风光。
```
可以看到,`Cursor` 通过调用 `amap``sequential-thinking` 两个服务器,成功生成了一个五一假期的自驾游行程方案,并且避开了拥堵路线,结合了天气情况。但是细心的同学可能发现,计划中的开始时间是 4 月 29 日,而今年的五一假期是 5 月 1 日开始的,产生偏差的原因是 `sequential-thinking` 使用了错误的假期时间。如何解决这个问题呢?我们可以尝试在分组中添加支持搜索的 MCP 服务器,这样就可以在查询时自动纠正错误的假期时间了,具体就不在这里展开了。
## 结语
MCPHub 将本地部署、一键安装、分组路由和可视化管理融为一体,以简洁而强大的设计,彻底解决了 MCP 服务器的部署、配置与运维难题。无论是追求快速验证的开发者,还是需要稳定可靠 AI 工具链的企业用户,都能通过 MCPHub 专注于核心业务与创新,而无需被底层细节所困扰。
尽管目前各家平台都在陆续推出各类 MCP 云服务但在数据隐私、合规性和定制化需求日益增长的背景下MCPHub 仍然是一个值得关注的本地部署解决方案​。
MCPHub 只是我一时兴起开发的小项目,没想到竟收获了这么多关注,非常感谢大家的支持!目前 MCPHub 还有不少地方需要优化和完善我也专门建了个交流群方便大家交流反馈。如果你也对这个项目感兴趣欢迎一起参与建设项目地址为https://github.com/samanhappy/mcphub。
![企业微信交流群](../assets/wegroup.jpg)

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { BrowserRouter as Router, Route, Routes, Navigate } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext';
import { ToastProvider } from './contexts/ToastContext';
import MainLayout from './layouts/MainLayout';
import ProtectedRoute from './components/ProtectedRoute';
import LoginPage from './pages/LoginPage';
@@ -8,29 +9,34 @@ import DashboardPage from './pages/Dashboard';
import ServersPage from './pages/ServersPage';
import GroupsPage from './pages/GroupsPage';
import SettingsPage from './pages/SettingsPage';
import MarketPage from './pages/MarketPage';
function App() {
return (
<AuthProvider>
<Router>
<Routes>
{/* 公共路由 */}
<Route path="/login" element={<LoginPage />} />
{/* 受保护的路由,使用 MainLayout 作为布局容器 */}
<Route element={<ProtectedRoute />}>
<Route element={<MainLayout />}>
<Route path="/" element={<DashboardPage />} />
<Route path="/servers" element={<ServersPage />} />
<Route path="/groups" element={<GroupsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<ToastProvider>
<Router>
<Routes>
{/* 公共路由 */}
<Route path="/login" element={<LoginPage />} />
{/* 受保护的路由,使用 MainLayout 作为布局容器 */}
<Route element={<ProtectedRoute />}>
<Route element={<MainLayout />}>
<Route path="/" element={<DashboardPage />} />
<Route path="/servers" element={<ServersPage />} />
<Route path="/groups" element={<GroupsPage />} />
<Route path="/market" element={<MarketPage />} />
<Route path="/market/:serverName" element={<MarketPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
</Route>
</Route>
{/* 未匹配的路由重定向到首页 */}
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</Router>
{/* 未匹配的路由重定向到首页 */}
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</Router>
</ToastProvider>
</AuthProvider>
);
}

View File

@@ -3,6 +3,7 @@ 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'
import { useToast } from '@/contexts/ToastContext'
interface GroupCardProps {
group: Group
@@ -18,6 +19,7 @@ const GroupCard = ({
onDelete
}: GroupCardProps) => {
const { t } = useTranslation()
const { showToast } = useToast()
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [copied, setCopied] = useState(false)
@@ -55,7 +57,7 @@ const GroupCard = ({
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
alert(t('common.copyFailed') || 'Copy failed')
showToast(t('common.copyFailed') || 'Copy failed', 'error')
console.error('Copy to clipboard failed:', err)
}
document.body.removeChild(textArea)

View File

@@ -0,0 +1,153 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { MarketServer } from '@/types';
interface MarketServerCardProps {
server: MarketServer;
onClick: (server: MarketServer) => void;
}
const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick }) => {
const { t } = useTranslation();
// Intelligently calculate how many tags to display to ensure they fit in a single line
const getTagsToDisplay = () => {
if (!server.tags || server.tags.length === 0) {
return { tagsToShow: [], hasMore: false, moreCount: 0 };
}
// Estimate available width in the card (in characters)
const estimatedAvailableWidth = 28; // Estimated number of characters that can fit in one line
// Calculate the character space needed for tags and plus sign (including # and spacing)
const calculateTagWidth = (tag: string) => tag.length + 3; // +3 for # and spacing
// Loop to determine the maximum number of tags that can be displayed
let totalWidth = 0;
let i = 0;
// First, sort tags by length to prioritize displaying shorter tags
const sortedTags = [...server.tags].sort((a, b) => a.length - b.length);
// Calculate how many tags can fit
for (i = 0; i < sortedTags.length; i++) {
const tagWidth = calculateTagWidth(sortedTags[i]);
// If this tag would make the total width exceed available width, stop adding
if (totalWidth + tagWidth > estimatedAvailableWidth) {
break;
}
totalWidth += tagWidth;
// If this is the last tag but there's still space, no need to show "more"
if (i === sortedTags.length - 1) {
return {
tagsToShow: sortedTags,
hasMore: false,
moreCount: 0
};
}
}
// If there's not enough space to display any tags, show at least one
if (i === 0 && sortedTags.length > 0) {
i = 1;
}
// Calculate space needed for the "more" tag
const moreCount = sortedTags.length - i;
const moreTagWidth = 3 + String(moreCount).length + t('market.moreTags').length;
// If there's enough remaining space to display the "more" tag
if (totalWidth + moreTagWidth <= estimatedAvailableWidth || i < 1) {
return {
tagsToShow: sortedTags.slice(0, i),
hasMore: true,
moreCount
};
}
// If there's not enough space for even the "more" tag, reduce one tag to make room
return {
tagsToShow: sortedTags.slice(0, Math.max(1, i - 1)),
hasMore: true,
moreCount: moreCount + 1
};
};
const { tagsToShow, hasMore, moreCount } = getTagsToDisplay();
return (
<div
className="bg-white rounded-lg shadow-md p-5 hover:shadow-lg transition-shadow cursor-pointer flex flex-col h-full"
onClick={() => onClick(server)}
>
<div className="flex justify-between items-start mb-3">
<h3 className="text-lg font-semibold text-gray-900 line-clamp-1 mr-2">{server.display_name}</h3>
{server.is_official && (
<span className="bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded flex-shrink-0">
{t('market.official')}
</span>
)}
</div>
<p className="text-gray-600 text-sm mb-4 line-clamp-2 min-h-[40px]">{server.description}</p>
{/* Categories */}
<div className="flex flex-wrap gap-1 mb-2 min-h-[28px]">
{server.categories?.length > 0 ? (
server.categories.map((category, index) => (
<span
key={index}
className="bg-gray-100 text-gray-800 text-xs px-2 py-1 rounded whitespace-nowrap"
>
{category}
</span>
))
) : (
<span className="text-xs text-gray-400 py-1">-</span>
)}
</div>
{/* Tags */}
<div className="relative mb-3 min-h-[28px] overflow-x-auto">
{server.tags?.length > 0 ? (
<div className="flex gap-1 items-center whitespace-nowrap">
{tagsToShow.map((tag, index) => (
<span
key={index}
className="bg-green-50 text-green-700 text-xs px-2 py-1 rounded flex-shrink-0"
>
#{tag}
</span>
))}
{hasMore && (
<span className="bg-gray-100 text-gray-600 text-xs px-1.5 py-1 rounded flex-shrink-0">
+{moreCount} {t('market.moreTags')}
</span>
)}
</div>
) : (
<span className="text-xs text-gray-400 py-1">-</span>
)}
</div>
<div className="flex justify-between items-center mt-auto pt-2 text-xs text-gray-500 border-t border-gray-100">
<div className="overflow-hidden">
<span className="whitespace-nowrap">{t('market.by')} </span>
<span className="font-medium whitespace-nowrap overflow-hidden text-ellipsis max-w-[120px] inline-block align-bottom">
{server.author?.name || t('market.unknown')}
</span>
</div>
<div className="flex items-center flex-shrink-0">
<svg className="h-4 w-4 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
</svg>
<span>{server.tools?.length || 0} {t('market.tools')}</span>
</div>
</div>
</div>
);
};
export default MarketServerCard;

View File

@@ -0,0 +1,297 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { MarketServer, MarketServerInstallation } from '@/types';
import ServerForm from './ServerForm';
interface MarketServerDetailProps {
server: MarketServer;
onBack: () => void;
onInstall: (server: MarketServer) => void;
installing?: boolean;
isInstalled?: boolean;
}
const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
server,
onBack,
onInstall,
installing = false,
isInstalled = false
}) => {
const { t } = useTranslation();
const [modalVisible, setModalVisible] = useState(false);
const [error, setError] = useState<string | null>(null);
// Helper function to determine button state
const getButtonProps = () => {
if (isInstalled) {
return {
className: "bg-green-600 cursor-default px-4 py-2 rounded text-sm font-medium text-white",
disabled: true,
text: t('market.installed')
};
} else if (installing) {
return {
className: "bg-gray-400 cursor-not-allowed px-4 py-2 rounded text-sm font-medium text-white",
disabled: true,
text: t('market.installing')
};
} else {
return {
className: "bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm font-medium text-white",
disabled: false,
text: t('market.install')
};
}
};
const toggleModal = () => {
setModalVisible(!modalVisible);
setError(null); // Clear any previous errors when toggling modal
};
const handleInstall = () => {
if (!isInstalled) {
toggleModal();
}
};
// Get the preferred installation configuration based on priority:
// npm > uvx > default
const getPreferredInstallation = (): MarketServerInstallation | undefined => {
if (!server.installations) {
return undefined;
}
if (server.installations.npm) {
return server.installations.npm;
} else if (server.installations.uvx) {
return server.installations.uvx;
} else if (server.installations.default) {
return server.installations.default;
}
// If none of the preferred types are available, get the first available installation type
const installTypes = Object.keys(server.installations);
if (installTypes.length > 0) {
return server.installations[installTypes[0]];
}
return undefined;
};
const handleSubmit = async (payload: any) => {
try {
setError(null);
// Pass the server object to the parent component for installation
onInstall(server);
setModalVisible(false);
} catch (err) {
console.error('Error installing server:', err);
setError(t('errors.serverInstall'));
}
};
const buttonProps = getButtonProps();
const preferredInstallation = getPreferredInstallation();
return (
<div className="bg-white rounded-lg shadow-md p-6">
<div className="mb-4">
<button
onClick={onBack}
className="text-gray-600 hover:text-gray-900 flex items-center"
>
<svg className="h-5 w-5 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
</svg>
{t('market.backToList')}
</button>
</div>
<div className="flex justify-between items-start mb-4">
<div>
<h2 className="text-2xl font-bold text-gray-900 flex items-center flex-wrap">
{server.display_name}
<span className="text-sm font-normal text-gray-500 ml-2">({server.name})</span>
<span className="text-sm font-normal text-gray-600 ml-4">
{t('market.author')}: {server.author.name} {t('market.license')}: {server.license}
<a
href={server.repository.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline ml-1"
>
{t('market.repository')}
</a>
</span>
</h2>
</div>
<div className="flex items-center">
{server.is_official && (
<span className="bg-blue-100 text-blue-800 text-sm font-medium px-4 py-2 rounded mr-2 flex items-center">
{t('market.official')}
</span>
)}
<button
onClick={handleInstall}
disabled={buttonProps.disabled}
className={buttonProps.className}
>
{buttonProps.text}
</button>
</div>
</div>
<p className="text-gray-700 mb-6">{server.description}</p>
<div className="mb-6">
<h3 className="text-lg font-semibold mb-3">{t('market.categories')} & {t('market.tags')}</h3>
<div className="flex flex-wrap gap-2">
{server.categories?.map((category, index) => (
<span key={`cat-${index}`} className="bg-gray-100 text-gray-800 px-3 py-1 rounded">
{category}
</span>
))}
{server.tags && server.tags.map((tag, index) => (
<span key={`tag-${index}`} className="bg-gray-100 text-green-700 px-2 py-1 rounded text-sm">
#{tag}
</span>
))}
</div>
</div>
{server.arguments && Object.keys(server.arguments).length > 0 && (
<div className="mb-6">
<h3 className="text-lg font-semibold mb-3">{t('market.arguments')}</h3>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">
{t('market.argumentName')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">
{t('market.description')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">
{t('market.required')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">
{t('market.example')}
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{Object.entries(server.arguments).map(([name, arg], index) => (
<tr key={index}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{name}
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{arg.description}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{arg.required ? (
<span className="text-green-600"></span>
) : (
<span className="text-red-600"></span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<code className="bg-gray-100 px-2 py-1 rounded">{arg.example}</code>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<div className="mb-6">
<h3 className="text-lg font-semibold mb-3">{t('market.tools')}</h3>
<div className="space-y-4">
{server.tools?.map((tool, index) => (
<div key={index} className="border border-gray-200 rounded p-4">
<h4 className="font-medium mb-2">
{tool.name}
<button
type="button"
onClick={() => {
// Toggle visibility of schema (simplified for this implementation)
const element = document.getElementById(`schema-${index}`);
if (element) {
element.classList.toggle('hidden');
}
}}
className="text-sm text-blue-600 hover:underline focus:outline-none ml-2"
>
{t('market.viewSchema')}
</button>
</h4>
<p className="text-gray-600 mb-2">{tool.description}</p>
<div className="mt-2">
<pre id={`schema-${index}`} className="hidden bg-gray-50 p-3 rounded text-sm overflow-auto mt-2">
{JSON.stringify(tool.inputSchema, null, 2)}
</pre>
</div>
</div>
))}
</div>
</div>
{server.examples && server.examples.length > 0 && (
<div className="mb-6">
<h3 className="text-lg font-semibold mb-3">{t('market.examples')}</h3>
<div className="space-y-4">
{server.examples.map((example, index) => (
<div key={index} className="border border-gray-200 rounded p-4">
<h4 className="font-medium mb-2">{example.title}</h4>
<p className="text-gray-600 mb-2">{example.description}</p>
<pre className="bg-gray-50 p-3 rounded text-sm overflow-auto">
{example.prompt}
</pre>
</div>
))}
</div>
</div>
)}
<div className="mt-6 flex justify-end">
<button
onClick={handleInstall}
disabled={buttonProps.disabled}
className={buttonProps.className}
>
{buttonProps.text}
</button>
</div>
{modalVisible && (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<ServerForm
onSubmit={handleSubmit}
onCancel={toggleModal}
modalTitle={t('market.installServer', { name: server.display_name })}
formError={error}
initialData={{
name: server.name,
status: 'disconnected',
config: preferredInstallation
? {
command: preferredInstallation.command || '',
args: preferredInstallation.args || [],
env: preferredInstallation.env || {}
}
: undefined
}}
/>
</div>
)}
</div>
);
};
export default MarketServerDetail;

View File

@@ -1,10 +1,11 @@
import { useState } from 'react'
import { useState, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Server } from '@/types'
import { ChevronDown, ChevronRight } from '@/components/icons/LucideIcons'
import { ChevronDown, ChevronRight, AlertCircle, Copy, Check } from 'lucide-react'
import Badge from '@/components/ui/Badge'
import ToolCard from '@/components/ui/ToolCard'
import DeleteDialog from '@/components/ui/DeleteDialog'
import { useToast } from '@/contexts/ToastContext'
interface ServerCardProps {
server: Server
@@ -15,9 +16,26 @@ interface ServerCardProps {
const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) => {
const { t } = useTranslation()
const { showToast } = useToast()
const [isExpanded, setIsExpanded] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isToggling, setIsToggling] = useState(false)
const [showErrorPopover, setShowErrorPopover] = useState(false)
const [copied, setCopied] = useState(false)
const errorPopoverRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (errorPopoverRef.current && !errorPopoverRef.current.contains(event.target as Node)) {
setShowErrorPopover(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [])
const handleRemove = (e: React.MouseEvent) => {
e.stopPropagation()
@@ -41,6 +59,44 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
}
}
const handleErrorIconClick = (e: React.MouseEvent) => {
e.stopPropagation()
setShowErrorPopover(!showErrorPopover)
}
const copyToClipboard = (e: React.MouseEvent) => {
e.stopPropagation()
if (!server.error) return
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(server.error).then(() => {
setCopied(true)
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
setTimeout(() => setCopied(false), 2000)
})
} else {
// Fallback for HTTP or unsupported clipboard API
const textArea = document.createElement('textarea')
textArea.value = server.error
// 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)
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
setTimeout(() => setCopied(false), 2000)
} catch (err) {
showToast(t('common.copyFailed') || 'Copy failed', 'error')
console.error('Copy to clipboard failed:', err)
}
document.body.removeChild(textArea)
}
}
const handleConfirmDelete = () => {
onRemove(server.name)
setShowDeleteDialog(false)
@@ -56,6 +112,59 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
<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} />
{server.error && (
<div className="relative">
<div
className="cursor-pointer"
onClick={handleErrorIconClick}
aria-label={t('server.viewErrorDetails')}
>
<AlertCircle className="text-red-500 hover:text-red-600" size={18} />
</div>
{showErrorPopover && (
<div
ref={errorPopoverRef}
className="absolute z-10 mt-2 bg-white border border-gray-200 rounded-md shadow-lg p-0 w-120"
style={{
left: '-231px',
top: '24px',
maxHeight: '300px',
overflowY: 'auto',
width: '480px',
transform: 'translateX(50%)'
}}
onClick={(e) => e.stopPropagation()}
>
<div className="flex justify-between items-center sticky top-0 bg-white py-2 px-4 border-b border-gray-200 z-20 shadow-sm">
<div className="flex items-center space-x-2">
<h4 className="text-sm font-medium text-red-600">{t('server.errorDetails')}</h4>
<button
onClick={copyToClipboard}
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
title={t('common.copy')}
>
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
</button>
</div>
<button
onClick={(e) => {
e.stopPropagation()
setShowErrorPopover(false)
}}
className="text-gray-400 hover:text-gray-600"
>
</button>
</div>
<div className="p-4 pt-2">
<pre className="text-sm text-gray-700 break-words whitespace-pre-wrap">{server.error}</pre>
</div>
</div>
)}
</div>
)}
</div>
<div className="flex space-x-2">
<button

View File

@@ -16,7 +16,7 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
const { t } = useTranslation();
const location = useLocation();
// 菜单项配置
// Menu item configuration
const menuItems: MenuItem[] = [
{
path: '/',
@@ -46,6 +46,15 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
</svg>
),
},
{
path: '/market',
label: t('nav.market'),
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M3 1a1 1 0 000 2h1.22l.305 1.222a.997.997 0 00.01.042l1.358 5.43-.893.892C3.74 11.846 4.632 14 6.414 14H15a1 1 0 000-2H6.414l1-1H14a1 1 0 00.894-.553l3-6A1 1 0 0017 3H6.28l-.31-1.243A1 1 0 005 1H3zM16 16.5a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0zM6.5 18a1.5 1.5 0 100-3 1.5 1.5 0 000 3z" />
</svg>
),
},
{
path: '/settings',
label: t('nav.settings'),

View File

@@ -0,0 +1,128 @@
import React from 'react';
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
}
const Pagination: React.FC<PaginationProps> = ({
currentPage,
totalPages,
onPageChange
}) => {
// Generate page buttons
const getPageButtons = () => {
const buttons = [];
const maxDisplayedPages = 5; // Maximum number of page buttons to display
// Always display first page
buttons.push(
<button
key="first"
onClick={() => onPageChange(1)}
className={`px-3 py-1 mx-1 rounded ${
currentPage === 1
? 'bg-blue-500 text-white'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
}`}
>
1
</button>
);
// Start range
let startPage = Math.max(2, currentPage - Math.floor(maxDisplayedPages / 2));
// If we're showing ellipsis after first page
if (startPage > 2) {
buttons.push(
<span key="ellipsis1" className="px-3 py-1">
...
</span>
);
}
// Middle pages
for (let i = startPage; i <= Math.min(totalPages - 1, startPage + maxDisplayedPages - 3); i++) {
buttons.push(
<button
key={i}
onClick={() => onPageChange(i)}
className={`px-3 py-1 mx-1 rounded ${
currentPage === i
? 'bg-blue-500 text-white'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
}`}
>
{i}
</button>
);
}
// If we're showing ellipsis before last page
if (startPage + maxDisplayedPages - 3 < totalPages - 1) {
buttons.push(
<span key="ellipsis2" className="px-3 py-1">
...
</span>
);
}
// Always display last page if there's more than one page
if (totalPages > 1) {
buttons.push(
<button
key="last"
onClick={() => onPageChange(totalPages)}
className={`px-3 py-1 mx-1 rounded ${
currentPage === totalPages
? 'bg-blue-500 text-white'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
}`}
>
{totalPages}
</button>
);
}
return buttons;
};
// If there's only one page, don't render pagination
if (totalPages <= 1) {
return null;
}
return (
<div className="flex justify-center items-center my-6">
<button
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className={`px-3 py-1 rounded mr-2 ${
currentPage === 1
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
}`}
>
&laquo; Prev
</button>
<div className="flex">{getPageButtons()}</div>
<button
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className={`px-3 py-1 rounded ml-2 ${
currentPage === totalPages
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700'
}`}
>
Next &raquo;
</button>
</div>
);
};
export default Pagination;

View File

@@ -0,0 +1,96 @@
import React, { useEffect, useState } from 'react';
import { Check, X } from 'lucide-react';
import { cn } from '@/utils/cn';
export type ToastType = 'success' | 'error' | 'info' | 'warning';
export interface ToastProps {
message: string;
type?: ToastType;
duration?: number;
onClose: () => void;
visible: boolean;
}
const Toast: React.FC<ToastProps> = ({
message,
type = 'info',
duration = 3000,
onClose,
visible
}) => {
useEffect(() => {
if (visible) {
const timer = setTimeout(() => {
onClose();
}, duration);
return () => clearTimeout(timer);
}
}, [visible, duration, onClose]);
const icons = {
success: <Check className="w-5 h-5 text-green-500" />,
error: <X className="w-5 h-5 text-red-500" />,
info: (
<svg className="w-5 h-5 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
warning: (
<svg className="w-5 h-5 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
)
};
const bgColors = {
success: 'bg-green-50 border-green-200',
error: 'bg-red-50 border-red-200',
info: 'bg-blue-50 border-blue-200',
warning: 'bg-yellow-50 border-yellow-200'
};
const textColors = {
success: 'text-green-800',
error: 'text-red-800',
info: 'text-blue-800',
warning: 'text-yellow-800'
};
return (
<div className={cn(
"fixed top-4 right-4 z-50 max-w-sm p-4 rounded-md shadow-lg border",
bgColors[type],
"transform transition-all duration-300 ease-in-out",
visible ? "translate-x-0 opacity-100" : "translate-x-full opacity-0"
)}>
<div className="flex items-start">
<div className="flex-shrink-0">
{icons[type]}
</div>
<div className="ml-3">
<p className={cn("text-sm font-medium", textColors[type])}>
{message}
</p>
</div>
<div className="ml-auto pl-3">
<div className="-mx-1.5 -my-1.5">
<button
onClick={onClose}
className={cn(
"inline-flex rounded-md p-1.5",
`hover:bg-${type}-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-${type}-500`
)}
>
<span className="sr-only">Dismiss</span>
<X className="h-5 w-5" />
</button>
</div>
</div>
</div>
</div>
);
};
export default Toast;

View File

@@ -97,4 +97,38 @@ export const ToggleGroup: React.FC<ToggleGroupProps> = ({
)}
</div>
);
};
interface SwitchProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
}
export const Switch: React.FC<SwitchProps> = ({
checked,
onCheckedChange,
disabled = false
}) => {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500",
checked ? "bg-blue-600" : "bg-gray-200",
disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
)}
onClick={() => !disabled && onCheckedChange(!checked)}
>
<span
className={cn(
"inline-block h-4 w-4 transform rounded-full bg-white transition-transform",
checked ? "translate-x-6" : "translate-x-1"
)}
/>
</button>
);
};

View File

@@ -0,0 +1,60 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
import Toast, { ToastType } from '@/components/ui/Toast';
interface ToastContextProps {
showToast: (message: string, type?: ToastType, duration?: number) => void;
}
const ToastContext = createContext<ToastContextProps | undefined>(undefined);
export const useToast = () => {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
};
interface ToastProviderProps {
children: ReactNode;
}
export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
const [toast, setToast] = useState<{
message: string;
type: ToastType;
visible: boolean;
duration: number;
}>({
message: '',
type: 'info',
visible: false,
duration: 3000,
});
const showToast = (message: string, type: ToastType = 'info', duration: number = 3000) => {
setToast({
message,
type,
visible: true,
duration,
});
};
const hideToast = () => {
setToast((prev) => ({ ...prev, visible: false }));
};
return (
<ToastContext.Provider value={{ showToast }}>
{children}
<Toast
message={toast.message}
type={toast.type}
duration={toast.duration}
onClose={hideToast}
visible={toast.visible}
/>
</ToastContext.Provider>
);
};

View File

@@ -0,0 +1,410 @@
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { MarketServer, ApiResponse } from '@/types';
export const useMarketData = () => {
const { t } = useTranslation();
const [servers, setServers] = useState<MarketServer[]>([]);
const [allServers, setAllServers] = useState<MarketServer[]>([]);
const [categories, setCategories] = useState<string[]>([]);
const [tags, setTags] = useState<string[]>([]);
const [selectedCategory, setSelectedCategory] = useState<string>('');
const [selectedTag, setSelectedTag] = useState<string>('');
const [searchQuery, setSearchQuery] = useState<string>('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentServer, setCurrentServer] = useState<MarketServer | null>(null);
const [installedServers, setInstalledServers] = useState<string[]>([]);
// Pagination states
const [currentPage, setCurrentPage] = useState(1);
const [serversPerPage, setServersPerPage] = useState(9);
const [totalPages, setTotalPages] = useState(1);
// Fetch all market servers
const fetchMarketServers = useCallback(async () => {
try {
setLoading(true);
const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/market/servers', {
headers: {
'x-auth-token': token || ''
}
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<MarketServer[]> = await response.json();
if (data && data.success && Array.isArray(data.data)) {
setAllServers(data.data);
// Apply pagination to the fetched data
applyPagination(data.data, currentPage);
} else {
console.error('Invalid market servers data format:', data);
setError(t('market.fetchError'));
}
} catch (err) {
console.error('Error fetching market servers:', err);
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [t, currentPage]);
// Apply pagination to data
const applyPagination = useCallback((data: MarketServer[], page: number, itemsPerPage = serversPerPage) => {
const totalItems = data.length;
const calculatedTotalPages = Math.ceil(totalItems / itemsPerPage);
setTotalPages(calculatedTotalPages);
// Ensure current page is valid
const validPage = Math.max(1, Math.min(page, calculatedTotalPages));
if (validPage !== page) {
setCurrentPage(validPage);
}
const startIndex = (validPage - 1) * itemsPerPage;
const paginatedServers = data.slice(startIndex, startIndex + itemsPerPage);
setServers(paginatedServers);
}, [serversPerPage]);
// Change page
const changePage = useCallback((page: number) => {
setCurrentPage(page);
applyPagination(allServers, page, serversPerPage);
}, [allServers, applyPagination, serversPerPage]);
// Fetch all categories
const fetchCategories = useCallback(async () => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/market/categories', {
headers: {
'x-auth-token': token || ''
}
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<string[]> = await response.json();
if (data && data.success && Array.isArray(data.data)) {
setCategories(data.data);
} else {
console.error('Invalid categories data format:', data);
}
} catch (err) {
console.error('Error fetching categories:', err);
}
}, []);
// Fetch all tags
const fetchTags = useCallback(async () => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/market/tags', {
headers: {
'x-auth-token': token || ''
}
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<string[]> = await response.json();
if (data && data.success && Array.isArray(data.data)) {
setTags(data.data);
} else {
console.error('Invalid tags data format:', data);
}
} catch (err) {
console.error('Error fetching tags:', err);
}
}, []);
// Fetch server by name
const fetchServerByName = useCallback(async (name: string) => {
try {
setLoading(true);
const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/market/servers/${name}`, {
headers: {
'x-auth-token': token || ''
}
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<MarketServer> = await response.json();
if (data && data.success && data.data) {
setCurrentServer(data.data);
return data.data;
} else {
console.error('Invalid server data format:', data);
setError(t('market.serverNotFound'));
return null;
}
} catch (err) {
console.error(`Error fetching server ${name}:`, err);
setError(err instanceof Error ? err.message : String(err));
return null;
} finally {
setLoading(false);
}
}, [t]);
// Search servers by query
const searchServers = useCallback(async (query: string) => {
try {
setLoading(true);
setSearchQuery(query);
if (!query.trim()) {
// Fetch fresh data from server instead of just applying pagination
fetchMarketServers();
return;
}
const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/market/servers/search?query=${encodeURIComponent(query)}`, {
headers: {
'x-auth-token': token || ''
}
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<MarketServer[]> = await response.json();
if (data && data.success && Array.isArray(data.data)) {
setAllServers(data.data);
setCurrentPage(1);
applyPagination(data.data, 1);
} else {
console.error('Invalid search results format:', data);
setError(t('market.searchError'));
}
} catch (err) {
console.error('Error searching servers:', err);
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [t, allServers, applyPagination, fetchMarketServers]);
// Filter servers by category
const filterByCategory = useCallback(async (category: string) => {
try {
setLoading(true);
setSelectedCategory(category);
setSelectedTag(''); // Reset tag filter when filtering by category
if (!category) {
fetchMarketServers();
return;
}
const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/market/categories/${encodeURIComponent(category)}`, {
headers: {
'x-auth-token': token || ''
}
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<MarketServer[]> = await response.json();
if (data && data.success && Array.isArray(data.data)) {
setAllServers(data.data);
setCurrentPage(1);
applyPagination(data.data, 1);
} else {
console.error('Invalid category filter results format:', data);
setError(t('market.filterError'));
}
} catch (err) {
console.error('Error filtering servers by category:', err);
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [t, fetchMarketServers, applyPagination]);
// Filter servers by tag
const filterByTag = useCallback(async (tag: string) => {
try {
setLoading(true);
setSelectedTag(tag);
setSelectedCategory(''); // Reset category filter when filtering by tag
if (!tag) {
fetchMarketServers();
return;
}
const token = localStorage.getItem('mcphub_token');
const response = await fetch(`/api/market/tags/${encodeURIComponent(tag)}`, {
headers: {
'x-auth-token': token || ''
}
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data: ApiResponse<MarketServer[]> = await response.json();
if (data && data.success && Array.isArray(data.data)) {
setAllServers(data.data);
setCurrentPage(1);
applyPagination(data.data, 1);
} else {
console.error('Invalid tag filter results format:', data);
setError(t('market.tagFilterError'));
}
} catch (err) {
console.error('Error filtering servers by tag:', err);
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, [t, fetchMarketServers, applyPagination]);
// Fetch installed servers
const fetchInstalledServers = useCallback(async () => {
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/servers', {
headers: {
'x-auth-token': token || ''
}
});
if (!response.ok) {
throw new Error(`Status: ${response.status}`);
}
const data = await response.json();
if (data && data.success && Array.isArray(data.data)) {
// Extract server names
const installedServerNames = data.data.map((server: any) => server.name);
setInstalledServers(installedServerNames);
}
} catch (err) {
console.error('Error fetching installed servers:', err);
}
}, []);
// Check if a server is already installed
const isServerInstalled = useCallback((serverName: string) => {
return installedServers.includes(serverName);
}, [installedServers]);
// Install server to the local environment
const installServer = useCallback(async (server: MarketServer) => {
try {
const installType = server.installations?.npm ? 'npm' : Object.keys(server.installations || {}).length > 0 ? Object.keys(server.installations)[0] : null;
if (!installType || !server.installations?.[installType]) {
setError(t('market.noInstallationMethod'));
return false;
}
const installation = server.installations[installType];
// Prepare server configuration
const serverConfig = {
name: server.name,
config: {
command: installation.command,
args: installation.args,
env: installation.env || {}
}
};
// Call the createServer API
const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/servers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || ''
},
body: JSON.stringify(serverConfig),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || `Status: ${response.status}`);
}
// Update installed servers list after successful installation
await fetchInstalledServers();
return true;
} catch (err) {
console.error('Error installing server:', err);
setError(err instanceof Error ? err.message : String(err));
return false;
}
}, [t, fetchInstalledServers]);
// Change servers per page
const changeServersPerPage = useCallback((perPage: number) => {
setServersPerPage(perPage);
setCurrentPage(1);
applyPagination(allServers, 1, perPage);
}, [allServers, applyPagination]);
// Load initial data
useEffect(() => {
fetchMarketServers();
fetchCategories();
fetchTags();
fetchInstalledServers();
}, [fetchMarketServers, fetchCategories, fetchTags, fetchInstalledServers]);
return {
servers,
allServers,
categories,
tags,
selectedCategory,
selectedTag,
searchQuery,
loading,
error,
setError,
currentServer,
fetchMarketServers,
fetchServerByName,
searchServers,
filterByCategory,
filterByTag,
installServer,
// Pagination properties and methods
currentPage,
totalPages,
serversPerPage,
changePage,
changeServersPerPage,
// Installed servers methods
isServerInstalled
};
};

View File

@@ -2,16 +2,16 @@ import { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Server, ApiResponse } from '@/types';
// 配置选项
// Configuration options
const CONFIG = {
// 初始化启动阶段的配置
// Initialization phase configuration
startup: {
maxAttempts: 60, // 初始化阶段最大尝试次数
pollingInterval: 3000 // 初始阶段轮询间隔 (3秒)
maxAttempts: 60, // Maximum number of attempts during initialization
pollingInterval: 3000 // Polling interval during initialization (3 seconds)
},
// 正常运行阶段的配置
// Normal operation phase configuration
normal: {
pollingInterval: 10000 // 正常运行时的轮询间隔 (10秒)
pollingInterval: 10000 // Polling interval during normal operation (10 seconds)
}
};
@@ -23,12 +23,12 @@ export const useServerData = () => {
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [fetchAttempts, setFetchAttempts] = useState(0);
// 轮询定时器引用
// Timer reference for polling
const intervalRef = useRef<NodeJS.Timeout | null>(null);
// 保存当前尝试次数,避免依赖循环
// Track current attempt count to avoid dependency cycles
const attemptsRef = useRef<number>(0);
// 清理定时器
// Clear the timer
const clearTimer = () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
@@ -36,9 +36,9 @@ export const useServerData = () => {
}
};
// 开始正常轮询
// Start normal polling
const startNormalPolling = useCallback(() => {
// 确保没有其他定时器在运行
// Ensure no other timers are running
clearTimer();
const fetchServers = async () => {
@@ -60,12 +60,12 @@ export const useServerData = () => {
setServers([]);
}
// 重置错误状态
// Reset error state
setError(null);
} catch (err) {
console.error('Error fetching servers during normal polling:', err);
// 使用友好的错误消息
// Use friendly error message
if (!navigator.onLine) {
setError(t('errors.network'));
} else if (err instanceof TypeError && (
@@ -79,21 +79,21 @@ export const useServerData = () => {
}
};
// 立即执行一次
// Execute immediately
fetchServers();
// 设置定期轮询
// Set up regular polling
intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval);
}, [t]);
useEffect(() => {
// 重置尝试计数
// Reset attempt count
if (refreshKey > 0) {
attemptsRef.current = 0;
setFetchAttempts(0);
}
// 初始化加载阶段的请求函数
// Initialization phase request function
const fetchInitialData = async () => {
try {
const token = localStorage.getItem('mcphub_token');
@@ -104,51 +104,51 @@ export const useServerData = () => {
});
const data = await response.json();
// 处理API响应中的包装对象提取data字段
// Handle API response wrapper object, extract data field
if (data && data.success && Array.isArray(data.data)) {
setServers(data.data);
setIsInitialLoading(false);
// 初始化成功,开始正常轮询
// Initialization successful, start normal polling
startNormalPolling();
return true;
} else if (data && Array.isArray(data)) {
// 兼容性处理如果API直接返回数组
// Compatibility handling, if API directly returns array
setServers(data);
setIsInitialLoading(false);
// 初始化成功,开始正常轮询
// Initialization successful, start normal polling
startNormalPolling();
return true;
} else {
// 如果数据格式不符合预期,设置为空数组
// If data format is not as expected, set to empty array
console.error('Invalid server data format:', data);
setServers([]);
setIsInitialLoading(false);
// 初始化成功但数据为空,开始正常轮询
// Initialization successful but data is empty, start normal polling
startNormalPolling();
return true;
}
} catch (err) {
// 增加尝试次数计数,使用 ref 避免触发 effect 重新运行
// Increment attempt count, use ref to avoid triggering effect rerun
attemptsRef.current += 1;
console.error(`Initial loading attempt ${attemptsRef.current} failed:`, err);
// 更新状态用于显示
// Update state for display
setFetchAttempts(attemptsRef.current);
// 设置适当的错误消息
// Set appropriate error message
if (!navigator.onLine) {
setError(t('errors.network'));
} else {
setError(t('errors.initialStartup'));
}
// 如果已超过最大尝试次数,放弃初始化并切换到正常轮询
// If maximum attempt count is exceeded, give up initialization and switch to normal polling
if (attemptsRef.current >= CONFIG.startup.maxAttempts) {
console.log('Maximum startup attempts reached, switching to normal polling');
setIsInitialLoading(false);
// 清除初始化的轮询
// Clear initialization polling
clearTimer();
// 切换到正常轮询模式
// Switch to normal polling mode
startNormalPolling();
}
@@ -156,45 +156,45 @@ export const useServerData = () => {
}
};
// 组件挂载时,根据当前状态设置适当的轮询
// On component mount, set appropriate polling based on current state
if (isInitialLoading) {
// 确保没有其他定时器在运行
// Ensure no other timers are running
clearTimer();
// 立即执行一次初始请求
// Execute initial request immediately
fetchInitialData();
// 设置初始阶段的轮询间隔
// Set polling interval for initialization phase
intervalRef.current = setInterval(fetchInitialData, CONFIG.startup.pollingInterval);
console.log(`Started initial polling with interval: ${CONFIG.startup.pollingInterval}ms`);
} else {
// 已经初始化完成,开始正常轮询
// Initialization completed, start normal polling
startNormalPolling();
}
// 清理函数
// Cleanup function
return () => {
clearTimer();
};
}, [refreshKey, t, isInitialLoading, startNormalPolling]);
// 手动触发刷新
// Manually trigger refresh
const triggerRefresh = () => {
// 清除当前的定时器
// Clear current timer
clearTimer();
// 如果在初始化阶段,重置初始化状态
// If in initialization phase, reset initialization state
if (isInitialLoading) {
setIsInitialLoading(true);
attemptsRef.current = 0;
setFetchAttempts(0);
}
// refreshKey 的改变会触发 useEffect 再次运行
// Change in refreshKey will trigger useEffect to run again
setRefreshKey(prevKey => prevKey + 1);
};
// 服务器相关操作
// Server related operations
const handleServerAdd = () => {
setRefreshKey(prevKey => prevKey + 1);
};

View File

@@ -0,0 +1,131 @@
import { useState, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { ApiResponse } from '@/types';
import { useToast } from '@/contexts/ToastContext';
// Define types for the settings data
interface RoutingConfig {
enableGlobalRoute: boolean;
enableGroupNameRoute: boolean;
}
interface SystemSettings {
systemConfig?: {
routing?: RoutingConfig;
};
}
export const useSettingsData = () => {
const { t } = useTranslation();
const { showToast } = useToast();
const [routingConfig, setRoutingConfig] = useState<RoutingConfig>({
enableGlobalRoute: true,
enableGroupNameRoute: true,
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
// Trigger a refresh of the settings data
const triggerRefresh = useCallback(() => {
setRefreshKey((prev) => prev + 1);
}, []);
// Fetch current settings
const fetchSettings = useCallback(async () => {
setLoading(true);
setError(null);
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/settings', {
headers: {
'x-auth-token': token || '',
},
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data: ApiResponse<SystemSettings> = await response.json();
if (data.success && data.data?.systemConfig?.routing) {
setRoutingConfig({
enableGlobalRoute: data.data.systemConfig.routing.enableGlobalRoute ?? true,
enableGroupNameRoute: data.data.systemConfig.routing.enableGroupNameRoute ?? true,
});
}
} catch (error) {
console.error('Failed to fetch settings:', error);
setError(error instanceof Error ? error.message : 'Failed to fetch settings');
showToast(t('errors.failedToFetchSettings'));
} finally {
setLoading(false);
}
}, [t, showToast]);
// Update routing configuration
const updateRoutingConfig = async (key: keyof RoutingConfig, value: boolean) => {
setLoading(true);
setError(null);
try {
const token = localStorage.getItem('mcphub_token');
const response = await fetch('/api/system-config', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'x-auth-token': token || '',
},
body: JSON.stringify({
routing: {
[key]: value,
},
}),
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
if (data.success) {
setRoutingConfig({
...routingConfig,
[key]: value,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(t('errors.failedToUpdateSystemConfig'));
return false;
}
} catch (error) {
console.error('Failed to update system config:', error);
setError(error instanceof Error ? error.message : 'Failed to update system config');
showToast(t('errors.failedToUpdateSystemConfig'));
return false;
} finally {
setLoading(false);
}
};
// Fetch settings when the component mounts or refreshKey changes
useEffect(() => {
fetchSettings();
}, [fetchSettings, refreshKey]);
return {
routingConfig,
loading,
error,
setError,
triggerRefresh,
fetchSettings,
updateRoutingConfig,
};
};

View File

@@ -32,8 +32,8 @@ i18n
},
detection: {
// Order of detection; we put 'navigator' first to use browser language
order: ['navigator', 'localStorage', 'cookie', 'htmlTag'],
// Order of detection; prioritize localStorage to respect user language choice
order: ['localStorage', 'cookie', 'htmlTag', 'navigator'],
// Cache the language in localStorage
caches: ['localStorage', 'cookie'],
}

View File

@@ -9,7 +9,8 @@
"profile": "Profile",
"changePassword": "Change Password",
"toggleSidebar": "Toggle Sidebar",
"welcomeUser": "Welcome, {{username}}"
"welcomeUser": "Welcome, {{username}}",
"name": "MCP Hub"
},
"auth": {
"login": "Login",
@@ -26,7 +27,9 @@
"passwordsNotMatch": "New password and confirmation do not match",
"changePasswordSuccess": "Password changed successfully",
"changePasswordError": "Failed to change password",
"changePassword": "Change Password"
"changePassword": "Change Password",
"passwordChanged": "Password changed successfully",
"passwordChangeError": "Failed to change password"
},
"server": {
"addServer": "Add Server",
@@ -40,7 +43,7 @@
"name": "Server Name",
"url": "Server URL",
"apiKey": "API Key",
"save": "Save Changes",
"save": "Save",
"cancel": "Cancel",
"invalidConfig": "Could not find configuration data for {{serverName}}",
"addError": "Failed to add server",
@@ -61,7 +64,13 @@
"toggleError": "Failed to toggle server {{serverName}}",
"alreadyExists": "Server {{serverName}} already exists",
"invalidData": "Invalid server data provided",
"notFound": "Server {{serverName}} not found"
"notFound": "Server {{serverName}} not found",
"namePlaceholder": "Enter server name",
"urlPlaceholder": "Enter server URL",
"commandPlaceholder": "Enter command",
"argumentsPlaceholder": "Enter arguments",
"errorDetails": "Error Details",
"viewErrorDetails": "View error details"
},
"status": {
"online": "Online",
@@ -75,7 +84,10 @@
"serverAdd": "Failed to add server. Please check the server status",
"serverUpdate": "Failed to edit server {{serverName}}. Please check the server status",
"serverFetch": "Failed to retrieve server data. Please try again later",
"initialStartup": "The server might be starting up. Please wait a moment as this process can take some time on first launch..."
"initialStartup": "The server might be starting up. Please wait a moment as this process can take some time on first launch...",
"serverInstall": "Failed to install server",
"failedToFetchSettings": "Failed to fetch settings",
"failedToUpdateRouteConfig": "Failed to update route configuration"
},
"common": {
"processing": "Processing...",
@@ -84,14 +96,18 @@
"refresh": "Refresh",
"create": "Create",
"submitting": "Submitting...",
"delete": "Delete"
"delete": "Delete",
"copy": "Copy",
"copySuccess": "Copied to clipboard",
"copyFailed": "Copy failed"
},
"nav": {
"dashboard": "Dashboard",
"servers": "Servers",
"groups": "Groups",
"settings": "Settings",
"changePassword": "Change Password"
"changePassword": "Change Password",
"market": "Market"
},
"pages": {
"dashboard": {
@@ -110,7 +126,14 @@
},
"settings": {
"title": "Settings",
"language": "Language"
"language": "Language",
"account": "Account Settings",
"password": "Change Password",
"appearance": "Appearance",
"routeConfig": "Route Configuration"
},
"market": {
"title": "Server Market - (Data from mcpm.sh)"
}
},
"groups": {
@@ -138,5 +161,54 @@
"noServers": "No servers in this group.",
"noServerOptions": "No servers available",
"serverCount": "{{count}} Servers"
},
"market": {
"title": "Server Market",
"official": "Official",
"by": "By",
"unknown": "Unknown",
"tools": "tools",
"search": "Search",
"searchPlaceholder": "Search for servers by name, category, or tags",
"clearFilters": "Clear",
"clearCategoryFilter": "",
"clearTagFilter": "",
"categories": "Categories",
"tags": "Tags",
"showTags": "Show tags",
"hideTags": "Hide tags",
"moreTags": "",
"noServers": "No servers found matching your search",
"backToList": "Back to list",
"install": "Install",
"installing": "Installing...",
"installed": "Installed",
"installServer": "Install Server: {{name}}",
"installSuccess": "Server {{serverName}} installed successfully",
"author": "Author",
"license": "License",
"repository": "Repository",
"examples": "Examples",
"arguments": "Arguments",
"argumentName": "Name",
"description": "Description",
"required": "Required",
"example": "Example",
"viewSchema": "View schema",
"fetchError": "Error fetching market servers",
"serverNotFound": "Server not found",
"searchError": "Error searching servers",
"filterError": "Error filtering servers by category",
"tagFilterError": "Error filtering servers by tag",
"noInstallationMethod": "No installation method available for this server",
"showing": "Showing {{from}}-{{to}} of {{total}} servers",
"perPage": "Per page"
},
"settings": {
"enableGlobalRoute": "Enable Global Route",
"enableGlobalRouteDescription": "Allow connections to /sse endpoint without specifying a group ID",
"enableGroupNameRoute": "Enable Group Name Route",
"enableGroupNameRouteDescription": "Allow connections to /sse endpoint using group names instead of just group IDs",
"systemConfigUpdated": "System configuration updated successfully"
}
}

View File

@@ -9,7 +9,8 @@
"profile": "个人资料",
"changePassword": "修改密码",
"toggleSidebar": "切换侧边栏",
"welcomeUser": "欢迎, {{username}}"
"welcomeUser": "欢迎, {{username}}",
"name": "MCP Hub"
},
"auth": {
"login": "登录",
@@ -26,7 +27,9 @@
"passwordsNotMatch": "新密码与确认密码不一致",
"changePasswordSuccess": "密码修改成功",
"changePasswordError": "修改密码失败",
"changePassword": "修改密码"
"changePassword": "修改密码",
"passwordChanged": "密码修改成功",
"passwordChangeError": "修改密码失败"
},
"server": {
"addServer": "添加服务器",
@@ -40,7 +43,7 @@
"name": "服务器名称",
"url": "服务器 URL",
"apiKey": "API 密钥",
"save": "保存更改",
"save": "保存",
"cancel": "取消",
"addError": "添加服务器失败",
"editError": "编辑服务器 {{serverName}} 失败",
@@ -61,7 +64,13 @@
"toggleError": "切换服务器 {{serverName}} 状态失败",
"alreadyExists": "服务器 {{serverName}} 已经存在",
"invalidData": "提供的服务器数据无效",
"notFound": "找不到服务器 {{serverName}}"
"notFound": "找不到服务器 {{serverName}}",
"namePlaceholder": "请输入服务器名称",
"urlPlaceholder": "请输入服务器URL",
"commandPlaceholder": "请输入命令",
"argumentsPlaceholder": "请输入参数",
"errorDetails": "错误详情",
"viewErrorDetails": "查看错误详情"
},
"status": {
"online": "在线",
@@ -75,7 +84,10 @@
"serverAdd": "添加服务器失败,请检查服务器状态",
"serverUpdate": "编辑服务器 {{serverName}} 失败,请检查服务器状态",
"serverFetch": "获取服务器数据失败,请稍后重试",
"initialStartup": "服务器可能正在启动中。首次启动可能需要一些时间,请耐心等候..."
"initialStartup": "服务器可能正在启动中。首次启动可能需要一些时间,请耐心等候...",
"serverInstall": "安装服务器失败",
"failedToFetchSettings": "获取设置失败",
"failedToUpdateSystemConfig": "更新系统配置失败"
},
"common": {
"processing": "处理中...",
@@ -84,14 +96,18 @@
"refresh": "刷新",
"create": "创建",
"submitting": "提交中...",
"delete": "删除"
"delete": "删除",
"copy": "复制",
"copySuccess": "已复制到剪贴板",
"copyFailed": "复制失败"
},
"nav": {
"dashboard": "仪表盘",
"servers": "服务器",
"settings": "设置",
"changePassword": "修改密码",
"groups": "分组"
"groups": "分组",
"market": "市场"
},
"pages": {
"dashboard": {
@@ -107,10 +123,17 @@
},
"settings": {
"title": "设置",
"language": "语言"
"language": "语言",
"account": "账户设置",
"password": "修改密码",
"appearance": "外观",
"routeConfig": "路由配置"
},
"groups": {
"title": "分组管理"
},
"market": {
"title": "服务器市场 - (数据来源于 mcpm.sh"
}
},
"groups": {
@@ -138,5 +161,54 @@
"noServers": "此分组中没有服务器。",
"noServerOptions": "没有可用的服务器",
"serverCount": "{{count}} 台服务器"
},
"market": {
"title": "服务器市场",
"official": "官方",
"by": "作者",
"unknown": "未知",
"tools": "工具",
"search": "搜索",
"searchPlaceholder": "搜索服务器名称、分类或标签",
"clearFilters": "清除",
"clearCategoryFilter": "",
"clearTagFilter": "",
"categories": "分类",
"tags": "标签",
"showTags": "显示标签",
"hideTags": "隐藏标签",
"moreTags": "",
"noServers": "未找到匹配的服务器",
"backToList": "返回列表",
"install": "安装",
"installing": "安装中...",
"installed": "已安装",
"installServer": "安装服务器: {{name}}",
"installSuccess": "服务器 {{serverName}} 安装成功",
"author": "作者",
"license": "许可证",
"repository": "代码仓库",
"examples": "示例",
"arguments": "参数",
"argumentName": "名称",
"description": "描述",
"required": "必填",
"example": "示例",
"viewSchema": "查看结构",
"fetchError": "获取服务器市场数据失败",
"serverNotFound": "未找到服务器",
"searchError": "搜索服务器失败",
"filterError": "按分类筛选服务器失败",
"tagFilterError": "按标签筛选服务器失败",
"noInstallationMethod": "该服务器没有可用的安装方法",
"showing": "显示 {{from}}-{{to}}/{{total}} 个服务器",
"perPage": "每页显示"
},
"settings": {
"enableGlobalRoute": "启用全局路由",
"enableGlobalRouteDescription": "允许不指定分组 ID 就连接到 /sse 端点",
"enableGroupNameRoute": "启用分组名称路由",
"enableGroupNameRouteDescription": "允许使用分组名称而非分组 ID 连接到 /sse 端点",
"systemConfigUpdated": "系统配置更新成功"
}
}

View File

@@ -7,7 +7,7 @@ const DashboardPage: React.FC = () => {
const { t } = useTranslation();
const { servers, error, setError, isLoading } = useServerData();
// 计算服务器统计信息
// Calculate server statistics
const serverStats = {
total: servers.length,
online: servers.filter(server => server.status === 'connected').length,
@@ -22,7 +22,7 @@ const DashboardPage: React.FC = () => {
connecting: 'status.connecting'
}
// 计算各状态百分比(用于仪表板展示)
// Calculate percentage for each status (for dashboard display)
const getStatusPercentage = (status: ServerStatus) => {
if (servers.length === 0) return 0;
return Math.round((servers.filter(server => server.status === status).length / servers.length) * 100);
@@ -64,7 +64,7 @@ const DashboardPage: React.FC = () => {
</div>
) : (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
{/* 服务器总数 */}
{/* Total servers */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="p-3 rounded-full bg-blue-100 text-blue-800">
@@ -79,7 +79,7 @@ const DashboardPage: React.FC = () => {
</div>
</div>
{/* 在线服务器 */}
{/* Online servers */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="p-3 rounded-full bg-green-100 text-green-800">
@@ -100,7 +100,7 @@ const DashboardPage: React.FC = () => {
</div>
</div>
{/* 离线服务器 */}
{/* Offline servers */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="p-3 rounded-full bg-red-100 text-red-800">
@@ -121,7 +121,7 @@ const DashboardPage: React.FC = () => {
</div>
</div>
{/* 连接中服务器 */}
{/* Connecting servers */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="p-3 rounded-full bg-yellow-100 text-yellow-800">
@@ -144,7 +144,7 @@ const DashboardPage: React.FC = () => {
</div>
)}
{/* 最近活动列表 */}
{/* Recent activity list */}
{servers.length > 0 && !isLoading && (
<div className="mt-8">
<h2 className="text-xl font-semibold text-gray-900 mb-4">{t('pages.dashboard.recentServers')}</h2>

View File

@@ -0,0 +1,356 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams, useLocation } from 'react-router-dom';
import { MarketServer } from '@/types';
import { useMarketData } from '@/hooks/useMarketData';
import { useToast } from '@/contexts/ToastContext';
import MarketServerCard from '@/components/MarketServerCard';
import MarketServerDetail from '@/components/MarketServerDetail';
import Pagination from '@/components/ui/Pagination';
const MarketPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const { serverName } = useParams<{ serverName?: string }>();
const { showToast } = useToast();
const {
servers,
allServers,
categories,
tags,
loading,
error,
setError,
searchServers,
filterByCategory,
filterByTag,
selectedCategory,
selectedTag,
installServer,
fetchServerByName,
isServerInstalled,
// Pagination
currentPage,
totalPages,
changePage,
serversPerPage,
changeServersPerPage
} = useMarketData();
const [selectedServer, setSelectedServer] = useState<MarketServer | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [installing, setInstalling] = useState(false);
const [showTags, setShowTags] = useState(false);
// Load server details if a server name is in the URL
useEffect(() => {
const loadServerDetails = async () => {
if (serverName) {
const server = await fetchServerByName(serverName);
if (server) {
setSelectedServer(server);
} else {
// If server not found, navigate back to market page
navigate('/market');
}
} else {
setSelectedServer(null);
}
};
loadServerDetails();
}, [serverName, fetchServerByName, navigate]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
searchServers(searchQuery);
};
const handleCategoryClick = (category: string) => {
filterByCategory(category);
};
const handleTagClick = (tag: string) => {
filterByTag(tag);
};
const handleClearFilters = () => {
setSearchQuery('');
filterByCategory('');
filterByTag('');
};
const handleServerClick = (server: MarketServer) => {
navigate(`/market/${server.name}`);
};
const handleBackToList = () => {
navigate('/market');
};
const handleInstall = async (server: MarketServer) => {
try {
setInstalling(true);
const success = await installServer(server);
if (success) {
// Show success message using toast instead of alert
showToast(t('market.installSuccess', { serverName: server.display_name }), 'success');
}
} finally {
setInstalling(false);
}
};
const handlePageChange = (page: number) => {
changePage(page);
// Scroll to top of page when changing pages
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleChangeItemsPerPage = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newValue = parseInt(e.target.value, 10);
changeServersPerPage(newValue);
};
const toggleTagsVisibility = () => {
setShowTags(!showTags);
};
// Render detailed view if a server is selected
if (selectedServer) {
return (
<MarketServerDetail
server={selectedServer}
onBack={handleBackToList}
onInstall={handleInstall}
installing={installing}
isInstalled={isServerInstalled(selectedServer.name)}
/>
);
}
return (
<div>
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center">
{t('market.title')}
<span className="text-sm text-gray-500 font-normal ml-2">{t('pages.market.title').split(' - ')[1]}</span>
</h1>
</div>
</div>
{error && (
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6">
<div className="flex items-center justify-between">
<p>{error}</p>
<button
onClick={() => setError(null)}
className="text-red-700 hover:text-red-900"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 01.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</div>
</div>
)}
{/* Search bar at the top */}
<div className="bg-white shadow rounded-lg p-6 mb-6">
<form onSubmit={handleSearch} className="flex space-x-4 mb-0">
<div className="flex-grow">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('market.searchPlaceholder')}
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
/>
</div>
<button
type="submit"
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded"
>
{t('market.search')}
</button>
{(searchQuery || selectedCategory || selectedTag) && (
<button
type="button"
onClick={handleClearFilters}
className="border border-gray-300 text-gray-700 font-medium py-2 px-4 rounded hover:bg-gray-50"
>
{t('market.clearFilters')}
</button>
)}
</form>
</div>
<div className="flex flex-col md:flex-row gap-6">
{/* Left sidebar for filters (without search) */}
<div className="md:w-48 flex-shrink-0">
<div className="bg-white shadow rounded-lg p-4 mb-6 sticky top-4">
{/* Categories */}
{categories.length > 0 ? (
<div className="mb-6">
<div className="flex justify-between items-center mb-3">
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
{selectedCategory && (
<span className="text-xs text-blue-600 cursor-pointer hover:underline" onClick={() => filterByCategory('')}>
{t('market.clearCategoryFilter')}
</span>
)}
</div>
<div className="flex flex-col gap-2">
{categories.map((category) => (
<button
key={category}
onClick={() => handleCategoryClick(category)}
className={`px-3 py-2 rounded text-sm text-left ${selectedCategory === category
? 'bg-blue-100 text-blue-800 font-medium'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
}`}
>
{category}
</button>
))}
</div>
</div>
) : loading ? (
<div className="mb-6">
<div className="mb-3">
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
</div>
<div className="flex flex-col gap-2 items-center py-4">
<svg className="animate-spin h-6 w-6 text-blue-500 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p className="text-sm text-gray-600">{t('app.loading')}</p>
</div>
</div>
) : (
<div className="mb-6">
<div className="mb-3">
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
</div>
<p className="text-sm text-gray-600 py-2">{t('market.noCategories')}</p>
</div>
)}
{/* Tags */}
{/* {tags.length > 0 && (
<div className="mb-4">
<div className="flex justify-between items-center mb-3">
<div className="flex items-center">
<h3 className="font-medium text-gray-900">{t('market.tags')}</h3>
<button
onClick={toggleTagsVisibility}
className="ml-2 p-1 text-gray-600 hover:text-blue-600 hover:bg-gray-100 rounded-full"
aria-label={showTags ? t('market.hideTags') : t('market.showTags')}
>
<svg xmlns="http://www.w3.org/2000/svg" className={`h-5 w-5 transition-transform ${showTags ? 'rotate-180' : ''}`} viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M5.293 7.293a1 1 011.414 0L10 10.586l3.293-3.293a1 1 011.414 1.414l-4 4a1 1 01-1.414 0l-4-4a1 1 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</div>
{selectedTag && (
<span className="text-xs text-blue-600 cursor-pointer hover:underline" onClick={() => filterByTag('')}>
{t('market.clearTagFilter')}
</span>
)}
</div>
{showTags && (
<div className="flex flex-wrap gap-2 max-h-48 overflow-y-auto pr-2">
{tags.map((tag) => (
<button
key={tag}
onClick={() => handleTagClick(tag)}
className={`px-2 py-1 rounded text-xs ${selectedTag === tag
? 'bg-green-100 text-green-800 font-medium'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
#{tag}
</button>
))}
</div>
)}
</div>
)} */}
</div>
</div>
{/* Main content area */}
<div className="flex-grow">
{loading ? (
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center">
<div className="flex flex-col items-center">
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p className="text-gray-600">{t('app.loading')}</p>
</div>
</div>
) : servers.length === 0 ? (
<div className="bg-white shadow rounded-lg p-6">
<p className="text-gray-600">{t('market.noServers')}</p>
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-6">
{servers.map((server, index) => (
<MarketServerCard
key={index}
server={server}
onClick={handleServerClick}
/>
))}
</div>
<div className="flex justify-between items-center mb-4">
<div className="text-sm text-gray-500">
{t('market.showing', {
from: (currentPage - 1) * serversPerPage + 1,
to: Math.min(currentPage * serversPerPage, allServers.length),
total: allServers.length
})}
</div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
/>
<div className="flex items-center space-x-2">
<label htmlFor="perPage" className="text-sm text-gray-600">
{t('market.perPage')}:
</label>
<select
id="perPage"
value={serversPerPage}
onChange={handleChangeItemsPerPage}
className="border rounded p-1 text-sm"
>
<option value="6">6</option>
<option value="9">9</option>
<option value="12">12</option>
<option value="24">24</option>
</select>
</div>
</div>
<div className="mt-6">
</div>
</>
)}
</div>
</div>
</div>
);
};
export default MarketPage;

View File

@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Server } from '@/types';
import ServerCard from '@/components/ServerCard';
import AddServerForm from '@/components/AddServerForm';
@@ -8,6 +9,7 @@ import { useServerData } from '@/hooks/useServerData';
const ServersPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const {
servers,
error,
@@ -16,9 +18,11 @@ const ServersPage: React.FC = () => {
handleServerAdd,
handleServerEdit,
handleServerRemove,
handleServerToggle
handleServerToggle,
triggerRefresh
} = useServerData();
const [editingServer, setEditingServer] = useState<Server | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const handleEditClick = async (server: Server) => {
const fullServerData = await handleServerEdit(server);
@@ -29,6 +33,18 @@ const ServersPage: React.FC = () => {
const handleEditComplete = () => {
setEditingServer(null);
triggerRefresh();
};
const handleRefresh = async () => {
setIsRefreshing(true);
try {
triggerRefresh();
// Add a slight delay to make the spinner visible
await new Promise(resolve => setTimeout(resolve, 500));
} finally {
setIsRefreshing(false);
}
};
return (
@@ -36,14 +52,31 @@ const ServersPage: React.FC = () => {
<div className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold text-gray-900">{t('pages.servers.title')}</h1>
<div className="flex space-x-4">
<AddServerForm onAdd={handleServerAdd} />
<button
onClick={() => handleServerAdd()}
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center"
onClick={() => navigate('/market')}
className="px-4 py-2 bg-emerald-100 text-emerald-800 rounded hover:bg-emerald-200 flex items-center"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clipRule="evenodd" />
<path d="M3 1a1 1 0 000 2h1.22l.305 1.222a.997.997 0 00.01.042l1.358 5.43-.893.892C3.74 11.846 4.632 14 6.414 14H15a1 1 0 000-2H6.414l1-1H14a1 1 0 00.894-.553l3-6A1 1 0 0017 3H6.28l-.31-1.243A1 1 0 005 1H3z" />
</svg>
{t('nav.market')}
</button>
<AddServerForm onAdd={handleServerAdd} />
<button
onClick={handleRefresh}
disabled={isRefreshing}
className={`px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center ${isRefreshing ? 'opacity-70 cursor-not-allowed' : ''}`}
>
{isRefreshing ? (
<svg className="animate-spin h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clipRule="evenodd" />
</svg>
)}
{t('common.refresh')}
</button>
</div>

View File

@@ -1,52 +1,146 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import ChangePasswordForm from '@/components/ChangePasswordForm';
import { Switch } from '@/components/ui/ToggleGroup';
import { useSettingsData } from '@/hooks/useSettingsData';
import { useToast } from '@/contexts/ToastContext';
const SettingsPage: React.FC = () => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const { showToast } = useToast();
const [currentLanguage, setCurrentLanguage] = useState(i18n.language);
// Update current language when it changes
useEffect(() => {
setCurrentLanguage(i18n.language);
}, [i18n.language]);
const {
routingConfig,
loading,
updateRoutingConfig
} = useSettingsData();
const [sectionsVisible, setSectionsVisible] = useState({
routingConfig: false,
password: false
});
const toggleSection = (section: 'routingConfig' | 'password') => {
setSectionsVisible(prev => ({
...prev,
[section]: !prev[section]
}));
};
const handleRoutingConfigChange = async (key: 'enableGlobalRoute' | 'enableGroupNameRoute', value: boolean) => {
await updateRoutingConfig(key, value);
};
const handlePasswordChangeSuccess = () => {
setTimeout(() => {
navigate('/');
}, 2000);
};
const handleLanguageChange = (lang: string) => {
localStorage.setItem('i18nextLng', lang);
window.location.reload();
};
return (
<div>
<div className="max-w-4xl mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-8">{t('pages.settings.title')}</h1>
<div className="bg-white shadow rounded-lg p-6">
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('auth.changePassword')}</h2>
<div className="max-w-lg">
<ChangePasswordForm onSuccess={handlePasswordChangeSuccess} />
{/* Language Settings */}
<div className="bg-white shadow rounded-lg p-6 mb-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-800">{t('pages.settings.language')}</h2>
<div className="flex space-x-3">
<button
className={`px-3 py-1.5 rounded-md transition-colors text-sm ${
currentLanguage.startsWith('en')
? 'bg-blue-500 text-white'
: 'bg-blue-100 text-blue-800 hover:bg-blue-200'
}`}
onClick={() => handleLanguageChange('en')}
>
English
</button>
<button
className={`px-3 py-1.5 rounded-md transition-colors text-sm ${
currentLanguage.startsWith('zh')
? 'bg-blue-500 text-white'
: 'bg-blue-100 text-blue-800 hover:bg-blue-200'
}`}
onClick={() => handleLanguageChange('zh')}
>
</button>
</div>
</div>
</div>
{/* 其他设置可以在这里添加 */}
<div className="bg-white shadow rounded-lg p-6 mt-6">
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('pages.settings.language')}</h2>
<div className="flex space-x-4">
<button
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200"
onClick={() => {
localStorage.setItem('i18nextLng', 'en');
window.location.reload();
}}
>
English
</button>
<button
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200"
onClick={() => {
localStorage.setItem('i18nextLng', 'zh');
window.location.reload();
}}
>
</button>
{/* Route Configuration Settings */}
<div className="bg-white shadow rounded-lg p-6 mb-6">
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => toggleSection('routingConfig')}
>
<h2 className="text-xl font-semibold text-gray-800">{t('pages.settings.routeConfig')}</h2>
<span className="text-gray-500">
{sectionsVisible.routingConfig ? '▼' : '►'}
</span>
</div>
{sectionsVisible.routingConfig && (
<div className="space-y-4 mt-4">
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
<div>
<h3 className="font-medium text-gray-700">{t('settings.enableGlobalRoute')}</h3>
<p className="text-sm text-gray-500">{t('settings.enableGlobalRouteDescription')}</p>
</div>
<Switch
disabled={loading}
checked={routingConfig.enableGlobalRoute}
onCheckedChange={(checked) => handleRoutingConfigChange('enableGlobalRoute', checked)}
/>
</div>
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
<div>
<h3 className="font-medium text-gray-700">{t('settings.enableGroupNameRoute')}</h3>
<p className="text-sm text-gray-500">{t('settings.enableGroupNameRouteDescription')}</p>
</div>
<Switch
disabled={loading}
checked={routingConfig.enableGroupNameRoute}
onCheckedChange={(checked) => handleRoutingConfigChange('enableGroupNameRoute', checked)}
/>
</div>
</div>
)}
</div>
{/* Change Password */}
<div className="bg-white shadow rounded-lg p-6 mb-6">
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => toggleSection('password')}
>
<h2 className="text-xl font-semibold text-gray-800">{t('auth.changePassword')}</h2>
<span className="text-gray-500">
{sectionsVisible.password ? '▼' : '►'}
</span>
</div>
{sectionsVisible.password && (
<div className="max-w-lg mt-4">
<ChangePasswordForm onSuccess={handlePasswordChangeSuccess} />
</div>
)}
</div>
</div>
);

View File

@@ -1,6 +1,60 @@
// Server status types
export type ServerStatus = 'connecting' | 'connected' | 'disconnected';
// Market server types
export interface MarketServerRepository {
type: string;
url: string;
}
export interface MarketServerAuthor {
name: string;
}
export interface MarketServerInstallation {
type: string;
command: string;
args: string[];
env?: Record<string, string>;
}
export interface MarketServerArgument {
description: string;
required: boolean;
example: string;
}
export interface MarketServerExample {
title: string;
description: string;
prompt: string;
}
export interface MarketServerTool {
name: string;
description: string;
inputSchema: Record<string, any>;
}
export interface MarketServer {
name: string;
display_name: string;
description: string;
repository: MarketServerRepository;
homepage: string;
author: MarketServerAuthor;
license: string;
categories: string[];
tags: string[];
examples: MarketServerExample[];
installations: {
[key: string]: MarketServerInstallation;
};
arguments: Record<string, MarketServerArgument>;
tools: MarketServerTool[];
is_official?: boolean;
}
// Tool input schema types
export interface ToolInputSchema {
type: string;
@@ -28,6 +82,7 @@ export interface ServerConfig {
export interface Server {
name: string;
status: ServerStatus;
error?: string;
tools?: Tool[];
config?: ServerConfig;
enabled?: boolean;

View File

@@ -1,6 +1,6 @@
{
"mcpServers": {
"amap-maps": {
"amap": {
"command": "npx",
"args": [
"-y",

74722
servers.json Normal file

File diff suppressed because one or more lines are too long

View File

@@ -7,6 +7,7 @@ dotenv.config();
const defaultConfig = {
port: process.env.PORT || 3000,
initTimeout: process.env.INIT_TIMEOUT || 300000,
timeout: process.env.REQUEST_TIMEOUT || 60000,
mcpHubName: 'mcphub',
mcpHubVersion: '0.0.1',
@@ -42,4 +43,4 @@ export const expandEnvVars = (value: string): string => {
return value.replace(/\$\{([^}]+)\}/g, (_, key) => process.env[key] || '');
};
export default defaultConfig;
export default defaultConfig;

View File

@@ -2,7 +2,7 @@ import { Request, Response } from 'express';
import { ApiResponse } from '../types/index.js';
import {
getAllGroups,
getGroupById,
getGroupByIdOrName,
createGroup,
updateGroup,
updateGroupServers,
@@ -41,7 +41,7 @@ export const getGroup = (req: Request, res: Response): void => {
return;
}
const group = getGroupById(id);
const group = getGroupByIdOrName(id);
if (!group) {
res.status(404).json({
success: false,
@@ -318,7 +318,7 @@ export const getGroupServers = (req: Request, res: Response): void => {
return;
}
const group = getGroupById(id);
const group = getGroupByIdOrName(id);
if (!group) {
res.status(404).json({
success: false,

View File

@@ -0,0 +1,154 @@
import { Request, Response } from 'express';
import { ApiResponse } from '../types/index.js';
import {
getMarketServers,
getMarketServerByName,
getMarketCategories,
getMarketTags,
searchMarketServers,
filterMarketServersByCategory,
filterMarketServersByTag
} from '../services/marketService.js';
// Get all market servers
export const getAllMarketServers = (_: Request, res: Response): void => {
try {
const marketServers = Object.values(getMarketServers());
const response: ApiResponse = {
success: true,
data: marketServers,
};
res.json(response);
} catch (error) {
res.status(500).json({
success: false,
message: 'Failed to get market servers information',
});
}
};
// Get a specific market server by name
export const getMarketServer = (req: Request, res: Response): void => {
try {
const { name } = req.params;
if (!name) {
res.status(400).json({
success: false,
message: 'Server name is required',
});
return;
}
const server = getMarketServerByName(name);
if (!server) {
res.status(404).json({
success: false,
message: 'Market server not found',
});
return;
}
const response: ApiResponse = {
success: true,
data: server,
};
res.json(response);
} catch (error) {
res.status(500).json({
success: false,
message: 'Failed to get market server information',
});
}
};
// Get all market categories
export const getAllMarketCategories = (_: Request, res: Response): void => {
try {
const categories = getMarketCategories();
const response: ApiResponse = {
success: true,
data: categories,
};
res.json(response);
} catch (error) {
res.status(500).json({
success: false,
message: 'Failed to get market categories',
});
}
};
// Get all market tags
export const getAllMarketTags = (_: Request, res: Response): void => {
try {
const tags = getMarketTags();
const response: ApiResponse = {
success: true,
data: tags,
};
res.json(response);
} catch (error) {
res.status(500).json({
success: false,
message: 'Failed to get market tags',
});
}
};
// Search market servers
export const searchMarketServersByQuery = (req: Request, res: Response): void => {
try {
const { query } = req.query;
const searchQuery = typeof query === 'string' ? query : '';
const servers = searchMarketServers(searchQuery);
const response: ApiResponse = {
success: true,
data: servers,
};
res.json(response);
} catch (error) {
res.status(500).json({
success: false,
message: 'Failed to search market servers',
});
}
};
// Filter market servers by category
export const getMarketServersByCategory = (req: Request, res: Response): void => {
try {
const { category } = req.params;
const servers = filterMarketServersByCategory(category);
const response: ApiResponse = {
success: true,
data: servers,
};
res.json(response);
} catch (error) {
res.status(500).json({
success: false,
message: 'Failed to filter market servers by category',
});
}
};
// Filter market servers by tag
export const getMarketServersByTag = (req: Request, res: Response): void => {
try {
const { tag } = req.params;
const servers = filterMarketServersByTag(tag);
const response: ApiResponse = {
success: true,
data: servers,
};
res.json(response);
} catch (error) {
res.status(500).json({
success: false,
message: 'Failed to filter market servers by tag',
});
}
};

View File

@@ -8,7 +8,7 @@ import {
notifyToolChanged,
toggleServerStatus,
} from '../services/mcpService.js';
import { loadSettings } from '../config/index.js';
import { loadSettings, saveSettings } from '../config/index.js';
export const getAllServers = (_: Request, res: Response): void => {
try {
@@ -244,3 +244,60 @@ export const toggleServer = async (req: Request, res: Response): Promise<void> =
});
}
};
export const updateSystemConfig = (req: Request, res: Response): void => {
try {
const { routing } = req.body;
if (!routing || (typeof routing.enableGlobalRoute !== 'boolean' && typeof routing.enableGroupNameRoute !== 'boolean')) {
res.status(400).json({
success: false,
message: 'Invalid system configuration provided',
});
return;
}
const settings = loadSettings();
if (!settings.systemConfig) {
settings.systemConfig = {
routing: {
enableGlobalRoute: true,
enableGroupNameRoute: true
}
};
}
if (!settings.systemConfig.routing) {
settings.systemConfig.routing = {
enableGlobalRoute: true,
enableGroupNameRoute: true
};
}
if (typeof routing.enableGlobalRoute === 'boolean') {
settings.systemConfig.routing.enableGlobalRoute = routing.enableGlobalRoute;
}
if (typeof routing.enableGroupNameRoute === 'boolean') {
settings.systemConfig.routing.enableGroupNameRoute = routing.enableGroupNameRoute;
}
if (saveSettings(settings)) {
res.json({
success: true,
data: settings.systemConfig,
message: 'System configuration updated successfully',
});
} else {
res.status(500).json({
success: false,
message: 'Failed to save system configuration',
});
}
} catch (error) {
res.status(500).json({
success: false,
message: 'Internal server error',
});
}
};

View File

@@ -1,6 +1,5 @@
import express from 'express';
import { check } from 'express-validator';
import path from 'path';
import {
getAllServers,
getAllSettings,
@@ -8,6 +7,7 @@ import {
updateServer,
deleteServer,
toggleServer,
updateSystemConfig
} from '../controllers/serverController.js';
import {
getGroups,
@@ -20,6 +20,15 @@ import {
getGroupServers,
updateGroupServersBatch
} from '../controllers/groupController.js';
import {
getAllMarketServers,
getMarketServer,
getAllMarketCategories,
getAllMarketTags,
searchMarketServersByQuery,
getMarketServersByCategory,
getMarketServersByTag
} from '../controllers/marketController.js';
import {
login,
register,
@@ -38,6 +47,7 @@ export const initRoutes = (app: express.Application): void => {
router.put('/servers/:name', updateServer);
router.delete('/servers/:name', deleteServer);
router.post('/servers/:name/toggle', toggleServer);
router.put('/system-config', updateSystemConfig);
// Group management routes
router.get('/groups', getGroups);
@@ -51,6 +61,15 @@ export const initRoutes = (app: express.Application): void => {
// New route for batch updating servers in a group
router.put('/groups/:id/servers/batch', updateGroupServersBatch);
// Market routes
router.get('/market/servers', getAllMarketServers);
router.get('/market/servers/search', searchMarketServersByQuery);
router.get('/market/servers/:name', getMarketServer);
router.get('/market/categories', getAllMarketCategories);
router.get('/market/categories/:category', getMarketServersByCategory);
router.get('/market/tags', getAllMarketTags);
router.get('/market/tags/:tag', getMarketServersByTag);
// Auth routes (these will NOT be protected by auth middleware)
app.post('/auth/login', [
check('username', 'Username is required').not().isEmpty(),
@@ -72,10 +91,6 @@ 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,5 +1,6 @@
import express from 'express';
import config from './config/index.js';
import path from 'path';
import { initMcpServer } from './services/mcpService.js';
import { initMiddlewares } from './middlewares/index.js';
import { initRoutes } from './routes/index.js';
@@ -31,12 +32,17 @@ export class AppServer {
initMcpServer(config.mcpHubName, config.mcpHubVersion)
.then(() => {
console.log('MCP server initialized successfully');
this.app.get('/sse/:groupId?', (req, res) => handleSseConnection(req, res));
this.app.get('/sse/:group?', (req, res) => handleSseConnection(req, res));
this.app.post('/messages', handleSseMessage);
})
.catch((error) => {
console.error('Error initializing MCP server:', error);
throw error;
})
.finally(() => {
this.app.get('*', (_req, res) => {
res.sendFile(path.join(process.cwd(), 'frontend', 'dist', 'index.html'));
});
});
} catch (error) {
console.error('Error initializing server:', error);

View File

@@ -9,10 +9,19 @@ export const getAllGroups = (): IGroup[] => {
return settings.groups || [];
};
// Get group by ID
export const getGroupById = (id: string): IGroup | undefined => {
// Get group by ID or name
export const getGroupByIdOrName = (key: string): IGroup | undefined => {
const settings = loadSettings();
const routingConfig = settings.systemConfig?.routing || {
enableGlobalRoute: true,
enableGroupNameRoute: true,
};
const groups = getAllGroups();
return groups.find((group) => group.id === id);
return (
groups.find(
(group) => group.id === key || (group.name === key && routingConfig.enableGroupNameRoute),
) || undefined
);
};
// Create a new group
@@ -218,6 +227,6 @@ export const removeServerFromGroup = (groupId: string, serverName: string): IGro
// Get all servers in a group
export const getServersInGroup = (groupId: string): string[] => {
const group = getGroupById(groupId);
const group = getGroupByIdOrName(groupId);
return group ? group.servers : [];
};

View File

@@ -0,0 +1,116 @@
import fs from 'fs';
import path from 'path';
import { MarketServer } from '../types/index.js';
// Get path to the servers.json file
export const getServersJsonPath = (): string => {
return path.resolve(process.cwd(), 'servers.json');
};
// Load all market servers from servers.json
export const getMarketServers = (): Record<string, MarketServer> => {
try {
const serversJsonPath = getServersJsonPath();
const data = fs.readFileSync(serversJsonPath, 'utf8');
const serversObj = JSON.parse(data) as Record<string, MarketServer>;
const sortedEntries = Object.entries(serversObj).sort(([, serverA], [, serverB]) => {
if (serverA.is_official && !serverB.is_official) return -1;
if (!serverA.is_official && serverB.is_official) return 1;
return 0;
});
return Object.fromEntries(sortedEntries);
} catch (error) {
console.error('Failed to load servers from servers.json:', error);
return {};
}
};
// Get a specific market server by name
export const getMarketServerByName = (name: string): MarketServer | null => {
const servers = getMarketServers();
return servers[name] || null;
};
// Get all categories from market servers
export const getMarketCategories = (): string[] => {
const servers = getMarketServers();
const categories = new Set<string>();
Object.values(servers).forEach((server) => {
server.categories?.forEach((category) => {
categories.add(category);
});
});
return Array.from(categories).sort();
};
// Get all tags from market servers
export const getMarketTags = (): string[] => {
const servers = getMarketServers();
const tags = new Set<string>();
Object.values(servers).forEach((server) => {
server.tags?.forEach((tag) => {
tags.add(tag);
});
});
return Array.from(tags).sort();
};
// Search market servers by query
export const searchMarketServers = (query: string): MarketServer[] => {
const servers = getMarketServers();
const searchTerms = query
.toLowerCase()
.split(' ')
.filter((term) => term.length > 0);
if (searchTerms.length === 0) {
return Object.values(servers);
}
return Object.values(servers).filter((server) => {
// Search in name, display_name, description, categories, and tags
const searchableText = [
server.name,
server.display_name,
server.description,
...(server.categories || []),
...(server.tags || []),
]
.join(' ')
.toLowerCase();
return searchTerms.some((term) => searchableText.includes(term));
});
};
// Filter market servers by category
export const filterMarketServersByCategory = (category: string): MarketServer[] => {
const servers = getMarketServers();
if (!category) {
return Object.values(servers);
}
return Object.values(servers).filter((server) => {
return server.categories?.includes(category);
});
};
// Filter market servers by tag
export const filterMarketServersByTag = (tag: string): MarketServer[] => {
const servers = getMarketServers();
if (!tag) {
return Object.values(servers);
}
return Object.values(servers).filter((server) => {
return server.tags?.includes(tag);
});
};

View File

@@ -6,15 +6,14 @@ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
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 { getGroup } from './sseService.js';
import { getServersInGroup } from './groupService.js';
let currentServer: Server;
export const initMcpServer = async (name: string, version: string): Promise<void> => {
currentServer = createMcpServer(name, version);
await registerAllTools(currentServer, true);
await registerAllTools(currentServer, true, true);
};
export const setMcpServer = (server: Server): void => {
@@ -26,11 +25,11 @@ export const getMcpServer = (): Server => {
};
export const notifyToolChanged = async () => {
await registerAllTools(currentServer, true);
await registerAllTools(currentServer, true, false);
currentServer
.sendToolListChanged()
.catch((error) => {
console.error('Failed to send tool list changed notification:', error);
console.warn('Failed to send tool list changed notification:', error.message);
})
.then(() => {
console.log('Tool list changed notification sent successfully');
@@ -41,7 +40,7 @@ export const notifyToolChanged = async () => {
let serverInfos: ServerInfo[] = [];
// Initialize MCP server clients
export const initializeClientsFromSettings = (): ServerInfo[] => {
export const initializeClientsFromSettings = (isInit: boolean): ServerInfo[] => {
const settings = loadSettings();
const existingServerInfos = serverInfos;
serverInfos = [];
@@ -53,6 +52,7 @@ export const initializeClientsFromSettings = (): ServerInfo[] => {
serverInfos.push({
name,
status: 'disconnected',
error: null,
tools: [],
createTime: Date.now(),
enabled: false,
@@ -89,6 +89,7 @@ export const initializeClientsFromSettings = (): ServerInfo[] => {
serverInfos.push({
name,
status: 'disconnected',
error: 'Missing required configuration',
tools: [],
createTime: Date.now(),
});
@@ -108,16 +109,55 @@ 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 = getServerByName(name);
if (serverInfo) {
serverInfo.status = 'disconnected';
}
});
const timeout = isInit ? Number(config.initTimeout) : Number(config.timeout);
client
.connect(transport, { timeout: timeout })
.then(() => {
console.log(`Successfully connected client for server: ${name}`);
client
.listTools({}, { timeout: timeout })
.then((tools) => {
console.log(`Successfully listed ${tools.tools.length} tools for server: ${name}`);
const serverInfo = getServerByName(name);
if (!serverInfo) {
console.warn(`Server info not found for server: ${name}`);
return;
}
serverInfo.tools = tools.tools.map((tool) => ({
name: tool.name,
description: tool.description || '',
inputSchema: tool.inputSchema || {},
}));
serverInfo.status = 'connected';
serverInfo.error = null;
})
.catch((error) => {
console.error(
`Failed to list tools for server ${name} by error: ${error} with stack: ${error.stack}`,
);
const serverInfo = getServerByName(name);
if (serverInfo) {
serverInfo.status = 'disconnected';
serverInfo.error = `Failed to list tools: ${error.stack} `;
}
});
})
.catch((error) => {
console.error(
`Failed to connect client for server ${name} by error: ${error} with stack: ${error.stack}`,
);
const serverInfo = getServerByName(name);
if (serverInfo) {
serverInfo.status = 'disconnected';
serverInfo.error = `Failed to connect: ${error.stack} `;
}
});
serverInfos.push({
name,
status: 'connecting',
error: null,
tools: [],
client,
transport,
@@ -130,42 +170,24 @@ export const initializeClientsFromSettings = (): ServerInfo[] => {
};
// Register all MCP tools
export const registerAllTools = async (server: Server, forceInit: boolean): Promise<void> => {
initializeClientsFromSettings();
for (const serverInfo of serverInfos) {
if (serverInfo.status === 'connected' && !forceInit) continue;
if (!serverInfo.client || !serverInfo.transport) continue;
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 || {},
}));
serverInfo.status = 'connected';
console.log(`Successfully connected to server: ${serverInfo.name}`);
} catch (error) {
console.error(
`Failed to connect to server for client: ${serverInfo.name} by error: ${error}`,
);
serverInfo.status = 'disconnected';
}
}
export const registerAllTools = async (
server: Server,
forceInit: boolean,
isInit: boolean,
): Promise<void> => {
initializeClientsFromSettings(isInit);
};
// Get all server information
export const getServersInfo = (): Omit<ServerInfo, 'client' | 'transport'>[] => {
const settings = loadSettings();
const infos = serverInfos.map(({ name, status, tools, createTime }) => {
const infos = serverInfos.map(({ name, status, tools, createTime, error }) => {
const serverConfig = settings.mcpServers[name];
const enabled = serverConfig ? serverConfig.enabled !== false : true;
return {
name,
status,
error,
tools,
createTime,
enabled,
@@ -204,7 +226,7 @@ export const addServer = async (
return { success: false, message: 'Failed to save settings' };
}
registerAllTools(currentServer, false);
registerAllTools(currentServer, false, false);
return { success: true, message: 'Server added successfully' };
} catch (error) {
console.error(`Failed to add server: ${name}`, error);
@@ -316,12 +338,12 @@ 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 group = getGroup(sessionId);
console.log(`Handling ListToolsRequest for group: ${group}`);
const allServerInfos = serverInfos.filter((serverInfo) => {
if (serverInfo.enabled === false) return false;
if (!groupId) return true;
const serversInGroup = getServersInGroup(groupId);
if (!group) return true;
const serversInGroup = getServersInGroup(group);
return serversInGroup.includes(serverInfo.name);
});

View File

@@ -1,33 +1,48 @@
import { Request, Response } from 'express';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { getMcpServer } from './mcpService.js';
import { loadSettings } from '../config/index.js';
const transports: { [sessionId: string]: { transport: SSEServerTransport; groupId: string } } = {};
const transports: { [sessionId: string]: { transport: SSEServerTransport; group: string } } = {};
export const getGroupId = (sessionId: string): string => {
return transports[sessionId]?.groupId || '';
export const getGroup = (sessionId: string): string => {
return transports[sessionId]?.group || '';
};
export const handleSseConnection = async (req: Request, res: Response): Promise<void> => {
const settings = loadSettings();
const routingConfig = settings.systemConfig?.routing || {
enableGlobalRoute: true,
enableGroupNameRoute: true,
};
const group = req.params.group;
// Check if this is a global route (no group) and if it's allowed
if (!group && !routingConfig.enableGlobalRoute) {
res.status(403).send('Global routes are disabled. Please specify a group ID.');
return;
}
const transport = new SSEServerTransport('/messages', res);
const groupId = req.params.groupId;
transports[transport.sessionId] = { transport, groupId };
transports[transport.sessionId] = { transport, group: group };
res.on('close', () => {
delete transports[transport.sessionId];
console.log(`SSE connection closed: ${transport.sessionId}`);
});
console.log(`New SSE connection established: ${transport.sessionId}`);
console.log(
`New SSE connection established: ${transport.sessionId} with group: ${group || 'global'}`,
);
await getMcpServer().connect(transport);
};
export const handleSseMessage = async (req: Request, res: Response): Promise<void> => {
const sessionId = req.query.sessionId as string;
const { transport, groupId } = transports[sessionId];
req.params.groupId = groupId;
req.query.groupId = groupId;
console.log(`Received message for sessionId: ${sessionId} in groupId: ${groupId}`);
const { transport, group } = transports[sessionId];
req.params.group = group;
req.query.group = group;
console.log(`Received message for sessionId: ${sessionId} in group: ${group}`);
if (transport) {
await transport.handlePostMessage(req, res);
} else {

View File

@@ -17,6 +17,60 @@ export interface IGroup {
servers: string[]; // Array of server names that belong to this group
}
// Market server types
export interface MarketServerRepository {
type: string;
url: string;
}
export interface MarketServerAuthor {
name: string;
}
export interface MarketServerInstallation {
type: string;
command: string;
args: string[];
env?: Record<string, string>;
}
export interface MarketServerArgument {
description: string;
required: boolean;
example: string;
}
export interface MarketServerExample {
title: string;
description: string;
prompt: string;
}
export interface MarketServerTool {
name: string;
description: string;
inputSchema: Record<string, any>;
}
export interface MarketServer {
name: string;
display_name: string;
description: string;
repository: MarketServerRepository;
homepage: string;
author: MarketServerAuthor;
license: string;
categories: string[];
tags: string[];
examples: MarketServerExample[];
installations: {
[key: string]: MarketServerInstallation;
};
arguments: Record<string, MarketServerArgument>;
tools: MarketServerTool[];
is_official?: boolean;
}
// Represents the settings for MCP servers
export interface McpSettings {
users?: IUser[]; // Array of user credentials and permissions
@@ -24,6 +78,13 @@ export interface McpSettings {
[key: string]: ServerConfig; // Key-value pairs of server names and their configurations
};
groups?: IGroup[]; // Array of server groups
systemConfig?: {
routing?: {
enableGlobalRoute?: boolean; // Controls whether the /sse endpoint without group is enabled
enableGroupNameRoute?: boolean; // Controls whether group routing by name is allowed
};
// Add other system configuration sections here in the future
};
}
// Configuration details for an individual server
@@ -39,6 +100,7 @@ export interface ServerConfig {
export interface ServerInfo {
name: string; // Unique name of the server
status: 'connected' | 'connecting' | 'disconnected'; // Current connection status
error: string | null; // Error message if any
tools: ToolInfo[]; // List of tools available on the server
client?: Client; // Client instance for communication
transport?: SSEClientTransport | StdioClientTransport; // Transport mechanism used