Compare commits

..

5 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
edd1a4b063 Fix linting errors in integration test
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-11 15:29:57 +00:00
copilot-swe-agent[bot]
1ff542ed45 Add bug fix summary documentation
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-11 15:27:47 +00:00
copilot-swe-agent[bot]
94d5649782 Add comprehensive integration tests for group persistence
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-11 15:25:45 +00:00
copilot-swe-agent[bot]
f9615c8693 Fix group creation not persisting - updated mergeSettings implementations
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-11 15:22:00 +00:00
copilot-swe-agent[bot]
29c6d4bd75 Initial plan 2025-10-11 15:12:09 +00:00
36 changed files with 1092 additions and 1367 deletions

View File

@@ -1,124 +0,0 @@
# 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

3
.gitignore vendored
View File

@@ -25,5 +25,4 @@ yarn-error.log*
*.log
coverage/
data/
temp-test-config/
data/

View File

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

169
BUGFIX_SUMMARY.md Normal file
View File

@@ -0,0 +1,169 @@
# Bug Fix: Group Creation Not Persisting in v0.9.11
## Issue Description
After deploying version 0.9.11, users were unable to add groups. The group creation appeared to succeed (no errors were reported), but the groups list remained empty.
## Root Cause Analysis
The problem was in the `mergeSettings` implementations in both `DataServiceImpl` and `DataServicex`:
### Before Fix
**DataServiceImpl.mergeSettings:**
```typescript
mergeSettings(all: McpSettings, newSettings: McpSettings, _user?: IUser): McpSettings {
return newSettings; // Simply returns newSettings, discarding fields from 'all'
}
```
**DataServicex.mergeSettings (admin user):**
```typescript
const result = { ...all };
result.users = newSettings.users; // Only copied users
result.systemConfig = newSettings.systemConfig; // Only copied systemConfig
return result;
// Missing: groups, mcpServers, userConfigs
```
### The Problem Flow
When a user created a group through the API:
1. `groupService.createGroup()` loaded settings: `loadSettings()` → returns complete settings
2. Modified the groups array by adding new group
3. Called `saveSettings(modifiedSettings)`
4. `saveSettings()` called `mergeSettings(originalSettings, modifiedSettings)`
5. **`mergeSettings()` only preserved `users` and `systemConfig`, discarding the `groups` array**
6. The file was saved without groups
7. Result: Groups were never persisted!
### Why This Happened
The `mergeSettings` function is designed to selectively merge changes from user operations while preserving the rest of the original settings. However, the implementations were incomplete and only handled `users` and `systemConfig`, ignoring:
- `groups` (the bug causing this issue!)
- `mcpServers`
- `userConfigs` (in DataServiceImpl)
## Solution
Updated both `mergeSettings` implementations to properly preserve ALL fields:
### DataServiceImpl.mergeSettings (Fixed)
```typescript
mergeSettings(all: McpSettings, newSettings: McpSettings, _user?: IUser): McpSettings {
return {
...all,
...newSettings,
// Explicitly handle each field, preserving from 'all' when not in newSettings
users: newSettings.users !== undefined ? newSettings.users : all.users,
mcpServers: newSettings.mcpServers !== undefined ? newSettings.mcpServers : all.mcpServers,
groups: newSettings.groups !== undefined ? newSettings.groups : all.groups,
systemConfig: newSettings.systemConfig !== undefined ? newSettings.systemConfig : all.systemConfig,
userConfigs: newSettings.userConfigs !== undefined ? newSettings.userConfigs : all.userConfigs,
};
}
```
### DataServicex.mergeSettings (Fixed)
```typescript
if (!currentUser || currentUser.isAdmin) {
const result = { ...all };
// Merge all fields, using newSettings values when present
if (newSettings.users !== undefined) result.users = newSettings.users;
if (newSettings.mcpServers !== undefined) result.mcpServers = newSettings.mcpServers;
if (newSettings.groups !== undefined) result.groups = newSettings.groups; // FIXED!
if (newSettings.systemConfig !== undefined) result.systemConfig = newSettings.systemConfig;
if (newSettings.userConfigs !== undefined) result.userConfigs = newSettings.userConfigs;
return result;
}
```
## Changes Made
### Modified Files
1. `src/services/dataService.ts` - Fixed mergeSettings implementation
2. `src/services/dataServicex.ts` - Fixed mergeSettings implementation
### New Test Files
1. `tests/services/groupService.test.ts` - 11 tests for group operations
2. `tests/services/dataServiceMerge.test.ts` - 7 tests for mergeSettings behavior
3. `tests/integration/groupPersistence.test.ts` - 5 integration tests
## Test Coverage
### Before Fix
- 81 tests passing
- No tests for group persistence or mergeSettings behavior
### After Fix
- **104 tests passing** (23 new tests)
- Comprehensive coverage of:
- Group creation and persistence
- mergeSettings behavior for both implementations
- Integration tests verifying end-to-end group operations
- Field preservation during merge operations
## Verification
### Automated Tests
```bash
pnpm test:ci
# Result: 104 tests passed
```
### Manual Testing
Created a test script that:
1. Creates a group
2. Clears cache
3. Reloads settings
4. Verifies the group persists
**Result: ✅ Group persists correctly**
### Integration Test Output
```
✅ Group creation works correctly
✅ Group persistence works correctly
✅ All tests passed! The group creation bug has been fixed.
```
## Impact Assessment
### Risk Level: LOW
- Minimal code changes (only mergeSettings implementations)
- All existing tests continue to pass
- No breaking changes to API or behavior
- Only fixes broken functionality
### Affected Components
- ✅ Group creation
- ✅ Group updates
- ✅ Server additions
- ✅ User config updates
- ✅ System config updates
### No Impact On
- MCP server operations
- Authentication
- API endpoints
- Frontend components
- Routing logic
## Deployment Notes
This fix is backward compatible and can be deployed immediately:
- No database migrations required
- No configuration changes needed
- Existing groups (if any managed to be saved) remain intact
- Fix is transparent to users
## Conclusion
The bug has been completely fixed with minimal, surgical changes to two functions. The fix:
- ✅ Resolves the reported issue
- ✅ Maintains backward compatibility
- ✅ Adds comprehensive test coverage
- ✅ Passes all existing tests
- ✅ Has been verified manually
Users can now successfully create and persist groups as expected.

View File

@@ -22,16 +22,6 @@ 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

