mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 02:39:19 -05:00
Compare commits
13 Commits
copilot/fi
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18cacca2d0 | ||
|
|
1f19ac392f | ||
|
|
840b1b34f1 | ||
|
|
b5dff990e5 | ||
|
|
19f11a0927 | ||
|
|
884870c9de | ||
|
|
7b8d9a7e5a | ||
|
|
8770b9ccfe | ||
|
|
063b081297 | ||
|
|
73ae33e777 | ||
|
|
dac0d376e8 | ||
|
|
803e35b14c | ||
|
|
a736398cd5 |
@@ -1,2 +1,8 @@
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
|
||||
# Database Configuration (Optional - for database-backed configuration)
|
||||
# Simply set DB_URL to enable database mode (auto-detected)
|
||||
# DB_URL=postgresql://mcphub:password@localhost:5432/mcphub
|
||||
# Or explicitly control with USE_DB (overrides auto-detection)
|
||||
# USE_DB=true
|
||||
|
||||
11
.github/copilot-instructions.md
vendored
11
.github/copilot-instructions.md
vendored
@@ -137,9 +137,18 @@ node scripts/verify-dist.js
|
||||
- `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/dao/` - Data access layer (supports JSON file & PostgreSQL)
|
||||
- `src/db/` - TypeORM entities & repositories (for PostgreSQL mode)
|
||||
- `src/types/index.ts` - TypeScript type definitions
|
||||
|
||||
### DAO Layer (Dual Data Source)
|
||||
|
||||
MCPHub supports **JSON file** (default) and **PostgreSQL** storage:
|
||||
|
||||
- Set `USE_DB=true` + `DB_URL=postgresql://...` to use database
|
||||
- When modifying data structures, update: `src/types/`, `src/dao/`, `src/db/entities/`, `src/db/repositories/`, `src/utils/migration.ts`
|
||||
- See `AGENTS.md` for detailed DAO modification checklist
|
||||
|
||||
### Critical Frontend Files
|
||||
|
||||
- `frontend/src/` - React application source
|
||||
|
||||
48
AGENTS.md
48
AGENTS.md
@@ -3,28 +3,76 @@
|
||||
These notes align current contributors around the code layout, daily commands, and collaboration habits that keep `@samanhappy/mcphub` moving quickly.
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
- Backend services live in `src`, grouped by responsibility (`controllers/`, `services/`, `dao/`, `routes/`, `utils/`), with `server.ts` orchestrating HTTP bootstrap.
|
||||
- `frontend/src` contains the Vite + React dashboard; `frontend/public` hosts static assets and translations sit in `locales/`.
|
||||
- Jest-aware test code is split between colocated specs (`src/**/*.{test,spec}.ts`) and higher-level suites in `tests/`; use `tests/utils/` helpers when exercising the CLI or SSE flows.
|
||||
- Build artifacts and bundles are generated into `dist/`, `frontend/dist/`, and `coverage/`; never edit these manually.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
- `pnpm dev` runs backend (`tsx watch src/index.ts`) and frontend (`vite`) together for local iteration.
|
||||
- `pnpm backend:dev`, `pnpm frontend:dev`, and `pnpm frontend:preview` target each surface independently; prefer them when debugging one stack.
|
||||
- `pnpm build` executes `pnpm backend:build` (TypeScript to `dist/`) and `pnpm frontend:build`; run before release or publishing.
|
||||
- `pnpm test`, `pnpm test:watch`, and `pnpm test:coverage` drive Jest; `pnpm lint` and `pnpm format` enforce style via ESLint and Prettier.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
- TypeScript everywhere; default to 2-space indentation and single quotes, letting Prettier settle formatting. ESLint configuration assumes ES modules.
|
||||
- Name services and data access layers with suffixes (`UserService`, `AuthDao`), React components and files in `PascalCase`, and utility modules in `camelCase`.
|
||||
- Keep DTOs and shared types in `src/types` to avoid duplication; re-export through index files only when it clarifies imports.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
- Use Jest with the `ts-jest` ESM preset; place shared setup in `tests/setup.ts` and mock helpers under `tests/utils/`.
|
||||
- Mirror production directory names when adding new suites and end filenames with `.test.ts` or `.spec.ts` for automatic discovery.
|
||||
- Aim to maintain or raise coverage when touching critical flows (auth, OAuth, SSE); add integration tests under `tests/integration/` when touching cross-service logic.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
- Follow the existing Conventional Commit pattern (`feat:`, `fix:`, `chore:`, etc.) with imperative, present-tense summaries and optional multi-line context.
|
||||
- Each PR should describe the behavior change, list testing performed, and link issues; include before/after screenshots or GIFs for frontend tweaks.
|
||||
- Re-run `pnpm build` and `pnpm test` before requesting review, and ensure generated artifacts stay out of the diff.
|
||||
|
||||
## DAO Layer & Dual Data Source
|
||||
|
||||
MCPHub supports **JSON file** (default) and **PostgreSQL** storage. Set `USE_DB=true` + `DB_URL` to switch.
|
||||
|
||||
### Key Files
|
||||
|
||||
- `src/types/index.ts` - Core interfaces (`IUser`, `IGroup`, `ServerConfig`, etc.)
|
||||
- `src/dao/*Dao.ts` - DAO interface + JSON implementation
|
||||
- `src/dao/*DaoDbImpl.ts` - Database implementation
|
||||
- `src/db/entities/*.ts` - TypeORM entities
|
||||
- `src/db/repositories/*.ts` - TypeORM repository wrappers
|
||||
- `src/utils/migration.ts` - JSON-to-database migration
|
||||
|
||||
### Modifying Data Structures (CRITICAL)
|
||||
|
||||
When adding/changing fields, update **ALL** these files:
|
||||
|
||||
| Step | File | Action |
|
||||
| ---- | -------------------------- | ---------------------------- |
|
||||
| 1 | `src/types/index.ts` | Add field to interface |
|
||||
| 2 | `src/dao/*Dao.ts` | Update JSON impl if needed |
|
||||
| 3 | `src/db/entities/*.ts` | Add TypeORM `@Column` |
|
||||
| 4 | `src/dao/*DaoDbImpl.ts` | Map field in create/update |
|
||||
| 5 | `src/db/repositories/*.ts` | Update if needed |
|
||||
| 6 | `src/utils/migration.ts` | Include in migration |
|
||||
| 7 | `mcp_settings.json` | Update example if applicable |
|
||||
|
||||
### Data Type Mapping
|
||||
|
||||
| Model | DAO | DB Entity | JSON Path |
|
||||
| -------------- | ----------------- | -------------- | ------------------------ |
|
||||
| `IUser` | `UserDao` | `User` | `settings.users[]` |
|
||||
| `ServerConfig` | `ServerDao` | `Server` | `settings.mcpServers{}` |
|
||||
| `IGroup` | `GroupDao` | `Group` | `settings.groups[]` |
|
||||
| `SystemConfig` | `SystemConfigDao` | `SystemConfig` | `settings.systemConfig` |
|
||||
| `UserConfig` | `UserConfigDao` | `UserConfig` | `settings.userConfigs{}` |
|
||||
|
||||
### Common Pitfalls
|
||||
|
||||
- Forgetting migration script → fields won't migrate to DB
|
||||
- Optional fields need `nullable: true` in entity
|
||||
- Complex objects need `simple-json` column type
|
||||
|
||||
188
README.fr.md
188
README.fr.md
@@ -1,6 +1,6 @@
|
||||
[English](README.md) | Français | [中文版](README.zh.md)
|
||||
# MCPHub : Le Hub Unifié pour les Serveurs MCP
|
||||
|
||||
# MCPHub : Le Hub Unifié pour les Serveurs MCP (Model Context Protocol)
|
||||
[English](README.md) | Français | [中文版](README.zh.md)
|
||||
|
||||
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.
|
||||
|
||||
@@ -13,171 +13,74 @@ MCPHub facilite la gestion et la mise à l'échelle de plusieurs serveurs MCP (M
|
||||
|
||||
## 🚀 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.
|
||||
- **Gestion centralisée** - Surveillez et contrôlez tous les serveurs MCP depuis un tableau de bord unifié
|
||||
- **Routage flexible** - Accédez à tous les serveurs, groupes spécifiques ou serveurs individuels via HTTP/SSE
|
||||
- **Routage intelligent** - Découverte d'outils propulsée par IA utilisant la recherche sémantique vectorielle ([En savoir plus](https://docs.mcphubx.com/features/smart-routing))
|
||||
- **Configuration à chaud** - Ajoutez, supprimez ou mettez à jour les serveurs sans temps d'arrêt
|
||||
- **Support OAuth 2.0** - Modes client et serveur pour une authentification sécurisée ([En savoir plus](https://docs.mcphubx.com/features/oauth))
|
||||
- **Mode Base de données** - Stockez la configuration dans PostgreSQL pour les environnements de production ([En savoir plus](https://docs.mcphubx.com/configuration/database-configuration))
|
||||
- **Prêt pour Docker** - Déployez instantanément avec la configuration conteneurisée
|
||||
|
||||
## 🔧 Démarrage rapide
|
||||
|
||||
### Configuration
|
||||
|
||||
Créez un fichier `mcp_settings.json` pour personnaliser les paramètres de votre serveur :
|
||||
Créez un fichier `mcp_settings.json` :
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"amap": {
|
||||
"time": {
|
||||
"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"]
|
||||
"args": ["-y", "time-mcp"]
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
📖 Consultez le [Guide de configuration](https://docs.mcphubx.com/configuration/mcp-settings) pour les options complètes incluant OAuth, les variables d'environnement, et plus.
|
||||
|
||||
### Déploiement avec Docker
|
||||
|
||||
**Recommandé** : Montez votre configuration personnalisée :
|
||||
|
||||
```bash
|
||||
# Exécutez avec une configuration personnalisée (recommandé)
|
||||
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
|
||||
# Ou exécutez avec les paramètres par défaut
|
||||
docker run -p 3000:3000 samanhappy/mcphub
|
||||
```
|
||||
|
||||
### Accéder au tableau de bord
|
||||
|
||||
Ouvrez `http://localhost:3000` et connectez-vous avec vos identifiants.
|
||||
Ouvrez `http://localhost:3000` et connectez-vous avec les identifiants par défaut : `admin` / `admin123`
|
||||
|
||||
> **Note** : Les identifiants par défaut sont `admin` / `admin123`.
|
||||
### Connecter les clients IA
|
||||
|
||||
**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 :
|
||||
Connectez les clients IA (Claude Desktop, Cursor, etc.) via :
|
||||
|
||||
```
|
||||
http://localhost:3000/mcp
|
||||
http://localhost:3000/mcp # Tous les serveurs
|
||||
http://localhost:3000/mcp/{group} # Groupe spécifique
|
||||
http://localhost:3000/mcp/{server} # Serveur spécifique
|
||||
http://localhost:3000/mcp/$smart # Routage intelligent
|
||||
```
|
||||
|
||||
Ce point de terminaison fournit une interface HTTP streamable unifiée pour tous vos serveurs MCP. Il vous permet de :
|
||||
📖 Consultez la [Référence API](https://docs.mcphubx.com/api-reference) pour la documentation détaillée des points de terminaison.
|
||||
|
||||
- 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
|
||||
## 📚 Documentation
|
||||
|
||||
**Routage intelligent (expérimental)** :
|
||||
|
||||
Le routage intelligent est le système de découverte d'outils intelligent de MCPHub qui utilise la recherche sémantique vectorielle pour trouver automatiquement les outils les plus pertinents pour une tâche donnée.
|
||||
|
||||
```
|
||||
http://localhost:3000/mcp/$smart
|
||||
```
|
||||
|
||||
**Comment ça marche** :
|
||||
|
||||
1. **Indexation des outils** : Tous les outils MCP sont automatiquement convertis en plongements vectoriels et stockés dans PostgreSQL avec pgvector.
|
||||
2. **Recherche sémantique** : Les requêtes des utilisateurs sont converties en vecteurs et comparées aux plongements des outils en utilisant la similarité cosinus.
|
||||
3. **Filtrage intelligent** : Des seuils dynamiques garantissent des résultats pertinents sans bruit.
|
||||
4. **Exécution précise** : Les outils trouvés peuvent être directement exécutés avec une validation appropriée des paramètres.
|
||||
|
||||
**Prérequis pour la configuration** :
|
||||
|
||||

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

|
||||
|
||||
Pour un accès ciblé à des groupes de serveurs spécifiques, utilisez le point de terminaison HTTP basé sur les groupes :
|
||||
|
||||
```
|
||||
http://localhost:3000/mcp/{group}
|
||||
```
|
||||
|
||||
Où `{group}` est l'ID ou le nom du groupe que vous avez créé dans le tableau de bord. Cela vous permet de :
|
||||
|
||||
- Vous connecter à un sous-ensemble spécifique de serveurs MCP organisés par cas d'utilisation
|
||||
- Isoler différents outils IA pour n'accéder qu'aux serveurs pertinents
|
||||
- Mettre en œuvre un contrôle d'accès plus granulaire pour différents environnements ou équipes
|
||||
|
||||
**Points de terminaison spécifiques aux serveurs** :
|
||||
Pour un accès direct à des serveurs individuels, utilisez le point de terminaison HTTP spécifique au serveur :
|
||||
|
||||
```
|
||||
http://localhost:3000/mcp/{server}
|
||||
```
|
||||
|
||||
Où `{server}` est le nom du serveur auquel vous souhaitez vous connecter. Cela vous permet d'accéder directement à un serveur MCP spécifique.
|
||||
|
||||
> **Note** : Si le nom du serveur et le nom du groupe sont identiques, le nom du groupe aura la priorité.
|
||||
|
||||
### Point de terminaison SSE (obsolète à l'avenir)
|
||||
|
||||
Connectez les clients IA (par exemple, Claude Desktop, Cursor, DeepChat, etc.) via :
|
||||
|
||||
```
|
||||
http://localhost:3000/sse
|
||||
```
|
||||
|
||||
Pour le routage intelligent, utilisez :
|
||||
|
||||
```
|
||||
http://localhost:3000/sse/$smart
|
||||
```
|
||||
|
||||
Pour un accès ciblé à des groupes de serveurs spécifiques, utilisez le point de terminaison SSE basé sur les groupes :
|
||||
|
||||
```
|
||||
http://localhost:3000/sse/{group}
|
||||
```
|
||||
|
||||
Pour un accès direct à des serveurs individuels, utilisez le point de terminaison SSE spécifique au serveur :
|
||||
|
||||
```
|
||||
http://localhost:3000/sse/{server}
|
||||
```
|
||||
| Sujet | Description |
|
||||
| ------------------------------------------------------------------------------------- | ------------------------------------------- |
|
||||
| [Démarrage rapide](https://docs.mcphubx.com/quickstart) | Commencez en 5 minutes |
|
||||
| [Configuration](https://docs.mcphubx.com/configuration/mcp-settings) | Options de configuration du serveur MCP |
|
||||
| [Mode Base de données](https://docs.mcphubx.com/configuration/database-configuration) | Configuration PostgreSQL pour la production |
|
||||
| [OAuth](https://docs.mcphubx.com/features/oauth) | Configuration client et serveur OAuth 2.0 |
|
||||
| [Routage intelligent](https://docs.mcphubx.com/features/smart-routing) | Découverte d'outils propulsée par IA |
|
||||
| [Configuration Docker](https://docs.mcphubx.com/configuration/docker-setup) | Guide de déploiement Docker |
|
||||
|
||||
## 🧑💻 Développement local
|
||||
|
||||
@@ -188,19 +91,9 @@ pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Cela démarre à la fois le frontend et le backend en mode développement avec rechargement à chaud.
|
||||
> Pour les utilisateurs Windows, démarrez le backend et le frontend séparément : `pnpm backend:dev`, `pnpm frontend:dev`
|
||||
|
||||
> 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
|
||||
```
|
||||
📖 Consultez le [Guide de développement](https://docs.mcphubx.com/development) pour les instructions de configuration détaillées.
|
||||
|
||||
## 🔍 Stack technique
|
||||
|
||||
@@ -211,19 +104,10 @@ proxy_buffering off
|
||||
|
||||
## 👥 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.
|
||||
Les contributions sont les bienvenues ! Rejoignez notre [communauté Discord](https://discord.gg/qMKNsn5Q) pour des discussions et du support.
|
||||
|
||||
## ❤️ Sponsor
|
||||
|
||||
Si vous aimez ce projet, vous pouvez peut-être envisager de :
|
||||
|
||||
[](https://ko-fi.com/samanhappy)
|
||||
|
||||
## 🌟 Historique des étoiles
|
||||
|
||||
289
README.md
289
README.md
@@ -13,276 +13,74 @@ MCPHub makes it easy to manage and scale multiple MCP (Model Context Protocol) s
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
- **Broadened MCP Server Support**: Seamlessly integrate any MCP server with minimal configuration.
|
||||
- **Centralized Dashboard**: Monitor real-time status and performance metrics from one sleek web UI.
|
||||
- **Flexible Protocol Handling**: Full compatibility with both stdio and SSE MCP protocols.
|
||||
- **Hot-Swappable Configuration**: Add, remove, or update MCP servers on the fly — no downtime required.
|
||||
- **Group-Based Access Control**: Organize servers into customizable groups for streamlined permissions management.
|
||||
- **Secure Authentication**: Built-in user management with role-based access powered by JWT and bcrypt.
|
||||
- **OAuth 2.0 Support**:
|
||||
- Full OAuth support for upstream MCP servers with proxy authorization capabilities
|
||||
- **NEW**: Act as OAuth 2.0 authorization server for external clients (ChatGPT Web, custom apps)
|
||||
- **Environment Variable Expansion**: Use environment variables anywhere in your configuration for secure credential management. See [Environment Variables Guide](docs/environment-variables.md).
|
||||
- **Docker-Ready**: Deploy instantly with our containerized setup.
|
||||
- **Centralized Management** - Monitor and control all MCP servers from a unified dashboard
|
||||
- **Flexible Routing** - Access all servers, specific groups, or individual servers via HTTP/SSE
|
||||
- **Smart Routing** - AI-powered tool discovery using vector semantic search ([Learn more](https://docs.mcphubx.com/features/smart-routing))
|
||||
- **Hot-Swappable Config** - Add, remove, or update servers without downtime
|
||||
- **OAuth 2.0 Support** - Both client and server modes for secure authentication ([Learn more](https://docs.mcphubx.com/features/oauth))
|
||||
- **Database Mode** - Store configuration in PostgreSQL for production environments ([Learn more](https://docs.mcphubx.com/configuration/database-configuration))
|
||||
- **Docker-Ready** - Deploy instantly with containerized setup
|
||||
|
||||
## 🔧 Quick Start
|
||||
|
||||
### Configuration
|
||||
|
||||
Create a `mcp_settings.json` file to customize your server settings:
|
||||
Create a `mcp_settings.json` file:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"amap": {
|
||||
"time": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@amap/amap-maps-mcp-server"],
|
||||
"env": {
|
||||
"AMAP_MAPS_API_KEY": "your-api-key"
|
||||
}
|
||||
},
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--headless"]
|
||||
"args": ["-y", "time-mcp"]
|
||||
},
|
||||
"fetch": {
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server-fetch"]
|
||||
},
|
||||
"slack": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-slack"],
|
||||
"env": {
|
||||
"SLACK_BOT_TOKEN": "your-bot-token",
|
||||
"SLACK_TEAM_ID": "your-team-id"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### OAuth Configuration (Optional)
|
||||
|
||||
MCPHub supports OAuth 2.0 for authenticating with upstream MCP servers. See the [OAuth feature guide](docs/features/oauth.mdx) for a full walkthrough. In practice you will run into two configuration patterns:
|
||||
|
||||
- **Dynamic registration servers** (e.g., Vercel, Linear) publish all metadata and allow MCPHub to self-register. Simply declare the server URL and MCPHub handles the rest.
|
||||
- **Manually provisioned servers** (e.g., GitHub Copilot) require you to create an OAuth App and provide the issued client ID/secret to MCPHub.
|
||||
|
||||
Dynamic registration example:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"vercel": {
|
||||
"type": "sse",
|
||||
"url": "https://mcp.vercel.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Manual registration example:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
"type": "sse",
|
||||
"url": "https://api.githubcopilot.com/mcp/",
|
||||
"oauth": {
|
||||
"clientId": "${GITHUB_OAUTH_APP_ID}",
|
||||
"clientSecret": "${GITHUB_OAUTH_APP_SECRET}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For manual providers, create the OAuth App in the upstream console, set the redirect URI to `http://localhost:3000/oauth/callback` (or your deployed domain), and then plug the credentials into the dashboard or config file.
|
||||
|
||||
#### OAuth Authorization Server (NEW)
|
||||
|
||||
MCPHub can now act as an OAuth 2.0 authorization server, allowing external applications to securely access your MCP servers using standard OAuth flows. This is particularly useful for integrating with ChatGPT Web and other services that require OAuth authentication.
|
||||
|
||||
**Enable OAuth Server:**
|
||||
|
||||
```json
|
||||
{
|
||||
"systemConfig": {
|
||||
"oauthServer": {
|
||||
"enabled": true,
|
||||
"accessTokenLifetime": 3600,
|
||||
"refreshTokenLifetime": 1209600,
|
||||
"allowedScopes": ["read", "write"]
|
||||
}
|
||||
},
|
||||
"oauthClients": [
|
||||
{
|
||||
"clientId": "your-client-id",
|
||||
"name": "ChatGPT Web",
|
||||
"redirectUris": ["https://chatgpt.com/oauth/callback"],
|
||||
"grants": ["authorization_code", "refresh_token"],
|
||||
"scopes": ["read", "write"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Key Features:**
|
||||
- Standard OAuth 2.0 authorization code flow
|
||||
- PKCE support for enhanced security
|
||||
- Token refresh capabilities
|
||||
- Compatible with ChatGPT Web and other OAuth clients
|
||||
|
||||
For detailed setup instructions, see the [OAuth Server Documentation](docs/oauth-server.md).
|
||||
📖 See [Configuration Guide](https://docs.mcphubx.com/configuration/mcp-settings) for full options including OAuth, environment variables, and more.
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
**Recommended**: Mount your custom config:
|
||||
|
||||
```bash
|
||||
# Run with custom config (recommended)
|
||||
docker run -p 3000:3000 -v ./mcp_settings.json:/app/mcp_settings.json -v ./data:/app/data samanhappy/mcphub
|
||||
```
|
||||
|
||||
or run with default settings:
|
||||
|
||||
```bash
|
||||
# Or run with default settings
|
||||
docker run -p 3000:3000 samanhappy/mcphub
|
||||
```
|
||||
|
||||
### Access the Dashboard
|
||||
### Access Dashboard
|
||||
|
||||
Open `http://localhost:3000` and log in with your credentials.
|
||||
Open `http://localhost:3000` and log in with default credentials: `admin` / `admin123`
|
||||
|
||||
> **Note**: Default credentials are `admin` / `admin123`.
|
||||
### Connect AI Clients
|
||||
|
||||
**Dashboard Overview**:
|
||||
|
||||
- Live status of all MCP servers
|
||||
- Enable/disable or reconfigure servers
|
||||
- Group management for organizing servers
|
||||
- User administration for access control
|
||||
|
||||
### Streamable HTTP Endpoint
|
||||
|
||||
> As of now, support for streaming HTTP endpoints varies across different AI clients. If you encounter issues, you can use the SSE endpoint or wait for future updates.
|
||||
|
||||
Connect AI clients (e.g., Claude Desktop, Cursor, DeepChat, etc.) via:
|
||||
Connect AI clients (Claude Desktop, Cursor, etc.) via:
|
||||
|
||||
```
|
||||
http://localhost:3000/mcp
|
||||
http://localhost:3000/mcp # All servers
|
||||
http://localhost:3000/mcp/{group} # Specific group
|
||||
http://localhost:3000/mcp/{server} # Specific server
|
||||
http://localhost:3000/mcp/$smart # Smart routing
|
||||
```
|
||||
|
||||
This endpoint provides a unified streamable HTTP interface for all your MCP servers. It allows you to:
|
||||
📖 See [API Reference](https://docs.mcphubx.com/api-reference) for detailed endpoint documentation.
|
||||
|
||||
- Send requests to any configured MCP server
|
||||
- Receive responses in real-time
|
||||
- Easily integrate with various AI clients and tools
|
||||
- Use the same endpoint for all servers, simplifying your integration process
|
||||
## 📚 Documentation
|
||||
|
||||
**Smart Routing (Experimental)**:
|
||||
|
||||
Smart Routing is MCPHub's intelligent tool discovery system that uses vector semantic search to automatically find the most relevant tools for any given task.
|
||||
|
||||
```
|
||||
# Search across all servers
|
||||
http://localhost:3000/mcp/$smart
|
||||
|
||||
# Search within a specific group
|
||||
http://localhost:3000/mcp/$smart/{group}
|
||||
```
|
||||
|
||||
**How it Works:**
|
||||
|
||||
1. **Tool Indexing**: All MCP tools are automatically converted to vector embeddings and stored in PostgreSQL with pgvector
|
||||
2. **Semantic Search**: User queries are converted to vectors and matched against tool embeddings using cosine similarity
|
||||
3. **Intelligent Filtering**: Dynamic thresholds ensure relevant results without noise
|
||||
4. **Precise Execution**: Found tools can be directly executed with proper parameter validation
|
||||
5. **Group Scoping**: Optionally limit searches to servers within a specific group for focused results
|
||||
|
||||
**Setup Requirements:**
|
||||
|
||||

|
||||
|
||||
To enable Smart Routing, you need:
|
||||
|
||||
- PostgreSQL with pgvector extension
|
||||
- OpenAI API key (or compatible embedding service)
|
||||
- Enable Smart Routing in MCPHub settings
|
||||
|
||||
**Group-Scoped Smart Routing**:
|
||||
|
||||
You can combine Smart Routing with group filtering to search only within specific server groups:
|
||||
|
||||
```
|
||||
# Search only within production servers
|
||||
http://localhost:3000/mcp/$smart/production
|
||||
|
||||
# Search only within development servers
|
||||
http://localhost:3000/mcp/$smart/development
|
||||
```
|
||||
|
||||
This enables:
|
||||
- **Focused Discovery**: Find tools only from relevant servers
|
||||
- **Environment Isolation**: Separate tool discovery by environment (dev, staging, prod)
|
||||
- **Team-Based Access**: Limit tool search to team-specific server groups
|
||||
|
||||
**Group-Specific Endpoints (Recommended)**:
|
||||
|
||||

|
||||
|
||||
For targeted access to specific server groups, use the group-based HTTP endpoint:
|
||||
|
||||
```
|
||||
http://localhost:3000/mcp/{group}
|
||||
```
|
||||
|
||||
Where `{group}` is the ID or name of the group you created in the dashboard. This allows you to:
|
||||
|
||||
- Connect to a specific subset of MCP servers organized by use case
|
||||
- Isolate different AI tools to access only relevant servers
|
||||
- Implement more granular access control for different environments or teams
|
||||
|
||||
**Server-Specific Endpoints**:
|
||||
For direct access to individual servers, use the server-specific HTTP endpoint:
|
||||
|
||||
```
|
||||
http://localhost:3000/mcp/{server}
|
||||
```
|
||||
|
||||
Where `{server}` is the name of the server you want to connect to. This allows you to access a specific MCP server directly.
|
||||
|
||||
> **Note**: If the server name and group name are the same, the group name will take precedence.
|
||||
|
||||
### SSE Endpoint (Deprecated in Future)
|
||||
|
||||
Connect AI clients (e.g., Claude Desktop, Cursor, DeepChat, etc.) via:
|
||||
|
||||
```
|
||||
http://localhost:3000/sse
|
||||
```
|
||||
|
||||
For smart routing, use:
|
||||
|
||||
```
|
||||
# Search across all servers
|
||||
http://localhost:3000/sse/$smart
|
||||
|
||||
# Search within a specific group
|
||||
http://localhost:3000/sse/$smart/{group}
|
||||
```
|
||||
|
||||
For targeted access to specific server groups, use the group-based SSE endpoint:
|
||||
|
||||
```
|
||||
http://localhost:3000/sse/{group}
|
||||
```
|
||||
|
||||
For direct access to individual servers, use the server-specific SSE endpoint:
|
||||
|
||||
```
|
||||
http://localhost:3000/sse/{server}
|
||||
```
|
||||
| Topic | Description |
|
||||
| ------------------------------------------------------------------------------ | --------------------------------- |
|
||||
| [Quick Start](https://docs.mcphubx.com/quickstart) | Get started in 5 minutes |
|
||||
| [Configuration](https://docs.mcphubx.com/configuration/mcp-settings) | MCP server configuration options |
|
||||
| [Database Mode](https://docs.mcphubx.com/configuration/database-configuration) | PostgreSQL setup for production |
|
||||
| [OAuth](https://docs.mcphubx.com/features/oauth) | OAuth 2.0 client and server setup |
|
||||
| [Smart Routing](https://docs.mcphubx.com/features/smart-routing) | AI-powered tool discovery |
|
||||
| [Docker Setup](https://docs.mcphubx.com/configuration/docker-setup) | Docker deployment guide |
|
||||
|
||||
## 🧑💻 Local Development
|
||||
|
||||
@@ -293,19 +91,9 @@ pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
This starts both frontend and backend in development mode with hot-reloading.
|
||||
> For Windows users, start backend and frontend separately: `pnpm backend:dev`, `pnpm frontend:dev`
|
||||
|
||||
> For windows users, you may need to start the backend server and frontend separately: `pnpm backend:dev`, `pnpm frontend:dev`.
|
||||
|
||||
## 🛠️ Common Issues
|
||||
|
||||
### Using Nginx as a Reverse Proxy
|
||||
|
||||
If you are using Nginx to reverse proxy MCPHub, please make sure to add the following configuration in your Nginx setup:
|
||||
|
||||
```nginx
|
||||
proxy_buffering off
|
||||
```
|
||||
📖 See [Development Guide](https://docs.mcphubx.com/development) for detailed setup instructions.
|
||||
|
||||
## 🔍 Tech Stack
|
||||
|
||||
@@ -316,19 +104,10 @@ proxy_buffering off
|
||||
|
||||
## 👥 Contributing
|
||||
|
||||
Contributions of any kind are welcome!
|
||||
|
||||
- New features & optimizations
|
||||
- Documentation improvements
|
||||
- Bug reports & fixes
|
||||
- Translations & suggestions
|
||||
|
||||
Welcome to join our [Discord community](https://discord.gg/qMKNsn5Q) for discussions and support.
|
||||
Contributions welcome! See our [Discord community](https://discord.gg/qMKNsn5Q) for discussions and support.
|
||||
|
||||
## ❤️ Sponsor
|
||||
|
||||
If you like this project, maybe you can consider:
|
||||
|
||||
[](https://ko-fi.com/samanhappy)
|
||||
|
||||
## 🌟 Star History
|
||||
|
||||
245
README.zh.md
245
README.zh.md
@@ -13,236 +13,74 @@ MCPHub 通过将多个 MCP(Model Context Protocol)服务器组织为灵活
|
||||
|
||||
## 🚀 功能亮点
|
||||
|
||||
- **广泛的 MCP 服务器支持**:无缝集成任何 MCP 服务器,配置简单。
|
||||
- **集中式管理控制台**:在一个简洁的 Web UI 中实时监控所有服务器的状态和性能指标。
|
||||
- **灵活的协议兼容**:完全支持 stdio 和 SSE 两种 MCP 协议。
|
||||
- **热插拔式配置**:在运行时动态添加、移除或更新服务器配置,无需停机。
|
||||
- **基于分组的访问控制**:自定义分组并管理服务器访问权限。
|
||||
- **安全认证机制**:内置用户管理,基于 JWT 和 bcrypt,实现角色权限控制。
|
||||
- **Docker 就绪**:提供容器化镜像,快速部署。
|
||||
- **集中式管理** - 在统一控制台中监控和管理所有 MCP 服务器
|
||||
- **灵活路由** - 通过 HTTP/SSE 访问所有服务器、特定分组或单个服务器
|
||||
- **智能路由** - 基于向量语义搜索的 AI 工具发现 ([了解更多](https://docs.mcphubx.com/zh/features/smart-routing))
|
||||
- **热插拔配置** - 无需停机即可添加、移除或更新服务器
|
||||
- **OAuth 2.0 支持** - 客户端和服务端模式,实现安全认证 ([了解更多](https://docs.mcphubx.com/zh/features/oauth))
|
||||
- **数据库模式** - 将配置存储在 PostgreSQL 中,适用于生产环境 ([了解更多](https://docs.mcphubx.com/zh/configuration/database-configuration))
|
||||
- **Docker 就绪** - 容器化部署,开箱即用
|
||||
|
||||
## 🔧 快速开始
|
||||
|
||||
### 配置
|
||||
|
||||
通过创建 `mcp_settings.json` 自定义服务器设置:
|
||||
创建 `mcp_settings.json` 文件:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"amap": {
|
||||
"time": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@amap/amap-maps-mcp-server"],
|
||||
"env": {
|
||||
"AMAP_MAPS_API_KEY": "your-api-key"
|
||||
}
|
||||
},
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--headless"]
|
||||
"args": ["-y", "time-mcp"]
|
||||
},
|
||||
"fetch": {
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server-fetch"]
|
||||
},
|
||||
"slack": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-slack"],
|
||||
"env": {
|
||||
"SLACK_BOT_TOKEN": "your-bot-token",
|
||||
"SLACK_TEAM_ID": "your-team-id"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### OAuth 配置(可选)
|
||||
|
||||
MCPHub 支持通过 OAuth 2.0 访问上游 MCP 服务器。完整说明请参阅[《OAuth 功能指南》](docs/zh/features/oauth.mdx)。实际使用中通常会遇到两类配置:
|
||||
|
||||
- **支持动态注册的服务器**(如 Vercel、Linear):会公开全部元数据,MCPHub 可自动注册并完成授权,仅需声明服务器地址。
|
||||
- **需要手动配置客户端的服务器**(如 GitHub Copilot):需要在提供商后台创建 OAuth 应用,并将获得的 Client ID/Secret 写入 MCPHub。
|
||||
|
||||
动态注册示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"vercel": {
|
||||
"type": "sse",
|
||||
"url": "https://mcp.vercel.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
手动注册示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
"type": "sse",
|
||||
"url": "https://api.githubcopilot.com/mcp/",
|
||||
"oauth": {
|
||||
"clientId": "${GITHUB_OAUTH_APP_ID}",
|
||||
"clientSecret": "${GITHUB_OAUTH_APP_SECRET}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
对于需要手动注册的提供商,请先在上游控制台创建 OAuth 应用,将回调地址设置为 `http://localhost:3000/oauth/callback`(或你的部署域名),然后在控制台或配置文件中填写凭据。
|
||||
📖 查看[配置指南](https://docs.mcphubx.com/zh/configuration/mcp-settings)了解完整选项,包括 OAuth、环境变量等。
|
||||
|
||||
### Docker 部署
|
||||
|
||||
**推荐**:挂载自定义配置:
|
||||
|
||||
```bash
|
||||
# 挂载自定义配置运行(推荐)
|
||||
docker run -p 3000:3000 -v ./mcp_settings.json:/app/mcp_settings.json -v ./data:/app/data samanhappy/mcphub
|
||||
```
|
||||
|
||||
或使用默认配置运行:
|
||||
|
||||
```bash
|
||||
# 或使用默认配置运行
|
||||
docker run -p 3000:3000 samanhappy/mcphub
|
||||
```
|
||||
|
||||
### 访问控制台
|
||||
|
||||
打开 `http://localhost:3000`,使用您的账号登录。
|
||||
打开 `http://localhost:3000`,使用默认账号登录:`admin` / `admin123`
|
||||
|
||||
> **提示**:默认用户名/密码为 `admin` / `admin123`。
|
||||
### 连接 AI 客户端
|
||||
|
||||
**控制台功能**:
|
||||
|
||||
- 实时监控所有 MCP 服务器状态
|
||||
- 启用/禁用或重新配置服务器
|
||||
- 分组管理,组织服务器访问
|
||||
- 用户管理,设定权限
|
||||
|
||||
### 支持流式的 HTTP 端点
|
||||
|
||||
> 截至目前,各家 AI 客户端对流式的 HTTP 端点支持不一,如果遇到问题,可以使用 SSE 端点或者等待更新。
|
||||
|
||||
通过以下地址连接 AI 客户端(如 Claude Desktop、Cursor、DeepChat 等):
|
||||
通过以下地址连接 AI 客户端(Claude Desktop、Cursor 等):
|
||||
|
||||
```
|
||||
http://localhost:3000/mcp
|
||||
http://localhost:3000/mcp # 所有服务器
|
||||
http://localhost:3000/mcp/{group} # 特定分组
|
||||
http://localhost:3000/mcp/{server} # 特定服务器
|
||||
http://localhost:3000/mcp/$smart # 智能路由
|
||||
```
|
||||
|
||||
这个端点为所有 MCP 服务器提供统一的流式 HTTP 接口。它允许您:
|
||||
📖 查看 [API 参考](https://docs.mcphubx.com/zh/api-reference)了解详细的端点文档。
|
||||
|
||||
- 向任何配置的 MCP 服务器发送请求
|
||||
- 实时接收响应
|
||||
- 轻松与各种 AI 客户端和工具集成
|
||||
- 对所有服务器使用相同的端点,简化集成过程
|
||||
## 📚 文档
|
||||
|
||||
**智能路由(实验性功能)**:
|
||||
|
||||
智能路由是 MCPHub 的智能工具发现系统,使用向量语义搜索自动为任何给定任务找到最相关的工具。
|
||||
|
||||
```
|
||||
# 在所有服务器中搜索
|
||||
http://localhost:3000/mcp/$smart
|
||||
|
||||
# 在特定分组中搜索
|
||||
http://localhost:3000/mcp/$smart/{group}
|
||||
```
|
||||
|
||||
**工作原理:**
|
||||
|
||||
1. **工具索引**:所有 MCP 工具自动转换为向量嵌入并存储在 PostgreSQL 与 pgvector 中
|
||||
2. **语义搜索**:用户查询转换为向量并使用余弦相似度与工具嵌入匹配
|
||||
3. **智能筛选**:动态阈值确保相关结果且无噪声
|
||||
4. **精确执行**:找到的工具可以直接执行并进行适当的参数验证
|
||||
5. **分组限定**:可选择将搜索限制在特定分组的服务器内以获得更精确的结果
|
||||
|
||||
**设置要求:**
|
||||
|
||||

|
||||
|
||||
为了启用智能路由,您需要:
|
||||
|
||||
- 支持 pgvector 扩展的 PostgreSQL
|
||||
- OpenAI API 密钥(或兼容的嵌入服务)
|
||||
- 在 MCPHub 设置中启用智能路由
|
||||
|
||||
**分组限定的智能路由**:
|
||||
|
||||
您可以将智能路由与分组筛选结合使用,仅在特定服务器分组内搜索:
|
||||
|
||||
```
|
||||
# 仅在生产服务器中搜索
|
||||
http://localhost:3000/mcp/$smart/production
|
||||
|
||||
# 仅在开发服务器中搜索
|
||||
http://localhost:3000/mcp/$smart/development
|
||||
```
|
||||
|
||||
这样可以实现:
|
||||
- **精准发现**:仅从相关服务器查找工具
|
||||
- **环境隔离**:按环境(开发、测试、生产)分离工具发现
|
||||
- **基于团队的访问**:将工具搜索限制在特定团队的服务器分组
|
||||
|
||||
**基于分组的 HTTP 端点(推荐)**:
|
||||

|
||||
要针对特定服务器分组进行访问,请使用基于分组的 HTTP 端点:
|
||||
|
||||
```
|
||||
http://localhost:3000/mcp/{group}
|
||||
```
|
||||
|
||||
其中 `{group}` 是您在控制面板中创建的分组 ID 或名称。这样做可以:
|
||||
|
||||
- 连接到按用例组织的特定 MCP 服务器子集
|
||||
- 隔离不同的 AI 工具,使其只能访问相关服务器
|
||||
- 为不同环境或团队实现更精细的访问控制
|
||||
- 通过分组名称轻松识别和管理服务器
|
||||
- 允许不同的 AI 客户端使用相同的端点,简化集成过程
|
||||
|
||||
**针对特定服务器的 HTTP 端点**:
|
||||
要针对特定服务器进行访问,请使用以下格式:
|
||||
|
||||
```
|
||||
http://localhost:3000/mcp/{server}
|
||||
```
|
||||
|
||||
其中 `{server}` 是您要连接的服务器名称。这样做可以直接访问特定的 MCP 服务器。
|
||||
|
||||
> **提示**:如果服务器名称和分组名称相同,则分组名称优先。
|
||||
|
||||
### SSE 端点集成 (未来可能废弃)
|
||||
|
||||
通过以下地址连接 AI 客户端(如 Claude Desktop、Cursor、DeepChat 等):
|
||||
|
||||
```
|
||||
http://localhost:3000/sse
|
||||
```
|
||||
|
||||
要启用智能路由,请使用:
|
||||
|
||||
```
|
||||
# 在所有服务器中搜索
|
||||
http://localhost:3000/sse/$smart
|
||||
|
||||
# 在特定分组中搜索
|
||||
http://localhost:3000/sse/$smart/{group}
|
||||
```
|
||||
|
||||
要针对特定服务器分组进行访问,请使用基于分组的 SSE 端点:
|
||||
|
||||
```
|
||||
http://localhost:3000/sse/{group}
|
||||
```
|
||||
|
||||
要针对特定服务器进行访问,请使用以下格式:
|
||||
|
||||
```
|
||||
http://localhost:3000/sse/{server}
|
||||
```
|
||||
| 主题 | 描述 |
|
||||
| ------------------------------------------------------------------------------ | ---------------------------- |
|
||||
| [快速开始](https://docs.mcphubx.com/zh/quickstart) | 5 分钟快速上手 |
|
||||
| [配置指南](https://docs.mcphubx.com/zh/configuration/mcp-settings) | MCP 服务器配置选项 |
|
||||
| [数据库模式](https://docs.mcphubx.com/zh/configuration/database-configuration) | PostgreSQL 生产环境配置 |
|
||||
| [OAuth](https://docs.mcphubx.com/zh/features/oauth) | OAuth 2.0 客户端和服务端配置 |
|
||||
| [智能路由](https://docs.mcphubx.com/zh/features/smart-routing) | AI 驱动的工具发现 |
|
||||
| [Docker 部署](https://docs.mcphubx.com/zh/configuration/docker-setup) | Docker 部署指南 |
|
||||
|
||||
## 🧑💻 本地开发
|
||||
|
||||
@@ -253,19 +91,9 @@ pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
此命令将在开发模式下启动前后端,并启用热重载。
|
||||
> Windows 用户需分别启动后端和前端:`pnpm backend:dev`,`pnpm frontend:dev`
|
||||
|
||||
> 针对 Windows 用户,可能需要分别启动后端服务器和前端:`pnpm backend:dev`,`pnpm frontend:dev`。
|
||||
|
||||
## 🛠️ 常见问题
|
||||
|
||||
### 使用 nginx 反向代理
|
||||
|
||||
如果您在使用 nginx 反向代理 MCPHub,请确保在 nginx 配置中添加以下内容:
|
||||
|
||||
```nginx
|
||||
proxy_buffering off
|
||||
```
|
||||
📖 查看[开发指南](https://docs.mcphubx.com/zh/development)了解详细设置说明。
|
||||
|
||||
## 🔍 技术栈
|
||||
|
||||
@@ -276,13 +104,6 @@ proxy_buffering off
|
||||
|
||||
## 👥 贡献指南
|
||||
|
||||
期待您的贡献!
|
||||
|
||||
- 新功能与优化
|
||||
- 文档完善
|
||||
- Bug 报告与修复
|
||||
- 翻译与建议
|
||||
|
||||
欢迎加入企微交流共建群,由于群人数限制,有兴趣的同学可以扫码添加管理员为好友后拉入群聊。
|
||||
|
||||
<img src="assets/wexin.png" width="350">
|
||||
@@ -293,7 +114,7 @@ proxy_buffering off
|
||||
|
||||
## 致谢
|
||||
|
||||
感谢以下人员的赞赏:小白、琛。你们的支持是我继续前进的动力!
|
||||
感谢以下朋友的赞赏:小白、唐秀川、琛、孔、黄祥取、兰军飞、无名之辈、Kyle,以及其他匿名支持者。
|
||||
|
||||
## 🌟 Star 历史趋势
|
||||
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
# Security Summary - OAuth Authorization Server Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
This document summarizes the security analysis and measures taken for the OAuth 2.0 authorization server implementation in MCPHub.
|
||||
|
||||
## Vulnerability Scan Results
|
||||
|
||||
### Dependency Vulnerabilities
|
||||
|
||||
✅ **PASSED**: No vulnerabilities found in dependencies
|
||||
- `@node-oauth/oauth2-server@5.2.1` - Clean scan, no known vulnerabilities
|
||||
- All other dependencies scanned and verified secure
|
||||
|
||||
### Code Security Analysis (CodeQL)
|
||||
|
||||
⚠️ **ADVISORY**: 12 alerts found regarding missing rate limiting on authentication endpoints
|
||||
|
||||
**Details:**
|
||||
- **Issue**: Authorization routes do not have rate limiting middleware
|
||||
- **Impact**: Potential brute force attacks on authentication endpoints
|
||||
- **Severity**: Medium
|
||||
- **Status**: Documented, not critical
|
||||
|
||||
**Affected Endpoints:**
|
||||
- `/oauth/authorize` (GET/POST)
|
||||
- `/oauth/token` (POST)
|
||||
- `/api/oauth/clients/*` (various methods)
|
||||
|
||||
**Mitigation:**
|
||||
1. All endpoints require proper authentication
|
||||
2. Authorization codes expire after 5 minutes by default
|
||||
3. Access tokens expire after 1 hour by default
|
||||
4. Failed authentication attempts are logged
|
||||
5. Documentation includes rate limiting recommendations for production
|
||||
|
||||
**Recommended Actions for Production:**
|
||||
- Implement `express-rate-limit` middleware on OAuth endpoints
|
||||
- Consider using reverse proxy rate limiting (nginx, Cloudflare)
|
||||
- Monitor for suspicious authentication patterns
|
||||
- Set up alerting for repeated failed attempts
|
||||
|
||||
## Security Features Implemented
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
✅ **OAuth 2.0 Compliance**: Fully compliant with RFC 6749
|
||||
✅ **PKCE Support**: RFC 7636 implementation for public clients
|
||||
✅ **Token-based Authentication**: Access tokens and refresh tokens
|
||||
✅ **JWT Integration**: Backward compatible with existing JWT auth
|
||||
✅ **User Permissions**: Proper admin status lookup for OAuth users
|
||||
|
||||
### Input Validation
|
||||
|
||||
✅ **Query Parameter Validation**: All OAuth parameters validated with regex patterns
|
||||
✅ **Client ID Validation**: Alphanumeric with hyphens/underscores only
|
||||
✅ **Redirect URI Validation**: Strict matching against registered URIs
|
||||
✅ **Scope Validation**: Only allowed scopes can be requested
|
||||
✅ **State Parameter**: CSRF protection via state validation
|
||||
|
||||
### Output Security
|
||||
|
||||
✅ **XSS Protection**: All user input HTML-escaped in authorization page
|
||||
✅ **HTML Escaping**: Custom escapeHtml function for template rendering
|
||||
✅ **Safe Token Handling**: Tokens never exposed in URLs or logs
|
||||
|
||||
### Token Security
|
||||
|
||||
✅ **Secure Token Generation**: Cryptographically random tokens (32 bytes)
|
||||
✅ **Token Expiration**: Configurable lifetimes for all token types
|
||||
✅ **Token Revocation**: Support for revoking access and refresh tokens
|
||||
✅ **Automatic Cleanup**: Expired tokens automatically removed from memory
|
||||
|
||||
### Transport Security
|
||||
|
||||
✅ **HTTPS Ready**: Designed for HTTPS in production
|
||||
✅ **No Tokens in URL**: Access tokens never passed in query parameters
|
||||
✅ **Secure Headers**: Proper Content-Type and security headers
|
||||
|
||||
### Client Security
|
||||
|
||||
✅ **Client Secret Support**: Optional for confidential clients
|
||||
✅ **Public Client Support**: PKCE for clients without secrets
|
||||
✅ **Redirect URI Whitelist**: Strict validation of redirect destinations
|
||||
✅ **Client Registration**: Secure client management API
|
||||
|
||||
### Code Quality
|
||||
|
||||
✅ **TypeScript Strict Mode**: Full type safety
|
||||
✅ **ESLint Clean**: No linting errors
|
||||
✅ **Test Coverage**: 180 tests passing, including 11 OAuth-specific tests
|
||||
✅ **Async Safety**: Proper async/await usage throughout
|
||||
✅ **Resource Cleanup**: Graceful shutdown support with interval cleanup
|
||||
|
||||
## Security Best Practices Followed
|
||||
|
||||
1. **Defense in Depth**: Multiple layers of security (auth, validation, escaping)
|
||||
2. **Principle of Least Privilege**: Scopes limit what clients can access
|
||||
3. **Fail Securely**: Invalid requests rejected with appropriate errors
|
||||
4. **Security by Default**: Secure settings out of the box
|
||||
5. **Standard Compliance**: Following OAuth 2.0 and PKCE RFCs
|
||||
6. **Code Reviews**: All changes reviewed for security implications
|
||||
7. **Documentation**: Comprehensive security guidance provided
|
||||
|
||||
## Known Limitations
|
||||
|
||||
### In-Memory Token Storage
|
||||
|
||||
**Issue**: Tokens stored in memory, not persisted to database
|
||||
**Impact**: Tokens lost on server restart
|
||||
**Mitigation**: Refresh tokens allow users to re-authenticate
|
||||
**Future**: Consider database storage for production deployments
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
**Issue**: No built-in rate limiting on OAuth endpoints
|
||||
**Impact**: Potential brute force attacks
|
||||
**Mitigation**:
|
||||
- Short-lived authorization codes (5 min default)
|
||||
- Authentication required for authorization endpoint
|
||||
- Documented recommendations for production
|
||||
**Future**: Consider adding rate limiting middleware
|
||||
|
||||
### Token Introspection
|
||||
|
||||
**Issue**: No token introspection endpoint (RFC 7662)
|
||||
**Impact**: Limited third-party token validation
|
||||
**Mitigation**: Clients can use userinfo endpoint
|
||||
**Future**: Consider implementing RFC 7662 if needed
|
||||
|
||||
## Production Deployment Recommendations
|
||||
|
||||
### Critical
|
||||
|
||||
1. ✅ Use HTTPS in production (SSL/TLS certificates)
|
||||
2. ✅ Change default admin password immediately
|
||||
3. ✅ Use strong client secrets for confidential clients
|
||||
4. ⚠️ Implement rate limiting (express-rate-limit or reverse proxy)
|
||||
5. ✅ Enable proper logging and monitoring
|
||||
|
||||
### Recommended
|
||||
|
||||
6. Consider using a database for token storage
|
||||
7. Set up automated security scanning in CI/CD
|
||||
8. Use a reverse proxy (nginx) with security headers
|
||||
9. Implement IP whitelisting for admin endpoints
|
||||
10. Regular security audits and dependency updates
|
||||
|
||||
### Optional
|
||||
|
||||
11. Implement token introspection endpoint
|
||||
12. Add support for JWT-based access tokens
|
||||
13. Integrate with external OAuth providers
|
||||
14. Implement advanced scope management
|
||||
15. Add OAuth client approval workflow
|
||||
|
||||
## Compliance & Standards
|
||||
|
||||
✅ **OAuth 2.0 (RFC 6749)**: Full authorization code grant implementation
|
||||
✅ **PKCE (RFC 7636)**: Code challenge and verifier support
|
||||
✅ **OAuth Server Metadata (RFC 8414)**: Discovery endpoint available
|
||||
✅ **OpenID Connect Compatible**: Basic userinfo endpoint
|
||||
|
||||
## Vulnerability Disclosure
|
||||
|
||||
If you discover a security vulnerability in MCPHub's OAuth implementation, please:
|
||||
|
||||
1. **Do Not** create a public GitHub issue
|
||||
2. Email the maintainers privately
|
||||
3. Provide detailed reproduction steps
|
||||
4. Allow time for a fix before public disclosure
|
||||
|
||||
## Security Update Policy
|
||||
|
||||
- **Critical vulnerabilities**: Patched within 24-48 hours
|
||||
- **High severity**: Patched within 1 week
|
||||
- **Medium severity**: Patched in next minor release
|
||||
- **Low severity**: Patched in next patch release
|
||||
|
||||
## Conclusion
|
||||
|
||||
The OAuth 2.0 authorization server implementation in MCPHub follows security best practices and is production-ready with the noted limitations. The main advisory regarding rate limiting should be addressed in production deployments through application-level or reverse proxy rate limiting.
|
||||
|
||||
**Overall Security Assessment**: ✅ **SECURE** with production hardening recommendations
|
||||
|
||||
**Last Updated**: 2025-11-02
|
||||
**Next Review**: Recommended quarterly or after major changes
|
||||
60
docker-compose.db.yml
Normal file
60
docker-compose.db.yml
Normal file
@@ -0,0 +1,60 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
# PostgreSQL database for MCPHub configuration
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: mcphub-postgres
|
||||
environment:
|
||||
POSTGRES_DB: mcphub
|
||||
POSTGRES_USER: mcphub
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-mcphub_password}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "${DB_PORT:-5432}:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U mcphub"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- mcphub-network
|
||||
|
||||
# MCPHub application
|
||||
mcphub:
|
||||
image: samanhappy/mcphub:latest
|
||||
container_name: mcphub
|
||||
environment:
|
||||
# Database connection (setting DB_URL automatically enables database mode)
|
||||
DB_URL: "postgresql://mcphub:${DB_PASSWORD:-mcphub_password}@postgres:5432/mcphub"
|
||||
|
||||
# Optional: Explicitly control database mode (overrides auto-detection)
|
||||
# USE_DB: "true"
|
||||
|
||||
# Application settings
|
||||
PORT: 3000
|
||||
NODE_ENV: production
|
||||
|
||||
# Optional: Custom npm registry
|
||||
# NPM_REGISTRY: https://registry.npmjs.org/
|
||||
|
||||
# Optional: Proxy settings
|
||||
# HTTP_PROXY: http://proxy:8080
|
||||
# HTTPS_PROXY: http://proxy:8080
|
||||
ports:
|
||||
- "${MCPHUB_PORT:-3000}:3000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- mcphub-network
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
mcphub-network:
|
||||
driver: bridge
|
||||
304
docs/configuration/database-configuration.mdx
Normal file
304
docs/configuration/database-configuration.mdx
Normal file
@@ -0,0 +1,304 @@
|
||||
---
|
||||
title: 'Database Configuration'
|
||||
description: 'Configuring MCPHub to use a PostgreSQL database as an alternative to the mcp_settings.json file.'
|
||||
---
|
||||
|
||||
# Database Configuration for MCPHub
|
||||
|
||||
## Overview
|
||||
|
||||
MCPHub supports storing configuration data in a PostgreSQL database as an alternative to the `mcp_settings.json` file. Database mode provides enhanced persistence and scalability for production environments and enterprise deployments.
|
||||
|
||||
## Why Use Database Configuration?
|
||||
|
||||
**Core Benefits:**
|
||||
- ✅ **Better Persistence** - Configuration stored in a professional database with transaction support and data integrity
|
||||
- ✅ **High Availability** - Leverage database replication and failover capabilities
|
||||
- ✅ **Enterprise Ready** - Meets enterprise data management and compliance requirements
|
||||
- ✅ **Backup & Recovery** - Use mature database backup tools and strategies
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Required for Database Mode
|
||||
|
||||
```bash
|
||||
# Database connection URL (PostgreSQL)
|
||||
# Simply setting DB_URL will automatically enable database mode
|
||||
DB_URL=postgresql://user:password@localhost:5432/mcphub
|
||||
|
||||
# Or explicitly control with USE_DB (overrides auto-detection)
|
||||
# USE_DB=true
|
||||
```
|
||||
|
||||
<Note>
|
||||
**Simplified Configuration**: You only need to set `DB_URL` to enable database mode. MCPHub will automatically detect and enable database mode when `DB_URL` is present. Use `USE_DB=false` to explicitly disable database mode even when `DB_URL` is set.
|
||||
</Note>
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Using Docker
|
||||
|
||||
#### Option A: Using External Database
|
||||
|
||||
If you already have a PostgreSQL database:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 3000:3000 \
|
||||
-v ./mcp_settings.json:/app/mcp_settings.json \
|
||||
-e DB_URL="postgresql://user:password@your-db-host:5432/mcphub" \
|
||||
samanhappy/mcphub
|
||||
```
|
||||
|
||||
#### Option B: Using PostgreSQL as a separate service
|
||||
|
||||
Create a `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_DB: mcphub
|
||||
POSTGRES_USER: mcphub
|
||||
POSTGRES_PASSWORD: your_secure_password
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
mcphub:
|
||||
image: samanhappy/mcphub:latest
|
||||
environment:
|
||||
USE_DB: "true"
|
||||
DB_URL: "postgresql://mcphub:your_secure_password@postgres:5432/mcphub"
|
||||
PORT: 3000
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
```
|
||||
|
||||
Run with:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### 2. Manual Setup
|
||||
|
||||
#### Step 1: Setup PostgreSQL Database
|
||||
|
||||
```bash
|
||||
# Install PostgreSQL (if not already installed)
|
||||
sudo apt-get install postgresql postgresql-contrib
|
||||
|
||||
# Create database and user
|
||||
sudo -u postgres psql <<EOF
|
||||
CREATE DATABASE mcphub;
|
||||
CREATE USER mcphub WITH ENCRYPTED PASSWORD 'your_password';
|
||||
GRANT ALL PRIVILEGES ON DATABASE mcphub TO mcphub;
|
||||
EOF
|
||||
```
|
||||
|
||||
#### Step 2: Install MCPHub
|
||||
|
||||
```bash
|
||||
npm install -g @samanhappy/mcphub
|
||||
```
|
||||
|
||||
#### Step 3: Set Environment Variables
|
||||
|
||||
Create a `.env` file:
|
||||
|
||||
```bash
|
||||
# Simply set DB_URL to enable database mode (USE_DB is auto-detected)
|
||||
DB_URL=postgresql://mcphub:your_password@localhost:5432/mcphub
|
||||
PORT=3000
|
||||
```
|
||||
|
||||
#### Step 4: Run Migration (Optional)
|
||||
|
||||
If you have an existing `mcp_settings.json` file, migrate it:
|
||||
|
||||
```bash
|
||||
# Run migration script
|
||||
npx tsx src/scripts/migrate-to-database.ts
|
||||
```
|
||||
|
||||
Or let MCPHub auto-migrate on first startup.
|
||||
|
||||
#### Step 5: Start MCPHub
|
||||
|
||||
```bash
|
||||
mcphub
|
||||
```
|
||||
|
||||
## Migration from File-Based to Database
|
||||
|
||||
MCPHub provides automatic migration on first startup when database mode is enabled. However, you can also run the migration manually.
|
||||
|
||||
### Automatic Migration
|
||||
|
||||
When you start MCPHub with `USE_DB=true` for the first time:
|
||||
|
||||
1. MCPHub connects to the database
|
||||
2. Checks if any users exist in the database
|
||||
3. If no users found, automatically migrates from `mcp_settings.json`
|
||||
4. Creates all tables and imports all data
|
||||
|
||||
### Manual Migration
|
||||
|
||||
Run the migration script:
|
||||
|
||||
```bash
|
||||
# Using npx
|
||||
npx tsx src/scripts/migrate-to-database.ts
|
||||
|
||||
# Or using Node
|
||||
node dist/scripts/migrate-to-database.js
|
||||
```
|
||||
|
||||
The migration will:
|
||||
- ✅ Create database tables if they don't exist
|
||||
- ✅ Import all users with hashed passwords
|
||||
- ✅ Import all MCP server configurations
|
||||
- ✅ Import all groups
|
||||
- ✅ Import system configuration
|
||||
- ✅ Import user-specific configurations
|
||||
- ✅ Skip existing records (safe to run multiple times)
|
||||
|
||||
## Configuration After Migration
|
||||
|
||||
Once running in database mode, all configuration changes are stored in the database:
|
||||
|
||||
- User management via `/api/users`
|
||||
- Server management via `/api/servers`
|
||||
- Group management via `/api/groups`
|
||||
- System settings via `/api/system/config`
|
||||
|
||||
The web dashboard works exactly the same way, but now stores changes in the database instead of the file.
|
||||
|
||||
## Database Schema
|
||||
|
||||
MCPHub creates the following tables:
|
||||
|
||||
- **users** - User accounts and authentication
|
||||
- **servers** - MCP server configurations
|
||||
- **groups** - Server groups
|
||||
- **system_config** - System-wide settings
|
||||
- **user_configs** - User-specific settings
|
||||
- **vector_embeddings** - Vector search data (for smart routing)
|
||||
|
||||
## Backup and Restore
|
||||
|
||||
### Backup
|
||||
|
||||
```bash
|
||||
# PostgreSQL backup
|
||||
pg_dump -U mcphub mcphub > mcphub_backup.sql
|
||||
|
||||
# Or using Docker
|
||||
docker exec postgres pg_dump -U mcphub mcphub > mcphub_backup.sql
|
||||
```
|
||||
|
||||
### Restore
|
||||
|
||||
```bash
|
||||
# PostgreSQL restore
|
||||
psql -U mcphub mcphub < mcphub_backup.sql
|
||||
|
||||
# Or using Docker
|
||||
docker exec -i postgres psql -U mcphub mcphub < mcphub_backup.sql
|
||||
```
|
||||
|
||||
## Switching Back to File-Based Config
|
||||
|
||||
If you need to switch back to file-based configuration:
|
||||
|
||||
1. Set `USE_DB=false` or remove `DB_URL` and `USE_DB` environment variables
|
||||
2. Restart MCPHub
|
||||
3. MCPHub will use `mcp_settings.json` again
|
||||
|
||||
Note: Changes made in database mode won't be reflected in the file unless you manually export them.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection Refused
|
||||
|
||||
```
|
||||
Error: connect ECONNREFUSED 127.0.0.1:5432
|
||||
```
|
||||
|
||||
**Solution:** Check that PostgreSQL is running and accessible:
|
||||
```bash
|
||||
# Check PostgreSQL status
|
||||
sudo systemctl status postgresql
|
||||
|
||||
# Or for Docker
|
||||
docker ps | grep postgres
|
||||
```
|
||||
|
||||
### Authentication Failed
|
||||
|
||||
```
|
||||
Error: password authentication failed for user "mcphub"
|
||||
```
|
||||
|
||||
**Solution:** Verify database credentials in `DB_URL` environment variable.
|
||||
|
||||
### Migration Failed
|
||||
|
||||
```
|
||||
❌ Migration failed: ...
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
1. Check that `mcp_settings.json` exists and is valid JSON
|
||||
2. Verify database connection
|
||||
3. Check logs for specific error messages
|
||||
4. Ensure database user has CREATE TABLE permissions
|
||||
|
||||
### Tables Already Exist
|
||||
|
||||
Database tables are automatically created if they don't exist. If you get errors about existing tables, check:
|
||||
1. Whether a previous migration partially completed
|
||||
2. Manual table creation conflicts
|
||||
3. Run with `synchronize: false` in database config if needed
|
||||
|
||||
## Environment Variables Reference
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `DB_URL` | Yes* | - | Full PostgreSQL connection URL. Setting this automatically enables database mode |
|
||||
| `USE_DB` | No | auto | Explicitly enable/disable database mode. If not set, auto-detected based on `DB_URL` presence |
|
||||
| `MCPHUB_SETTING_PATH` | No | - | Path to mcp_settings.json (for migration) |
|
||||
|
||||
*Required for database mode. Simply setting `DB_URL` enables database mode automatically
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Database Credentials:** Store database credentials securely, use environment variables or secrets management
|
||||
2. **Network Access:** Restrict database access to MCPHub instances only
|
||||
3. **Encryption:** Use SSL/TLS for database connections in production:
|
||||
```bash
|
||||
DB_URL=postgresql://user:password@host:5432/mcphub?sslmode=require
|
||||
```
|
||||
4. **Backup:** Regularly backup your database
|
||||
5. **Access Control:** Use strong database passwords and limit user permissions
|
||||
|
||||
## Performance
|
||||
|
||||
Database mode offers better performance for:
|
||||
- Multiple concurrent users
|
||||
- Frequent configuration changes
|
||||
- Large number of servers/groups
|
||||
|
||||
File mode may be faster for:
|
||||
- Single user setups
|
||||
- Read-heavy workloads with infrequent changes
|
||||
- Development/testing environments
|
||||
@@ -1,210 +0,0 @@
|
||||
# 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项目提供了坚实的数据管理基础,支持项目的长期发展和扩展需求。
|
||||
@@ -1,254 +0,0 @@
|
||||
# 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的数据管理变得更加结构化、可维护和可扩展。
|
||||
@@ -37,7 +37,8 @@
|
||||
"configuration/mcp-settings",
|
||||
"configuration/environment-variables",
|
||||
"configuration/docker-setup",
|
||||
"configuration/nginx"
|
||||
"configuration/nginx",
|
||||
"configuration/database-configuration"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -68,7 +69,8 @@
|
||||
"zh/configuration/mcp-settings",
|
||||
"zh/configuration/environment-variables",
|
||||
"zh/configuration/docker-setup",
|
||||
"zh/configuration/nginx"
|
||||
"zh/configuration/nginx",
|
||||
"zh/configuration/database-configuration"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -169,4 +171,4 @@
|
||||
"discord": "https://discord.gg/qMKNsn5Q"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
# Environment Variable Expansion in mcp_settings.json
|
||||
|
||||
## Overview
|
||||
|
||||
MCPHub now supports comprehensive environment variable expansion throughout the entire `mcp_settings.json` configuration file. This allows you to externalize sensitive information and configuration values, making your setup more secure and flexible.
|
||||
|
||||
## Supported Formats
|
||||
|
||||
MCPHub supports two environment variable formats:
|
||||
|
||||
1. **${VAR}** - Standard format (recommended)
|
||||
2. **$VAR** - Unix-style format (variable name must start with an uppercase letter or underscore, followed by uppercase letters, numbers, or underscores)
|
||||
|
||||
## What Can Be Expanded
|
||||
|
||||
Environment variables can now be used in **ANY** string value throughout your configuration:
|
||||
|
||||
- Server URLs
|
||||
- Commands and arguments
|
||||
- Headers
|
||||
- Environment variables passed to child processes
|
||||
- OpenAPI specifications and security configurations
|
||||
- OAuth credentials
|
||||
- System configuration values
|
||||
- Any other string fields
|
||||
|
||||
## Examples
|
||||
|
||||
### 1. SSE/HTTP Server Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-api-server": {
|
||||
"type": "sse",
|
||||
"url": "${MCP_SERVER_URL}",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${API_TOKEN}",
|
||||
"X-Custom-Header": "${CUSTOM_VALUE}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
```bash
|
||||
export MCP_SERVER_URL="https://api.example.com/mcp"
|
||||
export API_TOKEN="secret-token-123"
|
||||
export CUSTOM_VALUE="my-custom-value"
|
||||
```
|
||||
|
||||
### 2. Stdio Server Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-python-server": {
|
||||
"type": "stdio",
|
||||
"command": "${PYTHON_PATH}",
|
||||
"args": ["-m", "${MODULE_NAME}", "--api-key", "${API_KEY}"],
|
||||
"env": {
|
||||
"DATABASE_URL": "${DATABASE_URL}",
|
||||
"DEBUG": "${DEBUG_MODE}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
```bash
|
||||
export PYTHON_PATH="/usr/bin/python3"
|
||||
export MODULE_NAME="my_mcp_server"
|
||||
export API_KEY="secret-api-key"
|
||||
export DATABASE_URL="postgresql://localhost/mydb"
|
||||
export DEBUG_MODE="true"
|
||||
```
|
||||
|
||||
### 3. OpenAPI Server Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"openapi-service": {
|
||||
"type": "openapi",
|
||||
"openapi": {
|
||||
"url": "${OPENAPI_SPEC_URL}",
|
||||
"security": {
|
||||
"type": "apiKey",
|
||||
"apiKey": {
|
||||
"name": "X-API-Key",
|
||||
"in": "header",
|
||||
"value": "${OPENAPI_API_KEY}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
```bash
|
||||
export OPENAPI_SPEC_URL="https://api.example.com/openapi.json"
|
||||
export OPENAPI_API_KEY="your-api-key-here"
|
||||
```
|
||||
|
||||
### 4. OAuth Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"oauth-server": {
|
||||
"type": "sse",
|
||||
"url": "${OAUTH_SERVER_URL}",
|
||||
"oauth": {
|
||||
"clientId": "${OAUTH_CLIENT_ID}",
|
||||
"clientSecret": "${OAUTH_CLIENT_SECRET}",
|
||||
"accessToken": "${OAUTH_ACCESS_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
```bash
|
||||
export OAUTH_SERVER_URL="https://oauth.example.com/mcp"
|
||||
export OAUTH_CLIENT_ID="my-client-id"
|
||||
export OAUTH_CLIENT_SECRET="my-client-secret"
|
||||
export OAUTH_ACCESS_TOKEN="my-access-token"
|
||||
```
|
||||
|
||||
### 5. System Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"systemConfig": {
|
||||
"install": {
|
||||
"pythonIndexUrl": "${PYTHON_INDEX_URL}",
|
||||
"npmRegistry": "${NPM_REGISTRY}"
|
||||
},
|
||||
"mcpRouter": {
|
||||
"apiKey": "${MCPROUTER_API_KEY}",
|
||||
"referer": "${MCPROUTER_REFERER}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
```bash
|
||||
export PYTHON_INDEX_URL="https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||
export NPM_REGISTRY="https://registry.npmmirror.com"
|
||||
export MCPROUTER_API_KEY="router-api-key"
|
||||
export MCPROUTER_REFERER="https://myapp.com"
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
See [examples/mcp_settings_with_env_vars.json](../examples/mcp_settings_with_env_vars.json) for a comprehensive example configuration using environment variables.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Security
|
||||
|
||||
1. **Never commit sensitive values to version control** - Use environment variables for all secrets
|
||||
2. **Use .env files for local development** - MCPHub automatically loads `.env` files
|
||||
3. **Use secure secret management in production** - Consider using Docker secrets, Kubernetes secrets, or cloud provider secret managers
|
||||
|
||||
### Organization
|
||||
|
||||
1. **Group related variables** - Use prefixes for related configuration (e.g., `API_`, `DB_`, `OAUTH_`)
|
||||
2. **Document required variables** - Maintain a list of required environment variables in your README
|
||||
3. **Provide example .env file** - Create a `.env.example` file with placeholder values
|
||||
|
||||
### Example .env File
|
||||
|
||||
```bash
|
||||
# Server Configuration
|
||||
MCP_SERVER_URL=https://api.example.com/mcp
|
||||
API_TOKEN=your-api-token-here
|
||||
|
||||
# Python Server
|
||||
PYTHON_PATH=/usr/bin/python3
|
||||
MODULE_NAME=my_mcp_server
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://localhost/mydb
|
||||
|
||||
# OpenAPI
|
||||
OPENAPI_SPEC_URL=https://api.example.com/openapi.json
|
||||
OPENAPI_API_KEY=your-openapi-key
|
||||
|
||||
# OAuth
|
||||
OAUTH_CLIENT_ID=your-client-id
|
||||
OAUTH_CLIENT_SECRET=your-client-secret
|
||||
OAUTH_ACCESS_TOKEN=your-access-token
|
||||
```
|
||||
|
||||
## Docker Usage
|
||||
|
||||
When using Docker, pass environment variables using `-e` flag or `--env-file`:
|
||||
|
||||
```bash
|
||||
# Using individual variables
|
||||
docker run -e API_TOKEN=secret -e SERVER_URL=https://api.example.com mcphub
|
||||
|
||||
# Using env file
|
||||
docker run --env-file .env mcphub
|
||||
```
|
||||
|
||||
Or in docker-compose.yml:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
mcphub:
|
||||
image: mcphub
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- MCP_SERVER_URL=${MCP_SERVER_URL}
|
||||
- API_TOKEN=${API_TOKEN}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Variable Not Expanding
|
||||
|
||||
If a variable is not expanding:
|
||||
|
||||
1. Check that the variable is set: `echo $VAR_NAME`
|
||||
2. Verify the variable name matches exactly (case-sensitive)
|
||||
3. Ensure the variable is exported: `export VAR_NAME=value`
|
||||
4. Restart MCPHub after setting environment variables
|
||||
|
||||
### Empty Values
|
||||
|
||||
If an environment variable is not set, it will be replaced with an empty string. Make sure all required variables are set before starting MCPHub.
|
||||
|
||||
### Nested Variables
|
||||
|
||||
Environment variables in nested objects and arrays are fully supported:
|
||||
|
||||
```json
|
||||
{
|
||||
"nested": {
|
||||
"deep": {
|
||||
"value": "${MY_VAR}"
|
||||
}
|
||||
},
|
||||
"array": ["${VAR1}", "${VAR2}"]
|
||||
}
|
||||
```
|
||||
|
||||
## Migration from Previous Version
|
||||
|
||||
If you were previously using environment variables only in headers, no changes are needed. The new implementation is backward compatible and simply extends support to all configuration fields.
|
||||
|
||||
## Technical Details
|
||||
|
||||
- Environment variables are expanded once when the configuration is loaded
|
||||
- Expansion is recursive and handles nested objects and arrays
|
||||
- Non-string values (booleans, numbers, null) are preserved as-is
|
||||
- Empty string is used when an environment variable is not set
|
||||
@@ -1,169 +0,0 @@
|
||||
# OAuth 动态客户端注册实现总结
|
||||
|
||||
## 概述
|
||||
|
||||
成功为 MCPHub 的 OAuth 2.0 授权服务器添加了 RFC 7591 标准的动态客户端注册功能。此功能允许 OAuth 客户端在运行时自动注册,无需管理员手动配置。
|
||||
|
||||
## 实现的功能
|
||||
|
||||
### 1. 核心端点
|
||||
|
||||
#### POST /oauth/register - 注册新客户端
|
||||
- 公开端点,支持动态客户端注册
|
||||
- 自动生成 client_id 和可选的 client_secret
|
||||
- 返回 registration_access_token 用于后续管理
|
||||
- 支持 PKCE 流程(token_endpoint_auth_method: "none")
|
||||
|
||||
#### GET /oauth/register/:clientId - 读取客户端配置
|
||||
- 需要 registration_access_token 认证
|
||||
- 返回完整的客户端元数据
|
||||
|
||||
#### PUT /oauth/register/:clientId - 更新客户端配置
|
||||
- 需要 registration_access_token 认证
|
||||
- 支持更新 redirect_uris、scopes、metadata 等
|
||||
|
||||
#### DELETE /oauth/register/:clientId - 删除客户端注册
|
||||
- 需要 registration_access_token 认证
|
||||
- 删除客户端并清理相关 tokens
|
||||
|
||||
### 2. 配置选项
|
||||
|
||||
在 `mcp_settings.json` 中添加:
|
||||
|
||||
```json
|
||||
{
|
||||
"systemConfig": {
|
||||
"oauthServer": {
|
||||
"enabled": true,
|
||||
"dynamicRegistration": {
|
||||
"enabled": true,
|
||||
"allowedGrantTypes": ["authorization_code", "refresh_token"],
|
||||
"requiresAuthentication": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 客户端元数据支持
|
||||
|
||||
实现了 RFC 7591 定义的完整客户端元数据:
|
||||
|
||||
- `application_type`: "web" 或 "native"
|
||||
- `response_types`: OAuth 响应类型数组
|
||||
- `token_endpoint_auth_method`: 认证方法
|
||||
- `contacts`: 联系邮箱数组
|
||||
- `logo_uri`: 客户端 logo URL
|
||||
- `client_uri`: 客户端主页 URL
|
||||
- `policy_uri`: 隐私政策 URL
|
||||
- `tos_uri`: 服务条款 URL
|
||||
- `jwks_uri`: JSON Web Key Set URL
|
||||
- `jwks`: 内联 JSON Web Key Set
|
||||
|
||||
### 4. 安全特性
|
||||
|
||||
- **Registration Access Token**: 每个注册的客户端获得唯一的访问令牌
|
||||
- **Token 过期**: Registration tokens 30 天后过期
|
||||
- **HTTPS 验证**: Redirect URIs 必须使用 HTTPS(localhost 除外)
|
||||
- **Scope 验证**: 只允许配置中定义的 scopes
|
||||
- **Grant Type 限制**: 只允许配置中定义的 grant types
|
||||
|
||||
## 文件变更
|
||||
|
||||
### 新增文件
|
||||
1. `src/controllers/oauthDynamicRegistrationController.ts` - 动态注册控制器
|
||||
2. `examples/oauth-dynamic-registration-config.json` - 配置示例
|
||||
|
||||
### 修改文件
|
||||
1. `src/types/index.ts` - 添加元数据字段到 IOAuthClient 和 OAuthServerConfig
|
||||
2. `src/routes/index.ts` - 注册新的动态注册端点
|
||||
3. `src/controllers/oauthServerController.ts` - 元数据端点包含 registration_endpoint
|
||||
4. `docs/oauth-server.md` - 添加完整的动态注册文档
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 注册新客户端
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/oauth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"client_name": "My Application",
|
||||
"redirect_uris": ["https://example.com/callback"],
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"scope": "read write",
|
||||
"token_endpoint_auth_method": "none",
|
||||
"logo_uri": "https://example.com/logo.png",
|
||||
"client_uri": "https://example.com",
|
||||
"contacts": ["admin@example.com"]
|
||||
}'
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"client_id": "a1b2c3d4e5f6g7h8",
|
||||
"client_name": "My Application",
|
||||
"redirect_uris": ["https://example.com/callback"],
|
||||
"registration_access_token": "reg_token_xyz123",
|
||||
"registration_client_uri": "http://localhost:3000/oauth/register/a1b2c3d4e5f6g7h8",
|
||||
"client_id_issued_at": 1699200000
|
||||
}
|
||||
```
|
||||
|
||||
### 读取客户端配置
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/oauth/register/CLIENT_ID \
|
||||
-H "Authorization: Bearer REGISTRATION_ACCESS_TOKEN"
|
||||
```
|
||||
|
||||
### 更新客户端
|
||||
|
||||
```bash
|
||||
curl -X PUT http://localhost:3000/oauth/register/CLIENT_ID \
|
||||
-H "Authorization: Bearer REGISTRATION_ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"client_name": "Updated Name",
|
||||
"redirect_uris": ["https://example.com/callback", "https://example.com/callback2"]
|
||||
}'
|
||||
```
|
||||
|
||||
### 删除客户端
|
||||
|
||||
```bash
|
||||
curl -X DELETE http://localhost:3000/oauth/register/CLIENT_ID \
|
||||
-H "Authorization: Bearer REGISTRATION_ACCESS_TOKEN"
|
||||
```
|
||||
|
||||
## 测试结果
|
||||
|
||||
✅ 所有 180 个测试通过
|
||||
✅ TypeScript 编译成功
|
||||
✅ 代码覆盖率维持在合理水平
|
||||
✅ 与现有功能完全兼容
|
||||
|
||||
## RFC 合规性
|
||||
|
||||
完全遵循以下 RFC 标准:
|
||||
|
||||
- **RFC 7591**: OAuth 2.0 Dynamic Client Registration Protocol
|
||||
- **RFC 8414**: OAuth 2.0 Authorization Server Metadata
|
||||
- **RFC 7636**: Proof Key for Code Exchange (PKCE)
|
||||
- **RFC 9728**: OAuth 2.0 Protected Resource Metadata
|
||||
|
||||
## 下一步建议
|
||||
|
||||
1. **持久化存储**: 当前 registration tokens 存储在内存中,生产环境应使用数据库
|
||||
2. **速率限制**: 添加注册端点的速率限制以防止滥用
|
||||
3. **客户端证明**: 考虑添加软件声明(software_statement)支持
|
||||
4. **审计日志**: 记录所有注册、更新和删除操作
|
||||
5. **通知机制**: 在客户端注册时通知管理员(可选)
|
||||
|
||||
## 兼容性
|
||||
|
||||
- 与 ChatGPT Web 完全兼容
|
||||
- 支持所有标准 OAuth 2.0 客户端库
|
||||
- 向后兼容现有的手动客户端配置方式
|
||||
@@ -1,538 +0,0 @@
|
||||
# OAuth 2.0 Authorization Server
|
||||
|
||||
MCPHub can act as an OAuth 2.0 authorization server, allowing external applications like ChatGPT Web to securely authenticate and access your MCP servers.
|
||||
|
||||
## Overview
|
||||
|
||||
The OAuth 2.0 authorization server feature enables MCPHub to:
|
||||
|
||||
- Provide standard OAuth 2.0 authentication flows
|
||||
- Issue and manage access tokens for external clients
|
||||
- Support secure authorization without exposing user credentials
|
||||
- Enable integration with services that require OAuth (like ChatGPT Web)
|
||||
|
||||
## Configuration
|
||||
|
||||
### Enable OAuth Server
|
||||
|
||||
Add the following configuration to your `mcp_settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"systemConfig": {
|
||||
"oauthServer": {
|
||||
"enabled": true,
|
||||
"accessTokenLifetime": 3600,
|
||||
"refreshTokenLifetime": 1209600,
|
||||
"authorizationCodeLifetime": 300,
|
||||
"requireClientSecret": false,
|
||||
"allowedScopes": ["read", "write"],
|
||||
"requireState": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `enabled` | boolean | `false` | Enable/disable OAuth authorization server |
|
||||
| `accessTokenLifetime` | number | `3600` | Access token lifetime in seconds (1 hour) |
|
||||
| `refreshTokenLifetime` | number | `1209600` | Refresh token lifetime in seconds (14 days) |
|
||||
| `authorizationCodeLifetime` | number | `300` | Authorization code lifetime in seconds (5 minutes) |
|
||||
| `requireClientSecret` | boolean | `false` | Whether client secret is required (set to false for PKCE) |
|
||||
| `allowedScopes` | string[] | `["read", "write"]` | List of allowed OAuth scopes |
|
||||
| `requireState` | boolean | `false` | When `true`, rejects authorization requests that omit the `state` parameter |
|
||||
|
||||
## OAuth Clients
|
||||
|
||||
### Creating OAuth Clients
|
||||
|
||||
#### Via API (Recommended)
|
||||
|
||||
Create an OAuth client using the API:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/oauth/clients \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-auth-token: YOUR_JWT_TOKEN" \
|
||||
-d '{
|
||||
"name": "My Application",
|
||||
"redirectUris": ["https://example.com/callback"],
|
||||
"grants": ["authorization_code", "refresh_token"],
|
||||
"scopes": ["read", "write"],
|
||||
"requireSecret": false
|
||||
}'
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "OAuth client created successfully",
|
||||
"client": {
|
||||
"clientId": "a1b2c3d4e5f6g7h8",
|
||||
"clientSecret": null,
|
||||
"name": "My Application",
|
||||
"redirectUris": ["https://example.com/callback"],
|
||||
"grants": ["authorization_code", "refresh_token"],
|
||||
"scopes": ["read", "write"],
|
||||
"owner": "admin"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: If `requireSecret` is true, the `clientSecret` will be shown only once. Save it securely!
|
||||
|
||||
#### Via Configuration File
|
||||
|
||||
Alternatively, add OAuth clients directly to `mcp_settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"oauthClients": [
|
||||
{
|
||||
"clientId": "my-app-client",
|
||||
"clientSecret": "optional-secret-for-confidential-clients",
|
||||
"name": "My Application",
|
||||
"redirectUris": ["https://example.com/callback"],
|
||||
"grants": ["authorization_code", "refresh_token"],
|
||||
"scopes": ["read", "write"],
|
||||
"owner": "admin"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Managing OAuth Clients
|
||||
|
||||
#### List All Clients
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/api/oauth/clients \
|
||||
-H "x-auth-token: YOUR_JWT_TOKEN"
|
||||
```
|
||||
|
||||
#### Get Specific Client
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/api/oauth/clients/CLIENT_ID \
|
||||
-H "x-auth-token: YOUR_JWT_TOKEN"
|
||||
```
|
||||
|
||||
#### Update Client
|
||||
|
||||
```bash
|
||||
curl -X PUT http://localhost:3000/api/oauth/clients/CLIENT_ID \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-auth-token: YOUR_JWT_TOKEN" \
|
||||
-d '{
|
||||
"name": "Updated Name",
|
||||
"redirectUris": ["https://example.com/callback", "https://example.com/callback2"]
|
||||
}'
|
||||
```
|
||||
|
||||
#### Delete Client
|
||||
|
||||
```bash
|
||||
curl -X DELETE http://localhost:3000/api/oauth/clients/CLIENT_ID \
|
||||
-H "x-auth-token: YOUR_JWT_TOKEN"
|
||||
```
|
||||
|
||||
#### Regenerate Client Secret
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/oauth/clients/CLIENT_ID/regenerate-secret \
|
||||
-H "x-auth-token: YOUR_JWT_TOKEN"
|
||||
```
|
||||
|
||||
## OAuth Flow
|
||||
|
||||
MCPHub supports the OAuth 2.0 Authorization Code flow with PKCE (Proof Key for Code Exchange).
|
||||
|
||||
### 1. Authorization Request
|
||||
|
||||
The client application redirects the user to the authorization endpoint:
|
||||
|
||||
```
|
||||
GET /oauth/authorize?
|
||||
client_id=CLIENT_ID&
|
||||
redirect_uri=REDIRECT_URI&
|
||||
response_type=code&
|
||||
scope=read%20write&
|
||||
state=RANDOM_STATE&
|
||||
code_challenge=CODE_CHALLENGE&
|
||||
code_challenge_method=S256
|
||||
```
|
||||
|
||||
Parameters:
|
||||
- `client_id`: OAuth client ID
|
||||
- `redirect_uri`: Redirect URI (must match registered URI)
|
||||
- `response_type`: Must be `code`
|
||||
- `scope`: Space-separated list of scopes (e.g., `read write`)
|
||||
- `state`: Random string to prevent CSRF attacks
|
||||
- `code_challenge`: PKCE code challenge (optional but recommended)
|
||||
- `code_challenge_method`: PKCE method (`S256` or `plain`)
|
||||
|
||||
### 2. User Authorization
|
||||
|
||||
The user is presented with a consent page showing:
|
||||
- Application name
|
||||
- Requested scopes
|
||||
- Approve/Deny buttons
|
||||
|
||||
If the user approves, they are redirected to the redirect URI with an authorization code:
|
||||
|
||||
```
|
||||
https://example.com/callback?code=AUTHORIZATION_CODE&state=RANDOM_STATE
|
||||
```
|
||||
|
||||
### 3. Token Exchange
|
||||
|
||||
The client exchanges the authorization code for an access token:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/oauth/token \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "grant_type=authorization_code" \
|
||||
-d "code=AUTHORIZATION_CODE" \
|
||||
-d "redirect_uri=REDIRECT_URI" \
|
||||
-d "client_id=CLIENT_ID" \
|
||||
-d "code_verifier=CODE_VERIFIER"
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"access_token": "ACCESS_TOKEN",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"refresh_token": "REFRESH_TOKEN",
|
||||
"scope": "read write"
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Using Access Token
|
||||
|
||||
Use the access token to make authenticated requests:
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/api/servers \
|
||||
-H "Authorization: Bearer ACCESS_TOKEN"
|
||||
```
|
||||
|
||||
### 5. Refreshing Token
|
||||
|
||||
When the access token expires, use the refresh token to get a new one:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/oauth/token \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "grant_type=refresh_token" \
|
||||
-d "refresh_token=REFRESH_TOKEN" \
|
||||
-d "client_id=CLIENT_ID"
|
||||
```
|
||||
|
||||
## PKCE (Proof Key for Code Exchange)
|
||||
|
||||
PKCE is a security extension to OAuth 2.0 that prevents authorization code interception attacks. It's especially important for public clients (mobile apps, SPAs).
|
||||
|
||||
### Generating PKCE Parameters
|
||||
|
||||
1. Generate a code verifier (random string):
|
||||
```javascript
|
||||
const codeVerifier = crypto.randomBytes(32).toString('base64url');
|
||||
```
|
||||
|
||||
2. Generate code challenge from verifier:
|
||||
```javascript
|
||||
const codeChallenge = crypto
|
||||
.createHash('sha256')
|
||||
.update(codeVerifier)
|
||||
.digest('base64url');
|
||||
```
|
||||
|
||||
3. Include in authorization request:
|
||||
- `code_challenge`: The generated challenge
|
||||
- `code_challenge_method`: `S256`
|
||||
|
||||
4. Include in token request:
|
||||
- `code_verifier`: The original verifier
|
||||
|
||||
## OAuth Scopes
|
||||
|
||||
MCPHub supports the following default scopes:
|
||||
|
||||
| Scope | Description |
|
||||
|-------|-------------|
|
||||
| `read` | Read access to MCP servers and tools |
|
||||
| `write` | Execute tools and modify MCP server configurations |
|
||||
|
||||
You can customize allowed scopes in the `oauthServer.allowedScopes` configuration.
|
||||
|
||||
## Dynamic Client Registration (RFC 7591)
|
||||
|
||||
MCPHub supports RFC 7591 Dynamic Client Registration, allowing OAuth clients to register themselves programmatically without manual configuration.
|
||||
|
||||
### Enable Dynamic Registration
|
||||
|
||||
Add to your `mcp_settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"systemConfig": {
|
||||
"oauthServer": {
|
||||
"enabled": true,
|
||||
"dynamicRegistration": {
|
||||
"enabled": true,
|
||||
"allowedGrantTypes": ["authorization_code", "refresh_token"],
|
||||
"requiresAuthentication": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Register a New Client
|
||||
|
||||
**POST /oauth/register**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/oauth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"client_name": "My Application",
|
||||
"redirect_uris": ["https://example.com/callback"],
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"scope": "read write",
|
||||
"token_endpoint_auth_method": "none"
|
||||
}'
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"client_id": "a1b2c3d4e5f6g7h8",
|
||||
"client_name": "My Application",
|
||||
"redirect_uris": ["https://example.com/callback"],
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"scope": "read write",
|
||||
"token_endpoint_auth_method": "none",
|
||||
"registration_access_token": "reg_token_xyz123",
|
||||
"registration_client_uri": "http://localhost:3000/oauth/register/a1b2c3d4e5f6g7h8",
|
||||
"client_id_issued_at": 1699200000
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** Save the `registration_access_token` - it's required to read, update, or delete the client registration.
|
||||
|
||||
### Read Client Configuration
|
||||
|
||||
**GET /oauth/register/:clientId**
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/oauth/register/CLIENT_ID \
|
||||
-H "Authorization: Bearer REGISTRATION_ACCESS_TOKEN"
|
||||
```
|
||||
|
||||
### Update Client Configuration
|
||||
|
||||
**PUT /oauth/register/:clientId**
|
||||
|
||||
```bash
|
||||
curl -X PUT http://localhost:3000/oauth/register/CLIENT_ID \
|
||||
-H "Authorization: Bearer REGISTRATION_ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"client_name": "Updated Application Name",
|
||||
"redirect_uris": ["https://example.com/callback", "https://example.com/callback2"]
|
||||
}'
|
||||
```
|
||||
|
||||
### Delete Client Registration
|
||||
|
||||
**DELETE /oauth/register/:clientId**
|
||||
|
||||
```bash
|
||||
curl -X DELETE http://localhost:3000/oauth/register/CLIENT_ID \
|
||||
-H "Authorization: Bearer REGISTRATION_ACCESS_TOKEN"
|
||||
```
|
||||
|
||||
### Optional Client Metadata
|
||||
|
||||
When registering a client, you can include additional metadata:
|
||||
|
||||
- `application_type`: `"web"` or `"native"` (default: `"web"`)
|
||||
- `contacts`: Array of email addresses
|
||||
- `logo_uri`: URL of client logo
|
||||
- `client_uri`: URL of client homepage
|
||||
- `policy_uri`: URL of privacy policy
|
||||
- `tos_uri`: URL of terms of service
|
||||
- `jwks_uri`: URL of JSON Web Key Set
|
||||
- `jwks`: Inline JSON Web Key Set
|
||||
|
||||
Example:
|
||||
```json
|
||||
{
|
||||
"client_name": "My Application",
|
||||
"redirect_uris": ["https://example.com/callback"],
|
||||
"application_type": "web",
|
||||
"contacts": ["admin@example.com"],
|
||||
"logo_uri": "https://example.com/logo.png",
|
||||
"client_uri": "https://example.com",
|
||||
"policy_uri": "https://example.com/privacy",
|
||||
"tos_uri": "https://example.com/terms"
|
||||
}
|
||||
```
|
||||
|
||||
## Server Metadata
|
||||
|
||||
MCPHub provides OAuth 2.0 Authorization Server Metadata (RFC 8414) at:
|
||||
|
||||
```
|
||||
GET /.well-known/oauth-authorization-server
|
||||
```
|
||||
|
||||
Response (with dynamic registration enabled):
|
||||
```json
|
||||
{
|
||||
"issuer": "http://localhost:3000",
|
||||
"authorization_endpoint": "http://localhost:3000/oauth/authorize",
|
||||
"token_endpoint": "http://localhost:3000/oauth/token",
|
||||
"userinfo_endpoint": "http://localhost:3000/oauth/userinfo",
|
||||
"registration_endpoint": "http://localhost:3000/oauth/register",
|
||||
"scopes_supported": ["read", "write"],
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||
"token_endpoint_auth_methods_supported": ["none", "client_secret_basic", "client_secret_post"],
|
||||
"code_challenge_methods_supported": ["S256", "plain"]
|
||||
}
|
||||
```
|
||||
|
||||
## User Info Endpoint
|
||||
|
||||
Get authenticated user information (OpenID Connect compatible):
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/oauth/userinfo \
|
||||
-H "Authorization: Bearer ACCESS_TOKEN"
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"sub": "username",
|
||||
"username": "username"
|
||||
}
|
||||
```
|
||||
|
||||
## Integration with ChatGPT Web
|
||||
|
||||
To integrate MCPHub with ChatGPT Web:
|
||||
|
||||
1. Enable OAuth server in MCPHub configuration
|
||||
2. Create an OAuth client with ChatGPT's redirect URI
|
||||
3. Configure ChatGPT Web MCP Connector:
|
||||
- **MCP Server URL**: `http://your-mcphub-url/mcp`
|
||||
- **Authentication**: OAuth
|
||||
- **OAuth Client ID**: Your client ID
|
||||
- **OAuth Client Secret**: Leave empty (PKCE flow)
|
||||
- **Authorization URL**: `http://your-mcphub-url/oauth/authorize`
|
||||
- **Token URL**: `http://your-mcphub-url/oauth/token`
|
||||
- **Scopes**: `read write`
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **HTTPS in Production**: Always use HTTPS in production to protect tokens in transit
|
||||
2. **Secure Client Secrets**: If using confidential clients, store client secrets securely
|
||||
3. **Token Storage**: Access tokens are stored in memory by default. For production, consider using a database
|
||||
4. **Token Rotation**: Implement token rotation by using refresh tokens
|
||||
5. **Scope Limitation**: Grant only necessary scopes to clients
|
||||
6. **Redirect URI Validation**: Always validate redirect URIs strictly
|
||||
7. **State Parameter**: Always use the state parameter to prevent CSRF attacks
|
||||
8. **PKCE**: Use PKCE for public clients (strongly recommended)
|
||||
9. **Rate Limiting**: For production deployments, implement rate limiting on OAuth endpoints to prevent brute force attacks. Consider using middleware like `express-rate-limit`
|
||||
10. **Input Validation**: All OAuth parameters are validated, but additional application-level validation may be beneficial
|
||||
11. **XSS Protection**: The authorization page escapes all user input to prevent XSS attacks
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "OAuth server not available"
|
||||
|
||||
Make sure `oauthServer.enabled` is set to `true` in your configuration and restart MCPHub.
|
||||
|
||||
### "Invalid redirect_uri"
|
||||
|
||||
Ensure the redirect URI in the authorization request exactly matches one of the registered redirect URIs for the client.
|
||||
|
||||
### "Invalid client"
|
||||
|
||||
Verify the client ID is correct and the OAuth client exists in the configuration.
|
||||
|
||||
### Token expired
|
||||
|
||||
Use the refresh token to obtain a new access token, or re-authorize the application.
|
||||
|
||||
## Example: JavaScript Client
|
||||
|
||||
```javascript
|
||||
// Generate PKCE parameters
|
||||
const codeVerifier = crypto.randomBytes(32).toString('base64url');
|
||||
const codeChallenge = crypto
|
||||
.createHash('sha256')
|
||||
.update(codeVerifier)
|
||||
.digest('base64url');
|
||||
|
||||
// Store code verifier for later use
|
||||
sessionStorage.setItem('codeVerifier', codeVerifier);
|
||||
|
||||
// Redirect to authorization endpoint
|
||||
const authUrl = new URL('http://localhost:3000/oauth/authorize');
|
||||
authUrl.searchParams.set('client_id', 'my-client-id');
|
||||
authUrl.searchParams.set('redirect_uri', 'http://localhost:8080/callback');
|
||||
authUrl.searchParams.set('response_type', 'code');
|
||||
authUrl.searchParams.set('scope', 'read write');
|
||||
authUrl.searchParams.set('state', crypto.randomBytes(16).toString('hex'));
|
||||
authUrl.searchParams.set('code_challenge', codeChallenge);
|
||||
authUrl.searchParams.set('code_challenge_method', 'S256');
|
||||
|
||||
window.location.href = authUrl.toString();
|
||||
|
||||
// In callback handler:
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code');
|
||||
const codeVerifier = sessionStorage.getItem('codeVerifier');
|
||||
|
||||
// Exchange code for token
|
||||
const tokenResponse = await fetch('http://localhost:3000/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: code,
|
||||
redirect_uri: 'http://localhost:8080/callback',
|
||||
client_id: 'my-client-id',
|
||||
code_verifier: codeVerifier,
|
||||
}),
|
||||
});
|
||||
|
||||
const tokens = await tokenResponse.json();
|
||||
// Store tokens securely
|
||||
localStorage.setItem('accessToken', tokens.access_token);
|
||||
localStorage.setItem('refreshToken', tokens.refresh_token);
|
||||
|
||||
// Use access token
|
||||
const response = await fetch('http://localhost:3000/api/servers', {
|
||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||
});
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [OAuth 2.0 - RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749)
|
||||
- [OAuth 2.0 Authorization Server Metadata - RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414)
|
||||
- [PKCE - RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636)
|
||||
- [OAuth 2.0 for Browser-Based Apps](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps)
|
||||
@@ -1,209 +0,0 @@
|
||||
# OpenAPI Schema Support in MCPHub
|
||||
|
||||
MCPHub now supports both OpenAPI specification URLs and complete JSON schemas for OpenAPI server configuration. This allows you to either reference an external OpenAPI specification file or embed the complete schema directly in your configuration.
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### 1. Using OpenAPI Specification URL (Traditional)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "openapi",
|
||||
"openapi": {
|
||||
"url": "https://api.example.com/openapi.json",
|
||||
"version": "3.1.0",
|
||||
"security": {
|
||||
"type": "apiKey",
|
||||
"apiKey": {
|
||||
"name": "X-API-Key",
|
||||
"in": "header",
|
||||
"value": "your-api-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Using Complete JSON Schema (New)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "openapi",
|
||||
"openapi": {
|
||||
"schema": {
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "My API",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "https://api.example.com"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/users": {
|
||||
"get": {
|
||||
"operationId": "getUsers",
|
||||
"summary": "Get all users",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of users"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": "3.1.0",
|
||||
"security": {
|
||||
"type": "apiKey",
|
||||
"apiKey": {
|
||||
"name": "X-API-Key",
|
||||
"in": "header",
|
||||
"value": "your-api-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Benefits of JSON Schema Support
|
||||
|
||||
1. **Offline Development**: No need for external URLs during development
|
||||
2. **Version Control**: Schema changes can be tracked in your configuration
|
||||
3. **Security**: No external dependencies or network calls required
|
||||
4. **Customization**: Full control over the API specification
|
||||
5. **Testing**: Easy to create test configurations with mock schemas
|
||||
|
||||
## Frontend Form Support
|
||||
|
||||
The web interface now includes:
|
||||
|
||||
- **Input Mode Selection**: Choose between "Specification URL" or "JSON Schema"
|
||||
- **URL Input**: Traditional URL input field for external specifications
|
||||
- **Schema Editor**: Large text area with syntax highlighting for JSON schema input
|
||||
- **Validation**: Client-side JSON validation before submission
|
||||
- **Help Text**: Contextual help for schema format
|
||||
|
||||
## API Validation
|
||||
|
||||
The backend validates that:
|
||||
|
||||
- At least one of `url` or `schema` is provided for OpenAPI servers
|
||||
- JSON schemas are properly formatted when provided
|
||||
- Security configurations are valid for both input modes
|
||||
- All required OpenAPI fields are present
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### From URL to Schema
|
||||
|
||||
If you want to convert an existing URL-based configuration to schema-based:
|
||||
|
||||
1. Download your OpenAPI specification from the URL
|
||||
2. Copy the JSON content
|
||||
3. Update your configuration to use the `schema` field instead of `url`
|
||||
4. Paste the JSON content as the value of the `schema` field
|
||||
|
||||
### Maintaining Both
|
||||
|
||||
You can include both `url` and `schema` in your configuration. The system will prioritize the `schema` field if both are present.
|
||||
|
||||
## Examples
|
||||
|
||||
See the `examples/openapi-schema-config.json` file for complete configuration examples showing both URL and schema-based configurations.
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
- **Backend**: OpenAPI client supports both SwaggerParser.dereference() with URLs and direct schema objects
|
||||
- **Frontend**: Dynamic form rendering based on selected input mode
|
||||
- **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:
|
||||
|
||||
- Ensure schemas are properly validated before use
|
||||
- Be aware that large schemas increase configuration file size
|
||||
- Consider using URL-based approach for frequently changing APIs
|
||||
- Store sensitive information (like API keys) in environment variables, not in schemas
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Invalid JSON**: Ensure your schema is valid JSON format
|
||||
2. **Missing Required Fields**: OpenAPI schemas must include `openapi`, `info`, and `paths` fields
|
||||
3. **Schema Size**: Very large schemas may impact performance
|
||||
4. **Server Configuration**: Ensure the `servers` field in your schema points to the correct endpoints
|
||||
|
||||
### Validation Errors
|
||||
|
||||
The system provides detailed error messages for:
|
||||
|
||||
- Malformed JSON in schema field
|
||||
- Missing required OpenAPI fields
|
||||
- Invalid security configurations
|
||||
- Network issues with URL-based configurations
|
||||
@@ -1,172 +0,0 @@
|
||||
# OpenAPI Support in MCPHub
|
||||
|
||||
MCPHub now supports OpenAPI 3.1.1 servers as a new server type, allowing you to integrate REST APIs directly into your MCP workflow.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ **Full OpenAPI 3.1.1 Support**: Load and parse OpenAPI specifications
|
||||
- ✅ **Multiple Security Types**: None, API Key, HTTP Authentication, OAuth 2.0, OpenID Connect
|
||||
- ✅ **Dynamic Tool Generation**: Automatically creates MCP tools from OpenAPI operations
|
||||
- ✅ **Type Safety**: Full TypeScript support with proper type definitions
|
||||
- ✅ **Frontend Integration**: Easy-to-use forms for configuring OpenAPI servers
|
||||
- ✅ **Internationalization**: Support for English and Chinese languages
|
||||
|
||||
## Configuration
|
||||
|
||||
### Basic Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "openapi",
|
||||
"openapi": {
|
||||
"url": "https://api.example.com/v1/openapi.json",
|
||||
"version": "3.1.0",
|
||||
"security": {
|
||||
"type": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### With API Key Authentication
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "openapi",
|
||||
"openapi": {
|
||||
"url": "https://api.example.com/v1/openapi.json",
|
||||
"version": "3.1.0",
|
||||
"security": {
|
||||
"type": "apiKey",
|
||||
"apiKey": {
|
||||
"name": "X-API-Key",
|
||||
"in": "header",
|
||||
"value": "your-api-key-here"
|
||||
}
|
||||
}
|
||||
},
|
||||
"headers": {
|
||||
"Accept": "application/json"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### With HTTP Bearer Authentication
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "openapi",
|
||||
"openapi": {
|
||||
"url": "https://api.example.com/v1/openapi.json",
|
||||
"version": "3.1.0",
|
||||
"security": {
|
||||
"type": "http",
|
||||
"http": {
|
||||
"scheme": "bearer",
|
||||
"credentials": "your-bearer-token-here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Supported Security Types
|
||||
|
||||
1. **None**: No authentication required
|
||||
2. **API Key**: API key in header or query parameter
|
||||
3. **HTTP**: Basic, Bearer, or Digest authentication
|
||||
4. **OAuth 2.0**: OAuth 2.0 access tokens
|
||||
5. **OpenID Connect**: OpenID Connect ID tokens
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Specification Loading**: The OpenAPI client fetches and parses the OpenAPI specification
|
||||
2. **Tool Generation**: Each operation in the spec becomes an MCP tool
|
||||
3. **Request Handling**: Tools handle parameter validation and API calls
|
||||
4. **Response Processing**: API responses are returned as tool results
|
||||
|
||||
## Frontend Usage
|
||||
|
||||
1. Navigate to the Servers page
|
||||
2. Click "Add Server"
|
||||
3. Select "OpenAPI" as the server type
|
||||
4. Enter the OpenAPI specification URL
|
||||
5. Configure security settings if needed
|
||||
6. Add any additional headers
|
||||
7. Save the configuration
|
||||
|
||||
## Testing
|
||||
|
||||
You can test the OpenAPI integration using the provided test scripts:
|
||||
|
||||
```bash
|
||||
# Test OpenAPI client directly
|
||||
npx tsx test-openapi.ts
|
||||
|
||||
# Test full integration
|
||||
npx tsx test-integration.ts
|
||||
```
|
||||
|
||||
## Example: Swagger Petstore
|
||||
|
||||
The Swagger Petstore API is a perfect example for testing:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "openapi",
|
||||
"openapi": {
|
||||
"url": "https://petstore3.swagger.io/api/v3/openapi.json",
|
||||
"version": "3.1.0",
|
||||
"security": {
|
||||
"type": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This will create tools like:
|
||||
|
||||
- `addPet`: Add a new pet to the store
|
||||
- `findPetsByStatus`: Find pets by status
|
||||
- `getPetById`: Find pet by ID
|
||||
- And many more...
|
||||
|
||||
## Error Handling
|
||||
|
||||
The OpenAPI client includes comprehensive error handling:
|
||||
|
||||
- Network errors are properly caught and reported
|
||||
- Invalid specifications are rejected with clear error messages
|
||||
- API errors include response status and body information
|
||||
- Type validation ensures proper parameter handling
|
||||
|
||||
## Limitations
|
||||
|
||||
- Only supports OpenAPI 3.x specifications (3.0.0 and above)
|
||||
- Complex authentication flows (like OAuth 2.0 authorization code flow) require manual token management
|
||||
- Large specifications may take time to parse initially
|
||||
- Some advanced OpenAPI features may not be fully supported
|
||||
|
||||
## Contributing
|
||||
|
||||
To add new features or fix bugs in the OpenAPI integration:
|
||||
|
||||
1. Backend types: `src/types/index.ts`
|
||||
2. OpenAPI client: `src/clients/openapi.ts`
|
||||
3. Service integration: `src/services/mcpService.ts`
|
||||
4. Frontend forms: `frontend/src/components/ServerForm.tsx`
|
||||
5. Internationalization: `frontend/src/locales/`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Q: My OpenAPI server won't connect**
|
||||
A: Check that the specification URL is accessible and returns valid JSON/YAML
|
||||
|
||||
**Q: Tools aren't showing up**
|
||||
A: Verify that your OpenAPI specification includes valid operations with required fields
|
||||
|
||||
**Q: Authentication isn't working**
|
||||
A: Double-check your security configuration matches the API's requirements
|
||||
|
||||
**Q: Getting CORS errors**
|
||||
A: The API server needs to allow CORS requests from your MCPHub domain
|
||||
@@ -1,172 +0,0 @@
|
||||
# 测试框架和自动化测试实现报告
|
||||
|
||||
## 概述
|
||||
|
||||
本项目已成功引入现代化的测试框架和自动化测试流程。实现了基于Jest的测试环境,支持TypeScript、ES模块,并包含完整的CI/CD配置。
|
||||
|
||||
## 已实现的功能
|
||||
|
||||
### 1. 测试框架配置
|
||||
|
||||
- **Jest配置**: 使用`jest.config.cjs`配置文件,支持ES模块和TypeScript
|
||||
- **覆盖率报告**: 配置了代码覆盖率收集和报告
|
||||
- **测试环境**: 支持Node.js环境的单元测试和集成测试
|
||||
- **模块映射**: 配置了路径别名支持
|
||||
|
||||
### 2. 测试工具和辅助函数
|
||||
|
||||
创建了完善的测试工具库 (`tests/utils/testHelpers.ts`):
|
||||
|
||||
- **认证工具**: JWT token生成和管理
|
||||
- **HTTP测试**: Supertest集成用于API测试
|
||||
- **数据生成**: 测试数据工厂函数
|
||||
- **响应断言**: 自定义API响应验证器
|
||||
- **环境管理**: 测试环境变量配置
|
||||
|
||||
### 3. 测试用例实现
|
||||
|
||||
已实现的测试场景:
|
||||
|
||||
#### 基础配置测试 (`tests/basic.test.ts`)
|
||||
- Jest配置验证
|
||||
- 异步操作支持测试
|
||||
- 自定义匹配器验证
|
||||
|
||||
#### 认证逻辑测试 (`tests/auth.logic.test.ts`)
|
||||
- 用户登录逻辑
|
||||
- 密码验证
|
||||
- JWT生成和验证
|
||||
- 错误处理场景
|
||||
- 用户数据验证
|
||||
|
||||
#### 路径工具测试 (`tests/utils/pathLogic.test.ts`)
|
||||
- 配置文件路径解析
|
||||
- 环境变量处理
|
||||
- 文件系统操作
|
||||
- 错误处理和边界条件
|
||||
- 跨平台路径处理
|
||||
|
||||
### 4. CI/CD配置
|
||||
|
||||
GitHub Actions配置 (`.github/workflows/ci.yml`):
|
||||
|
||||
- **多Node.js版本支持**: 18.x和20.x
|
||||
- **自动化测试流程**:
|
||||
- 代码检查 (ESLint)
|
||||
- 类型检查 (TypeScript)
|
||||
- 单元测试执行
|
||||
- 覆盖率报告
|
||||
- **构建验证**: 应用构建和产物验证
|
||||
- **集成测试**: 包含数据库环境的集成测试
|
||||
|
||||
### 5. 测试脚本
|
||||
|
||||
在`package.json`中添加的测试命令:
|
||||
|
||||
```json
|
||||
{
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"test:verbose": "jest --verbose",
|
||||
"test:ci": "jest --ci --coverage --watchAll=false"
|
||||
}
|
||||
```
|
||||
|
||||
## 测试结果
|
||||
|
||||
当前测试统计:
|
||||
- **测试套件**: 3个
|
||||
- **测试用例**: 19个
|
||||
- **通过率**: 100%
|
||||
- **执行时间**: ~15秒
|
||||
|
||||
### 测试覆盖的功能模块
|
||||
|
||||
1. **认证系统**: 用户登录、JWT处理、密码验证
|
||||
2. **配置管理**: 文件路径解析、环境变量处理
|
||||
3. **基础设施**: Jest配置、测试工具验证
|
||||
|
||||
## 技术特点
|
||||
|
||||
### 现代化特性
|
||||
|
||||
- **ES模块支持**: 完全支持ES2022模块语法
|
||||
- **TypeScript集成**: 类型安全的测试编写
|
||||
- **异步测试**: Promise和async/await支持
|
||||
- **模拟系统**: Jest mock功能的深度使用
|
||||
- **参数化测试**: 数据驱动的测试用例
|
||||
|
||||
### 最佳实践
|
||||
|
||||
- **测试隔离**: 每个测试用例独立运行
|
||||
- **Mock管理**: 统一的mock清理和重置
|
||||
- **错误处理**: 完整的错误场景测试
|
||||
- **边界测试**: 输入验证和边界条件覆盖
|
||||
- **文档化**: 清晰的测试用例命名和描述
|
||||
|
||||
## 后续扩展计划
|
||||
|
||||
### 短期目标
|
||||
|
||||
1. **API测试**: 为REST API端点添加集成测试
|
||||
2. **数据库测试**: 添加数据模型和存储层测试
|
||||
3. **中间件测试**: 认证和权限中间件测试
|
||||
4. **服务层测试**: 核心业务逻辑测试
|
||||
|
||||
### 中期目标
|
||||
|
||||
1. **端到端测试**: 使用Playwright或Cypress
|
||||
2. **性能测试**: API响应时间和负载测试
|
||||
3. **安全测试**: 输入验证和安全漏洞测试
|
||||
4. **契约测试**: API契约验证
|
||||
|
||||
### 长期目标
|
||||
|
||||
1. **测试数据管理**: 测试数据库和fixture管理
|
||||
2. **视觉回归测试**: UI组件的视觉测试
|
||||
3. **监控集成**: 生产环境测试监控
|
||||
4. **自动化测试报告**: 详细的测试报告和趋势分析
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 添加新测试用例
|
||||
|
||||
1. 在`tests/`目录下创建对应的测试文件
|
||||
2. 使用`testHelpers.ts`中的工具函数
|
||||
3. 遵循命名约定: `*.test.ts`或`*.spec.ts`
|
||||
4. 确保测试用例具有清晰的描述和断言
|
||||
|
||||
### 运行测试
|
||||
|
||||
```bash
|
||||
# 运行所有测试
|
||||
pnpm test
|
||||
|
||||
# 监听模式
|
||||
pnpm test:watch
|
||||
|
||||
# 生成覆盖率报告
|
||||
pnpm test:coverage
|
||||
|
||||
# CI模式运行
|
||||
pnpm test:ci
|
||||
```
|
||||
|
||||
### Mock最佳实践
|
||||
|
||||
- 在`beforeEach`中清理所有mock
|
||||
- 使用具体的mock实现而不是空函数
|
||||
- 验证mock被正确调用
|
||||
- 保持mock的一致性和可维护性
|
||||
|
||||
## 结论
|
||||
|
||||
本项目已成功建立了完整的现代化测试框架,具备以下优势:
|
||||
|
||||
1. **高度可扩展**: 易于添加新的测试用例和测试类型
|
||||
2. **开发友好**: 丰富的工具函数和清晰的结构
|
||||
3. **CI/CD就绪**: 完整的自动化流水线配置
|
||||
4. **质量保证**: 代码覆盖率和持续测试验证
|
||||
|
||||
这个测试框架为项目的持续发展和质量保证提供了坚实的基础,支持敏捷开发和持续集成的最佳实践。
|
||||
304
docs/zh/configuration/database-configuration.mdx
Normal file
304
docs/zh/configuration/database-configuration.mdx
Normal file
@@ -0,0 +1,304 @@
|
||||
---
|
||||
title: '数据库配置'
|
||||
description: '使用 PostgreSQL 数据库配置 MCPHub 作为 mcp_settings.json 文件的替代方案。'
|
||||
---
|
||||
|
||||
# MCPHub 数据库配置
|
||||
|
||||
## 概述
|
||||
|
||||
MCPHub 支持将配置数据存储在 PostgreSQL 数据库中,作为 `mcp_settings.json` 文件配置的替代方案。数据库模式为生产环境和企业级部署提供了更强大的持久化和扩展能力。
|
||||
|
||||
## 为什么使用数据库配置?
|
||||
|
||||
**核心优势:**
|
||||
- ✅ **更好的持久化** - 配置数据存储在专业数据库中,支持事务和数据完整性
|
||||
- ✅ **高可用性** - 利用数据库复制和故障转移能力
|
||||
- ✅ **企业级支持** - 符合企业数据管理和合规要求
|
||||
- ✅ **备份恢复** - 使用成熟的数据库备份工具和策略
|
||||
|
||||
## 环境变量
|
||||
|
||||
### 数据库模式必需变量
|
||||
|
||||
```bash
|
||||
# 数据库连接 URL(PostgreSQL)
|
||||
# 只需设置 DB_URL 即可自动启用数据库模式
|
||||
DB_URL=postgresql://user:password@localhost:5432/mcphub
|
||||
|
||||
# 或显式控制 USE_DB(覆盖自动检测)
|
||||
# USE_DB=true
|
||||
```
|
||||
|
||||
<Note>
|
||||
**简化配置**:您只需设置 `DB_URL` 即可启用数据库模式。MCPHub 会自动检测 `DB_URL` 是否存在并启用数据库模式。如果需要在设置了 `DB_URL` 的情况下禁用数据库模式,可以显式设置 `USE_DB=false`。
|
||||
</Note>
|
||||
|
||||
## 设置说明
|
||||
|
||||
### 1. 使用 Docker
|
||||
|
||||
#### 方案 A:使用外部数据库
|
||||
|
||||
如果您已有 PostgreSQL 数据库:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 3000:3000 \
|
||||
-v ./mcp_settings.json:/app/mcp_settings.json \
|
||||
-e DB_URL="postgresql://user:password@your-db-host:5432/mcphub" \
|
||||
samanhappy/mcphub
|
||||
```
|
||||
|
||||
#### 方案 B:将 PostgreSQL 作为独立服务
|
||||
|
||||
创建 `docker-compose.yml` 文件:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_DB: mcphub
|
||||
POSTGRES_USER: mcphub
|
||||
POSTGRES_PASSWORD: your_secure_password
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
mcphub:
|
||||
image: samanhappy/mcphub:latest
|
||||
environment:
|
||||
USE_DB: "true"
|
||||
DB_URL: "postgresql://mcphub:your_secure_password@postgres:5432/mcphub"
|
||||
PORT: 3000
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
```
|
||||
|
||||
运行:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### 2. 手动设置
|
||||
|
||||
#### 步骤 1:设置 PostgreSQL 数据库
|
||||
|
||||
```bash
|
||||
# 安装 PostgreSQL(如果尚未安装)
|
||||
sudo apt-get install postgresql postgresql-contrib
|
||||
|
||||
# 创建数据库和用户
|
||||
sudo -u postgres psql <<EOF
|
||||
CREATE DATABASE mcphub;
|
||||
CREATE USER mcphub WITH ENCRYPTED PASSWORD 'your_password';
|
||||
GRANT ALL PRIVILEGES ON DATABASE mcphub TO mcphub;
|
||||
EOF
|
||||
```
|
||||
|
||||
#### 步骤 2:安装 MCPHub
|
||||
|
||||
```bash
|
||||
npm install -g @samanhappy/mcphub
|
||||
```
|
||||
|
||||
#### 步骤 3:设置环境变量
|
||||
|
||||
创建 `.env` 文件:
|
||||
|
||||
```bash
|
||||
# 只需设置 DB_URL 即可启用数据库模式(USE_DB 会自动检测)
|
||||
DB_URL=postgresql://mcphub:your_password@localhost:5432/mcphub
|
||||
PORT=3000
|
||||
```
|
||||
|
||||
#### 步骤 4:运行迁移(可选)
|
||||
|
||||
如果您有现有的 `mcp_settings.json` 文件,可以进行迁移:
|
||||
|
||||
```bash
|
||||
# 运行迁移脚本
|
||||
npx tsx src/scripts/migrate-to-database.ts
|
||||
```
|
||||
|
||||
或者让 MCPHub 在首次启动时自动迁移。
|
||||
|
||||
#### 步骤 5:启动 MCPHub
|
||||
|
||||
```bash
|
||||
mcphub
|
||||
```
|
||||
|
||||
## 从基于文件迁移到数据库
|
||||
|
||||
MCPHub 在启用数据库模式首次启动时提供自动迁移功能。您也可以手动运行迁移。
|
||||
|
||||
### 自动迁移
|
||||
|
||||
当您首次使用 `USE_DB=true` 启动 MCPHub 时:
|
||||
|
||||
1. MCPHub 连接到数据库
|
||||
2. 检查数据库中是否存在任何用户
|
||||
3. 如果未找到用户,自动从 `mcp_settings.json` 迁移
|
||||
4. 创建所有表并导入所有数据
|
||||
|
||||
### 手动迁移
|
||||
|
||||
运行迁移脚本:
|
||||
|
||||
```bash
|
||||
# 使用 npx
|
||||
npx tsx src/scripts/migrate-to-database.ts
|
||||
|
||||
# 或使用 Node
|
||||
node dist/scripts/migrate-to-database.js
|
||||
```
|
||||
|
||||
迁移将:
|
||||
- ✅ 如果不存在则创建数据库表
|
||||
- ✅ 导入所有用户(包含哈希密码)
|
||||
- ✅ 导入所有 MCP 服务器配置
|
||||
- ✅ 导入所有分组
|
||||
- ✅ 导入系统配置
|
||||
- ✅ 导入用户特定配置
|
||||
- ✅ 跳过已存在的记录(可安全多次运行)
|
||||
|
||||
## 迁移后的配置
|
||||
|
||||
在数据库模式下运行时,所有配置更改都存储在数据库中:
|
||||
|
||||
- 通过 `/api/users` 进行用户管理
|
||||
- 通过 `/api/servers` 进行服务器管理
|
||||
- 通过 `/api/groups` 进行分组管理
|
||||
- 通过 `/api/system/config` 进行系统设置
|
||||
|
||||
Web 仪表板的工作方式完全相同,但现在将更改存储在数据库中而不是文件中。
|
||||
|
||||
## 数据库架构
|
||||
|
||||
MCPHub 创建以下表:
|
||||
|
||||
- **users** - 用户账户和认证
|
||||
- **servers** - MCP 服务器配置
|
||||
- **groups** - 服务器分组
|
||||
- **system_config** - 系统级设置
|
||||
- **user_configs** - 用户特定设置
|
||||
- **vector_embeddings** - 向量搜索数据(用于智能路由)
|
||||
|
||||
## 备份和恢复
|
||||
|
||||
### 备份
|
||||
|
||||
```bash
|
||||
# PostgreSQL 备份
|
||||
pg_dump -U mcphub mcphub > mcphub_backup.sql
|
||||
|
||||
# 或使用 Docker
|
||||
docker exec postgres pg_dump -U mcphub mcphub > mcphub_backup.sql
|
||||
```
|
||||
|
||||
### 恢复
|
||||
|
||||
```bash
|
||||
# PostgreSQL 恢复
|
||||
psql -U mcphub mcphub < mcphub_backup.sql
|
||||
|
||||
# 或使用 Docker
|
||||
docker exec -i postgres psql -U mcphub mcphub < mcphub_backup.sql
|
||||
```
|
||||
|
||||
## 切换回基于文件的配置
|
||||
|
||||
如果您需要切换回基于文件的配置:
|
||||
|
||||
1. 设置 `USE_DB=false` 或删除 `DB_URL` 和 `USE_DB` 环境变量
|
||||
2. 重启 MCPHub
|
||||
3. MCPHub 将再次使用 `mcp_settings.json`
|
||||
|
||||
注意:在数据库模式下所做的更改不会反映到文件中,除非您手动导出。
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 连接被拒绝
|
||||
|
||||
```
|
||||
Error: connect ECONNREFUSED 127.0.0.1:5432
|
||||
```
|
||||
|
||||
**解决方案:** 检查 PostgreSQL 是否正在运行并可访问:
|
||||
```bash
|
||||
# 检查 PostgreSQL 状态
|
||||
sudo systemctl status postgresql
|
||||
|
||||
# 或对于 Docker
|
||||
docker ps | grep postgres
|
||||
```
|
||||
|
||||
### 认证失败
|
||||
|
||||
```
|
||||
Error: password authentication failed for user "mcphub"
|
||||
```
|
||||
|
||||
**解决方案:** 验证 `DB_URL` 环境变量中的数据库凭据。
|
||||
|
||||
### 迁移失败
|
||||
|
||||
```
|
||||
❌ Migration failed: ...
|
||||
```
|
||||
|
||||
**解决方案:**
|
||||
1. 检查 `mcp_settings.json` 是否存在且为有效的 JSON
|
||||
2. 验证数据库连接
|
||||
3. 检查日志获取具体错误信息
|
||||
4. 确保数据库用户具有 CREATE TABLE 权限
|
||||
|
||||
### 表已存在
|
||||
|
||||
如果数据库表不存在,会自动创建。如果遇到关于已存在表的错误,请检查:
|
||||
1. 之前的迁移是否部分完成
|
||||
2. 手动创建表的冲突
|
||||
3. 如果需要,在数据库配置中使用 `synchronize: false` 运行
|
||||
|
||||
## 环境变量参考
|
||||
|
||||
| 变量 | 必需 | 默认值 | 描述 |
|
||||
|------|------|--------|------|
|
||||
| `DB_URL` | 是* | - | 完整的 PostgreSQL 连接 URL。设置此变量会自动启用数据库模式 |
|
||||
| `USE_DB` | 否 | 自动 | 显式启用/禁用数据库模式。如果未设置,根据 `DB_URL` 是否存在自动检测 |
|
||||
| `MCPHUB_SETTING_PATH` | 否 | - | mcp_settings.json 的路径(用于迁移) |
|
||||
|
||||
*数据库模式必需。只需设置 `DB_URL` 即可自动启用数据库模式
|
||||
|
||||
## 安全注意事项
|
||||
|
||||
1. **数据库凭据:** 安全存储数据库凭据,使用环境变量或密钥管理
|
||||
2. **网络访问:** 仅限 MCPHub 实例访问数据库
|
||||
3. **加密:** 在生产环境中使用 SSL/TLS 进行数据库连接:
|
||||
```bash
|
||||
DB_URL=postgresql://user:password@host:5432/mcphub?sslmode=require
|
||||
```
|
||||
4. **备份:** 定期备份您的数据库
|
||||
5. **访问控制:** 使用强密码并限制用户权限
|
||||
|
||||
## 性能
|
||||
|
||||
数据库模式在以下场景提供更好的性能:
|
||||
- 多个并发用户
|
||||
- 频繁的配置更改
|
||||
- 大量服务器/分组
|
||||
|
||||
文件模式可能更快的场景:
|
||||
- 单用户设置
|
||||
- 读取密集型工作负载且更改不频繁
|
||||
- 开发/测试环境
|
||||
@@ -123,42 +123,6 @@ nano .env
|
||||
NODE_ENV=development
|
||||
PORT=3000
|
||||
HOST=localhost
|
||||
|
||||
# 数据库配置
|
||||
DATABASE_URL=sqlite:./data/dev.db
|
||||
|
||||
# JWT 配置
|
||||
JWT_SECRET=dev-jwt-secret-key
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# 日志配置
|
||||
LOG_LEVEL=debug
|
||||
LOG_FORMAT=dev
|
||||
|
||||
# CORS 配置
|
||||
CORS_ORIGIN=http://localhost:3000,http://localhost:3001
|
||||
|
||||
# 管理员账户
|
||||
ADMIN_EMAIL=dev@mcphub.io
|
||||
ADMIN_PASSWORD=dev123
|
||||
|
||||
# 开发功能开关
|
||||
ENABLE_DEBUG_ROUTES=true
|
||||
ENABLE_SWAGGER=true
|
||||
ENABLE_HOT_RELOAD=true
|
||||
```
|
||||
|
||||
### 数据库初始化
|
||||
|
||||
```bash
|
||||
# 生成 Prisma 客户端
|
||||
npx prisma generate
|
||||
|
||||
# 运行数据库迁移
|
||||
npx prisma migrate dev --name init
|
||||
|
||||
# 填充测试数据
|
||||
npm run db:seed
|
||||
```
|
||||
|
||||
## 启动开发服务器
|
||||
|
||||
@@ -528,7 +528,7 @@ docker-compose up -d
|
||||
````md
|
||||
```bash
|
||||
# 创建新的 MCP 服务器
|
||||
curl -X POST https://api.mcphub.io/api/servers \
|
||||
curl -X POST http://localhost:3000/api/servers \
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
@@ -539,11 +539,11 @@ curl -X POST https://api.mcphub.io/api/servers \
|
||||
}'
|
||||
|
||||
# 获取服务器列表
|
||||
curl -X GET "https://api.mcphub.io/api/servers?limit=10&active=true" \
|
||||
curl -X GET "http://localhost:3000/api/servers?limit=10&active=true" \
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN"
|
||||
|
||||
# 更新服务器配置
|
||||
curl -X PUT https://api.mcphub.io/api/servers/server-123 \
|
||||
curl -X PUT http://localhost:3000/api/servers/server-123 \
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
@@ -552,14 +552,14 @@ curl -X PUT https://api.mcphub.io/api/servers/server-123 \
|
||||
}'
|
||||
|
||||
# 删除服务器
|
||||
curl -X DELETE https://api.mcphub.io/api/servers/server-123 \
|
||||
curl -X DELETE http://localhost:3000/api/servers/server-123 \
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN"
|
||||
```
|
||||
````
|
||||
|
||||
```bash
|
||||
# 创建新的 MCP 服务器
|
||||
curl -X POST https://api.mcphub.io/api/servers \
|
||||
curl -X POST http://localhost:3000/api/servers \
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
@@ -570,11 +570,11 @@ curl -X POST https://api.mcphub.io/api/servers \
|
||||
}'
|
||||
|
||||
# 获取服务器列表
|
||||
curl -X GET "https://api.mcphub.io/api/servers?limit=10&active=true" \
|
||||
curl -X GET "http://localhost:3000/api/servers?limit=10&active=true" \
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN"
|
||||
|
||||
# 更新服务器配置
|
||||
curl -X PUT https://api.mcphub.io/api/servers/server-123 \
|
||||
curl -X PUT http://localhost:3000/api/servers/server-123 \
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
@@ -583,7 +583,7 @@ curl -X PUT https://api.mcphub.io/api/servers/server-123 \
|
||||
}'
|
||||
|
||||
# 删除服务器
|
||||
curl -X DELETE https://api.mcphub.io/api/servers/server-123 \
|
||||
curl -X DELETE http://localhost:3000/api/servers/server-123 \
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN"
|
||||
```
|
||||
|
||||
@@ -592,7 +592,7 @@ curl -X DELETE https://api.mcphub.io/api/servers/server-123 \
|
||||
````md
|
||||
```http
|
||||
POST /api/servers HTTP/1.1
|
||||
Host: api.mcphub.io
|
||||
Host: localhost:3000
|
||||
Authorization: Bearer YOUR_API_TOKEN
|
||||
Content-Type: application/json
|
||||
|
||||
@@ -607,7 +607,7 @@ Content-Type: application/json
|
||||
|
||||
```http
|
||||
POST /api/servers HTTP/1.1
|
||||
Host: api.mcphub.io
|
||||
Host: localhost:3000
|
||||
Authorization: Bearer YOUR_API_TOKEN
|
||||
Content-Type: application/json
|
||||
|
||||
@@ -746,7 +746,7 @@ app.listen(port, () => {
|
||||
```javascript
|
||||
// 初始化 MCPHub 客户端
|
||||
const client = new MCPHubClient({
|
||||
endpoint: 'https://api.mcphub.io',
|
||||
endpoint: 'http://localhost:3000',
|
||||
apiKey: process.env.API_KEY,
|
||||
timeout: 30000, // 30 秒超时
|
||||
retries: 3, // 重试 3 次
|
||||
|
||||
@@ -133,7 +133,7 @@ MCPHub 主要功能:
|
||||
```javascript
|
||||
// MCPHub 客户端初始化
|
||||
const mcpClient = new MCPClient({
|
||||
endpoint: 'https://api.mcphub.io',
|
||||
endpoint: 'http://localhost:3000',
|
||||
apiKey: process.env.MCPHUB_API_KEY,
|
||||
});
|
||||
```
|
||||
@@ -142,7 +142,7 @@ const mcpClient = new MCPClient({
|
||||
```javascript
|
||||
// MCPHub 客户端初始化
|
||||
const mcpClient = new MCPClient({
|
||||
endpoint: 'https://api.mcphub.io',
|
||||
endpoint: 'http://localhost:3000',
|
||||
apiKey: process.env.MCPHUB_API_KEY,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -241,20 +241,6 @@ MCPHub 文档支持多级分层导航:
|
||||
|
||||
```json title="docs.json"
|
||||
{
|
||||
"tabs": [
|
||||
{
|
||||
"name": "文档",
|
||||
"url": "https://docs.mcphub.io"
|
||||
},
|
||||
{
|
||||
"name": "API",
|
||||
"url": "https://api.mcphub.io"
|
||||
},
|
||||
{
|
||||
"name": "SDK",
|
||||
"url": "https://sdk.mcphub.io"
|
||||
}
|
||||
],
|
||||
"navigation": {
|
||||
"文档": [
|
||||
{
|
||||
@@ -336,13 +322,8 @@ MCPHub 文档支持以下图标库的图标:
|
||||
},
|
||||
{
|
||||
"name": "Discord 社区",
|
||||
"url": "https://discord.gg/mcphub",
|
||||
"url": "https://discord.gg/qMKNsn5Q",
|
||||
"icon": "discord"
|
||||
},
|
||||
{
|
||||
"name": "状态页面",
|
||||
"url": "https://status.mcphub.io",
|
||||
"icon": "status"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -95,6 +95,11 @@ const ServerForm = ({
|
||||
undefined,
|
||||
},
|
||||
oauth: getInitialOAuthConfig(initialData),
|
||||
// KeepAlive configuration initialization
|
||||
keepAlive: {
|
||||
enabled: initialData?.config?.enableKeepAlive || false,
|
||||
interval: initialData?.config?.keepAliveInterval || 60000,
|
||||
},
|
||||
// OpenAPI configuration initialization
|
||||
openapi:
|
||||
initialData && initialData.config && initialData.config.openapi
|
||||
@@ -151,6 +156,7 @@ const ServerForm = ({
|
||||
|
||||
const [isRequestOptionsExpanded, setIsRequestOptionsExpanded] = useState<boolean>(false);
|
||||
const [isOAuthSectionExpanded, setIsOAuthSectionExpanded] = useState<boolean>(false);
|
||||
const [isKeepAliveSectionExpanded, setIsKeepAliveSectionExpanded] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isEdit = !!initialData;
|
||||
|
||||
@@ -377,6 +383,15 @@ const ServerForm = ({
|
||||
env: Object.keys(env).length > 0 ? env : undefined,
|
||||
}),
|
||||
...(Object.keys(options).length > 0 ? { options } : {}),
|
||||
// KeepAlive configuration (only for SSE/streamable-http types)
|
||||
...(serverType === 'sse' || serverType === 'streamable-http'
|
||||
? {
|
||||
enableKeepAlive: formData.keepAlive?.enabled || false,
|
||||
...(formData.keepAlive?.enabled
|
||||
? { keepAliveInterval: formData.keepAlive.interval || 60000 }
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1255,6 +1270,86 @@ const ServerForm = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KeepAlive Configuration - only for SSE/Streamable HTTP */}
|
||||
{(serverType === 'sse' || serverType === 'streamable-http') && (
|
||||
<div className="mb-4">
|
||||
<div
|
||||
className="flex items-center justify-between cursor-pointer bg-gray-50 hover:bg-gray-100 p-3 rounded border border-gray-200"
|
||||
onClick={() => setIsKeepAliveSectionExpanded(!isKeepAliveSectionExpanded)}
|
||||
>
|
||||
<label className="text-gray-700 text-sm font-bold">
|
||||
{t('server.keepAlive', 'Keep-Alive')}
|
||||
</label>
|
||||
<span className="text-gray-500 text-sm">
|
||||
{isKeepAliveSectionExpanded ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isKeepAliveSectionExpanded && (
|
||||
<div className="border border-gray-200 rounded-b p-4 bg-gray-50 border-t-0">
|
||||
<div className="flex items-center mb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enableKeepAlive"
|
||||
checked={formData.keepAlive?.enabled || false}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
keepAlive: {
|
||||
...prev.keepAlive,
|
||||
enabled: e.target.checked,
|
||||
},
|
||||
}))
|
||||
}
|
||||
className="mr-2"
|
||||
/>
|
||||
<label htmlFor="enableKeepAlive" className="text-gray-600 text-sm">
|
||||
{t('server.enableKeepAlive', 'Enable Keep-Alive')}
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
{t(
|
||||
'server.keepAliveDescription',
|
||||
'Send periodic ping requests to maintain the connection. Useful for long-running connections that may timeout.',
|
||||
)}
|
||||
</p>
|
||||
<div>
|
||||
<label
|
||||
className="block text-gray-600 text-sm font-medium mb-1"
|
||||
htmlFor="keepAliveInterval"
|
||||
>
|
||||
{t('server.keepAliveInterval', 'Interval (ms)')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="keepAliveInterval"
|
||||
value={formData.keepAlive?.interval || 60000}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
keepAlive: {
|
||||
...prev.keepAlive,
|
||||
interval: parseInt(e.target.value) || 60000,
|
||||
},
|
||||
}))
|
||||
}
|
||||
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="60000"
|
||||
min="5000"
|
||||
max="300000"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{t(
|
||||
'server.keepAliveIntervalDescription',
|
||||
'Time between keep-alive pings in milliseconds (default: 60000ms = 1 minute)',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end mt-6">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
export const PERMISSIONS = {
|
||||
// Settings page permissions
|
||||
SETTINGS_SMART_ROUTING: 'settings:smart_routing',
|
||||
SETTINGS_SKIP_AUTH: 'settings:skip_auth',
|
||||
SETTINGS_ROUTE_CONFIG: 'settings:route_config',
|
||||
SETTINGS_INSTALL_CONFIG: 'settings:install_config',
|
||||
SETTINGS_SYSTEM_CONFIG: 'settings:system_config',
|
||||
SETTINGS_OAUTH_SERVER: 'settings:oauth_server',
|
||||
SETTINGS_EXPORT_CONFIG: 'settings:export_config',
|
||||
} as const;
|
||||
|
||||
@@ -283,31 +283,29 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
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');
|
||||
// Fetch single server config instead of all settings
|
||||
const encodedServerName = encodeURIComponent(server.name);
|
||||
const serverData: ApiResponse<{
|
||||
name: string;
|
||||
status: string;
|
||||
tools: any[];
|
||||
config: Record<string, any>;
|
||||
}> = await apiGet(`/servers/${encodedServerName}`);
|
||||
|
||||
if (
|
||||
settingsData &&
|
||||
settingsData.success &&
|
||||
settingsData.data &&
|
||||
settingsData.data.mcpServers &&
|
||||
settingsData.data.mcpServers[server.name]
|
||||
) {
|
||||
const serverConfig = settingsData.data.mcpServers[server.name];
|
||||
if (serverData && serverData.success && serverData.data) {
|
||||
return {
|
||||
name: server.name,
|
||||
status: server.status,
|
||||
tools: server.tools || [],
|
||||
config: serverConfig,
|
||||
name: serverData.data.name,
|
||||
status: serverData.data.status,
|
||||
tools: serverData.data.tools || [],
|
||||
config: serverData.data.config,
|
||||
};
|
||||
} else {
|
||||
console.error('Failed to get server config from settings:', settingsData);
|
||||
console.error('Failed to get server config:', serverData);
|
||||
setError(t('server.invalidConfig', { serverName: server.name }));
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching server settings:', err);
|
||||
console.error('Error fetching server config:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,69 +1,69 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import ChangePasswordForm from '@/components/ChangePasswordForm'
|
||||
import { Switch } from '@/components/ui/ToggleGroup'
|
||||
import { useSettingsData } from '@/hooks/useSettingsData'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
import { generateRandomKey } from '@/utils/key'
|
||||
import { PermissionChecker } from '@/components/PermissionChecker'
|
||||
import { PERMISSIONS } from '@/constants/permissions'
|
||||
import { Copy, Check, Download } from 'lucide-react'
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ChangePasswordForm from '@/components/ChangePasswordForm';
|
||||
import { Switch } from '@/components/ui/ToggleGroup';
|
||||
import { useSettingsData } from '@/hooks/useSettingsData';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import { generateRandomKey } from '@/utils/key';
|
||||
import { PermissionChecker } from '@/components/PermissionChecker';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { Copy, Check, Download } from 'lucide-react';
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [installConfig, setInstallConfig] = useState<{
|
||||
pythonIndexUrl: string
|
||||
npmRegistry: string
|
||||
baseUrl: string
|
||||
pythonIndexUrl: string;
|
||||
npmRegistry: string;
|
||||
baseUrl: string;
|
||||
}>({
|
||||
pythonIndexUrl: '',
|
||||
npmRegistry: '',
|
||||
baseUrl: 'http://localhost:3000',
|
||||
})
|
||||
});
|
||||
|
||||
const [tempSmartRoutingConfig, setTempSmartRoutingConfig] = useState<{
|
||||
dbUrl: string
|
||||
openaiApiBaseUrl: string
|
||||
openaiApiKey: string
|
||||
openaiApiEmbeddingModel: string
|
||||
dbUrl: string;
|
||||
openaiApiBaseUrl: string;
|
||||
openaiApiKey: string;
|
||||
openaiApiEmbeddingModel: string;
|
||||
}>({
|
||||
dbUrl: '',
|
||||
openaiApiBaseUrl: '',
|
||||
openaiApiKey: '',
|
||||
openaiApiEmbeddingModel: '',
|
||||
})
|
||||
});
|
||||
|
||||
const [tempMCPRouterConfig, setTempMCPRouterConfig] = useState<{
|
||||
apiKey: string
|
||||
referer: string
|
||||
title: string
|
||||
baseUrl: string
|
||||
apiKey: string;
|
||||
referer: string;
|
||||
title: string;
|
||||
baseUrl: string;
|
||||
}>({
|
||||
apiKey: '',
|
||||
referer: 'https://www.mcphubx.com',
|
||||
title: 'MCPHub',
|
||||
baseUrl: 'https://api.mcprouter.to/v1',
|
||||
})
|
||||
});
|
||||
|
||||
const [tempOAuthServerConfig, setTempOAuthServerConfig] = useState<{
|
||||
accessTokenLifetime: string
|
||||
refreshTokenLifetime: string
|
||||
authorizationCodeLifetime: string
|
||||
allowedScopes: string
|
||||
dynamicRegistrationAllowedGrantTypes: string
|
||||
accessTokenLifetime: string;
|
||||
refreshTokenLifetime: string;
|
||||
authorizationCodeLifetime: string;
|
||||
allowedScopes: string;
|
||||
dynamicRegistrationAllowedGrantTypes: string;
|
||||
}>({
|
||||
accessTokenLifetime: '3600',
|
||||
refreshTokenLifetime: '1209600',
|
||||
authorizationCodeLifetime: '300',
|
||||
allowedScopes: 'read, write',
|
||||
dynamicRegistrationAllowedGrantTypes: 'authorization_code, refresh_token',
|
||||
})
|
||||
});
|
||||
|
||||
const [tempNameSeparator, setTempNameSeparator] = useState<string>('-')
|
||||
const [tempNameSeparator, setTempNameSeparator] = useState<string>('-');
|
||||
|
||||
const {
|
||||
routingConfig,
|
||||
@@ -86,14 +86,14 @@ const SettingsPage: React.FC = () => {
|
||||
updateNameSeparator,
|
||||
updateSessionRebuild,
|
||||
exportMCPSettings,
|
||||
} = useSettingsData()
|
||||
} = useSettingsData();
|
||||
|
||||
// Update local installConfig when savedInstallConfig changes
|
||||
useEffect(() => {
|
||||
if (savedInstallConfig) {
|
||||
setInstallConfig(savedInstallConfig)
|
||||
setInstallConfig(savedInstallConfig);
|
||||
}
|
||||
}, [savedInstallConfig])
|
||||
}, [savedInstallConfig]);
|
||||
|
||||
// Update local tempSmartRoutingConfig when smartRoutingConfig changes
|
||||
useEffect(() => {
|
||||
@@ -103,9 +103,9 @@ const SettingsPage: React.FC = () => {
|
||||
openaiApiBaseUrl: smartRoutingConfig.openaiApiBaseUrl || '',
|
||||
openaiApiKey: smartRoutingConfig.openaiApiKey || '',
|
||||
openaiApiEmbeddingModel: smartRoutingConfig.openaiApiEmbeddingModel || '',
|
||||
})
|
||||
});
|
||||
}
|
||||
}, [smartRoutingConfig])
|
||||
}, [smartRoutingConfig]);
|
||||
|
||||
// Update local tempMCPRouterConfig when mcpRouterConfig changes
|
||||
useEffect(() => {
|
||||
@@ -115,9 +115,9 @@ const SettingsPage: React.FC = () => {
|
||||
referer: mcpRouterConfig.referer || 'https://www.mcphubx.com',
|
||||
title: mcpRouterConfig.title || 'MCPHub',
|
||||
baseUrl: mcpRouterConfig.baseUrl || 'https://api.mcprouter.to/v1',
|
||||
})
|
||||
});
|
||||
}
|
||||
}, [mcpRouterConfig])
|
||||
}, [mcpRouterConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (oauthServerConfig) {
|
||||
@@ -138,18 +138,18 @@ const SettingsPage: React.FC = () => {
|
||||
oauthServerConfig.allowedScopes && oauthServerConfig.allowedScopes.length > 0
|
||||
? oauthServerConfig.allowedScopes.join(', ')
|
||||
: '',
|
||||
dynamicRegistrationAllowedGrantTypes:
|
||||
oauthServerConfig.dynamicRegistration?.allowedGrantTypes?.length
|
||||
? oauthServerConfig.dynamicRegistration.allowedGrantTypes.join(', ')
|
||||
: '',
|
||||
})
|
||||
dynamicRegistrationAllowedGrantTypes: oauthServerConfig.dynamicRegistration
|
||||
?.allowedGrantTypes?.length
|
||||
? oauthServerConfig.dynamicRegistration.allowedGrantTypes.join(', ')
|
||||
: '',
|
||||
});
|
||||
}
|
||||
}, [oauthServerConfig])
|
||||
}, [oauthServerConfig]);
|
||||
|
||||
// Update local tempNameSeparator when nameSeparator changes
|
||||
useEffect(() => {
|
||||
setTempNameSeparator(nameSeparator)
|
||||
}, [nameSeparator])
|
||||
setTempNameSeparator(nameSeparator);
|
||||
}, [nameSeparator]);
|
||||
|
||||
const [sectionsVisible, setSectionsVisible] = useState({
|
||||
routingConfig: false,
|
||||
@@ -160,7 +160,7 @@ const SettingsPage: React.FC = () => {
|
||||
nameSeparator: false,
|
||||
password: false,
|
||||
exportConfig: false,
|
||||
})
|
||||
});
|
||||
|
||||
const toggleSection = (
|
||||
section:
|
||||
@@ -176,8 +176,8 @@ const SettingsPage: React.FC = () => {
|
||||
setSectionsVisible((prev) => ({
|
||||
...prev,
|
||||
[section]: !prev[section],
|
||||
}))
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRoutingConfigChange = async (
|
||||
key:
|
||||
@@ -191,39 +191,39 @@ const SettingsPage: React.FC = () => {
|
||||
// If enableBearerAuth is turned on and there's no key, generate one first
|
||||
if (key === 'enableBearerAuth' && value === true) {
|
||||
if (!tempRoutingConfig.bearerAuthKey && !routingConfig.bearerAuthKey) {
|
||||
const newKey = generateRandomKey()
|
||||
handleBearerAuthKeyChange(newKey)
|
||||
const newKey = generateRandomKey();
|
||||
handleBearerAuthKeyChange(newKey);
|
||||
|
||||
// Update both enableBearerAuth and bearerAuthKey in a single call
|
||||
const success = await updateRoutingConfigBatch({
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: newKey,
|
||||
})
|
||||
});
|
||||
|
||||
if (success) {
|
||||
// Update tempRoutingConfig to reflect the saved values
|
||||
setTempRoutingConfig((prev) => ({
|
||||
...prev,
|
||||
bearerAuthKey: newKey,
|
||||
}))
|
||||
}));
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await updateRoutingConfig(key, value)
|
||||
}
|
||||
await updateRoutingConfig(key, value);
|
||||
};
|
||||
|
||||
const handleBearerAuthKeyChange = (value: string) => {
|
||||
setTempRoutingConfig((prev) => ({
|
||||
...prev,
|
||||
bearerAuthKey: value,
|
||||
}))
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const saveBearerAuthKey = async () => {
|
||||
await updateRoutingConfig('bearerAuthKey', tempRoutingConfig.bearerAuthKey)
|
||||
}
|
||||
await updateRoutingConfig('bearerAuthKey', tempRoutingConfig.bearerAuthKey);
|
||||
};
|
||||
|
||||
const handleInstallConfigChange = (
|
||||
key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl',
|
||||
@@ -232,12 +232,12 @@ const SettingsPage: React.FC = () => {
|
||||
setInstallConfig({
|
||||
...installConfig,
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const saveInstallConfig = async (key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl') => {
|
||||
await updateInstallConfig(key, installConfig[key])
|
||||
}
|
||||
await updateInstallConfig(key, installConfig[key]);
|
||||
};
|
||||
|
||||
const handleSmartRoutingConfigChange = (
|
||||
key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel',
|
||||
@@ -246,14 +246,14 @@ const SettingsPage: React.FC = () => {
|
||||
setTempSmartRoutingConfig({
|
||||
...tempSmartRoutingConfig,
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const saveSmartRoutingConfig = async (
|
||||
key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel',
|
||||
) => {
|
||||
await updateSmartRoutingConfig(key, tempSmartRoutingConfig[key])
|
||||
}
|
||||
await updateSmartRoutingConfig(key, tempSmartRoutingConfig[key]);
|
||||
};
|
||||
|
||||
const handleMCPRouterConfigChange = (
|
||||
key: 'apiKey' | 'referer' | 'title' | 'baseUrl',
|
||||
@@ -262,24 +262,24 @@ const SettingsPage: React.FC = () => {
|
||||
setTempMCPRouterConfig({
|
||||
...tempMCPRouterConfig,
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const saveMCPRouterConfig = async (key: 'apiKey' | 'referer' | 'title' | 'baseUrl') => {
|
||||
await updateMCPRouterConfig(key, tempMCPRouterConfig[key])
|
||||
}
|
||||
await updateMCPRouterConfig(key, tempMCPRouterConfig[key]);
|
||||
};
|
||||
|
||||
type OAuthServerNumberField =
|
||||
| 'accessTokenLifetime'
|
||||
| 'refreshTokenLifetime'
|
||||
| 'authorizationCodeLifetime'
|
||||
| 'authorizationCodeLifetime';
|
||||
|
||||
const handleOAuthServerNumberChange = (key: OAuthServerNumberField, value: string) => {
|
||||
setTempOAuthServerConfig((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}))
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const handleOAuthServerTextChange = (
|
||||
key: 'allowedScopes' | 'dynamicRegistrationAllowedGrantTypes',
|
||||
@@ -288,52 +288,52 @@ const SettingsPage: React.FC = () => {
|
||||
setTempOAuthServerConfig((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}))
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const saveOAuthServerNumberConfig = async (key: OAuthServerNumberField) => {
|
||||
const rawValue = tempOAuthServerConfig[key]
|
||||
const rawValue = tempOAuthServerConfig[key];
|
||||
if (!rawValue || rawValue.trim() === '') {
|
||||
showToast(t('settings.invalidNumberInput') || 'Please enter a valid number', 'error')
|
||||
return
|
||||
showToast(t('settings.invalidNumberInput') || 'Please enter a valid number', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedValue = Number(rawValue)
|
||||
const parsedValue = Number(rawValue);
|
||||
if (Number.isNaN(parsedValue) || parsedValue < 0) {
|
||||
showToast(t('settings.invalidNumberInput') || 'Please enter a valid number', 'error')
|
||||
return
|
||||
showToast(t('settings.invalidNumberInput') || 'Please enter a valid number', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
await updateOAuthServerConfig(key, parsedValue)
|
||||
}
|
||||
await updateOAuthServerConfig(key, parsedValue);
|
||||
};
|
||||
|
||||
const saveOAuthServerAllowedScopes = async () => {
|
||||
const scopes = tempOAuthServerConfig.allowedScopes
|
||||
.split(',')
|
||||
.map((scope) => scope.trim())
|
||||
.filter((scope) => scope.length > 0)
|
||||
.filter((scope) => scope.length > 0);
|
||||
|
||||
await updateOAuthServerConfig('allowedScopes', scopes)
|
||||
}
|
||||
await updateOAuthServerConfig('allowedScopes', scopes);
|
||||
};
|
||||
|
||||
const saveOAuthServerGrantTypes = async () => {
|
||||
const grantTypes = tempOAuthServerConfig.dynamicRegistrationAllowedGrantTypes
|
||||
.split(',')
|
||||
.map((grant) => grant.trim())
|
||||
.filter((grant) => grant.length > 0)
|
||||
.filter((grant) => grant.length > 0);
|
||||
|
||||
await updateOAuthServerConfig('dynamicRegistration', {
|
||||
...oauthServerConfig.dynamicRegistration,
|
||||
allowedGrantTypes: grantTypes,
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleOAuthServerToggle = async (
|
||||
key: 'enabled' | 'requireClientSecret' | 'requireState',
|
||||
value: boolean,
|
||||
) => {
|
||||
await updateOAuthServerConfig(key, value)
|
||||
}
|
||||
await updateOAuthServerConfig(key, value);
|
||||
};
|
||||
|
||||
const handleDynamicRegistrationToggle = async (
|
||||
updates: Partial<typeof oauthServerConfig.dynamicRegistration>,
|
||||
@@ -341,137 +341,137 @@ const SettingsPage: React.FC = () => {
|
||||
await updateOAuthServerConfig('dynamicRegistration', {
|
||||
...oauthServerConfig.dynamicRegistration,
|
||||
...updates,
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const saveNameSeparator = async () => {
|
||||
await updateNameSeparator(tempNameSeparator)
|
||||
}
|
||||
await updateNameSeparator(tempNameSeparator);
|
||||
};
|
||||
|
||||
const handleSmartRoutingEnabledChange = async (value: boolean) => {
|
||||
// If enabling Smart Routing, validate required fields and save any unsaved changes
|
||||
if (value) {
|
||||
const currentDbUrl = tempSmartRoutingConfig.dbUrl || smartRoutingConfig.dbUrl
|
||||
const currentDbUrl = tempSmartRoutingConfig.dbUrl || smartRoutingConfig.dbUrl;
|
||||
const currentOpenaiApiKey =
|
||||
tempSmartRoutingConfig.openaiApiKey || smartRoutingConfig.openaiApiKey
|
||||
tempSmartRoutingConfig.openaiApiKey || smartRoutingConfig.openaiApiKey;
|
||||
|
||||
if (!currentDbUrl || !currentOpenaiApiKey) {
|
||||
const missingFields = []
|
||||
if (!currentDbUrl) missingFields.push(t('settings.dbUrl'))
|
||||
if (!currentOpenaiApiKey) missingFields.push(t('settings.openaiApiKey'))
|
||||
const missingFields = [];
|
||||
if (!currentDbUrl) missingFields.push(t('settings.dbUrl'));
|
||||
if (!currentOpenaiApiKey) missingFields.push(t('settings.openaiApiKey'));
|
||||
|
||||
showToast(
|
||||
t('settings.smartRoutingValidationError', {
|
||||
fields: missingFields.join(', '),
|
||||
}),
|
||||
)
|
||||
return
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare updates object with unsaved changes and enabled status
|
||||
const updates: any = { enabled: value }
|
||||
const updates: any = { enabled: value };
|
||||
|
||||
// Check for unsaved changes and include them in the batch update
|
||||
if (tempSmartRoutingConfig.dbUrl !== smartRoutingConfig.dbUrl) {
|
||||
updates.dbUrl = tempSmartRoutingConfig.dbUrl
|
||||
updates.dbUrl = tempSmartRoutingConfig.dbUrl;
|
||||
}
|
||||
if (tempSmartRoutingConfig.openaiApiBaseUrl !== smartRoutingConfig.openaiApiBaseUrl) {
|
||||
updates.openaiApiBaseUrl = tempSmartRoutingConfig.openaiApiBaseUrl
|
||||
updates.openaiApiBaseUrl = tempSmartRoutingConfig.openaiApiBaseUrl;
|
||||
}
|
||||
if (tempSmartRoutingConfig.openaiApiKey !== smartRoutingConfig.openaiApiKey) {
|
||||
updates.openaiApiKey = tempSmartRoutingConfig.openaiApiKey
|
||||
updates.openaiApiKey = tempSmartRoutingConfig.openaiApiKey;
|
||||
}
|
||||
if (
|
||||
tempSmartRoutingConfig.openaiApiEmbeddingModel !==
|
||||
smartRoutingConfig.openaiApiEmbeddingModel
|
||||
) {
|
||||
updates.openaiApiEmbeddingModel = tempSmartRoutingConfig.openaiApiEmbeddingModel
|
||||
updates.openaiApiEmbeddingModel = tempSmartRoutingConfig.openaiApiEmbeddingModel;
|
||||
}
|
||||
|
||||
// Save all changes in a single batch update
|
||||
await updateSmartRoutingConfigBatch(updates)
|
||||
await updateSmartRoutingConfigBatch(updates);
|
||||
} else {
|
||||
// If disabling, just update the enabled status
|
||||
await updateSmartRoutingConfig('enabled', value)
|
||||
await updateSmartRoutingConfig('enabled', value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordChangeSuccess = () => {
|
||||
setTimeout(() => {
|
||||
navigate('/')
|
||||
}, 2000)
|
||||
}
|
||||
navigate('/');
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const [copiedConfig, setCopiedConfig] = useState(false)
|
||||
const [mcpSettingsJson, setMcpSettingsJson] = useState<string>('')
|
||||
const [copiedConfig, setCopiedConfig] = useState(false);
|
||||
const [mcpSettingsJson, setMcpSettingsJson] = useState<string>('');
|
||||
|
||||
const fetchMcpSettings = async () => {
|
||||
try {
|
||||
const result = await exportMCPSettings()
|
||||
console.log('Fetched MCP settings:', result)
|
||||
const configJson = JSON.stringify(result.data, null, 2)
|
||||
setMcpSettingsJson(configJson)
|
||||
const result = await exportMCPSettings();
|
||||
console.log('Fetched MCP settings:', result);
|
||||
const configJson = JSON.stringify(result.data, null, 2);
|
||||
setMcpSettingsJson(configJson);
|
||||
} catch (error) {
|
||||
console.error('Error fetching MCP settings:', error)
|
||||
showToast(t('settings.exportError') || 'Failed to fetch settings', 'error')
|
||||
console.error('Error fetching MCP settings:', error);
|
||||
showToast(t('settings.exportError') || 'Failed to fetch settings', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (sectionsVisible.exportConfig && !mcpSettingsJson) {
|
||||
fetchMcpSettings()
|
||||
fetchMcpSettings();
|
||||
}
|
||||
}, [sectionsVisible.exportConfig])
|
||||
}, [sectionsVisible.exportConfig]);
|
||||
|
||||
const handleCopyConfig = async () => {
|
||||
if (!mcpSettingsJson) return
|
||||
if (!mcpSettingsJson) return;
|
||||
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(mcpSettingsJson)
|
||||
setCopiedConfig(true)
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
|
||||
setTimeout(() => setCopiedConfig(false), 2000)
|
||||
await navigator.clipboard.writeText(mcpSettingsJson);
|
||||
setCopiedConfig(true);
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success');
|
||||
setTimeout(() => setCopiedConfig(false), 2000);
|
||||
} else {
|
||||
// Fallback for HTTP or unsupported clipboard API
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = mcpSettingsJson
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-9999px'
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = mcpSettingsJson;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
setCopiedConfig(true)
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
|
||||
setTimeout(() => setCopiedConfig(false), 2000)
|
||||
document.execCommand('copy');
|
||||
setCopiedConfig(true);
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success');
|
||||
setTimeout(() => setCopiedConfig(false), 2000);
|
||||
} catch (err) {
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error')
|
||||
console.error('Copy to clipboard failed:', err)
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error');
|
||||
console.error('Copy to clipboard failed:', err);
|
||||
}
|
||||
document.body.removeChild(textArea)
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error copying configuration:', error)
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error')
|
||||
console.error('Error copying configuration:', error);
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadConfig = () => {
|
||||
if (!mcpSettingsJson) return
|
||||
if (!mcpSettingsJson) return;
|
||||
|
||||
const blob = new Blob([mcpSettingsJson], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'mcp_settings.json'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
showToast(t('settings.exportSuccess') || 'Settings exported successfully', 'success')
|
||||
}
|
||||
const blob = new Blob([mcpSettingsJson], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'mcp_settings.json';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
showToast(t('settings.exportSuccess') || 'Settings exported successfully', 'success');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto">
|
||||
@@ -643,9 +643,7 @@ const SettingsPage: React.FC = () => {
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">
|
||||
{t('settings.requireClientSecret')}
|
||||
</h3>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.requireClientSecret')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.requireClientSecretDescription')}
|
||||
</p>
|
||||
@@ -673,9 +671,7 @@ const SettingsPage: React.FC = () => {
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">
|
||||
{t('settings.accessTokenLifetime')}
|
||||
</h3>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.accessTokenLifetime')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.accessTokenLifetimeDescription')}
|
||||
</p>
|
||||
@@ -764,9 +760,7 @@ const SettingsPage: React.FC = () => {
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.allowedScopes')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.allowedScopesDescription')}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{t('settings.allowedScopesDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
@@ -946,142 +940,154 @@ const SettingsPage: React.FC = () => {
|
||||
</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 className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableSessionRebuild')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.enableSessionRebuildDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={enableSessionRebuild}
|
||||
onCheckedChange={(checked) => updateSessionRebuild(checked)}
|
||||
/>
|
||||
</div>
|
||||
<PermissionChecker permissions={PERMISSIONS.SETTINGS_SYSTEM_CONFIG}>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Route Configuration 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('routingConfig')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('pages.settings.routeConfig')}</h2>
|
||||
<span className="text-gray-500">{sectionsVisible.routingConfig ? '▼' : '►'}</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.routingConfig && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableBearerAuth')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.enableBearerAuthDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableBearerAuth}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableBearerAuth', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{routingConfig.enableBearerAuth && (
|
||||
{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.bearerAuthKey')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.bearerAuthKeyDescription')}</p>
|
||||
<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={tempRoutingConfig.bearerAuthKey}
|
||||
onChange={(e) => handleBearerAuthKeyChange(e.target.value)}
|
||||
placeholder={t('settings.bearerAuthKeyPlaceholder')}
|
||||
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 || !routingConfig.enableBearerAuth}
|
||||
disabled={loading}
|
||||
maxLength={5}
|
||||
/>
|
||||
<button
|
||||
onClick={saveBearerAuthKey}
|
||||
disabled={loading || !routingConfig.enableBearerAuth}
|
||||
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 className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableGlobalRoute')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.enableGlobalRouteDescription')}
|
||||
</p>
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">
|
||||
{t('settings.enableSessionRebuild')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.enableSessionRebuildDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={enableSessionRebuild}
|
||||
onCheckedChange={(checked) => updateSessionRebuild(checked)}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableGlobalRoute}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableGlobalRoute', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PermissionChecker>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableGroupNameRoute')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.enableGroupNameRouteDescription')}
|
||||
</p>
|
||||
{/* Route Configuration Settings */}
|
||||
<PermissionChecker permissions={PERMISSIONS.SETTINGS_ROUTE_CONFIG}>
|
||||
<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('routingConfig')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('pages.settings.routeConfig')}</h2>
|
||||
<span className="text-gray-500">{sectionsVisible.routingConfig ? '▼' : '►'}</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.routingConfig && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableBearerAuth')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.enableBearerAuthDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableBearerAuth}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableBearerAuth', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{routingConfig.enableBearerAuth && (
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.bearerAuthKey')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.bearerAuthKeyDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempRoutingConfig.bearerAuthKey}
|
||||
onChange={(e) => handleBearerAuthKeyChange(e.target.value)}
|
||||
placeholder={t('settings.bearerAuthKeyPlaceholder')}
|
||||
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 || !routingConfig.enableBearerAuth}
|
||||
/>
|
||||
<button
|
||||
onClick={saveBearerAuthKey}
|
||||
disabled={loading || !routingConfig.enableBearerAuth}
|
||||
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 className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableGlobalRoute')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.enableGlobalRouteDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableGlobalRoute}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableGlobalRoute', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">
|
||||
{t('settings.enableGroupNameRoute')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.enableGroupNameRouteDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableGroupNameRoute}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableGroupNameRoute', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableGroupNameRoute}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableGroupNameRoute', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PermissionChecker permissions={PERMISSIONS.SETTINGS_SKIP_AUTH}>
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.skipAuth')}</h3>
|
||||
@@ -1093,10 +1099,10 @@ const SettingsPage: React.FC = () => {
|
||||
onCheckedChange={(checked) => handleRoutingConfigChange('skipAuth', checked)}
|
||||
/>
|
||||
</div>
|
||||
</PermissionChecker>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PermissionChecker>
|
||||
|
||||
{/* Installation Configuration Settings */}
|
||||
<PermissionChecker permissions={PERMISSIONS.SETTINGS_INSTALL_CONFIG}>
|
||||
@@ -1188,7 +1194,10 @@ const SettingsPage: React.FC = () => {
|
||||
</PermissionChecker>
|
||||
|
||||
{/* Change Password */}
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card" data-section="password">
|
||||
<div
|
||||
className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card"
|
||||
data-section="password"
|
||||
>
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('password')}
|
||||
@@ -1258,7 +1267,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</PermissionChecker>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPage
|
||||
export default SettingsPage;
|
||||
|
||||
@@ -114,6 +114,8 @@ export interface ServerConfig {
|
||||
env?: Record<string, string>;
|
||||
headers?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
enableKeepAlive?: boolean; // Enable keep-alive for this server (requires global enable as well)
|
||||
keepAliveInterval?: number; // Keep-alive ping interval in milliseconds (default: 60000ms)
|
||||
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?: {
|
||||
@@ -250,6 +252,10 @@ export interface ServerFormData {
|
||||
resetTimeoutOnProgress?: boolean;
|
||||
maxTotalTimeout?: number;
|
||||
};
|
||||
keepAlive?: {
|
||||
enabled?: boolean;
|
||||
interval?: number;
|
||||
};
|
||||
oauth?: {
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
google-site-verification: googled76ca578b6543fbc.html
|
||||
@@ -123,6 +123,11 @@
|
||||
"maxTotalTimeoutDescription": "Maximum total timeout for requests sent to the MCP server (ms) (Use with progress notifications)",
|
||||
"resetTimeoutOnProgress": "Reset Timeout on Progress",
|
||||
"resetTimeoutOnProgressDescription": "Reset timeout on progress notifications",
|
||||
"keepAlive": "Keep-Alive Configuration",
|
||||
"enableKeepAlive": "Enable Keep-Alive",
|
||||
"keepAliveDescription": "Send periodic ping requests to maintain the connection. Useful for long-running connections that may timeout.",
|
||||
"keepAliveInterval": "Interval (ms)",
|
||||
"keepAliveIntervalDescription": "Time between keep-alive pings in milliseconds (default: 60000ms = 1 minute)",
|
||||
"remove": "Remove",
|
||||
"toggleError": "Failed to toggle server {{serverName}}",
|
||||
"alreadyExists": "Server {{serverName}} already exists",
|
||||
@@ -787,4 +792,4 @@
|
||||
"internalErrorMessage": "An unexpected error occurred while processing the OAuth callback.",
|
||||
"closeWindow": "Close Window"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,11 @@
|
||||
"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",
|
||||
"keepAlive": "Configuration du maintien de connexion",
|
||||
"enableKeepAlive": "Activer le maintien de connexion",
|
||||
"keepAliveDescription": "Envoyer des requêtes ping périodiques pour maintenir la connexion. Utile pour les connexions de longue durée qui peuvent expirer.",
|
||||
"keepAliveInterval": "Intervalle (ms)",
|
||||
"keepAliveIntervalDescription": "Temps entre les pings de maintien de connexion en millisecondes (par défaut : 60000ms = 1 minute)",
|
||||
"remove": "Retirer",
|
||||
"toggleError": "Échec du basculement du serveur {{serverName}}",
|
||||
"alreadyExists": "Le serveur {{serverName}} existe déjà",
|
||||
@@ -787,4 +792,4 @@
|
||||
"internalErrorMessage": "Une erreur inattendue s'est produite lors du traitement du callback OAuth.",
|
||||
"closeWindow": "Fermer la fenêtre"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,11 @@
|
||||
"maxTotalTimeoutDescription": "MCP sunucusuna gönderilen istekler için maksimum toplam zaman aşımı (ms) (İlerleme bildirimleriyle kullanın)",
|
||||
"resetTimeoutOnProgress": "İlerlemede Zaman Aşımını Sıfırla",
|
||||
"resetTimeoutOnProgressDescription": "İlerleme bildirimlerinde zaman aşımını sıfırla",
|
||||
"keepAlive": "Bağlantı Canlı Tutma Yapılandırması",
|
||||
"enableKeepAlive": "Bağlantı Canlı Tutmayı Etkinleştir",
|
||||
"keepAliveDescription": "Bağlantıyı korumak için periyodik ping istekleri gönderin. Zaman aşımına uğrayabilecek uzun süreli bağlantılar için yararlıdır.",
|
||||
"keepAliveInterval": "Aralık (ms)",
|
||||
"keepAliveIntervalDescription": "Canlı tutma pingleri arasındaki süre milisaniye cinsinden (varsayılan: 60000ms = 1 dakika)",
|
||||
"remove": "Kaldır",
|
||||
"toggleError": "{{serverName}} sunucusu açılamadı/kapatılamadı",
|
||||
"alreadyExists": "{{serverName}} sunucusu zaten mevcut",
|
||||
@@ -787,4 +792,4 @@
|
||||
"internalErrorMessage": "OAuth geri araması işlenirken beklenmeyen bir hata oluştu.",
|
||||
"closeWindow": "Pencereyi Kapat"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,11 @@
|
||||
"maxTotalTimeoutDescription": "无论是否有进度通知的最大总超时时间(毫秒)",
|
||||
"resetTimeoutOnProgress": "收到进度通知时重置超时",
|
||||
"resetTimeoutOnProgressDescription": "适用于发送周期性进度更新的长时间运行操作",
|
||||
"keepAlive": "保活配置",
|
||||
"enableKeepAlive": "启用保活",
|
||||
"keepAliveDescription": "定期发送 ping 请求以维持连接。适用于可能超时的长期连接。",
|
||||
"keepAliveInterval": "间隔时间(毫秒)",
|
||||
"keepAliveIntervalDescription": "保活 ping 的时间间隔(默认:60000毫秒 = 1分钟)",
|
||||
"remove": "移除",
|
||||
"toggleError": "切换服务器 {{serverName}} 状态失败",
|
||||
"alreadyExists": "服务器 {{serverName}} 已经存在",
|
||||
@@ -789,4 +794,4 @@
|
||||
"internalErrorMessage": "处理 OAuth 回调时发生意外错误。",
|
||||
"closeWindow": "关闭窗口"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,11 @@ import {
|
||||
SystemConfigDao,
|
||||
UserConfigDao,
|
||||
ServerConfigWithName,
|
||||
UserDaoImpl,
|
||||
ServerDaoImpl,
|
||||
GroupDaoImpl,
|
||||
SystemConfigDaoImpl,
|
||||
UserConfigDaoImpl,
|
||||
getUserDao,
|
||||
getServerDao,
|
||||
getGroupDao,
|
||||
getSystemConfigDao,
|
||||
getUserConfigDao,
|
||||
} from '../dao/index.js';
|
||||
|
||||
/**
|
||||
@@ -252,14 +252,14 @@ export class DaoConfigService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a DaoConfigService with default DAO implementations
|
||||
* Create a DaoConfigService with DAO implementations from factory
|
||||
*/
|
||||
export function createDaoConfigService(): DaoConfigService {
|
||||
return new DaoConfigService(
|
||||
new UserDaoImpl(),
|
||||
new ServerDaoImpl(),
|
||||
new GroupDaoImpl(),
|
||||
new SystemConfigDaoImpl(),
|
||||
new UserConfigDaoImpl(),
|
||||
getUserDao(),
|
||||
getServerDao(),
|
||||
getGroupDao(),
|
||||
getSystemConfigDao(),
|
||||
getUserConfigDao(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export const login = async (req: Request, res: Response): Promise<void> => {
|
||||
|
||||
try {
|
||||
// Find user by username
|
||||
const user = findUserByUsername(username);
|
||||
const user = await findUserByUsername(username);
|
||||
|
||||
if (!user) {
|
||||
res.status(401).json({
|
||||
@@ -192,7 +192,7 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
|
||||
}
|
||||
|
||||
// Find user by username
|
||||
const user = findUserByUsername(username);
|
||||
const user = await findUserByUsername(username);
|
||||
|
||||
if (!user) {
|
||||
res.status(404).json({ success: false, message: 'User not found' });
|
||||
|
||||
@@ -15,9 +15,9 @@ import {
|
||||
} from '../services/groupService.js';
|
||||
|
||||
// Get all groups
|
||||
export const getGroups = (_: Request, res: Response): void => {
|
||||
export const getGroups = async (_: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const groups = getAllGroups();
|
||||
const groups = await getAllGroups();
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: groups,
|
||||
@@ -32,7 +32,7 @@ export const getGroups = (_: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Get a specific group by ID
|
||||
export const getGroup = (req: Request, res: Response): void => {
|
||||
export const getGroup = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
if (!id) {
|
||||
@@ -43,7 +43,7 @@ export const getGroup = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const group = getGroupByIdOrName(id);
|
||||
const group = await getGroupByIdOrName(id);
|
||||
if (!group) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -66,7 +66,7 @@ export const getGroup = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Create a new group
|
||||
export const createNewGroup = (req: Request, res: Response): void => {
|
||||
export const createNewGroup = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { name, description, servers } = req.body;
|
||||
if (!name) {
|
||||
@@ -83,7 +83,7 @@ export const createNewGroup = (req: Request, res: Response): void => {
|
||||
const currentUser = (req as any).user;
|
||||
const owner = currentUser?.username || 'admin';
|
||||
|
||||
const newGroup = createGroup(name, description, serverList, owner);
|
||||
const newGroup = await createGroup(name, description, serverList, owner);
|
||||
if (!newGroup) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -107,7 +107,7 @@ export const createNewGroup = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Update an existing group
|
||||
export const updateExistingGroup = (req: Request, res: Response): void => {
|
||||
export const updateExistingGroup = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, description, servers } = req.body;
|
||||
@@ -133,7 +133,7 @@ export const updateExistingGroup = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedGroup = updateGroup(id, updateData);
|
||||
const updatedGroup = await updateGroup(id, updateData);
|
||||
if (!updatedGroup) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -157,7 +157,7 @@ export const updateExistingGroup = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Update servers in a group (batch update) - supports both string[] and server config format
|
||||
export const updateGroupServersBatch = (req: Request, res: Response): void => {
|
||||
export const updateGroupServersBatch = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { servers } = req.body;
|
||||
@@ -203,7 +203,7 @@ export const updateGroupServersBatch = (req: Request, res: Response): void => {
|
||||
}
|
||||
}
|
||||
|
||||
const updatedGroup = updateGroupServers(id, servers);
|
||||
const updatedGroup = await updateGroupServers(id, servers);
|
||||
if (!updatedGroup) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -227,7 +227,7 @@ export const updateGroupServersBatch = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Delete a group
|
||||
export const deleteExistingGroup = (req: Request, res: Response): void => {
|
||||
export const deleteExistingGroup = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
if (!id) {
|
||||
@@ -238,7 +238,7 @@ export const deleteExistingGroup = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const success = deleteGroup(id);
|
||||
const success = await deleteGroup(id);
|
||||
if (!success) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -260,7 +260,7 @@ export const deleteExistingGroup = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Add server to a group
|
||||
export const addServerToExistingGroup = (req: Request, res: Response): void => {
|
||||
export const addServerToExistingGroup = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { serverName } = req.body;
|
||||
@@ -280,7 +280,7 @@ export const addServerToExistingGroup = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedGroup = addServerToGroup(id, serverName);
|
||||
const updatedGroup = await addServerToGroup(id, serverName);
|
||||
if (!updatedGroup) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -304,7 +304,7 @@ export const addServerToExistingGroup = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Remove server from a group
|
||||
export const removeServerFromExistingGroup = (req: Request, res: Response): void => {
|
||||
export const removeServerFromExistingGroup = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id, serverName } = req.params;
|
||||
if (!id || !serverName) {
|
||||
@@ -315,7 +315,7 @@ export const removeServerFromExistingGroup = (req: Request, res: Response): void
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedGroup = removeServerFromGroup(id, serverName);
|
||||
const updatedGroup = await removeServerFromGroup(id, serverName);
|
||||
if (!updatedGroup) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -339,7 +339,7 @@ export const removeServerFromExistingGroup = (req: Request, res: Response): void
|
||||
};
|
||||
|
||||
// Get servers in a group
|
||||
export const getGroupServers = (req: Request, res: Response): void => {
|
||||
export const getGroupServers = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
if (!id) {
|
||||
@@ -350,7 +350,7 @@ export const getGroupServers = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const group = getGroupByIdOrName(id);
|
||||
const group = await getGroupByIdOrName(id);
|
||||
if (!group) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -373,7 +373,7 @@ export const getGroupServers = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Get server configurations in a group (including tool selections)
|
||||
export const getGroupServerConfigs = (req: Request, res: Response): void => {
|
||||
export const getGroupServerConfigs = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
if (!id) {
|
||||
@@ -384,7 +384,7 @@ export const getGroupServerConfigs = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const serverConfigs = getServerConfigsInGroup(id);
|
||||
const serverConfigs = await getServerConfigsInGroup(id);
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: serverConfigs,
|
||||
@@ -399,7 +399,7 @@ export const getGroupServerConfigs = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Get specific server configuration in a group
|
||||
export const getGroupServerConfig = (req: Request, res: Response): void => {
|
||||
export const getGroupServerConfig = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id, serverName } = req.params;
|
||||
if (!id || !serverName) {
|
||||
@@ -410,7 +410,7 @@ export const getGroupServerConfig = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const serverConfig = getServerConfigInGroup(id, serverName);
|
||||
const serverConfig = await getServerConfigInGroup(id, serverName);
|
||||
if (!serverConfig) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -433,7 +433,7 @@ export const getGroupServerConfig = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Update tools for a specific server in a group
|
||||
export const updateGroupServerTools = (req: Request, res: Response): void => {
|
||||
export const updateGroupServerTools = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id, serverName } = req.params;
|
||||
const { tools } = req.body;
|
||||
@@ -458,7 +458,7 @@ export const updateGroupServerTools = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedGroup = updateServerToolsInGroup(id, serverName, tools);
|
||||
const updatedGroup = await updateServerToolsInGroup(id, serverName, tools);
|
||||
if (!updatedGroup) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
|
||||
@@ -208,7 +208,7 @@ export const getGroupOpenAPISpec = async (req: Request, res: Response): Promise<
|
||||
const { name } = req.params;
|
||||
|
||||
// Check if group exists
|
||||
const group = getGroupByIdOrName(name);
|
||||
const group = await getGroupByIdOrName(name);
|
||||
if (!group) {
|
||||
getServerOpenAPISpec(req, res);
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ApiResponse, AddServerRequest } from '../types/index.js';
|
||||
import { ApiResponse, AddServerRequest, McpSettings } from '../types/index.js';
|
||||
import {
|
||||
getServersInfo,
|
||||
addServer,
|
||||
@@ -13,6 +13,7 @@ import { loadSettings, saveSettings } from '../config/index.js';
|
||||
import { syncAllServerToolsEmbeddings } from '../services/vectorSearchService.js';
|
||||
import { createSafeJSON } from '../utils/serialization.js';
|
||||
import { cloneDefaultOAuthServerConfig } from '../constants/oauthServerDefaults.js';
|
||||
import { getServerDao, getGroupDao, getSystemConfigDao } from '../dao/DaoFactory.js';
|
||||
|
||||
export const getAllServers = async (_: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
@@ -31,15 +32,45 @@ export const getAllServers = async (_: Request, res: Response): Promise<void> =>
|
||||
}
|
||||
};
|
||||
|
||||
export const getAllSettings = (_: Request, res: Response): void => {
|
||||
export const getAllSettings = async (_: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
// Get base settings from file (for OAuth clients, tokens, users, etc.)
|
||||
const fileSettings = loadSettings();
|
||||
|
||||
// Get servers from DAO (supports both file and database modes)
|
||||
const serverDao = getServerDao();
|
||||
const servers = await serverDao.findAll();
|
||||
|
||||
// Convert servers array to mcpServers map format
|
||||
const mcpServers: McpSettings['mcpServers'] = {};
|
||||
for (const server of servers) {
|
||||
const { name, ...config } = server;
|
||||
mcpServers[name] = config;
|
||||
}
|
||||
|
||||
// Get groups from DAO
|
||||
const groupDao = getGroupDao();
|
||||
const groups = await groupDao.findAll();
|
||||
|
||||
// Get system config from DAO
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
|
||||
// Merge all data into settings object
|
||||
const settings: McpSettings = {
|
||||
...fileSettings,
|
||||
mcpServers,
|
||||
groups,
|
||||
systemConfig,
|
||||
};
|
||||
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: createSafeJSON(settings),
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
console.error('Failed to get server settings:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get server settings',
|
||||
@@ -303,9 +334,12 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
|
||||
export const getServerConfig = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
const allServers = await getServersInfo();
|
||||
const serverInfo = allServers.find((s) => s.name === name);
|
||||
if (!serverInfo) {
|
||||
|
||||
// Get server configuration from DAO (supports both file and database modes)
|
||||
const serverDao = getServerDao();
|
||||
const serverConfig = await serverDao.findById(name);
|
||||
|
||||
if (!serverConfig) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Server not found',
|
||||
@@ -313,18 +347,26 @@ export const getServerConfig = async (req: Request, res: Response): Promise<void
|
||||
return;
|
||||
}
|
||||
|
||||
// Get runtime info (status, tools) from getServersInfo
|
||||
const allServers = await getServersInfo();
|
||||
const serverInfo = allServers.find((s) => s.name === name);
|
||||
|
||||
// Extract config without the name field
|
||||
const { name: serverName, ...config } = serverConfig;
|
||||
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: {
|
||||
name,
|
||||
status: serverInfo ? serverInfo.status : 'disconnected',
|
||||
tools: serverInfo ? serverInfo.tools : [],
|
||||
config: serverInfo,
|
||||
name: serverName,
|
||||
status: serverInfo?.status || 'disconnected',
|
||||
tools: serverInfo?.tools || [],
|
||||
config,
|
||||
},
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
console.error('Failed to get server configuration:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get server configuration',
|
||||
@@ -507,10 +549,17 @@ export const updateToolDescription = async (req: Request, res: Response): Promis
|
||||
}
|
||||
};
|
||||
|
||||
export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
export const updateSystemConfig = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { routing, install, smartRouting, mcpRouter, nameSeparator, enableSessionRebuild, oauthServer } = req.body;
|
||||
const currentUser = (req as any).user;
|
||||
const {
|
||||
routing,
|
||||
install,
|
||||
smartRouting,
|
||||
mcpRouter,
|
||||
nameSeparator,
|
||||
enableSessionRebuild,
|
||||
oauthServer,
|
||||
} = req.body;
|
||||
|
||||
const hasRoutingUpdate =
|
||||
routing &&
|
||||
@@ -542,8 +591,8 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
typeof mcpRouter.baseUrl === 'string');
|
||||
|
||||
const hasNameSeparatorUpdate = typeof nameSeparator === 'string';
|
||||
|
||||
const hasSessionRebuildUpdate = typeof enableSessionRebuild !== 'boolean';
|
||||
|
||||
const hasSessionRebuildUpdate = typeof enableSessionRebuild === 'boolean';
|
||||
|
||||
const hasOAuthServerUpdate =
|
||||
oauthServer &&
|
||||
@@ -575,9 +624,12 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = loadSettings();
|
||||
if (!settings.systemConfig) {
|
||||
settings.systemConfig = {
|
||||
// Get system config from DAO (supports both file and database modes)
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
let systemConfig = await systemConfigDao.get();
|
||||
|
||||
if (!systemConfig) {
|
||||
systemConfig = {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
@@ -607,8 +659,8 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
};
|
||||
}
|
||||
|
||||
if (!settings.systemConfig.routing) {
|
||||
settings.systemConfig.routing = {
|
||||
if (!systemConfig.routing) {
|
||||
systemConfig.routing = {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
@@ -617,16 +669,16 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
};
|
||||
}
|
||||
|
||||
if (!settings.systemConfig.install) {
|
||||
settings.systemConfig.install = {
|
||||
if (!systemConfig.install) {
|
||||
systemConfig.install = {
|
||||
pythonIndexUrl: '',
|
||||
npmRegistry: '',
|
||||
baseUrl: 'http://localhost:3000',
|
||||
};
|
||||
}
|
||||
|
||||
if (!settings.systemConfig.smartRouting) {
|
||||
settings.systemConfig.smartRouting = {
|
||||
if (!systemConfig.smartRouting) {
|
||||
systemConfig.smartRouting = {
|
||||
enabled: false,
|
||||
dbUrl: '',
|
||||
openaiApiBaseUrl: '',
|
||||
@@ -635,8 +687,8 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
};
|
||||
}
|
||||
|
||||
if (!settings.systemConfig.mcpRouter) {
|
||||
settings.systemConfig.mcpRouter = {
|
||||
if (!systemConfig.mcpRouter) {
|
||||
systemConfig.mcpRouter = {
|
||||
apiKey: '',
|
||||
referer: 'https://www.mcphubx.com',
|
||||
title: 'MCPHub',
|
||||
@@ -644,18 +696,18 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
};
|
||||
}
|
||||
|
||||
if (!settings.systemConfig.oauthServer) {
|
||||
settings.systemConfig.oauthServer = cloneDefaultOAuthServerConfig();
|
||||
if (!systemConfig.oauthServer) {
|
||||
systemConfig.oauthServer = cloneDefaultOAuthServerConfig();
|
||||
}
|
||||
|
||||
if (!settings.systemConfig.oauthServer.dynamicRegistration) {
|
||||
if (!systemConfig.oauthServer.dynamicRegistration) {
|
||||
const defaultConfig = cloneDefaultOAuthServerConfig();
|
||||
const defaultDynamic = defaultConfig.dynamicRegistration ?? {
|
||||
enabled: false,
|
||||
allowedGrantTypes: [],
|
||||
requiresAuthentication: false,
|
||||
};
|
||||
settings.systemConfig.oauthServer.dynamicRegistration = {
|
||||
systemConfig.oauthServer.dynamicRegistration = {
|
||||
enabled: defaultDynamic.enabled ?? false,
|
||||
allowedGrantTypes: [
|
||||
...(Array.isArray(defaultDynamic.allowedGrantTypes)
|
||||
@@ -668,50 +720,50 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
|
||||
if (routing) {
|
||||
if (typeof routing.enableGlobalRoute === 'boolean') {
|
||||
settings.systemConfig.routing.enableGlobalRoute = routing.enableGlobalRoute;
|
||||
systemConfig.routing.enableGlobalRoute = routing.enableGlobalRoute;
|
||||
}
|
||||
|
||||
if (typeof routing.enableGroupNameRoute === 'boolean') {
|
||||
settings.systemConfig.routing.enableGroupNameRoute = routing.enableGroupNameRoute;
|
||||
systemConfig.routing.enableGroupNameRoute = routing.enableGroupNameRoute;
|
||||
}
|
||||
|
||||
if (typeof routing.enableBearerAuth === 'boolean') {
|
||||
settings.systemConfig.routing.enableBearerAuth = routing.enableBearerAuth;
|
||||
systemConfig.routing.enableBearerAuth = routing.enableBearerAuth;
|
||||
}
|
||||
|
||||
if (typeof routing.bearerAuthKey === 'string') {
|
||||
settings.systemConfig.routing.bearerAuthKey = routing.bearerAuthKey;
|
||||
systemConfig.routing.bearerAuthKey = routing.bearerAuthKey;
|
||||
}
|
||||
|
||||
if (typeof routing.skipAuth === 'boolean') {
|
||||
settings.systemConfig.routing.skipAuth = routing.skipAuth;
|
||||
systemConfig.routing.skipAuth = routing.skipAuth;
|
||||
}
|
||||
}
|
||||
|
||||
if (install) {
|
||||
if (typeof install.pythonIndexUrl === 'string') {
|
||||
settings.systemConfig.install.pythonIndexUrl = install.pythonIndexUrl;
|
||||
systemConfig.install.pythonIndexUrl = install.pythonIndexUrl;
|
||||
}
|
||||
if (typeof install.npmRegistry === 'string') {
|
||||
settings.systemConfig.install.npmRegistry = install.npmRegistry;
|
||||
systemConfig.install.npmRegistry = install.npmRegistry;
|
||||
}
|
||||
if (typeof install.baseUrl === 'string') {
|
||||
settings.systemConfig.install.baseUrl = install.baseUrl;
|
||||
systemConfig.install.baseUrl = install.baseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// Track smartRouting state and configuration changes
|
||||
const wasSmartRoutingEnabled = settings.systemConfig.smartRouting.enabled || false;
|
||||
const previousSmartRoutingConfig = { ...settings.systemConfig.smartRouting };
|
||||
const wasSmartRoutingEnabled = systemConfig.smartRouting.enabled || false;
|
||||
const previousSmartRoutingConfig = { ...systemConfig.smartRouting };
|
||||
let needsSync = false;
|
||||
|
||||
if (smartRouting) {
|
||||
if (typeof smartRouting.enabled === 'boolean') {
|
||||
// If enabling Smart Routing, validate required fields
|
||||
if (smartRouting.enabled) {
|
||||
const currentDbUrl = smartRouting.dbUrl || settings.systemConfig.smartRouting.dbUrl;
|
||||
const currentDbUrl = smartRouting.dbUrl || systemConfig.smartRouting.dbUrl;
|
||||
const currentOpenaiApiKey =
|
||||
smartRouting.openaiApiKey || settings.systemConfig.smartRouting.openaiApiKey;
|
||||
smartRouting.openaiApiKey || systemConfig.smartRouting.openaiApiKey;
|
||||
|
||||
if (!currentDbUrl || !currentOpenaiApiKey) {
|
||||
const missingFields = [];
|
||||
@@ -725,32 +777,30 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
}
|
||||
settings.systemConfig.smartRouting.enabled = smartRouting.enabled;
|
||||
systemConfig.smartRouting.enabled = smartRouting.enabled;
|
||||
}
|
||||
if (typeof smartRouting.dbUrl === 'string') {
|
||||
settings.systemConfig.smartRouting.dbUrl = smartRouting.dbUrl;
|
||||
systemConfig.smartRouting.dbUrl = smartRouting.dbUrl;
|
||||
}
|
||||
if (typeof smartRouting.openaiApiBaseUrl === 'string') {
|
||||
settings.systemConfig.smartRouting.openaiApiBaseUrl = smartRouting.openaiApiBaseUrl;
|
||||
systemConfig.smartRouting.openaiApiBaseUrl = smartRouting.openaiApiBaseUrl;
|
||||
}
|
||||
if (typeof smartRouting.openaiApiKey === 'string') {
|
||||
settings.systemConfig.smartRouting.openaiApiKey = smartRouting.openaiApiKey;
|
||||
systemConfig.smartRouting.openaiApiKey = smartRouting.openaiApiKey;
|
||||
}
|
||||
if (typeof smartRouting.openaiApiEmbeddingModel === 'string') {
|
||||
settings.systemConfig.smartRouting.openaiApiEmbeddingModel =
|
||||
smartRouting.openaiApiEmbeddingModel;
|
||||
systemConfig.smartRouting.openaiApiEmbeddingModel = smartRouting.openaiApiEmbeddingModel;
|
||||
}
|
||||
|
||||
// Check if we need to sync embeddings
|
||||
const isNowEnabled = settings.systemConfig.smartRouting.enabled || false;
|
||||
const isNowEnabled = systemConfig.smartRouting.enabled || false;
|
||||
const hasConfigChanged =
|
||||
previousSmartRoutingConfig.dbUrl !== settings.systemConfig.smartRouting.dbUrl ||
|
||||
previousSmartRoutingConfig.dbUrl !== systemConfig.smartRouting.dbUrl ||
|
||||
previousSmartRoutingConfig.openaiApiBaseUrl !==
|
||||
settings.systemConfig.smartRouting.openaiApiBaseUrl ||
|
||||
previousSmartRoutingConfig.openaiApiKey !==
|
||||
settings.systemConfig.smartRouting.openaiApiKey ||
|
||||
systemConfig.smartRouting.openaiApiBaseUrl ||
|
||||
previousSmartRoutingConfig.openaiApiKey !== systemConfig.smartRouting.openaiApiKey ||
|
||||
previousSmartRoutingConfig.openaiApiEmbeddingModel !==
|
||||
settings.systemConfig.smartRouting.openaiApiEmbeddingModel;
|
||||
systemConfig.smartRouting.openaiApiEmbeddingModel;
|
||||
|
||||
// Sync if: first time enabling OR smart routing is enabled and any config changed
|
||||
needsSync = (!wasSmartRoutingEnabled && isNowEnabled) || (isNowEnabled && hasConfigChanged);
|
||||
@@ -758,21 +808,21 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
|
||||
if (mcpRouter) {
|
||||
if (typeof mcpRouter.apiKey === 'string') {
|
||||
settings.systemConfig.mcpRouter.apiKey = mcpRouter.apiKey;
|
||||
systemConfig.mcpRouter.apiKey = mcpRouter.apiKey;
|
||||
}
|
||||
if (typeof mcpRouter.referer === 'string') {
|
||||
settings.systemConfig.mcpRouter.referer = mcpRouter.referer;
|
||||
systemConfig.mcpRouter.referer = mcpRouter.referer;
|
||||
}
|
||||
if (typeof mcpRouter.title === 'string') {
|
||||
settings.systemConfig.mcpRouter.title = mcpRouter.title;
|
||||
systemConfig.mcpRouter.title = mcpRouter.title;
|
||||
}
|
||||
if (typeof mcpRouter.baseUrl === 'string') {
|
||||
settings.systemConfig.mcpRouter.baseUrl = mcpRouter.baseUrl;
|
||||
systemConfig.mcpRouter.baseUrl = mcpRouter.baseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
if (oauthServer) {
|
||||
const target = settings.systemConfig.oauthServer;
|
||||
const target = systemConfig.oauthServer;
|
||||
if (typeof oauthServer.enabled === 'boolean') {
|
||||
target.enabled = oauthServer.enabled;
|
||||
}
|
||||
@@ -826,17 +876,19 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
}
|
||||
|
||||
if (typeof nameSeparator === 'string') {
|
||||
settings.systemConfig.nameSeparator = nameSeparator;
|
||||
systemConfig.nameSeparator = nameSeparator;
|
||||
}
|
||||
|
||||
if (typeof enableSessionRebuild === 'boolean') {
|
||||
settings.systemConfig.enableSessionRebuild = enableSessionRebuild;
|
||||
systemConfig.enableSessionRebuild = enableSessionRebuild;
|
||||
}
|
||||
|
||||
if (saveSettings(settings, currentUser)) {
|
||||
// Save using DAO (supports both file and database modes)
|
||||
try {
|
||||
await systemConfigDao.update(systemConfig);
|
||||
res.json({
|
||||
success: true,
|
||||
data: settings.systemConfig,
|
||||
data: systemConfig,
|
||||
message: 'System configuration updated successfully',
|
||||
});
|
||||
|
||||
@@ -848,7 +900,8 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
console.error('Failed to sync server tools embeddings:', error);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
} catch (saveError) {
|
||||
console.error('Failed to save system configuration:', saveError);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to save system configuration',
|
||||
|
||||
@@ -9,13 +9,14 @@ import {
|
||||
getUserCount,
|
||||
getAdminCount,
|
||||
} from '../services/userService.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import { getSystemConfigDao } from '../dao/index.js';
|
||||
import { validatePasswordStrength } from '../utils/passwordValidation.js';
|
||||
|
||||
// Admin permission check middleware function
|
||||
const requireAdmin = (req: Request, res: Response): boolean => {
|
||||
const settings = loadSettings();
|
||||
if (settings.systemConfig?.routing?.skipAuth) {
|
||||
const requireAdmin = async (req: Request, res: Response): Promise<boolean> => {
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
if (systemConfig?.routing?.skipAuth) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -31,11 +32,11 @@ const requireAdmin = (req: Request, res: Response): boolean => {
|
||||
};
|
||||
|
||||
// Get all users (admin only)
|
||||
export const getUsers = (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
export const getUsers = async (req: Request, res: Response): Promise<void> => {
|
||||
if (!(await requireAdmin(req, res))) return;
|
||||
|
||||
try {
|
||||
const users = getAllUsers().map(({ password: _, ...user }) => user); // Remove password from response
|
||||
const users = (await getAllUsers()).map(({ password: _, ...user }) => user); // Remove password from response
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: users,
|
||||
@@ -50,8 +51,8 @@ export const getUsers = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Get a specific user by username (admin only)
|
||||
export const getUser = (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
export const getUser = async (req: Request, res: Response): Promise<void> => {
|
||||
if (!(await requireAdmin(req, res))) return;
|
||||
|
||||
try {
|
||||
const { username } = req.params;
|
||||
@@ -63,7 +64,7 @@ export const getUser = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = getUserByUsername(username);
|
||||
const user = await getUserByUsername(username);
|
||||
if (!user) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -88,7 +89,7 @@ export const getUser = (req: Request, res: Response): void => {
|
||||
|
||||
// Create a new user (admin only)
|
||||
export const createUser = async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!(await requireAdmin(req, res))) return;
|
||||
|
||||
try {
|
||||
const { username, password, isAdmin } = req.body;
|
||||
@@ -138,7 +139,7 @@ export const createUser = async (req: Request, res: Response): Promise<void> =>
|
||||
|
||||
// Update an existing user (admin only)
|
||||
export const updateExistingUser = async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!(await requireAdmin(req, res))) return;
|
||||
|
||||
try {
|
||||
const { username } = req.params;
|
||||
@@ -154,7 +155,7 @@ export const updateExistingUser = async (req: Request, res: Response): Promise<v
|
||||
|
||||
// Check if trying to change admin status
|
||||
if (isAdmin !== undefined) {
|
||||
const currentUser = getUserByUsername(username);
|
||||
const currentUser = await getUserByUsername(username);
|
||||
if (!currentUser) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -164,7 +165,7 @@ export const updateExistingUser = async (req: Request, res: Response): Promise<v
|
||||
}
|
||||
|
||||
// Prevent removing admin status from the last admin
|
||||
if (currentUser.isAdmin && !isAdmin && getAdminCount() === 1) {
|
||||
if (currentUser.isAdmin && !isAdmin && (await getAdminCount()) === 1) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Cannot remove admin status from the last admin user',
|
||||
@@ -222,8 +223,8 @@ export const updateExistingUser = async (req: Request, res: Response): Promise<v
|
||||
};
|
||||
|
||||
// Delete a user (admin only)
|
||||
export const deleteExistingUser = (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
export const deleteExistingUser = async (req: Request, res: Response): Promise<void> => {
|
||||
if (!(await requireAdmin(req, res))) return;
|
||||
|
||||
try {
|
||||
const { username } = req.params;
|
||||
@@ -245,7 +246,7 @@ export const deleteExistingUser = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const success = deleteUser(username);
|
||||
const success = await deleteUser(username);
|
||||
if (!success) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -267,12 +268,12 @@ export const deleteExistingUser = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Get user statistics (admin only)
|
||||
export const getUserStats = (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
export const getUserStats = async (req: Request, res: Response): Promise<void> => {
|
||||
if (!(await requireAdmin(req, res))) return;
|
||||
|
||||
try {
|
||||
const totalUsers = getUserCount();
|
||||
const adminUsers = getAdminCount();
|
||||
const totalUsers = await getUserCount();
|
||||
const adminUsers = await getAdminCount();
|
||||
const regularUsers = totalUsers - adminUsers;
|
||||
|
||||
const response: ApiResponse = {
|
||||
|
||||
@@ -107,6 +107,26 @@ export function getDaoFactory(): DaoFactory {
|
||||
return daoFactory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to database-backed DAOs based on environment variable
|
||||
* This is synchronous and should be called during app initialization
|
||||
*/
|
||||
export function initializeDaoFactory(): void {
|
||||
// If USE_DB is explicitly set, use its value; otherwise, auto-detect based on DB_URL presence
|
||||
const useDatabase =
|
||||
process.env.USE_DB !== undefined ? process.env.USE_DB === 'true' : !!process.env.DB_URL;
|
||||
if (useDatabase) {
|
||||
console.log('Using database-backed DAO implementations');
|
||||
// Dynamic import to avoid circular dependencies
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const DatabaseDaoFactoryModule = require('./DatabaseDaoFactory.js');
|
||||
setDaoFactory(DatabaseDaoFactoryModule.DatabaseDaoFactory.getInstance());
|
||||
} else {
|
||||
console.log('Using file-based DAO implementations');
|
||||
setDaoFactory(JsonFileDaoFactory.getInstance());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience functions to get specific DAOs
|
||||
*/
|
||||
|
||||
79
src/dao/DatabaseDaoFactory.ts
Normal file
79
src/dao/DatabaseDaoFactory.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { DaoFactory, UserDao, ServerDao, GroupDao, SystemConfigDao, UserConfigDao } from './index.js';
|
||||
import { UserDaoDbImpl } from './UserDaoDbImpl.js';
|
||||
import { ServerDaoDbImpl } from './ServerDaoDbImpl.js';
|
||||
import { GroupDaoDbImpl } from './GroupDaoDbImpl.js';
|
||||
import { SystemConfigDaoDbImpl } from './SystemConfigDaoDbImpl.js';
|
||||
import { UserConfigDaoDbImpl } from './UserConfigDaoDbImpl.js';
|
||||
|
||||
/**
|
||||
* Database-backed DAO factory implementation
|
||||
*/
|
||||
export class DatabaseDaoFactory implements DaoFactory {
|
||||
private static instance: DatabaseDaoFactory;
|
||||
|
||||
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(): DatabaseDaoFactory {
|
||||
if (!DatabaseDaoFactory.instance) {
|
||||
DatabaseDaoFactory.instance = new DatabaseDaoFactory();
|
||||
}
|
||||
return DatabaseDaoFactory.instance;
|
||||
}
|
||||
|
||||
private constructor() {
|
||||
// Private constructor for singleton
|
||||
}
|
||||
|
||||
getUserDao(): UserDao {
|
||||
if (!this.userDao) {
|
||||
this.userDao = new UserDaoDbImpl();
|
||||
}
|
||||
return this.userDao!;
|
||||
}
|
||||
|
||||
getServerDao(): ServerDao {
|
||||
if (!this.serverDao) {
|
||||
this.serverDao = new ServerDaoDbImpl();
|
||||
}
|
||||
return this.serverDao!;
|
||||
}
|
||||
|
||||
getGroupDao(): GroupDao {
|
||||
if (!this.groupDao) {
|
||||
this.groupDao = new GroupDaoDbImpl();
|
||||
}
|
||||
return this.groupDao!;
|
||||
}
|
||||
|
||||
getSystemConfigDao(): SystemConfigDao {
|
||||
if (!this.systemConfigDao) {
|
||||
this.systemConfigDao = new SystemConfigDaoDbImpl();
|
||||
}
|
||||
return this.systemConfigDao!;
|
||||
}
|
||||
|
||||
getUserConfigDao(): UserConfigDao {
|
||||
if (!this.userConfigDao) {
|
||||
this.userConfigDao = new UserConfigDaoDbImpl();
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
154
src/dao/GroupDaoDbImpl.ts
Normal file
154
src/dao/GroupDaoDbImpl.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { GroupDao } from './index.js';
|
||||
import { IGroup } from '../types/index.js';
|
||||
import { GroupRepository } from '../db/repositories/GroupRepository.js';
|
||||
|
||||
/**
|
||||
* Database-backed implementation of GroupDao
|
||||
*/
|
||||
export class GroupDaoDbImpl implements GroupDao {
|
||||
private repository: GroupRepository;
|
||||
|
||||
constructor() {
|
||||
this.repository = new GroupRepository();
|
||||
}
|
||||
|
||||
async findAll(): Promise<IGroup[]> {
|
||||
const groups = await this.repository.findAll();
|
||||
return groups.map((g) => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
description: g.description,
|
||||
servers: g.servers as any,
|
||||
owner: g.owner,
|
||||
}));
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<IGroup | null> {
|
||||
const group = await this.repository.findById(id);
|
||||
if (!group) return null;
|
||||
return {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
servers: group.servers as any,
|
||||
owner: group.owner,
|
||||
};
|
||||
}
|
||||
|
||||
async create(entity: Omit<IGroup, 'id'>): Promise<IGroup> {
|
||||
const group = await this.repository.create({
|
||||
name: entity.name,
|
||||
description: entity.description,
|
||||
servers: entity.servers as any,
|
||||
owner: entity.owner,
|
||||
});
|
||||
return {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
servers: group.servers as any,
|
||||
owner: group.owner,
|
||||
};
|
||||
}
|
||||
|
||||
async update(id: string, entity: Partial<IGroup>): Promise<IGroup | null> {
|
||||
const group = await this.repository.update(id, {
|
||||
name: entity.name,
|
||||
description: entity.description,
|
||||
servers: entity.servers as any,
|
||||
owner: entity.owner,
|
||||
});
|
||||
if (!group) return null;
|
||||
return {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
servers: group.servers as any,
|
||||
owner: group.owner,
|
||||
};
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
return await this.repository.delete(id);
|
||||
}
|
||||
|
||||
async exists(id: string): Promise<boolean> {
|
||||
return await this.repository.exists(id);
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
return await this.repository.count();
|
||||
}
|
||||
|
||||
async findByOwner(owner: string): Promise<IGroup[]> {
|
||||
const groups = await this.repository.findByOwner(owner);
|
||||
return groups.map((g) => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
description: g.description,
|
||||
servers: g.servers as any,
|
||||
owner: g.owner,
|
||||
}));
|
||||
}
|
||||
|
||||
async findByServer(serverName: string): Promise<IGroup[]> {
|
||||
const allGroups = await this.repository.findAll();
|
||||
return allGroups
|
||||
.filter((g) =>
|
||||
g.servers.some((s) => (typeof s === 'string' ? s === serverName : s.name === serverName)),
|
||||
)
|
||||
.map((g) => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
description: g.description,
|
||||
servers: g.servers as any,
|
||||
owner: g.owner,
|
||||
}));
|
||||
}
|
||||
|
||||
async addServerToGroup(groupId: string, serverName: string): Promise<boolean> {
|
||||
const group = await this.repository.findById(groupId);
|
||||
if (!group) return false;
|
||||
|
||||
// Check if server already exists
|
||||
const serverExists = group.servers.some((s) =>
|
||||
typeof s === 'string' ? s === serverName : s.name === serverName,
|
||||
);
|
||||
|
||||
if (!serverExists) {
|
||||
group.servers.push(serverName);
|
||||
await this.update(groupId, { servers: group.servers as any });
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async removeServerFromGroup(groupId: string, serverName: string): Promise<boolean> {
|
||||
const group = await this.repository.findById(groupId);
|
||||
if (!group) return false;
|
||||
|
||||
group.servers = group.servers.filter((s) =>
|
||||
typeof s === 'string' ? s !== serverName : s.name !== serverName,
|
||||
) as any;
|
||||
|
||||
await this.update(groupId, { servers: group.servers as any });
|
||||
return true;
|
||||
}
|
||||
|
||||
async updateServers(groupId: string, servers: string[] | IGroup['servers']): Promise<boolean> {
|
||||
const result = await this.update(groupId, { servers: servers as any });
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
async findByName(name: string): Promise<IGroup | null> {
|
||||
const group = await this.repository.findByName(name);
|
||||
if (!group) return null;
|
||||
return {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
servers: group.servers as any,
|
||||
owner: group.owner,
|
||||
};
|
||||
}
|
||||
}
|
||||
151
src/dao/ServerDaoDbImpl.ts
Normal file
151
src/dao/ServerDaoDbImpl.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { ServerDao, ServerConfigWithName } from './index.js';
|
||||
import { ServerRepository } from '../db/repositories/ServerRepository.js';
|
||||
|
||||
/**
|
||||
* Database-backed implementation of ServerDao
|
||||
*/
|
||||
export class ServerDaoDbImpl implements ServerDao {
|
||||
private repository: ServerRepository;
|
||||
|
||||
constructor() {
|
||||
this.repository = new ServerRepository();
|
||||
}
|
||||
|
||||
async findAll(): Promise<ServerConfigWithName[]> {
|
||||
const servers = await this.repository.findAll();
|
||||
return servers.map((s) => this.mapToServerConfig(s));
|
||||
}
|
||||
|
||||
async findById(name: string): Promise<ServerConfigWithName | null> {
|
||||
const server = await this.repository.findByName(name);
|
||||
return server ? this.mapToServerConfig(server) : null;
|
||||
}
|
||||
|
||||
async create(entity: ServerConfigWithName): Promise<ServerConfigWithName> {
|
||||
const server = await this.repository.create({
|
||||
name: entity.name,
|
||||
type: entity.type,
|
||||
url: entity.url,
|
||||
command: entity.command,
|
||||
args: entity.args,
|
||||
env: entity.env,
|
||||
headers: entity.headers,
|
||||
enabled: entity.enabled !== undefined ? entity.enabled : true,
|
||||
owner: entity.owner,
|
||||
enableKeepAlive: entity.enableKeepAlive,
|
||||
keepAliveInterval: entity.keepAliveInterval,
|
||||
tools: entity.tools,
|
||||
prompts: entity.prompts,
|
||||
options: entity.options,
|
||||
oauth: entity.oauth,
|
||||
});
|
||||
return this.mapToServerConfig(server);
|
||||
}
|
||||
|
||||
async update(
|
||||
name: string,
|
||||
entity: Partial<ServerConfigWithName>,
|
||||
): Promise<ServerConfigWithName | null> {
|
||||
const server = await this.repository.update(name, {
|
||||
type: entity.type,
|
||||
url: entity.url,
|
||||
command: entity.command,
|
||||
args: entity.args,
|
||||
env: entity.env,
|
||||
headers: entity.headers,
|
||||
enabled: entity.enabled,
|
||||
owner: entity.owner,
|
||||
enableKeepAlive: entity.enableKeepAlive,
|
||||
keepAliveInterval: entity.keepAliveInterval,
|
||||
tools: entity.tools,
|
||||
prompts: entity.prompts,
|
||||
options: entity.options,
|
||||
oauth: entity.oauth,
|
||||
});
|
||||
return server ? this.mapToServerConfig(server) : null;
|
||||
}
|
||||
|
||||
async delete(name: string): Promise<boolean> {
|
||||
return await this.repository.delete(name);
|
||||
}
|
||||
|
||||
async exists(name: string): Promise<boolean> {
|
||||
return await this.repository.exists(name);
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
return await this.repository.count();
|
||||
}
|
||||
|
||||
async findByOwner(owner: string): Promise<ServerConfigWithName[]> {
|
||||
const servers = await this.repository.findByOwner(owner);
|
||||
return servers.map((s) => this.mapToServerConfig(s));
|
||||
}
|
||||
|
||||
async findEnabled(): Promise<ServerConfigWithName[]> {
|
||||
const servers = await this.repository.findEnabled();
|
||||
return servers.map((s) => this.mapToServerConfig(s));
|
||||
}
|
||||
|
||||
async findByType(type: string): Promise<ServerConfigWithName[]> {
|
||||
const allServers = await this.repository.findAll();
|
||||
return allServers.filter((s) => s.type === type).map((s) => this.mapToServerConfig(s));
|
||||
}
|
||||
|
||||
async setEnabled(name: string, enabled: boolean): Promise<boolean> {
|
||||
const server = await this.repository.setEnabled(name, enabled);
|
||||
return server !== 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;
|
||||
}
|
||||
|
||||
private mapToServerConfig(server: {
|
||||
name: string;
|
||||
type?: string;
|
||||
url?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
headers?: Record<string, string>;
|
||||
enabled: boolean;
|
||||
owner?: string;
|
||||
enableKeepAlive?: boolean;
|
||||
keepAliveInterval?: number;
|
||||
tools?: Record<string, { enabled: boolean; description?: string }>;
|
||||
prompts?: Record<string, { enabled: boolean; description?: string }>;
|
||||
options?: Record<string, any>;
|
||||
oauth?: Record<string, any>;
|
||||
}): ServerConfigWithName {
|
||||
return {
|
||||
name: server.name,
|
||||
type: server.type as 'stdio' | 'sse' | 'streamable-http' | 'openapi' | undefined,
|
||||
url: server.url,
|
||||
command: server.command,
|
||||
args: server.args,
|
||||
env: server.env,
|
||||
headers: server.headers,
|
||||
enabled: server.enabled,
|
||||
owner: server.owner,
|
||||
enableKeepAlive: server.enableKeepAlive,
|
||||
keepAliveInterval: server.keepAliveInterval,
|
||||
tools: server.tools,
|
||||
prompts: server.prompts,
|
||||
options: server.options,
|
||||
oauth: server.oauth,
|
||||
};
|
||||
}
|
||||
}
|
||||
68
src/dao/SystemConfigDaoDbImpl.ts
Normal file
68
src/dao/SystemConfigDaoDbImpl.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { SystemConfigDao } from './index.js';
|
||||
import { SystemConfig } from '../types/index.js';
|
||||
import { SystemConfigRepository } from '../db/repositories/SystemConfigRepository.js';
|
||||
|
||||
/**
|
||||
* Database-backed implementation of SystemConfigDao
|
||||
*/
|
||||
export class SystemConfigDaoDbImpl implements SystemConfigDao {
|
||||
private repository: SystemConfigRepository;
|
||||
|
||||
constructor() {
|
||||
this.repository = new SystemConfigRepository();
|
||||
}
|
||||
|
||||
async get(): Promise<SystemConfig> {
|
||||
const config = await this.repository.get();
|
||||
return {
|
||||
routing: config.routing as any,
|
||||
install: config.install as any,
|
||||
smartRouting: config.smartRouting as any,
|
||||
mcpRouter: config.mcpRouter as any,
|
||||
nameSeparator: config.nameSeparator,
|
||||
oauth: config.oauth as any,
|
||||
oauthServer: config.oauthServer as any,
|
||||
enableSessionRebuild: config.enableSessionRebuild,
|
||||
};
|
||||
}
|
||||
|
||||
async update(config: Partial<SystemConfig>): Promise<SystemConfig> {
|
||||
const updated = await this.repository.update(config as any);
|
||||
return {
|
||||
routing: updated.routing as any,
|
||||
install: updated.install as any,
|
||||
smartRouting: updated.smartRouting as any,
|
||||
mcpRouter: updated.mcpRouter as any,
|
||||
nameSeparator: updated.nameSeparator,
|
||||
oauth: updated.oauth as any,
|
||||
oauthServer: updated.oauthServer as any,
|
||||
enableSessionRebuild: updated.enableSessionRebuild,
|
||||
};
|
||||
}
|
||||
|
||||
async reset(): Promise<SystemConfig> {
|
||||
const config = await this.repository.reset();
|
||||
return {
|
||||
routing: config.routing as any,
|
||||
install: config.install as any,
|
||||
smartRouting: config.smartRouting as any,
|
||||
mcpRouter: config.mcpRouter as any,
|
||||
nameSeparator: config.nameSeparator,
|
||||
oauth: config.oauth as any,
|
||||
oauthServer: config.oauthServer as any,
|
||||
enableSessionRebuild: config.enableSessionRebuild,
|
||||
};
|
||||
}
|
||||
|
||||
async getSection<K extends keyof SystemConfig>(section: K): Promise<SystemConfig[K]> {
|
||||
return (await this.repository.getSection(section)) as any;
|
||||
}
|
||||
|
||||
async updateSection<K extends keyof SystemConfig>(
|
||||
section: K,
|
||||
value: SystemConfig[K],
|
||||
): Promise<boolean> {
|
||||
await this.repository.updateSection(section, value as any);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
79
src/dao/UserConfigDaoDbImpl.ts
Normal file
79
src/dao/UserConfigDaoDbImpl.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { UserConfigDao } from './index.js';
|
||||
import { UserConfig } from '../types/index.js';
|
||||
import { UserConfigRepository } from '../db/repositories/UserConfigRepository.js';
|
||||
|
||||
/**
|
||||
* Database-backed implementation of UserConfigDao
|
||||
*/
|
||||
export class UserConfigDaoDbImpl implements UserConfigDao {
|
||||
private repository: UserConfigRepository;
|
||||
|
||||
constructor() {
|
||||
this.repository = new UserConfigRepository();
|
||||
}
|
||||
|
||||
async getAll(): Promise<Record<string, UserConfig>> {
|
||||
const configs = await this.repository.getAll();
|
||||
const result: Record<string, UserConfig> = {};
|
||||
|
||||
for (const [username, config] of Object.entries(configs)) {
|
||||
result[username] = {
|
||||
routing: config.routing,
|
||||
...config.additionalConfig,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async get(username: string): Promise<UserConfig> {
|
||||
const config = await this.repository.get(username);
|
||||
if (!config) {
|
||||
return { routing: {} };
|
||||
}
|
||||
return {
|
||||
routing: config.routing,
|
||||
...config.additionalConfig,
|
||||
};
|
||||
}
|
||||
|
||||
async update(username: string, config: Partial<UserConfig>): Promise<UserConfig> {
|
||||
const { routing, ...additionalConfig } = config;
|
||||
const updated = await this.repository.update(username, {
|
||||
routing,
|
||||
additionalConfig,
|
||||
});
|
||||
return {
|
||||
routing: updated.routing,
|
||||
...updated.additionalConfig,
|
||||
};
|
||||
}
|
||||
|
||||
async delete(username: string): Promise<boolean> {
|
||||
return await this.repository.delete(username);
|
||||
}
|
||||
|
||||
async getSection<K extends keyof UserConfig>(username: string, section: K): Promise<UserConfig[K]> {
|
||||
const config = await this.get(username);
|
||||
return config[section];
|
||||
}
|
||||
|
||||
async updateSection<K extends keyof UserConfig>(
|
||||
username: string,
|
||||
section: K,
|
||||
value: UserConfig[K],
|
||||
): Promise<boolean> {
|
||||
await this.update(username, { [section]: value } as Partial<UserConfig>);
|
||||
return true;
|
||||
}
|
||||
|
||||
async exists(username: string): Promise<boolean> {
|
||||
const config = await this.repository.get(username);
|
||||
return config !== null;
|
||||
}
|
||||
|
||||
async reset(username: string): Promise<UserConfig> {
|
||||
await this.repository.delete(username);
|
||||
return { routing: {} };
|
||||
}
|
||||
}
|
||||
108
src/dao/UserDaoDbImpl.ts
Normal file
108
src/dao/UserDaoDbImpl.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import bcrypt from 'bcrypt';
|
||||
import { UserDao } from './index.js';
|
||||
import { IUser } from '../types/index.js';
|
||||
import { UserRepository } from '../db/repositories/UserRepository.js';
|
||||
|
||||
/**
|
||||
* Database-backed implementation of UserDao
|
||||
*/
|
||||
export class UserDaoDbImpl implements UserDao {
|
||||
private repository: UserRepository;
|
||||
|
||||
constructor() {
|
||||
this.repository = new UserRepository();
|
||||
}
|
||||
|
||||
async findAll(): Promise<IUser[]> {
|
||||
const users = await this.repository.findAll();
|
||||
return users.map((u) => ({
|
||||
username: u.username,
|
||||
password: u.password,
|
||||
isAdmin: u.isAdmin,
|
||||
}));
|
||||
}
|
||||
|
||||
async findById(username: string): Promise<IUser | null> {
|
||||
const user = await this.repository.findByUsername(username);
|
||||
if (!user) return null;
|
||||
return {
|
||||
username: user.username,
|
||||
password: user.password,
|
||||
isAdmin: user.isAdmin,
|
||||
};
|
||||
}
|
||||
|
||||
async findByUsername(username: string): Promise<IUser | null> {
|
||||
return await this.findById(username);
|
||||
}
|
||||
|
||||
async create(entity: Omit<IUser, 'id'>): Promise<IUser> {
|
||||
const user = await this.repository.create({
|
||||
username: entity.username,
|
||||
password: entity.password,
|
||||
isAdmin: entity.isAdmin || false,
|
||||
});
|
||||
return {
|
||||
username: user.username,
|
||||
password: user.password,
|
||||
isAdmin: user.isAdmin,
|
||||
};
|
||||
}
|
||||
|
||||
async createWithHashedPassword(
|
||||
username: string,
|
||||
password: string,
|
||||
isAdmin: boolean,
|
||||
): Promise<IUser> {
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
return await this.create({ username, password: hashedPassword, isAdmin });
|
||||
}
|
||||
|
||||
async update(username: string, entity: Partial<IUser>): Promise<IUser | null> {
|
||||
const user = await this.repository.update(username, {
|
||||
password: entity.password,
|
||||
isAdmin: entity.isAdmin,
|
||||
});
|
||||
if (!user) return null;
|
||||
return {
|
||||
username: user.username,
|
||||
password: user.password,
|
||||
isAdmin: user.isAdmin,
|
||||
};
|
||||
}
|
||||
|
||||
async delete(username: string): Promise<boolean> {
|
||||
return await this.repository.delete(username);
|
||||
}
|
||||
|
||||
async exists(username: string): Promise<boolean> {
|
||||
return await this.repository.exists(username);
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
return await this.repository.count();
|
||||
}
|
||||
|
||||
async validateCredentials(username: string, password: string): Promise<boolean> {
|
||||
const user = await this.findByUsername(username);
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
return await bcrypt.compare(password, user.password);
|
||||
}
|
||||
|
||||
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 findAdmins(): Promise<IUser[]> {
|
||||
const users = await this.repository.findAdmins();
|
||||
return users.map((u) => ({
|
||||
username: u.username,
|
||||
password: u.password,
|
||||
isAdmin: u.isAdmin,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -187,7 +187,7 @@ export async function exampleUserConfigOperations() {
|
||||
console.log('All user configs:', Object.keys(allUserConfigs));
|
||||
|
||||
// Get specific section for user
|
||||
const userRoutingConfig = await userConfigDao.getSection('admin', 'routing');
|
||||
const userRoutingConfig = await userConfigDao.getSection('admin', 'routing' as never);
|
||||
console.log('Admin routing config:', userRoutingConfig);
|
||||
|
||||
// Delete user configuration
|
||||
|
||||
@@ -7,5 +7,13 @@ export * from './GroupDao.js';
|
||||
export * from './SystemConfigDao.js';
|
||||
export * from './UserConfigDao.js';
|
||||
|
||||
// Export database implementations
|
||||
export * from './UserDaoDbImpl.js';
|
||||
export * from './ServerDaoDbImpl.js';
|
||||
export * from './GroupDaoDbImpl.js';
|
||||
export * from './SystemConfigDaoDbImpl.js';
|
||||
export * from './UserConfigDaoDbImpl.js';
|
||||
|
||||
// Export the DAO factory and convenience functions
|
||||
export * from './DaoFactory.js';
|
||||
export * from './DatabaseDaoFactory.js';
|
||||
|
||||
36
src/db/entities/Group.ts
Normal file
36
src/db/entities/Group.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* Group entity for database storage
|
||||
*/
|
||||
@Entity({ name: 'groups' })
|
||||
export class Group {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description?: string;
|
||||
|
||||
@Column({ type: 'simple-json' })
|
||||
servers: Array<string | { name: string; tools?: string[] | 'all' }>;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
owner?: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export default Group;
|
||||
69
src/db/entities/Server.ts
Normal file
69
src/db/entities/Server.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* Server configuration entity for database storage
|
||||
*/
|
||||
@Entity({ name: 'servers' })
|
||||
export class Server {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, unique: true })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||
type?: string; // 'stdio', 'sse', 'streamable-http', 'openapi'
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
url?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 500, nullable: true })
|
||||
command?: string;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
args?: string[];
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
env?: Record<string, string>;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
headers?: Record<string, string>;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
enabled: boolean;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
owner?: string;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
enableKeepAlive?: boolean;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
keepAliveInterval?: number;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
tools?: Record<string, { enabled: boolean; description?: string }>;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
prompts?: Record<string, { enabled: boolean; description?: string }>;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
options?: Record<string, any>;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
oauth?: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export default Server;
|
||||
43
src/db/entities/SystemConfig.ts
Normal file
43
src/db/entities/SystemConfig.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Entity, Column, PrimaryColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* System configuration entity for database storage
|
||||
* Using singleton pattern - only one record with id = 'default'
|
||||
*/
|
||||
@Entity({ name: 'system_config' })
|
||||
export class SystemConfig {
|
||||
@PrimaryColumn({ type: 'varchar', length: 50, default: 'default' })
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
routing?: Record<string, any>;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
install?: Record<string, any>;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
smartRouting?: Record<string, any>;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
mcpRouter?: Record<string, any>;
|
||||
|
||||
@Column({ type: 'varchar', length: 10, nullable: true })
|
||||
nameSeparator?: string;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
oauth?: Record<string, any>;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
oauthServer?: Record<string, any>;
|
||||
|
||||
@Column({ type: 'boolean', nullable: true })
|
||||
enableSessionRebuild?: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export default SystemConfig;
|
||||
33
src/db/entities/User.ts
Normal file
33
src/db/entities/User.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* User entity for database storage
|
||||
*/
|
||||
@Entity({ name: 'users' })
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, unique: true })
|
||||
username: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
password: string;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isAdmin: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export default User;
|
||||
33
src/db/entities/UserConfig.ts
Normal file
33
src/db/entities/UserConfig.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* User configuration entity for database storage
|
||||
*/
|
||||
@Entity({ name: 'user_configs' })
|
||||
export class UserConfig {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, unique: true })
|
||||
username: string;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
routing?: Record<string, any>;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
additionalConfig?: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export default UserConfig;
|
||||
@@ -1,7 +1,12 @@
|
||||
import { VectorEmbedding } from './VectorEmbedding.js';
|
||||
import User from './User.js';
|
||||
import Server from './Server.js';
|
||||
import Group from './Group.js';
|
||||
import SystemConfig from './SystemConfig.js';
|
||||
import UserConfig from './UserConfig.js';
|
||||
|
||||
// Export all entities
|
||||
export default [VectorEmbedding];
|
||||
export default [VectorEmbedding, User, Server, Group, SystemConfig, UserConfig];
|
||||
|
||||
// Export individual entities for direct use
|
||||
export { VectorEmbedding };
|
||||
export { VectorEmbedding, User, Server, Group, SystemConfig, UserConfig };
|
||||
|
||||
95
src/db/repositories/GroupRepository.ts
Normal file
95
src/db/repositories/GroupRepository.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Repository } from 'typeorm';
|
||||
import { Group } from '../entities/Group.js';
|
||||
import { getAppDataSource } from '../connection.js';
|
||||
|
||||
/**
|
||||
* Repository for Group entity
|
||||
*/
|
||||
export class GroupRepository {
|
||||
private repository: Repository<Group>;
|
||||
|
||||
constructor() {
|
||||
this.repository = getAppDataSource().getRepository(Group);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all groups
|
||||
*/
|
||||
async findAll(): Promise<Group[]> {
|
||||
return await this.repository.find();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find group by ID
|
||||
*/
|
||||
async findById(id: string): Promise<Group | null> {
|
||||
return await this.repository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find group by name
|
||||
*/
|
||||
async findByName(name: string): Promise<Group | null> {
|
||||
return await this.repository.findOne({ where: { name } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new group
|
||||
*/
|
||||
async create(group: Omit<Group, 'id' | 'createdAt' | 'updatedAt'>): Promise<Group> {
|
||||
const newGroup = this.repository.create(group);
|
||||
return await this.repository.save(newGroup);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing group
|
||||
*/
|
||||
async update(id: string, groupData: Partial<Group>): Promise<Group | null> {
|
||||
const group = await this.findById(id);
|
||||
if (!group) {
|
||||
return null;
|
||||
}
|
||||
const updated = this.repository.merge(group, groupData);
|
||||
return await this.repository.save(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a group
|
||||
*/
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const result = await this.repository.delete({ id });
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if group exists by ID
|
||||
*/
|
||||
async exists(id: string): Promise<boolean> {
|
||||
const count = await this.repository.count({ where: { id } });
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if group exists by name
|
||||
*/
|
||||
async existsByName(name: string): Promise<boolean> {
|
||||
const count = await this.repository.count({ where: { name } });
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total groups
|
||||
*/
|
||||
async count(): Promise<number> {
|
||||
return await this.repository.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find groups by owner
|
||||
*/
|
||||
async findByOwner(owner: string): Promise<Group[]> {
|
||||
return await this.repository.find({ where: { owner } });
|
||||
}
|
||||
}
|
||||
|
||||
export default GroupRepository;
|
||||
94
src/db/repositories/ServerRepository.ts
Normal file
94
src/db/repositories/ServerRepository.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Repository } from 'typeorm';
|
||||
import { Server } from '../entities/Server.js';
|
||||
import { getAppDataSource } from '../connection.js';
|
||||
|
||||
/**
|
||||
* Repository for Server entity
|
||||
*/
|
||||
export class ServerRepository {
|
||||
private repository: Repository<Server>;
|
||||
|
||||
constructor() {
|
||||
this.repository = getAppDataSource().getRepository(Server);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all servers
|
||||
*/
|
||||
async findAll(): Promise<Server[]> {
|
||||
return await this.repository.find();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find server by name
|
||||
*/
|
||||
async findByName(name: string): Promise<Server | null> {
|
||||
return await this.repository.findOne({ where: { name } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new server
|
||||
*/
|
||||
async create(server: Omit<Server, 'id' | 'createdAt' | 'updatedAt'>): Promise<Server> {
|
||||
const newServer = this.repository.create(server);
|
||||
return await this.repository.save(newServer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing server
|
||||
*/
|
||||
async update(name: string, serverData: Partial<Server>): Promise<Server | null> {
|
||||
const server = await this.findByName(name);
|
||||
if (!server) {
|
||||
return null;
|
||||
}
|
||||
const updated = this.repository.merge(server, serverData);
|
||||
return await this.repository.save(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a server
|
||||
*/
|
||||
async delete(name: string): Promise<boolean> {
|
||||
const result = await this.repository.delete({ name });
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if server exists
|
||||
*/
|
||||
async exists(name: string): Promise<boolean> {
|
||||
const count = await this.repository.count({ where: { name } });
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total servers
|
||||
*/
|
||||
async count(): Promise<number> {
|
||||
return await this.repository.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find servers by owner
|
||||
*/
|
||||
async findByOwner(owner: string): Promise<Server[]> {
|
||||
return await this.repository.find({ where: { owner } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find enabled servers
|
||||
*/
|
||||
async findEnabled(): Promise<Server[]> {
|
||||
return await this.repository.find({ where: { enabled: true } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set server enabled status
|
||||
*/
|
||||
async setEnabled(name: string, enabled: boolean): Promise<Server | null> {
|
||||
return await this.update(name, { enabled });
|
||||
}
|
||||
}
|
||||
|
||||
export default ServerRepository;
|
||||
78
src/db/repositories/SystemConfigRepository.ts
Normal file
78
src/db/repositories/SystemConfigRepository.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Repository } from 'typeorm';
|
||||
import { SystemConfig } from '../entities/SystemConfig.js';
|
||||
import { getAppDataSource } from '../connection.js';
|
||||
|
||||
/**
|
||||
* Repository for SystemConfig entity
|
||||
* Uses singleton pattern with id = 'default'
|
||||
*/
|
||||
export class SystemConfigRepository {
|
||||
private repository: Repository<SystemConfig>;
|
||||
private readonly DEFAULT_ID = 'default';
|
||||
|
||||
constructor() {
|
||||
this.repository = getAppDataSource().getRepository(SystemConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get system configuration (singleton)
|
||||
*/
|
||||
async get(): Promise<SystemConfig> {
|
||||
let config = await this.repository.findOne({ where: { id: this.DEFAULT_ID } });
|
||||
|
||||
// Create default if doesn't exist
|
||||
if (!config) {
|
||||
config = this.repository.create({
|
||||
id: this.DEFAULT_ID,
|
||||
routing: {},
|
||||
install: {},
|
||||
smartRouting: {},
|
||||
mcpRouter: {},
|
||||
nameSeparator: '-',
|
||||
oauth: {},
|
||||
oauthServer: {},
|
||||
enableSessionRebuild: false,
|
||||
});
|
||||
config = await this.repository.save(config);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update system configuration
|
||||
*/
|
||||
async update(configData: Partial<SystemConfig>): Promise<SystemConfig> {
|
||||
const config = await this.get();
|
||||
const updated = this.repository.merge(config, configData);
|
||||
return await this.repository.save(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset system configuration to defaults
|
||||
*/
|
||||
async reset(): Promise<SystemConfig> {
|
||||
await this.repository.delete({ id: this.DEFAULT_ID });
|
||||
return await this.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific configuration section
|
||||
*/
|
||||
async getSection<K extends keyof SystemConfig>(section: K): Promise<SystemConfig[K]> {
|
||||
const config = await this.get();
|
||||
return config[section];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a specific configuration section
|
||||
*/
|
||||
async updateSection<K extends keyof SystemConfig>(
|
||||
section: K,
|
||||
value: SystemConfig[K],
|
||||
): Promise<SystemConfig> {
|
||||
return await this.update({ [section]: value } as Partial<SystemConfig>);
|
||||
}
|
||||
}
|
||||
|
||||
export default SystemConfigRepository;
|
||||
84
src/db/repositories/UserConfigRepository.ts
Normal file
84
src/db/repositories/UserConfigRepository.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Repository } from 'typeorm';
|
||||
import { UserConfig } from '../entities/UserConfig.js';
|
||||
import { getAppDataSource } from '../connection.js';
|
||||
|
||||
/**
|
||||
* Repository for UserConfig entity
|
||||
*/
|
||||
export class UserConfigRepository {
|
||||
private repository: Repository<UserConfig>;
|
||||
|
||||
constructor() {
|
||||
this.repository = getAppDataSource().getRepository(UserConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all user configs
|
||||
*/
|
||||
async getAll(): Promise<Record<string, UserConfig>> {
|
||||
const configs = await this.repository.find();
|
||||
const result: Record<string, UserConfig> = {};
|
||||
for (const config of configs) {
|
||||
result[config.username] = config;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user config by username
|
||||
*/
|
||||
async get(username: string): Promise<UserConfig | null> {
|
||||
return await this.repository.findOne({ where: { username } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user config
|
||||
*/
|
||||
async update(username: string, configData: Partial<UserConfig>): Promise<UserConfig> {
|
||||
let config = await this.get(username);
|
||||
|
||||
if (!config) {
|
||||
// Create new config if doesn't exist
|
||||
config = this.repository.create({
|
||||
username,
|
||||
routing: {},
|
||||
additionalConfig: {},
|
||||
...configData,
|
||||
});
|
||||
} else {
|
||||
// Merge with existing config
|
||||
config = this.repository.merge(config, configData);
|
||||
}
|
||||
|
||||
return await this.repository.save(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete user config
|
||||
*/
|
||||
async delete(username: string): Promise<boolean> {
|
||||
const result = await this.repository.delete({ username });
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific configuration section for a user
|
||||
*/
|
||||
async getSection<K extends keyof UserConfig>(username: string, section: K): Promise<UserConfig[K] | null> {
|
||||
const config = await this.get(username);
|
||||
return config ? config[section] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a specific configuration section for a user
|
||||
*/
|
||||
async updateSection<K extends keyof UserConfig>(
|
||||
username: string,
|
||||
section: K,
|
||||
value: UserConfig[K],
|
||||
): Promise<UserConfig> {
|
||||
return await this.update(username, { [section]: value } as Partial<UserConfig>);
|
||||
}
|
||||
}
|
||||
|
||||
export default UserConfigRepository;
|
||||
80
src/db/repositories/UserRepository.ts
Normal file
80
src/db/repositories/UserRepository.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Repository } from 'typeorm';
|
||||
import { User } from '../entities/User.js';
|
||||
import { getAppDataSource } from '../connection.js';
|
||||
|
||||
/**
|
||||
* Repository for User entity
|
||||
*/
|
||||
export class UserRepository {
|
||||
private repository: Repository<User>;
|
||||
|
||||
constructor() {
|
||||
this.repository = getAppDataSource().getRepository(User);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all users
|
||||
*/
|
||||
async findAll(): Promise<User[]> {
|
||||
return await this.repository.find();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user by username
|
||||
*/
|
||||
async findByUsername(username: string): Promise<User | null> {
|
||||
return await this.repository.findOne({ where: { username } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user
|
||||
*/
|
||||
async create(user: Omit<User, 'id' | 'createdAt' | 'updatedAt'>): Promise<User> {
|
||||
const newUser = this.repository.create(user);
|
||||
return await this.repository.save(newUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing user
|
||||
*/
|
||||
async update(username: string, userData: Partial<User>): Promise<User | null> {
|
||||
const user = await this.findByUsername(username);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
const updated = this.repository.merge(user, userData);
|
||||
return await this.repository.save(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user
|
||||
*/
|
||||
async delete(username: string): Promise<boolean> {
|
||||
const result = await this.repository.delete({ username });
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user exists
|
||||
*/
|
||||
async exists(username: string): Promise<boolean> {
|
||||
const count = await this.repository.count({ where: { username } });
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total users
|
||||
*/
|
||||
async count(): Promise<number> {
|
||||
return await this.repository.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all admin users
|
||||
*/
|
||||
async findAdmins(): Promise<User[]> {
|
||||
return await this.repository.find({ where: { isAdmin: true } });
|
||||
}
|
||||
}
|
||||
|
||||
export default UserRepository;
|
||||
@@ -1,4 +1,16 @@
|
||||
import VectorEmbeddingRepository from './VectorEmbeddingRepository.js';
|
||||
import { UserRepository } from './UserRepository.js';
|
||||
import { ServerRepository } from './ServerRepository.js';
|
||||
import { GroupRepository } from './GroupRepository.js';
|
||||
import { SystemConfigRepository } from './SystemConfigRepository.js';
|
||||
import { UserConfigRepository } from './UserConfigRepository.js';
|
||||
|
||||
// Export all repositories
|
||||
export { VectorEmbeddingRepository };
|
||||
export {
|
||||
VectorEmbeddingRepository,
|
||||
UserRepository,
|
||||
ServerRepository,
|
||||
GroupRepository,
|
||||
SystemConfigRepository,
|
||||
UserConfigRepository,
|
||||
};
|
||||
|
||||
14
src/index.ts
14
src/index.ts
@@ -1,10 +1,24 @@
|
||||
import 'reflect-metadata';
|
||||
import AppServer from './server.js';
|
||||
import { initializeDatabaseMode } from './utils/migration.js';
|
||||
|
||||
const appServer = new AppServer();
|
||||
|
||||
async function boot() {
|
||||
try {
|
||||
// Check if database mode is enabled
|
||||
// If USE_DB is explicitly set, use its value; otherwise, auto-detect based on DB_URL presence
|
||||
const useDatabase =
|
||||
process.env.USE_DB !== undefined ? process.env.USE_DB === 'true' : !!process.env.DB_URL;
|
||||
if (useDatabase) {
|
||||
console.log('Database mode enabled, initializing...');
|
||||
const dbInitialized = await initializeDatabaseMode();
|
||||
if (!dbInitialized) {
|
||||
console.error('Failed to initialize database mode');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
await appServer.initialize();
|
||||
appServer.start();
|
||||
} catch (error) {
|
||||
|
||||
@@ -72,8 +72,8 @@ export const auth = async (req: Request, res: Response, next: NextFunction): Pro
|
||||
if (oauthToken && oauthToken.accessToken === accessToken) {
|
||||
// Valid OAuth token - look up user to get admin status
|
||||
const { findUserByUsername } = await import('../models/User.js');
|
||||
const user = findUserByUsername(oauthToken.username);
|
||||
|
||||
const user = await findUserByUsername(oauthToken.username);
|
||||
|
||||
// Set user context with proper admin status
|
||||
(req as any).user = {
|
||||
username: oauthToken.username,
|
||||
|
||||
@@ -76,7 +76,7 @@ export const sseUserContextMiddleware = async (
|
||||
const rawAuthHeader = Array.isArray(req.headers.authorization)
|
||||
? req.headers.authorization[0]
|
||||
: req.headers.authorization;
|
||||
const bearerUser = resolveOAuthUserFromAuthHeader(rawAuthHeader);
|
||||
const bearerUser = await resolveOAuthUserFromAuthHeader(rawAuthHeader);
|
||||
|
||||
if (bearerUser) {
|
||||
userContextService.setCurrentUser(bearerUser);
|
||||
|
||||
@@ -1,58 +1,43 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { IUser } from '../types/index.js';
|
||||
import { loadSettings, saveSettings } from '../config/index.js';
|
||||
import { getUserDao } from '../dao/index.js';
|
||||
|
||||
// Get all users
|
||||
export const getUsers = (): IUser[] => {
|
||||
export const getUsers = async (): Promise<IUser[]> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
return settings.users || [];
|
||||
const userDao = getUserDao();
|
||||
return await userDao.findAll();
|
||||
} catch (error) {
|
||||
console.error('Error reading users from settings:', error);
|
||||
console.error('Error reading users:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Save users to settings
|
||||
const saveUsers = (users: IUser[]): void => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
settings.users = users;
|
||||
saveSettings(settings);
|
||||
} catch (error) {
|
||||
console.error('Error saving users to settings:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Create a new user
|
||||
export const createUser = async (userData: IUser): Promise<IUser | null> => {
|
||||
const users = getUsers();
|
||||
|
||||
// Check if username already exists
|
||||
if (users.some((user) => user.username === userData.username)) {
|
||||
try {
|
||||
const userDao = getUserDao();
|
||||
return await userDao.createWithHashedPassword(
|
||||
userData.username,
|
||||
userData.password,
|
||||
userData.isAdmin,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error creating user:', error);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Hash the password
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
const hashedPassword = await bcrypt.hash(userData.password, salt);
|
||||
|
||||
const newUser = {
|
||||
username: userData.username,
|
||||
password: hashedPassword,
|
||||
isAdmin: userData.isAdmin || false,
|
||||
};
|
||||
|
||||
users.push(newUser);
|
||||
saveUsers(users);
|
||||
|
||||
return newUser;
|
||||
};
|
||||
|
||||
// Find user by username
|
||||
export const findUserByUsername = (username: string): IUser | undefined => {
|
||||
const users = getUsers();
|
||||
return users.find((user) => user.username === username);
|
||||
export const findUserByUsername = async (username: string): Promise<IUser | undefined> => {
|
||||
try {
|
||||
const userDao = getUserDao();
|
||||
const user = await userDao.findByUsername(username);
|
||||
return user || undefined;
|
||||
} catch (error) {
|
||||
console.error('Error finding user:', error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Verify user password
|
||||
@@ -68,34 +53,22 @@ export const updateUserPassword = async (
|
||||
username: string,
|
||||
newPassword: string,
|
||||
): Promise<boolean> => {
|
||||
const users = getUsers();
|
||||
const userIndex = users.findIndex((user) => user.username === username);
|
||||
|
||||
if (userIndex === -1) {
|
||||
try {
|
||||
const userDao = getUserDao();
|
||||
return await userDao.updatePassword(username, newPassword);
|
||||
} catch (error) {
|
||||
console.error('Error updating password:', error);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hash the new password
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
const hashedPassword = await bcrypt.hash(newPassword, salt);
|
||||
|
||||
// Update the user's password
|
||||
users[userIndex].password = hashedPassword;
|
||||
saveUsers(users);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Initialize with default admin user if no users exist
|
||||
export const initializeDefaultUser = async (): Promise<void> => {
|
||||
const users = getUsers();
|
||||
const userDao = getUserDao();
|
||||
const users = await userDao.findAll();
|
||||
|
||||
if (users.length === 0) {
|
||||
await createUser({
|
||||
username: 'admin',
|
||||
password: 'admin123',
|
||||
isAdmin: true,
|
||||
});
|
||||
await userDao.createWithHashedPassword('admin', 'admin123', true);
|
||||
console.log('Default admin user created');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import config from '../config/index.js';
|
||||
import {
|
||||
getAllServers,
|
||||
getAllSettings,
|
||||
getServerConfig,
|
||||
createServer,
|
||||
updateServer,
|
||||
deleteServer,
|
||||
@@ -129,6 +130,7 @@ export const initRoutes = (app: express.Application): void => {
|
||||
|
||||
// API routes protected by auth middleware in middlewares/index.ts
|
||||
router.get('/servers', getAllServers);
|
||||
router.get('/servers/:name', getServerConfig);
|
||||
router.get('/settings', getAllSettings);
|
||||
router.post('/servers', createServer);
|
||||
router.put('/servers/:name', updateServer);
|
||||
|
||||
5
src/scripts/migrate-to-database.ts
Normal file
5
src/scripts/migrate-to-database.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
import 'reflect-metadata';
|
||||
import { runMigrationCli } from '../utils/migration.js';
|
||||
|
||||
runMigrationCli();
|
||||
@@ -61,7 +61,7 @@ export class AppServer {
|
||||
await initializeDefaultUser();
|
||||
|
||||
// Initialize OAuth provider if configured (for proxying upstream MCP OAuth)
|
||||
initOAuthProvider();
|
||||
await initOAuthProvider();
|
||||
const oauthRouter = getOAuthRouter();
|
||||
if (oauthRouter) {
|
||||
// Mount OAuth router at the root level (before other routes)
|
||||
@@ -71,7 +71,7 @@ export class AppServer {
|
||||
}
|
||||
|
||||
// Initialize OAuth authorization server (for MCPHub's own OAuth)
|
||||
initOAuthServer();
|
||||
await initOAuthServer();
|
||||
|
||||
initMiddlewares(this.app);
|
||||
initRoutes(this.app);
|
||||
@@ -103,8 +103,10 @@ export class AppServer {
|
||||
);
|
||||
|
||||
// User-scoped routes with user context middleware
|
||||
this.app.get(`${this.basePath}/:user/sse/:group(.*)?`, sseUserContextMiddleware, (req, res) =>
|
||||
handleSseConnection(req, res),
|
||||
this.app.get(
|
||||
`${this.basePath}/:user/sse/:group(.*)?`,
|
||||
sseUserContextMiddleware,
|
||||
(req, res) => handleSseConnection(req, res),
|
||||
);
|
||||
this.app.post(
|
||||
`${this.basePath}/:user/messages`,
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { DataService } from './dataService.js';
|
||||
import { getDataService } from './services.js';
|
||||
import './services.js';
|
||||
|
||||
describe('DataService', () => {
|
||||
test('should get default implementation and call foo method', async () => {
|
||||
const dataService: DataService = await getDataService();
|
||||
const consoleSpy = jest.spyOn(console, 'log');
|
||||
dataService.foo();
|
||||
expect(consoleSpy).toHaveBeenCalledWith('default implementation');
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,31 +1,69 @@
|
||||
import { IUser, McpSettings } from '../types/index.js';
|
||||
import { UserContextService } from './userContextService.js';
|
||||
import { UserConfig } from '../types/index.js';
|
||||
|
||||
export interface DataService {
|
||||
foo(): void;
|
||||
filterData(data: any[], user?: IUser): any[];
|
||||
filterSettings(settings: McpSettings, user?: IUser): McpSettings;
|
||||
mergeSettings(all: McpSettings, newSettings: McpSettings, user?: IUser): McpSettings;
|
||||
getPermissions(user: IUser): string[];
|
||||
}
|
||||
|
||||
export class DataServiceImpl implements DataService {
|
||||
foo() {
|
||||
console.log('default implementation');
|
||||
export class DataService {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
filterData(data: any[], _user?: IUser): any[] {
|
||||
return data;
|
||||
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 };
|
||||
// TODO: apply userConfig to filter settings as needed
|
||||
// const userConfig = settings.userConfigs?.[currentUser?.username || ''];
|
||||
delete result.userConfigs;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
filterSettings(settings: McpSettings, _user?: IUser): McpSettings {
|
||||
return settings;
|
||||
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.mcpServers = newSettings.mcpServers;
|
||||
result.users = newSettings.users;
|
||||
result.systemConfig = newSettings.systemConfig;
|
||||
result.groups = newSettings.groups;
|
||||
result.oauthClients = newSettings.oauthClients;
|
||||
result.oauthTokens = newSettings.oauthTokens;
|
||||
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
|
||||
? {
|
||||
// TODO: only allow modifying certain fields based on userConfig permissions
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
result.userConfigs[currentUser?.username || ''] = userConfig;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
mergeSettings(all: McpSettings, newSettings: McpSettings, _user?: IUser): McpSettings {
|
||||
return newSettings;
|
||||
}
|
||||
|
||||
getPermissions(_user: IUser): string[] {
|
||||
return ['*'];
|
||||
getPermissions(user: IUser): string[] {
|
||||
if (user && user.isAdmin) {
|
||||
return ['*', 'x'];
|
||||
} else {
|
||||
return [''];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
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.mcpServers = newSettings.mcpServers;
|
||||
result.users = newSettings.users;
|
||||
result.systemConfig = newSettings.systemConfig;
|
||||
result.groups = newSettings.groups;
|
||||
result.oauthClients = newSettings.oauthClients;
|
||||
result.oauthTokens = newSettings.oauthTokens;
|
||||
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 [''];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { IGroup, IGroupServerConfig } from '../types/index.js';
|
||||
import { loadSettings, saveSettings } from '../config/index.js';
|
||||
import { notifyToolChanged } from './mcpService.js';
|
||||
import { getDataService } from './services.js';
|
||||
import { getGroupDao, getServerDao, getSystemConfigDao } from '../dao/index.js';
|
||||
|
||||
// Helper function to normalize group servers configuration
|
||||
const normalizeGroupServers = (servers: string[] | IGroupServerConfig[]): IGroupServerConfig[] => {
|
||||
@@ -17,22 +17,24 @@ const normalizeGroupServers = (servers: string[] | IGroupServerConfig[]): IGroup
|
||||
};
|
||||
|
||||
// Get all groups
|
||||
export const getAllGroups = (): IGroup[] => {
|
||||
const settings = loadSettings();
|
||||
export const getAllGroups = async (): Promise<IGroup[]> => {
|
||||
const groupDao = getGroupDao();
|
||||
const groups = await groupDao.findAll();
|
||||
const dataService = getDataService();
|
||||
return dataService.filterData
|
||||
? dataService.filterData(settings.groups || [])
|
||||
: settings.groups || [];
|
||||
return dataService.filterData ? dataService.filterData(groups) : groups;
|
||||
};
|
||||
|
||||
// Get group by ID or name
|
||||
export const getGroupByIdOrName = (key: string): IGroup | undefined => {
|
||||
const settings = loadSettings();
|
||||
const routingConfig = settings.systemConfig?.routing || {
|
||||
export const getGroupByIdOrName = async (key: string): Promise<IGroup | undefined> => {
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
const routingConfig = systemConfig?.routing || {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
};
|
||||
const groups = getAllGroups();
|
||||
|
||||
const groups = await getAllGroups();
|
||||
return (
|
||||
groups.find(
|
||||
(group) => group.id === key || (group.name === key && routingConfig.enableGroupNameRoute),
|
||||
@@ -41,25 +43,28 @@ export const getGroupByIdOrName = (key: string): IGroup | undefined => {
|
||||
};
|
||||
|
||||
// Create a new group
|
||||
export const createGroup = (
|
||||
export const createGroup = async (
|
||||
name: string,
|
||||
description?: string,
|
||||
servers: string[] | IGroupServerConfig[] = [],
|
||||
owner?: string,
|
||||
): IGroup | null => {
|
||||
): Promise<IGroup | null> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
const groups = settings.groups || [];
|
||||
const groupDao = getGroupDao();
|
||||
const serverDao = getServerDao();
|
||||
|
||||
// Check if group with same name already exists
|
||||
if (groups.some((group) => group.name === name)) {
|
||||
const existingGroup = await groupDao.findByName(name);
|
||||
if (existingGroup) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Normalize servers configuration and filter out non-existent servers
|
||||
const normalizedServers = normalizeGroupServers(servers);
|
||||
const validServers: IGroupServerConfig[] = normalizedServers.filter(
|
||||
(serverConfig) => settings.mcpServers[serverConfig.name],
|
||||
const allServers = await serverDao.findAll();
|
||||
const serverNames = new Set(allServers.map((s) => s.name));
|
||||
const validServers: IGroupServerConfig[] = normalizedServers.filter((serverConfig) =>
|
||||
serverNames.has(serverConfig.name),
|
||||
);
|
||||
|
||||
const newGroup: IGroup = {
|
||||
@@ -70,18 +75,8 @@ export const createGroup = (
|
||||
owner: owner || 'admin',
|
||||
};
|
||||
|
||||
// Initialize groups array if it doesn't exist
|
||||
if (!settings.groups) {
|
||||
settings.groups = [];
|
||||
}
|
||||
|
||||
settings.groups.push(newGroup);
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return newGroup;
|
||||
const createdGroup = await groupDao.create(newGroup);
|
||||
return createdGroup;
|
||||
} catch (error) {
|
||||
console.error('Failed to create group:', error);
|
||||
return null;
|
||||
@@ -89,43 +84,38 @@ export const createGroup = (
|
||||
};
|
||||
|
||||
// Update an existing group
|
||||
export const updateGroup = (id: string, data: Partial<IGroup>): IGroup | null => {
|
||||
export const updateGroup = async (id: string, data: Partial<IGroup>): Promise<IGroup | null> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (!settings.groups) {
|
||||
return null;
|
||||
}
|
||||
const groupDao = getGroupDao();
|
||||
const serverDao = getServerDao();
|
||||
|
||||
const groupIndex = settings.groups.findIndex((group) => group.id === id);
|
||||
if (groupIndex === -1) {
|
||||
const existingGroup = await groupDao.findById(id);
|
||||
if (!existingGroup) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for name uniqueness if name is being updated
|
||||
if (data.name && settings.groups.some((g) => g.name === data.name && g.id !== id)) {
|
||||
return null;
|
||||
if (data.name && data.name !== existingGroup.name) {
|
||||
const groupWithName = await groupDao.findByName(data.name);
|
||||
if (groupWithName) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// If servers array is provided, validate server existence and normalize format
|
||||
if (data.servers) {
|
||||
const normalizedServers = normalizeGroupServers(data.servers);
|
||||
data.servers = normalizedServers.filter(
|
||||
(serverConfig) => settings.mcpServers[serverConfig.name],
|
||||
);
|
||||
const allServers = await serverDao.findAll();
|
||||
const serverNames = new Set(allServers.map((s) => s.name));
|
||||
data.servers = normalizedServers.filter((serverConfig) => serverNames.has(serverConfig.name));
|
||||
}
|
||||
|
||||
const updatedGroup = {
|
||||
...settings.groups[groupIndex],
|
||||
...data,
|
||||
};
|
||||
const updatedGroup = await groupDao.update(id, data);
|
||||
|
||||
settings.groups[groupIndex] = updatedGroup;
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return null;
|
||||
if (updatedGroup) {
|
||||
notifyToolChanged();
|
||||
}
|
||||
|
||||
notifyToolChanged();
|
||||
return updatedGroup;
|
||||
} catch (error) {
|
||||
console.error(`Failed to update group ${id}:`, error);
|
||||
@@ -135,35 +125,34 @@ export const updateGroup = (id: string, data: Partial<IGroup>): IGroup | null =>
|
||||
|
||||
// Update servers in a group (batch update)
|
||||
// Update group servers (maintaining backward compatibility)
|
||||
export const updateGroupServers = (
|
||||
export const updateGroupServers = async (
|
||||
groupId: string,
|
||||
servers: string[] | IGroupServerConfig[],
|
||||
): IGroup | null => {
|
||||
): Promise<IGroup | null> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (!settings.groups) {
|
||||
return null;
|
||||
}
|
||||
const groupDao = getGroupDao();
|
||||
const serverDao = getServerDao();
|
||||
|
||||
const groupIndex = settings.groups.findIndex((group) => group.id === groupId);
|
||||
if (groupIndex === -1) {
|
||||
const existingGroup = await groupDao.findById(groupId);
|
||||
if (!existingGroup) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Normalize and filter out non-existent servers
|
||||
const normalizedServers = normalizeGroupServers(servers);
|
||||
const validServers = normalizedServers.filter(
|
||||
(serverConfig) => settings.mcpServers[serverConfig.name],
|
||||
const allServers = await serverDao.findAll();
|
||||
const serverNames = new Set(allServers.map((s) => s.name));
|
||||
const validServers = normalizedServers.filter((serverConfig) =>
|
||||
serverNames.has(serverConfig.name),
|
||||
);
|
||||
|
||||
settings.groups[groupIndex].servers = validServers;
|
||||
const updatedGroup = await groupDao.update(groupId, { servers: validServers });
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return null;
|
||||
if (updatedGroup) {
|
||||
notifyToolChanged();
|
||||
}
|
||||
|
||||
notifyToolChanged();
|
||||
return settings.groups[groupIndex];
|
||||
return updatedGroup;
|
||||
} catch (error) {
|
||||
console.error(`Failed to update servers for group ${groupId}:`, error);
|
||||
return null;
|
||||
@@ -171,21 +160,10 @@ export const updateGroupServers = (
|
||||
};
|
||||
|
||||
// Delete a group
|
||||
export const deleteGroup = (id: string): boolean => {
|
||||
export const deleteGroup = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (!settings.groups) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const initialLength = settings.groups.length;
|
||||
settings.groups = settings.groups.filter((group) => group.id !== id);
|
||||
|
||||
if (settings.groups.length === initialLength) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return saveSettings(settings);
|
||||
const groupDao = getGroupDao();
|
||||
return await groupDao.delete(id);
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete group ${id}:`, error);
|
||||
return false;
|
||||
@@ -193,34 +171,37 @@ export const deleteGroup = (id: string): boolean => {
|
||||
};
|
||||
|
||||
// Add server to group
|
||||
export const addServerToGroup = (groupId: string, serverName: string): IGroup | null => {
|
||||
export const addServerToGroup = async (
|
||||
groupId: string,
|
||||
serverName: string,
|
||||
): Promise<IGroup | null> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (!settings.groups) {
|
||||
return null;
|
||||
}
|
||||
const groupDao = getGroupDao();
|
||||
const serverDao = getServerDao();
|
||||
|
||||
// Verify server exists
|
||||
if (!settings.mcpServers[serverName]) {
|
||||
const server = await serverDao.findById(serverName);
|
||||
if (!server) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const groupIndex = settings.groups.findIndex((group) => group.id === groupId);
|
||||
if (groupIndex === -1) {
|
||||
const group = await groupDao.findById(groupId);
|
||||
if (!group) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const group = settings.groups[groupIndex];
|
||||
const normalizedServers = normalizeGroupServers(group.servers);
|
||||
|
||||
// Add server to group if not already in it
|
||||
if (!normalizedServers.some((server) => server.name === serverName)) {
|
||||
if (!normalizedServers.some((s) => s.name === serverName)) {
|
||||
normalizedServers.push({ name: serverName, tools: 'all' });
|
||||
group.servers = normalizedServers;
|
||||
const updatedGroup = await groupDao.update(groupId, { servers: normalizedServers });
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return null;
|
||||
if (updatedGroup) {
|
||||
notifyToolChanged();
|
||||
}
|
||||
|
||||
return updatedGroup;
|
||||
}
|
||||
|
||||
notifyToolChanged();
|
||||
@@ -232,27 +213,22 @@ export const addServerToGroup = (groupId: string, serverName: string): IGroup |
|
||||
};
|
||||
|
||||
// Remove server from group
|
||||
export const removeServerFromGroup = (groupId: string, serverName: string): IGroup | null => {
|
||||
export const removeServerFromGroup = async (
|
||||
groupId: string,
|
||||
serverName: string,
|
||||
): Promise<IGroup | null> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (!settings.groups) {
|
||||
const groupDao = getGroupDao();
|
||||
|
||||
const group = await groupDao.findById(groupId);
|
||||
if (!group) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const groupIndex = settings.groups.findIndex((group) => group.id === groupId);
|
||||
if (groupIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const group = settings.groups[groupIndex];
|
||||
const normalizedServers = normalizeGroupServers(group.servers);
|
||||
group.servers = normalizedServers.filter((server) => server.name !== serverName);
|
||||
const filteredServers = normalizedServers.filter((server) => server.name !== serverName);
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return group;
|
||||
return await groupDao.update(groupId, { servers: filteredServers });
|
||||
} catch (error) {
|
||||
console.error(`Failed to remove server ${serverName} from group ${groupId}:`, error);
|
||||
return null;
|
||||
@@ -260,71 +236,69 @@ export const removeServerFromGroup = (groupId: string, serverName: string): IGro
|
||||
};
|
||||
|
||||
// Get all servers in a group
|
||||
export const getServersInGroup = (groupId: string): string[] => {
|
||||
const group = getGroupByIdOrName(groupId);
|
||||
export const getServersInGroup = async (groupId: string): Promise<string[]> => {
|
||||
const group = await getGroupByIdOrName(groupId);
|
||||
if (!group) return [];
|
||||
const normalizedServers = normalizeGroupServers(group.servers);
|
||||
return normalizedServers.map((server) => server.name);
|
||||
};
|
||||
|
||||
// Get server configuration from group (including tool selection)
|
||||
export const getServerConfigInGroup = (
|
||||
export const getServerConfigInGroup = async (
|
||||
groupId: string,
|
||||
serverName: string,
|
||||
): IGroupServerConfig | undefined => {
|
||||
const group = getGroupByIdOrName(groupId);
|
||||
): Promise<IGroupServerConfig | undefined> => {
|
||||
const group = await getGroupByIdOrName(groupId);
|
||||
if (!group) return undefined;
|
||||
const normalizedServers = normalizeGroupServers(group.servers);
|
||||
return normalizedServers.find((server) => server.name === serverName);
|
||||
};
|
||||
|
||||
// Get all server configurations in a group
|
||||
export const getServerConfigsInGroup = (groupId: string): IGroupServerConfig[] => {
|
||||
const group = getGroupByIdOrName(groupId);
|
||||
export const getServerConfigsInGroup = async (groupId: string): Promise<IGroupServerConfig[]> => {
|
||||
const group = await getGroupByIdOrName(groupId);
|
||||
if (!group) return [];
|
||||
return normalizeGroupServers(group.servers);
|
||||
};
|
||||
|
||||
// Update tools selection for a specific server in a group
|
||||
export const updateServerToolsInGroup = (
|
||||
export const updateServerToolsInGroup = async (
|
||||
groupId: string,
|
||||
serverName: string,
|
||||
tools: string[] | 'all',
|
||||
): IGroup | null => {
|
||||
): Promise<IGroup | null> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (!settings.groups) {
|
||||
return null;
|
||||
}
|
||||
const groupDao = getGroupDao();
|
||||
const serverDao = getServerDao();
|
||||
|
||||
const groupIndex = settings.groups.findIndex((group) => group.id === groupId);
|
||||
if (groupIndex === -1) {
|
||||
const group = await groupDao.findById(groupId);
|
||||
if (!group) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify server exists
|
||||
if (!settings.mcpServers[serverName]) {
|
||||
const server = await serverDao.findById(serverName);
|
||||
if (!server) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const group = settings.groups[groupIndex];
|
||||
const normalizedServers = normalizeGroupServers(group.servers);
|
||||
|
||||
const serverIndex = normalizedServers.findIndex((server) => server.name === serverName);
|
||||
const serverIndex = normalizedServers.findIndex((s) => s.name === serverName);
|
||||
if (serverIndex === -1) {
|
||||
return null; // Server not in group
|
||||
}
|
||||
|
||||
// Update the tools configuration for the server
|
||||
normalizedServers[serverIndex].tools = tools;
|
||||
group.servers = normalizedServers;
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return null;
|
||||
const updatedGroup = await groupDao.update(groupId, { servers: normalizedServers });
|
||||
|
||||
if (updatedGroup) {
|
||||
notifyToolChanged();
|
||||
}
|
||||
|
||||
notifyToolChanged();
|
||||
return group;
|
||||
return updatedGroup;
|
||||
} catch (error) {
|
||||
console.error(`Failed to update tools for server ${serverName} in group ${groupId}:`, error);
|
||||
return null;
|
||||
|
||||
73
src/services/keepAliveService.ts
Normal file
73
src/services/keepAliveService.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { ServerInfo, ServerConfig } from '../types/index.js';
|
||||
|
||||
export interface KeepAliveOptions {
|
||||
enabled?: boolean;
|
||||
intervalMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up keep-alive ping for MCP client connections (SSE or Streamable HTTP).
|
||||
* Keepalive is controlled per-server via `serverConfig.enableKeepAlive` (default off).
|
||||
*/
|
||||
export const setupClientKeepAlive = async (
|
||||
serverInfo: ServerInfo,
|
||||
serverConfig: ServerConfig,
|
||||
): Promise<void> => {
|
||||
// Only set up keep-alive for SSE or Streamable HTTP client transports
|
||||
const isSSE = serverInfo.transport instanceof SSEClientTransport;
|
||||
const isStreamableHttp = serverInfo.transport instanceof StreamableHTTPClientTransport;
|
||||
if (!isSSE && !isStreamableHttp) {
|
||||
return;
|
||||
}
|
||||
|
||||
const enabled = serverConfig.enableKeepAlive === true;
|
||||
if (!enabled) {
|
||||
// Ensure any previous timer is cleared
|
||||
if (serverInfo.keepAliveIntervalId) {
|
||||
clearInterval(serverInfo.keepAliveIntervalId as NodeJS.Timeout);
|
||||
serverInfo.keepAliveIntervalId = undefined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any existing interval first
|
||||
if (serverInfo.keepAliveIntervalId) {
|
||||
clearInterval(serverInfo.keepAliveIntervalId as NodeJS.Timeout);
|
||||
}
|
||||
|
||||
// Default interval: 60 seconds
|
||||
const interval = serverConfig.keepAliveInterval || 60000;
|
||||
|
||||
serverInfo.keepAliveIntervalId = setInterval(async () => {
|
||||
try {
|
||||
if (serverInfo.client && serverInfo.status === 'connected') {
|
||||
// Use client.ping() if available, otherwise fallback to listTools
|
||||
if (typeof (serverInfo.client as any).ping === 'function') {
|
||||
await (serverInfo.client as any).ping();
|
||||
console.log(`Keep-alive ping successful for server: ${serverInfo.name}`);
|
||||
} else {
|
||||
await serverInfo.client.listTools({ timeout: 5000 }).catch(() => void 0);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Keep-alive ping failed for server ${serverInfo.name}:`, error);
|
||||
}
|
||||
}, interval);
|
||||
|
||||
console.log(
|
||||
`Keep-alive enabled for server ${serverInfo.name} at ${Math.round(interval / 1000)}s interval`,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear keep-alive timer for a server.
|
||||
*/
|
||||
export const clearClientKeepAlive = (serverInfo: ServerInfo): void => {
|
||||
if (serverInfo.keepAliveIntervalId) {
|
||||
clearInterval(serverInfo.keepAliveIntervalId as NodeJS.Timeout);
|
||||
serverInfo.keepAliveIntervalId = undefined;
|
||||
console.log(`Cleared keep-alive interval for server: ${serverInfo.name}`);
|
||||
}
|
||||
};
|
||||
@@ -20,7 +20,7 @@ import type {
|
||||
OAuthTokens,
|
||||
} from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
import { ServerConfig } from '../types/index.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import { getSystemConfigDao } from '../dao/index.js';
|
||||
import {
|
||||
initializeOAuthForServer,
|
||||
getRegisteredClient,
|
||||
@@ -52,15 +52,29 @@ export class MCPHubOAuthProvider implements OAuthClientProvider {
|
||||
private serverConfig: ServerConfig;
|
||||
private _codeVerifier?: string;
|
||||
private _currentState?: string;
|
||||
private _systemInstallBaseUrl?: string;
|
||||
|
||||
constructor(serverName: string, serverConfig: ServerConfig) {
|
||||
constructor(serverName: string, serverConfig: ServerConfig, systemInstallBaseUrl?: string) {
|
||||
this.serverName = serverName;
|
||||
this.serverConfig = serverConfig;
|
||||
this._systemInstallBaseUrl = systemInstallBaseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create an MCPHubOAuthProvider with async config loading
|
||||
*/
|
||||
static async create(
|
||||
serverName: string,
|
||||
serverConfig: ServerConfig,
|
||||
): Promise<MCPHubOAuthProvider> {
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
const systemInstallBaseUrl = systemConfig?.install?.baseUrl;
|
||||
return new MCPHubOAuthProvider(serverName, serverConfig, systemInstallBaseUrl);
|
||||
}
|
||||
|
||||
private getSystemInstallBaseUrl(): string | undefined {
|
||||
const settings = loadSettings();
|
||||
return settings.systemConfig?.install?.baseUrl;
|
||||
return this._systemInstallBaseUrl;
|
||||
}
|
||||
|
||||
private sanitizeRedirectUri(input?: string): string | null {
|
||||
@@ -219,18 +233,9 @@ export class MCPHubOAuthProvider implements OAuthClientProvider {
|
||||
const clientInfo = getRegisteredClient(this.serverName);
|
||||
|
||||
if (!clientInfo) {
|
||||
// Try to use static client configuration from cached serverConfig first
|
||||
let serverConfig = this.serverConfig;
|
||||
|
||||
// If cached config doesn't have clientId, reload from settings
|
||||
if (!serverConfig?.oauth?.clientId) {
|
||||
const storedConfig = loadServerConfig(this.serverName);
|
||||
|
||||
if (storedConfig) {
|
||||
this.serverConfig = storedConfig;
|
||||
serverConfig = storedConfig;
|
||||
}
|
||||
}
|
||||
// Try to use static client configuration from cached serverConfig
|
||||
// Note: we only use cache here since this is a sync method
|
||||
const serverConfig = this.serverConfig;
|
||||
|
||||
// Try to use static client configuration from serverConfig
|
||||
if (serverConfig?.oauth?.clientId) {
|
||||
@@ -288,17 +293,8 @@ export class MCPHubOAuthProvider implements OAuthClientProvider {
|
||||
* Get stored OAuth tokens
|
||||
*/
|
||||
tokens(): OAuthTokens | undefined {
|
||||
// Use cached config first, but reload if needed
|
||||
let serverConfig = this.serverConfig;
|
||||
|
||||
// If cached config doesn't have tokens, try reloading
|
||||
if (!serverConfig?.oauth?.accessToken) {
|
||||
const storedConfig = loadServerConfig(this.serverName);
|
||||
if (storedConfig) {
|
||||
this.serverConfig = storedConfig;
|
||||
serverConfig = storedConfig;
|
||||
}
|
||||
}
|
||||
// Use cached config only (tokens are updated via saveTokens which updates cache)
|
||||
const serverConfig = this.serverConfig;
|
||||
|
||||
if (!serverConfig?.oauth?.accessToken) {
|
||||
return undefined;
|
||||
@@ -441,7 +437,7 @@ export class MCPHubOAuthProvider implements OAuthClientProvider {
|
||||
return this._codeVerifier;
|
||||
}
|
||||
|
||||
const storedConfig = loadServerConfig(this.serverName);
|
||||
const storedConfig = await loadServerConfig(this.serverName);
|
||||
const storedVerifier = storedConfig?.oauth?.pendingAuthorization?.codeVerifier;
|
||||
|
||||
if (storedVerifier) {
|
||||
@@ -458,7 +454,7 @@ export class MCPHubOAuthProvider implements OAuthClientProvider {
|
||||
* This keeps stored configuration in sync and forces a fresh authorization flow.
|
||||
*/
|
||||
async invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier'): Promise<void> {
|
||||
const storedConfig = loadServerConfig(this.serverName);
|
||||
const storedConfig = await loadServerConfig(this.serverName);
|
||||
|
||||
if (!storedConfig?.oauth) {
|
||||
if (scope === 'verifier' || scope === 'all') {
|
||||
@@ -585,8 +581,8 @@ export const createOAuthProvider = async (
|
||||
// Continue anyway - the SDK might be able to handle it
|
||||
}
|
||||
|
||||
// Create and return the provider
|
||||
const provider = new MCPHubOAuthProvider(serverName, serverConfig);
|
||||
// Create and return the provider using the factory method
|
||||
const provider = await MCPHubOAuthProvider.create(serverName, serverConfig);
|
||||
|
||||
console.log(`Created OAuth provider for server: ${serverName}`);
|
||||
return provider;
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
StreamableHTTPClientTransportOptions,
|
||||
} from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { ServerInfo, ServerConfig, Tool } from '../types/index.js';
|
||||
import { loadSettings, expandEnvVars, replaceEnvVars, getNameSeparator } from '../config/index.js';
|
||||
import { expandEnvVars, replaceEnvVars, getNameSeparator } from '../config/index.js';
|
||||
import config from '../config/index.js';
|
||||
import { getGroup } from './sseService.js';
|
||||
import { getServersInGroup, getServerConfigInGroup } from './groupService.js';
|
||||
@@ -23,45 +23,13 @@ import { saveToolsAsVectorEmbeddings, searchToolsByVector } from './vectorSearch
|
||||
import { OpenAPIClient } from '../clients/openapi.js';
|
||||
import { RequestContextService } from './requestContextService.js';
|
||||
import { getDataService } from './services.js';
|
||||
import { getServerDao, ServerConfigWithName } from '../dao/index.js';
|
||||
import { getServerDao, getSystemConfigDao, ServerConfigWithName } from '../dao/index.js';
|
||||
import { initializeAllOAuthClients } from './oauthService.js';
|
||||
import { createOAuthProvider } from './mcpOAuthProvider.js';
|
||||
|
||||
const servers: { [sessionId: string]: Server } = {};
|
||||
|
||||
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
|
||||
if (!(serverInfo.transport instanceof SSEClientTransport)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any existing interval first
|
||||
if (serverInfo.keepAliveIntervalId) {
|
||||
clearInterval(serverInfo.keepAliveIntervalId);
|
||||
}
|
||||
|
||||
// Use configured interval or default to 60 seconds for SSE
|
||||
const interval = serverConfig.keepAliveInterval || 60000;
|
||||
|
||||
serverInfo.keepAliveIntervalId = setInterval(async () => {
|
||||
try {
|
||||
if (serverInfo.client && serverInfo.status === 'connected') {
|
||||
await serverInfo.client.ping();
|
||||
console.log(`Keep-alive ping successful for server: ${serverInfo.name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Keep-alive ping failed for server ${serverInfo.name}:`, error);
|
||||
// TODO Consider handling reconnection logic here if needed
|
||||
}
|
||||
}, interval);
|
||||
|
||||
console.log(
|
||||
`Keep-alive ping set up for server ${serverInfo.name} with interval ${interval / 1000} seconds`,
|
||||
);
|
||||
};
|
||||
import { setupClientKeepAlive } from './keepAliveService.js';
|
||||
|
||||
export const initUpstreamServers = async (): Promise<void> => {
|
||||
// Initialize OAuth clients for servers with dynamic registration
|
||||
@@ -215,24 +183,25 @@ export const createTransportFromConfig = async (name: string, conf: ServerConfig
|
||||
};
|
||||
env['PATH'] = expandEnvVars(process.env.PATH as string) || '';
|
||||
|
||||
const settings = loadSettings();
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
// Add UV_DEFAULT_INDEX and npm_config_registry if needed
|
||||
if (
|
||||
settings.systemConfig?.install?.pythonIndexUrl &&
|
||||
systemConfig?.install?.pythonIndexUrl &&
|
||||
(conf.command === 'uvx' || conf.command === 'uv' || conf.command === 'python')
|
||||
) {
|
||||
env['UV_DEFAULT_INDEX'] = settings.systemConfig.install.pythonIndexUrl;
|
||||
env['UV_DEFAULT_INDEX'] = systemConfig.install.pythonIndexUrl;
|
||||
}
|
||||
|
||||
if (
|
||||
settings.systemConfig?.install?.npmRegistry &&
|
||||
systemConfig?.install?.npmRegistry &&
|
||||
(conf.command === 'npm' ||
|
||||
conf.command === 'npx' ||
|
||||
conf.command === 'pnpm' ||
|
||||
conf.command === 'yarn' ||
|
||||
conf.command === 'node')
|
||||
) {
|
||||
env['npm_config_registry'] = settings.systemConfig.install.npmRegistry;
|
||||
env['npm_config_registry'] = systemConfig.install.npmRegistry;
|
||||
}
|
||||
|
||||
// Expand environment variables in command
|
||||
@@ -293,7 +262,7 @@ const callToolWithReconnect = async (
|
||||
serverInfo.client.close();
|
||||
serverInfo.transport.close();
|
||||
|
||||
const server = await serverDao.findById(serverInfo.name);
|
||||
const server = await getServerDao().findById(serverInfo.name);
|
||||
if (!server) {
|
||||
throw new Error(`Server configuration not found for: ${serverInfo.name}`);
|
||||
}
|
||||
@@ -373,7 +342,7 @@ export const initializeClientsFromSettings = async (
|
||||
isInit: boolean,
|
||||
serverName?: string,
|
||||
): Promise<ServerInfo[]> => {
|
||||
const allServers: ServerConfigWithName[] = await serverDao.findAll();
|
||||
const allServers: ServerConfigWithName[] = await getServerDao().findAll();
|
||||
const existingServerInfos = serverInfos;
|
||||
const nextServerInfos: ServerInfo[] = [];
|
||||
|
||||
@@ -597,9 +566,10 @@ export const initializeClientsFromSettings = async (
|
||||
if (!dataError) {
|
||||
serverInfo.status = 'connected';
|
||||
serverInfo.error = null;
|
||||
|
||||
// Set up keep-alive ping for SSE connections
|
||||
setupKeepAlive(serverInfo, expandedConf);
|
||||
// Set up keep-alive ping for SSE connections via shared service
|
||||
setupClientKeepAlive(serverInfo, expandedConf).catch((e) =>
|
||||
console.warn(`Keepalive setup failed for ${name}:`, e),
|
||||
);
|
||||
} else {
|
||||
serverInfo.status = 'disconnected';
|
||||
serverInfo.error = `Failed to list data: ${dataError} `;
|
||||
@@ -650,7 +620,7 @@ export const registerAllTools = async (isInit: boolean, serverName?: string): Pr
|
||||
|
||||
// Get all server information
|
||||
export const getServersInfo = async (): Promise<Omit<ServerInfo, 'client' | 'transport'>[]> => {
|
||||
const allServers: ServerConfigWithName[] = await serverDao.findAll();
|
||||
const allServers: ServerConfigWithName[] = await getServerDao().findAll();
|
||||
const dataService = getDataService();
|
||||
const filterServerInfos: ServerInfo[] = dataService.filterData
|
||||
? dataService.filterData(serverInfos)
|
||||
@@ -756,7 +726,7 @@ export const reconnectServer = async (serverName: string): Promise<void> => {
|
||||
|
||||
// Filter tools by server configuration
|
||||
const filterToolsByConfig = async (serverName: string, tools: Tool[]): Promise<Tool[]> => {
|
||||
const serverConfig = await serverDao.findById(serverName);
|
||||
const serverConfig = await getServerDao().findById(serverName);
|
||||
if (!serverConfig || !serverConfig.tools) {
|
||||
// If no tool configuration exists, all tools are enabled by default
|
||||
return tools;
|
||||
@@ -780,7 +750,7 @@ export const addServer = async (
|
||||
config: ServerConfig,
|
||||
): Promise<{ success: boolean; message?: string }> => {
|
||||
const server: ServerConfigWithName = { name, ...config };
|
||||
const result = await serverDao.create(server);
|
||||
const result = await getServerDao().create(server);
|
||||
if (result) {
|
||||
return { success: true, message: 'Server added successfully' };
|
||||
} else {
|
||||
@@ -792,7 +762,7 @@ export const addServer = async (
|
||||
export const removeServer = async (
|
||||
name: string,
|
||||
): Promise<{ success: boolean; message?: string }> => {
|
||||
const result = await serverDao.delete(name);
|
||||
const result = await getServerDao().delete(name);
|
||||
if (!result) {
|
||||
return { success: false, message: 'Failed to remove server' };
|
||||
}
|
||||
@@ -808,24 +778,23 @@ export const addOrUpdateServer = async (
|
||||
allowOverride: boolean = false,
|
||||
): Promise<{ success: boolean; message?: string }> => {
|
||||
try {
|
||||
const exists = await serverDao.exists(name);
|
||||
const exists = await getServerDao().exists(name);
|
||||
if (exists && !allowOverride) {
|
||||
return { success: false, message: 'Server name already exists' };
|
||||
}
|
||||
|
||||
// If overriding and this is a DXT server (stdio type with file paths),
|
||||
// we might want to clean up old files in the future
|
||||
if (exists && config.type === 'stdio') {
|
||||
// Close existing server connections
|
||||
// If overriding an existing server, close connections and clear keep-alive timers
|
||||
if (exists) {
|
||||
// Close existing server connections (clears keep-alive intervals as well)
|
||||
closeServer(name);
|
||||
// Remove from server infos
|
||||
serverInfos = serverInfos.filter((serverInfo) => serverInfo.name !== name);
|
||||
}
|
||||
|
||||
if (exists) {
|
||||
await serverDao.update(name, config);
|
||||
await getServerDao().update(name, config);
|
||||
} else {
|
||||
await serverDao.create({ name, ...config });
|
||||
await getServerDao().create({ name, ...config });
|
||||
}
|
||||
|
||||
const action = exists ? 'updated' : 'added';
|
||||
@@ -860,7 +829,7 @@ export const toggleServerStatus = async (
|
||||
enabled: boolean,
|
||||
): Promise<{ success: boolean; message?: string }> => {
|
||||
try {
|
||||
await serverDao.setEnabled(name, enabled);
|
||||
await getServerDao().setEnabled(name, enabled);
|
||||
// If disabling, disconnect the server and remove from active servers
|
||||
if (!enabled) {
|
||||
closeServer(name);
|
||||
@@ -893,33 +862,33 @@ export const handleListToolsRequest = async (_: any, extra: any) => {
|
||||
if (group === '$smart' || group?.startsWith('$smart/')) {
|
||||
// Extract target group if pattern is $smart/{group}
|
||||
const targetGroup = group?.startsWith('$smart/') ? group.substring(7) : undefined;
|
||||
|
||||
|
||||
// Get info about available servers, filtered by target group if specified
|
||||
let availableServers = serverInfos.filter(
|
||||
(server) => server.status === 'connected' && server.enabled !== false,
|
||||
);
|
||||
|
||||
|
||||
// If a target group is specified, filter servers to only those in the group
|
||||
if (targetGroup) {
|
||||
const serversInGroup = getServersInGroup(targetGroup);
|
||||
const serversInGroup = await getServersInGroup(targetGroup);
|
||||
if (serversInGroup && serversInGroup.length > 0) {
|
||||
availableServers = availableServers.filter((server) =>
|
||||
serversInGroup.includes(server.name),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Create simple server information with only server names
|
||||
const serversList = availableServers
|
||||
.map((server) => {
|
||||
return `${server.name}`;
|
||||
})
|
||||
.join(', ');
|
||||
|
||||
|
||||
const scopeDescription = targetGroup
|
||||
? `servers in the "${targetGroup}" group`
|
||||
: 'all available servers';
|
||||
|
||||
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
@@ -973,36 +942,44 @@ Available servers: ${serversList}`,
|
||||
};
|
||||
}
|
||||
|
||||
const allServerInfos = getDataService()
|
||||
.filterData(serverInfos)
|
||||
.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);
|
||||
});
|
||||
// Need to filter servers based on group asynchronously
|
||||
const filteredServerInfos = [];
|
||||
for (const serverInfo of getDataService().filterData(serverInfos)) {
|
||||
if (serverInfo.enabled === false) continue;
|
||||
if (!group) {
|
||||
filteredServerInfos.push(serverInfo);
|
||||
continue;
|
||||
}
|
||||
const serversInGroup = await getServersInGroup(group);
|
||||
if (!serversInGroup || serversInGroup.length === 0) {
|
||||
if (serverInfo.name === group) filteredServerInfos.push(serverInfo);
|
||||
continue;
|
||||
}
|
||||
if (serversInGroup.includes(serverInfo.name)) {
|
||||
filteredServerInfos.push(serverInfo);
|
||||
}
|
||||
}
|
||||
|
||||
const allTools = [];
|
||||
for (const serverInfo of allServerInfos) {
|
||||
for (const serverInfo of filteredServerInfos) {
|
||||
if (serverInfo.tools && serverInfo.tools.length > 0) {
|
||||
// Filter tools based on server configuration
|
||||
let enabledTools = await filterToolsByConfig(serverInfo.name, serverInfo.tools);
|
||||
|
||||
// If this is a group request, apply group-level tool filtering
|
||||
if (group) {
|
||||
const serverConfig = getServerConfigInGroup(group, serverInfo.name);
|
||||
const serverConfig = await getServerConfigInGroup(group, serverInfo.name);
|
||||
if (serverConfig && serverConfig.tools !== 'all' && Array.isArray(serverConfig.tools)) {
|
||||
// Filter tools based on group configuration
|
||||
const allowedToolNames = serverConfig.tools.map(
|
||||
(toolName) => `${serverInfo.name}${getNameSeparator()}${toolName}`,
|
||||
(toolName: string) => `${serverInfo.name}${getNameSeparator()}${toolName}`,
|
||||
);
|
||||
enabledTools = enabledTools.filter((tool) => allowedToolNames.includes(tool.name));
|
||||
}
|
||||
}
|
||||
|
||||
// Apply custom descriptions from server configuration
|
||||
const serverConfig = await serverDao.findById(serverInfo.name);
|
||||
const serverConfig = await getServerDao().findById(serverInfo.name);
|
||||
const toolsWithCustomDescriptions = enabledTools.map((tool) => {
|
||||
const toolConfig = serverConfig?.tools?.[tool.name];
|
||||
return {
|
||||
@@ -1047,20 +1024,22 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
|
||||
}
|
||||
|
||||
console.log(`Using similarity threshold: ${thresholdNum} for query: "${query}"`);
|
||||
|
||||
|
||||
// Determine server filtering based on group
|
||||
const sessionId = extra.sessionId || '';
|
||||
const group = getGroup(sessionId);
|
||||
let servers: string[] | undefined = undefined; // No server filtering by default
|
||||
|
||||
|
||||
// If group is in format $smart/{group}, filter servers to that group
|
||||
if (group?.startsWith('$smart/')) {
|
||||
const targetGroup = group.substring(7);
|
||||
const serversInGroup = getServersInGroup(targetGroup);
|
||||
const serversInGroup = await getServersInGroup(targetGroup);
|
||||
if (serversInGroup !== undefined && serversInGroup !== null) {
|
||||
servers = serversInGroup;
|
||||
if (servers.length > 0) {
|
||||
console.log(`Filtering search to servers in group "${targetGroup}": ${servers.join(', ')}`);
|
||||
if (servers && servers.length > 0) {
|
||||
console.log(
|
||||
`Filtering search to servers in group "${targetGroup}": ${servers.join(', ')}`,
|
||||
);
|
||||
} else {
|
||||
console.log(`Group "${targetGroup}" has no servers, search will return no results`);
|
||||
}
|
||||
@@ -1088,7 +1067,7 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
|
||||
const enabledTools = await filterToolsByConfig(server.name, [actualTool]);
|
||||
if (enabledTools.length > 0) {
|
||||
// Apply custom description from configuration
|
||||
const serverConfig = await serverDao.findById(server.name);
|
||||
const serverConfig = await getServerDao().findById(server.name);
|
||||
const toolConfig = serverConfig?.tools?.[actualTool.name];
|
||||
|
||||
// Return the actual tool info from serverInfos with custom description
|
||||
@@ -1430,21 +1409,29 @@ export const handleListPromptsRequest = async (_: any, extra: any) => {
|
||||
const group = getGroup(sessionId);
|
||||
console.log(`Handling ListPromptsRequest for group: ${group}`);
|
||||
|
||||
const allServerInfos = getDataService()
|
||||
.filterData(serverInfos)
|
||||
.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);
|
||||
});
|
||||
// Need to filter servers based on group asynchronously
|
||||
const filteredServerInfos = [];
|
||||
for (const serverInfo of getDataService().filterData(serverInfos)) {
|
||||
if (serverInfo.enabled === false) continue;
|
||||
if (!group) {
|
||||
filteredServerInfos.push(serverInfo);
|
||||
continue;
|
||||
}
|
||||
const serversInGroup = await getServersInGroup(group);
|
||||
if (!serversInGroup || serversInGroup.length === 0) {
|
||||
if (serverInfo.name === group) filteredServerInfos.push(serverInfo);
|
||||
continue;
|
||||
}
|
||||
if (serversInGroup.includes(serverInfo.name)) {
|
||||
filteredServerInfos.push(serverInfo);
|
||||
}
|
||||
}
|
||||
|
||||
const allPrompts: any[] = [];
|
||||
for (const serverInfo of allServerInfos) {
|
||||
for (const serverInfo of filteredServerInfos) {
|
||||
if (serverInfo.prompts && serverInfo.prompts.length > 0) {
|
||||
// Filter prompts based on server configuration
|
||||
const serverConfig = await serverDao.findById(serverInfo.name);
|
||||
const serverConfig = await getServerDao().findById(serverInfo.name);
|
||||
|
||||
let enabledPrompts = serverInfo.prompts;
|
||||
if (serverConfig && serverConfig.prompts) {
|
||||
@@ -1457,7 +1444,7 @@ export const handleListPromptsRequest = async (_: any, extra: any) => {
|
||||
|
||||
// If this is a group request, apply group-level prompt filtering
|
||||
if (group) {
|
||||
const serverConfigInGroup = getServerConfigInGroup(group, serverInfo.name);
|
||||
const serverConfigInGroup = await getServerConfigInGroup(group, serverInfo.name);
|
||||
if (
|
||||
serverConfigInGroup &&
|
||||
serverConfigInGroup.tools !== 'all' &&
|
||||
@@ -1492,15 +1479,9 @@ export const createMcpServer = (name: string, version: string, group?: string):
|
||||
let serverName = name;
|
||||
|
||||
if (group) {
|
||||
// Check if it's a group or a single server
|
||||
const serversInGroup = getServersInGroup(group);
|
||||
if (!serversInGroup || serversInGroup.length === 0) {
|
||||
// Single server routing
|
||||
serverName = `${name}_${group}`;
|
||||
} else {
|
||||
// Group routing
|
||||
serverName = `${name}_${group}_group`;
|
||||
}
|
||||
// For createMcpServer we use sync approach since it's called synchronously
|
||||
// The actual group validation happens at request time
|
||||
serverName = `${name}_${group}_group`;
|
||||
}
|
||||
// If no group, use default name (global routing)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import OAuth2Server from '@node-oauth/oauth2-server';
|
||||
import { Request as ExpressRequest, Response as ExpressResponse } from 'express';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import { getSystemConfigDao } from '../dao/index.js';
|
||||
import { findUserByUsername, verifyPassword } from '../models/User.js';
|
||||
import {
|
||||
findOAuthClientById,
|
||||
@@ -50,8 +50,9 @@ const oauthModel: OAuth2Server.AuthorizationCodeModel & OAuth2Server.RefreshToke
|
||||
client: OAuth2Server.Client,
|
||||
user: OAuth2Server.User,
|
||||
) => {
|
||||
const settings = loadSettings();
|
||||
const oauthConfig = settings.systemConfig?.oauthServer;
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
const oauthConfig = systemConfig?.oauthServer;
|
||||
const lifetime = oauthConfig?.authorizationCodeLifetime || 300;
|
||||
|
||||
const scopeString = Array.isArray(code.scope) ? code.scope.join(' ') : code.scope;
|
||||
@@ -134,8 +135,9 @@ const oauthModel: OAuth2Server.AuthorizationCodeModel & OAuth2Server.RefreshToke
|
||||
client: OAuth2Server.Client,
|
||||
user: OAuth2Server.User,
|
||||
) => {
|
||||
const settings = loadSettings();
|
||||
const oauthConfig = settings.systemConfig?.oauthServer;
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
const oauthConfig = systemConfig?.oauthServer;
|
||||
const accessTokenLifetime = oauthConfig?.accessTokenLifetime || 3600;
|
||||
const refreshTokenLifetime = oauthConfig?.refreshTokenLifetime || 1209600;
|
||||
|
||||
@@ -252,7 +254,9 @@ const oauthModel: OAuth2Server.AuthorizationCodeModel & OAuth2Server.RefreshToke
|
||||
}
|
||||
|
||||
const requestedScopes = Array.isArray(scope) ? scope : scope.split(' ');
|
||||
const tokenScopes = Array.isArray(token.scope) ? token.scope : (token.scope as string).split(' ');
|
||||
const tokenScopes = Array.isArray(token.scope)
|
||||
? token.scope
|
||||
: (token.scope as string).split(' ');
|
||||
|
||||
return requestedScopes.every((s) => tokenScopes.includes(s));
|
||||
},
|
||||
@@ -261,8 +265,9 @@ const oauthModel: OAuth2Server.AuthorizationCodeModel & OAuth2Server.RefreshToke
|
||||
* Validate scope
|
||||
*/
|
||||
validateScope: async (user: OAuth2Server.User, client: OAuth2Server.Client, scope?: string[]) => {
|
||||
const settings = loadSettings();
|
||||
const oauthConfig = settings.systemConfig?.oauthServer;
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
const oauthConfig = systemConfig?.oauthServer;
|
||||
const allowedScopes = oauthConfig?.allowedScopes || ['read', 'write'];
|
||||
|
||||
if (!scope || scope.length === 0) {
|
||||
@@ -281,9 +286,10 @@ let oauth: OAuth2Server | null = null;
|
||||
/**
|
||||
* Initialize OAuth server
|
||||
*/
|
||||
export const initOAuthServer = (): void => {
|
||||
const settings = loadSettings();
|
||||
const oauthConfig = settings.systemConfig?.oauthServer;
|
||||
export const initOAuthServer = async (): Promise<void> => {
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
const oauthConfig = systemConfig?.oauthServer;
|
||||
const requireState = oauthConfig?.requireState === true;
|
||||
|
||||
if (!oauthConfig || !oauthConfig.enabled) {
|
||||
@@ -333,7 +339,7 @@ export const authenticateUser = async (
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<OAuth2Server.User | null> => {
|
||||
const user = findUserByUsername(username);
|
||||
const user = await findUserByUsername(username);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ProxyOAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/providers/proxyProvider.js';
|
||||
import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js';
|
||||
import { RequestHandler } from 'express';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import { getServerDao, getSystemConfigDao } from '../dao/index.js';
|
||||
import { initializeOAuthForServer, refreshAccessToken } from './oauthClientRegistration.js';
|
||||
|
||||
// Re-export for external use
|
||||
@@ -22,9 +22,10 @@ let oauthRouter: RequestHandler | null = null;
|
||||
/**
|
||||
* Initialize OAuth provider from system configuration
|
||||
*/
|
||||
export const initOAuthProvider = (): void => {
|
||||
const settings = loadSettings();
|
||||
const oauthConfig = settings.systemConfig?.oauth;
|
||||
export const initOAuthProvider = async (): Promise<void> => {
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
const oauthConfig = systemConfig?.oauth;
|
||||
|
||||
if (!oauthConfig || !oauthConfig.enabled) {
|
||||
console.log('OAuth provider is disabled or not configured');
|
||||
@@ -140,8 +141,8 @@ export const isOAuthEnabled = (): boolean => {
|
||||
* Handles both static tokens and dynamic OAuth flows with automatic token refresh
|
||||
*/
|
||||
export const getServerOAuthToken = async (serverName: string): Promise<string | undefined> => {
|
||||
const settings = loadSettings();
|
||||
const serverConfig = settings.mcpServers[serverName];
|
||||
const serverDao = getServerDao();
|
||||
const serverConfig = await serverDao.findById(serverName);
|
||||
|
||||
if (!serverConfig?.oauth) {
|
||||
return undefined;
|
||||
@@ -227,15 +228,15 @@ export const addOAuthHeader = async (
|
||||
* Call this at application startup to pre-register known OAuth servers
|
||||
*/
|
||||
export const initializeAllOAuthClients = async (): Promise<void> => {
|
||||
const settings = loadSettings();
|
||||
const serverDao = getServerDao();
|
||||
const allServers = await serverDao.findAll();
|
||||
|
||||
console.log('Initializing OAuth clients for explicitly configured servers...');
|
||||
|
||||
const serverNames = Object.keys(settings.mcpServers);
|
||||
const registrationPromises: Promise<void>[] = [];
|
||||
|
||||
for (const serverName of serverNames) {
|
||||
const serverConfig = settings.mcpServers[serverName];
|
||||
for (const serverConfig of allServers) {
|
||||
const serverName = serverConfig.name;
|
||||
|
||||
// Only initialize servers with explicitly enabled dynamic registration
|
||||
// Others will be auto-detected and registered on first 401 response
|
||||
|
||||
@@ -1,53 +1,58 @@
|
||||
import { loadSettings, saveSettings } from '../config/index.js';
|
||||
import { McpSettings, ServerConfig } from '../types/index.js';
|
||||
import { getServerDao } from '../dao/index.js';
|
||||
import { ServerConfig } from '../types/index.js';
|
||||
|
||||
type OAuthConfig = NonNullable<ServerConfig['oauth']>;
|
||||
export type ServerConfigWithOAuth = ServerConfig & { oauth: OAuthConfig };
|
||||
|
||||
export interface OAuthSettingsContext {
|
||||
settings: McpSettings;
|
||||
serverConfig: ServerConfig;
|
||||
oauth: OAuthConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the latest server configuration from disk.
|
||||
* Load the latest server configuration from DAO.
|
||||
*/
|
||||
export const loadServerConfig = (serverName: string): ServerConfig | undefined => {
|
||||
const settings = loadSettings();
|
||||
return settings.mcpServers?.[serverName];
|
||||
export const loadServerConfig = async (serverName: string): Promise<ServerConfig | undefined> => {
|
||||
const serverDao = getServerDao();
|
||||
const server = await serverDao.findById(serverName);
|
||||
if (!server) {
|
||||
return undefined;
|
||||
}
|
||||
const { name: _, ...config } = server;
|
||||
return config;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mutate OAuth configuration for a server and persist the updated settings.
|
||||
* The mutator receives the shared settings object to allow related updates when needed.
|
||||
* The mutator receives the server config to allow related updates when needed.
|
||||
*/
|
||||
export const mutateOAuthSettings = async (
|
||||
serverName: string,
|
||||
mutator: (context: OAuthSettingsContext) => void,
|
||||
): Promise<ServerConfigWithOAuth | undefined> => {
|
||||
const settings = loadSettings();
|
||||
const serverConfig = settings.mcpServers?.[serverName];
|
||||
const serverDao = getServerDao();
|
||||
const server = await serverDao.findById(serverName);
|
||||
|
||||
if (!serverConfig) {
|
||||
if (!server) {
|
||||
console.warn(`Server ${serverName} not found while updating OAuth settings`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { name: _, ...serverConfig } = server;
|
||||
|
||||
if (!serverConfig.oauth) {
|
||||
serverConfig.oauth = {};
|
||||
}
|
||||
|
||||
const context: OAuthSettingsContext = {
|
||||
settings,
|
||||
serverConfig,
|
||||
oauth: serverConfig.oauth,
|
||||
};
|
||||
|
||||
mutator(context);
|
||||
|
||||
const saved = saveSettings(settings);
|
||||
if (!saved) {
|
||||
const updated = await serverDao.update(serverName, { oauth: serverConfig.oauth });
|
||||
if (!updated) {
|
||||
throw new Error(`Failed to persist OAuth settings for server ${serverName}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { OpenAPIV3 } from 'openapi-types';
|
||||
import { Tool } from '../types/index.js';
|
||||
import { getServersInfo } from './mcpService.js';
|
||||
import config from '../config/index.js';
|
||||
import { loadSettings, getNameSeparator } from '../config/index.js';
|
||||
import config, { getNameSeparator } from '../config/index.js';
|
||||
import { getSystemConfigDao } from '../dao/index.js';
|
||||
|
||||
/**
|
||||
* Service for generating OpenAPI 3.x specifications from MCP tools
|
||||
@@ -174,7 +174,7 @@ export async function generateOpenAPISpec(
|
||||
const groupConfig: Map<string, string[] | 'all'> = new Map();
|
||||
if (options.groupFilter) {
|
||||
const { getGroupByIdOrName } = await import('./groupService.js');
|
||||
const group = getGroupByIdOrName(options.groupFilter);
|
||||
const group = await getGroupByIdOrName(options.groupFilter);
|
||||
if (group) {
|
||||
// Extract server names and their tool configurations from group
|
||||
const groupServerNames: string[] = [];
|
||||
@@ -250,12 +250,11 @@ export async function generateOpenAPISpec(
|
||||
paths[pathName][method] = operation;
|
||||
}
|
||||
|
||||
const settings = loadSettings();
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
// Get server URL
|
||||
const baseUrl =
|
||||
options.serverUrl ||
|
||||
settings.systemConfig?.install?.baseUrl ||
|
||||
`http://localhost:${config.port}`;
|
||||
options.serverUrl || systemConfig?.install?.baseUrl || `http://localhost:${config.port}`;
|
||||
const serverUrl = `${baseUrl}${config.basePath}/api`;
|
||||
|
||||
// Generate OpenAPI document
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createRequire } from 'module';
|
||||
import { join } from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
|
||||
type Class<T> = new (...args: any[]) => T;
|
||||
|
||||
@@ -11,7 +11,24 @@ interface Service<T> {
|
||||
const registry = new Map<string, Service<any>>();
|
||||
const instances = new Map<string, unknown>();
|
||||
|
||||
export function registerService<T>(key: string, entry: Service<T>) {
|
||||
async function tryLoadOverride<T>(key: string, overridePath: string): Promise<Class<T> | undefined> {
|
||||
try {
|
||||
const moduleUrl = pathToFileURL(overridePath).href;
|
||||
const mod = await import(moduleUrl);
|
||||
const override = mod[key.charAt(0).toUpperCase() + key.slice(1) + 'x'];
|
||||
if (typeof override === 'function') {
|
||||
return override as Class<T>;
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Ignore not-found errors and keep trying other paths; surface other errors for visibility
|
||||
if (error?.code !== 'ERR_MODULE_NOT_FOUND' && error?.code !== 'MODULE_NOT_FOUND') {
|
||||
console.warn(`Failed to load service override from ${overridePath}:`, error);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function registerService<T>(key: string, entry: Service<T>) {
|
||||
// Try to load override immediately during registration
|
||||
// Try multiple paths and file extensions in order
|
||||
const serviceDirs = ['src/services', 'dist/services'];
|
||||
@@ -22,18 +39,10 @@ export function registerService<T>(key: string, entry: Service<T>) {
|
||||
for (const fileExt of fileExts) {
|
||||
const overridePath = join(process.cwd(), serviceDir, overrideFileName + fileExt);
|
||||
|
||||
try {
|
||||
// Use createRequire with a stable path reference
|
||||
const require = createRequire(join(process.cwd(), 'package.json'));
|
||||
const mod = require(overridePath);
|
||||
const override = mod[key.charAt(0).toUpperCase() + key.slice(1) + 'x'];
|
||||
if (typeof override === 'function') {
|
||||
entry.override = override;
|
||||
break; // Found override, exit both loops
|
||||
}
|
||||
} catch (error) {
|
||||
// Continue trying next path/extension combination
|
||||
continue;
|
||||
const override = await tryLoadOverride<T>(key, overridePath);
|
||||
if (override) {
|
||||
entry.override = override;
|
||||
break; // Found override, exit both loops
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { registerService, getService } from './registry.js';
|
||||
import { DataService, DataServiceImpl } from './dataService.js';
|
||||
|
||||
registerService('dataService', {
|
||||
defaultImpl: DataServiceImpl,
|
||||
});
|
||||
import { DataService } from './dataService.js';
|
||||
|
||||
export function getDataService(): DataService {
|
||||
return getService<DataService>('dataService');
|
||||
return new DataService();
|
||||
}
|
||||
|
||||
@@ -10,6 +10,20 @@ import {
|
||||
transports,
|
||||
} from './sseService.js';
|
||||
|
||||
// Default mock system config
|
||||
const defaultSystemConfig = {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
enableSessionRebuild: false,
|
||||
};
|
||||
|
||||
// Mutable mock config that can be changed in tests
|
||||
let currentSystemConfig = { ...defaultSystemConfig };
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('./mcpService.js', () => ({
|
||||
deleteMcpServer: jest.fn(),
|
||||
@@ -25,21 +39,21 @@ jest.mock('../config/index.js', () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: config,
|
||||
loadSettings: jest.fn(() => ({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
enableSessionRebuild: false, // Default to false for tests
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock DAO layer
|
||||
jest.mock('../dao/index.js', () => ({
|
||||
getSystemConfigDao: jest.fn(() => ({
|
||||
get: jest.fn().mockImplementation(() => Promise.resolve(currentSystemConfig)),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock oauthBearer
|
||||
jest.mock('../utils/oauthBearer.js', () => ({
|
||||
resolveOAuthUserFromToken: jest.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
jest.mock('./userContextService.js', () => ({
|
||||
UserContextService: {
|
||||
getInstance: jest.fn(() => ({
|
||||
@@ -57,7 +71,9 @@ jest.mock('@modelcontextprotocol/sdk/server/sse.js', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({
|
||||
StreamableHTTPServerTransport: jest.fn().mockImplementation(() => mockStreamableHTTPServerTransport),
|
||||
StreamableHTTPServerTransport: jest
|
||||
.fn()
|
||||
.mockImplementation(() => mockStreamableHTTPServerTransport),
|
||||
}));
|
||||
|
||||
jest.mock('@modelcontextprotocol/sdk/types.js', () => ({
|
||||
@@ -66,11 +82,15 @@ jest.mock('@modelcontextprotocol/sdk/types.js', () => ({
|
||||
|
||||
// Import mocked modules
|
||||
import { getMcpServer } from './mcpService.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import { UserContextService } from './userContextService.js';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
|
||||
// Helper function to update the mock system config
|
||||
const setMockSystemConfig = (config: typeof defaultSystemConfig) => {
|
||||
currentSystemConfig = config;
|
||||
};
|
||||
|
||||
type MockResponse = Response & {
|
||||
status: jest.Mock;
|
||||
send: jest.Mock;
|
||||
@@ -79,8 +99,7 @@ type MockResponse = Response & {
|
||||
headersStore: Record<string, string>;
|
||||
};
|
||||
|
||||
const EXPECTED_METADATA_URL =
|
||||
'http://localhost:3000/.well-known/oauth-protected-resource/test';
|
||||
const EXPECTED_METADATA_URL = 'http://localhost:3000/.well-known/oauth-protected-resource/test';
|
||||
|
||||
// Create mock instances for testing
|
||||
const mockStreamableHTTPServerTransport = {
|
||||
@@ -156,18 +175,15 @@ describe('sseService', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Reset settings cache
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
enableSessionRebuild: false, // Default to false for tests
|
||||
// Reset settings cache to default
|
||||
setMockSystemConfig({
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
enableSessionRebuild: false, // Default to false for tests
|
||||
});
|
||||
});
|
||||
|
||||
@@ -185,15 +201,12 @@ describe('sseService', () => {
|
||||
});
|
||||
|
||||
it('should return 401 when bearer auth is enabled but no authorization header', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
setMockSystemConfig({
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -206,15 +219,12 @@ describe('sseService', () => {
|
||||
});
|
||||
|
||||
it('should return 401 when bearer auth is enabled with invalid token', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
setMockSystemConfig({
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -229,15 +239,12 @@ describe('sseService', () => {
|
||||
});
|
||||
|
||||
it('should pass when bearer auth is enabled with valid token', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
setMockSystemConfig({
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -279,15 +286,12 @@ describe('sseService', () => {
|
||||
|
||||
describe('handleSseConnection', () => {
|
||||
it('should reject global routes when disabled', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: false,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
},
|
||||
setMockSystemConfig({
|
||||
routing: {
|
||||
enableGlobalRoute: false,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -375,15 +379,12 @@ describe('sseService', () => {
|
||||
});
|
||||
|
||||
it('should return 401 when bearer auth fails', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
setMockSystemConfig({
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -400,15 +401,12 @@ describe('sseService', () => {
|
||||
|
||||
describe('handleMcpPostRequest', () => {
|
||||
it('should reject global routes when disabled', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: false,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
},
|
||||
setMockSystemConfig({
|
||||
routing: {
|
||||
enableGlobalRoute: false,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -463,17 +461,14 @@ describe('sseService', () => {
|
||||
|
||||
it('should transparently rebuild invalid session when enabled', async () => {
|
||||
// Enable session rebuild for this test
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
enableSessionRebuild: true, // Enable session rebuild
|
||||
setMockSystemConfig({
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
enableSessionRebuild: true, // Enable session rebuild
|
||||
});
|
||||
|
||||
const req = createMockRequest({
|
||||
@@ -487,20 +482,19 @@ describe('sseService', () => {
|
||||
|
||||
// With session rebuild enabled, invalid sessions should be transparently rebuilt
|
||||
expect(StreamableHTTPServerTransport).toHaveBeenCalled();
|
||||
const mockInstance = (StreamableHTTPServerTransport as jest.MockedClass<typeof StreamableHTTPServerTransport>).mock.results[0].value;
|
||||
const mockInstance = (
|
||||
StreamableHTTPServerTransport as jest.MockedClass<typeof StreamableHTTPServerTransport>
|
||||
).mock.results[0].value;
|
||||
expect(mockInstance.handleRequest).toHaveBeenCalledWith(req, res, req.body);
|
||||
});
|
||||
|
||||
it('should return 401 when bearer auth fails', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
setMockSystemConfig({
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -530,20 +524,17 @@ describe('sseService', () => {
|
||||
});
|
||||
it('should return error when session rebuild is disabled in handleMcpOtherRequest', async () => {
|
||||
// Clear transports before test
|
||||
Object.keys(transports).forEach(key => delete transports[key]);
|
||||
|
||||
Object.keys(transports).forEach((key) => delete transports[key]);
|
||||
|
||||
// Enable bearer auth for this test
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
enableSessionRebuild: false, // Disable session rebuild
|
||||
setMockSystemConfig({
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
enableSessionRebuild: false, // Disable session rebuild
|
||||
});
|
||||
|
||||
// Mock user context to exist
|
||||
@@ -555,7 +546,7 @@ describe('sseService', () => {
|
||||
const req = createMockRequest({
|
||||
headers: {
|
||||
'mcp-session-id': 'invalid-session',
|
||||
'authorization': 'Bearer test-key'
|
||||
authorization: 'Bearer test-key',
|
||||
},
|
||||
params: { group: 'test-group' },
|
||||
});
|
||||
@@ -570,23 +561,20 @@ describe('sseService', () => {
|
||||
|
||||
it('should transparently rebuild invalid session in handleMcpOtherRequest when enabled', async () => {
|
||||
// Enable bearer auth and session rebuild for this test
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
enableSessionRebuild: true, // Enable session rebuild
|
||||
setMockSystemConfig({
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
enableSessionRebuild: true, // Enable session rebuild
|
||||
});
|
||||
|
||||
const req = createMockRequest({
|
||||
headers: {
|
||||
'mcp-session-id': 'invalid-session',
|
||||
'authorization': 'Bearer test-key'
|
||||
authorization: 'Bearer test-key',
|
||||
},
|
||||
});
|
||||
const res = createMockResponse();
|
||||
@@ -596,21 +584,18 @@ describe('sseService', () => {
|
||||
// Should not return 400 error, but instead transparently rebuild the session
|
||||
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||
expect(res.send).not.toHaveBeenCalledWith('Invalid or missing session ID');
|
||||
|
||||
|
||||
// Should attempt to handle the request (session was rebuilt)
|
||||
expect(mockStreamableHTTPServerTransport.handleRequest).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 401 when bearer auth fails', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
setMockSystemConfig({
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { deleteMcpServer, getMcpServer } from './mcpService.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import config from '../config/index.js';
|
||||
import { getSystemConfigDao } from '../dao/index.js';
|
||||
import { UserContextService } from './userContextService.js';
|
||||
import { RequestContextService } from './requestContextService.js';
|
||||
import { IUser } from '../types/index.js';
|
||||
@@ -30,9 +30,10 @@ type BearerAuthResult =
|
||||
reason: 'missing' | 'invalid';
|
||||
};
|
||||
|
||||
const validateBearerAuth = (req: Request): BearerAuthResult => {
|
||||
const settings = loadSettings();
|
||||
const routingConfig = settings.systemConfig?.routing || {
|
||||
const validateBearerAuth = async (req: Request): Promise<BearerAuthResult> => {
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
const routingConfig = systemConfig?.routing || {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
@@ -54,7 +55,7 @@ const validateBearerAuth = (req: Request): BearerAuthResult => {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
const oauthUser = resolveOAuthUserFromToken(token);
|
||||
const oauthUser = await resolveOAuthUserFromToken(token);
|
||||
if (oauthUser) {
|
||||
return { valid: true, user: oauthUser };
|
||||
}
|
||||
@@ -170,7 +171,7 @@ export const handleSseConnection = async (req: Request, res: Response): Promise<
|
||||
const userContextService = UserContextService.getInstance();
|
||||
|
||||
// Check bearer auth using filtered settings
|
||||
const bearerAuthResult = validateBearerAuth(req);
|
||||
const bearerAuthResult = await validateBearerAuth(req);
|
||||
if (!bearerAuthResult.valid) {
|
||||
sendBearerAuthError(req, res, bearerAuthResult.reason);
|
||||
return;
|
||||
@@ -181,8 +182,9 @@ export const handleSseConnection = async (req: Request, res: Response): Promise<
|
||||
const currentUser = userContextService.getCurrentUser();
|
||||
const username = currentUser?.username;
|
||||
|
||||
const settings = loadSettings();
|
||||
const routingConfig = settings.systemConfig?.routing || {
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
const routingConfig = systemConfig?.routing || {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
@@ -213,25 +215,7 @@ export const handleSseConnection = async (req: Request, res: Response): Promise<
|
||||
const transport = new SSEServerTransport(messagesPath, res);
|
||||
transports[transport.sessionId] = { transport, group: group };
|
||||
|
||||
// Send keepalive ping every 30 seconds to prevent client from closing connection
|
||||
const keepAlive = setInterval(() => {
|
||||
try {
|
||||
// Send a ping notification to keep the connection alive
|
||||
transport.send({ jsonrpc: '2.0', method: 'ping' });
|
||||
console.log(`Sent keepalive ping for SSE session: ${transport.sessionId}`);
|
||||
} catch (e) {
|
||||
// If sending a ping fails, the connection is likely broken.
|
||||
// Log the error and clear the interval to prevent further attempts.
|
||||
console.warn(
|
||||
`Failed to send keepalive ping for SSE session ${transport.sessionId}, cleaning up interval:`,
|
||||
e,
|
||||
);
|
||||
clearInterval(keepAlive);
|
||||
}
|
||||
}, 30000); // Send ping every 30 seconds
|
||||
|
||||
res.on('close', () => {
|
||||
clearInterval(keepAlive);
|
||||
delete transports[transport.sessionId];
|
||||
deleteMcpServer(transport.sessionId);
|
||||
console.log(`SSE connection closed: ${transport.sessionId}`);
|
||||
@@ -248,7 +232,7 @@ export const handleSseMessage = async (req: Request, res: Response): Promise<voi
|
||||
const userContextService = UserContextService.getInstance();
|
||||
|
||||
// Check bearer auth using filtered settings
|
||||
const bearerAuthResult = validateBearerAuth(req);
|
||||
const bearerAuthResult = await validateBearerAuth(req);
|
||||
if (!bearerAuthResult.valid) {
|
||||
sendBearerAuthError(req, res, bearerAuthResult.reason);
|
||||
return;
|
||||
@@ -327,26 +311,8 @@ async function createSessionWithId(
|
||||
},
|
||||
});
|
||||
|
||||
// Send keepalive ping every 30 seconds to prevent client from closing connection
|
||||
const keepAlive = setInterval(() => {
|
||||
try {
|
||||
// Send a ping notification to keep the connection alive
|
||||
transport.send({ jsonrpc: '2.0', method: 'ping' });
|
||||
console.log(`Sent keepalive ping for StreamableHTTP session: ${sessionId}`);
|
||||
} catch (e) {
|
||||
// If sending a ping fails, the connection is likely broken.
|
||||
// Log the error and clear the interval to prevent further attempts.
|
||||
console.warn(
|
||||
`Failed to send keepalive ping for StreamableHTTP session ${sessionId}, cleaning up interval:`,
|
||||
e,
|
||||
);
|
||||
clearInterval(keepAlive);
|
||||
}
|
||||
}, 30000); // Send ping every 30 seconds
|
||||
|
||||
transport.onclose = () => {
|
||||
console.log(`[SESSION REBUILD] Transport closed: ${sessionId}`);
|
||||
clearInterval(keepAlive);
|
||||
delete transports[sessionId];
|
||||
deleteMcpServer(sessionId);
|
||||
};
|
||||
@@ -395,26 +361,8 @@ async function createNewSession(
|
||||
},
|
||||
});
|
||||
|
||||
// Send keepalive ping every 30 seconds to prevent client from closing connection
|
||||
const keepAlive = setInterval(() => {
|
||||
try {
|
||||
// Send a ping notification to keep the connection alive
|
||||
transport.send({ jsonrpc: '2.0', method: 'ping' });
|
||||
console.log(`Sent keepalive ping for StreamableHTTP session: ${newSessionId}`);
|
||||
} catch (e) {
|
||||
// If sending a ping fails, the connection is likely broken.
|
||||
// Log the error and clear the interval to prevent further attempts.
|
||||
console.warn(
|
||||
`Failed to send keepalive ping for StreamableHTTP session ${newSessionId}, cleaning up interval:`,
|
||||
e,
|
||||
);
|
||||
clearInterval(keepAlive);
|
||||
}
|
||||
}, 30000); // Send ping every 30 seconds
|
||||
|
||||
transport.onclose = () => {
|
||||
console.log(`[SESSION NEW] Transport closed: ${newSessionId}`);
|
||||
clearInterval(keepAlive);
|
||||
delete transports[newSessionId];
|
||||
deleteMcpServer(newSessionId);
|
||||
};
|
||||
@@ -429,7 +377,7 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
|
||||
const userContextService = UserContextService.getInstance();
|
||||
|
||||
// Check bearer auth using filtered settings
|
||||
const bearerAuthResult = validateBearerAuth(req);
|
||||
const bearerAuthResult = await validateBearerAuth(req);
|
||||
if (!bearerAuthResult.valid) {
|
||||
sendBearerAuthError(req, res, bearerAuthResult.reason);
|
||||
return;
|
||||
@@ -448,8 +396,9 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
|
||||
);
|
||||
|
||||
// Get filtered settings based on user context (after setting user context)
|
||||
const settings = loadSettings();
|
||||
const routingConfig = settings.systemConfig?.routing || {
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
const routingConfig = systemConfig?.routing || {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
};
|
||||
@@ -473,8 +422,7 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
|
||||
transport = transportInfo.transport as StreamableHTTPServerTransport;
|
||||
} else if (sessionId) {
|
||||
// Case 2: SessionId exists but transport is missing (server restart), check if session rebuild is enabled
|
||||
const settings = loadSettings();
|
||||
const enableSessionRebuild = settings.systemConfig?.enableSessionRebuild || false;
|
||||
const enableSessionRebuild = systemConfig?.enableSessionRebuild || false;
|
||||
|
||||
if (enableSessionRebuild) {
|
||||
console.log(
|
||||
@@ -680,7 +628,7 @@ export const handleMcpOtherRequest = async (req: Request, res: Response) => {
|
||||
const userContextService = UserContextService.getInstance();
|
||||
|
||||
// Check bearer auth using filtered settings
|
||||
const bearerAuthResult = validateBearerAuth(req);
|
||||
const bearerAuthResult = await validateBearerAuth(req);
|
||||
if (!bearerAuthResult.valid) {
|
||||
sendBearerAuthError(req, res, bearerAuthResult.reason);
|
||||
return;
|
||||
@@ -703,8 +651,9 @@ export const handleMcpOtherRequest = async (req: Request, res: Response) => {
|
||||
|
||||
// If session doesn't exist, attempt transparent rebuild if enabled
|
||||
if (!transportEntry) {
|
||||
const settings = loadSettings();
|
||||
const enableSessionRebuild = settings.systemConfig?.enableSessionRebuild || false;
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
const enableSessionRebuild = systemConfig?.enableSessionRebuild || false;
|
||||
|
||||
if (enableSessionRebuild) {
|
||||
console.log(
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { IUser } from '../types/index.js';
|
||||
import { getUsers, createUser, findUserByUsername } from '../models/User.js';
|
||||
import { saveSettings, loadSettings } from '../config/index.js';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { getUserDao } from '../dao/index.js';
|
||||
|
||||
// Get all users
|
||||
export const getAllUsers = (): IUser[] => {
|
||||
return getUsers();
|
||||
export const getAllUsers = async (): Promise<IUser[]> => {
|
||||
const userDao = getUserDao();
|
||||
return await userDao.findAll();
|
||||
};
|
||||
|
||||
// Get user by username
|
||||
export const getUserByUsername = (username: string): IUser | undefined => {
|
||||
return findUserByUsername(username);
|
||||
export const getUserByUsername = async (username: string): Promise<IUser | undefined> => {
|
||||
const userDao = getUserDao();
|
||||
const user = await userDao.findByUsername(username);
|
||||
return user || undefined;
|
||||
};
|
||||
|
||||
// Create a new user
|
||||
@@ -20,18 +21,13 @@ export const createNewUser = async (
|
||||
isAdmin: boolean = false,
|
||||
): Promise<IUser | null> => {
|
||||
try {
|
||||
const existingUser = findUserByUsername(username);
|
||||
const userDao = getUserDao();
|
||||
const existingUser = await userDao.findByUsername(username);
|
||||
if (existingUser) {
|
||||
return null; // User already exists
|
||||
}
|
||||
|
||||
const userData: IUser = {
|
||||
username,
|
||||
password,
|
||||
isAdmin,
|
||||
};
|
||||
|
||||
return await createUser(userData);
|
||||
return await userDao.createWithHashedPassword(username, password, isAdmin);
|
||||
} catch (error) {
|
||||
console.error('Failed to create user:', error);
|
||||
return null;
|
||||
@@ -44,36 +40,31 @@ export const updateUser = async (
|
||||
data: { isAdmin?: boolean; newPassword?: string },
|
||||
): Promise<IUser | null> => {
|
||||
try {
|
||||
const users = getUsers();
|
||||
const userIndex = users.findIndex((user) => user.username === username);
|
||||
const userDao = getUserDao();
|
||||
const user = await userDao.findByUsername(username);
|
||||
|
||||
if (userIndex === -1) {
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = users[userIndex];
|
||||
|
||||
// Update admin status if provided
|
||||
if (data.isAdmin !== undefined) {
|
||||
user.isAdmin = data.isAdmin;
|
||||
const result = await userDao.update(username, { isAdmin: data.isAdmin });
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Update password if provided
|
||||
if (data.newPassword) {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
user.password = await bcrypt.hash(data.newPassword, salt);
|
||||
const success = await userDao.updatePassword(username, data.newPassword);
|
||||
if (!success) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Save users array back to settings
|
||||
const { saveSettings, loadSettings } = await import('../config/index.js');
|
||||
const settings = loadSettings();
|
||||
settings.users = users;
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return user;
|
||||
// Return updated user
|
||||
return await userDao.findByUsername(username);
|
||||
} catch (error) {
|
||||
console.error('Failed to update user:', error);
|
||||
return null;
|
||||
@@ -81,10 +72,12 @@ export const updateUser = async (
|
||||
};
|
||||
|
||||
// Delete a user
|
||||
export const deleteUser = (username: string): boolean => {
|
||||
export const deleteUser = async (username: string): Promise<boolean> => {
|
||||
try {
|
||||
const userDao = getUserDao();
|
||||
|
||||
// Cannot delete the last admin user
|
||||
const users = getUsers();
|
||||
const users = await userDao.findAll();
|
||||
const adminUsers = users.filter((user) => user.isAdmin);
|
||||
const userToDelete = users.find((user) => user.username === username);
|
||||
|
||||
@@ -92,17 +85,7 @@ export const deleteUser = (username: string): boolean => {
|
||||
return false; // Cannot delete the last admin
|
||||
}
|
||||
|
||||
const filteredUsers = users.filter((user) => user.username !== username);
|
||||
|
||||
if (filteredUsers.length === users.length) {
|
||||
return false; // User not found
|
||||
}
|
||||
|
||||
// Save filtered users back to settings
|
||||
const settings = loadSettings();
|
||||
settings.users = filteredUsers;
|
||||
|
||||
return saveSettings(settings);
|
||||
return await userDao.delete(username);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete user:', error);
|
||||
return false;
|
||||
@@ -110,17 +93,21 @@ export const deleteUser = (username: string): boolean => {
|
||||
};
|
||||
|
||||
// Check if user has admin permissions
|
||||
export const isUserAdmin = (username: string): boolean => {
|
||||
const user = findUserByUsername(username);
|
||||
export const isUserAdmin = async (username: string): Promise<boolean> => {
|
||||
const userDao = getUserDao();
|
||||
const user = await userDao.findByUsername(username);
|
||||
return user?.isAdmin || false;
|
||||
};
|
||||
|
||||
// Get user count
|
||||
export const getUserCount = (): number => {
|
||||
return getUsers().length;
|
||||
export const getUserCount = async (): Promise<number> => {
|
||||
const userDao = getUserDao();
|
||||
return await userDao.count();
|
||||
};
|
||||
|
||||
// Get admin count
|
||||
export const getAdminCount = (): number => {
|
||||
return getUsers().filter((user) => user.isAdmin).length;
|
||||
export const getAdminCount = async (): Promise<number> => {
|
||||
const userDao = getUserDao();
|
||||
const admins = await userDao.findAdmins();
|
||||
return admins.length;
|
||||
};
|
||||
|
||||
@@ -283,6 +283,12 @@ export const searchToolsByVector = async (
|
||||
}>
|
||||
> => {
|
||||
try {
|
||||
// If serverNames is an empty array (not undefined), return empty results
|
||||
// This happens when using $smart/{group} with an empty or non-existent group
|
||||
if (serverNames !== undefined && serverNames.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const vectorRepository = getRepositoryFactory(
|
||||
'vectorEmbeddings',
|
||||
)() as VectorEmbeddingRepository;
|
||||
|
||||
@@ -176,12 +176,8 @@ export interface SystemConfig {
|
||||
}
|
||||
|
||||
export interface UserConfig {
|
||||
routing?: {
|
||||
enableGlobalRoute?: boolean; // Controls whether the /sse endpoint without group is enabled
|
||||
enableGroupNameRoute?: boolean; // Controls whether group routing by name is allowed
|
||||
enableBearerAuth?: boolean; // Controls whether bearer auth is enabled for group routes
|
||||
bearerAuthKey?: string; // The bearer auth key to validate against
|
||||
};
|
||||
routing?: Record<string, any>; // User-specific routing configuration
|
||||
[key: string]: any; // Allow additional dynamic properties
|
||||
}
|
||||
|
||||
// OAuth Client for MCPHub's own authorization server
|
||||
@@ -270,6 +266,7 @@ export interface ServerConfig {
|
||||
headers?: Record<string, string>; // HTTP headers for SSE/streamable-http/openapi servers
|
||||
enabled?: boolean; // Flag to enable/disable the server
|
||||
owner?: string; // Owner of the server, defaults to 'admin' user
|
||||
enableKeepAlive?: boolean; // Enable keep-alive for this server (requires global enable as well)
|
||||
keepAliveInterval?: number; // Keep-alive ping interval in milliseconds (default: 60000ms for SSE servers)
|
||||
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
|
||||
|
||||
195
src/utils/migration.ts
Normal file
195
src/utils/migration.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { loadOriginalSettings } from '../config/index.js';
|
||||
import { initializeDatabase } from '../db/connection.js';
|
||||
import { setDaoFactory } from '../dao/DaoFactory.js';
|
||||
import { DatabaseDaoFactory } from '../dao/DatabaseDaoFactory.js';
|
||||
import { UserRepository } from '../db/repositories/UserRepository.js';
|
||||
import { ServerRepository } from '../db/repositories/ServerRepository.js';
|
||||
import { GroupRepository } from '../db/repositories/GroupRepository.js';
|
||||
import { SystemConfigRepository } from '../db/repositories/SystemConfigRepository.js';
|
||||
import { UserConfigRepository } from '../db/repositories/UserConfigRepository.js';
|
||||
|
||||
/**
|
||||
* Migrate from file-based configuration to database
|
||||
*/
|
||||
export async function migrateToDatabase(): Promise<boolean> {
|
||||
try {
|
||||
console.log('Starting migration from file to database...');
|
||||
|
||||
// Initialize database connection
|
||||
await initializeDatabase();
|
||||
console.log('Database connection established');
|
||||
|
||||
// Load current settings from file
|
||||
const settings = loadOriginalSettings();
|
||||
console.log('Loaded settings from file');
|
||||
|
||||
// Create repositories
|
||||
const userRepo = new UserRepository();
|
||||
const serverRepo = new ServerRepository();
|
||||
const groupRepo = new GroupRepository();
|
||||
const systemConfigRepo = new SystemConfigRepository();
|
||||
const userConfigRepo = new UserConfigRepository();
|
||||
|
||||
// Migrate users
|
||||
if (settings.users && settings.users.length > 0) {
|
||||
console.log(`Migrating ${settings.users.length} users...`);
|
||||
for (const user of settings.users) {
|
||||
const exists = await userRepo.exists(user.username);
|
||||
if (!exists) {
|
||||
await userRepo.create({
|
||||
username: user.username,
|
||||
password: user.password,
|
||||
isAdmin: user.isAdmin || false,
|
||||
});
|
||||
console.log(` - Created user: ${user.username}`);
|
||||
} else {
|
||||
console.log(` - User already exists: ${user.username}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate servers
|
||||
if (settings.mcpServers) {
|
||||
const serverNames = Object.keys(settings.mcpServers);
|
||||
console.log(`Migrating ${serverNames.length} servers...`);
|
||||
for (const [name, config] of Object.entries(settings.mcpServers)) {
|
||||
const exists = await serverRepo.exists(name);
|
||||
if (!exists) {
|
||||
await serverRepo.create({
|
||||
name,
|
||||
type: config.type,
|
||||
url: config.url,
|
||||
command: config.command,
|
||||
args: config.args,
|
||||
env: config.env,
|
||||
headers: config.headers,
|
||||
enabled: config.enabled !== undefined ? config.enabled : true,
|
||||
owner: config.owner,
|
||||
enableKeepAlive: config.enableKeepAlive,
|
||||
keepAliveInterval: config.keepAliveInterval,
|
||||
tools: config.tools,
|
||||
prompts: config.prompts,
|
||||
options: config.options,
|
||||
oauth: config.oauth,
|
||||
});
|
||||
console.log(` - Created server: ${name}`);
|
||||
} else {
|
||||
console.log(` - Server already exists: ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate groups
|
||||
if (settings.groups && settings.groups.length > 0) {
|
||||
console.log(`Migrating ${settings.groups.length} groups...`);
|
||||
for (const group of settings.groups) {
|
||||
const exists = await groupRepo.existsByName(group.name);
|
||||
if (!exists) {
|
||||
await groupRepo.create({
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
servers: Array.isArray(group.servers) ? group.servers : [],
|
||||
owner: group.owner,
|
||||
});
|
||||
console.log(` - Created group: ${group.name}`);
|
||||
} else {
|
||||
console.log(` - Group already exists: ${group.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate system config
|
||||
if (settings.systemConfig) {
|
||||
console.log('Migrating system configuration...');
|
||||
const systemConfig = {
|
||||
routing: settings.systemConfig.routing || {},
|
||||
install: settings.systemConfig.install || {},
|
||||
smartRouting: settings.systemConfig.smartRouting || {},
|
||||
mcpRouter: settings.systemConfig.mcpRouter || {},
|
||||
nameSeparator: settings.systemConfig.nameSeparator,
|
||||
oauth: settings.systemConfig.oauth || {},
|
||||
oauthServer: settings.systemConfig.oauthServer || {},
|
||||
enableSessionRebuild: settings.systemConfig.enableSessionRebuild,
|
||||
};
|
||||
await systemConfigRepo.update(systemConfig);
|
||||
console.log(' - System configuration updated');
|
||||
}
|
||||
|
||||
// Migrate user configs
|
||||
if (settings.userConfigs) {
|
||||
const usernames = Object.keys(settings.userConfigs);
|
||||
console.log(`Migrating ${usernames.length} user configurations...`);
|
||||
for (const [username, config] of Object.entries(settings.userConfigs)) {
|
||||
const userConfig = {
|
||||
routing: config.routing || {},
|
||||
additionalConfig: config,
|
||||
};
|
||||
await userConfigRepo.update(username, userConfig);
|
||||
console.log(` - Updated configuration for user: ${username}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ Migration completed successfully');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('❌ Migration failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize database mode
|
||||
* This function should be called during application startup when USE_DB=true
|
||||
*/
|
||||
export async function initializeDatabaseMode(): Promise<boolean> {
|
||||
try {
|
||||
console.log('Initializing database mode...');
|
||||
|
||||
// Initialize database connection
|
||||
await initializeDatabase();
|
||||
console.log('Database connection established');
|
||||
|
||||
// Switch to database factory
|
||||
setDaoFactory(DatabaseDaoFactory.getInstance());
|
||||
console.log('Switched to database-backed DAO implementations');
|
||||
|
||||
// Check if migration is needed
|
||||
const userRepo = new UserRepository();
|
||||
const userCount = await userRepo.count();
|
||||
|
||||
if (userCount === 0) {
|
||||
console.log('No users found in database, running migration...');
|
||||
const migrated = await migrateToDatabase();
|
||||
if (!migrated) {
|
||||
throw new Error('Migration failed');
|
||||
}
|
||||
} else {
|
||||
console.log(`Database already contains ${userCount} users, skipping migration`);
|
||||
}
|
||||
|
||||
console.log('✅ Database mode initialized successfully');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to initialize database mode:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI tool for migration
|
||||
*/
|
||||
export async function runMigrationCli(): Promise<void> {
|
||||
console.log('MCPHub Configuration Migration Tool');
|
||||
console.log('====================================\n');
|
||||
|
||||
const success = await migrateToDatabase();
|
||||
|
||||
if (success) {
|
||||
console.log('\n✅ Migration completed successfully!');
|
||||
console.log('You can now set USE_DB=true to use database-backed configuration');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log('\n❌ Migration failed!');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { IUser } from '../types/index.js';
|
||||
/**
|
||||
* Resolve an MCPHub user from a raw OAuth bearer token.
|
||||
*/
|
||||
export const resolveOAuthUserFromToken = (token?: string): IUser | null => {
|
||||
export const resolveOAuthUserFromToken = async (token?: string): Promise<IUser | null> => {
|
||||
if (!token || !isOAuthServerEnabled()) {
|
||||
return null;
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export const resolveOAuthUserFromToken = (token?: string): IUser | null => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dbUser = findUserByUsername(oauthToken.username);
|
||||
const dbUser = await findUserByUsername(oauthToken.username);
|
||||
|
||||
return {
|
||||
username: oauthToken.username,
|
||||
@@ -28,7 +28,9 @@ export const resolveOAuthUserFromToken = (token?: string): IUser | null => {
|
||||
/**
|
||||
* Resolve an MCPHub user from an Authorization header.
|
||||
*/
|
||||
export const resolveOAuthUserFromAuthHeader = (authHeader?: string): IUser | null => {
|
||||
export const resolveOAuthUserFromAuthHeader = async (
|
||||
authHeader?: string,
|
||||
): Promise<IUser | null> => {
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -171,100 +171,37 @@ describe('Keepalive Functionality', () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('SSE Connection Keepalive', () => {
|
||||
it('should create a keepalive interval when establishing SSE connection', async () => {
|
||||
describe('SSE Connection (No Server-Side Keepalive)', () => {
|
||||
// Server-side keepalive was removed - keepalive is now only for upstream MCP server connections (client-side)
|
||||
// These tests verify that SSE connections work without server-side keepalive
|
||||
|
||||
it('should establish SSE connection without keepalive interval', async () => {
|
||||
await handleSseConnection(mockReq as Request, mockRes as Response);
|
||||
|
||||
// Verify setInterval was called with 30000ms (30 seconds)
|
||||
expect(global.setInterval).toHaveBeenCalledWith(expect.any(Function), 30000);
|
||||
// Verify no keepalive interval was created for server-side SSE
|
||||
expect(global.setInterval).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should send ping messages via transport', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
it('should register close event handler for cleanup', async () => {
|
||||
await handleSseConnection(mockReq as Request, mockRes as Response);
|
||||
|
||||
// Fast-forward time by 30 seconds
|
||||
jest.advanceTimersByTime(30000);
|
||||
|
||||
// Verify ping was sent using mockTransportInstance
|
||||
expect(mockTransportInstance.send).toHaveBeenCalledWith({
|
||||
jsonrpc: '2.0',
|
||||
method: 'ping',
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
// Verify close event handler was registered
|
||||
expect(mockRes.on).toHaveBeenCalledWith('close', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should send multiple pings at 30-second intervals', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
it('should clean up transport on connection close', async () => {
|
||||
await handleSseConnection(mockReq as Request, mockRes as Response);
|
||||
|
||||
// Fast-forward time by 90 seconds (3 intervals)
|
||||
jest.advanceTimersByTime(90000);
|
||||
|
||||
// Verify ping was sent 3 times using mockTransportInstance
|
||||
expect(mockTransportInstance.send).toHaveBeenCalledTimes(3);
|
||||
expect(mockTransportInstance.send).toHaveBeenCalledWith({
|
||||
jsonrpc: '2.0',
|
||||
method: 'ping',
|
||||
});
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('should clear keepalive interval when connection closes', async () => {
|
||||
await handleSseConnection(mockReq as Request, mockRes as Response);
|
||||
|
||||
// Verify interval was created
|
||||
expect(global.setInterval).toHaveBeenCalled();
|
||||
const intervalsBefore = intervals.length;
|
||||
expect(intervalsBefore).toBeGreaterThan(0);
|
||||
// Verify transport was registered
|
||||
expect(transports['test-session-id']).toBeDefined();
|
||||
|
||||
// Simulate connection close
|
||||
if (eventListeners['close']) {
|
||||
eventListeners['close']();
|
||||
}
|
||||
|
||||
// Verify clearInterval was called
|
||||
expect(global.clearInterval).toHaveBeenCalled();
|
||||
expect(intervals.length).toBeLessThan(intervalsBefore);
|
||||
});
|
||||
|
||||
it('should handle ping send errors gracefully', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
await handleSseConnection(mockReq as Request, mockRes as Response);
|
||||
|
||||
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
|
||||
|
||||
// Make transport.send throw an error on the first call
|
||||
let callCount = 0;
|
||||
mockTransportInstance.send.mockImplementation(() => {
|
||||
callCount++;
|
||||
throw new Error('Connection broken');
|
||||
});
|
||||
|
||||
// Fast-forward time by 30 seconds (first ping)
|
||||
jest.advanceTimersByTime(30000);
|
||||
|
||||
// Verify error was logged for the first ping
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to send keepalive ping'),
|
||||
expect.any(Error),
|
||||
);
|
||||
|
||||
const firstCallCount = callCount;
|
||||
|
||||
// Fast-forward time by another 30 seconds
|
||||
jest.advanceTimersByTime(30000);
|
||||
|
||||
// Verify no additional attempts were made after the error (interval was cleared)
|
||||
expect(callCount).toBe(firstCallCount);
|
||||
|
||||
consoleWarnSpy.mockRestore();
|
||||
jest.useRealTimers();
|
||||
// Verify transport was removed
|
||||
expect(transports['test-session-id']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not send pings after connection is closed', async () => {
|
||||
|
||||
@@ -12,31 +12,36 @@ jest.mock('openid-client', () => ({
|
||||
refreshTokenGrant: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock the DAO module
|
||||
jest.mock('../../src/dao/index.js', () => ({
|
||||
getSystemConfigDao: jest.fn(),
|
||||
getServerDao: jest.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
initOAuthProvider,
|
||||
isOAuthEnabled,
|
||||
getServerOAuthToken,
|
||||
addOAuthHeader,
|
||||
} from '../../src/services/oauthService.js';
|
||||
import * as config from '../../src/config/index.js';
|
||||
|
||||
// Mock the config module
|
||||
jest.mock('../../src/config/index.js', () => ({
|
||||
loadSettings: jest.fn(),
|
||||
}));
|
||||
import * as daoModule from '../../src/dao/index.js';
|
||||
|
||||
describe('OAuth Service', () => {
|
||||
const mockLoadSettings = config.loadSettings as jest.MockedFunction<typeof config.loadSettings>;
|
||||
const mockGetSystemConfigDao = daoModule.getSystemConfigDao as jest.MockedFunction<
|
||||
typeof daoModule.getSystemConfigDao
|
||||
>;
|
||||
const mockGetServerDao = daoModule.getServerDao as jest.MockedFunction<
|
||||
typeof daoModule.getServerDao
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('initOAuthProvider', () => {
|
||||
it('should not initialize OAuth when disabled', () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
it('should not initialize OAuth when disabled', async () => {
|
||||
mockGetSystemConfigDao.mockReturnValue({
|
||||
get: jest.fn().mockResolvedValue({
|
||||
oauth: {
|
||||
enabled: false,
|
||||
issuerUrl: 'http://auth.example.com',
|
||||
@@ -46,97 +51,90 @@ describe('OAuth Service', () => {
|
||||
},
|
||||
},
|
||||
enableSessionRebuild: false,
|
||||
},
|
||||
});
|
||||
}),
|
||||
} as any);
|
||||
|
||||
initOAuthProvider();
|
||||
await initOAuthProvider();
|
||||
expect(isOAuthEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should not initialize OAuth when not configured', () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
it('should not initialize OAuth when not configured', async () => {
|
||||
mockGetSystemConfigDao.mockReturnValue({
|
||||
get: jest.fn().mockResolvedValue({
|
||||
enableSessionRebuild: false,
|
||||
},
|
||||
});
|
||||
}),
|
||||
} as any);
|
||||
|
||||
initOAuthProvider();
|
||||
await initOAuthProvider();
|
||||
expect(isOAuthEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should attempt to initialize OAuth when enabled and properly configured', () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
oauth: {
|
||||
enabled: true,
|
||||
issuerUrl: 'http://auth.example.com',
|
||||
endpoints: {
|
||||
authorizationUrl: 'http://auth.example.com/authorize',
|
||||
tokenUrl: 'http://auth.example.com/token',
|
||||
},
|
||||
clients: [
|
||||
{
|
||||
client_id: 'test-client',
|
||||
redirect_uris: ['http://localhost:3000/callback'],
|
||||
},
|
||||
],
|
||||
it('should attempt to initialize OAuth when enabled and properly configured', async () => {
|
||||
const mockGet = jest.fn().mockResolvedValue({
|
||||
oauth: {
|
||||
enabled: true,
|
||||
issuerUrl: 'http://auth.example.com',
|
||||
endpoints: {
|
||||
authorizationUrl: 'http://auth.example.com/authorize',
|
||||
tokenUrl: 'http://auth.example.com/token',
|
||||
},
|
||||
enableSessionRebuild: false,
|
||||
clients: [
|
||||
{
|
||||
client_id: 'test-client',
|
||||
redirect_uris: ['http://localhost:3000/callback'],
|
||||
},
|
||||
],
|
||||
},
|
||||
enableSessionRebuild: false,
|
||||
});
|
||||
mockGetSystemConfigDao.mockReturnValue({
|
||||
get: mockGet,
|
||||
} as any);
|
||||
|
||||
// In a test environment, the ProxyOAuthServerProvider may not fully initialize
|
||||
// due to missing dependencies or network issues, which is expected
|
||||
initOAuthProvider();
|
||||
await initOAuthProvider();
|
||||
// We just verify that the function doesn't throw an error
|
||||
expect(mockLoadSettings).toHaveBeenCalled();
|
||||
expect(mockGet).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getServerOAuthToken', () => {
|
||||
it('should return undefined when server has no OAuth config', async () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
url: 'http://example.com',
|
||||
},
|
||||
},
|
||||
});
|
||||
mockGetServerDao.mockReturnValue({
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
url: 'http://example.com',
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const token = await getServerOAuthToken('test-server');
|
||||
expect(token).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when server has no access token', async () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
url: 'http://example.com',
|
||||
oauth: {
|
||||
clientId: 'test-client',
|
||||
},
|
||||
mockGetServerDao.mockReturnValue({
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
url: 'http://example.com',
|
||||
oauth: {
|
||||
clientId: 'test-client',
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const token = await getServerOAuthToken('test-server');
|
||||
expect(token).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return access token when configured', async () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
url: 'http://example.com',
|
||||
oauth: {
|
||||
clientId: 'test-client',
|
||||
accessToken: 'test-access-token',
|
||||
},
|
||||
mockGetServerDao.mockReturnValue({
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
url: 'http://example.com',
|
||||
oauth: {
|
||||
clientId: 'test-client',
|
||||
accessToken: 'test-access-token',
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const token = await getServerOAuthToken('test-server');
|
||||
expect(token).toBe('test-access-token');
|
||||
@@ -145,13 +143,11 @@ describe('OAuth Service', () => {
|
||||
|
||||
describe('addOAuthHeader', () => {
|
||||
it('should not modify headers when no OAuth token is configured', async () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
url: 'http://example.com',
|
||||
},
|
||||
},
|
||||
});
|
||||
mockGetServerDao.mockReturnValue({
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
url: 'http://example.com',
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
const result = await addOAuthHeader('test-server', headers);
|
||||
@@ -161,17 +157,15 @@ describe('OAuth Service', () => {
|
||||
});
|
||||
|
||||
it('should add Authorization header when OAuth token is configured', async () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
url: 'http://example.com',
|
||||
oauth: {
|
||||
clientId: 'test-client',
|
||||
accessToken: 'test-access-token',
|
||||
},
|
||||
mockGetServerDao.mockReturnValue({
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
url: 'http://example.com',
|
||||
oauth: {
|
||||
clientId: 'test-client',
|
||||
accessToken: 'test-access-token',
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
const result = await addOAuthHeader('test-server', headers);
|
||||
@@ -183,17 +177,15 @@ describe('OAuth Service', () => {
|
||||
});
|
||||
|
||||
it('should preserve existing headers when adding OAuth token', async () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
url: 'http://example.com',
|
||||
oauth: {
|
||||
clientId: 'test-client',
|
||||
accessToken: 'test-access-token',
|
||||
},
|
||||
mockGetServerDao.mockReturnValue({
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
url: 'http://example.com',
|
||||
oauth: {
|
||||
clientId: 'test-client',
|
||||
accessToken: 'test-access-token',
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
133
tests/services/vectorSearchService.test.ts
Normal file
133
tests/services/vectorSearchService.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
|
||||
// Mock dependencies before importing vectorSearchService
|
||||
jest.mock('../../src/db/index.js', () => ({
|
||||
getRepositoryFactory: jest.fn(() => () => ({
|
||||
searchByText: jest.fn(),
|
||||
saveEmbedding: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/db/connection.js', () => ({
|
||||
getAppDataSource: jest.fn(() => ({
|
||||
isInitialized: true,
|
||||
query: jest.fn(),
|
||||
})),
|
||||
initializeDatabase: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/smartRouting.js', () => ({
|
||||
getSmartRoutingConfig: jest.fn(() => ({
|
||||
enabled: true,
|
||||
openaiApiKey: 'test-key',
|
||||
openaiApiBaseUrl: 'https://api.openai.com/v1',
|
||||
openaiApiEmbeddingModel: 'text-embedding-3-small',
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('openai', () => {
|
||||
return {
|
||||
default: jest.fn().mockImplementation(() => ({
|
||||
apiKey: 'test-key',
|
||||
embeddings: {
|
||||
create: jest.fn().mockResolvedValue({
|
||||
data: [{ embedding: new Array(1536).fill(0.1) }],
|
||||
}),
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// Import after mocks are set up
|
||||
import { searchToolsByVector } from '../../src/services/vectorSearchService.js';
|
||||
import { getRepositoryFactory } from '../../src/db/index.js';
|
||||
|
||||
describe('vectorSearchService', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('searchToolsByVector', () => {
|
||||
it('should return empty array when serverNames is an empty array', async () => {
|
||||
// This test verifies the fix for the $smart/group routing issue
|
||||
// When serverNames is an empty array (empty group), no results should be returned
|
||||
const result = await searchToolsByVector('test query', 10, 0.3, []);
|
||||
|
||||
// Result should be empty when an empty server list is passed
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should search all servers when serverNames is undefined', async () => {
|
||||
const mockSearchResults = [
|
||||
{
|
||||
similarity: 0.9,
|
||||
embedding: {
|
||||
text_content: 'test tool description',
|
||||
metadata: JSON.stringify({
|
||||
serverName: 'server1',
|
||||
toolName: 'tool1',
|
||||
description: 'Test tool 1',
|
||||
inputSchema: {},
|
||||
}),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const mockRepository = {
|
||||
searchByText: jest.fn().mockResolvedValue(mockSearchResults),
|
||||
saveEmbedding: jest.fn(),
|
||||
};
|
||||
|
||||
(getRepositoryFactory as jest.Mock).mockReturnValue(() => mockRepository);
|
||||
|
||||
const result = await searchToolsByVector('test query', 10, 0.3, undefined);
|
||||
|
||||
// searchByText should be called since serverNames is undefined
|
||||
expect(mockRepository.searchByText).toHaveBeenCalled();
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result[0].serverName).toBe('server1');
|
||||
});
|
||||
|
||||
it('should filter results by serverNames when provided', async () => {
|
||||
const mockSearchResults = [
|
||||
{
|
||||
similarity: 0.9,
|
||||
embedding: {
|
||||
text_content: 'test tool 1',
|
||||
metadata: JSON.stringify({
|
||||
serverName: 'server1',
|
||||
toolName: 'tool1',
|
||||
description: 'Test tool 1',
|
||||
inputSchema: {},
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
similarity: 0.85,
|
||||
embedding: {
|
||||
text_content: 'test tool 2',
|
||||
metadata: JSON.stringify({
|
||||
serverName: 'server2',
|
||||
toolName: 'tool2',
|
||||
description: 'Test tool 2',
|
||||
inputSchema: {},
|
||||
}),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const mockRepository = {
|
||||
searchByText: jest.fn().mockResolvedValue(mockSearchResults),
|
||||
saveEmbedding: jest.fn(),
|
||||
};
|
||||
|
||||
(getRepositoryFactory as jest.Mock).mockReturnValue(() => mockRepository);
|
||||
|
||||
// Filter to only server1
|
||||
const result = await searchToolsByVector('test query', 10, 0.3, ['server1']);
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].serverName).toBe('server1');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user