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

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

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

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

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

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

|
||||
要针对特定服务器分组进行访问,请使用基于分组的 HTTP 端点:
|
||||
|
||||
```
|
||||
http://localhost:3000/mcp/{group}
|
||||
```
|
||||
|
||||
其中 `{group}` 是您在控制面板中创建的分组 ID 或名称。这样做可以:
|
||||
|
||||
- 连接到按用例组织的特定 MCP 服务器子集
|
||||
- 隔离不同的 AI 工具,使其只能访问相关服务器
|
||||
- 为不同环境或团队实现更精细的访问控制
|
||||
- 通过分组名称轻松识别和管理服务器
|
||||
- 允许不同的 AI 客户端使用相同的端点,简化集成过程
|
||||
|
||||
**针对特定服务器的 HTTP 端点**:
|
||||
要针对特定服务器进行访问,请使用以下格式:
|
||||
|
||||
```
|
||||
http://localhost:3000/mcp/{server}
|
||||
```
|
||||
|
||||
其中 `{server}` 是您要连接的服务器名称。这样做可以直接访问特定的 MCP 服务器。
|
||||
|
||||
> **提示**:如果服务器名称和分组名称相同,则分组名称优先。
|
||||
|
||||
### SSE 端点集成 (未来可能废弃)
|
||||
|
||||
通过以下地址连接 AI 客户端(如 Claude Desktop、Cursor、DeepChat 等):
|
||||
|
||||
```
|
||||
http://localhost:3000/sse
|
||||
```
|
||||
|
||||
要启用智能路由,请使用:
|
||||
|
||||
```
|
||||
# 在所有服务器中搜索
|
||||
http://localhost:3000/sse/$smart
|
||||
|
||||
# 在特定分组中搜索
|
||||
http://localhost:3000/sse/$smart/{group}
|
||||
```
|
||||
|
||||
要针对特定服务器分组进行访问,请使用基于分组的 SSE 端点:
|
||||
|
||||
```
|
||||
http://localhost:3000/sse/{group}
|
||||
```
|
||||
|
||||
要针对特定服务器进行访问,请使用以下格式:
|
||||
|
||||
```
|
||||
http://localhost:3000/sse/{server}
|
||||
```
|
||||
| 主题 | 描述 |
|
||||
| ------------------------------------------------------------------------------ | ---------------------------- |
|
||||
| [快速开始](https://docs.mcphubx.com/zh/quickstart) | 5 分钟快速上手 |
|
||||
| [配置指南](https://docs.mcphubx.com/zh/configuration/mcp-settings) | MCP 服务器配置选项 |
|
||||
| [数据库模式](https://docs.mcphubx.com/zh/configuration/database-configuration) | PostgreSQL 生产环境配置 |
|
||||
| [OAuth](https://docs.mcphubx.com/zh/features/oauth) | OAuth 2.0 客户端和服务端配置 |
|
||||
| [智能路由](https://docs.mcphubx.com/zh/features/smart-routing) | AI 驱动的工具发现 |
|
||||
| [Docker 部署](https://docs.mcphubx.com/zh/configuration/docker-setup) | Docker 部署指南 |
|
||||
|
||||
## 🧑💻 本地开发
|
||||
|
||||
@@ -253,19 +91,9 @@ pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
此命令将在开发模式下启动前后端,并启用热重载。
|
||||
> Windows 用户需分别启动后端和前端:`pnpm backend:dev`,`pnpm frontend:dev`
|
||||
|
||||
> 针对 Windows 用户,可能需要分别启动后端服务器和前端:`pnpm backend:dev`,`pnpm frontend:dev`。
|
||||
|
||||
## 🛠️ 常见问题
|
||||
|
||||
### 使用 nginx 反向代理
|
||||
|
||||
如果您在使用 nginx 反向代理 MCPHub,请确保在 nginx 配置中添加以下内容:
|
||||
|
||||
```nginx
|
||||
proxy_buffering off
|
||||
```
|
||||
📖 查看[开发指南](https://docs.mcphubx.com/zh/development)了解详细设置说明。
|
||||
|
||||
## 🔍 技术栈
|
||||
|
||||
@@ -276,13 +104,6 @@ proxy_buffering off
|
||||
|
||||
## 👥 贡献指南
|
||||
|
||||
期待您的贡献!
|
||||
|
||||
- 新功能与优化
|
||||
- 文档完善
|
||||
- Bug 报告与修复
|
||||
- 翻译与建议
|
||||
|
||||
欢迎加入企微交流共建群,由于群人数限制,有兴趣的同学可以扫码添加管理员为好友后拉入群聊。
|
||||
|
||||
<img src="assets/wexin.png" width="350">
|
||||
@@ -293,7 +114,7 @@ proxy_buffering off
|
||||
|
||||
## 致谢
|
||||
|
||||
感谢以下人员的赞赏:小白、琛。你们的支持是我继续前进的动力!
|
||||
感谢以下朋友的赞赏:小白、唐秀川、琛、孔、黄祥取、兰军飞、无名之辈、Kyle,以及其他匿名支持者。
|
||||
|
||||
## 🌟 Star 历史趋势
|
||||
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
# Security Summary - OAuth Authorization Server Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
This document summarizes the security analysis and measures taken for the OAuth 2.0 authorization server implementation in MCPHub.
|
||||
|
||||
## Vulnerability Scan Results
|
||||
|
||||
### Dependency Vulnerabilities
|
||||
|
||||
✅ **PASSED**: No vulnerabilities found in dependencies
|
||||
- `@node-oauth/oauth2-server@5.2.1` - Clean scan, no known vulnerabilities
|
||||
- All other dependencies scanned and verified secure
|
||||
|
||||
### Code Security Analysis (CodeQL)
|
||||
|
||||
⚠️ **ADVISORY**: 12 alerts found regarding missing rate limiting on authentication endpoints
|
||||
|
||||
**Details:**
|
||||
- **Issue**: Authorization routes do not have rate limiting middleware
|
||||
- **Impact**: Potential brute force attacks on authentication endpoints
|
||||
- **Severity**: Medium
|
||||
- **Status**: Documented, not critical
|
||||
|
||||
**Affected Endpoints:**
|
||||
- `/oauth/authorize` (GET/POST)
|
||||
- `/oauth/token` (POST)
|
||||
- `/api/oauth/clients/*` (various methods)
|
||||
|
||||
**Mitigation:**
|
||||
1. All endpoints require proper authentication
|
||||
2. Authorization codes expire after 5 minutes by default
|
||||
3. Access tokens expire after 1 hour by default
|
||||
4. Failed authentication attempts are logged
|
||||
5. Documentation includes rate limiting recommendations for production
|
||||
|
||||
**Recommended Actions for Production:**
|
||||
- Implement `express-rate-limit` middleware on OAuth endpoints
|
||||
- Consider using reverse proxy rate limiting (nginx, Cloudflare)
|
||||
- Monitor for suspicious authentication patterns
|
||||
- Set up alerting for repeated failed attempts
|
||||
|
||||
## Security Features Implemented
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
✅ **OAuth 2.0 Compliance**: Fully compliant with RFC 6749
|
||||
✅ **PKCE Support**: RFC 7636 implementation for public clients
|
||||
✅ **Token-based Authentication**: Access tokens and refresh tokens
|
||||
✅ **JWT Integration**: Backward compatible with existing JWT auth
|
||||
✅ **User Permissions**: Proper admin status lookup for OAuth users
|
||||
|
||||
### Input Validation
|
||||
|
||||
✅ **Query Parameter Validation**: All OAuth parameters validated with regex patterns
|
||||
✅ **Client ID Validation**: Alphanumeric with hyphens/underscores only
|
||||
✅ **Redirect URI Validation**: Strict matching against registered URIs
|
||||
✅ **Scope Validation**: Only allowed scopes can be requested
|
||||
✅ **State Parameter**: CSRF protection via state validation
|
||||
|
||||
### Output Security
|
||||
|
||||
✅ **XSS Protection**: All user input HTML-escaped in authorization page
|
||||
✅ **HTML Escaping**: Custom escapeHtml function for template rendering
|
||||
✅ **Safe Token Handling**: Tokens never exposed in URLs or logs
|
||||
|
||||
### Token Security
|
||||
|
||||
✅ **Secure Token Generation**: Cryptographically random tokens (32 bytes)
|
||||
✅ **Token Expiration**: Configurable lifetimes for all token types
|
||||
✅ **Token Revocation**: Support for revoking access and refresh tokens
|
||||
✅ **Automatic Cleanup**: Expired tokens automatically removed from memory
|
||||
|
||||
### Transport Security
|
||||
|
||||
✅ **HTTPS Ready**: Designed for HTTPS in production
|
||||
✅ **No Tokens in URL**: Access tokens never passed in query parameters
|
||||
✅ **Secure Headers**: Proper Content-Type and security headers
|
||||
|
||||
### Client Security
|
||||
|
||||
✅ **Client Secret Support**: Optional for confidential clients
|
||||
✅ **Public Client Support**: PKCE for clients without secrets
|
||||
✅ **Redirect URI Whitelist**: Strict validation of redirect destinations
|
||||
✅ **Client Registration**: Secure client management API
|
||||
|
||||
### Code Quality
|
||||
|
||||
✅ **TypeScript Strict Mode**: Full type safety
|
||||
✅ **ESLint Clean**: No linting errors
|
||||
✅ **Test Coverage**: 180 tests passing, including 11 OAuth-specific tests
|
||||
✅ **Async Safety**: Proper async/await usage throughout
|
||||
✅ **Resource Cleanup**: Graceful shutdown support with interval cleanup
|
||||
|
||||
## Security Best Practices Followed
|
||||
|
||||
1. **Defense in Depth**: Multiple layers of security (auth, validation, escaping)
|
||||
2. **Principle of Least Privilege**: Scopes limit what clients can access
|
||||
3. **Fail Securely**: Invalid requests rejected with appropriate errors
|
||||
4. **Security by Default**: Secure settings out of the box
|
||||
5. **Standard Compliance**: Following OAuth 2.0 and PKCE RFCs
|
||||
6. **Code Reviews**: All changes reviewed for security implications
|
||||
7. **Documentation**: Comprehensive security guidance provided
|
||||
|
||||
## Known Limitations
|
||||
|
||||
### In-Memory Token Storage
|
||||
|
||||
**Issue**: Tokens stored in memory, not persisted to database
|
||||
**Impact**: Tokens lost on server restart
|
||||
**Mitigation**: Refresh tokens allow users to re-authenticate
|
||||
**Future**: Consider database storage for production deployments
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
**Issue**: No built-in rate limiting on OAuth endpoints
|
||||
**Impact**: Potential brute force attacks
|
||||
**Mitigation**:
|
||||
- Short-lived authorization codes (5 min default)
|
||||
- Authentication required for authorization endpoint
|
||||
- Documented recommendations for production
|
||||
**Future**: Consider adding rate limiting middleware
|
||||
|
||||
### Token Introspection
|
||||
|
||||
**Issue**: No token introspection endpoint (RFC 7662)
|
||||
**Impact**: Limited third-party token validation
|
||||
**Mitigation**: Clients can use userinfo endpoint
|
||||
**Future**: Consider implementing RFC 7662 if needed
|
||||
|
||||
## Production Deployment Recommendations
|
||||
|
||||
### Critical
|
||||
|
||||
1. ✅ Use HTTPS in production (SSL/TLS certificates)
|
||||
2. ✅ Change default admin password immediately
|
||||
3. ✅ Use strong client secrets for confidential clients
|
||||
4. ⚠️ Implement rate limiting (express-rate-limit or reverse proxy)
|
||||
5. ✅ Enable proper logging and monitoring
|
||||
|
||||
### Recommended
|
||||
|
||||
6. Consider using a database for token storage
|
||||
7. Set up automated security scanning in CI/CD
|
||||
8. Use a reverse proxy (nginx) with security headers
|
||||
9. Implement IP whitelisting for admin endpoints
|
||||
10. Regular security audits and dependency updates
|
||||
|
||||
### Optional
|
||||
|
||||
11. Implement token introspection endpoint
|
||||
12. Add support for JWT-based access tokens
|
||||
13. Integrate with external OAuth providers
|
||||
14. Implement advanced scope management
|
||||
15. Add OAuth client approval workflow
|
||||
|
||||
## Compliance & Standards
|
||||
|
||||
✅ **OAuth 2.0 (RFC 6749)**: Full authorization code grant implementation
|
||||
✅ **PKCE (RFC 7636)**: Code challenge and verifier support
|
||||
✅ **OAuth Server Metadata (RFC 8414)**: Discovery endpoint available
|
||||
✅ **OpenID Connect Compatible**: Basic userinfo endpoint
|
||||
|
||||
## Vulnerability Disclosure
|
||||
|
||||
If you discover a security vulnerability in MCPHub's OAuth implementation, please:
|
||||
|
||||
1. **Do Not** create a public GitHub issue
|
||||
2. Email the maintainers privately
|
||||
3. Provide detailed reproduction steps
|
||||
4. Allow time for a fix before public disclosure
|
||||
|
||||
## Security Update Policy
|
||||
|
||||
- **Critical vulnerabilities**: Patched within 24-48 hours
|
||||
- **High severity**: Patched within 1 week
|
||||
- **Medium severity**: Patched in next minor release
|
||||
- **Low severity**: Patched in next patch release
|
||||
|
||||
## Conclusion
|
||||
|
||||
The OAuth 2.0 authorization server implementation in MCPHub follows security best practices and is production-ready with the noted limitations. The main advisory regarding rate limiting should be addressed in production deployments through application-level or reverse proxy rate limiting.
|
||||
|
||||
**Overall Security Assessment**: ✅ **SECURE** with production hardening recommendations
|
||||
|
||||
**Last Updated**: 2025-11-02
|
||||
**Next Review**: Recommended quarterly or after major changes
|
||||
60
docker-compose.db.yml
Normal file
60
docker-compose.db.yml
Normal file
@@ -0,0 +1,60 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
# PostgreSQL database for MCPHub configuration
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: mcphub-postgres
|
||||
environment:
|
||||
POSTGRES_DB: mcphub
|
||||
POSTGRES_USER: mcphub
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-mcphub_password}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "${DB_PORT:-5432}:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U mcphub"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- mcphub-network
|
||||
|
||||
# MCPHub application
|
||||
mcphub:
|
||||
image: samanhappy/mcphub:latest
|
||||
container_name: mcphub
|
||||
environment:
|
||||
# Database connection (setting DB_URL automatically enables database mode)
|
||||
DB_URL: "postgresql://mcphub:${DB_PASSWORD:-mcphub_password}@postgres:5432/mcphub"
|
||||
|
||||
# Optional: Explicitly control database mode (overrides auto-detection)
|
||||
# USE_DB: "true"
|
||||
|
||||
# Application settings
|
||||
PORT: 3000
|
||||
NODE_ENV: production
|
||||
|
||||
# Optional: Custom npm registry
|
||||
# NPM_REGISTRY: https://registry.npmjs.org/
|
||||
|
||||
# Optional: Proxy settings
|
||||
# HTTP_PROXY: http://proxy:8080
|
||||
# HTTPS_PROXY: http://proxy:8080
|
||||
ports:
|
||||
- "${MCPHUB_PORT:-3000}:3000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- mcphub-network
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
mcphub-network:
|
||||
driver: bridge
|
||||
304
docs/configuration/database-configuration.mdx
Normal file
304
docs/configuration/database-configuration.mdx
Normal file
@@ -0,0 +1,304 @@
|
||||
---
|
||||
title: 'Database Configuration'
|
||||
description: 'Configuring MCPHub to use a PostgreSQL database as an alternative to the mcp_settings.json file.'
|
||||
---
|
||||
|
||||
# Database Configuration for MCPHub
|
||||
|
||||
## Overview
|
||||
|
||||
MCPHub supports storing configuration data in a PostgreSQL database as an alternative to the `mcp_settings.json` file. Database mode provides enhanced persistence and scalability for production environments and enterprise deployments.
|
||||
|
||||
## Why Use Database Configuration?
|
||||
|
||||
**Core Benefits:**
|
||||
- ✅ **Better Persistence** - Configuration stored in a professional database with transaction support and data integrity
|
||||
- ✅ **High Availability** - Leverage database replication and failover capabilities
|
||||
- ✅ **Enterprise Ready** - Meets enterprise data management and compliance requirements
|
||||
- ✅ **Backup & Recovery** - Use mature database backup tools and strategies
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Required for Database Mode
|
||||
|
||||
```bash
|
||||
# Database connection URL (PostgreSQL)
|
||||
# Simply setting DB_URL will automatically enable database mode
|
||||
DB_URL=postgresql://user:password@localhost:5432/mcphub
|
||||
|
||||
# Or explicitly control with USE_DB (overrides auto-detection)
|
||||
# USE_DB=true
|
||||
```
|
||||
|
||||
<Note>
|
||||
**Simplified Configuration**: You only need to set `DB_URL` to enable database mode. MCPHub will automatically detect and enable database mode when `DB_URL` is present. Use `USE_DB=false` to explicitly disable database mode even when `DB_URL` is set.
|
||||
</Note>
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Using Docker
|
||||
|
||||
#### Option A: Using External Database
|
||||
|
||||
If you already have a PostgreSQL database:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 3000:3000 \
|
||||
-v ./mcp_settings.json:/app/mcp_settings.json \
|
||||
-e DB_URL="postgresql://user:password@your-db-host:5432/mcphub" \
|
||||
samanhappy/mcphub
|
||||
```
|
||||
|
||||
#### Option B: Using PostgreSQL as a separate service
|
||||
|
||||
Create a `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_DB: mcphub
|
||||
POSTGRES_USER: mcphub
|
||||
POSTGRES_PASSWORD: your_secure_password
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
mcphub:
|
||||
image: samanhappy/mcphub:latest
|
||||
environment:
|
||||
USE_DB: "true"
|
||||
DB_URL: "postgresql://mcphub:your_secure_password@postgres:5432/mcphub"
|
||||
PORT: 3000
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
```
|
||||
|
||||
Run with:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### 2. Manual Setup
|
||||
|
||||
#### Step 1: Setup PostgreSQL Database
|
||||
|
||||
```bash
|
||||
# Install PostgreSQL (if not already installed)
|
||||
sudo apt-get install postgresql postgresql-contrib
|
||||
|
||||
# Create database and user
|
||||
sudo -u postgres psql <<EOF
|
||||
CREATE DATABASE mcphub;
|
||||
CREATE USER mcphub WITH ENCRYPTED PASSWORD 'your_password';
|
||||
GRANT ALL PRIVILEGES ON DATABASE mcphub TO mcphub;
|
||||
EOF
|
||||
```
|
||||
|
||||
#### Step 2: Install MCPHub
|
||||
|
||||
```bash
|
||||
npm install -g @samanhappy/mcphub
|
||||
```
|
||||
|
||||
#### Step 3: Set Environment Variables
|
||||
|
||||
Create a `.env` file:
|
||||
|
||||
```bash
|
||||
# Simply set DB_URL to enable database mode (USE_DB is auto-detected)
|
||||
DB_URL=postgresql://mcphub:your_password@localhost:5432/mcphub
|
||||
PORT=3000
|
||||
```
|
||||
|
||||
#### Step 4: Run Migration (Optional)
|
||||
|
||||
If you have an existing `mcp_settings.json` file, migrate it:
|
||||
|
||||
```bash
|
||||
# Run migration script
|
||||
npx tsx src/scripts/migrate-to-database.ts
|
||||
```
|
||||
|
||||
Or let MCPHub auto-migrate on first startup.
|
||||
|
||||
#### Step 5: Start MCPHub
|
||||
|
||||
```bash
|
||||
mcphub
|
||||
```
|
||||
|
||||
## Migration from File-Based to Database
|
||||
|
||||
MCPHub provides automatic migration on first startup when database mode is enabled. However, you can also run the migration manually.
|
||||
|
||||
### Automatic Migration
|
||||
|
||||
When you start MCPHub with `USE_DB=true` for the first time:
|
||||
|
||||
1. MCPHub connects to the database
|
||||
2. Checks if any users exist in the database
|
||||
3. If no users found, automatically migrates from `mcp_settings.json`
|
||||
4. Creates all tables and imports all data
|
||||
|
||||
### Manual Migration
|
||||
|
||||
Run the migration script:
|
||||
|
||||
```bash
|
||||
# Using npx
|
||||
npx tsx src/scripts/migrate-to-database.ts
|
||||
|
||||
# Or using Node
|
||||
node dist/scripts/migrate-to-database.js
|
||||
```
|
||||
|
||||
The migration will:
|
||||
- ✅ Create database tables if they don't exist
|
||||
- ✅ Import all users with hashed passwords
|
||||
- ✅ Import all MCP server configurations
|
||||
- ✅ Import all groups
|
||||
- ✅ Import system configuration
|
||||
- ✅ Import user-specific configurations
|
||||
- ✅ Skip existing records (safe to run multiple times)
|
||||
|
||||
## Configuration After Migration
|
||||
|
||||
Once running in database mode, all configuration changes are stored in the database:
|
||||
|
||||
- User management via `/api/users`
|
||||
- Server management via `/api/servers`
|
||||
- Group management via `/api/groups`
|
||||
- System settings via `/api/system/config`
|
||||
|
||||
The web dashboard works exactly the same way, but now stores changes in the database instead of the file.
|
||||
|
||||
## Database Schema
|
||||
|
||||
MCPHub creates the following tables:
|
||||
|
||||
- **users** - User accounts and authentication
|
||||
- **servers** - MCP server configurations
|
||||
- **groups** - Server groups
|
||||
- **system_config** - System-wide settings
|
||||
- **user_configs** - User-specific settings
|
||||
- **vector_embeddings** - Vector search data (for smart routing)
|
||||
|
||||
## Backup and Restore
|
||||
|
||||
### Backup
|
||||
|
||||
```bash
|
||||
# PostgreSQL backup
|
||||
pg_dump -U mcphub mcphub > mcphub_backup.sql
|
||||
|
||||
# Or using Docker
|
||||
docker exec postgres pg_dump -U mcphub mcphub > mcphub_backup.sql
|
||||
```
|
||||
|
||||
### Restore
|
||||
|
||||
```bash
|
||||
# PostgreSQL restore
|
||||
psql -U mcphub mcphub < mcphub_backup.sql
|
||||
|
||||
# Or using Docker
|
||||
docker exec -i postgres psql -U mcphub mcphub < mcphub_backup.sql
|
||||
```
|
||||
|
||||
## Switching Back to File-Based Config
|
||||
|
||||
If you need to switch back to file-based configuration:
|
||||
|
||||
1. Set `USE_DB=false` or remove `DB_URL` and `USE_DB` environment variables
|
||||
2. Restart MCPHub
|
||||
3. MCPHub will use `mcp_settings.json` again
|
||||
|
||||
Note: Changes made in database mode won't be reflected in the file unless you manually export them.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection Refused
|
||||
|
||||
```
|
||||
Error: connect ECONNREFUSED 127.0.0.1:5432
|
||||
```
|
||||
|
||||
**Solution:** Check that PostgreSQL is running and accessible:
|
||||
```bash
|
||||
# Check PostgreSQL status
|
||||
sudo systemctl status postgresql
|
||||
|
||||
# Or for Docker
|
||||
docker ps | grep postgres
|
||||
```
|
||||
|
||||
### Authentication Failed
|
||||
|
||||
```
|
||||
Error: password authentication failed for user "mcphub"
|
||||
```
|
||||
|
||||
**Solution:** Verify database credentials in `DB_URL` environment variable.
|
||||
|
||||
### Migration Failed
|
||||
|
||||
```
|
||||
❌ Migration failed: ...
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
1. Check that `mcp_settings.json` exists and is valid JSON
|
||||
2. Verify database connection
|
||||
3. Check logs for specific error messages
|
||||
4. Ensure database user has CREATE TABLE permissions
|
||||
|
||||
### Tables Already Exist
|
||||
|
||||
Database tables are automatically created if they don't exist. If you get errors about existing tables, check:
|
||||
1. Whether a previous migration partially completed
|
||||
2. Manual table creation conflicts
|
||||
3. Run with `synchronize: false` in database config if needed
|
||||
|
||||
## Environment Variables Reference
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `DB_URL` | Yes* | - | Full PostgreSQL connection URL. Setting this automatically enables database mode |
|
||||
| `USE_DB` | No | auto | Explicitly enable/disable database mode. If not set, auto-detected based on `DB_URL` presence |
|
||||
| `MCPHUB_SETTING_PATH` | No | - | Path to mcp_settings.json (for migration) |
|
||||
|
||||
*Required for database mode. Simply setting `DB_URL` enables database mode automatically
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Database Credentials:** Store database credentials securely, use environment variables or secrets management
|
||||
2. **Network Access:** Restrict database access to MCPHub instances only
|
||||
3. **Encryption:** Use SSL/TLS for database connections in production:
|
||||
```bash
|
||||
DB_URL=postgresql://user:password@host:5432/mcphub?sslmode=require
|
||||
```
|
||||
4. **Backup:** Regularly backup your database
|
||||
5. **Access Control:** Use strong database passwords and limit user permissions
|
||||
|
||||
## Performance
|
||||
|
||||
Database mode offers better performance for:
|
||||
- Multiple concurrent users
|
||||
- Frequent configuration changes
|
||||
- Large number of servers/groups
|
||||
|
||||
File mode may be faster for:
|
||||
- Single user setups
|
||||
- Read-heavy workloads with infrequent changes
|
||||
- Development/testing environments
|
||||
@@ -1,210 +0,0 @@
|
||||
# MCPHub DAO Layer 实现总结
|
||||
|
||||
## 项目概述
|
||||
|
||||
本次开发为MCPHub项目引入了独立的数据访问对象(DAO)层,用于管理`mcp_settings.json`中的不同类型数据的增删改查操作。
|
||||
|
||||
## 已实现的功能
|
||||
|
||||
### 1. 核心DAO层架构
|
||||
|
||||
#### 基础架构
|
||||
- **BaseDao.ts**: 定义了通用的CRUD接口和抽象实现
|
||||
- **JsonFileBaseDao.ts**: 提供JSON文件操作的基础类,包含缓存机制
|
||||
- **DaoFactory.ts**: 工厂模式实现,提供DAO实例的创建和管理
|
||||
|
||||
#### 具体DAO实现
|
||||
1. **UserDao**: 用户数据管理
|
||||
- 用户创建(含密码哈希)
|
||||
- 密码验证
|
||||
- 权限管理
|
||||
- 管理员查询
|
||||
|
||||
2. **ServerDao**: 服务器配置管理
|
||||
- 服务器CRUD操作
|
||||
- 按所有者/类型/状态查询
|
||||
- 工具和提示配置管理
|
||||
- 启用/禁用控制
|
||||
|
||||
3. **GroupDao**: 群组管理
|
||||
- 群组CRUD操作
|
||||
- 服务器成员管理
|
||||
- 按所有者查询
|
||||
- 群组-服务器关系管理
|
||||
|
||||
4. **SystemConfigDao**: 系统配置管理
|
||||
- 系统级配置的读取和更新
|
||||
- 分段配置管理
|
||||
- 配置重置功能
|
||||
|
||||
5. **UserConfigDao**: 用户个人配置管理
|
||||
- 用户个人配置的CRUD操作
|
||||
- 分段配置管理
|
||||
- 批量配置查询
|
||||
|
||||
### 2. 配置服务集成
|
||||
|
||||
#### DaoConfigService
|
||||
- 使用DAO层重新实现配置加载和保存
|
||||
- 支持用户权限过滤
|
||||
- 提供配置合并和验证功能
|
||||
|
||||
#### ConfigManager
|
||||
- 双模式支持:传统文件方式 + 新DAO层
|
||||
- 运行时切换机制
|
||||
- 环境变量控制 (`USE_DAO_LAYER`)
|
||||
- 迁移工具集成
|
||||
|
||||
### 3. 迁移和验证工具
|
||||
|
||||
#### 迁移功能
|
||||
- 从传统JSON文件格式迁移到DAO层
|
||||
- 数据完整性验证
|
||||
- 性能对比分析
|
||||
- 迁移报告生成
|
||||
|
||||
#### 测试工具
|
||||
- DAO操作完整性测试
|
||||
- 示例数据生成和清理
|
||||
- 性能基准测试
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── dao/ # DAO层核心
|
||||
│ ├── base/
|
||||
│ │ ├── BaseDao.ts # 基础DAO接口
|
||||
│ │ └── JsonFileBaseDao.ts # JSON文件基础类
|
||||
│ ├── UserDao.ts # 用户数据访问
|
||||
│ ├── ServerDao.ts # 服务器配置访问
|
||||
│ ├── GroupDao.ts # 群组数据访问
|
||||
│ ├── SystemConfigDao.ts # 系统配置访问
|
||||
│ ├── UserConfigDao.ts # 用户配置访问
|
||||
│ ├── DaoFactory.ts # DAO工厂
|
||||
│ ├── examples.ts # 使用示例
|
||||
│ └── index.ts # 统一导出
|
||||
├── config/
|
||||
│ ├── DaoConfigService.ts # DAO配置服务
|
||||
│ ├── configManager.ts # 配置管理器
|
||||
│ └── migrationUtils.ts # 迁移工具
|
||||
├── scripts/
|
||||
│ └── dao-demo.ts # 演示脚本
|
||||
└── docs/
|
||||
└── dao-layer.md # 详细文档
|
||||
```
|
||||
|
||||
## 主要特性
|
||||
|
||||
### 1. 类型安全
|
||||
- 完整的TypeScript类型定义
|
||||
- 编译时类型检查
|
||||
- 接口约束和验证
|
||||
|
||||
### 2. 模块化设计
|
||||
- 每种数据类型独立的DAO
|
||||
- 清晰的关注点分离
|
||||
- 可插拔的实现方式
|
||||
|
||||
### 3. 缓存机制
|
||||
- JSON文件读取缓存
|
||||
- 文件修改时间检测
|
||||
- 缓存失效和刷新
|
||||
|
||||
### 4. 向后兼容
|
||||
- 保持现有API不变
|
||||
- 支持传统和DAO双模式
|
||||
- 平滑迁移路径
|
||||
|
||||
### 5. 未来扩展性
|
||||
- 数据库切换准备
|
||||
- 新数据类型支持
|
||||
- 复杂查询能力
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 启用DAO层
|
||||
```bash
|
||||
# 环境变量配置
|
||||
export USE_DAO_LAYER=true
|
||||
```
|
||||
|
||||
### 基本操作示例
|
||||
```typescript
|
||||
import { getUserDao, getServerDao } from './dao/index.js';
|
||||
|
||||
// 用户操作
|
||||
const userDao = getUserDao();
|
||||
await userDao.createWithHashedPassword('admin', 'password', true);
|
||||
const user = await userDao.findByUsername('admin');
|
||||
|
||||
// 服务器操作
|
||||
const serverDao = getServerDao();
|
||||
await serverDao.create({
|
||||
name: 'my-server',
|
||||
command: 'node',
|
||||
args: ['server.js']
|
||||
});
|
||||
```
|
||||
|
||||
### 迁移操作
|
||||
```typescript
|
||||
import { migrateToDao, validateMigration } from './config/configManager.js';
|
||||
|
||||
// 执行迁移
|
||||
await migrateToDao();
|
||||
|
||||
// 验证迁移
|
||||
await validateMigration();
|
||||
```
|
||||
|
||||
## 依赖包
|
||||
|
||||
新增的依赖包:
|
||||
- `bcrypt`: 用户密码哈希
|
||||
- `@types/bcrypt`: bcrypt类型定义
|
||||
- `uuid`: UUID生成(群组ID)
|
||||
- `@types/uuid`: uuid类型定义
|
||||
|
||||
## 测试状态
|
||||
|
||||
✅ **编译测试**: 项目成功编译,无TypeScript错误
|
||||
✅ **类型检查**: 所有类型定义正确
|
||||
✅ **依赖安装**: 必要依赖包已安装
|
||||
⏳ **运行时测试**: 需要在实际环境中测试
|
||||
⏳ **迁移测试**: 需要使用真实数据测试迁移
|
||||
|
||||
## 下一步计划
|
||||
|
||||
### 短期目标
|
||||
1. 在开发环境中测试DAO层功能
|
||||
2. 完善错误处理和边界情况
|
||||
3. 添加更多单元测试
|
||||
4. 性能优化和监控
|
||||
|
||||
### 中期目标
|
||||
1. 集成到现有业务逻辑中
|
||||
2. 提供Web界面的DAO层管理
|
||||
3. 添加数据备份和恢复功能
|
||||
4. 实现配置版本控制
|
||||
|
||||
### 长期目标
|
||||
1. 实现数据库后端支持
|
||||
2. 添加分布式配置管理
|
||||
3. 实现实时配置同步
|
||||
4. 支持配置审计和日志
|
||||
|
||||
## 优势总结
|
||||
|
||||
通过引入DAO层,MCPHub获得了以下优势:
|
||||
|
||||
1. **🏗️ 架构清晰**: 数据访问逻辑与业务逻辑分离
|
||||
2. **🔄 易于扩展**: 为未来数据库支持做好准备
|
||||
3. **🧪 便于测试**: 接口可以轻松模拟和单元测试
|
||||
4. **🔒 类型安全**: 完整的TypeScript类型支持
|
||||
5. **⚡ 性能优化**: 内置缓存和批量操作
|
||||
6. **🛡️ 数据完整性**: 强制数据验证和约束
|
||||
7. **📦 模块化**: 每种数据类型独立管理
|
||||
8. **🔧 可维护性**: 代码结构清晰,易于维护
|
||||
|
||||
这个DAO层的实现为MCPHub项目提供了坚实的数据管理基础,支持项目的长期发展和扩展需求。
|
||||
@@ -1,254 +0,0 @@
|
||||
# MCPHub DAO Layer 设计文档
|
||||
|
||||
## 概述
|
||||
|
||||
MCPHub的数据访问对象(DAO)层为项目中`mcp_settings.json`文件中的不同数据类型提供了统一的增删改查操作接口。这个设计使得未来从JSON文件存储切换到数据库存储变得更加容易。
|
||||
|
||||
## 架构设计
|
||||
|
||||
### 核心组件
|
||||
|
||||
```
|
||||
src/dao/
|
||||
├── base/
|
||||
│ ├── BaseDao.ts # 基础DAO接口和抽象实现
|
||||
│ └── JsonFileBaseDao.ts # JSON文件操作的基础类
|
||||
├── UserDao.ts # 用户数据访问对象
|
||||
├── ServerDao.ts # 服务器配置数据访问对象
|
||||
├── GroupDao.ts # 群组数据访问对象
|
||||
├── SystemConfigDao.ts # 系统配置数据访问对象
|
||||
├── UserConfigDao.ts # 用户配置数据访问对象
|
||||
├── DaoFactory.ts # DAO工厂类
|
||||
├── examples.ts # 使用示例
|
||||
└── index.ts # 统一导出
|
||||
```
|
||||
|
||||
### 数据类型映射
|
||||
|
||||
| 数据类型 | 原始位置 | DAO类 | 主要功能 |
|
||||
|---------|---------|-------|---------|
|
||||
| IUser | `settings.users[]` | UserDao | 用户管理、密码验证、权限控制 |
|
||||
| ServerConfig | `settings.mcpServers{}` | ServerDao | 服务器配置、启用/禁用、工具管理 |
|
||||
| IGroup | `settings.groups[]` | GroupDao | 群组管理、服务器分组、成员管理 |
|
||||
| SystemConfig | `settings.systemConfig` | SystemConfigDao | 系统级配置、路由设置、安装配置 |
|
||||
| UserConfig | `settings.userConfigs{}` | UserConfigDao | 用户个人配置 |
|
||||
|
||||
## 主要特性
|
||||
|
||||
### 1. 统一的CRUD接口
|
||||
|
||||
所有DAO都实现了基础的CRUD操作:
|
||||
|
||||
```typescript
|
||||
interface BaseDao<T, K = string> {
|
||||
findAll(): Promise<T[]>;
|
||||
findById(id: K): Promise<T | null>;
|
||||
create(entity: Omit<T, 'id'>): Promise<T>;
|
||||
update(id: K, entity: Partial<T>): Promise<T | null>;
|
||||
delete(id: K): Promise<boolean>;
|
||||
exists(id: K): Promise<boolean>;
|
||||
count(): Promise<number>;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 特定业务操作
|
||||
|
||||
每个DAO还提供了针对其数据类型的特定操作:
|
||||
|
||||
#### UserDao 特殊功能
|
||||
- `createWithHashedPassword()` - 创建用户时自动哈希密码
|
||||
- `validateCredentials()` - 验证用户凭据
|
||||
- `updatePassword()` - 更新用户密码
|
||||
- `findAdmins()` - 查找管理员用户
|
||||
|
||||
#### ServerDao 特殊功能
|
||||
- `findByOwner()` - 按所有者查找服务器
|
||||
- `findEnabled()` - 查找启用的服务器
|
||||
- `findByType()` - 按类型查找服务器
|
||||
- `setEnabled()` - 启用/禁用服务器
|
||||
- `updateTools()` - 更新服务器工具配置
|
||||
|
||||
#### GroupDao 特殊功能
|
||||
- `findByOwner()` - 按所有者查找群组
|
||||
- `findByServer()` - 查找包含特定服务器的群组
|
||||
- `addServerToGroup()` - 向群组添加服务器
|
||||
- `removeServerFromGroup()` - 从群组移除服务器
|
||||
- `findByName()` - 按名称查找群组
|
||||
|
||||
### 3. 配置管理特殊功能
|
||||
|
||||
#### SystemConfigDao
|
||||
- `getSection()` - 获取特定配置段
|
||||
- `updateSection()` - 更新特定配置段
|
||||
- `reset()` - 重置为默认配置
|
||||
|
||||
#### UserConfigDao
|
||||
- `getSection()` - 获取用户特定配置段
|
||||
- `updateSection()` - 更新用户特定配置段
|
||||
- `getAll()` - 获取所有用户配置
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 1. 基本使用
|
||||
|
||||
```typescript
|
||||
import { getUserDao, getServerDao, getGroupDao } from './dao/index.js';
|
||||
|
||||
// 用户操作
|
||||
const userDao = getUserDao();
|
||||
const newUser = await userDao.createWithHashedPassword('username', 'password', false);
|
||||
const user = await userDao.findByUsername('username');
|
||||
const isValid = await userDao.validateCredentials('username', 'password');
|
||||
|
||||
// 服务器操作
|
||||
const serverDao = getServerDao();
|
||||
const server = await serverDao.create({
|
||||
name: 'my-server',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
enabled: true
|
||||
});
|
||||
|
||||
// 群组操作
|
||||
const groupDao = getGroupDao();
|
||||
const group = await groupDao.create({
|
||||
name: 'my-group',
|
||||
description: 'Test group',
|
||||
servers: ['my-server']
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 配置服务集成
|
||||
|
||||
```typescript
|
||||
import { DaoConfigService, createDaoConfigService } from './config/DaoConfigService.js';
|
||||
|
||||
const daoService = createDaoConfigService();
|
||||
|
||||
// 加载完整配置
|
||||
const settings = await daoService.loadSettings();
|
||||
|
||||
// 保存配置
|
||||
await daoService.saveSettings(updatedSettings);
|
||||
```
|
||||
|
||||
### 3. 迁移管理
|
||||
|
||||
```typescript
|
||||
import { migrateToDao, switchToDao, switchToLegacy } from './config/configManager.js';
|
||||
|
||||
// 迁移到DAO层
|
||||
const success = await migrateToDao();
|
||||
|
||||
// 运行时切换
|
||||
switchToDao(); // 切换到DAO层
|
||||
switchToLegacy(); // 切换回传统方式
|
||||
```
|
||||
|
||||
## 配置选项
|
||||
|
||||
可以通过环境变量控制使用哪种数据访问方式:
|
||||
|
||||
```bash
|
||||
# 使用DAO层 (推荐)
|
||||
USE_DAO_LAYER=true
|
||||
|
||||
# 使用传统文件方式 (默认,向后兼容)
|
||||
USE_DAO_LAYER=false
|
||||
```
|
||||
|
||||
## 未来扩展
|
||||
|
||||
### 数据库支持
|
||||
|
||||
DAO层的设计使得切换到数据库变得容易,只需要:
|
||||
|
||||
1. 实现新的DAO实现类(如DatabaseUserDao)
|
||||
2. 创建新的DaoFactory
|
||||
3. 更新配置以使用新的工厂
|
||||
|
||||
```typescript
|
||||
// 未来的数据库实现示例
|
||||
class DatabaseUserDao implements UserDao {
|
||||
constructor(private db: Database) {}
|
||||
|
||||
async findAll(): Promise<IUser[]> {
|
||||
return this.db.query('SELECT * FROM users');
|
||||
}
|
||||
|
||||
// ... 其他方法
|
||||
}
|
||||
```
|
||||
|
||||
### 新数据类型
|
||||
|
||||
添加新数据类型只需要:
|
||||
|
||||
1. 定义数据接口
|
||||
2. 创建对应的DAO接口和实现
|
||||
3. 更新DaoFactory
|
||||
4. 更新配置服务
|
||||
|
||||
## 迁移指南
|
||||
|
||||
### 从传统方式迁移到DAO层
|
||||
|
||||
1. **备份数据**
|
||||
```bash
|
||||
cp mcp_settings.json mcp_settings.json.backup
|
||||
```
|
||||
|
||||
2. **运行迁移**
|
||||
```typescript
|
||||
import { performMigration } from './config/migrationUtils.js';
|
||||
await performMigration();
|
||||
```
|
||||
|
||||
3. **验证迁移**
|
||||
```typescript
|
||||
import { validateMigration } from './config/migrationUtils.js';
|
||||
const isValid = await validateMigration();
|
||||
```
|
||||
|
||||
4. **切换到DAO层**
|
||||
```bash
|
||||
export USE_DAO_LAYER=true
|
||||
```
|
||||
|
||||
### 性能对比
|
||||
|
||||
可以使用内置工具对比性能:
|
||||
|
||||
```typescript
|
||||
import { performanceComparison } from './config/migrationUtils.js';
|
||||
await performanceComparison();
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **类型安全**: 始终使用TypeScript接口确保类型安全
|
||||
2. **错误处理**: 在DAO操作周围实现适当的错误处理
|
||||
3. **事务**: 对于复杂操作,考虑使用事务(未来数据库实现)
|
||||
4. **缓存**: DAO层包含内置缓存机制
|
||||
5. **测试**: 使用DAO接口进行单元测试的模拟
|
||||
|
||||
## 示例代码
|
||||
|
||||
查看以下文件获取完整示例:
|
||||
|
||||
- `src/dao/examples.ts` - 基本DAO操作示例
|
||||
- `src/config/migrationUtils.ts` - 迁移和验证工具
|
||||
- `src/scripts/dao-demo.ts` - 交互式演示脚本
|
||||
|
||||
## 总结
|
||||
|
||||
DAO层为MCPHub提供了:
|
||||
|
||||
- 🏗️ **模块化设计**: 每种数据类型都有专门的访问层
|
||||
- 🔄 **易于迁移**: 为未来切换到数据库做好准备
|
||||
- 🧪 **可测试性**: 接口可以轻松模拟和测试
|
||||
- 🔒 **类型安全**: 完整的TypeScript类型支持
|
||||
- ⚡ **性能优化**: 内置缓存和批量操作支持
|
||||
- 🛡️ **数据完整性**: 强制数据验证和约束
|
||||
|
||||
通过引入DAO层,MCPHub的数据管理变得更加结构化、可维护和可扩展。
|
||||
@@ -37,7 +37,8 @@
|
||||
"configuration/mcp-settings",
|
||||
"configuration/environment-variables",
|
||||
"configuration/docker-setup",
|
||||
"configuration/nginx"
|
||||
"configuration/nginx",
|
||||
"configuration/database-configuration"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -68,7 +69,8 @@
|
||||
"zh/configuration/mcp-settings",
|
||||
"zh/configuration/environment-variables",
|
||||
"zh/configuration/docker-setup",
|
||||
"zh/configuration/nginx"
|
||||
"zh/configuration/nginx",
|
||||
"zh/configuration/database-configuration"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -169,4 +171,4 @@
|
||||
"discord": "https://discord.gg/qMKNsn5Q"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
# Environment Variable Expansion in mcp_settings.json
|
||||
|
||||
## Overview
|
||||
|
||||
MCPHub now supports comprehensive environment variable expansion throughout the entire `mcp_settings.json` configuration file. This allows you to externalize sensitive information and configuration values, making your setup more secure and flexible.
|
||||
|
||||
## Supported Formats
|
||||
|
||||
MCPHub supports two environment variable formats:
|
||||
|
||||
1. **${VAR}** - Standard format (recommended)
|
||||
2. **$VAR** - Unix-style format (variable name must start with an uppercase letter or underscore, followed by uppercase letters, numbers, or underscores)
|
||||
|
||||
## What Can Be Expanded
|
||||
|
||||
Environment variables can now be used in **ANY** string value throughout your configuration:
|
||||
|
||||
- Server URLs
|
||||
- Commands and arguments
|
||||
- Headers
|
||||
- Environment variables passed to child processes
|
||||
- OpenAPI specifications and security configurations
|
||||
- OAuth credentials
|
||||
- System configuration values
|
||||
- Any other string fields
|
||||
|
||||
## Examples
|
||||
|
||||
### 1. SSE/HTTP Server Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-api-server": {
|
||||
"type": "sse",
|
||||
"url": "${MCP_SERVER_URL}",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${API_TOKEN}",
|
||||
"X-Custom-Header": "${CUSTOM_VALUE}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
```bash
|
||||
export MCP_SERVER_URL="https://api.example.com/mcp"
|
||||
export API_TOKEN="secret-token-123"
|
||||
export CUSTOM_VALUE="my-custom-value"
|
||||
```
|
||||
|
||||
### 2. Stdio Server Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-python-server": {
|
||||
"type": "stdio",
|
||||
"command": "${PYTHON_PATH}",
|
||||
"args": ["-m", "${MODULE_NAME}", "--api-key", "${API_KEY}"],
|
||||
"env": {
|
||||
"DATABASE_URL": "${DATABASE_URL}",
|
||||
"DEBUG": "${DEBUG_MODE}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
```bash
|
||||
export PYTHON_PATH="/usr/bin/python3"
|
||||
export MODULE_NAME="my_mcp_server"
|
||||
export API_KEY="secret-api-key"
|
||||
export DATABASE_URL="postgresql://localhost/mydb"
|
||||
export DEBUG_MODE="true"
|
||||
```
|
||||
|
||||
### 3. OpenAPI Server Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"openapi-service": {
|
||||
"type": "openapi",
|
||||
"openapi": {
|
||||
"url": "${OPENAPI_SPEC_URL}",
|
||||
"security": {
|
||||
"type": "apiKey",
|
||||
"apiKey": {
|
||||
"name": "X-API-Key",
|
||||
"in": "header",
|
||||
"value": "${OPENAPI_API_KEY}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
```bash
|
||||
export OPENAPI_SPEC_URL="https://api.example.com/openapi.json"
|
||||
export OPENAPI_API_KEY="your-api-key-here"
|
||||
```
|
||||
|
||||
### 4. OAuth Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"oauth-server": {
|
||||
"type": "sse",
|
||||
"url": "${OAUTH_SERVER_URL}",
|
||||
"oauth": {
|
||||
"clientId": "${OAUTH_CLIENT_ID}",
|
||||
"clientSecret": "${OAUTH_CLIENT_SECRET}",
|
||||
"accessToken": "${OAUTH_ACCESS_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
```bash
|
||||
export OAUTH_SERVER_URL="https://oauth.example.com/mcp"
|
||||
export OAUTH_CLIENT_ID="my-client-id"
|
||||
export OAUTH_CLIENT_SECRET="my-client-secret"
|
||||
export OAUTH_ACCESS_TOKEN="my-access-token"
|
||||
```
|
||||
|
||||
### 5. System Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"systemConfig": {
|
||||
"install": {
|
||||
"pythonIndexUrl": "${PYTHON_INDEX_URL}",
|
||||
"npmRegistry": "${NPM_REGISTRY}"
|
||||
},
|
||||
"mcpRouter": {
|
||||
"apiKey": "${MCPROUTER_API_KEY}",
|
||||
"referer": "${MCPROUTER_REFERER}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
```bash
|
||||
export PYTHON_INDEX_URL="https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||
export NPM_REGISTRY="https://registry.npmmirror.com"
|
||||
export MCPROUTER_API_KEY="router-api-key"
|
||||
export MCPROUTER_REFERER="https://myapp.com"
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
See [examples/mcp_settings_with_env_vars.json](../examples/mcp_settings_with_env_vars.json) for a comprehensive example configuration using environment variables.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Security
|
||||
|
||||
1. **Never commit sensitive values to version control** - Use environment variables for all secrets
|
||||
2. **Use .env files for local development** - MCPHub automatically loads `.env` files
|
||||
3. **Use secure secret management in production** - Consider using Docker secrets, Kubernetes secrets, or cloud provider secret managers
|
||||
|
||||
### Organization
|
||||
|
||||
1. **Group related variables** - Use prefixes for related configuration (e.g., `API_`, `DB_`, `OAUTH_`)
|
||||
2. **Document required variables** - Maintain a list of required environment variables in your README
|
||||
3. **Provide example .env file** - Create a `.env.example` file with placeholder values
|
||||
|
||||
### Example .env File
|
||||
|
||||
```bash
|
||||
# Server Configuration
|
||||
MCP_SERVER_URL=https://api.example.com/mcp
|
||||
API_TOKEN=your-api-token-here
|
||||
|
||||
# Python Server
|
||||
PYTHON_PATH=/usr/bin/python3
|
||||
MODULE_NAME=my_mcp_server
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://localhost/mydb
|
||||
|
||||
# OpenAPI
|
||||
OPENAPI_SPEC_URL=https://api.example.com/openapi.json
|
||||
OPENAPI_API_KEY=your-openapi-key
|
||||
|
||||
# OAuth
|
||||
OAUTH_CLIENT_ID=your-client-id
|
||||
OAUTH_CLIENT_SECRET=your-client-secret
|
||||
OAUTH_ACCESS_TOKEN=your-access-token
|
||||
```
|
||||
|
||||
## Docker Usage
|
||||
|
||||
When using Docker, pass environment variables using `-e` flag or `--env-file`:
|
||||
|
||||
```bash
|
||||
# Using individual variables
|
||||
docker run -e API_TOKEN=secret -e SERVER_URL=https://api.example.com mcphub
|
||||
|
||||
# Using env file
|
||||
docker run --env-file .env mcphub
|
||||
```
|
||||
|
||||
Or in docker-compose.yml:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
mcphub:
|
||||
image: mcphub
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- MCP_SERVER_URL=${MCP_SERVER_URL}
|
||||
- API_TOKEN=${API_TOKEN}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Variable Not Expanding
|
||||
|
||||
If a variable is not expanding:
|
||||
|
||||
1. Check that the variable is set: `echo $VAR_NAME`
|
||||
2. Verify the variable name matches exactly (case-sensitive)
|
||||
3. Ensure the variable is exported: `export VAR_NAME=value`
|
||||
4. Restart MCPHub after setting environment variables
|
||||
|
||||
### Empty Values
|
||||
|
||||
If an environment variable is not set, it will be replaced with an empty string. Make sure all required variables are set before starting MCPHub.
|
||||
|
||||
### Nested Variables
|
||||
|
||||
Environment variables in nested objects and arrays are fully supported:
|
||||
|
||||
```json
|
||||
{
|
||||
"nested": {
|
||||
"deep": {
|
||||
"value": "${MY_VAR}"
|
||||
}
|
||||
},
|
||||
"array": ["${VAR1}", "${VAR2}"]
|
||||
}
|
||||
```
|
||||
|
||||
## Migration from Previous Version
|
||||
|
||||
If you were previously using environment variables only in headers, no changes are needed. The new implementation is backward compatible and simply extends support to all configuration fields.
|
||||
|
||||
## Technical Details
|
||||
|
||||
- Environment variables are expanded once when the configuration is loaded
|
||||
- Expansion is recursive and handles nested objects and arrays
|
||||
- Non-string values (booleans, numbers, null) are preserved as-is
|
||||
- Empty string is used when an environment variable is not set
|
||||
@@ -1,169 +0,0 @@
|
||||
# OAuth 动态客户端注册实现总结
|
||||
|
||||
## 概述
|
||||
|
||||
成功为 MCPHub 的 OAuth 2.0 授权服务器添加了 RFC 7591 标准的动态客户端注册功能。此功能允许 OAuth 客户端在运行时自动注册,无需管理员手动配置。
|
||||
|
||||
## 实现的功能
|
||||
|
||||
### 1. 核心端点
|
||||
|
||||
#### POST /oauth/register - 注册新客户端
|
||||
- 公开端点,支持动态客户端注册
|
||||
- 自动生成 client_id 和可选的 client_secret
|
||||
- 返回 registration_access_token 用于后续管理
|
||||
- 支持 PKCE 流程(token_endpoint_auth_method: "none")
|
||||
|
||||
#### GET /oauth/register/:clientId - 读取客户端配置
|
||||
- 需要 registration_access_token 认证
|
||||
- 返回完整的客户端元数据
|
||||
|
||||
#### PUT /oauth/register/:clientId - 更新客户端配置
|
||||
- 需要 registration_access_token 认证
|
||||
- 支持更新 redirect_uris、scopes、metadata 等
|
||||
|
||||
#### DELETE /oauth/register/:clientId - 删除客户端注册
|
||||
- 需要 registration_access_token 认证
|
||||
- 删除客户端并清理相关 tokens
|
||||
|
||||
### 2. 配置选项
|
||||
|
||||
在 `mcp_settings.json` 中添加:
|
||||
|
||||
```json
|
||||
{
|
||||
"systemConfig": {
|
||||
"oauthServer": {
|
||||
"enabled": true,
|
||||
"dynamicRegistration": {
|
||||
"enabled": true,
|
||||
"allowedGrantTypes": ["authorization_code", "refresh_token"],
|
||||
"requiresAuthentication": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 客户端元数据支持
|
||||
|
||||
实现了 RFC 7591 定义的完整客户端元数据:
|
||||
|
||||
- `application_type`: "web" 或 "native"
|
||||
- `response_types`: OAuth 响应类型数组
|
||||
- `token_endpoint_auth_method`: 认证方法
|
||||
- `contacts`: 联系邮箱数组
|
||||
- `logo_uri`: 客户端 logo URL
|
||||
- `client_uri`: 客户端主页 URL
|
||||
- `policy_uri`: 隐私政策 URL
|
||||
- `tos_uri`: 服务条款 URL
|
||||
- `jwks_uri`: JSON Web Key Set URL
|
||||
- `jwks`: 内联 JSON Web Key Set
|
||||
|
||||
### 4. 安全特性
|
||||
|
||||
- **Registration Access Token**: 每个注册的客户端获得唯一的访问令牌
|
||||
- **Token 过期**: Registration tokens 30 天后过期
|
||||
- **HTTPS 验证**: Redirect URIs 必须使用 HTTPS(localhost 除外)
|
||||
- **Scope 验证**: 只允许配置中定义的 scopes
|
||||
- **Grant Type 限制**: 只允许配置中定义的 grant types
|
||||
|
||||
## 文件变更
|
||||
|
||||
### 新增文件
|
||||
1. `src/controllers/oauthDynamicRegistrationController.ts` - 动态注册控制器
|
||||
2. `examples/oauth-dynamic-registration-config.json` - 配置示例
|
||||
|
||||
### 修改文件
|
||||
1. `src/types/index.ts` - 添加元数据字段到 IOAuthClient 和 OAuthServerConfig
|
||||
2. `src/routes/index.ts` - 注册新的动态注册端点
|
||||
3. `src/controllers/oauthServerController.ts` - 元数据端点包含 registration_endpoint
|
||||
4. `docs/oauth-server.md` - 添加完整的动态注册文档
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 注册新客户端
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/oauth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"client_name": "My Application",
|
||||
"redirect_uris": ["https://example.com/callback"],
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"scope": "read write",
|
||||
"token_endpoint_auth_method": "none",
|
||||
"logo_uri": "https://example.com/logo.png",
|
||||
"client_uri": "https://example.com",
|
||||
"contacts": ["admin@example.com"]
|
||||
}'
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"client_id": "a1b2c3d4e5f6g7h8",
|
||||
"client_name": "My Application",
|
||||
"redirect_uris": ["https://example.com/callback"],
|
||||
"registration_access_token": "reg_token_xyz123",
|
||||
"registration_client_uri": "http://localhost:3000/oauth/register/a1b2c3d4e5f6g7h8",
|
||||
"client_id_issued_at": 1699200000
|
||||
}
|
||||
```
|
||||
|
||||
### 读取客户端配置
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/oauth/register/CLIENT_ID \
|
||||
-H "Authorization: Bearer REGISTRATION_ACCESS_TOKEN"
|
||||
```
|
||||
|
||||
### 更新客户端
|
||||
|
||||
```bash
|
||||
curl -X PUT http://localhost:3000/oauth/register/CLIENT_ID \
|
||||
-H "Authorization: Bearer REGISTRATION_ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"client_name": "Updated Name",
|
||||
"redirect_uris": ["https://example.com/callback", "https://example.com/callback2"]
|
||||
}'
|
||||
```
|
||||
|
||||
### 删除客户端
|
||||
|
||||
```bash
|
||||
curl -X DELETE http://localhost:3000/oauth/register/CLIENT_ID \
|
||||
-H "Authorization: Bearer REGISTRATION_ACCESS_TOKEN"
|
||||
```
|
||||
|
||||
## 测试结果
|
||||
|
||||
✅ 所有 180 个测试通过
|
||||
✅ TypeScript 编译成功
|
||||
✅ 代码覆盖率维持在合理水平
|
||||
✅ 与现有功能完全兼容
|
||||
|
||||
## RFC 合规性
|
||||
|
||||
完全遵循以下 RFC 标准:
|
||||
|
||||
- **RFC 7591**: OAuth 2.0 Dynamic Client Registration Protocol
|
||||
- **RFC 8414**: OAuth 2.0 Authorization Server Metadata
|
||||
- **RFC 7636**: Proof Key for Code Exchange (PKCE)
|
||||
- **RFC 9728**: OAuth 2.0 Protected Resource Metadata
|
||||
|
||||
## 下一步建议
|
||||
|
||||
1. **持久化存储**: 当前 registration tokens 存储在内存中,生产环境应使用数据库
|
||||
2. **速率限制**: 添加注册端点的速率限制以防止滥用
|
||||
3. **客户端证明**: 考虑添加软件声明(software_statement)支持
|
||||
4. **审计日志**: 记录所有注册、更新和删除操作
|
||||
5. **通知机制**: 在客户端注册时通知管理员(可选)
|
||||
|
||||
## 兼容性
|
||||
|
||||
- 与 ChatGPT Web 完全兼容
|
||||
- 支持所有标准 OAuth 2.0 客户端库
|
||||
- 向后兼容现有的手动客户端配置方式
|
||||
@@ -1,538 +0,0 @@
|
||||
# OAuth 2.0 Authorization Server
|
||||
|
||||
MCPHub can act as an OAuth 2.0 authorization server, allowing external applications like ChatGPT Web to securely authenticate and access your MCP servers.
|
||||
|
||||
## Overview
|
||||
|
||||
The OAuth 2.0 authorization server feature enables MCPHub to:
|
||||
|
||||
- Provide standard OAuth 2.0 authentication flows
|
||||
- Issue and manage access tokens for external clients
|
||||
- Support secure authorization without exposing user credentials
|
||||
- Enable integration with services that require OAuth (like ChatGPT Web)
|
||||
|
||||
## Configuration
|
||||
|
||||
### Enable OAuth Server
|
||||
|
||||
Add the following configuration to your `mcp_settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"systemConfig": {
|
||||
"oauthServer": {
|
||||
"enabled": true,
|
||||
"accessTokenLifetime": 3600,
|
||||
"refreshTokenLifetime": 1209600,
|
||||
"authorizationCodeLifetime": 300,
|
||||
"requireClientSecret": false,
|
||||
"allowedScopes": ["read", "write"],
|
||||
"requireState": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `enabled` | boolean | `false` | Enable/disable OAuth authorization server |
|
||||
| `accessTokenLifetime` | number | `3600` | Access token lifetime in seconds (1 hour) |
|
||||
| `refreshTokenLifetime` | number | `1209600` | Refresh token lifetime in seconds (14 days) |
|
||||
| `authorizationCodeLifetime` | number | `300` | Authorization code lifetime in seconds (5 minutes) |
|
||||
| `requireClientSecret` | boolean | `false` | Whether client secret is required (set to false for PKCE) |
|
||||
| `allowedScopes` | string[] | `["read", "write"]` | List of allowed OAuth scopes |
|
||||
| `requireState` | boolean | `false` | When `true`, rejects authorization requests that omit the `state` parameter |
|
||||
|
||||
## OAuth Clients
|
||||
|
||||
### Creating OAuth Clients
|
||||
|
||||
#### Via API (Recommended)
|
||||
|
||||
Create an OAuth client using the API:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/oauth/clients \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-auth-token: YOUR_JWT_TOKEN" \
|
||||
-d '{
|
||||
"name": "My Application",
|
||||
"redirectUris": ["https://example.com/callback"],
|
||||
"grants": ["authorization_code", "refresh_token"],
|
||||
"scopes": ["read", "write"],
|
||||
"requireSecret": false
|
||||
}'
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "OAuth client created successfully",
|
||||
"client": {
|
||||
"clientId": "a1b2c3d4e5f6g7h8",
|
||||
"clientSecret": null,
|
||||
"name": "My Application",
|
||||
"redirectUris": ["https://example.com/callback"],
|
||||
"grants": ["authorization_code", "refresh_token"],
|
||||
"scopes": ["read", "write"],
|
||||
"owner": "admin"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: If `requireSecret` is true, the `clientSecret` will be shown only once. Save it securely!
|
||||
|
||||
#### Via Configuration File
|
||||
|
||||
Alternatively, add OAuth clients directly to `mcp_settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"oauthClients": [
|
||||
{
|
||||
"clientId": "my-app-client",
|
||||
"clientSecret": "optional-secret-for-confidential-clients",
|
||||
"name": "My Application",
|
||||
"redirectUris": ["https://example.com/callback"],
|
||||
"grants": ["authorization_code", "refresh_token"],
|
||||
"scopes": ["read", "write"],
|
||||
"owner": "admin"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Managing OAuth Clients
|
||||
|
||||
#### List All Clients
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/api/oauth/clients \
|
||||
-H "x-auth-token: YOUR_JWT_TOKEN"
|
||||
```
|
||||
|
||||
#### Get Specific Client
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/api/oauth/clients/CLIENT_ID \
|
||||
-H "x-auth-token: YOUR_JWT_TOKEN"
|
||||
```
|
||||
|
||||
#### Update Client
|
||||
|
||||
```bash
|
||||
curl -X PUT http://localhost:3000/api/oauth/clients/CLIENT_ID \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-auth-token: YOUR_JWT_TOKEN" \
|
||||
-d '{
|
||||
"name": "Updated Name",
|
||||
"redirectUris": ["https://example.com/callback", "https://example.com/callback2"]
|
||||
}'
|
||||
```
|
||||
|
||||
#### Delete Client
|
||||
|
||||
```bash
|
||||
curl -X DELETE http://localhost:3000/api/oauth/clients/CLIENT_ID \
|
||||
-H "x-auth-token: YOUR_JWT_TOKEN"
|
||||
```
|
||||
|
||||
#### Regenerate Client Secret
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/oauth/clients/CLIENT_ID/regenerate-secret \
|
||||
-H "x-auth-token: YOUR_JWT_TOKEN"
|
||||
```
|
||||
|
||||
## OAuth Flow
|
||||
|
||||
MCPHub supports the OAuth 2.0 Authorization Code flow with PKCE (Proof Key for Code Exchange).
|
||||
|
||||
### 1. Authorization Request
|
||||
|
||||
The client application redirects the user to the authorization endpoint:
|
||||
|
||||
```
|
||||
GET /oauth/authorize?
|
||||
client_id=CLIENT_ID&
|
||||
redirect_uri=REDIRECT_URI&
|
||||
response_type=code&
|
||||
scope=read%20write&
|
||||
state=RANDOM_STATE&
|
||||
code_challenge=CODE_CHALLENGE&
|
||||
code_challenge_method=S256
|
||||
```
|
||||
|
||||
Parameters:
|
||||
- `client_id`: OAuth client ID
|
||||
- `redirect_uri`: Redirect URI (must match registered URI)
|
||||
- `response_type`: Must be `code`
|
||||
- `scope`: Space-separated list of scopes (e.g., `read write`)
|
||||
- `state`: Random string to prevent CSRF attacks
|
||||
- `code_challenge`: PKCE code challenge (optional but recommended)
|
||||
- `code_challenge_method`: PKCE method (`S256` or `plain`)
|
||||
|
||||
### 2. User Authorization
|
||||
|
||||
The user is presented with a consent page showing:
|
||||
- Application name
|
||||
- Requested scopes
|
||||
- Approve/Deny buttons
|
||||
|
||||
If the user approves, they are redirected to the redirect URI with an authorization code:
|
||||
|
||||
```
|
||||
https://example.com/callback?code=AUTHORIZATION_CODE&state=RANDOM_STATE
|
||||
```
|
||||
|
||||
### 3. Token Exchange
|
||||
|
||||
The client exchanges the authorization code for an access token:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/oauth/token \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "grant_type=authorization_code" \
|
||||
-d "code=AUTHORIZATION_CODE" \
|
||||
-d "redirect_uri=REDIRECT_URI" \
|
||||
-d "client_id=CLIENT_ID" \
|
||||
-d "code_verifier=CODE_VERIFIER"
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"access_token": "ACCESS_TOKEN",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"refresh_token": "REFRESH_TOKEN",
|
||||
"scope": "read write"
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Using Access Token
|
||||
|
||||
Use the access token to make authenticated requests:
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/api/servers \
|
||||
-H "Authorization: Bearer ACCESS_TOKEN"
|
||||
```
|
||||
|
||||
### 5. Refreshing Token
|
||||
|
||||
When the access token expires, use the refresh token to get a new one:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/oauth/token \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "grant_type=refresh_token" \
|
||||
-d "refresh_token=REFRESH_TOKEN" \
|
||||
-d "client_id=CLIENT_ID"
|
||||
```
|
||||
|
||||
## PKCE (Proof Key for Code Exchange)
|
||||
|
||||
PKCE is a security extension to OAuth 2.0 that prevents authorization code interception attacks. It's especially important for public clients (mobile apps, SPAs).
|
||||
|
||||
### Generating PKCE Parameters
|
||||
|
||||
1. Generate a code verifier (random string):
|
||||
```javascript
|
||||
const codeVerifier = crypto.randomBytes(32).toString('base64url');
|
||||
```
|
||||
|
||||
2. Generate code challenge from verifier:
|
||||
```javascript
|
||||
const codeChallenge = crypto
|
||||
.createHash('sha256')
|
||||
.update(codeVerifier)
|
||||
.digest('base64url');
|
||||
```
|
||||
|
||||
3. Include in authorization request:
|
||||
- `code_challenge`: The generated challenge
|
||||
- `code_challenge_method`: `S256`
|
||||
|
||||
4. Include in token request:
|
||||
- `code_verifier`: The original verifier
|
||||
|
||||
## OAuth Scopes
|
||||
|
||||
MCPHub supports the following default scopes:
|
||||
|
||||
| Scope | Description |
|
||||
|-------|-------------|
|
||||
| `read` | Read access to MCP servers and tools |
|
||||
| `write` | Execute tools and modify MCP server configurations |
|
||||
|
||||
You can customize allowed scopes in the `oauthServer.allowedScopes` configuration.
|
||||
|
||||
## Dynamic Client Registration (RFC 7591)
|
||||
|
||||
MCPHub supports RFC 7591 Dynamic Client Registration, allowing OAuth clients to register themselves programmatically without manual configuration.
|
||||
|
||||
### Enable Dynamic Registration
|
||||
|
||||
Add to your `mcp_settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"systemConfig": {
|
||||
"oauthServer": {
|
||||
"enabled": true,
|
||||
"dynamicRegistration": {
|
||||
"enabled": true,
|
||||
"allowedGrantTypes": ["authorization_code", "refresh_token"],
|
||||
"requiresAuthentication": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Register a New Client
|
||||
|
||||
**POST /oauth/register**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/oauth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"client_name": "My Application",
|
||||
"redirect_uris": ["https://example.com/callback"],
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"scope": "read write",
|
||||
"token_endpoint_auth_method": "none"
|
||||
}'
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"client_id": "a1b2c3d4e5f6g7h8",
|
||||
"client_name": "My Application",
|
||||
"redirect_uris": ["https://example.com/callback"],
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"scope": "read write",
|
||||
"token_endpoint_auth_method": "none",
|
||||
"registration_access_token": "reg_token_xyz123",
|
||||
"registration_client_uri": "http://localhost:3000/oauth/register/a1b2c3d4e5f6g7h8",
|
||||
"client_id_issued_at": 1699200000
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** Save the `registration_access_token` - it's required to read, update, or delete the client registration.
|
||||
|
||||
### Read Client Configuration
|
||||
|
||||
**GET /oauth/register/:clientId**
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/oauth/register/CLIENT_ID \
|
||||
-H "Authorization: Bearer REGISTRATION_ACCESS_TOKEN"
|
||||
```
|
||||
|
||||
### Update Client Configuration
|
||||
|
||||
**PUT /oauth/register/:clientId**
|
||||
|
||||
```bash
|
||||
curl -X PUT http://localhost:3000/oauth/register/CLIENT_ID \
|
||||
-H "Authorization: Bearer REGISTRATION_ACCESS_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"client_name": "Updated Application Name",
|
||||
"redirect_uris": ["https://example.com/callback", "https://example.com/callback2"]
|
||||
}'
|
||||
```
|
||||
|
||||
### Delete Client Registration
|
||||
|
||||
**DELETE /oauth/register/:clientId**
|
||||
|
||||
```bash
|
||||
curl -X DELETE http://localhost:3000/oauth/register/CLIENT_ID \
|
||||
-H "Authorization: Bearer REGISTRATION_ACCESS_TOKEN"
|
||||
```
|
||||
|
||||
### Optional Client Metadata
|
||||
|
||||
When registering a client, you can include additional metadata:
|
||||
|
||||
- `application_type`: `"web"` or `"native"` (default: `"web"`)
|
||||
- `contacts`: Array of email addresses
|
||||
- `logo_uri`: URL of client logo
|
||||
- `client_uri`: URL of client homepage
|
||||
- `policy_uri`: URL of privacy policy
|
||||
- `tos_uri`: URL of terms of service
|
||||
- `jwks_uri`: URL of JSON Web Key Set
|
||||
- `jwks`: Inline JSON Web Key Set
|
||||
|
||||
Example:
|
||||
```json
|
||||
{
|
||||
"client_name": "My Application",
|
||||
"redirect_uris": ["https://example.com/callback"],
|
||||
"application_type": "web",
|
||||
"contacts": ["admin@example.com"],
|
||||
"logo_uri": "https://example.com/logo.png",
|
||||
"client_uri": "https://example.com",
|
||||
"policy_uri": "https://example.com/privacy",
|
||||
"tos_uri": "https://example.com/terms"
|
||||
}
|
||||
```
|
||||
|
||||
## Server Metadata
|
||||
|
||||
MCPHub provides OAuth 2.0 Authorization Server Metadata (RFC 8414) at:
|
||||
|
||||
```
|
||||
GET /.well-known/oauth-authorization-server
|
||||
```
|
||||
|
||||
Response (with dynamic registration enabled):
|
||||
```json
|
||||
{
|
||||
"issuer": "http://localhost:3000",
|
||||
"authorization_endpoint": "http://localhost:3000/oauth/authorize",
|
||||
"token_endpoint": "http://localhost:3000/oauth/token",
|
||||
"userinfo_endpoint": "http://localhost:3000/oauth/userinfo",
|
||||
"registration_endpoint": "http://localhost:3000/oauth/register",
|
||||
"scopes_supported": ["read", "write"],
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||
"token_endpoint_auth_methods_supported": ["none", "client_secret_basic", "client_secret_post"],
|
||||
"code_challenge_methods_supported": ["S256", "plain"]
|
||||
}
|
||||
```
|
||||
|
||||
## User Info Endpoint
|
||||
|
||||
Get authenticated user information (OpenID Connect compatible):
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/oauth/userinfo \
|
||||
-H "Authorization: Bearer ACCESS_TOKEN"
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"sub": "username",
|
||||
"username": "username"
|
||||
}
|
||||
```
|
||||
|
||||
## Integration with ChatGPT Web
|
||||
|
||||
To integrate MCPHub with ChatGPT Web:
|
||||
|
||||
1. Enable OAuth server in MCPHub configuration
|
||||
2. Create an OAuth client with ChatGPT's redirect URI
|
||||
3. Configure ChatGPT Web MCP Connector:
|
||||
- **MCP Server URL**: `http://your-mcphub-url/mcp`
|
||||
- **Authentication**: OAuth
|
||||
- **OAuth Client ID**: Your client ID
|
||||
- **OAuth Client Secret**: Leave empty (PKCE flow)
|
||||
- **Authorization URL**: `http://your-mcphub-url/oauth/authorize`
|
||||
- **Token URL**: `http://your-mcphub-url/oauth/token`
|
||||
- **Scopes**: `read write`
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **HTTPS in Production**: Always use HTTPS in production to protect tokens in transit
|
||||
2. **Secure Client Secrets**: If using confidential clients, store client secrets securely
|
||||
3. **Token Storage**: Access tokens are stored in memory by default. For production, consider using a database
|
||||
4. **Token Rotation**: Implement token rotation by using refresh tokens
|
||||
5. **Scope Limitation**: Grant only necessary scopes to clients
|
||||
6. **Redirect URI Validation**: Always validate redirect URIs strictly
|
||||
7. **State Parameter**: Always use the state parameter to prevent CSRF attacks
|
||||
8. **PKCE**: Use PKCE for public clients (strongly recommended)
|
||||
9. **Rate Limiting**: For production deployments, implement rate limiting on OAuth endpoints to prevent brute force attacks. Consider using middleware like `express-rate-limit`
|
||||
10. **Input Validation**: All OAuth parameters are validated, but additional application-level validation may be beneficial
|
||||
11. **XSS Protection**: The authorization page escapes all user input to prevent XSS attacks
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "OAuth server not available"
|
||||
|
||||
Make sure `oauthServer.enabled` is set to `true` in your configuration and restart MCPHub.
|
||||
|
||||
### "Invalid redirect_uri"
|
||||
|
||||
Ensure the redirect URI in the authorization request exactly matches one of the registered redirect URIs for the client.
|
||||
|
||||
### "Invalid client"
|
||||
|
||||
Verify the client ID is correct and the OAuth client exists in the configuration.
|
||||
|
||||
### Token expired
|
||||
|
||||
Use the refresh token to obtain a new access token, or re-authorize the application.
|
||||
|
||||
## Example: JavaScript Client
|
||||
|
||||
```javascript
|
||||
// Generate PKCE parameters
|
||||
const codeVerifier = crypto.randomBytes(32).toString('base64url');
|
||||
const codeChallenge = crypto
|
||||
.createHash('sha256')
|
||||
.update(codeVerifier)
|
||||
.digest('base64url');
|
||||
|
||||
// Store code verifier for later use
|
||||
sessionStorage.setItem('codeVerifier', codeVerifier);
|
||||
|
||||
// Redirect to authorization endpoint
|
||||
const authUrl = new URL('http://localhost:3000/oauth/authorize');
|
||||
authUrl.searchParams.set('client_id', 'my-client-id');
|
||||
authUrl.searchParams.set('redirect_uri', 'http://localhost:8080/callback');
|
||||
authUrl.searchParams.set('response_type', 'code');
|
||||
authUrl.searchParams.set('scope', 'read write');
|
||||
authUrl.searchParams.set('state', crypto.randomBytes(16).toString('hex'));
|
||||
authUrl.searchParams.set('code_challenge', codeChallenge);
|
||||
authUrl.searchParams.set('code_challenge_method', 'S256');
|
||||
|
||||
window.location.href = authUrl.toString();
|
||||
|
||||
// In callback handler:
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code');
|
||||
const codeVerifier = sessionStorage.getItem('codeVerifier');
|
||||
|
||||
// Exchange code for token
|
||||
const tokenResponse = await fetch('http://localhost:3000/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: code,
|
||||
redirect_uri: 'http://localhost:8080/callback',
|
||||
client_id: 'my-client-id',
|
||||
code_verifier: codeVerifier,
|
||||
}),
|
||||
});
|
||||
|
||||
const tokens = await tokenResponse.json();
|
||||
// Store tokens securely
|
||||
localStorage.setItem('accessToken', tokens.access_token);
|
||||
localStorage.setItem('refreshToken', tokens.refresh_token);
|
||||
|
||||
// Use access token
|
||||
const response = await fetch('http://localhost:3000/api/servers', {
|
||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||
});
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [OAuth 2.0 - RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749)
|
||||
- [OAuth 2.0 Authorization Server Metadata - RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414)
|
||||
- [PKCE - RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636)
|
||||
- [OAuth 2.0 for Browser-Based Apps](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps)
|
||||
@@ -1,209 +0,0 @@
|
||||
# OpenAPI Schema Support in MCPHub
|
||||
|
||||
MCPHub now supports both OpenAPI specification URLs and complete JSON schemas for OpenAPI server configuration. This allows you to either reference an external OpenAPI specification file or embed the complete schema directly in your configuration.
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### 1. Using OpenAPI Specification URL (Traditional)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "openapi",
|
||||
"openapi": {
|
||||
"url": "https://api.example.com/openapi.json",
|
||||
"version": "3.1.0",
|
||||
"security": {
|
||||
"type": "apiKey",
|
||||
"apiKey": {
|
||||
"name": "X-API-Key",
|
||||
"in": "header",
|
||||
"value": "your-api-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Using Complete JSON Schema (New)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "openapi",
|
||||
"openapi": {
|
||||
"schema": {
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "My API",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "https://api.example.com"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/users": {
|
||||
"get": {
|
||||
"operationId": "getUsers",
|
||||
"summary": "Get all users",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of users"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": "3.1.0",
|
||||
"security": {
|
||||
"type": "apiKey",
|
||||
"apiKey": {
|
||||
"name": "X-API-Key",
|
||||
"in": "header",
|
||||
"value": "your-api-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Benefits of JSON Schema Support
|
||||
|
||||
1. **Offline Development**: No need for external URLs during development
|
||||
2. **Version Control**: Schema changes can be tracked in your configuration
|
||||
3. **Security**: No external dependencies or network calls required
|
||||
4. **Customization**: Full control over the API specification
|
||||
5. **Testing**: Easy to create test configurations with mock schemas
|
||||
|
||||
## Frontend Form Support
|
||||
|
||||
The web interface now includes:
|
||||
|
||||
- **Input Mode Selection**: Choose between "Specification URL" or "JSON Schema"
|
||||
- **URL Input**: Traditional URL input field for external specifications
|
||||
- **Schema Editor**: Large text area with syntax highlighting for JSON schema input
|
||||
- **Validation**: Client-side JSON validation before submission
|
||||
- **Help Text**: Contextual help for schema format
|
||||
|
||||
## API Validation
|
||||
|
||||
The backend validates that:
|
||||
|
||||
- At least one of `url` or `schema` is provided for OpenAPI servers
|
||||
- JSON schemas are properly formatted when provided
|
||||
- Security configurations are valid for both input modes
|
||||
- All required OpenAPI fields are present
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### From URL to Schema
|
||||
|
||||
If you want to convert an existing URL-based configuration to schema-based:
|
||||
|
||||
1. Download your OpenAPI specification from the URL
|
||||
2. Copy the JSON content
|
||||
3. Update your configuration to use the `schema` field instead of `url`
|
||||
4. Paste the JSON content as the value of the `schema` field
|
||||
|
||||
### Maintaining Both
|
||||
|
||||
You can include both `url` and `schema` in your configuration. The system will prioritize the `schema` field if both are present.
|
||||
|
||||
## Examples
|
||||
|
||||
See the `examples/openapi-schema-config.json` file for complete configuration examples showing both URL and schema-based configurations.
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
- **Backend**: OpenAPI client supports both SwaggerParser.dereference() with URLs and direct schema objects
|
||||
- **Frontend**: Dynamic form rendering based on selected input mode
|
||||
- **Validation**: Enhanced validation logic in server controllers
|
||||
- **Type Safety**: Updated TypeScript interfaces for both input modes
|
||||
|
||||
## Header Passthrough Support
|
||||
|
||||
MCPHub supports passing through specific headers from tool call requests to upstream OpenAPI endpoints. This is useful for authentication tokens, API keys, and other request-specific headers.
|
||||
|
||||
### Configuration
|
||||
|
||||
Add `passthroughHeaders` to your OpenAPI configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "openapi",
|
||||
"openapi": {
|
||||
"url": "https://api.example.com/openapi.json",
|
||||
"version": "3.1.0",
|
||||
"passthroughHeaders": ["Authorization", "X-API-Key", "X-Custom-Header"],
|
||||
"security": {
|
||||
"type": "apiKey",
|
||||
"apiKey": {
|
||||
"name": "X-API-Key",
|
||||
"in": "header",
|
||||
"value": "your-api-key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Configuration**: List header names in the `passthroughHeaders` array
|
||||
2. **Tool Calls**: When calling tools via HTTP API, include headers in the request
|
||||
3. **Passthrough**: Only configured headers are forwarded to the upstream API
|
||||
4. **Case Insensitive**: Header matching is case-insensitive for flexibility
|
||||
|
||||
### Example Usage
|
||||
|
||||
```bash
|
||||
# Call an OpenAPI tool with passthrough headers
|
||||
curl -X POST "http://localhost:3000/api/tools/myapi/createUser" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer your-token" \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-H "X-Custom-Header: custom-value" \
|
||||
-d '{"name": "John Doe", "email": "john@example.com"}'
|
||||
```
|
||||
|
||||
In this example:
|
||||
|
||||
- If `passthroughHeaders` includes `["Authorization", "X-API-Key"]`
|
||||
- Only `Authorization` and `X-API-Key` headers will be forwarded
|
||||
- `X-Custom-Header` will be ignored (not in passthrough list)
|
||||
- `Content-Type` is handled by the OpenAPI operation definition
|
||||
|
||||
### Security Considerations
|
||||
|
||||
- **Whitelist Only**: Only explicitly configured headers are passed through
|
||||
- **Sensitive Data**: Be careful with headers containing sensitive information
|
||||
- **Validation**: Upstream APIs should validate all received headers
|
||||
- **Logging**: Headers may appear in logs - consider this for sensitive data
|
||||
|
||||
## Security Considerations
|
||||
|
||||
When using JSON schemas:
|
||||
|
||||
- Ensure schemas are properly validated before use
|
||||
- Be aware that large schemas increase configuration file size
|
||||
- Consider using URL-based approach for frequently changing APIs
|
||||
- Store sensitive information (like API keys) in environment variables, not in schemas
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Invalid JSON**: Ensure your schema is valid JSON format
|
||||
2. **Missing Required Fields**: OpenAPI schemas must include `openapi`, `info`, and `paths` fields
|
||||
3. **Schema Size**: Very large schemas may impact performance
|
||||
4. **Server Configuration**: Ensure the `servers` field in your schema points to the correct endpoints
|
||||
|
||||
### Validation Errors
|
||||
|
||||
The system provides detailed error messages for:
|
||||
|
||||
- Malformed JSON in schema field
|
||||
- Missing required OpenAPI fields
|
||||
- Invalid security configurations
|
||||
- Network issues with URL-based configurations
|
||||
@@ -1,172 +0,0 @@
|
||||
# OpenAPI Support in MCPHub
|
||||
|
||||
MCPHub now supports OpenAPI 3.1.1 servers as a new server type, allowing you to integrate REST APIs directly into your MCP workflow.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ **Full OpenAPI 3.1.1 Support**: Load and parse OpenAPI specifications
|
||||
- ✅ **Multiple Security Types**: None, API Key, HTTP Authentication, OAuth 2.0, OpenID Connect
|
||||
- ✅ **Dynamic Tool Generation**: Automatically creates MCP tools from OpenAPI operations
|
||||
- ✅ **Type Safety**: Full TypeScript support with proper type definitions
|
||||
- ✅ **Frontend Integration**: Easy-to-use forms for configuring OpenAPI servers
|
||||
- ✅ **Internationalization**: Support for English and Chinese languages
|
||||
|
||||
## Configuration
|
||||
|
||||
### Basic Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "openapi",
|
||||
"openapi": {
|
||||
"url": "https://api.example.com/v1/openapi.json",
|
||||
"version": "3.1.0",
|
||||
"security": {
|
||||
"type": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### With API Key Authentication
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "openapi",
|
||||
"openapi": {
|
||||
"url": "https://api.example.com/v1/openapi.json",
|
||||
"version": "3.1.0",
|
||||
"security": {
|
||||
"type": "apiKey",
|
||||
"apiKey": {
|
||||
"name": "X-API-Key",
|
||||
"in": "header",
|
||||
"value": "your-api-key-here"
|
||||
}
|
||||
}
|
||||
},
|
||||
"headers": {
|
||||
"Accept": "application/json"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### With HTTP Bearer Authentication
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "openapi",
|
||||
"openapi": {
|
||||
"url": "https://api.example.com/v1/openapi.json",
|
||||
"version": "3.1.0",
|
||||
"security": {
|
||||
"type": "http",
|
||||
"http": {
|
||||
"scheme": "bearer",
|
||||
"credentials": "your-bearer-token-here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Supported Security Types
|
||||
|
||||
1. **None**: No authentication required
|
||||
2. **API Key**: API key in header or query parameter
|
||||
3. **HTTP**: Basic, Bearer, or Digest authentication
|
||||
4. **OAuth 2.0**: OAuth 2.0 access tokens
|
||||
5. **OpenID Connect**: OpenID Connect ID tokens
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Specification Loading**: The OpenAPI client fetches and parses the OpenAPI specification
|
||||
2. **Tool Generation**: Each operation in the spec becomes an MCP tool
|
||||
3. **Request Handling**: Tools handle parameter validation and API calls
|
||||
4. **Response Processing**: API responses are returned as tool results
|
||||
|
||||
## Frontend Usage
|
||||
|
||||
1. Navigate to the Servers page
|
||||
2. Click "Add Server"
|
||||
3. Select "OpenAPI" as the server type
|
||||
4. Enter the OpenAPI specification URL
|
||||
5. Configure security settings if needed
|
||||
6. Add any additional headers
|
||||
7. Save the configuration
|
||||
|
||||
## Testing
|
||||
|
||||
You can test the OpenAPI integration using the provided test scripts:
|
||||
|
||||
```bash
|
||||
# Test OpenAPI client directly
|
||||
npx tsx test-openapi.ts
|
||||
|
||||
# Test full integration
|
||||
npx tsx test-integration.ts
|
||||
```
|
||||
|
||||
## Example: Swagger Petstore
|
||||
|
||||
The Swagger Petstore API is a perfect example for testing:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "openapi",
|
||||
"openapi": {
|
||||
"url": "https://petstore3.swagger.io/api/v3/openapi.json",
|
||||
"version": "3.1.0",
|
||||
"security": {
|
||||
"type": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This will create tools like:
|
||||
|
||||
- `addPet`: Add a new pet to the store
|
||||
- `findPetsByStatus`: Find pets by status
|
||||
- `getPetById`: Find pet by ID
|
||||
- And many more...
|
||||
|
||||
## Error Handling
|
||||
|
||||
The OpenAPI client includes comprehensive error handling:
|
||||
|
||||
- Network errors are properly caught and reported
|
||||
- Invalid specifications are rejected with clear error messages
|
||||
- API errors include response status and body information
|
||||
- Type validation ensures proper parameter handling
|
||||
|
||||
## Limitations
|
||||
|
||||
- Only supports OpenAPI 3.x specifications (3.0.0 and above)
|
||||
- Complex authentication flows (like OAuth 2.0 authorization code flow) require manual token management
|
||||
- Large specifications may take time to parse initially
|
||||
- Some advanced OpenAPI features may not be fully supported
|
||||
|
||||
## Contributing
|
||||
|
||||
To add new features or fix bugs in the OpenAPI integration:
|
||||
|
||||
1. Backend types: `src/types/index.ts`
|
||||
2. OpenAPI client: `src/clients/openapi.ts`
|
||||
3. Service integration: `src/services/mcpService.ts`
|
||||
4. Frontend forms: `frontend/src/components/ServerForm.tsx`
|
||||
5. Internationalization: `frontend/src/locales/`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Q: My OpenAPI server won't connect**
|
||||
A: Check that the specification URL is accessible and returns valid JSON/YAML
|
||||
|
||||
**Q: Tools aren't showing up**
|
||||
A: Verify that your OpenAPI specification includes valid operations with required fields
|
||||
|
||||
**Q: Authentication isn't working**
|
||||
A: Double-check your security configuration matches the API's requirements
|
||||
|
||||
**Q: Getting CORS errors**
|
||||
A: The API server needs to allow CORS requests from your MCPHub domain
|
||||
@@ -1,172 +0,0 @@
|
||||
# 测试框架和自动化测试实现报告
|
||||
|
||||
## 概述
|
||||
|
||||
本项目已成功引入现代化的测试框架和自动化测试流程。实现了基于Jest的测试环境,支持TypeScript、ES模块,并包含完整的CI/CD配置。
|
||||
|
||||
## 已实现的功能
|
||||
|
||||
### 1. 测试框架配置
|
||||
|
||||
- **Jest配置**: 使用`jest.config.cjs`配置文件,支持ES模块和TypeScript
|
||||
- **覆盖率报告**: 配置了代码覆盖率收集和报告
|
||||
- **测试环境**: 支持Node.js环境的单元测试和集成测试
|
||||
- **模块映射**: 配置了路径别名支持
|
||||
|
||||
### 2. 测试工具和辅助函数
|
||||
|
||||
创建了完善的测试工具库 (`tests/utils/testHelpers.ts`):
|
||||
|
||||
- **认证工具**: JWT token生成和管理
|
||||
- **HTTP测试**: Supertest集成用于API测试
|
||||
- **数据生成**: 测试数据工厂函数
|
||||
- **响应断言**: 自定义API响应验证器
|
||||
- **环境管理**: 测试环境变量配置
|
||||
|
||||
### 3. 测试用例实现
|
||||
|
||||
已实现的测试场景:
|
||||
|
||||
#### 基础配置测试 (`tests/basic.test.ts`)
|
||||
- Jest配置验证
|
||||
- 异步操作支持测试
|
||||
- 自定义匹配器验证
|
||||
|
||||
#### 认证逻辑测试 (`tests/auth.logic.test.ts`)
|
||||
- 用户登录逻辑
|
||||
- 密码验证
|
||||
- JWT生成和验证
|
||||
- 错误处理场景
|
||||
- 用户数据验证
|
||||
|
||||
#### 路径工具测试 (`tests/utils/pathLogic.test.ts`)
|
||||
- 配置文件路径解析
|
||||
- 环境变量处理
|
||||
- 文件系统操作
|
||||
- 错误处理和边界条件
|
||||
- 跨平台路径处理
|
||||
|
||||
### 4. CI/CD配置
|
||||
|
||||
GitHub Actions配置 (`.github/workflows/ci.yml`):
|
||||
|
||||
- **多Node.js版本支持**: 18.x和20.x
|
||||
- **自动化测试流程**:
|
||||
- 代码检查 (ESLint)
|
||||
- 类型检查 (TypeScript)
|
||||
- 单元测试执行
|
||||
- 覆盖率报告
|
||||
- **构建验证**: 应用构建和产物验证
|
||||
- **集成测试**: 包含数据库环境的集成测试
|
||||
|
||||
### 5. 测试脚本
|
||||
|
||||
在`package.json`中添加的测试命令:
|
||||
|
||||
```json
|
||||
{
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"test:verbose": "jest --verbose",
|
||||
"test:ci": "jest --ci --coverage --watchAll=false"
|
||||
}
|
||||
```
|
||||
|
||||
## 测试结果
|
||||
|
||||
当前测试统计:
|
||||
- **测试套件**: 3个
|
||||
- **测试用例**: 19个
|
||||
- **通过率**: 100%
|
||||
- **执行时间**: ~15秒
|
||||
|
||||
### 测试覆盖的功能模块
|
||||
|
||||
1. **认证系统**: 用户登录、JWT处理、密码验证
|
||||
2. **配置管理**: 文件路径解析、环境变量处理
|
||||
3. **基础设施**: Jest配置、测试工具验证
|
||||
|
||||
## 技术特点
|
||||
|
||||
### 现代化特性
|
||||
|
||||
- **ES模块支持**: 完全支持ES2022模块语法
|
||||
- **TypeScript集成**: 类型安全的测试编写
|
||||
- **异步测试**: Promise和async/await支持
|
||||
- **模拟系统**: Jest mock功能的深度使用
|
||||
- **参数化测试**: 数据驱动的测试用例
|
||||
|
||||
### 最佳实践
|
||||
|
||||
- **测试隔离**: 每个测试用例独立运行
|
||||
- **Mock管理**: 统一的mock清理和重置
|
||||
- **错误处理**: 完整的错误场景测试
|
||||
- **边界测试**: 输入验证和边界条件覆盖
|
||||
- **文档化**: 清晰的测试用例命名和描述
|
||||
|
||||
## 后续扩展计划
|
||||
|
||||
### 短期目标
|
||||
|
||||
1. **API测试**: 为REST API端点添加集成测试
|
||||
2. **数据库测试**: 添加数据模型和存储层测试
|
||||
3. **中间件测试**: 认证和权限中间件测试
|
||||
4. **服务层测试**: 核心业务逻辑测试
|
||||
|
||||
### 中期目标
|
||||
|
||||
1. **端到端测试**: 使用Playwright或Cypress
|
||||
2. **性能测试**: API响应时间和负载测试
|
||||
3. **安全测试**: 输入验证和安全漏洞测试
|
||||
4. **契约测试**: API契约验证
|
||||
|
||||
### 长期目标
|
||||
|
||||
1. **测试数据管理**: 测试数据库和fixture管理
|
||||
2. **视觉回归测试**: UI组件的视觉测试
|
||||
3. **监控集成**: 生产环境测试监控
|
||||
4. **自动化测试报告**: 详细的测试报告和趋势分析
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 添加新测试用例
|
||||
|
||||
1. 在`tests/`目录下创建对应的测试文件
|
||||
2. 使用`testHelpers.ts`中的工具函数
|
||||
3. 遵循命名约定: `*.test.ts`或`*.spec.ts`
|
||||
4. 确保测试用例具有清晰的描述和断言
|
||||
|
||||
### 运行测试
|
||||
|
||||
```bash
|
||||
# 运行所有测试
|
||||
pnpm test
|
||||
|
||||
# 监听模式
|
||||
pnpm test:watch
|
||||
|
||||
# 生成覆盖率报告
|
||||
pnpm test:coverage
|
||||
|
||||
# CI模式运行
|
||||
pnpm test:ci
|
||||
```
|
||||
|
||||
### Mock最佳实践
|
||||
|
||||
- 在`beforeEach`中清理所有mock
|
||||
- 使用具体的mock实现而不是空函数
|
||||
- 验证mock被正确调用
|
||||
- 保持mock的一致性和可维护性
|
||||
|
||||
## 结论
|
||||
|
||||
本项目已成功建立了完整的现代化测试框架,具备以下优势:
|
||||
|
||||
1. **高度可扩展**: 易于添加新的测试用例和测试类型
|
||||
2. **开发友好**: 丰富的工具函数和清晰的结构
|
||||
3. **CI/CD就绪**: 完整的自动化流水线配置
|
||||
4. **质量保证**: 代码覆盖率和持续测试验证
|
||||
|
||||
这个测试框架为项目的持续发展和质量保证提供了坚实的基础,支持敏捷开发和持续集成的最佳实践。
|
||||
304
docs/zh/configuration/database-configuration.mdx
Normal file
304
docs/zh/configuration/database-configuration.mdx
Normal file
@@ -0,0 +1,304 @@
|
||||
---
|
||||
title: '数据库配置'
|
||||
description: '使用 PostgreSQL 数据库配置 MCPHub 作为 mcp_settings.json 文件的替代方案。'
|
||||
---
|
||||
|
||||
# MCPHub 数据库配置
|
||||
|
||||
## 概述
|
||||
|
||||
MCPHub 支持将配置数据存储在 PostgreSQL 数据库中,作为 `mcp_settings.json` 文件配置的替代方案。数据库模式为生产环境和企业级部署提供了更强大的持久化和扩展能力。
|
||||
|
||||
## 为什么使用数据库配置?
|
||||
|
||||
**核心优势:**
|
||||
- ✅ **更好的持久化** - 配置数据存储在专业数据库中,支持事务和数据完整性
|
||||
- ✅ **高可用性** - 利用数据库复制和故障转移能力
|
||||
- ✅ **企业级支持** - 符合企业数据管理和合规要求
|
||||
- ✅ **备份恢复** - 使用成熟的数据库备份工具和策略
|
||||
|
||||
## 环境变量
|
||||
|
||||
### 数据库模式必需变量
|
||||
|
||||
```bash
|
||||
# 数据库连接 URL(PostgreSQL)
|
||||
# 只需设置 DB_URL 即可自动启用数据库模式
|
||||
DB_URL=postgresql://user:password@localhost:5432/mcphub
|
||||
|
||||
# 或显式控制 USE_DB(覆盖自动检测)
|
||||
# USE_DB=true
|
||||
```
|
||||
|
||||
<Note>
|
||||
**简化配置**:您只需设置 `DB_URL` 即可启用数据库模式。MCPHub 会自动检测 `DB_URL` 是否存在并启用数据库模式。如果需要在设置了 `DB_URL` 的情况下禁用数据库模式,可以显式设置 `USE_DB=false`。
|
||||
</Note>
|
||||
|
||||
## 设置说明
|
||||
|
||||
### 1. 使用 Docker
|
||||
|
||||
#### 方案 A:使用外部数据库
|
||||
|
||||
如果您已有 PostgreSQL 数据库:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 3000:3000 \
|
||||
-v ./mcp_settings.json:/app/mcp_settings.json \
|
||||
-e DB_URL="postgresql://user:password@your-db-host:5432/mcphub" \
|
||||
samanhappy/mcphub
|
||||
```
|
||||
|
||||
#### 方案 B:将 PostgreSQL 作为独立服务
|
||||
|
||||
创建 `docker-compose.yml` 文件:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_DB: mcphub
|
||||
POSTGRES_USER: mcphub
|
||||
POSTGRES_PASSWORD: your_secure_password
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
mcphub:
|
||||
image: samanhappy/mcphub:latest
|
||||
environment:
|
||||
USE_DB: "true"
|
||||
DB_URL: "postgresql://mcphub:your_secure_password@postgres:5432/mcphub"
|
||||
PORT: 3000
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
```
|
||||
|
||||
运行:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### 2. 手动设置
|
||||
|
||||
#### 步骤 1:设置 PostgreSQL 数据库
|
||||
|
||||
```bash
|
||||
# 安装 PostgreSQL(如果尚未安装)
|
||||
sudo apt-get install postgresql postgresql-contrib
|
||||
|
||||
# 创建数据库和用户
|
||||
sudo -u postgres psql <<EOF
|
||||
CREATE DATABASE mcphub;
|
||||
CREATE USER mcphub WITH ENCRYPTED PASSWORD 'your_password';
|
||||
GRANT ALL PRIVILEGES ON DATABASE mcphub TO mcphub;
|
||||
EOF
|
||||
```
|
||||
|
||||
#### 步骤 2:安装 MCPHub
|
||||
|
||||
```bash
|
||||
npm install -g @samanhappy/mcphub
|
||||
```
|
||||
|
||||
#### 步骤 3:设置环境变量
|
||||
|
||||
创建 `.env` 文件:
|
||||
|
||||
```bash
|
||||
# 只需设置 DB_URL 即可启用数据库模式(USE_DB 会自动检测)
|
||||
DB_URL=postgresql://mcphub:your_password@localhost:5432/mcphub
|
||||
PORT=3000
|
||||
```
|
||||
|
||||
#### 步骤 4:运行迁移(可选)
|
||||
|
||||
如果您有现有的 `mcp_settings.json` 文件,可以进行迁移:
|
||||
|
||||
```bash
|
||||
# 运行迁移脚本
|
||||
npx tsx src/scripts/migrate-to-database.ts
|
||||
```
|
||||
|
||||
或者让 MCPHub 在首次启动时自动迁移。
|
||||
|
||||
#### 步骤 5:启动 MCPHub
|
||||
|
||||
```bash
|
||||
mcphub
|
||||
```
|
||||
|
||||
## 从基于文件迁移到数据库
|
||||
|
||||
MCPHub 在启用数据库模式首次启动时提供自动迁移功能。您也可以手动运行迁移。
|
||||
|
||||
### 自动迁移
|
||||
|
||||
当您首次使用 `USE_DB=true` 启动 MCPHub 时:
|
||||
|
||||
1. MCPHub 连接到数据库
|
||||
2. 检查数据库中是否存在任何用户
|
||||
3. 如果未找到用户,自动从 `mcp_settings.json` 迁移
|
||||
4. 创建所有表并导入所有数据
|
||||
|
||||
### 手动迁移
|
||||
|
||||
运行迁移脚本:
|
||||
|
||||
```bash
|
||||
# 使用 npx
|
||||
npx tsx src/scripts/migrate-to-database.ts
|
||||
|
||||
# 或使用 Node
|
||||
node dist/scripts/migrate-to-database.js
|
||||
```
|
||||
|
||||
迁移将:
|
||||
- ✅ 如果不存在则创建数据库表
|
||||
- ✅ 导入所有用户(包含哈希密码)
|
||||
- ✅ 导入所有 MCP 服务器配置
|
||||
- ✅ 导入所有分组
|
||||
- ✅ 导入系统配置
|
||||
- ✅ 导入用户特定配置
|
||||
- ✅ 跳过已存在的记录(可安全多次运行)
|
||||
|
||||
## 迁移后的配置
|
||||
|
||||
在数据库模式下运行时,所有配置更改都存储在数据库中:
|
||||
|
||||
- 通过 `/api/users` 进行用户管理
|
||||
- 通过 `/api/servers` 进行服务器管理
|
||||
- 通过 `/api/groups` 进行分组管理
|
||||
- 通过 `/api/system/config` 进行系统设置
|
||||
|
||||
Web 仪表板的工作方式完全相同,但现在将更改存储在数据库中而不是文件中。
|
||||
|
||||
## 数据库架构
|
||||
|
||||
MCPHub 创建以下表:
|
||||
|
||||
- **users** - 用户账户和认证
|
||||
- **servers** - MCP 服务器配置
|
||||
- **groups** - 服务器分组
|
||||
- **system_config** - 系统级设置
|
||||
- **user_configs** - 用户特定设置
|
||||
- **vector_embeddings** - 向量搜索数据(用于智能路由)
|
||||
|
||||
## 备份和恢复
|
||||
|
||||
### 备份
|
||||
|
||||
```bash
|
||||
# PostgreSQL 备份
|
||||
pg_dump -U mcphub mcphub > mcphub_backup.sql
|
||||
|
||||
# 或使用 Docker
|
||||
docker exec postgres pg_dump -U mcphub mcphub > mcphub_backup.sql
|
||||
```
|
||||
|
||||
### 恢复
|
||||
|
||||
```bash
|
||||
# PostgreSQL 恢复
|
||||
psql -U mcphub mcphub < mcphub_backup.sql
|
||||
|
||||
# 或使用 Docker
|
||||
docker exec -i postgres psql -U mcphub mcphub < mcphub_backup.sql
|
||||
```
|
||||
|
||||
## 切换回基于文件的配置
|
||||
|
||||
如果您需要切换回基于文件的配置:
|
||||
|
||||
1. 设置 `USE_DB=false` 或删除 `DB_URL` 和 `USE_DB` 环境变量
|
||||
2. 重启 MCPHub
|
||||
3. MCPHub 将再次使用 `mcp_settings.json`
|
||||
|
||||
注意:在数据库模式下所做的更改不会反映到文件中,除非您手动导出。
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 连接被拒绝
|
||||
|
||||
```
|
||||
Error: connect ECONNREFUSED 127.0.0.1:5432
|
||||
```
|
||||
|
||||
**解决方案:** 检查 PostgreSQL 是否正在运行并可访问:
|
||||
```bash
|
||||
# 检查 PostgreSQL 状态
|
||||
sudo systemctl status postgresql
|
||||
|
||||
# 或对于 Docker
|
||||
docker ps | grep postgres
|
||||
```
|
||||
|
||||
### 认证失败
|
||||
|
||||
```
|
||||
Error: password authentication failed for user "mcphub"
|
||||
```
|
||||
|
||||
**解决方案:** 验证 `DB_URL` 环境变量中的数据库凭据。
|
||||
|
||||
### 迁移失败
|
||||
|
||||
```
|
||||
❌ Migration failed: ...
|
||||
```
|
||||
|
||||
**解决方案:**
|
||||
1. 检查 `mcp_settings.json` 是否存在且为有效的 JSON
|
||||
2. 验证数据库连接
|
||||
3. 检查日志获取具体错误信息
|
||||
4. 确保数据库用户具有 CREATE TABLE 权限
|
||||
|
||||
### 表已存在
|
||||
|
||||
如果数据库表不存在,会自动创建。如果遇到关于已存在表的错误,请检查:
|
||||
1. 之前的迁移是否部分完成
|
||||
2. 手动创建表的冲突
|
||||
3. 如果需要,在数据库配置中使用 `synchronize: false` 运行
|
||||
|
||||
## 环境变量参考
|
||||
|
||||
| 变量 | 必需 | 默认值 | 描述 |
|
||||
|------|------|--------|------|
|
||||
| `DB_URL` | 是* | - | 完整的 PostgreSQL 连接 URL。设置此变量会自动启用数据库模式 |
|
||||
| `USE_DB` | 否 | 自动 | 显式启用/禁用数据库模式。如果未设置,根据 `DB_URL` 是否存在自动检测 |
|
||||
| `MCPHUB_SETTING_PATH` | 否 | - | mcp_settings.json 的路径(用于迁移) |
|
||||
|
||||
*数据库模式必需。只需设置 `DB_URL` 即可自动启用数据库模式
|
||||
|
||||
## 安全注意事项
|
||||
|
||||
1. **数据库凭据:** 安全存储数据库凭据,使用环境变量或密钥管理
|
||||
2. **网络访问:** 仅限 MCPHub 实例访问数据库
|
||||
3. **加密:** 在生产环境中使用 SSL/TLS 进行数据库连接:
|
||||
```bash
|
||||
DB_URL=postgresql://user:password@host:5432/mcphub?sslmode=require
|
||||
```
|
||||
4. **备份:** 定期备份您的数据库
|
||||
5. **访问控制:** 使用强密码并限制用户权限
|
||||
|
||||
## 性能
|
||||
|
||||
数据库模式在以下场景提供更好的性能:
|
||||
- 多个并发用户
|
||||
- 频繁的配置更改
|
||||
- 大量服务器/分组
|
||||
|
||||
文件模式可能更快的场景:
|
||||
- 单用户设置
|
||||
- 读取密集型工作负载且更改不频繁
|
||||
- 开发/测试环境
|
||||
@@ -123,42 +123,6 @@ nano .env
|
||||
NODE_ENV=development
|
||||
PORT=3000
|
||||
HOST=localhost
|
||||
|
||||
# 数据库配置
|
||||
DATABASE_URL=sqlite:./data/dev.db
|
||||
|
||||
# JWT 配置
|
||||
JWT_SECRET=dev-jwt-secret-key
|
||||
JWT_EXPIRES_IN=7d
|
||||
|
||||
# 日志配置
|
||||
LOG_LEVEL=debug
|
||||
LOG_FORMAT=dev
|
||||
|
||||
# CORS 配置
|
||||
CORS_ORIGIN=http://localhost:3000,http://localhost:3001
|
||||
|
||||
# 管理员账户
|
||||
ADMIN_EMAIL=dev@mcphub.io
|
||||
ADMIN_PASSWORD=dev123
|
||||
|
||||
# 开发功能开关
|
||||
ENABLE_DEBUG_ROUTES=true
|
||||
ENABLE_SWAGGER=true
|
||||
ENABLE_HOT_RELOAD=true
|
||||
```
|
||||
|
||||
### 数据库初始化
|
||||
|
||||
```bash
|
||||
# 生成 Prisma 客户端
|
||||
npx prisma generate
|
||||
|
||||
# 运行数据库迁移
|
||||
npx prisma migrate dev --name init
|
||||
|
||||
# 填充测试数据
|
||||
npm run db:seed
|
||||
```
|
||||
|
||||
## 启动开发服务器
|
||||
|
||||
@@ -528,7 +528,7 @@ docker-compose up -d
|
||||
````md
|
||||
```bash
|
||||
# 创建新的 MCP 服务器
|
||||
curl -X POST https://api.mcphub.io/api/servers \
|
||||
curl -X POST http://localhost:3000/api/servers \
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
@@ -539,11 +539,11 @@ curl -X POST https://api.mcphub.io/api/servers \
|
||||
}'
|
||||
|
||||
# 获取服务器列表
|
||||
curl -X GET "https://api.mcphub.io/api/servers?limit=10&active=true" \
|
||||
curl -X GET "http://localhost:3000/api/servers?limit=10&active=true" \
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN"
|
||||
|
||||
# 更新服务器配置
|
||||
curl -X PUT https://api.mcphub.io/api/servers/server-123 \
|
||||
curl -X PUT http://localhost:3000/api/servers/server-123 \
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
@@ -552,14 +552,14 @@ curl -X PUT https://api.mcphub.io/api/servers/server-123 \
|
||||
}'
|
||||
|
||||
# 删除服务器
|
||||
curl -X DELETE https://api.mcphub.io/api/servers/server-123 \
|
||||
curl -X DELETE http://localhost:3000/api/servers/server-123 \
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN"
|
||||
```
|
||||
````
|
||||
|
||||
```bash
|
||||
# 创建新的 MCP 服务器
|
||||
curl -X POST https://api.mcphub.io/api/servers \
|
||||
curl -X POST http://localhost:3000/api/servers \
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
@@ -570,11 +570,11 @@ curl -X POST https://api.mcphub.io/api/servers \
|
||||
}'
|
||||
|
||||
# 获取服务器列表
|
||||
curl -X GET "https://api.mcphub.io/api/servers?limit=10&active=true" \
|
||||
curl -X GET "http://localhost:3000/api/servers?limit=10&active=true" \
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN"
|
||||
|
||||
# 更新服务器配置
|
||||
curl -X PUT https://api.mcphub.io/api/servers/server-123 \
|
||||
curl -X PUT http://localhost:3000/api/servers/server-123 \
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
@@ -583,7 +583,7 @@ curl -X PUT https://api.mcphub.io/api/servers/server-123 \
|
||||
}'
|
||||
|
||||
# 删除服务器
|
||||
curl -X DELETE https://api.mcphub.io/api/servers/server-123 \
|
||||
curl -X DELETE http://localhost:3000/api/servers/server-123 \
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN"
|
||||
```
|
||||
|
||||
@@ -592,7 +592,7 @@ curl -X DELETE https://api.mcphub.io/api/servers/server-123 \
|
||||
````md
|
||||
```http
|
||||
POST /api/servers HTTP/1.1
|
||||
Host: api.mcphub.io
|
||||
Host: localhost:3000
|
||||
Authorization: Bearer YOUR_API_TOKEN
|
||||
Content-Type: application/json
|
||||
|
||||
@@ -607,7 +607,7 @@ Content-Type: application/json
|
||||
|
||||
```http
|
||||
POST /api/servers HTTP/1.1
|
||||
Host: api.mcphub.io
|
||||
Host: localhost:3000
|
||||
Authorization: Bearer YOUR_API_TOKEN
|
||||
Content-Type: application/json
|
||||
|
||||
@@ -746,7 +746,7 @@ app.listen(port, () => {
|
||||
```javascript
|
||||
// 初始化 MCPHub 客户端
|
||||
const client = new MCPHubClient({
|
||||
endpoint: 'https://api.mcphub.io',
|
||||
endpoint: 'http://localhost:3000',
|
||||
apiKey: process.env.API_KEY,
|
||||
timeout: 30000, // 30 秒超时
|
||||
retries: 3, // 重试 3 次
|
||||
|
||||
@@ -133,7 +133,7 @@ MCPHub 主要功能:
|
||||
```javascript
|
||||
// MCPHub 客户端初始化
|
||||
const mcpClient = new MCPClient({
|
||||
endpoint: 'https://api.mcphub.io',
|
||||
endpoint: 'http://localhost:3000',
|
||||
apiKey: process.env.MCPHUB_API_KEY,
|
||||
});
|
||||
```
|
||||
@@ -142,7 +142,7 @@ const mcpClient = new MCPClient({
|
||||
```javascript
|
||||
// MCPHub 客户端初始化
|
||||
const mcpClient = new MCPClient({
|
||||
endpoint: 'https://api.mcphub.io',
|
||||
endpoint: 'http://localhost:3000',
|
||||
apiKey: process.env.MCPHUB_API_KEY,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -241,20 +241,6 @@ MCPHub 文档支持多级分层导航:
|
||||
|
||||
```json title="docs.json"
|
||||
{
|
||||
"tabs": [
|
||||
{
|
||||
"name": "文档",
|
||||
"url": "https://docs.mcphub.io"
|
||||
},
|
||||
{
|
||||
"name": "API",
|
||||
"url": "https://api.mcphub.io"
|
||||
},
|
||||
{
|
||||
"name": "SDK",
|
||||
"url": "https://sdk.mcphub.io"
|
||||
}
|
||||
],
|
||||
"navigation": {
|
||||
"文档": [
|
||||
{
|
||||
@@ -336,13 +322,8 @@ MCPHub 文档支持以下图标库的图标:
|
||||
},
|
||||
{
|
||||
"name": "Discord 社区",
|
||||
"url": "https://discord.gg/mcphub",
|
||||
"url": "https://discord.gg/qMKNsn5Q",
|
||||
"icon": "discord"
|
||||
},
|
||||
{
|
||||
"name": "状态页面",
|
||||
"url": "https://status.mcphub.io",
|
||||
"icon": "status"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
284
frontend/src/components/GroupImportForm.tsx
Normal file
284
frontend/src/components/GroupImportForm.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { apiPost } from '@/utils/fetchInterceptor';
|
||||
|
||||
interface GroupImportFormProps {
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
interface ImportGroupConfig {
|
||||
name: string;
|
||||
description?: string;
|
||||
servers?: string[] | Array<{ name: string; tools?: string[] | 'all' }>;
|
||||
}
|
||||
|
||||
interface ImportJsonFormat {
|
||||
groups: ImportGroupConfig[];
|
||||
}
|
||||
|
||||
const GroupImportForm: React.FC<GroupImportFormProps> = ({ onSuccess, onCancel }) => {
|
||||
const { t } = useTranslation();
|
||||
const [jsonInput, setJsonInput] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [previewGroups, setPreviewGroups] = useState<ImportGroupConfig[] | null>(null);
|
||||
|
||||
const examplePlaceholder = `{
|
||||
"groups": [
|
||||
{
|
||||
"name": "AI Assistants",
|
||||
"servers": ["openai-server", "anthropic-server"]
|
||||
},
|
||||
{
|
||||
"name": "Development Tools",
|
||||
"servers": [
|
||||
{
|
||||
"name": "github-server",
|
||||
"tools": ["create_issue", "list_repos"]
|
||||
},
|
||||
{
|
||||
"name": "gitlab-server",
|
||||
"tools": "all"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Supports:
|
||||
- Simple server list: ["server1", "server2"]
|
||||
- Advanced server config: [{"name": "server1", "tools": ["tool1", "tool2"]}]
|
||||
- All groups will be imported in a single efficient batch operation.`;
|
||||
|
||||
const parseAndValidateJson = (input: string): ImportJsonFormat | null => {
|
||||
try {
|
||||
const parsed = JSON.parse(input.trim());
|
||||
|
||||
// Validate structure
|
||||
if (!parsed.groups || !Array.isArray(parsed.groups)) {
|
||||
setError(t('groupImport.invalidFormat'));
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate each group
|
||||
for (const group of parsed.groups) {
|
||||
if (!group.name || typeof group.name !== 'string') {
|
||||
setError(t('groupImport.missingName'));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return parsed as ImportJsonFormat;
|
||||
} catch (e) {
|
||||
setError(t('groupImport.parseError'));
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreview = () => {
|
||||
setError(null);
|
||||
const parsed = parseAndValidateJson(jsonInput);
|
||||
if (!parsed) return;
|
||||
|
||||
setPreviewGroups(parsed.groups);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!previewGroups) return;
|
||||
|
||||
setIsImporting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Use batch import API for better performance
|
||||
const result = await apiPost('/groups/batch', {
|
||||
groups: previewGroups,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
const { successCount, failureCount, results } = result;
|
||||
|
||||
if (failureCount > 0) {
|
||||
const errors = results
|
||||
.filter((r: any) => !r.success)
|
||||
.map((r: any) => `${r.name}: ${r.message || t('groupImport.addFailed')}`);
|
||||
|
||||
setError(
|
||||
t('groupImport.partialSuccess', { count: successCount, total: previewGroups.length }) +
|
||||
'\n' +
|
||||
errors.join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
onSuccess();
|
||||
}
|
||||
} else {
|
||||
setError(result.message || t('groupImport.importFailed'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Import error:', err);
|
||||
setError(t('groupImport.importFailed'));
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderServerList = (
|
||||
servers?: string[] | Array<{ name: string; tools?: string[] | 'all' }>,
|
||||
) => {
|
||||
if (!servers || servers.length === 0) {
|
||||
return <span className="text-gray-500">{t('groups.noServers')}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{servers.map((server, idx) => {
|
||||
if (typeof server === 'string') {
|
||||
return (
|
||||
<div key={idx} className="text-sm">
|
||||
• {server}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div key={idx} className="text-sm">
|
||||
• {server.name}
|
||||
{server.tools && server.tools !== 'all' && (
|
||||
<span className="text-gray-500 ml-2">
|
||||
({Array.isArray(server.tools) ? server.tools.join(', ') : server.tools})
|
||||
</span>
|
||||
)}
|
||||
{server.tools === 'all' && <span className="text-gray-500 ml-2">(all tools)</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white shadow rounded-lg p-6 w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900">{t('groupImport.title')}</h2>
|
||||
<button onClick={onCancel} className="text-gray-500 hover:text-gray-700">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4 rounded">
|
||||
<p className="text-red-700 whitespace-pre-wrap">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!previewGroups ? (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{t('groupImport.inputLabel')}
|
||||
</label>
|
||||
<textarea
|
||||
value={jsonInput}
|
||||
onChange={(e) => setJsonInput(e.target.value)}
|
||||
className="w-full h-96 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"
|
||||
placeholder={examplePlaceholder}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-2">{t('groupImport.inputHelp')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-4">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 btn-secondary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePreview}
|
||||
disabled={!jsonInput.trim()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('groupImport.preview')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-3">
|
||||
{t('groupImport.previewTitle')}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{previewGroups.map((group, index) => (
|
||||
<div key={index} className="bg-gray-50 p-4 rounded-lg border border-gray-200">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-900">{group.name}</h4>
|
||||
{group.description && (
|
||||
<p className="text-sm text-gray-600 mt-1">{group.description}</p>
|
||||
)}
|
||||
<div className="mt-2 text-sm text-gray-600">
|
||||
<strong>{t('groups.servers')}:</strong>
|
||||
<div className="mt-1">{renderServerList(group.servers)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-4">
|
||||
<button
|
||||
onClick={() => setPreviewGroups(null)}
|
||||
disabled={isImporting}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 disabled:opacity-50 btn-secondary"
|
||||
>
|
||||
{t('common.back')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={isImporting}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center btn-primary"
|
||||
>
|
||||
{isImporting ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin h-4 w-4 mr-2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{t('groupImport.importing')}
|
||||
</>
|
||||
) : (
|
||||
t('groupImport.import')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupImportForm;
|
||||
@@ -14,6 +14,10 @@ interface McpServerConfig {
|
||||
type?: string;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
openapi?: {
|
||||
version: string;
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ImportJsonFormat {
|
||||
@@ -29,29 +33,16 @@ const JSONImportForm: React.FC<JSONImportFormProps> = ({ onSuccess, onCancel })
|
||||
null,
|
||||
);
|
||||
|
||||
const examplePlaceholder = `STDIO example:
|
||||
{
|
||||
const examplePlaceholder = `{
|
||||
"mcpServers": {
|
||||
"stdio-server-example": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "mcp-server-example"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SSE example:
|
||||
{
|
||||
"mcpServers": {
|
||||
},
|
||||
"sse-server-example": {
|
||||
"type": "sse",
|
||||
"url": "http://localhost:3000"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HTTP example:
|
||||
{
|
||||
"mcpServers": {
|
||||
},
|
||||
"http-server-example": {
|
||||
"type": "streamable-http",
|
||||
"url": "http://localhost:3001",
|
||||
@@ -59,9 +50,18 @@ HTTP example:
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer your-token"
|
||||
}
|
||||
},
|
||||
"openapi-server-example": {
|
||||
"type": "openapi",
|
||||
"openapi": {
|
||||
"url": "https://petstore.swagger.io/v2/swagger.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
}
|
||||
|
||||
Supports: STDIO, SSE, HTTP (streamable-http), OpenAPI
|
||||
All servers will be imported in a single efficient batch operation.`;
|
||||
|
||||
const parseAndValidateJson = (input: string): ImportJsonFormat | null => {
|
||||
try {
|
||||
@@ -95,6 +95,9 @@ HTTP example:
|
||||
if (config.headers) {
|
||||
normalizedConfig.headers = config.headers;
|
||||
}
|
||||
} else if (config.type === 'openapi') {
|
||||
normalizedConfig.type = 'openapi';
|
||||
normalizedConfig.openapi = config.openapi;
|
||||
} else {
|
||||
// Default to stdio
|
||||
normalizedConfig.type = 'stdio';
|
||||
@@ -118,38 +121,31 @@ HTTP example:
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let successCount = 0;
|
||||
const errors: string[] = [];
|
||||
// Use batch import API for better performance
|
||||
const result = await apiPost('/servers/batch', {
|
||||
servers: previewServers,
|
||||
});
|
||||
|
||||
for (const server of previewServers) {
|
||||
try {
|
||||
const result = await apiPost('/servers', {
|
||||
name: server.name,
|
||||
config: server.config,
|
||||
});
|
||||
if (result.success && result.data) {
|
||||
const { successCount, failureCount, results } = result.data;
|
||||
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
errors.push(`${server.name}: ${result.message || t('jsonImport.addFailed')}`);
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push(
|
||||
`${server.name}: ${err instanceof Error ? err.message : t('jsonImport.addFailed')}`,
|
||||
if (failureCount > 0) {
|
||||
const errors = results
|
||||
.filter((r: any) => !r.success)
|
||||
.map((r: any) => `${r.name}: ${r.message || t('jsonImport.addFailed')}`);
|
||||
|
||||
setError(
|
||||
t('jsonImport.partialSuccess', { count: successCount, total: previewServers.length }) +
|
||||
'\n' +
|
||||
errors.join('\n'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
setError(
|
||||
t('jsonImport.partialSuccess', { count: successCount, total: previewServers.length }) +
|
||||
'\n' +
|
||||
errors.join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
onSuccess();
|
||||
if (successCount > 0) {
|
||||
onSuccess();
|
||||
}
|
||||
} else {
|
||||
setError(result.message || t('jsonImport.importFailed'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Import error:', err);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -21,7 +21,14 @@ interface DynamicFormProps {
|
||||
title?: string; // Optional title to display instead of default parameters title
|
||||
}
|
||||
|
||||
const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, loading = false, storageKey, title }) => {
|
||||
const DynamicForm: React.FC<DynamicFormProps> = ({
|
||||
schema,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
loading = false,
|
||||
storageKey,
|
||||
title,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [formValues, setFormValues] = useState<Record<string, any>>({});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
@@ -40,9 +47,14 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
description: obj.description,
|
||||
enum: obj.enum,
|
||||
default: obj.default,
|
||||
properties: obj.properties ? Object.fromEntries(
|
||||
Object.entries(obj.properties).map(([key, value]) => [key, convertProperty(value)])
|
||||
) : undefined,
|
||||
properties: obj.properties
|
||||
? Object.fromEntries(
|
||||
Object.entries(obj.properties).map(([key, value]) => [
|
||||
key,
|
||||
convertProperty(value),
|
||||
]),
|
||||
)
|
||||
: undefined,
|
||||
required: obj.required,
|
||||
items: obj.items ? convertProperty(obj.items) : undefined,
|
||||
};
|
||||
@@ -52,9 +64,14 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
|
||||
return {
|
||||
type: schema.type,
|
||||
properties: schema.properties ? Object.fromEntries(
|
||||
Object.entries(schema.properties).map(([key, value]) => [key, convertProperty(value)])
|
||||
) : undefined,
|
||||
properties: schema.properties
|
||||
? Object.fromEntries(
|
||||
Object.entries(schema.properties).map(([key, value]) => [
|
||||
key,
|
||||
convertProperty(value),
|
||||
]),
|
||||
)
|
||||
: undefined,
|
||||
required: schema.required,
|
||||
};
|
||||
};
|
||||
@@ -167,7 +184,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
};
|
||||
|
||||
const handleInputChange = (path: string, value: any) => {
|
||||
setFormValues(prev => {
|
||||
setFormValues((prev) => {
|
||||
const newValues = { ...prev };
|
||||
const keys = path.split('.');
|
||||
let current = newValues;
|
||||
@@ -195,7 +212,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
|
||||
// Clear error for this field
|
||||
if (errors[path]) {
|
||||
setErrors(prev => {
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[path];
|
||||
return newErrors;
|
||||
@@ -209,10 +226,16 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
if (schema.type === 'object' && schema.properties) {
|
||||
Object.entries(schema.properties).forEach(([key, propSchema]) => {
|
||||
const fullPath = path ? `${path}.${key}` : key;
|
||||
const value = getNestedValue(values, fullPath);
|
||||
const value = values?.[key];
|
||||
|
||||
// Check required fields
|
||||
if (schema.required?.includes(key) && (value === undefined || value === null || value === '')) {
|
||||
if (
|
||||
schema.required?.includes(key) &&
|
||||
(value === undefined ||
|
||||
value === null ||
|
||||
value === '' ||
|
||||
(Array.isArray(value) && value.length === 0))
|
||||
) {
|
||||
newErrors[fullPath] = `${key} is required`;
|
||||
return;
|
||||
}
|
||||
@@ -223,7 +246,10 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
newErrors[fullPath] = `${key} must be a string`;
|
||||
} else if (propSchema.type === 'number' && typeof value !== 'number') {
|
||||
newErrors[fullPath] = `${key} must be a number`;
|
||||
} else if (propSchema.type === 'integer' && (!Number.isInteger(value) || typeof value !== 'number')) {
|
||||
} else if (
|
||||
propSchema.type === 'integer' &&
|
||||
(!Number.isInteger(value) || typeof value !== 'number')
|
||||
) {
|
||||
newErrors[fullPath] = `${key} must be an integer`;
|
||||
} else if (propSchema.type === 'boolean' && typeof value !== 'boolean') {
|
||||
newErrors[fullPath] = `${key} must be a boolean`;
|
||||
@@ -260,7 +286,12 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
return path.split('.').reduce((current, key) => current?.[key], obj);
|
||||
};
|
||||
|
||||
const renderObjectField = (key: string, schema: JsonSchema, currentValue: any, onChange: (value: any) => void): React.ReactNode => {
|
||||
const renderObjectField = (
|
||||
key: string,
|
||||
schema: JsonSchema,
|
||||
currentValue: any,
|
||||
onChange: (value: any) => void,
|
||||
): React.ReactNode => {
|
||||
const value = currentValue?.[key];
|
||||
|
||||
if (schema.type === 'string') {
|
||||
@@ -299,7 +330,12 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
step={schema.type === 'integer' ? '1' : 'any'}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value === '' ? '' : schema.type === 'integer' ? parseInt(e.target.value) : parseFloat(e.target.value);
|
||||
const val =
|
||||
e.target.value === ''
|
||||
? ''
|
||||
: schema.type === 'integer'
|
||||
? parseInt(e.target.value)
|
||||
: parseFloat(e.target.value);
|
||||
onChange(val);
|
||||
}}
|
||||
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500 form-input"
|
||||
@@ -333,7 +369,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
const renderField = (key: string, propSchema: JsonSchema, path: string = ''): React.ReactNode => {
|
||||
const fullPath = path ? `${path}.${key}` : key;
|
||||
const value = getNestedValue(formValues, fullPath);
|
||||
const error = errors[fullPath]; // Handle array type
|
||||
const error = errors[fullPath]; // Handle array type
|
||||
if (propSchema.type === 'array') {
|
||||
const arrayValue = getNestedValue(formValues, fullPath) || [];
|
||||
|
||||
@@ -341,7 +377,11 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
<div key={fullPath} className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{key}
|
||||
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
|
||||
{(path
|
||||
? getNestedValue(jsonSchema, path)?.required?.includes(key)
|
||||
: jsonSchema.required?.includes(key)) && (
|
||||
<span className="text-status-red ml-1">*</span>
|
||||
)}
|
||||
</label>
|
||||
{propSchema.description && (
|
||||
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
|
||||
@@ -349,9 +389,11 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
|
||||
<div className="border border-gray-200 rounded-md p-3 bg-gray-50">
|
||||
{arrayValue.map((item: any, index: number) => (
|
||||
<div key={index} className="mb-3 p-3 bg-white border rounded-md">
|
||||
<div key={index} className="mb-3 p-3 bg-white border border-gray-200 rounded-md">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm font-medium text-gray-600">{t('tool.item', { index: index + 1 })}</span>
|
||||
<span className="text-sm font-medium text-gray-600">
|
||||
{t('tool.item', { index: index + 1 })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -388,7 +430,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
<div key={objKey}>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
||||
{objKey}
|
||||
{propSchema.items?.required?.includes(objKey) && <span className="text-status-red ml-1">*</span>}
|
||||
{propSchema.items?.required?.includes(objKey) && (
|
||||
<span className="text-status-red ml-1">*</span>
|
||||
)}
|
||||
</label>
|
||||
{renderObjectField(objKey, objSchema as JsonSchema, item, (newValue) => {
|
||||
const newArray = [...arrayValue];
|
||||
@@ -429,7 +473,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
} // Handle object type
|
||||
} // Handle object type
|
||||
if (propSchema.type === 'object') {
|
||||
if (propSchema.properties) {
|
||||
// Object with defined properties - render as nested form
|
||||
@@ -437,16 +481,20 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
<div key={fullPath} className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{key}
|
||||
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
|
||||
{(path
|
||||
? getNestedValue(jsonSchema, path)?.required?.includes(key)
|
||||
: jsonSchema.required?.includes(key)) && (
|
||||
<span className="text-status-red ml-1">*</span>
|
||||
)}
|
||||
</label>
|
||||
{propSchema.description && (
|
||||
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
|
||||
)}
|
||||
|
||||
<div className="border border-gray-200 rounded-md p-4 bg-gray-50">
|
||||
{Object.entries(propSchema.properties).map(([objKey, objSchema]) => (
|
||||
renderField(objKey, objSchema as JsonSchema, fullPath)
|
||||
))}
|
||||
{Object.entries(propSchema.properties).map(([objKey, objSchema]) =>
|
||||
renderField(objKey, objSchema as JsonSchema, fullPath),
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
|
||||
@@ -458,7 +506,11 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
<div key={fullPath} className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{key}
|
||||
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
|
||||
{(path
|
||||
? getNestedValue(jsonSchema, path)?.required?.includes(key)
|
||||
: jsonSchema.required?.includes(key)) && (
|
||||
<span className="text-status-red ml-1">*</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-500 ml-1">(JSON object)</span>
|
||||
</label>
|
||||
{propSchema.description && (
|
||||
@@ -483,13 +535,16 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} if (propSchema.type === 'string') {
|
||||
}
|
||||
if (propSchema.type === 'string') {
|
||||
if (propSchema.enum) {
|
||||
return (
|
||||
<div key={fullPath} className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{key}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && (
|
||||
<span className="text-status-red ml-1">*</span>
|
||||
)}
|
||||
</label>
|
||||
{propSchema.description && (
|
||||
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
|
||||
@@ -514,7 +569,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
<div key={fullPath} className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{key}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && (
|
||||
<span className="text-status-red ml-1">*</span>
|
||||
)}
|
||||
</label>
|
||||
{propSchema.description && (
|
||||
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
|
||||
@@ -529,12 +586,15 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} if (propSchema.type === 'number' || propSchema.type === 'integer') {
|
||||
}
|
||||
if (propSchema.type === 'number' || propSchema.type === 'integer') {
|
||||
return (
|
||||
<div key={fullPath} className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{key}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && (
|
||||
<span className="text-status-red ml-1">*</span>
|
||||
)}
|
||||
</label>
|
||||
{propSchema.description && (
|
||||
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
|
||||
@@ -544,7 +604,12 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
step={propSchema.type === 'integer' ? '1' : 'any'}
|
||||
value={value !== undefined && value !== null ? value : ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value === '' ? '' : propSchema.type === 'integer' ? parseInt(e.target.value) : parseFloat(e.target.value);
|
||||
const val =
|
||||
e.target.value === ''
|
||||
? ''
|
||||
: propSchema.type === 'integer'
|
||||
? parseInt(e.target.value)
|
||||
: parseFloat(e.target.value);
|
||||
handleInputChange(fullPath, val);
|
||||
}}
|
||||
className={`w-full border rounded-md px-3 py-2 form-input ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
@@ -566,7 +631,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
/>
|
||||
<label className="ml-2 block text-sm text-gray-700">
|
||||
{key}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && (
|
||||
<span className="text-status-red ml-1">*</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
{propSchema.description && (
|
||||
@@ -575,12 +642,14 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
} // For other types, show as text input with description
|
||||
} // For other types, show as text input with description
|
||||
return (
|
||||
<div key={fullPath} className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{key}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && (
|
||||
<span className="text-status-red ml-1">*</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-500 ml-1">({propSchema.type})</span>
|
||||
</label>
|
||||
{propSchema.description && (
|
||||
@@ -631,20 +700,22 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
<button
|
||||
type="button"
|
||||
onClick={switchToFormMode}
|
||||
className={`px-3 py-1 text-sm rounded-md transition-colors ${!isJsonMode
|
||||
? 'bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary'
|
||||
: 'text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary'
|
||||
}`}
|
||||
className={`px-3 py-1 text-sm rounded-md transition-colors ${
|
||||
!isJsonMode
|
||||
? 'bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary'
|
||||
: 'text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary'
|
||||
}`}
|
||||
>
|
||||
{t('tool.formMode')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={switchToJsonMode}
|
||||
className={`px-3 py-1 text-sm rounded-md transition-colors ${isJsonMode
|
||||
? 'px-4 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary'
|
||||
: 'text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary'
|
||||
}`}
|
||||
className={`px-3 py-1 text-sm rounded-md transition-colors ${
|
||||
isJsonMode
|
||||
? 'px-4 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary'
|
||||
: 'text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary'
|
||||
}`}
|
||||
>
|
||||
{t('tool.jsonMode')}
|
||||
</button>
|
||||
@@ -662,8 +733,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
value={jsonText}
|
||||
onChange={(e) => handleJsonTextChange(e.target.value)}
|
||||
placeholder={`{\n "key": "value"\n}`}
|
||||
className={`w-full h-64 border rounded-md px-3 py-2 font-mono text-sm resize-y form-input ${jsonError ? 'border-red-500' : 'border-gray-300'
|
||||
} focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
className={`w-full h-64 border rounded-md px-3 py-2 font-mono text-sm resize-y form-input ${
|
||||
jsonError ? 'border-red-500' : 'border-gray-300'
|
||||
} focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
/>
|
||||
{jsonError && <p className="text-status-red text-xs mt-1">{jsonError}</p>}
|
||||
</div>
|
||||
@@ -696,7 +768,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
/* Form Mode */
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{Object.entries(jsonSchema.properties || {}).map(([key, propSchema]) =>
|
||||
renderField(key, propSchema)
|
||||
renderField(key, propSchema),
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-4">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
export const PERMISSIONS = {
|
||||
// Settings page permissions
|
||||
SETTINGS_SMART_ROUTING: 'settings:smart_routing',
|
||||
SETTINGS_SKIP_AUTH: 'settings:skip_auth',
|
||||
SETTINGS_ROUTE_CONFIG: 'settings:route_config',
|
||||
SETTINGS_INSTALL_CONFIG: 'settings:install_config',
|
||||
SETTINGS_SYSTEM_CONFIG: 'settings:system_config',
|
||||
SETTINGS_OAUTH_SERVER: 'settings:oauth_server',
|
||||
SETTINGS_EXPORT_CONFIG: 'settings:export_config',
|
||||
} as const;
|
||||
|
||||
@@ -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
|
||||
@@ -283,31 +284,29 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
const handleServerEdit = useCallback(
|
||||
async (server: Server) => {
|
||||
try {
|
||||
// Fetch settings to get the full server config before editing
|
||||
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> =
|
||||
await apiGet('/settings');
|
||||
// Fetch single server config instead of all settings
|
||||
const encodedServerName = encodeURIComponent(server.name);
|
||||
const serverData: ApiResponse<{
|
||||
name: string;
|
||||
status: string;
|
||||
tools: any[];
|
||||
config: Record<string, any>;
|
||||
}> = await apiGet(`/servers/${encodedServerName}`);
|
||||
|
||||
if (
|
||||
settingsData &&
|
||||
settingsData.success &&
|
||||
settingsData.data &&
|
||||
settingsData.data.mcpServers &&
|
||||
settingsData.data.mcpServers[server.name]
|
||||
) {
|
||||
const serverConfig = settingsData.data.mcpServers[server.name];
|
||||
if (serverData && serverData.success && serverData.data) {
|
||||
return {
|
||||
name: server.name,
|
||||
status: server.status,
|
||||
tools: server.tools || [],
|
||||
config: serverConfig,
|
||||
name: serverData.data.name,
|
||||
status: serverData.data.status,
|
||||
tools: serverData.data.tools || [],
|
||||
config: serverData.data.config,
|
||||
};
|
||||
} else {
|
||||
console.error('Failed to get server config from settings:', settingsData);
|
||||
console.error('Failed to get server config:', serverData);
|
||||
setError(t('server.invalidConfig', { serverName: server.name }));
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching server settings:', err);
|
||||
console.error('Error fetching server config:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return null;
|
||||
}
|
||||
@@ -360,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,
|
||||
@@ -372,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();
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useServerData } from '@/hooks/useServerData';
|
||||
import AddGroupForm from '@/components/AddGroupForm';
|
||||
import EditGroupForm from '@/components/EditGroupForm';
|
||||
import GroupCard from '@/components/GroupCard';
|
||||
import GroupImportForm from '@/components/GroupImportForm';
|
||||
|
||||
const GroupsPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -15,12 +16,13 @@ const GroupsPage: React.FC = () => {
|
||||
error: groupError,
|
||||
setError: setGroupError,
|
||||
deleteGroup,
|
||||
triggerRefresh
|
||||
triggerRefresh,
|
||||
} = useGroupData();
|
||||
const { servers } = useServerData({ refreshOnMount: true });
|
||||
|
||||
const [editingGroup, setEditingGroup] = useState<Group | null>(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [showImportForm, setShowImportForm] = useState(false);
|
||||
|
||||
const handleEditClick = (group: Group) => {
|
||||
setEditingGroup(group);
|
||||
@@ -47,6 +49,11 @@ const GroupsPage: React.FC = () => {
|
||||
triggerRefresh(); // Refresh the groups list after adding
|
||||
};
|
||||
|
||||
const handleImportSuccess = () => {
|
||||
setShowImportForm(false);
|
||||
triggerRefresh(); // Refresh the groups list after import
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
@@ -56,11 +63,38 @@ const GroupsPage: React.FC = () => {
|
||||
onClick={handleAddGroup}
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 3a1 1 0 00-1 1v5H4a1 1 0 100 2h5v5a1 1 0 102 0v-5h5a1 1 0 100-2h-5V4a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 mr-2"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 3a1 1 0 00-1 1v5H4a1 1 0 100 2h5v5a1 1 0 102 0v-5h5a1 1 0 100-2h-5V4a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{t('groups.add')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowImportForm(true)}
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 mr-2"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{t('groupImport.button')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -73,9 +107,25 @@ const GroupsPage: React.FC = () => {
|
||||
{groupsLoading ? (
|
||||
<div className="bg-white shadow rounded-lg p-6 loading-container">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
<svg
|
||||
className="animate-spin h-10 w-10 text-blue-500 mb-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<p className="text-gray-600">{t('app.loading')}</p>
|
||||
</div>
|
||||
@@ -98,8 +148,13 @@ const GroupsPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAddForm && (
|
||||
<AddGroupForm onAdd={handleAddComplete} onCancel={handleAddComplete} />
|
||||
{showAddForm && <AddGroupForm onAdd={handleAddComplete} onCancel={handleAddComplete} />}
|
||||
|
||||
{showImportForm && (
|
||||
<GroupImportForm
|
||||
onSuccess={handleImportSuccess}
|
||||
onCancel={() => setShowImportForm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingGroup && (
|
||||
@@ -113,4 +168,4 @@ const GroupsPage: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupsPage;
|
||||
export default GroupsPage;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,69 +1,69 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import ChangePasswordForm from '@/components/ChangePasswordForm'
|
||||
import { Switch } from '@/components/ui/ToggleGroup'
|
||||
import { useSettingsData } from '@/hooks/useSettingsData'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
import { generateRandomKey } from '@/utils/key'
|
||||
import { PermissionChecker } from '@/components/PermissionChecker'
|
||||
import { PERMISSIONS } from '@/constants/permissions'
|
||||
import { Copy, Check, Download } from 'lucide-react'
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ChangePasswordForm from '@/components/ChangePasswordForm';
|
||||
import { Switch } from '@/components/ui/ToggleGroup';
|
||||
import { useSettingsData } from '@/hooks/useSettingsData';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import { generateRandomKey } from '@/utils/key';
|
||||
import { PermissionChecker } from '@/components/PermissionChecker';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { Copy, Check, Download } from 'lucide-react';
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [installConfig, setInstallConfig] = useState<{
|
||||
pythonIndexUrl: string
|
||||
npmRegistry: string
|
||||
baseUrl: string
|
||||
pythonIndexUrl: string;
|
||||
npmRegistry: string;
|
||||
baseUrl: string;
|
||||
}>({
|
||||
pythonIndexUrl: '',
|
||||
npmRegistry: '',
|
||||
baseUrl: 'http://localhost:3000',
|
||||
})
|
||||
});
|
||||
|
||||
const [tempSmartRoutingConfig, setTempSmartRoutingConfig] = useState<{
|
||||
dbUrl: string
|
||||
openaiApiBaseUrl: string
|
||||
openaiApiKey: string
|
||||
openaiApiEmbeddingModel: string
|
||||
dbUrl: string;
|
||||
openaiApiBaseUrl: string;
|
||||
openaiApiKey: string;
|
||||
openaiApiEmbeddingModel: string;
|
||||
}>({
|
||||
dbUrl: '',
|
||||
openaiApiBaseUrl: '',
|
||||
openaiApiKey: '',
|
||||
openaiApiEmbeddingModel: '',
|
||||
})
|
||||
});
|
||||
|
||||
const [tempMCPRouterConfig, setTempMCPRouterConfig] = useState<{
|
||||
apiKey: string
|
||||
referer: string
|
||||
title: string
|
||||
baseUrl: string
|
||||
apiKey: string;
|
||||
referer: string;
|
||||
title: string;
|
||||
baseUrl: string;
|
||||
}>({
|
||||
apiKey: '',
|
||||
referer: 'https://www.mcphubx.com',
|
||||
title: 'MCPHub',
|
||||
baseUrl: 'https://api.mcprouter.to/v1',
|
||||
})
|
||||
});
|
||||
|
||||
const [tempOAuthServerConfig, setTempOAuthServerConfig] = useState<{
|
||||
accessTokenLifetime: string
|
||||
refreshTokenLifetime: string
|
||||
authorizationCodeLifetime: string
|
||||
allowedScopes: string
|
||||
dynamicRegistrationAllowedGrantTypes: string
|
||||
accessTokenLifetime: string;
|
||||
refreshTokenLifetime: string;
|
||||
authorizationCodeLifetime: string;
|
||||
allowedScopes: string;
|
||||
dynamicRegistrationAllowedGrantTypes: string;
|
||||
}>({
|
||||
accessTokenLifetime: '3600',
|
||||
refreshTokenLifetime: '1209600',
|
||||
authorizationCodeLifetime: '300',
|
||||
allowedScopes: 'read, write',
|
||||
dynamicRegistrationAllowedGrantTypes: 'authorization_code, refresh_token',
|
||||
})
|
||||
});
|
||||
|
||||
const [tempNameSeparator, setTempNameSeparator] = useState<string>('-')
|
||||
const [tempNameSeparator, setTempNameSeparator] = useState<string>('-');
|
||||
|
||||
const {
|
||||
routingConfig,
|
||||
@@ -86,14 +86,14 @@ const SettingsPage: React.FC = () => {
|
||||
updateNameSeparator,
|
||||
updateSessionRebuild,
|
||||
exportMCPSettings,
|
||||
} = useSettingsData()
|
||||
} = useSettingsData();
|
||||
|
||||
// Update local installConfig when savedInstallConfig changes
|
||||
useEffect(() => {
|
||||
if (savedInstallConfig) {
|
||||
setInstallConfig(savedInstallConfig)
|
||||
setInstallConfig(savedInstallConfig);
|
||||
}
|
||||
}, [savedInstallConfig])
|
||||
}, [savedInstallConfig]);
|
||||
|
||||
// Update local tempSmartRoutingConfig when smartRoutingConfig changes
|
||||
useEffect(() => {
|
||||
@@ -103,9 +103,9 @@ const SettingsPage: React.FC = () => {
|
||||
openaiApiBaseUrl: smartRoutingConfig.openaiApiBaseUrl || '',
|
||||
openaiApiKey: smartRoutingConfig.openaiApiKey || '',
|
||||
openaiApiEmbeddingModel: smartRoutingConfig.openaiApiEmbeddingModel || '',
|
||||
})
|
||||
});
|
||||
}
|
||||
}, [smartRoutingConfig])
|
||||
}, [smartRoutingConfig]);
|
||||
|
||||
// Update local tempMCPRouterConfig when mcpRouterConfig changes
|
||||
useEffect(() => {
|
||||
@@ -115,9 +115,9 @@ const SettingsPage: React.FC = () => {
|
||||
referer: mcpRouterConfig.referer || 'https://www.mcphubx.com',
|
||||
title: mcpRouterConfig.title || 'MCPHub',
|
||||
baseUrl: mcpRouterConfig.baseUrl || 'https://api.mcprouter.to/v1',
|
||||
})
|
||||
});
|
||||
}
|
||||
}, [mcpRouterConfig])
|
||||
}, [mcpRouterConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (oauthServerConfig) {
|
||||
@@ -138,18 +138,18 @@ const SettingsPage: React.FC = () => {
|
||||
oauthServerConfig.allowedScopes && oauthServerConfig.allowedScopes.length > 0
|
||||
? oauthServerConfig.allowedScopes.join(', ')
|
||||
: '',
|
||||
dynamicRegistrationAllowedGrantTypes:
|
||||
oauthServerConfig.dynamicRegistration?.allowedGrantTypes?.length
|
||||
? oauthServerConfig.dynamicRegistration.allowedGrantTypes.join(', ')
|
||||
: '',
|
||||
})
|
||||
dynamicRegistrationAllowedGrantTypes: oauthServerConfig.dynamicRegistration
|
||||
?.allowedGrantTypes?.length
|
||||
? oauthServerConfig.dynamicRegistration.allowedGrantTypes.join(', ')
|
||||
: '',
|
||||
});
|
||||
}
|
||||
}, [oauthServerConfig])
|
||||
}, [oauthServerConfig]);
|
||||
|
||||
// Update local tempNameSeparator when nameSeparator changes
|
||||
useEffect(() => {
|
||||
setTempNameSeparator(nameSeparator)
|
||||
}, [nameSeparator])
|
||||
setTempNameSeparator(nameSeparator);
|
||||
}, [nameSeparator]);
|
||||
|
||||
const [sectionsVisible, setSectionsVisible] = useState({
|
||||
routingConfig: false,
|
||||
@@ -160,7 +160,7 @@ const SettingsPage: React.FC = () => {
|
||||
nameSeparator: false,
|
||||
password: false,
|
||||
exportConfig: false,
|
||||
})
|
||||
});
|
||||
|
||||
const toggleSection = (
|
||||
section:
|
||||
@@ -176,8 +176,8 @@ const SettingsPage: React.FC = () => {
|
||||
setSectionsVisible((prev) => ({
|
||||
...prev,
|
||||
[section]: !prev[section],
|
||||
}))
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRoutingConfigChange = async (
|
||||
key:
|
||||
@@ -191,39 +191,39 @@ const SettingsPage: React.FC = () => {
|
||||
// If enableBearerAuth is turned on and there's no key, generate one first
|
||||
if (key === 'enableBearerAuth' && value === true) {
|
||||
if (!tempRoutingConfig.bearerAuthKey && !routingConfig.bearerAuthKey) {
|
||||
const newKey = generateRandomKey()
|
||||
handleBearerAuthKeyChange(newKey)
|
||||
const newKey = generateRandomKey();
|
||||
handleBearerAuthKeyChange(newKey);
|
||||
|
||||
// Update both enableBearerAuth and bearerAuthKey in a single call
|
||||
const success = await updateRoutingConfigBatch({
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: newKey,
|
||||
})
|
||||
});
|
||||
|
||||
if (success) {
|
||||
// Update tempRoutingConfig to reflect the saved values
|
||||
setTempRoutingConfig((prev) => ({
|
||||
...prev,
|
||||
bearerAuthKey: newKey,
|
||||
}))
|
||||
}));
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await updateRoutingConfig(key, value)
|
||||
}
|
||||
await updateRoutingConfig(key, value);
|
||||
};
|
||||
|
||||
const handleBearerAuthKeyChange = (value: string) => {
|
||||
setTempRoutingConfig((prev) => ({
|
||||
...prev,
|
||||
bearerAuthKey: value,
|
||||
}))
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const saveBearerAuthKey = async () => {
|
||||
await updateRoutingConfig('bearerAuthKey', tempRoutingConfig.bearerAuthKey)
|
||||
}
|
||||
await updateRoutingConfig('bearerAuthKey', tempRoutingConfig.bearerAuthKey);
|
||||
};
|
||||
|
||||
const handleInstallConfigChange = (
|
||||
key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl',
|
||||
@@ -232,12 +232,12 @@ const SettingsPage: React.FC = () => {
|
||||
setInstallConfig({
|
||||
...installConfig,
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const saveInstallConfig = async (key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl') => {
|
||||
await updateInstallConfig(key, installConfig[key])
|
||||
}
|
||||
await updateInstallConfig(key, installConfig[key]);
|
||||
};
|
||||
|
||||
const handleSmartRoutingConfigChange = (
|
||||
key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel',
|
||||
@@ -246,14 +246,14 @@ const SettingsPage: React.FC = () => {
|
||||
setTempSmartRoutingConfig({
|
||||
...tempSmartRoutingConfig,
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const saveSmartRoutingConfig = async (
|
||||
key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel',
|
||||
) => {
|
||||
await updateSmartRoutingConfig(key, tempSmartRoutingConfig[key])
|
||||
}
|
||||
await updateSmartRoutingConfig(key, tempSmartRoutingConfig[key]);
|
||||
};
|
||||
|
||||
const handleMCPRouterConfigChange = (
|
||||
key: 'apiKey' | 'referer' | 'title' | 'baseUrl',
|
||||
@@ -262,24 +262,24 @@ const SettingsPage: React.FC = () => {
|
||||
setTempMCPRouterConfig({
|
||||
...tempMCPRouterConfig,
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const saveMCPRouterConfig = async (key: 'apiKey' | 'referer' | 'title' | 'baseUrl') => {
|
||||
await updateMCPRouterConfig(key, tempMCPRouterConfig[key])
|
||||
}
|
||||
await updateMCPRouterConfig(key, tempMCPRouterConfig[key]);
|
||||
};
|
||||
|
||||
type OAuthServerNumberField =
|
||||
| 'accessTokenLifetime'
|
||||
| 'refreshTokenLifetime'
|
||||
| 'authorizationCodeLifetime'
|
||||
| 'authorizationCodeLifetime';
|
||||
|
||||
const handleOAuthServerNumberChange = (key: OAuthServerNumberField, value: string) => {
|
||||
setTempOAuthServerConfig((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}))
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const handleOAuthServerTextChange = (
|
||||
key: 'allowedScopes' | 'dynamicRegistrationAllowedGrantTypes',
|
||||
@@ -288,52 +288,52 @@ const SettingsPage: React.FC = () => {
|
||||
setTempOAuthServerConfig((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}))
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const saveOAuthServerNumberConfig = async (key: OAuthServerNumberField) => {
|
||||
const rawValue = tempOAuthServerConfig[key]
|
||||
const rawValue = tempOAuthServerConfig[key];
|
||||
if (!rawValue || rawValue.trim() === '') {
|
||||
showToast(t('settings.invalidNumberInput') || 'Please enter a valid number', 'error')
|
||||
return
|
||||
showToast(t('settings.invalidNumberInput') || 'Please enter a valid number', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedValue = Number(rawValue)
|
||||
const parsedValue = Number(rawValue);
|
||||
if (Number.isNaN(parsedValue) || parsedValue < 0) {
|
||||
showToast(t('settings.invalidNumberInput') || 'Please enter a valid number', 'error')
|
||||
return
|
||||
showToast(t('settings.invalidNumberInput') || 'Please enter a valid number', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
await updateOAuthServerConfig(key, parsedValue)
|
||||
}
|
||||
await updateOAuthServerConfig(key, parsedValue);
|
||||
};
|
||||
|
||||
const saveOAuthServerAllowedScopes = async () => {
|
||||
const scopes = tempOAuthServerConfig.allowedScopes
|
||||
.split(',')
|
||||
.map((scope) => scope.trim())
|
||||
.filter((scope) => scope.length > 0)
|
||||
.filter((scope) => scope.length > 0);
|
||||
|
||||
await updateOAuthServerConfig('allowedScopes', scopes)
|
||||
}
|
||||
await updateOAuthServerConfig('allowedScopes', scopes);
|
||||
};
|
||||
|
||||
const saveOAuthServerGrantTypes = async () => {
|
||||
const grantTypes = tempOAuthServerConfig.dynamicRegistrationAllowedGrantTypes
|
||||
.split(',')
|
||||
.map((grant) => grant.trim())
|
||||
.filter((grant) => grant.length > 0)
|
||||
.filter((grant) => grant.length > 0);
|
||||
|
||||
await updateOAuthServerConfig('dynamicRegistration', {
|
||||
...oauthServerConfig.dynamicRegistration,
|
||||
allowedGrantTypes: grantTypes,
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleOAuthServerToggle = async (
|
||||
key: 'enabled' | 'requireClientSecret' | 'requireState',
|
||||
value: boolean,
|
||||
) => {
|
||||
await updateOAuthServerConfig(key, value)
|
||||
}
|
||||
await updateOAuthServerConfig(key, value);
|
||||
};
|
||||
|
||||
const handleDynamicRegistrationToggle = async (
|
||||
updates: Partial<typeof oauthServerConfig.dynamicRegistration>,
|
||||
@@ -341,137 +341,137 @@ const SettingsPage: React.FC = () => {
|
||||
await updateOAuthServerConfig('dynamicRegistration', {
|
||||
...oauthServerConfig.dynamicRegistration,
|
||||
...updates,
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const saveNameSeparator = async () => {
|
||||
await updateNameSeparator(tempNameSeparator)
|
||||
}
|
||||
await updateNameSeparator(tempNameSeparator);
|
||||
};
|
||||
|
||||
const handleSmartRoutingEnabledChange = async (value: boolean) => {
|
||||
// If enabling Smart Routing, validate required fields and save any unsaved changes
|
||||
if (value) {
|
||||
const currentDbUrl = tempSmartRoutingConfig.dbUrl || smartRoutingConfig.dbUrl
|
||||
const currentDbUrl = tempSmartRoutingConfig.dbUrl || smartRoutingConfig.dbUrl;
|
||||
const currentOpenaiApiKey =
|
||||
tempSmartRoutingConfig.openaiApiKey || smartRoutingConfig.openaiApiKey
|
||||
tempSmartRoutingConfig.openaiApiKey || smartRoutingConfig.openaiApiKey;
|
||||
|
||||
if (!currentDbUrl || !currentOpenaiApiKey) {
|
||||
const missingFields = []
|
||||
if (!currentDbUrl) missingFields.push(t('settings.dbUrl'))
|
||||
if (!currentOpenaiApiKey) missingFields.push(t('settings.openaiApiKey'))
|
||||
const missingFields = [];
|
||||
if (!currentDbUrl) missingFields.push(t('settings.dbUrl'));
|
||||
if (!currentOpenaiApiKey) missingFields.push(t('settings.openaiApiKey'));
|
||||
|
||||
showToast(
|
||||
t('settings.smartRoutingValidationError', {
|
||||
fields: missingFields.join(', '),
|
||||
}),
|
||||
)
|
||||
return
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare updates object with unsaved changes and enabled status
|
||||
const updates: any = { enabled: value }
|
||||
const updates: any = { enabled: value };
|
||||
|
||||
// Check for unsaved changes and include them in the batch update
|
||||
if (tempSmartRoutingConfig.dbUrl !== smartRoutingConfig.dbUrl) {
|
||||
updates.dbUrl = tempSmartRoutingConfig.dbUrl
|
||||
updates.dbUrl = tempSmartRoutingConfig.dbUrl;
|
||||
}
|
||||
if (tempSmartRoutingConfig.openaiApiBaseUrl !== smartRoutingConfig.openaiApiBaseUrl) {
|
||||
updates.openaiApiBaseUrl = tempSmartRoutingConfig.openaiApiBaseUrl
|
||||
updates.openaiApiBaseUrl = tempSmartRoutingConfig.openaiApiBaseUrl;
|
||||
}
|
||||
if (tempSmartRoutingConfig.openaiApiKey !== smartRoutingConfig.openaiApiKey) {
|
||||
updates.openaiApiKey = tempSmartRoutingConfig.openaiApiKey
|
||||
updates.openaiApiKey = tempSmartRoutingConfig.openaiApiKey;
|
||||
}
|
||||
if (
|
||||
tempSmartRoutingConfig.openaiApiEmbeddingModel !==
|
||||
smartRoutingConfig.openaiApiEmbeddingModel
|
||||
) {
|
||||
updates.openaiApiEmbeddingModel = tempSmartRoutingConfig.openaiApiEmbeddingModel
|
||||
updates.openaiApiEmbeddingModel = tempSmartRoutingConfig.openaiApiEmbeddingModel;
|
||||
}
|
||||
|
||||
// Save all changes in a single batch update
|
||||
await updateSmartRoutingConfigBatch(updates)
|
||||
await updateSmartRoutingConfigBatch(updates);
|
||||
} else {
|
||||
// If disabling, just update the enabled status
|
||||
await updateSmartRoutingConfig('enabled', value)
|
||||
await updateSmartRoutingConfig('enabled', value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordChangeSuccess = () => {
|
||||
setTimeout(() => {
|
||||
navigate('/')
|
||||
}, 2000)
|
||||
}
|
||||
navigate('/');
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const [copiedConfig, setCopiedConfig] = useState(false)
|
||||
const [mcpSettingsJson, setMcpSettingsJson] = useState<string>('')
|
||||
const [copiedConfig, setCopiedConfig] = useState(false);
|
||||
const [mcpSettingsJson, setMcpSettingsJson] = useState<string>('');
|
||||
|
||||
const fetchMcpSettings = async () => {
|
||||
try {
|
||||
const result = await exportMCPSettings()
|
||||
console.log('Fetched MCP settings:', result)
|
||||
const configJson = JSON.stringify(result.data, null, 2)
|
||||
setMcpSettingsJson(configJson)
|
||||
const result = await exportMCPSettings();
|
||||
console.log('Fetched MCP settings:', result);
|
||||
const configJson = JSON.stringify(result.data, null, 2);
|
||||
setMcpSettingsJson(configJson);
|
||||
} catch (error) {
|
||||
console.error('Error fetching MCP settings:', error)
|
||||
showToast(t('settings.exportError') || 'Failed to fetch settings', 'error')
|
||||
console.error('Error fetching MCP settings:', error);
|
||||
showToast(t('settings.exportError') || 'Failed to fetch settings', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (sectionsVisible.exportConfig && !mcpSettingsJson) {
|
||||
fetchMcpSettings()
|
||||
fetchMcpSettings();
|
||||
}
|
||||
}, [sectionsVisible.exportConfig])
|
||||
}, [sectionsVisible.exportConfig]);
|
||||
|
||||
const handleCopyConfig = async () => {
|
||||
if (!mcpSettingsJson) return
|
||||
if (!mcpSettingsJson) return;
|
||||
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(mcpSettingsJson)
|
||||
setCopiedConfig(true)
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
|
||||
setTimeout(() => setCopiedConfig(false), 2000)
|
||||
await navigator.clipboard.writeText(mcpSettingsJson);
|
||||
setCopiedConfig(true);
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success');
|
||||
setTimeout(() => setCopiedConfig(false), 2000);
|
||||
} else {
|
||||
// Fallback for HTTP or unsupported clipboard API
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = mcpSettingsJson
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-9999px'
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = mcpSettingsJson;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
setCopiedConfig(true)
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
|
||||
setTimeout(() => setCopiedConfig(false), 2000)
|
||||
document.execCommand('copy');
|
||||
setCopiedConfig(true);
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success');
|
||||
setTimeout(() => setCopiedConfig(false), 2000);
|
||||
} catch (err) {
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error')
|
||||
console.error('Copy to clipboard failed:', err)
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error');
|
||||
console.error('Copy to clipboard failed:', err);
|
||||
}
|
||||
document.body.removeChild(textArea)
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error copying configuration:', error)
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error')
|
||||
console.error('Error copying configuration:', error);
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadConfig = () => {
|
||||
if (!mcpSettingsJson) return
|
||||
if (!mcpSettingsJson) return;
|
||||
|
||||
const blob = new Blob([mcpSettingsJson], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'mcp_settings.json'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
showToast(t('settings.exportSuccess') || 'Settings exported successfully', 'success')
|
||||
}
|
||||
const blob = new Blob([mcpSettingsJson], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'mcp_settings.json';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
showToast(t('settings.exportSuccess') || 'Settings exported successfully', 'success');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto">
|
||||
@@ -643,9 +643,7 @@ const SettingsPage: React.FC = () => {
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">
|
||||
{t('settings.requireClientSecret')}
|
||||
</h3>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.requireClientSecret')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.requireClientSecretDescription')}
|
||||
</p>
|
||||
@@ -673,9 +671,7 @@ const SettingsPage: React.FC = () => {
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">
|
||||
{t('settings.accessTokenLifetime')}
|
||||
</h3>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.accessTokenLifetime')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.accessTokenLifetimeDescription')}
|
||||
</p>
|
||||
@@ -764,9 +760,7 @@ const SettingsPage: React.FC = () => {
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.allowedScopes')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.allowedScopesDescription')}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{t('settings.allowedScopesDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
@@ -946,142 +940,154 @@ const SettingsPage: React.FC = () => {
|
||||
</PermissionChecker>
|
||||
|
||||
{/* System Settings */}
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('nameSeparator')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('settings.systemSettings')}</h2>
|
||||
<span className="text-gray-500">{sectionsVisible.nameSeparator ? '▼' : '►'}</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.nameSeparator && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.nameSeparatorLabel')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.nameSeparatorDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempNameSeparator}
|
||||
onChange={(e) => setTempNameSeparator(e.target.value)}
|
||||
placeholder="-"
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading}
|
||||
maxLength={5}
|
||||
/>
|
||||
<button
|
||||
onClick={saveNameSeparator}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableSessionRebuild')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.enableSessionRebuildDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={enableSessionRebuild}
|
||||
onCheckedChange={(checked) => updateSessionRebuild(checked)}
|
||||
/>
|
||||
</div>
|
||||
<PermissionChecker permissions={PERMISSIONS.SETTINGS_SYSTEM_CONFIG}>
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('nameSeparator')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('settings.systemSettings')}</h2>
|
||||
<span className="text-gray-500">{sectionsVisible.nameSeparator ? '▼' : '►'}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Route Configuration Settings */}
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('routingConfig')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('pages.settings.routeConfig')}</h2>
|
||||
<span className="text-gray-500">{sectionsVisible.routingConfig ? '▼' : '►'}</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.routingConfig && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableBearerAuth')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.enableBearerAuthDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableBearerAuth}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableBearerAuth', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{routingConfig.enableBearerAuth && (
|
||||
{sectionsVisible.nameSeparator && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.bearerAuthKey')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.bearerAuthKeyDescription')}</p>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.nameSeparatorLabel')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.nameSeparatorDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempRoutingConfig.bearerAuthKey}
|
||||
onChange={(e) => handleBearerAuthKeyChange(e.target.value)}
|
||||
placeholder={t('settings.bearerAuthKeyPlaceholder')}
|
||||
value={tempNameSeparator}
|
||||
onChange={(e) => setTempNameSeparator(e.target.value)}
|
||||
placeholder="-"
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading || !routingConfig.enableBearerAuth}
|
||||
disabled={loading}
|
||||
maxLength={5}
|
||||
/>
|
||||
<button
|
||||
onClick={saveBearerAuthKey}
|
||||
disabled={loading || !routingConfig.enableBearerAuth}
|
||||
onClick={saveNameSeparator}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableGlobalRoute')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.enableGlobalRouteDescription')}
|
||||
</p>
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">
|
||||
{t('settings.enableSessionRebuild')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.enableSessionRebuildDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={enableSessionRebuild}
|
||||
onCheckedChange={(checked) => updateSessionRebuild(checked)}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableGlobalRoute}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableGlobalRoute', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PermissionChecker>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableGroupNameRoute')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.enableGroupNameRouteDescription')}
|
||||
</p>
|
||||
{/* Route Configuration Settings */}
|
||||
<PermissionChecker permissions={PERMISSIONS.SETTINGS_ROUTE_CONFIG}>
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('routingConfig')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('pages.settings.routeConfig')}</h2>
|
||||
<span className="text-gray-500">{sectionsVisible.routingConfig ? '▼' : '►'}</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.routingConfig && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableBearerAuth')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.enableBearerAuthDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableBearerAuth}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableBearerAuth', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{routingConfig.enableBearerAuth && (
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.bearerAuthKey')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.bearerAuthKeyDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempRoutingConfig.bearerAuthKey}
|
||||
onChange={(e) => handleBearerAuthKeyChange(e.target.value)}
|
||||
placeholder={t('settings.bearerAuthKeyPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading || !routingConfig.enableBearerAuth}
|
||||
/>
|
||||
<button
|
||||
onClick={saveBearerAuthKey}
|
||||
disabled={loading || !routingConfig.enableBearerAuth}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableGlobalRoute')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.enableGlobalRouteDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableGlobalRoute}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableGlobalRoute', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">
|
||||
{t('settings.enableGroupNameRoute')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.enableGroupNameRouteDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableGroupNameRoute}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableGroupNameRoute', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableGroupNameRoute}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableGroupNameRoute', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PermissionChecker permissions={PERMISSIONS.SETTINGS_SKIP_AUTH}>
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.skipAuth')}</h3>
|
||||
@@ -1093,10 +1099,10 @@ const SettingsPage: React.FC = () => {
|
||||
onCheckedChange={(checked) => handleRoutingConfigChange('skipAuth', checked)}
|
||||
/>
|
||||
</div>
|
||||
</PermissionChecker>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PermissionChecker>
|
||||
|
||||
{/* Installation Configuration Settings */}
|
||||
<PermissionChecker permissions={PERMISSIONS.SETTINGS_INSTALL_CONFIG}>
|
||||
@@ -1188,7 +1194,10 @@ const SettingsPage: React.FC = () => {
|
||||
</PermissionChecker>
|
||||
|
||||
{/* Change Password */}
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card" data-section="password">
|
||||
<div
|
||||
className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card"
|
||||
data-section="password"
|
||||
>
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('password')}
|
||||
@@ -1258,7 +1267,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</PermissionChecker>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPage
|
||||
export default SettingsPage;
|
||||
|
||||
@@ -114,6 +114,8 @@ export interface ServerConfig {
|
||||
env?: Record<string, string>;
|
||||
headers?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
enableKeepAlive?: boolean; // Enable keep-alive for this server (requires global enable as well)
|
||||
keepAliveInterval?: number; // Keep-alive ping interval in milliseconds (default: 60000ms)
|
||||
tools?: Record<string, { enabled: boolean; description?: string }>; // Tool-specific configurations with enable/disable state and custom descriptions
|
||||
prompts?: Record<string, { enabled: boolean; description?: string }>; // Prompt-specific configurations with enable/disable state and custom descriptions
|
||||
options?: {
|
||||
@@ -250,6 +252,10 @@ export interface ServerFormData {
|
||||
resetTimeoutOnProgress?: boolean;
|
||||
maxTotalTimeout?: number;
|
||||
};
|
||||
keepAlive?: {
|
||||
enabled?: boolean;
|
||||
interval?: number;
|
||||
};
|
||||
oauth?: {
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
google-site-verification: googled76ca578b6543fbc.html
|
||||
@@ -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",
|
||||
@@ -268,7 +276,7 @@
|
||||
"recentServers": "Recent Servers"
|
||||
},
|
||||
"servers": {
|
||||
"title": "Servers Management"
|
||||
"title": "Server Management"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Group Management"
|
||||
@@ -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",
|
||||
@@ -662,6 +672,22 @@
|
||||
"importFailed": "Failed to import servers",
|
||||
"partialSuccess": "Imported {{count}} of {{total}} servers successfully. Some servers failed:"
|
||||
},
|
||||
"groupImport": {
|
||||
"button": "Import",
|
||||
"title": "Import Groups from JSON",
|
||||
"inputLabel": "Group Configuration JSON",
|
||||
"inputHelp": "Paste your group configuration JSON. Each group can contain a list of servers.",
|
||||
"preview": "Preview",
|
||||
"previewTitle": "Preview Groups to Import",
|
||||
"import": "Import",
|
||||
"importing": "Importing...",
|
||||
"invalidFormat": "Invalid JSON format. The JSON must contain a 'groups' array.",
|
||||
"missingName": "Each group must have a 'name' field.",
|
||||
"parseError": "Failed to parse JSON. Please check the format and try again.",
|
||||
"addFailed": "Failed to add group",
|
||||
"importFailed": "Failed to import groups",
|
||||
"partialSuccess": "Imported {{count}} of {{total}} groups successfully. Some groups failed:"
|
||||
},
|
||||
"users": {
|
||||
"add": "Add User",
|
||||
"addNew": "Add New User",
|
||||
@@ -718,6 +744,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 +814,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",
|
||||
@@ -662,6 +673,22 @@
|
||||
"importFailed": "Échec de l'importation des serveurs",
|
||||
"partialSuccess": "{{count}} serveur(s) sur {{total}} importé(s) avec succès. Certains serveurs ont échoué :"
|
||||
},
|
||||
"groupImport": {
|
||||
"button": "Importer",
|
||||
"title": "Importer des groupes depuis JSON",
|
||||
"inputLabel": "Configuration JSON des groupes",
|
||||
"inputHelp": "Collez votre configuration JSON de groupes. Chaque groupe peut contenir une liste de serveurs.",
|
||||
"preview": "Aperçu",
|
||||
"previewTitle": "Aperçu des groupes à importer",
|
||||
"import": "Importer",
|
||||
"importing": "Importation en cours...",
|
||||
"invalidFormat": "Format JSON invalide. Le JSON doit contenir un tableau 'groups'.",
|
||||
"missingName": "Chaque groupe doit avoir un champ 'name'.",
|
||||
"parseError": "Échec de l'analyse du JSON. Veuillez vérifier le format et réessayer.",
|
||||
"addFailed": "Échec de l'ajout du groupe",
|
||||
"importFailed": "Échec de l'importation des groupes",
|
||||
"partialSuccess": "{{count}} groupe(s) sur {{total}} importé(s) avec succès. Certains groupes ont échoué :"
|
||||
},
|
||||
"users": {
|
||||
"add": "Ajouter un utilisateur",
|
||||
"addNew": "Ajouter un nouvel utilisateur",
|
||||
@@ -787,4 +814,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",
|
||||
@@ -662,6 +673,22 @@
|
||||
"importFailed": "Sunucular içe aktarılamadı",
|
||||
"partialSuccess": "{{total}} sunucudan {{count}} tanesi başarıyla içe aktarıldı. Bazı sunucular başarısız oldu:"
|
||||
},
|
||||
"groupImport": {
|
||||
"button": "İçe Aktar",
|
||||
"title": "JSON'dan Grupları İçe Aktar",
|
||||
"inputLabel": "Grup Yapılandırma JSON",
|
||||
"inputHelp": "Grup yapılandırma JSON'unuzu yapıştırın. Her grup bir sunucu listesi içerebilir.",
|
||||
"preview": "Önizle",
|
||||
"previewTitle": "İçe Aktarılacak Grupları Önizle",
|
||||
"import": "İçe Aktar",
|
||||
"importing": "İçe aktarılıyor...",
|
||||
"invalidFormat": "Geçersiz JSON formatı. JSON bir 'groups' dizisi içermelidir.",
|
||||
"missingName": "Her grubun bir 'name' alanı olmalıdır.",
|
||||
"parseError": "JSON ayrıştırılamadı. Lütfen formatı kontrol edip tekrar deneyin.",
|
||||
"addFailed": "Grup eklenemedi",
|
||||
"importFailed": "Gruplar içe aktarılamadı",
|
||||
"partialSuccess": "{{total}} gruptan {{count}} tanesi başarıyla içe aktarıldı. Bazı gruplar başarısız oldu:"
|
||||
},
|
||||
"users": {
|
||||
"add": "Kullanıcı Ekle",
|
||||
"addNew": "Yeni Kullanıcı Ekle",
|
||||
@@ -787,4 +814,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": "启用全局路由",
|
||||
@@ -664,6 +675,22 @@
|
||||
"importFailed": "导入服务器失败",
|
||||
"partialSuccess": "成功导入 {{count}} / {{total}} 个服务器。部分服务器失败:"
|
||||
},
|
||||
"groupImport": {
|
||||
"button": "导入",
|
||||
"title": "从 JSON 导入分组",
|
||||
"inputLabel": "分组配置 JSON",
|
||||
"inputHelp": "粘贴您的分组配置 JSON。每个分组可以包含一个服务器列表。",
|
||||
"preview": "预览",
|
||||
"previewTitle": "预览要导入的分组",
|
||||
"import": "导入",
|
||||
"importing": "导入中...",
|
||||
"invalidFormat": "无效的 JSON 格式。JSON 必须包含 'groups' 数组。",
|
||||
"missingName": "每个分组必须有 'name' 字段。",
|
||||
"parseError": "解析 JSON 失败。请检查格式后重试。",
|
||||
"addFailed": "添加分组失败",
|
||||
"importFailed": "导入分组失败",
|
||||
"partialSuccess": "成功导入 {{count}} / {{total}} 个分组。部分分组失败:"
|
||||
},
|
||||
"users": {
|
||||
"add": "添加",
|
||||
"addNew": "添加新用户",
|
||||
@@ -789,4 +816,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
@@ -6,11 +6,11 @@ import {
|
||||
SystemConfigDao,
|
||||
UserConfigDao,
|
||||
ServerConfigWithName,
|
||||
UserDaoImpl,
|
||||
ServerDaoImpl,
|
||||
GroupDaoImpl,
|
||||
SystemConfigDaoImpl,
|
||||
UserConfigDaoImpl,
|
||||
getUserDao,
|
||||
getServerDao,
|
||||
getGroupDao,
|
||||
getSystemConfigDao,
|
||||
getUserConfigDao,
|
||||
} from '../dao/index.js';
|
||||
|
||||
/**
|
||||
@@ -252,14 +252,14 @@ export class DaoConfigService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a DaoConfigService with default DAO implementations
|
||||
* Create a DaoConfigService with DAO implementations from factory
|
||||
*/
|
||||
export function createDaoConfigService(): DaoConfigService {
|
||||
return new DaoConfigService(
|
||||
new UserDaoImpl(),
|
||||
new ServerDaoImpl(),
|
||||
new GroupDaoImpl(),
|
||||
new SystemConfigDaoImpl(),
|
||||
new UserConfigDaoImpl(),
|
||||
getUserDao(),
|
||||
getServerDao(),
|
||||
getGroupDao(),
|
||||
getSystemConfigDao(),
|
||||
getUserConfigDao(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export const login = async (req: Request, res: Response): Promise<void> => {
|
||||
|
||||
try {
|
||||
// Find user by username
|
||||
const user = findUserByUsername(username);
|
||||
const user = await findUserByUsername(username);
|
||||
|
||||
if (!user) {
|
||||
res.status(401).json({
|
||||
@@ -192,7 +192,7 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
|
||||
}
|
||||
|
||||
// Find user by username
|
||||
const user = findUserByUsername(username);
|
||||
const user = await findUserByUsername(username);
|
||||
|
||||
if (!user) {
|
||||
res.status(404).json({ success: false, message: 'User not found' });
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { Request, Response } from 'express';
|
||||
import config from '../config/index.js';
|
||||
import { loadSettings, loadOriginalSettings } from '../config/index.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import { getDataService } from '../services/services.js';
|
||||
import { DataService } from '../services/dataService.js';
|
||||
import { IUser } from '../types/index.js';
|
||||
import {
|
||||
getGroupDao,
|
||||
getOAuthClientDao,
|
||||
getOAuthTokenDao,
|
||||
getServerDao,
|
||||
getSystemConfigDao,
|
||||
getUserConfigDao,
|
||||
getUserDao,
|
||||
} from '../dao/DaoFactory.js';
|
||||
|
||||
const dataService: DataService = getDataService();
|
||||
|
||||
@@ -73,17 +82,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 +123,46 @@ 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
|
||||
// Return full settings via DAO layer (supports both file and database modes)
|
||||
const [servers, users, groups, systemConfig, userConfigs, oauthClients, oauthTokens] =
|
||||
await Promise.all([
|
||||
getServerDao().findAll(),
|
||||
getUserDao().findAll(),
|
||||
getGroupDao().findAll(),
|
||||
getSystemConfigDao().get(),
|
||||
getUserConfigDao().getAll(),
|
||||
getOAuthClientDao().findAll(),
|
||||
getOAuthTokenDao().findAll(),
|
||||
]);
|
||||
|
||||
const mcpServers: Record<string, any> = {};
|
||||
for (const { name: serverConfigName, ...config } of servers) {
|
||||
mcpServers[serverConfigName] = removeNullValues(config);
|
||||
}
|
||||
|
||||
const settings = {
|
||||
mcpServers,
|
||||
users,
|
||||
groups,
|
||||
systemConfig,
|
||||
userConfigs,
|
||||
oauthClients,
|
||||
oauthTokens,
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: settings,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
import {
|
||||
ApiResponse,
|
||||
AddGroupRequest,
|
||||
BatchCreateGroupsRequest,
|
||||
BatchCreateGroupsResponse,
|
||||
BatchGroupResult,
|
||||
} from '../types/index.js';
|
||||
import {
|
||||
getAllGroups,
|
||||
getGroupByIdOrName,
|
||||
@@ -15,9 +21,9 @@ import {
|
||||
} from '../services/groupService.js';
|
||||
|
||||
// Get all groups
|
||||
export const getGroups = (_: Request, res: Response): void => {
|
||||
export const getGroups = async (_: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const groups = getAllGroups();
|
||||
const groups = await getAllGroups();
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: groups,
|
||||
@@ -32,7 +38,7 @@ export const getGroups = (_: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Get a specific group by ID
|
||||
export const getGroup = (req: Request, res: Response): void => {
|
||||
export const getGroup = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
if (!id) {
|
||||
@@ -43,7 +49,7 @@ export const getGroup = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const group = getGroupByIdOrName(id);
|
||||
const group = await getGroupByIdOrName(id);
|
||||
if (!group) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -66,7 +72,7 @@ export const getGroup = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Create a new group
|
||||
export const createNewGroup = (req: Request, res: Response): void => {
|
||||
export const createNewGroup = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { name, description, servers } = req.body;
|
||||
if (!name) {
|
||||
@@ -83,7 +89,7 @@ export const createNewGroup = (req: Request, res: Response): void => {
|
||||
const currentUser = (req as any).user;
|
||||
const owner = currentUser?.username || 'admin';
|
||||
|
||||
const newGroup = createGroup(name, description, serverList, owner);
|
||||
const newGroup = await createGroup(name, description, serverList, owner);
|
||||
if (!newGroup) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -106,8 +112,145 @@ export const createNewGroup = (req: Request, res: Response): void => {
|
||||
}
|
||||
};
|
||||
|
||||
// Batch create groups - validates and creates multiple groups in one request
|
||||
export const batchCreateGroups = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { groups } = req.body as BatchCreateGroupsRequest;
|
||||
|
||||
// Validate request body
|
||||
if (!groups || !Array.isArray(groups)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Request body must contain a "groups" array',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (groups.length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Groups array cannot be empty',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Helper function to validate a single group configuration
|
||||
const validateGroupConfig = (group: AddGroupRequest): { valid: boolean; message?: string } => {
|
||||
if (!group.name || typeof group.name !== 'string') {
|
||||
return { valid: false, message: 'Group name is required and must be a string' };
|
||||
}
|
||||
|
||||
if (group.description !== undefined && typeof group.description !== 'string') {
|
||||
return { valid: false, message: 'Group description must be a string' };
|
||||
}
|
||||
|
||||
if (group.servers !== undefined && !Array.isArray(group.servers)) {
|
||||
return { valid: false, message: 'Group servers must be an array' };
|
||||
}
|
||||
|
||||
// Validate server configurations if provided in new format
|
||||
if (group.servers) {
|
||||
for (const server of group.servers) {
|
||||
if (typeof server === 'object' && server !== null) {
|
||||
if (!server.name || typeof server.name !== 'string') {
|
||||
return {
|
||||
valid: false,
|
||||
message: 'Server configuration must have a name property',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
server.tools !== undefined &&
|
||||
server.tools !== 'all' &&
|
||||
!Array.isArray(server.tools)
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
message: 'Server tools must be "all" or an array of tool names',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
// Process each group
|
||||
const results: BatchGroupResult[] = [];
|
||||
let successCount = 0;
|
||||
let failureCount = 0;
|
||||
|
||||
// Get current user for owner field
|
||||
const currentUser = (req as any).user;
|
||||
const defaultOwner = currentUser?.username || 'admin';
|
||||
|
||||
for (const groupData of groups) {
|
||||
const { name, description, servers } = groupData;
|
||||
|
||||
// Validate group configuration
|
||||
const validation = validateGroupConfig(groupData);
|
||||
if (!validation.valid) {
|
||||
results.push({
|
||||
name: name || 'unknown',
|
||||
success: false,
|
||||
message: validation.message,
|
||||
});
|
||||
failureCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const serverList = Array.isArray(servers) ? servers : [];
|
||||
const newGroup = await createGroup(name, description, serverList, defaultOwner);
|
||||
|
||||
if (newGroup) {
|
||||
results.push({
|
||||
name,
|
||||
success: true,
|
||||
message: 'Group created successfully',
|
||||
});
|
||||
successCount++;
|
||||
} else {
|
||||
results.push({
|
||||
name,
|
||||
success: false,
|
||||
message: 'Failed to create group or group name already exists',
|
||||
});
|
||||
failureCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
results.push({
|
||||
name,
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to create group',
|
||||
});
|
||||
failureCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Return response
|
||||
const response: BatchCreateGroupsResponse = {
|
||||
success: successCount > 0,
|
||||
successCount,
|
||||
failureCount,
|
||||
results,
|
||||
};
|
||||
|
||||
// Use 207 Multi-Status if there were partial failures, 200 if all succeeded
|
||||
const statusCode = failureCount > 0 && successCount > 0 ? 207 : successCount > 0 ? 200 : 400;
|
||||
res.status(statusCode).json(response);
|
||||
} catch (error) {
|
||||
console.error('Batch create groups error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Internal server error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Update an existing group
|
||||
export const updateExistingGroup = (req: Request, res: Response): void => {
|
||||
export const updateExistingGroup = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, description, servers } = req.body;
|
||||
@@ -133,7 +276,7 @@ export const updateExistingGroup = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedGroup = updateGroup(id, updateData);
|
||||
const updatedGroup = await updateGroup(id, updateData);
|
||||
if (!updatedGroup) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -157,7 +300,7 @@ export const updateExistingGroup = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Update servers in a group (batch update) - supports both string[] and server config format
|
||||
export const updateGroupServersBatch = (req: Request, res: Response): void => {
|
||||
export const updateGroupServersBatch = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { servers } = req.body;
|
||||
@@ -203,7 +346,7 @@ export const updateGroupServersBatch = (req: Request, res: Response): void => {
|
||||
}
|
||||
}
|
||||
|
||||
const updatedGroup = updateGroupServers(id, servers);
|
||||
const updatedGroup = await updateGroupServers(id, servers);
|
||||
if (!updatedGroup) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -227,7 +370,7 @@ export const updateGroupServersBatch = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Delete a group
|
||||
export const deleteExistingGroup = (req: Request, res: Response): void => {
|
||||
export const deleteExistingGroup = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
if (!id) {
|
||||
@@ -238,7 +381,7 @@ export const deleteExistingGroup = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const success = deleteGroup(id);
|
||||
const success = await deleteGroup(id);
|
||||
if (!success) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -260,7 +403,7 @@ export const deleteExistingGroup = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Add server to a group
|
||||
export const addServerToExistingGroup = (req: Request, res: Response): void => {
|
||||
export const addServerToExistingGroup = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { serverName } = req.body;
|
||||
@@ -280,7 +423,7 @@ export const addServerToExistingGroup = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedGroup = addServerToGroup(id, serverName);
|
||||
const updatedGroup = await addServerToGroup(id, serverName);
|
||||
if (!updatedGroup) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -304,7 +447,7 @@ export const addServerToExistingGroup = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Remove server from a group
|
||||
export const removeServerFromExistingGroup = (req: Request, res: Response): void => {
|
||||
export const removeServerFromExistingGroup = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id, serverName } = req.params;
|
||||
if (!id || !serverName) {
|
||||
@@ -315,7 +458,7 @@ export const removeServerFromExistingGroup = (req: Request, res: Response): void
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedGroup = removeServerFromGroup(id, serverName);
|
||||
const updatedGroup = await removeServerFromGroup(id, serverName);
|
||||
if (!updatedGroup) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -339,7 +482,7 @@ export const removeServerFromExistingGroup = (req: Request, res: Response): void
|
||||
};
|
||||
|
||||
// Get servers in a group
|
||||
export const getGroupServers = (req: Request, res: Response): void => {
|
||||
export const getGroupServers = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
if (!id) {
|
||||
@@ -350,7 +493,7 @@ export const getGroupServers = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const group = getGroupByIdOrName(id);
|
||||
const group = await getGroupByIdOrName(id);
|
||||
if (!group) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -373,7 +516,7 @@ export const getGroupServers = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Get server configurations in a group (including tool selections)
|
||||
export const getGroupServerConfigs = (req: Request, res: Response): void => {
|
||||
export const getGroupServerConfigs = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
if (!id) {
|
||||
@@ -384,7 +527,7 @@ export const getGroupServerConfigs = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const serverConfigs = getServerConfigsInGroup(id);
|
||||
const serverConfigs = await getServerConfigsInGroup(id);
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: serverConfigs,
|
||||
@@ -399,7 +542,7 @@ export const getGroupServerConfigs = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Get specific server configuration in a group
|
||||
export const getGroupServerConfig = (req: Request, res: Response): void => {
|
||||
export const getGroupServerConfig = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id, serverName } = req.params;
|
||||
if (!id || !serverName) {
|
||||
@@ -410,7 +553,7 @@ export const getGroupServerConfig = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const serverConfig = getServerConfigInGroup(id, serverName);
|
||||
const serverConfig = await getServerConfigInGroup(id, serverName);
|
||||
if (!serverConfig) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -433,7 +576,7 @@ export const getGroupServerConfig = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Update tools for a specific server in a group
|
||||
export const updateGroupServerTools = (req: Request, res: Response): void => {
|
||||
export const updateGroupServerTools = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id, serverName } = req.params;
|
||||
const { tools } = req.body;
|
||||
@@ -458,7 +601,7 @@ export const updateGroupServerTools = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedGroup = updateServerToolsInGroup(id, serverName, tools);
|
||||
const updatedGroup = await updateServerToolsInGroup(id, serverName, tools);
|
||||
if (!updatedGroup) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -208,7 +208,7 @@ export const getGroupOpenAPISpec = async (req: Request, res: Response): Promise<
|
||||
const { name } = req.params;
|
||||
|
||||
// Check if group exists
|
||||
const group = getGroupByIdOrName(name);
|
||||
const group = await getGroupByIdOrName(name);
|
||||
if (!group) {
|
||||
getServerOpenAPISpec(req, res);
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ApiResponse, AddServerRequest } from '../types/index.js';
|
||||
import {
|
||||
ApiResponse,
|
||||
AddServerRequest,
|
||||
McpSettings,
|
||||
BatchCreateServersRequest,
|
||||
BatchCreateServersResponse,
|
||||
BatchServerResult,
|
||||
ServerConfig,
|
||||
} from '../types/index.js';
|
||||
import {
|
||||
getServersInfo,
|
||||
addServer,
|
||||
@@ -8,11 +16,13 @@ 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';
|
||||
import { getServerDao, getGroupDao, getSystemConfigDao } from '../dao/DaoFactory.js';
|
||||
|
||||
export const getAllServers = async (_: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
@@ -31,15 +41,45 @@ export const getAllServers = async (_: Request, res: Response): Promise<void> =>
|
||||
}
|
||||
};
|
||||
|
||||
export const getAllSettings = (_: Request, res: Response): void => {
|
||||
export const getAllSettings = async (_: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
// Get base settings from file (for OAuth clients, tokens, users, etc.)
|
||||
const fileSettings = loadSettings();
|
||||
|
||||
// Get servers from DAO (supports both file and database modes)
|
||||
const serverDao = getServerDao();
|
||||
const servers = await serverDao.findAll();
|
||||
|
||||
// Convert servers array to mcpServers map format
|
||||
const mcpServers: McpSettings['mcpServers'] = {};
|
||||
for (const server of servers) {
|
||||
const { name, ...config } = server;
|
||||
mcpServers[name] = config;
|
||||
}
|
||||
|
||||
// Get groups from DAO
|
||||
const groupDao = getGroupDao();
|
||||
const groups = await groupDao.findAll();
|
||||
|
||||
// Get system config from DAO
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
|
||||
// Merge all data into settings object
|
||||
const settings: McpSettings = {
|
||||
...fileSettings,
|
||||
mcpServers,
|
||||
groups,
|
||||
systemConfig,
|
||||
};
|
||||
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: createSafeJSON(settings),
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
console.error('Failed to get server settings:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get server settings',
|
||||
@@ -157,6 +197,177 @@ export const createServer = async (req: Request, res: Response): Promise<void> =
|
||||
}
|
||||
};
|
||||
|
||||
// Batch create servers - validates and creates multiple servers in one request
|
||||
export const batchCreateServers = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { servers } = req.body as BatchCreateServersRequest;
|
||||
|
||||
// Validate request body
|
||||
if (!servers || !Array.isArray(servers)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Request body must contain a "servers" array',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (servers.length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Servers array cannot be empty',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Helper function to validate a single server configuration
|
||||
const validateServerConfig = (
|
||||
name: string,
|
||||
config: ServerConfig,
|
||||
): { valid: boolean; message?: string } => {
|
||||
if (!name || typeof name !== 'string') {
|
||||
return { valid: false, message: 'Server name is required and must be a string' };
|
||||
}
|
||||
|
||||
if (!config || typeof config !== 'object') {
|
||||
return { valid: false, message: 'Server configuration is required and must be an object' };
|
||||
}
|
||||
|
||||
if (
|
||||
!config.url &&
|
||||
!config.openapi?.url &&
|
||||
!config.openapi?.schema &&
|
||||
(!config.command || !config.args)
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
message:
|
||||
'Server configuration must include either a URL, OpenAPI specification URL or schema, or command with arguments',
|
||||
};
|
||||
}
|
||||
|
||||
// Validate server type if specified
|
||||
if (config.type && !['stdio', 'sse', 'streamable-http', 'openapi'].includes(config.type)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: 'Server type must be one of: stdio, sse, streamable-http, openapi',
|
||||
};
|
||||
}
|
||||
|
||||
// Validate URL is provided for sse and streamable-http types
|
||||
if ((config.type === 'sse' || config.type === 'streamable-http') && !config.url) {
|
||||
return { valid: false, message: `URL is required for ${config.type} server type` };
|
||||
}
|
||||
|
||||
// Validate OpenAPI specification URL or schema is provided for openapi type
|
||||
if (config.type === 'openapi' && !config.openapi?.url && !config.openapi?.schema) {
|
||||
return {
|
||||
valid: false,
|
||||
message: 'OpenAPI specification URL or schema is required for openapi server type',
|
||||
};
|
||||
}
|
||||
|
||||
// Validate headers if provided
|
||||
if (config.headers && typeof config.headers !== 'object') {
|
||||
return { valid: false, message: 'Headers must be an object' };
|
||||
}
|
||||
|
||||
// Validate that headers are only used with sse, streamable-http, and openapi types
|
||||
if (config.headers && config.type === 'stdio') {
|
||||
return { valid: false, message: 'Headers are not supported for stdio server type' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
// Process each server
|
||||
const results: BatchServerResult[] = [];
|
||||
let successCount = 0;
|
||||
let failureCount = 0;
|
||||
|
||||
// Get current user for owner field
|
||||
const currentUser = (req as any).user;
|
||||
const defaultOwner = currentUser?.username || 'admin';
|
||||
|
||||
for (const server of servers) {
|
||||
const { name, config } = server;
|
||||
|
||||
// Validate server configuration
|
||||
const validation = validateServerConfig(name, config);
|
||||
if (!validation.valid) {
|
||||
results.push({
|
||||
name: name || 'unknown',
|
||||
success: false,
|
||||
message: validation.message,
|
||||
});
|
||||
failureCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// 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
|
||||
if (!config.owner) {
|
||||
config.owner = defaultOwner;
|
||||
}
|
||||
|
||||
// Attempt to add server
|
||||
const result = await addServer(name, config);
|
||||
if (result.success) {
|
||||
results.push({
|
||||
name,
|
||||
success: true,
|
||||
});
|
||||
successCount++;
|
||||
} else {
|
||||
results.push({
|
||||
name,
|
||||
success: false,
|
||||
message: result.message || 'Failed to add server',
|
||||
});
|
||||
failureCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
results.push({
|
||||
name,
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Internal server error',
|
||||
});
|
||||
failureCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Notify tool changes if any server was added successfully
|
||||
if (successCount > 0) {
|
||||
notifyToolChanged();
|
||||
}
|
||||
|
||||
// Prepare response
|
||||
const response: ApiResponse<BatchCreateServersResponse> = {
|
||||
success: successCount > 0, // Success if at least one server was created
|
||||
data: {
|
||||
success: successCount > 0,
|
||||
successCount,
|
||||
failureCount,
|
||||
results,
|
||||
},
|
||||
};
|
||||
|
||||
// Return 207 Multi-Status if there were partial failures, 200 if all succeeded, 400 if all failed
|
||||
const statusCode = failureCount === 0 ? 200 : successCount === 0 ? 400 : 207;
|
||||
res.status(statusCode).json(response);
|
||||
} catch (error) {
|
||||
console.error('Batch create servers error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Internal server error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteServer = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
@@ -303,9 +514,12 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
|
||||
export const getServerConfig = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
const allServers = await getServersInfo();
|
||||
const serverInfo = allServers.find((s) => s.name === name);
|
||||
if (!serverInfo) {
|
||||
|
||||
// Get server configuration from DAO (supports both file and database modes)
|
||||
const serverDao = getServerDao();
|
||||
const serverConfig = await serverDao.findById(name);
|
||||
|
||||
if (!serverConfig) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Server not found',
|
||||
@@ -313,18 +527,26 @@ export const getServerConfig = async (req: Request, res: Response): Promise<void
|
||||
return;
|
||||
}
|
||||
|
||||
// Get runtime info (status, tools) from getServersInfo
|
||||
const allServers = await getServersInfo();
|
||||
const serverInfo = allServers.find((s) => s.name === name);
|
||||
|
||||
// Extract config without the name field
|
||||
const { name: serverName, ...config } = serverConfig;
|
||||
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: {
|
||||
name,
|
||||
status: serverInfo ? serverInfo.status : 'disconnected',
|
||||
tools: serverInfo ? serverInfo.tools : [],
|
||||
config: serverInfo,
|
||||
name: serverName,
|
||||
status: serverInfo?.status || 'disconnected',
|
||||
tools: serverInfo?.tools || [],
|
||||
config,
|
||||
},
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
console.error('Failed to get server configuration:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get server configuration',
|
||||
@@ -373,6 +595,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 {
|
||||
@@ -397,8 +645,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',
|
||||
@@ -407,14 +657,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',
|
||||
@@ -461,8 +712,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',
|
||||
@@ -471,18 +724,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',
|
||||
@@ -507,10 +760,17 @@ export const updateToolDescription = async (req: Request, res: Response): Promis
|
||||
}
|
||||
};
|
||||
|
||||
export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
export const updateSystemConfig = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { routing, install, smartRouting, mcpRouter, nameSeparator, enableSessionRebuild, oauthServer } = req.body;
|
||||
const currentUser = (req as any).user;
|
||||
const {
|
||||
routing,
|
||||
install,
|
||||
smartRouting,
|
||||
mcpRouter,
|
||||
nameSeparator,
|
||||
enableSessionRebuild,
|
||||
oauthServer,
|
||||
} = req.body;
|
||||
|
||||
const hasRoutingUpdate =
|
||||
routing &&
|
||||
@@ -542,8 +802,8 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
typeof mcpRouter.baseUrl === 'string');
|
||||
|
||||
const hasNameSeparatorUpdate = typeof nameSeparator === 'string';
|
||||
|
||||
const hasSessionRebuildUpdate = typeof enableSessionRebuild !== 'boolean';
|
||||
|
||||
const hasSessionRebuildUpdate = typeof enableSessionRebuild === 'boolean';
|
||||
|
||||
const hasOAuthServerUpdate =
|
||||
oauthServer &&
|
||||
@@ -575,9 +835,12 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = loadSettings();
|
||||
if (!settings.systemConfig) {
|
||||
settings.systemConfig = {
|
||||
// Get system config from DAO (supports both file and database modes)
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
let systemConfig = await systemConfigDao.get();
|
||||
|
||||
if (!systemConfig) {
|
||||
systemConfig = {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
@@ -607,8 +870,8 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
};
|
||||
}
|
||||
|
||||
if (!settings.systemConfig.routing) {
|
||||
settings.systemConfig.routing = {
|
||||
if (!systemConfig.routing) {
|
||||
systemConfig.routing = {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
@@ -617,16 +880,16 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
};
|
||||
}
|
||||
|
||||
if (!settings.systemConfig.install) {
|
||||
settings.systemConfig.install = {
|
||||
if (!systemConfig.install) {
|
||||
systemConfig.install = {
|
||||
pythonIndexUrl: '',
|
||||
npmRegistry: '',
|
||||
baseUrl: 'http://localhost:3000',
|
||||
};
|
||||
}
|
||||
|
||||
if (!settings.systemConfig.smartRouting) {
|
||||
settings.systemConfig.smartRouting = {
|
||||
if (!systemConfig.smartRouting) {
|
||||
systemConfig.smartRouting = {
|
||||
enabled: false,
|
||||
dbUrl: '',
|
||||
openaiApiBaseUrl: '',
|
||||
@@ -635,8 +898,8 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
};
|
||||
}
|
||||
|
||||
if (!settings.systemConfig.mcpRouter) {
|
||||
settings.systemConfig.mcpRouter = {
|
||||
if (!systemConfig.mcpRouter) {
|
||||
systemConfig.mcpRouter = {
|
||||
apiKey: '',
|
||||
referer: 'https://www.mcphubx.com',
|
||||
title: 'MCPHub',
|
||||
@@ -644,18 +907,18 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
};
|
||||
}
|
||||
|
||||
if (!settings.systemConfig.oauthServer) {
|
||||
settings.systemConfig.oauthServer = cloneDefaultOAuthServerConfig();
|
||||
if (!systemConfig.oauthServer) {
|
||||
systemConfig.oauthServer = cloneDefaultOAuthServerConfig();
|
||||
}
|
||||
|
||||
if (!settings.systemConfig.oauthServer.dynamicRegistration) {
|
||||
if (!systemConfig.oauthServer.dynamicRegistration) {
|
||||
const defaultConfig = cloneDefaultOAuthServerConfig();
|
||||
const defaultDynamic = defaultConfig.dynamicRegistration ?? {
|
||||
enabled: false,
|
||||
allowedGrantTypes: [],
|
||||
requiresAuthentication: false,
|
||||
};
|
||||
settings.systemConfig.oauthServer.dynamicRegistration = {
|
||||
systemConfig.oauthServer.dynamicRegistration = {
|
||||
enabled: defaultDynamic.enabled ?? false,
|
||||
allowedGrantTypes: [
|
||||
...(Array.isArray(defaultDynamic.allowedGrantTypes)
|
||||
@@ -668,50 +931,50 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
|
||||
if (routing) {
|
||||
if (typeof routing.enableGlobalRoute === 'boolean') {
|
||||
settings.systemConfig.routing.enableGlobalRoute = routing.enableGlobalRoute;
|
||||
systemConfig.routing.enableGlobalRoute = routing.enableGlobalRoute;
|
||||
}
|
||||
|
||||
if (typeof routing.enableGroupNameRoute === 'boolean') {
|
||||
settings.systemConfig.routing.enableGroupNameRoute = routing.enableGroupNameRoute;
|
||||
systemConfig.routing.enableGroupNameRoute = routing.enableGroupNameRoute;
|
||||
}
|
||||
|
||||
if (typeof routing.enableBearerAuth === 'boolean') {
|
||||
settings.systemConfig.routing.enableBearerAuth = routing.enableBearerAuth;
|
||||
systemConfig.routing.enableBearerAuth = routing.enableBearerAuth;
|
||||
}
|
||||
|
||||
if (typeof routing.bearerAuthKey === 'string') {
|
||||
settings.systemConfig.routing.bearerAuthKey = routing.bearerAuthKey;
|
||||
systemConfig.routing.bearerAuthKey = routing.bearerAuthKey;
|
||||
}
|
||||
|
||||
if (typeof routing.skipAuth === 'boolean') {
|
||||
settings.systemConfig.routing.skipAuth = routing.skipAuth;
|
||||
systemConfig.routing.skipAuth = routing.skipAuth;
|
||||
}
|
||||
}
|
||||
|
||||
if (install) {
|
||||
if (typeof install.pythonIndexUrl === 'string') {
|
||||
settings.systemConfig.install.pythonIndexUrl = install.pythonIndexUrl;
|
||||
systemConfig.install.pythonIndexUrl = install.pythonIndexUrl;
|
||||
}
|
||||
if (typeof install.npmRegistry === 'string') {
|
||||
settings.systemConfig.install.npmRegistry = install.npmRegistry;
|
||||
systemConfig.install.npmRegistry = install.npmRegistry;
|
||||
}
|
||||
if (typeof install.baseUrl === 'string') {
|
||||
settings.systemConfig.install.baseUrl = install.baseUrl;
|
||||
systemConfig.install.baseUrl = install.baseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// Track smartRouting state and configuration changes
|
||||
const wasSmartRoutingEnabled = settings.systemConfig.smartRouting.enabled || false;
|
||||
const previousSmartRoutingConfig = { ...settings.systemConfig.smartRouting };
|
||||
const wasSmartRoutingEnabled = systemConfig.smartRouting.enabled || false;
|
||||
const previousSmartRoutingConfig = { ...systemConfig.smartRouting };
|
||||
let needsSync = false;
|
||||
|
||||
if (smartRouting) {
|
||||
if (typeof smartRouting.enabled === 'boolean') {
|
||||
// If enabling Smart Routing, validate required fields
|
||||
if (smartRouting.enabled) {
|
||||
const currentDbUrl = smartRouting.dbUrl || settings.systemConfig.smartRouting.dbUrl;
|
||||
const currentDbUrl = smartRouting.dbUrl || systemConfig.smartRouting.dbUrl;
|
||||
const currentOpenaiApiKey =
|
||||
smartRouting.openaiApiKey || settings.systemConfig.smartRouting.openaiApiKey;
|
||||
smartRouting.openaiApiKey || systemConfig.smartRouting.openaiApiKey;
|
||||
|
||||
if (!currentDbUrl || !currentOpenaiApiKey) {
|
||||
const missingFields = [];
|
||||
@@ -725,32 +988,30 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
}
|
||||
settings.systemConfig.smartRouting.enabled = smartRouting.enabled;
|
||||
systemConfig.smartRouting.enabled = smartRouting.enabled;
|
||||
}
|
||||
if (typeof smartRouting.dbUrl === 'string') {
|
||||
settings.systemConfig.smartRouting.dbUrl = smartRouting.dbUrl;
|
||||
systemConfig.smartRouting.dbUrl = smartRouting.dbUrl;
|
||||
}
|
||||
if (typeof smartRouting.openaiApiBaseUrl === 'string') {
|
||||
settings.systemConfig.smartRouting.openaiApiBaseUrl = smartRouting.openaiApiBaseUrl;
|
||||
systemConfig.smartRouting.openaiApiBaseUrl = smartRouting.openaiApiBaseUrl;
|
||||
}
|
||||
if (typeof smartRouting.openaiApiKey === 'string') {
|
||||
settings.systemConfig.smartRouting.openaiApiKey = smartRouting.openaiApiKey;
|
||||
systemConfig.smartRouting.openaiApiKey = smartRouting.openaiApiKey;
|
||||
}
|
||||
if (typeof smartRouting.openaiApiEmbeddingModel === 'string') {
|
||||
settings.systemConfig.smartRouting.openaiApiEmbeddingModel =
|
||||
smartRouting.openaiApiEmbeddingModel;
|
||||
systemConfig.smartRouting.openaiApiEmbeddingModel = smartRouting.openaiApiEmbeddingModel;
|
||||
}
|
||||
|
||||
// Check if we need to sync embeddings
|
||||
const isNowEnabled = settings.systemConfig.smartRouting.enabled || false;
|
||||
const isNowEnabled = systemConfig.smartRouting.enabled || false;
|
||||
const hasConfigChanged =
|
||||
previousSmartRoutingConfig.dbUrl !== settings.systemConfig.smartRouting.dbUrl ||
|
||||
previousSmartRoutingConfig.dbUrl !== systemConfig.smartRouting.dbUrl ||
|
||||
previousSmartRoutingConfig.openaiApiBaseUrl !==
|
||||
settings.systemConfig.smartRouting.openaiApiBaseUrl ||
|
||||
previousSmartRoutingConfig.openaiApiKey !==
|
||||
settings.systemConfig.smartRouting.openaiApiKey ||
|
||||
systemConfig.smartRouting.openaiApiBaseUrl ||
|
||||
previousSmartRoutingConfig.openaiApiKey !== systemConfig.smartRouting.openaiApiKey ||
|
||||
previousSmartRoutingConfig.openaiApiEmbeddingModel !==
|
||||
settings.systemConfig.smartRouting.openaiApiEmbeddingModel;
|
||||
systemConfig.smartRouting.openaiApiEmbeddingModel;
|
||||
|
||||
// Sync if: first time enabling OR smart routing is enabled and any config changed
|
||||
needsSync = (!wasSmartRoutingEnabled && isNowEnabled) || (isNowEnabled && hasConfigChanged);
|
||||
@@ -758,21 +1019,21 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
|
||||
if (mcpRouter) {
|
||||
if (typeof mcpRouter.apiKey === 'string') {
|
||||
settings.systemConfig.mcpRouter.apiKey = mcpRouter.apiKey;
|
||||
systemConfig.mcpRouter.apiKey = mcpRouter.apiKey;
|
||||
}
|
||||
if (typeof mcpRouter.referer === 'string') {
|
||||
settings.systemConfig.mcpRouter.referer = mcpRouter.referer;
|
||||
systemConfig.mcpRouter.referer = mcpRouter.referer;
|
||||
}
|
||||
if (typeof mcpRouter.title === 'string') {
|
||||
settings.systemConfig.mcpRouter.title = mcpRouter.title;
|
||||
systemConfig.mcpRouter.title = mcpRouter.title;
|
||||
}
|
||||
if (typeof mcpRouter.baseUrl === 'string') {
|
||||
settings.systemConfig.mcpRouter.baseUrl = mcpRouter.baseUrl;
|
||||
systemConfig.mcpRouter.baseUrl = mcpRouter.baseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
if (oauthServer) {
|
||||
const target = settings.systemConfig.oauthServer;
|
||||
const target = systemConfig.oauthServer;
|
||||
if (typeof oauthServer.enabled === 'boolean') {
|
||||
target.enabled = oauthServer.enabled;
|
||||
}
|
||||
@@ -826,17 +1087,19 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
}
|
||||
|
||||
if (typeof nameSeparator === 'string') {
|
||||
settings.systemConfig.nameSeparator = nameSeparator;
|
||||
systemConfig.nameSeparator = nameSeparator;
|
||||
}
|
||||
|
||||
if (typeof enableSessionRebuild === 'boolean') {
|
||||
settings.systemConfig.enableSessionRebuild = enableSessionRebuild;
|
||||
systemConfig.enableSessionRebuild = enableSessionRebuild;
|
||||
}
|
||||
|
||||
if (saveSettings(settings, currentUser)) {
|
||||
// Save using DAO (supports both file and database modes)
|
||||
try {
|
||||
await systemConfigDao.update(systemConfig);
|
||||
res.json({
|
||||
success: true,
|
||||
data: settings.systemConfig,
|
||||
data: systemConfig,
|
||||
message: 'System configuration updated successfully',
|
||||
});
|
||||
|
||||
@@ -848,7 +1111,8 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
console.error('Failed to sync server tools embeddings:', error);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
} catch (saveError) {
|
||||
console.error('Failed to save system configuration:', saveError);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to save system configuration',
|
||||
@@ -886,8 +1150,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',
|
||||
@@ -896,14 +1162,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',
|
||||
@@ -950,8 +1217,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',
|
||||
@@ -960,18 +1229,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',
|
||||
|
||||
@@ -9,13 +9,14 @@ import {
|
||||
getUserCount,
|
||||
getAdminCount,
|
||||
} from '../services/userService.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import { getSystemConfigDao } from '../dao/index.js';
|
||||
import { validatePasswordStrength } from '../utils/passwordValidation.js';
|
||||
|
||||
// Admin permission check middleware function
|
||||
const requireAdmin = (req: Request, res: Response): boolean => {
|
||||
const settings = loadSettings();
|
||||
if (settings.systemConfig?.routing?.skipAuth) {
|
||||
const requireAdmin = async (req: Request, res: Response): Promise<boolean> => {
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
if (systemConfig?.routing?.skipAuth) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -31,11 +32,11 @@ const requireAdmin = (req: Request, res: Response): boolean => {
|
||||
};
|
||||
|
||||
// Get all users (admin only)
|
||||
export const getUsers = (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
export const getUsers = async (req: Request, res: Response): Promise<void> => {
|
||||
if (!(await requireAdmin(req, res))) return;
|
||||
|
||||
try {
|
||||
const users = getAllUsers().map(({ password: _, ...user }) => user); // Remove password from response
|
||||
const users = (await getAllUsers()).map(({ password: _, ...user }) => user); // Remove password from response
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: users,
|
||||
@@ -50,8 +51,8 @@ export const getUsers = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Get a specific user by username (admin only)
|
||||
export const getUser = (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
export const getUser = async (req: Request, res: Response): Promise<void> => {
|
||||
if (!(await requireAdmin(req, res))) return;
|
||||
|
||||
try {
|
||||
const { username } = req.params;
|
||||
@@ -63,7 +64,7 @@ export const getUser = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = getUserByUsername(username);
|
||||
const user = await getUserByUsername(username);
|
||||
if (!user) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -88,7 +89,7 @@ export const getUser = (req: Request, res: Response): void => {
|
||||
|
||||
// Create a new user (admin only)
|
||||
export const createUser = async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!(await requireAdmin(req, res))) return;
|
||||
|
||||
try {
|
||||
const { username, password, isAdmin } = req.body;
|
||||
@@ -138,7 +139,7 @@ export const createUser = async (req: Request, res: Response): Promise<void> =>
|
||||
|
||||
// Update an existing user (admin only)
|
||||
export const updateExistingUser = async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!(await requireAdmin(req, res))) return;
|
||||
|
||||
try {
|
||||
const { username } = req.params;
|
||||
@@ -154,7 +155,7 @@ export const updateExistingUser = async (req: Request, res: Response): Promise<v
|
||||
|
||||
// Check if trying to change admin status
|
||||
if (isAdmin !== undefined) {
|
||||
const currentUser = getUserByUsername(username);
|
||||
const currentUser = await getUserByUsername(username);
|
||||
if (!currentUser) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -164,7 +165,7 @@ export const updateExistingUser = async (req: Request, res: Response): Promise<v
|
||||
}
|
||||
|
||||
// Prevent removing admin status from the last admin
|
||||
if (currentUser.isAdmin && !isAdmin && getAdminCount() === 1) {
|
||||
if (currentUser.isAdmin && !isAdmin && (await getAdminCount()) === 1) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Cannot remove admin status from the last admin user',
|
||||
@@ -222,8 +223,8 @@ export const updateExistingUser = async (req: Request, res: Response): Promise<v
|
||||
};
|
||||
|
||||
// Delete a user (admin only)
|
||||
export const deleteExistingUser = (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
export const deleteExistingUser = async (req: Request, res: Response): Promise<void> => {
|
||||
if (!(await requireAdmin(req, res))) return;
|
||||
|
||||
try {
|
||||
const { username } = req.params;
|
||||
@@ -245,7 +246,7 @@ export const deleteExistingUser = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const success = deleteUser(username);
|
||||
const success = await deleteUser(username);
|
||||
if (!success) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -267,12 +268,12 @@ export const deleteExistingUser = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Get user statistics (admin only)
|
||||
export const getUserStats = (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
export const getUserStats = async (req: Request, res: Response): Promise<void> => {
|
||||
if (!(await requireAdmin(req, res))) return;
|
||||
|
||||
try {
|
||||
const totalUsers = getUserCount();
|
||||
const adminUsers = getAdminCount();
|
||||
const totalUsers = await getUserCount();
|
||||
const adminUsers = await getAdminCount();
|
||||
const regularUsers = totalUsers - adminUsers;
|
||||
|
||||
const response: ApiResponse = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +129,26 @@ export function getDaoFactory(): DaoFactory {
|
||||
return daoFactory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to database-backed DAOs based on environment variable
|
||||
* This is synchronous and should be called during app initialization
|
||||
*/
|
||||
export function initializeDaoFactory(): void {
|
||||
// If USE_DB is explicitly set, use its value; otherwise, auto-detect based on DB_URL presence
|
||||
const useDatabase =
|
||||
process.env.USE_DB !== undefined ? process.env.USE_DB === 'true' : !!process.env.DB_URL;
|
||||
if (useDatabase) {
|
||||
console.log('Using database-backed DAO implementations');
|
||||
// Dynamic import to avoid circular dependencies
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const DatabaseDaoFactoryModule = require('./DatabaseDaoFactory.js');
|
||||
setDaoFactory(DatabaseDaoFactoryModule.DatabaseDaoFactory.getInstance());
|
||||
} else {
|
||||
console.log('Using file-based DAO implementations');
|
||||
setDaoFactory(JsonFileDaoFactory.getInstance());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience functions to get specific DAOs
|
||||
*/
|
||||
@@ -129,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();
|
||||
}
|
||||
|
||||
108
src/dao/DatabaseDaoFactory.ts
Normal file
108
src/dao/DatabaseDaoFactory.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
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
|
||||
*/
|
||||
export class DatabaseDaoFactory implements DaoFactory {
|
||||
private static instance: DatabaseDaoFactory;
|
||||
|
||||
private userDao: UserDao | null = null;
|
||||
private serverDao: ServerDao | null = null;
|
||||
private groupDao: GroupDao | null = null;
|
||||
private systemConfigDao: SystemConfigDao | null = null;
|
||||
private userConfigDao: UserConfigDao | null = null;
|
||||
private oauthClientDao: OAuthClientDao | null = null;
|
||||
private oauthTokenDao: OAuthTokenDao | null = null;
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
*/
|
||||
public static getInstance(): DatabaseDaoFactory {
|
||||
if (!DatabaseDaoFactory.instance) {
|
||||
DatabaseDaoFactory.instance = new DatabaseDaoFactory();
|
||||
}
|
||||
return DatabaseDaoFactory.instance;
|
||||
}
|
||||
|
||||
private constructor() {
|
||||
// Private constructor for singleton
|
||||
}
|
||||
|
||||
getUserDao(): UserDao {
|
||||
if (!this.userDao) {
|
||||
this.userDao = new UserDaoDbImpl();
|
||||
}
|
||||
return this.userDao!;
|
||||
}
|
||||
|
||||
getServerDao(): ServerDao {
|
||||
if (!this.serverDao) {
|
||||
this.serverDao = new ServerDaoDbImpl();
|
||||
}
|
||||
return this.serverDao!;
|
||||
}
|
||||
|
||||
getGroupDao(): GroupDao {
|
||||
if (!this.groupDao) {
|
||||
this.groupDao = new GroupDaoDbImpl();
|
||||
}
|
||||
return this.groupDao!;
|
||||
}
|
||||
|
||||
getSystemConfigDao(): SystemConfigDao {
|
||||
if (!this.systemConfigDao) {
|
||||
this.systemConfigDao = new SystemConfigDaoDbImpl();
|
||||
}
|
||||
return this.systemConfigDao!;
|
||||
}
|
||||
|
||||
getUserConfigDao(): UserConfigDao {
|
||||
if (!this.userConfigDao) {
|
||||
this.userConfigDao = new UserConfigDaoDbImpl();
|
||||
}
|
||||
return this.userConfigDao!;
|
||||
}
|
||||
|
||||
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)
|
||||
*/
|
||||
public resetInstances(): void {
|
||||
this.userDao = null;
|
||||
this.serverDao = null;
|
||||
this.groupDao = null;
|
||||
this.systemConfigDao = null;
|
||||
this.userConfigDao = null;
|
||||
this.oauthClientDao = null;
|
||||
this.oauthTokenDao = null;
|
||||
}
|
||||
}
|
||||
154
src/dao/GroupDaoDbImpl.ts
Normal file
154
src/dao/GroupDaoDbImpl.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { GroupDao } from './index.js';
|
||||
import { IGroup } from '../types/index.js';
|
||||
import { GroupRepository } from '../db/repositories/GroupRepository.js';
|
||||
|
||||
/**
|
||||
* Database-backed implementation of GroupDao
|
||||
*/
|
||||
export class GroupDaoDbImpl implements GroupDao {
|
||||
private repository: GroupRepository;
|
||||
|
||||
constructor() {
|
||||
this.repository = new GroupRepository();
|
||||
}
|
||||
|
||||
async findAll(): Promise<IGroup[]> {
|
||||
const groups = await this.repository.findAll();
|
||||
return groups.map((g) => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
description: g.description,
|
||||
servers: g.servers as any,
|
||||
owner: g.owner,
|
||||
}));
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<IGroup | null> {
|
||||
const group = await this.repository.findById(id);
|
||||
if (!group) return null;
|
||||
return {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
servers: group.servers as any,
|
||||
owner: group.owner,
|
||||
};
|
||||
}
|
||||
|
||||
async create(entity: Omit<IGroup, 'id'>): Promise<IGroup> {
|
||||
const group = await this.repository.create({
|
||||
name: entity.name,
|
||||
description: entity.description,
|
||||
servers: entity.servers as any,
|
||||
owner: entity.owner,
|
||||
});
|
||||
return {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
servers: group.servers as any,
|
||||
owner: group.owner,
|
||||
};
|
||||
}
|
||||
|
||||
async update(id: string, entity: Partial<IGroup>): Promise<IGroup | null> {
|
||||
const group = await this.repository.update(id, {
|
||||
name: entity.name,
|
||||
description: entity.description,
|
||||
servers: entity.servers as any,
|
||||
owner: entity.owner,
|
||||
});
|
||||
if (!group) return null;
|
||||
return {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
servers: group.servers as any,
|
||||
owner: group.owner,
|
||||
};
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
return await this.repository.delete(id);
|
||||
}
|
||||
|
||||
async exists(id: string): Promise<boolean> {
|
||||
return await this.repository.exists(id);
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
return await this.repository.count();
|
||||
}
|
||||
|
||||
async findByOwner(owner: string): Promise<IGroup[]> {
|
||||
const groups = await this.repository.findByOwner(owner);
|
||||
return groups.map((g) => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
description: g.description,
|
||||
servers: g.servers as any,
|
||||
owner: g.owner,
|
||||
}));
|
||||
}
|
||||
|
||||
async findByServer(serverName: string): Promise<IGroup[]> {
|
||||
const allGroups = await this.repository.findAll();
|
||||
return allGroups
|
||||
.filter((g) =>
|
||||
g.servers.some((s) => (typeof s === 'string' ? s === serverName : s.name === serverName)),
|
||||
)
|
||||
.map((g) => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
description: g.description,
|
||||
servers: g.servers as any,
|
||||
owner: g.owner,
|
||||
}));
|
||||
}
|
||||
|
||||
async addServerToGroup(groupId: string, serverName: string): Promise<boolean> {
|
||||
const group = await this.repository.findById(groupId);
|
||||
if (!group) return false;
|
||||
|
||||
// Check if server already exists
|
||||
const serverExists = group.servers.some((s) =>
|
||||
typeof s === 'string' ? s === serverName : s.name === serverName,
|
||||
);
|
||||
|
||||
if (!serverExists) {
|
||||
group.servers.push(serverName);
|
||||
await this.update(groupId, { servers: group.servers as any });
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async removeServerFromGroup(groupId: string, serverName: string): Promise<boolean> {
|
||||
const group = await this.repository.findById(groupId);
|
||||
if (!group) return false;
|
||||
|
||||
group.servers = group.servers.filter((s) =>
|
||||
typeof s === 'string' ? s !== serverName : s.name !== serverName,
|
||||
) as any;
|
||||
|
||||
await this.update(groupId, { servers: group.servers as any });
|
||||
return true;
|
||||
}
|
||||
|
||||
async updateServers(groupId: string, servers: string[] | IGroup['servers']): Promise<boolean> {
|
||||
const result = await this.update(groupId, { servers: servers as any });
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
async findByName(name: string): Promise<IGroup | null> {
|
||||
const group = await this.repository.findByName(name);
|
||||
if (!group) return null;
|
||||
return {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
servers: group.servers as any,
|
||||
owner: group.owner,
|
||||
};
|
||||
}
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
155
src/dao/ServerDaoDbImpl.ts
Normal file
155
src/dao/ServerDaoDbImpl.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { ServerDao, ServerConfigWithName } from './index.js';
|
||||
import { ServerRepository } from '../db/repositories/ServerRepository.js';
|
||||
|
||||
/**
|
||||
* Database-backed implementation of ServerDao
|
||||
*/
|
||||
export class ServerDaoDbImpl implements ServerDao {
|
||||
private repository: ServerRepository;
|
||||
|
||||
constructor() {
|
||||
this.repository = new ServerRepository();
|
||||
}
|
||||
|
||||
async findAll(): Promise<ServerConfigWithName[]> {
|
||||
const servers = await this.repository.findAll();
|
||||
return servers.map((s) => this.mapToServerConfig(s));
|
||||
}
|
||||
|
||||
async findById(name: string): Promise<ServerConfigWithName | null> {
|
||||
const server = await this.repository.findByName(name);
|
||||
return server ? this.mapToServerConfig(server) : null;
|
||||
}
|
||||
|
||||
async create(entity: ServerConfigWithName): Promise<ServerConfigWithName> {
|
||||
const server = await this.repository.create({
|
||||
name: entity.name,
|
||||
type: entity.type,
|
||||
url: entity.url,
|
||||
command: entity.command,
|
||||
args: entity.args,
|
||||
env: entity.env,
|
||||
headers: entity.headers,
|
||||
enabled: entity.enabled !== undefined ? entity.enabled : true,
|
||||
owner: entity.owner,
|
||||
enableKeepAlive: entity.enableKeepAlive,
|
||||
keepAliveInterval: entity.keepAliveInterval,
|
||||
tools: entity.tools,
|
||||
prompts: entity.prompts,
|
||||
options: entity.options,
|
||||
oauth: entity.oauth,
|
||||
openapi: entity.openapi,
|
||||
});
|
||||
return this.mapToServerConfig(server);
|
||||
}
|
||||
|
||||
async update(
|
||||
name: string,
|
||||
entity: Partial<ServerConfigWithName>,
|
||||
): Promise<ServerConfigWithName | null> {
|
||||
const server = await this.repository.update(name, {
|
||||
type: entity.type,
|
||||
url: entity.url,
|
||||
command: entity.command,
|
||||
args: entity.args,
|
||||
env: entity.env,
|
||||
headers: entity.headers,
|
||||
enabled: entity.enabled,
|
||||
owner: entity.owner,
|
||||
enableKeepAlive: entity.enableKeepAlive,
|
||||
keepAliveInterval: entity.keepAliveInterval,
|
||||
tools: entity.tools,
|
||||
prompts: entity.prompts,
|
||||
options: entity.options,
|
||||
oauth: entity.oauth,
|
||||
openapi: entity.openapi,
|
||||
});
|
||||
return server ? this.mapToServerConfig(server) : null;
|
||||
}
|
||||
|
||||
async delete(name: string): Promise<boolean> {
|
||||
return await this.repository.delete(name);
|
||||
}
|
||||
|
||||
async exists(name: string): Promise<boolean> {
|
||||
return await this.repository.exists(name);
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
return await this.repository.count();
|
||||
}
|
||||
|
||||
async findByOwner(owner: string): Promise<ServerConfigWithName[]> {
|
||||
const servers = await this.repository.findByOwner(owner);
|
||||
return servers.map((s) => this.mapToServerConfig(s));
|
||||
}
|
||||
|
||||
async findEnabled(): Promise<ServerConfigWithName[]> {
|
||||
const servers = await this.repository.findEnabled();
|
||||
return servers.map((s) => this.mapToServerConfig(s));
|
||||
}
|
||||
|
||||
async findByType(type: string): Promise<ServerConfigWithName[]> {
|
||||
const allServers = await this.repository.findAll();
|
||||
return allServers.filter((s) => s.type === type).map((s) => this.mapToServerConfig(s));
|
||||
}
|
||||
|
||||
async setEnabled(name: string, enabled: boolean): Promise<boolean> {
|
||||
const server = await this.repository.setEnabled(name, enabled);
|
||||
return server !== null;
|
||||
}
|
||||
|
||||
async updateTools(
|
||||
name: string,
|
||||
tools: Record<string, { enabled: boolean; description?: string }>,
|
||||
): Promise<boolean> {
|
||||
const result = await this.update(name, { tools });
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
async updatePrompts(
|
||||
name: string,
|
||||
prompts: Record<string, { enabled: boolean; description?: string }>,
|
||||
): Promise<boolean> {
|
||||
const result = await this.update(name, { prompts });
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
private mapToServerConfig(server: {
|
||||
name: string;
|
||||
type?: string;
|
||||
url?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
headers?: Record<string, string>;
|
||||
enabled: boolean;
|
||||
owner?: string;
|
||||
enableKeepAlive?: boolean;
|
||||
keepAliveInterval?: number;
|
||||
tools?: Record<string, { enabled: boolean; description?: string }>;
|
||||
prompts?: Record<string, { enabled: boolean; description?: string }>;
|
||||
options?: Record<string, any>;
|
||||
oauth?: Record<string, any>;
|
||||
openapi?: Record<string, any>;
|
||||
}): ServerConfigWithName {
|
||||
return {
|
||||
name: server.name,
|
||||
type: server.type as 'stdio' | 'sse' | 'streamable-http' | 'openapi' | undefined,
|
||||
url: server.url,
|
||||
command: server.command,
|
||||
args: server.args,
|
||||
env: server.env,
|
||||
headers: server.headers,
|
||||
enabled: server.enabled,
|
||||
owner: server.owner,
|
||||
enableKeepAlive: server.enableKeepAlive,
|
||||
keepAliveInterval: server.keepAliveInterval,
|
||||
tools: server.tools,
|
||||
prompts: server.prompts,
|
||||
options: server.options,
|
||||
oauth: server.oauth,
|
||||
openapi: server.openapi,
|
||||
};
|
||||
}
|
||||
}
|
||||
68
src/dao/SystemConfigDaoDbImpl.ts
Normal file
68
src/dao/SystemConfigDaoDbImpl.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { SystemConfigDao } from './index.js';
|
||||
import { SystemConfig } from '../types/index.js';
|
||||
import { SystemConfigRepository } from '../db/repositories/SystemConfigRepository.js';
|
||||
|
||||
/**
|
||||
* Database-backed implementation of SystemConfigDao
|
||||
*/
|
||||
export class SystemConfigDaoDbImpl implements SystemConfigDao {
|
||||
private repository: SystemConfigRepository;
|
||||
|
||||
constructor() {
|
||||
this.repository = new SystemConfigRepository();
|
||||
}
|
||||
|
||||
async get(): Promise<SystemConfig> {
|
||||
const config = await this.repository.get();
|
||||
return {
|
||||
routing: config.routing as any,
|
||||
install: config.install as any,
|
||||
smartRouting: config.smartRouting as any,
|
||||
mcpRouter: config.mcpRouter as any,
|
||||
nameSeparator: config.nameSeparator,
|
||||
oauth: config.oauth as any,
|
||||
oauthServer: config.oauthServer as any,
|
||||
enableSessionRebuild: config.enableSessionRebuild,
|
||||
};
|
||||
}
|
||||
|
||||
async update(config: Partial<SystemConfig>): Promise<SystemConfig> {
|
||||
const updated = await this.repository.update(config as any);
|
||||
return {
|
||||
routing: updated.routing as any,
|
||||
install: updated.install as any,
|
||||
smartRouting: updated.smartRouting as any,
|
||||
mcpRouter: updated.mcpRouter as any,
|
||||
nameSeparator: updated.nameSeparator,
|
||||
oauth: updated.oauth as any,
|
||||
oauthServer: updated.oauthServer as any,
|
||||
enableSessionRebuild: updated.enableSessionRebuild,
|
||||
};
|
||||
}
|
||||
|
||||
async reset(): Promise<SystemConfig> {
|
||||
const config = await this.repository.reset();
|
||||
return {
|
||||
routing: config.routing as any,
|
||||
install: config.install as any,
|
||||
smartRouting: config.smartRouting as any,
|
||||
mcpRouter: config.mcpRouter as any,
|
||||
nameSeparator: config.nameSeparator,
|
||||
oauth: config.oauth as any,
|
||||
oauthServer: config.oauthServer as any,
|
||||
enableSessionRebuild: config.enableSessionRebuild,
|
||||
};
|
||||
}
|
||||
|
||||
async getSection<K extends keyof SystemConfig>(section: K): Promise<SystemConfig[K]> {
|
||||
return (await this.repository.getSection(section)) as any;
|
||||
}
|
||||
|
||||
async updateSection<K extends keyof SystemConfig>(
|
||||
section: K,
|
||||
value: SystemConfig[K],
|
||||
): Promise<boolean> {
|
||||
await this.repository.updateSection(section, value as any);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
79
src/dao/UserConfigDaoDbImpl.ts
Normal file
79
src/dao/UserConfigDaoDbImpl.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { UserConfigDao } from './index.js';
|
||||
import { UserConfig } from '../types/index.js';
|
||||
import { UserConfigRepository } from '../db/repositories/UserConfigRepository.js';
|
||||
|
||||
/**
|
||||
* Database-backed implementation of UserConfigDao
|
||||
*/
|
||||
export class UserConfigDaoDbImpl implements UserConfigDao {
|
||||
private repository: UserConfigRepository;
|
||||
|
||||
constructor() {
|
||||
this.repository = new UserConfigRepository();
|
||||
}
|
||||
|
||||
async getAll(): Promise<Record<string, UserConfig>> {
|
||||
const configs = await this.repository.getAll();
|
||||
const result: Record<string, UserConfig> = {};
|
||||
|
||||
for (const [username, config] of Object.entries(configs)) {
|
||||
result[username] = {
|
||||
routing: config.routing,
|
||||
...config.additionalConfig,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async get(username: string): Promise<UserConfig> {
|
||||
const config = await this.repository.get(username);
|
||||
if (!config) {
|
||||
return { routing: {} };
|
||||
}
|
||||
return {
|
||||
routing: config.routing,
|
||||
...config.additionalConfig,
|
||||
};
|
||||
}
|
||||
|
||||
async update(username: string, config: Partial<UserConfig>): Promise<UserConfig> {
|
||||
const { routing, ...additionalConfig } = config;
|
||||
const updated = await this.repository.update(username, {
|
||||
routing,
|
||||
additionalConfig,
|
||||
});
|
||||
return {
|
||||
routing: updated.routing,
|
||||
...updated.additionalConfig,
|
||||
};
|
||||
}
|
||||
|
||||
async delete(username: string): Promise<boolean> {
|
||||
return await this.repository.delete(username);
|
||||
}
|
||||
|
||||
async getSection<K extends keyof UserConfig>(username: string, section: K): Promise<UserConfig[K]> {
|
||||
const config = await this.get(username);
|
||||
return config[section];
|
||||
}
|
||||
|
||||
async updateSection<K extends keyof UserConfig>(
|
||||
username: string,
|
||||
section: K,
|
||||
value: UserConfig[K],
|
||||
): Promise<boolean> {
|
||||
await this.update(username, { [section]: value } as Partial<UserConfig>);
|
||||
return true;
|
||||
}
|
||||
|
||||
async exists(username: string): Promise<boolean> {
|
||||
const config = await this.repository.get(username);
|
||||
return config !== null;
|
||||
}
|
||||
|
||||
async reset(username: string): Promise<UserConfig> {
|
||||
await this.repository.delete(username);
|
||||
return { routing: {} };
|
||||
}
|
||||
}
|
||||
108
src/dao/UserDaoDbImpl.ts
Normal file
108
src/dao/UserDaoDbImpl.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import bcrypt from 'bcrypt';
|
||||
import { UserDao } from './index.js';
|
||||
import { IUser } from '../types/index.js';
|
||||
import { UserRepository } from '../db/repositories/UserRepository.js';
|
||||
|
||||
/**
|
||||
* Database-backed implementation of UserDao
|
||||
*/
|
||||
export class UserDaoDbImpl implements UserDao {
|
||||
private repository: UserRepository;
|
||||
|
||||
constructor() {
|
||||
this.repository = new UserRepository();
|
||||
}
|
||||
|
||||
async findAll(): Promise<IUser[]> {
|
||||
const users = await this.repository.findAll();
|
||||
return users.map((u) => ({
|
||||
username: u.username,
|
||||
password: u.password,
|
||||
isAdmin: u.isAdmin,
|
||||
}));
|
||||
}
|
||||
|
||||
async findById(username: string): Promise<IUser | null> {
|
||||
const user = await this.repository.findByUsername(username);
|
||||
if (!user) return null;
|
||||
return {
|
||||
username: user.username,
|
||||
password: user.password,
|
||||
isAdmin: user.isAdmin,
|
||||
};
|
||||
}
|
||||
|
||||
async findByUsername(username: string): Promise<IUser | null> {
|
||||
return await this.findById(username);
|
||||
}
|
||||
|
||||
async create(entity: Omit<IUser, 'id'>): Promise<IUser> {
|
||||
const user = await this.repository.create({
|
||||
username: entity.username,
|
||||
password: entity.password,
|
||||
isAdmin: entity.isAdmin || false,
|
||||
});
|
||||
return {
|
||||
username: user.username,
|
||||
password: user.password,
|
||||
isAdmin: user.isAdmin,
|
||||
};
|
||||
}
|
||||
|
||||
async createWithHashedPassword(
|
||||
username: string,
|
||||
password: string,
|
||||
isAdmin: boolean,
|
||||
): Promise<IUser> {
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
return await this.create({ username, password: hashedPassword, isAdmin });
|
||||
}
|
||||
|
||||
async update(username: string, entity: Partial<IUser>): Promise<IUser | null> {
|
||||
const user = await this.repository.update(username, {
|
||||
password: entity.password,
|
||||
isAdmin: entity.isAdmin,
|
||||
});
|
||||
if (!user) return null;
|
||||
return {
|
||||
username: user.username,
|
||||
password: user.password,
|
||||
isAdmin: user.isAdmin,
|
||||
};
|
||||
}
|
||||
|
||||
async delete(username: string): Promise<boolean> {
|
||||
return await this.repository.delete(username);
|
||||
}
|
||||
|
||||
async exists(username: string): Promise<boolean> {
|
||||
return await this.repository.exists(username);
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
return await this.repository.count();
|
||||
}
|
||||
|
||||
async validateCredentials(username: string, password: string): Promise<boolean> {
|
||||
const user = await this.findByUsername(username);
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
return await bcrypt.compare(password, user.password);
|
||||
}
|
||||
|
||||
async updatePassword(username: string, newPassword: string): Promise<boolean> {
|
||||
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
||||
const result = await this.update(username, { password: hashedPassword });
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
async findAdmins(): Promise<IUser[]> {
|
||||
const users = await this.repository.findAdmins();
|
||||
return users.map((u) => ({
|
||||
username: u.username,
|
||||
password: u.password,
|
||||
isAdmin: u.isAdmin,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -187,7 +187,7 @@ export async function exampleUserConfigOperations() {
|
||||
console.log('All user configs:', Object.keys(allUserConfigs));
|
||||
|
||||
// Get specific section for user
|
||||
const userRoutingConfig = await userConfigDao.getSection('admin', 'routing');
|
||||
const userRoutingConfig = await userConfigDao.getSection('admin', 'routing' as never);
|
||||
console.log('Admin routing config:', userRoutingConfig);
|
||||
|
||||
// Delete user configuration
|
||||
|
||||
@@ -6,6 +6,18 @@ 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';
|
||||
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';
|
||||
export * from './DatabaseDaoFactory.js';
|
||||
|
||||
36
src/db/entities/Group.ts
Normal file
36
src/db/entities/Group.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* Group entity for database storage
|
||||
*/
|
||||
@Entity({ name: 'groups' })
|
||||
export class Group {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description?: string;
|
||||
|
||||
@Column({ type: 'simple-json' })
|
||||
servers: Array<string | { name: string; tools?: string[] | 'all' }>;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
owner?: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export default Group;
|
||||
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;
|
||||
72
src/db/entities/Server.ts
Normal file
72
src/db/entities/Server.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* Server configuration entity for database storage
|
||||
*/
|
||||
@Entity({ name: 'servers' })
|
||||
export class Server {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, unique: true })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||
type?: string; // 'stdio', 'sse', 'streamable-http', 'openapi'
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
url?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 500, nullable: true })
|
||||
command?: string;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
args?: string[];
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
env?: Record<string, string>;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
headers?: Record<string, string>;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
enabled: boolean;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
owner?: string;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
enableKeepAlive?: boolean;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
keepAliveInterval?: number;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
tools?: Record<string, { enabled: boolean; description?: string }>;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
prompts?: Record<string, { enabled: boolean; description?: string }>;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
options?: Record<string, any>;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
oauth?: Record<string, any>;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
openapi?: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export default Server;
|
||||
43
src/db/entities/SystemConfig.ts
Normal file
43
src/db/entities/SystemConfig.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Entity, Column, PrimaryColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* System configuration entity for database storage
|
||||
* Using singleton pattern - only one record with id = 'default'
|
||||
*/
|
||||
@Entity({ name: 'system_config' })
|
||||
export class SystemConfig {
|
||||
@PrimaryColumn({ type: 'varchar', length: 50, default: 'default' })
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
routing?: Record<string, any>;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
install?: Record<string, any>;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
smartRouting?: Record<string, any>;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
mcpRouter?: Record<string, any>;
|
||||
|
||||
@Column({ type: 'varchar', length: 10, nullable: true })
|
||||
nameSeparator?: string;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
oauth?: Record<string, any>;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
oauthServer?: Record<string, any>;
|
||||
|
||||
@Column({ type: 'boolean', nullable: true })
|
||||
enableSessionRebuild?: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export default SystemConfig;
|
||||
33
src/db/entities/User.ts
Normal file
33
src/db/entities/User.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* User entity for database storage
|
||||
*/
|
||||
@Entity({ name: 'users' })
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, unique: true })
|
||||
username: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
password: string;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isAdmin: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export default User;
|
||||
33
src/db/entities/UserConfig.ts
Normal file
33
src/db/entities/UserConfig.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* User configuration entity for database storage
|
||||
*/
|
||||
@Entity({ name: 'user_configs' })
|
||||
export class UserConfig {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, unique: true })
|
||||
username: string;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
routing?: Record<string, any>;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
additionalConfig?: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export default UserConfig;
|
||||
@@ -1,7 +1,23 @@
|
||||
import { VectorEmbedding } from './VectorEmbedding.js';
|
||||
import User from './User.js';
|
||||
import Server from './Server.js';
|
||||
import Group from './Group.js';
|
||||
import SystemConfig from './SystemConfig.js';
|
||||
import UserConfig from './UserConfig.js';
|
||||
import OAuthClient from './OAuthClient.js';
|
||||
import OAuthToken from './OAuthToken.js';
|
||||
|
||||
// Export all entities
|
||||
export default [VectorEmbedding];
|
||||
export default [
|
||||
VectorEmbedding,
|
||||
User,
|
||||
Server,
|
||||
Group,
|
||||
SystemConfig,
|
||||
UserConfig,
|
||||
OAuthClient,
|
||||
OAuthToken,
|
||||
];
|
||||
|
||||
// Export individual entities for direct use
|
||||
export { VectorEmbedding };
|
||||
export { VectorEmbedding, User, Server, Group, SystemConfig, UserConfig, OAuthClient, OAuthToken };
|
||||
|
||||
95
src/db/repositories/GroupRepository.ts
Normal file
95
src/db/repositories/GroupRepository.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Repository } from 'typeorm';
|
||||
import { Group } from '../entities/Group.js';
|
||||
import { getAppDataSource } from '../connection.js';
|
||||
|
||||
/**
|
||||
* Repository for Group entity
|
||||
*/
|
||||
export class GroupRepository {
|
||||
private repository: Repository<Group>;
|
||||
|
||||
constructor() {
|
||||
this.repository = getAppDataSource().getRepository(Group);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all groups
|
||||
*/
|
||||
async findAll(): Promise<Group[]> {
|
||||
return await this.repository.find({ order: { createdAt: 'ASC' } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find group by ID
|
||||
*/
|
||||
async findById(id: string): Promise<Group | null> {
|
||||
return await this.repository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find group by name
|
||||
*/
|
||||
async findByName(name: string): Promise<Group | null> {
|
||||
return await this.repository.findOne({ where: { name } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new group
|
||||
*/
|
||||
async create(group: Omit<Group, 'id' | 'createdAt' | 'updatedAt'>): Promise<Group> {
|
||||
const newGroup = this.repository.create(group);
|
||||
return await this.repository.save(newGroup);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing group
|
||||
*/
|
||||
async update(id: string, groupData: Partial<Group>): Promise<Group | null> {
|
||||
const group = await this.findById(id);
|
||||
if (!group) {
|
||||
return null;
|
||||
}
|
||||
const updated = this.repository.merge(group, groupData);
|
||||
return await this.repository.save(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a group
|
||||
*/
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const result = await this.repository.delete({ id });
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if group exists by ID
|
||||
*/
|
||||
async exists(id: string): Promise<boolean> {
|
||||
const count = await this.repository.count({ where: { id } });
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if group exists by name
|
||||
*/
|
||||
async existsByName(name: string): Promise<boolean> {
|
||||
const count = await this.repository.count({ where: { name } });
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total groups
|
||||
*/
|
||||
async count(): Promise<number> {
|
||||
return await this.repository.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find groups by owner
|
||||
*/
|
||||
async findByOwner(owner: string): Promise<Group[]> {
|
||||
return await this.repository.find({ where: { owner }, order: { createdAt: 'ASC' } });
|
||||
}
|
||||
}
|
||||
|
||||
export default GroupRepository;
|
||||
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;
|
||||
94
src/db/repositories/ServerRepository.ts
Normal file
94
src/db/repositories/ServerRepository.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Repository } from 'typeorm';
|
||||
import { Server } from '../entities/Server.js';
|
||||
import { getAppDataSource } from '../connection.js';
|
||||
|
||||
/**
|
||||
* Repository for Server entity
|
||||
*/
|
||||
export class ServerRepository {
|
||||
private repository: Repository<Server>;
|
||||
|
||||
constructor() {
|
||||
this.repository = getAppDataSource().getRepository(Server);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all servers
|
||||
*/
|
||||
async findAll(): Promise<Server[]> {
|
||||
return await this.repository.find({ order: { createdAt: 'ASC' } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find server by name
|
||||
*/
|
||||
async findByName(name: string): Promise<Server | null> {
|
||||
return await this.repository.findOne({ where: { name } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new server
|
||||
*/
|
||||
async create(server: Omit<Server, 'id' | 'createdAt' | 'updatedAt'>): Promise<Server> {
|
||||
const newServer = this.repository.create(server);
|
||||
return await this.repository.save(newServer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing server
|
||||
*/
|
||||
async update(name: string, serverData: Partial<Server>): Promise<Server | null> {
|
||||
const server = await this.findByName(name);
|
||||
if (!server) {
|
||||
return null;
|
||||
}
|
||||
const updated = this.repository.merge(server, serverData);
|
||||
return await this.repository.save(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a server
|
||||
*/
|
||||
async delete(name: string): Promise<boolean> {
|
||||
const result = await this.repository.delete({ name });
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if server exists
|
||||
*/
|
||||
async exists(name: string): Promise<boolean> {
|
||||
const count = await this.repository.count({ where: { name } });
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total servers
|
||||
*/
|
||||
async count(): Promise<number> {
|
||||
return await this.repository.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find servers by owner
|
||||
*/
|
||||
async findByOwner(owner: string): Promise<Server[]> {
|
||||
return await this.repository.find({ where: { owner }, order: { createdAt: 'ASC' } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find enabled servers
|
||||
*/
|
||||
async findEnabled(): Promise<Server[]> {
|
||||
return await this.repository.find({ where: { enabled: true }, order: { createdAt: 'ASC' } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set server enabled status
|
||||
*/
|
||||
async setEnabled(name: string, enabled: boolean): Promise<Server | null> {
|
||||
return await this.update(name, { enabled });
|
||||
}
|
||||
}
|
||||
|
||||
export default ServerRepository;
|
||||
78
src/db/repositories/SystemConfigRepository.ts
Normal file
78
src/db/repositories/SystemConfigRepository.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Repository } from 'typeorm';
|
||||
import { SystemConfig } from '../entities/SystemConfig.js';
|
||||
import { getAppDataSource } from '../connection.js';
|
||||
|
||||
/**
|
||||
* Repository for SystemConfig entity
|
||||
* Uses singleton pattern with id = 'default'
|
||||
*/
|
||||
export class SystemConfigRepository {
|
||||
private repository: Repository<SystemConfig>;
|
||||
private readonly DEFAULT_ID = 'default';
|
||||
|
||||
constructor() {
|
||||
this.repository = getAppDataSource().getRepository(SystemConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get system configuration (singleton)
|
||||
*/
|
||||
async get(): Promise<SystemConfig> {
|
||||
let config = await this.repository.findOne({ where: { id: this.DEFAULT_ID } });
|
||||
|
||||
// Create default if doesn't exist
|
||||
if (!config) {
|
||||
config = this.repository.create({
|
||||
id: this.DEFAULT_ID,
|
||||
routing: {},
|
||||
install: {},
|
||||
smartRouting: {},
|
||||
mcpRouter: {},
|
||||
nameSeparator: '-',
|
||||
oauth: {},
|
||||
oauthServer: {},
|
||||
enableSessionRebuild: false,
|
||||
});
|
||||
config = await this.repository.save(config);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update system configuration
|
||||
*/
|
||||
async update(configData: Partial<SystemConfig>): Promise<SystemConfig> {
|
||||
const config = await this.get();
|
||||
const updated = this.repository.merge(config, configData);
|
||||
return await this.repository.save(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset system configuration to defaults
|
||||
*/
|
||||
async reset(): Promise<SystemConfig> {
|
||||
await this.repository.delete({ id: this.DEFAULT_ID });
|
||||
return await this.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific configuration section
|
||||
*/
|
||||
async getSection<K extends keyof SystemConfig>(section: K): Promise<SystemConfig[K]> {
|
||||
const config = await this.get();
|
||||
return config[section];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a specific configuration section
|
||||
*/
|
||||
async updateSection<K extends keyof SystemConfig>(
|
||||
section: K,
|
||||
value: SystemConfig[K],
|
||||
): Promise<SystemConfig> {
|
||||
return await this.update({ [section]: value } as Partial<SystemConfig>);
|
||||
}
|
||||
}
|
||||
|
||||
export default SystemConfigRepository;
|
||||
84
src/db/repositories/UserConfigRepository.ts
Normal file
84
src/db/repositories/UserConfigRepository.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Repository } from 'typeorm';
|
||||
import { UserConfig } from '../entities/UserConfig.js';
|
||||
import { getAppDataSource } from '../connection.js';
|
||||
|
||||
/**
|
||||
* Repository for UserConfig entity
|
||||
*/
|
||||
export class UserConfigRepository {
|
||||
private repository: Repository<UserConfig>;
|
||||
|
||||
constructor() {
|
||||
this.repository = getAppDataSource().getRepository(UserConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all user configs
|
||||
*/
|
||||
async getAll(): Promise<Record<string, UserConfig>> {
|
||||
const configs = await this.repository.find();
|
||||
const result: Record<string, UserConfig> = {};
|
||||
for (const config of configs) {
|
||||
result[config.username] = config;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user config by username
|
||||
*/
|
||||
async get(username: string): Promise<UserConfig | null> {
|
||||
return await this.repository.findOne({ where: { username } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user config
|
||||
*/
|
||||
async update(username: string, configData: Partial<UserConfig>): Promise<UserConfig> {
|
||||
let config = await this.get(username);
|
||||
|
||||
if (!config) {
|
||||
// Create new config if doesn't exist
|
||||
config = this.repository.create({
|
||||
username,
|
||||
routing: {},
|
||||
additionalConfig: {},
|
||||
...configData,
|
||||
});
|
||||
} else {
|
||||
// Merge with existing config
|
||||
config = this.repository.merge(config, configData);
|
||||
}
|
||||
|
||||
return await this.repository.save(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete user config
|
||||
*/
|
||||
async delete(username: string): Promise<boolean> {
|
||||
const result = await this.repository.delete({ username });
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific configuration section for a user
|
||||
*/
|
||||
async getSection<K extends keyof UserConfig>(username: string, section: K): Promise<UserConfig[K] | null> {
|
||||
const config = await this.get(username);
|
||||
return config ? config[section] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a specific configuration section for a user
|
||||
*/
|
||||
async updateSection<K extends keyof UserConfig>(
|
||||
username: string,
|
||||
section: K,
|
||||
value: UserConfig[K],
|
||||
): Promise<UserConfig> {
|
||||
return await this.update(username, { [section]: value } as Partial<UserConfig>);
|
||||
}
|
||||
}
|
||||
|
||||
export default UserConfigRepository;
|
||||
80
src/db/repositories/UserRepository.ts
Normal file
80
src/db/repositories/UserRepository.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Repository } from 'typeorm';
|
||||
import { User } from '../entities/User.js';
|
||||
import { getAppDataSource } from '../connection.js';
|
||||
|
||||
/**
|
||||
* Repository for User entity
|
||||
*/
|
||||
export class UserRepository {
|
||||
private repository: Repository<User>;
|
||||
|
||||
constructor() {
|
||||
this.repository = getAppDataSource().getRepository(User);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all users
|
||||
*/
|
||||
async findAll(): Promise<User[]> {
|
||||
return await this.repository.find({ order: { createdAt: 'ASC' } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user by username
|
||||
*/
|
||||
async findByUsername(username: string): Promise<User | null> {
|
||||
return await this.repository.findOne({ where: { username } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user
|
||||
*/
|
||||
async create(user: Omit<User, 'id' | 'createdAt' | 'updatedAt'>): Promise<User> {
|
||||
const newUser = this.repository.create(user);
|
||||
return await this.repository.save(newUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing user
|
||||
*/
|
||||
async update(username: string, userData: Partial<User>): Promise<User | null> {
|
||||
const user = await this.findByUsername(username);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
const updated = this.repository.merge(user, userData);
|
||||
return await this.repository.save(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user
|
||||
*/
|
||||
async delete(username: string): Promise<boolean> {
|
||||
const result = await this.repository.delete({ username });
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user exists
|
||||
*/
|
||||
async exists(username: string): Promise<boolean> {
|
||||
const count = await this.repository.count({ where: { username } });
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total users
|
||||
*/
|
||||
async count(): Promise<number> {
|
||||
return await this.repository.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all admin users
|
||||
*/
|
||||
async findAdmins(): Promise<User[]> {
|
||||
return await this.repository.find({ where: { isAdmin: true }, order: { createdAt: 'ASC' } });
|
||||
}
|
||||
}
|
||||
|
||||
export default UserRepository;
|
||||
@@ -1,4 +1,20 @@
|
||||
import VectorEmbeddingRepository from './VectorEmbeddingRepository.js';
|
||||
import { UserRepository } from './UserRepository.js';
|
||||
import { ServerRepository } from './ServerRepository.js';
|
||||
import { GroupRepository } from './GroupRepository.js';
|
||||
import { SystemConfigRepository } from './SystemConfigRepository.js';
|
||||
import { UserConfigRepository } from './UserConfigRepository.js';
|
||||
import { OAuthClientRepository } from './OAuthClientRepository.js';
|
||||
import { OAuthTokenRepository } from './OAuthTokenRepository.js';
|
||||
|
||||
// Export all repositories
|
||||
export { VectorEmbeddingRepository };
|
||||
export {
|
||||
VectorEmbeddingRepository,
|
||||
UserRepository,
|
||||
ServerRepository,
|
||||
GroupRepository,
|
||||
SystemConfigRepository,
|
||||
UserConfigRepository,
|
||||
OAuthClientRepository,
|
||||
OAuthTokenRepository,
|
||||
};
|
||||
|
||||
14
src/index.ts
14
src/index.ts
@@ -1,10 +1,24 @@
|
||||
import 'reflect-metadata';
|
||||
import AppServer from './server.js';
|
||||
import { initializeDatabaseMode } from './utils/migration.js';
|
||||
|
||||
const appServer = new AppServer();
|
||||
|
||||
async function boot() {
|
||||
try {
|
||||
// Check if database mode is enabled
|
||||
// If USE_DB is explicitly set, use its value; otherwise, auto-detect based on DB_URL presence
|
||||
const useDatabase =
|
||||
process.env.USE_DB !== undefined ? process.env.USE_DB === 'true' : !!process.env.DB_URL;
|
||||
if (useDatabase) {
|
||||
console.log('Database mode enabled, initializing...');
|
||||
const dbInitialized = await initializeDatabaseMode();
|
||||
if (!dbInitialized) {
|
||||
console.error('Failed to initialize database mode');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
await appServer.initialize();
|
||||
appServer.start();
|
||||
} catch (error) {
|
||||
|
||||
@@ -67,13 +67,13 @@ 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
|
||||
const { findUserByUsername } = await import('../models/User.js');
|
||||
const user = findUserByUsername(oauthToken.username);
|
||||
|
||||
const user = await findUserByUsername(oauthToken.username);
|
||||
|
||||
// Set user context with proper admin status
|
||||
(req as any).user = {
|
||||
username: oauthToken.username,
|
||||
|
||||
@@ -76,7 +76,7 @@ export const sseUserContextMiddleware = async (
|
||||
const rawAuthHeader = Array.isArray(req.headers.authorization)
|
||||
? req.headers.authorization[0]
|
||||
: req.headers.authorization;
|
||||
const bearerUser = resolveOAuthUserFromAuthHeader(rawAuthHeader);
|
||||
const bearerUser = await resolveOAuthUserFromAuthHeader(rawAuthHeader);
|
||||
|
||||
if (bearerUser) {
|
||||
userContextService.setCurrentUser(bearerUser);
|
||||
|
||||
@@ -1,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();
|
||||
}
|
||||
|
||||
@@ -1,58 +1,43 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { IUser } from '../types/index.js';
|
||||
import { loadSettings, saveSettings } from '../config/index.js';
|
||||
import { getUserDao } from '../dao/index.js';
|
||||
|
||||
// Get all users
|
||||
export const getUsers = (): IUser[] => {
|
||||
export const getUsers = async (): Promise<IUser[]> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
return settings.users || [];
|
||||
const userDao = getUserDao();
|
||||
return await userDao.findAll();
|
||||
} catch (error) {
|
||||
console.error('Error reading users from settings:', error);
|
||||
console.error('Error reading users:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Save users to settings
|
||||
const saveUsers = (users: IUser[]): void => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
settings.users = users;
|
||||
saveSettings(settings);
|
||||
} catch (error) {
|
||||
console.error('Error saving users to settings:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Create a new user
|
||||
export const createUser = async (userData: IUser): Promise<IUser | null> => {
|
||||
const users = getUsers();
|
||||
|
||||
// Check if username already exists
|
||||
if (users.some((user) => user.username === userData.username)) {
|
||||
try {
|
||||
const userDao = getUserDao();
|
||||
return await userDao.createWithHashedPassword(
|
||||
userData.username,
|
||||
userData.password,
|
||||
userData.isAdmin,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error creating user:', error);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Hash the password
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
const hashedPassword = await bcrypt.hash(userData.password, salt);
|
||||
|
||||
const newUser = {
|
||||
username: userData.username,
|
||||
password: hashedPassword,
|
||||
isAdmin: userData.isAdmin || false,
|
||||
};
|
||||
|
||||
users.push(newUser);
|
||||
saveUsers(users);
|
||||
|
||||
return newUser;
|
||||
};
|
||||
|
||||
// Find user by username
|
||||
export const findUserByUsername = (username: string): IUser | undefined => {
|
||||
const users = getUsers();
|
||||
return users.find((user) => user.username === username);
|
||||
export const findUserByUsername = async (username: string): Promise<IUser | undefined> => {
|
||||
try {
|
||||
const userDao = getUserDao();
|
||||
const user = await userDao.findByUsername(username);
|
||||
return user || undefined;
|
||||
} catch (error) {
|
||||
console.error('Error finding user:', error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Verify user password
|
||||
@@ -68,34 +53,22 @@ export const updateUserPassword = async (
|
||||
username: string,
|
||||
newPassword: string,
|
||||
): Promise<boolean> => {
|
||||
const users = getUsers();
|
||||
const userIndex = users.findIndex((user) => user.username === username);
|
||||
|
||||
if (userIndex === -1) {
|
||||
try {
|
||||
const userDao = getUserDao();
|
||||
return await userDao.updatePassword(username, newPassword);
|
||||
} catch (error) {
|
||||
console.error('Error updating password:', error);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hash the new password
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
const hashedPassword = await bcrypt.hash(newPassword, salt);
|
||||
|
||||
// Update the user's password
|
||||
users[userIndex].password = hashedPassword;
|
||||
saveUsers(users);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Initialize with default admin user if no users exist
|
||||
export const initializeDefaultUser = async (): Promise<void> => {
|
||||
const users = getUsers();
|
||||
const userDao = getUserDao();
|
||||
const users = await userDao.findAll();
|
||||
|
||||
if (users.length === 0) {
|
||||
await createUser({
|
||||
username: 'admin',
|
||||
password: 'admin123',
|
||||
isAdmin: true,
|
||||
});
|
||||
await userDao.createWithHashedPassword('admin', 'admin123', true);
|
||||
console.log('Default admin user created');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,10 +4,13 @@ import config from '../config/index.js';
|
||||
import {
|
||||
getAllServers,
|
||||
getAllSettings,
|
||||
getServerConfig,
|
||||
createServer,
|
||||
batchCreateServers,
|
||||
updateServer,
|
||||
deleteServer,
|
||||
toggleServer,
|
||||
reloadServer,
|
||||
toggleTool,
|
||||
updateToolDescription,
|
||||
togglePrompt,
|
||||
@@ -18,6 +21,7 @@ import {
|
||||
getGroups,
|
||||
getGroup,
|
||||
createNewGroup,
|
||||
batchCreateGroups,
|
||||
updateExistingGroup,
|
||||
deleteExistingGroup,
|
||||
addServerToExistingGroup,
|
||||
@@ -129,11 +133,14 @@ export const initRoutes = (app: express.Application): void => {
|
||||
|
||||
// API routes protected by auth middleware in middlewares/index.ts
|
||||
router.get('/servers', getAllServers);
|
||||
router.get('/servers/:name', getServerConfig);
|
||||
router.get('/settings', getAllSettings);
|
||||
router.post('/servers', createServer);
|
||||
router.post('/servers/batch', batchCreateServers);
|
||||
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);
|
||||
@@ -144,6 +151,7 @@ export const initRoutes = (app: express.Application): void => {
|
||||
router.get('/groups', getGroups);
|
||||
router.get('/groups/:id', getGroup);
|
||||
router.post('/groups', createNewGroup);
|
||||
router.post('/groups/batch', batchCreateGroups);
|
||||
router.put('/groups/:id', updateExistingGroup);
|
||||
router.delete('/groups/:id', deleteExistingGroup);
|
||||
router.post('/groups/:id/servers', addServerToExistingGroup);
|
||||
|
||||
5
src/scripts/migrate-to-database.ts
Normal file
5
src/scripts/migrate-to-database.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
import 'reflect-metadata';
|
||||
import { runMigrationCli } from '../utils/migration.js';
|
||||
|
||||
runMigrationCli();
|
||||
@@ -61,7 +61,7 @@ export class AppServer {
|
||||
await initializeDefaultUser();
|
||||
|
||||
// Initialize OAuth provider if configured (for proxying upstream MCP OAuth)
|
||||
initOAuthProvider();
|
||||
await initOAuthProvider();
|
||||
const oauthRouter = getOAuthRouter();
|
||||
if (oauthRouter) {
|
||||
// Mount OAuth router at the root level (before other routes)
|
||||
@@ -71,7 +71,7 @@ export class AppServer {
|
||||
}
|
||||
|
||||
// Initialize OAuth authorization server (for MCPHub's own OAuth)
|
||||
initOAuthServer();
|
||||
await initOAuthServer();
|
||||
|
||||
initMiddlewares(this.app);
|
||||
initRoutes(this.app);
|
||||
@@ -103,8 +103,10 @@ export class AppServer {
|
||||
);
|
||||
|
||||
// User-scoped routes with user context middleware
|
||||
this.app.get(`${this.basePath}/:user/sse/:group(.*)?`, sseUserContextMiddleware, (req, res) =>
|
||||
handleSseConnection(req, res),
|
||||
this.app.get(
|
||||
`${this.basePath}/:user/sse/:group(.*)?`,
|
||||
sseUserContextMiddleware,
|
||||
(req, res) => handleSseConnection(req, res),
|
||||
);
|
||||
this.app.post(
|
||||
`${this.basePath}/:user/messages`,
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { DataService } from './dataService.js';
|
||||
import { getDataService } from './services.js';
|
||||
import './services.js';
|
||||
|
||||
describe('DataService', () => {
|
||||
test('should get default implementation and call foo method', async () => {
|
||||
const dataService: DataService = await getDataService();
|
||||
const consoleSpy = jest.spyOn(console, 'log');
|
||||
dataService.foo();
|
||||
expect(consoleSpy).toHaveBeenCalledWith('default implementation');
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,31 +1,69 @@
|
||||
import { IUser, McpSettings } from '../types/index.js';
|
||||
import { UserContextService } from './userContextService.js';
|
||||
import { UserConfig } from '../types/index.js';
|
||||
|
||||
export interface DataService {
|
||||
foo(): void;
|
||||
filterData(data: any[], user?: IUser): any[];
|
||||
filterSettings(settings: McpSettings, user?: IUser): McpSettings;
|
||||
mergeSettings(all: McpSettings, newSettings: McpSettings, user?: IUser): McpSettings;
|
||||
getPermissions(user: IUser): string[];
|
||||
}
|
||||
|
||||
export class DataServiceImpl implements DataService {
|
||||
foo() {
|
||||
console.log('default implementation');
|
||||
export class DataService {
|
||||
filterData(data: any[], user?: IUser): any[] {
|
||||
// Use passed user parameter if available, otherwise fall back to context
|
||||
const currentUser = user || UserContextService.getInstance().getCurrentUser();
|
||||
if (!currentUser || currentUser.isAdmin) {
|
||||
return data;
|
||||
} else {
|
||||
return data.filter((item) => item.owner === currentUser?.username);
|
||||
}
|
||||
}
|
||||
|
||||
filterData(data: any[], _user?: IUser): any[] {
|
||||
return data;
|
||||
filterSettings(settings: McpSettings, user?: IUser): McpSettings {
|
||||
// Use passed user parameter if available, otherwise fall back to context
|
||||
const currentUser = user || UserContextService.getInstance().getCurrentUser();
|
||||
if (!currentUser || currentUser.isAdmin) {
|
||||
const result = { ...settings };
|
||||
delete result.userConfigs;
|
||||
return result;
|
||||
} else {
|
||||
const result = { ...settings };
|
||||
// TODO: apply userConfig to filter settings as needed
|
||||
// const userConfig = settings.userConfigs?.[currentUser?.username || ''];
|
||||
delete result.userConfigs;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
filterSettings(settings: McpSettings, _user?: IUser): McpSettings {
|
||||
return settings;
|
||||
mergeSettings(all: McpSettings, newSettings: McpSettings, user?: IUser): McpSettings {
|
||||
// Use passed user parameter if available, otherwise fall back to context
|
||||
const currentUser = user || UserContextService.getInstance().getCurrentUser();
|
||||
if (!currentUser || currentUser.isAdmin) {
|
||||
const result = { ...all };
|
||||
result.mcpServers = newSettings.mcpServers;
|
||||
result.users = newSettings.users;
|
||||
result.systemConfig = newSettings.systemConfig;
|
||||
result.groups = newSettings.groups;
|
||||
result.oauthClients = newSettings.oauthClients;
|
||||
result.oauthTokens = newSettings.oauthTokens;
|
||||
return result;
|
||||
} else {
|
||||
const result = JSON.parse(JSON.stringify(all));
|
||||
if (!result.userConfigs) {
|
||||
result.userConfigs = {};
|
||||
}
|
||||
const systemConfig = newSettings.systemConfig || {};
|
||||
const userConfig: UserConfig = {
|
||||
routing: systemConfig.routing
|
||||
? {
|
||||
// TODO: only allow modifying certain fields based on userConfig permissions
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
result.userConfigs[currentUser?.username || ''] = userConfig;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
mergeSettings(all: McpSettings, newSettings: McpSettings, _user?: IUser): McpSettings {
|
||||
return newSettings;
|
||||
}
|
||||
|
||||
getPermissions(_user: IUser): string[] {
|
||||
return ['*'];
|
||||
getPermissions(user: IUser): string[] {
|
||||
if (user && user.isAdmin) {
|
||||
return ['*', 'x'];
|
||||
} else {
|
||||
return [''];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { IUser, McpSettings, UserConfig } from '../types/index.js';
|
||||
import { DataService } from './dataService.js';
|
||||
import { UserContextService } from './userContextService.js';
|
||||
|
||||
export class DataServicex implements DataService {
|
||||
foo() {
|
||||
console.log('default implementation');
|
||||
}
|
||||
|
||||
filterData(data: any[], user?: IUser): any[] {
|
||||
// Use passed user parameter if available, otherwise fall back to context
|
||||
const currentUser = user || UserContextService.getInstance().getCurrentUser();
|
||||
if (!currentUser || currentUser.isAdmin) {
|
||||
return data;
|
||||
} else {
|
||||
return data.filter((item) => item.owner === currentUser?.username);
|
||||
}
|
||||
}
|
||||
|
||||
filterSettings(settings: McpSettings, user?: IUser): McpSettings {
|
||||
// Use passed user parameter if available, otherwise fall back to context
|
||||
const currentUser = user || UserContextService.getInstance().getCurrentUser();
|
||||
if (!currentUser || currentUser.isAdmin) {
|
||||
const result = { ...settings };
|
||||
delete result.userConfigs;
|
||||
return result;
|
||||
} else {
|
||||
const result = { ...settings };
|
||||
result.systemConfig = settings.userConfigs?.[currentUser?.username || ''] || {};
|
||||
delete result.userConfigs;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
mergeSettings(all: McpSettings, newSettings: McpSettings, user?: IUser): McpSettings {
|
||||
// Use passed user parameter if available, otherwise fall back to context
|
||||
const currentUser = user || UserContextService.getInstance().getCurrentUser();
|
||||
if (!currentUser || currentUser.isAdmin) {
|
||||
const result = { ...all };
|
||||
result.mcpServers = newSettings.mcpServers;
|
||||
result.users = newSettings.users;
|
||||
result.systemConfig = newSettings.systemConfig;
|
||||
result.groups = newSettings.groups;
|
||||
result.oauthClients = newSettings.oauthClients;
|
||||
result.oauthTokens = newSettings.oauthTokens;
|
||||
return result;
|
||||
} else {
|
||||
const result = JSON.parse(JSON.stringify(all));
|
||||
if (!result.userConfigs) {
|
||||
result.userConfigs = {};
|
||||
}
|
||||
const systemConfig = newSettings.systemConfig || {};
|
||||
const userConfig: UserConfig = {
|
||||
routing: systemConfig.routing
|
||||
? {
|
||||
enableGlobalRoute: systemConfig.routing.enableGlobalRoute,
|
||||
enableGroupNameRoute: systemConfig.routing.enableGroupNameRoute,
|
||||
enableBearerAuth: systemConfig.routing.enableBearerAuth,
|
||||
bearerAuthKey: systemConfig.routing.bearerAuthKey,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
result.userConfigs[currentUser?.username || ''] = userConfig;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
getPermissions(user: IUser): string[] {
|
||||
if (user && user.isAdmin) {
|
||||
return ['*', 'x'];
|
||||
} else {
|
||||
return [''];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { IGroup, IGroupServerConfig } from '../types/index.js';
|
||||
import { loadSettings, saveSettings } from '../config/index.js';
|
||||
import { notifyToolChanged } from './mcpService.js';
|
||||
import { getDataService } from './services.js';
|
||||
import { getGroupDao, getServerDao, getSystemConfigDao } from '../dao/index.js';
|
||||
|
||||
// Helper function to normalize group servers configuration
|
||||
const normalizeGroupServers = (servers: string[] | IGroupServerConfig[]): IGroupServerConfig[] => {
|
||||
@@ -17,22 +17,24 @@ const normalizeGroupServers = (servers: string[] | IGroupServerConfig[]): IGroup
|
||||
};
|
||||
|
||||
// Get all groups
|
||||
export const getAllGroups = (): IGroup[] => {
|
||||
const settings = loadSettings();
|
||||
export const getAllGroups = async (): Promise<IGroup[]> => {
|
||||
const groupDao = getGroupDao();
|
||||
const groups = await groupDao.findAll();
|
||||
const dataService = getDataService();
|
||||
return dataService.filterData
|
||||
? dataService.filterData(settings.groups || [])
|
||||
: settings.groups || [];
|
||||
return dataService.filterData ? dataService.filterData(groups) : groups;
|
||||
};
|
||||
|
||||
// Get group by ID or name
|
||||
export const getGroupByIdOrName = (key: string): IGroup | undefined => {
|
||||
const settings = loadSettings();
|
||||
const routingConfig = settings.systemConfig?.routing || {
|
||||
export const getGroupByIdOrName = async (key: string): Promise<IGroup | undefined> => {
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
const routingConfig = systemConfig?.routing || {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
};
|
||||
const groups = getAllGroups();
|
||||
|
||||
const groups = await getAllGroups();
|
||||
return (
|
||||
groups.find(
|
||||
(group) => group.id === key || (group.name === key && routingConfig.enableGroupNameRoute),
|
||||
@@ -41,25 +43,28 @@ export const getGroupByIdOrName = (key: string): IGroup | undefined => {
|
||||
};
|
||||
|
||||
// Create a new group
|
||||
export const createGroup = (
|
||||
export const createGroup = async (
|
||||
name: string,
|
||||
description?: string,
|
||||
servers: string[] | IGroupServerConfig[] = [],
|
||||
owner?: string,
|
||||
): IGroup | null => {
|
||||
): Promise<IGroup | null> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
const groups = settings.groups || [];
|
||||
const groupDao = getGroupDao();
|
||||
const serverDao = getServerDao();
|
||||
|
||||
// Check if group with same name already exists
|
||||
if (groups.some((group) => group.name === name)) {
|
||||
const existingGroup = await groupDao.findByName(name);
|
||||
if (existingGroup) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Normalize servers configuration and filter out non-existent servers
|
||||
const normalizedServers = normalizeGroupServers(servers);
|
||||
const validServers: IGroupServerConfig[] = normalizedServers.filter(
|
||||
(serverConfig) => settings.mcpServers[serverConfig.name],
|
||||
const allServers = await serverDao.findAll();
|
||||
const serverNames = new Set(allServers.map((s) => s.name));
|
||||
const validServers: IGroupServerConfig[] = normalizedServers.filter((serverConfig) =>
|
||||
serverNames.has(serverConfig.name),
|
||||
);
|
||||
|
||||
const newGroup: IGroup = {
|
||||
@@ -70,18 +75,8 @@ export const createGroup = (
|
||||
owner: owner || 'admin',
|
||||
};
|
||||
|
||||
// Initialize groups array if it doesn't exist
|
||||
if (!settings.groups) {
|
||||
settings.groups = [];
|
||||
}
|
||||
|
||||
settings.groups.push(newGroup);
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return newGroup;
|
||||
const createdGroup = await groupDao.create(newGroup);
|
||||
return createdGroup;
|
||||
} catch (error) {
|
||||
console.error('Failed to create group:', error);
|
||||
return null;
|
||||
@@ -89,43 +84,38 @@ export const createGroup = (
|
||||
};
|
||||
|
||||
// Update an existing group
|
||||
export const updateGroup = (id: string, data: Partial<IGroup>): IGroup | null => {
|
||||
export const updateGroup = async (id: string, data: Partial<IGroup>): Promise<IGroup | null> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (!settings.groups) {
|
||||
return null;
|
||||
}
|
||||
const groupDao = getGroupDao();
|
||||
const serverDao = getServerDao();
|
||||
|
||||
const groupIndex = settings.groups.findIndex((group) => group.id === id);
|
||||
if (groupIndex === -1) {
|
||||
const existingGroup = await groupDao.findById(id);
|
||||
if (!existingGroup) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for name uniqueness if name is being updated
|
||||
if (data.name && settings.groups.some((g) => g.name === data.name && g.id !== id)) {
|
||||
return null;
|
||||
if (data.name && data.name !== existingGroup.name) {
|
||||
const groupWithName = await groupDao.findByName(data.name);
|
||||
if (groupWithName) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// If servers array is provided, validate server existence and normalize format
|
||||
if (data.servers) {
|
||||
const normalizedServers = normalizeGroupServers(data.servers);
|
||||
data.servers = normalizedServers.filter(
|
||||
(serverConfig) => settings.mcpServers[serverConfig.name],
|
||||
);
|
||||
const allServers = await serverDao.findAll();
|
||||
const serverNames = new Set(allServers.map((s) => s.name));
|
||||
data.servers = normalizedServers.filter((serverConfig) => serverNames.has(serverConfig.name));
|
||||
}
|
||||
|
||||
const updatedGroup = {
|
||||
...settings.groups[groupIndex],
|
||||
...data,
|
||||
};
|
||||
const updatedGroup = await groupDao.update(id, data);
|
||||
|
||||
settings.groups[groupIndex] = updatedGroup;
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return null;
|
||||
if (updatedGroup) {
|
||||
notifyToolChanged();
|
||||
}
|
||||
|
||||
notifyToolChanged();
|
||||
return updatedGroup;
|
||||
} catch (error) {
|
||||
console.error(`Failed to update group ${id}:`, error);
|
||||
@@ -135,35 +125,34 @@ export const updateGroup = (id: string, data: Partial<IGroup>): IGroup | null =>
|
||||
|
||||
// Update servers in a group (batch update)
|
||||
// Update group servers (maintaining backward compatibility)
|
||||
export const updateGroupServers = (
|
||||
export const updateGroupServers = async (
|
||||
groupId: string,
|
||||
servers: string[] | IGroupServerConfig[],
|
||||
): IGroup | null => {
|
||||
): Promise<IGroup | null> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (!settings.groups) {
|
||||
return null;
|
||||
}
|
||||
const groupDao = getGroupDao();
|
||||
const serverDao = getServerDao();
|
||||
|
||||
const groupIndex = settings.groups.findIndex((group) => group.id === groupId);
|
||||
if (groupIndex === -1) {
|
||||
const existingGroup = await groupDao.findById(groupId);
|
||||
if (!existingGroup) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Normalize and filter out non-existent servers
|
||||
const normalizedServers = normalizeGroupServers(servers);
|
||||
const validServers = normalizedServers.filter(
|
||||
(serverConfig) => settings.mcpServers[serverConfig.name],
|
||||
const allServers = await serverDao.findAll();
|
||||
const serverNames = new Set(allServers.map((s) => s.name));
|
||||
const validServers = normalizedServers.filter((serverConfig) =>
|
||||
serverNames.has(serverConfig.name),
|
||||
);
|
||||
|
||||
settings.groups[groupIndex].servers = validServers;
|
||||
const updatedGroup = await groupDao.update(groupId, { servers: validServers });
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return null;
|
||||
if (updatedGroup) {
|
||||
notifyToolChanged();
|
||||
}
|
||||
|
||||
notifyToolChanged();
|
||||
return settings.groups[groupIndex];
|
||||
return updatedGroup;
|
||||
} catch (error) {
|
||||
console.error(`Failed to update servers for group ${groupId}:`, error);
|
||||
return null;
|
||||
@@ -171,21 +160,10 @@ export const updateGroupServers = (
|
||||
};
|
||||
|
||||
// Delete a group
|
||||
export const deleteGroup = (id: string): boolean => {
|
||||
export const deleteGroup = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (!settings.groups) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const initialLength = settings.groups.length;
|
||||
settings.groups = settings.groups.filter((group) => group.id !== id);
|
||||
|
||||
if (settings.groups.length === initialLength) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return saveSettings(settings);
|
||||
const groupDao = getGroupDao();
|
||||
return await groupDao.delete(id);
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete group ${id}:`, error);
|
||||
return false;
|
||||
@@ -193,34 +171,37 @@ export const deleteGroup = (id: string): boolean => {
|
||||
};
|
||||
|
||||
// Add server to group
|
||||
export const addServerToGroup = (groupId: string, serverName: string): IGroup | null => {
|
||||
export const addServerToGroup = async (
|
||||
groupId: string,
|
||||
serverName: string,
|
||||
): Promise<IGroup | null> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (!settings.groups) {
|
||||
return null;
|
||||
}
|
||||
const groupDao = getGroupDao();
|
||||
const serverDao = getServerDao();
|
||||
|
||||
// Verify server exists
|
||||
if (!settings.mcpServers[serverName]) {
|
||||
const server = await serverDao.findById(serverName);
|
||||
if (!server) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const groupIndex = settings.groups.findIndex((group) => group.id === groupId);
|
||||
if (groupIndex === -1) {
|
||||
const group = await groupDao.findById(groupId);
|
||||
if (!group) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const group = settings.groups[groupIndex];
|
||||
const normalizedServers = normalizeGroupServers(group.servers);
|
||||
|
||||
// Add server to group if not already in it
|
||||
if (!normalizedServers.some((server) => server.name === serverName)) {
|
||||
if (!normalizedServers.some((s) => s.name === serverName)) {
|
||||
normalizedServers.push({ name: serverName, tools: 'all' });
|
||||
group.servers = normalizedServers;
|
||||
const updatedGroup = await groupDao.update(groupId, { servers: normalizedServers });
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return null;
|
||||
if (updatedGroup) {
|
||||
notifyToolChanged();
|
||||
}
|
||||
|
||||
return updatedGroup;
|
||||
}
|
||||
|
||||
notifyToolChanged();
|
||||
@@ -232,27 +213,22 @@ export const addServerToGroup = (groupId: string, serverName: string): IGroup |
|
||||
};
|
||||
|
||||
// Remove server from group
|
||||
export const removeServerFromGroup = (groupId: string, serverName: string): IGroup | null => {
|
||||
export const removeServerFromGroup = async (
|
||||
groupId: string,
|
||||
serverName: string,
|
||||
): Promise<IGroup | null> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (!settings.groups) {
|
||||
const groupDao = getGroupDao();
|
||||
|
||||
const group = await groupDao.findById(groupId);
|
||||
if (!group) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const groupIndex = settings.groups.findIndex((group) => group.id === groupId);
|
||||
if (groupIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const group = settings.groups[groupIndex];
|
||||
const normalizedServers = normalizeGroupServers(group.servers);
|
||||
group.servers = normalizedServers.filter((server) => server.name !== serverName);
|
||||
const filteredServers = normalizedServers.filter((server) => server.name !== serverName);
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return group;
|
||||
return await groupDao.update(groupId, { servers: filteredServers });
|
||||
} catch (error) {
|
||||
console.error(`Failed to remove server ${serverName} from group ${groupId}:`, error);
|
||||
return null;
|
||||
@@ -260,71 +236,69 @@ export const removeServerFromGroup = (groupId: string, serverName: string): IGro
|
||||
};
|
||||
|
||||
// Get all servers in a group
|
||||
export const getServersInGroup = (groupId: string): string[] => {
|
||||
const group = getGroupByIdOrName(groupId);
|
||||
export const getServersInGroup = async (groupId: string): Promise<string[]> => {
|
||||
const group = await getGroupByIdOrName(groupId);
|
||||
if (!group) return [];
|
||||
const normalizedServers = normalizeGroupServers(group.servers);
|
||||
return normalizedServers.map((server) => server.name);
|
||||
};
|
||||
|
||||
// Get server configuration from group (including tool selection)
|
||||
export const getServerConfigInGroup = (
|
||||
export const getServerConfigInGroup = async (
|
||||
groupId: string,
|
||||
serverName: string,
|
||||
): IGroupServerConfig | undefined => {
|
||||
const group = getGroupByIdOrName(groupId);
|
||||
): Promise<IGroupServerConfig | undefined> => {
|
||||
const group = await getGroupByIdOrName(groupId);
|
||||
if (!group) return undefined;
|
||||
const normalizedServers = normalizeGroupServers(group.servers);
|
||||
return normalizedServers.find((server) => server.name === serverName);
|
||||
};
|
||||
|
||||
// Get all server configurations in a group
|
||||
export const getServerConfigsInGroup = (groupId: string): IGroupServerConfig[] => {
|
||||
const group = getGroupByIdOrName(groupId);
|
||||
export const getServerConfigsInGroup = async (groupId: string): Promise<IGroupServerConfig[]> => {
|
||||
const group = await getGroupByIdOrName(groupId);
|
||||
if (!group) return [];
|
||||
return normalizeGroupServers(group.servers);
|
||||
};
|
||||
|
||||
// Update tools selection for a specific server in a group
|
||||
export const updateServerToolsInGroup = (
|
||||
export const updateServerToolsInGroup = async (
|
||||
groupId: string,
|
||||
serverName: string,
|
||||
tools: string[] | 'all',
|
||||
): IGroup | null => {
|
||||
): Promise<IGroup | null> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (!settings.groups) {
|
||||
return null;
|
||||
}
|
||||
const groupDao = getGroupDao();
|
||||
const serverDao = getServerDao();
|
||||
|
||||
const groupIndex = settings.groups.findIndex((group) => group.id === groupId);
|
||||
if (groupIndex === -1) {
|
||||
const group = await groupDao.findById(groupId);
|
||||
if (!group) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify server exists
|
||||
if (!settings.mcpServers[serverName]) {
|
||||
const server = await serverDao.findById(serverName);
|
||||
if (!server) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const group = settings.groups[groupIndex];
|
||||
const normalizedServers = normalizeGroupServers(group.servers);
|
||||
|
||||
const serverIndex = normalizedServers.findIndex((server) => server.name === serverName);
|
||||
const serverIndex = normalizedServers.findIndex((s) => s.name === serverName);
|
||||
if (serverIndex === -1) {
|
||||
return null; // Server not in group
|
||||
}
|
||||
|
||||
// Update the tools configuration for the server
|
||||
normalizedServers[serverIndex].tools = tools;
|
||||
group.servers = normalizedServers;
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return null;
|
||||
const updatedGroup = await groupDao.update(groupId, { servers: normalizedServers });
|
||||
|
||||
if (updatedGroup) {
|
||||
notifyToolChanged();
|
||||
}
|
||||
|
||||
notifyToolChanged();
|
||||
return group;
|
||||
return updatedGroup;
|
||||
} catch (error) {
|
||||
console.error(`Failed to update tools for server ${serverName} in group ${groupId}:`, error);
|
||||
return null;
|
||||
|
||||
73
src/services/keepAliveService.ts
Normal file
73
src/services/keepAliveService.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { ServerInfo, ServerConfig } from '../types/index.js';
|
||||
|
||||
export interface KeepAliveOptions {
|
||||
enabled?: boolean;
|
||||
intervalMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up keep-alive ping for MCP client connections (SSE or Streamable HTTP).
|
||||
* Keepalive is controlled per-server via `serverConfig.enableKeepAlive` (default off).
|
||||
*/
|
||||
export const setupClientKeepAlive = async (
|
||||
serverInfo: ServerInfo,
|
||||
serverConfig: ServerConfig,
|
||||
): Promise<void> => {
|
||||
// Only set up keep-alive for SSE or Streamable HTTP client transports
|
||||
const isSSE = serverInfo.transport instanceof SSEClientTransport;
|
||||
const isStreamableHttp = serverInfo.transport instanceof StreamableHTTPClientTransport;
|
||||
if (!isSSE && !isStreamableHttp) {
|
||||
return;
|
||||
}
|
||||
|
||||
const enabled = serverConfig.enableKeepAlive === true;
|
||||
if (!enabled) {
|
||||
// Ensure any previous timer is cleared
|
||||
if (serverInfo.keepAliveIntervalId) {
|
||||
clearInterval(serverInfo.keepAliveIntervalId as NodeJS.Timeout);
|
||||
serverInfo.keepAliveIntervalId = undefined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any existing interval first
|
||||
if (serverInfo.keepAliveIntervalId) {
|
||||
clearInterval(serverInfo.keepAliveIntervalId as NodeJS.Timeout);
|
||||
}
|
||||
|
||||
// Default interval: 60 seconds
|
||||
const interval = serverConfig.keepAliveInterval || 60000;
|
||||
|
||||
serverInfo.keepAliveIntervalId = setInterval(async () => {
|
||||
try {
|
||||
if (serverInfo.client && serverInfo.status === 'connected') {
|
||||
// Use client.ping() if available, otherwise fallback to listTools
|
||||
if (typeof (serverInfo.client as any).ping === 'function') {
|
||||
await (serverInfo.client as any).ping();
|
||||
console.log(`Keep-alive ping successful for server: ${serverInfo.name}`);
|
||||
} else {
|
||||
await serverInfo.client.listTools({ timeout: 5000 }).catch(() => void 0);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Keep-alive ping failed for server ${serverInfo.name}:`, error);
|
||||
}
|
||||
}, interval);
|
||||
|
||||
console.log(
|
||||
`Keep-alive enabled for server ${serverInfo.name} at ${Math.round(interval / 1000)}s interval`,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear keep-alive timer for a server.
|
||||
*/
|
||||
export const clearClientKeepAlive = (serverInfo: ServerInfo): void => {
|
||||
if (serverInfo.keepAliveIntervalId) {
|
||||
clearInterval(serverInfo.keepAliveIntervalId as NodeJS.Timeout);
|
||||
serverInfo.keepAliveIntervalId = undefined;
|
||||
console.log(`Cleared keep-alive interval for server: ${serverInfo.name}`);
|
||||
}
|
||||
};
|
||||
@@ -20,7 +20,7 @@ import type {
|
||||
OAuthTokens,
|
||||
} from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
import { ServerConfig } from '../types/index.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import { getSystemConfigDao } from '../dao/index.js';
|
||||
import {
|
||||
initializeOAuthForServer,
|
||||
getRegisteredClient,
|
||||
@@ -52,15 +52,29 @@ export class MCPHubOAuthProvider implements OAuthClientProvider {
|
||||
private serverConfig: ServerConfig;
|
||||
private _codeVerifier?: string;
|
||||
private _currentState?: string;
|
||||
private _systemInstallBaseUrl?: string;
|
||||
|
||||
constructor(serverName: string, serverConfig: ServerConfig) {
|
||||
constructor(serverName: string, serverConfig: ServerConfig, systemInstallBaseUrl?: string) {
|
||||
this.serverName = serverName;
|
||||
this.serverConfig = serverConfig;
|
||||
this._systemInstallBaseUrl = systemInstallBaseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create an MCPHubOAuthProvider with async config loading
|
||||
*/
|
||||
static async create(
|
||||
serverName: string,
|
||||
serverConfig: ServerConfig,
|
||||
): Promise<MCPHubOAuthProvider> {
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
const systemInstallBaseUrl = systemConfig?.install?.baseUrl;
|
||||
return new MCPHubOAuthProvider(serverName, serverConfig, systemInstallBaseUrl);
|
||||
}
|
||||
|
||||
private getSystemInstallBaseUrl(): string | undefined {
|
||||
const settings = loadSettings();
|
||||
return settings.systemConfig?.install?.baseUrl;
|
||||
return this._systemInstallBaseUrl;
|
||||
}
|
||||
|
||||
private sanitizeRedirectUri(input?: string): string | null {
|
||||
@@ -219,18 +233,9 @@ export class MCPHubOAuthProvider implements OAuthClientProvider {
|
||||
const clientInfo = getRegisteredClient(this.serverName);
|
||||
|
||||
if (!clientInfo) {
|
||||
// Try to use static client configuration from cached serverConfig first
|
||||
let serverConfig = this.serverConfig;
|
||||
|
||||
// If cached config doesn't have clientId, reload from settings
|
||||
if (!serverConfig?.oauth?.clientId) {
|
||||
const storedConfig = loadServerConfig(this.serverName);
|
||||
|
||||
if (storedConfig) {
|
||||
this.serverConfig = storedConfig;
|
||||
serverConfig = storedConfig;
|
||||
}
|
||||
}
|
||||
// Try to use static client configuration from cached serverConfig
|
||||
// Note: we only use cache here since this is a sync method
|
||||
const serverConfig = this.serverConfig;
|
||||
|
||||
// Try to use static client configuration from serverConfig
|
||||
if (serverConfig?.oauth?.clientId) {
|
||||
@@ -288,17 +293,8 @@ export class MCPHubOAuthProvider implements OAuthClientProvider {
|
||||
* Get stored OAuth tokens
|
||||
*/
|
||||
tokens(): OAuthTokens | undefined {
|
||||
// Use cached config first, but reload if needed
|
||||
let serverConfig = this.serverConfig;
|
||||
|
||||
// If cached config doesn't have tokens, try reloading
|
||||
if (!serverConfig?.oauth?.accessToken) {
|
||||
const storedConfig = loadServerConfig(this.serverName);
|
||||
if (storedConfig) {
|
||||
this.serverConfig = storedConfig;
|
||||
serverConfig = storedConfig;
|
||||
}
|
||||
}
|
||||
// Use cached config only (tokens are updated via saveTokens which updates cache)
|
||||
const serverConfig = this.serverConfig;
|
||||
|
||||
if (!serverConfig?.oauth?.accessToken) {
|
||||
return undefined;
|
||||
@@ -441,7 +437,7 @@ export class MCPHubOAuthProvider implements OAuthClientProvider {
|
||||
return this._codeVerifier;
|
||||
}
|
||||
|
||||
const storedConfig = loadServerConfig(this.serverName);
|
||||
const storedConfig = await loadServerConfig(this.serverName);
|
||||
const storedVerifier = storedConfig?.oauth?.pendingAuthorization?.codeVerifier;
|
||||
|
||||
if (storedVerifier) {
|
||||
@@ -458,7 +454,7 @@ export class MCPHubOAuthProvider implements OAuthClientProvider {
|
||||
* This keeps stored configuration in sync and forces a fresh authorization flow.
|
||||
*/
|
||||
async invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier'): Promise<void> {
|
||||
const storedConfig = loadServerConfig(this.serverName);
|
||||
const storedConfig = await loadServerConfig(this.serverName);
|
||||
|
||||
if (!storedConfig?.oauth) {
|
||||
if (scope === 'verifier' || scope === 'all') {
|
||||
@@ -585,8 +581,8 @@ export const createOAuthProvider = async (
|
||||
// Continue anyway - the SDK might be able to handle it
|
||||
}
|
||||
|
||||
// Create and return the provider
|
||||
const provider = new MCPHubOAuthProvider(serverName, serverConfig);
|
||||
// Create and return the provider using the factory method
|
||||
const provider = await MCPHubOAuthProvider.create(serverName, serverConfig);
|
||||
|
||||
console.log(`Created OAuth provider for server: ${serverName}`);
|
||||
return provider;
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
StreamableHTTPClientTransportOptions,
|
||||
} from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { ServerInfo, ServerConfig, Tool } from '../types/index.js';
|
||||
import { loadSettings, expandEnvVars, replaceEnvVars, getNameSeparator } from '../config/index.js';
|
||||
import { expandEnvVars, replaceEnvVars, getNameSeparator } from '../config/index.js';
|
||||
import config from '../config/index.js';
|
||||
import { getGroup } from './sseService.js';
|
||||
import { getServersInGroup, getServerConfigInGroup } from './groupService.js';
|
||||
@@ -23,45 +23,13 @@ import { saveToolsAsVectorEmbeddings, searchToolsByVector } from './vectorSearch
|
||||
import { OpenAPIClient } from '../clients/openapi.js';
|
||||
import { RequestContextService } from './requestContextService.js';
|
||||
import { getDataService } from './services.js';
|
||||
import { getServerDao, ServerConfigWithName } from '../dao/index.js';
|
||||
import { getServerDao, getSystemConfigDao, ServerConfigWithName } from '../dao/index.js';
|
||||
import { initializeAllOAuthClients } from './oauthService.js';
|
||||
import { createOAuthProvider } from './mcpOAuthProvider.js';
|
||||
|
||||
const servers: { [sessionId: string]: Server } = {};
|
||||
|
||||
const serverDao = getServerDao();
|
||||
|
||||
// Helper function to set up keep-alive ping for SSE connections
|
||||
const setupKeepAlive = (serverInfo: ServerInfo, serverConfig: ServerConfig): void => {
|
||||
// Only set up keep-alive for SSE connections
|
||||
if (!(serverInfo.transport instanceof SSEClientTransport)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any existing interval first
|
||||
if (serverInfo.keepAliveIntervalId) {
|
||||
clearInterval(serverInfo.keepAliveIntervalId);
|
||||
}
|
||||
|
||||
// Use configured interval or default to 60 seconds for SSE
|
||||
const interval = serverConfig.keepAliveInterval || 60000;
|
||||
|
||||
serverInfo.keepAliveIntervalId = setInterval(async () => {
|
||||
try {
|
||||
if (serverInfo.client && serverInfo.status === 'connected') {
|
||||
await serverInfo.client.ping();
|
||||
console.log(`Keep-alive ping successful for server: ${serverInfo.name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Keep-alive ping failed for server ${serverInfo.name}:`, error);
|
||||
// TODO Consider handling reconnection logic here if needed
|
||||
}
|
||||
}, interval);
|
||||
|
||||
console.log(
|
||||
`Keep-alive ping set up for server ${serverInfo.name} with interval ${interval / 1000} seconds`,
|
||||
);
|
||||
};
|
||||
import { setupClientKeepAlive } from './keepAliveService.js';
|
||||
|
||||
export const initUpstreamServers = async (): Promise<void> => {
|
||||
// Initialize OAuth clients for servers with dynamic registration
|
||||
@@ -215,24 +183,25 @@ export const createTransportFromConfig = async (name: string, conf: ServerConfig
|
||||
};
|
||||
env['PATH'] = expandEnvVars(process.env.PATH as string) || '';
|
||||
|
||||
const settings = loadSettings();
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
// Add UV_DEFAULT_INDEX and npm_config_registry if needed
|
||||
if (
|
||||
settings.systemConfig?.install?.pythonIndexUrl &&
|
||||
systemConfig?.install?.pythonIndexUrl &&
|
||||
(conf.command === 'uvx' || conf.command === 'uv' || conf.command === 'python')
|
||||
) {
|
||||
env['UV_DEFAULT_INDEX'] = settings.systemConfig.install.pythonIndexUrl;
|
||||
env['UV_DEFAULT_INDEX'] = systemConfig.install.pythonIndexUrl;
|
||||
}
|
||||
|
||||
if (
|
||||
settings.systemConfig?.install?.npmRegistry &&
|
||||
systemConfig?.install?.npmRegistry &&
|
||||
(conf.command === 'npm' ||
|
||||
conf.command === 'npx' ||
|
||||
conf.command === 'pnpm' ||
|
||||
conf.command === 'yarn' ||
|
||||
conf.command === 'node')
|
||||
) {
|
||||
env['npm_config_registry'] = settings.systemConfig.install.npmRegistry;
|
||||
env['npm_config_registry'] = systemConfig.install.npmRegistry;
|
||||
}
|
||||
|
||||
// Expand environment variables in command
|
||||
@@ -293,7 +262,7 @@ const callToolWithReconnect = async (
|
||||
serverInfo.client.close();
|
||||
serverInfo.transport.close();
|
||||
|
||||
const server = await serverDao.findById(serverInfo.name);
|
||||
const server = await getServerDao().findById(serverInfo.name);
|
||||
if (!server) {
|
||||
throw new Error(`Server configuration not found for: ${serverInfo.name}`);
|
||||
}
|
||||
@@ -308,11 +277,7 @@ const callToolWithReconnect = async (
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
prompts: {},
|
||||
resources: {},
|
||||
tools: {},
|
||||
},
|
||||
capabilities: {},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -373,7 +338,7 @@ export const initializeClientsFromSettings = async (
|
||||
isInit: boolean,
|
||||
serverName?: string,
|
||||
): Promise<ServerInfo[]> => {
|
||||
const allServers: ServerConfigWithName[] = await serverDao.findAll();
|
||||
const allServers: ServerConfigWithName[] = await getServerDao().findAll();
|
||||
const existingServerInfos = serverInfos;
|
||||
const nextServerInfos: ServerInfo[] = [];
|
||||
|
||||
@@ -494,11 +459,7 @@ export const initializeClientsFromSettings = async (
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
prompts: {},
|
||||
resources: {},
|
||||
tools: {},
|
||||
},
|
||||
capabilities: {},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -597,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} `;
|
||||
@@ -650,11 +612,39 @@ export const registerAllTools = async (isInit: boolean, serverName?: string): Pr
|
||||
|
||||
// Get all server information
|
||||
export const getServersInfo = async (): Promise<Omit<ServerInfo, 'client' | 'transport'>[]> => {
|
||||
const allServers: ServerConfigWithName[] = await serverDao.findAll();
|
||||
const allServers: ServerConfigWithName[] = await getServerDao().findAll();
|
||||
const dataService = getDataService();
|
||||
|
||||
// Ensure that servers recently added via DAO but not yet initialized in serverInfos
|
||||
// are still visible in the servers list. This avoids a race condition where
|
||||
// a POST /api/servers immediately followed by GET /api/servers would not
|
||||
// return the newly created server until background initialization completes.
|
||||
const combinedServerInfos: ServerInfo[] = [...serverInfos];
|
||||
const existingNames = new Set(combinedServerInfos.map((s) => s.name));
|
||||
|
||||
for (const server of allServers) {
|
||||
if (!existingNames.has(server.name)) {
|
||||
const isEnabled = server.enabled === undefined ? true : server.enabled;
|
||||
combinedServerInfos.push({
|
||||
name: server.name,
|
||||
owner: server.owner,
|
||||
// Newly created servers that are enabled should appear as "connecting"
|
||||
// until the MCP client initialization completes. Disabled servers remain
|
||||
// in the "disconnected" state.
|
||||
status: isEnabled ? 'connecting' : 'disconnected',
|
||||
error: null,
|
||||
tools: [],
|
||||
prompts: [],
|
||||
createTime: Date.now(),
|
||||
enabled: isEnabled,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const filterServerInfos: ServerInfo[] = dataService.filterData
|
||||
? dataService.filterData(serverInfos)
|
||||
: serverInfos;
|
||||
? dataService.filterData(combinedServerInfos)
|
||||
: combinedServerInfos;
|
||||
|
||||
const infos = filterServerInfos.map(
|
||||
({ name, status, tools, prompts, createTime, error, oauth }) => {
|
||||
const serverConfig = allServers.find((server) => server.name === name);
|
||||
@@ -756,7 +746,7 @@ export const reconnectServer = async (serverName: string): Promise<void> => {
|
||||
|
||||
// Filter tools by server configuration
|
||||
const filterToolsByConfig = async (serverName: string, tools: Tool[]): Promise<Tool[]> => {
|
||||
const serverConfig = await serverDao.findById(serverName);
|
||||
const serverConfig = await getServerDao().findById(serverName);
|
||||
if (!serverConfig || !serverConfig.tools) {
|
||||
// If no tool configuration exists, all tools are enabled by default
|
||||
return tools;
|
||||
@@ -780,7 +770,7 @@ export const addServer = async (
|
||||
config: ServerConfig,
|
||||
): Promise<{ success: boolean; message?: string }> => {
|
||||
const server: ServerConfigWithName = { name, ...config };
|
||||
const result = await serverDao.create(server);
|
||||
const result = await getServerDao().create(server);
|
||||
if (result) {
|
||||
return { success: true, message: 'Server added successfully' };
|
||||
} else {
|
||||
@@ -792,7 +782,7 @@ export const addServer = async (
|
||||
export const removeServer = async (
|
||||
name: string,
|
||||
): Promise<{ success: boolean; message?: string }> => {
|
||||
const result = await serverDao.delete(name);
|
||||
const result = await getServerDao().delete(name);
|
||||
if (!result) {
|
||||
return { success: false, message: 'Failed to remove server' };
|
||||
}
|
||||
@@ -808,24 +798,23 @@ export const addOrUpdateServer = async (
|
||||
allowOverride: boolean = false,
|
||||
): Promise<{ success: boolean; message?: string }> => {
|
||||
try {
|
||||
const exists = await serverDao.exists(name);
|
||||
const exists = await getServerDao().exists(name);
|
||||
if (exists && !allowOverride) {
|
||||
return { success: false, message: 'Server name already exists' };
|
||||
}
|
||||
|
||||
// If overriding and this is a DXT server (stdio type with file paths),
|
||||
// we might want to clean up old files in the future
|
||||
if (exists && config.type === 'stdio') {
|
||||
// Close existing server connections
|
||||
// If overriding an existing server, close connections and clear keep-alive timers
|
||||
if (exists) {
|
||||
// Close existing server connections (clears keep-alive intervals as well)
|
||||
closeServer(name);
|
||||
// Remove from server infos
|
||||
serverInfos = serverInfos.filter((serverInfo) => serverInfo.name !== name);
|
||||
}
|
||||
|
||||
if (exists) {
|
||||
await serverDao.update(name, config);
|
||||
await getServerDao().update(name, config);
|
||||
} else {
|
||||
await serverDao.create({ name, ...config });
|
||||
await getServerDao().create({ name, ...config });
|
||||
}
|
||||
|
||||
const action = exists ? 'updated' : 'added';
|
||||
@@ -860,7 +849,7 @@ export const toggleServerStatus = async (
|
||||
enabled: boolean,
|
||||
): Promise<{ success: boolean; message?: string }> => {
|
||||
try {
|
||||
await serverDao.setEnabled(name, enabled);
|
||||
await getServerDao().setEnabled(name, enabled);
|
||||
// If disabling, disconnect the server and remove from active servers
|
||||
if (!enabled) {
|
||||
closeServer(name);
|
||||
@@ -893,33 +882,33 @@ export const handleListToolsRequest = async (_: any, extra: any) => {
|
||||
if (group === '$smart' || group?.startsWith('$smart/')) {
|
||||
// Extract target group if pattern is $smart/{group}
|
||||
const targetGroup = group?.startsWith('$smart/') ? group.substring(7) : undefined;
|
||||
|
||||
|
||||
// Get info about available servers, filtered by target group if specified
|
||||
let availableServers = serverInfos.filter(
|
||||
(server) => server.status === 'connected' && server.enabled !== false,
|
||||
);
|
||||
|
||||
|
||||
// If a target group is specified, filter servers to only those in the group
|
||||
if (targetGroup) {
|
||||
const serversInGroup = getServersInGroup(targetGroup);
|
||||
const serversInGroup = await getServersInGroup(targetGroup);
|
||||
if (serversInGroup && serversInGroup.length > 0) {
|
||||
availableServers = availableServers.filter((server) =>
|
||||
serversInGroup.includes(server.name),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Create simple server information with only server names
|
||||
const serversList = availableServers
|
||||
.map((server) => {
|
||||
return `${server.name}`;
|
||||
})
|
||||
.join(', ');
|
||||
|
||||
|
||||
const scopeDescription = targetGroup
|
||||
? `servers in the "${targetGroup}" group`
|
||||
: 'all available servers';
|
||||
|
||||
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
@@ -973,37 +962,36 @@ Available servers: ${serversList}`,
|
||||
};
|
||||
}
|
||||
|
||||
const allServerInfos = getDataService()
|
||||
.filterData(serverInfos)
|
||||
.filter((serverInfo) => {
|
||||
if (serverInfo.enabled === false) return false;
|
||||
if (!group) return true;
|
||||
const serversInGroup = getServersInGroup(group);
|
||||
if (!serversInGroup || serversInGroup.length === 0) return serverInfo.name === group;
|
||||
return serversInGroup.includes(serverInfo.name);
|
||||
});
|
||||
// Need to filter servers based on group asynchronously
|
||||
const filteredServerInfos = [];
|
||||
for (const serverInfo of getDataService().filterData(serverInfos)) {
|
||||
if (serverInfo.enabled === false) continue;
|
||||
if (!group) {
|
||||
filteredServerInfos.push(serverInfo);
|
||||
continue;
|
||||
}
|
||||
const serversInGroup = await getServersInGroup(group);
|
||||
if (!serversInGroup || serversInGroup.length === 0) {
|
||||
if (serverInfo.name === group) filteredServerInfos.push(serverInfo);
|
||||
continue;
|
||||
}
|
||||
if (serversInGroup.includes(serverInfo.name)) {
|
||||
filteredServerInfos.push(serverInfo);
|
||||
}
|
||||
}
|
||||
|
||||
const allTools = [];
|
||||
for (const serverInfo of allServerInfos) {
|
||||
for (const serverInfo of filteredServerInfos) {
|
||||
if (serverInfo.tools && serverInfo.tools.length > 0) {
|
||||
// Filter tools based on server configuration
|
||||
let enabledTools = await filterToolsByConfig(serverInfo.name, serverInfo.tools);
|
||||
let tools = await filterToolsByConfig(serverInfo.name, serverInfo.tools);
|
||||
|
||||
// If this is a group request, apply group-level tool filtering
|
||||
if (group) {
|
||||
const serverConfig = getServerConfigInGroup(group, serverInfo.name);
|
||||
if (serverConfig && serverConfig.tools !== 'all' && Array.isArray(serverConfig.tools)) {
|
||||
// Filter tools based on group configuration
|
||||
const allowedToolNames = serverConfig.tools.map(
|
||||
(toolName) => `${serverInfo.name}${getNameSeparator()}${toolName}`,
|
||||
);
|
||||
enabledTools = enabledTools.filter((tool) => allowedToolNames.includes(tool.name));
|
||||
}
|
||||
}
|
||||
tools = await filterToolsByGroup(group, serverInfo.name, tools);
|
||||
|
||||
// Apply custom descriptions from server configuration
|
||||
const serverConfig = await serverDao.findById(serverInfo.name);
|
||||
const toolsWithCustomDescriptions = enabledTools.map((tool) => {
|
||||
const serverConfig = await getServerDao().findById(serverInfo.name);
|
||||
const toolsWithCustomDescriptions = tools.map((tool) => {
|
||||
const toolConfig = serverConfig?.tools?.[tool.name];
|
||||
return {
|
||||
...tool,
|
||||
@@ -1047,20 +1035,25 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
|
||||
}
|
||||
|
||||
console.log(`Using similarity threshold: ${thresholdNum} for query: "${query}"`);
|
||||
|
||||
|
||||
// Determine server filtering based on group
|
||||
const sessionId = extra.sessionId || '';
|
||||
const group = getGroup(sessionId);
|
||||
let group = getGroup(sessionId);
|
||||
let servers: string[] | undefined = undefined; // No server filtering by default
|
||||
|
||||
|
||||
// If group is in format $smart/{group}, filter servers to that group
|
||||
if (group?.startsWith('$smart/')) {
|
||||
const targetGroup = group.substring(7);
|
||||
const serversInGroup = getServersInGroup(targetGroup);
|
||||
if (targetGroup) {
|
||||
group = targetGroup;
|
||||
}
|
||||
const serversInGroup = await getServersInGroup(targetGroup);
|
||||
if (serversInGroup !== undefined && serversInGroup !== null) {
|
||||
servers = serversInGroup;
|
||||
if (servers.length > 0) {
|
||||
console.log(`Filtering search to servers in group "${targetGroup}": ${servers.join(', ')}`);
|
||||
if (servers && servers.length > 0) {
|
||||
console.log(
|
||||
`Filtering search to servers in group "${targetGroup}": ${servers.join(', ')}`,
|
||||
);
|
||||
} else {
|
||||
console.log(`Group "${targetGroup}" has no servers, search will return no results`);
|
||||
}
|
||||
@@ -1085,10 +1078,10 @@ 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 serverDao.findById(server.name);
|
||||
const serverConfig = await getServerDao().findById(server.name);
|
||||
const toolConfig = serverConfig?.tools?.[actualTool.name];
|
||||
|
||||
// Return the actual tool info from serverInfos with custom description
|
||||
@@ -1112,19 +1105,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 = {
|
||||
@@ -1430,21 +1428,29 @@ export const handleListPromptsRequest = async (_: any, extra: any) => {
|
||||
const group = getGroup(sessionId);
|
||||
console.log(`Handling ListPromptsRequest for group: ${group}`);
|
||||
|
||||
const allServerInfos = getDataService()
|
||||
.filterData(serverInfos)
|
||||
.filter((serverInfo) => {
|
||||
if (serverInfo.enabled === false) return false;
|
||||
if (!group) return true;
|
||||
const serversInGroup = getServersInGroup(group);
|
||||
if (!serversInGroup || serversInGroup.length === 0) return serverInfo.name === group;
|
||||
return serversInGroup.includes(serverInfo.name);
|
||||
});
|
||||
// Need to filter servers based on group asynchronously
|
||||
const filteredServerInfos = [];
|
||||
for (const serverInfo of getDataService().filterData(serverInfos)) {
|
||||
if (serverInfo.enabled === false) continue;
|
||||
if (!group) {
|
||||
filteredServerInfos.push(serverInfo);
|
||||
continue;
|
||||
}
|
||||
const serversInGroup = await getServersInGroup(group);
|
||||
if (!serversInGroup || serversInGroup.length === 0) {
|
||||
if (serverInfo.name === group) filteredServerInfos.push(serverInfo);
|
||||
continue;
|
||||
}
|
||||
if (serversInGroup.includes(serverInfo.name)) {
|
||||
filteredServerInfos.push(serverInfo);
|
||||
}
|
||||
}
|
||||
|
||||
const allPrompts: any[] = [];
|
||||
for (const serverInfo of allServerInfos) {
|
||||
for (const serverInfo of filteredServerInfos) {
|
||||
if (serverInfo.prompts && serverInfo.prompts.length > 0) {
|
||||
// Filter prompts based on server configuration
|
||||
const serverConfig = await serverDao.findById(serverInfo.name);
|
||||
const serverConfig = await getServerDao().findById(serverInfo.name);
|
||||
|
||||
let enabledPrompts = serverInfo.prompts;
|
||||
if (serverConfig && serverConfig.prompts) {
|
||||
@@ -1457,7 +1463,7 @@ export const handleListPromptsRequest = async (_: any, extra: any) => {
|
||||
|
||||
// If this is a group request, apply group-level prompt filtering
|
||||
if (group) {
|
||||
const serverConfigInGroup = getServerConfigInGroup(group, serverInfo.name);
|
||||
const serverConfigInGroup = await getServerConfigInGroup(group, serverInfo.name);
|
||||
if (
|
||||
serverConfigInGroup &&
|
||||
serverConfigInGroup.tools !== 'all' &&
|
||||
@@ -1492,15 +1498,9 @@ export const createMcpServer = (name: string, version: string, group?: string):
|
||||
let serverName = name;
|
||||
|
||||
if (group) {
|
||||
// Check if it's a group or a single server
|
||||
const serversInGroup = getServersInGroup(group);
|
||||
if (!serversInGroup || serversInGroup.length === 0) {
|
||||
// Single server routing
|
||||
serverName = `${name}_${group}`;
|
||||
} else {
|
||||
// Group routing
|
||||
serverName = `${name}_${group}_group`;
|
||||
}
|
||||
// For createMcpServer we use sync approach since it's called synchronously
|
||||
// The actual group validation happens at request time
|
||||
serverName = `${name}_${group}_group`;
|
||||
}
|
||||
// If no group, use default name (global routing)
|
||||
|
||||
@@ -1514,3 +1514,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;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user