Compare commits

..

35 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
f9732fccb6 Add comprehensive documentation for per-session server instances
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-12 07:48:20 +00:00
copilot-swe-agent[bot]
7b3d441046 Add per-session server instance support to fix concurrent user isolation
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-12 07:45:21 +00:00
copilot-swe-agent[bot]
55a7d0b183 Initial plan 2025-10-12 07:32:31 +00:00
samanhappy
435227cbd4 fix: improve error handling and directory creation for settings path (#364) 2025-10-12 15:30:40 +08:00
Copilot
6a59becd8d Fix Windows startup error: Convert paths to file:// URLs for ESM dynamic imports (#363)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
Co-authored-by: samanhappy <samanhappy@gmail.com>
2025-10-12 11:31:44 +08:00
samanhappy
91698a50e3 fix: use specified environment setting path when available (#359) 2025-10-11 23:44:23 +08:00
samanhappy
a5d5045832 fix: add groups handling in mergeSettings method (#362) 2025-10-11 23:29:59 +08:00
samanhappy
198ea85225 feat: implement user management features with add, edit, and delete functionality 2025-10-02 15:11:08 +08:00
dependabot[bot]
6b39916909 chore(deps): bump typeorm from 0.3.26 to 0.3.27 (#356)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-02 14:47:55 +08:00
dependabot[bot]
9e8db370ff chore(deps-dev): bump @tailwindcss/postcss from 4.1.12 to 4.1.13 (#358)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-02 14:47:09 +08:00
dependabot[bot]
5d8bc44a73 chore(deps-dev): bump @types/node from 22.17.2 to 24.6.1 (#357)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-02 14:44:13 +08:00
dependabot[bot]
021901dbda chore(deps-dev): bump jest and @types/jest (#354)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-02 14:40:42 +08:00
WuWen
f6934a32dc feat: add configurable name separator for tools and prompts (#353) 2025-10-02 14:40:01 +08:00
samanhappy
7685b9bca8 feat: enhance visual hierarchy on LoginPage by increasing slogan size and spacing (#347) 2025-09-20 17:23:54 +08:00
samanhappy
c2dd91606f chore(deps): update @modelcontextprotocol/sdk to 1.18.1 and axios to 1.12.2 (#346) 2025-09-20 17:16:04 +08:00
samanhappy
66b6053f7f feat: add passthrough headers support for OpenAPI client and MCP protocol (#345) 2025-09-20 17:12:20 +08:00
dependabot[bot]
ba50a78879 chore(deps): bump axios from 1.11.0 to 1.12.0 (#342)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-14 16:14:13 +08:00
comeback01
a856404963 docs: add French translation for README (#339)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-09-12 17:56:00 +08:00
comeback01
9a65532a50 feat: add french localization (#337)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-09-12 14:27:05 +08:00
dependabot[bot]
c5aa97de50 chore(deps-dev): bump vite from 6.3.5 to 6.3.6 (#334)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-10 12:42:00 +08:00
dependabot[bot]
271c9fe2c3 chore(deps-dev): bump i18next from 24.2.3 to 25.4.2 (#329)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-05 08:51:54 +08:00
dependabot[bot]
d59961c4d4 chore(deps-dev): bump next from 15.5.0 to 15.5.2 (#328)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-05 08:51:23 +08:00
dependabot[bot]
d0ec80303a chore(deps-dev): bump jest-mock-extended from 4.0.0-beta1 to 4.0.0 (#330)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-04 22:49:30 +08:00
dependabot[bot]
69e92b5aa8 chore(deps): bump @apidevtools/swagger-parser from 11.0.1 to 12.0.0 (#331)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-04 22:48:29 +08:00
dependabot[bot]
5acae64b29 chore(deps-dev): bump @typescript-eslint/eslint-plugin from 6.21.0 to 7.0.0 (#327)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-04 22:47:30 +08:00
samanhappy
a5fc4a429d fix: ensure settings cache is cleared after saving and clearing cache (#326) 2025-09-01 10:15:50 +08:00
Junsen Huang
ce15330016 fix(server-context): fix server edite error (#325) 2025-08-30 17:37:30 +08:00
samanhappy
621bc36560 feat: Add OpenAPI-compatible tool execution endpoints for named APIs (#316) 2025-08-30 16:00:15 +08:00
Junsen Huang
c398223824 feat: refactor server data management to use global context (#319) 2025-08-28 22:15:16 +08:00
Copilot
5dd3e7978e Generate comprehensive GitHub Copilot instructions for MCPHub development (#314)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-08-27 21:58:06 +08:00
samanhappy
f577351f04 fix: set current working directory for StdioClientTransport to homedir (#311) 2025-08-27 19:23:00 +08:00
Copilot
62de87b1a4 Add granular OpenAPI endpoints for server-level and group-level tool access (#309)
Co-authored-by: samanhappy <samanhappy@gmail.com>
2025-08-27 17:25:32 +08:00
samanhappy
bbd6c891c9 feat(dao): Implement comprehensive DAO layer (#308)
Co-authored-by: samanhappy@qq.com <my6051199>
2025-08-27 15:21:30 +08:00
Copilot
f9019545c3 Fix integer parameter conversion in OpenAPI endpoints (#306)
Co-authored-by: samanhappy <samanhappy@gmail.com>
2025-08-27 11:04:25 +08:00
samanhappy
d778536388 fix: update tool call API endpoint structure and enhance error handling (#300) 2025-08-26 18:49:34 +08:00
85 changed files with 8530 additions and 1523 deletions

View File

@@ -1,50 +1,237 @@
# MCPHub Coding Instructions
**ALWAYS follow these instructions first and only fallback to additional search and context gathering if the information here is incomplete or found to be in error.**
## Project Overview
MCPHub is a TypeScript/Node.js MCP server management hub that provides unified access through HTTP endpoints.
MCPHub is a TypeScript/Node.js MCP (Model Context Protocol) server management hub that provides unified access through HTTP endpoints. It serves as a centralized dashboard for managing multiple MCP servers with real-time monitoring, authentication, and flexible routing.
**Core Components:**
- **Backend**: Express.js + TypeScript + ESM (`src/server.ts`)
- **Frontend**: React/Vite + Tailwind CSS (`frontend/`)
- **MCP Integration**: Connects multiple MCP servers (`src/services/mcpService.ts`)
- **Authentication**: JWT-based with bcrypt password hashing
- **Configuration**: JSON-based MCP server definitions (`mcp_settings.json`)
## Development Environment
## Working Effectively
### Bootstrap and Setup (CRITICAL - Follow Exact Steps)
```bash
# Install pnpm if not available
npm install -g pnpm
# Install dependencies - takes ~30 seconds
pnpm install
pnpm dev # Start both backend and frontend
pnpm backend:dev # Backend only
pnpm frontend:dev # Frontend only
# Setup environment (optional)
cp .env.example .env
# Build and test to verify setup
pnpm lint # ~3 seconds - NEVER CANCEL
pnpm backend:build # ~5 seconds - NEVER CANCEL
pnpm test:ci # ~16 seconds - NEVER CANCEL. Set timeout to 60+ seconds
pnpm frontend:build # ~5 seconds - NEVER CANCEL
pnpm build # ~10 seconds total - NEVER CANCEL. Set timeout to 60+ seconds
```
## Project Conventions
**CRITICAL TIMING**: These commands are fast but NEVER CANCEL them. Always wait for completion.
### File Structure
### Development Environment
- `src/services/` - Core business logic
- `src/controllers/` - HTTP request handlers
- `src/types/index.ts` - TypeScript type definitions
```bash
# Start both backend and frontend (recommended for most development)
pnpm dev # Backend on :3001, Frontend on :5173
# OR start separately (required on Windows, optional on Linux/macOS)
# Terminal 1: Backend only
pnpm backend:dev # Runs on port 3000 (or PORT env var)
# Terminal 2: Frontend only
pnpm frontend:dev # Runs on port 5173, proxies API to backend
```
**NEVER CANCEL**: Development servers may take 10-15 seconds to fully initialize all MCP servers.
### Build Commands (Production)
```bash
# Full production build - takes ~10 seconds total
pnpm build # NEVER CANCEL - Set timeout to 60+ seconds
# Individual builds
pnpm backend:build # TypeScript compilation - ~5 seconds
pnpm frontend:build # Vite build - ~5 seconds
# Start production server
pnpm start # Requires dist/ and frontend/dist/ to exist
```
### Testing and Validation
```bash
# Run all tests - takes ~16 seconds with 73 tests
pnpm test:ci # NEVER CANCEL - Set timeout to 60+ seconds
# Development testing
pnpm test # Interactive mode
pnpm test:watch # Watch mode for development
pnpm test:coverage # With coverage report
# Code quality
pnpm lint # ESLint - ~3 seconds
pnpm format # Prettier formatting - ~3 seconds
```
**CRITICAL**: All tests MUST pass before committing. Do not modify tests to make them pass unless specifically required for your changes.
## Manual Validation Requirements
**ALWAYS perform these validation steps after making changes:**
### 1. Basic Application Functionality
```bash
# Start the application
pnpm dev
# Verify backend responds (in another terminal)
curl http://localhost:3000/api/health
# Expected: Should return health status
# Verify frontend serves
curl -I http://localhost:3000/
# Expected: HTTP 200 OK with HTML content
```
### 2. MCP Server Integration Test
```bash
# Check MCP servers are loading (look for log messages)
# Expected log output should include:
# - "Successfully connected client for server: [name]"
# - "Successfully listed [N] tools for server: [name]"
# - Some servers may fail due to missing API keys (normal in dev)
```
### 3. Build Verification
```bash
# Verify production build works
pnpm build
node scripts/verify-dist.js
# Expected: "✅ Verification passed! Frontend and backend dist files are present."
```
**NEVER skip these validation steps**. If any fail, debug and fix before proceeding.
## Project Structure and Key Files
### Critical Backend Files
- `src/index.ts` - Application entry point
- `src/server.ts` - Express server setup and middleware
- `src/services/mcpService.ts` - **Core MCP server management logic**
- `src/config/index.ts` - Configuration management
- `src/routes/` - HTTP route definitions
- `src/controllers/` - HTTP request handlers
- `src/dao/` - Data access layer for users, groups, servers
- `src/types/index.ts` - TypeScript type definitions
### Key Notes
### Critical Frontend Files
- `frontend/src/` - React application source
- `frontend/src/pages/` - Page components (development entry point)
- `frontend/src/components/` - Reusable UI components
- Use ESM modules: Import with `.js` extensions, not `.ts`
- Configuration file: `mcp_settings.json`
- Endpoint formats: `/mcp/{group|server}` and `/mcp/$smart`
- All code comments must be written in English
- Frontend uses i18n with resource files in `locales/` folder
- Server-side code should use appropriate abstraction layers for extensibility and replaceability
### Configuration Files
- `mcp_settings.json` - **MCP server definitions and user accounts**
- `package.json` - Dependencies and scripts
- `tsconfig.json` - TypeScript configuration
- `jest.config.cjs` - Test configuration
- `.eslintrc.json` - Linting rules
## Development Process
### Docker and Deployment
- `Dockerfile` - Multi-stage build with Python base + Node.js
- `entrypoint.sh` - Docker startup script
- `bin/cli.js` - NPM package CLI entry point
- For complex features, implement step by step and wait for confirmation before proceeding to the next step
- After implementing features, no separate summary documentation is needed - update README.md and README.zh.md as appropriate
## Development Process and Conventions
### Code Style Requirements
- **ESM modules**: Always use `.js` extensions in imports, not `.ts`
- **English only**: All code comments must be written in English
- **TypeScript strict**: Follow strict type checking rules
- **Import style**: `import { something } from './file.js'` (note .js extension)
### Key Configuration Notes
- **MCP servers**: Defined in `mcp_settings.json` with command/args
- **Endpoints**: `/mcp/{group|server}` and `/mcp/$smart` for routing
- **i18n**: Frontend uses react-i18next with files in `locales/` folder
- **Authentication**: JWT tokens with bcrypt password hashing
- **Default credentials**: admin/admin123 (configured in mcp_settings.json)
### Development Entry Points
- **Add MCP server**: Modify `mcp_settings.json` and restart
- **New API endpoint**: Add route in `src/routes/`, controller in `src/controllers/`
- **Frontend feature**: Start from `frontend/src/pages/` or `frontend/src/components/`
- **Add tests**: Follow patterns in `tests/` directory
- **MCP Servers**: Modify `src/services/mcpService.ts`
- **API Endpoints**: Add routes in `src/routes/`, controllers in `src/controllers/`
- **Frontend Features**: Start from `frontend/src/pages/`
- **Testing**: Follow existing patterns in `tests/`
### Common Development Tasks
#### Adding a new MCP server:
1. Add server definition to `mcp_settings.json`
2. Restart backend to load new server
3. Check logs for successful connection
4. Test via dashboard or API endpoints
#### API development:
1. Define route in `src/routes/`
2. Implement controller in `src/controllers/`
3. Add types in `src/types/index.ts` if needed
4. Write tests in `tests/controllers/`
#### Frontend development:
1. Create/modify components in `frontend/src/components/`
2. Add pages in `frontend/src/pages/`
3. Update routing if needed
4. Test in development mode with `pnpm frontend:dev`
## Validation and CI Requirements
### Before Committing - ALWAYS Run:
```bash
pnpm lint # Must pass - ~3 seconds
pnpm backend:build # Must compile - ~5 seconds
pnpm test:ci # All tests must pass - ~16 seconds
pnpm build # Full build must work - ~10 seconds
```
**CRITICAL**: CI will fail if any of these commands fail. Fix issues locally first.
### CI Pipeline (.github/workflows/ci.yml)
- Runs on Node.js 20.x
- Tests: linting, type checking, unit tests with coverage
- **NEVER CANCEL**: CI builds may take 2-3 minutes total
## Troubleshooting
### Common Issues
- **"uvx command not found"**: Some MCP servers require `uvx` (Python package manager) - this is expected in development
- **Port already in use**: Change PORT environment variable or kill existing processes
- **Frontend not loading**: Ensure frontend was built with `pnpm frontend:build`
- **MCP server connection failed**: Check server command/args in `mcp_settings.json`
### Build Failures
- **TypeScript errors**: Run `pnpm backend:build` to see compilation errors
- **Test failures**: Run `pnpm test:verbose` for detailed test output
- **Lint errors**: Run `pnpm lint` and fix reported issues
### Development Issues
- **Backend not starting**: Check for port conflicts, verify `mcp_settings.json` syntax
- **Frontend proxy errors**: Ensure backend is running before starting frontend
- **Hot reload not working**: Restart development server
## Performance Notes
- **Install time**: pnpm install takes ~30 seconds
- **Build time**: Full build takes ~10 seconds
- **Test time**: Complete test suite takes ~16 seconds
- **Startup time**: Backend initialization takes 10-15 seconds (MCP server connections)
**Remember**: NEVER CANCEL any build or test commands. Always wait for completion even if they seem slow.

3
.gitignore vendored
View File

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

235
README.fr.md Normal file
View 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.
![Aperçu du tableau de bord](assets/dashboard.zh.png)
## 🌐 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** :
![Routage intelligent](assets/smart-routing.zh.png)
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é)** :
![Gestion des groupes](assets/group.zh.png)
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}
```
`{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}
```
`{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 :
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/samanhappy)
## 🌟 Historique des étoiles
[![Historique des étoiles](https://api.star-history.com/svg?repos=samanhappy/mcphub&type=Date)](https://www.star-history.com/#samanhappy/mcphub&Date)
## 📄 Licence
Sous licence [Apache 2.0 License](LICENSE).

View File

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

View File

@@ -1,6 +1,6 @@
# MCPHub一站式 MCP 服务器聚合平台
[English Version](README.md) | 中文版
[English](README.md) | [Français](README.fr.md) | 中文版
MCPHub 通过将多个 MCPModel Context Protocol服务器组织为灵活的流式 HTTPSSE端点简化了管理与扩展工作。系统支持按需访问全部服务器、单个服务器或按场景分组的服务器集合。

View File

@@ -1,8 +1,7 @@
#!/usr/bin/env node
import path from 'path';
import { fileURLToPath } from 'url';
import { execSync } from 'child_process';
import { fileURLToPath, pathToFileURL } from 'url';
import fs from 'fs';
// Enable debug logging if needed
@@ -90,7 +89,10 @@ checkFrontend(projectRoot);
// Start the server
console.log('🚀 Starting MCPHub server...');
import(path.join(projectRoot, 'dist', 'index.js')).catch(err => {
const entryPath = path.join(projectRoot, 'dist', 'index.js');
// Convert to file:// URL for cross-platform ESM compatibility (required on Windows)
const entryUrl = pathToFileURL(entryPath).href;
import(entryUrl).catch(err => {
console.error('Failed to start MCPHub:', err);
process.exit(1);
});

View File

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

View File

@@ -0,0 +1,210 @@
# MCPHub DAO Layer 实现总结
## 项目概述
本次开发为MCPHub项目引入了独立的数据访问对象(DAO)层,用于管理`mcp_settings.json`中的不同类型数据的增删改查操作。
## 已实现的功能
### 1. 核心DAO层架构
#### 基础架构
- **BaseDao.ts**: 定义了通用的CRUD接口和抽象实现
- **JsonFileBaseDao.ts**: 提供JSON文件操作的基础类包含缓存机制
- **DaoFactory.ts**: 工厂模式实现提供DAO实例的创建和管理
#### 具体DAO实现
1. **UserDao**: 用户数据管理
- 用户创建(含密码哈希)
- 密码验证
- 权限管理
- 管理员查询
2. **ServerDao**: 服务器配置管理
- 服务器CRUD操作
- 按所有者/类型/状态查询
- 工具和提示配置管理
- 启用/禁用控制
3. **GroupDao**: 群组管理
- 群组CRUD操作
- 服务器成员管理
- 按所有者查询
- 群组-服务器关系管理
4. **SystemConfigDao**: 系统配置管理
- 系统级配置的读取和更新
- 分段配置管理
- 配置重置功能
5. **UserConfigDao**: 用户个人配置管理
- 用户个人配置的CRUD操作
- 分段配置管理
- 批量配置查询
### 2. 配置服务集成
#### DaoConfigService
- 使用DAO层重新实现配置加载和保存
- 支持用户权限过滤
- 提供配置合并和验证功能
#### ConfigManager
- 双模式支持:传统文件方式 + 新DAO层
- 运行时切换机制
- 环境变量控制 (`USE_DAO_LAYER`)
- 迁移工具集成
### 3. 迁移和验证工具
#### 迁移功能
- 从传统JSON文件格式迁移到DAO层
- 数据完整性验证
- 性能对比分析
- 迁移报告生成
#### 测试工具
- DAO操作完整性测试
- 示例数据生成和清理
- 性能基准测试
## 文件结构
```
src/
├── dao/ # DAO层核心
│ ├── base/
│ │ ├── BaseDao.ts # 基础DAO接口
│ │ └── JsonFileBaseDao.ts # JSON文件基础类
│ ├── UserDao.ts # 用户数据访问
│ ├── ServerDao.ts # 服务器配置访问
│ ├── GroupDao.ts # 群组数据访问
│ ├── SystemConfigDao.ts # 系统配置访问
│ ├── UserConfigDao.ts # 用户配置访问
│ ├── DaoFactory.ts # DAO工厂
│ ├── examples.ts # 使用示例
│ └── index.ts # 统一导出
├── config/
│ ├── DaoConfigService.ts # DAO配置服务
│ ├── configManager.ts # 配置管理器
│ └── migrationUtils.ts # 迁移工具
├── scripts/
│ └── dao-demo.ts # 演示脚本
└── docs/
└── dao-layer.md # 详细文档
```
## 主要特性
### 1. 类型安全
- 完整的TypeScript类型定义
- 编译时类型检查
- 接口约束和验证
### 2. 模块化设计
- 每种数据类型独立的DAO
- 清晰的关注点分离
- 可插拔的实现方式
### 3. 缓存机制
- JSON文件读取缓存
- 文件修改时间检测
- 缓存失效和刷新
### 4. 向后兼容
- 保持现有API不变
- 支持传统和DAO双模式
- 平滑迁移路径
### 5. 未来扩展性
- 数据库切换准备
- 新数据类型支持
- 复杂查询能力
## 使用方法
### 启用DAO层
```bash
# 环境变量配置
export USE_DAO_LAYER=true
```
### 基本操作示例
```typescript
import { getUserDao, getServerDao } from './dao/index.js';
// 用户操作
const userDao = getUserDao();
await userDao.createWithHashedPassword('admin', 'password', true);
const user = await userDao.findByUsername('admin');
// 服务器操作
const serverDao = getServerDao();
await serverDao.create({
name: 'my-server',
command: 'node',
args: ['server.js']
});
```
### 迁移操作
```typescript
import { migrateToDao, validateMigration } from './config/configManager.js';
// 执行迁移
await migrateToDao();
// 验证迁移
await validateMigration();
```
## 依赖包
新增的依赖包:
- `bcrypt`: 用户密码哈希
- `@types/bcrypt`: bcrypt类型定义
- `uuid`: UUID生成群组ID
- `@types/uuid`: uuid类型定义
## 测试状态
**编译测试**: 项目成功编译无TypeScript错误
**类型检查**: 所有类型定义正确
**依赖安装**: 必要依赖包已安装
**运行时测试**: 需要在实际环境中测试
**迁移测试**: 需要使用真实数据测试迁移
## 下一步计划
### 短期目标
1. 在开发环境中测试DAO层功能
2. 完善错误处理和边界情况
3. 添加更多单元测试
4. 性能优化和监控
### 中期目标
1. 集成到现有业务逻辑中
2. 提供Web界面的DAO层管理
3. 添加数据备份和恢复功能
4. 实现配置版本控制
### 长期目标
1. 实现数据库后端支持
2. 添加分布式配置管理
3. 实现实时配置同步
4. 支持配置审计和日志
## 优势总结
通过引入DAO层MCPHub获得了以下优势
1. **🏗️ 架构清晰**: 数据访问逻辑与业务逻辑分离
2. **🔄 易于扩展**: 为未来数据库支持做好准备
3. **🧪 便于测试**: 接口可以轻松模拟和单元测试
4. **🔒 类型安全**: 完整的TypeScript类型支持
5. **⚡ 性能优化**: 内置缓存和批量操作
6. **🛡️ 数据完整性**: 强制数据验证和约束
7. **📦 模块化**: 每种数据类型独立管理
8. **🔧 可维护性**: 代码结构清晰,易于维护
这个DAO层的实现为MCPHub项目提供了坚实的数据管理基础支持项目的长期发展和扩展需求。

254
docs/dao-layer.md Normal file
View File

@@ -0,0 +1,254 @@
# MCPHub DAO Layer 设计文档
## 概述
MCPHub的数据访问对象(DAO)层为项目中`mcp_settings.json`文件中的不同数据类型提供了统一的增删改查操作接口。这个设计使得未来从JSON文件存储切换到数据库存储变得更加容易。
## 架构设计
### 核心组件
```
src/dao/
├── base/
│ ├── BaseDao.ts # 基础DAO接口和抽象实现
│ └── JsonFileBaseDao.ts # JSON文件操作的基础类
├── UserDao.ts # 用户数据访问对象
├── ServerDao.ts # 服务器配置数据访问对象
├── GroupDao.ts # 群组数据访问对象
├── SystemConfigDao.ts # 系统配置数据访问对象
├── UserConfigDao.ts # 用户配置数据访问对象
├── DaoFactory.ts # DAO工厂类
├── examples.ts # 使用示例
└── index.ts # 统一导出
```
### 数据类型映射
| 数据类型 | 原始位置 | DAO类 | 主要功能 |
|---------|---------|-------|---------|
| IUser | `settings.users[]` | UserDao | 用户管理、密码验证、权限控制 |
| ServerConfig | `settings.mcpServers{}` | ServerDao | 服务器配置、启用/禁用、工具管理 |
| IGroup | `settings.groups[]` | GroupDao | 群组管理、服务器分组、成员管理 |
| SystemConfig | `settings.systemConfig` | SystemConfigDao | 系统级配置、路由设置、安装配置 |
| UserConfig | `settings.userConfigs{}` | UserConfigDao | 用户个人配置 |
## 主要特性
### 1. 统一的CRUD接口
所有DAO都实现了基础的CRUD操作
```typescript
interface BaseDao<T, K = string> {
findAll(): Promise<T[]>;
findById(id: K): Promise<T | null>;
create(entity: Omit<T, 'id'>): Promise<T>;
update(id: K, entity: Partial<T>): Promise<T | null>;
delete(id: K): Promise<boolean>;
exists(id: K): Promise<boolean>;
count(): Promise<number>;
}
```
### 2. 特定业务操作
每个DAO还提供了针对其数据类型的特定操作
#### UserDao 特殊功能
- `createWithHashedPassword()` - 创建用户时自动哈希密码
- `validateCredentials()` - 验证用户凭据
- `updatePassword()` - 更新用户密码
- `findAdmins()` - 查找管理员用户
#### ServerDao 特殊功能
- `findByOwner()` - 按所有者查找服务器
- `findEnabled()` - 查找启用的服务器
- `findByType()` - 按类型查找服务器
- `setEnabled()` - 启用/禁用服务器
- `updateTools()` - 更新服务器工具配置
#### GroupDao 特殊功能
- `findByOwner()` - 按所有者查找群组
- `findByServer()` - 查找包含特定服务器的群组
- `addServerToGroup()` - 向群组添加服务器
- `removeServerFromGroup()` - 从群组移除服务器
- `findByName()` - 按名称查找群组
### 3. 配置管理特殊功能
#### SystemConfigDao
- `getSection()` - 获取特定配置段
- `updateSection()` - 更新特定配置段
- `reset()` - 重置为默认配置
#### UserConfigDao
- `getSection()` - 获取用户特定配置段
- `updateSection()` - 更新用户特定配置段
- `getAll()` - 获取所有用户配置
## 使用方法
### 1. 基本使用
```typescript
import { getUserDao, getServerDao, getGroupDao } from './dao/index.js';
// 用户操作
const userDao = getUserDao();
const newUser = await userDao.createWithHashedPassword('username', 'password', false);
const user = await userDao.findByUsername('username');
const isValid = await userDao.validateCredentials('username', 'password');
// 服务器操作
const serverDao = getServerDao();
const server = await serverDao.create({
name: 'my-server',
command: 'node',
args: ['server.js'],
enabled: true
});
// 群组操作
const groupDao = getGroupDao();
const group = await groupDao.create({
name: 'my-group',
description: 'Test group',
servers: ['my-server']
});
```
### 2. 配置服务集成
```typescript
import { DaoConfigService, createDaoConfigService } from './config/DaoConfigService.js';
const daoService = createDaoConfigService();
// 加载完整配置
const settings = await daoService.loadSettings();
// 保存配置
await daoService.saveSettings(updatedSettings);
```
### 3. 迁移管理
```typescript
import { migrateToDao, switchToDao, switchToLegacy } from './config/configManager.js';
// 迁移到DAO层
const success = await migrateToDao();
// 运行时切换
switchToDao(); // 切换到DAO层
switchToLegacy(); // 切换回传统方式
```
## 配置选项
可以通过环境变量控制使用哪种数据访问方式:
```bash
# 使用DAO层 (推荐)
USE_DAO_LAYER=true
# 使用传统文件方式 (默认,向后兼容)
USE_DAO_LAYER=false
```
## 未来扩展
### 数据库支持
DAO层的设计使得切换到数据库变得容易只需要
1. 实现新的DAO实现类如DatabaseUserDao
2. 创建新的DaoFactory
3. 更新配置以使用新的工厂
```typescript
// 未来的数据库实现示例
class DatabaseUserDao implements UserDao {
constructor(private db: Database) {}
async findAll(): Promise<IUser[]> {
return this.db.query('SELECT * FROM users');
}
// ... 其他方法
}
```
### 新数据类型
添加新数据类型只需要:
1. 定义数据接口
2. 创建对应的DAO接口和实现
3. 更新DaoFactory
4. 更新配置服务
## 迁移指南
### 从传统方式迁移到DAO层
1. **备份数据**
```bash
cp mcp_settings.json mcp_settings.json.backup
```
2. **运行迁移**
```typescript
import { performMigration } from './config/migrationUtils.js';
await performMigration();
```
3. **验证迁移**
```typescript
import { validateMigration } from './config/migrationUtils.js';
const isValid = await validateMigration();
```
4. **切换到DAO层**
```bash
export USE_DAO_LAYER=true
```
### 性能对比
可以使用内置工具对比性能:
```typescript
import { performanceComparison } from './config/migrationUtils.js';
await performanceComparison();
```
## 最佳实践
1. **类型安全**: 始终使用TypeScript接口确保类型安全
2. **错误处理**: 在DAO操作周围实现适当的错误处理
3. **事务**: 对于复杂操作,考虑使用事务(未来数据库实现)
4. **缓存**: DAO层包含内置缓存机制
5. **测试**: 使用DAO接口进行单元测试的模拟
## 示例代码
查看以下文件获取完整示例:
- `src/dao/examples.ts` - 基本DAO操作示例
- `src/config/migrationUtils.ts` - 迁移和验证工具
- `src/scripts/dao-demo.ts` - 交互式演示脚本
## 总结
DAO层为MCPHub提供了
- 🏗️ **模块化设计**: 每种数据类型都有专门的访问层
- 🔄 **易于迁移**: 为未来切换到数据库做好准备
- 🧪 **可测试性**: 接口可以轻松模拟和测试
- 🔒 **类型安全**: 完整的TypeScript类型支持
-**性能优化**: 内置缓存和批量操作支持
- 🛡️ **数据完整性**: 强制数据验证和约束
通过引入DAO层MCPHub的数据管理变得更加结构化、可维护和可扩展。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,22 +25,15 @@ export const callTool = async (
): Promise<ToolCallResult> => {
try {
// Construct the URL with optional server parameter
const url = server ? `/tools/call/${server}` : '/tools/call';
const url = server ? `/tools/${server}/${request.toolName}` : '/tools/call';
const response = await apiPost<any>(
url,
{
toolName: request.toolName,
arguments: request.arguments,
const response = await apiPost<any>(url, request.arguments, {
headers: {
Authorization: `Bearer ${localStorage.getItem('mcphub_token')}`, // Add bearer auth for MCP routing
},
{
headers: {
Authorization: `Bearer ${localStorage.getItem('mcphub_token')}`, // Add bearer auth for MCP routing
},
},
);
});
if (!response.success) {
if (response.success === false) {
return {
success: false,
error: response.message || 'Tool call failed',
@@ -49,7 +42,7 @@ export const callTool = async (
return {
success: true,
content: response.data?.content || [],
content: response?.content || [],
};
} catch (error) {
console.error('Error calling tool:', error);

View File

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

View File

@@ -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
View 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"
}
}
}

View File

@@ -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": "上传",

View File

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

57
package-lock.json generated
View File

@@ -12,11 +12,14 @@
"@apidevtools/swagger-parser": "^11.0.1",
"@modelcontextprotocol/sdk": "^1.17.4",
"@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",
"bcrypt": "^6.0.0",
"bcryptjs": "^3.0.2",
"cors": "^2.8.5",
"dotenv": "^16.6.1",
"dotenv-expand": "^12.0.2",
"express": "^4.21.2",
@@ -46,6 +49,7 @@
"@tailwindcss/postcss": "^4.1.12",
"@tailwindcss/vite": "^4.1.12",
"@types/bcryptjs": "^3.0.0",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.23",
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.10",
@@ -4254,6 +4258,15 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/bcryptjs": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-3.0.0.tgz",
@@ -4291,6 +4304,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/cors": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -5268,6 +5291,20 @@
],
"license": "MIT"
},
"node_modules/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.3.0",
"node-gyp-build": "^4.8.4"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/bcryptjs": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz",
@@ -9966,6 +10003,15 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/node-addon-api": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
"integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
@@ -10005,6 +10051,17 @@
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",

View File

@@ -45,19 +45,22 @@
"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",
"dotenv": "^16.6.1",
"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",
@@ -82,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",
@@ -97,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",

2465
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,265 @@
import { McpSettings, IUser, ServerConfig } from '../types/index.js';
import {
UserDao,
ServerDao,
GroupDao,
SystemConfigDao,
UserConfigDao,
ServerConfigWithName,
UserDaoImpl,
ServerDaoImpl,
GroupDaoImpl,
SystemConfigDaoImpl,
UserConfigDaoImpl,
} from '../dao/index.js';
/**
* Configuration service using DAO layer
*/
export class DaoConfigService {
constructor(
private userDao: UserDao,
private serverDao: ServerDao,
private groupDao: GroupDao,
private systemConfigDao: SystemConfigDao,
private userConfigDao: UserConfigDao,
) {}
/**
* Load complete settings using DAO layer
*/
async loadSettings(user?: IUser): Promise<McpSettings> {
const [users, servers, groups, systemConfig, userConfigs] = await Promise.all([
this.userDao.findAll(),
this.serverDao.findAll(),
this.groupDao.findAll(),
this.systemConfigDao.get(),
this.userConfigDao.getAll(),
]);
// Convert servers back to the original format
const mcpServers: { [key: string]: ServerConfig } = {};
for (const server of servers) {
const { name, ...config } = server;
mcpServers[name] = config;
}
const settings: McpSettings = {
users,
mcpServers,
groups,
systemConfig,
userConfigs,
};
// Apply user-specific filtering if needed
if (user && !user.isAdmin) {
return this.filterSettingsForUser(settings, user);
}
return settings;
}
/**
* Save settings using DAO layer
*/
async saveSettings(settings: McpSettings, user?: IUser): Promise<boolean> {
try {
// If user is not admin, merge with existing settings
if (user && !user.isAdmin) {
const currentSettings = await this.loadSettings();
settings = this.mergeSettingsForUser(currentSettings, settings, user);
}
// Save each component using respective DAOs
const promises: Promise<any>[] = [];
// Save users
if (settings.users) {
// Note: For users, we need to handle creation/updates separately
// since passwords might need hashing
// This is a simplified approach - in practice, you'd want more sophisticated handling
const currentUsers = await this.userDao.findAll();
for (const user of settings.users) {
const existing = currentUsers.find((u: IUser) => u.username === user.username);
if (existing) {
promises.push(this.userDao.update(user.username, user));
} else {
// For new users, we'd need to handle password hashing properly
// This is a placeholder - actual implementation would use createWithHashedPassword
console.warn('Creating new user requires special handling for password hashing');
}
}
}
// Save servers
if (settings.mcpServers) {
const currentServers = await this.serverDao.findAll();
const currentServerNames = new Set(currentServers.map((s: ServerConfigWithName) => s.name));
for (const [name, config] of Object.entries(settings.mcpServers)) {
const serverWithName: ServerConfigWithName = { name, ...config };
if (currentServerNames.has(name)) {
promises.push(this.serverDao.update(name, serverWithName));
} else {
promises.push(this.serverDao.create(serverWithName));
}
}
// Remove servers that are no longer in the settings
for (const existingServer of currentServers) {
if (!settings.mcpServers[existingServer.name]) {
promises.push(this.serverDao.delete(existingServer.name));
}
}
}
// Save groups
if (settings.groups) {
const currentGroups = await this.groupDao.findAll();
const currentGroupIds = new Set(currentGroups.map((g: any) => g.id));
for (const group of settings.groups) {
if (group.id && currentGroupIds.has(group.id)) {
promises.push(this.groupDao.update(group.id, group));
} else {
promises.push(this.groupDao.create(group));
}
}
// Remove groups that are no longer in the settings
const newGroupIds = new Set(settings.groups.map((g) => g.id).filter(Boolean));
for (const existingGroup of currentGroups) {
if (!newGroupIds.has(existingGroup.id)) {
promises.push(this.groupDao.delete(existingGroup.id));
}
}
}
// Save system config
if (settings.systemConfig) {
promises.push(this.systemConfigDao.update(settings.systemConfig));
}
// Save user configs
if (settings.userConfigs) {
for (const [username, config] of Object.entries(settings.userConfigs)) {
promises.push(this.userConfigDao.update(username, config));
}
}
await Promise.all(promises);
return true;
} catch (error) {
console.error('Failed to save settings using DAO layer:', error);
return false;
}
}
/**
* Filter settings for non-admin users
*/
private filterSettingsForUser(settings: McpSettings, user: IUser): McpSettings {
if (user.isAdmin) {
return settings;
}
// Non-admin users can only see their own servers and groups
const filteredServers: { [key: string]: ServerConfig } = {};
for (const [name, config] of Object.entries(settings.mcpServers || {})) {
if (config.owner === user.username || config.owner === undefined) {
filteredServers[name] = config;
}
}
const filteredGroups = (settings.groups || []).filter(
(group) => group.owner === user.username || group.owner === undefined,
);
return {
...settings,
mcpServers: filteredServers,
groups: filteredGroups,
users: [], // Non-admin users can't see user list
systemConfig: {}, // Non-admin users can't see system config
userConfigs: { [user.username]: settings.userConfigs?.[user.username] || {} },
};
}
/**
* Merge settings for non-admin users
*/
private mergeSettingsForUser(
currentSettings: McpSettings,
newSettings: McpSettings,
user: IUser,
): McpSettings {
if (user.isAdmin) {
return newSettings;
}
// Non-admin users can only modify their own servers, groups, and user config
const mergedSettings = { ...currentSettings };
// Merge servers (only user's own servers)
if (newSettings.mcpServers) {
for (const [name, config] of Object.entries(newSettings.mcpServers)) {
const existingConfig = currentSettings.mcpServers?.[name];
if (!existingConfig || existingConfig.owner === user.username) {
mergedSettings.mcpServers = mergedSettings.mcpServers || {};
mergedSettings.mcpServers[name] = { ...config, owner: user.username };
}
}
}
// Merge groups (only user's own groups)
if (newSettings.groups) {
const userGroups = newSettings.groups
.filter((group) => !group.owner || group.owner === user.username)
.map((group) => ({ ...group, owner: user.username }));
const otherGroups = (currentSettings.groups || []).filter(
(group) => group.owner !== user.username,
);
mergedSettings.groups = [...otherGroups, ...userGroups];
}
// Merge user config (only user's own config)
if (newSettings.userConfigs?.[user.username]) {
mergedSettings.userConfigs = mergedSettings.userConfigs || {};
mergedSettings.userConfigs[user.username] = newSettings.userConfigs[user.username];
}
return mergedSettings;
}
/**
* Clear all caches
*/
async clearCache(): Promise<void> {
// DAO implementations handle their own caching
// This could be extended to clear DAO-level caches if needed
}
/**
* Get cache info for debugging
*/
getCacheInfo(): { hasCache: boolean } {
// DAO implementations handle their own caching
return { hasCache: false };
}
}
/**
* Create a DaoConfigService with default DAO implementations
*/
export function createDaoConfigService(): DaoConfigService {
return new DaoConfigService(
new UserDaoImpl(),
new ServerDaoImpl(),
new GroupDaoImpl(),
new SystemConfigDaoImpl(),
new UserConfigDaoImpl(),
);
}

138
src/config/configManager.ts Normal file
View File

@@ -0,0 +1,138 @@
import dotenv from 'dotenv';
import { McpSettings, IUser } from '../types/index.js';
import { getPackageVersion } from '../utils/version.js';
import { getDataService } from '../services/services.js';
import { DataService } from '../services/dataService.js';
import { DaoConfigService, createDaoConfigService } from './DaoConfigService.js';
import {
loadOriginalSettings as legacyLoadSettings,
saveSettings as legacySaveSettings,
clearSettingsCache as legacyClearCache,
} from './index.js';
dotenv.config();
const defaultConfig = {
port: process.env.PORT || 3000,
initTimeout: process.env.INIT_TIMEOUT || 300000,
basePath: process.env.BASE_PATH || '',
readonly: 'true' === process.env.READONLY || false,
mcpHubName: 'mcphub',
mcpHubVersion: getPackageVersion(),
};
// Configuration for which data access method to use
const USE_DAO_LAYER = process.env.USE_DAO_LAYER === 'true';
// Services
const dataService: DataService = getDataService();
const daoConfigService: DaoConfigService = createDaoConfigService();
/**
* Load settings using either DAO layer or legacy file-based approach
*/
export const loadSettings = async (user?: IUser): Promise<McpSettings> => {
if (USE_DAO_LAYER) {
console.log('Loading settings using DAO layer');
return await daoConfigService.loadSettings(user);
} else {
console.log('Loading settings using legacy approach');
const settings = legacyLoadSettings();
return dataService.filterSettings!(settings, user);
}
};
/**
* Save settings using either DAO layer or legacy file-based approach
*/
export const saveSettings = async (settings: McpSettings, user?: IUser): Promise<boolean> => {
if (USE_DAO_LAYER) {
console.log('Saving settings using DAO layer');
return await daoConfigService.saveSettings(settings, user);
} else {
console.log('Saving settings using legacy approach');
const mergedSettings = dataService.mergeSettings!(legacyLoadSettings(), settings, user);
return legacySaveSettings(mergedSettings, user);
}
};
/**
* Clear settings cache
*/
export const clearSettingsCache = (): void => {
if (USE_DAO_LAYER) {
daoConfigService.clearCache();
} else {
legacyClearCache();
}
};
/**
* Get current cache status (for debugging)
*/
export const getSettingsCacheInfo = (): { hasCache: boolean; usingDao: boolean } => {
if (USE_DAO_LAYER) {
const daoInfo = daoConfigService.getCacheInfo();
return {
...daoInfo,
usingDao: true,
};
} else {
return {
hasCache: false, // Legacy method doesn't expose cache info here
usingDao: false,
};
}
};
/**
* Switch to DAO layer at runtime (for testing/migration purposes)
*/
export const switchToDao = (): void => {
process.env.USE_DAO_LAYER = 'true';
};
/**
* Switch to legacy file-based approach at runtime (for testing/rollback purposes)
*/
export const switchToLegacy = (): void => {
process.env.USE_DAO_LAYER = 'false';
};
/**
* Get DAO config service for direct access
*/
export const getDaoConfigService = (): DaoConfigService => {
return daoConfigService;
};
/**
* Migration utility to migrate from legacy format to DAO layer
*/
export const migrateToDao = async (): Promise<boolean> => {
try {
console.log('Starting migration from legacy format to DAO layer...');
// Load data using legacy method
const legacySettings = legacyLoadSettings();
// Save using DAO layer
switchToDao();
const success = await saveSettings(legacySettings);
if (success) {
console.log('Migration completed successfully');
return true;
} else {
console.error('Migration failed during save operation');
switchToLegacy();
return false;
}
} catch (error) {
console.error('Migration failed:', error);
switchToLegacy();
return false;
}
};
export default defaultConfig;

View File

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

View File

@@ -0,0 +1,241 @@
/**
* Migration utilities for moving from legacy file-based config to DAO layer
*/
import { loadSettings, migrateToDao, switchToDao, switchToLegacy } from './configManager.js';
import { UserDaoImpl, ServerDaoImpl, GroupDaoImpl } from '../dao/index.js';
/**
* Validate data integrity after migration
*/
export async function validateMigration(): Promise<boolean> {
try {
console.log('Validating migration...');
// Load settings using DAO layer
switchToDao();
const daoSettings = await loadSettings();
// Load settings using legacy method
switchToLegacy();
const legacySettings = await loadSettings();
// Compare key metrics
const daoUserCount = daoSettings.users?.length || 0;
const legacyUserCount = legacySettings.users?.length || 0;
const daoServerCount = Object.keys(daoSettings.mcpServers || {}).length;
const legacyServerCount = Object.keys(legacySettings.mcpServers || {}).length;
const daoGroupCount = daoSettings.groups?.length || 0;
const legacyGroupCount = legacySettings.groups?.length || 0;
console.log('Data comparison:');
console.log(`Users: DAO=${daoUserCount}, Legacy=${legacyUserCount}`);
console.log(`Servers: DAO=${daoServerCount}, Legacy=${legacyServerCount}`);
console.log(`Groups: DAO=${daoGroupCount}, Legacy=${legacyGroupCount}`);
const isValid =
daoUserCount === legacyUserCount &&
daoServerCount === legacyServerCount &&
daoGroupCount === legacyGroupCount;
if (isValid) {
console.log('✅ Migration validation passed');
} else {
console.log('❌ Migration validation failed');
}
return isValid;
} catch (error) {
console.error('Migration validation error:', error);
return false;
}
}
/**
* Perform a complete migration with validation
*/
export async function performMigration(): Promise<boolean> {
try {
console.log('🚀 Starting migration to DAO layer...');
// Step 1: Backup current data
console.log('📁 Creating backup of current data...');
switchToLegacy();
const _backupData = await loadSettings();
// Step 2: Perform migration
console.log('🔄 Migrating data to DAO layer...');
const migrationSuccess = await migrateToDao();
if (!migrationSuccess) {
console.error('❌ Migration failed');
return false;
}
// Step 3: Validate migration
console.log('🔍 Validating migration...');
const validationSuccess = await validateMigration();
if (!validationSuccess) {
console.error('❌ Migration validation failed');
// Could implement rollback here if needed
return false;
}
console.log('✅ Migration completed successfully!');
console.log('💡 You can now use the DAO layer by setting USE_DAO_LAYER=true');
return true;
} catch (error) {
console.error('Migration error:', error);
return false;
}
}
/**
* Test DAO operations with sample data
*/
export async function testDaoOperations(): Promise<boolean> {
try {
console.log('🧪 Testing DAO operations...');
switchToDao();
const userDao = new UserDaoImpl();
const serverDao = new ServerDaoImpl();
const groupDao = new GroupDaoImpl();
// Test user operations
console.log('Testing user operations...');
const testUser = await userDao.createWithHashedPassword('test-dao-user', 'password123', false);
console.log(`✅ Created test user: ${testUser.username}`);
const foundUser = await userDao.findByUsername('test-dao-user');
console.log(`✅ Found user: ${foundUser?.username}`);
const isValidPassword = await userDao.validateCredentials('test-dao-user', 'password123');
console.log(`✅ Password validation: ${isValidPassword}`);
// Test server operations
console.log('Testing server operations...');
const testServer = await serverDao.create({
name: 'test-dao-server',
command: 'node',
args: ['test.js'],
enabled: true,
owner: 'test-dao-user',
});
console.log(`✅ Created test server: ${testServer.name}`);
const userServers = await serverDao.findByOwner('test-dao-user');
console.log(`✅ Found ${userServers.length} servers for user`);
// Test group operations
console.log('Testing group operations...');
const testGroup = await groupDao.create({
name: 'test-dao-group',
description: 'Test group for DAO operations',
servers: ['test-dao-server'],
owner: 'test-dao-user',
});
console.log(`✅ Created test group: ${testGroup.name} (ID: ${testGroup.id})`);
const userGroups = await groupDao.findByOwner('test-dao-user');
console.log(`✅ Found ${userGroups.length} groups for user`);
// Cleanup test data
console.log('Cleaning up test data...');
await groupDao.delete(testGroup.id);
await serverDao.delete('test-dao-server');
await userDao.delete('test-dao-user');
console.log('✅ Test data cleaned up');
console.log('🎉 All DAO operations test passed!');
return true;
} catch (error) {
console.error('DAO operations test error:', error);
return false;
}
}
/**
* Performance comparison between legacy and DAO approaches
*/
export async function performanceComparison(): Promise<void> {
try {
console.log('⚡ Performance comparison...');
// Test legacy approach
console.log('Testing legacy approach...');
switchToLegacy();
const legacyStart = Date.now();
await loadSettings();
const legacyTime = Date.now() - legacyStart;
console.log(`Legacy load time: ${legacyTime}ms`);
// Test DAO approach
console.log('Testing DAO approach...');
switchToDao();
const daoStart = Date.now();
await loadSettings();
const daoTime = Date.now() - daoStart;
console.log(`DAO load time: ${daoTime}ms`);
// Comparison
const difference = daoTime - legacyTime;
const percentage = ((difference / legacyTime) * 100).toFixed(2);
console.log(`Performance difference: ${difference}ms (${percentage}%)`);
if (difference > 0) {
console.log(`DAO approach is ${percentage}% slower`);
} else {
console.log(`DAO approach is ${Math.abs(parseFloat(percentage))}% faster`);
}
} catch (error) {
console.error('Performance comparison error:', error);
}
}
/**
* Generate migration report
*/
export async function generateMigrationReport(): Promise<any> {
try {
console.log('📊 Generating migration report...');
// Collect statistics from both approaches
switchToLegacy();
const legacySettings = await loadSettings();
switchToDao();
const daoSettings = await loadSettings();
const report = {
timestamp: new Date().toISOString(),
legacy: {
users: legacySettings.users?.length || 0,
servers: Object.keys(legacySettings.mcpServers || {}).length,
groups: legacySettings.groups?.length || 0,
systemConfigSections: Object.keys(legacySettings.systemConfig || {}).length,
userConfigs: Object.keys(legacySettings.userConfigs || {}).length,
},
dao: {
users: daoSettings.users?.length || 0,
servers: Object.keys(daoSettings.mcpServers || {}).length,
groups: daoSettings.groups?.length || 0,
systemConfigSections: Object.keys(daoSettings.systemConfig || {}).length,
userConfigs: Object.keys(daoSettings.userConfigs || {}).length,
},
};
console.log('📈 Migration Report:');
console.log(JSON.stringify(report, null, 2));
return report;
} catch (error) {
console.error('Report generation error:', error);
return null;
}
}

View File

@@ -31,7 +31,7 @@ export const streamLogs = (req: Request, res: Response): void => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
Connection: 'keep-alive',
});
// Send initial data
@@ -52,4 +52,4 @@ export const streamLogs = (req: Request, res: Response): void => {
console.error('Error streaming logs:', error);
res.status(500).json({ success: false, error: 'Error streaming logs' });
}
};
};

View File

@@ -7,7 +7,7 @@ import {
getMarketTags,
searchMarketServers,
filterMarketServersByCategory,
filterMarketServersByTag
filterMarketServersByTag,
} from '../services/marketService.js';
// Get all market servers
@@ -100,7 +100,7 @@ export const searchMarketServersByQuery = (req: Request, res: Response): void =>
try {
const { query } = req.query;
const searchQuery = typeof query === 'string' ? query : '';
const servers = searchMarketServers(searchQuery);
const response: ApiResponse = {
success: true,
@@ -119,7 +119,7 @@ export const searchMarketServersByQuery = (req: Request, res: Response): void =>
export const getMarketServersByCategory = (req: Request, res: Response): void => {
try {
const { category } = req.params;
const servers = filterMarketServersByCategory(category);
const response: ApiResponse = {
success: true,
@@ -138,7 +138,7 @@ export const getMarketServersByCategory = (req: Request, res: Response): void =>
export const getMarketServersByTag = (req: Request, res: Response): void => {
try {
const { tag } = req.params;
const servers = filterMarketServersByTag(tag);
const response: ApiResponse = {
success: true,
@@ -151,4 +151,4 @@ export const getMarketServersByTag = (req: Request, res: Response): void => {
message: 'Failed to filter market servers by tag',
});
}
};
};

View File

@@ -1,21 +1,94 @@
import { Request, Response } from 'express';
import {
generateOpenAPISpec,
getAvailableServers,
import {
generateOpenAPISpec,
getAvailableServers,
getToolStats,
OpenAPIGenerationOptions
OpenAPIGenerationOptions,
} 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
* Provides OpenAPI specifications for MCP tools to enable OpenWebUI integration
*/
/**
* Convert query parameters to their proper types based on the tool's input schema
*/
function convertQueryParametersToTypes(
queryParams: Record<string, any>,
inputSchema: Record<string, any>,
): Record<string, any> {
if (!inputSchema || typeof inputSchema !== 'object' || !inputSchema.properties) {
return queryParams;
}
const convertedParams: Record<string, any> = {};
const properties = inputSchema.properties;
for (const [key, value] of Object.entries(queryParams)) {
const propDef = properties[key];
if (!propDef || typeof propDef !== 'object') {
// No schema definition found, keep as is
convertedParams[key] = value;
continue;
}
const propType = propDef.type;
try {
switch (propType) {
case 'integer':
case 'number':
// Convert string to number
if (typeof value === 'string') {
const numValue = propType === 'integer' ? parseInt(value, 10) : parseFloat(value);
convertedParams[key] = isNaN(numValue) ? value : numValue;
} else {
convertedParams[key] = value;
}
break;
case 'boolean':
// Convert string to boolean
if (typeof value === 'string') {
convertedParams[key] = value.toLowerCase() === 'true' || value === '1';
} else {
convertedParams[key] = value;
}
break;
case 'array':
// Handle array conversion if needed (e.g., comma-separated strings)
if (typeof value === 'string' && value.includes(',')) {
convertedParams[key] = value.split(',').map((item) => item.trim());
} else {
convertedParams[key] = value;
}
break;
default:
// For string and other types, keep as is
convertedParams[key] = value;
break;
}
} catch (error) {
// If conversion fails, keep the original value
console.warn(`Failed to convert parameter '${key}' to type '${propType}':`, error);
convertedParams[key] = value;
}
}
return convertedParams;
}
/**
* Generate and return OpenAPI specification
* GET /api/openapi.json
*/
export const getOpenAPISpec = (req: Request, res: Response): void => {
export const getOpenAPISpec = async (req: Request, res: Response): Promise<void> => {
try {
const options: OpenAPIGenerationOptions = {
title: req.query.title as string,
@@ -24,22 +97,22 @@ export const getOpenAPISpec = (req: Request, res: Response): void => {
serverUrl: req.query.serverUrl as string,
includeDisabledTools: req.query.includeDisabled === 'true',
groupFilter: req.query.group as string,
serverFilter: req.query.servers ? (req.query.servers as string).split(',') : undefined
serverFilter: req.query.servers ? (req.query.servers as string).split(',') : undefined,
};
const openApiSpec = generateOpenAPISpec(options);
const openApiSpec = await generateOpenAPISpec(options);
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.json(openApiSpec);
} catch (error) {
console.error('Error generating OpenAPI specification:', error);
res.status(500).json({
error: 'Failed to generate OpenAPI specification',
message: error instanceof Error ? error.message : 'Unknown error'
message: error instanceof Error ? error.message : 'Unknown error',
});
}
};
@@ -48,19 +121,19 @@ export const getOpenAPISpec = (req: Request, res: Response): void => {
* Get available servers for filtering
* GET /api/openapi/servers
*/
export const getOpenAPIServers = (req: Request, res: Response): void => {
export const getOpenAPIServers = async (req: Request, res: Response): Promise<void> => {
try {
const servers = getAvailableServers();
const servers = await getAvailableServers();
res.json({
success: true,
data: servers
data: servers,
});
} catch (error) {
console.error('Error getting available servers:', error);
res.status(500).json({
success: false,
error: 'Failed to get available servers',
message: error instanceof Error ? error.message : 'Unknown error'
message: error instanceof Error ? error.message : 'Unknown error',
});
}
};
@@ -69,19 +142,19 @@ export const getOpenAPIServers = (req: Request, res: Response): void => {
* Get tool statistics
* GET /api/openapi/stats
*/
export const getOpenAPIStats = (req: Request, res: Response): void => {
export const getOpenAPIStats = async (req: Request, res: Response): Promise<void> => {
try {
const stats = getToolStats();
const stats = await getToolStats();
res.json({
success: true,
data: stats
data: stats,
});
} catch (error) {
console.error('Error getting tool statistics:', error);
res.status(500).json({
success: false,
error: 'Failed to get tool statistics',
message: error instanceof Error ? error.message : 'Unknown error'
message: error instanceof Error ? error.message : 'Unknown error',
});
}
};
@@ -95,15 +168,29 @@ export const getOpenAPIStats = (req: Request, res: Response): void => {
export const executeToolViaOpenAPI = async (req: Request, res: Response): Promise<void> => {
try {
const { serverName, toolName } = req.params;
// Import handleCallToolRequest function
const { handleCallToolRequest } = await import('../services/mcpService.js');
// Get the server info to access the tool's input schema
const serverInfo = getServerByName(serverName);
let inputSchema: Record<string, any> = {};
if (serverInfo) {
// Find the tool in the server's tools list
const fullToolName = `${serverName}${getNameSeparator()}${toolName}`;
const tool = serverInfo.tools.find(
(t: any) => t.name === fullToolName || t.name === toolName,
);
if (tool && tool.inputSchema) {
inputSchema = tool.inputSchema as Record<string, any>;
}
}
// Prepare arguments from query params (GET) or body (POST)
const args = req.method === 'GET'
? req.query
: req.body || {};
let args = req.method === 'GET' ? req.query : req.body || {};
args = convertQueryParametersToTypes(args, inputSchema);
// Create a mock request structure that matches what handleCallToolRequest expects
const mockRequest = {
params: {
@@ -111,24 +198,109 @@ export const executeToolViaOpenAPI = async (req: Request, res: Response): Promis
arguments: args,
},
};
const extra = {
sessionId: req.headers['x-session-id'] as string || 'openapi-session',
sessionId: (req.headers['x-session-id'] as string) || 'openapi-session',
server: serverName,
headers: req.headers, // Pass all request headers for potential passthrough
};
console.log(`OpenAPI tool execution: ${serverName}/${toolName} with args:`, args);
const result = await handleCallToolRequest(mockRequest, extra);
// Return the result in OpenAPI format (matching MCP tool response structure)
res.json(result);
} catch (error) {
console.error('Error executing tool via OpenAPI:', error);
res.status(500).json({
error: 'Failed to execute tool',
message: error instanceof Error ? error.message : 'Unknown error'
message: error instanceof Error ? error.message : 'Unknown error',
});
}
};
};
/**
* Generate and return OpenAPI specification for a specific server
* GET /api/openapi/:name.json
*/
export const getServerOpenAPISpec = async (req: Request, res: Response): Promise<void> => {
try {
const { name } = req.params;
// Check if server exists
const availableServers = await getAvailableServers();
if (!availableServers.includes(name)) {
res.status(404).json({
error: 'Server not found',
message: `Server '${name}' is not connected or does not exist`,
});
return;
}
const options: OpenAPIGenerationOptions = {
title: (req.query.title as string) || `${name} MCP API`,
description:
(req.query.description as string) || `OpenAPI specification for ${name} MCP server tools`,
version: req.query.version as string,
serverUrl: req.query.serverUrl as string,
includeDisabledTools: req.query.includeDisabled === 'true',
serverFilter: [name], // Filter to only this server
};
const openApiSpec = await generateOpenAPISpec(options);
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.json(openApiSpec);
} catch (error) {
console.error('Error generating server OpenAPI specification:', error);
res.status(500).json({
error: 'Failed to generate server OpenAPI specification',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
};
/**
* Generate and return OpenAPI specification for a specific group
* GET /api/openapi/group/:groupName.json
*/
export const getGroupOpenAPISpec = async (req: Request, res: Response): Promise<void> => {
try {
const { name } = req.params;
// Check if group exists
const group = getGroupByIdOrName(name);
if (!group) {
getServerOpenAPISpec(req, res);
return;
}
const options: OpenAPIGenerationOptions = {
title: (req.query.title as string) || `${group.name} Group MCP API`,
description:
(req.query.description as string) || `OpenAPI specification for ${group.name} group tools`,
version: req.query.version as string,
serverUrl: req.query.serverUrl as string,
includeDisabledTools: req.query.includeDisabled === 'true',
groupFilter: name, // Use existing group filter functionality
};
const openApiSpec = await generateOpenAPISpec(options);
res.setHeader('Content-Type', 'application/json');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.json(openApiSpec);
} catch (error) {
console.error('Error generating group OpenAPI specification:', error);
res.status(500).json({
error: 'Failed to generate group OpenAPI specification',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
};

View File

@@ -17,7 +17,7 @@ export const getPrompt = async (req: Request, res: Response): Promise<void> => {
}
const promptArgs = {
params: req.body as { [key: string]: any }
params: req.body as { [key: string]: any },
};
const result = await handleGetPromptRequest(promptArgs, serverName);
if (result.isError) {

View File

@@ -13,9 +13,9 @@ import { loadSettings, saveSettings } from '../config/index.js';
import { syncAllServerToolsEmbeddings } from '../services/vectorSearchService.js';
import { createSafeJSON } from '../utils/serialization.js';
export const getAllServers = (_: Request, res: Response): void => {
export const getAllServers = async (_: Request, res: Response): Promise<void> => {
try {
const serversInfo = getServersInfo();
const serversInfo = await getServersInfo();
const response: ApiResponse = {
success: true,
data: createSafeJSON(serversInfo),
@@ -167,7 +167,7 @@ export const deleteServer = async (req: Request, res: Response): Promise<void> =
return;
}
const result = removeServer(name);
const result = await removeServer(name);
if (result.success) {
notifyToolChanged();
res.json({
@@ -299,11 +299,12 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
}
};
export const getServerConfig = (req: Request, res: Response): void => {
export const getServerConfig = async (req: Request, res: Response): Promise<void> => {
try {
const { name } = req.params;
const settings = loadSettings();
if (!settings.mcpServers || !settings.mcpServers[name]) {
const allServers = await getServersInfo();
const serverInfo = allServers.find((s) => s.name === name);
if (!serverInfo) {
res.status(404).json({
success: false,
message: 'Server not found',
@@ -311,15 +312,13 @@ export const getServerConfig = (req: Request, res: Response): void => {
return;
}
const serverInfo = getServersInfo().find((s) => s.name === name);
const serverConfig = settings.mcpServers[name];
const response: ApiResponse = {
success: true,
data: {
name,
status: serverInfo ? serverInfo.status : 'disconnected',
tools: serverInfo ? serverInfo.tools : [],
config: serverConfig,
config: serverInfo,
},
};
@@ -505,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 (
@@ -529,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,
@@ -711,6 +711,10 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
}
}
if (typeof nameSeparator === 'string') {
settings.systemConfig.nameSeparator = nameSeparator;
}
if (saveSettings(settings, currentUser)) {
res.json({
success: true,

View File

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

131
src/dao/DaoFactory.ts Normal file
View File

@@ -0,0 +1,131 @@
import { UserDao, UserDaoImpl } from './UserDao.js';
import { ServerDao, ServerDaoImpl } from './ServerDao.js';
import { GroupDao, GroupDaoImpl } from './GroupDao.js';
import { SystemConfigDao, SystemConfigDaoImpl } from './SystemConfigDao.js';
import { UserConfigDao, UserConfigDaoImpl } from './UserConfigDao.js';
/**
* DAO Factory interface for creating DAO instances
*/
export interface DaoFactory {
getUserDao(): UserDao;
getServerDao(): ServerDao;
getGroupDao(): GroupDao;
getSystemConfigDao(): SystemConfigDao;
getUserConfigDao(): UserConfigDao;
}
/**
* Default DAO factory implementation using JSON file-based DAOs
*/
export class JsonFileDaoFactory implements DaoFactory {
private static instance: JsonFileDaoFactory;
private userDao: UserDao | null = null;
private serverDao: ServerDao | null = null;
private groupDao: GroupDao | null = null;
private systemConfigDao: SystemConfigDao | null = null;
private userConfigDao: UserConfigDao | null = null;
/**
* Get singleton instance
*/
public static getInstance(): JsonFileDaoFactory {
if (!JsonFileDaoFactory.instance) {
JsonFileDaoFactory.instance = new JsonFileDaoFactory();
}
return JsonFileDaoFactory.instance;
}
private constructor() {
// Private constructor for singleton
}
getUserDao(): UserDao {
if (!this.userDao) {
this.userDao = new UserDaoImpl();
}
return this.userDao;
}
getServerDao(): ServerDao {
if (!this.serverDao) {
this.serverDao = new ServerDaoImpl();
}
return this.serverDao;
}
getGroupDao(): GroupDao {
if (!this.groupDao) {
this.groupDao = new GroupDaoImpl();
}
return this.groupDao;
}
getSystemConfigDao(): SystemConfigDao {
if (!this.systemConfigDao) {
this.systemConfigDao = new SystemConfigDaoImpl();
}
return this.systemConfigDao;
}
getUserConfigDao(): UserConfigDao {
if (!this.userConfigDao) {
this.userConfigDao = new UserConfigDaoImpl();
}
return this.userConfigDao;
}
/**
* Reset all cached DAO instances (useful for testing)
*/
public resetInstances(): void {
this.userDao = null;
this.serverDao = null;
this.groupDao = null;
this.systemConfigDao = null;
this.userConfigDao = null;
}
}
/**
* Global DAO factory instance
*/
let daoFactory: DaoFactory = JsonFileDaoFactory.getInstance();
/**
* Set the global DAO factory (useful for dependency injection)
*/
export function setDaoFactory(factory: DaoFactory): void {
daoFactory = factory;
}
/**
* Get the global DAO factory
*/
export function getDaoFactory(): DaoFactory {
return daoFactory;
}
/**
* Convenience functions to get specific DAOs
*/
export function getUserDao(): UserDao {
return getDaoFactory().getUserDao();
}
export function getServerDao(): ServerDao {
return getDaoFactory().getServerDao();
}
export function getGroupDao(): GroupDao {
return getDaoFactory().getGroupDao();
}
export function getSystemConfigDao(): SystemConfigDao {
return getDaoFactory().getSystemConfigDao();
}
export function getUserConfigDao(): UserConfigDao {
return getDaoFactory().getUserConfigDao();
}

221
src/dao/GroupDao.ts Normal file
View File

@@ -0,0 +1,221 @@
import { IGroup } from '../types/index.js';
import { BaseDao } from './base/BaseDao.js';
import { JsonFileBaseDao } from './base/JsonFileBaseDao.js';
import { v4 as uuidv4 } from 'uuid';
/**
* Group DAO interface with group-specific operations
*/
export interface GroupDao extends BaseDao<IGroup, string> {
/**
* Find groups by owner
*/
findByOwner(owner: string): Promise<IGroup[]>;
/**
* Find groups containing specific server
*/
findByServer(serverName: string): Promise<IGroup[]>;
/**
* Add server to group
*/
addServerToGroup(groupId: string, serverName: string): Promise<boolean>;
/**
* Remove server from group
*/
removeServerFromGroup(groupId: string, serverName: string): Promise<boolean>;
/**
* Update group servers
*/
updateServers(groupId: string, servers: string[] | IGroup['servers']): Promise<boolean>;
/**
* Find group by name
*/
findByName(name: string): Promise<IGroup | null>;
}
/**
* JSON file-based Group DAO implementation
*/
export class GroupDaoImpl extends JsonFileBaseDao implements GroupDao {
protected async getAll(): Promise<IGroup[]> {
const settings = await this.loadSettings();
return settings.groups || [];
}
protected async saveAll(groups: IGroup[]): Promise<void> {
const settings = await this.loadSettings();
settings.groups = groups;
await this.saveSettings(settings);
}
protected getEntityId(group: IGroup): string {
return group.id;
}
protected createEntity(data: Omit<IGroup, 'id'>): IGroup {
return {
id: uuidv4(),
owner: 'admin', // Default owner
...data,
servers: data.servers || [],
};
}
protected updateEntity(existing: IGroup, updates: Partial<IGroup>): IGroup {
return {
...existing,
...updates,
id: existing.id, // ID should not be updated
};
}
async findAll(): Promise<IGroup[]> {
return this.getAll();
}
async findById(id: string): Promise<IGroup | null> {
const groups = await this.getAll();
return groups.find((group) => group.id === id) || null;
}
async create(data: Omit<IGroup, 'id'>): Promise<IGroup> {
const groups = await this.getAll();
// Check if group name already exists
if (groups.find((group) => group.name === data.name)) {
throw new Error(`Group with name ${data.name} already exists`);
}
const newGroup = this.createEntity(data);
groups.push(newGroup);
await this.saveAll(groups);
return newGroup;
}
async update(id: string, updates: Partial<IGroup>): Promise<IGroup | null> {
const groups = await this.getAll();
const index = groups.findIndex((group) => group.id === id);
if (index === -1) {
return null;
}
// Check if name update would cause conflict
if (updates.name && updates.name !== groups[index].name) {
const existingGroup = groups.find((group) => group.name === updates.name && group.id !== id);
if (existingGroup) {
throw new Error(`Group with name ${updates.name} already exists`);
}
}
// Don't allow ID changes
const { id: _, ...allowedUpdates } = updates;
const updatedGroup = this.updateEntity(groups[index], allowedUpdates);
groups[index] = updatedGroup;
await this.saveAll(groups);
return updatedGroup;
}
async delete(id: string): Promise<boolean> {
const groups = await this.getAll();
const index = groups.findIndex((group) => group.id === id);
if (index === -1) {
return false;
}
groups.splice(index, 1);
await this.saveAll(groups);
return true;
}
async exists(id: string): Promise<boolean> {
const group = await this.findById(id);
return group !== null;
}
async count(): Promise<number> {
const groups = await this.getAll();
return groups.length;
}
async findByOwner(owner: string): Promise<IGroup[]> {
const groups = await this.getAll();
return groups.filter((group) => group.owner === owner);
}
async findByServer(serverName: string): Promise<IGroup[]> {
const groups = await this.getAll();
return groups.filter((group) => {
if (Array.isArray(group.servers)) {
return group.servers.some((server) => {
if (typeof server === 'string') {
return server === serverName;
} else {
return server.name === serverName;
}
});
}
return false;
});
}
async addServerToGroup(groupId: string, serverName: string): Promise<boolean> {
const group = await this.findById(groupId);
if (!group) {
return false;
}
// Check if server already exists in group
const serverExists = group.servers.some((server) => {
if (typeof server === 'string') {
return server === serverName;
} else {
return server.name === serverName;
}
});
if (serverExists) {
return true; // Already exists, consider it success
}
const updatedServers = [...group.servers, serverName] as IGroup['servers'];
const result = await this.update(groupId, { servers: updatedServers });
return result !== null;
}
async removeServerFromGroup(groupId: string, serverName: string): Promise<boolean> {
const group = await this.findById(groupId);
if (!group) {
return false;
}
const updatedServers = group.servers.filter((server) => {
if (typeof server === 'string') {
return server !== serverName;
} else {
return server.name !== serverName;
}
}) as IGroup['servers'];
const result = await this.update(groupId, { servers: updatedServers });
return result !== null;
}
async updateServers(groupId: string, servers: string[] | IGroup['servers']): Promise<boolean> {
const result = await this.update(groupId, { servers });
return result !== null;
}
async findByName(name: string): Promise<IGroup | null> {
const groups = await this.getAll();
return groups.find((group) => group.name === name) || null;
}
}

210
src/dao/ServerDao.ts Normal file
View File

@@ -0,0 +1,210 @@
import { ServerConfig } from '../types/index.js';
import { BaseDao } from './base/BaseDao.js';
import { JsonFileBaseDao } from './base/JsonFileBaseDao.js';
/**
* Server DAO interface with server-specific operations
*/
export interface ServerDao extends BaseDao<ServerConfigWithName, string> {
/**
* Find servers by owner
*/
findByOwner(owner: string): Promise<ServerConfigWithName[]>;
/**
* Find enabled servers only
*/
findEnabled(): Promise<ServerConfigWithName[]>;
/**
* Find servers by type
*/
findByType(type: string): Promise<ServerConfigWithName[]>;
/**
* Enable/disable server
*/
setEnabled(name: string, enabled: boolean): Promise<boolean>;
/**
* Update server tools configuration
*/
updateTools(
name: string,
tools: Record<string, { enabled: boolean; description?: string }>,
): Promise<boolean>;
/**
* Update server prompts configuration
*/
updatePrompts(
name: string,
prompts: Record<string, { enabled: boolean; description?: string }>,
): Promise<boolean>;
}
/**
* Server configuration with name for DAO operations
*/
export interface ServerConfigWithName extends ServerConfig {
name: string;
}
/**
* JSON file-based Server DAO implementation
*/
export class ServerDaoImpl extends JsonFileBaseDao implements ServerDao {
protected async getAll(): Promise<ServerConfigWithName[]> {
const settings = await this.loadSettings();
const servers: ServerConfigWithName[] = [];
for (const [name, config] of Object.entries(settings.mcpServers || {})) {
servers.push({
name,
...config,
});
}
return servers;
}
protected async saveAll(servers: ServerConfigWithName[]): Promise<void> {
const settings = await this.loadSettings();
settings.mcpServers = {};
for (const server of servers) {
const { name, ...config } = server;
settings.mcpServers[name] = config;
}
await this.saveSettings(settings);
}
protected getEntityId(server: ServerConfigWithName): string {
return server.name;
}
protected createEntity(_data: Omit<ServerConfigWithName, 'name'>): ServerConfigWithName {
throw new Error('Server name must be provided');
}
protected updateEntity(
existing: ServerConfigWithName,
updates: Partial<ServerConfigWithName>,
): ServerConfigWithName {
return {
...existing,
...updates,
name: existing.name, // Name should not be updated
};
}
async findAll(): Promise<ServerConfigWithName[]> {
return this.getAll();
}
async findById(name: string): Promise<ServerConfigWithName | null> {
const servers = await this.getAll();
return servers.find((server) => server.name === name) || null;
}
async create(
data: Omit<ServerConfigWithName, 'name'> & { name: string },
): Promise<ServerConfigWithName> {
const servers = await this.getAll();
// Check if server already exists
if (servers.find((server) => server.name === data.name)) {
throw new Error(`Server ${data.name} already exists`);
}
const newServer: ServerConfigWithName = {
enabled: true, // Default to enabled
owner: 'admin', // Default owner
...data,
};
servers.push(newServer);
await this.saveAll(servers);
return newServer;
}
async update(
name: string,
updates: Partial<ServerConfigWithName>,
): Promise<ServerConfigWithName | null> {
const servers = await this.getAll();
const index = servers.findIndex((server) => server.name === name);
if (index === -1) {
return null;
}
// Don't allow name changes
const { name: _, ...allowedUpdates } = updates;
const updatedServer = this.updateEntity(servers[index], allowedUpdates);
servers[index] = updatedServer;
await this.saveAll(servers);
return updatedServer;
}
async delete(name: string): Promise<boolean> {
const servers = await this.getAll();
const index = servers.findIndex((server) => server.name === name);
if (index === -1) {
return false;
}
servers.splice(index, 1);
await this.saveAll(servers);
return true;
}
async exists(name: string): Promise<boolean> {
const server = await this.findById(name);
return server !== null;
}
async count(): Promise<number> {
const servers = await this.getAll();
return servers.length;
}
async findByOwner(owner: string): Promise<ServerConfigWithName[]> {
const servers = await this.getAll();
return servers.filter((server) => server.owner === owner);
}
async findEnabled(): Promise<ServerConfigWithName[]> {
const servers = await this.getAll();
return servers.filter((server) => server.enabled !== false);
}
async findByType(type: string): Promise<ServerConfigWithName[]> {
const servers = await this.getAll();
return servers.filter((server) => server.type === type);
}
async setEnabled(name: string, enabled: boolean): Promise<boolean> {
const result = await this.update(name, { enabled });
return result !== null;
}
async updateTools(
name: string,
tools: Record<string, { enabled: boolean; description?: string }>,
): Promise<boolean> {
const result = await this.update(name, { tools });
return result !== null;
}
async updatePrompts(
name: string,
prompts: Record<string, { enabled: boolean; description?: string }>,
): Promise<boolean> {
const result = await this.update(name, { prompts });
return result !== null;
}
}

View File

@@ -0,0 +1,98 @@
import { SystemConfig } from '../types/index.js';
import { JsonFileBaseDao } from './base/JsonFileBaseDao.js';
/**
* System Configuration DAO interface
*/
export interface SystemConfigDao {
/**
* Get system configuration
*/
get(): Promise<SystemConfig>;
/**
* Update system configuration
*/
update(config: Partial<SystemConfig>): Promise<SystemConfig>;
/**
* Reset system configuration to defaults
*/
reset(): Promise<SystemConfig>;
/**
* Get specific configuration section
*/
getSection<K extends keyof SystemConfig>(section: K): Promise<SystemConfig[K] | undefined>;
/**
* Update specific configuration section
*/
updateSection<K extends keyof SystemConfig>(section: K, value: SystemConfig[K]): Promise<boolean>;
}
/**
* JSON file-based System Configuration DAO implementation
*/
export class SystemConfigDaoImpl extends JsonFileBaseDao implements SystemConfigDao {
async get(): Promise<SystemConfig> {
const settings = await this.loadSettings();
return settings.systemConfig || {};
}
async update(config: Partial<SystemConfig>): Promise<SystemConfig> {
const settings = await this.loadSettings();
const currentConfig = settings.systemConfig || {};
// Deep merge configuration
const updatedConfig = this.deepMerge(currentConfig, config);
settings.systemConfig = updatedConfig;
await this.saveSettings(settings);
return updatedConfig;
}
async reset(): Promise<SystemConfig> {
const settings = await this.loadSettings();
const defaultConfig: SystemConfig = {};
settings.systemConfig = defaultConfig;
await this.saveSettings(settings);
return defaultConfig;
}
async getSection<K extends keyof SystemConfig>(section: K): Promise<SystemConfig[K] | undefined> {
const config = await this.get();
return config[section];
}
async updateSection<K extends keyof SystemConfig>(
section: K,
value: SystemConfig[K],
): Promise<boolean> {
try {
await this.update({ [section]: value } as Partial<SystemConfig>);
return true;
} catch {
return false;
}
}
/**
* Deep merge two objects
*/
private deepMerge(target: any, source: any): any {
const result = { ...target };
for (const key in source) {
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
result[key] = this.deepMerge(target[key] || {}, source[key]);
} else {
result[key] = source[key];
}
}
return result;
}
}

146
src/dao/UserConfigDao.ts Normal file
View File

@@ -0,0 +1,146 @@
import { UserConfig } from '../types/index.js';
import { JsonFileBaseDao } from './base/JsonFileBaseDao.js';
/**
* User Configuration DAO interface
*/
export interface UserConfigDao {
/**
* Get user configuration
*/
get(username: string): Promise<UserConfig | undefined>;
/**
* Get all user configurations
*/
getAll(): Promise<Record<string, UserConfig>>;
/**
* Update user configuration
*/
update(username: string, config: Partial<UserConfig>): Promise<UserConfig>;
/**
* Delete user configuration
*/
delete(username: string): Promise<boolean>;
/**
* Check if user configuration exists
*/
exists(username: string): Promise<boolean>;
/**
* Reset user configuration to defaults
*/
reset(username: string): Promise<UserConfig>;
/**
* Get specific configuration section for user
*/
getSection<K extends keyof UserConfig>(
username: string,
section: K,
): Promise<UserConfig[K] | undefined>;
/**
* Update specific configuration section for user
*/
updateSection<K extends keyof UserConfig>(
username: string,
section: K,
value: UserConfig[K],
): Promise<boolean>;
}
/**
* JSON file-based User Configuration DAO implementation
*/
export class UserConfigDaoImpl extends JsonFileBaseDao implements UserConfigDao {
async get(username: string): Promise<UserConfig | undefined> {
const settings = await this.loadSettings();
return settings.userConfigs?.[username];
}
async getAll(): Promise<Record<string, UserConfig>> {
const settings = await this.loadSettings();
return settings.userConfigs || {};
}
async update(username: string, config: Partial<UserConfig>): Promise<UserConfig> {
const settings = await this.loadSettings();
if (!settings.userConfigs) {
settings.userConfigs = {};
}
const currentConfig = settings.userConfigs[username] || {};
// Deep merge configuration
const updatedConfig = this.deepMerge(currentConfig, config);
settings.userConfigs[username] = updatedConfig;
await this.saveSettings(settings);
return updatedConfig;
}
async delete(username: string): Promise<boolean> {
const settings = await this.loadSettings();
if (!settings.userConfigs || !settings.userConfigs[username]) {
return false;
}
delete settings.userConfigs[username];
await this.saveSettings(settings);
return true;
}
async exists(username: string): Promise<boolean> {
const config = await this.get(username);
return config !== undefined;
}
async reset(username: string): Promise<UserConfig> {
const defaultConfig: UserConfig = {};
return this.update(username, defaultConfig);
}
async getSection<K extends keyof UserConfig>(
username: string,
section: K,
): Promise<UserConfig[K] | undefined> {
const config = await this.get(username);
return config?.[section];
}
async updateSection<K extends keyof UserConfig>(
username: string,
section: K,
value: UserConfig[K],
): Promise<boolean> {
try {
await this.update(username, { [section]: value } as Partial<UserConfig>);
return true;
} catch {
return false;
}
}
/**
* Deep merge two objects
*/
private deepMerge(target: any, source: any): any {
const result = { ...target };
for (const key in source) {
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
result[key] = this.deepMerge(target[key] || {}, source[key]);
} else {
result[key] = source[key];
}
}
return result;
}
}

169
src/dao/UserDao.ts Normal file
View File

@@ -0,0 +1,169 @@
import { IUser } from '../types/index.js';
import { BaseDao } from './base/BaseDao.js';
import { JsonFileBaseDao } from './base/JsonFileBaseDao.js';
import bcrypt from 'bcryptjs';
/**
* User DAO interface with user-specific operations
*/
export interface UserDao extends BaseDao<IUser, string> {
/**
* Find user by username
*/
findByUsername(username: string): Promise<IUser | null>;
/**
* Validate user credentials
*/
validateCredentials(username: string, password: string): Promise<boolean>;
/**
* Create user with hashed password
*/
createWithHashedPassword(username: string, password: string, isAdmin?: boolean): Promise<IUser>;
/**
* Update user password
*/
updatePassword(username: string, newPassword: string): Promise<boolean>;
/**
* Find all admin users
*/
findAdmins(): Promise<IUser[]>;
}
/**
* JSON file-based User DAO implementation
*/
export class UserDaoImpl extends JsonFileBaseDao implements UserDao {
protected async getAll(): Promise<IUser[]> {
const settings = await this.loadSettings();
return settings.users || [];
}
protected async saveAll(users: IUser[]): Promise<void> {
const settings = await this.loadSettings();
settings.users = users;
await this.saveSettings(settings);
}
protected getEntityId(user: IUser): string {
return user.username;
}
protected createEntity(_data: Omit<IUser, 'username'>): IUser {
// This method should not be called directly for users
throw new Error('Use createWithHashedPassword instead');
}
protected updateEntity(existing: IUser, updates: Partial<IUser>): IUser {
return {
...existing,
...updates,
username: existing.username, // Username should not be updated
};
}
async findAll(): Promise<IUser[]> {
return this.getAll();
}
async findById(username: string): Promise<IUser | null> {
return this.findByUsername(username);
}
async findByUsername(username: string): Promise<IUser | null> {
const users = await this.getAll();
return users.find((user) => user.username === username) || null;
}
async create(_data: Omit<IUser, 'username'>): Promise<IUser> {
throw new Error('Use createWithHashedPassword instead');
}
async createWithHashedPassword(
username: string,
password: string,
isAdmin: boolean = false,
): Promise<IUser> {
const users = await this.getAll();
// Check if user already exists
if (users.find((user) => user.username === username)) {
throw new Error(`User ${username} already exists`);
}
const hashedPassword = await bcrypt.hash(password, 10);
const newUser: IUser = {
username,
password: hashedPassword,
isAdmin,
};
users.push(newUser);
await this.saveAll(users);
return newUser;
}
async update(username: string, updates: Partial<IUser>): Promise<IUser | null> {
const users = await this.getAll();
const index = users.findIndex((user) => user.username === username);
if (index === -1) {
return null;
}
// Don't allow username changes
const { username: _, ...allowedUpdates } = updates;
const updatedUser = this.updateEntity(users[index], allowedUpdates);
users[index] = updatedUser;
await this.saveAll(users);
return updatedUser;
}
async updatePassword(username: string, newPassword: string): Promise<boolean> {
const hashedPassword = await bcrypt.hash(newPassword, 10);
const result = await this.update(username, { password: hashedPassword });
return result !== null;
}
async delete(username: string): Promise<boolean> {
const users = await this.getAll();
const index = users.findIndex((user) => user.username === username);
if (index === -1) {
return false;
}
users.splice(index, 1);
await this.saveAll(users);
return true;
}
async exists(username: string): Promise<boolean> {
const user = await this.findByUsername(username);
return user !== null;
}
async count(): Promise<number> {
const users = await this.getAll();
return users.length;
}
async validateCredentials(username: string, password: string): Promise<boolean> {
const user = await this.findByUsername(username);
if (!user) {
return false;
}
return bcrypt.compare(password, user.password);
}
async findAdmins(): Promise<IUser[]> {
const users = await this.getAll();
return users.filter((user) => user.isAdmin === true);
}
}

107
src/dao/base/BaseDao.ts Normal file
View File

@@ -0,0 +1,107 @@
/**
* Base DAO interface providing common CRUD operations
*/
export interface BaseDao<T, K = string> {
/**
* Find all entities
*/
findAll(): Promise<T[]>;
/**
* Find entity by ID
*/
findById(id: K): Promise<T | null>;
/**
* Create new entity
*/
create(entity: Omit<T, 'id'>): Promise<T>;
/**
* Update existing entity
*/
update(id: K, entity: Partial<T>): Promise<T | null>;
/**
* Delete entity by ID
*/
delete(id: K): Promise<boolean>;
/**
* Check if entity exists
*/
exists(id: K): Promise<boolean>;
/**
* Count total entities
*/
count(): Promise<number>;
}
/**
* Base DAO implementation with common functionality
*/
export abstract class BaseDaoImpl<T, K = string> implements BaseDao<T, K> {
protected abstract getAll(): Promise<T[]>;
protected abstract saveAll(entities: T[]): Promise<void>;
protected abstract getEntityId(entity: T): K;
protected abstract createEntity(data: Omit<T, 'id'>): T;
protected abstract updateEntity(existing: T, updates: Partial<T>): T;
async findAll(): Promise<T[]> {
return this.getAll();
}
async findById(id: K): Promise<T | null> {
const entities = await this.getAll();
return entities.find((entity) => this.getEntityId(entity) === id) || null;
}
async create(data: Omit<T, 'id'>): Promise<T> {
const entities = await this.getAll();
const newEntity = this.createEntity(data);
entities.push(newEntity);
await this.saveAll(entities);
return newEntity;
}
async update(id: K, updates: Partial<T>): Promise<T | null> {
const entities = await this.getAll();
const index = entities.findIndex((entity) => this.getEntityId(entity) === id);
if (index === -1) {
return null;
}
const updatedEntity = this.updateEntity(entities[index], updates);
entities[index] = updatedEntity;
await this.saveAll(entities);
return updatedEntity;
}
async delete(id: K): Promise<boolean> {
const entities = await this.getAll();
const index = entities.findIndex((entity) => this.getEntityId(entity) === id);
if (index === -1) {
return false;
}
entities.splice(index, 1);
await this.saveAll(entities);
return true;
}
async exists(id: K): Promise<boolean> {
const entity = await this.findById(id);
return entity !== null;
}
async count(): Promise<number> {
const entities = await this.getAll();
return entities.length;
}
}

View File

@@ -0,0 +1,96 @@
import fs from 'fs';
import path from 'path';
import { McpSettings } from '../../types/index.js';
import { getSettingsPath, clearSettingsCache } from '../../config/index.js';
/**
* Abstract base class for JSON file-based DAO implementations
*/
export abstract class JsonFileBaseDao {
private settingsCache: McpSettings | null = null;
private lastModified: number = 0;
/**
* Load settings from JSON file with caching
*/
protected async loadSettings(): Promise<McpSettings> {
try {
const settingsPath = getSettingsPath();
const stats = fs.statSync(settingsPath);
const fileModified = stats.mtime.getTime();
// Check if cache is still valid
if (this.settingsCache && this.lastModified >= fileModified) {
return this.settingsCache;
}
const settingsData = fs.readFileSync(settingsPath, 'utf8');
const settings = JSON.parse(settingsData) as McpSettings;
// Update cache
this.settingsCache = settings;
this.lastModified = fileModified;
return settings;
} catch (error) {
console.error(`Failed to load settings:`, error);
const defaultSettings: McpSettings = {
mcpServers: {},
users: [],
groups: [],
systemConfig: {},
userConfigs: {},
};
// Cache default settings
this.settingsCache = defaultSettings;
this.lastModified = Date.now();
return defaultSettings;
}
}
/**
* Save settings to JSON file and update cache
*/
protected async saveSettings(settings: McpSettings): Promise<void> {
try {
// Ensure directory exists
const settingsPath = getSettingsPath();
const dir = path.dirname(settingsPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
// Update cache
this.settingsCache = settings;
this.lastModified = Date.now();
clearSettingsCache();
} catch (error) {
console.error(`Failed to save settings:`, error);
throw error;
}
}
/**
* Clear settings cache
*/
protected clearCache(): void {
this.settingsCache = null;
this.lastModified = 0;
clearSettingsCache();
}
/**
* Get cache status for debugging
*/
protected getCacheInfo(): { hasCache: boolean; lastModified: number } {
return {
hasCache: this.settingsCache !== null,
lastModified: this.lastModified,
};
}
}

233
src/dao/examples.ts Normal file
View File

@@ -0,0 +1,233 @@
/**
* Data access layer example and test utilities
*
* This file demonstrates how to use the DAO layer for managing different types of data
* in the MCPHub application.
*/
import {
getUserDao,
getServerDao,
getGroupDao,
getSystemConfigDao,
getUserConfigDao,
JsonFileDaoFactory,
setDaoFactory,
} from './DaoFactory.js';
/**
* Example usage of UserDao
*/
export async function exampleUserOperations() {
const userDao = getUserDao();
// Create a new user
const newUser = await userDao.createWithHashedPassword('testuser', 'password123', false);
console.log('Created user:', newUser.username);
// Find user by username
const foundUser = await userDao.findByUsername('testuser');
console.log('Found user:', foundUser?.username);
// Validate credentials
const isValid = await userDao.validateCredentials('testuser', 'password123');
console.log('Credentials valid:', isValid);
// Update user
await userDao.update('testuser', { isAdmin: true });
console.log('Updated user to admin');
// Find all admin users
const admins = await userDao.findAdmins();
console.log(
'Admin users:',
admins.map((u) => u.username),
);
// Delete user
await userDao.delete('testuser');
console.log('Deleted test user');
}
/**
* Example usage of ServerDao
*/
export async function exampleServerOperations() {
const serverDao = getServerDao();
// Create a new server
const newServer = await serverDao.create({
name: 'test-server',
command: 'node',
args: ['server.js'],
enabled: true,
owner: 'admin',
});
console.log('Created server:', newServer.name);
// Find servers by owner
const userServers = await serverDao.findByOwner('admin');
console.log(
'Servers owned by admin:',
userServers.map((s) => s.name),
);
// Find enabled servers
const enabledServers = await serverDao.findEnabled();
console.log(
'Enabled servers:',
enabledServers.map((s) => s.name),
);
// Update server tools
await serverDao.updateTools('test-server', {
tool1: { enabled: true, description: 'Test tool' },
});
console.log('Updated server tools');
// Delete server
await serverDao.delete('test-server');
console.log('Deleted test server');
}
/**
* Example usage of GroupDao
*/
export async function exampleGroupOperations() {
const groupDao = getGroupDao();
// Create a new group
const newGroup = await groupDao.create({
name: 'test-group',
description: 'Test group for development',
servers: ['server1', 'server2'],
owner: 'admin',
});
console.log('Created group:', newGroup.name, 'with ID:', newGroup.id);
// Find groups by owner
const userGroups = await groupDao.findByOwner('admin');
console.log(
'Groups owned by admin:',
userGroups.map((g) => g.name),
);
// Add server to group
await groupDao.addServerToGroup(newGroup.id, 'server3');
console.log('Added server3 to group');
// Find groups containing specific server
const groupsWithServer = await groupDao.findByServer('server1');
console.log(
'Groups containing server1:',
groupsWithServer.map((g) => g.name),
);
// Remove server from group
await groupDao.removeServerFromGroup(newGroup.id, 'server2');
console.log('Removed server2 from group');
// Delete group
await groupDao.delete(newGroup.id);
console.log('Deleted test group');
}
/**
* Example usage of SystemConfigDao
*/
export async function exampleSystemConfigOperations() {
const systemConfigDao = getSystemConfigDao();
// Get current system config
const currentConfig = await systemConfigDao.get();
console.log('Current system config:', currentConfig);
// Update routing configuration
await systemConfigDao.updateSection('routing', {
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: false,
});
console.log('Updated routing configuration');
// Update install configuration
await systemConfigDao.updateSection('install', {
pythonIndexUrl: 'https://pypi.org/simple/',
npmRegistry: 'https://registry.npmjs.org/',
baseUrl: 'https://mcphub.local',
});
console.log('Updated install configuration');
// Get specific section
const routingConfig = await systemConfigDao.getSection('routing');
console.log('Routing config:', routingConfig);
}
/**
* Example usage of UserConfigDao
*/
export async function exampleUserConfigOperations() {
const userConfigDao = getUserConfigDao();
// Update user configuration
await userConfigDao.update('admin', {
routing: {
enableGlobalRoute: false,
enableGroupNameRoute: true,
},
});
console.log('Updated admin user config');
// Get user configuration
const adminConfig = await userConfigDao.get('admin');
console.log('Admin config:', adminConfig);
// Get all user configurations
const allUserConfigs = await userConfigDao.getAll();
console.log('All user configs:', Object.keys(allUserConfigs));
// Get specific section for user
const userRoutingConfig = await userConfigDao.getSection('admin', 'routing');
console.log('Admin routing config:', userRoutingConfig);
// Delete user configuration
await userConfigDao.delete('admin');
console.log('Deleted admin user config');
}
/**
* Test all DAO operations
*/
export async function testAllDaoOperations() {
try {
console.log('=== Testing DAO Layer ===');
console.log('\n--- User Operations ---');
await exampleUserOperations();
console.log('\n--- Server Operations ---');
await exampleServerOperations();
console.log('\n--- Group Operations ---');
await exampleGroupOperations();
console.log('\n--- System Config Operations ---');
await exampleSystemConfigOperations();
console.log('\n--- User Config Operations ---');
await exampleUserConfigOperations();
console.log('\n=== DAO Layer Test Complete ===');
} catch (error) {
console.error('Error during DAO testing:', error);
}
}
/**
* Reset DAO factory for testing purposes
*/
export function resetDaoFactory() {
const factory = JsonFileDaoFactory.getInstance();
factory.resetInstances();
setDaoFactory(factory);
}

11
src/dao/index.ts Normal file
View File

@@ -0,0 +1,11 @@
// Export all DAO interfaces and implementations
export * from './base/BaseDao.js';
export * from './base/JsonFileBaseDao.js';
export * from './UserDao.js';
export * from './ServerDao.js';
export * from './GroupDao.js';
export * from './SystemConfigDao.js';
export * from './UserConfigDao.js';
// Export the DAO factory and convenience functions
export * from './DaoFactory.js';

View File

@@ -68,6 +68,7 @@ import {
getOpenAPIServers,
getOpenAPIStats,
executeToolViaOpenAPI,
getGroupOpenAPISpec,
} from '../controllers/openApiController.js';
import { auth } from '../middlewares/auth.js';
@@ -186,14 +187,17 @@ export const initRoutes = (app: express.Application): void => {
// Public configuration endpoint (no auth required to check skipAuth setting)
app.get(`${config.basePath}/public-config`, getPublicConfig);
// OpenAPI generation endpoints (no auth required for OpenWebUI integration)
// OpenAPI generation endpoints
app.get(`${config.basePath}/api/openapi.json`, getOpenAPISpec);
app.get(`${config.basePath}/api/:name/openapi.json`, getGroupOpenAPISpec);
app.get(`${config.basePath}/api/openapi/servers`, getOpenAPIServers);
app.get(`${config.basePath}/api/openapi/stats`, getOpenAPIStats);
// OpenAPI-compatible tool execution endpoints (no auth required for OpenWebUI integration)
// 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);
};

259
src/scripts/dao-demo.ts Normal file
View File

@@ -0,0 +1,259 @@
#!/usr/bin/env node
/**
* MCPHub DAO Layer Demo Script
*
* This script demonstrates how to use the new DAO layer for managing
* MCPHub configuration data.
*/
import {
loadSettings,
switchToDao,
switchToLegacy,
getDaoConfigService,
} from '../config/configManager.js';
import {
performMigration,
validateMigration,
testDaoOperations,
performanceComparison,
generateMigrationReport,
} from '../config/migrationUtils.js';
async function main() {
const args = process.argv.slice(2);
const command = args[0];
switch (command) {
case 'migrate':
{
console.log('🚀 Starting migration to DAO layer...');
const success = await performMigration();
process.exit(success ? 0 : 1);
}
break;
case 'validate':
{
console.log('🔍 Validating migration...');
const isValid = await validateMigration();
process.exit(isValid ? 0 : 1);
}
break;
case 'test':
{
console.log('🧪 Testing DAO operations...');
const testSuccess = await testDaoOperations();
process.exit(testSuccess ? 0 : 1);
}
break;
case 'compare':
{
console.log('⚡ Comparing performance...');
await performanceComparison();
process.exit(0);
}
break;
case 'report':
{
console.log('📊 Generating migration report...');
await generateMigrationReport();
process.exit(0);
}
break;
case 'demo':
{
await runDemo();
process.exit(0);
}
break;
case 'switch-dao':
{
switchToDao();
console.log('✅ Switched to DAO layer');
process.exit(0);
}
break;
case 'switch-legacy':
{
switchToLegacy();
console.log('✅ Switched to legacy file-based approach');
process.exit(0);
}
break;
default: {
printHelp();
process.exit(1);
}
}
}
function printHelp() {
console.log(`
MCPHub DAO Layer Demo
Usage: node dao-demo.js <command>
Commands:
migrate - Migrate from legacy format to DAO layer
validate - Validate migration integrity
test - Test DAO operations with sample data
compare - Compare performance between legacy and DAO approaches
report - Generate migration report
demo - Run interactive demo
switch-dao - Switch to DAO layer
switch-legacy - Switch to legacy file-based approach
Examples:
node dao-demo.js migrate
node dao-demo.js test
node dao-demo.js compare
`);
}
async function runDemo() {
console.log('🎭 MCPHub DAO Layer Interactive Demo');
console.log('=====================================\n');
try {
// Step 1: Show current configuration
console.log('📋 Step 1: Loading current configuration...');
switchToLegacy();
const legacySettings = await loadSettings();
console.log(`Current data:
- Users: ${legacySettings.users?.length || 0}
- Servers: ${Object.keys(legacySettings.mcpServers || {}).length}
- Groups: ${legacySettings.groups?.length || 0}
- System Config Sections: ${Object.keys(legacySettings.systemConfig || {}).length}
- User Configs: ${Object.keys(legacySettings.userConfigs || {}).length}
`);
// Step 2: Switch to DAO and show same data
console.log('🔄 Step 2: Switching to DAO layer...');
switchToDao();
const daoService = getDaoConfigService();
const daoSettings = await daoService.loadSettings();
console.log(`DAO layer data:
- Users: ${daoSettings.users?.length || 0}
- Servers: ${Object.keys(daoSettings.mcpServers || {}).length}
- Groups: ${daoSettings.groups?.length || 0}
- System Config Sections: ${Object.keys(daoSettings.systemConfig || {}).length}
- User Configs: ${Object.keys(daoSettings.userConfigs || {}).length}
`);
// Step 3: Demonstrate CRUD operations
console.log('🛠️ Step 3: Demonstrating CRUD operations...');
// Test user creation (if not exists)
try {
// Add demo data if needed
if (!daoSettings.users?.length) {
console.log('Creating demo user...');
// Note: In practice, you'd use the UserDao directly for password hashing
const demoSettings = {
...daoSettings,
users: [
{
username: 'demo-user',
password: 'hashed-password',
isAdmin: false,
},
],
};
await daoService.saveSettings(demoSettings);
console.log('✅ Demo user created');
}
// Add demo server if needed
if (!Object.keys(daoSettings.mcpServers || {}).length) {
console.log('Creating demo server...');
const demoSettings = {
...daoSettings,
mcpServers: {
'demo-server': {
command: 'echo',
args: ['hello'],
enabled: true,
owner: 'admin',
},
},
};
await daoService.saveSettings(demoSettings);
console.log('✅ Demo server created');
}
// Add demo group if needed
if (!daoSettings.groups?.length) {
console.log('Creating demo group...');
const demoSettings = {
...daoSettings,
groups: [
{
id: 'demo-group-1',
name: 'Demo Group',
description: 'A demo group for testing',
servers: ['demo-server'],
owner: 'admin',
},
],
};
await daoService.saveSettings(demoSettings);
console.log('✅ Demo group created');
}
} catch (error) {
console.log('⚠️ Some demo operations failed (this is expected for password hashing)');
console.log('In production, you would use individual DAO methods for proper handling');
}
// Step 4: Show benefits
console.log(`
🌟 Benefits of the DAO Layer:
1. 📦 Separation of Concerns
- Data access logic is separated from business logic
- Each data type has its own DAO with specific operations
2. 🔄 Easy Database Migration
- Ready for switching from JSON files to database
- Interface remains the same, implementation changes
3. 🧪 Better Testing
- Can easily mock DAO interfaces for unit tests
- Isolated testing of data access operations
4. 🔒 Type Safety
- Strong typing for all data operations
- Compile-time checking of data structure changes
5. 🚀 Enhanced Features
- User password hashing in UserDao
- Server filtering by owner/type in ServerDao
- Group membership management in GroupDao
- Section-based config updates in SystemConfigDao
6. 🏗️ Future Extensibility
- Easy to add new data types
- Consistent interface across all data operations
- Support for complex queries and relationships
`);
console.log('✅ Demo completed successfully!');
} catch (error) {
console.error('❌ Demo failed:', error);
}
}
// Run the main function
if (require.main === module) {
main().catch(console.error);
}

View File

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

View File

@@ -1,3 +1,4 @@
import os from 'os';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import {
CallToolRequestSchema,
@@ -11,16 +12,24 @@ 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, saveSettings, 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
const setupKeepAlive = (serverInfo: ServerInfo, serverConfig: ServerConfig): void => {
// Only set up keep-alive for SSE connections
@@ -74,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) => {
@@ -202,6 +213,7 @@ const createTransportFromConfig = (name: string, conf: ServerConfig): any => {
}
transport = new StdioClientTransport({
cwd: os.homedir(),
command: conf.command,
args: replaceEnvVars(conf.args) as string[],
env: env,
@@ -217,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,
@@ -253,15 +403,13 @@ const callToolWithReconnect = async (
serverInfo.client.close();
serverInfo.transport.close();
// Get server configuration to recreate transport
const settings = loadSettings();
const conf = settings.mcpServers[serverInfo.name];
if (!conf) {
const server = await serverDao.findById(serverInfo.name);
if (!server) {
throw new Error(`Server configuration not found for: ${serverInfo.name}`);
}
// Recreate transport using helper function
const newTransport = createTransportFromConfig(serverInfo.name, conf);
const newTransport = createTransportFromConfig(serverInfo.name, server);
// Create new client
const client = new Client(
@@ -290,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 || {}),
}));
@@ -335,11 +483,12 @@ export const initializeClientsFromSettings = async (
isInit: boolean,
serverName?: string,
): Promise<ServerInfo[]> => {
const settings = loadSettings();
const allServers: ServerConfigWithName[] = await serverDao.findAll();
const existingServerInfos = serverInfos;
serverInfos = [];
for (const [name, conf] of Object.entries(settings.mcpServers)) {
for (const conf of allServers) {
const { name } = conf;
// Skip disabled servers
if (conf.enabled === false) {
console.log(`Skipping disabled server: ${name}`);
@@ -399,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);
@@ -414,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),
}));
@@ -483,6 +633,7 @@ export const initializeClientsFromSettings = async (
transport,
options: requestOptions,
createTime: Date.now(),
config: conf, // Store reference to original config
};
serverInfos.push(serverInfo);
@@ -500,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 || {}),
}));
@@ -523,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,
@@ -567,14 +718,14 @@ export const registerAllTools = async (isInit: boolean, serverName?: string): Pr
};
// Get all server information
export const getServersInfo = (): Omit<ServerInfo, 'client' | 'transport'>[] => {
const settings = loadSettings();
export const getServersInfo = async (): Promise<Omit<ServerInfo, 'client' | 'transport'>[]> => {
const allServers: ServerConfigWithName[] = await serverDao.findAll();
const dataService = getDataService();
const filterServerInfos: ServerInfo[] = dataService.filterData
? dataService.filterData(serverInfos)
: serverInfos;
const infos = filterServerInfos.map(({ name, status, tools, prompts, createTime, error }) => {
const serverConfig = settings.mcpServers[name];
const serverConfig = allServers.find((server) => server.name === name);
const enabled = serverConfig ? serverConfig.enabled !== false : true;
// Add enabled status and custom description to each tool
@@ -614,15 +765,52 @@ export const getServersInfo = (): Omit<ServerInfo, 'client' | 'transport'>[] =>
};
// Get server by name
const getServerByName = (name: string): ServerInfo | undefined => {
export const getServerByName = (name: string): ServerInfo | undefined => {
return serverInfos.find((serverInfo) => serverInfo.name === name);
};
// Filter tools by server configuration
const filterToolsByConfig = (serverName: string, tools: Tool[]): Tool[] => {
const settings = loadSettings();
const serverConfig = settings.mcpServers[serverName];
// 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);
if (!serverConfig || !serverConfig.tools) {
// If no tool configuration exists, all tools are enabled by default
return tools;
@@ -635,8 +823,8 @@ const filterToolsByConfig = (serverName: string, tools: Tool[]): Tool[] => {
});
};
// 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));
};
@@ -645,44 +833,26 @@ export const addServer = async (
name: string,
config: ServerConfig,
): Promise<{ success: boolean; message?: string }> => {
try {
const settings = loadSettings();
if (settings.mcpServers[name]) {
return { success: false, message: 'Server name already exists' };
}
settings.mcpServers[name] = config;
if (!saveSettings(settings)) {
return { success: false, message: 'Failed to save settings' };
}
const server: ServerConfigWithName = { name, ...config };
const result = await serverDao.create(server);
if (result) {
return { success: true, message: 'Server added successfully' };
} catch (error) {
console.error(`Failed to add server: ${name}`, error);
} else {
return { success: false, message: 'Failed to add server' };
}
};
// Remove server
export const removeServer = (name: string): { success: boolean; message?: string } => {
try {
const settings = loadSettings();
if (!settings.mcpServers[name]) {
return { success: false, message: 'Server not found' };
}
delete settings.mcpServers[name];
if (!saveSettings(settings)) {
return { success: false, message: 'Failed to save settings' };
}
serverInfos = serverInfos.filter((serverInfo) => serverInfo.name !== name);
return { success: true, message: 'Server removed successfully' };
} catch (error) {
console.error(`Failed to remove server: ${name}`, error);
return { success: false, message: `Failed to remove server: ${error}` };
export const removeServer = async (
name: string,
): Promise<{ success: boolean; message?: string }> => {
const result = await serverDao.delete(name);
if (!result) {
return { success: false, message: 'Failed to remove server' };
}
serverInfos = serverInfos.filter((serverInfo) => serverInfo.name !== name);
return { success: true, message: 'Server removed successfully' };
};
// Add or update server (supports overriding existing servers for DXT)
@@ -692,9 +862,7 @@ export const addOrUpdateServer = async (
allowOverride: boolean = false,
): Promise<{ success: boolean; message?: string }> => {
try {
const settings = loadSettings();
const exists = !!settings.mcpServers[name];
const exists = await serverDao.exists(name);
if (exists && !allowOverride) {
return { success: false, message: 'Server name already exists' };
}
@@ -708,9 +876,10 @@ export const addOrUpdateServer = async (
serverInfos = serverInfos.filter((serverInfo) => serverInfo.name !== name);
}
settings.mcpServers[name] = config;
if (!saveSettings(settings)) {
return { success: false, message: 'Failed to save settings' };
if (exists) {
await serverDao.update(name, config);
} else {
await serverDao.create({ name, ...config });
}
const action = exists ? 'updated' : 'added';
@@ -745,18 +914,7 @@ export const toggleServerStatus = async (
enabled: boolean,
): Promise<{ success: boolean; message?: string }> => {
try {
const settings = loadSettings();
if (!settings.mcpServers[name]) {
return { success: false, message: 'Server not found' };
}
// Update the enabled status in settings
settings.mcpServers[name].enabled = enabled;
if (!saveSettings(settings)) {
return { success: false, message: 'Failed to save settings' };
}
await serverDao.setEnabled(name, enabled);
// If disabling, disconnect the server and remove from active servers
if (!enabled) {
closeServer(name);
@@ -851,21 +1009,41 @@ 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) {
if (serverInfo.tools && serverInfo.tools.length > 0) {
// Filter tools based on server configuration
let enabledTools = filterToolsByConfig(serverInfo.name, serverInfo.tools);
let enabledTools = await filterToolsByConfig(serverInfo.name, serverInfo.tools);
// If this is a group request, apply group-level tool filtering
if (group) {
@@ -873,15 +1051,14 @@ 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));
}
}
// Apply custom descriptions from server configuration
const settings = loadSettings();
const serverConfig = settings.mcpServers[serverInfo.name];
const serverConfig = await serverDao.findById(serverInfo.name);
const toolsWithCustomDescriptions = enabledTools.map((tool) => {
const toolConfig = serverConfig?.tools?.[tool.name];
return {
@@ -931,8 +1108,9 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
const searchResults = await searchToolsByVector(query, limitNum, thresholdNum, servers);
console.log(`Search results: ${JSON.stringify(searchResults)}`);
// Find actual tool information from serverInfos by serverName and toolName
const tools = searchResults
.map((result) => {
// First resolve all tool promises
const resolvedTools = await Promise.all(
searchResults.map(async (result) => {
// Find the server in serverInfos
const server = serverInfos.find(
(serverInfo) =>
@@ -945,17 +1123,17 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
const actualTool = server.tools.find((tool) => tool.name === result.toolName);
if (actualTool) {
// Check if the tool is enabled in configuration
const enabledTools = filterToolsByConfig(server.name, [actualTool]);
const enabledTools = await filterToolsByConfig(server.name, [actualTool]);
if (enabledTools.length > 0) {
// Apply custom description from configuration
const settings = loadSettings();
const serverConfig = settings.mcpServers[server.name];
const serverConfig = await serverDao.findById(server.name);
const toolConfig = serverConfig?.tools?.[actualTool.name];
// Return the actual tool info from serverInfos with custom description
return {
...actualTool,
description: toolConfig?.description || actualTool.description,
serverName: result.serverName, // Add serverName for filtering
};
}
}
@@ -966,19 +1144,25 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
name: result.toolName,
description: result.description || '',
inputSchema: cleanInputSchema(result.inputSchema || {}),
serverName: result.serverName, // Add serverName for filtering
};
})
.filter((tool) => {
}),
);
// Now filter the resolved tools
const tools = await Promise.all(
resolvedTools.filter(async (tool) => {
// Additional filter to remove tools that are disabled
if (tool.name) {
const serverName = searchResults.find((r) => r.toolName === tool.name)?.serverName;
const serverName = tool.serverName;
if (serverName) {
const enabledTools = filterToolsByConfig(serverName, [tool as Tool]);
const enabledTools = await filterToolsByConfig(serverName, [tool as Tool]);
return enabledTools.length > 0;
}
}
return true; // Keep fallback results
});
}),
);
// Add usage guidance to the response
const response = {
@@ -1017,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) {
@@ -1054,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 {
@@ -1085,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,
@@ -1102,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}`);
}
@@ -1113,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 {
@@ -1140,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,
@@ -1184,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 = {
@@ -1234,8 +1483,7 @@ export const handleListPromptsRequest = async (_: any, extra: any) => {
for (const serverInfo of allServerInfos) {
if (serverInfo.prompts && serverInfo.prompts.length > 0) {
// Filter prompts based on server configuration
const settings = loadSettings();
const serverConfig = settings.mcpServers[serverInfo.name];
const serverConfig = await serverDao.findById(serverInfo.name);
let enabledPrompts = serverInfo.prompts;
if (serverConfig && serverConfig.prompts) {

View File

@@ -2,7 +2,7 @@ import { OpenAPIV3 } from 'openapi-types';
import { Tool } from '../types/index.js';
import { getServersInfo } from './mcpService.js';
import config from '../config/index.js';
import { loadSettings } from '../config/index.js';
import { loadSettings, getNameSeparator } from '../config/index.js';
/**
* Service for generating OpenAPI 3.x specifications from MCP tools
@@ -158,16 +158,43 @@ function generateOperationFromTool(tool: Tool, serverName: string): OpenAPIV3.Op
/**
* Generate OpenAPI specification from MCP tools
*/
export function generateOpenAPISpec(options: OpenAPIGenerationOptions = {}): OpenAPIV3.Document {
const serverInfos = getServersInfo();
export async function generateOpenAPISpec(
options: OpenAPIGenerationOptions = {},
): Promise<OpenAPIV3.Document> {
const serverInfos = await getServersInfo();
// Filter servers based on options
const filteredServers = serverInfos.filter(
let filteredServers = serverInfos.filter(
(server) =>
server.status === 'connected' &&
(!options.serverFilter || options.serverFilter.includes(server.name)),
);
// Apply group filter if specified
const groupConfig: Map<string, string[] | 'all'> = new Map();
if (options.groupFilter) {
const { getGroupByIdOrName } = await import('./groupService.js');
const group = getGroupByIdOrName(options.groupFilter);
if (group) {
// Extract server names and their tool configurations from group
const groupServerNames: string[] = [];
for (const server of group.servers) {
if (typeof server === 'string') {
groupServerNames.push(server);
groupConfig.set(server, 'all');
} else {
groupServerNames.push(server.name);
groupConfig.set(server.name, server.tools || 'all');
}
}
// Filter to only servers in the group
filteredServers = filteredServers.filter((server) => groupServerNames.includes(server.name));
} else {
// Group not found, return empty specification
filteredServers = [];
}
}
// Collect all tools from filtered servers
const allTools: Array<{ tool: Tool; serverName: string }> = [];
@@ -176,7 +203,22 @@ export function generateOpenAPISpec(options: OpenAPIGenerationOptions = {}): Ope
? serverInfo.tools
: serverInfo.tools.filter((tool) => tool.enabled !== false);
for (const tool of tools) {
// Apply group-specific tool filtering if group filter is specified
let filteredTools = tools;
if (options.groupFilter && groupConfig.has(serverInfo.name)) {
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 + separator, '')),
);
}
}
for (const tool of filteredTools) {
allTools.push({ tool, serverName: serverInfo.name });
}
}
@@ -283,20 +325,20 @@ export function generateOpenAPISpec(options: OpenAPIGenerationOptions = {}): Ope
/**
* Get available server names for filtering
*/
export function getAvailableServers(): string[] {
const serverInfos = getServersInfo();
export async function getAvailableServers(): Promise<string[]> {
const serverInfos = await getServersInfo();
return serverInfos.filter((server) => server.status === 'connected').map((server) => server.name);
}
/**
* Get statistics about available tools
*/
export function getToolStats(): {
export async function getToolStats(): Promise<{
totalServers: number;
totalTools: number;
serverBreakdown: Array<{ name: string; toolCount: number; status: string }>;
} {
const serverInfos = getServersInfo();
}> {
const serverInfos = await getServersInfo();
const serverBreakdown = serverInfos.map((server) => ({
name: server.name,

View File

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

View File

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

View File

@@ -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 } } = {};
@@ -43,7 +44,7 @@ export const handleSseConnection = async (req: Request, res: Response): Promise<
const userContextService = UserContextService.getInstance();
const currentUser = userContextService.getCurrentUser();
const username = currentUser?.username;
// Check bearer auth using filtered settings
if (!validateBearerAuth(req)) {
console.warn('Bearer authentication failed or not provided');
@@ -74,7 +75,7 @@ export const handleSseConnection = async (req: Request, res: Response): Promise<
}
// Construct the appropriate messages path based on user context
const messagesPath = username
const messagesPath = username
? `${config.basePath}/${username}/messages`
: `${config.basePath}/messages`;
@@ -100,7 +101,7 @@ export const handleSseMessage = async (req: Request, res: Response): Promise<voi
const userContextService = UserContextService.getInstance();
const currentUser = userContextService.getCurrentUser();
const username = currentUser?.username;
// Check bearer auth using filtered settings
if (!validateBearerAuth(req)) {
res.status(401).send('Bearer authentication required or invalid token');
@@ -127,9 +128,20 @@ export const handleSseMessage = async (req: Request, res: Response): Promise<voi
const { transport, group } = transportData;
req.params.group = group;
req.query.group = group;
console.log(`Received message for sessionId: ${sessionId} in group: ${group}${username ? ` for user: ${username}` : ''}`);
console.log(
`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> => {
@@ -137,14 +149,14 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
const userContextService = UserContextService.getInstance();
const currentUser = userContextService.getCurrentUser();
const username = currentUser?.username;
const sessionId = req.headers['mcp-session-id'] as string | undefined;
const group = req.params.group;
const body = req.body;
console.log(
`Handling MCP post request for sessionId: ${sessionId} and group: ${group}${username ? ` for user: ${username}` : ''} with body: ${JSON.stringify(body)}`,
);
// Check bearer auth using filtered settings
if (!validateBearerAuth(req)) {
res.status(401).send('Bearer authentication required or invalid token');
@@ -183,7 +195,9 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
}
};
console.log(`MCP connection established: ${transport.sessionId}${username ? ` for user: ${username}` : ''}`);
console.log(
`MCP connection established: ${transport.sessionId}${username ? ` for user: ${username}` : ''}`,
);
await getMcpServer(transport.sessionId, group).connect(transport);
} else {
res.status(400).json({
@@ -198,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) => {
@@ -206,9 +230,9 @@ export const handleMcpOtherRequest = async (req: Request, res: Response) => {
const userContextService = UserContextService.getInstance();
const currentUser = userContextService.getCurrentUser();
const username = currentUser?.username;
console.log(`Handling MCP other request${username ? ` for user: ${username}` : ''}`);
// Check bearer auth using filtered settings
if (!validateBearerAuth(req)) {
res.status(401).send('Bearer authentication required or invalid token');

View File

@@ -499,7 +499,7 @@ export const syncAllServerToolsEmbeddings = async (): Promise<void> => {
// Import getServersInfo to get all server information
const { getServersInfo } = await import('./mcpService.js');
const servers = getServersInfo();
const servers = await getServersInfo();
let totalToolsSynced = 0;
let serversSynced = 0;

View File

@@ -144,6 +144,7 @@ export interface SystemConfig {
title?: string; // Title header for MCPRouter API requests
baseUrl?: string; // Base URL for MCPRouter API (default: https://api.mcprouter.to/v1)
};
nameSeparator?: string; // Separator used between server name and tool/prompt name (default: '-')
}
export interface UserConfig {
@@ -177,6 +178,7 @@ export interface ServerConfig {
enabled?: boolean; // Flag to enable/disable the server
owner?: string; // Owner of the server, defaults to 'admin' user
keepAliveInterval?: number; // Keep-alive ping interval in milliseconds (default: 60000ms for SSE servers)
perSession?: boolean; // If true, creates a separate server instance for each session (useful for stateful servers like playwright)
tools?: Record<string, { enabled: boolean; description?: string }>; // Tool-specific configurations with enable/disable state and custom descriptions
prompts?: Record<string, { enabled: boolean; description?: string }>; // Prompt-specific configurations with enable/disable state and custom descriptions
options?: Partial<Pick<RequestOptions, 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout'>>; // MCP request options configuration
@@ -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

View File

@@ -5,6 +5,13 @@ import { dirname } from 'path';
// Project root directory - use process.cwd() as a simpler alternative
const rootDir = process.cwd();
function getParentPath(p: string, filename: string): string {
if (p.endsWith(filename)) {
p = p.slice(0, -filename.length);
}
return path.resolve(p);
}
/**
* Find the path to a configuration file by checking multiple potential locations.
* @param filename The name of the file to locate (e.g., 'servers.json', 'mcp_settings.json')
@@ -12,15 +19,35 @@ const rootDir = process.cwd();
* @returns The path to the file
*/
export const getConfigFilePath = (filename: string, description = 'Configuration'): string => {
const envPath = process.env.MCPHUB_SETTING_PATH;
if (filename === 'mcp_settings.json') {
const envPath = process.env.MCPHUB_SETTING_PATH;
if (envPath) {
// Ensure directory exists
const dir = getParentPath(envPath, filename);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
console.log(`Created directory for settings at ${dir}`);
}
// if full path, return as is
if (envPath?.endsWith(filename)) {
return envPath;
}
// if directory, return path under that directory
return path.resolve(envPath, filename);
}
}
const potentialPaths = [
...(envPath ? [envPath] : []),
// Prioritize process.cwd() as the first location to check
path.resolve(process.cwd(), filename),
// Use path relative to the root directory
path.join(rootDir, filename),
// If installed with npx, may need to look one level up
path.join(dirname(rootDir), filename),
...[
// Prioritize process.cwd() as the first location to check
path.resolve(process.cwd(), filename),
// Use path relative to the root directory
path.join(rootDir, filename),
// If installed with npx, may need to look one level up
path.join(dirname(rootDir), filename),
],
];
for (const filePath of potentialPaths) {

View File

@@ -0,0 +1,302 @@
// Simple unit test to validate the type conversion logic
describe('Parameter Type Conversion Logic', () => {
// Extract the conversion function for testing
function convertQueryParametersToTypes(
queryParams: Record<string, any>,
inputSchema: Record<string, any>
): Record<string, any> {
if (!inputSchema || typeof inputSchema !== 'object' || !inputSchema.properties) {
return queryParams;
}
const convertedParams: Record<string, any> = {};
const properties = inputSchema.properties;
for (const [key, value] of Object.entries(queryParams)) {
const propDef = properties[key];
if (!propDef || typeof propDef !== 'object') {
// No schema definition found, keep as is
convertedParams[key] = value;
continue;
}
const propType = propDef.type;
try {
switch (propType) {
case 'integer':
case 'number':
// Convert string to number
if (typeof value === 'string') {
const numValue = propType === 'integer' ? parseInt(value, 10) : parseFloat(value);
convertedParams[key] = isNaN(numValue) ? value : numValue;
} else {
convertedParams[key] = value;
}
break;
case 'boolean':
// Convert string to boolean
if (typeof value === 'string') {
convertedParams[key] = value.toLowerCase() === 'true' || value === '1';
} else {
convertedParams[key] = value;
}
break;
case 'array':
// Handle array conversion if needed (e.g., comma-separated strings)
if (typeof value === 'string' && value.includes(',')) {
convertedParams[key] = value.split(',').map(item => item.trim());
} else {
convertedParams[key] = value;
}
break;
default:
// For string and other types, keep as is
convertedParams[key] = value;
break;
}
} catch (error) {
// If conversion fails, keep the original value
console.warn(`Failed to convert parameter '${key}' to type '${propType}':`, error);
convertedParams[key] = value;
}
}
return convertedParams;
}
test('should convert integer parameters correctly', () => {
const queryParams = {
limit: '5',
offset: '10',
name: 'test'
};
const inputSchema = {
type: 'object',
properties: {
limit: { type: 'integer' },
offset: { type: 'integer' },
name: { type: 'string' }
}
};
const result = convertQueryParametersToTypes(queryParams, inputSchema);
expect(result).toEqual({
limit: 5, // Converted to integer
offset: 10, // Converted to integer
name: 'test' // Remains string
});
});
test('should convert number parameters correctly', () => {
const queryParams = {
price: '19.99',
discount: '0.15'
};
const inputSchema = {
type: 'object',
properties: {
price: { type: 'number' },
discount: { type: 'number' }
}
};
const result = convertQueryParametersToTypes(queryParams, inputSchema);
expect(result).toEqual({
price: 19.99,
discount: 0.15
});
});
test('should convert boolean parameters correctly', () => {
const queryParams = {
enabled: 'true',
disabled: 'false',
active: '1',
inactive: '0'
};
const inputSchema = {
type: 'object',
properties: {
enabled: { type: 'boolean' },
disabled: { type: 'boolean' },
active: { type: 'boolean' },
inactive: { type: 'boolean' }
}
};
const result = convertQueryParametersToTypes(queryParams, inputSchema);
expect(result).toEqual({
enabled: true,
disabled: false,
active: true,
inactive: false
});
});
test('should convert array parameters correctly', () => {
const queryParams = {
tags: 'tag1,tag2,tag3',
ids: '1,2,3'
};
const inputSchema = {
type: 'object',
properties: {
tags: { type: 'array' },
ids: { type: 'array' }
}
};
const result = convertQueryParametersToTypes(queryParams, inputSchema);
expect(result).toEqual({
tags: ['tag1', 'tag2', 'tag3'],
ids: ['1', '2', '3']
});
});
test('should handle missing schema gracefully', () => {
const queryParams = {
limit: '5',
name: 'test'
};
const result = convertQueryParametersToTypes(queryParams, {});
expect(result).toEqual({
limit: '5', // Should remain as string
name: 'test' // Should remain as string
});
});
test('should handle properties not in schema', () => {
const queryParams = {
limit: '5',
unknownParam: 'value'
};
const inputSchema = {
type: 'object',
properties: {
limit: { type: 'integer' }
}
};
const result = convertQueryParametersToTypes(queryParams, inputSchema);
expect(result).toEqual({
limit: 5, // Converted based on schema
unknownParam: 'value' // Kept as is (no schema)
});
});
test('should handle invalid number conversion gracefully', () => {
const queryParams = {
limit: 'not-a-number',
price: 'invalid'
};
const inputSchema = {
type: 'object',
properties: {
limit: { type: 'integer' },
price: { type: 'number' }
}
};
const result = convertQueryParametersToTypes(queryParams, inputSchema);
expect(result).toEqual({
limit: 'not-a-number', // Should remain as string when conversion fails
price: 'invalid' // Should remain as string when conversion fails
});
});
});
// Test the new OpenAPI endpoints functionality
describe('OpenAPI Granular Endpoints', () => {
// Mock the required services
const mockGetAvailableServers = jest.fn();
const mockGenerateOpenAPISpec = jest.fn();
const mockGetGroupByIdOrName = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
test('should generate server-specific OpenAPI spec', async () => {
// Mock available servers
mockGetAvailableServers.mockResolvedValue(['server1', 'server2']);
// Mock OpenAPI spec generation
const mockSpec = { openapi: '3.0.3', info: { title: 'server1 MCP API' } };
mockGenerateOpenAPISpec.mockResolvedValue(mockSpec);
// Test server spec generation options
const expectedOptions = {
title: 'server1 MCP API',
description: 'OpenAPI specification for server1 MCP server tools',
serverFilter: ['server1']
};
// Verify that the correct options would be passed
expect(expectedOptions.serverFilter).toEqual(['server1']);
expect(expectedOptions.title).toBe('server1 MCP API');
});
test('should generate group-specific OpenAPI spec', async () => {
// Mock group data
const mockGroup = {
id: 'group1',
name: 'webtools',
servers: [
{ name: 'server1', tools: 'all' },
{ name: 'server2', tools: ['tool1', 'tool2'] }
]
};
mockGetGroupByIdOrName.mockReturnValue(mockGroup);
// Mock OpenAPI spec generation
const mockSpec = { openapi: '3.0.3', info: { title: 'webtools Group MCP API' } };
mockGenerateOpenAPISpec.mockResolvedValue(mockSpec);
// Test group spec generation options
const expectedOptions = {
title: 'webtools Group MCP API',
description: 'OpenAPI specification for webtools group tools',
groupFilter: 'webtools'
};
// Verify that the correct options would be passed
expect(expectedOptions.groupFilter).toBe('webtools');
expect(expectedOptions.title).toBe('webtools Group MCP API');
});
test('should handle non-existent server', async () => {
// Mock available servers (not including 'nonexistent')
mockGetAvailableServers.mockResolvedValue(['server1', 'server2']);
// Verify error handling for non-existent server
const serverExists = ['server1', 'server2'].includes('nonexistent');
expect(serverExists).toBe(false);
});
test('should handle non-existent group', async () => {
// Mock group lookup returning null
mockGetGroupByIdOrName.mockReturnValue(null);
// Verify error handling for non-existent group
const group = mockGetGroupByIdOrName('nonexistent');
expect(group).toBeNull();
});
});

View File

@@ -97,7 +97,7 @@ describe('Real Client Transport Integration Tests', () => {
expect(error).toBeNull();
expect(isConnected).toBe(true);
}, 30000);
}, 60000);
it('should connect using real SSEClientTransport with group', async () => {
const testGroup = 'integration-test-group';
@@ -155,7 +155,7 @@ describe('Real Client Transport Integration Tests', () => {
expect(error).toBeNull();
expect(isConnected).toBe(true);
}, 30000);
}, 60000);
});
describe('StreamableHTTP Client Transport Tests', () => {
@@ -214,7 +214,7 @@ describe('Real Client Transport Integration Tests', () => {
expect(error).toBeNull();
expect(isConnected).toBe(true);
}, 30000);
}, 60000);
it('should connect using real StreamableHTTPClientTransport with group', async () => {
const testGroup = 'integration-test-group';
@@ -272,7 +272,7 @@ describe('Real Client Transport Integration Tests', () => {
expect(error).toBeNull();
expect(isConnected).toBe(true);
}, 30000);
}, 60000);
});
describe('Real Client Authentication Tests', () => {
@@ -288,7 +288,7 @@ describe('Real Client Transport Integration Tests', () => {
_authAppServer = authResult.appServer;
_authHttpServer = authResult.httpServer;
authBaseURL = authResult.baseURL;
}, 30000);
}, 60000);
afterAll(async () => {
if (_authHttpServer) {
@@ -345,7 +345,7 @@ describe('Real Client Transport Integration Tests', () => {
if (error) {
expect(error.message).toContain('401');
}
}, 30000);
}, 60000);
it('should connect with SSEClientTransport with valid auth', async () => {
const sseUrl = new URL(`${authBaseURL}/sse`);
@@ -402,7 +402,7 @@ describe('Real Client Transport Integration Tests', () => {
expect(error).toBeNull();
expect(isConnected).toBe(true);
}, 30000);
}, 60000);
it('should connect with StreamableHTTPClientTransport with auth', async () => {
const mcpUrl = new URL(`${authBaseURL}/mcp`);
@@ -460,6 +460,6 @@ describe('Real Client Transport Integration Tests', () => {
expect(error).toBeNull();
expect(isConnected).toBe(true);
}, 30000);
}, 60000);
});
});

View File

@@ -2,51 +2,51 @@ import { generateOpenAPISpec, getToolStats } from '../../src/services/openApiGen
describe('OpenAPI Generator Service', () => {
describe('generateOpenAPISpec', () => {
it('should generate a valid OpenAPI specification', () => {
const spec = generateOpenAPISpec();
it('should generate a valid OpenAPI specification', async () => {
const spec = await generateOpenAPISpec();
// Check basic structure
expect(spec).toHaveProperty('openapi');
expect(spec).toHaveProperty('info');
expect(spec).toHaveProperty('servers');
expect(spec).toHaveProperty('paths');
expect(spec).toHaveProperty('components');
// Check OpenAPI version
expect(spec.openapi).toBe('3.0.3');
// Check info section
expect(spec.info).toHaveProperty('title');
expect(spec.info).toHaveProperty('description');
expect(spec.info).toHaveProperty('version');
// Check components
expect(spec.components).toHaveProperty('schemas');
expect(spec.components).toHaveProperty('securitySchemes');
// Check security schemes
expect(spec.components?.securitySchemes).toHaveProperty('bearerAuth');
});
it('should generate spec with custom options', () => {
it('should generate spec with custom options', async () => {
const options = {
title: 'Custom API',
description: 'Custom description',
version: '2.0.0',
serverUrl: 'https://custom.example.com'
serverUrl: 'https://custom.example.com',
};
const spec = generateOpenAPISpec(options);
const spec = await generateOpenAPISpec(options);
expect(spec.info.title).toBe('Custom API');
expect(spec.info.description).toBe('Custom description');
expect(spec.info.version).toBe('2.0.0');
expect(spec.servers[0].url).toContain('https://custom.example.com');
expect(spec.servers?.[0].url).toContain('https://custom.example.com');
});
it('should handle empty server list gracefully', () => {
const spec = generateOpenAPISpec();
it('should handle empty server list gracefully', async () => {
const spec = await generateOpenAPISpec();
// Should not throw and should have valid structure
expect(spec).toHaveProperty('paths');
expect(typeof spec.paths).toBe('object');
@@ -54,16 +54,16 @@ describe('OpenAPI Generator Service', () => {
});
describe('getToolStats', () => {
it('should return valid tool statistics', () => {
const stats = getToolStats();
it('should return valid tool statistics', async () => {
const stats = await getToolStats();
expect(stats).toHaveProperty('totalServers');
expect(stats).toHaveProperty('totalTools');
expect(stats).toHaveProperty('serverBreakdown');
expect(typeof stats.totalServers).toBe('number');
expect(typeof stats.totalTools).toBe('number');
expect(Array.isArray(stats.serverBreakdown)).toBe(true);
});
});
});
});

View File

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

View File

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

View File

@@ -0,0 +1,131 @@
// Test for CLI path handling functionality
import path from 'path';
import { pathToFileURL } from 'url';
describe('CLI Path Handling', () => {
describe('Cross-platform ESM URL conversion', () => {
it('should convert Unix-style absolute path to file:// URL', () => {
const unixPath = '/home/user/project/dist/index.js';
const fileUrl = pathToFileURL(unixPath).href;
expect(fileUrl).toMatch(/^file:\/\//);
expect(fileUrl).toContain('index.js');
});
it('should handle relative paths correctly', () => {
const relativePath = path.join(process.cwd(), 'dist', 'index.js');
const fileUrl = pathToFileURL(relativePath).href;
expect(fileUrl).toMatch(/^file:\/\//);
expect(fileUrl).toContain('dist');
expect(fileUrl).toContain('index.js');
});
it('should produce valid URL format', () => {
const testPath = path.join(process.cwd(), 'test', 'file.js');
const fileUrl = pathToFileURL(testPath).href;
// Should be a valid URL
expect(() => new URL(fileUrl)).not.toThrow();
// Should start with file://
expect(fileUrl.startsWith('file://')).toBe(true);
});
it('should handle paths with spaces', () => {
const pathWithSpaces = path.join(process.cwd(), 'my folder', 'dist', 'index.js');
const fileUrl = pathToFileURL(pathWithSpaces).href;
expect(fileUrl).toMatch(/^file:\/\//);
expect(fileUrl).toContain('index.js');
// Spaces should be URL-encoded
expect(fileUrl).toContain('%20');
});
it('should handle paths with special characters', () => {
const pathWithSpecialChars = path.join(process.cwd(), 'test@dir', 'file#1.js');
const fileUrl = pathToFileURL(pathWithSpecialChars).href;
expect(fileUrl).toMatch(/^file:\/\//);
// Special characters should be URL-encoded
expect(() => new URL(fileUrl)).not.toThrow();
});
// Windows-specific path handling simulation
it('should handle Windows-style paths correctly', () => {
// Simulate a Windows path structure
// Note: On non-Windows systems, this creates a relative path,
// but the test verifies the conversion mechanism works
const mockWindowsPath = 'C:\\Users\\User\\project\\dist\\index.js';
// On Windows, pathToFileURL would convert C:\ to file:///C:/
// On Unix, it treats it as a relative path, but the conversion still works
const fileUrl = pathToFileURL(mockWindowsPath).href;
expect(fileUrl).toMatch(/^file:\/\//);
expect(fileUrl).toContain('index.js');
});
});
describe('Path normalization', () => {
it('should normalize path separators', () => {
const mixedPath = path.join('dist', 'index.js');
const fileUrl = pathToFileURL(path.resolve(mixedPath)).href;
expect(fileUrl).toMatch(/^file:\/\//);
// All separators should be forward slashes in URL
expect(fileUrl.split('file://')[1]).not.toContain('\\');
});
it('should handle multiple consecutive slashes', () => {
const messyPath = path.normalize('/dist//index.js');
const fileUrl = pathToFileURL(path.resolve(messyPath)).href;
expect(fileUrl).toMatch(/^file:\/\//);
expect(() => new URL(fileUrl)).not.toThrow();
});
});
describe('Path resolution for CLI use case', () => {
it('should convert package root path to valid import URL', () => {
const packageRoot = process.cwd();
const entryPath = path.join(packageRoot, 'dist', 'index.js');
const entryUrl = pathToFileURL(entryPath).href;
expect(entryUrl).toMatch(/^file:\/\//);
expect(entryUrl).toContain('dist');
expect(entryUrl).toContain('index.js');
expect(() => new URL(entryUrl)).not.toThrow();
});
it('should handle nested directory structures', () => {
const deepPath = path.join(process.cwd(), 'a', 'b', 'c', 'd', 'file.js');
const fileUrl = pathToFileURL(deepPath).href;
expect(fileUrl).toMatch(/^file:\/\//);
expect(fileUrl).toContain('file.js');
expect(() => new URL(fileUrl)).not.toThrow();
});
it('should produce URL compatible with dynamic import()', () => {
// This test verifies the exact pattern used in bin/cli.js
const projectRoot = process.cwd();
const entryPath = path.join(projectRoot, 'dist', 'index.js');
const entryUrl = pathToFileURL(entryPath).href;
// The URL should be valid for import()
expect(entryUrl).toMatch(/^file:\/\//);
expect(typeof entryUrl).toBe('string');
// Verify the URL format is valid
const urlObj = new URL(entryUrl);
expect(urlObj.protocol).toBe('file:');
expect(urlObj.href).toBe(entryUrl);
// On Windows, pathToFileURL converts 'C:\path' to 'file:///C:/path'
// On Unix, it converts '/path' to 'file:///path'
// Both formats are valid for dynamic import()
expect(entryUrl).toContain('index.js');
});
});
});