mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 10:49:35 -05:00
Compare commits
16 Commits
v0.9.9
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edd1a4b063 | ||
|
|
1ff542ed45 | ||
|
|
94d5649782 | ||
|
|
f9615c8693 | ||
|
|
29c6d4bd75 | ||
|
|
198ea85225 | ||
|
|
6b39916909 | ||
|
|
9e8db370ff | ||
|
|
5d8bc44a73 | ||
|
|
021901dbda | ||
|
|
f6934a32dc | ||
|
|
7685b9bca8 | ||
|
|
c2dd91606f | ||
|
|
66b6053f7f | ||
|
|
ba50a78879 | ||
|
|
a856404963 |
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.
|
||||
235
README.fr.md
Normal file
235
README.fr.md
Normal file
@@ -0,0 +1,235 @@
|
||||
[English](README.md) | Français | [中文版](README.zh.md)
|
||||
|
||||
# MCPHub : Le Hub Unifié pour les Serveurs MCP (Model Context Protocol)
|
||||
|
||||
MCPHub facilite la gestion et la mise à l'échelle de plusieurs serveurs MCP (Model Context Protocol) en les organisant en points de terminaison HTTP streamables (SSE) flexibles, prenant en charge l'accès à tous les serveurs, à des serveurs individuels ou à des groupes de serveurs logiques.
|
||||
|
||||

