Compare commits

...

13 Commits

Author SHA1 Message Date
samanhappy
4379513a35 feat: add GitHub Actions workflow for publishing to NPM (#49) 2025-05-05 19:39:09 +08:00
samanhappy
9a06bae225 fix: improve error handling in CallToolRequest by providing detailed error messages (#48) 2025-05-05 19:09:02 +08:00
samanhappy
e5aaae466f feat: add log management features including log viewing, filtering, and streaming (#45) 2025-05-02 21:41:16 +08:00
samanhappy
9b1338a356 feat: add support for HTTP_PROXY and HTTPS_PROXY environment variables in Dockerfile and entrypoint script (#42) 2025-05-02 12:40:44 +08:00
samanhappy
10d4616601 Implement theme switching and enhance dark mode support (#43) 2025-05-02 12:40:11 +08:00
samanhappy
9ca242a0e4 docs: update README and README.zh to clarify Streamable HTTP endpoint support (#41) 2025-05-01 17:42:46 +08:00
samanhappy
0a6259decf feat: enhance configuration file handling and dynamic frontend path resolution (#40)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-04-30 22:38:21 +08:00
samanhappy
7887a3a5f9 docs: update README to reflect changes in HTTP endpoint support and deprecate SSE endpoint (#38) 2025-04-28 14:49:40 +08:00
samanhappy
7f33615161 feat: support Streamable HTTP transport for downstream (#32) 2025-04-27 13:55:25 +08:00
samanhappy
c9ec3b77ce fix: sort market servers by official status (#36) 2025-04-26 21:17:05 +08:00
samanhappy
142c3f628a Update group chat invitation details (#34) 2025-04-25 10:00:56 +08:00
samanhappy
bbb99b6f17 fix: enhance MCP server tool registration and client initialization logic (#31) 2025-04-23 16:27:21 +08:00
samanhappy
c1eabb5607 fix: improve client connection handling and tool listing in mcpService (#30) 2025-04-23 15:46:06 +08:00
49 changed files with 1928 additions and 275 deletions

61
.github/workflows/npm-publish.yml vendored Normal file
View 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 }}

View File

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

View File

@@ -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.
![Dashboard Preview](assets/dashboard.png)
@@ -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)**:
![Group Management](assets/group.png)
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)**:
![Group Management](assets/group.png)
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

View File

@@ -2,7 +2,7 @@
[English Version](README.md) | 中文版
MCPHub 是一个统一的 MCPModel Context Protocol模型上下文协议服务器聚合平台可以根据场景将多个服务器聚合到不同的 SSE 端点。它通过直观的界面和强大的协议处理能力,简化了您的 AI 工具集成流程。
MCPHub 是一个统一的 MCPModel Context Protocol模型上下文协议服务器聚合平台可以根据场景将多个服务器聚合到不同的流式 HTTPSSE端点。它通过直观的界面和强大的协议处理能力,简化了您的 AI 工具集成流程。
![控制面板预览](assets/dashboard.zh.png)
@@ -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 端点(推荐)**
![分组](assets/group.zh.png)
要针对特定服务器分组进行访问,请使用基于分组的 HTTP 端点:
```
http://localhost:3000/mcp/{group}
```
其中 `{group}` 是您在控制面板中创建的分组 ID 或名称。这样做可以:
- 连接到按用例组织的特定 MCP 服务器子集
- 隔离不同的 AI 工具,使其只能访问相关服务器
- 为不同环境或团队实现更精细的访问控制
- 通过分组名称轻松识别和管理服务器
- 允许不同的 AI 客户端使用相同的端点,简化集成过程
### SSE 端点集成 (即将废弃)
通过以下地址连接 AI 客户端(如 Claude Desktop、Cursor、DeepChat 等):
```
http://localhost:3000/sse
```
**基于分组的 SSE 端点(推荐)**
![分组](assets/group.zh.png)
要针对特定服务器分组进行访问,请使用基于分组的 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

96
bin/cli.js Executable file
View 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);
});

View File

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

View File

@@ -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>
);
}

View File

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

View File

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

View File

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

View File

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

View 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;

View File

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

View File

@@ -1,10 +1,11 @@
import { useState } from 'react'
import { useState, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Server } from '@/types'
import { ChevronDown, ChevronRight } from '@/components/icons/LucideIcons'
import 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

View File

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

View File

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

View File

@@ -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 === '/'}

View File

@@ -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
);
};

View File

@@ -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>
);
}

View 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;

View 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>
);
};

View File

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

View File

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

View File

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

View File

@@ -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": "添加新分组",

View File

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

View 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;

View File

@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Server } from '@/types';
import ServerCard from '@/components/ServerCard';
import AddServerForm from '@/components/AddServerForm';
@@ -8,6 +9,7 @@ import { useServerData } from '@/hooks/useServerData';
const ServersPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const {
servers,
error,
@@ -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}

View File

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

View 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 };
};

View File

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

View File

@@ -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: [],
}

View File

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

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

44
scripts/verify-dist.js Executable file
View 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.');

View File

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

View 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' });
}
};

View File

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

View File

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

View File

@@ -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(),

View File

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

View File

@@ -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);
});
};
};

View File

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

View File

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

View File

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

View File

@@ -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
View 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;
};