@@ -1,7 +1,8 @@
#!/usr/bin/env node
import path from 'path';
import { fileURLToPath, pathToFileURL } from 'url';
import { fileURLToPath } from 'url';
import { execSync } from 'child_process';
import fs from 'fs';
// Enable debug logging if needed
@@ -89,10 +90,7 @@ checkFrontend(projectRoot);
// Start the server
console.log('🚀 Starting MCPHub server...');
const entryPath = path.join(projectRoot, 'dist', 'index.js');
// Convert to file:// URL for cross-platform ESM compatibility (required on Windows)
const entryUrl = pathToFileURL(entryPath).href;
import(entryUrl).catch(err => {
import(path.join(projectRoot, 'dist', 'index.js')).catch(err => {
console.error('Failed to start MCPHub:', err);
process.exit(1);
});

View File

@@ -41,50 +41,6 @@ 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

@@ -41,50 +41,6 @@ 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

@@ -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"
# Handle HTTP_PROXY and HTTPS_PROXY environment variables
# 处理 HTTP_PROXY HTTPS_PROXY 环境变量
if [ -n "$HTTP_PROXY" ]; then
echo "Setting HTTP proxy to ${HTTP_PROXY}"
npm config set proxy "$HTTP_PROXY"
@@ -19,33 +19,4 @@ 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,7 +7,6 @@ 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
@@ -40,8 +39,6 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
}
}, [])
const { exportMCPSettings } = useSettingsData()
const handleRemove = (e: React.MouseEvent) => {
e.stopPropagation()
setShowDeleteDialog(true)
@@ -102,39 +99,6 @@ 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)
@@ -147,7 +111,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) {
@@ -169,7 +133,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) {
@@ -186,33 +150,21 @@ 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 */}
@@ -221,9 +173,7 @@ 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 && (
@@ -246,25 +196,19 @@ 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
@@ -278,9 +222,7 @@ 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>
)}
@@ -288,9 +230,6 @@ 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"
@@ -300,20 +239,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
@@ -332,19 +271,10 @@ 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>
@@ -352,18 +282,14 @@ 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>
@@ -383,4 +309,4 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
)
}
export default ServerCard
export default ServerCard

View File

@@ -4,7 +4,6 @@ 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,21 +420,6 @@ 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();
@@ -469,6 +454,5 @@ export const useSettingsData = () => {
updateMCPRouterConfig,
updateMCPRouterConfigBatch,
updateNameSeparator,
exportMCPSettings,
};
};

View File

@@ -1,55 +1,54 @@
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'
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';
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,
@@ -67,15 +66,14 @@ const SettingsPage: React.FC = () => {
updateSmartRoutingConfigBatch,
updateMCPRouterConfig,
updateNameSeparator,
exportMCPSettings,
} = useSettingsData()
} = useSettingsData();
// Update local installConfig when savedInstallConfig changes
useEffect(() => {
if (savedInstallConfig) {
setInstallConfig(savedInstallConfig)
setInstallConfig(savedInstallConfig);
}
}, [savedInstallConfig])
}, [savedInstallConfig]);
// Update local tempSmartRoutingConfig when smartRoutingConfig changes
useEffect(() => {
@@ -85,9 +83,9 @@ const SettingsPage: React.FC = () => {
openaiApiBaseUrl: smartRoutingConfig.openaiApiBaseUrl || '',
openaiApiKey: smartRoutingConfig.openaiApiKey || '',
openaiApiEmbeddingModel: smartRoutingConfig.openaiApiEmbeddingModel || '',
})
});
}
}, [smartRoutingConfig])
}, [smartRoutingConfig]);
// Update local tempMCPRouterConfig when mcpRouterConfig changes
useEffect(() => {
@@ -97,14 +95,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,
@@ -112,244 +110,138 @@ const SettingsPage: React.FC = () => {
smartRoutingConfig: false,
mcpRouterConfig: false,
nameSeparator: false,
password: false,
exportConfig: false,
})
password: false
});
const toggleSection = (
section:
| 'routingConfig'
| 'installConfig'
| 'smartRoutingConfig'
| 'mcpRouterConfig'
| 'nameSeparator'
| 'password'
| 'exportConfig',
) => {
setSectionsVisible((prev) => ({
const toggleSection = (section: 'routingConfig' | 'installConfig' | 'smartRoutingConfig' | 'mcpRouterConfig' | 'nameSeparator' | 'password') => {
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)
}
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')
}
navigate('/');
}, 2000);
};
return (
<div className="container mx-auto">
@@ -373,9 +265,7 @@ 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}
@@ -387,8 +277,7 @@ 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">
@@ -413,8 +302,7 @@ 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">
@@ -444,9 +332,7 @@ 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}
@@ -463,17 +349,13 @@ 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}
@@ -510,9 +392,7 @@ 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
@@ -536,9 +416,7 @@ 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
@@ -570,7 +448,9 @@ 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 && (
@@ -610,7 +490,9 @@ 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 && (
@@ -623,9 +505,7 @@ const SettingsPage: React.FC = () => {
<Switch
disabled={loading}
checked={routingConfig.enableBearerAuth}
onCheckedChange={(checked) =>
handleRoutingConfigChange('enableBearerAuth', checked)
}
onCheckedChange={(checked) => handleRoutingConfigChange('enableBearerAuth', checked)}
/>
</div>
@@ -658,32 +538,24 @@ 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>
@@ -700,6 +572,7 @@ const SettingsPage: React.FC = () => {
/>
</div>
</PermissionChecker>
</div>
)}
</div>
@@ -712,7 +585,9 @@ 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 && (
@@ -800,7 +675,9 @@ 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 && (
@@ -809,61 +686,8 @@ const SettingsPage: React.FC = () => {
</div>
)}
</div>
</div >
);
};
{/* 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
export default SettingsPage;

View File

@@ -75,7 +75,6 @@
"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.",
@@ -125,7 +124,6 @@
"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",
@@ -202,7 +200,6 @@
"copyJson": "Copy JSON",
"copySuccess": "Copied to clipboard",
"copyFailed": "Copy failed",
"copied": "Copied",
"close": "Close",
"confirm": "Confirm",
"language": "Language",
@@ -505,14 +502,7 @@
"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.",
"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"
"restartRequired": "Configuration saved. It is recommended to restart the application to ensure all services load the new settings correctly."
},
"dxt": {
"upload": "Upload",

View File

@@ -75,7 +75,6 @@
"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.",
@@ -125,7 +124,6 @@
"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",
@@ -202,7 +200,6 @@
"copyJson": "Copier le JSON",
"copySuccess": "Copié dans le presse-papiers",
"copyFailed": "Échec de la copie",
"copied": "Copié",
"close": "Fermer",
"confirm": "Confirmer",
"language": "Langue",
@@ -505,14 +502,7 @@
"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.",
"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"
"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."
},
"dxt": {
"upload": "Télécharger",

View File

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

View File

@@ -42,4 +42,4 @@
"isAdmin": true
}
]
}
}

View File

@@ -60,7 +60,6 @@
"dotenv-expand": "^12.0.2",
"express": "^4.21.2",
"express-validator": "^7.2.1",
"i18next": "^25.5.0",
"i18next-fs-backend": "^2.6.0",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2",
@@ -100,6 +99,7 @@
"clsx": "^2.1.1",
"concurrently": "^9.2.0",
"eslint": "^8.57.1",
"i18next": "^25.5.0",
"i18next-browser-languagedetector": "^8.2.0",
"jest": "^30.2.0",
"jest-environment-node": "^30.0.5",

59
pnpm-lock.yaml generated
View File

@@ -57,9 +57,6 @@ importers:
express-validator:
specifier: ^7.2.1
version: 7.2.1
i18next:
specifier: ^25.5.0
version: 25.5.0(typescript@5.9.2)
i18next-fs-backend:
specifier: ^2.6.0
version: 2.6.0
@@ -172,6 +169,9 @@ importers:
eslint:
specifier: ^8.57.1
version: 8.57.1
i18next:
specifier: ^25.5.0
version: 25.5.0(typescript@5.9.2)
i18next-browser-languagedetector:
specifier: ^8.2.0
version: 8.2.0
@@ -693,92 +693,78 @@ packages:
resolution: {integrity: sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.0':
resolution: {integrity: sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.0':
resolution: {integrity: sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.0':
resolution: {integrity: sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.0':
resolution: {integrity: sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.0':
resolution: {integrity: sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.0':
resolution: {integrity: sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.3':
resolution: {integrity: sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.3':
resolution: {integrity: sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.3':
resolution: {integrity: sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.3':
resolution: {integrity: sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.3':
resolution: {integrity: sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.3':
resolution: {integrity: sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.3':
resolution: {integrity: sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.3':
resolution: {integrity: sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==}
@@ -970,28 +956,24 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@15.5.2':
resolution: {integrity: sha512-s6N8k8dF9YGc5T01UPQ08yxsK6fUow5gG1/axWc1HVVBYQBgOjca4oUZF7s4p+kwhkB1bDSGR8QznWrFZ/Rt5g==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@15.5.2':
resolution: {integrity: sha512-o1RV/KOODQh6dM6ZRJGZbc+MOAHww33Vbs5JC9Mp1gDk8cpEO+cYC/l7rweiEalkSm5/1WGa4zY7xrNwObN4+Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@15.5.2':
resolution: {integrity: sha512-/VUnh7w8RElYZ0IV83nUcP/J4KJ6LLYliiBIri3p3aW2giF+PAVgZb6mk8jbQSB3WlTai8gEmCAr7kptFa1H6g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@15.5.2':
resolution: {integrity: sha512-sMPyTvRcNKXseNQ/7qRfVRLa0VhR0esmQ29DD6pqvG71+JdVnESJaHPA8t7bc67KD5spP3+DOCNLhqlEI2ZgQg==}
@@ -1209,67 +1191,56 @@ packages:
resolution: {integrity: sha512-54v4okehwl5TaSIkpp97rAHGp7t3ghinRd/vyC1iXqXMfjYUTm7TfYmCzXDoHUPTTf36L8pr0E7YsD3CfB3ZDg==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.50.1':
resolution: {integrity: sha512-p/LaFyajPN/0PUHjv8TNyxLiA7RwmDoVY3flXHPSzqrGcIp/c2FjwPPP5++u87DGHtw+5kSH5bCJz0mvXngYxw==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.50.1':
resolution: {integrity: sha512-2AbMhFFkTo6Ptna1zO7kAXXDLi7H9fGTbVaIq2AAYO7yzcAsuTNWPHhb2aTA6GPiP+JXh85Y8CiS54iZoj4opw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.50.1':
resolution: {integrity: sha512-Cgef+5aZwuvesQNw9eX7g19FfKX5/pQRIyhoXLCiBOrWopjo7ycfB292TX9MDcDijiuIJlx1IzJz3IoCPfqs9w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loongarch64-gnu@4.50.1':
resolution: {integrity: sha512-RPhTwWMzpYYrHrJAS7CmpdtHNKtt2Ueo+BlLBjfZEhYBhK00OsEqM08/7f+eohiF6poe0YRDDd8nAvwtE/Y62Q==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.50.1':
resolution: {integrity: sha512-eSGMVQw9iekut62O7eBdbiccRguuDgiPMsw++BVUg+1K7WjZXHOg/YOT9SWMzPZA+w98G+Fa1VqJgHZOHHnY0Q==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.50.1':
resolution: {integrity: sha512-S208ojx8a4ciIPrLgazF6AgdcNJzQE4+S9rsmOmDJkusvctii+ZvEuIC4v/xFqzbuP8yDjn73oBlNDgF6YGSXQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.50.1':
resolution: {integrity: sha512-3Ag8Ls1ggqkGUvSZWYcdgFwriy2lWo+0QlYgEFra/5JGtAd6C5Hw59oojx1DeqcA2Wds2ayRgvJ4qxVTzCHgzg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.50.1':
resolution: {integrity: sha512-t9YrKfaxCYe7l7ldFERE1BRg/4TATxIg+YieHQ966jwvo7ddHJxPj9cNFWLAzhkVsbBvNA4qTbPVNsZKBO4NSg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.50.1':
resolution: {integrity: sha512-MCgtFB2+SVNuQmmjHf+wfI4CMxy3Tk8XjA5Z//A0AKD7QXUYFMQcns91K6dEHBvZPCnhJSyDWLApk40Iq/H3tA==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.50.1':
resolution: {integrity: sha512-nEvqG+0jeRmqaUMuwzlfMKwcIVffy/9KGbAGyoa26iu6eSngAYQ512bMXuqqPrlTyfqdlB9FVINs93j534UJrg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openharmony-arm64@4.50.1':
resolution: {integrity: sha512-RDsLm+phmT3MJd9SNxA9MNuEAO/J2fhW8GXk62G/B4G7sLVumNFbRwDL6v5NrESb48k+QMqdGbHgEtfU0LCpbA==}
@@ -1330,28 +1301,24 @@ packages:
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@swc/core-linux-arm64-musl@1.13.5':
resolution: {integrity: sha512-9+ZxFN5GJag4CnYnq6apKTnnezpfJhCumyz0504/JbHLo+Ue+ZtJnf3RhyA9W9TINtLE0bC4hKpWi8ZKoETyOQ==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@swc/core-linux-x64-gnu@1.13.5':
resolution: {integrity: sha512-WD530qvHrki8Ywt/PloKUjaRKgstQqNGvmZl54g06kA+hqtSE2FTG9gngXr3UJxYu/cNAjJYiBifm7+w4nbHbA==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@swc/core-linux-x64-musl@1.13.5':
resolution: {integrity: sha512-Luj8y4OFYx4DHNQTWjdIuKTq2f5k6uSXICqx+FSabnXptaOBAbJHNbHT/06JZh6NRUouaf0mYXN0mcsqvkhd7Q==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@swc/core-win32-arm64-msvc@1.13.5':
resolution: {integrity: sha512-cZ6UpumhF9SDJvv4DA2fo9WIzlNFuKSkZpZmPG1c+4PFSEMy5DFOjBSllCvnqihCabzXzpn6ykCwBmHpy31vQw==}
@@ -1471,56 +1438,48 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-gnu@4.1.14':
resolution: {integrity: sha512-qaEy0dIZ6d9vyLnmeg24yzA8XuEAD9WjpM5nIM1sUgQ/Zv7cVkharPDQcmm/t/TvXoKo/0knI3me3AGfdx6w1w==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.12':
resolution: {integrity: sha512-V8pAM3s8gsrXcCv6kCHSuwyb/gPsd863iT+v1PGXC4fSL/OJqsKhfK//v8P+w9ThKIoqNbEnsZqNy+WDnwQqCA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-arm64-musl@4.1.14':
resolution: {integrity: sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.12':
resolution: {integrity: sha512-xYfqYLjvm2UQ3TZggTGrwxjYaLB62b1Wiysw/YE3Yqbh86sOMoTn0feF98PonP7LtjsWOWcXEbGqDL7zv0uW8Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-gnu@4.1.14':
resolution: {integrity: sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.12':
resolution: {integrity: sha512-ha0pHPamN+fWZY7GCzz5rKunlv9L5R8kdh+YNvP5awe3LtuXb5nRi/H27GeL2U+TdhDOptU7T6Is7mdwh5Ar3A==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-musl@4.1.14':
resolution: {integrity: sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.12':
resolution: {integrity: sha512-4tSyu3dW+ktzdEpuk6g49KdEangu3eCYoqPhWNsZgUhyegEda3M9rG0/j1GV/JjVVsj+lG7jWAyrTlLzd/WEBg==}
@@ -1857,49 +1816,41 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@@ -3246,28 +3197,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.30.1:
resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.30.1:
resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.30.1:
resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.30.1:
resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==}

View File

@@ -42,9 +42,8 @@ export const loadOriginalSettings = (): McpSettings => {
console.log(`Loaded settings from ${settingsPath}`);
return settings;
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(`Failed to load settings from ${settingsPath}:`, errorMessage);
} catch (error) {
console.error(`Failed to load settings from ${settingsPath}:`, error);
const defaultSettings = { mcpServers: {}, users: [] };
// Cache default settings

View File

@@ -1,6 +1,6 @@
import { Request, Response } from 'express';
import config from '../config/index.js';
import { loadSettings, loadOriginalSettings } from '../config/index.js';
import { loadSettings } from '../config/index.js';
import { getDataService } from '../services/services.js';
import { DataService } from '../services/dataService.js';
import { IUser } from '../types/index.js';
@@ -72,46 +72,3 @@ 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, getMcpSettingsJson } from '../controllers/configController.js';
import { getRuntimeConfig, getPublicConfig } from '../controllers/configController.js';
import { callTool } from '../controllers/toolController.js';
import { getPrompt } from '../controllers/promptController.js';
import { uploadDxtFile, uploadMiddleware } from '../controllers/dxtController.js';
@@ -149,9 +149,6 @@ 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,26 +15,9 @@ 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 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();
}
}
// Get the current working directory (will be project root in most cases)
const currentFileDir = process.cwd() + '/src';
export class AppServer {
private app: express.Application;
@@ -184,11 +167,10 @@ 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:', currentDir);
console.log('DEBUG: Script directory:', currentFileDir);
}
// First, find the package root directory
@@ -223,9 +205,51 @@ export class AppServer {
// Helper method to find the package root (where package.json is located)
private findPackageRoot(): string | null {
// Use the shared utility function which properly handles ESM module paths
const currentDir = getCurrentFileDir();
return findPackageRoot(currentDir);
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;
}
}

View File

@@ -22,7 +22,19 @@ export class DataServiceImpl implements DataService {
}
mergeSettings(all: McpSettings, newSettings: McpSettings, _user?: IUser): McpSettings {
return newSettings;
// Merge all fields from newSettings into all, preserving fields not present in newSettings
return {
...all,
...newSettings,
// Ensure arrays and objects are properly handled
users: newSettings.users !== undefined ? newSettings.users : all.users,
mcpServers: newSettings.mcpServers !== undefined ? newSettings.mcpServers : all.mcpServers,
groups: newSettings.groups !== undefined ? newSettings.groups : all.groups,
systemConfig:
newSettings.systemConfig !== undefined ? newSettings.systemConfig : all.systemConfig,
userConfigs:
newSettings.userConfigs !== undefined ? newSettings.userConfigs : all.userConfigs,
};
}
getPermissions(_user: IUser): string[] {

View File

@@ -36,12 +36,17 @@ export class DataServicex implements DataService {
// Use passed user parameter if available, otherwise fall back to context
const currentUser = user || UserContextService.getInstance().getCurrentUser();
if (!currentUser || currentUser.isAdmin) {
// Admin users can modify all settings
const result = { ...all };
result.users = newSettings.users;
result.systemConfig = newSettings.systemConfig;
result.groups = newSettings.groups;
// Merge all fields, using newSettings values when present
if (newSettings.users !== undefined) result.users = newSettings.users;
if (newSettings.mcpServers !== undefined) result.mcpServers = newSettings.mcpServers;
if (newSettings.groups !== undefined) result.groups = newSettings.groups;
if (newSettings.systemConfig !== undefined) result.systemConfig = newSettings.systemConfig;
if (newSettings.userConfigs !== undefined) result.userConfigs = newSettings.userConfigs;
return result;
} else {
// Non-admin users can only modify their own userConfig
const result = JSON.parse(JSON.stringify(all));
if (!result.userConfigs) {
result.userConfigs = {};

View File

@@ -14,11 +14,6 @@ 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

@@ -43,6 +43,7 @@ export function registerService<T>(key: string, entry: Service<T>) {
}
}
console.log(`Service registered: ${key} with entry:`, entry);
registry.set(key, entry);
}

View File

@@ -1,11 +0,0 @@
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,178 +1,10 @@
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);
}
return path.resolve(p);
}
/**
* 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')
@@ -180,27 +12,9 @@ function getParentPath(p: string, filename: string): string {
* @returns The path to the file
*/
export const getConfigFilePath = (filename: string, description = 'Configuration'): string => {
if (filename === 'mcp_settings.json') {
const envPath = process.env.MCPHUB_SETTING_PATH;
if (envPath) {
// Ensure directory exists
const dir = getParentPath(envPath, filename);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
console.log(`Created directory for settings at ${dir}`);
}
// if full path, return as is
if (envPath?.endsWith(filename)) {
return envPath;
}
// if directory, return path under that directory
return path.resolve(envPath, filename);
}
}
const envPath = process.env.MCPHUB_SETTING_PATH;
const potentialPaths = [
...(envPath ? [envPath] : []),
// Prioritize process.cwd() as the first location to check
path.resolve(process.cwd(), filename),
// Use path relative to the root directory
@@ -209,28 +23,12 @@ export const getConfigFilePath = (filename: string, description = 'Configuration
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,24 +1,13 @@
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 = (searchPath?: string): string => {
export const getPackageVersion = (): string => {
try {
// 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 packageJsonPath = path.resolve(process.cwd(), 'package.json');
const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf8');
const packageJson = JSON.parse(packageJsonContent);
return packageJson.version || 'dev';

View File

@@ -1,139 +0,0 @@
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

@@ -0,0 +1,182 @@
/**
* Integration test for group persistence
* This test verifies that groups can be created and persisted through the full stack
*/
import fs from 'fs';
import path from 'path';
import { getAllGroups, createGroup, deleteGroup } from '../../src/services/groupService.js';
import * as config from '../../src/config/index.js';
describe('Group Persistence Integration Tests', () => {
const testSettingsPath = path.join(__dirname, '..', 'fixtures', 'test_mcp_settings.json');
let originalGetConfigFilePath: any;
beforeAll(async () => {
// Mock getConfigFilePath to use our test settings file
// eslint-disable-next-line @typescript-eslint/no-var-requires
const pathModule = require('../../src/utils/path.js');
originalGetConfigFilePath = pathModule.getConfigFilePath;
pathModule.getConfigFilePath = (filename: string) => {
if (filename === 'mcp_settings.json') {
return testSettingsPath;
}
return originalGetConfigFilePath(filename);
};
// Create test settings file
const testSettings = {
mcpServers: {
'test-server-1': {
command: 'echo',
args: ['test1'],
},
'test-server-2': {
command: 'echo',
args: ['test2'],
},
},
groups: [],
users: [{ username: 'admin', password: 'hash', isAdmin: true }],
};
// Ensure fixtures directory exists
const fixturesDir = path.dirname(testSettingsPath);
if (!fs.existsSync(fixturesDir)) {
fs.mkdirSync(fixturesDir, { recursive: true });
}
fs.writeFileSync(testSettingsPath, JSON.stringify(testSettings, null, 2));
});
afterAll(() => {
// Restore original function
if (originalGetConfigFilePath) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const pathModule = require('../../src/utils/path.js');
pathModule.getConfigFilePath = originalGetConfigFilePath;
}
// Clean up test file
if (fs.existsSync(testSettingsPath)) {
fs.unlinkSync(testSettingsPath);
}
});
beforeEach(() => {
// Clear the settings cache before each test
config.clearSettingsCache();
// Reset test settings file to clean state
const testSettings = {
mcpServers: {
'test-server-1': {
command: 'echo',
args: ['test1'],
},
'test-server-2': {
command: 'echo',
args: ['test2'],
},
},
groups: [],
users: [{ username: 'admin', password: 'hash', isAdmin: true }],
};
fs.writeFileSync(testSettingsPath, JSON.stringify(testSettings, null, 2));
});
it('should persist a newly created group to file', () => {
// Create a group
const groupName = 'integration-test-group';
const description = 'Test group for integration testing';
const servers = ['test-server-1'];
const newGroup = createGroup(groupName, description, servers);
expect(newGroup).not.toBeNull();
expect(newGroup?.name).toBe(groupName);
// Clear cache and reload settings from file
config.clearSettingsCache();
// Verify group was persisted to file
const savedSettings = JSON.parse(fs.readFileSync(testSettingsPath, 'utf8'));
expect(savedSettings.groups).toHaveLength(1);
expect(savedSettings.groups[0].name).toBe(groupName);
expect(savedSettings.groups[0].description).toBe(description);
expect(savedSettings.groups[0].servers).toHaveLength(1);
expect(savedSettings.groups[0].servers[0]).toEqual({ name: 'test-server-1', tools: 'all' });
});
it('should persist multiple groups sequentially', () => {
// Create first group
const group1 = createGroup('group-1', 'First group', ['test-server-1']);
expect(group1).not.toBeNull();
// Clear cache
config.clearSettingsCache();
// Create second group
const group2 = createGroup('group-2', 'Second group', ['test-server-2']);
expect(group2).not.toBeNull();
// Clear cache and verify both groups are persisted
config.clearSettingsCache();
const savedSettings = JSON.parse(fs.readFileSync(testSettingsPath, 'utf8'));
expect(savedSettings.groups).toHaveLength(2);
expect(savedSettings.groups[0].name).toBe('group-1');
expect(savedSettings.groups[1].name).toBe('group-2');
});
it('should preserve mcpServers when creating groups', () => {
// Get initial mcpServers
const initialSettings = JSON.parse(fs.readFileSync(testSettingsPath, 'utf8'));
const initialServers = initialSettings.mcpServers;
// Create a group
const newGroup = createGroup('test-group', 'Test', ['test-server-1']);
expect(newGroup).not.toBeNull();
// Verify mcpServers are preserved
config.clearSettingsCache();
const savedSettings = JSON.parse(fs.readFileSync(testSettingsPath, 'utf8'));
expect(savedSettings.mcpServers).toEqual(initialServers);
expect(savedSettings.groups).toHaveLength(1);
});
it('should allow deleting a persisted group', () => {
// Create a group
const newGroup = createGroup('temp-group', 'Temporary', ['test-server-1']);
expect(newGroup).not.toBeNull();
const groupId = newGroup!.id;
// Verify it's saved
config.clearSettingsCache();
let savedSettings = JSON.parse(fs.readFileSync(testSettingsPath, 'utf8'));
expect(savedSettings.groups).toHaveLength(1);
// Delete the group
const deleted = deleteGroup(groupId);
expect(deleted).toBe(true);
// Verify it's deleted from file
config.clearSettingsCache();
savedSettings = JSON.parse(fs.readFileSync(testSettingsPath, 'utf8'));
expect(savedSettings.groups).toHaveLength(0);
});
it('should handle empty groups array correctly', () => {
// Get all groups when none exist
const groups = getAllGroups();
expect(groups).toEqual([]);
// Create a group
createGroup('first-group', 'First', ['test-server-1']);
// Clear cache and get groups again
config.clearSettingsCache();
const groupsAfterCreate = getAllGroups();
expect(groupsAfterCreate).toHaveLength(1);
});
});

View File

@@ -0,0 +1,225 @@
import { DataServiceImpl } from '../../src/services/dataService.js';
import { DataServicex } from '../../src/services/dataServicex.js';
import { McpSettings, IUser } from '../../src/types/index.js';
describe('DataService mergeSettings', () => {
describe('DataServiceImpl', () => {
let service: DataServiceImpl;
beforeEach(() => {
service = new DataServiceImpl();
});
it('should merge all fields from newSettings into existing settings', () => {
const all: McpSettings = {
users: [
{ username: 'admin', password: 'hash1', isAdmin: true },
{ username: 'user1', password: 'hash2', isAdmin: false },
],
mcpServers: {
'server1': { command: 'cmd1', args: [] },
'server2': { command: 'cmd2', args: [] },
},
groups: [
{ id: '1', name: 'group1', servers: [], owner: 'admin' },
],
systemConfig: {
routing: { enableGlobalRoute: true, enableGroupNameRoute: true },
},
userConfigs: {
user1: { routing: { enableGlobalRoute: false, enableGroupNameRoute: false } },
},
};
const newSettings: McpSettings = {
mcpServers: {},
groups: [
{ id: '1', name: 'group1', servers: [], owner: 'admin' },
{ id: '2', name: 'group2', servers: [], owner: 'admin' },
],
};
const result = service.mergeSettings(all, newSettings);
// New groups should be present
expect(result.groups).toHaveLength(2);
expect(result.groups).toEqual(newSettings.groups);
// Other fields from 'all' should be preserved when not in newSettings
expect(result.users).toEqual(all.users);
expect(result.systemConfig).toEqual(all.systemConfig);
expect(result.userConfigs).toEqual(all.userConfigs);
});
it('should preserve fields not present in newSettings', () => {
const all: McpSettings = {
users: [{ username: 'admin', password: 'hash', isAdmin: true }],
mcpServers: {
'server1': { command: 'cmd1', args: [] },
},
groups: [{ id: '1', name: 'group1', servers: [], owner: 'admin' }],
systemConfig: { routing: { enableGlobalRoute: true, enableGroupNameRoute: true } },
};
const newSettings: McpSettings = {
mcpServers: {},
groups: [
{ id: '1', name: 'group1', servers: [], owner: 'admin' },
{ id: '2', name: 'group2', servers: [], owner: 'admin' },
],
};
const result = service.mergeSettings(all, newSettings);
// Groups from newSettings should be present
expect(result.groups).toEqual(newSettings.groups);
// Other fields should be preserved from 'all'
expect(result.users).toEqual(all.users);
expect(result.systemConfig).toEqual(all.systemConfig);
});
it('should handle undefined fields in newSettings', () => {
const all: McpSettings = {
users: [{ username: 'admin', password: 'hash', isAdmin: true }],
mcpServers: { 'server1': { command: 'cmd1', args: [] } },
groups: [{ id: '1', name: 'group1', servers: [], owner: 'admin' }],
};
const newSettings: McpSettings = {
mcpServers: {},
// groups is undefined
};
const result = service.mergeSettings(all, newSettings);
// Groups from 'all' should be preserved since newSettings.groups is undefined
expect(result.groups).toEqual(all.groups);
expect(result.users).toEqual(all.users);
});
});
describe('DataServicex', () => {
let service: DataServicex;
beforeEach(() => {
service = new DataServicex();
});
it('should merge all fields for admin users', () => {
const adminUser: IUser = { username: 'admin', password: 'hash', isAdmin: true };
const all: McpSettings = {
users: [adminUser],
mcpServers: {
'server1': { command: 'cmd1', args: [] },
},
groups: [{ id: '1', name: 'group1', servers: [], owner: 'admin' }],
systemConfig: { routing: { enableGlobalRoute: true, enableGroupNameRoute: true } },
};
const newSettings: McpSettings = {
mcpServers: {},
groups: [
{ id: '1', name: 'group1', servers: [], owner: 'admin' },
{ id: '2', name: 'group2', servers: [], owner: 'admin' },
],
systemConfig: { routing: { enableGlobalRoute: false, enableGroupNameRoute: false } },
};
const result = service.mergeSettings(all, newSettings, adminUser);
// All fields from newSettings should be merged
expect(result.groups).toEqual(newSettings.groups);
expect(result.systemConfig).toEqual(newSettings.systemConfig);
// Users should be preserved from 'all' since not in newSettings
expect(result.users).toEqual(all.users);
});
it('should preserve groups for admin users when adding new groups', () => {
const adminUser: IUser = { username: 'admin', password: 'hash', isAdmin: true };
const all: McpSettings = {
users: [adminUser],
mcpServers: { 'server1': { command: 'cmd1', args: [] } },
groups: [{ id: '1', name: 'group1', servers: [], owner: 'admin' }],
};
const newSettings: McpSettings = {
mcpServers: {},
groups: [
{ id: '1', name: 'group1', servers: [], owner: 'admin' },
{ id: '2', name: 'group2', servers: [], owner: 'admin' },
],
};
const result = service.mergeSettings(all, newSettings, adminUser);
// New groups should be present
expect(result.groups).toHaveLength(2);
expect(result.groups).toEqual(newSettings.groups);
});
it('should handle non-admin users correctly', () => {
const regularUser: IUser = { username: 'user1', password: 'hash', isAdmin: false };
const all: McpSettings = {
users: [regularUser],
mcpServers: { 'server1': { command: 'cmd1', args: [] } },
groups: [{ id: '1', name: 'group1', servers: [], owner: 'admin' }],
userConfigs: {},
};
const newSettings: McpSettings = {
mcpServers: {},
systemConfig: {
routing: {
enableGlobalRoute: false,
enableGroupNameRoute: false,
enableBearerAuth: true,
bearerAuthKey: 'test-key',
},
},
};
const result = service.mergeSettings(all, newSettings, regularUser);
// For non-admin users, groups should not change
expect(result.groups).toEqual(all.groups);
// User config should be updated
expect(result.userConfigs).toBeDefined();
expect(result.userConfigs?.['user1']).toBeDefined();
expect(result.userConfigs?.['user1'].routing).toEqual(newSettings.systemConfig?.routing);
});
it('should preserve all fields from original when only updating systemConfig', () => {
const adminUser: IUser = { username: 'admin', password: 'hash', isAdmin: true };
const all: McpSettings = {
users: [adminUser],
mcpServers: { 'server1': { command: 'cmd1', args: [] } },
groups: [{ id: '1', name: 'group1', servers: [], owner: 'admin' }],
systemConfig: { routing: { enableGlobalRoute: true, enableGroupNameRoute: true } },
};
const newSettings: McpSettings = {
mcpServers: {},
systemConfig: { routing: { enableGlobalRoute: false, enableGroupNameRoute: false } },
};
const result = service.mergeSettings(all, newSettings, adminUser);
// Groups should be preserved from 'all' since not in newSettings
expect(result.groups).toEqual(all.groups);
// SystemConfig should be updated from newSettings
expect(result.systemConfig).toEqual(newSettings.systemConfig);
// Users should be preserved from 'all' since not in newSettings
expect(result.users).toEqual(all.users);
// mcpServers should be updated from newSettings (empty in this case)
// This is expected behavior - when mcpServers is explicitly provided, it replaces the old value
expect(result.mcpServers).toEqual(newSettings.mcpServers);
});
});
});

View File

@@ -0,0 +1,262 @@
import { createGroup, getAllGroups, deleteGroup } from '../../src/services/groupService.js';
import * as config from '../../src/config/index.js';
import { McpSettings } from '../../src/types/index.js';
// Mock the config module
jest.mock('../../src/config/index.js', () => {
let mockSettings: McpSettings = {
mcpServers: {
'test-server': {
command: 'test',
args: [],
},
},
groups: [],
users: [],
};
return {
loadSettings: jest.fn(() => mockSettings),
saveSettings: jest.fn((settings: McpSettings) => {
mockSettings = settings;
return true;
}),
clearSettingsCache: jest.fn(),
};
});
// Mock the mcpService
jest.mock('../../src/services/mcpService.js', () => ({
notifyToolChanged: jest.fn(),
}));
// Mock the dataService
jest.mock('../../src/services/services.js', () => ({
getDataService: jest.fn(() => ({
filterData: (data: any[]) => data,
filterSettings: (settings: any) => settings,
mergeSettings: (all: any, newSettings: any) => newSettings,
getPermissions: () => ['*'],
})),
}));
describe('Group Service', () => {
beforeEach(() => {
// Reset mocks before each test
jest.clearAllMocks();
// Reset the mock settings to initial state
const mockSettings: McpSettings = {
mcpServers: {
'test-server': {
command: 'test',
args: [],
},
'test-server-2': {
command: 'test2',
args: [],
},
},
groups: [],
users: [],
};
(config.loadSettings as jest.Mock).mockReturnValue(mockSettings);
(config.saveSettings as jest.Mock).mockImplementation((settings: McpSettings) => {
mockSettings.groups = settings.groups;
return true;
});
});
describe('createGroup', () => {
it('should create a new group and persist it', () => {
const groupName = 'test-group';
const description = 'Test group description';
const servers = ['test-server'];
const newGroup = createGroup(groupName, description, servers);
expect(newGroup).not.toBeNull();
expect(newGroup?.name).toBe(groupName);
expect(newGroup?.description).toBe(description);
expect(newGroup?.servers).toHaveLength(1);
expect(newGroup?.servers[0]).toEqual({ name: 'test-server', tools: 'all' });
// Verify saveSettings was called
expect(config.saveSettings).toHaveBeenCalled();
// Verify the settings passed to saveSettings include the new group
const savedSettings = (config.saveSettings as jest.Mock).mock.calls[0][0];
expect(savedSettings.groups).toHaveLength(1);
expect(savedSettings.groups[0].name).toBe(groupName);
});
it('should create a group with multiple servers', () => {
const groupName = 'multi-server-group';
const servers = ['test-server', 'test-server-2'];
const newGroup = createGroup(groupName, undefined, servers);
expect(newGroup).not.toBeNull();
expect(newGroup?.servers).toHaveLength(2);
expect(newGroup?.servers[0]).toEqual({ name: 'test-server', tools: 'all' });
expect(newGroup?.servers[1]).toEqual({ name: 'test-server-2', tools: 'all' });
});
it('should create a group with server configuration objects', () => {
const groupName = 'config-group';
const servers = [
{ name: 'test-server', tools: 'all' },
{ name: 'test-server-2', tools: ['tool1', 'tool2'] },
];
const newGroup = createGroup(groupName, undefined, servers);
expect(newGroup).not.toBeNull();
expect(newGroup?.servers).toHaveLength(2);
expect(newGroup?.servers[0]).toEqual({ name: 'test-server', tools: 'all' });
expect(newGroup?.servers[1]).toEqual({ name: 'test-server-2', tools: ['tool1', 'tool2'] });
});
it('should filter out non-existent servers', () => {
const groupName = 'filtered-group';
const servers = ['test-server', 'non-existent-server'];
const newGroup = createGroup(groupName, undefined, servers);
expect(newGroup).not.toBeNull();
expect(newGroup?.servers).toHaveLength(1);
expect(newGroup?.servers[0]).toEqual({ name: 'test-server', tools: 'all' });
});
it('should not create a group with duplicate name', () => {
const groupName = 'duplicate-group';
// Create first group
const firstGroup = createGroup(groupName, 'First group');
expect(firstGroup).not.toBeNull();
// Update the mock to include the first group
const mockSettings: McpSettings = {
mcpServers: {
'test-server': {
command: 'test',
args: [],
},
},
groups: [firstGroup!],
users: [],
};
(config.loadSettings as jest.Mock).mockReturnValue(mockSettings);
// Try to create second group with same name
const secondGroup = createGroup(groupName, 'Second group');
expect(secondGroup).toBeNull();
});
it('should set owner to admin by default', () => {
const groupName = 'owned-group';
const newGroup = createGroup(groupName);
expect(newGroup).not.toBeNull();
expect(newGroup?.owner).toBe('admin');
});
it('should set custom owner when provided', () => {
const groupName = 'custom-owned-group';
const owner = 'testuser';
const newGroup = createGroup(groupName, undefined, [], owner);
expect(newGroup).not.toBeNull();
expect(newGroup?.owner).toBe(owner);
});
});
describe('getAllGroups', () => {
it('should return all groups', () => {
const mockSettings: McpSettings = {
mcpServers: {},
groups: [
{
id: '1',
name: 'group1',
servers: [],
owner: 'admin',
},
{
id: '2',
name: 'group2',
servers: [],
owner: 'admin',
},
],
users: [],
};
(config.loadSettings as jest.Mock).mockReturnValue(mockSettings);
const groups = getAllGroups();
expect(groups).toHaveLength(2);
expect(groups[0].name).toBe('group1');
expect(groups[1].name).toBe('group2');
});
it('should return empty array when no groups exist', () => {
const mockSettings: McpSettings = {
mcpServers: {},
users: [],
};
(config.loadSettings as jest.Mock).mockReturnValue(mockSettings);
const groups = getAllGroups();
expect(groups).toEqual([]);
});
});
describe('deleteGroup', () => {
it('should delete a group by id', () => {
const mockSettings: McpSettings = {
mcpServers: {},
groups: [
{
id: 'group-to-delete',
name: 'Delete Me',
servers: [],
owner: 'admin',
},
],
users: [],
};
(config.loadSettings as jest.Mock).mockReturnValue(mockSettings);
(config.saveSettings as jest.Mock).mockImplementation((settings: McpSettings) => {
mockSettings.groups = settings.groups;
return true;
});
const result = deleteGroup('group-to-delete');
expect(result).toBe(true);
expect(config.saveSettings).toHaveBeenCalled();
// Verify the settings passed to saveSettings have the group removed
const savedSettings = (config.saveSettings as jest.Mock).mock.calls[0][0];
expect(savedSettings.groups).toHaveLength(0);
});
it('should return false when group does not exist', () => {
const mockSettings: McpSettings = {
mcpServers: {},
groups: [],
users: [],
};
(config.loadSettings as jest.Mock).mockReturnValue(mockSettings);
const result = deleteGroup('non-existent-id');
expect(result).toBe(false);
});
});
});

View File

@@ -8,11 +8,6 @@ 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

View File

@@ -1,131 +0,0 @@
// Test for CLI path handling functionality
import path from 'path';
import { pathToFileURL } from 'url';
describe('CLI Path Handling', () => {
describe('Cross-platform ESM URL conversion', () => {
it('should convert Unix-style absolute path to file:// URL', () => {
const unixPath = '/home/user/project/dist/index.js';
const fileUrl = pathToFileURL(unixPath).href;
expect(fileUrl).toMatch(/^file:\/\//);
expect(fileUrl).toContain('index.js');
});
it('should handle relative paths correctly', () => {
const relativePath = path.join(process.cwd(), 'dist', 'index.js');
const fileUrl = pathToFileURL(relativePath).href;
expect(fileUrl).toMatch(/^file:\/\//);
expect(fileUrl).toContain('dist');
expect(fileUrl).toContain('index.js');
});
it('should produce valid URL format', () => {
const testPath = path.join(process.cwd(), 'test', 'file.js');
const fileUrl = pathToFileURL(testPath).href;
// Should be a valid URL
expect(() => new URL(fileUrl)).not.toThrow();
// Should start with file://
expect(fileUrl.startsWith('file://')).toBe(true);
});
it('should handle paths with spaces', () => {
const pathWithSpaces = path.join(process.cwd(), 'my folder', 'dist', 'index.js');
const fileUrl = pathToFileURL(pathWithSpaces).href;
expect(fileUrl).toMatch(/^file:\/\//);
expect(fileUrl).toContain('index.js');
// Spaces should be URL-encoded
expect(fileUrl).toContain('%20');
});
it('should handle paths with special characters', () => {
const pathWithSpecialChars = path.join(process.cwd(), 'test@dir', 'file#1.js');
const fileUrl = pathToFileURL(pathWithSpecialChars).href;
expect(fileUrl).toMatch(/^file:\/\//);
// Special characters should be URL-encoded
expect(() => new URL(fileUrl)).not.toThrow();
});
// Windows-specific path handling simulation
it('should handle Windows-style paths correctly', () => {
// Simulate a Windows path structure
// Note: On non-Windows systems, this creates a relative path,
// but the test verifies the conversion mechanism works
const mockWindowsPath = 'C:\\Users\\User\\project\\dist\\index.js';
// On Windows, pathToFileURL would convert C:\ to file:///C:/
// On Unix, it treats it as a relative path, but the conversion still works
const fileUrl = pathToFileURL(mockWindowsPath).href;
expect(fileUrl).toMatch(/^file:\/\//);
expect(fileUrl).toContain('index.js');
});
});
describe('Path normalization', () => {
it('should normalize path separators', () => {
const mixedPath = path.join('dist', 'index.js');
const fileUrl = pathToFileURL(path.resolve(mixedPath)).href;
expect(fileUrl).toMatch(/^file:\/\//);
// All separators should be forward slashes in URL
expect(fileUrl.split('file://')[1]).not.toContain('\\');
});
it('should handle multiple consecutive slashes', () => {
const messyPath = path.normalize('/dist//index.js');
const fileUrl = pathToFileURL(path.resolve(messyPath)).href;
expect(fileUrl).toMatch(/^file:\/\//);
expect(() => new URL(fileUrl)).not.toThrow();
});
});
describe('Path resolution for CLI use case', () => {
it('should convert package root path to valid import URL', () => {
const packageRoot = process.cwd();
const entryPath = path.join(packageRoot, 'dist', 'index.js');
const entryUrl = pathToFileURL(entryPath).href;
expect(entryUrl).toMatch(/^file:\/\//);
expect(entryUrl).toContain('dist');
expect(entryUrl).toContain('index.js');
expect(() => new URL(entryUrl)).not.toThrow();
});
it('should handle nested directory structures', () => {
const deepPath = path.join(process.cwd(), 'a', 'b', 'c', 'd', 'file.js');
const fileUrl = pathToFileURL(deepPath).href;
expect(fileUrl).toMatch(/^file:\/\//);
expect(fileUrl).toContain('file.js');
expect(() => new URL(fileUrl)).not.toThrow();
});
it('should produce URL compatible with dynamic import()', () => {
// This test verifies the exact pattern used in bin/cli.js
const projectRoot = process.cwd();
const entryPath = path.join(projectRoot, 'dist', 'index.js');
const entryUrl = pathToFileURL(entryPath).href;
// The URL should be valid for import()
expect(entryUrl).toMatch(/^file:\/\//);
expect(typeof entryUrl).toBe('string');
// Verify the URL format is valid
const urlObj = new URL(entryUrl);
expect(urlObj.protocol).toBe('file:');
expect(urlObj.href).toBe(entryUrl);
// On Windows, pathToFileURL converts 'C:\path' to 'file:///C:/path'
// On Unix, it converts '/path' to 'file:///path'
// Both formats are valid for dynamic import()
expect(entryUrl).toContain('index.js');
});
});
});