Compare commits

..

5 Commits

Author SHA1 Message Date
Copilot
3e9e5cc3c9 feat: Auto-start Docker daemon when installed in container (#370)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
Co-authored-by: samanhappy <samanhappy@gmail.com>
2025-10-13 22:38:13 +08:00
samanhappy
16a92096b3 feat: Enhance package root detection and version retrieval using ESM-compatible methods (#371) 2025-10-13 22:36:29 +08:00
Copilot
4d736c543d feat: Add MCP settings export and copy functionality (#367)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
Co-authored-by: samanhappy <samanhappy@gmail.com>
2025-10-13 19:39:01 +08:00
samanhappy
f53c4a0e3b fix: assign server name from key in getMarketServers function (#369) 2025-10-13 18:19:21 +08:00
Copilot
d4bdb099d0 Add Docker CLI support to Docker image with INSTALL_EXT build argument (#366)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-12 16:51:02 +08:00
28 changed files with 1163 additions and 641 deletions

124
.github/DOCKER_CLI_TEST.md vendored Normal file
View File

@@ -0,0 +1,124 @@
# Docker Engine Installation Test Procedure
This document describes how to test the Docker Engine installation feature added with the `INSTALL_EXT=true` build argument.
## Test 1: Build with INSTALL_EXT=false (default)
```bash
# Build without extended features
docker build -t mcphub:base .
# Run the container
docker run --rm mcphub:base docker --version
```
**Expected Result**: `docker: not found` error (Docker is NOT installed)
## Test 2: Build with INSTALL_EXT=true
```bash
# Build with extended features
docker build --build-arg INSTALL_EXT=true -t mcphub:extended .
# Test Docker CLI is available
docker run --rm mcphub:extended docker --version
```
**Expected Result**: Docker version output (e.g., `Docker version 27.x.x, build xxxxx`)
## Test 3: Docker-in-Docker with Auto-start Daemon
```bash
# Build with extended features
docker build --build-arg INSTALL_EXT=true -t mcphub:extended .
# Run with privileged mode (allows Docker daemon to start)
docker run -d \
--name mcphub-test \
--privileged \
-p 3000:3000 \
mcphub:extended
# Wait a few seconds for daemon to start
sleep 5
# Test Docker commands from inside the container
docker exec mcphub-test docker ps
docker exec mcphub-test docker images
docker exec mcphub-test docker info
# Cleanup
docker stop mcphub-test
docker rm mcphub-test
```
**Expected Result**:
- Docker daemon should auto-start inside the container
- Docker commands should work without mounting the host's Docker socket
- `docker info` should show the container's own Docker daemon
## Test 4: Docker-in-Docker with Host Socket (Alternative)
```bash
# Build with extended features
docker build --build-arg INSTALL_EXT=true -t mcphub:extended .
# Run with Docker socket mounted (uses host's daemon)
docker run -d \
--name mcphub-test \
-p 3000:3000 \
-v /var/run/docker.sock:/var/run/docker.sock \
mcphub:extended
# Test Docker commands from inside the container
docker exec mcphub-test docker ps
docker exec mcphub-test docker images
# Cleanup
docker stop mcphub-test
docker rm mcphub-test
```
**Expected Result**:
- Docker daemon should NOT auto-start (socket already exists from host)
- Docker commands should work and show the host's containers and images
## Test 5: Verify Image Size
```bash
# Build both versions
docker build -t mcphub:base .
docker build --build-arg INSTALL_EXT=true -t mcphub:extended .
# Compare image sizes
docker images mcphub:*
```
**Expected Result**:
- The `extended` image should be larger than the `base` image
- The size difference should be reasonable (Docker Engine adds ~100-150MB)
## Test 6: Architecture Support
```bash
# On AMD64/x86_64
docker build --build-arg INSTALL_EXT=true --platform linux/amd64 -t mcphub:extended-amd64 .
# On ARM64
docker build --build-arg INSTALL_EXT=true --platform linux/arm64 -t mcphub:extended-arm64 .
```
**Expected Result**:
- Both builds should succeed
- AMD64 includes Chrome/Playwright + Docker Engine
- ARM64 includes Docker Engine only (Chrome installation is skipped)
## Notes
- The Docker Engine installation follows the official Docker documentation
- Includes full Docker daemon (`dockerd`), CLI (`docker`), and containerd
- The daemon auto-starts when running in privileged mode
- The installation uses the Debian Bookworm repository
- All temporary files are cleaned up to minimize image size
- The feature is opt-in via the `INSTALL_EXT` build argument
- `iptables` is installed as it's required for Docker networking

View File

@@ -1,7 +1,7 @@
{
"semi": true,
"semi": false,
"trailingComma": "all",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2
}
}

View File

@@ -22,6 +22,16 @@ RUN if [ "$INSTALL_EXT" = "true" ]; then \
else \
echo "Skipping Chrome installation on non-amd64 architecture: $ARCH"; \
fi; \
# Install Docker Engine (includes CLI and daemon) \
apt-get update && \
apt-get install -y ca-certificates curl iptables && \
install -m 0755 -d /etc/apt/keyrings && \
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc && \
chmod a+r /etc/apt/keyrings/docker.asc && \
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null && \
apt-get update && \
apt-get install -y docker-ce docker-ce-cli containerd.io && \
apt-get clean && rm -rf /var/lib/apt/lists/*; \
fi
RUN uv tool install mcp-server-fetch

View File

@@ -41,6 +41,50 @@ docker run -d \
mcphub:local
```
### Building with Extended Features
The Docker image supports an `INSTALL_EXT` build argument to include additional tools:
```bash
# Build with extended features (includes Docker Engine, Chrome/Playwright)
docker build --build-arg INSTALL_EXT=true -t mcphub:extended .
# Option 1: Run with automatic Docker-in-Docker (requires privileged mode)
docker run -d \
--name mcphub \
--privileged \
-p 3000:3000 \
-v $(pwd)/mcp_settings.json:/app/mcp_settings.json \
mcphub:extended
# Option 2: Run with Docker socket mounted (use host's Docker daemon)
docker run -d \
--name mcphub \
-p 3000:3000 \
-v $(pwd)/mcp_settings.json:/app/mcp_settings.json \
-v /var/run/docker.sock:/var/run/docker.sock \
mcphub:extended
# Verify Docker is available
docker exec mcphub docker --version
docker exec mcphub docker ps
```
<Note>
**What's included with INSTALL_EXT=true:**
- **Docker Engine**: Full Docker daemon with CLI for container management. The daemon auto-starts when the container runs in privileged mode.
- **Chrome/Playwright** (amd64 only): For browser automation tasks
The extended image is larger but provides additional capabilities for advanced use cases.
</Note>
<Warning>
**Docker-in-Docker Security Considerations:**
- **Privileged mode** (`--privileged`): Required for the Docker daemon to start inside the container. This gives the container elevated permissions on the host.
- **Docker socket mounting** (`/var/run/docker.sock`): Gives the container access to the host's Docker daemon. Both approaches should only be used in trusted environments.
- For production, consider using Docker socket mounting instead of privileged mode for better security.
</Warning>
## Docker Compose Setup
### Basic Configuration

View File

@@ -47,8 +47,7 @@ MCPHub uses several configuration files:
},
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest", "--headless", "--isolated"],
"perSession": true
"args": ["@playwright/mcp@latest", "--headless"]
},
"slack": {
"command": "npx",
@@ -76,42 +75,6 @@ MCPHub uses several configuration files:
| Field | Type | Default | Description |
| -------------- | ------- | --------------- | --------------------------- |
| `env` | object | `{}` | Environment variables |
| `perSession` | boolean | `false` | Create separate server instance per user session (for stateful servers like playwright) |
| `enabled` | boolean | `true` | Enable/disable the server |
| `timeout` | number | `60000` | Request timeout in milliseconds |
| `keepAliveInterval` | number | `60000` | Keep-alive ping interval for SSE servers (ms) |
## Per-Session Server Instances
Some MCP servers maintain state that should be isolated between different users. For example, the Playwright server maintains browser sessions that could leak form data or other state between concurrent users.
To prevent this, you can set `perSession: true` in the server configuration. This creates a separate server instance for each user session instead of sharing a single instance across all users.
### When to Use Per-Session Servers
Use `perSession: true` for servers that:
- Maintain browser state (like Playwright)
- Store user-specific data in memory
- Have file handles or database connections that shouldn't be shared
- Could cause race conditions when multiple users access simultaneously
### Example Configuration
```json
{
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest", "--headless", "--isolated"],
"perSession": true
}
}
```
**Important Notes:**
- Each per-session server instance consumes additional resources (memory, CPU)
- Per-session servers are automatically cleaned up when the user session ends
- For Playwright, also use the `--isolated` flag to ensure browser contexts are isolated
- Not recommended for stateless servers that can safely be shared
## Common MCP Server Examples
@@ -138,9 +101,8 @@ Use `perSession: true` for servers that:
{
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest", "--headless", "--isolated"],
"args": ["@playwright/mcp@latest", "--headless"],
"timeout": 60000,
"perSession": true,
"env": {
"PLAYWRIGHT_BROWSERS_PATH": "/tmp/browsers"
}
@@ -148,8 +110,6 @@ Use `perSession: true` for servers that:
}
```
**Note**: The `--isolated` flag ensures each browser session is isolated, and `perSession: true` creates a separate server instance for each user session, preventing state leakage between concurrent users.
### File and System Servers
#### Filesystem Server

View File

@@ -41,6 +41,50 @@ docker run -d \
mcphub:local
```
### 构建扩展功能版本
Docker 镜像支持 `INSTALL_EXT` 构建参数以包含额外工具:
```bash
# 构建扩展功能版本(包含 Docker 引擎、Chrome/Playwright
docker build --build-arg INSTALL_EXT=true -t mcphub:extended .
# 方式 1: 使用自动 Docker-in-Docker需要特权模式
docker run -d \
--name mcphub \
--privileged \
-p 3000:3000 \
-v $(pwd)/mcp_settings.json:/app/mcp_settings.json \
mcphub:extended
# 方式 2: 挂载 Docker socket使用宿主机的 Docker 守护进程)
docker run -d \
--name mcphub \
-p 3000:3000 \
-v $(pwd)/mcp_settings.json:/app/mcp_settings.json \
-v /var/run/docker.sock:/var/run/docker.sock \
mcphub:extended
# 验证 Docker 可用
docker exec mcphub docker --version
docker exec mcphub docker ps
```
<Note>
**INSTALL_EXT=true 包含的功能:**
- **Docker 引擎**:完整的 Docker 守护进程和 CLI用于容器管理。在特权模式下运行时守护进程会自动启动。
- **Chrome/Playwright**(仅 amd64用于浏览器自动化任务
扩展镜像较大,但为高级用例提供了额外功能。
</Note>
<Warning>
**Docker-in-Docker 安全注意事项:**
- **特权模式**`--privileged`):容器内启动 Docker 守护进程需要此权限。这会授予容器在宿主机上的提升权限。
- **Docker socket 挂载**`/var/run/docker.sock`):使容器可以访问宿主机的 Docker 守护进程。两种方式都应仅在可信环境中使用。
- 生产环境建议使用 Docker socket 挂载而非特权模式,以提高安全性。
</Warning>
## Docker Compose 设置
### 基本配置

View File

@@ -50,9 +50,8 @@ MCPHub 使用几个配置文件:
},
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest", "--headless", "--isolated"],
"timeout": 60000,
"perSession": true
"args": ["@playwright/mcp@latest", "--headless"],
"timeout": 60000
},
"slack": {
"command": "npx",
@@ -80,48 +79,13 @@ MCPHub 使用几个配置文件:
| 字段 | 类型 | 默认值 | 描述 |
| -------------- | ------- | --------------- | ------------------ |
| `env` | object | `{}` | 环境变量 |
| `perSession` | boolean | `false` | 为每个用户会话创建独立的服务器实例(用于有状态的服务器,如 playwright |
| `enabled` | boolean | `true` | 启用/禁用服务器 |
| `timeout` | number | `60000` | 请求超时(毫秒) |
| `keepAliveInterval` | number | `60000` | SSE 服务器的保活 ping 间隔(毫秒) |
| `cwd` | string | `process.cwd()` | 工作目录 |
| `timeout` | number | `30000` | 启动超时(毫秒) |
| `restart` | boolean | `true` | 失败时自动重启 |
| `maxRestarts` | number | `5` | 最大重启次数 |
| `restartDelay` | number | `5000` | 重启间延迟(毫秒) |
| `stdio` | string | `pipe` | stdio 配置 |
## 会话隔离的服务器实例
某些 MCP 服务器会维护应该在不同用户之间隔离的状态。例如Playwright 服务器维护可能在并发用户之间泄漏表单数据或其他状态的浏览器会话。
为了防止这种情况,您可以在服务器配置中设置 `perSession: true`。这将为每个用户会话创建一个单独的服务器实例,而不是在所有用户之间共享单个实例。
### 何时使用会话隔离的服务器
对于以下服务器使用 `perSession: true`
- 维护浏览器状态(如 Playwright
- 在内存中存储用户特定数据
- 具有不应共享的文件句柄或数据库连接
- 当多个用户同时访问时可能导致竞争条件
### 示例配置
```json
{
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest", "--headless", "--isolated"],
"perSession": true
}
}
```
**重要提示:**
- 每个会话隔离的服务器实例都会消耗额外的资源内存、CPU
- 会话隔离的服务器在用户会话结束时会自动清理
- 对于 Playwright还要使用 `--isolated` 标志以确保浏览器上下文被隔离
- 不建议用于可以安全共享的无状态服务器
## 常见 MCP 服务器示例
### Web 和 API 服务器
@@ -147,9 +111,8 @@ MCPHub 使用几个配置文件:
{
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest", "--headless", "--isolated"],
"args": ["@playwright/mcp@latest", "--headless"],
"timeout": 60000,
"perSession": true,
"env": {
"PLAYWRIGHT_BROWSERS_PATH": "/tmp/browsers"
}
@@ -157,8 +120,6 @@ MCPHub 使用几个配置文件:
}
```
**注意**: `--isolated` 标志确保每个浏览器会话是隔离的,而 `perSession: true` 为每个用户会话创建单独的服务器实例,防止并发用户之间的状态泄漏。
### 文件和系统服务器
#### 文件系统服务器

View File

@@ -4,7 +4,7 @@ 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 环境变量
# Handle HTTP_PROXY and HTTPS_PROXY environment variables
if [ -n "$HTTP_PROXY" ]; then
echo "Setting HTTP proxy to ${HTTP_PROXY}"
npm config set proxy "$HTTP_PROXY"
@@ -19,4 +19,33 @@ fi
echo "Using REQUEST_TIMEOUT: $REQUEST_TIMEOUT"
# Auto-start Docker daemon if Docker is installed
if command -v dockerd >/dev/null 2>&1; then
echo "Docker daemon detected, starting dockerd..."
# Create docker directory if it doesn't exist
mkdir -p /var/lib/docker
# Start dockerd in the background
dockerd --host=unix:///var/run/docker.sock --storage-driver=vfs > /var/log/dockerd.log 2>&1 &
# Wait for Docker daemon to be ready
echo "Waiting for Docker daemon to be ready..."
TIMEOUT=15
ELAPSED=0
while ! docker info >/dev/null 2>&1; do
if [ $ELAPSED -ge $TIMEOUT ]; then
echo "WARNING: Docker daemon failed to start within ${TIMEOUT} seconds"
echo "Check /var/log/dockerd.log for details"
break
fi
sleep 1
ELAPSED=$((ELAPSED + 1))
done
if docker info >/dev/null 2>&1; then
echo "Docker daemon started successfully"
fi
fi
exec "$@"

View File

@@ -7,6 +7,7 @@ import ToolCard from '@/components/ui/ToolCard'
import PromptCard from '@/components/ui/PromptCard'
import DeleteDialog from '@/components/ui/DeleteDialog'
import { useToast } from '@/contexts/ToastContext'
import { useSettingsData } from '@/hooks/useSettingsData'
interface ServerCardProps {
server: Server
@@ -39,6 +40,8 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
}
}, [])
const { exportMCPSettings } = useSettingsData()
const handleRemove = (e: React.MouseEvent) => {
e.stopPropagation()
setShowDeleteDialog(true)
@@ -99,6 +102,39 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
}
}
const handleCopyServerConfig = async (e: React.MouseEvent) => {
e.stopPropagation()
try {
const result = await exportMCPSettings(server.name)
const configJson = JSON.stringify(result.data, null, 2)
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(configJson)
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
} else {
// Fallback for HTTP or unsupported clipboard API
const textArea = document.createElement('textarea')
textArea.value = configJson
textArea.style.position = 'fixed'
textArea.style.left = '-9999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
try {
document.execCommand('copy')
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
} catch (err) {
showToast(t('common.copyFailed') || 'Copy failed', 'error')
console.error('Copy to clipboard failed:', err)
}
document.body.removeChild(textArea)
}
} catch (error) {
console.error('Error copying server configuration:', error)
showToast(t('common.copyFailed') || 'Copy failed', 'error')
}
}
const handleConfirmDelete = () => {
onRemove(server.name)
setShowDeleteDialog(false)
@@ -111,7 +147,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
if (result.success) {
showToast(
t(enabled ? 'tool.enableSuccess' : 'tool.disableSuccess', { name: toolName }),
'success'
'success',
)
// Trigger refresh to update the tool's state in the UI
if (onRefresh) {
@@ -133,7 +169,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
if (result.success) {
showToast(
t(enabled ? 'tool.enableSuccess' : 'tool.disableSuccess', { name: promptName }),
'success'
'success',
)
// Trigger refresh to update the prompt's state in the UI
if (onRefresh) {
@@ -150,21 +186,33 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
return (
<>
<div className={`bg-white shadow rounded-lg p-6 mb-6 page-card transition-all duration-200 ${server.enabled === false ? 'opacity-60' : ''}`}>
<div
className={`bg-white shadow rounded-lg p-6 mb-6 page-card transition-all duration-200 ${server.enabled === false ? 'opacity-60' : ''}`}
>
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<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>
<h2
className={`text-xl font-semibold ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'}`}
>
{server.name}
</h2>
<StatusBadge status={server.status} />
{/* Tool count display */}
<div className="flex items-center px-2 py-1 bg-blue-50 text-blue-700 rounded-full text-sm btn-primary">
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
<path
fillRule="evenodd"
d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z"
clipRule="evenodd"
/>
</svg>
<span>{server.tools?.length || 0} {t('server.tools')}</span>
<span>
{server.tools?.length || 0} {t('server.tools')}
</span>
</div>
{/* Prompt count display */}
@@ -173,7 +221,9 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
<path d="M2 5a2 2 0 012-2h7a2 2 0 012 2v4a2 2 0 01-2 2H9l-3 3v-3H4a2 2 0 01-2-2V5z" />
<path d="M15 7v2a4 4 0 01-4 4H9.828l-1.766 1.767c.28.149.599.233.938.233h2l3 3v-3h2a2 2 0 002-2V9a2 2 0 00-2-2h-1z" />
</svg>
<span>{server.prompts?.length || 0} {t('server.prompts')}</span>
<span>
{server.prompts?.length || 0} {t('server.prompts')}
</span>
</div>
{server.error && (
@@ -196,19 +246,25 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
maxHeight: '300px',
overflowY: 'auto',
width: '480px',
transform: 'translateX(50%)'
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>
<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 btn-secondary"
title={t('common.copy')}
>
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
{copied ? (
<Check size={14} className="text-green-500" />
) : (
<Copy size={14} />
)}
</button>
</div>
<button
@@ -222,7 +278,9 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
</button>
</div>
<div className="p-4 pt-2">
<pre className="text-sm text-gray-700 break-words whitespace-pre-wrap">{server.error}</pre>
<pre className="text-sm text-gray-700 break-words whitespace-pre-wrap">
{server.error}
</pre>
</div>
</div>
)}
@@ -230,6 +288,9 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
)}
</div>
<div className="flex space-x-2">
<button onClick={handleCopyServerConfig} className={`px-3 py-1 btn-secondary`}>
{t('server.copy')}
</button>
<button
onClick={handleEdit}
className="px-3 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary"
@@ -239,20 +300,20 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
<div className="flex items-center">
<button
onClick={handleToggle}
className={`px-3 py-1 text-sm rounded transition-colors ${isToggling
? 'bg-gray-200 text-gray-500'
: server.enabled !== false
? 'bg-green-100 text-green-800 hover:bg-green-200 btn-secondary'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-primary'
}`}
className={`px-3 py-1 text-sm rounded transition-colors ${
isToggling
? 'bg-gray-200 text-gray-500'
: server.enabled !== false
? 'bg-green-100 text-green-800 hover:bg-green-200 btn-secondary'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-primary'
}`}
disabled={isToggling}
>
{isToggling
? t('common.processing')
: server.enabled !== false
? t('server.disable')
: t('server.enable')
}
: t('server.enable')}
</button>
</div>
<button
@@ -271,10 +332,19 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
<>
{server.tools && (
<div className="mt-6">
<h6 className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}>{t('server.tools')}</h6>
<h6
className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}
>
{t('server.tools')}
</h6>
<div className="space-y-4">
{server.tools.map((tool, index) => (
<ToolCard key={index} server={server.name} tool={tool} onToggle={handleToolToggle} />
<ToolCard
key={index}
server={server.name}
tool={tool}
onToggle={handleToolToggle}
/>
))}
</div>
</div>
@@ -282,14 +352,18 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
{server.prompts && (
<div className="mt-6">
<h6 className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}>{t('server.prompts')}</h6>
<h6
className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}
>
{t('server.prompts')}
</h6>
<div className="space-y-4">
{server.prompts.map((prompt, index) => (
<PromptCard
key={index}
server={server.name}
prompt={prompt}
onToggle={handlePromptToggle}
<PromptCard
key={index}
server={server.name}
prompt={prompt}
onToggle={handlePromptToggle}
/>
))}
</div>
@@ -309,4 +383,4 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
)
}
export default ServerCard
export default ServerCard

