Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c00f1d606 | ||
|
|
3d49c652ad | ||
|
|
c4008f617d |
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -25,7 +25,7 @@ MCPHub 是一个统一的 MCP(Model Context Protocol,模型上下文协议
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"amap-maps": {
|
||||
"amap": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@amap/amap-maps-mcp-server"],
|
||||
"env": {
|
||||
|
||||
BIN
assets/cursor-mcp.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
assets/cursor-query.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
assets/cursor-tools.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
BIN
assets/group.png
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
BIN
assets/market.zh.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
assets/wegroup.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
232
doc/intro2.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# 本地部署、一键安装、分组路由:MCPHub 重塑 MCP 服务器体验
|
||||
|
||||
## 概述
|
||||
|
||||
现代 AI 应用场景中,将大模型(LLM)与各种数据源和工具无缝对接,往往需要手动编写大量胶水代码,并且无法快速复用。MCP(Model Context Protocol)协议由 Anthropic 在 2024 年开源,旨在提供类似“USB‑C”接口般的标准化通信方式,简化 AI 助手与内容仓库、业务系统等的集成流程。然而,MCP 服务器部署常常需要大量环境依赖、手动配置及持续运行,开发者常因安装和配置耗费大量时间和精力。MCPHub 作为一款开源的一站式聚合平台,通过直观的 Web UI、Docker 镜像和热插拔配置,实现本地或容器里的“一键安装”与“分组路由”,大幅降低 MCP 服务器的使用门槛和运维成本。
|
||||
|
||||
尽管目前各家平台都在陆续推出各类 MCP 云服务,但在数据隐私、合规性和定制化需求日益增长的背景下,MCPHub 仍然是一个值得关注的本地部署解决方案。本文将深入探讨 MCPHub 的设计理念、使用场景和技术架构,帮助开发者快速上手并充分利用这一强大工具。
|
||||
|
||||
## MCPHub 是什么
|
||||
|
||||
### MCP 协议简介
|
||||
|
||||
Model Context Protocol(MCP)是一种开放标准,类似“USB‑C”接口,为 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
|
||||
|
||||
### 1. 复杂的环境依赖与配置
|
||||
|
||||
- MCP 服务器常依赖 Node.js、Python 等多种运行时,需手动维护大量命令、参数和环境变量。
|
||||
- MCPHub 内置 MCP 服务器市场,包含多种常用 MCP 服务器,支持一键安装和自动配置,简化了环境搭建过程。
|
||||
- 通过 Docker 部署,MCPHub 可在任何支持 Docker 的平台上运行,避免了环境不一致的问题。
|
||||
|
||||

|
||||
|
||||
### 2. 持续运行的服务压力
|
||||
|
||||
- MCP 要求长连接服务常驻内存,重启或升级时需要人工干预,缺乏弹性。
|
||||
- 借助 Docker 容器化部署,MCPHub 可快速重建环境,享受容器带来的弹性与隔离优势。
|
||||
|
||||
### 3. 路由与分组管理缺乏统一视图
|
||||
|
||||
- 传统方式下,很难可视化地将不同 MCP 服务按场景分类,容易造成请求混淆和性能瓶颈。
|
||||
- MCPHub 支持动态创建分组(如“地图检索”、“网页自动化”、“聊天”等),为每个分组生成独立的 SSE 端点,实现各类用例的隔离与优化。
|
||||
|
||||

|
||||
|
||||
## 如何使用 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` 在运行过程中调用了多个工具。
|
||||
|
||||

|
||||
|
||||
最终生成结果如下:
|
||||
|
||||
```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。
|
||||
|
||||

|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
153
frontend/src/components/MarketServerCard.tsx
Normal 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;
|
||||
297
frontend/src/components/MarketServerDetail.tsx
Normal 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;
|
||||
@@ -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'),
|
||||
|
||||
128
frontend/src/components/ui/Pagination.tsx
Normal 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'
|
||||
}`}
|
||||
>
|
||||
« 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 »
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Pagination;
|
||||
96
frontend/src/components/ui/Toast.tsx
Normal 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;
|
||||
60
frontend/src/contexts/ToastContext.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
410
frontend/src/hooks/useMarketData.ts
Normal 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
|
||||
};
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "每页显示"
|
||||
}
|
||||
}
|
||||
336
frontend/src/pages/MarketPage.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"amap-maps": {
|
||||
"amap": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
|
||||
74722
servers.json
Normal file
154
src/controllers/marketController.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import express from 'express';
|
||||
import { check } from 'express-validator';
|
||||
import path from 'path';
|
||||
import {
|
||||
getAllServers,
|
||||
getAllSettings,
|
||||
@@ -20,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,
|
||||
@@ -51,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(),
|
||||
@@ -72,10 +89,6 @@ export const initRoutes = (app: express.Application): void => {
|
||||
], changePassword);
|
||||
|
||||
app.use('/api', router);
|
||||
|
||||
app.get('*', (_req, res) => {
|
||||
res.sendFile(path.join(process.cwd(), 'frontend', 'dist', 'index.html'));
|
||||
});
|
||||
};
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import express from 'express';
|
||||
import config from './config/index.js';
|
||||
import path from 'path';
|
||||
import { initMcpServer } from './services/mcpService.js';
|
||||
import { initMiddlewares } from './middlewares/index.js';
|
||||
import { initRoutes } from './routes/index.js';
|
||||
@@ -37,6 +38,11 @@ export class AppServer {
|
||||
.catch((error) => {
|
||||
console.error('Error initializing MCP server:', error);
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
this.app.get('*', (_req, res) => {
|
||||
res.sendFile(path.join(process.cwd(), 'frontend', 'dist', 'index.html'));
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error initializing server:', error);
|
||||
|
||||
103
src/services/marketService.ts
Normal 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);
|
||||
});
|
||||
};
|
||||
@@ -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
|
||||
|
||||