mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-25 03:12:13 -05:00
Compare commits
11 Commits
v0.9.10
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edd1a4b063 | ||
|
|
1ff542ed45 | ||
|
|
94d5649782 | ||
|
|
f9615c8693 | ||
|
|
29c6d4bd75 | ||
|
|
198ea85225 | ||
|
|
6b39916909 | ||
|
|
9e8db370ff | ||
|
|
5d8bc44a73 | ||
|
|
021901dbda | ||
|
|
f6934a32dc |
169
BUGFIX_SUMMARY.md
Normal file
169
BUGFIX_SUMMARY.md
Normal 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.
|
||||
153
frontend/src/components/AddUserForm.tsx
Normal file
153
frontend/src/components/AddUserForm.tsx
Normal 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;
|
||||
161
frontend/src/components/EditUserForm.tsx
Normal file
161
frontend/src/components/EditUserForm.tsx
Normal 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;
|
||||
@@ -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 (
|
||||
|
||||
96
frontend/src/components/UserCard.tsx
Normal file
96
frontend/src/components/UserCard.tsx
Normal 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;
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
100
frontend/src/hooks/useUserData.ts
Normal file
100
frontend/src/hooks/useUserData.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "上传",
|
||||
|
||||
@@ -42,4 +42,4 @@
|
||||
"isAdmin": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
1928
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -138,3 +138,8 @@ export const expandEnvVars = (value: string): string => {
|
||||
};
|
||||
|
||||
export default defaultConfig;
|
||||
|
||||
export function getNameSeparator(): string {
|
||||
const settings = loadSettings();
|
||||
return settings.systemConfig?.nameSeparator || '-';
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
77
src/services/dataServicex.ts
Normal file
77
src/services/dataServicex.ts
Normal 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 [''];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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, '')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
182
tests/integration/groupPersistence.test.ts
Normal file
182
tests/integration/groupPersistence.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
225
tests/services/dataServiceMerge.test.ts
Normal file
225
tests/services/dataServiceMerge.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
262
tests/services/groupService.test.ts
Normal file
262
tests/services/groupService.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user