Compare commits

..

2 Commits

Author SHA1 Message Date
samanhappy
2c00f1d606 docs: Add comprehensive introduction and usage guide for MCPHub, deta… (#26) 2025-04-20 17:49:40 +08:00
samanhappy
3d49c652ad introduce market 2025-04-20 13:58:52 +08:00
32 changed files with 76991 additions and 38 deletions

View File

@@ -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": {

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

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 服务器的使用门槛和运维成本​。
尽管目前各家平台都在陆续推出各类 MCP 云服务但在数据隐私、合规性和定制化需求日益增长的背景下MCPHub 仍然是一个值得关注的本地部署解决方案​。本文将深入探讨 MCPHub 的设计理念、使用场景和技术架构,帮助开发者快速上手并充分利用这一强大工具​。
## 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 命令启动的服务器。它通过一个集中式 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 服务按场景分类,容易造成请求混淆和性能瓶颈。
- 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 中的 MCPHub 分组](../assets/cursor-mcp.png)
然后,我们可以测试一下,比如输入:深度思考一下,帮我制定一个五一假期从南京出发的自驾行出游计划,要求避开拥堵路线,结合天气情况,并且可以体验到不同的自然风光。
![Cursor 中的 MCPHub 分组](../assets/cursor-query.png)
接着可以看到,`Cursor` 在运行过程中调用了多个工具。
![Cursor 中的 MCPHub 分组](../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 专注于核心业务与创新,而无需被底层细节所困扰。立即体验 MCPHub开启高效易用的 MCP 服务器管理之旅!
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();
// 智能计算要显示多少个标签,确保在单行内展示
const getTagsToDisplay = () => {
if (!server.tags || server.tags.length === 0) {
return { tagsToShow: [], hasMore: false, moreCount: 0 };
}
// 估计卡片内单行可用宽度(以字符为单位)
const estimatedAvailableWidth = 30; // 估计一行可以容纳的字符数
// 计算标签和加号所需的字符空间(包括#号和间距)
const calculateTagWidth = (tag: string) => tag.length + 3; // +3 for # and spacing
// 循环确定能显示的最大标签数量
let totalWidth = 0;
let i = 0;
// 首先对标签按长度排序,优先显示较短的标签
const sortedTags = [...server.tags].sort((a, b) => a.length - b.length);
// 计算能够放入的标签数量
for (i = 0; i < sortedTags.length; i++) {
const tagWidth = calculateTagWidth(sortedTags[i]);
// 如果这个标签会使总宽度超出可用宽度,停止添加
if (totalWidth + tagWidth > estimatedAvailableWidth) {
break;
}
totalWidth += tagWidth;
// 如果这是最后一个标签但仍有空间,不需要显示"更多"
if (i === sortedTags.length - 1) {
return {
tagsToShow: sortedTags,
hasMore: false,
moreCount: 0
};
}
}
// 如果没有足够空间显示任何标签,至少显示一个
if (i === 0 && sortedTags.length > 0) {
i = 1;
}
// 计算"更多"标签所需的空间
const moreCount = sortedTags.length - i;
const moreTagWidth = 3 + String(moreCount).length + t('market.moreTags').length;
// 如果剩余空间足够显示"更多"标签
if (totalWidth + moreTagWidth <= estimatedAvailableWidth || i < 1) {
return {
tagsToShow: sortedTags.slice(0, i),
hasMore: true,
moreCount
};
}
// 如果连"更多"标签都放不下,减少一个标签以腾出空间
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

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

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

@@ -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,11 @@
"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"
},
"status": {
"online": "Online",
@@ -75,7 +82,8 @@
"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"
},
"common": {
"processing": "Processing...",
@@ -84,14 +92,16 @@
"refresh": "Refresh",
"create": "Create",
"submitting": "Submitting...",
"delete": "Delete"
"delete": "Delete",
"copy": "Copy"
},
"nav": {
"dashboard": "Dashboard",
"servers": "Servers",
"groups": "Groups",
"settings": "Settings",
"changePassword": "Change Password"
"changePassword": "Change Password",
"market": "Market"
},
"pages": {
"dashboard": {
@@ -110,7 +120,13 @@
},
"settings": {
"title": "Settings",
"language": "Language"
"language": "Language",
"account": "Account Settings",
"password": "Change Password",
"appearance": "Appearance"
},
"market": {
"title": "Server Market - (Data from mcpm.sh)"
}
},
"groups": {
@@ -138,5 +154,47 @@
"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"
}
}

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,11 @@
"toggleError": "切换服务器 {{serverName}} 状态失败",
"alreadyExists": "服务器 {{serverName}} 已经存在",
"invalidData": "提供的服务器数据无效",
"notFound": "找不到服务器 {{serverName}}"
"notFound": "找不到服务器 {{serverName}}",
"namePlaceholder": "请输入服务器名称",
"urlPlaceholder": "请输入服务器URL",
"commandPlaceholder": "请输入命令",
"argumentsPlaceholder": "请输入参数"
},
"status": {
"online": "在线",
@@ -75,7 +82,8 @@
"serverAdd": "添加服务器失败,请检查服务器状态",
"serverUpdate": "编辑服务器 {{serverName}} 失败,请检查服务器状态",
"serverFetch": "获取服务器数据失败,请稍后重试",
"initialStartup": "服务器可能正在启动中。首次启动可能需要一些时间,请耐心等候..."
"initialStartup": "服务器可能正在启动中。首次启动可能需要一些时间,请耐心等候...",
"serverInstall": "安装服务器失败"
},
"common": {
"processing": "处理中...",
@@ -84,14 +92,16 @@
"refresh": "刷新",
"create": "创建",
"submitting": "提交中...",
"delete": "删除"
"delete": "删除",
"copy": "复制"
},
"nav": {
"dashboard": "仪表盘",
"servers": "服务器",
"settings": "设置",
"changePassword": "修改密码",
"groups": "分组"
"groups": "分组",
"market": "市场"
},
"pages": {
"dashboard": {
@@ -107,10 +117,16 @@
},
"settings": {
"title": "设置",
"language": "语言"
"language": "语言",
"account": "账户设置",
"password": "修改密码",
"appearance": "外观"
},
"groups": {
"title": "分组管理"
},
"market": {
"title": "服务器市场 - (数据来源于 mcpm.sh"
}
},
"groups": {
@@ -138,5 +154,47 @@
"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": "每页显示"
}
}

View File

@@ -0,0 +1,336 @@
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>
)}
{/* 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,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;

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

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

@@ -19,6 +19,15 @@ import {
getGroupServers,
updateGroupServersBatch
} from '../controllers/groupController.js';
import {
getAllMarketServers,
getMarketServer,
getAllMarketCategories,
getAllMarketTags,
searchMarketServersByQuery,
getMarketServersByCategory,
getMarketServersByTag
} from '../controllers/marketController.js';
import {
login,
register,
@@ -50,6 +59,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(),

View File

@@ -0,0 +1,103 @@
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');
return JSON.parse(data);
} 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

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