Compare commits

...

7 Commits

30 changed files with 866 additions and 218 deletions

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/*

View File

@@ -136,9 +136,9 @@ pnpm dev
- Bug 报告与修复
- 翻译与建议
欢迎加入企微交流共建群
欢迎加入企微交流共建群,由于群人数限制,有兴趣的同学可以扫码添加管理员为好友后拉入群聊。
<img src="assets/wegroup.png" width="500">
<img src="assets/wexin.png" width="350">
## 📄 许可证

BIN
assets/wexin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

View File

@@ -4,8 +4,6 @@
现代 AI 应用场景中将大模型LLM与各种数据源和工具无缝对接往往需要手动编写大量胶水代码并且无法快速复用。MCPModel Context Protocol协议由 Anthropic 在 2024 年开源旨在提供类似“USBC”接口般的标准化通信方式简化 AI 助手与内容仓库、业务系统等的集成流程。然而MCP 服务器部署常常需要大量环境依赖、手动配置及持续运行开发者常因安装和配置耗费大量时间和精力。MCPHub 作为一款开源的一站式聚合平台,通过直观的 Web UI、Docker 镜像和热插拔配置,实现本地或容器里的“一键安装”与“分组路由”,大幅降低 MCP 服务器的使用门槛和运维成本​。
尽管目前各家平台都在陆续推出各类 MCP 云服务但在数据隐私、合规性和定制化需求日益增长的背景下MCPHub 仍然是一个值得关注的本地部署解决方案​。本文将深入探讨 MCPHub 的设计理念、使用场景和技术架构,帮助开发者快速上手并充分利用这一强大工具​。
## MCPHub 是什么
### MCP 协议简介
@@ -14,7 +12,7 @@ Model Context ProtocolMCP是一种开放标准类似“USBC”接口
### MCPHub 项目概览
MCPHub 是一个统一的 MCP 服务器聚合平台,内置 MCP 服务器市场实现一键安装。前端基于 React、Vite 和 Tailwind CSS 构建,后端兼容任意使用 npx 或 uvx 命令启动的服务器。它通过一个集中式 Dashboard 实时展示各服务器的运行状态,并支持在运行时热插拔增删改服务器配置,无需停机维护。支持分组式访问控制,可以通过独立的 SSE 端点访问不同的 MCP 服务器组合,管理员可灵活定义不同团队或环境的权限策略。官方提供 Docker 镜像,仅需一条命令即可快速启动本地或云端服务。
MCPHub 是一个统一的 MCP 服务器聚合平台,内置 MCP 服务器市场实现一键安装。前端基于 React、Vite 和 Tailwind CSS 构建,后端兼容任意使用 npx 或 uvx 命令启动的 MCP 服务器。它通过一个集中式 Dashboard 实时展示各服务器的运行状态,并支持在运行时热插拔增删改服务器配置,无需停机维护。支持分组式访问控制,可以通过独立的 SSE 端点访问不同的 MCP 服务器组合,管理员可灵活定义不同团队或环境的权限策略。官方提供 Docker 镜像,仅需一条命令即可快速启动本地或云端服务。
![MCPHub 控制面板](../assets/dashboard.zh.png)
@@ -23,10 +21,10 @@ MCPHub 是一个统一的 MCP 服务器聚合平台,内置 MCP 服务器市场
### 1. 复杂的环境依赖与配置
- MCP 服务器常依赖 Node.js、Python 等多种运行时,需手动维护大量命令、参数和环境变量。
- MCPHub 内置 MCP 服务器市场,包含多常用 MCP 服务器,支持一键安装和自动配置,简化了环境搭建过程。
- MCPHub 内置 MCP 服务器市场,包含多常用 MCP 服务器,支持一键安装和自动配置,简化了环境搭建过程。
- 通过 Docker 部署MCPHub 可在任何支持 Docker 的平台上运行,避免了环境不一致的问题。
![MCPHub 市场](../assets/market.zh.png)
![MCPHub 市场](../assets/market.zh.png)
### 2. 持续运行的服务压力
@@ -35,10 +33,10 @@ MCPHub 是一个统一的 MCP 服务器聚合平台,内置 MCP 服务器市场
### 3. 路由与分组管理缺乏统一视图
- 传统方式下,很难可视化地将不同 MCP 服务按场景分类,容易造成请求混淆和性能瓶颈
- 传统方式下,很难可视化地将不同 MCP 服务按场景分类,容易造成 token 浪费和工具选择精度下降
- MCPHub 支持动态创建分组(如“地图检索”、“网页自动化”、“聊天”等),为每个分组生成独立的 SSE 端点,实现各类用例的隔离与优化。
![MCPHub 分组](../assets/group.zh.png)
![MCPHub 分组](../assets/group.zh.png)
## 如何使用 MCPHub
@@ -48,7 +46,7 @@ MCPHub 是一个统一的 MCP 服务器聚合平台,内置 MCP 服务器市场
docker run -p 3000:3000 samanhappy/mcphub
```
这样就可以在本地快速启动 MCPHub默认监听 3000 端口。
一条命令就可以在本地快速启动 MCPHub默认监听 3000 端口。
MCPHub 使用`mcp_settings.json`保存所有服务器、分组和用户的配置。你可以创建一个 `mcp_settings.json` 文件,并将其挂载到 Docker 容器中,以便在重启时保留配置。
@@ -115,7 +113,7 @@ http://localhost:3000/sse
http://localhost:3000/sse/{groupId}
```
其中 `{groupId}` 是分组的唯一标识符,可以从控制台获取。比如我创建了一个名为 `map` 的分组,选择了 `amap``sequential-thinking` 两个服务器,那么可以通过以下 URL 访问这个分组的 SSE 端点:
其中 `{groupId}` 是分组的唯一标识符,可以从控制台获取。比如我在上面的截图中创建了一个名为 `map` 的分组,选择了 `amap``sequential-thinking` 两个服务器,那么可以通过以下 URL 访问这个分组的 SSE 端点:
```
http://localhost:3000/sse/a800bef7-c4c1-4460-9557-5f4404cdd0bd
@@ -135,17 +133,17 @@ http://localhost:3000/sse/a800bef7-c4c1-4460-9557-5f4404cdd0bd
}
```
配置完成后,可以从 `Cursor` 中看到所有可用的 MCP 服务器,并可以直接使用它们
配置完成后,可以从 `Cursor` 中看到所有可用的 MCP 服务器工具列表
![Cursor 中的 MCPHub 分组](../assets/cursor-mcp.png)
![Cursor 中的 MCP 配置](../assets/cursor-mcp.png)
然后,我们可以测试一下,比如输入:深度思考一下,帮我制定一个五一假期从南京出发的自驾行出游计划,要求避开拥堵路线,结合天气情况,并且可以体验到不同的自然风光。
![Cursor 中的 MCPHub 分组](../assets/cursor-query.png)
![Cursor 中的测试输入](../assets/cursor-query.png)
接着可以看到,`Cursor` 在运行过程中调用了多个工具。
![Cursor 中的 MCPHub 分组](../assets/cursor-tools.png)
![Cursor 中的工具调用](../assets/cursor-tools.png)
最终生成结果如下:
@@ -221,11 +219,13 @@ http://localhost:3000/sse/a800bef7-c4c1-4460-9557-5f4404cdd0bd
既避开了主要拥堵路段,又能欣赏到不同的自然风光。
```
结果中可以看到,`Cursor` 通过调用 `amap``sequential-thinking` 两个服务器,成功生成了一个五一假期的自驾游行程方案,并且避开了拥堵路线,结合了天气情况。但是细心的同学可能发现,计划中的开始时间是 4 月 29 日,而今年的五一假期是 5 月 1 日开始的,产生偏差的原因是 `sequential-thinking` 使用了错误的假期时间。如何解决这个问题呢?我们可以尝试在分组中添加支持搜索的 MCP 服务器,这样就可以在查询时自动纠正错误的假期时间了,具体就不在这里展开了。
可以看到,`Cursor` 通过调用 `amap``sequential-thinking` 两个服务器,成功生成了一个五一假期的自驾游行程方案,并且避开了拥堵路线,结合了天气情况。但是细心的同学可能发现,计划中的开始时间是 4 月 29 日,而今年的五一假期是 5 月 1 日开始的,产生偏差的原因是 `sequential-thinking` 使用了错误的假期时间。如何解决这个问题呢?我们可以尝试在分组中添加支持搜索的 MCP 服务器,这样就可以在查询时自动纠正错误的假期时间了,具体就不在这里展开了。
## 结语
MCPHub 将本地部署、一键安装、分组路由和可视化管理融为一体,以简洁而强大的设计,彻底解决了 MCP 服务器的部署、配置与运维难题。无论是追求快速验证的开发者,还是需要稳定可靠 AI 工具链的企业用户,都能通过 MCPHub 专注于核心业务与创新,而无需被底层细节所困扰。立即体验 MCPHub开启高效易用的 MCP 服务器管理之旅!
MCPHub 将本地部署、一键安装、分组路由和可视化管理融为一体,以简洁而强大的设计,彻底解决了 MCP 服务器的部署、配置与运维难题。无论是追求快速验证的开发者,还是需要稳定可靠 AI 工具链的企业用户,都能通过 MCPHub 专注于核心业务与创新,而无需被底层细节所困扰。
尽管目前各家平台都在陆续推出各类 MCP 云服务但在数据隐私、合规性和定制化需求日益增长的背景下MCPHub 仍然是一个值得关注的本地部署解决方案​。
MCPHub 只是我一时兴起开发的小项目,没想到竟收获了这么多关注,非常感谢大家的支持!目前 MCPHub 还有不少地方需要优化和完善我也专门建了个交流群方便大家交流反馈。如果你也对这个项目感兴趣欢迎一起参与建设项目地址为https://github.com/samanhappy/mcphub。

View File

@@ -10,37 +10,37 @@ interface MarketServerCardProps {
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 };
}
// 估计卡片内单行可用宽度(以字符为单位)
const estimatedAvailableWidth = 30; // 估计一行可以容纳的字符数
// 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,
@@ -50,16 +50,16 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
}
}
// 如果没有足够空间显示任何标签,至少显示一个
// If there's not enough space to display any tags, show at least one
if (i === 0 && sortedTags.length > 0) {
i = 1;
}
// 计算"更多"标签所需的空间
// Calculate space needed for the "more" tag
const moreCount = sortedTags.length - i;
const moreTagWidth = 3 + String(moreCount).length + t('market.moreTags').length;
// 如果剩余空间足够显示"更多"标签
// If there's enough remaining space to display the "more" tag
if (totalWidth + moreTagWidth <= estimatedAvailableWidth || i < 1) {
return {
tagsToShow: sortedTags.slice(0, i),
@@ -68,7 +68,7 @@ const MarketServerCard: React.FC<MarketServerCardProps> = ({ server, onClick })
};
}
// 如果连"更多"标签都放不下,减少一个标签以腾出空间
// 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,

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: '/',

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

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

@@ -68,7 +68,9 @@
"namePlaceholder": "Enter server name",
"urlPlaceholder": "Enter server URL",
"commandPlaceholder": "Enter command",
"argumentsPlaceholder": "Enter arguments"
"argumentsPlaceholder": "Enter arguments",
"errorDetails": "Error Details",
"viewErrorDetails": "View error details"
},
"status": {
"online": "Online",
@@ -83,7 +85,9 @@
"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...",
"serverInstall": "Failed to install server"
"serverInstall": "Failed to install server",
"failedToFetchSettings": "Failed to fetch settings",
"failedToUpdateRouteConfig": "Failed to update route configuration"
},
"common": {
"processing": "Processing...",
@@ -93,7 +97,9 @@
"create": "Create",
"submitting": "Submitting...",
"delete": "Delete",
"copy": "Copy"
"copy": "Copy",
"copySuccess": "Copied to clipboard",
"copyFailed": "Copy failed"
},
"nav": {
"dashboard": "Dashboard",
@@ -123,7 +129,8 @@
"language": "Language",
"account": "Account Settings",
"password": "Change Password",
"appearance": "Appearance"
"appearance": "Appearance",
"routeConfig": "Route Configuration"
},
"market": {
"title": "Server Market - (Data from mcpm.sh)"
@@ -196,5 +203,12 @@
"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

@@ -68,7 +68,9 @@
"namePlaceholder": "请输入服务器名称",
"urlPlaceholder": "请输入服务器URL",
"commandPlaceholder": "请输入命令",
"argumentsPlaceholder": "请输入参数"
"argumentsPlaceholder": "请输入参数",
"errorDetails": "错误详情",
"viewErrorDetails": "查看错误详情"
},
"status": {
"online": "在线",
@@ -83,7 +85,9 @@
"serverUpdate": "编辑服务器 {{serverName}} 失败,请检查服务器状态",
"serverFetch": "获取服务器数据失败,请稍后重试",
"initialStartup": "服务器可能正在启动中。首次启动可能需要一些时间,请耐心等候...",
"serverInstall": "安装服务器失败"
"serverInstall": "安装服务器失败",
"failedToFetchSettings": "获取设置失败",
"failedToUpdateSystemConfig": "更新系统配置失败"
},
"common": {
"processing": "处理中...",
@@ -93,7 +97,9 @@
"create": "创建",
"submitting": "提交中...",
"delete": "删除",
"copy": "复制"
"copy": "复制",
"copySuccess": "已复制到剪贴板",
"copyFailed": "复制失败"
},
"nav": {
"dashboard": "仪表盘",
@@ -120,7 +126,8 @@
"language": "语言",
"account": "账户设置",
"password": "修改密码",
"appearance": "外观"
"appearance": "外观",
"routeConfig": "路由配置"
},
"groups": {
"title": "分组管理"
@@ -196,5 +203,12 @@
"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

@@ -193,7 +193,7 @@ const MarketPage: React.FC = () => {
<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 && (
{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>
@@ -218,6 +218,26 @@ const MarketPage: React.FC = () => {
))}
</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 */}

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