|
||||
|
||||
## 🌐 Démo en direct et Documentation
|
||||
|
||||
- **Documentation** : [docs.mcphubx.com](https://docs.mcphubx.com/)
|
||||
- **Environnement de démo** : [demo.mcphubx.com](https://demo.mcphubx.com/)
|
||||
|
||||
## 🚀 Fonctionnalités
|
||||
|
||||
- **Support étendu des serveurs MCP** : Intégrez de manière transparente n'importe quel serveur MCP avec une configuration minimale.
|
||||
- **Tableau de bord centralisé** : Surveillez l'état en temps réel et les métriques de performance depuis une interface web élégante.
|
||||
- **Gestion flexible des protocoles** : Compatibilité totale avec les protocoles MCP stdio et SSE.
|
||||
- **Configuration à chaud** : Ajoutez, supprimez ou mettez à jour les serveurs MCP à la volée, sans temps d'arrêt.
|
||||
- **Contrôle d'accès basé sur les groupes** : Organisez les serveurs en groupes personnalisables pour une gestion simplifiée des autorisations.
|
||||
- **Authentification sécurisée** : Gestion des utilisateurs intégrée avec contrôle d'accès basé sur les rôles, optimisée par JWT et bcrypt.
|
||||
- **Prêt pour Docker** : Déployez instantanément avec notre configuration conteneurisée.
|
||||
|
||||
## 🔧 Démarrage rapide
|
||||
|
||||
### Configuration
|
||||
|
||||
Créez un fichier `mcp_settings.json` pour personnaliser les paramètres de votre serveur :
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"amap": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@amap/amap-maps-mcp-server"],
|
||||
"env": {
|
||||
"AMAP_MAPS_API_KEY": "votre-clé-api"
|
||||
}
|
||||
},
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--headless"]
|
||||
},
|
||||
"fetch": {
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server-fetch"]
|
||||
},
|
||||
"slack": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-slack"],
|
||||
"env": {
|
||||
"SLACK_BOT_TOKEN": "votre-jeton-bot",
|
||||
"SLACK_TEAM_ID": "votre-id-équipe"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Déploiement avec Docker
|
||||
|
||||
**Recommandé** : Montez votre configuration personnalisée :
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 -v ./mcp_settings.json:/app/mcp_settings.json -v ./data:/app/data samanhappy/mcphub
|
||||
```
|
||||
|
||||
Ou exécutez avec les paramètres par défaut :
|
||||
|
||||
```bash
|
||||
docker run -p 3000:3000 samanhappy/mcphub
|
||||
```
|
||||
|
||||
### Accéder au tableau de bord
|
||||
|
||||
Ouvrez `http://localhost:3000` et connectez-vous avec vos identifiants.
|
||||
|
||||
> **Note** : Les identifiants par défaut sont `admin` / `admin123`.
|
||||
|
||||
**Aperçu du tableau de bord** :
|
||||
|
||||
- État en direct de tous les serveurs MCP
|
||||
- Activer/désactiver ou reconfigurer les serveurs
|
||||
- Gestion des groupes pour organiser les serveurs
|
||||
- Administration des utilisateurs pour le contrôle d'accès
|
||||
|
||||
### Point de terminaison HTTP streamable
|
||||
|
||||
> Pour le moment, la prise en charge des points de terminaison HTTP en streaming varie selon les clients IA. Si vous rencontrez des problèmes, vous pouvez utiliser le point de terminaison SSE ou attendre les futures mises à jour.
|
||||
|
||||
Connectez les clients IA (par exemple, Claude Desktop, Cursor, DeepChat, etc.) via :
|
||||
|
||||
```
|
||||
http://localhost:3000/mcp
|
||||
```
|
||||
|
||||
Ce point de terminaison fournit une interface HTTP streamable unifiée pour tous vos serveurs MCP. Il vous permet de :
|
||||
|
||||
- Envoyer des requêtes à n'importe quel serveur MCP configuré
|
||||
- Recevoir des réponses en temps réel
|
||||
- Intégrer facilement avec divers clients et outils IA
|
||||
- Utiliser le même point de terminaison pour tous les serveurs, simplifiant votre processus d'intégration
|
||||
|
||||
**Routage intelligent (expérimental)** :
|
||||
|
||||
Le routage intelligent est le système de découverte d'outils intelligent de MCPHub qui utilise la recherche sémantique vectorielle pour trouver automatiquement les outils les plus pertinents pour une tâche donnée.
|
||||
|
||||
```
|
||||
http://localhost:3000/mcp/$smart
|
||||
```
|
||||
|
||||
**Comment ça marche** :
|
||||
|
||||
1. **Indexation des outils** : Tous les outils MCP sont automatiquement convertis en plongements vectoriels et stockés dans PostgreSQL avec pgvector.
|
||||
2. **Recherche sémantique** : Les requêtes des utilisateurs sont converties en vecteurs et comparées aux plongements des outils en utilisant la similarité cosinus.
|
||||
3. **Filtrage intelligent** : Des seuils dynamiques garantissent des résultats pertinents sans bruit.
|
||||
4. **Exécution précise** : Les outils trouvés peuvent être directement exécutés avec une validation appropriée des paramètres.
|
||||
|
||||
**Prérequis pour la configuration** :
|
||||
|
||||

|
||||
|
||||
Pour activer le routage intelligent, vous avez besoin de :
|
||||
|
||||
- PostgreSQL avec l'extension pgvector
|
||||
- Une clé API OpenAI (ou un service de plongement compatible)
|
||||
- Activer le routage intelligent dans les paramètres de MCPHub
|
||||
|
||||
**Points de terminaison spécifiques aux groupes (recommandé)** :
|
||||
|
||||

|
||||
|
||||
Pour un accès ciblé à des groupes de serveurs spécifiques, utilisez le point de terminaison HTTP basé sur les groupes :
|
||||
|
||||
```
|
||||
http://localhost:3000/mcp/{group}
|
||||
```
|
||||
|
||||
Où `{group}` est l'ID ou le nom du groupe que vous avez créé dans le tableau de bord. Cela vous permet de :
|
||||
|
||||
- Vous connecter à un sous-ensemble spécifique de serveurs MCP organisés par cas d'utilisation
|
||||
- Isoler différents outils IA pour n'accéder qu'aux serveurs pertinents
|
||||
- Mettre en œuvre un contrôle d'accès plus granulaire pour différents environnements ou équipes
|
||||
|
||||
**Points de terminaison spécifiques aux serveurs** :
|
||||
Pour un accès direct à des serveurs individuels, utilisez le point de terminaison HTTP spécifique au serveur :
|
||||
|
||||
```
|
||||
http://localhost:3000/mcp/{server}
|
||||
```
|
||||
|
||||
Où `{server}` est le nom du serveur auquel vous souhaitez vous connecter. Cela vous permet d'accéder directement à un serveur MCP spécifique.
|
||||
|
||||
> **Note** : Si le nom du serveur et le nom du groupe sont identiques, le nom du groupe aura la priorité.
|
||||
|
||||
### Point de terminaison SSE (obsolète à l'avenir)
|
||||
|
||||
Connectez les clients IA (par exemple, Claude Desktop, Cursor, DeepChat, etc.) via :
|
||||
|
||||
```
|
||||
http://localhost:3000/sse
|
||||
```
|
||||
|
||||
Pour le routage intelligent, utilisez :
|
||||
|
||||
```
|
||||
http://localhost:3000/sse/$smart
|
||||
```
|
||||
|
||||
Pour un accès ciblé à des groupes de serveurs spécifiques, utilisez le point de terminaison SSE basé sur les groupes :
|
||||
|
||||
```
|
||||
http://localhost:3000/sse/{group}
|
||||
```
|
||||
|
||||
Pour un accès direct à des serveurs individuels, utilisez le point de terminaison SSE spécifique au serveur :
|
||||
|
||||
```
|
||||
http://localhost:3000/sse/{server}
|
||||
```
|
||||
|
||||
## 🧑💻 Développement local
|
||||
|
||||
```bash
|
||||
git clone https://github.com/samanhappy/mcphub.git
|
||||
cd mcphub
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Cela démarre à la fois le frontend et le backend en mode développement avec rechargement à chaud.
|
||||
|
||||
> Pour les utilisateurs de Windows, vous devrez peut-être démarrer le serveur backend et le frontend séparément : `pnpm backend:dev`, `pnpm frontend:dev`.
|
||||
|
||||
## 🛠️ Problèmes courants
|
||||
|
||||
### Utiliser Nginx comme proxy inverse
|
||||
|
||||
Si vous utilisez Nginx pour inverser le proxy de MCPHub, assurez-vous d'ajouter la configuration suivante dans votre configuration Nginx :
|
||||
|
||||
```nginx
|
||||
proxy_buffering off
|
||||
```
|
||||
|
||||
## 🔍 Stack technique
|
||||
|
||||
- **Backend** : Node.js, Express, TypeScript
|
||||
- **Frontend** : React, Vite, Tailwind CSS
|
||||
- **Authentification** : JWT & bcrypt
|
||||
- **Protocole** : Model Context Protocol SDK
|
||||
|
||||
## 👥 Contribuer
|
||||
|
||||
Les contributions de toute nature sont les bienvenues !
|
||||
|
||||
- Nouvelles fonctionnalités et optimisations
|
||||
- Améliorations de la documentation
|
||||
- Rapports de bugs et corrections
|
||||
- Traductions et suggestions
|
||||
|
||||
Rejoignez notre [communauté Discord](https://discord.gg/qMKNsn5Q) pour des discussions et du soutien.
|
||||
|
||||
## ❤️ Sponsor
|
||||
|
||||
Si vous aimez ce projet, vous pouvez peut-être envisager de :
|
||||
|
||||
[](https://ko-fi.com/samanhappy)
|
||||
|
||||
## 🌟 Historique des étoiles
|
||||
|
||||
[](https://www.star-history.com/#samanhappy/mcphub&Date)
|
||||
|
||||
## 📄 Licence
|
||||
|
||||
Sous licence [Apache 2.0 License](LICENSE).
|
||||
@@ -1,6 +1,6 @@
|
||||
# MCPHub: The Unified Hub for Model Context Protocol (MCP) Servers
|
||||
|
||||
English | [中文版](README.zh.md)
|
||||
English | [Français](README.fr.md) | [中文版](README.zh.md)
|
||||
|
||||
MCPHub makes it easy to manage and scale multiple MCP (Model Context Protocol) servers by organizing them into flexible Streamable HTTP (SSE) endpoints—supporting access to all servers, individual servers, or logical server groups.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MCPHub:一站式 MCP 服务器聚合平台
|
||||
|
||||
[English Version](README.md) | 中文版
|
||||
[English](README.md) | [Français](README.fr.md) | 中文版
|
||||
|
||||
MCPHub 通过将多个 MCP(Model Context Protocol)服务器组织为灵活的流式 HTTP(SSE)端点,简化了管理与扩展工作。系统支持按需访问全部服务器、单个服务器或按场景分组的服务器集合。
|
||||
|
||||
|
||||
@@ -121,6 +121,66 @@ See the `examples/openapi-schema-config.json` file for complete configuration ex
|
||||
- **Validation**: Enhanced validation logic in server controllers
|
||||
- **Type Safety**: Updated TypeScript interfaces for both input modes
|
||||
|
||||
## Header Passthrough Support
|
||||
|
||||
MCPHub supports passing through specific headers from tool call requests to upstream OpenAPI endpoints. This is useful for authentication tokens, API keys, and other request-specific headers.
|
||||
|
||||
### Configuration
|
||||
|
||||
Add `passthroughHeaders` to your OpenAPI configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "openapi",
|
||||
"openapi": {
|
||||
"url": "https://api.example.com/openapi.json",
|
||||
"version": "3.1.0",
|
||||
"passthroughHeaders": ["Authorization", "X-API-Key", "X-Custom-Header"],
|
||||
"security": {
|
||||
"type": "apiKey",
|
||||
"apiKey": {
|
||||
"name": "X-API-Key",
|
||||
"in": "header",
|
||||
"value": "your-api-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Configuration**: List header names in the `passthroughHeaders` array
|
||||
2. **Tool Calls**: When calling tools via HTTP API, include headers in the request
|
||||
3. **Passthrough**: Only configured headers are forwarded to the upstream API
|
||||
4. **Case Insensitive**: Header matching is case-insensitive for flexibility
|
||||
|
||||
### Example Usage
|
||||
|
||||
```bash
|
||||
# Call an OpenAPI tool with passthrough headers
|
||||
curl -X POST "http://localhost:3000/api/tools/myapi/createUser" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer your-token" \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-H "X-Custom-Header: custom-value" \
|
||||
-d '{"name": "John Doe", "email": "john@example.com"}'
|
||||
```
|
||||
|
||||
In this example:
|
||||
|
||||
- If `passthroughHeaders` includes `["Authorization", "X-API-Key"]`
|
||||
- Only `Authorization` and `X-API-Key` headers will be forwarded
|
||||
- `X-Custom-Header` will be ignored (not in passthrough list)
|
||||
- `Content-Type` is handled by the OpenAPI operation definition
|
||||
|
||||
### Security Considerations
|
||||
|
||||
- **Whitelist Only**: Only explicitly configured headers are passed through
|
||||
- **Sensitive Data**: Be careful with headers containing sensitive information
|
||||
- **Validation**: Upstream APIs should validate all received headers
|
||||
- **Logging**: Headers may appear in logs - consider this for sensitive data
|
||||
|
||||
## Security Considerations
|
||||
|
||||
When using JSON schemas:
|
||||
|
||||
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;
|
||||
@@ -65,13 +65,16 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
oauth2Token: initialData.config.openapi.security?.oauth2?.token || '',
|
||||
// OpenID Connect initialization
|
||||
openIdConnectUrl: initialData.config.openapi.security?.openIdConnect?.url || '',
|
||||
openIdConnectToken: initialData.config.openapi.security?.openIdConnect?.token || ''
|
||||
openIdConnectToken: initialData.config.openapi.security?.openIdConnect?.token || '',
|
||||
// Passthrough headers initialization
|
||||
passthroughHeaders: initialData.config.openapi.passthroughHeaders ? initialData.config.openapi.passthroughHeaders.join(', ') : '',
|
||||
} : {
|
||||
inputMode: 'url',
|
||||
url: '',
|
||||
schema: '',
|
||||
version: '3.1.0',
|
||||
securityType: 'none'
|
||||
securityType: 'none',
|
||||
passthroughHeaders: '',
|
||||
}
|
||||
})
|
||||
|
||||
@@ -235,6 +238,14 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
};
|
||||
}
|
||||
|
||||
// Add passthrough headers if provided
|
||||
if (formData.openapi?.passthroughHeaders && formData.openapi.passthroughHeaders.trim()) {
|
||||
openapi.passthroughHeaders = formData.openapi.passthroughHeaders
|
||||
.split(',')
|
||||
.map(header => header.trim())
|
||||
.filter(header => header.length > 0);
|
||||
}
|
||||
|
||||
return openapi;
|
||||
})(),
|
||||
...(Object.keys(headers).length > 0 ? { headers } : {})
|
||||
@@ -616,6 +627,24 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Passthrough Headers Configuration */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2">
|
||||
{t('server.openapi.passthroughHeaders')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.openapi?.passthroughHeaders || ''}
|
||||
onChange={(e) => setFormData(prev => ({
|
||||
...prev,
|
||||
openapi: { ...prev.openapi, passthroughHeaders: e.target.value, url: prev.openapi?.url || '' }
|
||||
}))}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
placeholder="Authorization, X-API-Key, X-Custom-Header"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">{t('server.openapi.passthroughHeadersHelp')}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<label className="block text-gray-700 text-sm font-bold">
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -69,10 +69,10 @@ const LoginPage: React.FC = () => {
|
||||
|
||||
{/* Main content */}
|
||||
<div className="relative mx-auto flex min-h-screen w-full max-w-md items-center justify-center px-6 py-16">
|
||||
<div className="w-full space-y-6">
|
||||
<div className="w-full space-y-16">
|
||||
{/* Centered slogan */}
|
||||
<div className="flex justify-center w-full">
|
||||
<h1 className="text-3xl font-extrabold leading-tight tracking-tight text-gray-900 dark:text-white sm:text-4xl whitespace-nowrap">
|
||||
<h1 className="text-5xl sm:text-5xl font-extrabold leading-tight tracking-tight text-gray-900 dark:text-white whitespace-nowrap">
|
||||
<span className="bg-gradient-to-r from-indigo-400 via-cyan-400 to-emerald-400 bg-clip-text text-transparent">
|
||||
{t('auth.slogan')}
|
||||
</span>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -127,6 +127,7 @@ export interface ServerConfig {
|
||||
schema?: Record<string, any>; // Complete OpenAPI JSON schema
|
||||
version?: string; // OpenAPI version (default: '3.1.0')
|
||||
security?: OpenAPISecurityConfig; // Security configuration for API calls
|
||||
passthroughHeaders?: string[]; // Header names to pass through from tool call requests to upstream OpenAPI endpoints
|
||||
};
|
||||
}
|
||||
|
||||
@@ -232,6 +233,8 @@ export interface ServerFormData {
|
||||
openIdConnectClientId?: string;
|
||||
openIdConnectClientSecret?: string;
|
||||
openIdConnectToken?: string;
|
||||
// Passthrough headers
|
||||
passthroughHeaders?: string; // Comma-separated list of header names
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -159,7 +159,9 @@
|
||||
"openIdConnectToken": "ID Token",
|
||||
"apiKeyInHeader": "Header",
|
||||
"apiKeyInQuery": "Query",
|
||||
"apiKeyInCookie": "Cookie"
|
||||
"apiKeyInCookie": "Cookie",
|
||||
"passthroughHeaders": "Passthrough Headers",
|
||||
"passthroughHeadersHelp": "Comma-separated list of header names to pass through from tool call requests to upstream OpenAPI endpoints (e.g., Authorization, X-API-Key)"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
@@ -496,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",
|
||||
|
||||
@@ -159,7 +159,9 @@
|
||||
"openIdConnectToken": "Jeton d'identification",
|
||||
"apiKeyInHeader": "En-tête",
|
||||
"apiKeyInQuery": "Requête",
|
||||
"apiKeyInCookie": "Cookie"
|
||||
"apiKeyInCookie": "Cookie",
|
||||
"passthroughHeaders": "En-têtes de transmission",
|
||||
"passthroughHeadersHelp": "Liste séparée par des virgules des noms d'en-têtes à transmettre des requêtes d'appel d'outils vers les points de terminaison OpenAPI en amont (par ex. : Authorization, X-API-Key)"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
@@ -496,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",
|
||||
@@ -618,4 +624,4 @@
|
||||
"serverToolsUpdated": "Outils du serveur mis à jour avec succès"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,7 +159,9 @@
|
||||
"openIdConnectToken": "ID 令牌",
|
||||
"apiKeyInHeader": "请求头",
|
||||
"apiKeyInQuery": "查询",
|
||||
"apiKeyInCookie": "Cookie"
|
||||
"apiKeyInCookie": "Cookie",
|
||||
"passthroughHeaders": "透传请求头",
|
||||
"passthroughHeadersHelp": "要从工具调用请求透传到上游OpenAPI接口的请求头名称列表,用逗号分隔(如:Authorization, X-API-Key)"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
@@ -498,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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
10
package.json
10
package.json
@@ -46,13 +46,13 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@apidevtools/swagger-parser": "^12.0.0",
|
||||
"@modelcontextprotocol/sdk": "^1.17.4",
|
||||
"@modelcontextprotocol/sdk": "^1.18.1",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/multer": "^1.4.13",
|
||||
"@types/pg": "^8.15.5",
|
||||
"adm-zip": "^0.5.16",
|
||||
"axios": "^1.11.0",
|
||||
"axios": "^1.12.2",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"cors": "^2.8.5",
|
||||
@@ -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",
|
||||
|
||||
1948
pnpm-lock.yaml
generated
1948
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -299,7 +299,11 @@ export class OpenAPIClient {
|
||||
return schema;
|
||||
}
|
||||
|
||||
async callTool(toolName: string, args: Record<string, unknown>): Promise<unknown> {
|
||||
async callTool(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
passthroughHeaders?: Record<string, string>,
|
||||
): Promise<unknown> {
|
||||
const tool = this.tools.find((t) => t.name === toolName);
|
||||
if (!tool) {
|
||||
throw new Error(`Tool '${toolName}' not found`);
|
||||
@@ -340,18 +344,32 @@ export class OpenAPIClient {
|
||||
requestConfig.data = args.body;
|
||||
}
|
||||
|
||||
// Collect all headers to be sent
|
||||
const allHeaders: Record<string, string> = {};
|
||||
|
||||
// Add headers if any header parameters are defined
|
||||
const headerParams = tool.parameters?.filter((p) => p.in === 'header') || [];
|
||||
if (headerParams.length > 0) {
|
||||
requestConfig.headers = {};
|
||||
for (const param of headerParams) {
|
||||
const value = args[param.name];
|
||||
if (value !== undefined) {
|
||||
requestConfig.headers[param.name] = String(value);
|
||||
for (const param of headerParams) {
|
||||
const value = args[param.name];
|
||||
if (value !== undefined) {
|
||||
allHeaders[param.name] = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Add passthrough headers based on configuration
|
||||
if (passthroughHeaders && this.config.openapi?.passthroughHeaders) {
|
||||
for (const headerName of this.config.openapi.passthroughHeaders) {
|
||||
if (passthroughHeaders[headerName]) {
|
||||
allHeaders[headerName] = passthroughHeaders[headerName];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set headers if any were collected
|
||||
if (Object.keys(allHeaders).length > 0) {
|
||||
requestConfig.headers = allHeaders;
|
||||
}
|
||||
|
||||
const response = await this.httpClient.request(requestConfig);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
@@ -201,6 +202,7 @@ export const executeToolViaOpenAPI = async (req: Request, res: Response): Promis
|
||||
const extra = {
|
||||
sessionId: (req.headers['x-session-id'] as string) || 'openapi-session',
|
||||
server: serverName,
|
||||
headers: req.headers, // Pass all request headers for potential passthrough
|
||||
};
|
||||
|
||||
const result = await handleCallToolRequest(mockRequest, extra);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -61,6 +61,7 @@ export const callTool = async (req: Request, res: Response): Promise<void> => {
|
||||
const extra = {
|
||||
sessionId: req.headers['x-session-id'] || 'api-session',
|
||||
server: server || undefined,
|
||||
headers: req.headers, // Include request headers for passthrough
|
||||
};
|
||||
|
||||
const result = (await handleCallToolRequest(mockRequest, extra)) as ToolCallResult;
|
||||
|
||||
@@ -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,12 +12,13 @@ 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';
|
||||
import { saveToolsAsVectorEmbeddings, searchToolsByVector } from './vectorSearchService.js';
|
||||
import { OpenAPIClient } from '../clients/openapi.js';
|
||||
import { RequestContextService } from './requestContextService.js';
|
||||
import { getDataService } from './services.js';
|
||||
import { getServerDao, ServerConfigWithName } from '../dao/index.js';
|
||||
|
||||
@@ -293,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 || {}),
|
||||
}));
|
||||
@@ -403,6 +404,7 @@ export const initializeClientsFromSettings = async (
|
||||
prompts: [],
|
||||
createTime: Date.now(),
|
||||
enabled: conf.enabled === undefined ? true : conf.enabled,
|
||||
config: conf, // Store reference to original config for OpenAPI passthrough headers
|
||||
};
|
||||
serverInfos.push(serverInfo);
|
||||
|
||||
@@ -418,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),
|
||||
}));
|
||||
@@ -487,6 +489,7 @@ export const initializeClientsFromSettings = async (
|
||||
transport,
|
||||
options: requestOptions,
|
||||
createTime: Date.now(),
|
||||
config: conf, // Store reference to original config
|
||||
};
|
||||
serverInfos.push(serverInfo);
|
||||
|
||||
@@ -504,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 || {}),
|
||||
}));
|
||||
@@ -527,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,
|
||||
@@ -845,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));
|
||||
}
|
||||
@@ -1032,11 +1035,40 @@ 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;
|
||||
|
||||
const result = await openApiClient.callTool(cleanToolName, finalArgs);
|
||||
// Extract passthrough headers from extra or request context
|
||||
let passthroughHeaders: Record<string, string> | undefined;
|
||||
let requestHeaders: Record<string, string | string[] | undefined> | null = null;
|
||||
|
||||
// Try to get headers from extra parameter first (if available)
|
||||
if (extra?.headers) {
|
||||
requestHeaders = extra.headers;
|
||||
} else {
|
||||
// Fallback to request context service
|
||||
const requestContextService = RequestContextService.getInstance();
|
||||
requestHeaders = requestContextService.getHeaders();
|
||||
}
|
||||
|
||||
if (requestHeaders && targetServerInfo.config?.openapi?.passthroughHeaders) {
|
||||
passthroughHeaders = {};
|
||||
for (const headerName of targetServerInfo.config.openapi.passthroughHeaders) {
|
||||
// Handle different header name cases (Express normalizes headers to lowercase)
|
||||
const headerValue =
|
||||
requestHeaders[headerName] || requestHeaders[headerName.toLowerCase()];
|
||||
if (headerValue) {
|
||||
passthroughHeaders[headerName] = Array.isArray(headerValue)
|
||||
? headerValue[0]
|
||||
: String(headerValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await openApiClient.callTool(cleanToolName, finalArgs, passthroughHeaders);
|
||||
|
||||
console.log(`OpenAPI tool invocation result: ${JSON.stringify(result)}`);
|
||||
return {
|
||||
@@ -1063,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,
|
||||
@@ -1091,15 +1125,48 @@ 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(
|
||||
`Invoking OpenAPI tool '${cleanToolName}' on server '${serverInfo.name}' with arguments: ${JSON.stringify(request.params.arguments)}`,
|
||||
);
|
||||
|
||||
const result = await openApiClient.callTool(cleanToolName, request.params.arguments || {});
|
||||
// Extract passthrough headers from extra or request context
|
||||
let passthroughHeaders: Record<string, string> | undefined;
|
||||
let requestHeaders: Record<string, string | string[] | undefined> | null = null;
|
||||
|
||||
// Try to get headers from extra parameter first (if available)
|
||||
if (extra?.headers) {
|
||||
requestHeaders = extra.headers;
|
||||
} else {
|
||||
// Fallback to request context service
|
||||
const requestContextService = RequestContextService.getInstance();
|
||||
requestHeaders = requestContextService.getHeaders();
|
||||
}
|
||||
|
||||
if (requestHeaders && serverInfo.config?.openapi?.passthroughHeaders) {
|
||||
passthroughHeaders = {};
|
||||
for (const headerName of serverInfo.config.openapi.passthroughHeaders) {
|
||||
// Handle different header name cases (Express normalizes headers to lowercase)
|
||||
const headerValue =
|
||||
requestHeaders[headerName] || requestHeaders[headerName.toLowerCase()];
|
||||
if (headerValue) {
|
||||
passthroughHeaders[headerName] = Array.isArray(headerValue)
|
||||
? headerValue[0]
|
||||
: String(headerValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await openApiClient.callTool(
|
||||
cleanToolName,
|
||||
request.params.arguments || {},
|
||||
passthroughHeaders,
|
||||
);
|
||||
|
||||
console.log(`OpenAPI tool invocation result: ${JSON.stringify(result)}`);
|
||||
return {
|
||||
@@ -1118,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,
|
||||
@@ -1162,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, '')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
105
src/services/requestContextService.ts
Normal file
105
src/services/requestContextService.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Request } from 'express';
|
||||
|
||||
/**
|
||||
* Request context interface for MCP request handling
|
||||
*/
|
||||
export interface RequestContext {
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
sessionId?: string;
|
||||
userAgent?: string;
|
||||
remoteAddress?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for managing request context during MCP request processing
|
||||
* This allows MCP request handlers to access HTTP headers and other request metadata
|
||||
*/
|
||||
export class RequestContextService {
|
||||
private static instance: RequestContextService;
|
||||
private requestContext: RequestContext | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): RequestContextService {
|
||||
if (!RequestContextService.instance) {
|
||||
RequestContextService.instance = new RequestContextService();
|
||||
}
|
||||
return RequestContextService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current request context from Express request
|
||||
*/
|
||||
public setRequestContext(req: Request): void {
|
||||
this.requestContext = {
|
||||
headers: req.headers,
|
||||
sessionId: (req.headers['mcp-session-id'] as string) || undefined,
|
||||
userAgent: req.headers['user-agent'] as string,
|
||||
remoteAddress: req.ip || req.socket?.remoteAddress,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set request context from custom data
|
||||
*/
|
||||
public setCustomRequestContext(context: RequestContext): void {
|
||||
this.requestContext = context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current request context
|
||||
*/
|
||||
public getRequestContext(): RequestContext | null {
|
||||
return this.requestContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get headers from the current request context
|
||||
*/
|
||||
public getHeaders(): Record<string, string | string[] | undefined> | null {
|
||||
return this.requestContext?.headers || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific header value (case-insensitive)
|
||||
*/
|
||||
public getHeader(name: string): string | string[] | undefined {
|
||||
if (!this.requestContext?.headers) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Try exact match first
|
||||
if (this.requestContext.headers[name]) {
|
||||
return this.requestContext.headers[name];
|
||||
}
|
||||
|
||||
// Try lowercase match (Express normalizes headers to lowercase)
|
||||
const lowerName = name.toLowerCase();
|
||||
if (this.requestContext.headers[lowerName]) {
|
||||
return this.requestContext.headers[lowerName];
|
||||
}
|
||||
|
||||
// Try case-insensitive search
|
||||
for (const [key, value] of Object.entries(this.requestContext.headers)) {
|
||||
if (key.toLowerCase() === lowerName) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the current request context
|
||||
*/
|
||||
public clearRequestContext(): void {
|
||||
this.requestContext = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session ID from current request context
|
||||
*/
|
||||
public getSessionId(): string | undefined {
|
||||
return this.requestContext?.sessionId;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { deleteMcpServer, getMcpServer } from './mcpService.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import config from '../config/index.js';
|
||||
import { UserContextService } from './userContextService.js';
|
||||
import { RequestContextService } from './requestContextService.js';
|
||||
|
||||
const transports: { [sessionId: string]: { transport: Transport; group: string } } = {};
|
||||
|
||||
@@ -131,7 +132,16 @@ export const handleSseMessage = async (req: Request, res: Response): Promise<voi
|
||||
`Received message for sessionId: ${sessionId} in group: ${group}${username ? ` for user: ${username}` : ''}`,
|
||||
);
|
||||
|
||||
await (transport as SSEServerTransport).handlePostMessage(req, res);
|
||||
// Set request context for MCP handlers to access HTTP headers
|
||||
const requestContextService = RequestContextService.getInstance();
|
||||
requestContextService.setRequestContext(req);
|
||||
|
||||
try {
|
||||
await (transport as SSEServerTransport).handlePostMessage(req, res);
|
||||
} finally {
|
||||
// Clean up request context after handling
|
||||
requestContextService.clearRequestContext();
|
||||
}
|
||||
};
|
||||
|
||||
export const handleMcpPostRequest = async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -202,7 +212,17 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
|
||||
}
|
||||
|
||||
console.log(`Handling request using transport with type ${transport.constructor.name}`);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
|
||||
// Set request context for MCP handlers to access HTTP headers
|
||||
const requestContextService = RequestContextService.getInstance();
|
||||
requestContextService.setRequestContext(req);
|
||||
|
||||
try {
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
} finally {
|
||||
// Clean up request context after handling
|
||||
requestContextService.clearRequestContext();
|
||||
}
|
||||
};
|
||||
|
||||
export const handleMcpOtherRequest = async (req: Request, res: Response) => {
|
||||
|
||||
@@ -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 {
|
||||
@@ -186,6 +187,7 @@ export interface ServerConfig {
|
||||
schema?: Record<string, any>; // Complete OpenAPI JSON schema
|
||||
version?: string; // OpenAPI version (default: '3.1.0')
|
||||
security?: OpenAPISecurityConfig; // Security configuration for API calls
|
||||
passthroughHeaders?: string[]; // Header names to pass through from tool call requests to upstream OpenAPI endpoints
|
||||
};
|
||||
}
|
||||
|
||||
@@ -236,6 +238,7 @@ export interface ServerInfo {
|
||||
createTime: number; // Timestamp of when the server was created
|
||||
enabled?: boolean; // Flag to indicate if the server is enabled
|
||||
keepAliveIntervalId?: NodeJS.Timeout; // Timer ID for keep-alive ping interval
|
||||
config?: ServerConfig; // Reference to the original server configuration for OpenAPI passthrough headers
|
||||
}
|
||||
|
||||
// Details about a tool available on the server
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
141
tests/services/requestContextService.test.ts
Normal file
141
tests/services/requestContextService.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { RequestContextService } from '../../src/services/requestContextService.js';
|
||||
import { Request } from 'express';
|
||||
|
||||
describe('RequestContextService', () => {
|
||||
let service: RequestContextService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = RequestContextService.getInstance();
|
||||
service.clearRequestContext();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
service.clearRequestContext();
|
||||
});
|
||||
|
||||
it('should be a singleton', () => {
|
||||
const service1 = RequestContextService.getInstance();
|
||||
const service2 = RequestContextService.getInstance();
|
||||
expect(service1).toBe(service2);
|
||||
});
|
||||
|
||||
it('should set and get request context from Express request', () => {
|
||||
const mockRequest = {
|
||||
headers: {
|
||||
authorization: 'Bearer test-token',
|
||||
'x-api-key': 'test-api-key',
|
||||
'user-agent': 'test-agent',
|
||||
},
|
||||
ip: '127.0.0.1',
|
||||
connection: { remoteAddress: '127.0.0.1' },
|
||||
} as unknown as Request;
|
||||
|
||||
service.setRequestContext(mockRequest);
|
||||
const context = service.getRequestContext();
|
||||
|
||||
expect(context).toBeTruthy();
|
||||
expect(context?.headers).toEqual(mockRequest.headers);
|
||||
expect(context?.userAgent).toBe('test-agent');
|
||||
expect(context?.remoteAddress).toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
it('should get specific headers case-insensitively', () => {
|
||||
const mockRequest = {
|
||||
headers: {
|
||||
authorization: 'Bearer test-token',
|
||||
'X-API-Key': 'test-api-key',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
ip: '127.0.0.1',
|
||||
connection: { remoteAddress: '127.0.0.1' },
|
||||
} as unknown as Request;
|
||||
|
||||
service.setRequestContext(mockRequest);
|
||||
|
||||
// Test exact match
|
||||
expect(service.getHeader('authorization')).toBe('Bearer test-token');
|
||||
expect(service.getHeader('X-API-Key')).toBe('test-api-key');
|
||||
|
||||
// Test case-insensitive match
|
||||
expect(service.getHeader('Authorization')).toBe('Bearer test-token');
|
||||
expect(service.getHeader('x-api-key')).toBe('test-api-key');
|
||||
expect(service.getHeader('CONTENT-TYPE')).toBe('application/json');
|
||||
|
||||
// Test non-existent header
|
||||
expect(service.getHeader('non-existent')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle array header values', () => {
|
||||
const mockRequest = {
|
||||
headers: {
|
||||
accept: ['application/json', 'text/html'],
|
||||
authorization: 'Bearer test-token',
|
||||
},
|
||||
ip: '127.0.0.1',
|
||||
connection: { remoteAddress: '127.0.0.1' },
|
||||
} as unknown as Request;
|
||||
|
||||
service.setRequestContext(mockRequest);
|
||||
|
||||
const acceptHeader = service.getHeader('accept');
|
||||
expect(acceptHeader).toEqual(['application/json', 'text/html']);
|
||||
|
||||
const authHeader = service.getHeader('authorization');
|
||||
expect(authHeader).toBe('Bearer test-token');
|
||||
});
|
||||
|
||||
it('should extract session ID from mcp-session-id header', () => {
|
||||
const mockRequest = {
|
||||
headers: {
|
||||
'mcp-session-id': 'test-session-123',
|
||||
authorization: 'Bearer test-token',
|
||||
},
|
||||
ip: '127.0.0.1',
|
||||
connection: { remoteAddress: '127.0.0.1' },
|
||||
} as unknown as Request;
|
||||
|
||||
service.setRequestContext(mockRequest);
|
||||
|
||||
expect(service.getSessionId()).toBe('test-session-123');
|
||||
});
|
||||
|
||||
it('should handle custom request context', () => {
|
||||
const customContext = {
|
||||
headers: {
|
||||
'custom-header': 'custom-value',
|
||||
authorization: 'Bearer custom-token',
|
||||
},
|
||||
sessionId: 'custom-session',
|
||||
userAgent: 'custom-agent',
|
||||
remoteAddress: '192.168.1.1',
|
||||
};
|
||||
|
||||
service.setCustomRequestContext(customContext);
|
||||
const context = service.getRequestContext();
|
||||
|
||||
expect(context).toEqual(customContext);
|
||||
expect(service.getHeader('custom-header')).toBe('custom-value');
|
||||
expect(service.getSessionId()).toBe('custom-session');
|
||||
});
|
||||
|
||||
it('should return null when no context is set', () => {
|
||||
expect(service.getRequestContext()).toBeNull();
|
||||
expect(service.getHeaders()).toBeNull();
|
||||
expect(service.getHeader('any-header')).toBeUndefined();
|
||||
expect(service.getSessionId()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should clear request context', () => {
|
||||
const mockRequest = {
|
||||
headers: { authorization: 'Bearer test-token' },
|
||||
ip: '127.0.0.1',
|
||||
connection: { remoteAddress: '127.0.0.1' },
|
||||
} as unknown as Request;
|
||||
|
||||
service.setRequestContext(mockRequest);
|
||||
expect(service.getRequestContext()).toBeTruthy();
|
||||
|
||||
service.clearRequestContext();
|
||||
expect(service.getRequestContext()).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user