mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 02:39:19 -05:00
Compare commits
24 Commits
copilot/fi
...
v0.11.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bd28ec89b | ||
|
|
41a42f82d0 | ||
|
|
7aa3ff3bb1 | ||
|
|
71667dab2c | ||
|
|
1921a0363b | ||
|
|
f9fe2e444b | ||
|
|
8d420a927b | ||
|
|
cb77593fd7 | ||
|
|
dbcebecf40 | ||
|
|
54e877cbd8 | ||
|
|
61b748151f | ||
|
|
4f05815210 | ||
|
|
691d91f207 | ||
|
|
3d58042ce5 | ||
|
|
81486b09df | ||
|
|
a41707c228 | ||
|
|
7391e57f35 | ||
|
|
9d8f5ba370 | ||
|
|
764959eaca | ||
|
|
b5dff990e5 | ||
|
|
19f11a0927 | ||
|
|
884870c9de | ||
|
|
7b8d9a7e5a | ||
|
|
8770b9ccfe |
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
|
||||
|
||||
@@ -2,7 +2,7 @@ FROM python:3.13-slim-bookworm AS base
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||
|
||||
RUN apt-get update && apt-get install -y curl gnupg git \
|
||||
RUN apt-get update && apt-get install -y curl gnupg git build-essential \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
216
README.fr.md
216
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,201 +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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Mode Base de données (NOUVEAU)
|
||||
|
||||
MCPHub prend en charge le stockage de la configuration dans une base de données PostgreSQL comme alternative au fichier `mcp_settings.json`. Le mode base de données offre une persistance et une évolutivité améliorées pour les environnements de production et les déploiements d'entreprise.
|
||||
|
||||
**Avantages principaux :**
|
||||
|
||||
- ✅ **Meilleure persistance** - Configuration stockée dans une base de données professionnelle avec support des transactions et intégrité des données
|
||||
- ✅ **Haute disponibilité** - Profitez des capacités de réplication et de basculement de la base de données
|
||||
- ✅ **Prêt pour l'entreprise** - Répond aux exigences de gestion des données et de conformité d'entreprise
|
||||
- ✅ **Sauvegarde et récupération** - Utilisez des outils et stratégies de sauvegarde de base de données matures
|
||||
|
||||
**Variables d'environnement :**
|
||||
|
||||
```bash
|
||||
# Définissez simplement DB_URL pour activer automatiquement le mode base de données
|
||||
DB_URL=postgresql://user:password@host:5432/mcphub
|
||||
|
||||
# Ou contrôlez explicitement avec USE_DB (optionnel, remplace la détection automatique)
|
||||
# USE_DB=true
|
||||
```
|
||||
|
||||
> **Note** : Vous n'avez qu'à définir `DB_URL` pour activer le mode base de données. MCPHub détectera automatiquement et activera le mode base de données lorsque `DB_URL` est présent. Utilisez `USE_DB=false` pour désactiver explicitement le mode base de données même lorsque `DB_URL` est défini.
|
||||
|
||||
📖 Consultez le [Guide de configuration de la base de données](docs/configuration/database-configuration.mdx) complet pour :
|
||||
|
||||
- Instructions de configuration détaillées
|
||||
- Migration depuis la configuration basée sur fichiers
|
||||
- Procédures de sauvegarde et de restauration
|
||||
- Conseils de dépannage
|
||||
📖 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
|
||||
|
||||
@@ -218,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
|
||||
|
||||
@@ -241,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
|
||||
|
||||
321
README.md
321
README.md
@@ -13,308 +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).
|
||||
|
||||
### Database Mode (NEW)
|
||||
|
||||
MCPHub supports storing configuration in a PostgreSQL database as an alternative to `mcp_settings.json`. Database mode provides enhanced persistence and scalability for production environments and enterprise deployments.
|
||||
|
||||
**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:**
|
||||
|
||||
```bash
|
||||
# Simply set DB_URL to enable database mode (auto-detected)
|
||||
DB_URL=postgresql://user:password@host:5432/mcphub
|
||||
|
||||
# Or explicitly control with USE_DB (optional, overrides auto-detection)
|
||||
# USE_DB=true
|
||||
```
|
||||
|
||||
> **Note**: 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.
|
||||
|
||||
📖 See the complete [Database Configuration Guide](docs/configuration/database-configuration.mdx) for:
|
||||
|
||||
- Detailed setup instructions
|
||||
- Migration from file-based config
|
||||
- Backup and restore procedures
|
||||
- Troubleshooting tips
|
||||
📖 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
|
||||
|
||||
@@ -325,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
|
||||
|
||||
@@ -348,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
|
||||
|
||||
276
README.zh.md
276
README.zh.md
@@ -13,267 +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`(或你的部署域名),然后在控制台或配置文件中填写凭据。
|
||||
|
||||
### 数据库模式(新功能)
|
||||
|
||||
MCPHub 支持将配置数据存储在 PostgreSQL 数据库中,作为 `mcp_settings.json` 文件配置的替代方案。数据库模式为生产环境和企业级部署提供了更强大的持久化和扩展能力。
|
||||
|
||||
**核心优势:**
|
||||
|
||||
- ✅ **更好的持久化** - 配置数据存储在专业数据库中,支持事务和数据完整性
|
||||
- ✅ **高可用性** - 利用数据库复制和故障转移能力
|
||||
- ✅ **企业级支持** - 符合企业数据管理和合规要求
|
||||
- ✅ **备份恢复** - 使用成熟的数据库备份工具和策略
|
||||
|
||||
**环境变量:**
|
||||
|
||||
```bash
|
||||
# 只需设置 DB_URL 即可自动启用数据库模式
|
||||
DB_URL=postgresql://user:password@host:5432/mcphub
|
||||
|
||||
# 或显式控制 USE_DB(可选,覆盖自动检测)
|
||||
# USE_DB=true
|
||||
```
|
||||
|
||||
> **提示**:您只需设置 `DB_URL` 即可启用数据库模式。MCPHub 会自动检测 `DB_URL` 是否存在并启用数据库模式。如果需要在设置了 `DB_URL` 的情况下禁用数据库模式,可以显式设置 `USE_DB=false`。
|
||||
|
||||
📖 查看完整的[数据库配置指南](docs/zh/configuration/database-configuration.mdx)了解:
|
||||
|
||||
- 详细的设置说明
|
||||
- 从文件配置迁移
|
||||
- 备份和恢复流程
|
||||
- 故障排除技巧
|
||||
📖 查看[配置指南](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 部署指南 |
|
||||
|
||||
## 🧑💻 本地开发
|
||||
|
||||
@@ -284,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)了解详细设置说明。
|
||||
|
||||
## 🔍 技术栈
|
||||
|
||||
@@ -307,13 +104,6 @@ proxy_buffering off
|
||||
|
||||
## 👥 贡献指南
|
||||
|
||||
期待您的贡献!
|
||||
|
||||
- 新功能与优化
|
||||
- 文档完善
|
||||
- Bug 报告与修复
|
||||
- 翻译与建议
|
||||
|
||||
欢迎加入企微交流共建群,由于群人数限制,有兴趣的同学可以扫码添加管理员为好友后拉入群聊。
|
||||
|
||||
<img src="assets/wexin.png" width="350">
|
||||
@@ -324,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
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
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
|
||||
@@ -23,34 +28,29 @@ DB_URL=postgresql://user:password@localhost:5432/mcphub
|
||||
|
||||
# Or explicitly control with USE_DB (overrides auto-detection)
|
||||
# USE_DB=true
|
||||
|
||||
# Alternative: Use separate components
|
||||
# DB_HOST=localhost
|
||||
# DB_PORT=5432
|
||||
# DB_NAME=mcphub
|
||||
# DB_USER=user
|
||||
# DB_PASSWORD=password
|
||||
```
|
||||
|
||||
<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>
|
||||
|
||||
### Optional Settings
|
||||
|
||||
```bash
|
||||
# Automatic migration on startup (default: true)
|
||||
AUTO_MIGRATE=true
|
||||
|
||||
# Keep file-based config as fallback (default: false)
|
||||
KEEP_FILE_CONFIG=false
|
||||
```
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Using Docker
|
||||
|
||||
#### Option A: Using PostgreSQL as a separate service
|
||||
#### 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`:
|
||||
|
||||
@@ -89,18 +89,6 @@ Run with:
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
#### Option B: Using External Database
|
||||
|
||||
If you already have a PostgreSQL database:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 3000:3000 \
|
||||
-e USE_DB=true \
|
||||
-e DB_URL="postgresql://user:password@your-db-host:5432/mcphub" \
|
||||
samanhappy/mcphub:latest
|
||||
```
|
||||
|
||||
### 2. Manual Setup
|
||||
|
||||
#### Step 1: Setup PostgreSQL Database
|
||||
@@ -232,7 +220,7 @@ docker exec -i postgres psql -U mcphub mcphub < mcphub_backup.sql
|
||||
|
||||
If you need to switch back to file-based configuration:
|
||||
|
||||
1. Set `USE_DB=false` or remove the environment variable
|
||||
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
|
||||
|
||||
@@ -288,12 +276,6 @@ Database tables are automatically created if they don't exist. If you get errors
|
||||
|----------|----------|---------|-------------|
|
||||
| `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 |
|
||||
| `DB_HOST` | No | `localhost` | Database host (if not using DB_URL) |
|
||||
| `DB_PORT` | No | `5432` | Database port (if not using DB_URL) |
|
||||
| `DB_NAME` | No | `mcphub` | Database name (if not using DB_URL) |
|
||||
| `DB_USER` | No | `mcphub` | Database user (if not using DB_URL) |
|
||||
| `DB_PASSWORD` | No | - | Database password (if not using DB_URL) |
|
||||
| `AUTO_MIGRATE` | No | `true` | Auto-migrate from file on first start |
|
||||
| `MCPHUB_SETTING_PATH` | No | - | Path to mcp_settings.json (for migration) |
|
||||
|
||||
*Required for database mode. Simply setting `DB_URL` enables database mode automatically
|
||||
@@ -320,9 +302,3 @@ File mode may be faster for:
|
||||
- Single user setups
|
||||
- Read-heavy workloads with infrequent changes
|
||||
- Development/testing environments
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
- GitHub Issues: https://github.com/samanhappy/mcphub/issues
|
||||
- Documentation: https://mcphub.io/docs
|
||||
|
||||
@@ -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的数据管理变得更加结构化、可维护和可扩展。
|
||||
@@ -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. **质量保证**: 代码覆盖率和持续测试验证
|
||||
|
||||
这个测试框架为项目的持续发展和质量保证提供了坚实的基础,支持敏捷开发和持续集成的最佳实践。
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
title: '数据库配置'
|
||||
description: '使用 PostgreSQL 数据库配置 MCPHub 作为 mcp_settings.json 文件的替代方案。'
|
||||
---
|
||||
|
||||
# MCPHub 数据库配置
|
||||
|
||||
## 概述
|
||||
@@ -23,34 +28,29 @@ DB_URL=postgresql://user:password@localhost:5432/mcphub
|
||||
|
||||
# 或显式控制 USE_DB(覆盖自动检测)
|
||||
# USE_DB=true
|
||||
|
||||
# 替代方案:使用单独的配置项
|
||||
# DB_HOST=localhost
|
||||
# DB_PORT=5432
|
||||
# DB_NAME=mcphub
|
||||
# DB_USER=user
|
||||
# DB_PASSWORD=password
|
||||
```
|
||||
|
||||
<Note>
|
||||
**简化配置**:您只需设置 `DB_URL` 即可启用数据库模式。MCPHub 会自动检测 `DB_URL` 是否存在并启用数据库模式。如果需要在设置了 `DB_URL` 的情况下禁用数据库模式,可以显式设置 `USE_DB=false`。
|
||||
</Note>
|
||||
|
||||
### 可选设置
|
||||
|
||||
```bash
|
||||
# 启动时自动迁移(默认:true)
|
||||
AUTO_MIGRATE=true
|
||||
|
||||
# 保留基于文件的配置作为后备(默认:false)
|
||||
KEEP_FILE_CONFIG=false
|
||||
```
|
||||
|
||||
## 设置说明
|
||||
|
||||
### 1. 使用 Docker
|
||||
|
||||
#### 方案 A:将 PostgreSQL 作为独立服务
|
||||
#### 方案 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` 文件:
|
||||
|
||||
@@ -89,18 +89,6 @@ volumes:
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
#### 方案 B:使用外部数据库
|
||||
|
||||
如果您已有 PostgreSQL 数据库:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 3000:3000 \
|
||||
-e USE_DB=true \
|
||||
-e DB_URL="postgresql://user:password@your-db-host:5432/mcphub" \
|
||||
samanhappy/mcphub:latest
|
||||
```
|
||||
|
||||
### 2. 手动设置
|
||||
|
||||
#### 步骤 1:设置 PostgreSQL 数据库
|
||||
@@ -232,7 +220,7 @@ docker exec -i postgres psql -U mcphub mcphub < mcphub_backup.sql
|
||||
|
||||
如果您需要切换回基于文件的配置:
|
||||
|
||||
1. 设置 `USE_DB=false` 或删除该环境变量
|
||||
1. 设置 `USE_DB=false` 或删除 `DB_URL` 和 `USE_DB` 环境变量
|
||||
2. 重启 MCPHub
|
||||
3. MCPHub 将再次使用 `mcp_settings.json`
|
||||
|
||||
@@ -288,12 +276,6 @@ Error: password authentication failed for user "mcphub"
|
||||
|------|------|--------|------|
|
||||
| `DB_URL` | 是* | - | 完整的 PostgreSQL 连接 URL。设置此变量会自动启用数据库模式 |
|
||||
| `USE_DB` | 否 | 自动 | 显式启用/禁用数据库模式。如果未设置,根据 `DB_URL` 是否存在自动检测 |
|
||||
| `DB_HOST` | 否 | `localhost` | 数据库主机(如果不使用 DB_URL) |
|
||||
| `DB_PORT` | 否 | `5432` | 数据库端口(如果不使用 DB_URL) |
|
||||
| `DB_NAME` | 否 | `mcphub` | 数据库名称(如果不使用 DB_URL) |
|
||||
| `DB_USER` | 否 | `mcphub` | 数据库用户(如果不使用 DB_URL) |
|
||||
| `DB_PASSWORD` | 否 | - | 数据库密码(如果不使用 DB_URL) |
|
||||
| `AUTO_MIGRATE` | 否 | `true` | 首次启动时自动从文件迁移 |
|
||||
| `MCPHUB_SETTING_PATH` | 否 | - | mcp_settings.json 的路径(用于迁移) |
|
||||
|
||||
*数据库模式必需。只需设置 `DB_URL` 即可自动启用数据库模式
|
||||
@@ -320,9 +302,3 @@ Error: password authentication failed for user "mcphub"
|
||||
- 单用户设置
|
||||
- 读取密集型工作负载且更改不频繁
|
||||
- 开发/测试环境
|
||||
|
||||
## 支持
|
||||
|
||||
如有问题或疑问:
|
||||
- GitHub Issues: https://github.com/samanhappy/mcphub/issues
|
||||
- 文档: https://mcphub.io/docs
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AuthProvider } from './contexts/AuthContext';
|
||||
import { ToastProvider } from './contexts/ToastContext';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { ServerProvider } from './contexts/ServerContext';
|
||||
import { SettingsProvider } from './contexts/SettingsContext';
|
||||
import MainLayout from './layouts/MainLayout';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
@@ -27,42 +28,41 @@ function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<ServerProvider>
|
||||
<ToastProvider>
|
||||
<Router basename={basename}>
|
||||
<Routes>
|
||||
{/* 公共路由 */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<ServerProvider>
|
||||
<ToastProvider>
|
||||
<SettingsProvider>
|
||||
<Router basename={basename}>
|
||||
<Routes>
|
||||
{/* 公共路由 */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* 受保护的路由,使用 MainLayout 作为布局容器 */}
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/servers" element={<ServersPage />} />
|
||||
<Route path="/groups" element={<GroupsPage />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/market" element={<MarketPage />} />
|
||||
<Route path="/market/:serverName" element={<MarketPage />} />
|
||||
{/* Legacy cloud routes redirect to market with cloud tab */}
|
||||
<Route path="/cloud" element={<Navigate to="/market?tab=cloud" replace />} />
|
||||
<Route
|
||||
path="/cloud/:serverName"
|
||||
element={<CloudRedirect />}
|
||||
/>
|
||||
<Route path="/logs" element={<LogsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
{/* 受保护的路由,使用 MainLayout 作为布局容器 */}
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/servers" element={<ServersPage />} />
|
||||
<Route path="/groups" element={<GroupsPage />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/market" element={<MarketPage />} />
|
||||
<Route path="/market/:serverName" element={<MarketPage />} />
|
||||
{/* Legacy cloud routes redirect to market with cloud tab */}
|
||||
<Route path="/cloud" element={<Navigate to="/market?tab=cloud" replace />} />
|
||||
<Route path="/cloud/:serverName" element={<CloudRedirect />} />
|
||||
<Route path="/logs" element={<LogsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
{/* 未匹配的路由重定向到首页 */}
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</ToastProvider>
|
||||
{/* 未匹配的路由重定向到首页 */}
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</SettingsProvider>
|
||||
</ToastProvider>
|
||||
</ServerProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
|
||||
@@ -15,14 +15,16 @@ interface ServerCardProps {
|
||||
onEdit: (server: Server) => void;
|
||||
onToggle?: (server: Server, enabled: boolean) => Promise<boolean>;
|
||||
onRefresh?: () => void;
|
||||
onReload?: (server: Server) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCardProps) => {
|
||||
const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh, onReload }: ServerCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [isToggling, setIsToggling] = useState(false);
|
||||
const [isReloading, setIsReloading] = useState(false);
|
||||
const [showErrorPopover, setShowErrorPopover] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const errorPopoverRef = useRef<HTMLDivElement>(null);
|
||||
@@ -64,6 +66,26 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
}
|
||||
};
|
||||
|
||||
const handleReload = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (isReloading || !onReload) return;
|
||||
|
||||
setIsReloading(true);
|
||||
try {
|
||||
const success = await onReload(server);
|
||||
if (success) {
|
||||
showToast(t('server.reloadSuccess') || 'Server reloaded successfully', 'success');
|
||||
} else {
|
||||
showToast(
|
||||
t('server.reloadError', { serverName: server.name }) || 'Failed to reload server',
|
||||
'error',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsReloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleErrorIconClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setShowErrorPopover(!showErrorPopover);
|
||||
@@ -106,6 +128,10 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const result = await exportMCPSettings(server.name);
|
||||
if (!result || !result.success || !result.data) {
|
||||
showToast(result?.message || t('common.copyFailed') || 'Copy failed', 'error');
|
||||
return;
|
||||
}
|
||||
const configJson = JSON.stringify(result.data, null, 2);
|
||||
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
@@ -326,7 +352,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
? 'bg-green-100 text-green-800 hover:bg-green-200 btn-secondary'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-primary'
|
||||
}`}
|
||||
disabled={isToggling}
|
||||
disabled={isToggling || isReloading}
|
||||
>
|
||||
{isToggling
|
||||
? t('common.processing')
|
||||
@@ -335,6 +361,15 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
: t('server.enable')}
|
||||
</button>
|
||||
</div>
|
||||
{server.enabled !== false && onReload && (
|
||||
<button
|
||||
onClick={handleReload}
|
||||
className="px-3 py-1 bg-purple-100 text-purple-800 rounded hover:bg-purple-200 text-sm btn-secondary disabled:opacity-70 disabled:cursor-not-allowed"
|
||||
disabled={isReloading || isToggling}
|
||||
>
|
||||
{isReloading ? t('common.processing') : t('server.reload')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
className="px-3 py-1 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm btn-danger"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,152 +1,174 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Prompt } from '@/types'
|
||||
import { ChevronDown, ChevronRight, Play, Loader, Edit, Check } from '@/components/icons/LucideIcons'
|
||||
import { Switch } from './ToggleGroup'
|
||||
import { getPrompt, PromptCallResult } from '@/services/promptService'
|
||||
import { useSettingsData } from '@/hooks/useSettingsData'
|
||||
import DynamicForm from './DynamicForm'
|
||||
import PromptResult from './PromptResult'
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Prompt } from '@/types';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Play,
|
||||
Loader,
|
||||
Edit,
|
||||
Check,
|
||||
} from '@/components/icons/LucideIcons';
|
||||
import { Switch } from './ToggleGroup';
|
||||
import { getPrompt, updatePromptDescription, PromptCallResult } from '@/services/promptService';
|
||||
import { useSettingsData } from '@/hooks/useSettingsData';
|
||||
import DynamicForm from './DynamicForm';
|
||||
import PromptResult from './PromptResult';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
|
||||
interface PromptCardProps {
|
||||
server: string
|
||||
prompt: Prompt
|
||||
onToggle?: (promptName: string, enabled: boolean) => void
|
||||
onDescriptionUpdate?: (promptName: string, description: string) => void
|
||||
server: string;
|
||||
prompt: Prompt;
|
||||
onToggle?: (promptName: string, enabled: boolean) => void;
|
||||
onDescriptionUpdate?: (promptName: string, description: string) => void;
|
||||
}
|
||||
|
||||
const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { nameSeparator } = useSettingsData()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [showRunForm, setShowRunForm] = useState(false)
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [result, setResult] = useState<PromptCallResult | null>(null)
|
||||
const [isEditingDescription, setIsEditingDescription] = useState(false)
|
||||
const [customDescription, setCustomDescription] = useState(prompt.description || '')
|
||||
const descriptionInputRef = useRef<HTMLInputElement>(null)
|
||||
const descriptionTextRef = useRef<HTMLSpanElement>(null)
|
||||
const [textWidth, setTextWidth] = useState<number>(0)
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
const { nameSeparator } = useSettingsData();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [showRunForm, setShowRunForm] = useState(false);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [result, setResult] = useState<PromptCallResult | null>(null);
|
||||
const [isEditingDescription, setIsEditingDescription] = useState(false);
|
||||
const [customDescription, setCustomDescription] = useState(prompt.description || '');
|
||||
const descriptionInputRef = useRef<HTMLInputElement>(null);
|
||||
const descriptionTextRef = useRef<HTMLSpanElement>(null);
|
||||
const [textWidth, setTextWidth] = useState<number>(0);
|
||||
|
||||
// Focus the input when editing mode is activated
|
||||
useEffect(() => {
|
||||
if (isEditingDescription && descriptionInputRef.current) {
|
||||
descriptionInputRef.current.focus()
|
||||
descriptionInputRef.current.focus();
|
||||
// Set input width to match text width
|
||||
if (textWidth > 0) {
|
||||
descriptionInputRef.current.style.width = `${textWidth + 20}px` // Add some padding
|
||||
descriptionInputRef.current.style.width = `${textWidth + 20}px`; // Add some padding
|
||||
}
|
||||
}
|
||||
}, [isEditingDescription, textWidth])
|
||||
}, [isEditingDescription, textWidth]);
|
||||
|
||||
// Measure text width when not editing
|
||||
useEffect(() => {
|
||||
if (!isEditingDescription && descriptionTextRef.current) {
|
||||
setTextWidth(descriptionTextRef.current.offsetWidth)
|
||||
setTextWidth(descriptionTextRef.current.offsetWidth);
|
||||
}
|
||||
}, [isEditingDescription, customDescription])
|
||||
}, [isEditingDescription, customDescription]);
|
||||
|
||||
// Generate a unique key for localStorage based on prompt name and server
|
||||
const getStorageKey = useCallback(() => {
|
||||
return `mcphub_prompt_form_${server ? `${server}_` : ''}${prompt.name}`
|
||||
}, [prompt.name, server])
|
||||
return `mcphub_prompt_form_${server ? `${server}_` : ''}${prompt.name}`;
|
||||
}, [prompt.name, server]);
|
||||
|
||||
// Clear form data from localStorage
|
||||
const clearStoredFormData = useCallback(() => {
|
||||
localStorage.removeItem(getStorageKey())
|
||||
}, [getStorageKey])
|
||||
localStorage.removeItem(getStorageKey());
|
||||
}, [getStorageKey]);
|
||||
|
||||
const handleToggle = (enabled: boolean) => {
|
||||
if (onToggle) {
|
||||
onToggle(prompt.name, enabled)
|
||||
onToggle(prompt.name, enabled);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDescriptionEdit = () => {
|
||||
setIsEditingDescription(true)
|
||||
}
|
||||
setIsEditingDescription(true);
|
||||
};
|
||||
|
||||
const handleDescriptionSave = async () => {
|
||||
// For now, we'll just update the local state
|
||||
// In a real implementation, you would call an API to update the description
|
||||
setIsEditingDescription(false)
|
||||
if (onDescriptionUpdate) {
|
||||
onDescriptionUpdate(prompt.name, customDescription)
|
||||
setIsEditingDescription(false);
|
||||
try {
|
||||
const result = await updatePromptDescription(server, prompt.name, customDescription);
|
||||
if (result.success) {
|
||||
showToast(t('prompt.descriptionUpdateSuccess'), 'success');
|
||||
if (onDescriptionUpdate) {
|
||||
onDescriptionUpdate(prompt.name, customDescription);
|
||||
}
|
||||
} else {
|
||||
showToast(result.error || t('prompt.descriptionUpdateFailed'), 'error');
|
||||
// Revert to original description on failure
|
||||
setCustomDescription(prompt.description || '');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating prompt description:', error);
|
||||
showToast(t('prompt.descriptionUpdateFailed'), 'error');
|
||||
// Revert to original description on failure
|
||||
setCustomDescription(prompt.description || '');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDescriptionChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setCustomDescription(e.target.value)
|
||||
}
|
||||
setCustomDescription(e.target.value);
|
||||
};
|
||||
|
||||
const handleDescriptionKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleDescriptionSave()
|
||||
handleDescriptionSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
setCustomDescription(prompt.description || '')
|
||||
setIsEditingDescription(false)
|
||||
setCustomDescription(prompt.description || '');
|
||||
setIsEditingDescription(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleGetPrompt = async (arguments_: Record<string, any>) => {
|
||||
setIsRunning(true)
|
||||
setIsRunning(true);
|
||||
try {
|
||||
const result = await getPrompt({ promptName: prompt.name, arguments: arguments_ }, server)
|
||||
console.log('GetPrompt result:', result)
|
||||
const result = await getPrompt({ promptName: prompt.name, arguments: arguments_ }, server);
|
||||
console.log('GetPrompt result:', result);
|
||||
setResult({
|
||||
success: result.success,
|
||||
data: result.data,
|
||||
error: result.error
|
||||
})
|
||||
error: result.error,
|
||||
});
|
||||
// Clear form data on successful submission
|
||||
// clearStoredFormData()
|
||||
} catch (error) {
|
||||
setResult({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
})
|
||||
});
|
||||
} finally {
|
||||
setIsRunning(false)
|
||||
setIsRunning(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelRun = () => {
|
||||
setShowRunForm(false)
|
||||
setShowRunForm(false);
|
||||
// Clear form data when cancelled
|
||||
clearStoredFormData()
|
||||
setResult(null)
|
||||
}
|
||||
clearStoredFormData();
|
||||
setResult(null);
|
||||
};
|
||||
|
||||
const handleCloseResult = () => {
|
||||
setResult(null)
|
||||
}
|
||||
setResult(null);
|
||||
};
|
||||
|
||||
// Convert prompt arguments to ToolInputSchema format for DynamicForm
|
||||
const convertToSchema = () => {
|
||||
if (!prompt.arguments || prompt.arguments.length === 0) {
|
||||
return { type: 'object', properties: {}, required: [] }
|
||||
return { type: 'object', properties: {}, required: [] };
|
||||
}
|
||||
|
||||
const properties: Record<string, any> = {}
|
||||
const required: string[] = []
|
||||
const properties: Record<string, any> = {};
|
||||
const required: string[] = [];
|
||||
|
||||
prompt.arguments.forEach(arg => {
|
||||
prompt.arguments.forEach((arg) => {
|
||||
properties[arg.name] = {
|
||||
type: 'string', // Default to string for prompts
|
||||
description: arg.description || ''
|
||||
}
|
||||
description: arg.description || '',
|
||||
};
|
||||
|
||||
if (arg.required) {
|
||||
required.push(arg.name)
|
||||
required.push(arg.name);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'object',
|
||||
properties,
|
||||
required
|
||||
}
|
||||
}
|
||||
required,
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 shadow rounded-lg p-4 mb-4">
|
||||
@@ -158,9 +180,7 @@ const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCar
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
{prompt.name.replace(server + nameSeparator, '')}
|
||||
{prompt.title && (
|
||||
<span className="ml-2 text-sm font-normal text-gray-600">
|
||||
{prompt.title}
|
||||
</span>
|
||||
<span className="ml-2 text-sm font-normal text-gray-600">{prompt.title}</span>
|
||||
)}
|
||||
<span className="ml-2 text-sm font-normal text-gray-500 inline-flex items-center">
|
||||
{isEditingDescription ? (
|
||||
@@ -175,14 +195,14 @@ const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCar
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
minWidth: '100px',
|
||||
width: textWidth > 0 ? `${textWidth + 20}px` : 'auto'
|
||||
width: textWidth > 0 ? `${textWidth + 20}px` : 'auto',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="ml-2 p-1 text-green-600 hover:text-green-800 cursor-pointer transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDescriptionSave()
|
||||
e.stopPropagation();
|
||||
handleDescriptionSave();
|
||||
}}
|
||||
>
|
||||
<Check size={16} />
|
||||
@@ -190,12 +210,14 @@ const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCar
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span ref={descriptionTextRef}>{customDescription || t('tool.noDescription')}</span>
|
||||
<span ref={descriptionTextRef}>
|
||||
{customDescription || t('tool.noDescription')}
|
||||
</span>
|
||||
<button
|
||||
className="ml-2 p-1 text-gray-500 hover:text-blue-600 cursor-pointer transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDescriptionEdit()
|
||||
e.stopPropagation();
|
||||
handleDescriptionEdit();
|
||||
}}
|
||||
>
|
||||
<Edit size={14} />
|
||||
@@ -206,10 +228,7 @@ const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCar
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className="flex items-center space-x-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center space-x-2" onClick={(e) => e.stopPropagation()}>
|
||||
{prompt.enabled !== undefined && (
|
||||
<Switch
|
||||
checked={prompt.enabled}
|
||||
@@ -220,18 +239,14 @@ const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCar
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsExpanded(true) // Ensure card is expanded when showing run form
|
||||
setShowRunForm(true)
|
||||
e.stopPropagation();
|
||||
setIsExpanded(true); // Ensure card is expanded when showing run form
|
||||
setShowRunForm(true);
|
||||
}}
|
||||
className="flex items-center space-x-1 px-3 py-1 text-sm text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-md transition-colors btn-primary"
|
||||
disabled={isRunning || !prompt.enabled}
|
||||
>
|
||||
{isRunning ? (
|
||||
<Loader size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Play size={14} />
|
||||
)}
|
||||
{isRunning ? <Loader size={14} className="animate-spin" /> : <Play size={14} />}
|
||||
<span>{isRunning ? t('tool.running') : t('tool.run')}</span>
|
||||
</button>
|
||||
<button className="text-gray-400 hover:text-gray-600">
|
||||
@@ -251,7 +266,9 @@ const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCar
|
||||
onCancel={handleCancelRun}
|
||||
loading={isRunning}
|
||||
storageKey={getStorageKey()}
|
||||
title={t('prompt.runPromptWithName', { name: prompt.name.replace(server + nameSeparator, '') })}
|
||||
title={t('prompt.runPromptWithName', {
|
||||
name: prompt.name.replace(server + nameSeparator, ''),
|
||||
})}
|
||||
/>
|
||||
{/* Prompt Result */}
|
||||
{result && (
|
||||
@@ -278,9 +295,7 @@ const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCar
|
||||
<p className="text-sm text-gray-600 mt-1">{arg.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 ml-2">
|
||||
{arg.title || ''}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 ml-2">{arg.title || ''}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -296,7 +311,7 @@ const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCar
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default PromptCard
|
||||
export default PromptCard;
|
||||
|
||||
@@ -30,6 +30,7 @@ interface ServerContextType {
|
||||
handleServerEdit: (server: Server) => Promise<any>;
|
||||
handleServerRemove: (serverName: string) => Promise<boolean>;
|
||||
handleServerToggle: (server: Server, enabled: boolean) => Promise<boolean>;
|
||||
handleServerReload: (server: Server) => Promise<boolean>;
|
||||
}
|
||||
|
||||
// Create Context
|
||||
@@ -358,6 +359,30 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleServerReload = useCallback(
|
||||
async (server: Server) => {
|
||||
try {
|
||||
const encodedServerName = encodeURIComponent(server.name);
|
||||
const result = await apiPost(`/servers/${encodedServerName}/reload`, {});
|
||||
|
||||
if (!result || !result.success) {
|
||||
console.error('Failed to reload server:', result);
|
||||
setError(t('server.reloadError', { serverName: server.name }));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Refresh server list after successful reload
|
||||
triggerRefresh();
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error reloading server:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[t, triggerRefresh],
|
||||
);
|
||||
|
||||
const value: ServerContextType = {
|
||||
servers,
|
||||
error,
|
||||
@@ -370,6 +395,7 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
handleServerEdit,
|
||||
handleServerRemove,
|
||||
handleServerToggle,
|
||||
handleServerReload,
|
||||
};
|
||||
|
||||
return <ServerContext.Provider value={value}>{children}</ServerContext.Provider>;
|
||||
|
||||
705
frontend/src/contexts/SettingsContext.tsx
Normal file
705
frontend/src/contexts/SettingsContext.tsx
Normal file
@@ -0,0 +1,705 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ApiResponse } from '@/types';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import { apiGet, apiPut } from '@/utils/fetchInterceptor';
|
||||
|
||||
// Define types for the settings data
|
||||
interface RoutingConfig {
|
||||
enableGlobalRoute: boolean;
|
||||
enableGroupNameRoute: boolean;
|
||||
enableBearerAuth: boolean;
|
||||
bearerAuthKey: string;
|
||||
skipAuth: boolean;
|
||||
}
|
||||
|
||||
interface InstallConfig {
|
||||
pythonIndexUrl: string;
|
||||
npmRegistry: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
interface SmartRoutingConfig {
|
||||
enabled: boolean;
|
||||
dbUrl: string;
|
||||
openaiApiBaseUrl: string;
|
||||
openaiApiKey: string;
|
||||
openaiApiEmbeddingModel: string;
|
||||
}
|
||||
|
||||
interface MCPRouterConfig {
|
||||
apiKey: string;
|
||||
referer: string;
|
||||
title: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
interface OAuthServerConfig {
|
||||
enabled: boolean;
|
||||
accessTokenLifetime: number;
|
||||
refreshTokenLifetime: number;
|
||||
authorizationCodeLifetime: number;
|
||||
requireClientSecret: boolean;
|
||||
allowedScopes: string[];
|
||||
requireState: boolean;
|
||||
dynamicRegistration: {
|
||||
enabled: boolean;
|
||||
allowedGrantTypes: string[];
|
||||
requiresAuthentication: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface SystemSettings {
|
||||
systemConfig?: {
|
||||
routing?: RoutingConfig;
|
||||
install?: InstallConfig;
|
||||
smartRouting?: SmartRoutingConfig;
|
||||
mcpRouter?: MCPRouterConfig;
|
||||
nameSeparator?: string;
|
||||
oauthServer?: OAuthServerConfig;
|
||||
enableSessionRebuild?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface TempRoutingConfig {
|
||||
bearerAuthKey: string;
|
||||
}
|
||||
|
||||
interface SettingsContextValue {
|
||||
routingConfig: RoutingConfig;
|
||||
tempRoutingConfig: TempRoutingConfig;
|
||||
setTempRoutingConfig: React.Dispatch<React.SetStateAction<TempRoutingConfig>>;
|
||||
installConfig: InstallConfig;
|
||||
smartRoutingConfig: SmartRoutingConfig;
|
||||
mcpRouterConfig: MCPRouterConfig;
|
||||
oauthServerConfig: OAuthServerConfig;
|
||||
nameSeparator: string;
|
||||
enableSessionRebuild: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
setError: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
triggerRefresh: () => void;
|
||||
fetchSettings: () => Promise<void>;
|
||||
updateRoutingConfig: (key: keyof RoutingConfig, value: any) => Promise<boolean | undefined>;
|
||||
updateInstallConfig: (key: keyof InstallConfig, value: any) => Promise<boolean | undefined>;
|
||||
updateSmartRoutingConfig: (
|
||||
key: keyof SmartRoutingConfig,
|
||||
value: any,
|
||||
) => Promise<boolean | undefined>;
|
||||
updateSmartRoutingConfigBatch: (
|
||||
updates: Partial<SmartRoutingConfig>,
|
||||
) => Promise<boolean | undefined>;
|
||||
updateRoutingConfigBatch: (updates: Partial<RoutingConfig>) => Promise<boolean | undefined>;
|
||||
updateMCPRouterConfig: (key: keyof MCPRouterConfig, value: any) => Promise<boolean | undefined>;
|
||||
updateMCPRouterConfigBatch: (updates: Partial<MCPRouterConfig>) => Promise<boolean | undefined>;
|
||||
updateOAuthServerConfig: (
|
||||
key: keyof OAuthServerConfig,
|
||||
value: any,
|
||||
) => Promise<boolean | undefined>;
|
||||
updateOAuthServerConfigBatch: (
|
||||
updates: Partial<OAuthServerConfig>,
|
||||
) => Promise<boolean | undefined>;
|
||||
updateNameSeparator: (value: string) => Promise<boolean | undefined>;
|
||||
updateSessionRebuild: (value: boolean) => Promise<boolean | undefined>;
|
||||
exportMCPSettings: (serverName?: string) => Promise<any>;
|
||||
}
|
||||
|
||||
const getDefaultOAuthServerConfig = (): OAuthServerConfig => ({
|
||||
enabled: true,
|
||||
accessTokenLifetime: 3600,
|
||||
refreshTokenLifetime: 1209600,
|
||||
authorizationCodeLifetime: 300,
|
||||
requireClientSecret: false,
|
||||
allowedScopes: ['read', 'write'],
|
||||
requireState: false,
|
||||
dynamicRegistration: {
|
||||
enabled: true,
|
||||
allowedGrantTypes: ['authorization_code', 'refresh_token'],
|
||||
requiresAuthentication: false,
|
||||
},
|
||||
});
|
||||
|
||||
const SettingsContext = createContext<SettingsContextValue | undefined>(undefined);
|
||||
|
||||
export const useSettings = () => {
|
||||
const context = useContext(SettingsContext);
|
||||
if (!context) {
|
||||
throw new Error('useSettings must be used within a SettingsProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface SettingsProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children }) => {
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [routingConfig, setRoutingConfig] = useState<RoutingConfig>({
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
skipAuth: false,
|
||||
});
|
||||
|
||||
const [tempRoutingConfig, setTempRoutingConfig] = useState<TempRoutingConfig>({
|
||||
bearerAuthKey: '',
|
||||
});
|
||||
|
||||
const [installConfig, setInstallConfig] = useState<InstallConfig>({
|
||||
pythonIndexUrl: '',
|
||||
npmRegistry: '',
|
||||
baseUrl: 'http://localhost:3000',
|
||||
});
|
||||
|
||||
const [smartRoutingConfig, setSmartRoutingConfig] = useState<SmartRoutingConfig>({
|
||||
enabled: false,
|
||||
dbUrl: '',
|
||||
openaiApiBaseUrl: '',
|
||||
openaiApiKey: '',
|
||||
openaiApiEmbeddingModel: '',
|
||||
});
|
||||
|
||||
const [mcpRouterConfig, setMCPRouterConfig] = useState<MCPRouterConfig>({
|
||||
apiKey: '',
|
||||
referer: 'https://www.mcphubx.com',
|
||||
title: 'MCPHub',
|
||||
baseUrl: 'https://api.mcprouter.to/v1',
|
||||
});
|
||||
|
||||
const [oauthServerConfig, setOAuthServerConfig] = useState<OAuthServerConfig>(
|
||||
getDefaultOAuthServerConfig(),
|
||||
);
|
||||
|
||||
const [nameSeparator, setNameSeparator] = useState<string>('-');
|
||||
const [enableSessionRebuild, setEnableSessionRebuild] = useState<boolean>(false);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
// Trigger a refresh of the settings data
|
||||
const triggerRefresh = useCallback(() => {
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
}, []);
|
||||
|
||||
// Fetch current settings
|
||||
const fetchSettings = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data: ApiResponse<SystemSettings> = await apiGet('/settings');
|
||||
|
||||
if (data.success && data.data?.systemConfig?.routing) {
|
||||
setRoutingConfig({
|
||||
enableGlobalRoute: data.data.systemConfig.routing.enableGlobalRoute ?? true,
|
||||
enableGroupNameRoute: data.data.systemConfig.routing.enableGroupNameRoute ?? true,
|
||||
enableBearerAuth: data.data.systemConfig.routing.enableBearerAuth ?? false,
|
||||
bearerAuthKey: data.data.systemConfig.routing.bearerAuthKey || '',
|
||||
skipAuth: data.data.systemConfig.routing.skipAuth ?? false,
|
||||
});
|
||||
}
|
||||
if (data.success && data.data?.systemConfig?.install) {
|
||||
setInstallConfig({
|
||||
pythonIndexUrl: data.data.systemConfig.install.pythonIndexUrl || '',
|
||||
npmRegistry: data.data.systemConfig.install.npmRegistry || '',
|
||||
baseUrl: data.data.systemConfig.install.baseUrl || 'http://localhost:3000',
|
||||
});
|
||||
}
|
||||
if (data.success && data.data?.systemConfig?.smartRouting) {
|
||||
setSmartRoutingConfig({
|
||||
enabled: data.data.systemConfig.smartRouting.enabled ?? false,
|
||||
dbUrl: data.data.systemConfig.smartRouting.dbUrl || '',
|
||||
openaiApiBaseUrl: data.data.systemConfig.smartRouting.openaiApiBaseUrl || '',
|
||||
openaiApiKey: data.data.systemConfig.smartRouting.openaiApiKey || '',
|
||||
openaiApiEmbeddingModel:
|
||||
data.data.systemConfig.smartRouting.openaiApiEmbeddingModel || '',
|
||||
});
|
||||
}
|
||||
if (data.success && data.data?.systemConfig?.mcpRouter) {
|
||||
setMCPRouterConfig({
|
||||
apiKey: data.data.systemConfig.mcpRouter.apiKey || '',
|
||||
referer: data.data.systemConfig.mcpRouter.referer || 'https://www.mcphubx.com',
|
||||
title: data.data.systemConfig.mcpRouter.title || 'MCPHub',
|
||||
baseUrl: data.data.systemConfig.mcpRouter.baseUrl || 'https://api.mcprouter.to/v1',
|
||||
});
|
||||
}
|
||||
if (data.success) {
|
||||
if (data.data?.systemConfig?.oauthServer) {
|
||||
const oauth = data.data.systemConfig.oauthServer;
|
||||
const defaultOauthConfig = getDefaultOAuthServerConfig();
|
||||
const defaultDynamic = defaultOauthConfig.dynamicRegistration;
|
||||
const allowedScopes = Array.isArray(oauth.allowedScopes)
|
||||
? [...oauth.allowedScopes]
|
||||
: [...defaultOauthConfig.allowedScopes];
|
||||
const dynamicAllowedGrantTypes = Array.isArray(
|
||||
oauth.dynamicRegistration?.allowedGrantTypes,
|
||||
)
|
||||
? [...oauth.dynamicRegistration!.allowedGrantTypes!]
|
||||
: [...defaultDynamic.allowedGrantTypes];
|
||||
|
||||
setOAuthServerConfig({
|
||||
enabled: oauth.enabled ?? defaultOauthConfig.enabled,
|
||||
accessTokenLifetime:
|
||||
oauth.accessTokenLifetime ?? defaultOauthConfig.accessTokenLifetime,
|
||||
refreshTokenLifetime:
|
||||
oauth.refreshTokenLifetime ?? defaultOauthConfig.refreshTokenLifetime,
|
||||
authorizationCodeLifetime:
|
||||
oauth.authorizationCodeLifetime ?? defaultOauthConfig.authorizationCodeLifetime,
|
||||
requireClientSecret:
|
||||
oauth.requireClientSecret ?? defaultOauthConfig.requireClientSecret,
|
||||
requireState: oauth.requireState ?? defaultOauthConfig.requireState,
|
||||
allowedScopes,
|
||||
dynamicRegistration: {
|
||||
enabled: oauth.dynamicRegistration?.enabled ?? defaultDynamic.enabled,
|
||||
allowedGrantTypes: dynamicAllowedGrantTypes,
|
||||
requiresAuthentication:
|
||||
oauth.dynamicRegistration?.requiresAuthentication ??
|
||||
defaultDynamic.requiresAuthentication,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
setOAuthServerConfig(getDefaultOAuthServerConfig());
|
||||
}
|
||||
}
|
||||
if (data.success && data.data?.systemConfig?.nameSeparator !== undefined) {
|
||||
setNameSeparator(data.data.systemConfig.nameSeparator);
|
||||
}
|
||||
if (data.success && data.data?.systemConfig?.enableSessionRebuild !== undefined) {
|
||||
setEnableSessionRebuild(data.data.systemConfig.enableSessionRebuild);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to fetch settings');
|
||||
showToast(t('errors.failedToFetchSettings'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t, showToast]);
|
||||
|
||||
// Update routing configuration
|
||||
const updateRoutingConfig = async (key: keyof RoutingConfig, value: any) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await apiPut('/system-config', {
|
||||
routing: {
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
setRoutingConfig({
|
||||
...routingConfig,
|
||||
[key]: value,
|
||||
});
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
setError(data.error || 'Failed to update routing config');
|
||||
showToast(data.error || t('errors.failedToUpdateRoutingConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update routing config:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to update routing config');
|
||||
showToast(t('errors.failedToUpdateRoutingConfig'));
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update install configuration
|
||||
const updateInstallConfig = async (key: keyof InstallConfig, value: any) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await apiPut('/system-config', {
|
||||
install: {
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
setInstallConfig({
|
||||
...installConfig,
|
||||
[key]: value,
|
||||
});
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
setError(data.error || 'Failed to update install config');
|
||||
showToast(data.error || t('errors.failedToUpdateInstallConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update install config:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to update install config');
|
||||
showToast(t('errors.failedToUpdateInstallConfig'));
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update smart routing configuration
|
||||
const updateSmartRoutingConfig = async (key: keyof SmartRoutingConfig, value: any) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await apiPut('/system-config', {
|
||||
smartRouting: {
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
setSmartRoutingConfig({
|
||||
...smartRoutingConfig,
|
||||
[key]: value,
|
||||
});
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
setError(data.error || 'Failed to update smart routing config');
|
||||
showToast(data.error || t('errors.failedToUpdateSmartRoutingConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update smart routing config:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to update smart routing config');
|
||||
showToast(t('errors.failedToUpdateSmartRoutingConfig'));
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Batch update smart routing configuration
|
||||
const updateSmartRoutingConfigBatch = async (updates: Partial<SmartRoutingConfig>) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await apiPut('/system-config', {
|
||||
smartRouting: updates,
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
setSmartRoutingConfig({
|
||||
...smartRoutingConfig,
|
||||
...updates,
|
||||
});
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
setError(data.error || 'Failed to update smart routing config');
|
||||
showToast(data.error || t('errors.failedToUpdateSmartRoutingConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update smart routing config:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to update smart routing config');
|
||||
showToast(t('errors.failedToUpdateSmartRoutingConfig'));
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Batch update routing configuration
|
||||
const updateRoutingConfigBatch = async (updates: Partial<RoutingConfig>) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await apiPut('/system-config', {
|
||||
routing: updates,
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
setRoutingConfig({
|
||||
...routingConfig,
|
||||
...updates,
|
||||
});
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
setError(data.error || 'Failed to update routing config');
|
||||
showToast(data.error || t('errors.failedToUpdateRoutingConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update routing config:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to update routing config');
|
||||
showToast(t('errors.failedToUpdateRoutingConfig'));
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update MCP Router configuration
|
||||
const updateMCPRouterConfig = async (key: keyof MCPRouterConfig, value: any) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await apiPut('/system-config', {
|
||||
mcpRouter: {
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
setMCPRouterConfig({
|
||||
...mcpRouterConfig,
|
||||
[key]: value,
|
||||
});
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
setError(data.error || 'Failed to update MCP Router config');
|
||||
showToast(data.error || t('errors.failedToUpdateMCPRouterConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update MCP Router config:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to update MCP Router config');
|
||||
showToast(t('errors.failedToUpdateMCPRouterConfig'));
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Batch update MCP Router configuration
|
||||
const updateMCPRouterConfigBatch = async (updates: Partial<MCPRouterConfig>) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await apiPut('/system-config', {
|
||||
mcpRouter: updates,
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
setMCPRouterConfig({
|
||||
...mcpRouterConfig,
|
||||
...updates,
|
||||
});
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
setError(data.error || 'Failed to update MCP Router config');
|
||||
showToast(data.error || t('errors.failedToUpdateMCPRouterConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update MCP Router config:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to update MCP Router config');
|
||||
showToast(t('errors.failedToUpdateMCPRouterConfig'));
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update OAuth server configuration
|
||||
const updateOAuthServerConfig = async (key: keyof OAuthServerConfig, value: any) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await apiPut('/system-config', {
|
||||
oauthServer: {
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
setOAuthServerConfig({
|
||||
...oauthServerConfig,
|
||||
[key]: value,
|
||||
});
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
setError(data.error || 'Failed to update OAuth server config');
|
||||
showToast(data.error || t('errors.failedToUpdateOAuthServerConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update OAuth server config:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to update OAuth server config');
|
||||
showToast(t('errors.failedToUpdateOAuthServerConfig'));
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Batch update OAuth server configuration
|
||||
const updateOAuthServerConfigBatch = async (updates: Partial<OAuthServerConfig>) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await apiPut('/system-config', {
|
||||
oauthServer: updates,
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
setOAuthServerConfig({
|
||||
...oauthServerConfig,
|
||||
...updates,
|
||||
});
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
setError(data.error || 'Failed to update OAuth server config');
|
||||
showToast(data.error || t('errors.failedToUpdateOAuthServerConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update OAuth server config:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to update OAuth server config');
|
||||
showToast(t('errors.failedToUpdateOAuthServerConfig'));
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update name separator
|
||||
const updateNameSeparator = async (value: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await apiPut('/system-config', {
|
||||
nameSeparator: value,
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
setNameSeparator(value);
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
setError(data.error || 'Failed to update name separator');
|
||||
showToast(data.error || t('errors.failedToUpdateNameSeparator'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update name separator:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to update name separator');
|
||||
showToast(t('errors.failedToUpdateNameSeparator'));
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update session rebuild flag
|
||||
const updateSessionRebuild = async (value: boolean) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await apiPut('/system-config', {
|
||||
enableSessionRebuild: value,
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
setEnableSessionRebuild(value);
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
setError(data.error || 'Failed to update session rebuild setting');
|
||||
showToast(data.error || t('errors.failedToUpdateSessionRebuild'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update session rebuild setting:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to update session rebuild setting');
|
||||
showToast(t('errors.failedToUpdateSessionRebuild'));
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportMCPSettings = async (serverName?: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
return await apiGet(`/mcp-settings/export?serverName=${serverName ? serverName : ''}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to export MCP settings:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to export MCP settings';
|
||||
setError(errorMessage);
|
||||
showToast(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch settings when the component mounts or refreshKey changes
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, [fetchSettings, refreshKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (routingConfig) {
|
||||
setTempRoutingConfig({
|
||||
bearerAuthKey: routingConfig.bearerAuthKey,
|
||||
});
|
||||
}
|
||||
}, [routingConfig]);
|
||||
|
||||
const value: SettingsContextValue = {
|
||||
routingConfig,
|
||||
tempRoutingConfig,
|
||||
setTempRoutingConfig,
|
||||
installConfig,
|
||||
smartRoutingConfig,
|
||||
mcpRouterConfig,
|
||||
oauthServerConfig,
|
||||
nameSeparator,
|
||||
enableSessionRebuild,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
triggerRefresh,
|
||||
fetchSettings,
|
||||
updateRoutingConfig,
|
||||
updateInstallConfig,
|
||||
updateSmartRoutingConfig,
|
||||
updateSmartRoutingConfigBatch,
|
||||
updateRoutingConfigBatch,
|
||||
updateMCPRouterConfig,
|
||||
updateMCPRouterConfigBatch,
|
||||
updateOAuthServerConfig,
|
||||
updateOAuthServerConfigBatch,
|
||||
updateNameSeparator,
|
||||
updateSessionRebuild,
|
||||
exportMCPSettings,
|
||||
};
|
||||
|
||||
return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;
|
||||
};
|
||||
@@ -1,658 +1,10 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ApiResponse } from '@/types';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import { apiGet, apiPut } from '../utils/fetchInterceptor';
|
||||
|
||||
// Define types for the settings data
|
||||
interface RoutingConfig {
|
||||
enableGlobalRoute: boolean;
|
||||
enableGroupNameRoute: boolean;
|
||||
enableBearerAuth: boolean;
|
||||
bearerAuthKey: string;
|
||||
skipAuth: boolean;
|
||||
}
|
||||
|
||||
interface InstallConfig {
|
||||
pythonIndexUrl: string;
|
||||
npmRegistry: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
interface SmartRoutingConfig {
|
||||
enabled: boolean;
|
||||
dbUrl: string;
|
||||
openaiApiBaseUrl: string;
|
||||
openaiApiKey: string;
|
||||
openaiApiEmbeddingModel: string;
|
||||
}
|
||||
|
||||
interface MCPRouterConfig {
|
||||
apiKey: string;
|
||||
referer: string;
|
||||
title: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
interface OAuthServerConfig {
|
||||
enabled: boolean;
|
||||
accessTokenLifetime: number;
|
||||
refreshTokenLifetime: number;
|
||||
authorizationCodeLifetime: number;
|
||||
requireClientSecret: boolean;
|
||||
allowedScopes: string[];
|
||||
requireState: boolean;
|
||||
dynamicRegistration: {
|
||||
enabled: boolean;
|
||||
allowedGrantTypes: string[];
|
||||
requiresAuthentication: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface SystemSettings {
|
||||
systemConfig?: {
|
||||
routing?: RoutingConfig;
|
||||
install?: InstallConfig;
|
||||
smartRouting?: SmartRoutingConfig;
|
||||
mcpRouter?: MCPRouterConfig;
|
||||
nameSeparator?: string;
|
||||
oauthServer?: OAuthServerConfig;
|
||||
enableSessionRebuild?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface TempRoutingConfig {
|
||||
bearerAuthKey: string;
|
||||
}
|
||||
|
||||
const getDefaultOAuthServerConfig = (): OAuthServerConfig => ({
|
||||
enabled: true,
|
||||
accessTokenLifetime: 3600,
|
||||
refreshTokenLifetime: 1209600,
|
||||
authorizationCodeLifetime: 300,
|
||||
requireClientSecret: false,
|
||||
allowedScopes: ['read', 'write'],
|
||||
requireState: false,
|
||||
dynamicRegistration: {
|
||||
enabled: true,
|
||||
allowedGrantTypes: ['authorization_code', 'refresh_token'],
|
||||
requiresAuthentication: false,
|
||||
},
|
||||
});
|
||||
import { useSettings } from '@/contexts/SettingsContext';
|
||||
|
||||
/**
|
||||
* Hook that provides access to settings data via SettingsContext.
|
||||
* This hook is a thin wrapper around useSettings to maintain backward compatibility.
|
||||
* The actual data fetching happens once in SettingsProvider, avoiding duplicate API calls.
|
||||
*/
|
||||
export const useSettingsData = () => {
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [routingConfig, setRoutingConfig] = useState<RoutingConfig>({
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
skipAuth: false,
|
||||
});
|
||||
|
||||
const [tempRoutingConfig, setTempRoutingConfig] = useState<TempRoutingConfig>({
|
||||
bearerAuthKey: '',
|
||||
});
|
||||
|
||||
const [installConfig, setInstallConfig] = useState<InstallConfig>({
|
||||
pythonIndexUrl: '',
|
||||
npmRegistry: '',
|
||||
baseUrl: 'http://localhost:3000',
|
||||
});
|
||||
|
||||
const [smartRoutingConfig, setSmartRoutingConfig] = useState<SmartRoutingConfig>({
|
||||
enabled: false,
|
||||
dbUrl: '',
|
||||
openaiApiBaseUrl: '',
|
||||
openaiApiKey: '',
|
||||
openaiApiEmbeddingModel: '',
|
||||
});
|
||||
|
||||
const [mcpRouterConfig, setMCPRouterConfig] = useState<MCPRouterConfig>({
|
||||
apiKey: '',
|
||||
referer: 'https://www.mcphubx.com',
|
||||
title: 'MCPHub',
|
||||
baseUrl: 'https://api.mcprouter.to/v1',
|
||||
});
|
||||
|
||||
const [oauthServerConfig, setOAuthServerConfig] = useState<OAuthServerConfig>(
|
||||
getDefaultOAuthServerConfig(),
|
||||
);
|
||||
|
||||
const [nameSeparator, setNameSeparator] = useState<string>('-');
|
||||
const [enableSessionRebuild, setEnableSessionRebuild] = useState<boolean>(false);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
// Trigger a refresh of the settings data
|
||||
const triggerRefresh = useCallback(() => {
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
}, []);
|
||||
|
||||
// Fetch current settings
|
||||
const fetchSettings = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data: ApiResponse<SystemSettings> = await apiGet('/settings');
|
||||
|
||||
if (data.success && data.data?.systemConfig?.routing) {
|
||||
setRoutingConfig({
|
||||
enableGlobalRoute: data.data.systemConfig.routing.enableGlobalRoute ?? true,
|
||||
enableGroupNameRoute: data.data.systemConfig.routing.enableGroupNameRoute ?? true,
|
||||
enableBearerAuth: data.data.systemConfig.routing.enableBearerAuth ?? false,
|
||||
bearerAuthKey: data.data.systemConfig.routing.bearerAuthKey || '',
|
||||
skipAuth: data.data.systemConfig.routing.skipAuth ?? false,
|
||||
});
|
||||
}
|
||||
if (data.success && data.data?.systemConfig?.install) {
|
||||
setInstallConfig({
|
||||
pythonIndexUrl: data.data.systemConfig.install.pythonIndexUrl || '',
|
||||
npmRegistry: data.data.systemConfig.install.npmRegistry || '',
|
||||
baseUrl: data.data.systemConfig.install.baseUrl || 'http://localhost:3000',
|
||||
});
|
||||
}
|
||||
if (data.success && data.data?.systemConfig?.smartRouting) {
|
||||
setSmartRoutingConfig({
|
||||
enabled: data.data.systemConfig.smartRouting.enabled ?? false,
|
||||
dbUrl: data.data.systemConfig.smartRouting.dbUrl || '',
|
||||
openaiApiBaseUrl: data.data.systemConfig.smartRouting.openaiApiBaseUrl || '',
|
||||
openaiApiKey: data.data.systemConfig.smartRouting.openaiApiKey || '',
|
||||
openaiApiEmbeddingModel:
|
||||
data.data.systemConfig.smartRouting.openaiApiEmbeddingModel || '',
|
||||
});
|
||||
}
|
||||
if (data.success && data.data?.systemConfig?.mcpRouter) {
|
||||
setMCPRouterConfig({
|
||||
apiKey: data.data.systemConfig.mcpRouter.apiKey || '',
|
||||
referer: data.data.systemConfig.mcpRouter.referer || 'https://www.mcphubx.com',
|
||||
title: data.data.systemConfig.mcpRouter.title || 'MCPHub',
|
||||
baseUrl: data.data.systemConfig.mcpRouter.baseUrl || 'https://api.mcprouter.to/v1',
|
||||
});
|
||||
}
|
||||
if (data.success) {
|
||||
if (data.data?.systemConfig?.oauthServer) {
|
||||
const oauth = data.data.systemConfig.oauthServer;
|
||||
const defaultOauthConfig = getDefaultOAuthServerConfig();
|
||||
const defaultDynamic = defaultOauthConfig.dynamicRegistration;
|
||||
const allowedScopes = Array.isArray(oauth.allowedScopes)
|
||||
? [...oauth.allowedScopes]
|
||||
: [...defaultOauthConfig.allowedScopes];
|
||||
const dynamicAllowedGrantTypes = Array.isArray(
|
||||
oauth.dynamicRegistration?.allowedGrantTypes,
|
||||
)
|
||||
? [...oauth.dynamicRegistration!.allowedGrantTypes!]
|
||||
: [...defaultDynamic.allowedGrantTypes];
|
||||
|
||||
setOAuthServerConfig({
|
||||
enabled: oauth.enabled ?? defaultOauthConfig.enabled,
|
||||
accessTokenLifetime:
|
||||
oauth.accessTokenLifetime ?? defaultOauthConfig.accessTokenLifetime,
|
||||
refreshTokenLifetime:
|
||||
oauth.refreshTokenLifetime ?? defaultOauthConfig.refreshTokenLifetime,
|
||||
authorizationCodeLifetime:
|
||||
oauth.authorizationCodeLifetime ?? defaultOauthConfig.authorizationCodeLifetime,
|
||||
requireClientSecret:
|
||||
oauth.requireClientSecret ?? defaultOauthConfig.requireClientSecret,
|
||||
requireState: oauth.requireState ?? defaultOauthConfig.requireState,
|
||||
allowedScopes,
|
||||
dynamicRegistration: {
|
||||
enabled: oauth.dynamicRegistration?.enabled ?? defaultDynamic.enabled,
|
||||
allowedGrantTypes: dynamicAllowedGrantTypes,
|
||||
requiresAuthentication:
|
||||
oauth.dynamicRegistration?.requiresAuthentication ??
|
||||
defaultDynamic.requiresAuthentication,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
setOAuthServerConfig(getDefaultOAuthServerConfig());
|
||||
}
|
||||
}
|
||||
if (data.success && data.data?.systemConfig?.nameSeparator !== undefined) {
|
||||
setNameSeparator(data.data.systemConfig.nameSeparator);
|
||||
}
|
||||
if (data.success && data.data?.systemConfig?.enableSessionRebuild !== undefined) {
|
||||
setEnableSessionRebuild(data.data.systemConfig.enableSessionRebuild);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to fetch settings');
|
||||
// 使用一个稳定的 showToast 引用,避免将其加入依赖数组
|
||||
showToast(t('errors.failedToFetchSettings'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]); // 移除 showToast 依赖
|
||||
|
||||
// Update routing configuration
|
||||
const updateRoutingConfig = async (key: keyof RoutingConfig, value: any) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await apiPut('/system-config', {
|
||||
routing: {
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
setRoutingConfig({
|
||||
...routingConfig,
|
||||
[key]: value,
|
||||
});
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
showToast(data.message || t('errors.failedToUpdateRouteConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update routing config:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to update routing config');
|
||||
showToast(t('errors.failedToUpdateRouteConfig'));
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update install configuration
|
||||
const updateInstallConfig = async (key: keyof InstallConfig, value: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await apiPut('/system-config', {
|
||||
install: {
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
setInstallConfig({
|
||||
...installConfig,
|
||||
[key]: value,
|
||||
});
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update system config:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to update system config');
|
||||
showToast(t('errors.failedToUpdateSystemConfig'));
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update smart routing configuration
|
||||
const updateSmartRoutingConfig = async <T extends keyof SmartRoutingConfig>(
|
||||
key: T,
|
||||
value: SmartRoutingConfig[T],
|
||||
) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await apiPut('/system-config', {
|
||||
smartRouting: {
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
setSmartRoutingConfig({
|
||||
...smartRoutingConfig,
|
||||
[key]: value,
|
||||
});
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
showToast(data.message || t('errors.failedToUpdateSmartRoutingConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update smart routing config:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to update smart routing config';
|
||||
setError(errorMessage);
|
||||
showToast(errorMessage);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update multiple smart routing configuration fields at once
|
||||
const updateSmartRoutingConfigBatch = async (updates: Partial<SmartRoutingConfig>) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await apiPut('/system-config', {
|
||||
smartRouting: updates,
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
setSmartRoutingConfig({
|
||||
...smartRoutingConfig,
|
||||
...updates,
|
||||
});
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
showToast(data.message || t('errors.failedToUpdateSmartRoutingConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update smart routing config:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to update smart routing config';
|
||||
setError(errorMessage);
|
||||
showToast(errorMessage);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update multiple routing configuration fields at once
|
||||
const updateRoutingConfigBatch = async (updates: Partial<RoutingConfig>) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await apiPut('/system-config', {
|
||||
routing: updates,
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
setRoutingConfig({
|
||||
...routingConfig,
|
||||
...updates,
|
||||
});
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
showToast(data.message || t('errors.failedToUpdateRouteConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update routing config:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to update routing config');
|
||||
showToast(t('errors.failedToUpdateRouteConfig'));
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update MCPRouter configuration
|
||||
const updateMCPRouterConfig = async <T extends keyof MCPRouterConfig>(
|
||||
key: T,
|
||||
value: MCPRouterConfig[T],
|
||||
) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await apiPut('/system-config', {
|
||||
mcpRouter: {
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
setMCPRouterConfig({
|
||||
...mcpRouterConfig,
|
||||
[key]: value,
|
||||
});
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update MCPRouter config:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to update MCPRouter config';
|
||||
setError(errorMessage);
|
||||
showToast(errorMessage);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update multiple MCPRouter configuration fields at once
|
||||
const updateMCPRouterConfigBatch = async (updates: Partial<MCPRouterConfig>) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await apiPut('/system-config', {
|
||||
mcpRouter: updates,
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
setMCPRouterConfig({
|
||||
...mcpRouterConfig,
|
||||
...updates,
|
||||
});
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update MCPRouter config:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to update MCPRouter config';
|
||||
setError(errorMessage);
|
||||
showToast(errorMessage);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update OAuth server configuration
|
||||
const updateOAuthServerConfig = async <T extends keyof OAuthServerConfig>(
|
||||
key: T,
|
||||
value: OAuthServerConfig[T],
|
||||
) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await apiPut('/system-config', {
|
||||
oauthServer: {
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
setOAuthServerConfig((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}));
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update OAuth server config:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to update OAuth server config';
|
||||
setError(errorMessage);
|
||||
showToast(errorMessage);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update multiple OAuth server config fields
|
||||
const updateOAuthServerConfigBatch = async (updates: Partial<OAuthServerConfig>) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await apiPut('/system-config', {
|
||||
oauthServer: updates,
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
setOAuthServerConfig((prev) => ({
|
||||
...prev,
|
||||
...updates,
|
||||
}));
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update OAuth server config:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to update OAuth server config';
|
||||
setError(errorMessage);
|
||||
showToast(errorMessage);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update name separator
|
||||
const updateNameSeparator = async (value: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await apiPut('/system-config', {
|
||||
nameSeparator: value,
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
setNameSeparator(value);
|
||||
showToast(t('settings.restartRequired'), 'info');
|
||||
return true;
|
||||
} else {
|
||||
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update name separator:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to update name separator';
|
||||
setError(errorMessage);
|
||||
showToast(errorMessage);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update session rebuild setting
|
||||
const updateSessionRebuild = async (value: boolean) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await apiPut('/system-config', {
|
||||
enableSessionRebuild: value,
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
setEnableSessionRebuild(value);
|
||||
showToast(t('settings.restartRequired'), 'info');
|
||||
return true;
|
||||
} else {
|
||||
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update session rebuild setting:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to update session rebuild setting';
|
||||
setError(errorMessage);
|
||||
showToast(errorMessage);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportMCPSettings = async (serverName?: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
return await apiGet(`/mcp-settings/export?serverName=${serverName ? serverName : ''}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to export MCP settings:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to export MCP settings';
|
||||
setError(errorMessage);
|
||||
showToast(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch settings when the component mounts or refreshKey changes
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, [fetchSettings, refreshKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (routingConfig) {
|
||||
setTempRoutingConfig({
|
||||
bearerAuthKey: routingConfig.bearerAuthKey,
|
||||
});
|
||||
}
|
||||
}, [routingConfig]);
|
||||
|
||||
return {
|
||||
routingConfig,
|
||||
tempRoutingConfig,
|
||||
setTempRoutingConfig,
|
||||
installConfig,
|
||||
smartRoutingConfig,
|
||||
mcpRouterConfig,
|
||||
oauthServerConfig,
|
||||
nameSeparator,
|
||||
enableSessionRebuild,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
triggerRefresh,
|
||||
fetchSettings,
|
||||
updateRoutingConfig,
|
||||
updateInstallConfig,
|
||||
updateSmartRoutingConfig,
|
||||
updateSmartRoutingConfigBatch,
|
||||
updateRoutingConfigBatch,
|
||||
updateMCPRouterConfig,
|
||||
updateMCPRouterConfigBatch,
|
||||
updateOAuthServerConfig,
|
||||
updateOAuthServerConfigBatch,
|
||||
updateNameSeparator,
|
||||
updateSessionRebuild,
|
||||
exportMCPSettings,
|
||||
};
|
||||
return useSettings();
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ const ServersPage: React.FC = () => {
|
||||
handleServerEdit,
|
||||
handleServerRemove,
|
||||
handleServerToggle,
|
||||
handleServerReload,
|
||||
triggerRefresh
|
||||
} = useServerData({ refreshOnMount: true });
|
||||
const [editingServer, setEditingServer] = useState<Server | null>(null);
|
||||
@@ -159,6 +160,7 @@ const ServersPage: React.FC = () => {
|
||||
onEdit={handleEditClick}
|
||||
onToggle={handleServerToggle}
|
||||
onRefresh={triggerRefresh}
|
||||
onReload={handleServerReload}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -189,4 +191,4 @@ const ServersPage: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ServersPage;
|
||||
export default ServersPage;
|
||||
|
||||
@@ -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
|
||||
@@ -116,6 +116,9 @@
|
||||
"enabled": "Enabled",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"reload": "Reload",
|
||||
"reloadSuccess": "Server reloaded successfully",
|
||||
"reloadError": "Failed to reload server {{serverName}}",
|
||||
"requestOptions": "Connection Configuration",
|
||||
"timeout": "Request Timeout",
|
||||
"timeoutDescription": "Timeout for requests to the MCP server (ms)",
|
||||
@@ -123,6 +126,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",
|
||||
@@ -531,7 +539,9 @@
|
||||
"description": "Description",
|
||||
"messages": "Messages",
|
||||
"noDescription": "No description available",
|
||||
"runPromptWithName": "Get Prompt: {{name}}"
|
||||
"runPromptWithName": "Get Prompt: {{name}}",
|
||||
"descriptionUpdateSuccess": "Prompt description updated successfully",
|
||||
"descriptionUpdateFailed": "Failed to update prompt description"
|
||||
},
|
||||
"settings": {
|
||||
"enableGlobalRoute": "Enable Global Route",
|
||||
@@ -718,6 +728,7 @@
|
||||
"failedToRemoveServer": "Server not found or failed to remove",
|
||||
"internalServerError": "Internal server error",
|
||||
"failedToGetServers": "Failed to get servers information",
|
||||
"failedToReloadServer": "Failed to reload server",
|
||||
"failedToGetServerSettings": "Failed to get server settings",
|
||||
"failedToGetServerConfig": "Failed to get server configuration",
|
||||
"failedToSaveSettings": "Failed to save settings",
|
||||
@@ -787,4 +798,4 @@
|
||||
"internalErrorMessage": "An unexpected error occurred while processing the OAuth callback.",
|
||||
"closeWindow": "Close Window"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,6 +116,9 @@
|
||||
"enabled": "Activé",
|
||||
"enable": "Activer",
|
||||
"disable": "Désactiver",
|
||||
"reload": "Recharger",
|
||||
"reloadSuccess": "Serveur rechargé avec succès",
|
||||
"reloadError": "Échec du rechargement du serveur {{serverName}}",
|
||||
"requestOptions": "Configuration de la connexion",
|
||||
"timeout": "Délai d'attente de la requête",
|
||||
"timeoutDescription": "Délai d'attente pour les requêtes vers le serveur MCP (ms)",
|
||||
@@ -123,6 +126,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à",
|
||||
@@ -203,6 +211,7 @@
|
||||
"serverAdd": "Échec de l'ajout du serveur. Veuillez vérifier l'état du serveur",
|
||||
"serverUpdate": "Échec de la modification du serveur {{serverName}}. Veuillez vérifier l'état du serveur",
|
||||
"serverFetch": "Échec de la récupération des données du serveur. Veuillez réessayer plus tard",
|
||||
"failedToReloadServer": "Échec du rechargement du serveur",
|
||||
"initialStartup": "Le serveur est peut-être en cours de démarrage. Veuillez patienter un instant car ce processus peut prendre du temps au premier lancement...",
|
||||
"serverInstall": "Échec de l'installation du serveur",
|
||||
"failedToFetchSettings": "Échec de la récupération des paramètres",
|
||||
@@ -531,7 +540,9 @@
|
||||
"description": "Description",
|
||||
"messages": "Messages",
|
||||
"noDescription": "Aucune description disponible",
|
||||
"runPromptWithName": "Obtenir l'invite : {{name}}"
|
||||
"runPromptWithName": "Obtenir l'invite : {{name}}",
|
||||
"descriptionUpdateSuccess": "Description de l'invite mise à jour avec succès",
|
||||
"descriptionUpdateFailed": "Échec de la mise à jour de la description de l'invite"
|
||||
},
|
||||
"settings": {
|
||||
"enableGlobalRoute": "Activer la route globale",
|
||||
@@ -787,4 +798,4 @@
|
||||
"internalErrorMessage": "Une erreur inattendue s'est produite lors du traitement du callback OAuth.",
|
||||
"closeWindow": "Fermer la fenêtre"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,6 +116,9 @@
|
||||
"enabled": "Etkin",
|
||||
"enable": "Etkinleştir",
|
||||
"disable": "Devre Dışı Bırak",
|
||||
"reload": "Yeniden Yükle",
|
||||
"reloadSuccess": "Sunucu başarıyla yeniden yüklendi",
|
||||
"reloadError": "Sunucu {{serverName}} yeniden yüklenemedi",
|
||||
"requestOptions": "Bağlantı Yapılandırması",
|
||||
"timeout": "İstek Zaman Aşımı",
|
||||
"timeoutDescription": "MCP sunucusuna yapılan istekler için zaman aşımı (ms)",
|
||||
@@ -123,6 +126,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",
|
||||
@@ -203,6 +211,7 @@
|
||||
"serverAdd": "Sunucu eklenemedi. Lütfen sunucu durumunu kontrol edin",
|
||||
"serverUpdate": "{{serverName}} sunucusu düzenlenemedi. Lütfen sunucu durumunu kontrol edin",
|
||||
"serverFetch": "Sunucu verileri alınamadı. Lütfen daha sonra tekrar deneyin",
|
||||
"failedToReloadServer": "Sunucu yeniden yüklenemedi",
|
||||
"initialStartup": "Sunucu başlatılıyor olabilir. İlk başlatmada bu işlem biraz zaman alabileceğinden lütfen bekleyin...",
|
||||
"serverInstall": "Sunucu yüklenemedi",
|
||||
"failedToFetchSettings": "Ayarlar getirilemedi",
|
||||
@@ -531,7 +540,9 @@
|
||||
"description": "Açıklama",
|
||||
"messages": "Mesajlar",
|
||||
"noDescription": "Kullanılabilir açıklama yok",
|
||||
"runPromptWithName": "İsteği Getir: {{name}}"
|
||||
"runPromptWithName": "İsteği Getir: {{name}}",
|
||||
"descriptionUpdateSuccess": "İstek açıklaması başarıyla güncellendi",
|
||||
"descriptionUpdateFailed": "İstek açıklaması güncellenemedi"
|
||||
},
|
||||
"settings": {
|
||||
"enableGlobalRoute": "Global Yönlendirmeyi Etkinleştir",
|
||||
@@ -787,4 +798,4 @@
|
||||
"internalErrorMessage": "OAuth geri araması işlenirken beklenmeyen bir hata oluştu.",
|
||||
"closeWindow": "Pencereyi Kapat"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -116,6 +116,9 @@
|
||||
"enabled": "已启用",
|
||||
"enable": "启用",
|
||||
"disable": "禁用",
|
||||
"reload": "重载",
|
||||
"reloadSuccess": "服务器重载成功",
|
||||
"reloadError": "重载服务器 {{serverName}} 失败",
|
||||
"requestOptions": "连接配置",
|
||||
"timeout": "请求超时",
|
||||
"timeoutDescription": "请求超时时间(毫秒)",
|
||||
@@ -123,6 +126,11 @@
|
||||
"maxTotalTimeoutDescription": "无论是否有进度通知的最大总超时时间(毫秒)",
|
||||
"resetTimeoutOnProgress": "收到进度通知时重置超时",
|
||||
"resetTimeoutOnProgressDescription": "适用于发送周期性进度更新的长时间运行操作",
|
||||
"keepAlive": "保活配置",
|
||||
"enableKeepAlive": "启用保活",
|
||||
"keepAliveDescription": "定期发送 ping 请求以维持连接。适用于可能超时的长期连接。",
|
||||
"keepAliveInterval": "间隔时间(毫秒)",
|
||||
"keepAliveIntervalDescription": "保活 ping 的时间间隔(默认:60000毫秒 = 1分钟)",
|
||||
"remove": "移除",
|
||||
"toggleError": "切换服务器 {{serverName}} 状态失败",
|
||||
"alreadyExists": "服务器 {{serverName}} 已经存在",
|
||||
@@ -203,6 +211,7 @@
|
||||
"serverAdd": "添加服务器失败,请检查服务器状态",
|
||||
"serverUpdate": "编辑服务器 {{serverName}} 失败,请检查服务器状态",
|
||||
"serverFetch": "获取服务器数据失败,请稍后重试",
|
||||
"failedToReloadServer": "重载服务器失败",
|
||||
"initialStartup": "服务器可能正在启动中。首次启动可能需要一些时间,请耐心等候...",
|
||||
"serverInstall": "安装服务器失败",
|
||||
"failedToFetchSettings": "获取设置失败",
|
||||
@@ -532,7 +541,9 @@
|
||||
"description": "描述",
|
||||
"messages": "消息",
|
||||
"noDescription": "无描述信息",
|
||||
"runPromptWithName": "获取提示词: {{name}}"
|
||||
"runPromptWithName": "获取提示词: {{name}}",
|
||||
"descriptionUpdateSuccess": "提示词描述更新成功",
|
||||
"descriptionUpdateFailed": "更新提示词描述失败"
|
||||
},
|
||||
"settings": {
|
||||
"enableGlobalRoute": "启用全局路由",
|
||||
@@ -789,4 +800,4 @@
|
||||
"internalErrorMessage": "处理 OAuth 回调时发生意外错误。",
|
||||
"closeWindow": "关闭窗口"
|
||||
}
|
||||
}
|
||||
}
|
||||
13
package.json
13
package.json
@@ -60,7 +60,7 @@
|
||||
"dotenv": "^16.6.1",
|
||||
"dotenv-expand": "^12.0.2",
|
||||
"express": "^4.21.2",
|
||||
"express-validator": "^7.2.1",
|
||||
"express-validator": "^7.3.1",
|
||||
"i18next": "^25.5.0",
|
||||
"i18next-fs-backend": "^2.6.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
@@ -110,8 +110,8 @@
|
||||
"next": "^15.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.6.2",
|
||||
"react": "19.1.1",
|
||||
"react-dom": "19.1.1",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
"react-i18next": "^15.7.2",
|
||||
"react-router-dom": "^7.8.2",
|
||||
"supertest": "^7.1.4",
|
||||
@@ -132,7 +132,10 @@
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"brace-expansion@1.1.11": "1.1.12",
|
||||
"brace-expansion@2.0.1": "2.0.2"
|
||||
"brace-expansion@2.0.1": "2.0.2",
|
||||
"glob@10.4.5": "10.5.0",
|
||||
"js-yaml": "4.1.1",
|
||||
"jws@3.2.2": "4.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1681
pnpm-lock.yaml
generated
1681
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import { loadSettings, loadOriginalSettings } from '../config/index.js';
|
||||
import { getDataService } from '../services/services.js';
|
||||
import { DataService } from '../services/dataService.js';
|
||||
import { IUser } from '../types/index.js';
|
||||
import { getServerDao } from '../dao/DaoFactory.js';
|
||||
|
||||
const dataService: DataService = getDataService();
|
||||
|
||||
@@ -73,17 +74,39 @@ export const getPublicConfig = (req: Request, res: Response): void => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Recursively remove null values from an object
|
||||
*/
|
||||
const removeNullValues = <T>(obj: T): T => {
|
||||
if (obj === null || obj === undefined) {
|
||||
return obj;
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => removeNullValues(item)) as T;
|
||||
}
|
||||
if (typeof obj === 'object') {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (value !== null) {
|
||||
result[key] = removeNullValues(value);
|
||||
}
|
||||
}
|
||||
return result as T;
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get MCP settings in JSON format for export/copy
|
||||
* Supports both full settings and individual server configuration
|
||||
*/
|
||||
export const getMcpSettingsJson = (req: Request, res: Response): void => {
|
||||
export const getMcpSettingsJson = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName } = req.query;
|
||||
const settings = loadOriginalSettings();
|
||||
if (serverName && typeof serverName === 'string') {
|
||||
// Return individual server configuration
|
||||
const serverConfig = settings.mcpServers[serverName];
|
||||
// Return individual server configuration using DAO
|
||||
const serverDao = getServerDao();
|
||||
const serverConfig = await serverDao.findById(serverName);
|
||||
if (!serverConfig) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -92,16 +115,21 @@ export const getMcpSettingsJson = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the 'name' field from config as it's used as the key
|
||||
const { name, ...configWithoutName } = serverConfig;
|
||||
// Remove null values from the config
|
||||
const cleanedConfig = removeNullValues(configWithoutName);
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
mcpServers: {
|
||||
[serverName]: serverConfig,
|
||||
[name]: cleanedConfig,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Return full settings
|
||||
const settings = loadOriginalSettings();
|
||||
res.json({
|
||||
success: true,
|
||||
data: settings,
|
||||
|
||||
@@ -14,10 +14,10 @@ import { IOAuthClient } from '../types/index.js';
|
||||
* GET /api/oauth/clients
|
||||
* Get all OAuth clients
|
||||
*/
|
||||
export const getAllClients = (req: Request, res: Response): void => {
|
||||
export const getAllClients = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const clients = getOAuthClients();
|
||||
|
||||
const clients = await getOAuthClients();
|
||||
|
||||
// Don't expose client secrets in the list
|
||||
const sanitizedClients = clients.map((client) => ({
|
||||
clientId: client.clientId,
|
||||
@@ -45,10 +45,10 @@ export const getAllClients = (req: Request, res: Response): void => {
|
||||
* GET /api/oauth/clients/:clientId
|
||||
* Get a specific OAuth client
|
||||
*/
|
||||
export const getClient = (req: Request, res: Response): void => {
|
||||
export const getClient = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { clientId } = req.params;
|
||||
const client = findOAuthClientById(clientId);
|
||||
const client = await findOAuthClientById(clientId);
|
||||
|
||||
if (!client) {
|
||||
res.status(404).json({
|
||||
@@ -85,7 +85,7 @@ export const getClient = (req: Request, res: Response): void => {
|
||||
* POST /api/oauth/clients
|
||||
* Create a new OAuth client
|
||||
*/
|
||||
export const createClient = (req: Request, res: Response): void => {
|
||||
export const createClient = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Validate request
|
||||
const errors = validationResult(req);
|
||||
@@ -105,7 +105,8 @@ export const createClient = (req: Request, res: Response): void => {
|
||||
const clientId = crypto.randomBytes(16).toString('hex');
|
||||
|
||||
// Generate client secret if required
|
||||
const clientSecret = requireSecret !== false ? crypto.randomBytes(32).toString('hex') : undefined;
|
||||
const clientSecret =
|
||||
requireSecret !== false ? crypto.randomBytes(32).toString('hex') : undefined;
|
||||
|
||||
// Create client
|
||||
const client: IOAuthClient = {
|
||||
@@ -118,7 +119,7 @@ export const createClient = (req: Request, res: Response): void => {
|
||||
owner: user?.username || 'admin',
|
||||
};
|
||||
|
||||
const createdClient = createOAuthClient(client);
|
||||
const createdClient = await createOAuthClient(client);
|
||||
|
||||
// Return client with secret (only shown once)
|
||||
res.status(201).json({
|
||||
@@ -139,7 +140,7 @@ export const createClient = (req: Request, res: Response): void => {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Create OAuth client error:', error);
|
||||
|
||||
|
||||
if (error instanceof Error && error.message.includes('already exists')) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
@@ -158,18 +159,19 @@ export const createClient = (req: Request, res: Response): void => {
|
||||
* PUT /api/oauth/clients/:clientId
|
||||
* Update an OAuth client
|
||||
*/
|
||||
export const updateClient = (req: Request, res: Response): void => {
|
||||
export const updateClient = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { clientId } = req.params;
|
||||
const { name, redirectUris, grants, scopes } = req.body;
|
||||
|
||||
const updates: Partial<IOAuthClient> = {};
|
||||
if (name) updates.name = name;
|
||||
if (redirectUris) updates.redirectUris = Array.isArray(redirectUris) ? redirectUris : [redirectUris];
|
||||
if (redirectUris)
|
||||
updates.redirectUris = Array.isArray(redirectUris) ? redirectUris : [redirectUris];
|
||||
if (grants) updates.grants = grants;
|
||||
if (scopes) updates.scopes = scopes;
|
||||
|
||||
const updatedClient = updateOAuthClient(clientId, updates);
|
||||
const updatedClient = await updateOAuthClient(clientId, updates);
|
||||
|
||||
if (!updatedClient) {
|
||||
res.status(404).json({
|
||||
@@ -205,10 +207,10 @@ export const updateClient = (req: Request, res: Response): void => {
|
||||
* DELETE /api/oauth/clients/:clientId
|
||||
* Delete an OAuth client
|
||||
*/
|
||||
export const deleteClient = (req: Request, res: Response): void => {
|
||||
export const deleteClient = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { clientId } = req.params;
|
||||
const deleted = deleteOAuthClient(clientId);
|
||||
const deleted = await deleteOAuthClient(clientId);
|
||||
|
||||
if (!deleted) {
|
||||
res.status(404).json({
|
||||
@@ -235,10 +237,10 @@ export const deleteClient = (req: Request, res: Response): void => {
|
||||
* POST /api/oauth/clients/:clientId/regenerate-secret
|
||||
* Regenerate client secret
|
||||
*/
|
||||
export const regenerateSecret = (req: Request, res: Response): void => {
|
||||
export const regenerateSecret = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { clientId } = req.params;
|
||||
const client = findOAuthClientById(clientId);
|
||||
const client = await findOAuthClientById(clientId);
|
||||
|
||||
if (!client) {
|
||||
res.status(404).json({
|
||||
@@ -250,7 +252,7 @@ export const regenerateSecret = (req: Request, res: Response): void => {
|
||||
|
||||
// Generate new secret
|
||||
const newSecret = crypto.randomBytes(32).toString('hex');
|
||||
const updatedClient = updateOAuthClient(clientId, { clientSecret: newSecret });
|
||||
const updatedClient = await updateOAuthClient(clientId, { clientSecret: newSecret });
|
||||
|
||||
if (!updatedClient) {
|
||||
res.status(500).json({
|
||||
|
||||
@@ -48,7 +48,7 @@ const verifyRegistrationToken = (token: string): string | null => {
|
||||
* RFC 7591 Dynamic Client Registration
|
||||
* Public endpoint for registering new OAuth clients
|
||||
*/
|
||||
export const registerClient = (req: Request, res: Response): void => {
|
||||
export const registerClient = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
const oauthConfig = settings.systemConfig?.oauthServer;
|
||||
@@ -183,7 +183,7 @@ export const registerClient = (req: Request, res: Response): void => {
|
||||
},
|
||||
};
|
||||
|
||||
const createdClient = createOAuthClient(client);
|
||||
const createdClient = await createOAuthClient(client);
|
||||
|
||||
// Build response according to RFC 7591
|
||||
const response: any = {
|
||||
@@ -238,7 +238,7 @@ export const registerClient = (req: Request, res: Response): void => {
|
||||
* RFC 7591 Client Configuration Endpoint
|
||||
* Read client configuration
|
||||
*/
|
||||
export const getClientConfiguration = (req: Request, res: Response): void => {
|
||||
export const getClientConfiguration = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { clientId } = req.params;
|
||||
const authHeader = req.headers.authorization;
|
||||
@@ -262,7 +262,7 @@ export const getClientConfiguration = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = findOAuthClientById(clientId);
|
||||
const client = await findOAuthClientById(clientId);
|
||||
if (!client) {
|
||||
res.status(404).json({
|
||||
error: 'invalid_client',
|
||||
@@ -311,7 +311,7 @@ export const getClientConfiguration = (req: Request, res: Response): void => {
|
||||
* RFC 7591 Client Update Endpoint
|
||||
* Update client configuration
|
||||
*/
|
||||
export const updateClientConfiguration = (req: Request, res: Response): void => {
|
||||
export const updateClientConfiguration = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { clientId } = req.params;
|
||||
const authHeader = req.headers.authorization;
|
||||
@@ -335,7 +335,7 @@ export const updateClientConfiguration = (req: Request, res: Response): void =>
|
||||
return;
|
||||
}
|
||||
|
||||
const client = findOAuthClientById(clientId);
|
||||
const client = await findOAuthClientById(clientId);
|
||||
if (!client) {
|
||||
res.status(404).json({
|
||||
error: 'invalid_client',
|
||||
@@ -443,7 +443,7 @@ export const updateClientConfiguration = (req: Request, res: Response): void =>
|
||||
};
|
||||
}
|
||||
|
||||
const updatedClient = updateOAuthClient(clientId, updates);
|
||||
const updatedClient = await updateOAuthClient(clientId, updates);
|
||||
|
||||
if (!updatedClient) {
|
||||
res.status(500).json({
|
||||
@@ -495,7 +495,7 @@ export const updateClientConfiguration = (req: Request, res: Response): void =>
|
||||
* RFC 7591 Client Delete Endpoint
|
||||
* Delete client registration
|
||||
*/
|
||||
export const deleteClientRegistration = (req: Request, res: Response): void => {
|
||||
export const deleteClientRegistration = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { clientId } = req.params;
|
||||
const authHeader = req.headers.authorization;
|
||||
@@ -519,7 +519,7 @@ export const deleteClientRegistration = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = deleteOAuthClient(clientId);
|
||||
const deleted = await deleteOAuthClient(clientId);
|
||||
|
||||
if (!deleted) {
|
||||
res.status(404).json({
|
||||
|
||||
@@ -212,7 +212,7 @@ export const getAuthorize = async (req: Request, res: Response): Promise<void> =
|
||||
}
|
||||
|
||||
// Verify client
|
||||
const client = findOAuthClientById(client_id as string);
|
||||
const client = await findOAuthClientById(client_id as string);
|
||||
if (!client) {
|
||||
res.status(400).json({ error: 'invalid_client', error_description: 'Client not found' });
|
||||
return;
|
||||
|
||||
@@ -8,8 +8,9 @@ import {
|
||||
notifyToolChanged,
|
||||
syncToolEmbedding,
|
||||
toggleServerStatus,
|
||||
reconnectServer,
|
||||
} from '../services/mcpService.js';
|
||||
import { loadSettings, saveSettings } from '../config/index.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import { syncAllServerToolsEmbeddings } from '../services/vectorSearchService.js';
|
||||
import { createSafeJSON } from '../utils/serialization.js';
|
||||
import { cloneDefaultOAuthServerConfig } from '../constants/oauthServerDefaults.js';
|
||||
@@ -156,6 +157,11 @@ export const createServer = async (req: Request, res: Response): Promise<void> =
|
||||
return;
|
||||
}
|
||||
|
||||
// Set default keep-alive interval for SSE servers if not specified
|
||||
if ((config.type === 'sse' || (!config.type && config.url)) && !config.keepAliveInterval) {
|
||||
config.keepAliveInterval = 60000; // Default 60 seconds for SSE servers
|
||||
}
|
||||
|
||||
// Set owner property - use current user's username, default to 'admin'
|
||||
if (!config.owner) {
|
||||
const currentUser = (req as any).user;
|
||||
@@ -294,6 +300,11 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
|
||||
return;
|
||||
}
|
||||
|
||||
// Set default keep-alive interval for SSE servers if not specified
|
||||
if ((config.type === 'sse' || (!config.type && config.url)) && !config.keepAliveInterval) {
|
||||
config.keepAliveInterval = 60000; // Default 60 seconds for SSE servers
|
||||
}
|
||||
|
||||
// Set owner property if not provided - use current user's username, default to 'admin'
|
||||
if (!config.owner) {
|
||||
const currentUser = (req as any).user;
|
||||
@@ -405,6 +416,32 @@ export const toggleServer = async (req: Request, res: Response): Promise<void> =
|
||||
}
|
||||
};
|
||||
|
||||
export const reloadServer = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
if (!name) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Server name is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await reconnectServer(name);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Server ${name} reloaded successfully`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to reload server:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to reload server',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle tool status for a specific server
|
||||
export const toggleTool = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
@@ -429,8 +466,10 @@ export const toggleTool = async (req: Request, res: Response): Promise<void> =>
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = loadSettings();
|
||||
if (!settings.mcpServers[serverName]) {
|
||||
const serverDao = getServerDao();
|
||||
const server = await serverDao.findById(serverName);
|
||||
|
||||
if (!server) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Server not found',
|
||||
@@ -439,14 +478,15 @@ export const toggleTool = async (req: Request, res: Response): Promise<void> =>
|
||||
}
|
||||
|
||||
// Initialize tools config if it doesn't exist
|
||||
if (!settings.mcpServers[serverName].tools) {
|
||||
settings.mcpServers[serverName].tools = {};
|
||||
}
|
||||
const tools = server.tools || {};
|
||||
|
||||
// Set the tool's enabled state
|
||||
settings.mcpServers[serverName].tools![toolName] = { enabled };
|
||||
// Set the tool's enabled state (preserve existing description if any)
|
||||
tools[toolName] = { ...tools[toolName], enabled };
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
// Update via DAO (supports both file and database modes)
|
||||
const result = await serverDao.updateTools(serverName, tools);
|
||||
|
||||
if (!result) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to save settings',
|
||||
@@ -493,8 +533,10 @@ export const updateToolDescription = async (req: Request, res: Response): Promis
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = loadSettings();
|
||||
if (!settings.mcpServers[serverName]) {
|
||||
const serverDao = getServerDao();
|
||||
const server = await serverDao.findById(serverName);
|
||||
|
||||
if (!server) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Server not found',
|
||||
@@ -503,18 +545,18 @@ export const updateToolDescription = async (req: Request, res: Response): Promis
|
||||
}
|
||||
|
||||
// Initialize tools config if it doesn't exist
|
||||
if (!settings.mcpServers[serverName].tools) {
|
||||
settings.mcpServers[serverName].tools = {};
|
||||
}
|
||||
const tools = server.tools || {};
|
||||
|
||||
// Set the tool's description
|
||||
if (!settings.mcpServers[serverName].tools![toolName]) {
|
||||
settings.mcpServers[serverName].tools![toolName] = { enabled: true };
|
||||
if (!tools[toolName]) {
|
||||
tools[toolName] = { enabled: true };
|
||||
}
|
||||
tools[toolName].description = description;
|
||||
|
||||
settings.mcpServers[serverName].tools![toolName].description = description;
|
||||
// Update via DAO (supports both file and database modes)
|
||||
const result = await serverDao.updateTools(serverName, tools);
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
if (!result) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to save settings',
|
||||
@@ -929,8 +971,10 @@ export const togglePrompt = async (req: Request, res: Response): Promise<void> =
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = loadSettings();
|
||||
if (!settings.mcpServers[serverName]) {
|
||||
const serverDao = getServerDao();
|
||||
const server = await serverDao.findById(serverName);
|
||||
|
||||
if (!server) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Server not found',
|
||||
@@ -939,14 +983,15 @@ export const togglePrompt = async (req: Request, res: Response): Promise<void> =
|
||||
}
|
||||
|
||||
// Initialize prompts config if it doesn't exist
|
||||
if (!settings.mcpServers[serverName].prompts) {
|
||||
settings.mcpServers[serverName].prompts = {};
|
||||
}
|
||||
const prompts = server.prompts || {};
|
||||
|
||||
// Set the prompt's enabled state
|
||||
settings.mcpServers[serverName].prompts![promptName] = { enabled };
|
||||
// Set the prompt's enabled state (preserve existing description if any)
|
||||
prompts[promptName] = { ...prompts[promptName], enabled };
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
// Update via DAO (supports both file and database modes)
|
||||
const result = await serverDao.updatePrompts(serverName, prompts);
|
||||
|
||||
if (!result) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to save settings',
|
||||
@@ -993,8 +1038,10 @@ export const updatePromptDescription = async (req: Request, res: Response): Prom
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = loadSettings();
|
||||
if (!settings.mcpServers[serverName]) {
|
||||
const serverDao = getServerDao();
|
||||
const server = await serverDao.findById(serverName);
|
||||
|
||||
if (!server) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Server not found',
|
||||
@@ -1003,18 +1050,18 @@ export const updatePromptDescription = async (req: Request, res: Response): Prom
|
||||
}
|
||||
|
||||
// Initialize prompts config if it doesn't exist
|
||||
if (!settings.mcpServers[serverName].prompts) {
|
||||
settings.mcpServers[serverName].prompts = {};
|
||||
}
|
||||
const prompts = server.prompts || {};
|
||||
|
||||
// Set the prompt's description
|
||||
if (!settings.mcpServers[serverName].prompts![promptName]) {
|
||||
settings.mcpServers[serverName].prompts![promptName] = { enabled: true };
|
||||
if (!prompts[promptName]) {
|
||||
prompts[promptName] = { enabled: true };
|
||||
}
|
||||
prompts[promptName].description = description;
|
||||
|
||||
settings.mcpServers[serverName].prompts![promptName].description = description;
|
||||
// Update via DAO (supports both file and database modes)
|
||||
const result = await serverDao.updatePrompts(serverName, prompts);
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
if (!result) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to save settings',
|
||||
|
||||
@@ -3,6 +3,8 @@ import { ServerDao, ServerDaoImpl } from './ServerDao.js';
|
||||
import { GroupDao, GroupDaoImpl } from './GroupDao.js';
|
||||
import { SystemConfigDao, SystemConfigDaoImpl } from './SystemConfigDao.js';
|
||||
import { UserConfigDao, UserConfigDaoImpl } from './UserConfigDao.js';
|
||||
import { OAuthClientDao, OAuthClientDaoImpl } from './OAuthClientDao.js';
|
||||
import { OAuthTokenDao, OAuthTokenDaoImpl } from './OAuthTokenDao.js';
|
||||
|
||||
/**
|
||||
* DAO Factory interface for creating DAO instances
|
||||
@@ -13,6 +15,8 @@ export interface DaoFactory {
|
||||
getGroupDao(): GroupDao;
|
||||
getSystemConfigDao(): SystemConfigDao;
|
||||
getUserConfigDao(): UserConfigDao;
|
||||
getOAuthClientDao(): OAuthClientDao;
|
||||
getOAuthTokenDao(): OAuthTokenDao;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,6 +30,8 @@ export class JsonFileDaoFactory implements DaoFactory {
|
||||
private groupDao: GroupDao | null = null;
|
||||
private systemConfigDao: SystemConfigDao | null = null;
|
||||
private userConfigDao: UserConfigDao | null = null;
|
||||
private oauthClientDao: OAuthClientDao | null = null;
|
||||
private oauthTokenDao: OAuthTokenDao | null = null;
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
@@ -76,6 +82,20 @@ export class JsonFileDaoFactory implements DaoFactory {
|
||||
return this.userConfigDao;
|
||||
}
|
||||
|
||||
getOAuthClientDao(): OAuthClientDao {
|
||||
if (!this.oauthClientDao) {
|
||||
this.oauthClientDao = new OAuthClientDaoImpl();
|
||||
}
|
||||
return this.oauthClientDao;
|
||||
}
|
||||
|
||||
getOAuthTokenDao(): OAuthTokenDao {
|
||||
if (!this.oauthTokenDao) {
|
||||
this.oauthTokenDao = new OAuthTokenDaoImpl();
|
||||
}
|
||||
return this.oauthTokenDao;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all cached DAO instances (useful for testing)
|
||||
*/
|
||||
@@ -85,6 +105,8 @@ export class JsonFileDaoFactory implements DaoFactory {
|
||||
this.groupDao = null;
|
||||
this.systemConfigDao = null;
|
||||
this.userConfigDao = null;
|
||||
this.oauthClientDao = null;
|
||||
this.oauthTokenDao = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,3 +171,11 @@ export function getSystemConfigDao(): SystemConfigDao {
|
||||
export function getUserConfigDao(): UserConfigDao {
|
||||
return getDaoFactory().getUserConfigDao();
|
||||
}
|
||||
|
||||
export function getOAuthClientDao(): OAuthClientDao {
|
||||
return getDaoFactory().getOAuthClientDao();
|
||||
}
|
||||
|
||||
export function getOAuthTokenDao(): OAuthTokenDao {
|
||||
return getDaoFactory().getOAuthTokenDao();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { DaoFactory, UserDao, ServerDao, GroupDao, SystemConfigDao, UserConfigDao } from './index.js';
|
||||
import {
|
||||
DaoFactory,
|
||||
UserDao,
|
||||
ServerDao,
|
||||
GroupDao,
|
||||
SystemConfigDao,
|
||||
UserConfigDao,
|
||||
OAuthClientDao,
|
||||
OAuthTokenDao,
|
||||
} 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';
|
||||
import { OAuthClientDaoDbImpl } from './OAuthClientDaoDbImpl.js';
|
||||
import { OAuthTokenDaoDbImpl } from './OAuthTokenDaoDbImpl.js';
|
||||
|
||||
/**
|
||||
* Database-backed DAO factory implementation
|
||||
@@ -16,6 +27,8 @@ export class DatabaseDaoFactory implements DaoFactory {
|
||||
private groupDao: GroupDao | null = null;
|
||||
private systemConfigDao: SystemConfigDao | null = null;
|
||||
private userConfigDao: UserConfigDao | null = null;
|
||||
private oauthClientDao: OAuthClientDao | null = null;
|
||||
private oauthTokenDao: OAuthTokenDao | null = null;
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
@@ -66,6 +79,20 @@ export class DatabaseDaoFactory implements DaoFactory {
|
||||
return this.userConfigDao!;
|
||||
}
|
||||
|
||||
getOAuthClientDao(): OAuthClientDao {
|
||||
if (!this.oauthClientDao) {
|
||||
this.oauthClientDao = new OAuthClientDaoDbImpl();
|
||||
}
|
||||
return this.oauthClientDao!;
|
||||
}
|
||||
|
||||
getOAuthTokenDao(): OAuthTokenDao {
|
||||
if (!this.oauthTokenDao) {
|
||||
this.oauthTokenDao = new OAuthTokenDaoDbImpl();
|
||||
}
|
||||
return this.oauthTokenDao!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all cached DAO instances (useful for testing)
|
||||
*/
|
||||
@@ -75,5 +102,7 @@ export class DatabaseDaoFactory implements DaoFactory {
|
||||
this.groupDao = null;
|
||||
this.systemConfigDao = null;
|
||||
this.userConfigDao = null;
|
||||
this.oauthClientDao = null;
|
||||
this.oauthTokenDao = null;
|
||||
}
|
||||
}
|
||||
|
||||
146
src/dao/OAuthClientDao.ts
Normal file
146
src/dao/OAuthClientDao.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { IOAuthClient } from '../types/index.js';
|
||||
import { BaseDao } from './base/BaseDao.js';
|
||||
import { JsonFileBaseDao } from './base/JsonFileBaseDao.js';
|
||||
|
||||
/**
|
||||
* OAuth Client DAO interface with OAuth client-specific operations
|
||||
*/
|
||||
export interface OAuthClientDao extends BaseDao<IOAuthClient, string> {
|
||||
/**
|
||||
* Find OAuth client by client ID
|
||||
*/
|
||||
findByClientId(clientId: string): Promise<IOAuthClient | null>;
|
||||
|
||||
/**
|
||||
* Find OAuth clients by owner
|
||||
*/
|
||||
findByOwner(owner: string): Promise<IOAuthClient[]>;
|
||||
|
||||
/**
|
||||
* Validate client credentials
|
||||
*/
|
||||
validateCredentials(clientId: string, clientSecret?: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON file-based OAuth Client DAO implementation
|
||||
*/
|
||||
export class OAuthClientDaoImpl extends JsonFileBaseDao implements OAuthClientDao {
|
||||
protected async getAll(): Promise<IOAuthClient[]> {
|
||||
const settings = await this.loadSettings();
|
||||
return settings.oauthClients || [];
|
||||
}
|
||||
|
||||
protected async saveAll(clients: IOAuthClient[]): Promise<void> {
|
||||
const settings = await this.loadSettings();
|
||||
settings.oauthClients = clients;
|
||||
await this.saveSettings(settings);
|
||||
}
|
||||
|
||||
protected getEntityId(client: IOAuthClient): string {
|
||||
return client.clientId;
|
||||
}
|
||||
|
||||
protected createEntity(_data: Omit<IOAuthClient, 'clientId'>): IOAuthClient {
|
||||
throw new Error('clientId must be provided');
|
||||
}
|
||||
|
||||
protected updateEntity(existing: IOAuthClient, updates: Partial<IOAuthClient>): IOAuthClient {
|
||||
return {
|
||||
...existing,
|
||||
...updates,
|
||||
clientId: existing.clientId, // clientId should not be updated
|
||||
};
|
||||
}
|
||||
|
||||
async findAll(): Promise<IOAuthClient[]> {
|
||||
return this.getAll();
|
||||
}
|
||||
|
||||
async findById(clientId: string): Promise<IOAuthClient | null> {
|
||||
return this.findByClientId(clientId);
|
||||
}
|
||||
|
||||
async findByClientId(clientId: string): Promise<IOAuthClient | null> {
|
||||
const clients = await this.getAll();
|
||||
return clients.find((client) => client.clientId === clientId) || null;
|
||||
}
|
||||
|
||||
async findByOwner(owner: string): Promise<IOAuthClient[]> {
|
||||
const clients = await this.getAll();
|
||||
return clients.filter((client) => client.owner === owner);
|
||||
}
|
||||
|
||||
async create(data: IOAuthClient): Promise<IOAuthClient> {
|
||||
const clients = await this.getAll();
|
||||
|
||||
// Check if client already exists
|
||||
if (clients.find((client) => client.clientId === data.clientId)) {
|
||||
throw new Error(`OAuth client ${data.clientId} already exists`);
|
||||
}
|
||||
|
||||
const newClient: IOAuthClient = {
|
||||
...data,
|
||||
owner: data.owner || 'admin',
|
||||
};
|
||||
|
||||
clients.push(newClient);
|
||||
await this.saveAll(clients);
|
||||
|
||||
return newClient;
|
||||
}
|
||||
|
||||
async update(clientId: string, updates: Partial<IOAuthClient>): Promise<IOAuthClient | null> {
|
||||
const clients = await this.getAll();
|
||||
const index = clients.findIndex((client) => client.clientId === clientId);
|
||||
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Don't allow clientId changes
|
||||
const { clientId: _, ...allowedUpdates } = updates;
|
||||
const updatedClient = this.updateEntity(clients[index], allowedUpdates);
|
||||
clients[index] = updatedClient;
|
||||
|
||||
await this.saveAll(clients);
|
||||
return updatedClient;
|
||||
}
|
||||
|
||||
async delete(clientId: string): Promise<boolean> {
|
||||
const clients = await this.getAll();
|
||||
const index = clients.findIndex((client) => client.clientId === clientId);
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
clients.splice(index, 1);
|
||||
await this.saveAll(clients);
|
||||
return true;
|
||||
}
|
||||
|
||||
async exists(clientId: string): Promise<boolean> {
|
||||
const client = await this.findByClientId(clientId);
|
||||
return client !== null;
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
const clients = await this.getAll();
|
||||
return clients.length;
|
||||
}
|
||||
|
||||
async validateCredentials(clientId: string, clientSecret?: string): Promise<boolean> {
|
||||
const client = await this.findByClientId(clientId);
|
||||
if (!client) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If client has no secret (public client), accept if no secret provided
|
||||
if (!client.clientSecret) {
|
||||
return !clientSecret;
|
||||
}
|
||||
|
||||
// If client has a secret, it must match
|
||||
return client.clientSecret === clientSecret;
|
||||
}
|
||||
}
|
||||
109
src/dao/OAuthClientDaoDbImpl.ts
Normal file
109
src/dao/OAuthClientDaoDbImpl.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { OAuthClientDao } from './OAuthClientDao.js';
|
||||
import { OAuthClientRepository } from '../db/repositories/OAuthClientRepository.js';
|
||||
import { IOAuthClient } from '../types/index.js';
|
||||
|
||||
/**
|
||||
* Database-backed implementation of OAuthClientDao
|
||||
*/
|
||||
export class OAuthClientDaoDbImpl implements OAuthClientDao {
|
||||
private repository: OAuthClientRepository;
|
||||
|
||||
constructor() {
|
||||
this.repository = new OAuthClientRepository();
|
||||
}
|
||||
|
||||
async findAll(): Promise<IOAuthClient[]> {
|
||||
const clients = await this.repository.findAll();
|
||||
return clients.map((c) => this.mapToOAuthClient(c));
|
||||
}
|
||||
|
||||
async findById(clientId: string): Promise<IOAuthClient | null> {
|
||||
const client = await this.repository.findByClientId(clientId);
|
||||
return client ? this.mapToOAuthClient(client) : null;
|
||||
}
|
||||
|
||||
async findByClientId(clientId: string): Promise<IOAuthClient | null> {
|
||||
return this.findById(clientId);
|
||||
}
|
||||
|
||||
async findByOwner(owner: string): Promise<IOAuthClient[]> {
|
||||
const clients = await this.repository.findByOwner(owner);
|
||||
return clients.map((c) => this.mapToOAuthClient(c));
|
||||
}
|
||||
|
||||
async create(entity: IOAuthClient): Promise<IOAuthClient> {
|
||||
const client = await this.repository.create({
|
||||
clientId: entity.clientId,
|
||||
clientSecret: entity.clientSecret,
|
||||
name: entity.name,
|
||||
redirectUris: entity.redirectUris,
|
||||
grants: entity.grants,
|
||||
scopes: entity.scopes,
|
||||
owner: entity.owner || 'admin',
|
||||
metadata: entity.metadata,
|
||||
});
|
||||
return this.mapToOAuthClient(client);
|
||||
}
|
||||
|
||||
async update(clientId: string, entity: Partial<IOAuthClient>): Promise<IOAuthClient | null> {
|
||||
const client = await this.repository.update(clientId, {
|
||||
clientSecret: entity.clientSecret,
|
||||
name: entity.name,
|
||||
redirectUris: entity.redirectUris,
|
||||
grants: entity.grants,
|
||||
scopes: entity.scopes,
|
||||
owner: entity.owner,
|
||||
metadata: entity.metadata,
|
||||
});
|
||||
return client ? this.mapToOAuthClient(client) : null;
|
||||
}
|
||||
|
||||
async delete(clientId: string): Promise<boolean> {
|
||||
return await this.repository.delete(clientId);
|
||||
}
|
||||
|
||||
async exists(clientId: string): Promise<boolean> {
|
||||
return await this.repository.exists(clientId);
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
return await this.repository.count();
|
||||
}
|
||||
|
||||
async validateCredentials(clientId: string, clientSecret?: string): Promise<boolean> {
|
||||
const client = await this.findByClientId(clientId);
|
||||
if (!client) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If client has no secret (public client), accept if no secret provided
|
||||
if (!client.clientSecret) {
|
||||
return !clientSecret;
|
||||
}
|
||||
|
||||
// If client has a secret, it must match
|
||||
return client.clientSecret === clientSecret;
|
||||
}
|
||||
|
||||
private mapToOAuthClient(client: {
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
name: string;
|
||||
redirectUris: string[];
|
||||
grants: string[];
|
||||
scopes?: string[];
|
||||
owner?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}): IOAuthClient {
|
||||
return {
|
||||
clientId: client.clientId,
|
||||
clientSecret: client.clientSecret,
|
||||
name: client.name,
|
||||
redirectUris: client.redirectUris,
|
||||
grants: client.grants,
|
||||
scopes: client.scopes,
|
||||
owner: client.owner,
|
||||
metadata: client.metadata as IOAuthClient['metadata'],
|
||||
};
|
||||
}
|
||||
}
|
||||
259
src/dao/OAuthTokenDao.ts
Normal file
259
src/dao/OAuthTokenDao.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { IOAuthToken } from '../types/index.js';
|
||||
import { BaseDao } from './base/BaseDao.js';
|
||||
import { JsonFileBaseDao } from './base/JsonFileBaseDao.js';
|
||||
|
||||
/**
|
||||
* OAuth Token DAO interface with OAuth token-specific operations
|
||||
*/
|
||||
export interface OAuthTokenDao extends BaseDao<IOAuthToken, string> {
|
||||
/**
|
||||
* Find token by access token
|
||||
*/
|
||||
findByAccessToken(accessToken: string): Promise<IOAuthToken | null>;
|
||||
|
||||
/**
|
||||
* Find token by refresh token
|
||||
*/
|
||||
findByRefreshToken(refreshToken: string): Promise<IOAuthToken | null>;
|
||||
|
||||
/**
|
||||
* Find tokens by client ID
|
||||
*/
|
||||
findByClientId(clientId: string): Promise<IOAuthToken[]>;
|
||||
|
||||
/**
|
||||
* Find tokens by username
|
||||
*/
|
||||
findByUsername(username: string): Promise<IOAuthToken[]>;
|
||||
|
||||
/**
|
||||
* Revoke token (delete by access token or refresh token)
|
||||
*/
|
||||
revokeToken(token: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Revoke all tokens for a user
|
||||
*/
|
||||
revokeUserTokens(username: string): Promise<number>;
|
||||
|
||||
/**
|
||||
* Revoke all tokens for a client
|
||||
*/
|
||||
revokeClientTokens(clientId: string): Promise<number>;
|
||||
|
||||
/**
|
||||
* Clean up expired tokens
|
||||
*/
|
||||
cleanupExpired(): Promise<number>;
|
||||
|
||||
/**
|
||||
* Check if access token is valid (exists and not expired)
|
||||
*/
|
||||
isAccessTokenValid(accessToken: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Check if refresh token is valid (exists and not expired)
|
||||
*/
|
||||
isRefreshTokenValid(refreshToken: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON file-based OAuth Token DAO implementation
|
||||
*/
|
||||
export class OAuthTokenDaoImpl extends JsonFileBaseDao implements OAuthTokenDao {
|
||||
protected async getAll(): Promise<IOAuthToken[]> {
|
||||
const settings = await this.loadSettings();
|
||||
// Convert stored dates back to Date objects
|
||||
return (settings.oauthTokens || []).map((token) => ({
|
||||
...token,
|
||||
accessTokenExpiresAt: new Date(token.accessTokenExpiresAt),
|
||||
refreshTokenExpiresAt: token.refreshTokenExpiresAt
|
||||
? new Date(token.refreshTokenExpiresAt)
|
||||
: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
protected async saveAll(tokens: IOAuthToken[]): Promise<void> {
|
||||
const settings = await this.loadSettings();
|
||||
settings.oauthTokens = tokens;
|
||||
await this.saveSettings(settings);
|
||||
}
|
||||
|
||||
protected getEntityId(token: IOAuthToken): string {
|
||||
return token.accessToken;
|
||||
}
|
||||
|
||||
protected createEntity(_data: Omit<IOAuthToken, 'accessToken'>): IOAuthToken {
|
||||
throw new Error('accessToken must be provided');
|
||||
}
|
||||
|
||||
protected updateEntity(existing: IOAuthToken, updates: Partial<IOAuthToken>): IOAuthToken {
|
||||
return {
|
||||
...existing,
|
||||
...updates,
|
||||
accessToken: existing.accessToken, // accessToken should not be updated
|
||||
};
|
||||
}
|
||||
|
||||
async findAll(): Promise<IOAuthToken[]> {
|
||||
return this.getAll();
|
||||
}
|
||||
|
||||
async findById(accessToken: string): Promise<IOAuthToken | null> {
|
||||
return this.findByAccessToken(accessToken);
|
||||
}
|
||||
|
||||
async findByAccessToken(accessToken: string): Promise<IOAuthToken | null> {
|
||||
const tokens = await this.getAll();
|
||||
return tokens.find((token) => token.accessToken === accessToken) || null;
|
||||
}
|
||||
|
||||
async findByRefreshToken(refreshToken: string): Promise<IOAuthToken | null> {
|
||||
const tokens = await this.getAll();
|
||||
return tokens.find((token) => token.refreshToken === refreshToken) || null;
|
||||
}
|
||||
|
||||
async findByClientId(clientId: string): Promise<IOAuthToken[]> {
|
||||
const tokens = await this.getAll();
|
||||
return tokens.filter((token) => token.clientId === clientId);
|
||||
}
|
||||
|
||||
async findByUsername(username: string): Promise<IOAuthToken[]> {
|
||||
const tokens = await this.getAll();
|
||||
return tokens.filter((token) => token.username === username);
|
||||
}
|
||||
|
||||
async create(data: IOAuthToken): Promise<IOAuthToken> {
|
||||
const tokens = await this.getAll();
|
||||
|
||||
// Remove any existing tokens with the same access token or refresh token
|
||||
const filteredTokens = tokens.filter(
|
||||
(t) => t.accessToken !== data.accessToken && t.refreshToken !== data.refreshToken,
|
||||
);
|
||||
|
||||
const newToken: IOAuthToken = {
|
||||
...data,
|
||||
};
|
||||
|
||||
filteredTokens.push(newToken);
|
||||
await this.saveAll(filteredTokens);
|
||||
|
||||
return newToken;
|
||||
}
|
||||
|
||||
async update(accessToken: string, updates: Partial<IOAuthToken>): Promise<IOAuthToken | null> {
|
||||
const tokens = await this.getAll();
|
||||
const index = tokens.findIndex((token) => token.accessToken === accessToken);
|
||||
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Don't allow accessToken changes
|
||||
const { accessToken: _, ...allowedUpdates } = updates;
|
||||
const updatedToken = this.updateEntity(tokens[index], allowedUpdates);
|
||||
tokens[index] = updatedToken;
|
||||
|
||||
await this.saveAll(tokens);
|
||||
return updatedToken;
|
||||
}
|
||||
|
||||
async delete(accessToken: string): Promise<boolean> {
|
||||
const tokens = await this.getAll();
|
||||
const index = tokens.findIndex((token) => token.accessToken === accessToken);
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
tokens.splice(index, 1);
|
||||
await this.saveAll(tokens);
|
||||
return true;
|
||||
}
|
||||
|
||||
async exists(accessToken: string): Promise<boolean> {
|
||||
const token = await this.findByAccessToken(accessToken);
|
||||
return token !== null;
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
const tokens = await this.getAll();
|
||||
return tokens.length;
|
||||
}
|
||||
|
||||
async revokeToken(token: string): Promise<boolean> {
|
||||
const tokens = await this.getAll();
|
||||
const tokenData = tokens.find((t) => t.accessToken === token || t.refreshToken === token);
|
||||
|
||||
if (!tokenData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const filteredTokens = tokens.filter(
|
||||
(t) => t.accessToken !== tokenData.accessToken && t.refreshToken !== tokenData.refreshToken,
|
||||
);
|
||||
|
||||
await this.saveAll(filteredTokens);
|
||||
return true;
|
||||
}
|
||||
|
||||
async revokeUserTokens(username: string): Promise<number> {
|
||||
const tokens = await this.getAll();
|
||||
const userTokens = tokens.filter((token) => token.username === username);
|
||||
const remainingTokens = tokens.filter((token) => token.username !== username);
|
||||
|
||||
await this.saveAll(remainingTokens);
|
||||
return userTokens.length;
|
||||
}
|
||||
|
||||
async revokeClientTokens(clientId: string): Promise<number> {
|
||||
const tokens = await this.getAll();
|
||||
const clientTokens = tokens.filter((token) => token.clientId === clientId);
|
||||
const remainingTokens = tokens.filter((token) => token.clientId !== clientId);
|
||||
|
||||
await this.saveAll(remainingTokens);
|
||||
return clientTokens.length;
|
||||
}
|
||||
|
||||
async cleanupExpired(): Promise<number> {
|
||||
const tokens = await this.getAll();
|
||||
const now = new Date();
|
||||
|
||||
const validTokens = tokens.filter((token) => {
|
||||
// Keep if access token is still valid
|
||||
if (token.accessTokenExpiresAt > now) {
|
||||
return true;
|
||||
}
|
||||
// Or if refresh token exists and is still valid
|
||||
if (token.refreshToken && token.refreshTokenExpiresAt && token.refreshTokenExpiresAt > now) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const expiredCount = tokens.length - validTokens.length;
|
||||
if (expiredCount > 0) {
|
||||
await this.saveAll(validTokens);
|
||||
}
|
||||
|
||||
return expiredCount;
|
||||
}
|
||||
|
||||
async isAccessTokenValid(accessToken: string): Promise<boolean> {
|
||||
const token = await this.findByAccessToken(accessToken);
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
return token.accessTokenExpiresAt > new Date();
|
||||
}
|
||||
|
||||
async isRefreshTokenValid(refreshToken: string): Promise<boolean> {
|
||||
const token = await this.findByRefreshToken(refreshToken);
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
if (!token.refreshTokenExpiresAt) {
|
||||
return true; // No expiration means always valid
|
||||
}
|
||||
return token.refreshTokenExpiresAt > new Date();
|
||||
}
|
||||
}
|
||||
122
src/dao/OAuthTokenDaoDbImpl.ts
Normal file
122
src/dao/OAuthTokenDaoDbImpl.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { OAuthTokenDao } from './OAuthTokenDao.js';
|
||||
import { OAuthTokenRepository } from '../db/repositories/OAuthTokenRepository.js';
|
||||
import { IOAuthToken } from '../types/index.js';
|
||||
|
||||
/**
|
||||
* Database-backed implementation of OAuthTokenDao
|
||||
*/
|
||||
export class OAuthTokenDaoDbImpl implements OAuthTokenDao {
|
||||
private repository: OAuthTokenRepository;
|
||||
|
||||
constructor() {
|
||||
this.repository = new OAuthTokenRepository();
|
||||
}
|
||||
|
||||
async findAll(): Promise<IOAuthToken[]> {
|
||||
const tokens = await this.repository.findAll();
|
||||
return tokens.map((t) => this.mapToOAuthToken(t));
|
||||
}
|
||||
|
||||
async findById(accessToken: string): Promise<IOAuthToken | null> {
|
||||
const token = await this.repository.findByAccessToken(accessToken);
|
||||
return token ? this.mapToOAuthToken(token) : null;
|
||||
}
|
||||
|
||||
async findByAccessToken(accessToken: string): Promise<IOAuthToken | null> {
|
||||
return this.findById(accessToken);
|
||||
}
|
||||
|
||||
async findByRefreshToken(refreshToken: string): Promise<IOAuthToken | null> {
|
||||
const token = await this.repository.findByRefreshToken(refreshToken);
|
||||
return token ? this.mapToOAuthToken(token) : null;
|
||||
}
|
||||
|
||||
async findByClientId(clientId: string): Promise<IOAuthToken[]> {
|
||||
const tokens = await this.repository.findByClientId(clientId);
|
||||
return tokens.map((t) => this.mapToOAuthToken(t));
|
||||
}
|
||||
|
||||
async findByUsername(username: string): Promise<IOAuthToken[]> {
|
||||
const tokens = await this.repository.findByUsername(username);
|
||||
return tokens.map((t) => this.mapToOAuthToken(t));
|
||||
}
|
||||
|
||||
async create(entity: IOAuthToken): Promise<IOAuthToken> {
|
||||
const token = await this.repository.create({
|
||||
accessToken: entity.accessToken,
|
||||
accessTokenExpiresAt: entity.accessTokenExpiresAt,
|
||||
refreshToken: entity.refreshToken,
|
||||
refreshTokenExpiresAt: entity.refreshTokenExpiresAt,
|
||||
scope: entity.scope,
|
||||
clientId: entity.clientId,
|
||||
username: entity.username,
|
||||
});
|
||||
return this.mapToOAuthToken(token);
|
||||
}
|
||||
|
||||
async update(accessToken: string, entity: Partial<IOAuthToken>): Promise<IOAuthToken | null> {
|
||||
const token = await this.repository.update(accessToken, {
|
||||
accessTokenExpiresAt: entity.accessTokenExpiresAt,
|
||||
refreshToken: entity.refreshToken,
|
||||
refreshTokenExpiresAt: entity.refreshTokenExpiresAt,
|
||||
scope: entity.scope,
|
||||
});
|
||||
return token ? this.mapToOAuthToken(token) : null;
|
||||
}
|
||||
|
||||
async delete(accessToken: string): Promise<boolean> {
|
||||
return await this.repository.delete(accessToken);
|
||||
}
|
||||
|
||||
async exists(accessToken: string): Promise<boolean> {
|
||||
return await this.repository.exists(accessToken);
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
return await this.repository.count();
|
||||
}
|
||||
|
||||
async revokeToken(token: string): Promise<boolean> {
|
||||
return await this.repository.revokeToken(token);
|
||||
}
|
||||
|
||||
async revokeUserTokens(username: string): Promise<number> {
|
||||
return await this.repository.revokeUserTokens(username);
|
||||
}
|
||||
|
||||
async revokeClientTokens(clientId: string): Promise<number> {
|
||||
return await this.repository.revokeClientTokens(clientId);
|
||||
}
|
||||
|
||||
async cleanupExpired(): Promise<number> {
|
||||
return await this.repository.cleanupExpired();
|
||||
}
|
||||
|
||||
async isAccessTokenValid(accessToken: string): Promise<boolean> {
|
||||
return await this.repository.isAccessTokenValid(accessToken);
|
||||
}
|
||||
|
||||
async isRefreshTokenValid(refreshToken: string): Promise<boolean> {
|
||||
return await this.repository.isRefreshTokenValid(refreshToken);
|
||||
}
|
||||
|
||||
private mapToOAuthToken(token: {
|
||||
accessToken: string;
|
||||
accessTokenExpiresAt: Date;
|
||||
refreshToken?: string;
|
||||
refreshTokenExpiresAt?: Date;
|
||||
scope?: string;
|
||||
clientId: string;
|
||||
username: string;
|
||||
}): IOAuthToken {
|
||||
return {
|
||||
accessToken: token.accessToken,
|
||||
accessTokenExpiresAt: token.accessTokenExpiresAt,
|
||||
refreshToken: token.refreshToken,
|
||||
refreshTokenExpiresAt: token.refreshTokenExpiresAt,
|
||||
scope: token.scope,
|
||||
clientId: token.clientId,
|
||||
username: token.username,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export class ServerDaoDbImpl implements ServerDao {
|
||||
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,
|
||||
@@ -41,7 +42,10 @@ export class ServerDaoDbImpl implements ServerDao {
|
||||
return this.mapToServerConfig(server);
|
||||
}
|
||||
|
||||
async update(name: string, entity: Partial<ServerConfigWithName>): Promise<ServerConfigWithName | null> {
|
||||
async update(
|
||||
name: string,
|
||||
entity: Partial<ServerConfigWithName>,
|
||||
): Promise<ServerConfigWithName | null> {
|
||||
const server = await this.repository.update(name, {
|
||||
type: entity.type,
|
||||
url: entity.url,
|
||||
@@ -51,6 +55,7 @@ export class ServerDaoDbImpl implements ServerDao {
|
||||
headers: entity.headers,
|
||||
enabled: entity.enabled,
|
||||
owner: entity.owner,
|
||||
enableKeepAlive: entity.enableKeepAlive,
|
||||
keepAliveInterval: entity.keepAliveInterval,
|
||||
tools: entity.tools,
|
||||
prompts: entity.prompts,
|
||||
@@ -118,6 +123,7 @@ export class ServerDaoDbImpl implements ServerDao {
|
||||
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 }>;
|
||||
@@ -134,6 +140,7 @@ export class ServerDaoDbImpl implements ServerDao {
|
||||
headers: server.headers,
|
||||
enabled: server.enabled,
|
||||
owner: server.owner,
|
||||
enableKeepAlive: server.enableKeepAlive,
|
||||
keepAliveInterval: server.keepAliveInterval,
|
||||
tools: server.tools,
|
||||
prompts: server.prompts,
|
||||
|
||||
@@ -6,6 +6,8 @@ export * from './ServerDao.js';
|
||||
export * from './GroupDao.js';
|
||||
export * from './SystemConfigDao.js';
|
||||
export * from './UserConfigDao.js';
|
||||
export * from './OAuthClientDao.js';
|
||||
export * from './OAuthTokenDao.js';
|
||||
|
||||
// Export database implementations
|
||||
export * from './UserDaoDbImpl.js';
|
||||
@@ -13,6 +15,8 @@ export * from './ServerDaoDbImpl.js';
|
||||
export * from './GroupDaoDbImpl.js';
|
||||
export * from './SystemConfigDaoDbImpl.js';
|
||||
export * from './UserConfigDaoDbImpl.js';
|
||||
export * from './OAuthClientDaoDbImpl.js';
|
||||
export * from './OAuthTokenDaoDbImpl.js';
|
||||
|
||||
// Export the DAO factory and convenience functions
|
||||
export * from './DaoFactory.js';
|
||||
|
||||
60
src/db/entities/OAuthClient.ts
Normal file
60
src/db/entities/OAuthClient.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* OAuth Client entity for database storage
|
||||
* Represents OAuth clients registered with MCPHub's authorization server
|
||||
*/
|
||||
@Entity({ name: 'oauth_clients' })
|
||||
export class OAuthClient {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'client_id', type: 'varchar', length: 255, unique: true })
|
||||
clientId: string;
|
||||
|
||||
@Column({ name: 'client_secret', type: 'varchar', length: 255, nullable: true })
|
||||
clientSecret?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
name: string;
|
||||
|
||||
@Column({ name: 'redirect_uris', type: 'simple-json' })
|
||||
redirectUris: string[];
|
||||
|
||||
@Column({ type: 'simple-json' })
|
||||
grants: string[];
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
scopes?: string[];
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
owner?: string;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
metadata?: {
|
||||
application_type?: 'web' | 'native';
|
||||
response_types?: string[];
|
||||
token_endpoint_auth_method?: string;
|
||||
contacts?: string[];
|
||||
logo_uri?: string;
|
||||
client_uri?: string;
|
||||
policy_uri?: string;
|
||||
tos_uri?: string;
|
||||
jwks_uri?: string;
|
||||
jwks?: object;
|
||||
};
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export default OAuthClient;
|
||||
51
src/db/entities/OAuthToken.ts
Normal file
51
src/db/entities/OAuthToken.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* OAuth Token entity for database storage
|
||||
* Represents OAuth tokens issued by MCPHub's authorization server
|
||||
*/
|
||||
@Entity({ name: 'oauth_tokens' })
|
||||
export class OAuthToken {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'access_token', type: 'varchar', length: 512, unique: true })
|
||||
accessToken: string;
|
||||
|
||||
@Column({ name: 'access_token_expires_at', type: 'timestamp' })
|
||||
accessTokenExpiresAt: Date;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'refresh_token', type: 'varchar', length: 512, nullable: true, unique: true })
|
||||
refreshToken?: string;
|
||||
|
||||
@Column({ name: 'refresh_token_expires_at', type: 'timestamp', nullable: true })
|
||||
refreshTokenExpiresAt?: Date;
|
||||
|
||||
@Column({ type: 'varchar', length: 512, nullable: true })
|
||||
scope?: string;
|
||||
|
||||
@Index()
|
||||
@Column({ name: 'client_id', type: 'varchar', length: 255 })
|
||||
clientId: string;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
username: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export default OAuthToken;
|
||||
@@ -41,6 +41,9 @@ export class Server {
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
owner?: string;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
enableKeepAlive?: boolean;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
keepAliveInterval?: number;
|
||||
|
||||
|
||||
@@ -4,9 +4,20 @@ import Server from './Server.js';
|
||||
import Group from './Group.js';
|
||||
import SystemConfig from './SystemConfig.js';
|
||||
import UserConfig from './UserConfig.js';
|
||||
import OAuthClient from './OAuthClient.js';
|
||||
import OAuthToken from './OAuthToken.js';
|
||||
|
||||
// Export all entities
|
||||
export default [VectorEmbedding, User, Server, Group, SystemConfig, UserConfig];
|
||||
export default [
|
||||
VectorEmbedding,
|
||||
User,
|
||||
Server,
|
||||
Group,
|
||||
SystemConfig,
|
||||
UserConfig,
|
||||
OAuthClient,
|
||||
OAuthToken,
|
||||
];
|
||||
|
||||
// Export individual entities for direct use
|
||||
export { VectorEmbedding, User, Server, Group, SystemConfig, UserConfig };
|
||||
export { VectorEmbedding, User, Server, Group, SystemConfig, UserConfig, OAuthClient, OAuthToken };
|
||||
|
||||
@@ -16,7 +16,7 @@ export class GroupRepository {
|
||||
* Find all groups
|
||||
*/
|
||||
async findAll(): Promise<Group[]> {
|
||||
return await this.repository.find();
|
||||
return await this.repository.find({ order: { createdAt: 'ASC' } });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,7 +88,7 @@ export class GroupRepository {
|
||||
* Find groups by owner
|
||||
*/
|
||||
async findByOwner(owner: string): Promise<Group[]> {
|
||||
return await this.repository.find({ where: { owner } });
|
||||
return await this.repository.find({ where: { owner }, order: { createdAt: 'ASC' } });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
80
src/db/repositories/OAuthClientRepository.ts
Normal file
80
src/db/repositories/OAuthClientRepository.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Repository } from 'typeorm';
|
||||
import { OAuthClient } from '../entities/OAuthClient.js';
|
||||
import { getAppDataSource } from '../connection.js';
|
||||
|
||||
/**
|
||||
* Repository for OAuthClient entity
|
||||
*/
|
||||
export class OAuthClientRepository {
|
||||
private repository: Repository<OAuthClient>;
|
||||
|
||||
constructor() {
|
||||
this.repository = getAppDataSource().getRepository(OAuthClient);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all OAuth clients
|
||||
*/
|
||||
async findAll(): Promise<OAuthClient[]> {
|
||||
return await this.repository.find();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find OAuth client by client ID
|
||||
*/
|
||||
async findByClientId(clientId: string): Promise<OAuthClient | null> {
|
||||
return await this.repository.findOne({ where: { clientId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find OAuth clients by owner
|
||||
*/
|
||||
async findByOwner(owner: string): Promise<OAuthClient[]> {
|
||||
return await this.repository.find({ where: { owner } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new OAuth client
|
||||
*/
|
||||
async create(client: Omit<OAuthClient, 'id' | 'createdAt' | 'updatedAt'>): Promise<OAuthClient> {
|
||||
const newClient = this.repository.create(client);
|
||||
return await this.repository.save(newClient);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing OAuth client
|
||||
*/
|
||||
async update(clientId: string, clientData: Partial<OAuthClient>): Promise<OAuthClient | null> {
|
||||
const client = await this.findByClientId(clientId);
|
||||
if (!client) {
|
||||
return null;
|
||||
}
|
||||
const updated = this.repository.merge(client, clientData);
|
||||
return await this.repository.save(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an OAuth client
|
||||
*/
|
||||
async delete(clientId: string): Promise<boolean> {
|
||||
const result = await this.repository.delete({ clientId });
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OAuth client exists
|
||||
*/
|
||||
async exists(clientId: string): Promise<boolean> {
|
||||
const count = await this.repository.count({ where: { clientId } });
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total OAuth clients
|
||||
*/
|
||||
async count(): Promise<number> {
|
||||
return await this.repository.count();
|
||||
}
|
||||
}
|
||||
|
||||
export default OAuthClientRepository;
|
||||
183
src/db/repositories/OAuthTokenRepository.ts
Normal file
183
src/db/repositories/OAuthTokenRepository.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { Repository, MoreThan } from 'typeorm';
|
||||
import { OAuthToken } from '../entities/OAuthToken.js';
|
||||
import { getAppDataSource } from '../connection.js';
|
||||
|
||||
/**
|
||||
* Repository for OAuthToken entity
|
||||
*/
|
||||
export class OAuthTokenRepository {
|
||||
private repository: Repository<OAuthToken>;
|
||||
|
||||
constructor() {
|
||||
this.repository = getAppDataSource().getRepository(OAuthToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all OAuth tokens
|
||||
*/
|
||||
async findAll(): Promise<OAuthToken[]> {
|
||||
return await this.repository.find();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find OAuth token by access token
|
||||
*/
|
||||
async findByAccessToken(accessToken: string): Promise<OAuthToken | null> {
|
||||
return await this.repository.findOne({ where: { accessToken } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find OAuth token by refresh token
|
||||
*/
|
||||
async findByRefreshToken(refreshToken: string): Promise<OAuthToken | null> {
|
||||
return await this.repository.findOne({ where: { refreshToken } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find OAuth tokens by client ID
|
||||
*/
|
||||
async findByClientId(clientId: string): Promise<OAuthToken[]> {
|
||||
return await this.repository.find({ where: { clientId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find OAuth tokens by username
|
||||
*/
|
||||
async findByUsername(username: string): Promise<OAuthToken[]> {
|
||||
return await this.repository.find({ where: { username } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new OAuth token
|
||||
*/
|
||||
async create(token: Omit<OAuthToken, 'id' | 'createdAt' | 'updatedAt'>): Promise<OAuthToken> {
|
||||
// Remove any existing tokens with the same access token or refresh token
|
||||
if (token.accessToken) {
|
||||
await this.repository.delete({ accessToken: token.accessToken });
|
||||
}
|
||||
if (token.refreshToken) {
|
||||
await this.repository.delete({ refreshToken: token.refreshToken });
|
||||
}
|
||||
|
||||
const newToken = this.repository.create(token);
|
||||
return await this.repository.save(newToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing OAuth token
|
||||
*/
|
||||
async update(accessToken: string, tokenData: Partial<OAuthToken>): Promise<OAuthToken | null> {
|
||||
const token = await this.findByAccessToken(accessToken);
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
const updated = this.repository.merge(token, tokenData);
|
||||
return await this.repository.save(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an OAuth token by access token
|
||||
*/
|
||||
async delete(accessToken: string): Promise<boolean> {
|
||||
const result = await this.repository.delete({ accessToken });
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OAuth token exists by access token
|
||||
*/
|
||||
async exists(accessToken: string): Promise<boolean> {
|
||||
const count = await this.repository.count({ where: { accessToken } });
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total OAuth tokens
|
||||
*/
|
||||
async count(): Promise<number> {
|
||||
return await this.repository.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke token by access token or refresh token
|
||||
*/
|
||||
async revokeToken(token: string): Promise<boolean> {
|
||||
// Try to find by access token first
|
||||
let tokenEntity = await this.findByAccessToken(token);
|
||||
if (!tokenEntity) {
|
||||
// Try to find by refresh token
|
||||
tokenEntity = await this.findByRefreshToken(token);
|
||||
}
|
||||
|
||||
if (!tokenEntity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await this.repository.delete({ id: tokenEntity.id });
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all tokens for a user
|
||||
*/
|
||||
async revokeUserTokens(username: string): Promise<number> {
|
||||
const result = await this.repository.delete({ username });
|
||||
return result.affected ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all tokens for a client
|
||||
*/
|
||||
async revokeClientTokens(clientId: string): Promise<number> {
|
||||
const result = await this.repository.delete({ clientId });
|
||||
return result.affected ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired tokens
|
||||
*/
|
||||
async cleanupExpired(): Promise<number> {
|
||||
const now = new Date();
|
||||
|
||||
// Delete tokens where both access token and refresh token are expired
|
||||
// (or refresh token doesn't exist)
|
||||
const result = await this.repository
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.from(OAuthToken)
|
||||
.where('access_token_expires_at < :now', { now })
|
||||
.andWhere('(refresh_token_expires_at IS NULL OR refresh_token_expires_at < :now)', { now })
|
||||
.execute();
|
||||
|
||||
return result.affected ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if access token is valid (exists and not expired)
|
||||
*/
|
||||
async isAccessTokenValid(accessToken: string): Promise<boolean> {
|
||||
const count = await this.repository.count({
|
||||
where: {
|
||||
accessToken,
|
||||
accessTokenExpiresAt: MoreThan(new Date()),
|
||||
},
|
||||
});
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if refresh token is valid (exists and not expired)
|
||||
*/
|
||||
async isRefreshTokenValid(refreshToken: string): Promise<boolean> {
|
||||
const token = await this.findByRefreshToken(refreshToken);
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
if (!token.refreshTokenExpiresAt) {
|
||||
return true; // No expiration means always valid
|
||||
}
|
||||
return token.refreshTokenExpiresAt > new Date();
|
||||
}
|
||||
}
|
||||
|
||||
export default OAuthTokenRepository;
|
||||
@@ -16,7 +16,7 @@ export class ServerRepository {
|
||||
* Find all servers
|
||||
*/
|
||||
async findAll(): Promise<Server[]> {
|
||||
return await this.repository.find();
|
||||
return await this.repository.find({ order: { createdAt: 'ASC' } });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,14 +73,14 @@ export class ServerRepository {
|
||||
* Find servers by owner
|
||||
*/
|
||||
async findByOwner(owner: string): Promise<Server[]> {
|
||||
return await this.repository.find({ where: { owner } });
|
||||
return await this.repository.find({ where: { owner }, order: { createdAt: 'ASC' } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find enabled servers
|
||||
*/
|
||||
async findEnabled(): Promise<Server[]> {
|
||||
return await this.repository.find({ where: { enabled: true } });
|
||||
return await this.repository.find({ where: { enabled: true }, order: { createdAt: 'ASC' } });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,7 +16,7 @@ export class UserRepository {
|
||||
* Find all users
|
||||
*/
|
||||
async findAll(): Promise<User[]> {
|
||||
return await this.repository.find();
|
||||
return await this.repository.find({ order: { createdAt: 'ASC' } });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,7 +73,7 @@ export class UserRepository {
|
||||
* Find all admin users
|
||||
*/
|
||||
async findAdmins(): Promise<User[]> {
|
||||
return await this.repository.find({ where: { isAdmin: true } });
|
||||
return await this.repository.find({ where: { isAdmin: true }, order: { createdAt: 'ASC' } });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { ServerRepository } from './ServerRepository.js';
|
||||
import { GroupRepository } from './GroupRepository.js';
|
||||
import { SystemConfigRepository } from './SystemConfigRepository.js';
|
||||
import { UserConfigRepository } from './UserConfigRepository.js';
|
||||
import { OAuthClientRepository } from './OAuthClientRepository.js';
|
||||
import { OAuthTokenRepository } from './OAuthTokenRepository.js';
|
||||
|
||||
// Export all repositories
|
||||
export {
|
||||
@@ -13,4 +15,6 @@ export {
|
||||
GroupRepository,
|
||||
SystemConfigRepository,
|
||||
UserConfigRepository,
|
||||
OAuthClientRepository,
|
||||
OAuthTokenRepository,
|
||||
};
|
||||
|
||||
@@ -67,7 +67,7 @@ export const auth = async (req: Request, res: Response, next: NextFunction): Pro
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith('Bearer ') && isOAuthServerEnabled()) {
|
||||
const accessToken = authHeader.substring(7);
|
||||
const oauthToken = getToken(accessToken);
|
||||
const oauthToken = await getToken(accessToken);
|
||||
|
||||
if (oauthToken && oauthToken.accessToken === accessToken) {
|
||||
// Valid OAuth token - look up user to get admin status
|
||||
|
||||
@@ -1,112 +1,89 @@
|
||||
import crypto from 'crypto';
|
||||
import { loadSettings, saveSettings } from '../config/index.js';
|
||||
import { getOAuthClientDao, getOAuthTokenDao } from '../dao/index.js';
|
||||
import { IOAuthClient, IOAuthAuthorizationCode, IOAuthToken } from '../types/index.js';
|
||||
|
||||
// In-memory storage for authorization codes and tokens
|
||||
// Authorization codes are short-lived and kept in memory only.
|
||||
// Tokens are mirrored to settings (mcp_settings.json) for persistence.
|
||||
// In-memory storage for authorization codes (short-lived, no persistence needed)
|
||||
const authorizationCodes = new Map<string, IOAuthAuthorizationCode>();
|
||||
const tokens = new Map<string, IOAuthToken>();
|
||||
|
||||
// Initialize token store from settings on first import
|
||||
(() => {
|
||||
// In-memory cache for tokens (also persisted via DAO)
|
||||
const tokensCache = new Map<string, IOAuthToken>();
|
||||
|
||||
// Flag to track if we've initialized from DAO
|
||||
let initialized = false;
|
||||
|
||||
/**
|
||||
* Initialize token cache from DAO (async)
|
||||
*/
|
||||
const initializeTokenCache = async (): Promise<void> => {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (Array.isArray(settings.oauthTokens)) {
|
||||
for (const stored of settings.oauthTokens) {
|
||||
const token: IOAuthToken = {
|
||||
...stored,
|
||||
accessTokenExpiresAt: new Date(stored.accessTokenExpiresAt),
|
||||
refreshTokenExpiresAt: stored.refreshTokenExpiresAt
|
||||
? new Date(stored.refreshTokenExpiresAt)
|
||||
: undefined,
|
||||
};
|
||||
tokens.set(token.accessToken, token);
|
||||
if (token.refreshToken) {
|
||||
tokens.set(token.refreshToken, token);
|
||||
}
|
||||
const tokenDao = getOAuthTokenDao();
|
||||
const allTokens = await tokenDao.findAll();
|
||||
for (const token of allTokens) {
|
||||
tokensCache.set(token.accessToken, token);
|
||||
if (token.refreshToken) {
|
||||
tokensCache.set(token.refreshToken, token);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize OAuth tokens from settings:', error);
|
||||
console.error('Failed to initialize OAuth tokens from DAO:', error);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
// Initialize on module load (fire and forget for backward compatibility)
|
||||
initializeTokenCache().catch(console.error);
|
||||
|
||||
/**
|
||||
* Get all OAuth clients from configuration
|
||||
*/
|
||||
export const getOAuthClients = (): IOAuthClient[] => {
|
||||
const settings = loadSettings();
|
||||
return settings.oauthClients || [];
|
||||
export const getOAuthClients = async (): Promise<IOAuthClient[]> => {
|
||||
const clientDao = getOAuthClientDao();
|
||||
return clientDao.findAll();
|
||||
};
|
||||
|
||||
/**
|
||||
* Find OAuth client by client ID
|
||||
*/
|
||||
export const findOAuthClientById = (clientId: string): IOAuthClient | undefined => {
|
||||
const clients = getOAuthClients();
|
||||
return clients.find((c) => c.clientId === clientId);
|
||||
export const findOAuthClientById = async (clientId: string): Promise<IOAuthClient | undefined> => {
|
||||
const clientDao = getOAuthClientDao();
|
||||
const client = await clientDao.findByClientId(clientId);
|
||||
return client || undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new OAuth client
|
||||
*/
|
||||
export const createOAuthClient = (client: IOAuthClient): IOAuthClient => {
|
||||
const settings = loadSettings();
|
||||
if (!settings.oauthClients) {
|
||||
settings.oauthClients = [];
|
||||
}
|
||||
export const createOAuthClient = async (client: IOAuthClient): Promise<IOAuthClient> => {
|
||||
const clientDao = getOAuthClientDao();
|
||||
|
||||
// Check if client already exists
|
||||
const existing = settings.oauthClients.find((c) => c.clientId === client.clientId);
|
||||
const existing = await clientDao.findByClientId(client.clientId);
|
||||
if (existing) {
|
||||
throw new Error(`OAuth client with ID ${client.clientId} already exists`);
|
||||
}
|
||||
|
||||
settings.oauthClients.push(client);
|
||||
saveSettings(settings);
|
||||
return client;
|
||||
return clientDao.create(client);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update an existing OAuth client
|
||||
*/
|
||||
export const updateOAuthClient = (
|
||||
export const updateOAuthClient = async (
|
||||
clientId: string,
|
||||
updates: Partial<IOAuthClient>,
|
||||
): IOAuthClient | null => {
|
||||
const settings = loadSettings();
|
||||
if (!settings.oauthClients) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const index = settings.oauthClients.findIndex((c) => c.clientId === clientId);
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
settings.oauthClients[index] = { ...settings.oauthClients[index], ...updates };
|
||||
saveSettings(settings);
|
||||
return settings.oauthClients[index];
|
||||
): Promise<IOAuthClient | null> => {
|
||||
const clientDao = getOAuthClientDao();
|
||||
return clientDao.update(clientId, updates);
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete an OAuth client
|
||||
*/
|
||||
export const deleteOAuthClient = (clientId: string): boolean => {
|
||||
const settings = loadSettings();
|
||||
if (!settings.oauthClients) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const index = settings.oauthClients.findIndex((c) => c.clientId === clientId);
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
settings.oauthClients.splice(index, 1);
|
||||
saveSettings(settings);
|
||||
return true;
|
||||
export const deleteOAuthClient = async (clientId: string): Promise<boolean> => {
|
||||
const clientDao = getOAuthClientDao();
|
||||
return clientDao.delete(clientId);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -163,11 +140,11 @@ export const revokeAuthorizationCode = (code: string): void => {
|
||||
/**
|
||||
* Save access token and optionally refresh token
|
||||
*/
|
||||
export const saveToken = (
|
||||
export const saveToken = async (
|
||||
tokenData: Omit<IOAuthToken, 'accessToken' | 'accessTokenExpiresAt'>,
|
||||
accessTokenLifetime: number = 3600,
|
||||
refreshTokenLifetime?: number,
|
||||
): IOAuthToken => {
|
||||
): Promise<IOAuthToken> => {
|
||||
const accessToken = generateToken();
|
||||
const accessTokenExpiresAt = new Date(Date.now() + accessTokenLifetime * 1000);
|
||||
|
||||
@@ -187,30 +164,18 @@ export const saveToken = (
|
||||
...tokenData,
|
||||
};
|
||||
|
||||
tokens.set(accessToken, token);
|
||||
// Update cache
|
||||
tokensCache.set(accessToken, token);
|
||||
if (refreshToken) {
|
||||
tokens.set(refreshToken, token);
|
||||
tokensCache.set(refreshToken, token);
|
||||
}
|
||||
|
||||
// Persist tokens to settings
|
||||
// Persist to DAO
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
const existing = settings.oauthTokens || [];
|
||||
const filtered = existing.filter(
|
||||
(t) => t.accessToken !== token.accessToken && t.refreshToken !== token.refreshToken,
|
||||
);
|
||||
const updated = [
|
||||
...filtered,
|
||||
{
|
||||
...token,
|
||||
accessTokenExpiresAt: token.accessTokenExpiresAt,
|
||||
refreshTokenExpiresAt: token.refreshTokenExpiresAt,
|
||||
},
|
||||
];
|
||||
settings.oauthTokens = updated;
|
||||
saveSettings(settings);
|
||||
const tokenDao = getOAuthTokenDao();
|
||||
await tokenDao.create(token);
|
||||
} catch (error) {
|
||||
console.error('Failed to persist OAuth token to settings:', error);
|
||||
console.error('Failed to persist OAuth token to DAO:', error);
|
||||
}
|
||||
|
||||
return token;
|
||||
@@ -219,8 +184,27 @@ export const saveToken = (
|
||||
/**
|
||||
* Get token by access token or refresh token
|
||||
*/
|
||||
export const getToken = (token: string): IOAuthToken | undefined => {
|
||||
const tokenData = tokens.get(token);
|
||||
export const getToken = async (token: string): Promise<IOAuthToken | undefined> => {
|
||||
// First check cache
|
||||
let tokenData = tokensCache.get(token);
|
||||
|
||||
// If not in cache, try DAO
|
||||
if (!tokenData) {
|
||||
const tokenDao = getOAuthTokenDao();
|
||||
tokenData =
|
||||
(await tokenDao.findByAccessToken(token)) ||
|
||||
(await tokenDao.findByRefreshToken(token)) ||
|
||||
undefined;
|
||||
|
||||
// Update cache if found
|
||||
if (tokenData) {
|
||||
tokensCache.set(tokenData.accessToken, tokenData);
|
||||
if (tokenData.refreshToken) {
|
||||
tokensCache.set(tokenData.refreshToken, tokenData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!tokenData) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -245,34 +229,28 @@ export const getToken = (token: string): IOAuthToken | undefined => {
|
||||
/**
|
||||
* Revoke token (both access and refresh tokens)
|
||||
*/
|
||||
export const revokeToken = (token: string): void => {
|
||||
const tokenData = tokens.get(token);
|
||||
export const revokeToken = async (token: string): Promise<void> => {
|
||||
const tokenData = tokensCache.get(token);
|
||||
if (tokenData) {
|
||||
tokens.delete(tokenData.accessToken);
|
||||
tokensCache.delete(tokenData.accessToken);
|
||||
if (tokenData.refreshToken) {
|
||||
tokens.delete(tokenData.refreshToken);
|
||||
tokensCache.delete(tokenData.refreshToken);
|
||||
}
|
||||
}
|
||||
|
||||
// Also remove from persisted settings
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (Array.isArray(settings.oauthTokens)) {
|
||||
settings.oauthTokens = settings.oauthTokens.filter(
|
||||
(t) =>
|
||||
t.accessToken !== tokenData.accessToken && t.refreshToken !== tokenData.refreshToken,
|
||||
);
|
||||
saveSettings(settings);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to remove OAuth token from settings:', error);
|
||||
}
|
||||
// Also remove from DAO
|
||||
try {
|
||||
const tokenDao = getOAuthTokenDao();
|
||||
await tokenDao.revokeToken(token);
|
||||
} catch (error) {
|
||||
console.error('Failed to remove OAuth token from DAO:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clean up expired codes and tokens (should be called periodically)
|
||||
*/
|
||||
export const cleanupExpired = (): void => {
|
||||
export const cleanupExpired = async (): Promise<void> => {
|
||||
const now = new Date();
|
||||
|
||||
// Clean up expired authorization codes
|
||||
@@ -282,9 +260,9 @@ export const cleanupExpired = (): void => {
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up expired tokens
|
||||
// Clean up expired tokens from cache
|
||||
const processedTokens = new Set<string>();
|
||||
for (const [_key, token] of tokens.entries()) {
|
||||
for (const [_key, token] of tokensCache.entries()) {
|
||||
// Skip if we've already processed this token
|
||||
if (processedTokens.has(token.accessToken)) {
|
||||
continue;
|
||||
@@ -294,35 +272,19 @@ export const cleanupExpired = (): void => {
|
||||
const accessExpired = token.accessTokenExpiresAt < now;
|
||||
const refreshExpired = token.refreshTokenExpiresAt && token.refreshTokenExpiresAt < now;
|
||||
|
||||
// If both are expired, remove the token
|
||||
// If both are expired, remove from cache
|
||||
if (accessExpired && (!token.refreshToken || refreshExpired)) {
|
||||
tokens.delete(token.accessToken);
|
||||
tokensCache.delete(token.accessToken);
|
||||
if (token.refreshToken) {
|
||||
tokens.delete(token.refreshToken);
|
||||
tokensCache.delete(token.refreshToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync persisted tokens: keep only non-expired ones
|
||||
// Clean up expired tokens from DAO
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (Array.isArray(settings.oauthTokens)) {
|
||||
const validTokens: IOAuthToken[] = [];
|
||||
for (const stored of settings.oauthTokens) {
|
||||
const accessExpiresAt = new Date(stored.accessTokenExpiresAt);
|
||||
const refreshExpiresAt = stored.refreshTokenExpiresAt
|
||||
? new Date(stored.refreshTokenExpiresAt)
|
||||
: undefined;
|
||||
const accessExpired = accessExpiresAt < now;
|
||||
const refreshExpired = refreshExpiresAt && refreshExpiresAt < now;
|
||||
|
||||
if (!accessExpired || (stored.refreshToken && !refreshExpired)) {
|
||||
validTokens.push(stored);
|
||||
}
|
||||
}
|
||||
settings.oauthTokens = validTokens;
|
||||
saveSettings(settings);
|
||||
}
|
||||
const tokenDao = getOAuthTokenDao();
|
||||
await tokenDao.cleanupExpired();
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup persisted OAuth tokens:', error);
|
||||
}
|
||||
@@ -331,7 +293,12 @@ export const cleanupExpired = (): void => {
|
||||
// Run cleanup every 5 minutes in production
|
||||
let cleanupIntervalId: NodeJS.Timeout | null = null;
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
cleanupIntervalId = setInterval(cleanupExpired, 5 * 60 * 1000);
|
||||
cleanupIntervalId = setInterval(
|
||||
() => {
|
||||
cleanupExpired().catch(console.error);
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
// Allow the interval to not keep the process alive
|
||||
cleanupIntervalId.unref();
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
updateServer,
|
||||
deleteServer,
|
||||
toggleServer,
|
||||
reloadServer,
|
||||
toggleTool,
|
||||
updateToolDescription,
|
||||
togglePrompt,
|
||||
@@ -136,6 +137,7 @@ export const initRoutes = (app: express.Application): void => {
|
||||
router.put('/servers/:name', updateServer);
|
||||
router.delete('/servers/:name', deleteServer);
|
||||
router.post('/servers/:name/toggle', toggleServer);
|
||||
router.post('/servers/:name/reload', reloadServer);
|
||||
router.post('/servers/:serverName/tools/:toolName/toggle', toggleTool);
|
||||
router.put('/servers/:serverName/tools/:toolName/description', updateToolDescription);
|
||||
router.post('/servers/:serverName/prompts/:promptName/toggle', togglePrompt);
|
||||
|
||||
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}`);
|
||||
}
|
||||
};
|
||||
@@ -29,42 +29,7 @@ import { createOAuthProvider } from './mcpOAuthProvider.js';
|
||||
|
||||
const servers: { [sessionId: string]: Server } = {};
|
||||
|
||||
// Helper function to set up keep-alive ping for SSE connections
|
||||
const setupKeepAlive = (serverInfo: ServerInfo, serverConfig: ServerConfig): void => {
|
||||
// Only applicable to SSE connections
|
||||
if (!(serverInfo.transport instanceof SSEClientTransport)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep-alive is disabled by default to avoid excessive API calls to external MCP servers
|
||||
if (serverConfig.keepAlive !== true) {
|
||||
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
|
||||
@@ -312,11 +277,7 @@ const callToolWithReconnect = async (
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
prompts: {},
|
||||
resources: {},
|
||||
tools: {},
|
||||
},
|
||||
capabilities: {},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -498,11 +459,7 @@ export const initializeClientsFromSettings = async (
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
prompts: {},
|
||||
resources: {},
|
||||
tools: {},
|
||||
},
|
||||
capabilities: {},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -601,9 +558,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} `;
|
||||
@@ -817,10 +775,9 @@ export const addOrUpdateServer = async (
|
||||
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);
|
||||
@@ -999,23 +956,14 @@ Available servers: ${serversList}`,
|
||||
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);
|
||||
let tools = await filterToolsByConfig(serverInfo.name, serverInfo.tools);
|
||||
|
||||
// If this is a group request, apply group-level tool filtering
|
||||
if (group) {
|
||||
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: string) => `${serverInfo.name}${getNameSeparator()}${toolName}`,
|
||||
);
|
||||
enabledTools = enabledTools.filter((tool) => allowedToolNames.includes(tool.name));
|
||||
}
|
||||
}
|
||||
tools = await filterToolsByGroup(group, serverInfo.name, tools);
|
||||
|
||||
// Apply custom descriptions from server configuration
|
||||
const serverConfig = await getServerDao().findById(serverInfo.name);
|
||||
const toolsWithCustomDescriptions = enabledTools.map((tool) => {
|
||||
const toolsWithCustomDescriptions = tools.map((tool) => {
|
||||
const toolConfig = serverConfig?.tools?.[tool.name];
|
||||
return {
|
||||
...tool,
|
||||
@@ -1062,12 +1010,15 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
|
||||
|
||||
// Determine server filtering based on group
|
||||
const sessionId = extra.sessionId || '';
|
||||
const group = getGroup(sessionId);
|
||||
let 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);
|
||||
if (targetGroup) {
|
||||
group = targetGroup;
|
||||
}
|
||||
const serversInGroup = await getServersInGroup(targetGroup);
|
||||
if (serversInGroup !== undefined && serversInGroup !== null) {
|
||||
servers = serversInGroup;
|
||||
@@ -1099,8 +1050,8 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
|
||||
const actualTool = server.tools.find((tool) => tool.name === result.toolName);
|
||||
if (actualTool) {
|
||||
// Check if the tool is enabled in configuration
|
||||
const enabledTools = await filterToolsByConfig(server.name, [actualTool]);
|
||||
if (enabledTools.length > 0) {
|
||||
const tools = await filterToolsByConfig(server.name, [actualTool]);
|
||||
if (tools.length > 0) {
|
||||
// Apply custom description from configuration
|
||||
const serverConfig = await getServerDao().findById(server.name);
|
||||
const toolConfig = serverConfig?.tools?.[actualTool.name];
|
||||
@@ -1126,19 +1077,24 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
|
||||
);
|
||||
|
||||
// Now filter the resolved tools
|
||||
const tools = await Promise.all(
|
||||
resolvedTools.filter(async (tool) => {
|
||||
// Additional filter to remove tools that are disabled
|
||||
const filterResults = await Promise.all(
|
||||
resolvedTools.map(async (tool) => {
|
||||
if (tool.name) {
|
||||
const serverName = tool.serverName;
|
||||
if (serverName) {
|
||||
const enabledTools = await filterToolsByConfig(serverName, [tool as Tool]);
|
||||
return enabledTools.length > 0;
|
||||
let tools = await filterToolsByConfig(serverName, [tool as Tool]);
|
||||
if (tools.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
tools = await filterToolsByGroup(group, serverName, tools);
|
||||
return tools.length > 0;
|
||||
}
|
||||
}
|
||||
return true; // Keep fallback results
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
const tools = resolvedTools.filter((_, i) => filterResults[i]);
|
||||
|
||||
// Add usage guidance to the response
|
||||
const response = {
|
||||
@@ -1530,3 +1486,18 @@ export const createMcpServer = (name: string, version: string, group?: string):
|
||||
server.setRequestHandler(ListPromptsRequestSchema, handleListPromptsRequest);
|
||||
return server;
|
||||
};
|
||||
|
||||
// Filter tools based on group configuration
|
||||
async function filterToolsByGroup(group: string | undefined, serverName: string, tools: Tool[]) {
|
||||
if (group) {
|
||||
const serverConfig = await getServerConfigInGroup(group, serverName);
|
||||
if (serverConfig && serverConfig.tools !== 'all' && Array.isArray(serverConfig.tools)) {
|
||||
// Filter tools based on group configuration
|
||||
const allowedToolNames = serverConfig.tools.map(
|
||||
(toolName: string) => `${serverName}${getNameSeparator()}${toolName}`,
|
||||
);
|
||||
tools = tools.filter((tool) => allowedToolNames.includes(tool.name));
|
||||
}
|
||||
}
|
||||
return tools;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const oauthModel: OAuth2Server.AuthorizationCodeModel & OAuth2Server.RefreshToke
|
||||
* Get client by client ID
|
||||
*/
|
||||
getClient: async (clientId: string, clientSecret?: string) => {
|
||||
const client = findOAuthClientById(clientId);
|
||||
const client = await findOAuthClientById(clientId);
|
||||
if (!client) {
|
||||
return false;
|
||||
}
|
||||
@@ -92,7 +92,7 @@ const oauthModel: OAuth2Server.AuthorizationCodeModel & OAuth2Server.RefreshToke
|
||||
return false;
|
||||
}
|
||||
|
||||
const client = findOAuthClientById(code.clientId);
|
||||
const client = await findOAuthClientById(code.clientId);
|
||||
if (!client) {
|
||||
return false;
|
||||
}
|
||||
@@ -143,7 +143,7 @@ const oauthModel: OAuth2Server.AuthorizationCodeModel & OAuth2Server.RefreshToke
|
||||
|
||||
const scopeString = Array.isArray(token.scope) ? token.scope.join(' ') : token.scope;
|
||||
|
||||
const savedToken = saveToken(
|
||||
const savedToken = await saveToken(
|
||||
{
|
||||
scope: scopeString,
|
||||
clientId: client.id,
|
||||
@@ -172,12 +172,12 @@ const oauthModel: OAuth2Server.AuthorizationCodeModel & OAuth2Server.RefreshToke
|
||||
* Get access token
|
||||
*/
|
||||
getAccessToken: async (accessToken: string) => {
|
||||
const token = getToken(accessToken);
|
||||
const token = await getToken(accessToken);
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const client = findOAuthClientById(token.clientId);
|
||||
const client = await findOAuthClientById(token.clientId);
|
||||
if (!client) {
|
||||
return false;
|
||||
}
|
||||
@@ -205,12 +205,12 @@ const oauthModel: OAuth2Server.AuthorizationCodeModel & OAuth2Server.RefreshToke
|
||||
* Get refresh token
|
||||
*/
|
||||
getRefreshToken: async (refreshToken: string) => {
|
||||
const token = getToken(refreshToken);
|
||||
const token = await getToken(refreshToken);
|
||||
if (!token || token.refreshToken !== refreshToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const client = findOAuthClientById(token.clientId);
|
||||
const client = await findOAuthClientById(token.clientId);
|
||||
if (!client) {
|
||||
return false;
|
||||
}
|
||||
@@ -240,7 +240,7 @@ const oauthModel: OAuth2Server.AuthorizationCodeModel & OAuth2Server.RefreshToke
|
||||
revokeToken: async (token: OAuth2Server.Token | OAuth2Server.RefreshToken) => {
|
||||
const refreshToken = 'refreshToken' in token ? token.refreshToken : undefined;
|
||||
if (refreshToken) {
|
||||
revokeToken(refreshToken);
|
||||
await revokeToken(refreshToken);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -215,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}`);
|
||||
@@ -329,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);
|
||||
};
|
||||
@@ -397,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);
|
||||
};
|
||||
|
||||
@@ -266,8 +266,8 @@ 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
|
||||
keepAlive?: boolean; // Enable keep-alive ping for SSE servers (default: false - disabled to avoid excessive API calls)
|
||||
keepAliveInterval?: number; // Keep-alive ping interval in milliseconds (default: 60000ms when keepAlive is enabled)
|
||||
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
|
||||
options?: Partial<Pick<RequestOptions, 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout'>>; // MCP request options configuration
|
||||
|
||||
@@ -7,6 +7,8 @@ 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';
|
||||
import { OAuthClientRepository } from '../db/repositories/OAuthClientRepository.js';
|
||||
import { OAuthTokenRepository } from '../db/repositories/OAuthTokenRepository.js';
|
||||
|
||||
/**
|
||||
* Migrate from file-based configuration to database
|
||||
@@ -29,6 +31,8 @@ export async function migrateToDatabase(): Promise<boolean> {
|
||||
const groupRepo = new GroupRepository();
|
||||
const systemConfigRepo = new SystemConfigRepository();
|
||||
const userConfigRepo = new UserConfigRepository();
|
||||
const oauthClientRepo = new OAuthClientRepository();
|
||||
const oauthTokenRepo = new OAuthTokenRepository();
|
||||
|
||||
// Migrate users
|
||||
if (settings.users && settings.users.length > 0) {
|
||||
@@ -65,6 +69,7 @@ export async function migrateToDatabase(): Promise<boolean> {
|
||||
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,
|
||||
@@ -128,6 +133,53 @@ export async function migrateToDatabase(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate OAuth clients
|
||||
if (settings.oauthClients && settings.oauthClients.length > 0) {
|
||||
console.log(`Migrating ${settings.oauthClients.length} OAuth clients...`);
|
||||
for (const client of settings.oauthClients) {
|
||||
const exists = await oauthClientRepo.exists(client.clientId);
|
||||
if (!exists) {
|
||||
await oauthClientRepo.create({
|
||||
clientId: client.clientId,
|
||||
clientSecret: client.clientSecret,
|
||||
name: client.name,
|
||||
redirectUris: client.redirectUris,
|
||||
grants: client.grants,
|
||||
scopes: client.scopes,
|
||||
owner: client.owner,
|
||||
metadata: client.metadata,
|
||||
});
|
||||
console.log(` - Created OAuth client: ${client.clientId}`);
|
||||
} else {
|
||||
console.log(` - OAuth client already exists: ${client.clientId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate OAuth tokens
|
||||
if (settings.oauthTokens && settings.oauthTokens.length > 0) {
|
||||
console.log(`Migrating ${settings.oauthTokens.length} OAuth tokens...`);
|
||||
for (const token of settings.oauthTokens) {
|
||||
const exists = await oauthTokenRepo.exists(token.accessToken);
|
||||
if (!exists) {
|
||||
await oauthTokenRepo.create({
|
||||
accessToken: token.accessToken,
|
||||
refreshToken: token.refreshToken,
|
||||
accessTokenExpiresAt: new Date(token.accessTokenExpiresAt),
|
||||
refreshTokenExpiresAt: token.refreshTokenExpiresAt
|
||||
? new Date(token.refreshTokenExpiresAt)
|
||||
: undefined,
|
||||
scope: token.scope,
|
||||
clientId: token.clientId,
|
||||
username: token.username,
|
||||
});
|
||||
console.log(` - Created OAuth token for client: ${token.clientId}`);
|
||||
} else {
|
||||
console.log(` - OAuth token already exists: ${token.accessToken.substring(0, 8)}...`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ Migration completed successfully');
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
||||
@@ -11,7 +11,7 @@ export const resolveOAuthUserFromToken = async (token?: string): Promise<IUser |
|
||||
return null;
|
||||
}
|
||||
|
||||
const oauthToken = getOAuthStoredToken(token);
|
||||
const oauthToken = await getOAuthStoredToken(token);
|
||||
if (!oauthToken || oauthToken.accessToken !== token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,33 +1,43 @@
|
||||
import { getMcpSettingsJson } from '../../src/controllers/configController.js'
|
||||
import * as config from '../../src/config/index.js'
|
||||
import { Request, Response } from 'express'
|
||||
import { getMcpSettingsJson } from '../../src/controllers/configController.js';
|
||||
import * as config from '../../src/config/index.js';
|
||||
import * as DaoFactory from '../../src/dao/DaoFactory.js';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
// Mock the config module
|
||||
jest.mock('../../src/config/index.js')
|
||||
jest.mock('../../src/config/index.js');
|
||||
// Mock the DaoFactory module
|
||||
jest.mock('../../src/dao/DaoFactory.js');
|
||||
|
||||
describe('ConfigController - getMcpSettingsJson', () => {
|
||||
let mockRequest: Partial<Request>
|
||||
let mockResponse: Partial<Response>
|
||||
let mockJson: jest.Mock
|
||||
let mockStatus: jest.Mock
|
||||
let mockRequest: Partial<Request>;
|
||||
let mockResponse: Partial<Response>;
|
||||
let mockJson: jest.Mock;
|
||||
let mockStatus: jest.Mock;
|
||||
let mockServerDao: { findById: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
mockJson = jest.fn()
|
||||
mockStatus = jest.fn().mockReturnThis()
|
||||
mockJson = jest.fn();
|
||||
mockStatus = jest.fn().mockReturnThis();
|
||||
mockRequest = {
|
||||
query: {},
|
||||
}
|
||||
};
|
||||
mockResponse = {
|
||||
json: mockJson,
|
||||
status: mockStatus,
|
||||
}
|
||||
};
|
||||
mockServerDao = {
|
||||
findById: jest.fn(),
|
||||
};
|
||||
|
||||
// Setup ServerDao mock
|
||||
(DaoFactory.getServerDao as jest.Mock).mockReturnValue(mockServerDao);
|
||||
|
||||
// Reset mocks
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Full Settings Export', () => {
|
||||
it('should handle settings without users array', () => {
|
||||
it('should handle settings without users array', async () => {
|
||||
const mockSettings = {
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
@@ -35,11 +45,11 @@ describe('ConfigController - getMcpSettingsJson', () => {
|
||||
args: ['--test'],
|
||||
},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
;(config.loadOriginalSettings as jest.Mock).mockReturnValue(mockSettings)
|
||||
(config.loadOriginalSettings as jest.Mock).mockReturnValue(mockSettings);
|
||||
|
||||
getMcpSettingsJson(mockRequest as Request, mockResponse as Response)
|
||||
await getMcpSettingsJson(mockRequest as Request, mockResponse as Response);
|
||||
|
||||
expect(mockJson).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
@@ -47,40 +57,27 @@ describe('ConfigController - getMcpSettingsJson', () => {
|
||||
mcpServers: mockSettings.mcpServers,
|
||||
users: undefined,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Individual Server Export', () => {
|
||||
it('should return individual server configuration when serverName is specified', () => {
|
||||
const mockSettings = {
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
command: 'test',
|
||||
args: ['--test'],
|
||||
env: {
|
||||
TEST_VAR: 'test-value',
|
||||
},
|
||||
},
|
||||
'another-server': {
|
||||
command: 'another',
|
||||
args: ['--another'],
|
||||
},
|
||||
it('should return individual server configuration when serverName is specified', async () => {
|
||||
const serverConfig = {
|
||||
name: 'test-server',
|
||||
command: 'test',
|
||||
args: ['--test'],
|
||||
env: {
|
||||
TEST_VAR: 'test-value',
|
||||
},
|
||||
users: [
|
||||
{
|
||||
username: 'admin',
|
||||
password: '$2b$10$hashedpassword',
|
||||
isAdmin: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
mockRequest.query = { serverName: 'test-server' }
|
||||
;(config.loadOriginalSettings as jest.Mock).mockReturnValue(mockSettings)
|
||||
mockRequest.query = { serverName: 'test-server' };
|
||||
mockServerDao.findById.mockResolvedValue(serverConfig);
|
||||
|
||||
getMcpSettingsJson(mockRequest as Request, mockResponse as Response)
|
||||
await getMcpSettingsJson(mockRequest as Request, mockResponse as Response);
|
||||
|
||||
expect(mockServerDao.findById).toHaveBeenCalledWith('test-server');
|
||||
expect(mockJson).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
data: {
|
||||
@@ -94,46 +91,73 @@ describe('ConfigController - getMcpSettingsJson', () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 404 when server does not exist', () => {
|
||||
const mockSettings = {
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
command: 'test',
|
||||
args: ['--test'],
|
||||
},
|
||||
},
|
||||
}
|
||||
it('should return 404 when server does not exist', async () => {
|
||||
mockRequest.query = { serverName: 'non-existent-server' };
|
||||
mockServerDao.findById.mockResolvedValue(null);
|
||||
|
||||
mockRequest.query = { serverName: 'non-existent-server' }
|
||||
;(config.loadOriginalSettings as jest.Mock).mockReturnValue(mockSettings)
|
||||
await getMcpSettingsJson(mockRequest as Request, mockResponse as Response);
|
||||
|
||||
getMcpSettingsJson(mockRequest as Request, mockResponse as Response)
|
||||
|
||||
expect(mockStatus).toHaveBeenCalledWith(404)
|
||||
expect(mockServerDao.findById).toHaveBeenCalledWith('non-existent-server');
|
||||
expect(mockStatus).toHaveBeenCalledWith(404);
|
||||
expect(mockJson).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
message: "Server 'non-existent-server' not found",
|
||||
})
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove null values from server configuration', async () => {
|
||||
const serverConfig = {
|
||||
name: 'test-server',
|
||||
command: 'test',
|
||||
args: ['--test'],
|
||||
url: null,
|
||||
env: null,
|
||||
headers: null,
|
||||
options: {
|
||||
timeout: 30,
|
||||
retries: null,
|
||||
},
|
||||
};
|
||||
|
||||
mockRequest.query = { serverName: 'test-server' };
|
||||
mockServerDao.findById.mockResolvedValue(serverConfig);
|
||||
|
||||
await getMcpSettingsJson(mockRequest as Request, mockResponse as Response);
|
||||
|
||||
expect(mockJson).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
data: {
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
command: 'test',
|
||||
args: ['--test'],
|
||||
options: {
|
||||
timeout: 30,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle errors gracefully and return 500', () => {
|
||||
const errorMessage = 'Failed to load settings'
|
||||
;(config.loadOriginalSettings as jest.Mock).mockImplementation(() => {
|
||||
throw new Error(errorMessage)
|
||||
})
|
||||
it('should handle errors gracefully and return 500', async () => {
|
||||
const errorMessage = 'Failed to load settings';
|
||||
(config.loadOriginalSettings as jest.Mock).mockImplementation(() => {
|
||||
throw new Error(errorMessage);
|
||||
});
|
||||
|
||||
getMcpSettingsJson(mockRequest as Request, mockResponse as Response)
|
||||
await getMcpSettingsJson(mockRequest as Request, mockResponse as Response);
|
||||
|
||||
expect(mockStatus).toHaveBeenCalledWith(500)
|
||||
expect(mockStatus).toHaveBeenCalledWith(500);
|
||||
expect(mockJson).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
message: 'Failed to get MCP settings',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,29 +10,86 @@ import {
|
||||
getToken,
|
||||
revokeToken,
|
||||
} from '../../src/models/OAuth.js';
|
||||
import { IOAuthClient, IOAuthToken } from '../../src/types/index.js';
|
||||
|
||||
// Mock the config module to use in-memory storage for tests
|
||||
let mockSettings = { mcpServers: {}, users: [], oauthClients: [] };
|
||||
// Mock in-memory storage for OAuth clients and tokens
|
||||
let mockOAuthClients: IOAuthClient[] = [];
|
||||
let mockOAuthTokens: IOAuthToken[] = [];
|
||||
|
||||
jest.mock('../../src/config/index.js', () => ({
|
||||
loadSettings: jest.fn(() => ({ ...mockSettings })),
|
||||
saveSettings: jest.fn((settings: any) => {
|
||||
mockSettings = { ...settings };
|
||||
return true;
|
||||
}),
|
||||
loadOriginalSettings: jest.fn(() => ({ ...mockSettings })),
|
||||
}));
|
||||
// Mock the DAO factory to use in-memory storage for tests
|
||||
jest.mock('../../src/dao/index.js', () => {
|
||||
const originalModule = jest.requireActual('../../src/dao/index.js');
|
||||
|
||||
return {
|
||||
...originalModule,
|
||||
getOAuthClientDao: jest.fn(() => ({
|
||||
findAll: jest.fn(async () => [...mockOAuthClients]),
|
||||
findByClientId: jest.fn(
|
||||
async (clientId: string) => mockOAuthClients.find((c) => c.clientId === clientId) || null,
|
||||
),
|
||||
create: jest.fn(async (client: IOAuthClient) => {
|
||||
mockOAuthClients.push(client);
|
||||
return client;
|
||||
}),
|
||||
update: jest.fn(async (clientId: string, updates: Partial<IOAuthClient>) => {
|
||||
const index = mockOAuthClients.findIndex((c) => c.clientId === clientId);
|
||||
if (index === -1) return null;
|
||||
mockOAuthClients[index] = { ...mockOAuthClients[index], ...updates };
|
||||
return mockOAuthClients[index];
|
||||
}),
|
||||
delete: jest.fn(async (clientId: string) => {
|
||||
const index = mockOAuthClients.findIndex((c) => c.clientId === clientId);
|
||||
if (index === -1) return false;
|
||||
mockOAuthClients.splice(index, 1);
|
||||
return true;
|
||||
}),
|
||||
})),
|
||||
getOAuthTokenDao: jest.fn(() => ({
|
||||
findAll: jest.fn(async () => [...mockOAuthTokens]),
|
||||
findByAccessToken: jest.fn(
|
||||
async (accessToken: string) =>
|
||||
mockOAuthTokens.find((t) => t.accessToken === accessToken) || null,
|
||||
),
|
||||
findByRefreshToken: jest.fn(
|
||||
async (refreshToken: string) =>
|
||||
mockOAuthTokens.find((t) => t.refreshToken === refreshToken) || null,
|
||||
),
|
||||
create: jest.fn(async (token: IOAuthToken) => {
|
||||
mockOAuthTokens.push(token);
|
||||
return token;
|
||||
}),
|
||||
revokeToken: jest.fn(async (token: string) => {
|
||||
const index = mockOAuthTokens.findIndex(
|
||||
(t) => t.accessToken === token || t.refreshToken === token,
|
||||
);
|
||||
if (index === -1) return false;
|
||||
mockOAuthTokens.splice(index, 1);
|
||||
return true;
|
||||
}),
|
||||
cleanupExpired: jest.fn(async () => {
|
||||
const now = new Date();
|
||||
mockOAuthTokens = mockOAuthTokens.filter((t) => {
|
||||
const accessExpired = t.accessTokenExpiresAt < now;
|
||||
const refreshExpired =
|
||||
!t.refreshToken || (t.refreshTokenExpiresAt && t.refreshTokenExpiresAt < now);
|
||||
return !accessExpired || !refreshExpired;
|
||||
});
|
||||
}),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
describe('OAuth Model', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Reset mock settings before each test
|
||||
mockSettings = { mcpServers: {}, users: [], oauthClients: [] };
|
||||
// Reset mock storage before each test
|
||||
mockOAuthClients = [];
|
||||
mockOAuthTokens = [];
|
||||
});
|
||||
|
||||
describe('OAuth Client Management', () => {
|
||||
test('should create a new OAuth client', () => {
|
||||
const client = {
|
||||
test('should create a new OAuth client', async () => {
|
||||
const client: IOAuthClient = {
|
||||
clientId: 'test-client',
|
||||
clientSecret: 'test-secret',
|
||||
name: 'Test Client',
|
||||
@@ -41,15 +98,15 @@ describe('OAuth Model', () => {
|
||||
scopes: ['read', 'write'],
|
||||
};
|
||||
|
||||
const created = createOAuthClient(client);
|
||||
const created = await createOAuthClient(client);
|
||||
expect(created).toEqual(client);
|
||||
|
||||
const found = findOAuthClientById('test-client');
|
||||
const found = await findOAuthClientById('test-client');
|
||||
expect(found).toEqual(client);
|
||||
});
|
||||
|
||||
test('should not create duplicate OAuth client', () => {
|
||||
const client = {
|
||||
test('should not create duplicate OAuth client', async () => {
|
||||
const client: IOAuthClient = {
|
||||
clientId: 'test-client',
|
||||
clientSecret: 'test-secret',
|
||||
name: 'Test Client',
|
||||
@@ -58,12 +115,12 @@ describe('OAuth Model', () => {
|
||||
scopes: ['read'],
|
||||
};
|
||||
|
||||
createOAuthClient(client);
|
||||
expect(() => createOAuthClient(client)).toThrow();
|
||||
await createOAuthClient(client);
|
||||
await expect(createOAuthClient(client)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('should update an OAuth client', () => {
|
||||
const client = {
|
||||
test('should update an OAuth client', async () => {
|
||||
const client: IOAuthClient = {
|
||||
clientId: 'test-client',
|
||||
clientSecret: 'test-secret',
|
||||
name: 'Test Client',
|
||||
@@ -72,9 +129,9 @@ describe('OAuth Model', () => {
|
||||
scopes: ['read'],
|
||||
};
|
||||
|
||||
createOAuthClient(client);
|
||||
await createOAuthClient(client);
|
||||
|
||||
const updated = updateOAuthClient('test-client', {
|
||||
const updated = await updateOAuthClient('test-client', {
|
||||
name: 'Updated Client',
|
||||
scopes: ['read', 'write'],
|
||||
});
|
||||
@@ -83,8 +140,8 @@ describe('OAuth Model', () => {
|
||||
expect(updated?.scopes).toEqual(['read', 'write']);
|
||||
});
|
||||
|
||||
test('should delete an OAuth client', () => {
|
||||
const client = {
|
||||
test('should delete an OAuth client', async () => {
|
||||
const client: IOAuthClient = {
|
||||
clientId: 'test-client',
|
||||
clientSecret: 'test-secret',
|
||||
name: 'Test Client',
|
||||
@@ -93,12 +150,12 @@ describe('OAuth Model', () => {
|
||||
scopes: ['read'],
|
||||
};
|
||||
|
||||
createOAuthClient(client);
|
||||
expect(findOAuthClientById('test-client')).toBeDefined();
|
||||
await createOAuthClient(client);
|
||||
expect(await findOAuthClientById('test-client')).toBeDefined();
|
||||
|
||||
const deleted = deleteOAuthClient('test-client');
|
||||
const deleted = await deleteOAuthClient('test-client');
|
||||
expect(deleted).toBe(true);
|
||||
expect(findOAuthClientById('test-client')).toBeUndefined();
|
||||
expect(await findOAuthClientById('test-client')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -157,8 +214,8 @@ describe('OAuth Model', () => {
|
||||
});
|
||||
|
||||
describe('Token Management', () => {
|
||||
test('should save and retrieve token', () => {
|
||||
const token = saveToken(
|
||||
test('should save and retrieve token', async () => {
|
||||
const token = await saveToken(
|
||||
{
|
||||
scope: 'read write',
|
||||
clientId: 'test-client',
|
||||
@@ -172,14 +229,14 @@ describe('OAuth Model', () => {
|
||||
expect(token.refreshToken).toBeDefined();
|
||||
expect(token.accessTokenExpiresAt).toBeInstanceOf(Date);
|
||||
|
||||
const retrieved = getToken(token.accessToken);
|
||||
const retrieved = await getToken(token.accessToken);
|
||||
expect(retrieved).toBeDefined();
|
||||
expect(retrieved?.clientId).toBe('test-client');
|
||||
expect(retrieved?.username).toBe('testuser');
|
||||
});
|
||||
|
||||
test('should retrieve token by refresh token', () => {
|
||||
const token = saveToken(
|
||||
test('should retrieve token by refresh token', async () => {
|
||||
const token = await saveToken(
|
||||
{
|
||||
scope: 'read',
|
||||
clientId: 'test-client',
|
||||
@@ -191,13 +248,13 @@ describe('OAuth Model', () => {
|
||||
|
||||
expect(token.refreshToken).toBeDefined();
|
||||
|
||||
const retrieved = getToken(token.refreshToken!);
|
||||
const retrieved = await getToken(token.refreshToken!);
|
||||
expect(retrieved).toBeDefined();
|
||||
expect(retrieved?.accessToken).toBe(token.accessToken);
|
||||
});
|
||||
|
||||
test('should not retrieve expired access token', async () => {
|
||||
const token = saveToken(
|
||||
const token = await saveToken(
|
||||
{
|
||||
scope: 'read',
|
||||
clientId: 'test-client',
|
||||
@@ -208,12 +265,12 @@ describe('OAuth Model', () => {
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const retrieved = getToken(token.accessToken);
|
||||
const retrieved = await getToken(token.accessToken);
|
||||
expect(retrieved).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should revoke token', () => {
|
||||
const token = saveToken(
|
||||
test('should revoke token', async () => {
|
||||
const token = await saveToken(
|
||||
{
|
||||
scope: 'read',
|
||||
clientId: 'test-client',
|
||||
@@ -223,13 +280,13 @@ describe('OAuth Model', () => {
|
||||
86400,
|
||||
);
|
||||
|
||||
expect(getToken(token.accessToken)).toBeDefined();
|
||||
expect(await getToken(token.accessToken)).toBeDefined();
|
||||
|
||||
revokeToken(token.accessToken);
|
||||
expect(getToken(token.accessToken)).toBeUndefined();
|
||||
await revokeToken(token.accessToken);
|
||||
expect(await getToken(token.accessToken)).toBeUndefined();
|
||||
|
||||
if (token.refreshToken) {
|
||||
expect(getToken(token.refreshToken)).toBeUndefined();
|
||||
expect(await getToken(token.refreshToken)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -13,7 +13,6 @@ export const createMockSettings = (overrides: Partial<McpSettings> = {}): McpSet
|
||||
args: ['-y', 'time-mcp'],
|
||||
env: {},
|
||||
enabled: true,
|
||||
keepAlive: false,
|
||||
keepAliveInterval: 30000,
|
||||
type: 'stdio',
|
||||
} as ServerConfig,
|
||||
|
||||
Reference in New Issue
Block a user