@@ -82,6 +82,7 @@ export interface ServerConfig {
export interface Server {
name: string;
status: ServerStatus;
error?: string;
tools?: Tool[];
config?: ServerConfig;
enabled?: boolean;

View File

@@ -23,7 +23,7 @@
"author": "",
"license": "ISC",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.9.0",
"@modelcontextprotocol/sdk": "^1.10.2",
"@radix-ui/react-accordion": "^1.2.3",
"@radix-ui/react-slot": "^1.1.2",
"@shadcn/ui": "^0.0.4",

10
pnpm-lock.yaml generated
View File

@@ -9,8 +9,8 @@ importers:
.:
dependencies:
'@modelcontextprotocol/sdk':
specifier: ^1.9.0
version: 1.9.0
specifier: ^1.10.2
version: 1.10.2
'@radix-ui/react-accordion':
specifier: ^1.2.3
version: 1.2.3(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -867,8 +867,8 @@ packages:
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
'@modelcontextprotocol/sdk@1.9.0':
resolution: {integrity: sha512-Jq2EUCQpe0iyO5FGpzVYDNFR6oR53AIrwph9yWl7uSc7IWUMsrmpmSaTGra5hQNunXpM+9oit85p924jWuHzUA==}
'@modelcontextprotocol/sdk@1.10.2':
resolution: {integrity: sha512-rb6AMp2DR4SN+kc6L1ta2NCpApyA9WYNx3CrTSZvGxq9wH71bRur+zRqPfg0vQ9mjywR7qZdX2RGHOPq3ss+tA==}
engines: {node: '>=18'}
'@next/env@15.2.4':
@@ -4268,7 +4268,7 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0
'@modelcontextprotocol/sdk@1.9.0':
'@modelcontextprotocol/sdk@1.10.2':
dependencies:
content-type: 1.0.5
cors: 2.8.5

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

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

@@ -7,6 +7,7 @@ import {
updateServer,
deleteServer,
toggleServer,
updateSystemConfig
} from '../controllers/serverController.js';
import {
getGroups,
@@ -46,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);

View File

@@ -4,7 +4,12 @@ import path from 'path';
import { initMcpServer } from './services/mcpService.js';
import { initMiddlewares } from './middlewares/index.js';
import { initRoutes } from './routes/index.js';
import { handleSseConnection, handleSseMessage } from './services/sseService.js';
import {
handleSseConnection,
handleSseMessage,
handleMcpPostRequest,
handleMcpOtherRequest,
} from './services/sseService.js';
import { migrateUserData } from './utils/migration.js';
import { initializeDefaultUser } from './models/User.js';
@@ -32,8 +37,11 @@ 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);
this.app.post('/mcp/:group?', handleMcpPostRequest);
this.app.get('/mcp/:group?', handleMcpOtherRequest);
this.app.delete('/mcp/:group?', handleMcpOtherRequest);
})
.catch((error) => {
console.error('Error initializing MCP 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

@@ -12,7 +12,15 @@ export const getMarketServers = (): Record<string, MarketServer> => {
try {
const serversJsonPath = getServersJsonPath();
const data = fs.readFileSync(serversJsonPath, 'utf8');
return JSON.parse(data);
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 {};
@@ -29,13 +37,13 @@ export const getMarketServerByName = (name: string): MarketServer | null => {
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();
};
@@ -43,25 +51,28 @@ export const getMarketCategories = (): string[] => {
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);
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 = [
@@ -69,21 +80,23 @@ export const searchMarketServers = (query: string): MarketServer[] => {
server.display_name,
server.description,
...(server.categories || []),
...(server.tags || [])
].join(' ').toLowerCase();
return searchTerms.some(term => searchableText.includes(term));
...(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);
});
@@ -92,12 +105,12 @@ export const filterMarketServersByCategory = (category: string): MarketServer[]
// 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,41 +1,119 @@
import { Request, Response } from 'express';
import { randomUUID } from 'node:crypto';
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.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: Transport; 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);
await (transport as SSEServerTransport).handlePostMessage(req, res);
} else {
console.error(`No transport found for sessionId: ${sessionId}`);
res.status(400).send('No transport found for sessionId');
}
};
export const handleMcpPostRequest = async (req: Request, res: Response): Promise<void> => {
console.log('Handling MCP post request');
const sessionId = req.headers['mcp-session-id'] as string | undefined;
const group = req.params.group;
const settings = loadSettings();
const routingConfig = settings.systemConfig?.routing || {
enableGlobalRoute: true,
enableGroupNameRoute: true,
};
if (!group && !routingConfig.enableGlobalRoute) {
res.status(403).send('Global routes are disabled. Please specify a group ID.');
return;
}
let transport: StreamableHTTPServerTransport;
if (sessionId && transports[sessionId]) {
transport = transports[sessionId].transport as StreamableHTTPServerTransport;
} else if (!sessionId && isInitializeRequest(req.body)) {
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sessionId) => {
transports[sessionId] = { transport, group };
},
});
transport.onclose = () => {
if (transport.sessionId) {
delete transports[transport.sessionId];
}
};
await getMcpServer().connect(transport);
} else {
res.status(400).json({
jsonrpc: '2.0',
error: {
code: -32000,
message: 'Bad Request: No valid session ID provided',
},
id: null,
});
return;
}
await transport.handleRequest(req, res, req.body);
};
export const handleMcpOtherRequest = async (req: Request, res: Response) => {
console.log('Handling MCP other request');
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (!sessionId || !transports[sessionId]) {
res.status(400).send('Invalid or missing session ID');
return;
}
const { transport } = transports[sessionId];
await (transport as StreamableHTTPServerTransport).handleRequest(req, res);
};
export const getConnectionCount = (): number => {
return Object.keys(transports).length;
};

View File

@@ -78,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
@@ -93,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