mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 10:49:35 -05:00
Compare commits
29 Commits
v0.9.6
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9732fccb6 | ||
|
|
7b3d441046 | ||
|
|
55a7d0b183 | ||
|
|
435227cbd4 | ||
|
|
6a59becd8d | ||
|
|
91698a50e3 | ||
|
|
a5d5045832 | ||
|
|
198ea85225 | ||
|
|
6b39916909 | ||
|
|
9e8db370ff | ||
|
|
5d8bc44a73 | ||
|
|
021901dbda | ||
|
|
f6934a32dc | ||
|
|
7685b9bca8 | ||
|
|
c2dd91606f | ||
|
|
66b6053f7f | ||
|
|
ba50a78879 | ||
|
|
a856404963 | ||
|
|
9a65532a50 | ||
|
|
c5aa97de50 | ||
|
|
271c9fe2c3 | ||
|
|
d59961c4d4 | ||
|
|
d0ec80303a | ||
|
|
69e92b5aa8 | ||
|
|
5acae64b29 | ||
|
|
a5fc4a429d | ||
|
|
ce15330016 | ||
|
|
621bc36560 | ||
|
|
c398223824 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -25,4 +25,5 @@ yarn-error.log*
|
||||
*.log
|
||||
coverage/
|
||||
|
||||
data/
|
||||
data/
|
||||
temp-test-config/
|
||||
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)端点,简化了管理与扩展工作。系统支持按需访问全部服务器、单个服务器或按场景分组的服务器集合。
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { execSync } from 'child_process';
|
||||
import { fileURLToPath, pathToFileURL } from 'url';
|
||||
import fs from 'fs';
|
||||
|
||||
// Enable debug logging if needed
|
||||
@@ -90,7 +89,10 @@ checkFrontend(projectRoot);
|
||||
|
||||
// Start the server
|
||||
console.log('🚀 Starting MCPHub server...');
|
||||
import(path.join(projectRoot, 'dist', 'index.js')).catch(err => {
|
||||
const entryPath = path.join(projectRoot, 'dist', 'index.js');
|
||||
// Convert to file:// URL for cross-platform ESM compatibility (required on Windows)
|
||||
const entryUrl = pathToFileURL(entryPath).href;
|
||||
import(entryUrl).catch(err => {
|
||||
console.error('Failed to start MCPHub:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -47,7 +47,8 @@ MCPHub uses several configuration files:
|
||||
},
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--headless"]
|
||||
"args": ["@playwright/mcp@latest", "--headless", "--isolated"],
|
||||
"perSession": true
|
||||
},
|
||||
"slack": {
|
||||
"command": "npx",
|
||||
@@ -75,6 +76,42 @@ MCPHub uses several configuration files:
|
||||
| Field | Type | Default | Description |
|
||||
| -------------- | ------- | --------------- | --------------------------- |
|
||||
| `env` | object | `{}` | Environment variables |
|
||||
| `perSession` | boolean | `false` | Create separate server instance per user session (for stateful servers like playwright) |
|
||||
| `enabled` | boolean | `true` | Enable/disable the server |
|
||||
| `timeout` | number | `60000` | Request timeout in milliseconds |
|
||||
| `keepAliveInterval` | number | `60000` | Keep-alive ping interval for SSE servers (ms) |
|
||||
|
||||
## Per-Session Server Instances
|
||||
|
||||
Some MCP servers maintain state that should be isolated between different users. For example, the Playwright server maintains browser sessions that could leak form data or other state between concurrent users.
|
||||
|
||||
To prevent this, you can set `perSession: true` in the server configuration. This creates a separate server instance for each user session instead of sharing a single instance across all users.
|
||||
|
||||
### When to Use Per-Session Servers
|
||||
|
||||
Use `perSession: true` for servers that:
|
||||
- Maintain browser state (like Playwright)
|
||||
- Store user-specific data in memory
|
||||
- Have file handles or database connections that shouldn't be shared
|
||||
- Could cause race conditions when multiple users access simultaneously
|
||||
|
||||
### Example Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--headless", "--isolated"],
|
||||
"perSession": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important Notes:**
|
||||
- Each per-session server instance consumes additional resources (memory, CPU)
|
||||
- Per-session servers are automatically cleaned up when the user session ends
|
||||
- For Playwright, also use the `--isolated` flag to ensure browser contexts are isolated
|
||||
- Not recommended for stateless servers that can safely be shared
|
||||
|
||||
## Common MCP Server Examples
|
||||
|
||||
@@ -101,8 +138,9 @@ MCPHub uses several configuration files:
|
||||
{
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--headless"],
|
||||
"args": ["@playwright/mcp@latest", "--headless", "--isolated"],
|
||||
"timeout": 60000,
|
||||
"perSession": true,
|
||||
"env": {
|
||||
"PLAYWRIGHT_BROWSERS_PATH": "/tmp/browsers"
|
||||
}
|
||||
@@ -110,6 +148,8 @@ MCPHub uses several configuration files:
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: The `--isolated` flag ensures each browser session is isolated, and `perSession: true` creates a separate server instance for each user session, preventing state leakage between concurrent users.
|
||||
|
||||
### File and System Servers
|
||||
|
||||
#### Filesystem Server
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -50,8 +50,9 @@ MCPHub 使用几个配置文件:
|
||||
},
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--headless"],
|
||||
"timeout": 60000
|
||||
"args": ["@playwright/mcp@latest", "--headless", "--isolated"],
|
||||
"timeout": 60000,
|
||||
"perSession": true
|
||||
},
|
||||
"slack": {
|
||||
"command": "npx",
|
||||
@@ -79,13 +80,48 @@ MCPHub 使用几个配置文件:
|
||||
| 字段 | 类型 | 默认值 | 描述 |
|
||||
| -------------- | ------- | --------------- | ------------------ |
|
||||
| `env` | object | `{}` | 环境变量 |
|
||||
| `perSession` | boolean | `false` | 为每个用户会话创建独立的服务器实例(用于有状态的服务器,如 playwright) |
|
||||
| `enabled` | boolean | `true` | 启用/禁用服务器 |
|
||||
| `timeout` | number | `60000` | 请求超时(毫秒) |
|
||||
| `keepAliveInterval` | number | `60000` | SSE 服务器的保活 ping 间隔(毫秒) |
|
||||
| `cwd` | string | `process.cwd()` | 工作目录 |
|
||||
| `timeout` | number | `30000` | 启动超时(毫秒) |
|
||||
| `restart` | boolean | `true` | 失败时自动重启 |
|
||||
| `maxRestarts` | number | `5` | 最大重启次数 |
|
||||
| `restartDelay` | number | `5000` | 重启间延迟(毫秒) |
|
||||
| `stdio` | string | `pipe` | stdio 配置 |
|
||||
|
||||
## 会话隔离的服务器实例
|
||||
|
||||
某些 MCP 服务器会维护应该在不同用户之间隔离的状态。例如,Playwright 服务器维护可能在并发用户之间泄漏表单数据或其他状态的浏览器会话。
|
||||
|
||||
为了防止这种情况,您可以在服务器配置中设置 `perSession: true`。这将为每个用户会话创建一个单独的服务器实例,而不是在所有用户之间共享单个实例。
|
||||
|
||||
### 何时使用会话隔离的服务器
|
||||
|
||||
对于以下服务器使用 `perSession: true`:
|
||||
- 维护浏览器状态(如 Playwright)
|
||||
- 在内存中存储用户特定数据
|
||||
- 具有不应共享的文件句柄或数据库连接
|
||||
- 当多个用户同时访问时可能导致竞争条件
|
||||
|
||||
### 示例配置
|
||||
|
||||
```json
|
||||
{
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--headless", "--isolated"],
|
||||
"perSession": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**重要提示:**
|
||||
- 每个会话隔离的服务器实例都会消耗额外的资源(内存、CPU)
|
||||
- 会话隔离的服务器在用户会话结束时会自动清理
|
||||
- 对于 Playwright,还要使用 `--isolated` 标志以确保浏览器上下文被隔离
|
||||
- 不建议用于可以安全共享的无状态服务器
|
||||
|
||||
## 常见 MCP 服务器示例
|
||||
|
||||
### Web 和 API 服务器
|
||||
@@ -111,8 +147,9 @@ MCPHub 使用几个配置文件:
|
||||
{
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--headless"],
|
||||
"args": ["@playwright/mcp@latest", "--headless", "--isolated"],
|
||||
"timeout": 60000,
|
||||
"perSession": true,
|
||||
"env": {
|
||||
"PLAYWRIGHT_BROWSERS_PATH": "/tmp/browsers"
|
||||
}
|
||||
@@ -120,6 +157,8 @@ MCPHub 使用几个配置文件:
|
||||
}
|
||||
```
|
||||
|
||||
**注意**: `--isolated` 标志确保每个浏览器会话是隔离的,而 `perSession: true` 为每个用户会话创建单独的服务器实例,防止并发用户之间的状态泄漏。
|
||||
|
||||
### 文件和系统服务器
|
||||
|
||||
#### 文件系统服务器
|
||||
|
||||
@@ -3,6 +3,7 @@ import { BrowserRouter as Router, Route, Routes, Navigate, useParams } from 'rea
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { ToastProvider } from './contexts/ToastContext';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { ServerProvider } from './contexts/ServerContext';
|
||||
import MainLayout from './layouts/MainLayout';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
@@ -26,6 +27,7 @@ function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<ServerProvider>
|
||||
<ToastProvider>
|
||||
<Router basename={basename}>
|
||||
<Routes>
|
||||
@@ -57,6 +59,7 @@ function App() {
|
||||
</Routes>
|
||||
</Router>
|
||||
</ToastProvider>
|
||||
</ServerProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
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;
|
||||
@@ -231,8 +231,8 @@ const CloudServerDetail: React.FC<CloudServerDetailProps> = ({
|
||||
className="w-full border rounded-md px-3 py-2 border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
|
||||
>
|
||||
<option value=""></option>
|
||||
<option value="true">True</option>
|
||||
<option value="false">False</option>
|
||||
<option value="true">{t('common.true')}</option>
|
||||
<option value="false">{t('common.false')}</option>
|
||||
</select>
|
||||
);
|
||||
} else if (propSchema.type === 'number' || propSchema.type === 'integer') {
|
||||
|
||||
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 } : {})
|
||||
@@ -306,7 +317,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
onChange={() => updateServerType('stdio')}
|
||||
className="mr-1"
|
||||
/>
|
||||
<label htmlFor="command">STDIO</label>
|
||||
<label htmlFor="command">{t('server.typeStdio')}</label>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
@@ -318,7 +329,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
onChange={() => updateServerType('sse')}
|
||||
className="mr-1"
|
||||
/>
|
||||
<label htmlFor="url">SSE</label>
|
||||
<label htmlFor="url">{t('server.typeSse')}</label>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
@@ -330,7 +341,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
onChange={() => updateServerType('streamable-http')}
|
||||
className="mr-1"
|
||||
/>
|
||||
<label htmlFor="streamable-http">Streamable HTTP</label>
|
||||
<label htmlFor="streamable-http">{t('server.typeStreamableHttp')}</label>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
@@ -342,7 +353,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
onChange={() => updateServerType('openapi')}
|
||||
className="mr-1"
|
||||
/>
|
||||
<label htmlFor="openapi">OpenAPI</label>
|
||||
<label htmlFor="openapi">{t('server.typeOpenapi')}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -500,9 +511,9 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
}))}
|
||||
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
|
||||
>
|
||||
<option value="header">Header</option>
|
||||
<option value="query">Query</option>
|
||||
<option value="cookie">Cookie</option>
|
||||
<option value="header">{t('server.openapi.apiKeyInHeader')}</option>
|
||||
<option value="query">{t('server.openapi.apiKeyInQuery')}</option>
|
||||
<option value="cookie">{t('server.openapi.apiKeyInCookie')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -537,9 +548,9 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
}))}
|
||||
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
|
||||
>
|
||||
<option value="basic">Basic</option>
|
||||
<option value="bearer">Bearer</option>
|
||||
<option value="digest">Digest</option>
|
||||
<option value="basic">{t('server.openapi.httpSchemeBasic')}</option>
|
||||
<option value="bearer">{t('server.openapi.httpSchemeBearer')}</option>
|
||||
<option value="digest">{t('server.openapi.httpSchemeDigest')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -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;
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const DiscordIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
@@ -11,7 +13,7 @@ export const DiscordIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
fill="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<title>Discord</title>
|
||||
<title>{t('common.discord')}</title>
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const GitHubIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
@@ -11,7 +13,7 @@ export const GitHubIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
fill="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<title>GitHub</title>
|
||||
<title>{t('common.github')}</title>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const SponsorIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
@@ -11,7 +13,7 @@ export const SponsorIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
fill="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<title>Sponsor</title>
|
||||
<title>{t('sponsor.label')}</title>
|
||||
<path d="M17.625 1.499c-2.32 0-4.354 1.203-5.625 3.03-1.271-1.827-3.305-3.03-5.625-3.03C3.129 1.499 0 4.253 0 8.249c0 4.275 3.068 7.847 5.828 10.227a33.14 33.14 0 0 0 5.616 3.876l.028.017.008.003-.001.003c.163.085.342.126.521.125.179.001.358-.041.521-.125l-.001-.003.008-.003.028-.017a33.14 33.14 0 0 0 5.616-3.876C20.932 16.096 24 12.524 24 8.249c0-3.996-3.129-6.75-6.375-6.75zm-.919 15.275a30.766 30.766 0 0 1-4.703 3.316l-.004-.002-.004.002a30.955 30.955 0 0 1-4.703-3.316c-2.677-2.307-5.047-5.298-5.047-8.523 0-2.754 2.121-4.5 4.125-4.5 2.06 0 3.914 1.479 4.544 3.684.143.495.596.797 1.086.796.49.001.943-.302 1.085-.796.63-2.205 2.484-3.684 4.544-3.684 2.004 0 4.125 1.746 4.125 4.5 0 3.225-2.37 6.216-5.048 8.523z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const WeChatIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
@@ -11,7 +13,7 @@ export const WeChatIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
fill="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<title>WeChat</title>
|
||||
<title>{t('common.wechat')}</title>
|
||||
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,8 @@ const LanguageSwitch: React.FC = () => {
|
||||
// Available languages
|
||||
const availableLanguages = [
|
||||
{ code: 'en', label: 'English' },
|
||||
{ code: 'zh', label: '中文' }
|
||||
{ code: 'zh', label: '中文' },
|
||||
{ code: 'fr', label: 'Français' }
|
||||
];
|
||||
|
||||
// Update current language when it changes
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
@@ -19,6 +20,8 @@ const Toast: React.FC<ToastProps> = ({
|
||||
onClose,
|
||||
visible
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -83,7 +86,7 @@ const Toast: React.FC<ToastProps> = ({
|
||||
`hover:bg-${type}-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-${type}-500`
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">Dismiss</span>
|
||||
<span className="sr-only">{t('common.dismiss')}</span>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
359
frontend/src/contexts/ServerContext.tsx
Normal file
359
frontend/src/contexts/ServerContext.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
import React, { createContext, useState, useEffect, useRef, useCallback, useContext } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Server, ApiResponse } from '@/types';
|
||||
import { apiGet, apiPost, apiDelete } from '../utils/fetchInterceptor';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
// Configuration options
|
||||
const CONFIG = {
|
||||
// Initialization phase configuration
|
||||
startup: {
|
||||
maxAttempts: 60, // Maximum number of attempts during initialization
|
||||
pollingInterval: 3000, // Polling interval during initialization (3 seconds)
|
||||
},
|
||||
// Normal operation phase configuration
|
||||
normal: {
|
||||
pollingInterval: 10000, // Polling interval during normal operation (10 seconds)
|
||||
},
|
||||
};
|
||||
|
||||
// Context type definition
|
||||
interface ServerContextType {
|
||||
servers: Server[];
|
||||
error: string | null;
|
||||
setError: (error: string | null) => void;
|
||||
isLoading: boolean;
|
||||
fetchAttempts: number;
|
||||
triggerRefresh: () => void;
|
||||
refreshIfNeeded: () => void; // Smart refresh with debounce
|
||||
handleServerAdd: () => void;
|
||||
handleServerEdit: (server: Server) => Promise<any>;
|
||||
handleServerRemove: (serverName: string) => Promise<boolean>;
|
||||
handleServerToggle: (server: Server, enabled: boolean) => Promise<boolean>;
|
||||
}
|
||||
|
||||
// Create Context
|
||||
const ServerContext = createContext<ServerContextType | undefined>(undefined);
|
||||
|
||||
// Provider component
|
||||
export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { t } = useTranslation();
|
||||
const { auth } = useAuth();
|
||||
const [servers, setServers] = useState<Server[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
const [fetchAttempts, setFetchAttempts] = useState(0);
|
||||
|
||||
// Timer reference for polling
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
// Track current attempt count to avoid dependency cycles
|
||||
const attemptsRef = useRef<number>(0);
|
||||
// Track last fetch time to implement smart refresh
|
||||
const lastFetchTimeRef = useRef<number>(0);
|
||||
// Minimum interval between manual refreshes (5 seconds in dev, 3 seconds in prod)
|
||||
const MIN_REFRESH_INTERVAL = process.env.NODE_ENV === 'development' ? 5000 : 3000;
|
||||
|
||||
// Clear the timer
|
||||
const clearTimer = () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Start normal polling
|
||||
const startNormalPolling = useCallback((options?: { immediate?: boolean }) => {
|
||||
const immediate = options?.immediate ?? true;
|
||||
// Ensure no other timers are running
|
||||
clearTimer();
|
||||
|
||||
const fetchServers = async () => {
|
||||
try {
|
||||
console.log('[ServerContext] Fetching servers from API...');
|
||||
const data = await apiGet('/servers');
|
||||
|
||||
// Update last fetch time
|
||||
lastFetchTimeRef.current = Date.now();
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data);
|
||||
} else if (data && Array.isArray(data)) {
|
||||
setServers(data);
|
||||
} else {
|
||||
console.error('Invalid server data format:', data);
|
||||
setServers([]);
|
||||
}
|
||||
|
||||
// Reset error state
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error fetching servers during normal polling:', err);
|
||||
|
||||
// Use friendly error message
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'));
|
||||
} else if (
|
||||
err instanceof TypeError &&
|
||||
(err.message.includes('NetworkError') || err.message.includes('Failed to fetch'))
|
||||
) {
|
||||
setError(t('errors.serverConnection'));
|
||||
} else {
|
||||
setError(t('errors.serverFetch'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Execute immediately unless explicitly skipped
|
||||
if (immediate) {
|
||||
fetchServers();
|
||||
}
|
||||
|
||||
// Set up regular polling
|
||||
intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval);
|
||||
}, [t]);
|
||||
|
||||
// Watch for authentication status changes
|
||||
useEffect(() => {
|
||||
if (auth.isAuthenticated) {
|
||||
console.log('[ServerContext] User authenticated, triggering refresh');
|
||||
// When user logs in, trigger a refresh to load servers
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
} else {
|
||||
console.log('[ServerContext] User not authenticated, clearing data and stopping polling');
|
||||
// When user logs out, clear data and stop polling
|
||||
clearTimer();
|
||||
setServers([]);
|
||||
setIsInitialLoading(false);
|
||||
setError(null);
|
||||
}
|
||||
}, [auth.isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
// If not authenticated, don't poll
|
||||
if (!auth.isAuthenticated) {
|
||||
console.log('[ServerContext] User not authenticated, skipping polling setup');
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset attempt count
|
||||
if (refreshKey > 0) {
|
||||
attemptsRef.current = 0;
|
||||
setFetchAttempts(0);
|
||||
}
|
||||
|
||||
// Initialization phase request function
|
||||
const fetchInitialData = async () => {
|
||||
try {
|
||||
console.log('[ServerContext] Initial fetch - attempt', attemptsRef.current + 1);
|
||||
const data = await apiGet('/servers');
|
||||
|
||||
// Update last fetch time
|
||||
lastFetchTimeRef.current = Date.now();
|
||||
|
||||
// Handle API response wrapper object, extract data field
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data);
|
||||
setIsInitialLoading(false);
|
||||
// Initialization successful, start normal polling (skip immediate to avoid duplicate fetch)
|
||||
startNormalPolling({ immediate: false });
|
||||
return true;
|
||||
} else if (data && Array.isArray(data)) {
|
||||
// Compatibility handling, if API directly returns array
|
||||
setServers(data);
|
||||
setIsInitialLoading(false);
|
||||
// Initialization successful, start normal polling (skip immediate to avoid duplicate fetch)
|
||||
startNormalPolling({ immediate: false });
|
||||
return true;
|
||||
} else {
|
||||
// If data format is not as expected, set to empty array
|
||||
console.error('Invalid server data format:', data);
|
||||
setServers([]);
|
||||
setIsInitialLoading(false);
|
||||
// Initialization successful but data is empty, start normal polling (skip immediate)
|
||||
startNormalPolling({ immediate: false });
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
// Increment attempt count, use ref to avoid triggering effect rerun
|
||||
attemptsRef.current += 1;
|
||||
console.error(`Initial loading attempt ${attemptsRef.current} failed:`, err);
|
||||
|
||||
// Update state for display
|
||||
setFetchAttempts(attemptsRef.current);
|
||||
|
||||
// Set appropriate error message
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'));
|
||||
} else {
|
||||
setError(t('errors.initialStartup'));
|
||||
}
|
||||
|
||||
// If maximum attempt count is exceeded, give up initialization and switch to normal polling
|
||||
if (attemptsRef.current >= CONFIG.startup.maxAttempts) {
|
||||
console.log('Maximum startup attempts reached, switching to normal polling');
|
||||
setIsInitialLoading(false);
|
||||
// Clear initialization polling
|
||||
clearTimer();
|
||||
// Switch to normal polling mode
|
||||
startNormalPolling();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// On component mount, set appropriate polling based on current state
|
||||
if (isInitialLoading) {
|
||||
// Ensure no other timers are running
|
||||
clearTimer();
|
||||
|
||||
// Execute initial request immediately
|
||||
fetchInitialData();
|
||||
|
||||
// Set polling interval for initialization phase
|
||||
intervalRef.current = setInterval(fetchInitialData, CONFIG.startup.pollingInterval);
|
||||
console.log(`Started initial polling with interval: ${CONFIG.startup.pollingInterval}ms`);
|
||||
} else {
|
||||
// Initialization completed, start normal polling
|
||||
startNormalPolling();
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
clearTimer();
|
||||
};
|
||||
}, [refreshKey, t, isInitialLoading, startNormalPolling]);
|
||||
|
||||
// Manually trigger refresh (always refreshes)
|
||||
const triggerRefresh = useCallback(() => {
|
||||
// Clear current timer
|
||||
clearTimer();
|
||||
|
||||
// If in initialization phase, reset initialization state
|
||||
if (isInitialLoading) {
|
||||
setIsInitialLoading(true);
|
||||
attemptsRef.current = 0;
|
||||
setFetchAttempts(0);
|
||||
}
|
||||
|
||||
// Change in refreshKey will trigger useEffect to run again
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
}, [isInitialLoading]);
|
||||
|
||||
// Smart refresh with debounce (only refresh if enough time has passed)
|
||||
const refreshIfNeeded = useCallback(() => {
|
||||
const now = Date.now();
|
||||
const timeSinceLastFetch = now - lastFetchTimeRef.current;
|
||||
|
||||
// Log who is calling this
|
||||
console.log('[ServerContext] refreshIfNeeded called, time since last fetch:', timeSinceLastFetch, 'ms');
|
||||
|
||||
// Only refresh if enough time has passed since last fetch
|
||||
if (timeSinceLastFetch >= MIN_REFRESH_INTERVAL) {
|
||||
console.log('[ServerContext] Triggering refresh (exceeded MIN_REFRESH_INTERVAL:', MIN_REFRESH_INTERVAL, 'ms)');
|
||||
triggerRefresh();
|
||||
} else {
|
||||
console.log('[ServerContext] Skipping refresh (MIN_REFRESH_INTERVAL:', MIN_REFRESH_INTERVAL, 'ms, time since last:', timeSinceLastFetch, 'ms)');
|
||||
}
|
||||
}, [triggerRefresh]);
|
||||
|
||||
// Server related operations
|
||||
const handleServerAdd = useCallback(() => {
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
}, []);
|
||||
|
||||
const handleServerEdit = useCallback(async (server: Server) => {
|
||||
try {
|
||||
// Fetch settings to get the full server config before editing
|
||||
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> =
|
||||
await apiGet('/settings');
|
||||
|
||||
if (
|
||||
settingsData &&
|
||||
settingsData.success &&
|
||||
settingsData.data &&
|
||||
settingsData.data.mcpServers &&
|
||||
settingsData.data.mcpServers[server.name]
|
||||
) {
|
||||
const serverConfig = settingsData.data.mcpServers[server.name];
|
||||
return {
|
||||
name: server.name,
|
||||
status: server.status,
|
||||
tools: server.tools || [],
|
||||
config: serverConfig,
|
||||
};
|
||||
} else {
|
||||
console.error('Failed to get server config from settings:', settingsData);
|
||||
setError(t('server.invalidConfig', { serverName: server.name }));
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching server settings:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return null;
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleServerRemove = useCallback(async (serverName: string) => {
|
||||
try {
|
||||
const result = await apiDelete(`/servers/${serverName}`);
|
||||
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('server.deleteError', { serverName }));
|
||||
return false;
|
||||
}
|
||||
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(t('errors.general') + ': ' + (err instanceof Error ? err.message : String(err)));
|
||||
return false;
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleServerToggle = useCallback(async (server: Server, enabled: boolean) => {
|
||||
try {
|
||||
const result = await apiPost(`/servers/${server.name}/toggle`, { enabled });
|
||||
|
||||
if (!result || !result.success) {
|
||||
console.error('Failed to toggle server:', result);
|
||||
setError(result?.message || t('server.toggleError', { serverName: server.name }));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update the UI immediately to reflect the change
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error toggling server:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const value: ServerContextType = {
|
||||
servers,
|
||||
error,
|
||||
setError,
|
||||
isLoading: isInitialLoading,
|
||||
fetchAttempts,
|
||||
triggerRefresh,
|
||||
refreshIfNeeded,
|
||||
handleServerAdd,
|
||||
handleServerEdit,
|
||||
handleServerRemove,
|
||||
handleServerToggle,
|
||||
};
|
||||
|
||||
return <ServerContext.Provider value={value}>{children}</ServerContext.Provider>;
|
||||
};
|
||||
|
||||
// Custom hook to use the Server context
|
||||
export const useServerContext = () => {
|
||||
const context = useContext(ServerContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useServerContext must be used within a ServerProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,272 +1,19 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Server, ApiResponse } from '@/types';
|
||||
import { apiGet, apiPost, apiDelete } from '../utils/fetchInterceptor';
|
||||
// This hook now delegates to the ServerContext to avoid duplicate requests
|
||||
// All components will share the same server data and polling mechanism
|
||||
import { useServerContext } from '@/contexts/ServerContext';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
// Configuration options
|
||||
const CONFIG = {
|
||||
// Initialization phase configuration
|
||||
startup: {
|
||||
maxAttempts: 60, // Maximum number of attempts during initialization
|
||||
pollingInterval: 3000, // Polling interval during initialization (3 seconds)
|
||||
},
|
||||
// Normal operation phase configuration
|
||||
normal: {
|
||||
pollingInterval: 10000, // Polling interval during normal operation (10 seconds)
|
||||
},
|
||||
};
|
||||
|
||||
export const useServerData = () => {
|
||||
const { t } = useTranslation();
|
||||
const [servers, setServers] = useState<Server[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
const [fetchAttempts, setFetchAttempts] = useState(0);
|
||||
|
||||
// Timer reference for polling
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
// Track current attempt count to avoid dependency cycles
|
||||
const attemptsRef = useRef<number>(0);
|
||||
|
||||
// Clear the timer
|
||||
const clearTimer = () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Start normal polling
|
||||
const startNormalPolling = useCallback(() => {
|
||||
// Ensure no other timers are running
|
||||
clearTimer();
|
||||
|
||||
const fetchServers = async () => {
|
||||
try {
|
||||
const data = await apiGet('/servers');
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data);
|
||||
} else if (data && Array.isArray(data)) {
|
||||
setServers(data);
|
||||
} else {
|
||||
console.error('Invalid server data format:', data);
|
||||
setServers([]);
|
||||
}
|
||||
|
||||
// Reset error state
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error fetching servers during normal polling:', err);
|
||||
|
||||
// Use friendly error message
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'));
|
||||
} else if (
|
||||
err instanceof TypeError &&
|
||||
(err.message.includes('NetworkError') || err.message.includes('Failed to fetch'))
|
||||
) {
|
||||
setError(t('errors.serverConnection'));
|
||||
} else {
|
||||
setError(t('errors.serverFetch'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Execute immediately
|
||||
fetchServers();
|
||||
|
||||
// Set up regular polling
|
||||
intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval);
|
||||
}, [t]);
|
||||
export const useServerData = (options?: { refreshOnMount?: boolean }) => {
|
||||
const context = useServerContext();
|
||||
const { refreshIfNeeded } = context;
|
||||
|
||||
// Optionally refresh on mount for pages that need fresh data
|
||||
useEffect(() => {
|
||||
// Reset attempt count
|
||||
if (refreshKey > 0) {
|
||||
attemptsRef.current = 0;
|
||||
setFetchAttempts(0);
|
||||
if (options?.refreshOnMount) {
|
||||
refreshIfNeeded();
|
||||
}
|
||||
}, [options?.refreshOnMount, refreshIfNeeded]);
|
||||
|
||||
// Initialization phase request function
|
||||
const fetchInitialData = async () => {
|
||||
try {
|
||||
const data = await apiGet('/servers');
|
||||
|
||||
// Handle API response wrapper object, extract data field
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data);
|
||||
setIsInitialLoading(false);
|
||||
// Initialization successful, start normal polling
|
||||
startNormalPolling();
|
||||
return true;
|
||||
} else if (data && Array.isArray(data)) {
|
||||
// Compatibility handling, if API directly returns array
|
||||
setServers(data);
|
||||
setIsInitialLoading(false);
|
||||
// Initialization successful, start normal polling
|
||||
startNormalPolling();
|
||||
return true;
|
||||
} else {
|
||||
// If data format is not as expected, set to empty array
|
||||
console.error('Invalid server data format:', data);
|
||||
setServers([]);
|
||||
setIsInitialLoading(false);
|
||||
// Initialization successful but data is empty, start normal polling
|
||||
startNormalPolling();
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
// Increment attempt count, use ref to avoid triggering effect rerun
|
||||
attemptsRef.current += 1;
|
||||
console.error(`Initial loading attempt ${attemptsRef.current} failed:`, err);
|
||||
|
||||
// Update state for display
|
||||
setFetchAttempts(attemptsRef.current);
|
||||
|
||||
// Set appropriate error message
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'));
|
||||
} else {
|
||||
setError(t('errors.initialStartup'));
|
||||
}
|
||||
|
||||
// If maximum attempt count is exceeded, give up initialization and switch to normal polling
|
||||
if (attemptsRef.current >= CONFIG.startup.maxAttempts) {
|
||||
console.log('Maximum startup attempts reached, switching to normal polling');
|
||||
setIsInitialLoading(false);
|
||||
// Clear initialization polling
|
||||
clearTimer();
|
||||
// Switch to normal polling mode
|
||||
startNormalPolling();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// On component mount, set appropriate polling based on current state
|
||||
if (isInitialLoading) {
|
||||
// Ensure no other timers are running
|
||||
clearTimer();
|
||||
|
||||
// Execute initial request immediately
|
||||
fetchInitialData();
|
||||
|
||||
// Set polling interval for initialization phase
|
||||
intervalRef.current = setInterval(fetchInitialData, CONFIG.startup.pollingInterval);
|
||||
console.log(`Started initial polling with interval: ${CONFIG.startup.pollingInterval}ms`);
|
||||
} else {
|
||||
// Initialization completed, start normal polling
|
||||
startNormalPolling();
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
clearTimer();
|
||||
};
|
||||
}, [refreshKey, t, isInitialLoading, startNormalPolling]);
|
||||
|
||||
// Manually trigger refresh
|
||||
const triggerRefresh = () => {
|
||||
// Clear current timer
|
||||
clearTimer();
|
||||
|
||||
// If in initialization phase, reset initialization state
|
||||
if (isInitialLoading) {
|
||||
setIsInitialLoading(true);
|
||||
attemptsRef.current = 0;
|
||||
setFetchAttempts(0);
|
||||
}
|
||||
|
||||
// Change in refreshKey will trigger useEffect to run again
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
};
|
||||
|
||||
// Server related operations
|
||||
const handleServerAdd = () => {
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
};
|
||||
|
||||
const handleServerEdit = async (server: Server) => {
|
||||
try {
|
||||
// Fetch settings to get the full server config before editing
|
||||
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> =
|
||||
await apiGet('/settings');
|
||||
|
||||
if (
|
||||
settingsData &&
|
||||
settingsData.success &&
|
||||
settingsData.data &&
|
||||
settingsData.data.mcpServers &&
|
||||
settingsData.data.mcpServers[server.name]
|
||||
) {
|
||||
const serverConfig = settingsData.data.mcpServers[server.name];
|
||||
return {
|
||||
name: server.name,
|
||||
status: server.status,
|
||||
tools: server.tools || [],
|
||||
config: serverConfig,
|
||||
};
|
||||
} else {
|
||||
console.error('Failed to get server config from settings:', settingsData);
|
||||
setError(t('server.invalidConfig', { serverName: server.name }));
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching server settings:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleServerRemove = async (serverName: string) => {
|
||||
try {
|
||||
const result = await apiDelete(`/servers/${serverName}`);
|
||||
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('server.deleteError', { serverName }));
|
||||
return false;
|
||||
}
|
||||
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(t('errors.general') + ': ' + (err instanceof Error ? err.message : String(err)));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleServerToggle = async (server: Server, enabled: boolean) => {
|
||||
try {
|
||||
const result = await apiPost(`/servers/${server.name}/toggle`, { enabled });
|
||||
|
||||
if (!result || !result.success) {
|
||||
console.error('Failed to toggle server:', result);
|
||||
setError(result?.message || t('server.toggleError', { serverName: server.name }));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update the UI immediately to reflect the change
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error toggling server:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
servers,
|
||||
error,
|
||||
setError,
|
||||
isLoading: isInitialLoading,
|
||||
fetchAttempts,
|
||||
triggerRefresh,
|
||||
handleServerAdd,
|
||||
handleServerEdit,
|
||||
handleServerRemove,
|
||||
handleServerToggle,
|
||||
};
|
||||
return context;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
// Import shared translations from root locales directory
|
||||
import enTranslation from '../../locales/en.json';
|
||||
import zhTranslation from '../../locales/zh.json';
|
||||
import frTranslation from '../../locales/fr.json';
|
||||
|
||||
i18n
|
||||
// Detect user language
|
||||
@@ -20,6 +21,9 @@ i18n
|
||||
zh: {
|
||||
translation: zhTranslation,
|
||||
},
|
||||
fr: {
|
||||
translation: frTranslation,
|
||||
},
|
||||
},
|
||||
fallbackLng: 'en',
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useServerData } from '@/hooks/useServerData';
|
||||
import { Server } from '@/types';
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { servers, error, setError, isLoading } = useServerData();
|
||||
const { servers, error, setError, isLoading } = useServerData({ refreshOnMount: true });
|
||||
|
||||
// Calculate server statistics
|
||||
const serverStats = {
|
||||
total: servers.length,
|
||||
online: servers.filter(server => server.status === 'connected').length,
|
||||
offline: servers.filter(server => server.status === 'disconnected').length,
|
||||
connecting: servers.filter(server => server.status === 'connecting').length
|
||||
online: servers.filter((server: Server) => server.status === 'connected').length,
|
||||
offline: servers.filter((server: Server) => server.status === 'disconnected').length,
|
||||
connecting: servers.filter((server: Server) => server.status === 'connecting').length
|
||||
};
|
||||
|
||||
// Map status to translation keys
|
||||
const statusTranslations = {
|
||||
const statusTranslations: Record<string, string> = {
|
||||
connected: 'status.online',
|
||||
disconnected: 'status.offline',
|
||||
connecting: 'status.connecting'
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -17,7 +17,7 @@ const GroupsPage: React.FC = () => {
|
||||
deleteGroup,
|
||||
triggerRefresh
|
||||
} = useGroupData();
|
||||
const { servers } = useServerData();
|
||||
const { servers } = useServerData({ refreshOnMount: true });
|
||||
|
||||
const [editingGroup, setEditingGroup] = useState<Group | null>(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -21,7 +21,7 @@ const ServersPage: React.FC = () => {
|
||||
handleServerRemove,
|
||||
handleServerToggle,
|
||||
triggerRefresh
|
||||
} = useServerData();
|
||||
} = useServerData({ refreshOnMount: true });
|
||||
const [editingServer, setEditingServer] = useState<Server | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [showDxtUpload, setShowDxtUpload] = useState(false);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -93,6 +93,10 @@
|
||||
"updateError": "Failed to update server",
|
||||
"editTitle": "Edit Server: {{serverName}}",
|
||||
"type": "Server Type",
|
||||
"typeStdio": "STDIO",
|
||||
"typeSse": "SSE",
|
||||
"typeStreamableHttp": "Streamable HTTP",
|
||||
"typeOpenapi": "OpenAPI",
|
||||
"command": "Command",
|
||||
"arguments": "Arguments",
|
||||
"envVars": "Environment Variables",
|
||||
@@ -145,11 +149,19 @@
|
||||
"httpAuthConfig": "HTTP Authentication Configuration",
|
||||
"httpScheme": "Authentication Scheme",
|
||||
"httpCredentials": "Credentials",
|
||||
"httpSchemeBasic": "Basic",
|
||||
"httpSchemeBearer": "Bearer",
|
||||
"httpSchemeDigest": "Digest",
|
||||
"oauth2Config": "OAuth 2.0 Configuration",
|
||||
"oauth2Token": "Access Token",
|
||||
"openIdConnectConfig": "OpenID Connect Configuration",
|
||||
"openIdConnectUrl": "Discovery URL",
|
||||
"openIdConnectToken": "ID Token"
|
||||
"openIdConnectToken": "ID Token",
|
||||
"apiKeyInHeader": "Header",
|
||||
"apiKeyInQuery": "Query",
|
||||
"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": {
|
||||
@@ -190,7 +202,13 @@
|
||||
"copyFailed": "Copy failed",
|
||||
"close": "Close",
|
||||
"confirm": "Confirm",
|
||||
"language": "Language"
|
||||
"language": "Language",
|
||||
"true": "True",
|
||||
"false": "False",
|
||||
"dismiss": "Dismiss",
|
||||
"github": "GitHub",
|
||||
"wechat": "WeChat",
|
||||
"discord": "Discord"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -480,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",
|
||||
|
||||
627
locales/fr.json
Normal file
627
locales/fr.json
Normal file
@@ -0,0 +1,627 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "Tableau de bord MCPHub",
|
||||
"error": "Erreur",
|
||||
"closeButton": "Fermer",
|
||||
"noServers": "Aucun serveur MCP disponible",
|
||||
"loading": "Chargement...",
|
||||
"logout": "Déconnexion",
|
||||
"profile": "Profil",
|
||||
"changePassword": "Changer le mot de passe",
|
||||
"toggleSidebar": "Basculer la barre latérale",
|
||||
"welcomeUser": "Bienvenue, {{username}}",
|
||||
"name": "MCPHub"
|
||||
},
|
||||
"about": {
|
||||
"title": "À propos",
|
||||
"versionInfo": "Version MCPHub : {{version}}",
|
||||
"newVersion": "Nouvelle version disponible !",
|
||||
"currentVersion": "Version actuelle",
|
||||
"newVersionAvailable": "La nouvelle version {{version}} est disponible",
|
||||
"viewOnGitHub": "Voir sur GitHub",
|
||||
"checkForUpdates": "Vérifier les mises à jour",
|
||||
"checking": "Vérification des mises à jour..."
|
||||
},
|
||||
"profile": {
|
||||
"viewProfile": "Voir le profil",
|
||||
"userCenter": "Centre utilisateur"
|
||||
},
|
||||
"sponsor": {
|
||||
"label": "Sponsor",
|
||||
"title": "Soutenir le projet",
|
||||
"rewardAlt": "QR Code de récompense",
|
||||
"supportMessage": "Soutenez le développement de MCPHub en m'offrant un café !",
|
||||
"supportButton": "Soutenir sur Ko-fi"
|
||||
},
|
||||
"wechat": {
|
||||
"label": "WeChat",
|
||||
"title": "Se connecter via WeChat",
|
||||
"qrCodeAlt": "QR Code WeChat",
|
||||
"scanMessage": "Scannez ce QR code pour nous contacter sur WeChat"
|
||||
},
|
||||
"discord": {
|
||||
"label": "Discord",
|
||||
"title": "Rejoignez notre serveur Discord",
|
||||
"community": "Rejoignez notre communauté grandissante sur Discord pour du support, des discussions et des mises à jour !"
|
||||
},
|
||||
"theme": {
|
||||
"title": "Thème",
|
||||
"light": "Clair",
|
||||
"dark": "Sombre",
|
||||
"system": "Système"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Connexion",
|
||||
"loginTitle": "Se connecter à MCPHub",
|
||||
"slogan": "Le Hub unifié pour les serveurs MCP",
|
||||
"subtitle": "Plateforme de gestion centralisée pour les serveurs Model Context Protocol. Organisez, surveillez et mettez à l'échelle plusieurs serveurs MCP avec des stratégies de routage flexibles.",
|
||||
"username": "Nom d'utilisateur",
|
||||
"password": "Mot de passe",
|
||||
"loggingIn": "Connexion en cours...",
|
||||
"emptyFields": "Le nom d'utilisateur et le mot de passe ne peuvent pas être vides",
|
||||
"loginFailed": "Échec de la connexion, veuillez vérifier votre nom d'utilisateur et votre mot de passe",
|
||||
"loginError": "Une erreur est survenue lors de la connexion",
|
||||
"currentPassword": "Mot de passe actuel",
|
||||
"newPassword": "Nouveau mot de passe",
|
||||
"confirmPassword": "Confirmer le mot de passe",
|
||||
"passwordsNotMatch": "Le nouveau mot de passe et la confirmation ne correspondent pas",
|
||||
"changePasswordSuccess": "Mot de passe changé avec succès",
|
||||
"changePasswordError": "Échec du changement de mot de passe",
|
||||
"changePassword": "Changer le mot de passe",
|
||||
"passwordChanged": "Mot de passe changé avec succès",
|
||||
"passwordChangeError": "Échec du changement de mot de passe"
|
||||
},
|
||||
"server": {
|
||||
"addServer": "Ajouter un serveur",
|
||||
"add": "Ajouter",
|
||||
"edit": "Modifier",
|
||||
"delete": "Supprimer",
|
||||
"confirmDelete": "Êtes-vous sûr de vouloir supprimer ce serveur ?",
|
||||
"deleteWarning": "La suppression du serveur '{{name}}' le supprimera ainsi que toutes ses données. Cette action est irréversible.",
|
||||
"status": "Statut",
|
||||
"tools": "Outils",
|
||||
"prompts": "Invites",
|
||||
"name": "Nom du serveur",
|
||||
"url": "URL du serveur",
|
||||
"apiKey": "Clé API",
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
"invalidConfig": "Impossible de trouver les données de configuration pour {{serverName}}",
|
||||
"addError": "Échec de l'ajout du serveur",
|
||||
"editError": "Échec de la modification du serveur {{serverName}}",
|
||||
"deleteError": "Échec de la suppression du serveur {{serverName}}",
|
||||
"updateError": "Échec de la mise à jour du serveur",
|
||||
"editTitle": "Modifier le serveur : {{serverName}}",
|
||||
"type": "Type de serveur",
|
||||
"typeStdio": "STDIO",
|
||||
"typeSse": "SSE",
|
||||
"typeStreamableHttp": "HTTP diffusable",
|
||||
"typeOpenapi": "OpenAPI",
|
||||
"command": "Commande",
|
||||
"arguments": "Arguments",
|
||||
"envVars": "Variables d'environnement",
|
||||
"headers": "En-têtes HTTP",
|
||||
"key": "clé",
|
||||
"value": "valeur",
|
||||
"enabled": "Activé",
|
||||
"enable": "Activer",
|
||||
"disable": "Désactiver",
|
||||
"requestOptions": "Configuration",
|
||||
"timeout": "Délai d'attente de la requête",
|
||||
"timeoutDescription": "Délai d'attente pour les requêtes vers le serveur MCP (ms)",
|
||||
"maxTotalTimeout": "Délai d'attente total maximum",
|
||||
"maxTotalTimeoutDescription": "Délai d'attente total maximum pour les requêtes envoyées au serveur MCP (ms) (à utiliser avec les notifications de progression)",
|
||||
"resetTimeoutOnProgress": "Réinitialiser le délai d'attente en cas de progression",
|
||||
"resetTimeoutOnProgressDescription": "Réinitialiser le délai d'attente lors des notifications de progression",
|
||||
"remove": "Retirer",
|
||||
"toggleError": "Échec du basculement du serveur {{serverName}}",
|
||||
"alreadyExists": "Le serveur {{serverName}} existe déjà",
|
||||
"invalidData": "Données de serveur invalides fournies",
|
||||
"notFound": "Serveur {{serverName}} non trouvé",
|
||||
"namePlaceholder": "Entrez le nom du serveur",
|
||||
"urlPlaceholder": "Entrez l'URL du serveur",
|
||||
"commandPlaceholder": "Entrez la commande",
|
||||
"argumentsPlaceholder": "Entrez les arguments",
|
||||
"errorDetails": "Détails de l'erreur",
|
||||
"viewErrorDetails": "Voir les détails de l'erreur",
|
||||
"confirmVariables": "Confirmer la configuration des variables",
|
||||
"variablesDetected": "Variables détectées dans la configuration. Veuillez confirmer que ces variables sont correctement configurées :",
|
||||
"detectedVariables": "Variables détectées",
|
||||
"confirmVariablesMessage": "Veuillez vous assurer que ces variables sont correctement définies dans votre environnement d'exécution. Continuer l'ajout du serveur ?",
|
||||
"confirmAndAdd": "Confirmer et ajouter",
|
||||
"openapi": {
|
||||
"inputMode": "Mode de saisie",
|
||||
"inputModeUrl": "URL de la spécification",
|
||||
"inputModeSchema": "Schéma JSON",
|
||||
"specUrl": "URL de la spécification OpenAPI",
|
||||
"schema": "Schéma JSON OpenAPI",
|
||||
"schemaHelp": "Collez votre schéma JSON OpenAPI complet ici",
|
||||
"security": "Type de sécurité",
|
||||
"securityNone": "Aucun",
|
||||
"securityApiKey": "Clé API",
|
||||
"securityHttp": "Authentification HTTP",
|
||||
"securityOAuth2": "OAuth 2.0",
|
||||
"securityOpenIdConnect": "OpenID Connect",
|
||||
"apiKeyConfig": "Configuration de la clé API",
|
||||
"apiKeyName": "Nom de l'en-tête/paramètre",
|
||||
"apiKeyIn": "Emplacement",
|
||||
"apiKeyValue": "Valeur de la clé API",
|
||||
"httpAuthConfig": "Configuration de l'authentification HTTP",
|
||||
"httpScheme": "Schéma d'authentification",
|
||||
"httpCredentials": "Identifiants",
|
||||
"httpSchemeBasic": "Basic",
|
||||
"httpSchemeBearer": "Bearer",
|
||||
"httpSchemeDigest": "Digest",
|
||||
"oauth2Config": "Configuration OAuth 2.0",
|
||||
"oauth2Token": "Jeton d'accès",
|
||||
"openIdConnectConfig": "Configuration OpenID Connect",
|
||||
"openIdConnectUrl": "URL de découverte",
|
||||
"openIdConnectToken": "Jeton d'identification",
|
||||
"apiKeyInHeader": "En-tête",
|
||||
"apiKeyInQuery": "Requête",
|
||||
"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": {
|
||||
"online": "En ligne",
|
||||
"offline": "Hors ligne",
|
||||
"connecting": "Connexion en cours"
|
||||
},
|
||||
"errors": {
|
||||
"general": "Une erreur est survenue",
|
||||
"network": "Erreur de connexion réseau. Veuillez vérifier votre connexion Internet",
|
||||
"serverConnection": "Impossible de se connecter au serveur. Veuillez vérifier si le serveur est en cours d'exécution",
|
||||
"serverAdd": "Échec de l'ajout du serveur. Veuillez vérifier l'état du serveur",
|
||||
"serverUpdate": "Échec de la modification du serveur {{serverName}}. Veuillez vérifier l'état du serveur",
|
||||
"serverFetch": "Échec de la récupération des données du serveur. Veuillez réessayer plus tard",
|
||||
"initialStartup": "Le serveur est peut-être en cours de démarrage. Veuillez patienter un instant car ce processus peut prendre du temps au premier lancement...",
|
||||
"serverInstall": "Échec de l'installation du serveur",
|
||||
"failedToFetchSettings": "Échec de la récupération des paramètres",
|
||||
"failedToUpdateRouteConfig": "Échec de la mise à jour de la configuration de routage",
|
||||
"failedToUpdateSmartRoutingConfig": "Échec de la mise à jour de la configuration du routage intelligent"
|
||||
},
|
||||
"common": {
|
||||
"processing": "En cours de traitement...",
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
"refresh": "Actualiser",
|
||||
"create": "Créer",
|
||||
"creating": "Création en cours...",
|
||||
"update": "Mettre à jour",
|
||||
"updating": "Mise à jour en cours...",
|
||||
"submitting": "Envoi en cours...",
|
||||
"delete": "Supprimer",
|
||||
"remove": "Retirer",
|
||||
"copy": "Copier",
|
||||
"copyId": "Copier l'ID",
|
||||
"copyUrl": "Copier l'URL",
|
||||
"copyJson": "Copier le JSON",
|
||||
"copySuccess": "Copié dans le presse-papiers",
|
||||
"copyFailed": "Échec de la copie",
|
||||
"close": "Fermer",
|
||||
"confirm": "Confirmer",
|
||||
"language": "Langue",
|
||||
"true": "Vrai",
|
||||
"false": "Faux",
|
||||
"dismiss": "Rejeter",
|
||||
"github": "GitHub",
|
||||
"wechat": "WeChat",
|
||||
"discord": "Discord"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Tableau de bord",
|
||||
"servers": "Serveurs",
|
||||
"groups": "Groupes",
|
||||
"users": "Utilisateurs",
|
||||
"settings": "Paramètres",
|
||||
"changePassword": "Changer le mot de passe",
|
||||
"market": "Marché",
|
||||
"cloud": "Marché Cloud",
|
||||
"logs": "Journaux"
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
"totalServers": "Total",
|
||||
"onlineServers": "En ligne",
|
||||
"offlineServers": "Hors ligne",
|
||||
"connectingServers": "En connexion",
|
||||
"recentServers": "Serveurs récents"
|
||||
},
|
||||
"servers": {
|
||||
"title": "Gestion des serveurs"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Gestion des groupes"
|
||||
},
|
||||
"users": {
|
||||
"title": "Gestion des utilisateurs"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Paramètres",
|
||||
"language": "Langue",
|
||||
"account": "Paramètres du compte",
|
||||
"password": "Changer le mot de passe",
|
||||
"appearance": "Apparence",
|
||||
"routeConfig": "Sécurité",
|
||||
"installConfig": "Installation",
|
||||
"smartRouting": "Routage intelligent"
|
||||
},
|
||||
"market": {
|
||||
"title": "Marché Hub - Marchés locaux et Cloud"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Journaux système"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
"filters": "Filtres",
|
||||
"search": "Rechercher dans les journaux...",
|
||||
"autoScroll": "Défilement automatique",
|
||||
"clearLogs": "Effacer les journaux",
|
||||
"loading": "Chargement des journaux...",
|
||||
"noLogs": "Aucun journal disponible.",
|
||||
"noMatch": "Aucun journal ne correspond aux filtres actuels.",
|
||||
"mainProcess": "Processus principal",
|
||||
"childProcess": "Processus enfant",
|
||||
"main": "Principal",
|
||||
"child": "Enfant"
|
||||
},
|
||||
"groups": {
|
||||
"add": "Ajouter",
|
||||
"addNew": "Ajouter un nouveau groupe",
|
||||
"edit": "Modifier le groupe",
|
||||
"delete": "Supprimer",
|
||||
"confirmDelete": "Êtes-vous sûr de vouloir supprimer ce groupe ?",
|
||||
"deleteWarning": "La suppression du groupe '{{name}}' le supprimera ainsi que toutes ses associations de serveurs. Cette action est irréversible.",
|
||||
"name": "Nom du groupe",
|
||||
"namePlaceholder": "Entrez le nom du groupe",
|
||||
"nameRequired": "Le nom du groupe est requis",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Entrez la description du groupe (facultatif)",
|
||||
"createError": "Échec de la création du groupe",
|
||||
"updateError": "Échec de la mise à jour du groupe",
|
||||
"deleteError": "Échec de la suppression du groupe",
|
||||
"serverAddError": "Échec de l'ajout du serveur au groupe",
|
||||
"serverRemoveError": "Échec de la suppression du serveur du groupe",
|
||||
"addServer": "Ajouter un serveur au groupe",
|
||||
"selectServer": "Sélectionnez un serveur à ajouter",
|
||||
"servers": "Serveurs dans le groupe",
|
||||
"remove": "Retirer",
|
||||
"noGroups": "Aucun groupe disponible. Créez un nouveau groupe pour commencer.",
|
||||
"noServers": "Aucun serveur dans ce groupe.",
|
||||
"noServerOptions": "Aucun serveur disponible",
|
||||
"serverCount": "{{count}} serveurs",
|
||||
"toolSelection": "Sélection d'outils",
|
||||
"toolsSelected": "Sélectionné",
|
||||
"allTools": "Tous",
|
||||
"selectedTools": "Outils sélectionnés",
|
||||
"selectAll": "Tout sélectionner",
|
||||
"selectNone": "Ne rien sélectionner",
|
||||
"configureTools": "Configurer les outils"
|
||||
},
|
||||
"market": {
|
||||
"title": "Installation locale",
|
||||
"official": "Officiel",
|
||||
"by": "Par",
|
||||
"unknown": "Inconnu",
|
||||
"tools": "outils",
|
||||
"search": "Rechercher",
|
||||
"searchPlaceholder": "Rechercher des serveurs par nom, catégorie ou tags",
|
||||
"clearFilters": "Effacer",
|
||||
"clearCategoryFilter": "",
|
||||
"clearTagFilter": "",
|
||||
"categories": "Catégories",
|
||||
"tags": "Tags",
|
||||
"showTags": "Afficher les tags",
|
||||
"hideTags": "Masquer les tags",
|
||||
"moreTags": "",
|
||||
"noServers": "Aucun serveur trouvé correspondant à votre recherche",
|
||||
"backToList": "Retour à la liste",
|
||||
"install": "Installer",
|
||||
"installing": "Installation en cours...",
|
||||
"installed": "Installé",
|
||||
"installServer": "Installer le serveur : {{name}}",
|
||||
"installSuccess": "Serveur {{serverName}} installé avec succès",
|
||||
"author": "Auteur",
|
||||
"license": "Licence",
|
||||
"repository": "Dépôt",
|
||||
"examples": "Exemples",
|
||||
"arguments": "Arguments",
|
||||
"argumentName": "Nom",
|
||||
"description": "Description",
|
||||
"required": "Requis",
|
||||
"example": "Exemple",
|
||||
"viewSchema": "Voir le schéma",
|
||||
"fetchError": "Erreur lors de la récupération des serveurs du marché",
|
||||
"serverNotFound": "Serveur non trouvé",
|
||||
"searchError": "Erreur lors de la recherche de serveurs",
|
||||
"filterError": "Erreur lors du filtrage des serveurs par catégorie",
|
||||
"tagFilterError": "Erreur lors du filtrage des serveurs par tag",
|
||||
"noInstallationMethod": "Aucune méthode d'installation disponible pour ce serveur",
|
||||
"showing": "Affichage de {{from}}-{{to}} sur {{total}} serveurs",
|
||||
"perPage": "Par page",
|
||||
"confirmVariablesMessage": "Veuillez vous assurer que ces variables sont correctement définies dans votre environnement d'exécution. Continuer l'installation du serveur ?",
|
||||
"confirmAndInstall": "Confirmer et installer"
|
||||
},
|
||||
"cloud": {
|
||||
"title": "Support Cloud",
|
||||
"subtitle": "Propulsé par MCPRouter",
|
||||
"by": "Par",
|
||||
"server": "Serveur",
|
||||
"config": "Config",
|
||||
"created": "Créé",
|
||||
"updated": "Mis à jour",
|
||||
"available": "Disponible",
|
||||
"description": "Description",
|
||||
"details": "Détails",
|
||||
"tools": "Outils",
|
||||
"tool": "outil",
|
||||
"toolsAvailable": "{{count}} outil disponible||{{count}} outils disponibles",
|
||||
"loadingTools": "Chargement des outils...",
|
||||
"noTools": "Aucun outil disponible pour ce serveur",
|
||||
"noDescription": "Aucune description disponible",
|
||||
"viewDetails": "Voir les détails",
|
||||
"parameters": "Paramètres",
|
||||
"result": "Résultat",
|
||||
"error": "Erreur",
|
||||
"callTool": "Appeler",
|
||||
"calling": "Appel en cours...",
|
||||
"toolCallSuccess": "L'outil {{toolName}} a été exécuté avec succès",
|
||||
"toolCallError": "Échec de l'appel de l'outil {{toolName}} : {{error}}",
|
||||
"viewSchema": "Voir le schéma",
|
||||
"backToList": "Retour au marché Cloud",
|
||||
"search": "Rechercher",
|
||||
"searchPlaceholder": "Rechercher des serveurs cloud par nom, titre ou auteur",
|
||||
"clearFilters": "Effacer les filtres",
|
||||
"clearCategoryFilter": "Effacer",
|
||||
"clearTagFilter": "Effacer",
|
||||
"categories": "Catégories",
|
||||
"tags": "Tags",
|
||||
"noCategories": "Aucune catégorie trouvée",
|
||||
"noTags": "Aucun tag trouvé",
|
||||
"noServers": "Aucun serveur cloud trouvé",
|
||||
"fetchError": "Erreur lors de la récupération des serveurs cloud",
|
||||
"serverNotFound": "Serveur cloud non trouvé",
|
||||
"searchError": "Erreur lors de la recherche de serveurs cloud",
|
||||
"filterError": "Erreur lors du filtrage des serveurs cloud par catégorie",
|
||||
"tagFilterError": "Erreur lors du filtrage des serveurs cloud par tag",
|
||||
"showing": "Affichage de {{from}}-{{to}} sur {{total}} serveurs cloud",
|
||||
"perPage": "Par page",
|
||||
"apiKeyNotConfigured": "Clé API MCPRouter non configurée",
|
||||
"apiKeyNotConfiguredDescription": "Pour utiliser les serveurs cloud, vous devez configurer votre clé API MCPRouter.",
|
||||
"getApiKey": "Obtenir une clé API",
|
||||
"configureInSettings": "Configurer dans les paramètres",
|
||||
"installServer": "Installer {{name}}",
|
||||
"installSuccess": "Serveur {{name}} installé avec succès",
|
||||
"installError": "Échec de l'installation du serveur : {{error}}"
|
||||
},
|
||||
"tool": {
|
||||
"run": "Exécuter",
|
||||
"running": "Exécution en cours...",
|
||||
"runTool": "Exécuter l'outil",
|
||||
"cancel": "Annuler",
|
||||
"noDescription": "Aucune description disponible",
|
||||
"inputSchema": "Schéma d'entrée :",
|
||||
"runToolWithName": "Exécuter l'outil : {{name}}",
|
||||
"execution": "Exécution de l'outil",
|
||||
"successful": "Réussi",
|
||||
"failed": "Échoué",
|
||||
"result": "Résultat :",
|
||||
"error": "Erreur",
|
||||
"errorDetails": "Détails de l'erreur :",
|
||||
"noContent": "L'outil a été exécuté avec succès mais n'a renvoyé aucun contenu.",
|
||||
"unknownError": "Une erreur inconnue est survenue",
|
||||
"jsonResponse": "Réponse JSON :",
|
||||
"toolResult": "Résultat de l'outil",
|
||||
"noParameters": "Cet outil ne nécessite aucun paramètre.",
|
||||
"selectOption": "Sélectionnez une option",
|
||||
"enterValue": "Entrez la valeur {{type}}",
|
||||
"enabled": "Activé",
|
||||
"enableSuccess": "Outil {{name}} activé avec succès",
|
||||
"disableSuccess": "Outil {{name}} désactivé avec succès",
|
||||
"toggleFailed": "Échec du basculement de l'état de l'outil",
|
||||
"parameters": "Paramètres de l'outil",
|
||||
"formMode": "Mode formulaire",
|
||||
"jsonMode": "Mode JSON",
|
||||
"jsonConfiguration": "Configuration JSON",
|
||||
"invalidJsonFormat": "Format JSON invalide",
|
||||
"fixJsonBeforeSwitching": "Veuillez corriger le format JSON avant de passer en mode formulaire",
|
||||
"item": "Élément {{index}}",
|
||||
"addItem": "Ajouter un élément {{key}}",
|
||||
"enterKey": "Entrez {{key}}"
|
||||
},
|
||||
"prompt": {
|
||||
"run": "Obtenir",
|
||||
"running": "Obtention en cours...",
|
||||
"result": "Résultat de l'invite",
|
||||
"error": "Erreur de l'invite",
|
||||
"execution": "Exécution de l'invite",
|
||||
"successful": "Réussi",
|
||||
"failed": "Échoué",
|
||||
"errorDetails": "Détails de l'erreur :",
|
||||
"noContent": "L'invite a été exécutée avec succès mais n'a renvoyé aucun contenu.",
|
||||
"unknownError": "Une erreur inconnue est survenue",
|
||||
"jsonResponse": "Réponse JSON :",
|
||||
"description": "Description",
|
||||
"messages": "Messages",
|
||||
"noDescription": "Aucune description disponible",
|
||||
"runPromptWithName": "Obtenir l'invite : {{name}}"
|
||||
},
|
||||
"settings": {
|
||||
"enableGlobalRoute": "Activer la route globale",
|
||||
"enableGlobalRouteDescription": "Autoriser les connexions au point de terminaison /sse sans spécifier d'ID de groupe",
|
||||
"enableGroupNameRoute": "Activer la route par nom de groupe",
|
||||
"enableGroupNameRouteDescription": "Autoriser les connexions au point de terminaison /sse en utilisant les noms de groupe au lieu des ID de groupe",
|
||||
"enableBearerAuth": "Activer l'authentification Bearer",
|
||||
"enableBearerAuthDescription": "Exiger une authentification par jeton Bearer pour les requêtes MCP",
|
||||
"bearerAuthKey": "Clé d'authentification Bearer",
|
||||
"bearerAuthKeyDescription": "La clé d'authentification qui sera requise dans le jeton Bearer",
|
||||
"bearerAuthKeyPlaceholder": "Entrez la clé d'authentification Bearer",
|
||||
"skipAuth": "Ignorer l'authentification",
|
||||
"skipAuthDescription": "Contourner l'exigence de connexion pour l'accès au frontend et à l'API (DÉSACTIVÉ PAR DÉFAUT pour des raisons de sécurité)",
|
||||
"pythonIndexUrl": "URL du dépôt de paquets Python",
|
||||
"pythonIndexUrlDescription": "Définir la variable d'environnement UV_DEFAULT_INDEX pour l'installation de paquets Python",
|
||||
"pythonIndexUrlPlaceholder": "ex. https://pypi.org/simple",
|
||||
"npmRegistry": "URL du registre NPM",
|
||||
"npmRegistryDescription": "Définir la variable d'environnement npm_config_registry pour l'installation de paquets NPM",
|
||||
"npmRegistryPlaceholder": "ex. https://registry.npmjs.org/",
|
||||
"baseUrl": "URL de base",
|
||||
"baseUrlDescription": "URL de base pour les requêtes MCP",
|
||||
"baseUrlPlaceholder": "ex. http://localhost:3000",
|
||||
"installConfig": "Installation",
|
||||
"systemConfigUpdated": "Configuration système mise à jour avec succès",
|
||||
"enableSmartRouting": "Activer le routage intelligent",
|
||||
"enableSmartRoutingDescription": "Activer la fonctionnalité de routage intelligent pour rechercher l'outil le plus approprié en fonction de l'entrée (en utilisant le nom de groupe $smart)",
|
||||
"dbUrl": "URL PostgreSQL (nécessite le support de pgvector)",
|
||||
"dbUrlPlaceholder": "ex. postgresql://user:password@localhost:5432/dbname",
|
||||
"openaiApiBaseUrl": "URL de base de l'API OpenAI",
|
||||
"openaiApiBaseUrlPlaceholder": "https://api.openai.com/v1",
|
||||
"openaiApiKey": "Clé API OpenAI",
|
||||
"openaiApiKeyPlaceholder": "Entrez la clé API OpenAI",
|
||||
"openaiApiEmbeddingModel": "Modèle d'intégration OpenAI",
|
||||
"openaiApiEmbeddingModelPlaceholder": "text-embedding-3-small",
|
||||
"smartRoutingConfigUpdated": "Configuration du routage intelligent mise à jour avec succès",
|
||||
"smartRoutingRequiredFields": "L'URL de la base de données et la clé API OpenAI sont requises pour activer le routage intelligent",
|
||||
"smartRoutingValidationError": "Veuillez remplir les champs obligatoires avant d'activer le routage intelligent : {{fields}}",
|
||||
"mcpRouterConfig": "Marché Cloud",
|
||||
"mcpRouterApiKey": "Clé API MCPRouter",
|
||||
"mcpRouterApiKeyDescription": "Clé API pour accéder aux services du marché cloud MCPRouter",
|
||||
"mcpRouterApiKeyPlaceholder": "Entrez la clé API MCPRouter",
|
||||
"mcpRouterReferer": "Référent",
|
||||
"mcpRouterRefererDescription": "En-tête Referer pour les requêtes API MCPRouter",
|
||||
"mcpRouterRefererPlaceholder": "https://www.mcphubx.com",
|
||||
"mcpRouterTitle": "Titre",
|
||||
"mcpRouterTitleDescription": "En-tête Title pour les requêtes API MCPRouter",
|
||||
"mcpRouterTitlePlaceholder": "MCPHub",
|
||||
"mcpRouterBaseUrl": "URL de base",
|
||||
"mcpRouterBaseUrlDescription": "URL de base pour l'API MCPRouter",
|
||||
"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",
|
||||
"uploadTitle": "Télécharger l'extension DXT",
|
||||
"dropFileHere": "Déposez votre fichier .dxt ici",
|
||||
"orClickToSelect": "ou cliquez pour sélectionner depuis votre ordinateur",
|
||||
"invalidFileType": "Veuillez sélectionner un fichier .dxt valide",
|
||||
"noFileSelected": "Veuillez sélectionner un fichier .dxt à télécharger",
|
||||
"uploading": "Téléchargement en cours...",
|
||||
"uploadFailed": "Échec du téléchargement du fichier DXT",
|
||||
"installServer": "Installer le serveur MCP depuis DXT",
|
||||
"extensionInfo": "Informations sur l'extension",
|
||||
"name": "Nom",
|
||||
"version": "Version",
|
||||
"description": "Description",
|
||||
"author": "Auteur",
|
||||
"tools": "Outils",
|
||||
"serverName": "Nom du serveur",
|
||||
"serverNamePlaceholder": "Entrez un nom pour ce serveur",
|
||||
"install": "Installer",
|
||||
"installing": "Installation en cours...",
|
||||
"installFailed": "Échec de l'installation du serveur depuis DXT",
|
||||
"serverExistsTitle": "Le serveur existe déjà",
|
||||
"serverExistsConfirm": "Le serveur '{{serverName}}' existe déjà. Voulez-vous le remplacer par la nouvelle version ?",
|
||||
"override": "Remplacer"
|
||||
},
|
||||
"users": {
|
||||
"add": "Ajouter un utilisateur",
|
||||
"addNew": "Ajouter un nouvel utilisateur",
|
||||
"edit": "Modifier l'utilisateur",
|
||||
"delete": "Supprimer l'utilisateur",
|
||||
"create": "Créer un utilisateur",
|
||||
"update": "Mettre à jour l'utilisateur",
|
||||
"username": "Nom d'utilisateur",
|
||||
"password": "Mot de passe",
|
||||
"newPassword": "Nouveau mot de passe",
|
||||
"confirmPassword": "Confirmer le mot de passe",
|
||||
"adminRole": "Administrateur",
|
||||
"admin": "Admin",
|
||||
"user": "Utilisateur",
|
||||
"permissions": "Permissions",
|
||||
"adminPermissions": "Accès complet au système",
|
||||
"userPermissions": "Accès limité",
|
||||
"currentUser": "Vous",
|
||||
"noUsers": "Aucun utilisateur trouvé",
|
||||
"adminRequired": "Un accès administrateur est requis pour gérer les utilisateurs",
|
||||
"usernameRequired": "Le nom d'utilisateur est requis",
|
||||
"passwordRequired": "Le mot de passe est requis",
|
||||
"passwordTooShort": "Le mot de passe doit comporter au moins 6 caractères",
|
||||
"passwordMismatch": "Les mots de passe ne correspondent pas",
|
||||
"usernamePlaceholder": "Entrez le nom d'utilisateur",
|
||||
"passwordPlaceholder": "Entrez le mot de passe",
|
||||
"newPasswordPlaceholder": "Laissez vide pour conserver le mot de passe actuel",
|
||||
"confirmPasswordPlaceholder": "Confirmez le nouveau mot de passe",
|
||||
"createError": "Échec de la création de l'utilisateur",
|
||||
"updateError": "Échec de la mise à jour de l'utilisateur",
|
||||
"deleteError": "Échec de la suppression de l'utilisateur",
|
||||
"statsError": "Échec de la récupération des statistiques utilisateur",
|
||||
"deleteConfirmation": "Êtes-vous sûr de vouloir supprimer l'utilisateur '{{username}}' ? Cette action est irréversible.",
|
||||
"confirmDelete": "Supprimer l'utilisateur",
|
||||
"deleteWarning": "Êtes-vous sûr de vouloir supprimer l'utilisateur '{{username}}' ? Cette action est irréversible."
|
||||
},
|
||||
"api": {
|
||||
"errors": {
|
||||
"readonly": "Lecture seule pour l'environnement de démonstration",
|
||||
"invalid_credentials": "Nom d'utilisateur ou mot de passe invalide",
|
||||
"serverNameRequired": "Le nom du serveur est requis",
|
||||
"serverConfigRequired": "La configuration du serveur est requise",
|
||||
"serverConfigInvalid": "La configuration du serveur doit inclure une URL, une URL de spécification OpenAPI ou un schéma, ou une commande avec des arguments",
|
||||
"serverTypeInvalid": "Le type de serveur doit être l'un des suivants : stdio, sse, streamable-http, openapi",
|
||||
"urlRequiredForType": "L'URL est requise pour le type de serveur {{type}}",
|
||||
"openapiSpecRequired": "L'URL de la spécification OpenAPI ou le schéma est requis pour le type de serveur openapi",
|
||||
"headersInvalidFormat": "Les en-têtes doivent être un objet",
|
||||
"headersNotSupportedForStdio": "Les en-têtes ne sont pas pris en charge pour le type de serveur stdio",
|
||||
"serverNotFound": "Serveur non trouvé",
|
||||
"failedToRemoveServer": "Serveur non trouvé ou échec de la suppression",
|
||||
"internalServerError": "Erreur interne du serveur",
|
||||
"failedToGetServers": "Échec de la récupération des informations sur les serveurs",
|
||||
"failedToGetServerSettings": "Échec de la récupération des paramètres du serveur",
|
||||
"failedToGetServerConfig": "Échec de la récupération de la configuration du serveur",
|
||||
"failedToSaveSettings": "Échec de l'enregistrement des paramètres",
|
||||
"toolNameRequired": "Le nom du serveur et le nom de l'outil sont requis",
|
||||
"descriptionMustBeString": "La description doit être une chaîne de caractères",
|
||||
"groupIdRequired": "L'ID de groupe est requis",
|
||||
"groupNameRequired": "Le nom du groupe est requis",
|
||||
"groupNotFound": "Groupe non trouvé",
|
||||
"groupIdAndServerNameRequired": "L'ID de groupe和le nom du serveur sont requis",
|
||||
"groupOrServerNotFound": "Groupe ou serveur non trouvé",
|
||||
"toolsMustBeAllOrArray": "Les outils doivent être \"all\" ou un tableau de chaînes de caractères",
|
||||
"serverNameAndToolNameRequired": "Le nom du serveur et le nom de l'outil sont requis",
|
||||
"usernameRequired": "Le nom d'utilisateur est requis",
|
||||
"userNotFound": "Utilisateur non trouvé",
|
||||
"failedToGetUsers": "Échec de la récupération des informations sur les utilisateurs",
|
||||
"failedToGetUserInfo": "Échec de la récupération des informations sur l'utilisateur",
|
||||
"failedToGetUserStats": "Échec de la récupération des statistiques de l'utilisateur",
|
||||
"marketServerNameRequired": "Le nom du serveur du marché est requis",
|
||||
"marketServerNotFound": "Serveur du marché non trouvé",
|
||||
"failedToGetMarketServers": "Échec de la récupération des informations sur les serveurs du marché",
|
||||
"failedToGetMarketServer": "Échec de la récupération des informations sur le serveur du marché",
|
||||
"failedToGetMarketCategories": "Échec de la récupération des catégories du marché",
|
||||
"failedToGetMarketTags": "Échec de la récupération des tags du marché",
|
||||
"failedToSearchMarketServers": "Échec de la recherche des serveurs du marché",
|
||||
"failedToFilterMarketServers": "Échec du filtrage des serveurs du marché",
|
||||
"failedToProcessDxtFile": "Échec du traitement du fichier DXT"
|
||||
},
|
||||
"success": {
|
||||
"serverCreated": "Serveur créé avec succès",
|
||||
"serverUpdated": "Serveur mis à jour avec succès",
|
||||
"serverRemoved": "Serveur supprimé avec succès",
|
||||
"serverToggled": "État du serveur basculé avec succès",
|
||||
"toolToggled": "Outil {{name}} {{action}} avec succès",
|
||||
"toolDescriptionUpdated": "Description de l'outil {{name}} mise à jour avec succès",
|
||||
"systemConfigUpdated": "Configuration système mise à jour avec succès",
|
||||
"groupCreated": "Groupe créé avec succès",
|
||||
"groupUpdated": "Groupe mis à jour avec succès",
|
||||
"groupDeleted": "Groupe supprimé avec succès",
|
||||
"serverAddedToGroup": "Serveur ajouté au groupe avec succès",
|
||||
"serverRemovedFromGroup": "Serveur supprimé du groupe avec succès",
|
||||
"serverToolsUpdated": "Outils du serveur mis à jour avec succès"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,10 @@
|
||||
"updateError": "更新服务器失败",
|
||||
"editTitle": "编辑服务器: {{serverName}}",
|
||||
"type": "服务器类型",
|
||||
"typeStdio": "STDIO",
|
||||
"typeSse": "SSE",
|
||||
"typeStreamableHttp": "流式 HTTP",
|
||||
"typeOpenapi": "OpenAPI",
|
||||
"command": "命令",
|
||||
"arguments": "参数",
|
||||
"envVars": "环境变量",
|
||||
@@ -145,11 +149,19 @@
|
||||
"httpAuthConfig": "HTTP 认证配置",
|
||||
"httpScheme": "认证方案",
|
||||
"httpCredentials": "凭据",
|
||||
"httpSchemeBasic": "Basic",
|
||||
"httpSchemeBearer": "Bearer",
|
||||
"httpSchemeDigest": "Digest",
|
||||
"oauth2Config": "OAuth 2.0 配置",
|
||||
"oauth2Token": "访问令牌",
|
||||
"openIdConnectConfig": "OpenID Connect 配置",
|
||||
"openIdConnectUrl": "发现 URL",
|
||||
"openIdConnectToken": "ID 令牌"
|
||||
"openIdConnectToken": "ID 令牌",
|
||||
"apiKeyInHeader": "请求头",
|
||||
"apiKeyInQuery": "查询",
|
||||
"apiKeyInCookie": "Cookie",
|
||||
"passthroughHeaders": "透传请求头",
|
||||
"passthroughHeadersHelp": "要从工具调用请求透传到上游OpenAPI接口的请求头名称列表,用逗号分隔(如:Authorization, X-API-Key)"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
@@ -191,7 +203,13 @@
|
||||
"copyFailed": "复制失败",
|
||||
"close": "关闭",
|
||||
"confirm": "确认",
|
||||
"language": "语言"
|
||||
"language": "语言",
|
||||
"true": "是",
|
||||
"false": "否",
|
||||
"dismiss": "忽略",
|
||||
"github": "GitHub",
|
||||
"wechat": "微信",
|
||||
"discord": "Discord"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "仪表盘",
|
||||
@@ -482,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": "上传",
|
||||
|
||||
@@ -14,8 +14,10 @@
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"@playwright/mcp@latest",
|
||||
"--headless"
|
||||
]
|
||||
"--headless",
|
||||
"--isolated"
|
||||
],
|
||||
"perSession": true
|
||||
},
|
||||
"fetch": {
|
||||
"command": "uvx",
|
||||
|
||||
18
package.json
18
package.json
@@ -45,14 +45,14 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@apidevtools/swagger-parser": "^11.0.1",
|
||||
"@modelcontextprotocol/sdk": "^1.17.4",
|
||||
"@apidevtools/swagger-parser": "^12.0.0",
|
||||
"@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",
|
||||
@@ -60,6 +60,7 @@
|
||||
"dotenv-expand": "^12.0.2",
|
||||
"express": "^4.21.2",
|
||||
"express-validator": "^7.2.1",
|
||||
"i18next": "^25.5.0",
|
||||
"i18next-fs-backend": "^2.6.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.2",
|
||||
@@ -84,14 +85,14 @@
|
||||
"@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",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
@@ -99,11 +100,10 @@
|
||||
"clsx": "^2.1.1",
|
||||
"concurrently": "^9.2.0",
|
||||
"eslint": "^8.57.1",
|
||||
"i18next": "^24.2.3",
|
||||
"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-beta1",
|
||||
"jest-mock-extended": "4.0.0",
|
||||
"lucide-react": "^0.486.0",
|
||||
"next": "^15.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
|
||||
2392
pnpm-lock.yaml
generated
2392
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) {
|
||||
|
||||
@@ -42,8 +42,9 @@ export const loadOriginalSettings = (): McpSettings => {
|
||||
|
||||
console.log(`Loaded settings from ${settingsPath}`);
|
||||
return settings;
|
||||
} catch (error) {
|
||||
console.error(`Failed to load settings from ${settingsPath}:`, error);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`Failed to load settings from ${settingsPath}:`, errorMessage);
|
||||
const defaultSettings = { mcpServers: {}, users: [] };
|
||||
|
||||
// Cache default settings
|
||||
@@ -138,3 +139,8 @@ export const expandEnvVars = (value: string): string => {
|
||||
};
|
||||
|
||||
export default defaultConfig;
|
||||
|
||||
export function getNameSeparator(): string {
|
||||
const settings = loadSettings();
|
||||
return settings.systemConfig?.nameSeparator || '-';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { McpSettings } from '../../types/index.js';
|
||||
import { getSettingsPath } from '../../config/index.js';
|
||||
import { getSettingsPath, clearSettingsCache } from '../../config/index.js';
|
||||
|
||||
/**
|
||||
* Abstract base class for JSON file-based DAO implementations
|
||||
@@ -67,6 +67,8 @@ export abstract class JsonFileBaseDao {
|
||||
// Update cache
|
||||
this.settingsCache = settings;
|
||||
this.lastModified = Date.now();
|
||||
|
||||
clearSettingsCache();
|
||||
} catch (error) {
|
||||
console.error(`Failed to save settings:`, error);
|
||||
throw error;
|
||||
@@ -79,6 +81,7 @@ export abstract class JsonFileBaseDao {
|
||||
protected clearCache(): void {
|
||||
this.settingsCache = null;
|
||||
this.lastModified = 0;
|
||||
clearSettingsCache();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -196,6 +196,8 @@ export const initRoutes = (app: express.Application): void => {
|
||||
// OpenAPI-compatible tool execution endpoints
|
||||
app.get(`${config.basePath}/api/tools/:serverName/:toolName`, executeToolViaOpenAPI);
|
||||
app.post(`${config.basePath}/api/tools/:serverName/:toolName`, executeToolViaOpenAPI);
|
||||
app.get(`${config.basePath}/api/:name/tools/:serverName/:toolName`, executeToolViaOpenAPI);
|
||||
app.post(`${config.basePath}/api/:name/tools/:serverName/:toolName`, executeToolViaOpenAPI);
|
||||
|
||||
app.use(`${config.basePath}/api`, router);
|
||||
};
|
||||
|
||||
72
src/services/dataServicex.ts
Normal file
72
src/services/dataServicex.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { IUser, McpSettings, UserConfig } from '../types/index.js';
|
||||
import { DataService } from './dataService.js';
|
||||
import { UserContextService } from './userContextService.js';
|
||||
|
||||
export class DataServicex implements DataService {
|
||||
foo() {
|
||||
console.log('default implementation');
|
||||
}
|
||||
|
||||
filterData(data: any[], user?: IUser): any[] {
|
||||
// Use passed user parameter if available, otherwise fall back to context
|
||||
const currentUser = user || UserContextService.getInstance().getCurrentUser();
|
||||
if (!currentUser || currentUser.isAdmin) {
|
||||
return data;
|
||||
} else {
|
||||
return data.filter((item) => item.owner === currentUser?.username);
|
||||
}
|
||||
}
|
||||
|
||||
filterSettings(settings: McpSettings, user?: IUser): McpSettings {
|
||||
// Use passed user parameter if available, otherwise fall back to context
|
||||
const currentUser = user || UserContextService.getInstance().getCurrentUser();
|
||||
if (!currentUser || currentUser.isAdmin) {
|
||||
const result = { ...settings };
|
||||
delete result.userConfigs;
|
||||
return result;
|
||||
} else {
|
||||
const result = { ...settings };
|
||||
result.systemConfig = settings.userConfigs?.[currentUser?.username || ''] || {};
|
||||
delete result.userConfigs;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
mergeSettings(all: McpSettings, newSettings: McpSettings, user?: IUser): McpSettings {
|
||||
// Use passed user parameter if available, otherwise fall back to context
|
||||
const currentUser = user || UserContextService.getInstance().getCurrentUser();
|
||||
if (!currentUser || currentUser.isAdmin) {
|
||||
const result = { ...all };
|
||||
result.users = newSettings.users;
|
||||
result.systemConfig = newSettings.systemConfig;
|
||||
result.groups = newSettings.groups;
|
||||
return result;
|
||||
} else {
|
||||
const result = JSON.parse(JSON.stringify(all));
|
||||
if (!result.userConfigs) {
|
||||
result.userConfigs = {};
|
||||
}
|
||||
const systemConfig = newSettings.systemConfig || {};
|
||||
const userConfig: UserConfig = {
|
||||
routing: systemConfig.routing
|
||||
? {
|
||||
enableGlobalRoute: systemConfig.routing.enableGlobalRoute,
|
||||
enableGroupNameRoute: systemConfig.routing.enableGroupNameRoute,
|
||||
enableBearerAuth: systemConfig.routing.enableBearerAuth,
|
||||
bearerAuthKey: systemConfig.routing.bearerAuthKey,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
result.userConfigs[currentUser?.username || ''] = userConfig;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
getPermissions(user: IUser): string[] {
|
||||
if (user && user.isAdmin) {
|
||||
return ['*', 'x'];
|
||||
} else {
|
||||
return [''];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,17 +12,22 @@ 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';
|
||||
|
||||
const servers: { [sessionId: string]: Server } = {};
|
||||
|
||||
// Per-session server instances for servers with perSession=true
|
||||
// Key format: `${sessionId}:${serverName}`
|
||||
const perSessionServerInfos: { [key: string]: ServerInfo } = {};
|
||||
|
||||
const serverDao = getServerDao();
|
||||
|
||||
// Helper function to set up keep-alive ping for SSE connections
|
||||
@@ -78,6 +83,8 @@ export const getMcpServer = (sessionId?: string, group?: string): Server => {
|
||||
|
||||
export const deleteMcpServer = (sessionId: string): void => {
|
||||
delete servers[sessionId];
|
||||
// Clean up any per-session servers for this session
|
||||
cleanupPerSessionServers(sessionId);
|
||||
};
|
||||
|
||||
export const notifyToolChanged = async (name?: string) => {
|
||||
@@ -222,6 +229,144 @@ const createTransportFromConfig = (name: string, conf: ServerConfig): any => {
|
||||
return transport;
|
||||
};
|
||||
|
||||
// Helper function to get or create per-session server instance
|
||||
export const getOrCreatePerSessionServer = async (
|
||||
sessionId: string,
|
||||
serverName: string,
|
||||
serverConfig: ServerConfig,
|
||||
): Promise<ServerInfo> => {
|
||||
const key = `${sessionId}:${serverName}`;
|
||||
|
||||
// Return existing session server if it exists
|
||||
if (perSessionServerInfos[key]) {
|
||||
return perSessionServerInfos[key];
|
||||
}
|
||||
|
||||
console.log(`Creating per-session server instance for session ${sessionId}, server ${serverName}`);
|
||||
|
||||
// Create new transport for this session
|
||||
const transport = createTransportFromConfig(serverName, serverConfig);
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: `mcp-client-${serverName}-${sessionId}`,
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
prompts: {},
|
||||
resources: {},
|
||||
tools: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Get request options from server configuration, with fallbacks
|
||||
const serverRequestOptions = serverConfig.options || {};
|
||||
const requestOptions = {
|
||||
timeout: serverRequestOptions.timeout || 60000,
|
||||
resetTimeoutOnProgress: serverRequestOptions.resetTimeoutOnProgress || false,
|
||||
maxTotalTimeout: serverRequestOptions.maxTotalTimeout,
|
||||
};
|
||||
|
||||
// Create server info for this session
|
||||
const serverInfo: ServerInfo = {
|
||||
name: serverName,
|
||||
owner: serverConfig.owner,
|
||||
status: 'connecting',
|
||||
error: null,
|
||||
tools: [],
|
||||
prompts: [],
|
||||
client,
|
||||
transport,
|
||||
options: requestOptions,
|
||||
createTime: Date.now(),
|
||||
config: serverConfig,
|
||||
sessionId: sessionId,
|
||||
};
|
||||
|
||||
perSessionServerInfos[key] = serverInfo;
|
||||
|
||||
// Connect asynchronously
|
||||
client
|
||||
.connect(transport, requestOptions)
|
||||
.then(() => {
|
||||
console.log(`Successfully connected per-session client for server: ${serverName}, session: ${sessionId}`);
|
||||
const capabilities = client.getServerCapabilities();
|
||||
|
||||
if (capabilities?.tools) {
|
||||
client
|
||||
.listTools({}, requestOptions)
|
||||
.then((tools) => {
|
||||
console.log(`Successfully listed ${tools.tools.length} tools for per-session server: ${serverName}, session: ${sessionId}`);
|
||||
serverInfo.tools = tools.tools.map((tool) => ({
|
||||
name: `${serverName}${getNameSeparator()}${tool.name}`,
|
||||
description: tool.description || '',
|
||||
inputSchema: cleanInputSchema(tool.inputSchema || {}),
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Failed to list tools for per-session server ${serverName}, session ${sessionId}:`, error);
|
||||
});
|
||||
}
|
||||
|
||||
if (capabilities?.prompts) {
|
||||
client
|
||||
.listPrompts({}, requestOptions)
|
||||
.then((prompts) => {
|
||||
console.log(`Successfully listed ${prompts.prompts.length} prompts for per-session server: ${serverName}, session: ${sessionId}`);
|
||||
serverInfo.prompts = prompts.prompts.map((prompt) => ({
|
||||
name: `${serverName}${getNameSeparator()}${prompt.name}`,
|
||||
title: prompt.title,
|
||||
description: prompt.description,
|
||||
arguments: prompt.arguments,
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Failed to list prompts for per-session server ${serverName}, session ${sessionId}:`, error);
|
||||
});
|
||||
}
|
||||
|
||||
serverInfo.status = 'connected';
|
||||
serverInfo.error = null;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Failed to connect per-session client for server ${serverName}, session ${sessionId}:`, error);
|
||||
serverInfo.status = 'disconnected';
|
||||
serverInfo.error = `Failed to connect: ${error.stack}`;
|
||||
});
|
||||
|
||||
return serverInfo;
|
||||
};
|
||||
|
||||
// Helper function to clean up per-session servers for a session
|
||||
export const cleanupPerSessionServers = (sessionId: string): void => {
|
||||
const keysToDelete: string[] = [];
|
||||
|
||||
for (const key in perSessionServerInfos) {
|
||||
if (key.startsWith(`${sessionId}:`)) {
|
||||
const serverInfo = perSessionServerInfos[key];
|
||||
try {
|
||||
if (serverInfo.client) {
|
||||
serverInfo.client.close();
|
||||
}
|
||||
if (serverInfo.transport) {
|
||||
serverInfo.transport.close();
|
||||
}
|
||||
if (serverInfo.keepAliveIntervalId) {
|
||||
clearInterval(serverInfo.keepAliveIntervalId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Error closing per-session server ${key}:`, error);
|
||||
}
|
||||
keysToDelete.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
keysToDelete.forEach(key => delete perSessionServerInfos[key]);
|
||||
console.log(`Cleaned up ${keysToDelete.length} per-session servers for session ${sessionId}`);
|
||||
};
|
||||
|
||||
// Helper function to handle client.callTool with reconnection logic
|
||||
const callToolWithReconnect = async (
|
||||
serverInfo: ServerInfo,
|
||||
@@ -293,7 +438,7 @@ const callToolWithReconnect = async (
|
||||
try {
|
||||
const tools = await client.listTools({}, serverInfo.options || {});
|
||||
serverInfo.tools = tools.tools.map((tool) => ({
|
||||
name: `${serverInfo.name}-${tool.name}`,
|
||||
name: `${serverInfo.name}${getNameSeparator()}${tool.name}`,
|
||||
description: tool.description || '',
|
||||
inputSchema: cleanInputSchema(tool.inputSchema || {}),
|
||||
}));
|
||||
@@ -403,6 +548,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 +564,7 @@ export const initializeClientsFromSettings = async (
|
||||
// Convert OpenAPI tools to MCP tool format
|
||||
const openApiTools = openApiClient.getTools();
|
||||
const mcpTools: Tool[] = openApiTools.map((tool) => ({
|
||||
name: `${name}-${tool.name}`,
|
||||
name: `${name}${getNameSeparator()}${tool.name}`,
|
||||
description: tool.description,
|
||||
inputSchema: cleanInputSchema(tool.inputSchema),
|
||||
}));
|
||||
@@ -487,6 +633,7 @@ export const initializeClientsFromSettings = async (
|
||||
transport,
|
||||
options: requestOptions,
|
||||
createTime: Date.now(),
|
||||
config: conf, // Store reference to original config
|
||||
};
|
||||
serverInfos.push(serverInfo);
|
||||
|
||||
@@ -504,7 +651,7 @@ export const initializeClientsFromSettings = async (
|
||||
.then((tools) => {
|
||||
console.log(`Successfully listed ${tools.tools.length} tools for server: ${name}`);
|
||||
serverInfo.tools = tools.tools.map((tool) => ({
|
||||
name: `${name}-${tool.name}`,
|
||||
name: `${name}${getNameSeparator()}${tool.name}`,
|
||||
description: tool.description || '',
|
||||
inputSchema: cleanInputSchema(tool.inputSchema || {}),
|
||||
}));
|
||||
@@ -527,7 +674,7 @@ export const initializeClientsFromSettings = async (
|
||||
`Successfully listed ${prompts.prompts.length} prompts for server: ${name}`,
|
||||
);
|
||||
serverInfo.prompts = prompts.prompts.map((prompt) => ({
|
||||
name: `${name}-${prompt.name}`,
|
||||
name: `${name}${getNameSeparator()}${prompt.name}`,
|
||||
title: prompt.title,
|
||||
description: prompt.description,
|
||||
arguments: prompt.arguments,
|
||||
@@ -622,6 +769,45 @@ export const getServerByName = (name: string): ServerInfo | undefined => {
|
||||
return serverInfos.find((serverInfo) => serverInfo.name === name);
|
||||
};
|
||||
|
||||
// Get server by name with session support (for per-session servers)
|
||||
const getServerByNameWithSession = async (name: string, sessionId?: string): Promise<ServerInfo | undefined> => {
|
||||
// First check if this server is configured for per-session instances
|
||||
const serverConfig = await serverDao.findById(name);
|
||||
|
||||
if (serverConfig?.perSession && sessionId) {
|
||||
// Try to get or create per-session server
|
||||
const key = `${sessionId}:${name}`;
|
||||
if (perSessionServerInfos[key]) {
|
||||
return perSessionServerInfos[key];
|
||||
}
|
||||
// Create new per-session server instance
|
||||
return await getOrCreatePerSessionServer(sessionId, name, serverConfig);
|
||||
}
|
||||
|
||||
// Fall back to shared server
|
||||
return serverInfos.find((serverInfo) => serverInfo.name === name && !serverInfo.sessionId);
|
||||
};
|
||||
|
||||
// Get server by tool name with session support (for per-session servers)
|
||||
const getServerByToolWithSession = async (toolName: string, sessionId?: string): Promise<ServerInfo | undefined> => {
|
||||
// First try to find in per-session servers if sessionId is provided
|
||||
if (sessionId) {
|
||||
for (const key in perSessionServerInfos) {
|
||||
if (key.startsWith(`${sessionId}:`)) {
|
||||
const serverInfo = perSessionServerInfos[key];
|
||||
if (serverInfo.tools.some((tool) => tool.name === toolName)) {
|
||||
return serverInfo;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to shared servers
|
||||
return serverInfos.find((serverInfo) =>
|
||||
!serverInfo.sessionId && serverInfo.tools.some((tool) => tool.name === toolName)
|
||||
);
|
||||
};
|
||||
|
||||
// Filter tools by server configuration
|
||||
const filterToolsByConfig = async (serverName: string, tools: Tool[]): Promise<Tool[]> => {
|
||||
const serverConfig = await serverDao.findById(serverName);
|
||||
@@ -637,8 +823,8 @@ const filterToolsByConfig = async (serverName: string, tools: Tool[]): Promise<T
|
||||
});
|
||||
};
|
||||
|
||||
// Get server by tool name
|
||||
const getServerByTool = (toolName: string): ServerInfo | undefined => {
|
||||
// Get server by tool name (legacy - use getServerByToolWithSession instead)
|
||||
const _getServerByTool = (toolName: string): ServerInfo | undefined => {
|
||||
return serverInfos.find((serverInfo) => serverInfo.tools.some((tool) => tool.name === toolName));
|
||||
};
|
||||
|
||||
@@ -823,15 +1009,35 @@ Available servers: ${serversList}`;
|
||||
};
|
||||
}
|
||||
|
||||
const allServerInfos = getDataService()
|
||||
// Get shared servers
|
||||
let allServerInfos = getDataService()
|
||||
.filterData(serverInfos)
|
||||
.filter((serverInfo) => {
|
||||
if (serverInfo.enabled === false) return false;
|
||||
if (serverInfo.sessionId) return false; // Exclude per-session servers from shared list
|
||||
if (!group) return true;
|
||||
const serversInGroup = getServersInGroup(group);
|
||||
if (!serversInGroup || serversInGroup.length === 0) return serverInfo.name === group;
|
||||
return serversInGroup.includes(serverInfo.name);
|
||||
});
|
||||
|
||||
// Add per-session servers for this session
|
||||
if (sessionId) {
|
||||
const sessionServers = Object.values(perSessionServerInfos).filter(
|
||||
(serverInfo) => serverInfo.sessionId === sessionId && serverInfo.status === 'connected'
|
||||
);
|
||||
|
||||
// Filter session servers by group if applicable
|
||||
const filteredSessionServers = sessionServers.filter((serverInfo) => {
|
||||
if (serverInfo.enabled === false) return false;
|
||||
if (!group) return true;
|
||||
const serversInGroup = getServersInGroup(group);
|
||||
if (!serversInGroup || serversInGroup.length === 0) return serverInfo.name === group;
|
||||
return serversInGroup.includes(serverInfo.name);
|
||||
});
|
||||
|
||||
allServerInfos = [...allServerInfos, ...filteredSessionServers];
|
||||
}
|
||||
|
||||
const allTools = [];
|
||||
for (const serverInfo of allServerInfos) {
|
||||
@@ -845,7 +1051,7 @@ Available servers: ${serversList}`;
|
||||
if (serverConfig && serverConfig.tools !== 'all' && Array.isArray(serverConfig.tools)) {
|
||||
// Filter tools based on group configuration
|
||||
const allowedToolNames = serverConfig.tools.map(
|
||||
(toolName) => `${serverInfo.name}-${toolName}`,
|
||||
(toolName) => `${serverInfo.name}${getNameSeparator()}${toolName}`,
|
||||
);
|
||||
enabledTools = enabledTools.filter((tool) => allowedToolNames.includes(tool.name));
|
||||
}
|
||||
@@ -995,17 +1201,13 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
|
||||
}
|
||||
|
||||
const { arguments: toolArgs = {} } = request.params.arguments || {};
|
||||
const sessionId = extra?.sessionId;
|
||||
let targetServerInfo: ServerInfo | undefined;
|
||||
if (extra && extra.server) {
|
||||
targetServerInfo = getServerByName(extra.server);
|
||||
targetServerInfo = await getServerByNameWithSession(extra.server, sessionId);
|
||||
} else {
|
||||
// Find the first server that has this tool
|
||||
targetServerInfo = serverInfos.find(
|
||||
(serverInfo) =>
|
||||
serverInfo.status === 'connected' &&
|
||||
serverInfo.enabled !== false &&
|
||||
serverInfo.tools.some((tool) => tool.name === toolName),
|
||||
);
|
||||
// Find the first server that has this tool (session-aware)
|
||||
targetServerInfo = await getServerByToolWithSession(toolName, sessionId);
|
||||
}
|
||||
|
||||
if (!targetServerInfo) {
|
||||
@@ -1032,11 +1234,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 +1294,10 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
|
||||
`Invoking tool '${toolName}' on server '${targetServerInfo.name}' with arguments: ${JSON.stringify(finalArgs)}`,
|
||||
);
|
||||
|
||||
toolName = toolName.startsWith(`${targetServerInfo.name}-`)
|
||||
? toolName.replace(`${targetServerInfo.name}-`, '')
|
||||
const separator = getNameSeparator();
|
||||
const prefix = `${targetServerInfo.name}${separator}`;
|
||||
toolName = toolName.startsWith(prefix)
|
||||
? toolName.substring(prefix.length)
|
||||
: toolName;
|
||||
const result = await callToolWithReconnect(
|
||||
targetServerInfo,
|
||||
@@ -1080,7 +1313,8 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
|
||||
}
|
||||
|
||||
// Regular tool handling
|
||||
const serverInfo = getServerByTool(request.params.name);
|
||||
const sessionId = extra?.sessionId;
|
||||
const serverInfo = await getServerByToolWithSession(request.params.name, sessionId);
|
||||
if (!serverInfo) {
|
||||
throw new Error(`Server not found: ${request.params.name}`);
|
||||
}
|
||||
@@ -1091,15 +1325,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 +1385,10 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
|
||||
throw new Error(`Client not found for server: ${serverInfo.name}`);
|
||||
}
|
||||
|
||||
request.params.name = request.params.name.startsWith(`${serverInfo.name}-`)
|
||||
? request.params.name.replace(`${serverInfo.name}-`, '')
|
||||
const separator = getNameSeparator();
|
||||
const prefix = `${serverInfo.name}${separator}`;
|
||||
request.params.name = request.params.name.startsWith(prefix)
|
||||
? request.params.name.substring(prefix.length)
|
||||
: request.params.name;
|
||||
const result = await callToolWithReconnect(
|
||||
serverInfo,
|
||||
@@ -1162,8 +1431,10 @@ export const handleGetPromptRequest = async (request: any, extra: any) => {
|
||||
}
|
||||
|
||||
// Remove server prefix from prompt name if present
|
||||
const cleanPromptName = name.startsWith(`${server.name}-`)
|
||||
? name.replace(`${server.name}-`, '')
|
||||
const separator = getNameSeparator();
|
||||
const prefix = `${server.name}${separator}`;
|
||||
const cleanPromptName = name.startsWith(prefix)
|
||||
? name.substring(prefix.length)
|
||||
: name;
|
||||
|
||||
const promptParams = {
|
||||
|
||||
@@ -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, '')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,6 @@ export function registerService<T>(key: string, entry: Service<T>) {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Service registered: ${key} with entry:`, entry);
|
||||
registry.set(key, entry);
|
||||
}
|
||||
|
||||
|
||||
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 {
|
||||
@@ -177,6 +178,7 @@ export interface ServerConfig {
|
||||
enabled?: boolean; // Flag to enable/disable the server
|
||||
owner?: string; // Owner of the server, defaults to 'admin' user
|
||||
keepAliveInterval?: number; // Keep-alive ping interval in milliseconds (default: 60000ms for SSE servers)
|
||||
perSession?: boolean; // If true, creates a separate server instance for each session (useful for stateful servers like playwright)
|
||||
tools?: Record<string, { enabled: boolean; description?: string }>; // Tool-specific configurations with enable/disable state and custom descriptions
|
||||
prompts?: Record<string, { enabled: boolean; description?: string }>; // Prompt-specific configurations with enable/disable state and custom descriptions
|
||||
options?: Partial<Pick<RequestOptions, 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout'>>; // MCP request options configuration
|
||||
@@ -186,6 +188,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 +239,8 @@ 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
|
||||
sessionId?: string; // Session ID for per-session server instances (undefined for shared servers)
|
||||
}
|
||||
|
||||
// Details about a tool available on the server
|
||||
|
||||
@@ -5,6 +5,13 @@ import { dirname } from 'path';
|
||||
// Project root directory - use process.cwd() as a simpler alternative
|
||||
const rootDir = process.cwd();
|
||||
|
||||
function getParentPath(p: string, filename: string): string {
|
||||
if (p.endsWith(filename)) {
|
||||
p = p.slice(0, -filename.length);
|
||||
}
|
||||
return path.resolve(p);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the path to a configuration file by checking multiple potential locations.
|
||||
* @param filename The name of the file to locate (e.g., 'servers.json', 'mcp_settings.json')
|
||||
@@ -12,15 +19,35 @@ const rootDir = process.cwd();
|
||||
* @returns The path to the file
|
||||
*/
|
||||
export const getConfigFilePath = (filename: string, description = 'Configuration'): string => {
|
||||
const envPath = process.env.MCPHUB_SETTING_PATH;
|
||||
if (filename === 'mcp_settings.json') {
|
||||
const envPath = process.env.MCPHUB_SETTING_PATH;
|
||||
if (envPath) {
|
||||
// Ensure directory exists
|
||||
const dir = getParentPath(envPath, filename);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
console.log(`Created directory for settings at ${dir}`);
|
||||
}
|
||||
|
||||
// if full path, return as is
|
||||
if (envPath?.endsWith(filename)) {
|
||||
return envPath;
|
||||
}
|
||||
|
||||
// if directory, return path under that directory
|
||||
return path.resolve(envPath, filename);
|
||||
}
|
||||
}
|
||||
|
||||
const potentialPaths = [
|
||||
...(envPath ? [envPath] : []),
|
||||
// Prioritize process.cwd() as the first location to check
|
||||
path.resolve(process.cwd(), filename),
|
||||
// Use path relative to the root directory
|
||||
path.join(rootDir, filename),
|
||||
// If installed with npx, may need to look one level up
|
||||
path.join(dirname(rootDir), filename),
|
||||
...[
|
||||
// Prioritize process.cwd() as the first location to check
|
||||
path.resolve(process.cwd(), filename),
|
||||
// Use path relative to the root directory
|
||||
path.join(rootDir, filename),
|
||||
// If installed with npx, may need to look one level up
|
||||
path.join(dirname(rootDir), filename),
|
||||
],
|
||||
];
|
||||
|
||||
for (const filePath of potentialPaths) {
|
||||
|
||||
111
tests/services/perSessionServers.test.ts
Normal file
111
tests/services/perSessionServers.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
getOrCreatePerSessionServer,
|
||||
cleanupPerSessionServers,
|
||||
} from '../../src/services/mcpService';
|
||||
import { ServerConfig } from '../../src/types';
|
||||
|
||||
// Mock the serverDao
|
||||
jest.mock('../../src/dao/index.js', () => ({
|
||||
getServerDao: () => ({
|
||||
findById: jest.fn((name: string) => {
|
||||
if (name === 'playwright') {
|
||||
return Promise.resolve({
|
||||
name: 'playwright',
|
||||
command: 'npx',
|
||||
args: ['@playwright/mcp@latest', '--headless', '--isolated'],
|
||||
perSession: true,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
}),
|
||||
findAll: jest.fn(() => Promise.resolve([])),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the Client and Transport classes
|
||||
jest.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
|
||||
Client: jest.fn().mockImplementation(() => ({
|
||||
connect: jest.fn(() => Promise.resolve()),
|
||||
close: jest.fn(),
|
||||
listTools: jest.fn(() => Promise.resolve({ tools: [] })),
|
||||
listPrompts: jest.fn(() => Promise.resolve({ prompts: [] })),
|
||||
getServerCapabilities: jest.fn(() => ({ tools: true, prompts: true })),
|
||||
callTool: jest.fn((params) => Promise.resolve({ content: [{ type: 'text', text: `Tool ${params.name} called` }] })),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
|
||||
StdioClientTransport: jest.fn().mockImplementation(() => ({
|
||||
close: jest.fn(),
|
||||
stderr: {
|
||||
on: jest.fn(),
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('Per-Session Server Instances', () => {
|
||||
afterEach(() => {
|
||||
// Clean up any created sessions
|
||||
cleanupPerSessionServers('session1');
|
||||
cleanupPerSessionServers('session2');
|
||||
});
|
||||
|
||||
it('should create separate server instances for different sessions', async () => {
|
||||
const config: ServerConfig = {
|
||||
command: 'npx',
|
||||
args: ['@playwright/mcp@latest', '--headless', '--isolated'],
|
||||
perSession: true,
|
||||
};
|
||||
|
||||
// Create server for session1
|
||||
const server1 = await getOrCreatePerSessionServer('session1', 'playwright', config);
|
||||
expect(server1).toBeDefined();
|
||||
expect(server1.sessionId).toBe('session1');
|
||||
|
||||
// Create server for session2
|
||||
const server2 = await getOrCreatePerSessionServer('session2', 'playwright', config);
|
||||
expect(server2).toBeDefined();
|
||||
expect(server2.sessionId).toBe('session2');
|
||||
|
||||
// They should be different instances
|
||||
expect(server1).not.toBe(server2);
|
||||
});
|
||||
|
||||
it('should reuse existing per-session server for the same session', async () => {
|
||||
const config: ServerConfig = {
|
||||
command: 'npx',
|
||||
args: ['@playwright/mcp@latest', '--headless', '--isolated'],
|
||||
perSession: true,
|
||||
};
|
||||
|
||||
// Create server for session1
|
||||
const server1 = await getOrCreatePerSessionServer('session1', 'playwright', config);
|
||||
|
||||
// Request the same server again
|
||||
const server2 = await getOrCreatePerSessionServer('session1', 'playwright', config);
|
||||
|
||||
// Should be the same instance
|
||||
expect(server1).toBe(server2);
|
||||
});
|
||||
|
||||
it('should clean up per-session servers when session ends', async () => {
|
||||
const config: ServerConfig = {
|
||||
command: 'npx',
|
||||
args: ['@playwright/mcp@latest', '--headless', '--isolated'],
|
||||
perSession: true,
|
||||
};
|
||||
|
||||
// Create server for session1
|
||||
const server1 = await getOrCreatePerSessionServer('session1', 'playwright', config);
|
||||
expect(server1).toBeDefined();
|
||||
|
||||
// Clean up session1
|
||||
cleanupPerSessionServers('session1');
|
||||
|
||||
// Create again should create a new instance (not the same object)
|
||||
const server2 = await getOrCreatePerSessionServer('session1', 'playwright', config);
|
||||
expect(server2).toBeDefined();
|
||||
expect(server2).not.toBe(server1);
|
||||
});
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
131
tests/utils/cliPathHandling.test.ts
Normal file
131
tests/utils/cliPathHandling.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
// Test for CLI path handling functionality
|
||||
import path from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
|
||||
describe('CLI Path Handling', () => {
|
||||
describe('Cross-platform ESM URL conversion', () => {
|
||||
it('should convert Unix-style absolute path to file:// URL', () => {
|
||||
const unixPath = '/home/user/project/dist/index.js';
|
||||
const fileUrl = pathToFileURL(unixPath).href;
|
||||
|
||||
expect(fileUrl).toMatch(/^file:\/\//);
|
||||
expect(fileUrl).toContain('index.js');
|
||||
});
|
||||
|
||||
it('should handle relative paths correctly', () => {
|
||||
const relativePath = path.join(process.cwd(), 'dist', 'index.js');
|
||||
const fileUrl = pathToFileURL(relativePath).href;
|
||||
|
||||
expect(fileUrl).toMatch(/^file:\/\//);
|
||||
expect(fileUrl).toContain('dist');
|
||||
expect(fileUrl).toContain('index.js');
|
||||
});
|
||||
|
||||
it('should produce valid URL format', () => {
|
||||
const testPath = path.join(process.cwd(), 'test', 'file.js');
|
||||
const fileUrl = pathToFileURL(testPath).href;
|
||||
|
||||
// Should be a valid URL
|
||||
expect(() => new URL(fileUrl)).not.toThrow();
|
||||
|
||||
// Should start with file://
|
||||
expect(fileUrl.startsWith('file://')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle paths with spaces', () => {
|
||||
const pathWithSpaces = path.join(process.cwd(), 'my folder', 'dist', 'index.js');
|
||||
const fileUrl = pathToFileURL(pathWithSpaces).href;
|
||||
|
||||
expect(fileUrl).toMatch(/^file:\/\//);
|
||||
expect(fileUrl).toContain('index.js');
|
||||
// Spaces should be URL-encoded
|
||||
expect(fileUrl).toContain('%20');
|
||||
});
|
||||
|
||||
it('should handle paths with special characters', () => {
|
||||
const pathWithSpecialChars = path.join(process.cwd(), 'test@dir', 'file#1.js');
|
||||
const fileUrl = pathToFileURL(pathWithSpecialChars).href;
|
||||
|
||||
expect(fileUrl).toMatch(/^file:\/\//);
|
||||
// Special characters should be URL-encoded
|
||||
expect(() => new URL(fileUrl)).not.toThrow();
|
||||
});
|
||||
|
||||
// Windows-specific path handling simulation
|
||||
it('should handle Windows-style paths correctly', () => {
|
||||
// Simulate a Windows path structure
|
||||
// Note: On non-Windows systems, this creates a relative path,
|
||||
// but the test verifies the conversion mechanism works
|
||||
const mockWindowsPath = 'C:\\Users\\User\\project\\dist\\index.js';
|
||||
|
||||
// On Windows, pathToFileURL would convert C:\ to file:///C:/
|
||||
// On Unix, it treats it as a relative path, but the conversion still works
|
||||
const fileUrl = pathToFileURL(mockWindowsPath).href;
|
||||
|
||||
expect(fileUrl).toMatch(/^file:\/\//);
|
||||
expect(fileUrl).toContain('index.js');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Path normalization', () => {
|
||||
it('should normalize path separators', () => {
|
||||
const mixedPath = path.join('dist', 'index.js');
|
||||
const fileUrl = pathToFileURL(path.resolve(mixedPath)).href;
|
||||
|
||||
expect(fileUrl).toMatch(/^file:\/\//);
|
||||
// All separators should be forward slashes in URL
|
||||
expect(fileUrl.split('file://')[1]).not.toContain('\\');
|
||||
});
|
||||
|
||||
it('should handle multiple consecutive slashes', () => {
|
||||
const messyPath = path.normalize('/dist//index.js');
|
||||
const fileUrl = pathToFileURL(path.resolve(messyPath)).href;
|
||||
|
||||
expect(fileUrl).toMatch(/^file:\/\//);
|
||||
expect(() => new URL(fileUrl)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Path resolution for CLI use case', () => {
|
||||
it('should convert package root path to valid import URL', () => {
|
||||
const packageRoot = process.cwd();
|
||||
const entryPath = path.join(packageRoot, 'dist', 'index.js');
|
||||
const entryUrl = pathToFileURL(entryPath).href;
|
||||
|
||||
expect(entryUrl).toMatch(/^file:\/\//);
|
||||
expect(entryUrl).toContain('dist');
|
||||
expect(entryUrl).toContain('index.js');
|
||||
expect(() => new URL(entryUrl)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle nested directory structures', () => {
|
||||
const deepPath = path.join(process.cwd(), 'a', 'b', 'c', 'd', 'file.js');
|
||||
const fileUrl = pathToFileURL(deepPath).href;
|
||||
|
||||
expect(fileUrl).toMatch(/^file:\/\//);
|
||||
expect(fileUrl).toContain('file.js');
|
||||
expect(() => new URL(fileUrl)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should produce URL compatible with dynamic import()', () => {
|
||||
// This test verifies the exact pattern used in bin/cli.js
|
||||
const projectRoot = process.cwd();
|
||||
const entryPath = path.join(projectRoot, 'dist', 'index.js');
|
||||
const entryUrl = pathToFileURL(entryPath).href;
|
||||
|
||||
// The URL should be valid for import()
|
||||
expect(entryUrl).toMatch(/^file:\/\//);
|
||||
expect(typeof entryUrl).toBe('string');
|
||||
|
||||
// Verify the URL format is valid
|
||||
const urlObj = new URL(entryUrl);
|
||||
expect(urlObj.protocol).toBe('file:');
|
||||
expect(urlObj.href).toBe(entryUrl);
|
||||
|
||||
// On Windows, pathToFileURL converts 'C:\path' to 'file:///C:/path'
|
||||
// On Unix, it converts '/path' to 'file:///path'
|
||||
// Both formats are valid for dynamic import()
|
||||
expect(entryUrl).toContain('index.js');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user