Compare commits

...

11 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
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
29 changed files with 2935 additions and 763 deletions

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

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

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

View File

@@ -84,9 +84,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",
@@ -101,7 +101,7 @@
"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",

1928
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -138,3 +138,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

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

@@ -0,0 +1,77 @@
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) {
// Admin users can modify all settings
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;
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 = {};
}
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';
@@ -294,7 +294,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 +420,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 +507,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 +530,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,
@@ -848,7 +848,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));
}
@@ -1035,8 +1035,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 +1095,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,
@@ -1121,8 +1125,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 +1185,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 +1231,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

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

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