mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 10:49:35 -05:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4379513a35 | ||
|
|
9a06bae225 | ||
|
|
e5aaae466f | ||
|
|
9b1338a356 | ||
|
|
10d4616601 | ||
|
|
9ca242a0e4 | ||
|
|
0a6259decf | ||
|
|
7887a3a5f9 | ||
|
|
7f33615161 | ||
|
|
c9ec3b77ce | ||
|
|
142c3f628a | ||
|
|
bbb99b6f17 | ||
|
|
c1eabb5607 |
61
.github/workflows/npm-publish.yml
vendored
Normal file
61
.github/workflows/npm-publish.yml
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
name: Publish to NPM
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*.*.*']
|
||||
|
||||
jobs:
|
||||
publish-npm:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '18'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Update version from tag
|
||||
run: |
|
||||
# 提取标签版本号(移除 'v' 前缀)
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
echo "Updating package.json version to $VERSION"
|
||||
# 使用 jq 更新 package.json 中的版本号
|
||||
jq ".version = \"$VERSION\"" package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
# 显示更新后的版本号
|
||||
echo "Updated version in package.json:"
|
||||
grep -m 1 "version" package.json
|
||||
|
||||
- name: Build package
|
||||
run: pnpm build
|
||||
|
||||
- name: Publish to NPM
|
||||
run: pnpm publish --no-git-checks --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -2,6 +2,12 @@ FROM python:3.13-slim-bookworm AS base
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||
|
||||
# 添加 HTTP_PROXY 和 HTTPS_PROXY 环境变量
|
||||
ARG HTTP_PROXY=""
|
||||
ARG HTTPS_PROXY=""
|
||||
ENV HTTP_PROXY=$HTTP_PROXY
|
||||
ENV HTTPS_PROXY=$HTTPS_PROXY
|
||||
|
||||
RUN apt-get update && apt-get install -y curl gnupg git \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
|
||||
55
README.md
55
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文版](README.zh.md)
|
||||
|
||||
MCPHub is a unified management platform that aggregates multiple MCP (Model Context Protocol) servers into separate SSE endpoints for different scenarios by group. It streamlines your AI tool integrations through an intuitive interface and robust protocol handling.
|
||||
MCPHub is a unified management platform that aggregates multiple MCP (Model Context Protocol) servers into separate Streamable HTTP (SSE) endpoints for different scenarios by group. It streamlines your AI tool integrations through an intuitive interface and robust protocol handling.
|
||||
|
||||

|
||||
|
||||
@@ -88,27 +88,45 @@ Open `http://localhost:3000` and log in with your credentials.
|
||||
- Group management for organizing servers
|
||||
- User administration for access control
|
||||
|
||||
### SSE Endpoint
|
||||
### Streamable HTTP Endpoint
|
||||
> As of now, support for streaming HTTP endpoints varies across different AI clients. If you encounter issues, you can use the SSE endpoint or wait for future updates.
|
||||
|
||||
Connect AI clients (e.g., Claude Desktop, Cursor, Cherry Studio) via:
|
||||
Connect AI clients (e.g., Claude Desktop, Cursor, DeepChat, etc.) via:
|
||||
```
|
||||
http://localhost:3000/mcp
|
||||
```
|
||||
This endpoint provides a unified streamable HTTP interface for all your MCP servers. It allows you to:
|
||||
- Send requests to any configured MCP server
|
||||
- Receive responses in real-time
|
||||
- Easily integrate with various AI clients and tools
|
||||
- Use the same endpoint for all servers, simplifying your integration process
|
||||
|
||||
**Group-Specific Endpoints (Recommended)**:
|
||||
|
||||

|
||||
|
||||
For targeted access to specific server groups, use the group-based HTTP endpoint:
|
||||
```
|
||||
http://localhost:3000/mcp/{group}
|
||||
```
|
||||
|
||||
Where `{group}` is the ID or name of the group you created in the dashboard. This allows you to:
|
||||
- Connect to a specific subset of MCP servers organized by use case
|
||||
- Isolate different AI tools to access only relevant servers
|
||||
- Implement more granular access control for different environments or teams
|
||||
|
||||
### SSE Endpoint (Deprecated in Future)
|
||||
|
||||
Connect AI clients (e.g., Claude Desktop, Cursor, DeepChat, etc.) via:
|
||||
```
|
||||
http://localhost:3000/sse
|
||||
```
|
||||
|
||||
**Group-Specific Endpoints (Recommended)**:
|
||||
|
||||