View File

@@ -4,6 +4,7 @@ export const PERMISSIONS = {
SETTINGS_SMART_ROUTING: 'settings:smart_routing',
SETTINGS_SKIP_AUTH: 'settings:skip_auth',
SETTINGS_INSTALL_CONFIG: 'settings:install_config',
SETTINGS_EXPORT_CONFIG: 'settings:export_config',
} as const;
export default PERMISSIONS;

View File

@@ -420,6 +420,21 @@ export const useSettingsData = () => {
}
};
const exportMCPSettings = async (serverName?: string) => {
setLoading(true);
setError(null);
try {
return await apiGet(`/mcp-settings/export?serverName=${serverName ? serverName : ''}`);
} catch (error) {
console.error('Failed to export MCP settings:', error);
const errorMessage = error instanceof Error ? error.message : 'Failed to export MCP settings';
setError(errorMessage);
showToast(errorMessage);
} finally {
setLoading(false);
}
};
// Fetch settings when the component mounts or refreshKey changes
useEffect(() => {
fetchSettings();
@@ -454,5 +469,6 @@ export const useSettingsData = () => {
updateMCPRouterConfig,
updateMCPRouterConfigBatch,
updateNameSeparator,
exportMCPSettings,
};
};

View File

@@ -1,54 +1,55 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import ChangePasswordForm from '@/components/ChangePasswordForm';
import { Switch } from '@/components/ui/ToggleGroup';
import { useSettingsData } from '@/hooks/useSettingsData';
import { useToast } from '@/contexts/ToastContext';
import { generateRandomKey } from '@/utils/key';
import { PermissionChecker } from '@/components/PermissionChecker';
import { PERMISSIONS } from '@/constants/permissions';
import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import ChangePasswordForm from '@/components/ChangePasswordForm'
import { Switch } from '@/components/ui/ToggleGroup'
import { useSettingsData } from '@/hooks/useSettingsData'
import { useToast } from '@/contexts/ToastContext'
import { generateRandomKey } from '@/utils/key'
import { PermissionChecker } from '@/components/PermissionChecker'
import { PERMISSIONS } from '@/constants/permissions'
import { Copy, Check, Download } from 'lucide-react'
const SettingsPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { showToast } = useToast();
const { t } = useTranslation()
const navigate = useNavigate()
const { showToast } = useToast()
const [installConfig, setInstallConfig] = useState<{
pythonIndexUrl: string;
npmRegistry: string;
baseUrl: string;
pythonIndexUrl: string
npmRegistry: string
baseUrl: string
}>({
pythonIndexUrl: '',
npmRegistry: '',
baseUrl: 'http://localhost:3000',
});
})
const [tempSmartRoutingConfig, setTempSmartRoutingConfig] = useState<{
dbUrl: string;
openaiApiBaseUrl: string;
openaiApiKey: string;
openaiApiEmbeddingModel: string;
dbUrl: string
openaiApiBaseUrl: string
openaiApiKey: string
openaiApiEmbeddingModel: string
}>({
dbUrl: '',
openaiApiBaseUrl: '',
openaiApiKey: '',
openaiApiEmbeddingModel: '',
});
})
const [tempMCPRouterConfig, setTempMCPRouterConfig] = useState<{
apiKey: string;
referer: string;
title: string;
baseUrl: string;
apiKey: string
referer: string
title: string
baseUrl: string
}>({
apiKey: '',
referer: 'https://www.mcphubx.com',
title: 'MCPHub',
baseUrl: 'https://api.mcprouter.to/v1',
});
})
const [tempNameSeparator, setTempNameSeparator] = useState<string>('-');
const [tempNameSeparator, setTempNameSeparator] = useState<string>('-')
const {
routingConfig,
@@ -66,14 +67,15 @@ const SettingsPage: React.FC = () => {
updateSmartRoutingConfigBatch,
updateMCPRouterConfig,
updateNameSeparator,
} = useSettingsData();
exportMCPSettings,
} = useSettingsData()
// Update local installConfig when savedInstallConfig changes
useEffect(() => {
if (savedInstallConfig) {
setInstallConfig(savedInstallConfig);
setInstallConfig(savedInstallConfig)
}
}, [savedInstallConfig]);
}, [savedInstallConfig])
// Update local tempSmartRoutingConfig when smartRoutingConfig changes
useEffect(() => {
@@ -83,9 +85,9 @@ const SettingsPage: React.FC = () => {
openaiApiBaseUrl: smartRoutingConfig.openaiApiBaseUrl || '',
openaiApiKey: smartRoutingConfig.openaiApiKey || '',
openaiApiEmbeddingModel: smartRoutingConfig.openaiApiEmbeddingModel || '',
});
})
}
}, [smartRoutingConfig]);
}, [smartRoutingConfig])
// Update local tempMCPRouterConfig when mcpRouterConfig changes
useEffect(() => {
@@ -95,14 +97,14 @@ const SettingsPage: React.FC = () => {
referer: mcpRouterConfig.referer || 'https://www.mcphubx.com',
title: mcpRouterConfig.title || 'MCPHub',
baseUrl: mcpRouterConfig.baseUrl || 'https://api.mcprouter.to/v1',
});
})
}
}, [mcpRouterConfig]);
}, [mcpRouterConfig])
// Update local tempNameSeparator when nameSeparator changes
useEffect(() => {
setTempNameSeparator(nameSeparator);
}, [nameSeparator]);
setTempNameSeparator(nameSeparator)
}, [nameSeparator])
const [sectionsVisible, setSectionsVisible] = useState({
routingConfig: false,
@@ -110,138 +112,244 @@ const SettingsPage: React.FC = () => {
smartRoutingConfig: false,
mcpRouterConfig: false,
nameSeparator: false,
password: false
});
password: false,
exportConfig: false,
})
const toggleSection = (section: 'routingConfig' | 'installConfig' | 'smartRoutingConfig' | 'mcpRouterConfig' | 'nameSeparator' | 'password') => {
setSectionsVisible(prev => ({
const toggleSection = (
section:
| 'routingConfig'
| 'installConfig'
| 'smartRoutingConfig'
| 'mcpRouterConfig'
| 'nameSeparator'
| 'password'
| 'exportConfig',
) => {
setSectionsVisible((prev) => ({
...prev,
[section]: !prev[section]
}));
};
[section]: !prev[section],
}))
}
const handleRoutingConfigChange = async (key: 'enableGlobalRoute' | 'enableGroupNameRoute' | 'enableBearerAuth' | 'bearerAuthKey' | 'skipAuth', value: boolean | string) => {
const handleRoutingConfigChange = async (
key:
| 'enableGlobalRoute'
| 'enableGroupNameRoute'
| 'enableBearerAuth'
| 'bearerAuthKey'
| 'skipAuth',
value: boolean | string,
) => {
// If enableBearerAuth is turned on and there's no key, generate one first
if (key === 'enableBearerAuth' && value === true) {
if (!tempRoutingConfig.bearerAuthKey && !routingConfig.bearerAuthKey) {
const newKey = generateRandomKey();
handleBearerAuthKeyChange(newKey);
const newKey = generateRandomKey()
handleBearerAuthKeyChange(newKey)
// Update both enableBearerAuth and bearerAuthKey in a single call
const success = await updateRoutingConfigBatch({
enableBearerAuth: true,
bearerAuthKey: newKey
});
bearerAuthKey: newKey,
})
if (success) {
// Update tempRoutingConfig to reflect the saved values
setTempRoutingConfig(prev => ({
setTempRoutingConfig((prev) => ({
...prev,
bearerAuthKey: newKey
}));
bearerAuthKey: newKey,
}))
}
return;
return
}
}
await updateRoutingConfig(key, value);
};
await updateRoutingConfig(key, value)
}
const handleBearerAuthKeyChange = (value: string) => {
setTempRoutingConfig(prev => ({
setTempRoutingConfig((prev) => ({
...prev,
bearerAuthKey: value
}));
};
bearerAuthKey: value,
}))
}
const saveBearerAuthKey = async () => {
await updateRoutingConfig('bearerAuthKey', tempRoutingConfig.bearerAuthKey);
};
await updateRoutingConfig('bearerAuthKey', tempRoutingConfig.bearerAuthKey)
}
const handleInstallConfigChange = (key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl', value: string) => {
const handleInstallConfigChange = (
key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl',
value: string,
) => {
setInstallConfig({
...installConfig,
[key]: value
});
};
[key]: value,
})
}
const saveInstallConfig = async (key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl') => {
await updateInstallConfig(key, installConfig[key]);
};
await updateInstallConfig(key, installConfig[key])
}
const handleSmartRoutingConfigChange = (key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel', value: string) => {
const handleSmartRoutingConfigChange = (
key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel',
value: string,
) => {
setTempSmartRoutingConfig({
...tempSmartRoutingConfig,
[key]: value
});
};
[key]: value,
})
}
const saveSmartRoutingConfig = async (key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel') => {
await updateSmartRoutingConfig(key, tempSmartRoutingConfig[key]);
};
const saveSmartRoutingConfig = async (
key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel',
) => {
await updateSmartRoutingConfig(key, tempSmartRoutingConfig[key])
}
const handleMCPRouterConfigChange = (key: 'apiKey' | 'referer' | 'title' | 'baseUrl', value: string) => {
const handleMCPRouterConfigChange = (
key: 'apiKey' | 'referer' | 'title' | 'baseUrl',
value: string,
) => {
setTempMCPRouterConfig({
...tempMCPRouterConfig,
[key]: value
});
};
[key]: value,
})
}
const saveMCPRouterConfig = async (key: 'apiKey' | 'referer' | 'title' | 'baseUrl') => {
await updateMCPRouterConfig(key, tempMCPRouterConfig[key]);
};
await updateMCPRouterConfig(key, tempMCPRouterConfig[key])
}
const saveNameSeparator = async () => {
await updateNameSeparator(tempNameSeparator);
};
await updateNameSeparator(tempNameSeparator)
}
const handleSmartRoutingEnabledChange = async (value: boolean) => {
// If enabling Smart Routing, validate required fields and save any unsaved changes
if (value) {
const currentDbUrl = tempSmartRoutingConfig.dbUrl || smartRoutingConfig.dbUrl;
const currentOpenaiApiKey = tempSmartRoutingConfig.openaiApiKey || smartRoutingConfig.openaiApiKey;
const currentDbUrl = tempSmartRoutingConfig.dbUrl || smartRoutingConfig.dbUrl
const currentOpenaiApiKey =
tempSmartRoutingConfig.openaiApiKey || smartRoutingConfig.openaiApiKey
if (!currentDbUrl || !currentOpenaiApiKey) {
const missingFields = [];
if (!currentDbUrl) missingFields.push(t('settings.dbUrl'));
if (!currentOpenaiApiKey) missingFields.push(t('settings.openaiApiKey'));
const missingFields = []
if (!currentDbUrl) missingFields.push(t('settings.dbUrl'))
if (!currentOpenaiApiKey) missingFields.push(t('settings.openaiApiKey'))
showToast(t('settings.smartRoutingValidationError', {
fields: missingFields.join(', ')
}));
return;
showToast(
t('settings.smartRoutingValidationError', {
fields: missingFields.join(', '),
}),
)
return
}
// Prepare updates object with unsaved changes and enabled status
const updates: any = { enabled: value };
const updates: any = { enabled: value }
// Check for unsaved changes and include them in the batch update
if (tempSmartRoutingConfig.dbUrl !== smartRoutingConfig.dbUrl) {
updates.dbUrl = tempSmartRoutingConfig.dbUrl;
updates.dbUrl = tempSmartRoutingConfig.dbUrl
}
if (tempSmartRoutingConfig.openaiApiBaseUrl !== smartRoutingConfig.openaiApiBaseUrl) {
updates.openaiApiBaseUrl = tempSmartRoutingConfig.openaiApiBaseUrl;
updates.openaiApiBaseUrl = tempSmartRoutingConfig.openaiApiBaseUrl
}
if (tempSmartRoutingConfig.openaiApiKey !== smartRoutingConfig.openaiApiKey) {
updates.openaiApiKey = tempSmartRoutingConfig.openaiApiKey;
updates.openaiApiKey = tempSmartRoutingConfig.openaiApiKey
}
if (tempSmartRoutingConfig.openaiApiEmbeddingModel !== smartRoutingConfig.openaiApiEmbeddingModel) {
updates.openaiApiEmbeddingModel = tempSmartRoutingConfig.openaiApiEmbeddingModel;
if (
tempSmartRoutingConfig.openaiApiEmbeddingModel !==
smartRoutingConfig.openaiApiEmbeddingModel
) {
updates.openaiApiEmbeddingModel = tempSmartRoutingConfig.openaiApiEmbeddingModel
}
// Save all changes in a single batch update
await updateSmartRoutingConfigBatch(updates);
await updateSmartRoutingConfigBatch(updates)
} else {
// If disabling, just update the enabled status
await updateSmartRoutingConfig('enabled', value);
await updateSmartRoutingConfig('enabled', value)
}
};
}
const handlePasswordChangeSuccess = () => {
setTimeout(() => {
navigate('/');
}, 2000);
};
navigate('/')
}, 2000)
}
const [copiedConfig, setCopiedConfig] = useState(false)
const [mcpSettingsJson, setMcpSettingsJson] = useState<string>('')
const fetchMcpSettings = async () => {
try {
const result = await exportMCPSettings()
console.log('Fetched MCP settings:', result)
const configJson = JSON.stringify(result, null, 2)
setMcpSettingsJson(configJson)
} catch (error) {
console.error('Error fetching MCP settings:', error)
showToast(t('settings.exportError') || 'Failed to fetch settings', 'error')
}
}
useEffect(() => {
if (sectionsVisible.exportConfig && !mcpSettingsJson) {
fetchMcpSettings()
}
}, [sectionsVisible.exportConfig])
const handleCopyConfig = async () => {
if (!mcpSettingsJson) return
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(mcpSettingsJson)
setCopiedConfig(true)
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
setTimeout(() => setCopiedConfig(false), 2000)
} else {
// Fallback for HTTP or unsupported clipboard API
const textArea = document.createElement('textarea')
textArea.value = mcpSettingsJson
textArea.style.position = 'fixed'
textArea.style.left = '-9999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
try {
document.execCommand('copy')
setCopiedConfig(true)
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
setTimeout(() => setCopiedConfig(false), 2000)
} catch (err) {
showToast(t('common.copyFailed') || 'Copy failed', 'error')
console.error('Copy to clipboard failed:', err)
}
document.body.removeChild(textArea)
}
} catch (error) {
console.error('Error copying configuration:', error)
showToast(t('common.copyFailed') || 'Copy failed', 'error')
}
}
const handleDownloadConfig = () => {
if (!mcpSettingsJson) return
const blob = new Blob([mcpSettingsJson], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = 'mcp_settings.json'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
showToast(t('settings.exportSuccess') || 'Settings exported successfully', 'success')
}
return (
<div className="container mx-auto">
@@ -265,7 +373,9 @@ const SettingsPage: React.FC = () => {
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
<div>
<h3 className="font-medium text-gray-700">{t('settings.enableSmartRouting')}</h3>
<p className="text-sm text-gray-500">{t('settings.enableSmartRoutingDescription')}</p>
<p className="text-sm text-gray-500">
{t('settings.enableSmartRoutingDescription')}
</p>
</div>
<Switch
disabled={loading}
@@ -277,7 +387,8 @@ const SettingsPage: React.FC = () => {
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">
<span className="text-red-500 px-1">*</span>{t('settings.dbUrl')}
<span className="text-red-500 px-1">*</span>
{t('settings.dbUrl')}
</h3>
</div>
<div className="flex items-center gap-3">
@@ -302,7 +413,8 @@ const SettingsPage: React.FC = () => {
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">
<span className="text-red-500 px-1">*</span>{t('settings.openaiApiKey')}
<span className="text-red-500 px-1">*</span>
{t('settings.openaiApiKey')}
</h3>
</div>
<div className="flex items-center gap-3">
@@ -332,7 +444,9 @@ const SettingsPage: React.FC = () => {
<input
type="text"
value={tempSmartRoutingConfig.openaiApiBaseUrl}
onChange={(e) => handleSmartRoutingConfigChange('openaiApiBaseUrl', e.target.value)}
onChange={(e) =>
handleSmartRoutingConfigChange('openaiApiBaseUrl', e.target.value)
}
placeholder={t('settings.openaiApiBaseUrlPlaceholder')}
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
disabled={loading}
@@ -349,13 +463,17 @@ const SettingsPage: React.FC = () => {
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">{t('settings.openaiApiEmbeddingModel')}</h3>
<h3 className="font-medium text-gray-700">
{t('settings.openaiApiEmbeddingModel')}
</h3>
</div>
<div className="flex items-center gap-3">
<input
type="text"
value={tempSmartRoutingConfig.openaiApiEmbeddingModel}
onChange={(e) => handleSmartRoutingConfigChange('openaiApiEmbeddingModel', e.target.value)}
onChange={(e) =>
handleSmartRoutingConfigChange('openaiApiEmbeddingModel', e.target.value)
}
placeholder={t('settings.openaiApiEmbeddingModelPlaceholder')}
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
disabled={loading}
@@ -392,7 +510,9 @@ const SettingsPage: React.FC = () => {
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">{t('settings.mcpRouterApiKey')}</h3>
<p className="text-sm text-gray-500">{t('settings.mcpRouterApiKeyDescription')}</p>
<p className="text-sm text-gray-500">
{t('settings.mcpRouterApiKeyDescription')}
</p>
</div>
<div className="flex items-center gap-3">
<input
@@ -416,7 +536,9 @@ const SettingsPage: React.FC = () => {
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">{t('settings.mcpRouterBaseUrl')}</h3>
<p className="text-sm text-gray-500">{t('settings.mcpRouterBaseUrlDescription')}</p>
<p className="text-sm text-gray-500">
{t('settings.mcpRouterBaseUrlDescription')}
</p>
</div>
<div className="flex items-center gap-3">
<input
@@ -448,9 +570,7 @@ const SettingsPage: React.FC = () => {
onClick={() => toggleSection('nameSeparator')}
>
<h2 className="font-semibold text-gray-800">{t('settings.systemSettings')}</h2>
<span className="text-gray-500">
{sectionsVisible.nameSeparator ? '▼' : '►'}
</span>
<span className="text-gray-500">{sectionsVisible.nameSeparator ? '▼' : '►'}</span>
</div>
{sectionsVisible.nameSeparator && (
@@ -490,9 +610,7 @@ const SettingsPage: React.FC = () => {
onClick={() => toggleSection('routingConfig')}
>
<h2 className="font-semibold text-gray-800">{t('pages.settings.routeConfig')}</h2>
<span className="text-gray-500">
{sectionsVisible.routingConfig ? '▼' : '►'}
</span>
<span className="text-gray-500">{sectionsVisible.routingConfig ? '▼' : '►'}</span>
</div>
{sectionsVisible.routingConfig && (
@@ -505,7 +623,9 @@ const SettingsPage: React.FC = () => {
<Switch
disabled={loading}
checked={routingConfig.enableBearerAuth}
onCheckedChange={(checked) => handleRoutingConfigChange('enableBearerAuth', checked)}
onCheckedChange={(checked) =>
handleRoutingConfigChange('enableBearerAuth', checked)
}
/>
</div>
@@ -538,24 +658,32 @@ const SettingsPage: React.FC = () => {
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
<div>
<h3 className="font-medium text-gray-700">{t('settings.enableGlobalRoute')}</h3>
<p className="text-sm text-gray-500">{t('settings.enableGlobalRouteDescription')}</p>
<p className="text-sm text-gray-500">
{t('settings.enableGlobalRouteDescription')}
</p>
</div>
<Switch
disabled={loading}
checked={routingConfig.enableGlobalRoute}
onCheckedChange={(checked) => handleRoutingConfigChange('enableGlobalRoute', checked)}
onCheckedChange={(checked) =>
handleRoutingConfigChange('enableGlobalRoute', checked)
}
/>
</div>
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
<div>
<h3 className="font-medium text-gray-700">{t('settings.enableGroupNameRoute')}</h3>
<p className="text-sm text-gray-500">{t('settings.enableGroupNameRouteDescription')}</p>
<p className="text-sm text-gray-500">
{t('settings.enableGroupNameRouteDescription')}
</p>
</div>
<Switch
disabled={loading}
checked={routingConfig.enableGroupNameRoute}
onCheckedChange={(checked) => handleRoutingConfigChange('enableGroupNameRoute', checked)}
onCheckedChange={(checked) =>
handleRoutingConfigChange('enableGroupNameRoute', checked)
}
/>
</div>
@@ -572,7 +700,6 @@ const SettingsPage: React.FC = () => {
/>
</div>
</PermissionChecker>
</div>
)}
</div>
@@ -585,9 +712,7 @@ const SettingsPage: React.FC = () => {
onClick={() => toggleSection('installConfig')}
>
<h2 className="font-semibold text-gray-800">{t('settings.installConfig')}</h2>
<span className="text-gray-500">
{sectionsVisible.installConfig ? '▼' : '►'}
</span>
<span className="text-gray-500">{sectionsVisible.installConfig ? '▼' : '►'}</span>
</div>
{sectionsVisible.installConfig && (
@@ -675,9 +800,7 @@ const SettingsPage: React.FC = () => {
onClick={() => toggleSection('password')}
>
<h2 className="font-semibold text-gray-800">{t('auth.changePassword')}</h2>
<span className="text-gray-500">
{sectionsVisible.password ? '▼' : '►'}
</span>
<span className="text-gray-500">{sectionsVisible.password ? '▼' : '►'}</span>
</div>
{sectionsVisible.password && (
@@ -686,8 +809,61 @@ const SettingsPage: React.FC = () => {
</div>
)}
</div>
</div >
);
};
export default SettingsPage;
{/* Export MCP Settings */}
<PermissionChecker permissions={PERMISSIONS.SETTINGS_EXPORT_CONFIG}>
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card">
<div
className="flex justify-between items-center cursor-pointer"
onClick={() => toggleSection('exportConfig')}
>
<h2 className="font-semibold text-gray-800">{t('settings.exportMcpSettings')}</h2>
<span className="text-gray-500">{sectionsVisible.exportConfig ? '▼' : '►'}</span>
</div>
{sectionsVisible.exportConfig && (
<div className="space-y-4 mt-4">
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-4">
<h3 className="font-medium text-gray-700">{t('settings.mcpSettingsJson')}</h3>
<p className="text-sm text-gray-500">
{t('settings.mcpSettingsJsonDescription')}
</p>
</div>
<div className="space-y-3">
<div className="flex items-center gap-3">
<button
onClick={handleCopyConfig}
disabled={!mcpSettingsJson}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
>
{copiedConfig ? <Check size={16} /> : <Copy size={16} />}
{copiedConfig ? t('common.copied') : t('settings.copyToClipboard')}
</button>
<button
onClick={handleDownloadConfig}
disabled={!mcpSettingsJson}
className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
>
<Download size={16} />
{t('settings.downloadJson')}
</button>
</div>
{mcpSettingsJson && (
<div className="mt-3">
<pre className="bg-gray-900 text-gray-100 p-4 rounded-md overflow-x-auto text-xs max-h-96">
{mcpSettingsJson}
</pre>
</div>
)}
</div>
</div>
</div>
)}
</div>
</PermissionChecker>
</div>
)
}
export default SettingsPage

View File

@@ -75,6 +75,7 @@
"addServer": "Add Server",
"add": "Add",
"edit": "Edit",
"copy": "Copy",
"delete": "Delete",
"confirmDelete": "Are you sure you want to delete this server?",
"deleteWarning": "Deleting server '{{name}}' will remove it and all its data. This action cannot be undone.",
@@ -124,6 +125,7 @@
"argumentsPlaceholder": "Enter arguments",
"errorDetails": "Error Details",
"viewErrorDetails": "View error details",
"copyConfig": "Copy Configuration",
"confirmVariables": "Confirm Variable Configuration",
"variablesDetected": "Variables detected in configuration. Please confirm these variables are properly configured:",
"detectedVariables": "Detected Variables",
@@ -200,6 +202,7 @@
"copyJson": "Copy JSON",
"copySuccess": "Copied to clipboard",
"copyFailed": "Copy failed",
"copied": "Copied",
"close": "Close",
"confirm": "Confirm",
"language": "Language",
@@ -502,7 +505,14 @@
"systemSettings": "System Settings",
"nameSeparatorLabel": "Name Separator",
"nameSeparatorDescription": "Character used to separate server name and tool/prompt name (default: -)",
"restartRequired": "Configuration saved. It is recommended to restart the application to ensure all services load the new settings correctly."
"restartRequired": "Configuration saved. It is recommended to restart the application to ensure all services load the new settings correctly.",
"exportMcpSettings": "Export Settings",
"mcpSettingsJson": "MCP Settings JSON",
"mcpSettingsJsonDescription": "View, copy, or download your current mcp_settings.json configuration for backup or migration to other tools",
"copyToClipboard": "Copy to Clipboard",
"downloadJson": "Download JSON",
"exportSuccess": "Settings exported successfully",
"exportError": "Failed to fetch settings"
},
"dxt": {
"upload": "Upload",

View File

@@ -75,6 +75,7 @@
"addServer": "Ajouter un serveur",
"add": "Ajouter",
"edit": "Modifier",
"copy": "Copier",
"delete": "Supprimer",
"confirmDelete": "Êtes-vous sûr de vouloir supprimer ce serveur ?",
"deleteWarning": "La suppression du serveur '{{name}}' le supprimera ainsi que toutes ses données. Cette action est irréversible.",
@@ -124,6 +125,7 @@
"argumentsPlaceholder": "Entrez les arguments",
"errorDetails": "Détails de l'erreur",
"viewErrorDetails": "Voir les détails de l'erreur",
"copyConfig": "Copier la configuration",
"confirmVariables": "Confirmer la configuration des variables",
"variablesDetected": "Variables détectées dans la configuration. Veuillez confirmer que ces variables sont correctement configurées :",
"detectedVariables": "Variables détectées",
@@ -200,6 +202,7 @@
"copyJson": "Copier le JSON",
"copySuccess": "Copié dans le presse-papiers",
"copyFailed": "Échec de la copie",
"copied": "Copié",
"close": "Fermer",
"confirm": "Confirmer",
"language": "Langue",
@@ -502,7 +505,14 @@
"systemSettings": "Paramètres système",
"nameSeparatorLabel": "Séparateur de noms",
"nameSeparatorDescription": "Caractère utilisé pour séparer le nom du serveur et le nom de l'outil/prompt (par défaut : -)",
"restartRequired": "Configuration enregistrée. Il est recommandé de redémarrer l'application pour s'assurer que tous les services chargent correctement les nouveaux paramètres."
"restartRequired": "Configuration enregistrée. Il est recommandé de redémarrer l'application pour s'assurer que tous les services chargent correctement les nouveaux paramètres.",
"exportMcpSettings": "Exporter les paramètres",
"mcpSettingsJson": "JSON des paramètres MCP",
"mcpSettingsJsonDescription": "Afficher, copier ou télécharger votre configuration mcp_settings.json actuelle pour la sauvegarde ou la migration vers d'autres outils",
"copyToClipboard": "Copier dans le presse-papiers",
"downloadJson": "Télécharger JSON",
"exportSuccess": "Paramètres exportés avec succès",
"exportError": "Échec de la récupération des paramètres"
},
"dxt": {
"upload": "Télécharger",

View File

@@ -75,6 +75,7 @@
"addServer": "添加服务器",
"add": "添加",
"edit": "编辑",
"copy": "复制",
"delete": "删除",
"confirmDelete": "您确定要删除此服务器吗?",
"deleteWarning": "删除服务器 '{{name}}' 将会移除该服务器及其所有数据。此操作无法撤销。",
@@ -124,6 +125,7 @@
"argumentsPlaceholder": "请输入参数",
"errorDetails": "错误详情",
"viewErrorDetails": "查看错误详情",
"copyConfig": "复制配置",
"confirmVariables": "确认变量配置",
"variablesDetected": "检测到配置中包含变量,请确认这些变量是否已正确配置:",
"detectedVariables": "检测到的变量",
@@ -201,6 +203,7 @@
"copyJson": "复制JSON",
"copySuccess": "已复制到剪贴板",
"copyFailed": "复制失败",
"copied": "已复制",
"close": "关闭",
"confirm": "确认",
"language": "语言",
@@ -504,7 +507,14 @@
"systemSettings": "系统设置",
"nameSeparatorLabel": "名称分隔符",
"nameSeparatorDescription": "用于分隔服务器名称和工具/提示名称(默认:-",
"restartRequired": "配置已保存。为确保所有服务正确加载新设置,建议重启应用。"
"restartRequired": "配置已保存。为确保所有服务正确加载新设置,建议重启应用。",
"exportMcpSettings": "导出配置",
"mcpSettingsJson": "MCP 配置 JSON",
"mcpSettingsJsonDescription": "查看、复制或下载当前的 mcp_settings.json 配置,可用于备份或迁移到其他工具",
"copyToClipboard": "复制到剪贴板",
"downloadJson": "下载 JSON",
"exportSuccess": "配置导出成功",
"exportError": "获取配置失败"
},
"dxt": {
"upload": "上传",

View File

@@ -14,10 +14,8 @@
"command": "npx",
"args": [
"@playwright/mcp@latest",
"--headless",
"--isolated"
],
"perSession": true
"--headless"
]
},
"fetch": {
"command": "uvx",

View File

@@ -1,6 +1,6 @@
import { Request, Response } from 'express';
import config from '../config/index.js';
import { loadSettings } from '../config/index.js';
import { loadSettings, loadOriginalSettings } from '../config/index.js';
import { getDataService } from '../services/services.js';
import { DataService } from '../services/dataService.js';
import { IUser } from '../types/index.js';
@@ -72,3 +72,46 @@ export const getPublicConfig = (req: Request, res: Response): void => {
});
}
};
/**
* Get MCP settings in JSON format for export/copy
* Supports both full settings and individual server configuration
*/
export const getMcpSettingsJson = (req: Request, res: Response): void => {
try {
const { serverName } = req.query;
const settings = loadOriginalSettings();
if (serverName && typeof serverName === 'string') {
// Return individual server configuration
const serverConfig = settings.mcpServers[serverName];
if (!serverConfig) {
res.status(404).json({
success: false,
message: `Server '${serverName}' not found`,
});
return;
}
res.json({
success: true,
data: {
mcpServers: {
[serverName]: serverConfig,
},
},
});
} else {
// Return full settings
res.json({
success: true,
data: settings,
});
}
} catch (error) {
console.error('Error getting MCP settings JSON:', error);
res.status(500).json({
success: false,
message: 'Failed to get MCP settings',
});
}
};

View File

@@ -58,7 +58,7 @@ import {
} from '../controllers/cloudController.js';
import { login, register, getCurrentUser, changePassword } from '../controllers/authController.js';
import { getAllLogs, clearLogs, streamLogs } from '../controllers/logController.js';
import { getRuntimeConfig, getPublicConfig } from '../controllers/configController.js';
import { getRuntimeConfig, getPublicConfig, getMcpSettingsJson } from '../controllers/configController.js';
import { callTool } from '../controllers/toolController.js';
import { getPrompt } from '../controllers/promptController.js';
import { uploadDxtFile, uploadMiddleware } from '../controllers/dxtController.js';
@@ -149,6 +149,9 @@ export const initRoutes = (app: express.Application): void => {
router.delete('/logs', clearLogs);
router.get('/logs/stream', streamLogs);
// MCP settings export route
router.get('/mcp-settings/export', getMcpSettingsJson);
// Auth routes - move to router instead of app directly
router.post(
'/auth/login',

View File

@@ -15,9 +15,26 @@ import {
} from './services/sseService.js';
import { initializeDefaultUser } from './models/User.js';
import { sseUserContextMiddleware } from './middlewares/userContext.js';
import { findPackageRoot } from './utils/path.js';
import { getCurrentModuleDir } from './utils/moduleDir.js';
// Get the current working directory (will be project root in most cases)
const currentFileDir = process.cwd() + '/src';
/**
* Get the directory of the current module
* This is wrapped in a function to allow easy mocking in test environments
*/
function getCurrentFileDir(): string {
// In test environments, use process.cwd() to avoid import.meta issues
if (process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined) {
return process.cwd();
}
try {
return getCurrentModuleDir();
} catch {
// Fallback for environments where import.meta might not be available
return process.cwd();
}
}
export class AppServer {
private app: express.Application;
@@ -167,10 +184,11 @@ export class AppServer {
private findFrontendDistPath(): string | null {
// Debug flag for detailed logging
const debug = process.env.DEBUG === 'true';
const currentDir = getCurrentFileDir();
if (debug) {
console.log('DEBUG: Current directory:', process.cwd());
console.log('DEBUG: Script directory:', currentFileDir);
console.log('DEBUG: Script directory:', currentDir);
}
// First, find the package root directory
@@ -205,51 +223,9 @@ export class AppServer {
// 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(currentFileDir, '..', '..'),
// Current working directory
process.cwd(),
// When running from dist directory
path.resolve(currentFileDir, '..'),
// When installed via npx
path.resolve(currentFileDir, '..', '..', '..'),
];
// 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;
// Use the shared utility function which properly handles ESM module paths
const currentDir = getCurrentFileDir();
return findPackageRoot(currentDir);
}
}

View File

@@ -14,6 +14,11 @@ export const getMarketServers = (): Record<string, MarketServer> => {
const data = fs.readFileSync(serversJsonPath, 'utf8');
const serversObj = JSON.parse(data) as Record<string, MarketServer>;
// use key as name field
Object.entries(serversObj).forEach(([key, server]) => {
server.name = key;
});
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;

View File

@@ -24,10 +24,6 @@ import { getServerDao, ServerConfigWithName } from '../dao/index.js';
const servers: { [sessionId: string]: Server } = {};
// Per-session server instances for servers with perSession=true
// Key format: `${sessionId}:${serverName}`
const perSessionServerInfos: { [key: string]: ServerInfo } = {};
const serverDao = getServerDao();
// Helper function to set up keep-alive ping for SSE connections
@@ -83,8 +79,6 @@ export const getMcpServer = (sessionId?: string, group?: string): Server => {
export const deleteMcpServer = (sessionId: string): void => {
delete servers[sessionId];
// Clean up any per-session servers for this session
cleanupPerSessionServers(sessionId);
};
export const notifyToolChanged = async (name?: string) => {
@@ -229,144 +223,6 @@ const createTransportFromConfig = (name: string, conf: ServerConfig): any => {
return transport;
};
// Helper function to get or create per-session server instance
export const getOrCreatePerSessionServer = async (
sessionId: string,
serverName: string,
serverConfig: ServerConfig,
): Promise<ServerInfo> => {
const key = `${sessionId}:${serverName}`;
// Return existing session server if it exists
if (perSessionServerInfos[key]) {
return perSessionServerInfos[key];
}
console.log(`Creating per-session server instance for session ${sessionId}, server ${serverName}`);
// Create new transport for this session
const transport = createTransportFromConfig(serverName, serverConfig);
const client = new Client(
{
name: `mcp-client-${serverName}-${sessionId}`,
version: '1.0.0',
},
{
capabilities: {
prompts: {},
resources: {},
tools: {},
},
},
);
// Get request options from server configuration, with fallbacks
const serverRequestOptions = serverConfig.options || {};
const requestOptions = {
timeout: serverRequestOptions.timeout || 60000,
resetTimeoutOnProgress: serverRequestOptions.resetTimeoutOnProgress || false,
maxTotalTimeout: serverRequestOptions.maxTotalTimeout,
};
// Create server info for this session
const serverInfo: ServerInfo = {
name: serverName,
owner: serverConfig.owner,
status: 'connecting',
error: null,
tools: [],
prompts: [],
client,
transport,
options: requestOptions,
createTime: Date.now(),
config: serverConfig,
sessionId: sessionId,
};
perSessionServerInfos[key] = serverInfo;
// Connect asynchronously
client
.connect(transport, requestOptions)
.then(() => {
console.log(`Successfully connected per-session client for server: ${serverName}, session: ${sessionId}`);
const capabilities = client.getServerCapabilities();
if (capabilities?.tools) {
client
.listTools({}, requestOptions)
.then((tools) => {
console.log(`Successfully listed ${tools.tools.length} tools for per-session server: ${serverName}, session: ${sessionId}`);
serverInfo.tools = tools.tools.map((tool) => ({
name: `${serverName}${getNameSeparator()}${tool.name}`,
description: tool.description || '',
inputSchema: cleanInputSchema(tool.inputSchema || {}),
}));
})
.catch((error) => {
console.error(`Failed to list tools for per-session server ${serverName}, session ${sessionId}:`, error);
});
}
if (capabilities?.prompts) {
client
.listPrompts({}, requestOptions)
.then((prompts) => {
console.log(`Successfully listed ${prompts.prompts.length} prompts for per-session server: ${serverName}, session: ${sessionId}`);
serverInfo.prompts = prompts.prompts.map((prompt) => ({
name: `${serverName}${getNameSeparator()}${prompt.name}`,
title: prompt.title,
description: prompt.description,
arguments: prompt.arguments,
}));
})
.catch((error) => {
console.error(`Failed to list prompts for per-session server ${serverName}, session ${sessionId}:`, error);
});
}
serverInfo.status = 'connected';
serverInfo.error = null;
})
.catch((error) => {
console.error(`Failed to connect per-session client for server ${serverName}, session ${sessionId}:`, error);
serverInfo.status = 'disconnected';
serverInfo.error = `Failed to connect: ${error.stack}`;
});
return serverInfo;
};
// Helper function to clean up per-session servers for a session
export const cleanupPerSessionServers = (sessionId: string): void => {
const keysToDelete: string[] = [];
for (const key in perSessionServerInfos) {
if (key.startsWith(`${sessionId}:`)) {
const serverInfo = perSessionServerInfos[key];
try {
if (serverInfo.client) {
serverInfo.client.close();
}
if (serverInfo.transport) {
serverInfo.transport.close();
}
if (serverInfo.keepAliveIntervalId) {
clearInterval(serverInfo.keepAliveIntervalId);
}
} catch (error) {
console.warn(`Error closing per-session server ${key}:`, error);
}
keysToDelete.push(key);
}
}
keysToDelete.forEach(key => delete perSessionServerInfos[key]);
console.log(`Cleaned up ${keysToDelete.length} per-session servers for session ${sessionId}`);
};
// Helper function to handle client.callTool with reconnection logic
const callToolWithReconnect = async (
serverInfo: ServerInfo,
@@ -769,45 +625,6 @@ export const getServerByName = (name: string): ServerInfo | undefined => {
return serverInfos.find((serverInfo) => serverInfo.name === name);
};
// Get server by name with session support (for per-session servers)
const getServerByNameWithSession = async (name: string, sessionId?: string): Promise<ServerInfo | undefined> => {
// First check if this server is configured for per-session instances
const serverConfig = await serverDao.findById(name);
if (serverConfig?.perSession && sessionId) {
// Try to get or create per-session server
const key = `${sessionId}:${name}`;
if (perSessionServerInfos[key]) {
return perSessionServerInfos[key];
}
// Create new per-session server instance
return await getOrCreatePerSessionServer(sessionId, name, serverConfig);
}
// Fall back to shared server
return serverInfos.find((serverInfo) => serverInfo.name === name && !serverInfo.sessionId);
};
// Get server by tool name with session support (for per-session servers)
const getServerByToolWithSession = async (toolName: string, sessionId?: string): Promise<ServerInfo | undefined> => {
// First try to find in per-session servers if sessionId is provided
if (sessionId) {
for (const key in perSessionServerInfos) {
if (key.startsWith(`${sessionId}:`)) {
const serverInfo = perSessionServerInfos[key];
if (serverInfo.tools.some((tool) => tool.name === toolName)) {
return serverInfo;
}
}
}
}
// Fall back to shared servers
return serverInfos.find((serverInfo) =>
!serverInfo.sessionId && serverInfo.tools.some((tool) => tool.name === toolName)
);
};
// Filter tools by server configuration
const filterToolsByConfig = async (serverName: string, tools: Tool[]): Promise<Tool[]> => {
const serverConfig = await serverDao.findById(serverName);
@@ -823,8 +640,8 @@ const filterToolsByConfig = async (serverName: string, tools: Tool[]): Promise<T
});
};
// Get server by tool name (legacy - use getServerByToolWithSession instead)
const _getServerByTool = (toolName: string): ServerInfo | undefined => {
// Get server by tool name
const getServerByTool = (toolName: string): ServerInfo | undefined => {
return serverInfos.find((serverInfo) => serverInfo.tools.some((tool) => tool.name === toolName));
};
@@ -1009,36 +826,16 @@ Available servers: ${serversList}`;
};
}
// Get shared servers
let allServerInfos = getDataService()
const allServerInfos = getDataService()
.filterData(serverInfos)
.filter((serverInfo) => {
if (serverInfo.enabled === false) return false;
if (serverInfo.sessionId) return false; // Exclude per-session servers from shared list
if (!group) return true;
const serversInGroup = getServersInGroup(group);
if (!serversInGroup || serversInGroup.length === 0) return serverInfo.name === group;
return serversInGroup.includes(serverInfo.name);
});
// Add per-session servers for this session
if (sessionId) {
const sessionServers = Object.values(perSessionServerInfos).filter(
(serverInfo) => serverInfo.sessionId === sessionId && serverInfo.status === 'connected'
);
// Filter session servers by group if applicable
const filteredSessionServers = sessionServers.filter((serverInfo) => {
if (serverInfo.enabled === false) return false;
if (!group) return true;
const serversInGroup = getServersInGroup(group);
if (!serversInGroup || serversInGroup.length === 0) return serverInfo.name === group;
return serversInGroup.includes(serverInfo.name);
});
allServerInfos = [...allServerInfos, ...filteredSessionServers];
}
const allTools = [];
for (const serverInfo of allServerInfos) {
if (serverInfo.tools && serverInfo.tools.length > 0) {
@@ -1201,13 +998,17 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
}
const { arguments: toolArgs = {} } = request.params.arguments || {};
const sessionId = extra?.sessionId;
let targetServerInfo: ServerInfo | undefined;
if (extra && extra.server) {
targetServerInfo = await getServerByNameWithSession(extra.server, sessionId);
targetServerInfo = getServerByName(extra.server);
} else {
// Find the first server that has this tool (session-aware)
targetServerInfo = await getServerByToolWithSession(toolName, sessionId);
// Find the first server that has this tool
targetServerInfo = serverInfos.find(
(serverInfo) =>
serverInfo.status === 'connected' &&
serverInfo.enabled !== false &&
serverInfo.tools.some((tool) => tool.name === toolName),
);
}
if (!targetServerInfo) {
@@ -1313,8 +1114,7 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
}
// Regular tool handling
const sessionId = extra?.sessionId;
const serverInfo = await getServerByToolWithSession(request.params.name, sessionId);
const serverInfo = getServerByTool(request.params.name);
if (!serverInfo) {
throw new Error(`Server not found: ${request.params.name}`);
}

View File

@@ -178,7 +178,6 @@ export interface ServerConfig {
enabled?: boolean; // Flag to enable/disable the server
owner?: string; // Owner of the server, defaults to 'admin' user
keepAliveInterval?: number; // Keep-alive ping interval in milliseconds (default: 60000ms for SSE servers)
perSession?: boolean; // If true, creates a separate server instance for each session (useful for stateful servers like playwright)
tools?: Record<string, { enabled: boolean; description?: string }>; // Tool-specific configurations with enable/disable state and custom descriptions
prompts?: Record<string, { enabled: boolean; description?: string }>; // Prompt-specific configurations with enable/disable state and custom descriptions
options?: Partial<Pick<RequestOptions, 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout'>>; // MCP request options configuration
@@ -240,7 +239,6 @@ export interface ServerInfo {
enabled?: boolean; // Flag to indicate if the server is enabled
keepAliveIntervalId?: NodeJS.Timeout; // Timer ID for keep-alive ping interval
config?: ServerConfig; // Reference to the original server configuration for OpenAPI passthrough headers
sessionId?: string; // Session ID for per-session server instances (undefined for shared servers)
}
// Details about a tool available on the server

11
src/utils/moduleDir.ts Normal file
View File

@@ -0,0 +1,11 @@
import { fileURLToPath } from 'url';
import path from 'path';
/**
* Get the directory of the current module
* This is in a separate file to allow mocking in test environments
*/
export function getCurrentModuleDir(): string {
const currentModuleFile = fileURLToPath(import.meta.url);
return path.dirname(currentModuleFile);
}

View File

@@ -1,10 +1,171 @@
import fs from 'fs';
import path from 'path';
import { dirname } from 'path';
import { getCurrentModuleDir } from './moduleDir.js';
// Project root directory - use process.cwd() as a simpler alternative
const rootDir = process.cwd();
// Cache the package root for performance
let cachedPackageRoot: string | null | undefined = undefined;
/**
* Initialize package root by trying to find it using the module directory
* This should be called when the module is first loaded
*/
function initializePackageRoot(): void {
// Skip initialization in test environments
if (process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined) {
return;
}
try {
// Try to get the current module's directory
const currentModuleDir = getCurrentModuleDir();
// This file is in src/utils/path.ts (or dist/utils/path.js when compiled)
// So package.json should be 2 levels up
const possibleRoots = [
path.resolve(currentModuleDir, '..', '..'), // dist -> package root
path.resolve(currentModuleDir, '..'), // dist/utils -> dist -> package root
];
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') {
cachedPackageRoot = root;
return;
}
} catch {
// Continue checking
}
}
}
} catch {
// If initialization fails, cachedPackageRoot remains undefined
// and findPackageRoot will search normally
}
}
// Initialize on module load (unless in test environment)
initializePackageRoot();
/**
* Find the package root directory (where package.json is located)
* This works correctly when the package is installed globally or locally
* @param startPath Starting path to search from (defaults to checking module paths)
* @returns The package root directory path, or null if not found
*/
export const findPackageRoot = (startPath?: string): string | null => {
// Return cached value if available and no specific start path is requested
if (cachedPackageRoot !== undefined && !startPath) {
return cachedPackageRoot;
}
const debug = process.env.DEBUG === 'true';
// Possible locations for package.json relative to the search path
const possibleRoots: string[] = [];
if (startPath) {
// When start path is provided (from fileURLToPath(import.meta.url))
possibleRoots.push(
// When in dist/utils (compiled code) - go up 2 levels
path.resolve(startPath, '..', '..'),
// When in dist/ (compiled code) - go up 1 level
path.resolve(startPath, '..'),
// Direct parent directories
path.resolve(startPath)
);
}
// Try to use require.resolve to find the module location (works in CommonJS and ESM with createRequire)
try {
// In ESM, we can use import.meta.resolve, but it's async in some versions
// So we'll try to find the module by checking the node_modules structure
// Check if this file is in a node_modules installation
const currentFile = new Error().stack?.split('\n')[2]?.match(/\((.+?):\d+:\d+\)$/)?.[1];
if (currentFile) {
const nodeModulesIndex = currentFile.indexOf('node_modules');
if (nodeModulesIndex !== -1) {
// Extract the package path from node_modules
const afterNodeModules = currentFile.substring(nodeModulesIndex + 'node_modules'.length + 1);
const packageNameEnd = afterNodeModules.indexOf(path.sep);
if (packageNameEnd !== -1) {
const packagePath = currentFile.substring(0, nodeModulesIndex + 'node_modules'.length + 1 + packageNameEnd);
possibleRoots.push(packagePath);
}
}
}
} catch {
// Ignore errors
}
// Check module.filename location (works in Node.js when available)
if (typeof __filename !== 'undefined') {
const moduleDir = path.dirname(__filename);
possibleRoots.push(
path.resolve(moduleDir, '..', '..'),
path.resolve(moduleDir, '..')
);
}
// Check common installation locations
possibleRoots.push(
// Current working directory (for development/tests)
process.cwd(),
// Parent of cwd
path.resolve(process.cwd(), '..')
);
if (debug) {
console.log('DEBUG: Searching for package.json from:', startPath || 'multiple locations');
console.log('DEBUG: Checking paths:', possibleRoots);
}
// Remove duplicates
const uniqueRoots = [...new Set(possibleRoots)];
for (const root of uniqueRoots) {
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}`);
}
// Cache the result if no specific start path was requested
if (!startPath) {
cachedPackageRoot = root;
}
return root;
}
} catch (e) {
// Continue to the next potential root
if (debug) {
console.error(`DEBUG: Failed to parse package.json at ${packageJsonPath}:`, e);
}
}
}
}
if (debug) {
console.warn('DEBUG: Could not find package root directory');
}
// Cache null result as well to avoid repeated searches
if (!startPath) {
cachedPackageRoot = null;
}
return null;
};
function getParentPath(p: string, filename: string): string {
if (p.endsWith(filename)) {
p = p.slice(0, -filename.length);
@@ -40,22 +201,36 @@ export const getConfigFilePath = (filename: string, description = 'Configuration
}
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),
],
// 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),
];
// Also check in the installed package root directory
const packageRoot = findPackageRoot();
if (packageRoot) {
potentialPaths.push(path.join(packageRoot, filename));
}
for (const filePath of potentialPaths) {
if (fs.existsSync(filePath)) {
return filePath;
}
}
// If all paths do not exist, check if we have a fallback in the package root
// If the file exists in the package root, use it as the default
if (packageRoot) {
const packageConfigPath = path.join(packageRoot, filename);
if (fs.existsSync(packageConfigPath)) {
console.log(`Using ${description} from package: ${packageConfigPath}`);
return packageConfigPath;
}
}
// 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

View File

@@ -1,13 +1,24 @@
import fs from 'fs';
import path from 'path';
import { findPackageRoot } from './path.js';
/**
* Gets the package version from package.json
* @param searchPath Optional path to start searching from (defaults to cwd)
* @returns The version string from package.json, or 'dev' if not found
*/
export const getPackageVersion = (): string => {
export const getPackageVersion = (searchPath?: string): string => {
try {
const packageJsonPath = path.resolve(process.cwd(), 'package.json');
// Use provided path or fallback to current working directory
const startPath = searchPath || process.cwd();
const packageRoot = findPackageRoot(startPath);
if (!packageRoot) {
console.warn('Could not find package root, using default version');
return 'dev';
}
const packageJsonPath = path.join(packageRoot, 'package.json');
const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf8');
const packageJson = JSON.parse(packageJsonContent);
return packageJson.version || 'dev';

View File

@@ -0,0 +1,139 @@
import { getMcpSettingsJson } from '../../src/controllers/configController.js'
import * as config from '../../src/config/index.js'
import { Request, Response } from 'express'
// Mock the config module
jest.mock('../../src/config/index.js')
describe('ConfigController - getMcpSettingsJson', () => {
let mockRequest: Partial<Request>
let mockResponse: Partial<Response>
let mockJson: jest.Mock
let mockStatus: jest.Mock
beforeEach(() => {
mockJson = jest.fn()
mockStatus = jest.fn().mockReturnThis()
mockRequest = {
query: {},
}
mockResponse = {
json: mockJson,
status: mockStatus,
}
// Reset mocks
jest.clearAllMocks()
})
describe('Full Settings Export', () => {
it('should handle settings without users array', () => {
const mockSettings = {
mcpServers: {
'test-server': {
command: 'test',
args: ['--test'],
},
},
}
;(config.loadOriginalSettings as jest.Mock).mockReturnValue(mockSettings)
getMcpSettingsJson(mockRequest as Request, mockResponse as Response)
expect(mockJson).toHaveBeenCalledWith({
success: true,
data: {
mcpServers: mockSettings.mcpServers,
users: undefined,
},
})
})
})
describe('Individual Server Export', () => {
it('should return individual server configuration when serverName is specified', () => {
const mockSettings = {
mcpServers: {
'test-server': {
command: 'test',
args: ['--test'],
env: {
TEST_VAR: 'test-value',
},
},
'another-server': {
command: 'another',
args: ['--another'],
},
},
users: [
{
username: 'admin',
password: '$2b$10$hashedpassword',
isAdmin: true,
},
],
}
mockRequest.query = { serverName: 'test-server' }
;(config.loadOriginalSettings as jest.Mock).mockReturnValue(mockSettings)
getMcpSettingsJson(mockRequest as Request, mockResponse as Response)
expect(mockJson).toHaveBeenCalledWith({
success: true,
data: {
mcpServers: {
'test-server': {
command: 'test',
args: ['--test'],
env: {
TEST_VAR: 'test-value',
},
},
},
},
})
})
it('should return 404 when server does not exist', () => {
const mockSettings = {
mcpServers: {
'test-server': {
command: 'test',
args: ['--test'],
},
},
}
mockRequest.query = { serverName: 'non-existent-server' }
;(config.loadOriginalSettings as jest.Mock).mockReturnValue(mockSettings)
getMcpSettingsJson(mockRequest as Request, mockResponse as Response)
expect(mockStatus).toHaveBeenCalledWith(404)
expect(mockJson).toHaveBeenCalledWith({
success: false,
message: "Server 'non-existent-server' not found",
})
})
})
describe('Error Handling', () => {
it('should handle errors gracefully and return 500', () => {
const errorMessage = 'Failed to load settings'
;(config.loadOriginalSettings as jest.Mock).mockImplementation(() => {
throw new Error(errorMessage)
})
getMcpSettingsJson(mockRequest as Request, mockResponse as Response)
expect(mockStatus).toHaveBeenCalledWith(500)
expect(mockJson).toHaveBeenCalledWith({
success: false,
message: 'Failed to get MCP settings',
})
})
})
})

View File

@@ -1,111 +0,0 @@
import {
getOrCreatePerSessionServer,
cleanupPerSessionServers,
} from '../../src/services/mcpService';
import { ServerConfig } from '../../src/types';
// Mock the serverDao
jest.mock('../../src/dao/index.js', () => ({
getServerDao: () => ({
findById: jest.fn((name: string) => {
if (name === 'playwright') {
return Promise.resolve({
name: 'playwright',
command: 'npx',
args: ['@playwright/mcp@latest', '--headless', '--isolated'],
perSession: true,
enabled: true,
});
}
return Promise.resolve(null);
}),
findAll: jest.fn(() => Promise.resolve([])),
}),
}));
// Mock the Client and Transport classes
jest.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
Client: jest.fn().mockImplementation(() => ({
connect: jest.fn(() => Promise.resolve()),
close: jest.fn(),
listTools: jest.fn(() => Promise.resolve({ tools: [] })),
listPrompts: jest.fn(() => Promise.resolve({ prompts: [] })),
getServerCapabilities: jest.fn(() => ({ tools: true, prompts: true })),
callTool: jest.fn((params) => Promise.resolve({ content: [{ type: 'text', text: `Tool ${params.name} called` }] })),
})),
}));
jest.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
StdioClientTransport: jest.fn().mockImplementation(() => ({
close: jest.fn(),
stderr: {
on: jest.fn(),
},
})),
}));
describe('Per-Session Server Instances', () => {
afterEach(() => {
// Clean up any created sessions
cleanupPerSessionServers('session1');
cleanupPerSessionServers('session2');
});
it('should create separate server instances for different sessions', async () => {
const config: ServerConfig = {
command: 'npx',
args: ['@playwright/mcp@latest', '--headless', '--isolated'],
perSession: true,
};
// Create server for session1
const server1 = await getOrCreatePerSessionServer('session1', 'playwright', config);
expect(server1).toBeDefined();
expect(server1.sessionId).toBe('session1');
// Create server for session2
const server2 = await getOrCreatePerSessionServer('session2', 'playwright', config);
expect(server2).toBeDefined();
expect(server2.sessionId).toBe('session2');
// They should be different instances
expect(server1).not.toBe(server2);
});
it('should reuse existing per-session server for the same session', async () => {
const config: ServerConfig = {
command: 'npx',
args: ['@playwright/mcp@latest', '--headless', '--isolated'],
perSession: true,
};
// Create server for session1
const server1 = await getOrCreatePerSessionServer('session1', 'playwright', config);
// Request the same server again
const server2 = await getOrCreatePerSessionServer('session1', 'playwright', config);
// Should be the same instance
expect(server1).toBe(server2);
});
it('should clean up per-session servers when session ends', async () => {
const config: ServerConfig = {
command: 'npx',
args: ['@playwright/mcp@latest', '--headless', '--isolated'],
perSession: true,
};
// Create server for session1
const server1 = await getOrCreatePerSessionServer('session1', 'playwright', config);
expect(server1).toBeDefined();
// Clean up session1
cleanupPerSessionServers('session1');
// Create again should create a new instance (not the same object)
const server2 = await getOrCreatePerSessionServer('session1', 'playwright', config);
expect(server2).toBeDefined();
expect(server2).not.toBe(server1);
});
});

View File

@@ -8,6 +8,11 @@ Object.assign(process.env, {
DATABASE_URL: 'sqlite::memory:',
});
// Mock moduleDir to avoid import.meta parsing issues in Jest
jest.mock('../src/utils/moduleDir.js', () => ({
getCurrentModuleDir: jest.fn(() => process.cwd()),
}));
// Global test utilities
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace