Compare commits

...

13 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
f9732fccb6 Add comprehensive documentation for per-session server instances
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-12 07:48:20 +00:00
copilot-swe-agent[bot]
7b3d441046 Add per-session server instance support to fix concurrent user isolation
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-12 07:45:21 +00:00
copilot-swe-agent[bot]
55a7d0b183 Initial plan 2025-10-12 07:32:31 +00:00
samanhappy
435227cbd4 fix: improve error handling and directory creation for settings path (#364) 2025-10-12 15:30:40 +08:00
Copilot
6a59becd8d Fix Windows startup error: Convert paths to file:// URLs for ESM dynamic imports (#363)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
Co-authored-by: samanhappy <samanhappy@gmail.com>
2025-10-12 11:31:44 +08:00
samanhappy
91698a50e3 fix: use specified environment setting path when available (#359) 2025-10-11 23:44:23 +08:00
samanhappy
a5d5045832 fix: add groups handling in mergeSettings method (#362) 2025-10-11 23:29:59 +08:00
samanhappy
198ea85225 feat: implement user management features with add, edit, and delete functionality 2025-10-02 15:11:08 +08:00
dependabot[bot]
6b39916909 chore(deps): bump typeorm from 0.3.26 to 0.3.27 (#356)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-02 14:47:55 +08:00
dependabot[bot]
9e8db370ff chore(deps-dev): bump @tailwindcss/postcss from 4.1.12 to 4.1.13 (#358)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-02 14:47:09 +08:00
dependabot[bot]
5d8bc44a73 chore(deps-dev): bump @types/node from 22.17.2 to 24.6.1 (#357)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-02 14:44:13 +08:00
dependabot[bot]
021901dbda chore(deps-dev): bump jest and @types/jest (#354)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-02 14:40:42 +08:00
WuWen
f6934a32dc feat: add configurable name separator for tools and prompts (#353) 2025-10-02 14:40:01 +08:00
32 changed files with 2725 additions and 800 deletions

3
.gitignore vendored
View File

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

View File

@@ -1,8 +1,7 @@
#!/usr/bin/env node
import path from 'path';
import { fileURLToPath } from 'url';
import { execSync } from 'child_process';
import { fileURLToPath, pathToFileURL } from 'url';
import fs from 'fs';
// Enable debug logging if needed
@@ -90,7 +89,10 @@ checkFrontend(projectRoot);
// Start the server
console.log('🚀 Starting MCPHub server...');
import(path.join(projectRoot, 'dist', 'index.js')).catch(err => {
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 => {
console.error('Failed to start MCPHub:', err);
process.exit(1);
});

View File

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

View File

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

View File

@@ -0,0 +1,153 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useUserData } from '@/hooks/useUserData';
import { UserFormData } from '@/types';
interface AddUserFormProps {
onAdd: () => void;
onCancel: () => void;
}
const AddUserForm = ({ onAdd, onCancel }: AddUserFormProps) => {
const { t } = useTranslation();
const { createUser } = useUserData();
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState<UserFormData>({
username: '',
password: '',
isAdmin: false,
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!formData.username.trim()) {
setError(t('users.usernameRequired'));
return;
}
if (!formData.password.trim()) {
setError(t('users.passwordRequired'));
return;
}
if (formData.password.length < 6) {
setError(t('users.passwordTooShort'));
return;
}
setIsSubmitting(true);
try {
const result = await createUser(formData);
if (result?.success) {
onAdd();
} else {
setError(result?.message || t('users.createError'));
}
} catch (err) {
setError(err instanceof Error ? err.message : t('users.createError'));
} finally {
setIsSubmitting(false);
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
};
return (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-center justify-center z-50">
<div className="bg-white p-8 rounded-lg shadow-xl max-w-md w-full mx-4">
<form onSubmit={handleSubmit}>
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('users.addNew')}</h2>
{error && (
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-3 mb-4">
<p className="text-sm">{error}</p>
</div>
)}
<div className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
{t('users.username')} *
</label>
<input
type="text"
id="username"
name="username"
value={formData.username}
onChange={handleInputChange}
placeholder={t('users.usernamePlaceholder')}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
disabled={isSubmitting}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
{t('users.password')} *
</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleInputChange}
placeholder={t('users.passwordPlaceholder')}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
disabled={isSubmitting}
minLength={6}
/>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="isAdmin"
name="isAdmin"
checked={formData.isAdmin}
onChange={handleInputChange}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
disabled={isSubmitting}
/>
<label htmlFor="isAdmin" className="ml-2 block text-sm text-gray-700">
{t('users.adminRole')}
</label>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 transition-colors duration-200"
disabled={isSubmitting}
>
{t('common.cancel')}
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isSubmitting}
>
{isSubmitting ? t('common.creating') : t('users.create')}
</button>
</div>
</form>
</div>
</div>
);
};
export default AddUserForm;

View File

@@ -0,0 +1,161 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useUserData } from '@/hooks/useUserData';
import { User, UserUpdateData } from '@/types';
interface EditUserFormProps {
user: User;
onEdit: () => void;
onCancel: () => void;
}
const EditUserForm = ({ user, onEdit, onCancel }: EditUserFormProps) => {
const { t } = useTranslation();
const { updateUser } = useUserData();
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState({
isAdmin: user.isAdmin,
newPassword: '',
confirmPassword: '',
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
// Validate passwords match if changing password
if (formData.newPassword && formData.newPassword !== formData.confirmPassword) {
setError(t('users.passwordMismatch'));
return;
}
if (formData.newPassword && formData.newPassword.length < 6) {
setError(t('users.passwordTooShort'));
return;
}
setIsSubmitting(true);
try {
const updateData: UserUpdateData = {
isAdmin: formData.isAdmin,
};
if (formData.newPassword) {
updateData.newPassword = formData.newPassword;
}
const result = await updateUser(user.username, updateData);
if (result?.success) {
onEdit();
} else {
setError(result?.message || t('users.updateError'));
}
} catch (err) {
setError(err instanceof Error ? err.message : t('users.updateError'));
} finally {
setIsSubmitting(false);
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
};
return (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-center justify-center z-50">
<div className="bg-white p-8 rounded-lg shadow-xl max-w-md w-full mx-4">
<form onSubmit={handleSubmit}>
<h2 className="text-xl font-semibold text-gray-800 mb-4">
{t('users.edit')} - {user.username}
</h2>
{error && (
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-3 mb-4">
<p className="text-sm">{error}</p>
</div>
)}
<div className="space-y-4">
<div className="flex items-center">
<input
type="checkbox"
id="isAdmin"
name="isAdmin"
checked={formData.isAdmin}
onChange={handleInputChange}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
disabled={isSubmitting}
/>
<label htmlFor="isAdmin" className="ml-2 block text-sm text-gray-700">
{t('users.adminRole')}
</label>
</div>
<div>
<label htmlFor="newPassword" className="block text-sm font-medium text-gray-700 mb-1">
{t('users.newPassword')}
</label>
<input
type="password"
id="newPassword"
name="newPassword"
value={formData.newPassword}
onChange={handleInputChange}
placeholder={t('users.newPasswordPlaceholder')}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isSubmitting}
minLength={6}
/>
</div>
{formData.newPassword && (
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-1">
{t('users.confirmPassword')}
</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleInputChange}
placeholder={t('users.confirmPasswordPlaceholder')}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isSubmitting}
minLength={6}
/>
</div>
)}
</div>
<div className="flex justify-end space-x-3 mt-6">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 transition-colors duration-200"
disabled={isSubmitting}
>
{t('common.cancel')}
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isSubmitting}
>
{isSubmitting ? t('common.updating') : t('users.update')}
</button>
</div>
</form>
</div>
</div>
);
};
export default EditUserForm;

View File

@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { IGroupServerConfig, Server, Tool } from '@/types';
import { cn } from '@/utils/cn';
import { useSettingsData } from '@/hooks/useSettingsData';
interface ServerToolConfigProps {
servers: Server[];
@@ -17,6 +18,7 @@ export const ServerToolConfig: React.FC<ServerToolConfigProps> = ({
className
}) => {
const { t } = useTranslation();
const { nameSeparator } = useSettingsData();
const [expandedServers, setExpandedServers] = useState<Set<string>>(new Set());
// Normalize current value to IGroupServerConfig[] format
@@ -116,7 +118,7 @@ export const ServerToolConfig: React.FC<ServerToolConfigProps> = ({
const server = availableServers.find(s => s.name === serverName);
if (!server) return;
const allToolNames = server.tools?.map(tool => tool.name.replace(`${serverName}-`, '')) || [];
const allToolNames = server.tools?.map(tool => tool.name.replace(`${serverName}${nameSeparator}`, '')) || [];
const serverConfig = normalizedValue.find(config => config.name === serverName);
if (!serverConfig) {
@@ -279,7 +281,7 @@ export const ServerToolConfig: React.FC<ServerToolConfigProps> = ({
<div className="grid grid-cols-1 gap-2 max-h-32 overflow-y-auto">
{serverTools.map(tool => {
const toolName = tool.name.replace(`${server.name}-`, '');
const toolName = tool.name.replace(`${server.name}${nameSeparator}`, '');
const isToolChecked = isToolSelected(server.name, toolName);
return (

View File

@@ -0,0 +1,96 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { User, IUser } from '@/types';
import { Edit, Trash } from '@/components/icons/LucideIcons';
import DeleteDialog from '@/components/ui/DeleteDialog';
interface UserCardProps {
user: User;
currentUser: IUser | null;
onEdit: (user: User) => void;
onDelete: (username: string) => void;
}
const UserCard: React.FC<UserCardProps> = ({ user, currentUser, onEdit, onDelete }) => {
const { t } = useTranslation();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const handleDeleteClick = () => {
setShowDeleteDialog(true);
};
const handleConfirmDelete = () => {
onDelete(user.username);
setShowDeleteDialog(false);
};
const isCurrentUser = currentUser?.username === user.username;
const canDelete = !isCurrentUser; // Can't delete own account
return (
<div className="bg-white shadow rounded-lg p-6">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center space-x-3 mb-2">
<div className="w-10 h-10 bg-blue-500 rounded-full flex items-center justify-center">
<span className="text-white font-medium text-sm">
{user.username.charAt(0).toUpperCase()}
</span>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">
{user.username}
{isCurrentUser && (
<span className="ml-2 px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded">
{t('users.currentUser')}
</span>
)}
</h3>
<div className="flex items-center space-x-2">
<span
className={`px-2 py-1 text-xs font-medium rounded ${user.isAdmin
? 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{user.isAdmin ? t('users.admin') : t('users.user')}
</span>
</div>
</div>
</div>
</div>
<div className="flex space-x-2">
<button
onClick={() => onEdit(user)}
className="text-gray-500 hover:text-gray-700"
title={t('users.edit')}
>
<Edit size={18} />
</button>
{canDelete && (
<button
onClick={handleDeleteClick}
className="text-gray-500 hover:text-red-600"
title={t('users.delete')}
>
<Trash size={18} />
</button>
)}
</div>
</div>
<DeleteDialog
isOpen={showDeleteDialog}
onClose={() => setShowDeleteDialog(false)}
onConfirm={handleConfirmDelete}
serverName={user.username}
isGroup={false}
isUser={true}
/>
</div>
);
};
export default UserCard;

View File

@@ -4,6 +4,7 @@ import { Prompt } from '@/types'
import { ChevronDown, ChevronRight, Play, Loader, Edit, Check } from '@/components/icons/LucideIcons'
import { Switch } from './ToggleGroup'
import { getPrompt, PromptCallResult } from '@/services/promptService'
import { useSettingsData } from '@/hooks/useSettingsData'
import DynamicForm from './DynamicForm'
import PromptResult from './PromptResult'
@@ -16,6 +17,7 @@ interface PromptCardProps {
const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCardProps) => {
const { t } = useTranslation()
const { nameSeparator } = useSettingsData()
const [isExpanded, setIsExpanded] = useState(false)
const [showRunForm, setShowRunForm] = useState(false)
const [isRunning, setIsRunning] = useState(false)
@@ -154,7 +156,7 @@ const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCar
>
<div className="flex-1">
<h3 className="text-lg font-medium text-gray-900">
{prompt.name.replace(server + '-', '')}
{prompt.name.replace(server + nameSeparator, '')}
{prompt.title && (
<span className="ml-2 text-sm font-normal text-gray-600">
{prompt.title}
@@ -249,7 +251,7 @@ const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCar
onCancel={handleCancelRun}
loading={isRunning}
storageKey={getStorageKey()}
title={t('prompt.runPromptWithName', { name: prompt.name.replace(server + '-', '') })}
title={t('prompt.runPromptWithName', { name: prompt.name.replace(server + nameSeparator, '') })}
/>
{/* Prompt Result */}
{result && (

View File

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import { Tool } from '@/types'
import { ChevronDown, ChevronRight, Play, Loader, Edit, Check } from '@/components/icons/LucideIcons'
import { callTool, ToolCallResult, updateToolDescription } from '@/services/toolService'
import { useSettingsData } from '@/hooks/useSettingsData'
import { Switch } from './ToggleGroup'
import DynamicForm from './DynamicForm'
import ToolResult from './ToolResult'
@@ -25,6 +26,7 @@ function isEmptyValue(value: any): boolean {
const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps) => {
const { t } = useTranslation()
const { nameSeparator } = useSettingsData()
const [isExpanded, setIsExpanded] = useState(false)
const [showRunForm, setShowRunForm] = useState(false)
const [isRunning, setIsRunning] = useState(false)
@@ -148,7 +150,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
>
<div className="flex-1">
<h3 className="text-lg font-medium text-gray-900">
{tool.name.replace(server + '-', '')}
{tool.name.replace(server + nameSeparator, '')}
<span className="ml-2 text-sm font-normal text-gray-600 inline-flex items-center">
{isEditingDescription ? (
<>
@@ -246,7 +248,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
onCancel={handleCancelRun}
loading={isRunning}
storageKey={getStorageKey()}
title={t('tool.runToolWithName', { name: tool.name.replace(server + '-', '') })}
title={t('tool.runToolWithName', { name: tool.name.replace(server + nameSeparator, '') })}
/>
{/* Tool Result */}
{result && (

View File

@@ -40,6 +40,7 @@ interface SystemSettings {
install?: InstallConfig;
smartRouting?: SmartRoutingConfig;
mcpRouter?: MCPRouterConfig;
nameSeparator?: string;
};
}
@@ -84,6 +85,8 @@ export const useSettingsData = () => {
baseUrl: 'https://api.mcprouter.to/v1',
});
const [nameSeparator, setNameSeparator] = useState<string>('-');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
@@ -135,6 +138,9 @@ export const useSettingsData = () => {
baseUrl: data.data.systemConfig.mcpRouter.baseUrl || 'https://api.mcprouter.to/v1',
});
}
if (data.success && data.data?.systemConfig?.nameSeparator !== undefined) {
setNameSeparator(data.data.systemConfig.nameSeparator);
}
} catch (error) {
console.error('Failed to fetch settings:', error);
setError(error instanceof Error ? error.message : 'Failed to fetch settings');
@@ -384,6 +390,36 @@ export const useSettingsData = () => {
}
};
// Update name separator
const updateNameSeparator = async (value: string) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
nameSeparator: value,
});
if (data.success) {
setNameSeparator(value);
showToast(t('settings.restartRequired'), 'info');
return true;
} else {
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
return false;
}
} catch (error) {
console.error('Failed to update name separator:', error);
const errorMessage =
error instanceof Error ? error.message : 'Failed to update name separator';
setError(errorMessage);
showToast(errorMessage);
return false;
} finally {
setLoading(false);
}
};
// Fetch settings when the component mounts or refreshKey changes
useEffect(() => {
fetchSettings();
@@ -404,6 +440,7 @@ export const useSettingsData = () => {
installConfig,
smartRoutingConfig,
mcpRouterConfig,
nameSeparator,
loading,
error,
setError,
@@ -416,5 +453,6 @@ export const useSettingsData = () => {
updateRoutingConfigBatch,
updateMCPRouterConfig,
updateMCPRouterConfigBatch,
updateNameSeparator,
};
};

View File

@@ -0,0 +1,100 @@
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { User, ApiResponse, UserFormData, UserUpdateData } from '@/types';
import { apiDelete, apiGet, apiPost, apiPut } from '../utils/fetchInterceptor';
export const useUserData = () => {
const { t } = useTranslation();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const fetchUsers = useCallback(async () => {
try {
setLoading(true);
const data: ApiResponse<User[]> = await apiGet('/users');
if (!data.success) {
setError(data.message || t('users.fetchError'));
return;
}
if (data && data.success && Array.isArray(data.data)) {
setUsers(data.data);
} else {
console.error('Invalid user data format:', data);
setUsers([]);
}
setError(null);
} catch (err) {
console.error('Error fetching users:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch users');
setUsers([]);
} finally {
setLoading(false);
}
}, []);
// Trigger a refresh of the users data
const triggerRefresh = useCallback(() => {
setRefreshKey((prev) => prev + 1);
}, []);
// Create a new user
const createUser = async (userData: UserFormData) => {
try {
const result: ApiResponse<User> = await apiPost('/users', userData);
triggerRefresh();
return result;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create user');
return null;
}
};
// Update an existing user
const updateUser = async (username: string, data: UserUpdateData) => {
try {
const result: ApiResponse<User> = await apiPut(`/users/${username}`, data);
triggerRefresh();
return result || null;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update user');
return null;
}
};
// Delete a user
const deleteUser = async (username: string) => {
try {
const result = await apiDelete(`/users/${username}`);
if (!result?.success) {
setError(result?.message || t('users.deleteError'));
return result;
}
triggerRefresh();
return result;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete user');
return false;
}
};
// Fetch users when the component mounts or refreshKey changes
useEffect(() => {
fetchUsers();
}, [fetchUsers, refreshKey]);
return {
users,
loading,
error,
setError,
triggerRefresh,
createUser,
updateUser,
deleteUser,
};
};

View File

@@ -48,6 +48,8 @@ const SettingsPage: React.FC = () => {
baseUrl: 'https://api.mcprouter.to/v1',
});
const [tempNameSeparator, setTempNameSeparator] = useState<string>('-');
const {
routingConfig,
tempRoutingConfig,
@@ -55,13 +57,15 @@ const SettingsPage: React.FC = () => {
installConfig: savedInstallConfig,
smartRoutingConfig,
mcpRouterConfig,
nameSeparator,
loading,
updateRoutingConfig,
updateRoutingConfigBatch,
updateInstallConfig,
updateSmartRoutingConfig,
updateSmartRoutingConfigBatch,
updateMCPRouterConfig
updateMCPRouterConfig,
updateNameSeparator,
} = useSettingsData();
// Update local installConfig when savedInstallConfig changes
@@ -95,15 +99,21 @@ const SettingsPage: React.FC = () => {
}
}, [mcpRouterConfig]);
// Update local tempNameSeparator when nameSeparator changes
useEffect(() => {
setTempNameSeparator(nameSeparator);
}, [nameSeparator]);
const [sectionsVisible, setSectionsVisible] = useState({
routingConfig: false,
installConfig: false,
smartRoutingConfig: false,
mcpRouterConfig: false,
nameSeparator: false,
password: false
});
const toggleSection = (section: 'routingConfig' | 'installConfig' | 'smartRoutingConfig' | 'mcpRouterConfig' | 'password') => {
const toggleSection = (section: 'routingConfig' | 'installConfig' | 'smartRoutingConfig' | 'mcpRouterConfig' | 'nameSeparator' | 'password') => {
setSectionsVisible(prev => ({
...prev,
[section]: !prev[section]
@@ -181,6 +191,10 @@ const SettingsPage: React.FC = () => {
await updateMCPRouterConfig(key, tempMCPRouterConfig[key]);
};
const saveNameSeparator = async () => {
await updateNameSeparator(tempNameSeparator);
};
const handleSmartRoutingEnabledChange = async (value: boolean) => {
// If enabling Smart Routing, validate required fields and save any unsaved changes
if (value) {
@@ -427,6 +441,48 @@ const SettingsPage: React.FC = () => {
</div>
</PermissionChecker>
{/* System Settings */}
<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('nameSeparator')}
>
<h2 className="font-semibold text-gray-800">{t('settings.systemSettings')}</h2>
<span className="text-gray-500">
{sectionsVisible.nameSeparator ? '▼' : '►'}
</span>
</div>
{sectionsVisible.nameSeparator && (
<div className="space-y-4 mt-4">
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">{t('settings.nameSeparatorLabel')}</h3>
<p className="text-sm text-gray-500">{t('settings.nameSeparatorDescription')}</p>
</div>
<div className="flex items-center gap-3">
<input
type="text"
value={tempNameSeparator}
onChange={(e) => setTempNameSeparator(e.target.value)}
placeholder="-"
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}
maxLength={5}
/>
<button
onClick={saveNameSeparator}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
>
{t('common.save')}
</button>
</div>
</div>
</div>
)}
</div>
{/* Route Configuration Settings */}
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card">
<div

View File

@@ -1,8 +1,125 @@
import React from 'react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { User } from '@/types';
import { useUserData } from '@/hooks/useUserData';
import { useAuth } from '@/contexts/AuthContext';
import AddUserForm from '@/components/AddUserForm';
import EditUserForm from '@/components/EditUserForm';
import UserCard from '@/components/UserCard';
const UsersPage: React.FC = () => {
const { t } = useTranslation();
const { auth } = useAuth();
const currentUser = auth.user;
const {
users,
loading: usersLoading,
error: userError,
setError: setUserError,
deleteUser,
triggerRefresh
} = useUserData();
const [editingUser, setEditingUser] = useState<User | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
// Check if current user is admin
if (!currentUser?.isAdmin) {
return (
<div className="bg-white shadow rounded-lg p-6">
<p className="text-red-600">{t('users.adminRequired')}</p>
</div>
);
}
const handleEditClick = (user: User) => {
setEditingUser(user);
};
const handleEditComplete = () => {
setEditingUser(null);
triggerRefresh(); // Refresh the users list after editing
};
const handleDeleteUser = async (username: string) => {
const result = await deleteUser(username);
if (!result?.success) {
setUserError(result?.message || t('users.deleteError'));
}
};
const handleAddUser = () => {
setShowAddForm(true);
};
const handleAddComplete = () => {
setShowAddForm(false);
triggerRefresh(); // Refresh the users list after adding
};
return (
<div></div>
<div>
<div className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold text-gray-900">{t('pages.users.title')}</h1>
<div className="flex space-x-4">
<button
onClick={handleAddUser}
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 3a1 1 0 00-1 1v5H4a1 1 0 100 2h5v5a1 1 0 102 0v-5h5a1 1 0 100-2h-5V4a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
{t('users.add')}
</button>
</div>
</div>
{userError && (
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6 error-box rounded-lg">
<p>{userError}</p>
</div>
)}
{usersLoading ? (
<div className="bg-white shadow rounded-lg p-6 loading-container">
<div className="flex flex-col items-center justify-center">
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p className="text-gray-600">{t('app.loading')}</p>
</div>
</div>
) : users.length === 0 ? (
<div className="bg-white shadow rounded-lg p-6 empty-state">
<p className="text-gray-600">{t('users.noUsers')}</p>
</div>
) : (
<div className="space-y-6">
{users.map((user) => (
<UserCard
key={user.username}
user={user}
currentUser={currentUser}
onEdit={handleEditClick}
onDelete={handleDeleteUser}
/>
))}
</div>
)}
{showAddForm && (
<AddUserForm onAdd={handleAddComplete} onCancel={handleAddComplete} />
)}
{editingUser && (
<EditUserForm
user={editingUser}
onEdit={handleEditComplete}
onCancel={() => setEditingUser(null)}
/>
)}
</div>
);
};

View File

@@ -20,6 +20,7 @@ export interface SystemConfig {
openaiApiKey?: string;
openaiApiEmbeddingModel?: string;
};
nameSeparator?: string;
}
export interface PublicConfigResponse {
@@ -96,3 +97,5 @@ export const shouldSkipAuth = async (): Promise<boolean> => {
return false;
}
};

View File

@@ -498,7 +498,11 @@
"mcpRouterTitlePlaceholder": "MCPHub",
"mcpRouterBaseUrl": "Base URL",
"mcpRouterBaseUrlDescription": "Base URL for MCPRouter API",
"mcpRouterBaseUrlPlaceholder": "https://api.mcprouter.to/v1"
"mcpRouterBaseUrlPlaceholder": "https://api.mcprouter.to/v1",
"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."
},
"dxt": {
"upload": "Upload",

View File

@@ -498,7 +498,11 @@
"mcpRouterTitlePlaceholder": "MCPHub",
"mcpRouterBaseUrl": "URL de base",
"mcpRouterBaseUrlDescription": "URL de base pour l'API MCPRouter",
"mcpRouterBaseUrlPlaceholder": "https://api.mcprouter.to/v1"
"mcpRouterBaseUrlPlaceholder": "https://api.mcprouter.to/v1",
"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."
},
"dxt": {
"upload": "Télécharger",

View File

@@ -500,7 +500,11 @@
"mcpRouterTitlePlaceholder": "MCPHub",
"mcpRouterBaseUrl": "基础地址",
"mcpRouterBaseUrlDescription": "MCPRouter API 的基础地址",
"mcpRouterBaseUrlPlaceholder": "https://api.mcprouter.to/v1"
"mcpRouterBaseUrlPlaceholder": "https://api.mcprouter.to/v1",
"systemSettings": "系统设置",
"nameSeparatorLabel": "名称分隔符",
"nameSeparatorDescription": "用于分隔服务器名称和工具/提示名称(默认:-",
"restartRequired": "配置已保存。为确保所有服务正确加载新设置,建议重启应用。"
},
"dxt": {
"upload": "上传",

View File

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

View File

@@ -60,6 +60,7 @@
"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",
@@ -84,9 +85,9 @@
"@types/bcryptjs": "^3.0.0",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.23",
"@types/jest": "^29.5.14",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^22.17.2",
"@types/node": "^24.6.2",
"@types/react": "^19.1.11",
"@types/react-dom": "^19.1.7",
"@types/supertest": "^6.0.3",
@@ -99,9 +100,8 @@
"clsx": "^2.1.1",
"concurrently": "^9.2.0",
"eslint": "^8.57.1",
"i18next": "^25.5.0",
"i18next-browser-languagedetector": "^8.2.0",
"jest": "^29.7.0",
"jest": "^30.2.0",
"jest-environment-node": "^30.0.5",
"jest-mock-extended": "4.0.0",
"lucide-react": "^0.486.0",

1987
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -42,8 +42,9 @@ export const loadOriginalSettings = (): McpSettings => {
console.log(`Loaded settings from ${settingsPath}`);
return settings;
} catch (error) {
console.error(`Failed to load settings from ${settingsPath}:`, error);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.warn(`Failed to load settings from ${settingsPath}:`, errorMessage);
const defaultSettings = { mcpServers: {}, users: [] };
// Cache default settings
@@ -138,3 +139,8 @@ export const expandEnvVars = (value: string): string => {
};
export default defaultConfig;
export function getNameSeparator(): string {
const settings = loadSettings();
return settings.systemConfig?.nameSeparator || '-';
}

View File

@@ -7,6 +7,7 @@ import {
} from '../services/openApiGeneratorService.js';
import { getServerByName } from '../services/mcpService.js';
import { getGroupByIdOrName } from '../services/groupService.js';
import { getNameSeparator } from '../config/index.js';
/**
* Controller for OpenAPI generation endpoints
@@ -177,7 +178,7 @@ export const executeToolViaOpenAPI = async (req: Request, res: Response): Promis
if (serverInfo) {
// Find the tool in the server's tools list
const fullToolName = `${serverName}-${toolName}`;
const fullToolName = `${serverName}${getNameSeparator()}${toolName}`;
const tool = serverInfo.tools.find(
(t: any) => t.name === fullToolName || t.name === toolName,
);

View File

@@ -504,7 +504,7 @@ export const updateToolDescription = async (req: Request, res: Response): Promis
export const updateSystemConfig = (req: Request, res: Response): void => {
try {
const { routing, install, smartRouting, mcpRouter } = req.body;
const { routing, install, smartRouting, mcpRouter, nameSeparator } = req.body;
const currentUser = (req as any).user;
if (
@@ -528,7 +528,8 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
(typeof mcpRouter.apiKey !== 'string' &&
typeof mcpRouter.referer !== 'string' &&
typeof mcpRouter.title !== 'string' &&
typeof mcpRouter.baseUrl !== 'string'))
typeof mcpRouter.baseUrl !== 'string')) &&
(typeof nameSeparator !== 'string')
) {
res.status(400).json({
success: false,
@@ -710,6 +711,10 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
}
}
if (typeof nameSeparator === 'string') {
settings.systemConfig.nameSeparator = nameSeparator;
}
if (saveSettings(settings, currentUser)) {
res.json({
success: true,

View File

@@ -0,0 +1,72 @@
import { IUser, McpSettings, UserConfig } from '../types/index.js';
import { DataService } from './dataService.js';
import { UserContextService } from './userContextService.js';
export class DataServicex implements DataService {
foo() {
console.log('default implementation');
}
filterData(data: any[], user?: IUser): any[] {
// Use passed user parameter if available, otherwise fall back to context
const currentUser = user || UserContextService.getInstance().getCurrentUser();
if (!currentUser || currentUser.isAdmin) {
return data;
} else {
return data.filter((item) => item.owner === currentUser?.username);
}
}
filterSettings(settings: McpSettings, user?: IUser): McpSettings {
// Use passed user parameter if available, otherwise fall back to context
const currentUser = user || UserContextService.getInstance().getCurrentUser();
if (!currentUser || currentUser.isAdmin) {
const result = { ...settings };
delete result.userConfigs;
return result;
} else {
const result = { ...settings };
result.systemConfig = settings.userConfigs?.[currentUser?.username || ''] || {};
delete result.userConfigs;
return result;
}
}
mergeSettings(all: McpSettings, newSettings: McpSettings, user?: IUser): McpSettings {
// Use passed user parameter if available, otherwise fall back to context
const currentUser = user || UserContextService.getInstance().getCurrentUser();
if (!currentUser || currentUser.isAdmin) {
const result = { ...all };
result.users = newSettings.users;
result.systemConfig = newSettings.systemConfig;
result.groups = newSettings.groups;
return result;
} else {
const result = JSON.parse(JSON.stringify(all));
if (!result.userConfigs) {
result.userConfigs = {};
}
const systemConfig = newSettings.systemConfig || {};
const userConfig: UserConfig = {
routing: systemConfig.routing
? {
enableGlobalRoute: systemConfig.routing.enableGlobalRoute,
enableGroupNameRoute: systemConfig.routing.enableGroupNameRoute,
enableBearerAuth: systemConfig.routing.enableBearerAuth,
bearerAuthKey: systemConfig.routing.bearerAuthKey,
}
: undefined,
};
result.userConfigs[currentUser?.username || ''] = userConfig;
return result;
}
}
getPermissions(user: IUser): string[] {
if (user && user.isAdmin) {
return ['*', 'x'];
} else {
return [''];
}
}
}

View File

@@ -12,7 +12,7 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { ServerInfo, ServerConfig, Tool } from '../types/index.js';
import { loadSettings, expandEnvVars, replaceEnvVars } from '../config/index.js';
import { loadSettings, expandEnvVars, replaceEnvVars, getNameSeparator } from '../config/index.js';
import config from '../config/index.js';
import { getGroup } from './sseService.js';
import { getServersInGroup, getServerConfigInGroup } from './groupService.js';
@@ -24,6 +24,10 @@ import { getServerDao, ServerConfigWithName } from '../dao/index.js';
const servers: { [sessionId: string]: Server } = {};
// Per-session server instances for servers with perSession=true
// Key format: `${sessionId}:${serverName}`
const perSessionServerInfos: { [key: string]: ServerInfo } = {};
const serverDao = getServerDao();
// Helper function to set up keep-alive ping for SSE connections
@@ -79,6 +83,8 @@ export const getMcpServer = (sessionId?: string, group?: string): Server => {
export const deleteMcpServer = (sessionId: string): void => {
delete servers[sessionId];
// Clean up any per-session servers for this session
cleanupPerSessionServers(sessionId);
};
export const notifyToolChanged = async (name?: string) => {
@@ -223,6 +229,144 @@ const createTransportFromConfig = (name: string, conf: ServerConfig): any => {
return transport;
};
// Helper function to get or create per-session server instance
export const getOrCreatePerSessionServer = async (
sessionId: string,
serverName: string,
serverConfig: ServerConfig,
): Promise<ServerInfo> => {
const key = `${sessionId}:${serverName}`;
// Return existing session server if it exists
if (perSessionServerInfos[key]) {
return perSessionServerInfos[key];
}
console.log(`Creating per-session server instance for session ${sessionId}, server ${serverName}`);
// Create new transport for this session
const transport = createTransportFromConfig(serverName, serverConfig);
const client = new Client(
{
name: `mcp-client-${serverName}-${sessionId}`,
version: '1.0.0',
},
{
capabilities: {
prompts: {},
resources: {},
tools: {},
},
},
);
// Get request options from server configuration, with fallbacks
const serverRequestOptions = serverConfig.options || {};
const requestOptions = {
timeout: serverRequestOptions.timeout || 60000,
resetTimeoutOnProgress: serverRequestOptions.resetTimeoutOnProgress || false,
maxTotalTimeout: serverRequestOptions.maxTotalTimeout,
};
// Create server info for this session
const serverInfo: ServerInfo = {
name: serverName,
owner: serverConfig.owner,
status: 'connecting',
error: null,
tools: [],
prompts: [],
client,
transport,
options: requestOptions,
createTime: Date.now(),
config: serverConfig,
sessionId: sessionId,
};
perSessionServerInfos[key] = serverInfo;
// Connect asynchronously
client
.connect(transport, requestOptions)
.then(() => {
console.log(`Successfully connected per-session client for server: ${serverName}, session: ${sessionId}`);
const capabilities = client.getServerCapabilities();
if (capabilities?.tools) {
client
.listTools({}, requestOptions)
.then((tools) => {
console.log(`Successfully listed ${tools.tools.length} tools for per-session server: ${serverName}, session: ${sessionId}`);
serverInfo.tools = tools.tools.map((tool) => ({
name: `${serverName}${getNameSeparator()}${tool.name}`,
description: tool.description || '',
inputSchema: cleanInputSchema(tool.inputSchema || {}),
}));
})
.catch((error) => {
console.error(`Failed to list tools for per-session server ${serverName}, session ${sessionId}:`, error);
});
}
if (capabilities?.prompts) {
client
.listPrompts({}, requestOptions)
.then((prompts) => {
console.log(`Successfully listed ${prompts.prompts.length} prompts for per-session server: ${serverName}, session: ${sessionId}`);
serverInfo.prompts = prompts.prompts.map((prompt) => ({
name: `${serverName}${getNameSeparator()}${prompt.name}`,
title: prompt.title,
description: prompt.description,
arguments: prompt.arguments,
}));
})
.catch((error) => {
console.error(`Failed to list prompts for per-session server ${serverName}, session ${sessionId}:`, error);
});
}
serverInfo.status = 'connected';
serverInfo.error = null;
})
.catch((error) => {
console.error(`Failed to connect per-session client for server ${serverName}, session ${sessionId}:`, error);
serverInfo.status = 'disconnected';
serverInfo.error = `Failed to connect: ${error.stack}`;
});
return serverInfo;
};
// Helper function to clean up per-session servers for a session
export const cleanupPerSessionServers = (sessionId: string): void => {
const keysToDelete: string[] = [];
for (const key in perSessionServerInfos) {
if (key.startsWith(`${sessionId}:`)) {
const serverInfo = perSessionServerInfos[key];
try {
if (serverInfo.client) {
serverInfo.client.close();
}
if (serverInfo.transport) {
serverInfo.transport.close();
}
if (serverInfo.keepAliveIntervalId) {
clearInterval(serverInfo.keepAliveIntervalId);
}
} catch (error) {
console.warn(`Error closing per-session server ${key}:`, error);
}
keysToDelete.push(key);
}
}
keysToDelete.forEach(key => delete perSessionServerInfos[key]);
console.log(`Cleaned up ${keysToDelete.length} per-session servers for session ${sessionId}`);
};
// Helper function to handle client.callTool with reconnection logic
const callToolWithReconnect = async (
serverInfo: ServerInfo,
@@ -294,7 +438,7 @@ const callToolWithReconnect = async (
try {
const tools = await client.listTools({}, serverInfo.options || {});
serverInfo.tools = tools.tools.map((tool) => ({
name: `${serverInfo.name}-${tool.name}`,
name: `${serverInfo.name}${getNameSeparator()}${tool.name}`,
description: tool.description || '',
inputSchema: cleanInputSchema(tool.inputSchema || {}),
}));
@@ -420,7 +564,7 @@ export const initializeClientsFromSettings = async (
// Convert OpenAPI tools to MCP tool format
const openApiTools = openApiClient.getTools();
const mcpTools: Tool[] = openApiTools.map((tool) => ({
name: `${name}-${tool.name}`,
name: `${name}${getNameSeparator()}${tool.name}`,
description: tool.description,
inputSchema: cleanInputSchema(tool.inputSchema),
}));
@@ -507,7 +651,7 @@ export const initializeClientsFromSettings = async (
.then((tools) => {
console.log(`Successfully listed ${tools.tools.length} tools for server: ${name}`);
serverInfo.tools = tools.tools.map((tool) => ({
name: `${name}-${tool.name}`,
name: `${name}${getNameSeparator()}${tool.name}`,
description: tool.description || '',
inputSchema: cleanInputSchema(tool.inputSchema || {}),
}));
@@ -530,7 +674,7 @@ export const initializeClientsFromSettings = async (
`Successfully listed ${prompts.prompts.length} prompts for server: ${name}`,
);
serverInfo.prompts = prompts.prompts.map((prompt) => ({
name: `${name}-${prompt.name}`,
name: `${name}${getNameSeparator()}${prompt.name}`,
title: prompt.title,
description: prompt.description,
arguments: prompt.arguments,
@@ -625,6 +769,45 @@ export const getServerByName = (name: string): ServerInfo | undefined => {
return serverInfos.find((serverInfo) => serverInfo.name === name);
};
// Get server by name with session support (for per-session servers)
const getServerByNameWithSession = async (name: string, sessionId?: string): Promise<ServerInfo | undefined> => {
// First check if this server is configured for per-session instances
const serverConfig = await serverDao.findById(name);
if (serverConfig?.perSession && sessionId) {
// Try to get or create per-session server
const key = `${sessionId}:${name}`;
if (perSessionServerInfos[key]) {
return perSessionServerInfos[key];
}
// Create new per-session server instance
return await getOrCreatePerSessionServer(sessionId, name, serverConfig);
}
// Fall back to shared server
return serverInfos.find((serverInfo) => serverInfo.name === name && !serverInfo.sessionId);
};
// Get server by tool name with session support (for per-session servers)
const getServerByToolWithSession = async (toolName: string, sessionId?: string): Promise<ServerInfo | undefined> => {
// First try to find in per-session servers if sessionId is provided
if (sessionId) {
for (const key in perSessionServerInfos) {
if (key.startsWith(`${sessionId}:`)) {
const serverInfo = perSessionServerInfos[key];
if (serverInfo.tools.some((tool) => tool.name === toolName)) {
return serverInfo;
}
}
}
}
// Fall back to shared servers
return serverInfos.find((serverInfo) =>
!serverInfo.sessionId && serverInfo.tools.some((tool) => tool.name === toolName)
);
};
// Filter tools by server configuration
const filterToolsByConfig = async (serverName: string, tools: Tool[]): Promise<Tool[]> => {
const serverConfig = await serverDao.findById(serverName);
@@ -640,8 +823,8 @@ const filterToolsByConfig = async (serverName: string, tools: Tool[]): Promise<T
});
};
// Get server by tool name
const getServerByTool = (toolName: string): ServerInfo | undefined => {
// Get server by tool name (legacy - use getServerByToolWithSession instead)
const _getServerByTool = (toolName: string): ServerInfo | undefined => {
return serverInfos.find((serverInfo) => serverInfo.tools.some((tool) => tool.name === toolName));
};
@@ -826,15 +1009,35 @@ Available servers: ${serversList}`;
};
}
const allServerInfos = getDataService()
// Get shared servers
let allServerInfos = getDataService()
.filterData(serverInfos)
.filter((serverInfo) => {
if (serverInfo.enabled === false) return false;
if (serverInfo.sessionId) return false; // Exclude per-session servers from shared list
if (!group) return true;
const serversInGroup = getServersInGroup(group);
if (!serversInGroup || serversInGroup.length === 0) return serverInfo.name === group;
return serversInGroup.includes(serverInfo.name);
});
// Add per-session servers for this session
if (sessionId) {
const sessionServers = Object.values(perSessionServerInfos).filter(
(serverInfo) => serverInfo.sessionId === sessionId && serverInfo.status === 'connected'
);
// Filter session servers by group if applicable
const filteredSessionServers = sessionServers.filter((serverInfo) => {
if (serverInfo.enabled === false) return false;
if (!group) return true;
const serversInGroup = getServersInGroup(group);
if (!serversInGroup || serversInGroup.length === 0) return serverInfo.name === group;
return serversInGroup.includes(serverInfo.name);
});
allServerInfos = [...allServerInfos, ...filteredSessionServers];
}
const allTools = [];
for (const serverInfo of allServerInfos) {
@@ -848,7 +1051,7 @@ Available servers: ${serversList}`;
if (serverConfig && serverConfig.tools !== 'all' && Array.isArray(serverConfig.tools)) {
// Filter tools based on group configuration
const allowedToolNames = serverConfig.tools.map(
(toolName) => `${serverInfo.name}-${toolName}`,
(toolName) => `${serverInfo.name}${getNameSeparator()}${toolName}`,
);
enabledTools = enabledTools.filter((tool) => allowedToolNames.includes(tool.name));
}
@@ -998,17 +1201,13 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
}
const { arguments: toolArgs = {} } = request.params.arguments || {};
const sessionId = extra?.sessionId;
let targetServerInfo: ServerInfo | undefined;
if (extra && extra.server) {
targetServerInfo = getServerByName(extra.server);
targetServerInfo = await getServerByNameWithSession(extra.server, sessionId);
} else {
// Find the first server that has this tool
targetServerInfo = serverInfos.find(
(serverInfo) =>
serverInfo.status === 'connected' &&
serverInfo.enabled !== false &&
serverInfo.tools.some((tool) => tool.name === toolName),
);
// Find the first server that has this tool (session-aware)
targetServerInfo = await getServerByToolWithSession(toolName, sessionId);
}
if (!targetServerInfo) {
@@ -1035,8 +1234,10 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
);
// Remove server prefix from tool name if present
const cleanToolName = toolName.startsWith(`${targetServerInfo.name}-`)
? toolName.replace(`${targetServerInfo.name}-`, '')
const separator = getNameSeparator();
const prefix = `${targetServerInfo.name}${separator}`;
const cleanToolName = toolName.startsWith(prefix)
? toolName.substring(prefix.length)
: toolName;
// Extract passthrough headers from extra or request context
@@ -1093,8 +1294,10 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
`Invoking tool '${toolName}' on server '${targetServerInfo.name}' with arguments: ${JSON.stringify(finalArgs)}`,
);
toolName = toolName.startsWith(`${targetServerInfo.name}-`)
? toolName.replace(`${targetServerInfo.name}-`, '')
const separator = getNameSeparator();
const prefix = `${targetServerInfo.name}${separator}`;
toolName = toolName.startsWith(prefix)
? toolName.substring(prefix.length)
: toolName;
const result = await callToolWithReconnect(
targetServerInfo,
@@ -1110,7 +1313,8 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
}
// Regular tool handling
const serverInfo = getServerByTool(request.params.name);
const sessionId = extra?.sessionId;
const serverInfo = await getServerByToolWithSession(request.params.name, sessionId);
if (!serverInfo) {
throw new Error(`Server not found: ${request.params.name}`);
}
@@ -1121,8 +1325,10 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
const openApiClient = serverInfo.openApiClient;
// Remove server prefix from tool name if present
const cleanToolName = request.params.name.startsWith(`${serverInfo.name}-`)
? request.params.name.replace(`${serverInfo.name}-`, '')
const separator = getNameSeparator();
const prefix = `${serverInfo.name}${separator}`;
const cleanToolName = request.params.name.startsWith(prefix)
? request.params.name.substring(prefix.length)
: request.params.name;
console.log(
@@ -1179,8 +1385,10 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
throw new Error(`Client not found for server: ${serverInfo.name}`);
}
request.params.name = request.params.name.startsWith(`${serverInfo.name}-`)
? request.params.name.replace(`${serverInfo.name}-`, '')
const separator = getNameSeparator();
const prefix = `${serverInfo.name}${separator}`;
request.params.name = request.params.name.startsWith(prefix)
? request.params.name.substring(prefix.length)
: request.params.name;
const result = await callToolWithReconnect(
serverInfo,
@@ -1223,8 +1431,10 @@ export const handleGetPromptRequest = async (request: any, extra: any) => {
}
// Remove server prefix from prompt name if present
const cleanPromptName = name.startsWith(`${server.name}-`)
? name.replace(`${server.name}-`, '')
const separator = getNameSeparator();
const prefix = `${server.name}${separator}`;
const cleanPromptName = name.startsWith(prefix)
? name.substring(prefix.length)
: name;
const promptParams = {

View File

@@ -2,7 +2,7 @@ import { OpenAPIV3 } from 'openapi-types';
import { Tool } from '../types/index.js';
import { getServersInfo } from './mcpService.js';
import config from '../config/index.js';
import { loadSettings } from '../config/index.js';
import { loadSettings, getNameSeparator } from '../config/index.js';
/**
* Service for generating OpenAPI 3.x specifications from MCP tools
@@ -209,10 +209,11 @@ export async function generateOpenAPISpec(
const allowedTools = groupConfig.get(serverInfo.name);
if (allowedTools !== 'all') {
// Filter tools to only include those specified in the group configuration
const separator = getNameSeparator();
filteredTools = tools.filter(
(tool) =>
Array.isArray(allowedTools) &&
allowedTools.includes(tool.name.replace(serverInfo.name + '-', '')),
allowedTools.includes(tool.name.replace(serverInfo.name + separator, '')),
);
}
}

View File

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

View File

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

View File

@@ -5,6 +5,13 @@ import { dirname } from 'path';
// Project root directory - use process.cwd() as a simpler alternative
const rootDir = process.cwd();
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')
@@ -12,15 +19,35 @@ const rootDir = process.cwd();
* @returns The path to the file
*/
export const getConfigFilePath = (filename: string, description = 'Configuration'): string => {
const envPath = process.env.MCPHUB_SETTING_PATH;
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 potentialPaths = [
...(envPath ? [envPath] : []),
// Prioritize process.cwd() as the first location to check
path.resolve(process.cwd(), filename),
// Use path relative to the root directory
path.join(rootDir, filename),
// If installed with npx, may need to look one level up
path.join(dirname(rootDir), filename),
...[
// Prioritize process.cwd() as the first location to check
path.resolve(process.cwd(), filename),
// Use path relative to the root directory
path.join(rootDir, filename),
// If installed with npx, may need to look one level up
path.join(dirname(rootDir), filename),
],
];
for (const filePath of potentialPaths) {

View File

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

View File

@@ -0,0 +1,131 @@
// 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');
});
});
});