|
||||
|
||||
For targeted access to specific server groups, use the group-based SSE endpoint:
|
||||
```
|
||||
http://localhost:3000/sse/{groupId}
|
||||
http://localhost:3000/sse/{group}
|
||||
```
|
||||
|
||||
Where `{groupId}` is the ID of the group you created in the dashboard. This allows you to:
|
||||
- Connect to a specific subset of MCP servers organized by use case
|
||||
- Isolate different AI tools to access only relevant servers
|
||||
- Implement more granular access control for different environments or teams
|
||||
|
||||
## 🧑💻 Local Development
|
||||
|
||||
```bash
|
||||
@@ -120,6 +138,17 @@ pnpm dev
|
||||
|
||||
This starts both frontend and backend in development mode with hot-reloading.
|
||||
|
||||
> For windows users, you may need to start the backend server and frontend separately: `pnpm backend:dev`, `pnpm frontend:dev`.
|
||||
|
||||
## 🛠️ Common Issues
|
||||
|
||||
### Using Nginx as a Reverse Proxy
|
||||
If you are using Nginx to reverse proxy MCPHub, please make sure to add the following configuration in your Nginx setup:
|
||||
|
||||
```nginx
|
||||
proxy_buffering off
|
||||
```
|
||||
|
||||
## 🔍 Tech Stack
|
||||
|
||||
- **Backend**: Node.js, Express, TypeScript
|
||||
|
||||
58
README.zh.md
58
README.zh.md
@@ -2,7 +2,7 @@
|
||||
|
||||
[English Version](README.md) | 中文版
|
||||
|
||||
MCPHub 是一个统一的 MCP(Model Context Protocol,模型上下文协议)服务器聚合平台,可以根据场景将多个服务器聚合到不同的 SSE 端点。它通过直观的界面和强大的协议处理能力,简化了您的 AI 工具集成流程。
|
||||
MCPHub 是一个统一的 MCP(Model Context Protocol,模型上下文协议)服务器聚合平台,可以根据场景将多个服务器聚合到不同的流式 HTTP(SSE)端点。它通过直观的界面和强大的协议处理能力,简化了您的 AI 工具集成流程。
|
||||
|
||||

|
||||
|
||||
@@ -88,27 +88,44 @@ docker run -p 3000:3000 samanhappy/mcphub
|
||||
- 分组管理,组织服务器访问
|
||||
- 用户管理,设定权限
|
||||
|
||||
### SSE 端点集成
|
||||
### 支持流式的 HTTP 端点
|
||||
> 截至目前,各家 AI 客户端对流式的 HTTP 端点支持不一,如果遇到问题,可以使用 SSE 端点或者等待更新。
|
||||
|
||||
通过以下地址连接 AI 客户端(如 Claude Desktop、Cursor、Cherry Studio 等):
|
||||
通过以下地址连接 AI 客户端(如 Claude Desktop、Cursor、DeepChat 等):
|
||||
```
|
||||
http://localhost:3000/mcp
|
||||
```
|
||||
这个端点为所有 MCP 服务器提供统一的流式 HTTP 接口。它允许您:
|
||||
- 向任何配置的 MCP 服务器发送请求
|
||||
- 实时接收响应
|
||||
- 轻松与各种 AI 客户端和工具集成
|
||||
- 对所有服务器使用相同的端点,简化集成过程
|
||||
|
||||
**基于分组的 HTTP 端点(推荐)**:
|
||||

|
||||
要针对特定服务器分组进行访问,请使用基于分组的 HTTP 端点:
|
||||
```
|
||||
http://localhost:3000/mcp/{group}
|
||||
```
|
||||
其中 `{group}` 是您在控制面板中创建的分组 ID 或名称。这样做可以:
|
||||
- 连接到按用例组织的特定 MCP 服务器子集
|
||||
- 隔离不同的 AI 工具,使其只能访问相关服务器
|
||||
- 为不同环境或团队实现更精细的访问控制
|
||||
- 通过分组名称轻松识别和管理服务器
|
||||
- 允许不同的 AI 客户端使用相同的端点,简化集成过程
|
||||
|
||||
### SSE 端点集成 (即将废弃)
|
||||
|
||||
通过以下地址连接 AI 客户端(如 Claude Desktop、Cursor、DeepChat 等):
|
||||
```
|
||||
http://localhost:3000/sse
|
||||
```
|
||||
|
||||
**基于分组的 SSE 端点(推荐)**:
|
||||
|
||||

|
||||
|
||||
要针对特定服务器分组进行访问,请使用基于分组的 SSE 端点:
|
||||
```
|
||||
http://localhost:3000/sse/{groupId}
|
||||
http://localhost:3000/sse/{group}
|
||||
```
|
||||
|
||||
其中 `{groupId}` 是您在控制面板中创建的分组 ID。这样做可以:
|
||||
- 连接到按用例组织的特定 MCP 服务器子集
|
||||
- 隔离不同的 AI 工具,使其只能访问相关服务器
|
||||
- 为不同环境或团队实现更精细的访问控制
|
||||
|
||||
## 🧑💻 本地开发
|
||||
|
||||
```bash
|
||||
@@ -120,6 +137,17 @@ pnpm dev
|
||||
|
||||
此命令将在开发模式下启动前后端,并启用热重载。
|
||||
|
||||
> 针对 Windows 用户,可能需要分别启动后端服务器和前端:`pnpm backend:dev`,`pnpm frontend:dev`。
|
||||
|
||||
## 🛠️ 常见问题
|
||||
|
||||
### 使用 nginx 反向代理
|
||||
如果您在使用 nginx 反向代理 MCPHub,请确保在 nginx 配置中添加以下内容:
|
||||
|
||||
```nginx
|
||||
proxy_buffering off
|
||||
```
|
||||
|
||||
## 🔍 技术栈
|
||||
|
||||
- **后端**:Node.js、Express、TypeScript
|
||||
@@ -136,9 +164,9 @@ pnpm dev
|
||||
- Bug 报告与修复
|
||||
- 翻译与建议
|
||||
|
||||
欢迎加入企微交流共建群
|
||||
欢迎加入企微交流共建群,由于群人数限制,有兴趣的同学可以扫码添加管理员为好友后拉入群聊。
|
||||
|
||||
<img src="assets/wegroup.png" width="500">
|
||||
<img src="assets/wexin.png" width="350">
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
|
||||
BIN
assets/wexin.png
Normal file
BIN
assets/wexin.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
96
bin/cli.js
Executable file
96
bin/cli.js
Executable file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
|
||||
// Enable debug logging if needed
|
||||
// process.env.DEBUG = 'true';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Start with more debug information
|
||||
console.log('📋 MCPHub CLI');
|
||||
console.log(`📁 CLI script location: ${__dirname}`);
|
||||
|
||||
// The npm package directory structure when installed is:
|
||||
// node_modules/@samanhappy/mcphub/
|
||||
// - dist/
|
||||
// - bin/
|
||||
// - frontend/dist/
|
||||
|
||||
// Get the package root - this is where package.json is located
|
||||
function findPackageRoot() {
|
||||
const isDebug = process.env.DEBUG === 'true';
|
||||
|
||||
// Possible locations for package.json
|
||||
const possibleRoots = [
|
||||
// Standard npm package location
|
||||
path.resolve(__dirname, '..'),
|
||||
// When installed via npx
|
||||
path.resolve(__dirname, '..', '..', '..')
|
||||
];
|
||||
|
||||
// Special handling for npx
|
||||
if (process.argv[1] && process.argv[1].includes('_npx')) {
|
||||
const npxDir = path.dirname(process.argv[1]);
|
||||
possibleRoots.unshift(path.resolve(npxDir, '..'));
|
||||
}
|
||||
|
||||
if (isDebug) {
|
||||
console.log('DEBUG: Checking for package.json in:', possibleRoots);
|
||||
}
|
||||
|
||||
for (const root of possibleRoots) {
|
||||
const packageJsonPath = path.join(root, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
if (pkg.name === 'mcphub' || pkg.name === '@samanhappy/mcphub') {
|
||||
if (isDebug) {
|
||||
console.log(`DEBUG: Found package.json at ${packageJsonPath}`);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue to the next potential root
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('⚠️ Could not find package.json, using default path');
|
||||
return path.resolve(__dirname, '..');
|
||||
}
|
||||
|
||||
// Locate and check the frontend distribution
|
||||
function checkFrontend(packageRoot) {
|
||||
const isDebug = process.env.DEBUG === 'true';
|
||||
const frontendDistPath = path.join(packageRoot, 'frontend', 'dist');
|
||||
|
||||
if (isDebug) {
|
||||
console.log(`DEBUG: Checking frontend at: ${frontendDistPath}`);
|
||||
}
|
||||
|
||||
if (fs.existsSync(frontendDistPath) && fs.existsSync(path.join(frontendDistPath, 'index.html'))) {
|
||||
console.log('✅ Frontend distribution found');
|
||||
return true;
|
||||
} else {
|
||||
console.log('⚠️ Frontend distribution not found at', frontendDistPath);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const projectRoot = findPackageRoot();
|
||||
console.log(`📦 Using package root: ${projectRoot}`);
|
||||
|
||||
// Check if frontend exists
|
||||
checkFrontend(projectRoot);
|
||||
|
||||
// Start the server
|
||||
console.log('🚀 Starting MCPHub server...');
|
||||
import(path.join(projectRoot, 'dist', 'index.js')).catch(err => {
|
||||
console.error('Failed to start MCPHub:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -4,6 +4,19 @@ NPM_REGISTRY=${NPM_REGISTRY:-https://registry.npmjs.org/}
|
||||
echo "Setting npm registry to ${NPM_REGISTRY}"
|
||||
npm config set registry "$NPM_REGISTRY"
|
||||
|
||||
# 处理 HTTP_PROXY 和 HTTPS_PROXY 环境变量
|
||||
if [ -n "$HTTP_PROXY" ]; then
|
||||
echo "Setting HTTP proxy to ${HTTP_PROXY}"
|
||||
npm config set proxy "$HTTP_PROXY"
|
||||
export HTTP_PROXY="$HTTP_PROXY"
|
||||
fi
|
||||
|
||||
if [ -n "$HTTPS_PROXY" ]; then
|
||||
echo "Setting HTTPS proxy to ${HTTPS_PROXY}"
|
||||
npm config set https-proxy "$HTTPS_PROXY"
|
||||
export HTTPS_PROXY="$HTTPS_PROXY"
|
||||
fi
|
||||
|
||||
echo "Using REQUEST_TIMEOUT: $REQUEST_TIMEOUT"
|
||||
echo "Using UV_PYTHON_INSTALL_MIRROR: $UV_PYTHON_INSTALL_MIRROR"
|
||||
|
||||
|
||||
@@ -2,6 +2,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 { ThemeProvider } from './contexts/ThemeContext';
|
||||
import MainLayout from './layouts/MainLayout';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
@@ -10,34 +11,38 @@ import ServersPage from './pages/ServersPage';
|
||||
import GroupsPage from './pages/GroupsPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import MarketPage from './pages/MarketPage';
|
||||
import LogsPage from './pages/LogsPage';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<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 />} />
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<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="/logs" element={<LogsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* 未匹配的路由重定向到首页 */}
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
|
||||
{/* 未匹配的路由重定向到首页 */}
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
|
||||
<div className="p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('groups.addNew')}</h2>
|
||||
|
||||
@@ -74,7 +74,7 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => {
|
||||
</button>
|
||||
|
||||
{modalVisible && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<ServerForm
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={toggleModal}
|
||||
|
||||
@@ -82,7 +82,7 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg shadow-lg max-w-md w-full">
|
||||
<div className="p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('groups.edit')}</h2>
|
||||
|
||||
@@ -61,7 +61,7 @@ const EditServerForm = ({ server, onEdit, onCancel }: EditServerFormProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<ServerForm
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={onCancel}
|
||||
|
||||
179
frontend/src/components/LogViewer.tsx
Normal file
179
frontend/src/components/LogViewer.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { LogEntry } from '../services/logService';
|
||||
import { Button } from './ui/Button';
|
||||
import { Badge } from './ui/Badge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface LogViewerProps {
|
||||
logs: LogEntry[];
|
||||
isLoading?: boolean;
|
||||
error?: Error | null;
|
||||
onClear?: () => void;
|
||||
}
|
||||
|
||||
const LogViewer: React.FC<LogViewerProps> = ({ logs, isLoading = false, error = null, onClear }) => {
|
||||
const { t } = useTranslation();
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [filter, setFilter] = useState<string>('');
|
||||
const [typeFilter, setTypeFilter] = useState<Array<'info' | 'error' | 'warn' | 'debug'>>(['info', 'error', 'warn', 'debug']);
|
||||
const [sourceFilter, setSourceFilter] = useState<Array<'main' | 'child-process'>>(['main', 'child-process']);
|
||||
|
||||
// Auto scroll to bottom when new logs come in if autoScroll is enabled
|
||||
useEffect(() => {
|
||||
if (autoScroll && logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs, autoScroll]);
|
||||
|
||||
// Filter logs based on current filter settings
|
||||
const filteredLogs = logs.filter(log => {
|
||||
const matchesText = filter ? log.message.toLowerCase().includes(filter.toLowerCase()) : true;
|
||||
const matchesType = typeFilter.includes(log.type);
|
||||
const matchesSource = sourceFilter.includes(log.source as 'main' | 'child-process');
|
||||
return matchesText && matchesType && matchesSource;
|
||||
});
|
||||
|
||||
// Format timestamp to readable format
|
||||
const formatTimestamp = (timestamp: number) => {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
};
|
||||
|
||||
// Get badge color based on log type
|
||||
const getLogTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'error': return 'bg-red-500';
|
||||
case 'warn': return 'bg-yellow-500';
|
||||
case 'debug': return 'bg-purple-500';
|
||||
default: return 'bg-blue-500';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="bg-card p-3 rounded-t-md border-b flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-sm">{t('logs.filters')}:</span>
|
||||
|
||||
{/* Text search filter */}
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('logs.search')}
|
||||
className="px-2 py-1 text-sm border rounded"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
|
||||
{/* Log type filters */}
|
||||
<div className="flex gap-1 items-center">
|
||||
{(['info', 'error', 'warn', 'debug'] as const).map(type => (
|
||||
<Badge
|
||||
key={type}
|
||||
variant={typeFilter.includes(type) ? 'default' : 'outline'}
|
||||
className={`cursor-pointer ${typeFilter.includes(type) ? getLogTypeColor(type) : ''}`}
|
||||
onClick={() => {
|
||||
if (typeFilter.includes(type)) {
|
||||
setTypeFilter(prev => prev.filter(t => t !== type));
|
||||
} else {
|
||||
setTypeFilter(prev => [...prev, type]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{type}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Log source filters */}
|
||||
<div className="flex gap-1 items-center ml-2">
|
||||
{(['main', 'child-process'] as const).map(source => (
|
||||
<Badge
|
||||
key={source}
|
||||
variant={sourceFilter.includes(source) ? 'default' : 'outline'}
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
if (sourceFilter.includes(source)) {
|
||||
setSourceFilter(prev => prev.filter(s => s !== source));
|
||||
} else {
|
||||
setSourceFilter(prev => [...prev, source]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{source === 'main' ? t('logs.mainProcess') : t('logs.childProcess')}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-1 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoScroll}
|
||||
onChange={() => setAutoScroll(!autoScroll)}
|
||||
className="form-checkbox h-4 w-4"
|
||||
/>
|
||||
{t('logs.autoScroll')}
|
||||
</label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClear}
|
||||
disabled={isLoading || logs.length === 0}
|
||||
>
|
||||
{t('logs.clearLogs')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
className="flex-grow p-2 overflow-auto bg-card rounded-b-md font-mono text-sm"
|
||||
style={{ maxHeight: 'calc(100vh - 300px)' }}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center items-center h-full">
|
||||
<span>{t('logs.loading')}</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-red-500 p-2">
|
||||
{error.message}
|
||||
</div>
|
||||
) : filteredLogs.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground p-8">
|
||||
{filter || typeFilter.length < 4 || sourceFilter.length < 2
|
||||
? t('logs.noMatch')
|
||||
: t('logs.noLogs')}
|
||||
</div>
|
||||
) : (
|
||||
filteredLogs.map((log, index) => (
|
||||
<div
|
||||
key={`${log.timestamp}-${index}`}
|
||||
className={`py-1 border-b border-gray-100 dark:border-gray-800 ${log.type === 'error' ? 'text-red-500' :
|
||||
log.type === 'warn' ? 'text-yellow-500' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="text-gray-400">[{formatTimestamp(log.timestamp)}]</span>
|
||||
<Badge className={`ml-2 mr-1 ${getLogTypeColor(log.type)}`}>
|
||||
{log.type}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="mr-2">
|
||||
{log.source === 'main' ? t('logs.main') : t('logs.child')}
|
||||
{log.processId ? ` (${log.processId})` : ''}
|
||||
</Badge>
|
||||
<span className="whitespace-pre-wrap">{log.message}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogViewer;
|
||||
@@ -270,7 +270,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
</div>
|
||||
|
||||
{modalVisible && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<ServerForm
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={toggleModal}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Server } from '@/types'
|
||||
import { ChevronDown, ChevronRight } from '@/components/icons/LucideIcons'
|
||||
import Badge from '@/components/ui/Badge'
|
||||
import { ChevronDown, ChevronRight, AlertCircle, Copy, Check } from 'lucide-react'
|
||||
import { StatusBadge } from '@/components/ui/Badge'
|
||||
import ToolCard from '@/components/ui/ToolCard'
|
||||
import DeleteDialog from '@/components/ui/DeleteDialog'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
|
||||
interface ServerCardProps {
|
||||
server: Server
|
||||
@@ -15,9 +16,26 @@ interface ServerCardProps {
|
||||
|
||||
const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { showToast } = useToast()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isToggling, setIsToggling] = useState(false)
|
||||
const [showErrorPopover, setShowErrorPopover] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const errorPopoverRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (errorPopoverRef.current && !errorPopoverRef.current.contains(event.target as Node)) {
|
||||
setShowErrorPopover(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleRemove = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
@@ -41,6 +59,44 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
|
||||
}
|
||||
}
|
||||
|
||||
const handleErrorIconClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setShowErrorPopover(!showErrorPopover)
|
||||
}
|
||||
|
||||
const copyToClipboard = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!server.error) return
|
||||
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(server.error).then(() => {
|
||||
setCopied(true)
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
} else {
|
||||
// Fallback for HTTP or unsupported clipboard API
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = server.error
|
||||
// Avoid scrolling to bottom
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-9999px'
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
setCopied(true)
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (err) {
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error')
|
||||
console.error('Copy to clipboard failed:', err)
|
||||
}
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
onRemove(server.name)
|
||||
setShowDeleteDialog(false)
|
||||
@@ -55,7 +111,60 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<h2 className={`text-xl font-semibold ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'}`}>{server.name}</h2>
|
||||
<Badge status={server.status} />
|
||||
<StatusBadge status={server.status} />
|
||||
|
||||
{server.error && (
|
||||
<div className="relative">
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
onClick={handleErrorIconClick}
|
||||
aria-label={t('server.viewErrorDetails')}
|
||||
>
|
||||
<AlertCircle className="text-red-500 hover:text-red-600" size={18} />
|
||||
</div>
|
||||
|
||||
{showErrorPopover && (
|
||||
<div
|
||||
ref={errorPopoverRef}
|
||||
className="absolute z-10 mt-2 bg-white border border-gray-200 rounded-md shadow-lg p-0 w-120"
|
||||
style={{
|
||||
left: '-231px',
|
||||
top: '24px',
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
width: '480px',
|
||||
transform: 'translateX(50%)'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex justify-between items-center sticky top-0 bg-white py-2 px-4 border-b border-gray-200 z-20 shadow-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h4 className="text-sm font-medium text-red-600">{t('server.errorDetails')}</h4>
|
||||
<button
|
||||
onClick={copyToClipboard}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
title={t('common.copy')}
|
||||
>
|
||||
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowErrorPopover(false)
|
||||
}}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 pt-2">
|
||||
<pre className="text-sm text-gray-700 break-words whitespace-pre-wrap">{server.error}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
|
||||
@@ -6,8 +6,8 @@ interface ContentProps {
|
||||
|
||||
const Content: React.FC<ContentProps> = ({ children }) => {
|
||||
return (
|
||||
<main className="flex-1 p-6 overflow-auto">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<main className="flex-1 overflow-auto p-6 bg-gray-100 dark:bg-gray-900">
|
||||
<div className="container mx-auto">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import ThemeSwitch from '@/components/ui/ThemeSwitch';
|
||||
|
||||
interface HeaderProps {
|
||||
onToggleSidebar: () => void;
|
||||
@@ -18,13 +19,13 @@ const Header: React.FC<HeaderProps> = ({ onToggleSidebar }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="bg-white shadow-sm z-10">
|
||||
<header className="bg-white dark:bg-gray-800 shadow-sm z-10">
|
||||
<div className="flex justify-between items-center px-4 py-3">
|
||||
<div className="flex items-center">
|
||||
{/* 侧边栏切换按钮 */}
|
||||
<button
|
||||
onClick={onToggleSidebar}
|
||||
className="p-2 rounded-md text-gray-500 hover:text-gray-900 hover:bg-gray-100 focus:outline-none"
|
||||
className="p-2 rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none"
|
||||
aria-label={t('app.toggleSidebar')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
@@ -33,13 +34,16 @@ const Header: React.FC<HeaderProps> = ({ onToggleSidebar }) => {
|
||||
</button>
|
||||
|
||||
{/* 应用标题 */}
|
||||
<h1 className="ml-4 text-xl font-bold text-gray-900">{t('app.title')}</h1>
|
||||
<h1 className="ml-4 text-xl font-bold text-gray-900 dark:text-white">{t('app.title')}</h1>
|
||||
</div>
|
||||
|
||||
{/* 用户信息和操作 */}
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Theme Switch */}
|
||||
<ThemeSwitch />
|
||||
|
||||
{auth.user && (
|
||||
<span className="text-sm text-gray-700">
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{t('app.welcomeUser', { username: auth.user.username })}
|
||||
</span>
|
||||
)}
|
||||
@@ -47,7 +51,7 @@ const Header: React.FC<HeaderProps> = ({ onToggleSidebar }) => {
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-3 py-1.5 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm"
|
||||
className="px-3 py-1.5 bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-100 rounded hover:bg-red-200 dark:hover:bg-red-800 text-sm"
|
||||
>
|
||||
{t('app.logout')}
|
||||
</button>
|
||||
|
||||
@@ -55,6 +55,15 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/logs',
|
||||
label: t('nav.logs'),
|
||||
icon: (
|
||||
<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 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clipRule="evenodd" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
label: t('nav.settings'),
|
||||
@@ -68,7 +77,7 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`bg-white shadow-sm transition-all duration-300 ease-in-out ${
|
||||
className={`bg-white dark:bg-gray-800 shadow-sm transition-all duration-300 ease-in-out ${
|
||||
collapsed ? 'w-16' : 'w-64'
|
||||
}`}
|
||||
>
|
||||
@@ -80,8 +89,8 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
|
||||
className={({ isActive }) =>
|
||||
`flex items-center px-3 py-2 rounded-md transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`
|
||||
}
|
||||
end={item.path === '/'}
|
||||
|
||||
@@ -1,25 +1,61 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ServerStatus } from '@/types'
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ServerStatus } from '@/types';
|
||||
import { cn } from '../../utils/cn';
|
||||
|
||||
interface BadgeProps {
|
||||
status: ServerStatus
|
||||
type BadgeVariant = 'default' | 'secondary' | 'outline' | 'destructive';
|
||||
|
||||
type BadgeProps = {
|
||||
children: React.ReactNode;
|
||||
variant?: BadgeVariant;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const badgeVariants = {
|
||||
default: 'bg-blue-500 text-white hover:bg-blue-600',
|
||||
secondary: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600',
|
||||
outline: 'bg-transparent border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800',
|
||||
destructive: 'bg-red-500 text-white hover:bg-red-600',
|
||||
};
|
||||
|
||||
export function Badge({
|
||||
children,
|
||||
variant = 'default',
|
||||
className,
|
||||
onClick
|
||||
}: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors',
|
||||
badgeVariants[variant],
|
||||
onClick ? 'cursor-pointer' : '',
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const Badge = ({ status }: BadgeProps) => {
|
||||
const { t } = useTranslation()
|
||||
// For backward compatibility with existing code
|
||||
export const StatusBadge = ({ status }: { status: 'connected' | 'disconnected' | 'connecting' }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const colors = {
|
||||
connecting: 'bg-yellow-100 text-yellow-800',
|
||||
connected: 'bg-green-100 text-green-800',
|
||||
disconnected: 'bg-red-100 text-red-800',
|
||||
}
|
||||
};
|
||||
|
||||
// Map status to translation keys
|
||||
const statusTranslations = {
|
||||
connected: 'status.online',
|
||||
disconnected: 'status.offline',
|
||||
connecting: 'status.connecting'
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
@@ -27,7 +63,5 @@ const Badge = ({ status }: BadgeProps) => {
|
||||
>
|
||||
{t(statusTranslations[status] || status)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default Badge
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../../utils/cn';
|
||||
|
||||
type ButtonVariant = 'default' | 'outline' | 'ghost' | 'link' | 'destructive';
|
||||
type ButtonSize = 'default' | 'sm' | 'lg' | 'icon';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
asChild?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const variantStyles: Record<ButtonVariant, string> = {
|
||||
default: 'bg-blue-500 text-white hover:bg-blue-600 focus:ring-blue-500',
|
||||
outline: 'border border-gray-300 dark:border-gray-700 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300',
|
||||
ghost: 'bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300',
|
||||
link: 'bg-transparent underline-offset-4 hover:underline text-blue-500 hover:text-blue-600',
|
||||
destructive: 'bg-red-500 text-white hover:bg-red-600 focus:ring-red-500',
|
||||
};
|
||||
|
||||
const sizeStyles: Record<ButtonSize, string> = {
|
||||
default: 'h-10 py-2 px-4',
|
||||
sm: 'h-8 px-3 text-sm',
|
||||
lg: 'h-12 px-6',
|
||||
icon: 'h-10 w-10 p-0',
|
||||
};
|
||||
|
||||
export function Button({
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
className,
|
||||
disabled,
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-md inline-flex items-center justify-center font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none',
|
||||
variantStyles[variant],
|
||||
sizeStyles[size],
|
||||
className
|
||||
)}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
51
frontend/src/components/ui/ThemeSwitch.tsx
Normal file
51
frontend/src/components/ui/ThemeSwitch.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { Sun, Moon, Monitor } from 'lucide-react';
|
||||
|
||||
const ThemeSwitch: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex bg-gray-200 dark:bg-gray-700 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setTheme('light')}
|
||||
className={`flex items-center justify-center rounded-md p-1.5 ${theme === 'light'
|
||||
? 'bg-white text-yellow-600 shadow'
|
||||
: 'text-black dark:text-gray-300 hover:text-yellow-600 dark:hover:text-yellow-500'
|
||||
}`}
|
||||
title={t('theme.light')}
|
||||
aria-label={t('theme.light')}
|
||||
>
|
||||
<Sun size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTheme('dark')}
|
||||
className={`flex items-center justify-center rounded-md p-1.5 ${theme === 'dark'
|
||||
? 'bg-gray-800 text-blue-400 shadow'
|
||||
: 'text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400'
|
||||
}`}
|
||||
title={t('theme.dark')}
|
||||
aria-label={t('theme.dark')}
|
||||
>
|
||||
<Moon size={18} />
|
||||
</button>
|
||||
{/* <button
|
||||
onClick={() => setTheme('system')}
|
||||
className={`flex items-center justify-center rounded-md p-1.5 ${theme === 'system'
|
||||
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow'
|
||||
: 'text-black dark:text-gray-300 hover:text-green-600 dark:hover:text-green-400'
|
||||
}`}
|
||||
title={t('theme.system')}
|
||||
aria-label={t('theme.system')}
|
||||
>
|
||||
<Monitor size={18} />
|
||||
</button> */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeSwitch;
|
||||
76
frontend/src/contexts/ThemeContext.tsx
Normal file
76
frontend/src/contexts/ThemeContext.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
resolvedTheme: 'light' | 'dark'; // The actual theme used after resolving system preference
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const ThemeProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
// Get theme from localStorage or default to 'system'
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const savedTheme = localStorage.getItem('theme') as Theme;
|
||||
return savedTheme || 'system';
|
||||
});
|
||||
|
||||
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
|
||||
|
||||
// Function to set theme and save to localStorage
|
||||
const handleSetTheme = (newTheme: Theme) => {
|
||||
setTheme(newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
};
|
||||
|
||||
// Effect to handle system theme changes and apply theme to document
|
||||
useEffect(() => {
|
||||
const updateTheme = () => {
|
||||
const root = window.document.documentElement;
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
|
||||
// Determine which theme to use
|
||||
const themeToApply = theme === 'system' ? systemTheme : theme;
|
||||
setResolvedTheme(themeToApply as 'light' | 'dark');
|
||||
|
||||
// Apply or remove dark class based on theme
|
||||
if (themeToApply === 'dark') {
|
||||
console.log('Applying dark mode to HTML root element'); // 添加日志
|
||||
root.classList.add('dark');
|
||||
document.body.style.backgroundColor = '#111827'; // Force a dark background to ensure visible effect
|
||||
} else {
|
||||
console.log('Removing dark mode from HTML root element'); // 添加日志
|
||||
root.classList.remove('dark');
|
||||
document.body.style.backgroundColor = ''; // Reset background color
|
||||
}
|
||||
};
|
||||
|
||||
// Set up listeners for system theme changes
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
mediaQuery.addEventListener('change', updateTheme);
|
||||
|
||||
// Initial theme setup
|
||||
updateTheme();
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', updateTheme);
|
||||
};
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme: handleSetTheme, resolvedTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Use standard Tailwind directives */
|
||||
/* Use project's custom Tailwind import */
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Add some custom styles to verify CSS is working correctly */
|
||||
@@ -11,6 +11,52 @@ body {
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Dark mode override styles - these will apply when dark class is on html element */
|
||||
.dark body {
|
||||
background-color: #111827;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.dark .bg-white {
|
||||
background-color: #1f2937 !important;
|
||||
}
|
||||
|
||||
.dark .text-gray-900 {
|
||||
color: #f9fafb !important;
|
||||
}
|
||||
|
||||
.dark .text-gray-800 {
|
||||
color: #f3f4f6 !important;
|
||||
}
|
||||
|
||||
.dark .text-gray-700 {
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
|
||||
.dark .text-gray-600 {
|
||||
color: #d1d5db !important;
|
||||
}
|
||||
|
||||
.dark .text-gray-500 {
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
|
||||
.dark .border-gray-300 {
|
||||
border-color: #4b5563 !important;
|
||||
}
|
||||
|
||||
.dark .bg-gray-100 {
|
||||
background-color: #374151 !important;
|
||||
}
|
||||
|
||||
.dark .bg-gray-50 {
|
||||
background-color: #1f2937 !important;
|
||||
}
|
||||
|
||||
.dark .shadow {
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.4), 0 1px 2px 0 rgba(0, 0, 0, 0.24) !important;
|
||||
}
|
||||
|
||||
.bg-custom-blue {
|
||||
background-color: #4a90e2;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ const MainLayout: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-gray-100">
|
||||
<div className="flex flex-col min-h-screen bg-gray-100 dark:bg-gray-900">
|
||||
{/* 顶部导航 */}
|
||||
<Header onToggleSidebar={toggleSidebar} />
|
||||
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
"welcomeUser": "Welcome, {{username}}",
|
||||
"name": "MCP Hub"
|
||||
},
|
||||
"theme": {
|
||||
"title": "Theme",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Login",
|
||||
"loginTitle": "Login to MCP Hub",
|
||||
@@ -68,7 +74,9 @@
|
||||
"namePlaceholder": "Enter server name",
|
||||
"urlPlaceholder": "Enter server URL",
|
||||
"commandPlaceholder": "Enter command",
|
||||
"argumentsPlaceholder": "Enter arguments"
|
||||
"argumentsPlaceholder": "Enter arguments",
|
||||
"errorDetails": "Error Details",
|
||||
"viewErrorDetails": "View error details"
|
||||
},
|
||||
"status": {
|
||||
"online": "Online",
|
||||
@@ -95,7 +103,9 @@
|
||||
"create": "Create",
|
||||
"submitting": "Submitting...",
|
||||
"delete": "Delete",
|
||||
"copy": "Copy"
|
||||
"copy": "Copy",
|
||||
"copySuccess": "Copied to clipboard",
|
||||
"copyFailed": "Copy failed"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -103,7 +113,8 @@
|
||||
"groups": "Groups",
|
||||
"settings": "Settings",
|
||||
"changePassword": "Change Password",
|
||||
"market": "Market"
|
||||
"market": "Market",
|
||||
"logs": "Logs"
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
@@ -130,8 +141,24 @@
|
||||
},
|
||||
"market": {
|
||||
"title": "Server Market - (Data from mcpm.sh)"
|
||||
},
|
||||
"logs": {
|
||||
"title": "System Logs"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
"filters": "Filters",
|
||||
"search": "Search logs...",
|
||||
"autoScroll": "Auto-scroll",
|
||||
"clearLogs": "Clear logs",
|
||||
"loading": "Loading logs...",
|
||||
"noLogs": "No logs available.",
|
||||
"noMatch": "No logs match the current filters.",
|
||||
"mainProcess": "Main Process",
|
||||
"childProcess": "Child Process",
|
||||
"main": "Main",
|
||||
"child": "Child"
|
||||
},
|
||||
"groups": {
|
||||
"add": "Add",
|
||||
"addNew": "Add New Group",
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
"welcomeUser": "欢迎, {{username}}",
|
||||
"name": "MCP Hub"
|
||||
},
|
||||
"theme": {
|
||||
"title": "主题",
|
||||
"light": "浅色",
|
||||
"dark": "深色",
|
||||
"system": "系统"
|
||||
},
|
||||
"auth": {
|
||||
"login": "登录",
|
||||
"loginTitle": "登录 MCP Hub",
|
||||
@@ -68,7 +74,9 @@
|
||||
"namePlaceholder": "请输入服务器名称",
|
||||
"urlPlaceholder": "请输入服务器URL",
|
||||
"commandPlaceholder": "请输入命令",
|
||||
"argumentsPlaceholder": "请输入参数"
|
||||
"argumentsPlaceholder": "请输入参数",
|
||||
"errorDetails": "错误详情",
|
||||
"viewErrorDetails": "查看错误详情"
|
||||
},
|
||||
"status": {
|
||||
"online": "在线",
|
||||
@@ -85,7 +93,8 @@
|
||||
"initialStartup": "服务器可能正在启动中。首次启动可能需要一些时间,请耐心等候...",
|
||||
"serverInstall": "安装服务器失败",
|
||||
"failedToFetchSettings": "获取设置失败",
|
||||
"failedToUpdateSystemConfig": "更新系统配置失败"
|
||||
"failedToUpdateSystemConfig": "更新系统配置失败",
|
||||
"failedToUpdateRouteConfig": "更新路由配置失败"
|
||||
},
|
||||
"common": {
|
||||
"processing": "处理中...",
|
||||
@@ -95,7 +104,9 @@
|
||||
"create": "创建",
|
||||
"submitting": "提交中...",
|
||||
"delete": "删除",
|
||||
"copy": "复制"
|
||||
"copy": "复制",
|
||||
"copySuccess": "已复制到剪贴板",
|
||||
"copyFailed": "复制失败"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "仪表盘",
|
||||
@@ -103,7 +114,8 @@
|
||||
"settings": "设置",
|
||||
"changePassword": "修改密码",
|
||||
"groups": "分组",
|
||||
"market": "市场"
|
||||
"market": "市场",
|
||||
"logs": "日志"
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
@@ -130,8 +142,24 @@
|
||||
},
|
||||
"market": {
|
||||
"title": "服务器市场 - (数据来源于 mcpm.sh)"
|
||||
},
|
||||
"logs": {
|
||||
"title": "系统日志"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
"filters": "筛选",
|
||||
"search": "搜索日志...",
|
||||
"autoScroll": "自动滚动",
|
||||
"clearLogs": "清除日志",
|
||||
"loading": "加载日志中...",
|
||||
"noLogs": "暂无日志。",
|
||||
"noMatch": "没有匹配当前筛选条件的日志。",
|
||||
"mainProcess": "主进程",
|
||||
"childProcess": "子进程",
|
||||
"main": "主",
|
||||
"child": "子"
|
||||
},
|
||||
"groups": {
|
||||
"add": "添加",
|
||||
"addNew": "添加新分组",
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ThemeSwitch from '@/components/ui/ThemeSwitch';
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -39,10 +40,13 @@ const LoginPage: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="absolute top-4 right-4">
|
||||
<ThemeSwitch />
|
||||
</div>
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||
{t('auth.loginTitle')}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -58,7 +62,7 @@ const LoginPage: React.FC = () => {
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
placeholder={t('auth.username')}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
@@ -74,7 +78,7 @@ const LoginPage: React.FC = () => {
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
placeholder={t('auth.password')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
@@ -83,7 +87,7 @@ const LoginPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-red-500 text-sm text-center">{error}</div>
|
||||
<div className="text-red-500 dark:text-red-400 text-sm text-center">{error}</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
|
||||
28
frontend/src/pages/LogsPage.tsx
Normal file
28
frontend/src/pages/LogsPage.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
// filepath: /Users/sunmeng/code/github/mcphub/frontend/src/pages/LogsPage.tsx
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LogViewer from '../components/LogViewer';
|
||||
import { useLogs } from '../services/logService';
|
||||
|
||||
const LogsPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { logs, loading, error, clearLogs } = useLogs();
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h1 className="text-2xl font-bold">{t('pages.logs.title')}</h1>
|
||||
</div>
|
||||
<div className="bg-card rounded-md shadow-sm">
|
||||
<LogViewer
|
||||
logs={logs}
|
||||
isLoading={loading}
|
||||
error={error}
|
||||
onClear={clearLogs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogsPage;
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Server } from '@/types';
|
||||
import ServerCard from '@/components/ServerCard';
|
||||
import AddServerForm from '@/components/AddServerForm';
|
||||
@@ -8,6 +9,7 @@ import { useServerData } from '@/hooks/useServerData';
|
||||
|
||||
const ServersPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
servers,
|
||||
error,
|
||||
@@ -31,6 +33,7 @@ const ServersPage: React.FC = () => {
|
||||
|
||||
const handleEditComplete = () => {
|
||||
setEditingServer(null);
|
||||
triggerRefresh();
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
@@ -49,6 +52,15 @@ const ServersPage: React.FC = () => {
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">{t('pages.servers.title')}</h1>
|
||||
<div className="flex space-x-4">
|
||||
<button
|
||||
onClick={() => navigate('/market')}
|
||||
className="px-4 py-2 bg-emerald-100 text-emerald-800 rounded hover:bg-emerald-200 flex items-center"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M3 1a1 1 0 000 2h1.22l.305 1.222a.997.997 0 00.01.042l1.358 5.43-.893.892C3.74 11.846 4.632 14 6.414 14H15a1 1 0 000-2H6.414l1-1H14a1 1 0 00.894-.553l3-6A1 1 0 0017 3H6.28l-.31-1.243A1 1 0 005 1H3z" />
|
||||
</svg>
|
||||
{t('nav.market')}
|
||||
</button>
|
||||
<AddServerForm onAdd={handleServerAdd} />
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
|
||||
@@ -51,13 +51,13 @@ const SettingsPage: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="container mx-auto">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-8">{t('pages.settings.title')}</h1>
|
||||
|
||||
{/* Language Settings */}
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-gray-800">{t('pages.settings.language')}</h2>
|
||||
<h2 className="font-semibold text-gray-800">{t('pages.settings.language')}</h2>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
className={`px-3 py-1.5 rounded-md transition-colors text-sm ${
|
||||
@@ -84,12 +84,12 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Route Configuration Settings */}
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('routingConfig')}
|
||||
>
|
||||
<h2 className="text-xl font-semibold text-gray-800">{t('pages.settings.routeConfig')}</h2>
|
||||
<h2 className="font-semibold text-gray-800">{t('pages.settings.routeConfig')}</h2>
|
||||
<span className="text-gray-500">
|
||||
{sectionsVisible.routingConfig ? '▼' : '►'}
|
||||
</span>
|
||||
@@ -125,12 +125,12 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Change Password */}
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('password')}
|
||||
>
|
||||
<h2 className="text-xl font-semibold text-gray-800">{t('auth.changePassword')}</h2>
|
||||
<h2 className="font-semibold text-gray-800">{t('auth.changePassword')}</h2>
|
||||
<span className="text-gray-500">
|
||||
{sectionsVisible.password ? '▼' : '►'}
|
||||
</span>
|
||||
|
||||
152
frontend/src/services/logService.ts
Normal file
152
frontend/src/services/logService.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getToken } from './authService'; // Import getToken function
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: number;
|
||||
type: 'info' | 'error' | 'warn' | 'debug';
|
||||
source: string;
|
||||
message: string;
|
||||
processId?: string;
|
||||
}
|
||||
|
||||
// Fetch all logs
|
||||
export const fetchLogs = async (): Promise<LogEntry[]> => {
|
||||
try {
|
||||
// Get authentication token
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
throw new Error('Authentication token not found. Please log in.');
|
||||
}
|
||||
|
||||
const response = await fetch('/api/logs', {
|
||||
headers: {
|
||||
'x-auth-token': token
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to fetch logs');
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching logs:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Clear all logs
|
||||
export const clearLogs = async (): Promise<void> => {
|
||||
try {
|
||||
// Get authentication token
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
throw new Error('Authentication token not found. Please log in.');
|
||||
}
|
||||
|
||||
const response = await fetch('/api/logs', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'x-auth-token': token
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to clear logs');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error clearing logs:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Hook to use logs with SSE streaming
|
||||
export const useLogs = () => {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let eventSource: EventSource | null = null;
|
||||
let isMounted = true;
|
||||
|
||||
const connectToLogStream = () => {
|
||||
try {
|
||||
// Close existing connection if any
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
|
||||
// Get the authentication token
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
setError(new Error('Authentication token not found. Please log in.'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Connect to SSE endpoint with auth token in URL
|
||||
eventSource = new EventSource(`/api/logs/stream?token=${token}`);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
if (!isMounted) return;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'initial') {
|
||||
setLogs(data.logs);
|
||||
setLoading(false);
|
||||
} else if (data.type === 'log') {
|
||||
setLogs(prevLogs => [...prevLogs, data.log]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error parsing SSE message:', err);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
if (!isMounted) return;
|
||||
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
// Attempt to reconnect after a delay
|
||||
setTimeout(connectToLogStream, 5000);
|
||||
}
|
||||
|
||||
setError(new Error('Connection to log stream lost, attempting to reconnect...'));
|
||||
};
|
||||
} catch (err) {
|
||||
if (!isMounted) return;
|
||||
setError(err instanceof Error ? err : new Error('Failed to connect to log stream'));
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial connection
|
||||
connectToLogStream();
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const clearAllLogs = async () => {
|
||||
try {
|
||||
await clearLogs();
|
||||
setLogs([]);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to clear logs'));
|
||||
}
|
||||
};
|
||||
|
||||
return { logs, loading, error, clearLogs: clearAllLogs };
|
||||
};
|
||||
@@ -82,6 +82,7 @@ export interface ServerConfig {
|
||||
export interface Server {
|
||||
name: string;
|
||||
status: ServerStatus;
|
||||
error?: string;
|
||||
tools?: Tool[];
|
||||
config?: ServerConfig;
|
||||
enabled?: boolean;
|
||||
|
||||
12
frontend/tailwind.config.js
Normal file
12
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class', // Use class strategy for dark mode
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
89
package.json
89
package.json
@@ -1,11 +1,24 @@
|
||||
{
|
||||
"name": "mcphub",
|
||||
"version": "0.0.1",
|
||||
"name": "@samanhappy/mcphub",
|
||||
"version": "0.0.27",
|
||||
"description": "A hub server for mcp servers",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"mcphub": "bin/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"bin",
|
||||
"mcp_settings.json",
|
||||
"servers.json",
|
||||
"frontend/dist",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "pnpm backend:build && pnpm frontend:build",
|
||||
"backend:build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"backend:dev": "tsx watch src/index.ts",
|
||||
"lint": "eslint . --ext .ts",
|
||||
@@ -14,36 +27,55 @@
|
||||
"frontend:dev": "cd frontend && vite",
|
||||
"frontend:build": "cd frontend && vite build",
|
||||
"frontend:preview": "cd frontend && vite preview",
|
||||
"dev": "concurrently \"pnpm backend:dev\" \"pnpm frontend:dev\""
|
||||
"dev": "concurrently \"pnpm backend:dev\" \"pnpm frontend:dev\"",
|
||||
"prepublishOnly": "npm run build && node scripts/verify-dist.js"
|
||||
},
|
||||
"keywords": [
|
||||
"typescript",
|
||||
"server"
|
||||
"server",
|
||||
"mcp",
|
||||
"model context protocol"
|
||||
],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.9.0",
|
||||
"@radix-ui/react-accordion": "^1.2.3",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@shadcn/ui": "^0.0.4",
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@types/react": "^19.0.12",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"@modelcontextprotocol/sdk": "^1.10.2",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-validator": "^7.2.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@radix-ui/react-accordion": "^1.2.3",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@shadcn/ui": "^0.0.4",
|
||||
"@tailwindcss/postcss": "^4.1.3",
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.5",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"@types/node": "^20.8.2",
|
||||
"@types/react": "^19.0.12",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.4",
|
||||
"@typescript-eslint/parser": "^6.7.4",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"concurrently": "^8.2.2",
|
||||
"eslint": "^8.50.0",
|
||||
"i18next": "^24.2.3",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jest": "^29.7.0",
|
||||
"lucide-react": "^0.486.0",
|
||||
"next": "^15.2.4",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.0.3",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^15.4.1",
|
||||
@@ -51,27 +83,14 @@
|
||||
"tailwind-merge": "^3.1.0",
|
||||
"tailwind-scrollbar-hide": "^2.0.0",
|
||||
"tailwindcss": "^4.0.17",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.3",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.5",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"@types/node": "^20.8.2",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.4",
|
||||
"@typescript-eslint/parser": "^6.7.4",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"concurrently": "^8.2.2",
|
||||
"eslint": "^8.50.0",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.0.3",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.4.18"
|
||||
"vite": "^5.4.18",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
}
|
||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -9,8 +9,8 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: ^1.9.0
|
||||
version: 1.9.0
|
||||
specifier: ^1.10.2
|
||||
version: 1.10.2
|
||||
'@radix-ui/react-accordion':
|
||||
specifier: ^1.2.3
|
||||
version: 1.2.3(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
@@ -867,8 +867,8 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.9':
|
||||
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
|
||||
|
||||
'@modelcontextprotocol/sdk@1.9.0':
|
||||
resolution: {integrity: sha512-Jq2EUCQpe0iyO5FGpzVYDNFR6oR53AIrwph9yWl7uSc7IWUMsrmpmSaTGra5hQNunXpM+9oit85p924jWuHzUA==}
|
||||
'@modelcontextprotocol/sdk@1.10.2':
|
||||
resolution: {integrity: sha512-rb6AMp2DR4SN+kc6L1ta2NCpApyA9WYNx3CrTSZvGxq9wH71bRur+zRqPfg0vQ9mjywR7qZdX2RGHOPq3ss+tA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@next/env@15.2.4':
|
||||
@@ -4268,7 +4268,7 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.0
|
||||
|
||||
'@modelcontextprotocol/sdk@1.9.0':
|
||||
'@modelcontextprotocol/sdk@1.10.2':
|
||||
dependencies:
|
||||
content-type: 1.0.5
|
||||
cors: 2.8.5
|
||||
|
||||
44
scripts/verify-dist.js
Executable file
44
scripts/verify-dist.js
Executable file
@@ -0,0 +1,44 @@
|
||||
// scripts/verify-dist.js
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const projectRoot = path.resolve(__dirname, '..');
|
||||
|
||||
// Check if frontend dist exists
|
||||
const frontendDistPath = path.join(projectRoot, 'frontend', 'dist');
|
||||
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
|
||||
|
||||
if (!fs.existsSync(frontendDistPath)) {
|
||||
console.error('❌ Error: frontend/dist directory does not exist!');
|
||||
console.error('Run "npm run frontend:build" to generate the frontend dist files.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(frontendIndexPath)) {
|
||||
console.error('❌ Error: frontend/dist/index.html does not exist!');
|
||||
console.error('Frontend build may be incomplete. Run "npm run frontend:build" again.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check if backend dist exists
|
||||
const backendDistPath = path.join(projectRoot, 'dist');
|
||||
const serverJsPath = path.join(backendDistPath, 'server.js');
|
||||
|
||||
if (!fs.existsSync(backendDistPath)) {
|
||||
console.error('❌ Error: dist directory does not exist!');
|
||||
console.error('Run "npm run backend:build" to generate the backend dist files.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(serverJsPath)) {
|
||||
console.error('❌ Error: dist/server.js does not exist!');
|
||||
console.error('Backend build may be incomplete. Run "npm run backend:build" again.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// All checks passed
|
||||
console.log('✅ Verification passed! Frontend and backend dist files are present.');
|
||||
console.log('📦 Package is ready for publishing.');
|
||||
@@ -1,19 +1,20 @@
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { McpSettings } from '../types/index.js';
|
||||
import { getConfigFilePath } from '../utils/path.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const defaultConfig = {
|
||||
port: process.env.PORT || 3000,
|
||||
initTimeout: process.env.INIT_TIMEOUT || 300000,
|
||||
timeout: process.env.REQUEST_TIMEOUT || 60000,
|
||||
mcpHubName: 'mcphub',
|
||||
mcpHubVersion: '0.0.1',
|
||||
};
|
||||
|
||||
export const getSettingsPath = (): string => {
|
||||
return path.resolve(process.cwd(), 'mcp_settings.json');
|
||||
return getConfigFilePath('mcp_settings.json', 'Settings');
|
||||
};
|
||||
|
||||
export const loadSettings = (): McpSettings => {
|
||||
@@ -42,4 +43,4 @@ export const expandEnvVars = (value: string): string => {
|
||||
return value.replace(/\$\{([^}]+)\}/g, (_, key) => process.env[key] || '');
|
||||
};
|
||||
|
||||
export default defaultConfig;
|
||||
export default defaultConfig;
|
||||
|
||||
55
src/controllers/logController.ts
Normal file
55
src/controllers/logController.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// filepath: /Users/sunmeng/code/github/mcphub/src/controllers/logController.ts
|
||||
import { Request, Response } from 'express';
|
||||
import logService from '../services/logService.js';
|
||||
|
||||
// Get all logs
|
||||
export const getAllLogs = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const logs = logService.getLogs();
|
||||
res.json({ success: true, data: logs });
|
||||
} catch (error) {
|
||||
console.error('Error getting logs:', error);
|
||||
res.status(500).json({ success: false, error: 'Error getting logs' });
|
||||
}
|
||||
};
|
||||
|
||||
// Clear all logs
|
||||
export const clearLogs = (req: Request, res: Response): void => {
|
||||
try {
|
||||
logService.clearLogs();
|
||||
res.json({ success: true, message: 'Logs cleared successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error clearing logs:', error);
|
||||
res.status(500).json({ success: false, error: 'Error clearing logs' });
|
||||
}
|
||||
};
|
||||
|
||||
// Stream logs via SSE
|
||||
export const streamLogs = (req: Request, res: Response): void => {
|
||||
try {
|
||||
// Set headers for SSE
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
});
|
||||
|
||||
// Send initial data
|
||||
const logs = logService.getLogs();
|
||||
res.write(`data: ${JSON.stringify({ type: 'initial', logs })}\n\n`);
|
||||
|
||||
// Subscribe to log events
|
||||
const unsubscribe = logService.subscribe((log) => {
|
||||
res.write(`data: ${JSON.stringify({ type: 'log', log })}\n\n`);
|
||||
});
|
||||
|
||||
// Handle client disconnect
|
||||
req.on('close', () => {
|
||||
unsubscribe();
|
||||
console.log('Client disconnected from log stream');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error streaming logs:', error);
|
||||
res.status(500).json({ success: false, error: 'Error streaming logs' });
|
||||
}
|
||||
};
|
||||
@@ -6,8 +6,10 @@ const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this';
|
||||
|
||||
// Middleware to authenticate JWT token
|
||||
export const auth = (req: Request, res: Response, next: NextFunction): void => {
|
||||
// Get token from header
|
||||
const token = req.header('x-auth-token');
|
||||
// Get token from header or query parameter
|
||||
const headerToken = req.header('x-auth-token');
|
||||
const queryToken = req.query.token as string;
|
||||
const token = headerToken || queryToken;
|
||||
|
||||
// Check if no token
|
||||
if (!token) {
|
||||
|
||||
@@ -1,8 +1,36 @@
|
||||
import express, { Request, Response, NextFunction } from 'express';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
import fs from 'fs';
|
||||
import { auth } from './auth.js';
|
||||
import { initializeDefaultUser } from '../models/User.js';
|
||||
|
||||
// Create __dirname equivalent for ES modules
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Try to find the correct frontend file path
|
||||
const findFrontendPath = (): string => {
|
||||
// First try development environment path
|
||||
const devPath = path.join(dirname(__dirname), 'frontend', 'dist', 'index.html');
|
||||
if (fs.existsSync(devPath)) {
|
||||
return path.join(dirname(__dirname), 'frontend', 'dist');
|
||||
}
|
||||
|
||||
// Try npm/npx installed path (remove /dist directory)
|
||||
const npmPath = path.join(dirname(dirname(__dirname)), 'frontend', 'dist', 'index.html');
|
||||
if (fs.existsSync(npmPath)) {
|
||||
return path.join(dirname(dirname(__dirname)), 'frontend', 'dist');
|
||||
}
|
||||
|
||||
// If none of the above paths exist, return the most reasonable default path and log a warning
|
||||
console.warn('Warning: Could not locate frontend files. Using default path.');
|
||||
return path.join(dirname(__dirname), 'frontend', 'dist');
|
||||
};
|
||||
|
||||
const frontendPath = findFrontendPath();
|
||||
|
||||
export const errorHandler = (
|
||||
err: Error,
|
||||
_req: Request,
|
||||
@@ -17,7 +45,8 @@ export const errorHandler = (
|
||||
};
|
||||
|
||||
export const initMiddlewares = (app: express.Application): void => {
|
||||
app.use(express.static('frontend/dist'));
|
||||
// Serve static files from the dynamically determined frontend path
|
||||
app.use(express.static(frontendPath));
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (req.path !== '/sse' && req.path !== '/messages') {
|
||||
@@ -36,7 +65,8 @@ export const initMiddlewares = (app: express.Application): void => {
|
||||
app.use('/api', auth);
|
||||
|
||||
app.get('/', (_req: Request, res: Response) => {
|
||||
res.sendFile(path.join(process.cwd(), 'frontend', 'dist', 'index.html'));
|
||||
// Serve the frontend application
|
||||
res.sendFile(path.join(frontendPath, 'index.html'));
|
||||
});
|
||||
|
||||
app.use(errorHandler);
|
||||
|
||||
@@ -35,6 +35,11 @@ import {
|
||||
getCurrentUser,
|
||||
changePassword
|
||||
} from '../controllers/authController.js';
|
||||
import {
|
||||
getAllLogs,
|
||||
clearLogs,
|
||||
streamLogs
|
||||
} from '../controllers/logController.js';
|
||||
import { auth } from '../middlewares/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -70,6 +75,11 @@ export const initRoutes = (app: express.Application): void => {
|
||||
router.get('/market/tags', getAllMarketTags);
|
||||
router.get('/market/tags/:tag', getMarketServersByTag);
|
||||
|
||||
// Log routes
|
||||
router.get('/logs', getAllLogs);
|
||||
router.delete('/logs', clearLogs);
|
||||
router.get('/logs/stream', streamLogs);
|
||||
|
||||
// Auth routes (these will NOT be protected by auth middleware)
|
||||
app.post('/auth/login', [
|
||||
check('username', 'Username is required').not().isEmpty(),
|
||||
|
||||
146
src/server.ts
146
src/server.ts
@@ -1,16 +1,27 @@
|
||||
import express from 'express';
|
||||
import config from './config/index.js';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import fs from 'fs';
|
||||
import { initMcpServer } from './services/mcpService.js';
|
||||
import { initMiddlewares } from './middlewares/index.js';
|
||||
import { initRoutes } from './routes/index.js';
|
||||
import { handleSseConnection, handleSseMessage } from './services/sseService.js';
|
||||
import { migrateUserData } from './utils/migration.js';
|
||||
import {
|
||||
handleSseConnection,
|
||||
handleSseMessage,
|
||||
handleMcpPostRequest,
|
||||
handleMcpOtherRequest,
|
||||
} from './services/sseService.js';
|
||||
import { initializeDefaultUser } from './models/User.js';
|
||||
|
||||
// Get the directory name in ESM
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
export class AppServer {
|
||||
private app: express.Application;
|
||||
private port: number | string;
|
||||
private frontendPath: string | null = null;
|
||||
|
||||
constructor() {
|
||||
this.app = express();
|
||||
@@ -19,9 +30,6 @@ export class AppServer {
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
try {
|
||||
// Migrate user data from users.json to mcp_settings.json if needed
|
||||
migrateUserData();
|
||||
|
||||
// Initialize default admin user if no users exist
|
||||
await initializeDefaultUser();
|
||||
|
||||
@@ -34,15 +42,17 @@ export class AppServer {
|
||||
console.log('MCP server initialized successfully');
|
||||
this.app.get('/sse/:group?', (req, res) => handleSseConnection(req, res));
|
||||
this.app.post('/messages', handleSseMessage);
|
||||
this.app.post('/mcp/:group?', handleMcpPostRequest);
|
||||
this.app.get('/mcp/:group?', handleMcpOtherRequest);
|
||||
this.app.delete('/mcp/:group?', handleMcpOtherRequest);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error initializing MCP server:', error);
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
this.app.get('*', (_req, res) => {
|
||||
res.sendFile(path.join(process.cwd(), 'frontend', 'dist', 'index.html'));
|
||||
});
|
||||
// Find and serve frontend
|
||||
this.findAndServeFrontend();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error initializing server:', error);
|
||||
@@ -50,15 +60,135 @@ export class AppServer {
|
||||
}
|
||||
}
|
||||
|
||||
private findAndServeFrontend(): void {
|
||||
// Find frontend path
|
||||
this.frontendPath = this.findFrontendDistPath();
|
||||
|
||||
if (this.frontendPath) {
|
||||
console.log(`Serving frontend from: ${this.frontendPath}`);
|
||||
this.app.use(express.static(this.frontendPath));
|
||||
|
||||
// Add the wildcard route for SPA
|
||||
if (fs.existsSync(path.join(this.frontendPath, 'index.html'))) {
|
||||
this.app.get('*', (_req, res) => {
|
||||
res.sendFile(path.join(this.frontendPath!, 'index.html'));
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.warn('Frontend dist directory not found. Server will run without frontend.');
|
||||
this.app.get('/', (_req, res) => {
|
||||
res
|
||||
.status(404)
|
||||
.send('Frontend not found. MCPHub API is running, but the UI is not available.');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.app.listen(this.port, () => {
|
||||
console.log(`Server is running on port ${this.port}`);
|
||||
if (this.frontendPath) {
|
||||
console.log(`Open http://localhost:${this.port} in your browser to access MCPHub UI`);
|
||||
} else {
|
||||
console.log(
|
||||
`MCPHub API is running on http://localhost:${this.port}, but the UI is not available`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getApp(): express.Application {
|
||||
return this.app;
|
||||
}
|
||||
|
||||
// Helper method to find frontend dist path in different environments
|
||||
private findFrontendDistPath(): string | null {
|
||||
// Debug flag for detailed logging
|
||||
const debug = process.env.DEBUG === 'true';
|
||||
|
||||
if (debug) {
|
||||
console.log('DEBUG: Current directory:', process.cwd());
|
||||
console.log('DEBUG: Script directory:', __dirname);
|
||||
}
|
||||
|
||||
// First, find the package root directory
|
||||
const packageRoot = this.findPackageRoot();
|
||||
|
||||
if (debug) {
|
||||
console.log('DEBUG: Using package root:', packageRoot);
|
||||
}
|
||||
|
||||
if (!packageRoot) {
|
||||
console.warn('Could not determine package root directory');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for frontend dist in the standard location
|
||||
const frontendDistPath = path.join(packageRoot, 'frontend', 'dist');
|
||||
|
||||
if (debug) {
|
||||
console.log(`DEBUG: Checking frontend at: ${frontendDistPath}`);
|
||||
}
|
||||
|
||||
if (
|
||||
fs.existsSync(frontendDistPath) &&
|
||||
fs.existsSync(path.join(frontendDistPath, 'index.html'))
|
||||
) {
|
||||
return frontendDistPath;
|
||||
}
|
||||
|
||||
console.warn('Frontend distribution not found at', frontendDistPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper method to find the package root (where package.json is located)
|
||||
private findPackageRoot(): string | null {
|
||||
const debug = process.env.DEBUG === 'true';
|
||||
|
||||
// Possible locations for package.json
|
||||
const possibleRoots = [
|
||||
// Standard npm package location
|
||||
path.resolve(__dirname, '..', '..'),
|
||||
// Current working directory
|
||||
process.cwd(),
|
||||
// When running from dist directory
|
||||
path.resolve(__dirname, '..'),
|
||||
// When installed via npx
|
||||
path.resolve(__dirname, '..', '..', '..'),
|
||||
];
|
||||
|
||||
// Special handling for npx
|
||||
if (process.argv[1] && process.argv[1].includes('_npx')) {
|
||||
const npxDir = path.dirname(process.argv[1]);
|
||||
possibleRoots.unshift(path.resolve(npxDir, '..'));
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.log('DEBUG: Checking for package.json in:', possibleRoots);
|
||||
}
|
||||
|
||||
for (const root of possibleRoots) {
|
||||
const packageJsonPath = path.join(root, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
if (pkg.name === 'mcphub' || pkg.name === '@samanhappy/mcphub') {
|
||||
if (debug) {
|
||||
console.log(`DEBUG: Found package.json at ${packageJsonPath}`);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
} catch (e) {
|
||||
if (debug) {
|
||||
console.error(`DEBUG: Failed to parse package.json at ${packageJsonPath}:`, e);
|
||||
}
|
||||
// Continue to the next potential root
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default AppServer;
|
||||
|
||||
204
src/services/logService.ts
Normal file
204
src/services/logService.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
// filepath: /Users/sunmeng/code/github/mcphub/src/services/logService.ts
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { EventEmitter } from 'events';
|
||||
import * as os from 'os';
|
||||
import * as process from 'process';
|
||||
|
||||
interface LogEntry {
|
||||
timestamp: number;
|
||||
type: 'info' | 'error' | 'warn' | 'debug';
|
||||
source: string;
|
||||
message: string;
|
||||
processId?: string;
|
||||
}
|
||||
|
||||
// ANSI color codes for console output
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
bright: '\x1b[1m',
|
||||
dim: '\x1b[2m',
|
||||
underscore: '\x1b[4m',
|
||||
blink: '\x1b[5m',
|
||||
reverse: '\x1b[7m',
|
||||
hidden: '\x1b[8m',
|
||||
|
||||
black: '\x1b[30m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
magenta: '\x1b[35m',
|
||||
cyan: '\x1b[36m',
|
||||
white: '\x1b[37m',
|
||||
|
||||
bgBlack: '\x1b[40m',
|
||||
bgRed: '\x1b[41m',
|
||||
bgGreen: '\x1b[42m',
|
||||
bgYellow: '\x1b[43m',
|
||||
bgBlue: '\x1b[44m',
|
||||
bgMagenta: '\x1b[45m',
|
||||
bgCyan: '\x1b[46m',
|
||||
bgWhite: '\x1b[47m'
|
||||
};
|
||||
|
||||
// Level colors for different log types
|
||||
const levelColors = {
|
||||
info: colors.green,
|
||||
error: colors.red,
|
||||
warn: colors.yellow,
|
||||
debug: colors.cyan
|
||||
};
|
||||
|
||||
// Maximum number of logs to keep in memory
|
||||
const MAX_LOGS = 1000;
|
||||
|
||||
class LogService {
|
||||
private logs: LogEntry[] = [];
|
||||
private logEmitter = new EventEmitter();
|
||||
private childProcesses: { [id: string]: ChildProcess } = {};
|
||||
private mainProcessId: string;
|
||||
private hostname: string;
|
||||
|
||||
constructor() {
|
||||
this.mainProcessId = process.pid.toString();
|
||||
this.hostname = os.hostname();
|
||||
this.overrideConsole();
|
||||
}
|
||||
|
||||
// Format a timestamp for display
|
||||
private formatTimestamp(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
// Format a log message for console output
|
||||
private formatLogMessage(type: 'info' | 'error' | 'warn' | 'debug', source: string, message: string, processId?: string): string {
|
||||
const timestamp = this.formatTimestamp(Date.now());
|
||||
const pid = processId || this.mainProcessId;
|
||||
const level = type.toUpperCase();
|
||||
const levelColor = levelColors[type];
|
||||
|
||||
return `${colors.dim}[${timestamp}]${colors.reset} ${levelColor}${colors.bright}[${level}]${colors.reset} ${colors.blue}[${pid}]${colors.reset} ${colors.magenta}[${source}]${colors.reset} ${message}`;
|
||||
}
|
||||
|
||||
// Override console methods to capture logs
|
||||
private overrideConsole() {
|
||||
const originalConsoleLog = console.log;
|
||||
const originalConsoleError = console.error;
|
||||
const originalConsoleWarn = console.warn;
|
||||
const originalConsoleDebug = console.debug;
|
||||
|
||||
console.log = (...args: any[]) => {
|
||||
const message = args.map(arg => this.formatArgument(arg)).join(' ');
|
||||
this.addLog('info', 'main', message);
|
||||
originalConsoleLog.apply(console, [this.formatLogMessage('info', 'main', message)]);
|
||||
};
|
||||
|
||||
console.error = (...args: any[]) => {
|
||||
const message = args.map(arg => this.formatArgument(arg)).join(' ');
|
||||
this.addLog('error', 'main', message);
|
||||
originalConsoleError.apply(console, [this.formatLogMessage('error', 'main', message)]);
|
||||
};
|
||||
|
||||
console.warn = (...args: any[]) => {
|
||||
const message = args.map(arg => this.formatArgument(arg)).join(' ');
|
||||
this.addLog('warn', 'main', message);
|
||||
originalConsoleWarn.apply(console, [this.formatLogMessage('warn', 'main', message)]);
|
||||
};
|
||||
|
||||
console.debug = (...args: any[]) => {
|
||||
const message = args.map(arg => this.formatArgument(arg)).join(' ');
|
||||
this.addLog('debug', 'main', message);
|
||||
originalConsoleDebug.apply(console, [this.formatLogMessage('debug', 'main', message)]);
|
||||
};
|
||||
}
|
||||
|
||||
// Format an argument for logging
|
||||
private formatArgument(arg: any): string {
|
||||
if (arg === null) return 'null';
|
||||
if (arg === undefined) return 'undefined';
|
||||
if (typeof arg === 'object') {
|
||||
try {
|
||||
return JSON.stringify(arg, null, 2);
|
||||
} catch (e) {
|
||||
return String(arg);
|
||||
}
|
||||
}
|
||||
return String(arg);
|
||||
}
|
||||
|
||||
// Add a log entry to the logs array
|
||||
private addLog(type: 'info' | 'error' | 'warn' | 'debug', source: string, message: string, processId?: string) {
|
||||
const log: LogEntry = {
|
||||
timestamp: Date.now(),
|
||||
type,
|
||||
source,
|
||||
message,
|
||||
processId: processId || this.mainProcessId
|
||||
};
|
||||
|
||||
this.logs.push(log);
|
||||
|
||||
// Limit the number of logs kept in memory
|
||||
if (this.logs.length > MAX_LOGS) {
|
||||
this.logs.shift();
|
||||
}
|
||||
|
||||
// Emit the log event for SSE subscribers
|
||||
this.logEmitter.emit('log', log);
|
||||
}
|
||||
|
||||
// Capture output from a child process
|
||||
public captureChildProcess(command: string, args: string[], processId: string): ChildProcess {
|
||||
const childProcess = spawn(command, args);
|
||||
this.childProcesses[processId] = childProcess;
|
||||
|
||||
childProcess.stdout.on('data', (data) => {
|
||||
const output = data.toString().trim();
|
||||
if (output) {
|
||||
this.addLog('info', 'child-process', output, processId);
|
||||
console.log(this.formatLogMessage('info', 'child-process', output, processId));
|
||||
}
|
||||
});
|
||||
|
||||
childProcess.stderr.on('data', (data) => {
|
||||
const output = data.toString().trim();
|
||||
if (output) {
|
||||
this.addLog('error', 'child-process', output, processId);
|
||||
console.error(this.formatLogMessage('error', 'child-process', output, processId));
|
||||
}
|
||||
});
|
||||
|
||||
childProcess.on('close', (code) => {
|
||||
const message = `Process exited with code ${code}`;
|
||||
this.addLog('info', 'child-process', message, processId);
|
||||
console.log(this.formatLogMessage('info', 'child-process', message, processId));
|
||||
delete this.childProcesses[processId];
|
||||
});
|
||||
|
||||
return childProcess;
|
||||
}
|
||||
|
||||
// Get all logs
|
||||
public getLogs(): LogEntry[] {
|
||||
return this.logs;
|
||||
}
|
||||
|
||||
// Subscribe to log events
|
||||
public subscribe(callback: (log: LogEntry) => void): () => void {
|
||||
this.logEmitter.on('log', callback);
|
||||
return () => {
|
||||
this.logEmitter.off('log', callback);
|
||||
};
|
||||
}
|
||||
|
||||
// Clear all logs
|
||||
public clearLogs(): void {
|
||||
this.logs = [];
|
||||
this.logEmitter.emit('clear');
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance
|
||||
const logService = new LogService();
|
||||
export default logService;
|
||||
@@ -1,10 +1,10 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { MarketServer } from '../types/index.js';
|
||||
import { getConfigFilePath } from '../utils/path.js';
|
||||
|
||||
// Get path to the servers.json file
|
||||
export const getServersJsonPath = (): string => {
|
||||
return path.resolve(process.cwd(), 'servers.json');
|
||||
return getConfigFilePath('servers.json', 'Servers');
|
||||
};
|
||||
|
||||
// Load all market servers from servers.json
|
||||
@@ -12,7 +12,15 @@ export const getMarketServers = (): Record<string, MarketServer> => {
|
||||
try {
|
||||
const serversJsonPath = getServersJsonPath();
|
||||
const data = fs.readFileSync(serversJsonPath, 'utf8');
|
||||
return JSON.parse(data);
|
||||
const serversObj = JSON.parse(data) as Record<string, MarketServer>;
|
||||
|
||||
const sortedEntries = Object.entries(serversObj).sort(([, serverA], [, serverB]) => {
|
||||
if (serverA.is_official && !serverB.is_official) return -1;
|
||||
if (!serverA.is_official && serverB.is_official) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return Object.fromEntries(sortedEntries);
|
||||
} catch (error) {
|
||||
console.error('Failed to load servers from servers.json:', error);
|
||||
return {};
|
||||
@@ -29,13 +37,13 @@ export const getMarketServerByName = (name: string): MarketServer | null => {
|
||||
export const getMarketCategories = (): string[] => {
|
||||
const servers = getMarketServers();
|
||||
const categories = new Set<string>();
|
||||
|
||||
|
||||
Object.values(servers).forEach((server) => {
|
||||
server.categories?.forEach((category) => {
|
||||
categories.add(category);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
return Array.from(categories).sort();
|
||||
};
|
||||
|
||||
@@ -43,25 +51,28 @@ export const getMarketCategories = (): string[] => {
|
||||
export const getMarketTags = (): string[] => {
|
||||
const servers = getMarketServers();
|
||||
const tags = new Set<string>();
|
||||
|
||||
|
||||
Object.values(servers).forEach((server) => {
|
||||
server.tags?.forEach((tag) => {
|
||||
tags.add(tag);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
return Array.from(tags).sort();
|
||||
};
|
||||
|
||||
// Search market servers by query
|
||||
export const searchMarketServers = (query: string): MarketServer[] => {
|
||||
const servers = getMarketServers();
|
||||
const searchTerms = query.toLowerCase().split(' ').filter(term => term.length > 0);
|
||||
|
||||
const searchTerms = query
|
||||
.toLowerCase()
|
||||
.split(' ')
|
||||
.filter((term) => term.length > 0);
|
||||
|
||||
if (searchTerms.length === 0) {
|
||||
return Object.values(servers);
|
||||
}
|
||||
|
||||
|
||||
return Object.values(servers).filter((server) => {
|
||||
// Search in name, display_name, description, categories, and tags
|
||||
const searchableText = [
|
||||
@@ -69,21 +80,23 @@ export const searchMarketServers = (query: string): MarketServer[] => {
|
||||
server.display_name,
|
||||
server.description,
|
||||
...(server.categories || []),
|
||||
...(server.tags || [])
|
||||
].join(' ').toLowerCase();
|
||||
|
||||
return searchTerms.some(term => searchableText.includes(term));
|
||||
...(server.tags || []),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
return searchTerms.some((term) => searchableText.includes(term));
|
||||
});
|
||||
};
|
||||
|
||||
// Filter market servers by category
|
||||
export const filterMarketServersByCategory = (category: string): MarketServer[] => {
|
||||
const servers = getMarketServers();
|
||||
|
||||
|
||||
if (!category) {
|
||||
return Object.values(servers);
|
||||
}
|
||||
|
||||
|
||||
return Object.values(servers).filter((server) => {
|
||||
return server.categories?.includes(category);
|
||||
});
|
||||
@@ -92,12 +105,12 @@ export const filterMarketServersByCategory = (category: string): MarketServer[]
|
||||
// Filter market servers by tag
|
||||
export const filterMarketServersByTag = (tag: string): MarketServer[] => {
|
||||
const servers = getMarketServers();
|
||||
|
||||
|
||||
if (!tag) {
|
||||
return Object.values(servers);
|
||||
}
|
||||
|
||||
|
||||
return Object.values(servers).filter((server) => {
|
||||
return server.tags?.includes(tag);
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,7 +6,6 @@ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
||||
import { ServerInfo, ServerConfig } from '../types/index.js';
|
||||
import { loadSettings, saveSettings, expandEnvVars } from '../config/index.js';
|
||||
import config from '../config/index.js';
|
||||
import { get } from 'http';
|
||||
import { getGroup } from './sseService.js';
|
||||
import { getServersInGroup } from './groupService.js';
|
||||
|
||||
@@ -14,7 +13,7 @@ let currentServer: Server;
|
||||
|
||||
export const initMcpServer = async (name: string, version: string): Promise<void> => {
|
||||
currentServer = createMcpServer(name, version);
|
||||
await registerAllTools(currentServer, true);
|
||||
await registerAllTools(currentServer, true, true);
|
||||
};
|
||||
|
||||
export const setMcpServer = (server: Server): void => {
|
||||
@@ -26,11 +25,11 @@ export const getMcpServer = (): Server => {
|
||||
};
|
||||
|
||||
export const notifyToolChanged = async () => {
|
||||
await registerAllTools(currentServer, true);
|
||||
await registerAllTools(currentServer, true, false);
|
||||
currentServer
|
||||
.sendToolListChanged()
|
||||
.catch((error) => {
|
||||
console.error('Failed to send tool list changed notification:', error);
|
||||
console.warn('Failed to send tool list changed notification:', error.message);
|
||||
})
|
||||
.then(() => {
|
||||
console.log('Tool list changed notification sent successfully');
|
||||
@@ -41,7 +40,7 @@ export const notifyToolChanged = async () => {
|
||||
let serverInfos: ServerInfo[] = [];
|
||||
|
||||
// Initialize MCP server clients
|
||||
export const initializeClientsFromSettings = (): ServerInfo[] => {
|
||||
export const initializeClientsFromSettings = (isInit: boolean): ServerInfo[] => {
|
||||
const settings = loadSettings();
|
||||
const existingServerInfos = serverInfos;
|
||||
serverInfos = [];
|
||||
@@ -53,6 +52,7 @@ export const initializeClientsFromSettings = (): ServerInfo[] => {
|
||||
serverInfos.push({
|
||||
name,
|
||||
status: 'disconnected',
|
||||
error: null,
|
||||
tools: [],
|
||||
createTime: Date.now(),
|
||||
enabled: false,
|
||||
@@ -84,11 +84,15 @@ export const initializeClientsFromSettings = (): ServerInfo[] => {
|
||||
args: conf.args,
|
||||
env: env,
|
||||
});
|
||||
transport.stderr?.on('data', (data) => {
|
||||
console.error(`Error from server ${name}: ${data}`);
|
||||
});
|
||||
} else {
|
||||
console.warn(`Skipping server '${name}': missing required configuration`);
|
||||
serverInfos.push({
|
||||
name,
|
||||
status: 'disconnected',
|
||||
error: 'Missing required configuration',
|
||||
tools: [],
|
||||
createTime: Date.now(),
|
||||
});
|
||||
@@ -108,16 +112,55 @@ export const initializeClientsFromSettings = (): ServerInfo[] => {
|
||||
},
|
||||
},
|
||||
);
|
||||
client.connect(transport, { timeout: Number(config.timeout) }).catch((error) => {
|
||||
console.error(`Failed to connect client for server ${name} by error: ${error}`);
|
||||
const serverInfo = getServerByName(name);
|
||||
if (serverInfo) {
|
||||
serverInfo.status = 'disconnected';
|
||||
}
|
||||
});
|
||||
const timeout = isInit ? Number(config.initTimeout) : Number(config.timeout);
|
||||
client
|
||||
.connect(transport, { timeout: timeout })
|
||||
.then(() => {
|
||||
console.log(`Successfully connected client for server: ${name}`);
|
||||
|
||||
client
|
||||
.listTools({}, { timeout: timeout })
|
||||
.then((tools) => {
|
||||
console.log(`Successfully listed ${tools.tools.length} tools for server: ${name}`);
|
||||
const serverInfo = getServerByName(name);
|
||||
if (!serverInfo) {
|
||||
console.warn(`Server info not found for server: ${name}`);
|
||||
return;
|
||||
}
|
||||
|
||||
serverInfo.tools = tools.tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description || '',
|
||||
inputSchema: tool.inputSchema || {},
|
||||
}));
|
||||
serverInfo.status = 'connected';
|
||||
serverInfo.error = null;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`Failed to list tools for server ${name} by error: ${error} with stack: ${error.stack}`,
|
||||
);
|
||||
const serverInfo = getServerByName(name);
|
||||
if (serverInfo) {
|
||||
serverInfo.status = 'disconnected';
|
||||
serverInfo.error = `Failed to list tools: ${error.stack} `;
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`Failed to connect client for server ${name} by error: ${error} with stack: ${error.stack}`,
|
||||
);
|
||||
const serverInfo = getServerByName(name);
|
||||
if (serverInfo) {
|
||||
serverInfo.status = 'disconnected';
|
||||
serverInfo.error = `Failed to connect: ${error.stack} `;
|
||||
}
|
||||
});
|
||||
serverInfos.push({
|
||||
name,
|
||||
status: 'connecting',
|
||||
error: null,
|
||||
tools: [],
|
||||
client,
|
||||
transport,
|
||||
@@ -130,42 +173,24 @@ export const initializeClientsFromSettings = (): ServerInfo[] => {
|
||||
};
|
||||
|
||||
// Register all MCP tools
|
||||
export const registerAllTools = async (server: Server, forceInit: boolean): Promise<void> => {
|
||||
initializeClientsFromSettings();
|
||||
for (const serverInfo of serverInfos) {
|
||||
if (serverInfo.status === 'connected' && !forceInit) continue;
|
||||
if (!serverInfo.client || !serverInfo.transport) continue;
|
||||
|
||||
try {
|
||||
serverInfo.status = 'connecting';
|
||||
console.log(`Connecting to server: ${serverInfo.name}...`);
|
||||
const tools = await serverInfo.client.listTools({}, { timeout: Number(config.timeout) });
|
||||
serverInfo.tools = tools.tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description || '',
|
||||
inputSchema: tool.inputSchema || {},
|
||||
}));
|
||||
|
||||
serverInfo.status = 'connected';
|
||||
console.log(`Successfully connected to server: ${serverInfo.name}`);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to connect to server for client: ${serverInfo.name} by error: ${error}`,
|
||||
);
|
||||
serverInfo.status = 'disconnected';
|
||||
}
|
||||
}
|
||||
export const registerAllTools = async (
|
||||
server: Server,
|
||||
forceInit: boolean,
|
||||
isInit: boolean,
|
||||
): Promise<void> => {
|
||||
initializeClientsFromSettings(isInit);
|
||||
};
|
||||
|
||||
// Get all server information
|
||||
export const getServersInfo = (): Omit<ServerInfo, 'client' | 'transport'>[] => {
|
||||
const settings = loadSettings();
|
||||
const infos = serverInfos.map(({ name, status, tools, createTime }) => {
|
||||
const infos = serverInfos.map(({ name, status, tools, createTime, error }) => {
|
||||
const serverConfig = settings.mcpServers[name];
|
||||
const enabled = serverConfig ? serverConfig.enabled !== false : true;
|
||||
return {
|
||||
name,
|
||||
status,
|
||||
error,
|
||||
tools,
|
||||
createTime,
|
||||
enabled,
|
||||
@@ -204,7 +229,7 @@ export const addServer = async (
|
||||
return { success: false, message: 'Failed to save settings' };
|
||||
}
|
||||
|
||||
registerAllTools(currentServer, false);
|
||||
registerAllTools(currentServer, false, false);
|
||||
return { success: true, message: 'Server added successfully' };
|
||||
} catch (error) {
|
||||
console.error(`Failed to add server: ${name}`, error);
|
||||
@@ -340,9 +365,6 @@ export const createMcpServer = (name: string, version: string): Server => {
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request, _) => {
|
||||
console.log(`Handling CallToolRequest for tool: ${request.params.name}`);
|
||||
try {
|
||||
if (!request.params.arguments) {
|
||||
throw new Error('Arguments are required');
|
||||
}
|
||||
const serverInfo = getServerByTool(request.params.name);
|
||||
if (!serverInfo) {
|
||||
throw new Error(`Server not found: ${request.params.name}`);
|
||||
@@ -356,7 +378,15 @@ export const createMcpServer = (name: string, version: string): Server => {
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Error handling CallToolRequest: ${error}`);
|
||||
return { error: `Failed to call tool: ${error}` };
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error: ${error}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
return server;
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { getMcpServer } from './mcpService.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
|
||||
const transports: { [sessionId: string]: { transport: SSEServerTransport; group: string } } = {};
|
||||
const transports: { [sessionId: string]: { transport: Transport; group: string } } = {};
|
||||
|
||||
export const getGroup = (sessionId: string): string => {
|
||||
return transports[sessionId]?.group || '';
|
||||
@@ -44,13 +48,72 @@ export const handleSseMessage = async (req: Request, res: Response): Promise<voi
|
||||
req.query.group = group;
|
||||
console.log(`Received message for sessionId: ${sessionId} in group: ${group}`);
|
||||
if (transport) {
|
||||
await transport.handlePostMessage(req, res);
|
||||
await (transport as SSEServerTransport).handlePostMessage(req, res);
|
||||
} else {
|
||||
console.error(`No transport found for sessionId: ${sessionId}`);
|
||||
res.status(400).send('No transport found for sessionId');
|
||||
}
|
||||
};
|
||||
|
||||
export const handleMcpPostRequest = async (req: Request, res: Response): Promise<void> => {
|
||||
console.log('Handling MCP post request');
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
const group = req.params.group;
|
||||
const settings = loadSettings();
|
||||
const routingConfig = settings.systemConfig?.routing || {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
};
|
||||
if (!group && !routingConfig.enableGlobalRoute) {
|
||||
res.status(403).send('Global routes are disabled. Please specify a group ID.');
|
||||
return;
|
||||
}
|
||||
|
||||
let transport: StreamableHTTPServerTransport;
|
||||
if (sessionId && transports[sessionId]) {
|
||||
transport = transports[sessionId].transport as StreamableHTTPServerTransport;
|
||||
} else if (!sessionId && isInitializeRequest(req.body)) {
|
||||
transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: (sessionId) => {
|
||||
transports[sessionId] = { transport, group };
|
||||
},
|
||||
});
|
||||
|
||||
transport.onclose = () => {
|
||||
if (transport.sessionId) {
|
||||
delete transports[transport.sessionId];
|
||||
}
|
||||
};
|
||||
|
||||
await getMcpServer().connect(transport);
|
||||
} else {
|
||||
res.status(400).json({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32000,
|
||||
message: 'Bad Request: No valid session ID provided',
|
||||
},
|
||||
id: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
};
|
||||
|
||||
export const handleMcpOtherRequest = async (req: Request, res: Response) => {
|
||||
console.log('Handling MCP other request');
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
if (!sessionId || !transports[sessionId]) {
|
||||
res.status(400).send('Invalid or missing session ID');
|
||||
return;
|
||||
}
|
||||
|
||||
const { transport } = transports[sessionId];
|
||||
await (transport as StreamableHTTPServerTransport).handleRequest(req, res);
|
||||
};
|
||||
|
||||
export const getConnectionCount = (): number => {
|
||||
return Object.keys(transports).length;
|
||||
};
|
||||
|
||||
@@ -100,6 +100,7 @@ export interface ServerConfig {
|
||||
export interface ServerInfo {
|
||||
name: string; // Unique name of the server
|
||||
status: 'connected' | 'connecting' | 'disconnected'; // Current connection status
|
||||
error: string | null; // Error message if any
|
||||
tools: ToolInfo[]; // List of tools available on the server
|
||||
client?: Client; // Client instance for communication
|
||||
transport?: SSEClientTransport | StdioClientTransport; // Transport mechanism used
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
// filepath: /Users/sunmeng/code/github/mcphub/src/utils/migration.ts
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { loadSettings, saveSettings } from '../config/index.js';
|
||||
import { IUser } from '../types/index.js';
|
||||
|
||||
/**
|
||||
* Migrates user data from the old users.json file to mcp_settings.json
|
||||
* This is a one-time migration to support the refactoring from separate
|
||||
* users.json to integrated user data in mcp_settings.json
|
||||
*/
|
||||
export const migrateUserData = (): void => {
|
||||
const oldUsersFilePath = path.join(process.cwd(), 'data', 'users.json');
|
||||
|
||||
// Check if the old users file exists
|
||||
if (fs.existsSync(oldUsersFilePath)) {
|
||||
try {
|
||||
// Read users from the old file
|
||||
const usersData = fs.readFileSync(oldUsersFilePath, 'utf8');
|
||||
const users = JSON.parse(usersData) as IUser[];
|
||||
|
||||
if (users && Array.isArray(users) && users.length > 0) {
|
||||
console.log(`Migrating ${users.length} users from users.json to mcp_settings.json`);
|
||||
|
||||
// Load current settings
|
||||
const settings = loadSettings();
|
||||
|
||||
// Merge users, giving priority to existing settings users
|
||||
const existingUsernames = new Set((settings.users || []).map(u => u.username));
|
||||
const newUsers = users.filter(u => !existingUsernames.has(u.username));
|
||||
|
||||
settings.users = [...(settings.users || []), ...newUsers];
|
||||
|
||||
// Save updated settings
|
||||
if (saveSettings(settings)) {
|
||||
console.log('User data migration completed successfully');
|
||||
|
||||
// Rename the old file as backup
|
||||
const backupPath = `${oldUsersFilePath}.bak.${Date.now()}`;
|
||||
fs.renameSync(oldUsersFilePath, backupPath);
|
||||
console.log(`Renamed old users file to ${backupPath}`);
|
||||
}
|
||||
} else {
|
||||
console.log('No users found in users.json, skipping migration');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during user data migration:', error);
|
||||
}
|
||||
} else {
|
||||
console.log('users.json not found, no migration needed');
|
||||
}
|
||||
};
|
||||
42
src/utils/path.ts
Normal file
42
src/utils/path.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
|
||||
// Get current file's directory
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
// Project root directory should be the parent directory of src
|
||||
const rootDir = dirname(dirname(__dirname));
|
||||
|
||||
/**
|
||||
* Find the path to a configuration file by checking multiple potential locations.
|
||||
* @param filename The name of the file to locate (e.g., 'servers.json', 'mcp_settings.json')
|
||||
* @param description Brief description of the file for logging purposes
|
||||
* @returns The path to the file
|
||||
*/
|
||||
export const getConfigFilePath = (filename: string, description = 'Configuration'): string => {
|
||||
// Try to find the correct path to the file
|
||||
const potentialPaths = [
|
||||
// Prioritize process.cwd() as the first location to check
|
||||
path.resolve(process.cwd(), filename),
|
||||
// Use path relative to the root directory
|
||||
path.join(rootDir, filename),
|
||||
// If installed with npx, may need to look one level up
|
||||
path.join(dirname(rootDir), filename)
|
||||
];
|
||||
|
||||
for (const filePath of potentialPaths) {
|
||||
if (fs.existsSync(filePath)) {
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
|
||||
// If all paths do not exist, use default path
|
||||
// Using the default path is acceptable because it ensures the application can proceed
|
||||
// even if the configuration file is missing. This fallback is particularly useful in
|
||||
// development environments or when the file is optional.
|
||||
const defaultPath = path.resolve(process.cwd(), filename);
|
||||
console.debug(`${description} file not found at any expected location, using default path: ${defaultPath}`);
|
||||
return defaultPath;
|
||||
};
|
||||
Reference in New Issue
Block a user