mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 02:39:19 -05:00
Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bd28ec89b | ||
|
|
41a42f82d0 | ||
|
|
7aa3ff3bb1 | ||
|
|
71667dab2c | ||
|
|
1921a0363b | ||
|
|
f9fe2e444b | ||
|
|
8d420a927b | ||
|
|
cb77593fd7 | ||
|
|
dbcebecf40 | ||
|
|
54e877cbd8 | ||
|
|
61b748151f | ||
|
|
4f05815210 | ||
|
|
691d91f207 | ||
|
|
3d58042ce5 | ||
|
|
81486b09df | ||
|
|
a41707c228 | ||
|
|
7391e57f35 | ||
|
|
9d8f5ba370 | ||
|
|
764959eaca | ||
|
|
b5dff990e5 | ||
|
|
19f11a0927 | ||
|
|
884870c9de | ||
|
|
7b8d9a7e5a | ||
|
|
8770b9ccfe | ||
|
|
063b081297 | ||
|
|
73ae33e777 | ||
|
|
dac0d376e8 | ||
|
|
803e35b14c | ||
|
|
a736398cd5 | ||
|
|
6de3221974 | ||
|
|
ac0b60ed4b | ||
|
|
a57218d076 | ||
|
|
8c985b7de1 | ||
|
|
01bb011736 | ||
|
|
449e6ea4fd | ||
|
|
1869f283ba | ||
|
|
07adeab036 | ||
|
|
5d7d8fdd1a | ||
|
|
fb847797c0 | ||
|
|
8df2b4704a | ||
|
|
602b5cb80e | ||
|
|
e63f045819 | ||
|
|
a4e4791b60 | ||
|
|
01370ea959 | ||
|
|
f5d66c1bb7 | ||
|
|
9e59dd9fb0 | ||
|
|
250487f042 | ||
|
|
da91708420 | ||
|
|
576bba1f9e | ||
|
|
f4b83929a6 | ||
|
|
3825f389cd | ||
|
|
44e0309fd4 | ||
|
|
7e570a900a | ||
|
|
6268a02c0e | ||
|
|
695d663939 | ||
|
|
d595e5d874 | ||
|
|
ff797b4ab9 | ||
|
|
9105507722 | ||
|
|
f79028ed64 | ||
|
|
5ca5e2ad47 | ||
|
|
2f7726b008 | ||
|
|
26b26a5fb1 | ||
|
|
7dbd6c386e | ||
|
|
c1fee91142 | ||
|
|
1130f6833e | ||
|
|
c3f1de8f5b |
@@ -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
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,6 +2,7 @@
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
package-lock.json
|
||||
|
||||
# production
|
||||
dist
|
||||
|
||||
78
AGENTS.md
Normal file
78
AGENTS.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Repository Guidelines
|
||||
|
||||
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
|
||||
|
||||
186
README.md
186
README.md
@@ -13,171 +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.
|
||||
- **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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
📖 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.
|
||||
|
||||
```
|
||||
http://localhost:3000/mcp/$smart
|
||||
```
|
||||
|
||||
**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
|
||||
|
||||
**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-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:
|
||||
|
||||
```
|
||||
http://localhost:3000/sse/$smart
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
@@ -188,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
|
||||
|
||||
@@ -211,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
|
||||
|
||||
182
README.zh.md
182
README.zh.md
@@ -13,171 +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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
📖 查看[配置指南](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
|
||||
```
|
||||
|
||||
**工作原理:**
|
||||
|
||||
1. **工具索引**:所有 MCP 工具自动转换为向量嵌入并存储在 PostgreSQL 与 pgvector 中
|
||||
2. **语义搜索**:用户查询转换为向量并使用余弦相似度与工具嵌入匹配
|
||||
3. **智能筛选**:动态阈值确保相关结果且无噪声
|
||||
4. **精确执行**:找到的工具可以直接执行并进行适当的参数验证
|
||||
|
||||
**设置要求:**
|
||||
|
||||

|
||||
|
||||
为了启用智能路由,您需要:
|
||||
|
||||
- 支持 pgvector 扩展的 PostgreSQL
|
||||
- OpenAI API 密钥(或兼容的嵌入服务)
|
||||
- 在 MCPHub 设置中启用智能路由
|
||||
|
||||
**基于分组的 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
|
||||
```
|
||||
|
||||
要针对特定服务器分组进行访问,请使用基于分组的 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 部署指南 |
|
||||
|
||||
## 🧑💻 本地开发
|
||||
|
||||
@@ -188,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)了解详细设置说明。
|
||||
|
||||
## 🔍 技术栈
|
||||
|
||||
@@ -211,13 +104,6 @@ proxy_buffering off
|
||||
|
||||
## 👥 贡献指南
|
||||
|
||||
期待您的贡献!
|
||||
|
||||
- 新功能与优化
|
||||
- 文档完善
|
||||
- Bug 报告与修复
|
||||
- 翻译与建议
|
||||
|
||||
欢迎加入企微交流共建群,由于群人数限制,有兴趣的同学可以扫码添加管理员为好友后拉入群聊。
|
||||
|
||||
<img src="assets/wexin.png" width="350">
|
||||
@@ -228,7 +114,7 @@ proxy_buffering off
|
||||
|
||||
## 致谢
|
||||
|
||||
感谢以下人员的赞赏:小白、琛。你们的支持是我继续前进的动力!
|
||||
感谢以下朋友的赞赏:小白、唐秀川、琛、孔、黄祥取、兰军飞、无名之辈、Kyle,以及其他匿名支持者。
|
||||
|
||||
## 🌟 Star 历史趋势
|
||||
|
||||
|
||||
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
|
||||
@@ -60,6 +60,32 @@ Generates and returns the complete OpenAPI 3.0.3 specification for all connected
|
||||
Comma-separated list of server names to include
|
||||
</ParamField>
|
||||
|
||||
### Group/Server-Specific OpenAPI Specification
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```bash GET /api/:name/openapi.json
|
||||
curl "http://localhost:3000/api/mygroup/openapi.json"
|
||||
```
|
||||
|
||||
```bash With Parameters
|
||||
curl "http://localhost:3000/api/myserver/openapi.json?title=My Server API&version=1.0.0"
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
Generates and returns the OpenAPI 3.0.3 specification for a specific group or server. If a group with the given name exists, it returns the specification for all servers in that group. Otherwise, it treats the name as a server name and returns the specification for that server only.
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
<ParamField path="name" type="string" required>
|
||||
Group ID/name or server name
|
||||
</ParamField>
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
Same as the main OpenAPI specification endpoint (title, description, version, serverUrl, includeDisabled).
|
||||
|
||||
### Available Servers
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
142
docs/api-reference/prompts.mdx
Normal file
142
docs/api-reference/prompts.mdx
Normal file
@@ -0,0 +1,142 @@
|
||||
---
|
||||
title: "Prompts"
|
||||
description: "Manage and execute MCP prompts."
|
||||
---
|
||||
|
||||
import { Card, Cards } from 'mintlify';
|
||||
|
||||
<Card
|
||||
title="POST /api/mcp/:serverName/prompts/:promptName"
|
||||
href="#get-a-prompt"
|
||||
>
|
||||
Execute a prompt on an MCP server.
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="POST /api/servers/:serverName/prompts/:promptName/toggle"
|
||||
href="#toggle-a-prompt"
|
||||
>
|
||||
Enable or disable a prompt.
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="PUT /api/servers/:serverName/prompts/:promptName/description"
|
||||
href="#update-prompt-description"
|
||||
>
|
||||
Update the description of a prompt.
|
||||
</Card>
|
||||
|
||||
---
|
||||
|
||||
### Get a Prompt
|
||||
|
||||
Execute a prompt on an MCP server and get the result.
|
||||
|
||||
- **Endpoint**: `/api/mcp/:serverName/prompts/:promptName`
|
||||
- **Method**: `POST`
|
||||
- **Authentication**: Required
|
||||
- **Parameters**:
|
||||
- `:serverName` (string, required): The name of the MCP server.
|
||||
- `:promptName` (string, required): The name of the prompt.
|
||||
- **Body**:
|
||||
```json
|
||||
{
|
||||
"arguments": {
|
||||
"arg1": "value1",
|
||||
"arg2": "value2"
|
||||
}
|
||||
}
|
||||
```
|
||||
- `arguments` (object, optional): Arguments to pass to the prompt.
|
||||
|
||||
- **Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "Prompt content"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Example Request:**
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/api/mcp/myserver/prompts/code-review" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-d '{
|
||||
"arguments": {
|
||||
"language": "typescript",
|
||||
"code": "const x = 1;"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Toggle a Prompt
|
||||
|
||||
Enable or disable a specific prompt on a server.
|
||||
|
||||
- **Endpoint**: `/api/servers/:serverName/prompts/:promptName/toggle`
|
||||
- **Method**: `POST`
|
||||
- **Authentication**: Required
|
||||
- **Parameters**:
|
||||
- `:serverName` (string, required): The name of the server.
|
||||
- `:promptName` (string, required): The name of the prompt.
|
||||
- **Body**:
|
||||
```json
|
||||
{
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
- `enabled` (boolean, required): `true` to enable the prompt, `false` to disable it.
|
||||
|
||||
**Example Request:**
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/api/servers/myserver/prompts/code-review/toggle" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-d '{"enabled": false}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Update Prompt Description
|
||||
|
||||
Update the description of a specific prompt.
|
||||
|
||||
- **Endpoint**: `/api/servers/:serverName/prompts/:promptName/description`
|
||||
- **Method**: `PUT`
|
||||
- **Authentication**: Required
|
||||
- **Parameters**:
|
||||
- `:serverName` (string, required): The name of the server.
|
||||
- `:promptName` (string, required): The name of the prompt.
|
||||
- **Body**:
|
||||
```json
|
||||
{
|
||||
"description": "New prompt description"
|
||||
}
|
||||
```
|
||||
- `description` (string, required): The new description for the prompt.
|
||||
|
||||
**Example Request:**
|
||||
|
||||
```bash
|
||||
curl -X PUT "http://localhost:3000/api/servers/myserver/prompts/code-review/description" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-d '{"description": "Review code for best practices and potential issues"}'
|
||||
```
|
||||
|
||||
**Note**: Prompts are templates that can be used to generate standardized requests to MCP servers. They are defined by the MCP server and can have arguments that are filled in when the prompt is executed.
|
||||
@@ -54,6 +54,20 @@ import { Card, Cards } from 'mintlify';
|
||||
Update the description of a tool.
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="PUT /api/system-config"
|
||||
href="#update-system-config"
|
||||
>
|
||||
Update system configuration settings.
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="GET /api/settings"
|
||||
href="#get-settings"
|
||||
>
|
||||
Get all server settings and configurations.
|
||||
</Card>
|
||||
|
||||
---
|
||||
|
||||
### Get All Servers
|
||||
@@ -207,3 +221,45 @@ Updates the description of a specific tool.
|
||||
}
|
||||
```
|
||||
- `description` (string, required): The new description for the tool.
|
||||
|
||||
---
|
||||
|
||||
### Update System Config
|
||||
|
||||
Updates the system-wide configuration settings.
|
||||
|
||||
- **Endpoint**: `/api/system-config`
|
||||
- **Method**: `PUT`
|
||||
- **Body**:
|
||||
```json
|
||||
{
|
||||
"openaiApiKey": "sk-...",
|
||||
"openaiBaseUrl": "https://api.openai.com/v1",
|
||||
"modelName": "gpt-4",
|
||||
"temperature": 0.7,
|
||||
"maxTokens": 2048
|
||||
}
|
||||
```
|
||||
- All fields are optional. Only provided fields will be updated.
|
||||
|
||||
---
|
||||
|
||||
### Get Settings
|
||||
|
||||
Retrieves all server settings and configurations.
|
||||
|
||||
- **Endpoint**: `/api/settings`
|
||||
- **Method**: `GET`
|
||||
- **Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"servers": [...],
|
||||
"groups": [...],
|
||||
"systemConfig": {...}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: For detailed prompt management, see the [Prompts API](/api-reference/prompts) documentation.
|
||||
|
||||
113
docs/api-reference/system.mdx
Normal file
113
docs/api-reference/system.mdx
Normal file
@@ -0,0 +1,113 @@
|
||||
---
|
||||
title: "System"
|
||||
description: "System and utility endpoints."
|
||||
---
|
||||
|
||||
import { Card, Cards } from 'mintlify';
|
||||
|
||||
<Card
|
||||
title="GET /health"
|
||||
href="#health-check"
|
||||
>
|
||||
Check the health status of the MCPHub server.
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="GET /oauth/callback"
|
||||
href="#oauth-callback"
|
||||
>
|
||||
OAuth callback endpoint for authentication flows.
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="POST /api/dxt/upload"
|
||||
href="#upload-dxt-file"
|
||||
>
|
||||
Upload a DXT configuration file.
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="GET /api/mcp-settings/export"
|
||||
href="#export-mcp-settings"
|
||||
>
|
||||
Export MCP settings as JSON.
|
||||
</Card>
|
||||
|
||||
---
|
||||
|
||||
### Health Check
|
||||
|
||||
Check the health status of the MCPHub server.
|
||||
|
||||
- **Endpoint**: `/health`
|
||||
- **Method**: `GET`
|
||||
- **Authentication**: Not required
|
||||
- **Response**:
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"timestamp": "2024-11-12T01:30:00.000Z",
|
||||
"uptime": 12345
|
||||
}
|
||||
```
|
||||
|
||||
**Example Request:**
|
||||
|
||||
```bash
|
||||
curl "http://localhost:3000/health"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### OAuth Callback
|
||||
|
||||
OAuth callback endpoint for handling OAuth authentication flows. This endpoint is automatically called by OAuth providers after user authorization.
|
||||
|
||||
- **Endpoint**: `/oauth/callback`
|
||||
- **Method**: `GET`
|
||||
- **Authentication**: Not required (public callback URL)
|
||||
- **Query Parameters**: Varies by OAuth provider (typically includes `code`, `state`, etc.)
|
||||
|
||||
**Note**: This endpoint is used internally by MCPHub's OAuth integration and should not be called directly by clients.
|
||||
|
||||
---
|
||||
|
||||
### Upload DXT File
|
||||
|
||||
Upload a DXT (Desktop Extension) configuration file to import server configurations.
|
||||
|
||||
- **Endpoint**: `/api/dxt/upload`
|
||||
- **Method**: `POST`
|
||||
- **Authentication**: Required
|
||||
- **Content-Type**: `multipart/form-data`
|
||||
- **Body**:
|
||||
- `file` (file, required): The DXT configuration file to upload.
|
||||
|
||||
**Example Request:**
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/api/dxt/upload" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-F "file=@config.dxt"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Export MCP Settings
|
||||
|
||||
Export the current MCP settings configuration as a JSON file.
|
||||
|
||||
- **Endpoint**: `/api/mcp-settings/export`
|
||||
- **Method**: `GET`
|
||||
- **Authentication**: Required
|
||||
- **Response**: Returns the `mcp_settings.json` configuration file.
|
||||
|
||||
**Example Request:**
|
||||
|
||||
```bash
|
||||
curl "http://localhost:3000/api/mcp-settings/export" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-o mcp_settings.json
|
||||
```
|
||||
|
||||
**Note**: This endpoint allows you to download a backup of your MCP settings, which can be used to restore or migrate your configuration.
|
||||
86
docs/api-reference/tools.mdx
Normal file
86
docs/api-reference/tools.mdx
Normal file
@@ -0,0 +1,86 @@
|
||||
---
|
||||
title: "Tools"
|
||||
description: "Execute MCP tools programmatically."
|
||||
---
|
||||
|
||||
import { Card, Cards } from 'mintlify';
|
||||
|
||||
<Card
|
||||
title="POST /api/tools/call/:server"
|
||||
href="#call-a-tool"
|
||||
>
|
||||
Call a specific tool on an MCP server.
|
||||
</Card>
|
||||
|
||||
---
|
||||
|
||||
### Call a Tool
|
||||
|
||||
Execute a specific tool on an MCP server with given arguments.
|
||||
|
||||
- **Endpoint**: `/api/tools/call/:server`
|
||||
- **Method**: `POST`
|
||||
- **Parameters**:
|
||||
- `:server` (string, required): The name of the MCP server.
|
||||
- **Body**:
|
||||
```json
|
||||
{
|
||||
"toolName": "tool-name",
|
||||
"arguments": {
|
||||
"param1": "value1",
|
||||
"param2": "value2"
|
||||
}
|
||||
}
|
||||
```
|
||||
- `toolName` (string, required): The name of the tool to execute.
|
||||
- `arguments` (object, optional): The arguments to pass to the tool. Defaults to an empty object.
|
||||
|
||||
- **Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Tool execution result"
|
||||
}
|
||||
],
|
||||
"toolName": "tool-name",
|
||||
"arguments": {
|
||||
"param1": "value1",
|
||||
"param2": "value2"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Example Request:**
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/api/tools/call/amap" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-d '{
|
||||
"toolName": "amap-maps_weather",
|
||||
"arguments": {
|
||||
"city": "Beijing"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- The tool arguments are automatically converted to the proper types based on the tool's input schema.
|
||||
- Use the `x-session-id` header to maintain session state across multiple tool calls if needed.
|
||||
- This endpoint requires authentication.
|
||||
|
||||
---
|
||||
|
||||
### Alternative: OpenAPI Tool Execution
|
||||
|
||||
For OpenAPI-compatible tool execution without authentication, see the [OpenAPI Integration](/api-reference/openapi#tool-execution) documentation. The OpenAPI endpoints provide:
|
||||
|
||||
- **GET** `/api/tools/:serverName/:toolName` - For simple tools with query parameters
|
||||
- **POST** `/api/tools/:serverName/:toolName` - For complex tools with JSON body
|
||||
|
||||
These endpoints are designed for integration with OpenWebUI and other OpenAPI-compatible systems.
|
||||
195
docs/api-reference/users.mdx
Normal file
195
docs/api-reference/users.mdx
Normal file
@@ -0,0 +1,195 @@
|
||||
---
|
||||
title: "Users"
|
||||
description: "Manage users in MCPHub."
|
||||
---
|
||||
|
||||
import { Card, Cards } from 'mintlify';
|
||||
|
||||
<Card
|
||||
title="GET /api/users"
|
||||
href="#get-all-users"
|
||||
>
|
||||
Get a list of all users.
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="GET /api/users/:username"
|
||||
href="#get-a-user"
|
||||
>
|
||||
Get details of a specific user.
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="POST /api/users"
|
||||
href="#create-a-user"
|
||||
>
|
||||
Create a new user.
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="PUT /api/users/:username"
|
||||
href="#update-a-user"
|
||||
>
|
||||
Update an existing user.
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="DELETE /api/users/:username"
|
||||
href="#delete-a-user"
|
||||
>
|
||||
Delete a user.
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="GET /api/users-stats"
|
||||
href="#get-user-statistics"
|
||||
>
|
||||
Get statistics about users and their server access.
|
||||
</Card>
|
||||
|
||||
---
|
||||
|
||||
### Get All Users
|
||||
|
||||
Retrieves a list of all users in the system.
|
||||
|
||||
- **Endpoint**: `/api/users`
|
||||
- **Method**: `GET`
|
||||
- **Authentication**: Required (Admin only)
|
||||
- **Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"username": "admin",
|
||||
"role": "admin",
|
||||
"servers": ["server1", "server2"],
|
||||
"groups": ["group1"]
|
||||
},
|
||||
{
|
||||
"username": "user1",
|
||||
"role": "user",
|
||||
"servers": ["server1"],
|
||||
"groups": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get a User
|
||||
|
||||
Retrieves details of a specific user.
|
||||
|
||||
- **Endpoint**: `/api/users/:username`
|
||||
- **Method**: `GET`
|
||||
- **Authentication**: Required (Admin only)
|
||||
- **Parameters**:
|
||||
- `:username` (string, required): The username of the user.
|
||||
- **Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"username": "user1",
|
||||
"role": "user",
|
||||
"servers": ["server1", "server2"],
|
||||
"groups": ["group1"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Create a User
|
||||
|
||||
Creates a new user in the system.
|
||||
|
||||
- **Endpoint**: `/api/users`
|
||||
- **Method**: `POST`
|
||||
- **Authentication**: Required (Admin only)
|
||||
- **Body**:
|
||||
```json
|
||||
{
|
||||
"username": "newuser",
|
||||
"password": "securepassword",
|
||||
"role": "user",
|
||||
"servers": ["server1"],
|
||||
"groups": ["group1"]
|
||||
}
|
||||
```
|
||||
- `username` (string, required): The username for the new user.
|
||||
- `password` (string, required): The password for the new user. Must be at least 6 characters.
|
||||
- `role` (string, optional): The role of the user. Either `"admin"` or `"user"`. Defaults to `"user"`.
|
||||
- `servers` (array of strings, optional): List of server names the user has access to.
|
||||
- `groups` (array of strings, optional): List of group IDs the user belongs to.
|
||||
|
||||
---
|
||||
|
||||
### Update a User
|
||||
|
||||
Updates an existing user's information.
|
||||
|
||||
- **Endpoint**: `/api/users/:username`
|
||||
- **Method**: `PUT`
|
||||
- **Authentication**: Required (Admin only)
|
||||
- **Parameters**:
|
||||
- `:username` (string, required): The username of the user to update.
|
||||
- **Body**:
|
||||
```json
|
||||
{
|
||||
"password": "newpassword",
|
||||
"role": "admin",
|
||||
"servers": ["server1", "server2", "server3"],
|
||||
"groups": ["group1", "group2"]
|
||||
}
|
||||
```
|
||||
- `password` (string, optional): New password for the user.
|
||||
- `role` (string, optional): New role for the user.
|
||||
- `servers` (array of strings, optional): Updated list of accessible servers.
|
||||
- `groups` (array of strings, optional): Updated list of groups.
|
||||
|
||||
---
|
||||
|
||||
### Delete a User
|
||||
|
||||
Removes a user from the system.
|
||||
|
||||
- **Endpoint**: `/api/users/:username`
|
||||
- **Method**: `DELETE`
|
||||
- **Authentication**: Required (Admin only)
|
||||
- **Parameters**:
|
||||
- `:username` (string, required): The username of the user to delete.
|
||||
|
||||
---
|
||||
|
||||
### Get User Statistics
|
||||
|
||||
Retrieves statistics about users and their access to servers and groups.
|
||||
|
||||
- **Endpoint**: `/api/users-stats`
|
||||
- **Method**: `GET`
|
||||
- **Authentication**: Required (Admin only)
|
||||
- **Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"totalUsers": 5,
|
||||
"adminUsers": 1,
|
||||
"regularUsers": 4,
|
||||
"usersPerServer": {
|
||||
"server1": 3,
|
||||
"server2": 2
|
||||
},
|
||||
"usersPerGroup": {
|
||||
"group1": 2,
|
||||
"group2": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: All user management endpoints require admin authentication.
|
||||
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的数据管理变得更加结构化、可维护和可扩展。
|
||||
@@ -78,7 +78,7 @@ git clone https://github.com/YOUR_USERNAME/mcphub.git
|
||||
cd mcphub
|
||||
|
||||
# 2. Add upstream remote
|
||||
git remote add upstream https://github.com/mcphub/mcphub.git
|
||||
git remote add upstream https://github.com/samanhappy/mcphub.git
|
||||
|
||||
# 3. Install dependencies
|
||||
pnpm install
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
"pages": [
|
||||
"features/server-management",
|
||||
"features/group-management",
|
||||
"features/smart-routing"
|
||||
"features/smart-routing",
|
||||
"features/oauth"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -36,7 +37,8 @@
|
||||
"configuration/mcp-settings",
|
||||
"configuration/environment-variables",
|
||||
"configuration/docker-setup",
|
||||
"configuration/nginx"
|
||||
"configuration/nginx",
|
||||
"configuration/database-configuration"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -57,7 +59,8 @@
|
||||
"pages": [
|
||||
"zh/features/server-management",
|
||||
"zh/features/group-management",
|
||||
"zh/features/smart-routing"
|
||||
"zh/features/smart-routing",
|
||||
"zh/features/oauth"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -66,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"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -94,9 +98,13 @@
|
||||
"pages": [
|
||||
"api-reference/servers",
|
||||
"api-reference/groups",
|
||||
"api-reference/users",
|
||||
"api-reference/tools",
|
||||
"api-reference/prompts",
|
||||
"api-reference/auth",
|
||||
"api-reference/logs",
|
||||
"api-reference/config"
|
||||
"api-reference/config",
|
||||
"api-reference/system"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -124,9 +132,13 @@
|
||||
"pages": [
|
||||
"zh/api-reference/servers",
|
||||
"zh/api-reference/groups",
|
||||
"zh/api-reference/users",
|
||||
"zh/api-reference/tools",
|
||||
"zh/api-reference/prompts",
|
||||
"zh/api-reference/auth",
|
||||
"zh/api-reference/logs",
|
||||
"zh/api-reference/config"
|
||||
"zh/api-reference/config",
|
||||
"zh/api-reference/system"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
141
docs/features/oauth.mdx
Normal file
141
docs/features/oauth.mdx
Normal file
@@ -0,0 +1,141 @@
|
||||
# OAuth Support
|
||||
|
||||
## At a Glance
|
||||
- Covers end-to-end OAuth 2.0 Authorization Code with PKCE for upstream MCP servers.
|
||||
- Supports automatic discovery from `WWW-Authenticate` responses and RFC 8414 metadata.
|
||||
- Implements dynamic client registration (RFC 7591) and resource indicators (RFC 8707).
|
||||
- Persists client credentials and tokens to `mcp_settings.json` for reconnects.
|
||||
|
||||
## When MCPHub Switches to OAuth
|
||||
1. MCPHub calls an MCP server that requires authorization and receives `401 Unauthorized`.
|
||||
2. The response exposes a `WWW-Authenticate` header pointing to protected resource metadata (`authorization_server` or `as_uri`).
|
||||
3. MCPHub discovers the authorization server metadata, registers (if needed), and opens the browser so the user can authorize once.
|
||||
4. After the callback is handled, MCPHub reconnects with fresh tokens and resumes requests transparently.
|
||||
|
||||
> MCPHub logs each stage (discovery, registration, authorization URL, token exchange) in the server detail view and the backend logs.
|
||||
|
||||
## Quick Start by Server Type
|
||||
|
||||
### Servers with Dynamic Registration Support
|
||||
Some servers expose complete OAuth metadata and allow dynamic client registration. For example, Vercel and Linear MCP servers only need their SSE endpoint configured:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"vercel": {
|
||||
"type": "sse",
|
||||
"url": "https://mcp.vercel.com"
|
||||
},
|
||||
"linear": {
|
||||
"type": "sse",
|
||||
"url": "https://mcp.linear.app/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- MCPHub discovers the authorization server, registers the client, and handles PKCE automatically.
|
||||
- Tokens are stored in `mcp_settings.json`; no additional dashboard configuration is needed.
|
||||
|
||||
### Servers Requiring Manual Client Provisioning
|
||||
Other providers do not support dynamic registration. GitHub’s MCP endpoint (`https://api.githubcopilot.com/mcp/`) is one example. To connect:
|
||||
|
||||
1. Create an OAuth App in the provider’s console (for GitHub, go to **Settings → Developer settings → OAuth Apps**).
|
||||
2. Set the callback/redirect URL to `http://localhost:3000/oauth/callback` (or your deployed dashboard domain).
|
||||
3. Copy the issued client ID and client secret.
|
||||
4. Supply the credentials through the MCPHub dashboard or by editing `mcp_settings.json` as shown below.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
"type": "sse",
|
||||
"url": "https://api.githubcopilot.com/mcp/",
|
||||
"oauth": {
|
||||
"clientId": "${GITHUB_OAUTH_APP_ID}",
|
||||
"clientSecret": "${GITHUB_OAUTH_APP_SECRET}",
|
||||
"scopes": ["replace-with-provider-scope"],
|
||||
"resource": "https://api.githubcopilot.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- MCPHub skips dynamic registration and uses the credentials you provide to complete the OAuth exchange.
|
||||
- Update the dashboard or configuration file whenever you rotate secrets.
|
||||
- Replace `scopes` with the exact scope strings required by the provider.
|
||||
|
||||
## Configuration Options
|
||||
You can rely on auto-detection for most servers or declare OAuth settings explicitly in `mcp_settings.json`. Only populate the fields you need.
|
||||
|
||||
### Basic Auto Detection (Minimal Config)
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"secured-sse": {
|
||||
"type": "sse",
|
||||
"url": "https://mcp.example.com/sse",
|
||||
"oauth": {
|
||||
"scopes": ["mcp.tools", "mcp.prompts"],
|
||||
"resource": "https://mcp.example.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- MCPHub will discover the authorization server from challenge headers and walk the user through authorization automatically.
|
||||
- Tokens (including refresh tokens) are stored on disk and reused on restart.
|
||||
|
||||
### Static Client Credentials (Bring Your Own Client)
|
||||
```json
|
||||
{
|
||||
"oauth": {
|
||||
"clientId": "mcphub-client",
|
||||
"clientSecret": "replace-me-if-required",
|
||||
"authorizationEndpoint": "https://auth.example.com/oauth/authorize",
|
||||
"tokenEndpoint": "https://auth.example.com/oauth/token",
|
||||
"redirectUri": "http://localhost:3000/oauth/callback"
|
||||
}
|
||||
}
|
||||
```
|
||||
- Use this when the authorization server requires manual client provisioning.
|
||||
- `redirectUri` defaults to `http://localhost:3000/oauth/callback`; override it when running behind a custom domain.
|
||||
|
||||
### Dynamic Client Registration (RFC 7591)
|
||||
```json
|
||||
{
|
||||
"oauth": {
|
||||
"dynamicRegistration": {
|
||||
"enabled": true,
|
||||
"issuer": "https://auth.example.com",
|
||||
"metadata": {
|
||||
"client_name": "MCPHub",
|
||||
"redirect_uris": [
|
||||
"http://localhost:3000/oauth/callback",
|
||||
"https://mcphub.example.com/oauth/callback"
|
||||
],
|
||||
"scope": "mcp.tools mcp.prompts",
|
||||
"grant_types": ["authorization_code", "refresh_token"]
|
||||
},
|
||||
"initialAccessToken": "optional-token-if-required"
|
||||
},
|
||||
"scopes": ["mcp.tools", "mcp.prompts"],
|
||||
"resource": "https://mcp.example.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
- MCPHub discovers endpoints via `issuer`, registers itself, and persists the issued `client_id`/`client_secret`.
|
||||
- Provide `initialAccessToken` only when the registration endpoint is protected.
|
||||
|
||||
## Authorization Flow
|
||||
1. **Initialization** – On startup MCPHub processes every server entry, discovers metadata, and registers the client if `dynamicRegistration.enabled` is true.
|
||||
2. **User Authorization** – Initiating a connection launches the system browser to the server’s authorize page with PKCE parameters.
|
||||
3. **Callback Handling** – The built-in route (`/oauth/callback`) verifies the `state`, completes the token exchange, and saves the tokens via the MCP SDK.
|
||||
4. **Token Lifecycle** – Access and refresh tokens are cached in memory, refreshed automatically, and written back to `mcp_settings.json`.
|
||||
|
||||
## Tips & Troubleshooting
|
||||
- Confirm that the redirect URI used during authorization exactly matches one of the `redirect_uris` registered with the authorization server.
|
||||
- When running behind HTTPS, expose the callback URL publicly or configure a reverse proxy at `/oauth/callback`.
|
||||
- If discovery fails, supply `authorizationEndpoint` and `tokenEndpoint` explicitly to bypass metadata lookup.
|
||||
- Remove stale tokens from `mcp_settings.json` if an authorization server revokes access—MCPHub will prompt for a fresh login on the next request.
|
||||
@@ -276,17 +276,92 @@ Access Smart Routing through the special `$smart` endpoint:
|
||||
<Tabs>
|
||||
<Tab title="HTTP MCP">
|
||||
```
|
||||
# Search across all servers
|
||||
http://localhost:3000/mcp/$smart
|
||||
|
||||
# Search within a specific group
|
||||
http://localhost:3000/mcp/$smart/{group}
|
||||
```
|
||||
</Tab>
|
||||
|
||||
<Tab title="SSE (Legacy)">
|
||||
```
|
||||
# Search across all servers
|
||||
http://localhost:3000/sse/$smart
|
||||
|
||||
# Search within a specific group
|
||||
http://localhost:3000/sse/$smart/{group}
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Group-Scoped Smart Routing
|
||||
|
||||
Smart Routing now supports group-scoped searches, allowing you to limit tool discovery to servers within a specific group:
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Using Group-Scoped Smart Routing">
|
||||
Connect your AI client to a group-specific Smart Routing endpoint:
|
||||
|
||||
```
|
||||
http://localhost:3000/mcp/$smart/production
|
||||
```
|
||||
|
||||
This endpoint will only search for tools within servers that belong to the "production" group.
|
||||
|
||||
**Benefits:**
|
||||
- **Focused Results**: Only tools from relevant servers are returned
|
||||
- **Better Performance**: Reduced search space for faster queries
|
||||
- **Environment Isolation**: Keep development, staging, and production tools separate
|
||||
- **Access Control**: Limit tool discovery based on user permissions
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Example: Environment-Based Groups">
|
||||
Create groups for different environments:
|
||||
|
||||
```bash
|
||||
# Development environment
|
||||
http://localhost:3000/mcp/$smart/development
|
||||
|
||||
# Staging environment
|
||||
http://localhost:3000/mcp/$smart/staging
|
||||
|
||||
# Production environment
|
||||
http://localhost:3000/mcp/$smart/production
|
||||
```
|
||||
|
||||
Each endpoint will only return tools from servers in that specific environment group.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Example: Team-Based Groups">
|
||||
Organize tools by team or department:
|
||||
|
||||
```bash
|
||||
# Backend team tools
|
||||
http://localhost:3000/mcp/$smart/backend-team
|
||||
|
||||
# Frontend team tools
|
||||
http://localhost:3000/mcp/$smart/frontend-team
|
||||
|
||||
# DevOps team tools
|
||||
http://localhost:3000/mcp/$smart/devops-team
|
||||
```
|
||||
|
||||
This enables teams to have focused access to their relevant toolsets.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="How It Works">
|
||||
When using `$smart/{group}`:
|
||||
|
||||
1. The system identifies the specified group
|
||||
2. Retrieves all servers belonging to that group
|
||||
3. Filters the tool search to only those servers
|
||||
4. Returns results scoped to the group's servers
|
||||
|
||||
If the group doesn't exist or has no servers, the search will return no results.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
{/* ### Basic Usage
|
||||
|
||||
Connect your AI client to the Smart Routing endpoint and make natural language requests:
|
||||
|
||||
@@ -294,22 +294,47 @@ Optional for Smart Routing:
|
||||
labels:
|
||||
app: mcphub
|
||||
spec:
|
||||
initContainers:
|
||||
- name: prepare-config
|
||||
image: busybox:1.28
|
||||
command:
|
||||
[
|
||||
"sh",
|
||||
"-c",
|
||||
"cp /config-ro/mcp_settings.json /etc/mcphub/mcp_settings.json",
|
||||
]
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /config-ro
|
||||
readOnly: true
|
||||
- name: app-storage
|
||||
mountPath: /etc/mcphub
|
||||
containers:
|
||||
- name: mcphub
|
||||
image: samanhappy/mcphub:latest
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
env:
|
||||
- name: PORT
|
||||
value: "3000"
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /app/mcp_settings.json
|
||||
subPath: mcp_settings.json
|
||||
- name: mcphub
|
||||
image: samanhappy/mcphub:latest
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
env:
|
||||
- name: PORT
|
||||
value: "3000"
|
||||
- name: MCPHUB_SETTING_PATH
|
||||
value: /etc/mcphub/mcp_settings.json
|
||||
volumeMounts:
|
||||
- name: app-storage
|
||||
mountPath: /etc/mcphub
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: mcphub-config
|
||||
- name: config
|
||||
configMap:
|
||||
name: mcphub-config
|
||||
- name: app-storage
|
||||
emptyDir: {}
|
||||
```
|
||||
|
||||
#### 3. Service
|
||||
|
||||
@@ -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. **质量保证**: 代码覆盖率和持续测试验证
|
||||
|
||||
这个测试框架为项目的持续发展和质量保证提供了坚实的基础,支持敏捷开发和持续集成的最佳实践。
|
||||
@@ -60,6 +60,32 @@ curl "http://localhost:3000/api/openapi.json?title=我的 MCP API&version=2.0.0"
|
||||
要包含的服务器名称列表(逗号分隔)
|
||||
</ParamField>
|
||||
|
||||
### 组/服务器特定的 OpenAPI 规范
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```bash GET /api/:name/openapi.json
|
||||
curl "http://localhost:3000/api/mygroup/openapi.json"
|
||||
```
|
||||
|
||||
```bash 带参数
|
||||
curl "http://localhost:3000/api/myserver/openapi.json?title=我的服务器 API&version=1.0.0"
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
为特定组或服务器生成并返回 OpenAPI 3.0.3 规范。如果存在具有给定名称的组,则返回该组中所有服务器的规范。否则,将名称视为服务器名称并仅返回该服务器的规范。
|
||||
|
||||
**路径参数:**
|
||||
|
||||
<ParamField path="name" type="string" required>
|
||||
组 ID/名称或服务器名称
|
||||
</ParamField>
|
||||
|
||||
**查询参数:**
|
||||
|
||||
与主 OpenAPI 规范端点相同(title、description、version、serverUrl、includeDisabled)。
|
||||
|
||||
### 可用服务器
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
142
docs/zh/api-reference/prompts.mdx
Normal file
142
docs/zh/api-reference/prompts.mdx
Normal file
@@ -0,0 +1,142 @@
|
||||
---
|
||||
title: "提示词"
|
||||
description: "管理和执行 MCP 提示词。"
|
||||
---
|
||||
|
||||
import { Card, Cards } from 'mintlify';
|
||||
|
||||
<Card
|
||||
title="POST /api/mcp/:serverName/prompts/:promptName"
|
||||
href="#get-a-prompt"
|
||||
>
|
||||
在 MCP 服务器上执行提示词。
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="POST /api/servers/:serverName/prompts/:promptName/toggle"
|
||||
href="#toggle-a-prompt"
|
||||
>
|
||||
启用或禁用提示词。
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="PUT /api/servers/:serverName/prompts/:promptName/description"
|
||||
href="#update-prompt-description"
|
||||
>
|
||||
更新提示词的描述。
|
||||
</Card>
|
||||
|
||||
---
|
||||
|
||||
### 获取提示词
|
||||
|
||||
在 MCP 服务器上执行提示词并获取结果。
|
||||
|
||||
- **端点**: `/api/mcp/:serverName/prompts/:promptName`
|
||||
- **方法**: `POST`
|
||||
- **身份验证**: 必需
|
||||
- **参数**:
|
||||
- `:serverName` (字符串, 必需): MCP 服务器的名称。
|
||||
- `:promptName` (字符串, 必需): 提示词的名称。
|
||||
- **请求正文**:
|
||||
```json
|
||||
{
|
||||
"arguments": {
|
||||
"arg1": "value1",
|
||||
"arg2": "value2"
|
||||
}
|
||||
}
|
||||
```
|
||||
- `arguments` (对象, 可选): 传递给提示词的参数。
|
||||
|
||||
- **响应**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "提示词内容"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**请求示例:**
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/api/mcp/myserver/prompts/code-review" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-d '{
|
||||
"arguments": {
|
||||
"language": "typescript",
|
||||
"code": "const x = 1;"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 切换提示词
|
||||
|
||||
启用或禁用服务器上的特定提示词。
|
||||
|
||||
- **端点**: `/api/servers/:serverName/prompts/:promptName/toggle`
|
||||
- **方法**: `POST`
|
||||
- **身份验证**: 必需
|
||||
- **参数**:
|
||||
- `:serverName` (字符串, 必需): 服务器的名称。
|
||||
- `:promptName` (字符串, 必需): 提示词的名称。
|
||||
- **请求正文**:
|
||||
```json
|
||||
{
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
- `enabled` (布尔值, 必需): `true` 启用提示词, `false` 禁用提示词。
|
||||
|
||||
**请求示例:**
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/api/servers/myserver/prompts/code-review/toggle" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-d '{"enabled": false}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 更新提示词描述
|
||||
|
||||
更新特定提示词的描述。
|
||||
|
||||
- **端点**: `/api/servers/:serverName/prompts/:promptName/description`
|
||||
- **方法**: `PUT`
|
||||
- **身份验证**: 必需
|
||||
- **参数**:
|
||||
- `:serverName` (字符串, 必需): 服务器的名称。
|
||||
- `:promptName` (字符串, 必需): 提示词的名称。
|
||||
- **请求正文**:
|
||||
```json
|
||||
{
|
||||
"description": "新的提示词描述"
|
||||
}
|
||||
```
|
||||
- `description` (字符串, 必需): 提示词的新描述。
|
||||
|
||||
**请求示例:**
|
||||
|
||||
```bash
|
||||
curl -X PUT "http://localhost:3000/api/servers/myserver/prompts/code-review/description" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-d '{"description": "审查代码的最佳实践和潜在问题"}'
|
||||
```
|
||||
|
||||
**注意**: 提示词是可用于生成标准化请求到 MCP 服务器的模板。它们由 MCP 服务器定义,并且可以具有在执行提示词时填充的参数。
|
||||
@@ -54,6 +54,20 @@ import { Card, Cards } from 'mintlify';
|
||||
更新工具的描述。
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="PUT /api/system-config"
|
||||
href="#update-system-config"
|
||||
>
|
||||
更新系统配置设置。
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="GET /api/settings"
|
||||
href="#get-settings"
|
||||
>
|
||||
获取所有服务器设置和配置。
|
||||
</Card>
|
||||
|
||||
---
|
||||
|
||||
### 获取所有服务器
|
||||
@@ -207,3 +221,45 @@ import { Card, Cards } from 'mintlify';
|
||||
}
|
||||
```
|
||||
- `description` (string, 必填): 工具的新描述。
|
||||
|
||||
---
|
||||
|
||||
### 更新系统配置
|
||||
|
||||
更新系统范围的配置设置。
|
||||
|
||||
- **端点**: `/api/system-config`
|
||||
- **方法**: `PUT`
|
||||
- **正文**:
|
||||
```json
|
||||
{
|
||||
"openaiApiKey": "sk-...",
|
||||
"openaiBaseUrl": "https://api.openai.com/v1",
|
||||
"modelName": "gpt-4",
|
||||
"temperature": 0.7,
|
||||
"maxTokens": 2048
|
||||
}
|
||||
```
|
||||
- 所有字段都是可选的。只有提供的字段会被更新。
|
||||
|
||||
---
|
||||
|
||||
### 获取设置
|
||||
|
||||
检索所有服务器设置和配置。
|
||||
|
||||
- **端点**: `/api/settings`
|
||||
- **方法**: `GET`
|
||||
- **响应**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"servers": [...],
|
||||
"groups": [...],
|
||||
"systemConfig": {...}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**注意**: 有关详细的提示词管理,请参阅 [提示词 API](/zh/api-reference/prompts) 文档。
|
||||
|
||||
113
docs/zh/api-reference/system.mdx
Normal file
113
docs/zh/api-reference/system.mdx
Normal file
@@ -0,0 +1,113 @@
|
||||
---
|
||||
title: "系统"
|
||||
description: "系统和实用程序端点。"
|
||||
---
|
||||
|
||||
import { Card, Cards } from 'mintlify';
|
||||
|
||||
<Card
|
||||
title="GET /health"
|
||||
href="#health-check"
|
||||
>
|
||||
检查 MCPHub 服务器的健康状态。
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="GET /oauth/callback"
|
||||
href="#oauth-callback"
|
||||
>
|
||||
用于身份验证流程的 OAuth 回调端点。
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="POST /api/dxt/upload"
|
||||
href="#upload-dxt-file"
|
||||
>
|
||||
上传 DXT 配置文件。
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="GET /api/mcp-settings/export"
|
||||
href="#export-mcp-settings"
|
||||
>
|
||||
将 MCP 设置导出为 JSON。
|
||||
</Card>
|
||||
|
||||
---
|
||||
|
||||
### 健康检查
|
||||
|
||||
检查 MCPHub 服务器的健康状态。
|
||||
|
||||
- **端点**: `/health`
|
||||
- **方法**: `GET`
|
||||
- **身份验证**: 不需要
|
||||
- **响应**:
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"timestamp": "2024-11-12T01:30:00.000Z",
|
||||
"uptime": 12345
|
||||
}
|
||||
```
|
||||
|
||||
**请求示例:**
|
||||
|
||||
```bash
|
||||
curl "http://localhost:3000/health"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### OAuth 回调
|
||||
|
||||
用于处理 OAuth 身份验证流程的 OAuth 回调端点。此端点在用户授权后由 OAuth 提供商自动调用。
|
||||
|
||||
- **端点**: `/oauth/callback`
|
||||
- **方法**: `GET`
|
||||
- **身份验证**: 不需要(公共回调 URL)
|
||||
- **查询参数**: 因 OAuth 提供商而异(通常包括 `code`、`state` 等)
|
||||
|
||||
**注意**: 此端点由 MCPHub 的 OAuth 集成内部使用,客户端不应直接调用。
|
||||
|
||||
---
|
||||
|
||||
### 上传 DXT 文件
|
||||
|
||||
上传 DXT(桌面扩展)配置文件以导入服务器配置。
|
||||
|
||||
- **端点**: `/api/dxt/upload`
|
||||
- **方法**: `POST`
|
||||
- **身份验证**: 必需
|
||||
- **Content-Type**: `multipart/form-data`
|
||||
- **正文**:
|
||||
- `file` (文件, 必需): 要上传的 DXT 配置文件。
|
||||
|
||||
**请求示例:**
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/api/dxt/upload" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-F "file=@config.dxt"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 导出 MCP 设置
|
||||
|
||||
将当前 MCP 设置配置导出为 JSON 文件。
|
||||
|
||||
- **端点**: `/api/mcp-settings/export`
|
||||
- **方法**: `GET`
|
||||
- **身份验证**: 必需
|
||||
- **响应**: 返回 `mcp_settings.json` 配置文件。
|
||||
|
||||
**请求示例:**
|
||||
|
||||
```bash
|
||||
curl "http://localhost:3000/api/mcp-settings/export" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-o mcp_settings.json
|
||||
```
|
||||
|
||||
**注意**: 此端点允许您下载 MCP 设置的备份,可用于恢复或迁移您的配置。
|
||||
86
docs/zh/api-reference/tools.mdx
Normal file
86
docs/zh/api-reference/tools.mdx
Normal file
@@ -0,0 +1,86 @@
|
||||
---
|
||||
title: "工具"
|
||||
description: "以编程方式执行 MCP 工具。"
|
||||
---
|
||||
|
||||
import { Card, Cards } from 'mintlify';
|
||||
|
||||
<Card
|
||||
title="POST /api/tools/call/:server"
|
||||
href="#call-a-tool"
|
||||
>
|
||||
在 MCP 服务器上调用特定工具。
|
||||
</Card>
|
||||
|
||||
---
|
||||
|
||||
### 调用工具
|
||||
|
||||
使用给定参数在 MCP 服务器上执行特定工具。
|
||||
|
||||
- **端点**: `/api/tools/call/:server`
|
||||
- **方法**: `POST`
|
||||
- **参数**:
|
||||
- `:server` (字符串, 必需): MCP 服务器的名称。
|
||||
- **请求正文**:
|
||||
```json
|
||||
{
|
||||
"toolName": "tool-name",
|
||||
"arguments": {
|
||||
"param1": "value1",
|
||||
"param2": "value2"
|
||||
}
|
||||
}
|
||||
```
|
||||
- `toolName` (字符串, 必需): 要执行的工具名称。
|
||||
- `arguments` (对象, 可选): 传递给工具的参数。默认为空对象。
|
||||
|
||||
- **响应**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "工具执行结果"
|
||||
}
|
||||
],
|
||||
"toolName": "tool-name",
|
||||
"arguments": {
|
||||
"param1": "value1",
|
||||
"param2": "value2"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**请求示例:**
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/api/tools/call/amap" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-d '{
|
||||
"toolName": "amap-maps_weather",
|
||||
"arguments": {
|
||||
"city": "Beijing"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
**注意事项:**
|
||||
- 工具参数会根据工具的输入模式自动转换为适当的类型。
|
||||
- 如果需要,可以使用 `x-session-id` 请求头在多个工具调用之间维护会话状态。
|
||||
- 此端点需要身份验证。
|
||||
|
||||
---
|
||||
|
||||
### 替代方案:OpenAPI 工具执行
|
||||
|
||||
有关无需身份验证的 OpenAPI 兼容工具执行,请参阅 [OpenAPI 集成](/api-reference/openapi#tool-execution) 文档。OpenAPI 端点提供:
|
||||
|
||||
- **GET** `/api/tools/:serverName/:toolName` - 用于带查询参数的简单工具
|
||||
- **POST** `/api/tools/:serverName/:toolName` - 用于带 JSON 正文的复杂工具
|
||||
|
||||
这些端点专为与 OpenWebUI 和其他 OpenAPI 兼容系统集成而设计。
|
||||
195
docs/zh/api-reference/users.mdx
Normal file
195
docs/zh/api-reference/users.mdx
Normal file
@@ -0,0 +1,195 @@
|
||||
---
|
||||
title: "用户"
|
||||
description: "在 MCPHub 中管理用户。"
|
||||
---
|
||||
|
||||
import { Card, Cards } from 'mintlify';
|
||||
|
||||
<Card
|
||||
title="GET /api/users"
|
||||
href="#get-all-users"
|
||||
>
|
||||
获取所有用户的列表。
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="GET /api/users/:username"
|
||||
href="#get-a-user"
|
||||
>
|
||||
获取特定用户的详细信息。
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="POST /api/users"
|
||||
href="#create-a-user"
|
||||
>
|
||||
创建新用户。
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="PUT /api/users/:username"
|
||||
href="#update-a-user"
|
||||
>
|
||||
更新现有用户。
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="DELETE /api/users/:username"
|
||||
href="#delete-a-user"
|
||||
>
|
||||
删除用户。
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="GET /api/users-stats"
|
||||
href="#get-user-statistics"
|
||||
>
|
||||
获取有关用户及其服务器访问权限的统计信息。
|
||||
</Card>
|
||||
|
||||
---
|
||||
|
||||
### 获取所有用户
|
||||
|
||||
检索系统中所有用户的列表。
|
||||
|
||||
- **端点**: `/api/users`
|
||||
- **方法**: `GET`
|
||||
- **身份验证**: 必需(仅管理员)
|
||||
- **响应**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"username": "admin",
|
||||
"role": "admin",
|
||||
"servers": ["server1", "server2"],
|
||||
"groups": ["group1"]
|
||||
},
|
||||
{
|
||||
"username": "user1",
|
||||
"role": "user",
|
||||
"servers": ["server1"],
|
||||
"groups": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 获取用户
|
||||
|
||||
检索特定用户的详细信息。
|
||||
|
||||
- **端点**: `/api/users/:username`
|
||||
- **方法**: `GET`
|
||||
- **身份验证**: 必需(仅管理员)
|
||||
- **参数**:
|
||||
- `:username` (字符串, 必需): 用户的用户名。
|
||||
- **响应**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"username": "user1",
|
||||
"role": "user",
|
||||
"servers": ["server1", "server2"],
|
||||
"groups": ["group1"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 创建用户
|
||||
|
||||
在系统中创建新用户。
|
||||
|
||||
- **端点**: `/api/users`
|
||||
- **方法**: `POST`
|
||||
- **身份验证**: 必需(仅管理员)
|
||||
- **请求正文**:
|
||||
```json
|
||||
{
|
||||
"username": "newuser",
|
||||
"password": "securepassword",
|
||||
"role": "user",
|
||||
"servers": ["server1"],
|
||||
"groups": ["group1"]
|
||||
}
|
||||
```
|
||||
- `username` (字符串, 必需): 新用户的用户名。
|
||||
- `password` (字符串, 必需): 新用户的密码。至少 6 个字符。
|
||||
- `role` (字符串, 可选): 用户的角色。可以是 `"admin"` 或 `"user"`。默认为 `"user"`。
|
||||
- `servers` (字符串数组, 可选): 用户可以访问的服务器名称列表。
|
||||
- `groups` (字符串数组, 可选): 用户所属的组 ID 列表。
|
||||
|
||||
---
|
||||
|
||||
### 更新用户
|
||||
|
||||
更新现有用户的信息。
|
||||
|
||||
- **端点**: `/api/users/:username`
|
||||
- **方法**: `PUT`
|
||||
- **身份验证**: 必需(仅管理员)
|
||||
- **参数**:
|
||||
- `:username` (字符串, 必需): 要更新的用户的用户名。
|
||||
- **请求正文**:
|
||||
```json
|
||||
{
|
||||
"password": "newpassword",
|
||||
"role": "admin",
|
||||
"servers": ["server1", "server2", "server3"],
|
||||
"groups": ["group1", "group2"]
|
||||
}
|
||||
```
|
||||
- `password` (字符串, 可选): 用户的新密码。
|
||||
- `role` (字符串, 可选): 用户的新角色。
|
||||
- `servers` (字符串数组, 可选): 更新的可访问服务器列表。
|
||||
- `groups` (字符串数组, 可选): 更新的组列表。
|
||||
|
||||
---
|
||||
|
||||
### 删除用户
|
||||
|
||||
从系统中删除用户。
|
||||
|
||||
- **端点**: `/api/users/:username`
|
||||
- **方法**: `DELETE`
|
||||
- **身份验证**: 必需(仅管理员)
|
||||
- **参数**:
|
||||
- `:username` (字符串, 必需): 要删除的用户的用户名。
|
||||
|
||||
---
|
||||
|
||||
### 获取用户统计信息
|
||||
|
||||
检索有关用户及其对服务器和组的访问权限的统计信息。
|
||||
|
||||
- **端点**: `/api/users-stats`
|
||||
- **方法**: `GET`
|
||||
- **身份验证**: 必需(仅管理员)
|
||||
- **响应**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"totalUsers": 5,
|
||||
"adminUsers": 1,
|
||||
"regularUsers": 4,
|
||||
"usersPerServer": {
|
||||
"server1": 3,
|
||||
"server2": 2
|
||||
},
|
||||
"usersPerGroup": {
|
||||
"group1": 2,
|
||||
"group2": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**注意**: 所有用户管理端点都需要管理员身份验证。
|
||||
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. **访问控制:** 使用强密码并限制用户权限
|
||||
|
||||
## 性能
|
||||
|
||||
数据库模式在以下场景提供更好的性能:
|
||||
- 多个并发用户
|
||||
- 频繁的配置更改
|
||||
- 大量服务器/分组
|
||||
|
||||
文件模式可能更快的场景:
|
||||
- 单用户设置
|
||||
- 读取密集型工作负载且更改不频繁
|
||||
- 开发/测试环境
|
||||
@@ -48,7 +48,7 @@ docker --version
|
||||
|
||||
```bash
|
||||
# 克隆主仓库
|
||||
git clone https://github.com/mcphub/mcphub.git
|
||||
git clone https://github.com/samanhappy/mcphub.git
|
||||
cd mcphub
|
||||
|
||||
# 或者克隆您的 fork
|
||||
@@ -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
|
||||
```
|
||||
|
||||
## 启动开发服务器
|
||||
|
||||
@@ -388,7 +388,7 @@ CMD ["node", "dist/index.js"]
|
||||
````md
|
||||
```bash
|
||||
# 克隆 MCPHub 仓库
|
||||
git clone https://github.com/mcphub/mcphub.git
|
||||
git clone https://github.com/samanhappy/mcphub.git
|
||||
cd mcphub
|
||||
|
||||
# 安装依赖
|
||||
@@ -413,7 +413,7 @@ npm start
|
||||
|
||||
```bash
|
||||
# 克隆 MCPHub 仓库
|
||||
git clone https://github.com/mcphub/mcphub.git
|
||||
git clone https://github.com/samanhappy/mcphub.git
|
||||
cd mcphub
|
||||
|
||||
# 安装依赖
|
||||
@@ -441,7 +441,7 @@ npm start
|
||||
```powershell
|
||||
# Windows PowerShell 安装步骤
|
||||
# 克隆仓库
|
||||
git clone https://github.com/mcphub/mcphub.git
|
||||
git clone https://github.com/samanhappy/mcphub.git
|
||||
Set-Location mcphub
|
||||
|
||||
# 安装 Node.js 依赖
|
||||
@@ -458,7 +458,7 @@ npm run dev
|
||||
```powershell
|
||||
# Windows PowerShell 安装步骤
|
||||
# 克隆仓库
|
||||
git clone https://github.com/mcphub/mcphub.git
|
||||
git clone https://github.com/samanhappy/mcphub.git
|
||||
Set-Location mcphub
|
||||
|
||||
# 安装 Node.js 依赖
|
||||
@@ -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": {
|
||||
"文档": [
|
||||
{
|
||||
@@ -331,18 +317,13 @@ MCPHub 文档支持以下图标库的图标:
|
||||
"pages": [
|
||||
{
|
||||
"name": "GitHub 仓库",
|
||||
"url": "https://github.com/mcphub/mcphub",
|
||||
"url": "https://github.com/samanhappy/mcphub",
|
||||
"icon": "github"
|
||||
},
|
||||
{
|
||||
"name": "Discord 社区",
|
||||
"url": "https://discord.gg/mcphub",
|
||||
"url": "https://discord.gg/qMKNsn5Q",
|
||||
"icon": "discord"
|
||||
},
|
||||
{
|
||||
"name": "状态页面",
|
||||
"url": "https://status.mcphub.io",
|
||||
"icon": "status"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -382,7 +363,6 @@ zh/
|
||||
"pages": [
|
||||
"zh/concepts/introduction",
|
||||
"zh/concepts/architecture",
|
||||
"zh/concepts/mcp-protocol",
|
||||
"zh/concepts/routing"
|
||||
]
|
||||
}
|
||||
|
||||
141
docs/zh/features/oauth.mdx
Normal file
141
docs/zh/features/oauth.mdx
Normal file
@@ -0,0 +1,141 @@
|
||||
# OAuth 支持
|
||||
|
||||
## 核心亮点
|
||||
- 覆盖上游 MCP 服务器的 OAuth 2.0 授权码(PKCE)全流程。
|
||||
- 支持从 `WWW-Authenticate` 响应和 RFC 8414 元数据自动发现。
|
||||
- 实现动态客户端注册(RFC 7591)以及资源指示(RFC 8707)。
|
||||
- 会将客户端凭据与令牌持久化到 `mcp_settings.json`,重启后直接复用。
|
||||
|
||||
## MCPHub 何时启用 OAuth
|
||||
1. MCPHub 调用需要授权的 MCP 服务器并收到 `401 Unauthorized`。
|
||||
2. 响应通过 `WWW-Authenticate` header 暴露受保护资源的元数据(`authorization_server` 或 `as_uri`)。
|
||||
3. MCPHub 自动发现授权服务器、按需注册客户端,并引导用户完成一次授权。
|
||||
4. 回调处理完成后,MCPHub 使用新令牌重新连接并继续请求。
|
||||
|
||||
> MCPHub 会在服务器详情视图和后端日志中记录发现、注册、授权链接、换取令牌等关键步骤。
|
||||
|
||||
## 按服务器类型快速上手
|
||||
|
||||
### 支持动态注册的服务器
|
||||
部分服务器会公开完整的 OAuth 元数据,并允许客户端动态注册。例如 Vercel 与 Linear 的 MCP 服务器只需配置 SSE 地址即可:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"vercel": {
|
||||
"type": "sse",
|
||||
"url": "https://mcp.vercel.com"
|
||||
},
|
||||
"linear": {
|
||||
"type": "sse",
|
||||
"url": "https://mcp.linear.app/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- MCPHub 会自动发现授权服务器、完成注册,并处理整个 PKCE 流程。
|
||||
- 所有凭据与令牌会写入 `mcp_settings.json`,无须在控制台额外配置。
|
||||
|
||||
### 需要手动配置客户端的服务器
|
||||
另有一些服务端并不支持动态注册。GitHub 的 MCP 端点(`https://api.githubcopilot.com/mcp/`)就是典型例子,接入步骤如下:
|
||||
|
||||
1. 在服务提供商控制台创建 OAuth 应用(GitHub 路径为 **Settings → Developer settings → OAuth Apps**)。
|
||||
2. 将回调/重定向地址设置为 `http://localhost:3000/oauth/callback`(或你的部署域名)。
|
||||
3. 复制生成的 Client ID 与 Client Secret。
|
||||
4. 通过 MCPHub 控制台或直接编辑 `mcp_settings.json` 写入如下配置。
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
"type": "sse",
|
||||
"url": "https://api.githubcopilot.com/mcp/",
|
||||
"oauth": {
|
||||
"clientId": "${GITHUB_OAUTH_APP_ID}",
|
||||
"clientSecret": "${GITHUB_OAUTH_APP_SECRET}",
|
||||
"scopes": ["replace-with-provider-scope"],
|
||||
"resource": "https://api.githubcopilot.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- MCPHub 会跳过动态注册,直接使用你提供的凭据完成授权流程。
|
||||
- 凭据轮换时需要同步更新控制台或配置文件。
|
||||
- 将 `scopes` 替换为服务端要求的具体 Scope。
|
||||
|
||||
## 配置方式
|
||||
大多数场景可依赖自动检测,也可以在 `mcp_settings.json` 中显式声明 OAuth 配置。只填写确实需要的字段。
|
||||
|
||||
### 自动检测(最小配置)
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"secured-sse": {
|
||||
"type": "sse",
|
||||
"url": "https://mcp.example.com/sse",
|
||||
"oauth": {
|
||||
"scopes": ["mcp.tools", "mcp.prompts"],
|
||||
"resource": "https://mcp.example.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- MCPHub 会根据挑战头自动发现授权服务器,并引导用户完成授权。
|
||||
- 令牌(包含刷新令牌)会写入磁盘,重启后自动复用。
|
||||
|
||||
### 静态客户端凭据(自带 Client)
|
||||
```json
|
||||
{
|
||||
"oauth": {
|
||||
"clientId": "mcphub-client",
|
||||
"clientSecret": "replace-me-if-required",
|
||||
"authorizationEndpoint": "https://auth.example.com/oauth/authorize",
|
||||
"tokenEndpoint": "https://auth.example.com/oauth/token",
|
||||
"redirectUri": "http://localhost:3000/oauth/callback"
|
||||
}
|
||||
}
|
||||
```
|
||||
- 适用于需要手动注册客户端的授权服务器。
|
||||
- `redirectUri` 默认是 `http://localhost:3000/oauth/callback`,如在自定义域部署请同步更新。
|
||||
|
||||
### 动态客户端注册(RFC 7591)
|
||||
```json
|
||||
{
|
||||
"oauth": {
|
||||
"dynamicRegistration": {
|
||||
"enabled": true,
|
||||
"issuer": "https://auth.example.com",
|
||||
"metadata": {
|
||||
"client_name": "MCPHub",
|
||||
"redirect_uris": [
|
||||
"http://localhost:3000/oauth/callback",
|
||||
"https://mcphub.example.com/oauth/callback"
|
||||
],
|
||||
"scope": "mcp.tools mcp.prompts",
|
||||
"grant_types": ["authorization_code", "refresh_token"]
|
||||
},
|
||||
"initialAccessToken": "optional-token-if-required"
|
||||
},
|
||||
"scopes": ["mcp.tools", "mcp.prompts"],
|
||||
"resource": "https://mcp.example.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
- MCPHub 会通过 `issuer` 发现端点、完成注册,并持久化下发的 `client_id`/`client_secret`。
|
||||
- 只有当注册端点受保护时才需要提供 `initialAccessToken`。
|
||||
|
||||
## 授权流程
|
||||
1. **初始化**:启动时遍历服务器配置,发现元数据并在启用 `dynamicRegistration` 时注册客户端。
|
||||
2. **用户授权**:建立连接时自动打开系统浏览器,携带 PKCE 参数访问授权页。
|
||||
3. **回调处理**:内置路径 `/oauth/callback` 校验 `state`、完成换取令牌,并通过 MCP SDK 保存结果。
|
||||
4. **令牌生命周期**:访问令牌与刷新令牌会缓存于内存,自动刷新,并写回 `mcp_settings.json`。
|
||||
|
||||
## 提示与排障
|
||||
- 确保授权过程中使用的回调地址与已注册的 `redirect_uris` 完全一致。
|
||||
- 若部署在 HTTPS 域名下,请对外暴露 `/oauth/callback` 或通过反向代理转发。
|
||||
- 如无法完成自动发现,可显式提供 `authorizationEndpoint` 与 `tokenEndpoint`。
|
||||
- 授权服务器吊销令牌后,可手动清理 `mcp_settings.json` 中的旧令牌,MCPHub 会在下一次请求时重新触发授权。
|
||||
@@ -35,9 +35,6 @@ MCPHub 是一个现代化的 Model Context Protocol (MCP) 服务器管理平台
|
||||
了解 MCPHub 的核心概念,为深入使用做好准备。
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="MCP 协议介绍" icon="network-wired" href="/zh/concepts/mcp-protocol">
|
||||
深入了解 Model Context Protocol 的工作原理和最佳实践
|
||||
</Card>
|
||||
<Card title="智能路由机制" icon="route" href="/zh/features/smart-routing">
|
||||
学习 MCPHub 的智能路由算法和配置策略
|
||||
</Card>
|
||||
@@ -57,12 +54,6 @@ MCPHub 支持多种部署方式,满足不同规模和场景的需求。
|
||||
<Card title="Docker 部署" icon="docker" href="/zh/configuration/docker-setup">
|
||||
使用 Docker 容器快速部署,支持单机和集群模式
|
||||
</Card>
|
||||
<Card title="云服务部署" icon="cloud" href="/zh/deployment/cloud">
|
||||
在 AWS、GCP、Azure 等云平台上部署 MCPHub
|
||||
</Card>
|
||||
<Card title="Kubernetes" icon="dharmachakra" href="/zh/deployment/kubernetes">
|
||||
在 Kubernetes 集群中部署高可用的 MCPHub 服务
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## API 和集成
|
||||
@@ -73,9 +64,6 @@ MCPHub 提供完整的 RESTful API 和多语言 SDK,方便与现有系统集
|
||||
<Card title="API 参考文档" icon="code" href="/zh/api-reference/introduction">
|
||||
完整的 API 接口文档,包含详细的请求示例和响应格式
|
||||
</Card>
|
||||
<Card title="SDK 和工具" icon="toolbox" href="/zh/sdk">
|
||||
官方 SDK 和命令行工具,加速开发集成
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## 社区和支持
|
||||
@@ -83,7 +71,7 @@ MCPHub 提供完整的 RESTful API 和多语言 SDK,方便与现有系统集
|
||||
加入 MCPHub 社区,获取帮助和分享经验。
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="GitHub 仓库" icon="github" href="https://github.com/mcphub/mcphub">
|
||||
<Card title="GitHub 仓库" icon="github" href="https://github.com/samanhappy/mcphub">
|
||||
查看源代码、提交问题和贡献代码
|
||||
</Card>
|
||||
<Card title="Discord 社区" icon="discord" href="https://discord.gg/mcphub">
|
||||
|
||||
80
examples/mcp_settings_with_env_vars.json
Normal file
80
examples/mcp_settings_with_env_vars.json
Normal file
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"example-sse-server": {
|
||||
"type": "sse",
|
||||
"url": "${MCP_SERVER_URL}",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${API_TOKEN}",
|
||||
"X-Custom-Header": "${CUSTOM_HEADER_VALUE}"
|
||||
},
|
||||
"enabled": true
|
||||
},
|
||||
"example-streamable-http": {
|
||||
"type": "streamable-http",
|
||||
"url": "https://${SERVER_HOST}/mcp",
|
||||
"headers": {
|
||||
"API-Key": "${API_KEY}"
|
||||
}
|
||||
},
|
||||
"example-stdio-server": {
|
||||
"type": "stdio",
|
||||
"command": "${PYTHON_PATH}",
|
||||
"args": [
|
||||
"-m",
|
||||
"${MODULE_NAME}",
|
||||
"--config",
|
||||
"${CONFIG_PATH}"
|
||||
],
|
||||
"env": {
|
||||
"API_KEY": "${MY_API_KEY}",
|
||||
"DEBUG": "${DEBUG_MODE}",
|
||||
"DATABASE_URL": "${DATABASE_URL}"
|
||||
}
|
||||
},
|
||||
"example-openapi-server": {
|
||||
"type": "openapi",
|
||||
"openapi": {
|
||||
"url": "${OPENAPI_SPEC_URL}",
|
||||
"security": {
|
||||
"type": "apiKey",
|
||||
"apiKey": {
|
||||
"name": "X-API-Key",
|
||||
"in": "header",
|
||||
"value": "${OPENAPI_API_KEY}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"headers": {
|
||||
"User-Agent": "MCPHub/${VERSION}"
|
||||
}
|
||||
},
|
||||
"example-oauth-server": {
|
||||
"type": "sse",
|
||||
"url": "${OAUTH_SERVER_URL}",
|
||||
"oauth": {
|
||||
"clientId": "${OAUTH_CLIENT_ID}",
|
||||
"clientSecret": "${OAUTH_CLIENT_SECRET}",
|
||||
"accessToken": "${OAUTH_ACCESS_TOKEN}",
|
||||
"scopes": ["read", "write"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"users": [
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "${ADMIN_PASSWORD_HASH}",
|
||||
"isAdmin": true
|
||||
}
|
||||
],
|
||||
"systemConfig": {
|
||||
"install": {
|
||||
"pythonIndexUrl": "${PYTHON_INDEX_URL}",
|
||||
"npmRegistry": "${NPM_REGISTRY}"
|
||||
},
|
||||
"mcpRouter": {
|
||||
"apiKey": "${MCPROUTER_API_KEY}",
|
||||
"referer": "${MCPROUTER_REFERER}",
|
||||
"baseUrl": "${MCPROUTER_BASE_URL}"
|
||||
}
|
||||
}
|
||||
}
|
||||
25
examples/oauth-dynamic-registration-config.json
Normal file
25
examples/oauth-dynamic-registration-config.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"systemConfig": {
|
||||
"oauthServer": {
|
||||
"enabled": true,
|
||||
"accessTokenLifetime": 3600,
|
||||
"refreshTokenLifetime": 1209600,
|
||||
"authorizationCodeLifetime": 300,
|
||||
"requireClientSecret": false,
|
||||
"allowedScopes": ["read", "write"],
|
||||
"dynamicRegistration": {
|
||||
"enabled": true,
|
||||
"allowedGrantTypes": ["authorization_code", "refresh_token"],
|
||||
"requiresAuthentication": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"mcpServers": {},
|
||||
"users": [
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "$2b$10$abcdefghijklmnopqrstuv",
|
||||
"isAdmin": true
|
||||
}
|
||||
]
|
||||
}
|
||||
76
examples/oauth-server-config.json
Normal file
76
examples/oauth-server-config.json
Normal file
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"@playwright/mcp@latest",
|
||||
"--headless"
|
||||
]
|
||||
},
|
||||
"fetch": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"mcp-server-fetch"
|
||||
]
|
||||
}
|
||||
},
|
||||
"users": [
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "$2b$10$Vt7krIvjNgyN67LXqly0uOcTpN0LI55cYRbcKC71pUDAP0nJ7RPa.",
|
||||
"isAdmin": true
|
||||
}
|
||||
],
|
||||
"systemConfig": {
|
||||
"oauthServer": {
|
||||
"enabled": true,
|
||||
"accessTokenLifetime": 3600,
|
||||
"refreshTokenLifetime": 1209600,
|
||||
"authorizationCodeLifetime": 300,
|
||||
"requireClientSecret": false,
|
||||
"allowedScopes": [
|
||||
"read",
|
||||
"write"
|
||||
]
|
||||
},
|
||||
"routing": {
|
||||
"skipAuth": false
|
||||
}
|
||||
},
|
||||
"oauthClients": [
|
||||
{
|
||||
"clientId": "chatgpt-web-client",
|
||||
"name": "ChatGPT Web Integration",
|
||||
"redirectUris": [
|
||||
"https://chatgpt.com/oauth/callback",
|
||||
"https://chat.openai.com/oauth/callback"
|
||||
],
|
||||
"grants": [
|
||||
"authorization_code",
|
||||
"refresh_token"
|
||||
],
|
||||
"scopes": [
|
||||
"read",
|
||||
"write"
|
||||
],
|
||||
"owner": "admin"
|
||||
},
|
||||
{
|
||||
"clientId": "example-public-app",
|
||||
"name": "Example Public Application",
|
||||
"redirectUris": [
|
||||
"http://localhost:8080/callback",
|
||||
"http://localhost:3001/callback"
|
||||
],
|
||||
"grants": [
|
||||
"authorization_code",
|
||||
"refresh_token"
|
||||
],
|
||||
"scopes": [
|
||||
"read",
|
||||
"write"
|
||||
],
|
||||
"owner": "admin"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -57,28 +57,28 @@ const AddUserForm = ({ onAdd, onCancel }: AddUserFormProps) => {
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData(prev => ({
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
[name]: type === 'checkbox' ? checked : value,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-center justify-center z-50">
|
||||
<div className="bg-white p-8 rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white p-8 rounded-xl shadow-2xl max-w-md w-full mx-4 border border-gray-100">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('users.addNew')}</h2>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6">{t('users.addNew')}</h2>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-3 mb-4">
|
||||
<p className="text-sm">{error}</p>
|
||||
<div className="bg-red-50 border-l-4 border-red-500 text-red-700 p-4 mb-6 rounded-md">
|
||||
<p className="text-sm font-medium">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('users.username')} *
|
||||
{t('users.username')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -87,7 +87,7 @@ const AddUserForm = ({ onAdd, onCancel }: AddUserFormProps) => {
|
||||
value={formData.username}
|
||||
onChange={handleInputChange}
|
||||
placeholder={t('users.usernamePlaceholder')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent form-input transition-all duration-200"
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
@@ -95,7 +95,7 @@ const AddUserForm = ({ onAdd, onCancel }: AddUserFormProps) => {
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('users.password')} *
|
||||
{t('users.password')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
@@ -104,43 +104,68 @@ const AddUserForm = ({ onAdd, onCancel }: AddUserFormProps) => {
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
placeholder={t('users.passwordPlaceholder')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent form-input transition-all duration-200"
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center pt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isAdmin"
|
||||
name="isAdmin"
|
||||
checked={formData.isAdmin}
|
||||
onChange={handleInputChange}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
className="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded transition-colors duration-200"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<label htmlFor="isAdmin" className="ml-2 block text-sm text-gray-700">
|
||||
<label
|
||||
htmlFor="isAdmin"
|
||||
className="ml-3 block text-sm font-medium text-gray-700 cursor-pointer select-none"
|
||||
>
|
||||
{t('users.adminRole')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<div className="flex justify-end space-x-3 mt-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 transition-colors duration-200"
|
||||
className="px-5 py-2.5 text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-all duration-200 font-medium btn-secondary shadow-sm"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-5 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all duration-200 font-medium btn-primary shadow-md disabled:opacity-70 disabled:cursor-not-allowed flex items-center"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting && (
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
||||
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>
|
||||
)}
|
||||
{isSubmitting ? t('common.creating') : t('users.create')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChangePasswordCredentials } from '../types';
|
||||
import { changePassword } from '../services/authService';
|
||||
import { validatePasswordStrength } from '../utils/passwordValidation';
|
||||
|
||||
interface ChangePasswordFormProps {
|
||||
onSuccess?: () => void;
|
||||
@@ -18,6 +19,7 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [passwordErrors, setPasswordErrors] = useState<string[]>([]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
@@ -25,6 +27,12 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
setConfirmPassword(value);
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
|
||||
// Validate password strength on change for new password
|
||||
if (name === 'newPassword') {
|
||||
const validation = validatePasswordStrength(value);
|
||||
setPasswordErrors(validation.errors);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -32,6 +40,14 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
// Validate password strength
|
||||
const validation = validatePasswordStrength(formData.newPassword);
|
||||
if (!validation.isValid) {
|
||||
setError(t('auth.passwordStrengthError'));
|
||||
setPasswordErrors(validation.errors);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate passwords match
|
||||
if (formData.newPassword !== confirmPassword) {
|
||||
setError(t('auth.passwordsNotMatch'));
|
||||
@@ -100,8 +116,24 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
value={formData.newPassword}
|
||||
onChange={handleChange}
|
||||
required
|
||||
minLength={6}
|
||||
minLength={8}
|
||||
/>
|
||||
{/* Password strength hints */}
|
||||
{formData.newPassword && passwordErrors.length > 0 && (
|
||||
<div className="mt-2 text-sm text-gray-600">
|
||||
<p className="font-semibold mb-1">{t('auth.passwordStrengthHint')}</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
{passwordErrors.map((errorKey) => (
|
||||
<li key={errorKey} className="text-red-600">
|
||||
{t(`auth.${errorKey}`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{formData.newPassword && passwordErrors.length === 0 && (
|
||||
<p className="mt-2 text-sm text-green-600">✓ {t('auth.passwordStrengthHint')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
@@ -116,7 +148,7 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
value={confirmPassword}
|
||||
onChange={handleChange}
|
||||
required
|
||||
minLength={6}
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -62,93 +62,132 @@ const EditUserForm = ({ user, onEdit, onCancel }: EditUserFormProps) => {
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData(prev => ({
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
[name]: type === 'checkbox' ? checked : value,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-center justify-center z-50">
|
||||
<div className="bg-white p-8 rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white p-8 rounded-xl shadow-2xl max-w-md w-full mx-4 border border-gray-100">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">
|
||||
{t('users.edit')} - {user.username}
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6">
|
||||
{t('users.edit')} - <span className="text-blue-600">{user.username}</span>
|
||||
</h2>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-3 mb-4">
|
||||
<p className="text-sm">{error}</p>
|
||||
<div className="bg-red-50 border-l-4 border-red-500 text-red-700 p-4 mb-6 rounded-md">
|
||||
<p className="text-sm font-medium">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center">
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center pt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isAdmin"
|
||||
name="isAdmin"
|
||||
checked={formData.isAdmin}
|
||||
onChange={handleInputChange}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
className="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded transition-colors duration-200"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<label htmlFor="isAdmin" className="ml-2 block text-sm text-gray-700">
|
||||
<label
|
||||
htmlFor="isAdmin"
|
||||
className="ml-3 block text-sm font-medium text-gray-700 cursor-pointer select-none"
|
||||
>
|
||||
{t('users.adminRole')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="newPassword" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('users.newPassword')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="newPassword"
|
||||
name="newPassword"
|
||||
value={formData.newPassword}
|
||||
onChange={handleInputChange}
|
||||
placeholder={t('users.newPasswordPlaceholder')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
disabled={isSubmitting}
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-gray-100 pt-4 mt-2">
|
||||
<p className="text-xs text-gray-500 uppercase font-semibold tracking-wider mb-3">
|
||||
{t('users.changePassword')}
|
||||
</p>
|
||||
|
||||
{formData.newPassword && (
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('users.confirmPassword')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
placeholder={t('users.confirmPasswordPlaceholder')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
disabled={isSubmitting}
|
||||
minLength={6}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="newPassword"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
{t('users.newPassword')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="newPassword"
|
||||
name="newPassword"
|
||||
value={formData.newPassword}
|
||||
onChange={handleInputChange}
|
||||
placeholder={t('users.newPasswordPlaceholder')}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent form-input transition-all duration-200"
|
||||
disabled={isSubmitting}
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.newPassword && (
|
||||
<div className="animate-fadeIn">
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
{t('users.confirmPassword')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
placeholder={t('users.confirmPasswordPlaceholder')}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent form-input transition-all duration-200"
|
||||
disabled={isSubmitting}
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<div className="flex justify-end space-x-3 mt-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 transition-colors duration-200"
|
||||
className="px-5 py-2.5 text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-all duration-200 font-medium btn-secondary shadow-sm"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-5 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all duration-200 font-medium btn-primary shadow-md disabled:opacity-70 disabled:cursor-not-allowed flex items-center"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting && (
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
|
||||
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>
|
||||
)}
|
||||
{isSubmitting ? t('common.updating') : t('users.update')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
311
frontend/src/components/JSONImportForm.tsx
Normal file
311
frontend/src/components/JSONImportForm.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { apiPost } from '@/utils/fetchInterceptor';
|
||||
|
||||
interface JSONImportFormProps {
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
interface McpServerConfig {
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
type?: string;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ImportJsonFormat {
|
||||
mcpServers: Record<string, McpServerConfig>;
|
||||
}
|
||||
|
||||
const JSONImportForm: React.FC<JSONImportFormProps> = ({ onSuccess, onCancel }) => {
|
||||
const { t } = useTranslation();
|
||||
const [jsonInput, setJsonInput] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [previewServers, setPreviewServers] = useState<Array<{ name: string; config: any }> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const examplePlaceholder = `STDIO example:
|
||||
{
|
||||
"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",
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer your-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const parseAndValidateJson = (input: string): ImportJsonFormat | null => {
|
||||
try {
|
||||
const parsed = JSON.parse(input.trim());
|
||||
|
||||
// Validate structure
|
||||
if (!parsed.mcpServers || typeof parsed.mcpServers !== 'object') {
|
||||
setError(t('jsonImport.invalidFormat'));
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed as ImportJsonFormat;
|
||||
} catch (e) {
|
||||
setError(t('jsonImport.parseError'));
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreview = () => {
|
||||
setError(null);
|
||||
const parsed = parseAndValidateJson(jsonInput);
|
||||
if (!parsed) return;
|
||||
|
||||
const servers = Object.entries(parsed.mcpServers).map(([name, config]) => {
|
||||
// Normalize config to MCPHub format
|
||||
const normalizedConfig: any = {};
|
||||
|
||||
if (config.type === 'sse' || config.type === 'streamable-http') {
|
||||
normalizedConfig.type = config.type;
|
||||
normalizedConfig.url = config.url;
|
||||
if (config.headers) {
|
||||
normalizedConfig.headers = config.headers;
|
||||
}
|
||||
} else {
|
||||
// Default to stdio
|
||||
normalizedConfig.type = 'stdio';
|
||||
normalizedConfig.command = config.command;
|
||||
normalizedConfig.args = config.args || [];
|
||||
if (config.env) {
|
||||
normalizedConfig.env = config.env;
|
||||
}
|
||||
}
|
||||
|
||||
return { name, config: normalizedConfig };
|
||||
});
|
||||
|
||||
setPreviewServers(servers);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!previewServers) return;
|
||||
|
||||
setIsImporting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let successCount = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const server of previewServers) {
|
||||
try {
|
||||
const result = await apiPost('/servers', {
|
||||
name: server.name,
|
||||
config: server.config,
|
||||
});
|
||||
|
||||
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 (errors.length > 0) {
|
||||
setError(
|
||||
t('jsonImport.partialSuccess', { count: successCount, total: previewServers.length }) +
|
||||
'\n' +
|
||||
errors.join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Import error:', err);
|
||||
setError(t('jsonImport.importFailed'));
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
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('jsonImport.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>
|
||||
)}
|
||||
|
||||
{!previewServers ? (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{t('jsonImport.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('jsonImport.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('jsonImport.preview')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-3">
|
||||
{t('jsonImport.previewTitle')}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{previewServers.map((server, 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">{server.name}</h4>
|
||||
<div className="mt-2 space-y-1 text-sm text-gray-600">
|
||||
<div>
|
||||
<strong>{t('server.type')}:</strong> {server.config.type || 'stdio'}
|
||||
</div>
|
||||
{server.config.command && (
|
||||
<div>
|
||||
<strong>{t('server.command')}:</strong> {server.config.command}
|
||||
</div>
|
||||
)}
|
||||
{server.config.args && server.config.args.length > 0 && (
|
||||
<div>
|
||||
<strong>{t('server.arguments')}:</strong>{' '}
|
||||
{server.config.args.join(' ')}
|
||||
</div>
|
||||
)}
|
||||
{server.config.url && (
|
||||
<div>
|
||||
<strong>{t('server.url')}:</strong> {server.config.url}
|
||||
</div>
|
||||
)}
|
||||
{server.config.env && Object.keys(server.config.env).length > 0 && (
|
||||
<div>
|
||||
<strong>{t('server.envVars')}:</strong>{' '}
|
||||
{Object.keys(server.config.env).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{server.config.headers &&
|
||||
Object.keys(server.config.headers).length > 0 && (
|
||||
<div>
|
||||
<strong>{t('server.headers')}:</strong>{' '}
|
||||
{Object.keys(server.config.headers).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-4">
|
||||
<button
|
||||
onClick={() => setPreviewServers(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('jsonImport.importing')}
|
||||
</>
|
||||
) : (
|
||||
t('jsonImport.import')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JSONImportForm;
|
||||
@@ -19,7 +19,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
onBack,
|
||||
onInstall,
|
||||
installing = false,
|
||||
isInstalled = false
|
||||
isInstalled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
@@ -32,21 +32,23 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
const getButtonProps = () => {
|
||||
if (isInstalled) {
|
||||
return {
|
||||
className: "bg-green-600 cursor-default px-4 py-2 rounded text-sm font-medium text-white",
|
||||
className: 'bg-green-600 cursor-default px-4 py-2 rounded text-sm font-medium text-white',
|
||||
disabled: true,
|
||||
text: t('market.installed')
|
||||
text: t('market.installed'),
|
||||
};
|
||||
} else if (installing) {
|
||||
return {
|
||||
className: "bg-gray-400 cursor-not-allowed px-4 py-2 rounded text-sm font-medium text-white",
|
||||
className:
|
||||
'bg-gray-400 cursor-not-allowed px-4 py-2 rounded text-sm font-medium text-white',
|
||||
disabled: true,
|
||||
text: t('market.installing')
|
||||
text: t('market.installing'),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
className: "bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm font-medium text-white btn-primary",
|
||||
className:
|
||||
'bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm font-medium text-white btn-primary',
|
||||
disabled: false,
|
||||
text: t('market.install')
|
||||
text: t('market.install'),
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -133,12 +135,18 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="mb-4">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-gray-600 hover:text-gray-900 flex items-center"
|
||||
>
|
||||
<svg className="h-5 w-5 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
|
||||
<button onClick={onBack} className="text-gray-600 hover:text-gray-900 flex items-center">
|
||||
<svg
|
||||
className="h-5 w-5 mr-1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{t('market.backToList')}
|
||||
</button>
|
||||
@@ -150,7 +158,8 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
{server.display_name}
|
||||
<span className="text-sm font-normal text-gray-500 ml-2">({server.name})</span>
|
||||
<span className="text-sm font-normal text-gray-600 ml-4">
|
||||
{t('market.author')}: {server.author.name} • {t('market.license')}: {server.license} •
|
||||
{t('market.author')}: {server.author?.name || t('market.unknown')} •{' '}
|
||||
{t('market.license')}: {server.license} •
|
||||
<a
|
||||
href={server.repository.url}
|
||||
target="_blank"
|
||||
@@ -182,18 +191,24 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
<p className="text-gray-700 mb-6">{server.description}</p>
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold mb-3">{t('market.categories')} & {t('market.tags')}</h3>
|
||||
<h3 className="text-lg font-semibold mb-3">
|
||||
{t('market.categories')} & {t('market.tags')}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{server.categories?.map((category, index) => (
|
||||
<span key={`cat-${index}`} className="bg-gray-100 text-gray-800 px-3 py-1 rounded">
|
||||
{category}
|
||||
</span>
|
||||
))}
|
||||
{server.tags && server.tags.map((tag, index) => (
|
||||
<span key={`tag-${index}`} className="bg-gray-100 text-green-700 px-2 py-1 rounded text-sm">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
{server.tags &&
|
||||
server.tags.map((tag, index) => (
|
||||
<span
|
||||
key={`tag-${index}`}
|
||||
className="bg-gray-100 text-green-700 px-2 py-1 rounded text-sm"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -224,9 +239,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{name}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">
|
||||
{arg.description}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{arg.description}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{arg.required ? (
|
||||
<span className="text-green-600">✓</span>
|
||||
@@ -268,7 +281,10 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
</h4>
|
||||
<p className="text-gray-600 mb-2">{tool.description}</p>
|
||||
<div className="mt-2">
|
||||
<pre id={`schema-${index}`} className="hidden bg-gray-50 p-3 rounded text-sm overflow-auto mt-2">
|
||||
<pre
|
||||
id={`schema-${index}`}
|
||||
className="hidden bg-gray-50 p-3 rounded text-sm overflow-auto mt-2"
|
||||
>
|
||||
{JSON.stringify(tool.inputSchema, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -285,9 +301,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
<div key={index} className="border border-gray-200 rounded p-4">
|
||||
<h4 className="font-medium mb-2">{example.title}</h4>
|
||||
<p className="text-gray-600 mb-2">{example.description}</p>
|
||||
<pre className="bg-gray-50 p-3 rounded text-sm overflow-auto">
|
||||
{example.prompt}
|
||||
</pre>
|
||||
<pre className="bg-gray-50 p-3 rounded text-sm overflow-auto">{example.prompt}</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -316,11 +330,11 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
status: 'disconnected',
|
||||
config: preferredInstallation
|
||||
? {
|
||||
command: preferredInstallation.command || '',
|
||||
args: preferredInstallation.args || [],
|
||||
env: preferredInstallation.env || {}
|
||||
}
|
||||
: undefined
|
||||
command: preferredInstallation.command || '',
|
||||
args: preferredInstallation.args || [],
|
||||
env: preferredInstallation.env || {},
|
||||
}
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -332,14 +346,16 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
{t('server.confirmVariables')}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
{t('server.variablesDetected')}
|
||||
</p>
|
||||
<p className="text-gray-600 mb-4">{t('server.variablesDetected')}</p>
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 mb-4">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
@@ -356,14 +372,12 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm mb-6">
|
||||
{t('market.confirmVariablesMessage')}
|
||||
</p>
|
||||
<p className="text-gray-600 text-sm mb-6">{t('market.confirmVariablesMessage')}</p>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setConfirmationVisible(false)
|
||||
setPendingPayload(null)
|
||||
setConfirmationVisible(false);
|
||||
setPendingPayload(null);
|
||||
}}
|
||||
className="px-4 py-2 text-gray-600 border border-gray-300 rounded hover:bg-gray-50 btn-secondary"
|
||||
>
|
||||
|
||||
@@ -1,188 +1,233 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Server } from '@/types'
|
||||
import { ChevronDown, ChevronRight, AlertCircle, Copy, Check } from 'lucide-react'
|
||||
import { StatusBadge } from '@/components/ui/Badge'
|
||||
import ToolCard from '@/components/ui/ToolCard'
|
||||
import PromptCard from '@/components/ui/PromptCard'
|
||||
import DeleteDialog from '@/components/ui/DeleteDialog'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
import { useSettingsData } from '@/hooks/useSettingsData'
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Server } from '@/types';
|
||||
import { ChevronDown, ChevronRight, AlertCircle, Copy, Check } from 'lucide-react';
|
||||
import { StatusBadge } from '@/components/ui/Badge';
|
||||
import ToolCard from '@/components/ui/ToolCard';
|
||||
import PromptCard from '@/components/ui/PromptCard';
|
||||
import DeleteDialog from '@/components/ui/DeleteDialog';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import { useSettingsData } from '@/hooks/useSettingsData';
|
||||
|
||||
interface ServerCardProps {
|
||||
server: Server
|
||||
onRemove: (serverName: string) => void
|
||||
onEdit: (server: Server) => void
|
||||
onToggle?: (server: Server, enabled: boolean) => Promise<boolean>
|
||||
onRefresh?: () => void
|
||||
server: Server;
|
||||
onRemove: (serverName: string) => void;
|
||||
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 { t } = useTranslation()
|
||||
const { showToast } = useToast()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isToggling, setIsToggling] = useState(false)
|
||||
const [showErrorPopover, setShowErrorPopover] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const errorPopoverRef = useRef<HTMLDivElement>(null)
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (errorPopoverRef.current && !errorPopoverRef.current.contains(event.target as Node)) {
|
||||
setShowErrorPopover(false)
|
||||
setShowErrorPopover(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [])
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { exportMCPSettings } = useSettingsData()
|
||||
const { exportMCPSettings } = useSettingsData();
|
||||
|
||||
const handleRemove = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setShowDeleteDialog(true)
|
||||
}
|
||||
e.stopPropagation();
|
||||
setShowDeleteDialog(true);
|
||||
};
|
||||
|
||||
const handleEdit = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onEdit(server)
|
||||
}
|
||||
e.stopPropagation();
|
||||
onEdit(server);
|
||||
};
|
||||
|
||||
const handleToggle = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (isToggling || !onToggle) return
|
||||
e.stopPropagation();
|
||||
if (isToggling || !onToggle) return;
|
||||
|
||||
setIsToggling(true)
|
||||
setIsToggling(true);
|
||||
try {
|
||||
await onToggle(server, !(server.enabled !== false))
|
||||
await onToggle(server, !(server.enabled !== false));
|
||||
} finally {
|
||||
setIsToggling(false)
|
||||
setIsToggling(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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)
|
||||
}
|
||||
e.stopPropagation();
|
||||
setShowErrorPopover(!showErrorPopover);
|
||||
};
|
||||
|
||||
const copyToClipboard = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!server.error) return
|
||||
e.stopPropagation();
|
||||
if (!server.error) return;
|
||||
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(server.error).then(() => {
|
||||
setCopied(true)
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
setCopied(true);
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
} else {
|
||||
// Fallback for HTTP or unsupported clipboard API
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = server.error
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = server.error;
|
||||
// Avoid scrolling to bottom
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-9999px'
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
setCopied(true)
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
document.execCommand('copy');
|
||||
setCopied(true);
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success');
|
||||
setTimeout(() => setCopied(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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyServerConfig = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const result = await exportMCPSettings(server.name)
|
||||
const configJson = JSON.stringify(result.data, null, 2)
|
||||
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) {
|
||||
await navigator.clipboard.writeText(configJson)
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
|
||||
await navigator.clipboard.writeText(configJson);
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success');
|
||||
} else {
|
||||
// Fallback for HTTP or unsupported clipboard API
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = configJson
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-9999px'
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = configJson;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
|
||||
document.execCommand('copy');
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success');
|
||||
} 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 server configuration:', error)
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error')
|
||||
console.error('Error copying server configuration:', error);
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
onRemove(server.name)
|
||||
setShowDeleteDialog(false)
|
||||
}
|
||||
onRemove(server.name);
|
||||
setShowDeleteDialog(false);
|
||||
};
|
||||
|
||||
const handleToolToggle = async (toolName: string, enabled: boolean) => {
|
||||
try {
|
||||
const { toggleTool } = await import('@/services/toolService')
|
||||
const result = await toggleTool(server.name, toolName, enabled)
|
||||
const { toggleTool } = await import('@/services/toolService');
|
||||
const result = await toggleTool(server.name, toolName, enabled);
|
||||
if (result.success) {
|
||||
showToast(
|
||||
t(enabled ? 'tool.enableSuccess' : 'tool.disableSuccess', { name: toolName }),
|
||||
'success',
|
||||
)
|
||||
);
|
||||
// Trigger refresh to update the tool's state in the UI
|
||||
if (onRefresh) {
|
||||
onRefresh()
|
||||
onRefresh();
|
||||
}
|
||||
} else {
|
||||
showToast(result.error || t('tool.toggleFailed'), 'error')
|
||||
showToast(result.error || t('tool.toggleFailed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling tool:', error)
|
||||
showToast(t('tool.toggleFailed'), 'error')
|
||||
console.error('Error toggling tool:', error);
|
||||
showToast(t('tool.toggleFailed'), 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePromptToggle = async (promptName: string, enabled: boolean) => {
|
||||
try {
|
||||
const { togglePrompt } = await import('@/services/promptService')
|
||||
const result = await togglePrompt(server.name, promptName, enabled)
|
||||
const { togglePrompt } = await import('@/services/promptService');
|
||||
const result = await togglePrompt(server.name, promptName, enabled);
|
||||
if (result.success) {
|
||||
showToast(
|
||||
t(enabled ? 'tool.enableSuccess' : 'tool.disableSuccess', { name: promptName }),
|
||||
'success',
|
||||
)
|
||||
);
|
||||
// Trigger refresh to update the prompt's state in the UI
|
||||
if (onRefresh) {
|
||||
onRefresh()
|
||||
onRefresh();
|
||||
}
|
||||
} else {
|
||||
showToast(result.error || t('tool.toggleFailed'), 'error')
|
||||
showToast(result.error || t('tool.toggleFailed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling prompt:', error)
|
||||
showToast(t('tool.toggleFailed'), 'error')
|
||||
console.error('Error toggling prompt:', error);
|
||||
showToast(t('tool.toggleFailed'), 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleOAuthAuthorization = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
// Open the OAuth authorization URL in a new window
|
||||
if (server.oauth?.authorizationUrl) {
|
||||
const width = 600;
|
||||
const height = 700;
|
||||
const left = window.screen.width / 2 - width / 2;
|
||||
const top = window.screen.height / 2 - height / 2;
|
||||
|
||||
window.open(
|
||||
server.oauth.authorizationUrl,
|
||||
'OAuth Authorization',
|
||||
`width=${width},height=${height},left=${left},top=${top}`,
|
||||
);
|
||||
|
||||
showToast(t('status.oauthWindowOpened'), 'info');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -199,7 +244,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
>
|
||||
{server.name}
|
||||
</h2>
|
||||
<StatusBadge status={server.status} />
|
||||
<StatusBadge status={server.status} onAuthClick={handleOAuthAuthorization} />
|
||||
|
||||
{/* Tool count display */}
|
||||
<div className="flex items-center px-2 py-1 bg-blue-50 text-blue-700 rounded-full text-sm btn-primary">
|
||||
@@ -269,8 +314,8 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowErrorPopover(false)
|
||||
e.stopPropagation();
|
||||
setShowErrorPopover(false);
|
||||
}}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
@@ -307,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')
|
||||
@@ -316,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"
|
||||
@@ -380,7 +434,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
serverName={server.name}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerCard
|
||||
export default ServerCard;
|
||||
|
||||
@@ -42,6 +42,20 @@ const ServerForm = ({
|
||||
}));
|
||||
};
|
||||
|
||||
const getInitialOAuthConfig = (data: Server | null): ServerFormData['oauth'] => {
|
||||
const oauth = data?.config?.oauth;
|
||||
return {
|
||||
clientId: oauth?.clientId || '',
|
||||
clientSecret: oauth?.clientSecret || '',
|
||||
scopes: oauth?.scopes ? oauth.scopes.join(' ') : '',
|
||||
accessToken: oauth?.accessToken || '',
|
||||
refreshToken: oauth?.refreshToken || '',
|
||||
authorizationEndpoint: oauth?.authorizationEndpoint || '',
|
||||
tokenEndpoint: oauth?.tokenEndpoint || '',
|
||||
resource: oauth?.resource || '',
|
||||
};
|
||||
};
|
||||
|
||||
const [serverType, setServerType] = useState<'stdio' | 'sse' | 'streamable-http' | 'openapi'>(
|
||||
getInitialServerType(),
|
||||
);
|
||||
@@ -80,6 +94,12 @@ const ServerForm = ({
|
||||
initialData.config.options.maxTotalTimeout) ||
|
||||
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
|
||||
@@ -135,6 +155,8 @@ 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;
|
||||
|
||||
@@ -186,6 +208,19 @@ const ServerForm = ({
|
||||
setHeaderVars(newHeaderVars);
|
||||
};
|
||||
|
||||
const handleOAuthChange = <K extends keyof NonNullable<ServerFormData['oauth']>>(
|
||||
field: K,
|
||||
value: string,
|
||||
) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
oauth: {
|
||||
...(prev.oauth || {}),
|
||||
[field]: value,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
// Handle options changes
|
||||
const handleOptionsChange = (
|
||||
field: 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout',
|
||||
@@ -232,6 +267,42 @@ const ServerForm = ({
|
||||
options.maxTotalTimeout = formData.options.maxTotalTimeout;
|
||||
}
|
||||
|
||||
const oauthConfig = (() => {
|
||||
if (!formData.oauth) return undefined;
|
||||
const {
|
||||
clientId,
|
||||
clientSecret,
|
||||
scopes,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
authorizationEndpoint,
|
||||
tokenEndpoint,
|
||||
resource,
|
||||
} = formData.oauth;
|
||||
|
||||
const oauth: Record<string, unknown> = {};
|
||||
if (clientId && clientId.trim()) oauth.clientId = clientId.trim();
|
||||
if (clientSecret && clientSecret.trim()) oauth.clientSecret = clientSecret.trim();
|
||||
if (scopes && scopes.trim()) {
|
||||
const parsedScopes = scopes
|
||||
.split(/[\s,]+/)
|
||||
.map((scope) => scope.trim())
|
||||
.filter((scope) => scope.length > 0);
|
||||
if (parsedScopes.length > 0) {
|
||||
oauth.scopes = parsedScopes;
|
||||
}
|
||||
}
|
||||
if (accessToken && accessToken.trim()) oauth.accessToken = accessToken.trim();
|
||||
if (refreshToken && refreshToken.trim()) oauth.refreshToken = refreshToken.trim();
|
||||
if (authorizationEndpoint && authorizationEndpoint.trim()) {
|
||||
oauth.authorizationEndpoint = authorizationEndpoint.trim();
|
||||
}
|
||||
if (tokenEndpoint && tokenEndpoint.trim()) oauth.tokenEndpoint = tokenEndpoint.trim();
|
||||
if (resource && resource.trim()) oauth.resource = resource.trim();
|
||||
|
||||
return Object.keys(oauth).length > 0 ? oauth : undefined;
|
||||
})();
|
||||
|
||||
const payload = {
|
||||
name: formData.name,
|
||||
config: {
|
||||
@@ -304,6 +375,7 @@ const ServerForm = ({
|
||||
? {
|
||||
url: formData.url,
|
||||
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
||||
...(oauthConfig ? { oauth: oauthConfig } : {}),
|
||||
}
|
||||
: {
|
||||
command: formData.command,
|
||||
@@ -311,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 }
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -896,6 +977,132 @@ const ServerForm = ({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<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={() => setIsOAuthSectionExpanded(!isOAuthSectionExpanded)}
|
||||
>
|
||||
<label className="text-gray-700 text-sm font-bold">
|
||||
{t('server.oauth.sectionTitle')}
|
||||
</label>
|
||||
<span className="text-gray-500 text-sm">{isOAuthSectionExpanded ? '▼' : '▶'}</span>
|
||||
</div>
|
||||
|
||||
{isOAuthSectionExpanded && (
|
||||
<div className="border border-gray-200 rounded-b p-4 bg-gray-50 border-t-0">
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
{t('server.oauth.sectionDescription')}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">
|
||||
{t('server.oauth.clientId')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.oauth?.clientId || ''}
|
||||
onChange={(e) => handleOAuthChange('clientId', e.target.value)}
|
||||
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="client id"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">
|
||||
{t('server.oauth.clientSecret')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.oauth?.clientSecret || ''}
|
||||
onChange={(e) => handleOAuthChange('clientSecret', e.target.value)}
|
||||
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="client secret"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
{/*
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">
|
||||
{t('server.oauth.authorizationEndpoint')}
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.oauth?.authorizationEndpoint || ''}
|
||||
onChange={(e) => handleOAuthChange('authorizationEndpoint', e.target.value)}
|
||||
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="https://auth.example.com/authorize"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">
|
||||
{t('server.oauth.tokenEndpoint')}
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.oauth?.tokenEndpoint || ''}
|
||||
onChange={(e) => handleOAuthChange('tokenEndpoint', e.target.value)}
|
||||
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="https://auth.example.com/token"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">
|
||||
{t('server.oauth.scopes')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.oauth?.scopes || ''}
|
||||
onChange={(e) => handleOAuthChange('scopes', e.target.value)}
|
||||
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={t('server.oauth.scopesPlaceholder')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">
|
||||
{t('server.oauth.resource')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.oauth?.resource || ''}
|
||||
onChange={(e) => handleOAuthChange('resource', e.target.value)}
|
||||
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="https://mcp.example.com/mcp"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">
|
||||
{t('server.oauth.accessToken')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.oauth?.accessToken || ''}
|
||||
onChange={(e) => handleOAuthChange('accessToken', e.target.value)}
|
||||
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="access-token"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">
|
||||
{t('server.oauth.refreshToken')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.oauth?.refreshToken || ''}
|
||||
onChange={(e) => handleOAuthChange('refreshToken', e.target.value)}
|
||||
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="refresh-token"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
*/}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -1063,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"
|
||||
|
||||
@@ -13,24 +13,21 @@ type BadgeProps = {
|
||||
|
||||
const badgeVariants = {
|
||||
default: 'bg-blue-500 text-white hover:bg-blue-600',
|
||||
secondary: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600',
|
||||
outline: 'bg-transparent border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800',
|
||||
secondary:
|
||||
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600',
|
||||
outline:
|
||||
'bg-transparent border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800',
|
||||
destructive: 'bg-red-500 text-white hover:bg-red-600',
|
||||
};
|
||||
|
||||
export function Badge({
|
||||
children,
|
||||
variant = 'default',
|
||||
className,
|
||||
onClick
|
||||
}: BadgeProps) {
|
||||
export function Badge({ children, variant = 'default', className, onClick }: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors',
|
||||
badgeVariants[variant],
|
||||
onClick ? 'cursor-pointer' : '',
|
||||
className
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
@@ -40,27 +37,40 @@ export function Badge({
|
||||
}
|
||||
|
||||
// For backward compatibility with existing code
|
||||
export const StatusBadge = ({ status }: { status: 'connected' | 'disconnected' | 'connecting' }) => {
|
||||
export const StatusBadge = ({
|
||||
status,
|
||||
onAuthClick,
|
||||
}: {
|
||||
status: 'connected' | 'disconnected' | 'connecting' | 'oauth_required';
|
||||
onAuthClick?: (e: React.MouseEvent) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const colors = {
|
||||
connecting: 'status-badge-connecting',
|
||||
connected: 'status-badge-online',
|
||||
disconnected: 'status-badge-offline',
|
||||
oauth_required: 'status-badge-oauth-required',
|
||||
};
|
||||
|
||||
// Map status to translation keys
|
||||
const statusTranslations = {
|
||||
connected: 'status.online',
|
||||
disconnected: 'status.offline',
|
||||
connecting: 'status.connecting'
|
||||
connecting: 'status.connecting',
|
||||
oauth_required: 'status.oauthRequired',
|
||||
};
|
||||
|
||||
const isOAuthRequired = status === 'oauth_required';
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${colors[status]}`}
|
||||
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${colors[status]} ${isOAuthRequired && onAuthClick ? 'cursor-pointer hover:opacity-80' : ''}`}
|
||||
onClick={isOAuthRequired && onAuthClick ? (e) => onAuthClick(e) : undefined}
|
||||
title={isOAuthRequired ? t('status.clickToAuthorize') : undefined}
|
||||
>
|
||||
{isOAuthRequired && '🔐 '}
|
||||
{t(statusTranslations[status] || status)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
116
frontend/src/components/ui/DefaultPasswordWarningModal.tsx
Normal file
116
frontend/src/components/ui/DefaultPasswordWarningModal.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface DefaultPasswordWarningModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const DefaultPasswordWarningModal: React.FC<DefaultPasswordWarningModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleGoToSettings = () => {
|
||||
onClose();
|
||||
navigate('/settings');
|
||||
// Auto-scroll to password section after a small delay to ensure page is loaded
|
||||
setTimeout(() => {
|
||||
const passwordSection = document.querySelector('[data-section="password"]');
|
||||
if (passwordSection) {
|
||||
passwordSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
// If the section is collapsed, expand it
|
||||
const clickTarget = passwordSection.querySelector('[role="button"]');
|
||||
if (clickTarget && !passwordSection.querySelector('.mt-4')) {
|
||||
(clickTarget as HTMLElement).click();
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-[100] flex items-center justify-center p-4"
|
||||
onClick={handleBackdropClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full transform transition-all duration-200 ease-out"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="password-warning-title"
|
||||
aria-describedby="password-warning-message"
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="flex-shrink-0">
|
||||
<svg
|
||||
className="w-6 h-6 text-yellow-600 dark:text-yellow-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3
|
||||
id="password-warning-title"
|
||||
className="text-lg font-medium text-gray-900 dark:text-white mb-2"
|
||||
>
|
||||
{t('auth.defaultPasswordWarning')}
|
||||
</h3>
|
||||
<p
|
||||
id="password-warning-message"
|
||||
className="text-gray-600 dark:text-gray-300 leading-relaxed"
|
||||
>
|
||||
{t('auth.defaultPasswordMessage')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors duration-150 btn-secondary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGoToSettings}
|
||||
className="px-4 py-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded-md transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yellow-500 btn-warning"
|
||||
autoFocus
|
||||
>
|
||||
{t('auth.goToSettings')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DefaultPasswordWarningModal;
|
||||
@@ -11,7 +11,8 @@ const LanguageSwitch: React.FC = () => {
|
||||
const availableLanguages = [
|
||||
{ code: 'en', label: 'English' },
|
||||
{ code: 'zh', label: '中文' },
|
||||
{ code: 'fr', label: 'Français' }
|
||||
{ code: 'fr', label: 'Français' },
|
||||
{ code: 'tr', label: 'Türkçe' }
|
||||
];
|
||||
|
||||
// Update current language when it changes
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Tool } from '@/types'
|
||||
import { ChevronDown, ChevronRight, Play, Loader, Edit, Check } from '@/components/icons/LucideIcons'
|
||||
import { ChevronDown, ChevronRight, Play, Loader, Edit, Check, Copy } from '@/components/icons/LucideIcons'
|
||||
import { callTool, ToolCallResult, updateToolDescription } from '@/services/toolService'
|
||||
import { useSettingsData } from '@/hooks/useSettingsData'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
import { Switch } from './ToggleGroup'
|
||||
import DynamicForm from './DynamicForm'
|
||||
import ToolResult from './ToolResult'
|
||||
@@ -26,6 +27,7 @@ function isEmptyValue(value: any): boolean {
|
||||
|
||||
const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { showToast } = useToast()
|
||||
const { nameSeparator } = useSettingsData()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [showRunForm, setShowRunForm] = useState(false)
|
||||
@@ -36,6 +38,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
|
||||
const descriptionInputRef = useRef<HTMLInputElement>(null)
|
||||
const descriptionTextRef = useRef<HTMLSpanElement>(null)
|
||||
const [textWidth, setTextWidth] = useState<number>(0)
|
||||
const [copiedToolName, setCopiedToolName] = useState(false)
|
||||
|
||||
// Focus the input when editing mode is activated
|
||||
useEffect(() => {
|
||||
@@ -108,6 +111,41 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyToolName = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(tool.name)
|
||||
setCopiedToolName(true)
|
||||
showToast(t('common.copySuccess'), 'success')
|
||||
setTimeout(() => setCopiedToolName(false), 2000)
|
||||
} else {
|
||||
// Fallback for HTTP or unsupported clipboard API
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = tool.name
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-9999px'
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
setCopiedToolName(true)
|
||||
showToast(t('common.copySuccess'), 'success')
|
||||
setTimeout(() => setCopiedToolName(false), 2000)
|
||||
} catch (err) {
|
||||
showToast(t('common.copyFailed'), 'error')
|
||||
console.error('Copy to clipboard failed:', err)
|
||||
}
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(t('common.copyFailed'), 'error')
|
||||
console.error('Copy to clipboard failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRunTool = async (arguments_: Record<string, any>) => {
|
||||
setIsRunning(true)
|
||||
try {
|
||||
@@ -149,8 +187,19 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
<h3 className="text-lg font-medium text-gray-900 inline-flex items-center">
|
||||
{tool.name.replace(server + nameSeparator, '')}
|
||||
<button
|
||||
className="ml-2 p-1 text-gray-500 hover:text-blue-600 cursor-pointer transition-colors"
|
||||
onClick={handleCopyToolName}
|
||||
title={t('common.copy')}
|
||||
>
|
||||
{copiedToolName ? (
|
||||
<Check size={16} className="text-green-500" />
|
||||
) : (
|
||||
<Copy size={16} />
|
||||
)}
|
||||
</button>
|
||||
<span className="ml-2 text-sm font-normal text-gray-600 inline-flex items-center">
|
||||
{isEditingDescription ? (
|
||||
<>
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
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;
|
||||
|
||||
|
||||
@@ -14,12 +14,12 @@ const initialState: AuthState = {
|
||||
// Create auth context
|
||||
const AuthContext = createContext<{
|
||||
auth: AuthState;
|
||||
login: (username: string, password: string) => Promise<boolean>;
|
||||
login: (username: string, password: string) => Promise<{ success: boolean; isUsingDefaultPassword?: boolean }>;
|
||||
register: (username: string, password: string, isAdmin?: boolean) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
}>({
|
||||
auth: initialState,
|
||||
login: async () => false,
|
||||
login: async () => ({ success: false }),
|
||||
register: async () => false,
|
||||
logout: () => { },
|
||||
});
|
||||
@@ -90,7 +90,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
}, []);
|
||||
|
||||
// Login function
|
||||
const login = async (username: string, password: string): Promise<boolean> => {
|
||||
const login = async (username: string, password: string): Promise<{ success: boolean; isUsingDefaultPassword?: boolean }> => {
|
||||
try {
|
||||
const response = await authService.login({ username, password });
|
||||
|
||||
@@ -101,14 +101,17 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
user: response.user,
|
||||
error: null,
|
||||
});
|
||||
return true;
|
||||
return {
|
||||
success: true,
|
||||
isUsingDefaultPassword: response.isUsingDefaultPassword,
|
||||
};
|
||||
} else {
|
||||
setAuth({
|
||||
...initialState,
|
||||
loading: false,
|
||||
error: response.message || 'Authentication failed',
|
||||
});
|
||||
return false;
|
||||
return { success: false };
|
||||
}
|
||||
} catch (error) {
|
||||
setAuth({
|
||||
@@ -116,7 +119,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
loading: false,
|
||||
error: 'Authentication failed',
|
||||
});
|
||||
return false;
|
||||
return { success: false };
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -287,9 +287,13 @@ export const useCloudData = () => {
|
||||
const callServerTool = useCallback(
|
||||
async (serverName: string, toolName: string, args: Record<string, any>) => {
|
||||
try {
|
||||
const data = await apiPost(`/cloud/servers/${serverName}/tools/${toolName}/call`, {
|
||||
arguments: args,
|
||||
});
|
||||
// URL-encode server and tool names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
|
||||
const data = await apiPost(
|
||||
`/cloud/servers/${encodeURIComponent(serverName)}/tools/${encodeURIComponent(toolName)}/call`,
|
||||
{
|
||||
arguments: args,
|
||||
},
|
||||
);
|
||||
|
||||
if (data && data.success) {
|
||||
return data.data;
|
||||
|
||||
@@ -1,474 +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 SystemSettings {
|
||||
systemConfig?: {
|
||||
routing?: RoutingConfig;
|
||||
install?: InstallConfig;
|
||||
smartRouting?: SmartRoutingConfig;
|
||||
mcpRouter?: MCPRouterConfig;
|
||||
nameSeparator?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface TempRoutingConfig {
|
||||
bearerAuthKey: string;
|
||||
}
|
||||
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 [nameSeparator, setNameSeparator] = useState<string>('-');
|
||||
|
||||
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 && data.data?.systemConfig?.nameSeparator !== undefined) {
|
||||
setNameSeparator(data.data.systemConfig.nameSeparator);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to fetch settings');
|
||||
// 使用一个稳定的 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 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);
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
nameSeparator,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
triggerRefresh,
|
||||
fetchSettings,
|
||||
updateRoutingConfig,
|
||||
updateInstallConfig,
|
||||
updateSmartRoutingConfig,
|
||||
updateSmartRoutingConfigBatch,
|
||||
updateRoutingConfigBatch,
|
||||
updateMCPRouterConfig,
|
||||
updateMCPRouterConfigBatch,
|
||||
updateNameSeparator,
|
||||
exportMCPSettings,
|
||||
};
|
||||
return useSettings();
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
import enTranslation from '../../locales/en.json';
|
||||
import zhTranslation from '../../locales/zh.json';
|
||||
import frTranslation from '../../locales/fr.json';
|
||||
import trTranslation from '../../locales/tr.json';
|
||||
|
||||
i18n
|
||||
// Detect user language
|
||||
@@ -24,6 +25,9 @@ i18n
|
||||
fr: {
|
||||
translation: frTranslation,
|
||||
},
|
||||
tr: {
|
||||
translation: trTranslation,
|
||||
},
|
||||
},
|
||||
fallbackLng: 'en',
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
|
||||
@@ -144,6 +144,18 @@ body {
|
||||
border: 1px solid rgba(255, 193, 7, 0.3);
|
||||
}
|
||||
|
||||
.status-badge-oauth-required {
|
||||
background-color: white !important;
|
||||
color: rgba(156, 39, 176, 0.9) !important;
|
||||
border: 1px solid #ba68c8;
|
||||
}
|
||||
|
||||
.dark .status-badge-oauth-required {
|
||||
background-color: rgba(156, 39, 176, 0.15) !important;
|
||||
color: rgba(186, 104, 200, 0.9) !important;
|
||||
border: 1px solid rgba(156, 39, 176, 0.3);
|
||||
}
|
||||
|
||||
/* Enhanced status icons for dark theme */
|
||||
.dark .status-icon-blue {
|
||||
background-color: rgba(59, 130, 246, 0.15) !important;
|
||||
|
||||
@@ -12,14 +12,16 @@ const DashboardPage: React.FC = () => {
|
||||
total: servers.length,
|
||||
online: servers.filter((server: Server) => server.status === 'connected').length,
|
||||
offline: servers.filter((server: Server) => server.status === 'disconnected').length,
|
||||
connecting: servers.filter((server: Server) => server.status === 'connecting').length
|
||||
connecting: servers.filter((server: Server) => server.status === 'connecting').length,
|
||||
oauthRequired: servers.filter((server: Server) => server.status === 'oauth_required').length,
|
||||
};
|
||||
|
||||
// Map status to translation keys
|
||||
const statusTranslations: Record<string, string> = {
|
||||
connected: 'status.online',
|
||||
disconnected: 'status.offline',
|
||||
connecting: 'status.connecting'
|
||||
connecting: 'status.connecting',
|
||||
oauth_required: 'status.oauthRequired',
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -38,8 +40,17 @@ const DashboardPage: React.FC = () => {
|
||||
className="ml-4 text-gray-500 hover:text-gray-700 transition-colors duration-200"
|
||||
aria-label={t('app.closeButton')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 111.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z" clipRule="evenodd" />
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 111.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -49,9 +60,25 @@ const DashboardPage: React.FC = () => {
|
||||
{isLoading && (
|
||||
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center loading-container">
|
||||
<div className="flex flex-col items-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>
|
||||
@@ -64,12 +91,25 @@ const DashboardPage: React.FC = () => {
|
||||
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-blue-100 text-blue-800 icon-container status-icon-blue">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-8 w-8"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h2 className="text-xl font-semibold text-gray-700">{t('pages.dashboard.totalServers')}</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-700">
|
||||
{t('pages.dashboard.totalServers')}
|
||||
</h2>
|
||||
<p className="text-3xl font-bold text-gray-900">{serverStats.total}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -79,12 +119,25 @@ const DashboardPage: React.FC = () => {
|
||||
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-green-100 text-green-800 icon-container status-icon-green">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-8 w-8"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h2 className="text-xl font-semibold text-gray-700">{t('pages.dashboard.onlineServers')}</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-700">
|
||||
{t('pages.dashboard.onlineServers')}
|
||||
</h2>
|
||||
<p className="text-3xl font-bold text-gray-900">{serverStats.online}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,12 +147,25 @@ const DashboardPage: React.FC = () => {
|
||||
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-red-100 text-red-800 icon-container status-icon-red">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-8 w-8"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h2 className="text-xl font-semibold text-gray-700">{t('pages.dashboard.offlineServers')}</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-700">
|
||||
{t('pages.dashboard.offlineServers')}
|
||||
</h2>
|
||||
<p className="text-3xl font-bold text-gray-900">{serverStats.offline}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,16 +175,28 @@ const DashboardPage: React.FC = () => {
|
||||
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-yellow-100 text-yellow-800 icon-container status-icon-yellow">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-8 w-8"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h2 className="text-xl font-semibold text-gray-700">{t('pages.dashboard.connectingServers')}</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-700">
|
||||
{t('pages.dashboard.connectingServers')}
|
||||
</h2>
|
||||
<p className="text-3xl font-bold text-gray-900">{serverStats.connecting}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -126,24 +204,41 @@ const DashboardPage: React.FC = () => {
|
||||
{/* Recent activity list */}
|
||||
{servers.length > 0 && !isLoading && (
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">{t('pages.dashboard.recentServers')}</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">
|
||||
{t('pages.dashboard.recentServers')}
|
||||
</h2>
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden table-container">
|
||||
<table className="min-w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
{t('server.name')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
{t('server.status')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
{t('server.tools')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
{t('server.prompts')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
{t('server.enabled')}
|
||||
</th>
|
||||
</tr>
|
||||
@@ -155,12 +250,18 @@ const DashboardPage: React.FC = () => {
|
||||
{server.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${server.status === 'connected'
|
||||
? 'status-badge-online'
|
||||
: server.status === 'disconnected'
|
||||
? 'status-badge-offline'
|
||||
: 'status-badge-connecting'
|
||||
}`}>
|
||||
<span
|
||||
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||||
server.status === 'connected'
|
||||
? 'status-badge-online'
|
||||
: server.status === 'disconnected'
|
||||
? 'status-badge-offline'
|
||||
: server.status === 'oauth_required'
|
||||
? 'status-badge-oauth-required'
|
||||
: 'status-badge-connecting'
|
||||
}`}
|
||||
>
|
||||
{server.status === 'oauth_required' && '🔐 '}
|
||||
{t(statusTranslations[server.status] || server.status)}
|
||||
</span>
|
||||
</td>
|
||||
@@ -188,4 +289,4 @@ const DashboardPage: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
export default DashboardPage;
|
||||
|
||||
@@ -1,9 +1,33 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { getToken } from '../services/authService';
|
||||
import ThemeSwitch from '@/components/ui/ThemeSwitch';
|
||||
import LanguageSwitch from '@/components/ui/LanguageSwitch';
|
||||
import DefaultPasswordWarningModal from '@/components/ui/DefaultPasswordWarningModal';
|
||||
|
||||
const sanitizeReturnUrl = (value: string | null): string | null => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Support both relative paths and absolute URLs on the same origin
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost';
|
||||
const url = new URL(value, origin);
|
||||
if (url.origin !== origin) {
|
||||
return null;
|
||||
}
|
||||
const relativePath = `${url.pathname}${url.search}${url.hash}`;
|
||||
return relativePath || '/';
|
||||
} catch {
|
||||
if (value.startsWith('/') && !value.startsWith('//')) {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -11,8 +35,48 @@ const LoginPage: React.FC = () => {
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showDefaultPasswordWarning, setShowDefaultPasswordWarning] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const returnUrl = useMemo(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
return sanitizeReturnUrl(params.get('returnUrl'));
|
||||
}, [location.search]);
|
||||
|
||||
const buildRedirectTarget = useCallback(() => {
|
||||
if (!returnUrl) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
// Only attach JWT when returning to the OAuth authorize endpoint
|
||||
if (!returnUrl.startsWith('/oauth/authorize')) {
|
||||
return returnUrl;
|
||||
}
|
||||
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
return returnUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
const origin = window.location.origin;
|
||||
const url = new URL(returnUrl, origin);
|
||||
url.searchParams.set('token', token);
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
} catch {
|
||||
const separator = returnUrl.includes('?') ? '&' : '?';
|
||||
return `${returnUrl}${separator}token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
}, [returnUrl]);
|
||||
|
||||
const redirectAfterLogin = useCallback(() => {
|
||||
if (returnUrl) {
|
||||
window.location.assign(buildRedirectTarget());
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
}, [buildRedirectTarget, navigate, returnUrl]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -26,10 +90,15 @@ const LoginPage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await login(username, password);
|
||||
const result = await login(username, password);
|
||||
|
||||
if (success) {
|
||||
navigate('/');
|
||||
if (result.success) {
|
||||
if (result.isUsingDefaultPassword) {
|
||||
// Show warning modal instead of navigating immediately
|
||||
setShowDefaultPasswordWarning(true);
|
||||
} else {
|
||||
redirectAfterLogin();
|
||||
}
|
||||
} else {
|
||||
setError(t('auth.loginFailed'));
|
||||
}
|
||||
@@ -40,6 +109,11 @@ const LoginPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseWarning = () => {
|
||||
setShowDefaultPasswordWarning(false);
|
||||
redirectAfterLogin();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen w-full overflow-hidden bg-gray-50 dark:bg-gray-950">
|
||||
{/* Top-right controls */}
|
||||
@@ -138,8 +212,14 @@ const LoginPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Default Password Warning Modal */}
|
||||
<DefaultPasswordWarningModal
|
||||
isOpen={showDefaultPasswordWarning}
|
||||
onClose={handleCloseWarning}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
export default LoginPage;
|
||||
|
||||
@@ -7,6 +7,7 @@ import AddServerForm from '@/components/AddServerForm';
|
||||
import EditServerForm from '@/components/EditServerForm';
|
||||
import { useServerData } from '@/hooks/useServerData';
|
||||
import DxtUploadForm from '@/components/DxtUploadForm';
|
||||
import JSONImportForm from '@/components/JSONImportForm';
|
||||
|
||||
const ServersPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -20,11 +21,13 @@ const ServersPage: React.FC = () => {
|
||||
handleServerEdit,
|
||||
handleServerRemove,
|
||||
handleServerToggle,
|
||||
handleServerReload,
|
||||
triggerRefresh
|
||||
} = useServerData({ refreshOnMount: true });
|
||||
const [editingServer, setEditingServer] = useState<Server | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [showDxtUpload, setShowDxtUpload] = useState(false);
|
||||
const [showJsonImport, setShowJsonImport] = useState(false);
|
||||
|
||||
const handleEditClick = async (server: Server) => {
|
||||
const fullServerData = await handleServerEdit(server);
|
||||
@@ -55,6 +58,12 @@ const ServersPage: React.FC = () => {
|
||||
triggerRefresh();
|
||||
};
|
||||
|
||||
const handleJsonImportSuccess = () => {
|
||||
// Close import dialog and refresh servers
|
||||
setShowJsonImport(false);
|
||||
triggerRefresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
@@ -70,6 +79,15 @@ const ServersPage: React.FC = () => {
|
||||
{t('nav.market')}
|
||||
</button>
|
||||
<AddServerForm onAdd={handleServerAdd} />
|
||||
<button
|
||||
onClick={() => setShowJsonImport(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-1zM6.293 6.707a1 1 0 010-1.414l3-3a1 1 0 011.414 0l3 3a1 1 0 01-1.414 1.414L11 5.414V13a1 1 0 11-2 0V5.414L7.707 6.707a1 1 0 01-1.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{t('jsonImport.button')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDxtUpload(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"
|
||||
@@ -142,6 +160,7 @@ const ServersPage: React.FC = () => {
|
||||
onEdit={handleEditClick}
|
||||
onToggle={handleServerToggle}
|
||||
onRefresh={triggerRefresh}
|
||||
onReload={handleServerReload}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -161,8 +180,15 @@ const ServersPage: React.FC = () => {
|
||||
onCancel={() => setShowDxtUpload(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showJsonImport && (
|
||||
<JSONImportForm
|
||||
onSuccess={handleJsonImportSuccess}
|
||||
onCancel={() => setShowJsonImport(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServersPage;
|
||||
export default ServersPage;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,8 @@ import { useUserData } from '@/hooks/useUserData';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import AddUserForm from '@/components/AddUserForm';
|
||||
import EditUserForm from '@/components/EditUserForm';
|
||||
import UserCard from '@/components/UserCard';
|
||||
import { Edit, Trash, User as UserIcon } from 'lucide-react';
|
||||
import DeleteDialog from '@/components/ui/DeleteDialog';
|
||||
|
||||
const UsersPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -22,11 +23,12 @@ const UsersPage: React.FC = () => {
|
||||
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [userToDelete, setUserToDelete] = useState<string | null>(null);
|
||||
|
||||
// Check if current user is admin
|
||||
if (!currentUser?.isAdmin) {
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<div className="bg-white shadow rounded-lg p-6 dashboard-card">
|
||||
<p className="text-red-600">{t('users.adminRequired')}</p>
|
||||
</div>
|
||||
);
|
||||
@@ -41,10 +43,17 @@ const UsersPage: React.FC = () => {
|
||||
triggerRefresh(); // Refresh the users list after editing
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (username: string) => {
|
||||
const result = await deleteUser(username);
|
||||
if (!result?.success) {
|
||||
setUserError(result?.message || t('users.deleteError'));
|
||||
const handleDeleteClick = (username: string) => {
|
||||
setUserToDelete(username);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (userToDelete) {
|
||||
const result = await deleteUser(userToDelete);
|
||||
if (!result?.success) {
|
||||
setUserError(result?.message || t('users.deleteError'));
|
||||
}
|
||||
setUserToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -58,13 +67,13 @@ const UsersPage: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="container mx-auto">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">{t('pages.users.title')}</h1>
|
||||
<div className="flex space-x-4">
|
||||
<button
|
||||
onClick={handleAddUser}
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 flex items-center btn-primary transition-all duration-200 shadow-sm"
|
||||
>
|
||||
<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" />
|
||||
@@ -75,13 +84,23 @@ const UsersPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{userError && (
|
||||
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6 error-box rounded-lg">
|
||||
<p>{userError}</p>
|
||||
<div className="bg-red-50 border-l-4 border-red-500 text-red-700 p-4 mb-6 error-box rounded-lg shadow-sm">
|
||||
<div className="flex justify-between items-center">
|
||||
<p>{userError}</p>
|
||||
<button
|
||||
onClick={() => setUserError(null)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 111.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{usersLoading ? (
|
||||
<div className="bg-white shadow rounded-lg p-6 loading-container">
|
||||
<div className="bg-white shadow rounded-lg p-6 loading-container flex justify-center items-center h-64">
|
||||
<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>
|
||||
@@ -91,20 +110,93 @@ const UsersPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="bg-white shadow rounded-lg p-6 empty-state">
|
||||
<p className="text-gray-600">{t('users.noUsers')}</p>
|
||||
<div className="bg-white shadow rounded-lg p-6 empty-state dashboard-card">
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<div className="p-4 bg-gray-100 rounded-full mb-4">
|
||||
<UserIcon className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
<p className="text-gray-600 text-lg font-medium">{t('users.noUsers')}</p>
|
||||
<button
|
||||
onClick={handleAddUser}
|
||||
className="mt-4 text-blue-600 hover:text-blue-800 font-medium"
|
||||
>
|
||||
{t('users.addFirst')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{users.map((user) => (
|
||||
<UserCard
|
||||
key={user.username}
|
||||
user={user}
|
||||
currentUser={currentUser}
|
||||
onEdit={handleEditClick}
|
||||
onDelete={handleDeleteUser}
|
||||
/>
|
||||
))}
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden table-container dashboard-card">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('users.username')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('users.role')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('users.actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{users.map((user) => {
|
||||
const isCurrentUser = currentUser?.username === user.username;
|
||||
return (
|
||||
<tr key={user.username} className="hover:bg-gray-50 transition-colors duration-150">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 h-10 w-10">
|
||||
<div className="h-10 w-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold text-lg">
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900 flex items-center">
|
||||
{user.username}
|
||||
{isCurrentUser && (
|
||||
<span className="ml-2 px-2 py-0.5 text-xs bg-blue-100 text-blue-800 rounded-full border border-blue-200">
|
||||
{t('users.currentUser')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${user.isAdmin
|
||||
? 'bg-purple-100 text-purple-800 border border-purple-200'
|
||||
: 'bg-gray-100 text-gray-800 border border-gray-200'
|
||||
}`}>
|
||||
{user.isAdmin ? t('users.admin') : t('users.user')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={() => handleEditClick(user)}
|
||||
className="text-blue-600 hover:text-blue-900 p-1 rounded hover:bg-blue-50 transition-colors"
|
||||
title={t('users.edit')}
|
||||
>
|
||||
<Edit size={18} />
|
||||
</button>
|
||||
{!isCurrentUser && (
|
||||
<button
|
||||
onClick={() => handleDeleteClick(user.username)}
|
||||
className="text-red-600 hover:text-red-900 p-1 rounded hover:bg-red-50 transition-colors"
|
||||
title={t('users.delete')}
|
||||
>
|
||||
<Trash size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -119,6 +211,15 @@ const UsersPage: React.FC = () => {
|
||||
onCancel={() => setEditingUser(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DeleteDialog
|
||||
isOpen={!!userToDelete}
|
||||
onClose={() => setUserToDelete(null)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
serverName={userToDelete || ''}
|
||||
isGroup={false}
|
||||
isUser={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -59,8 +59,9 @@ export const getPrompt = async (
|
||||
server?: string,
|
||||
): Promise<GetPromptResult> => {
|
||||
try {
|
||||
// URL-encode server and prompt names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
|
||||
const response = await apiPost(
|
||||
`/mcp/${server}/prompts/${encodeURIComponent(request.promptName)}`,
|
||||
`/mcp/${encodeURIComponent(server || '')}/prompts/${encodeURIComponent(request.promptName)}`,
|
||||
{
|
||||
name: request.promptName,
|
||||
arguments: request.arguments,
|
||||
@@ -94,9 +95,13 @@ export const togglePrompt = async (
|
||||
enabled: boolean,
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
const response = await apiPost<any>(`/servers/${serverName}/prompts/${promptName}/toggle`, {
|
||||
enabled,
|
||||
});
|
||||
// URL-encode server and prompt names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
|
||||
const response = await apiPost<any>(
|
||||
`/servers/${encodeURIComponent(serverName)}/prompts/${encodeURIComponent(promptName)}/toggle`,
|
||||
{
|
||||
enabled,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
success: response.success,
|
||||
@@ -120,8 +125,9 @@ export const updatePromptDescription = async (
|
||||
description: string,
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
// URL-encode server and prompt names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
|
||||
const response = await apiPut<any>(
|
||||
`/servers/${serverName}/prompts/${promptName}/description`,
|
||||
`/servers/${encodeURIComponent(serverName)}/prompts/${encodeURIComponent(promptName)}/description`,
|
||||
{ description },
|
||||
{
|
||||
headers: {
|
||||
|
||||
@@ -25,7 +25,10 @@ export const callTool = async (
|
||||
): Promise<ToolCallResult> => {
|
||||
try {
|
||||
// Construct the URL with optional server parameter
|
||||
const url = server ? `/tools/${server}/${request.toolName}` : '/tools/call';
|
||||
// URL-encode server and tool names to handle slashes in names (e.g., "com.atlassian/atlassian-mcp-server")
|
||||
const url = server
|
||||
? `/tools/${encodeURIComponent(server)}/${encodeURIComponent(request.toolName)}`
|
||||
: '/tools/call';
|
||||
|
||||
const response = await apiPost<any>(url, request.arguments, {
|
||||
headers: {
|
||||
@@ -62,8 +65,9 @@ export const toggleTool = async (
|
||||
enabled: boolean,
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
// URL-encode server and tool names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
|
||||
const response = await apiPost<any>(
|
||||
`/servers/${serverName}/tools/${toolName}/toggle`,
|
||||
`/servers/${encodeURIComponent(serverName)}/tools/${encodeURIComponent(toolName)}/toggle`,
|
||||
{ enabled },
|
||||
{
|
||||
headers: {
|
||||
@@ -94,8 +98,9 @@ export const updateToolDescription = async (
|
||||
description: string,
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
// URL-encode server and tool names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
|
||||
const response = await apiPut<any>(
|
||||
`/servers/${serverName}/tools/${toolName}/description`,
|
||||
`/servers/${encodeURIComponent(serverName)}/tools/${encodeURIComponent(toolName)}/description`,
|
||||
{ description },
|
||||
{
|
||||
headers: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Server status types
|
||||
export type ServerStatus = 'connecting' | 'connected' | 'disconnected';
|
||||
export type ServerStatus = 'connecting' | 'connected' | 'disconnected' | 'oauth_required';
|
||||
|
||||
// Market server types
|
||||
export interface MarketServerRepository {
|
||||
@@ -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?: {
|
||||
@@ -121,6 +123,43 @@ export interface ServerConfig {
|
||||
resetTimeoutOnProgress?: boolean; // Reset timeout on progress notifications
|
||||
maxTotalTimeout?: number; // Maximum total timeout in milliseconds
|
||||
}; // MCP request options configuration
|
||||
// OAuth authentication for upstream MCP servers
|
||||
oauth?: {
|
||||
clientId?: string; // OAuth client ID
|
||||
clientSecret?: string; // OAuth client secret
|
||||
scopes?: string[]; // Required OAuth scopes
|
||||
accessToken?: string; // Pre-obtained access token (if available)
|
||||
refreshToken?: string; // Refresh token for renewing access
|
||||
dynamicRegistration?: {
|
||||
enabled?: boolean; // Enable/disable dynamic registration
|
||||
issuer?: string; // OAuth issuer URL for discovery
|
||||
registrationEndpoint?: string; // Direct registration endpoint URL
|
||||
metadata?: {
|
||||
client_name?: string;
|
||||
client_uri?: string;
|
||||
logo_uri?: string;
|
||||
scope?: string;
|
||||
redirect_uris?: string[];
|
||||
grant_types?: string[];
|
||||
response_types?: string[];
|
||||
token_endpoint_auth_method?: string;
|
||||
contacts?: string[];
|
||||
software_id?: string;
|
||||
software_version?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
initialAccessToken?: string;
|
||||
};
|
||||
resource?: string; // OAuth resource parameter (RFC8707)
|
||||
authorizationEndpoint?: string; // Authorization endpoint (authorization code flow)
|
||||
tokenEndpoint?: string; // Token endpoint for exchanging authorization codes for tokens
|
||||
pendingAuthorization?: {
|
||||
authorizationUrl?: string;
|
||||
state?: string;
|
||||
codeVerifier?: string;
|
||||
createdAt?: number;
|
||||
};
|
||||
};
|
||||
// OpenAPI specific configuration
|
||||
openapi?: {
|
||||
url?: string; // OpenAPI specification URL
|
||||
@@ -172,6 +211,10 @@ export interface Server {
|
||||
prompts?: Prompt[];
|
||||
config?: ServerConfig;
|
||||
enabled?: boolean;
|
||||
oauth?: {
|
||||
authorizationUrl?: string;
|
||||
state?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Group types
|
||||
@@ -209,6 +252,20 @@ export interface ServerFormData {
|
||||
resetTimeoutOnProgress?: boolean;
|
||||
maxTotalTimeout?: number;
|
||||
};
|
||||
keepAlive?: {
|
||||
enabled?: boolean;
|
||||
interval?: number;
|
||||
};
|
||||
oauth?: {
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
scopes?: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
authorizationEndpoint?: string;
|
||||
tokenEndpoint?: string;
|
||||
resource?: string;
|
||||
};
|
||||
// OpenAPI specific fields
|
||||
openapi?: {
|
||||
url?: string;
|
||||
@@ -308,6 +365,7 @@ export interface AuthResponse {
|
||||
token?: string;
|
||||
user?: IUser;
|
||||
message?: string;
|
||||
isUsingDefaultPassword?: boolean;
|
||||
}
|
||||
|
||||
// Official Registry types (from registry.modelcontextprotocol.io)
|
||||
|
||||
38
frontend/src/utils/passwordValidation.ts
Normal file
38
frontend/src/utils/passwordValidation.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Frontend password strength validation utility
|
||||
* Should match backend validation rules
|
||||
*/
|
||||
|
||||
export interface PasswordValidationResult {
|
||||
isValid: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export const validatePasswordStrength = (password: string): PasswordValidationResult => {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Check minimum length
|
||||
if (password.length < 8) {
|
||||
errors.push('passwordMinLength');
|
||||
}
|
||||
|
||||
// Check for at least one letter
|
||||
if (!/[a-zA-Z]/.test(password)) {
|
||||
errors.push('passwordRequireLetter');
|
||||
}
|
||||
|
||||
// Check for at least one number
|
||||
if (!/\d/.test(password)) {
|
||||
errors.push('passwordRequireNumber');
|
||||
}
|
||||
|
||||
// Check for at least one special character
|
||||
if (!/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password)) {
|
||||
errors.push('passwordRequireSpecial');
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
google-site-verification: googled76ca578b6543fbc.html
|
||||
@@ -40,7 +40,7 @@ module.exports = {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||
},
|
||||
transformIgnorePatterns: ['node_modules/(?!(@modelcontextprotocol|other-esm-packages)/)'],
|
||||
transformIgnorePatterns: ['node_modules/(?!(@modelcontextprotocol|openid-client|oauth4webapi)/)'],
|
||||
extensionsToTreatAsEsm: ['.ts'],
|
||||
testTimeout: 30000,
|
||||
verbose: true,
|
||||
|
||||
133
locales/en.json
133
locales/en.json
@@ -69,7 +69,16 @@
|
||||
"changePasswordError": "Failed to change password",
|
||||
"changePassword": "Change Password",
|
||||
"passwordChanged": "Password changed successfully",
|
||||
"passwordChangeError": "Failed to change password"
|
||||
"passwordChangeError": "Failed to change password",
|
||||
"defaultPasswordWarning": "Default Password Security Warning",
|
||||
"defaultPasswordMessage": "You are using the default password (admin123), which poses a security risk. Please change your password immediately to protect your account.",
|
||||
"goToSettings": "Go to Settings",
|
||||
"passwordStrengthError": "Password does not meet security requirements",
|
||||
"passwordMinLength": "Password must be at least 8 characters long",
|
||||
"passwordRequireLetter": "Password must contain at least one letter",
|
||||
"passwordRequireNumber": "Password must contain at least one number",
|
||||
"passwordRequireSpecial": "Password must contain at least one special character",
|
||||
"passwordStrengthHint": "Password must be at least 8 characters and contain letters, numbers, and special characters"
|
||||
},
|
||||
"server": {
|
||||
"addServer": "Add Server",
|
||||
@@ -107,13 +116,21 @@
|
||||
"enabled": "Enabled",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"requestOptions": "Configuration",
|
||||
"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)",
|
||||
"maxTotalTimeout": "Maximum Total Timeout",
|
||||
"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",
|
||||
@@ -164,12 +181,28 @@
|
||||
"apiKeyInCookie": "Cookie",
|
||||
"passthroughHeaders": "Passthrough Headers",
|
||||
"passthroughHeadersHelp": "Comma-separated list of header names to pass through from tool call requests to upstream OpenAPI endpoints (e.g., Authorization, X-API-Key)"
|
||||
},
|
||||
"oauth": {
|
||||
"sectionTitle": "OAuth Configuration",
|
||||
"sectionDescription": "Configure client credentials for OAuth-protected servers (optional).",
|
||||
"clientId": "Client ID",
|
||||
"clientSecret": "Client Secret",
|
||||
"authorizationEndpoint": "Authorization Endpoint",
|
||||
"tokenEndpoint": "Token Endpoint",
|
||||
"scopes": "Scopes",
|
||||
"scopesPlaceholder": "scope1 scope2",
|
||||
"resource": "Resource / Audience",
|
||||
"accessToken": "Access Token",
|
||||
"refreshToken": "Refresh Token"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"connecting": "Connecting"
|
||||
"connecting": "Connecting",
|
||||
"oauthRequired": "OAuth Required",
|
||||
"clickToAuthorize": "Click to authorize with OAuth",
|
||||
"oauthWindowOpened": "OAuth authorization window opened. Please complete the authorization."
|
||||
},
|
||||
"errors": {
|
||||
"general": "Something went wrong",
|
||||
@@ -188,6 +221,7 @@
|
||||
"processing": "Processing...",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"back": "Back",
|
||||
"refresh": "Refresh",
|
||||
"create": "Create",
|
||||
"creating": "Creating...",
|
||||
@@ -258,7 +292,8 @@
|
||||
"appearance": "Appearance",
|
||||
"routeConfig": "Security",
|
||||
"installConfig": "Installation",
|
||||
"smartRouting": "Smart Routing"
|
||||
"smartRouting": "Smart Routing",
|
||||
"oauthServer": "OAuth Server"
|
||||
},
|
||||
"market": {
|
||||
"title": "Market Hub - Local and Cloud Markets"
|
||||
@@ -357,6 +392,16 @@
|
||||
"confirmVariablesMessage": "Please ensure these variables are properly defined in your runtime environment. Continue installing server?",
|
||||
"confirmAndInstall": "Confirm and Install"
|
||||
},
|
||||
"oauthServer": {
|
||||
"authorizeTitle": "Authorize Application",
|
||||
"authorizeSubtitle": "Allow this application to access your MCPHub account.",
|
||||
"buttons": {
|
||||
"approve": "Allow access",
|
||||
"deny": "Deny",
|
||||
"approveSubtitle": "Recommended if you trust this application.",
|
||||
"denySubtitle": "You can always grant access later."
|
||||
}
|
||||
},
|
||||
"cloud": {
|
||||
"title": "Cloud Support",
|
||||
"subtitle": "Powered by MCPRouter",
|
||||
@@ -494,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",
|
||||
@@ -548,6 +595,8 @@
|
||||
"systemSettings": "System Settings",
|
||||
"nameSeparatorLabel": "Name Separator",
|
||||
"nameSeparatorDescription": "Character used to separate server name and tool/prompt name (default: -)",
|
||||
"enableSessionRebuild": "Enable Server Session Rebuild",
|
||||
"enableSessionRebuildDescription": "When enabled, applies the improved server session rebuild code for better session management experience",
|
||||
"restartRequired": "Configuration saved. It is recommended to restart the application to ensure all services load the new settings correctly.",
|
||||
"exportMcpSettings": "Export Settings",
|
||||
"mcpSettingsJson": "MCP Settings JSON",
|
||||
@@ -555,7 +604,33 @@
|
||||
"copyToClipboard": "Copy to Clipboard",
|
||||
"downloadJson": "Download JSON",
|
||||
"exportSuccess": "Settings exported successfully",
|
||||
"exportError": "Failed to fetch settings"
|
||||
"exportError": "Failed to fetch settings",
|
||||
"enableOauthServer": "Enable OAuth Server",
|
||||
"enableOauthServerDescription": "Allow MCPHub to issue OAuth tokens for external clients",
|
||||
"requireClientSecret": "Require Client Secret",
|
||||
"requireClientSecretDescription": "When enabled, confidential clients must present a client secret (disable for PKCE-only clients)",
|
||||
"requireState": "Require State Parameter",
|
||||
"requireStateDescription": "Reject authorization requests that omit the OAuth state parameter",
|
||||
"accessTokenLifetime": "Access Token Lifetime (seconds)",
|
||||
"accessTokenLifetimeDescription": "How long issued access tokens remain valid",
|
||||
"accessTokenLifetimePlaceholder": "e.g. 3600",
|
||||
"refreshTokenLifetime": "Refresh Token Lifetime (seconds)",
|
||||
"refreshTokenLifetimeDescription": "How long refresh tokens remain valid",
|
||||
"refreshTokenLifetimePlaceholder": "e.g. 1209600",
|
||||
"authorizationCodeLifetime": "Authorization Code Lifetime (seconds)",
|
||||
"authorizationCodeLifetimeDescription": "How long authorization codes remain valid before they can be exchanged",
|
||||
"authorizationCodeLifetimePlaceholder": "e.g. 300",
|
||||
"allowedScopes": "Allowed Scopes",
|
||||
"allowedScopesDescription": "Comma-separated list of scopes users can approve during authorization",
|
||||
"allowedScopesPlaceholder": "e.g. read, write",
|
||||
"enableDynamicRegistration": "Enable Dynamic Client Registration",
|
||||
"dynamicRegistrationDescription": "Allow RFC 7591 compliant clients to self-register using the public endpoint",
|
||||
"dynamicRegistrationAllowedGrantTypes": "Allowed Grant Types",
|
||||
"dynamicRegistrationAllowedGrantTypesDescription": "Comma-separated list of grants permitted for dynamically registered clients",
|
||||
"dynamicRegistrationAllowedGrantTypesPlaceholder": "e.g. authorization_code, refresh_token",
|
||||
"dynamicRegistrationAuth": "Require Authentication",
|
||||
"dynamicRegistrationAuthDescription": "Protect the registration endpoint so only authenticated requests can register clients",
|
||||
"invalidNumberInput": "Please enter a valid non-negative number"
|
||||
},
|
||||
"dxt": {
|
||||
"upload": "Upload",
|
||||
@@ -582,6 +657,21 @@
|
||||
"serverExistsConfirm": "Server '{{serverName}}' already exists. Do you want to override it with the new version?",
|
||||
"override": "Override"
|
||||
},
|
||||
"jsonImport": {
|
||||
"button": "Import",
|
||||
"title": "Import Servers from JSON",
|
||||
"inputLabel": "Server Configuration JSON",
|
||||
"inputHelp": "Paste your server configuration JSON. Supports STDIO, SSE, and HTTP (streamable-http) server types.",
|
||||
"preview": "Preview",
|
||||
"previewTitle": "Preview Servers to Import",
|
||||
"import": "Import",
|
||||
"importing": "Importing...",
|
||||
"invalidFormat": "Invalid JSON format. The JSON must contain an 'mcpServers' object.",
|
||||
"parseError": "Failed to parse JSON. Please check the format and try again.",
|
||||
"addFailed": "Failed to add server",
|
||||
"importFailed": "Failed to import servers",
|
||||
"partialSuccess": "Imported {{count}} of {{total}} servers successfully. Some servers failed:"
|
||||
},
|
||||
"users": {
|
||||
"add": "Add User",
|
||||
"addNew": "Add New User",
|
||||
@@ -593,9 +683,13 @@
|
||||
"password": "Password",
|
||||
"newPassword": "New Password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"changePassword": "Change Password",
|
||||
"adminRole": "Administrator",
|
||||
"admin": "Admin",
|
||||
"user": "User",
|
||||
"role": "Role",
|
||||
"actions": "Actions",
|
||||
"addFirst": "Add your first user",
|
||||
"permissions": "Permissions",
|
||||
"adminPermissions": "Full system access",
|
||||
"userPermissions": "Limited access",
|
||||
@@ -634,6 +728,7 @@
|
||||
"failedToRemoveServer": "Server not found or failed to remove",
|
||||
"internalServerError": "Internal server error",
|
||||
"failedToGetServers": "Failed to get servers information",
|
||||
"failedToReloadServer": "Failed to reload server",
|
||||
"failedToGetServerSettings": "Failed to get server settings",
|
||||
"failedToGetServerConfig": "Failed to get server configuration",
|
||||
"failedToSaveSettings": "Failed to save settings",
|
||||
@@ -676,5 +771,31 @@
|
||||
"serverRemovedFromGroup": "Server removed from group successfully",
|
||||
"serverToolsUpdated": "Server tools updated successfully"
|
||||
}
|
||||
},
|
||||
"oauthCallback": {
|
||||
"authorizationFailed": "Authorization Failed",
|
||||
"authorizationFailedError": "Error",
|
||||
"authorizationFailedDetails": "Details",
|
||||
"invalidRequest": "Invalid Request",
|
||||
"missingStateParameter": "Missing required OAuth state parameter.",
|
||||
"missingCodeParameter": "Missing required authorization code parameter.",
|
||||
"serverNotFound": "Server Not Found",
|
||||
"serverNotFoundMessage": "Could not find server associated with this authorization request.",
|
||||
"sessionExpiredMessage": "The authorization session may have expired. Please try authorizing again.",
|
||||
"authorizationSuccessful": "Authorization Successful",
|
||||
"server": "Server",
|
||||
"status": "Status",
|
||||
"connected": "Connected",
|
||||
"successMessage": "The server has been successfully authorized and connected.",
|
||||
"autoCloseMessage": "This window will close automatically in 3 seconds...",
|
||||
"closeNow": "Close Now",
|
||||
"connectionError": "Connection Error",
|
||||
"connectionErrorMessage": "Authorization was successful, but failed to connect to the server.",
|
||||
"reconnectMessage": "Please try reconnecting from the dashboard.",
|
||||
"configurationError": "Configuration Error",
|
||||
"configurationErrorMessage": "Server transport does not support OAuth finishAuth(). Please ensure the server is configured with streamable-http transport.",
|
||||
"internalError": "Internal Error",
|
||||
"internalErrorMessage": "An unexpected error occurred while processing the OAuth callback.",
|
||||
"closeWindow": "Close Window"
|
||||
}
|
||||
}
|
||||
133
locales/fr.json
133
locales/fr.json
@@ -69,7 +69,16 @@
|
||||
"changePasswordError": "Échec du changement de mot de passe",
|
||||
"changePassword": "Changer le mot de passe",
|
||||
"passwordChanged": "Mot de passe changé avec succès",
|
||||
"passwordChangeError": "Échec du changement de mot de passe"
|
||||
"passwordChangeError": "Échec du changement de mot de passe",
|
||||
"defaultPasswordWarning": "Avertissement de sécurité du mot de passe par défaut",
|
||||
"defaultPasswordMessage": "Vous utilisez le mot de passe par défaut (admin123), ce qui présente un risque de sécurité. Veuillez changer votre mot de passe immédiatement pour protéger votre compte.",
|
||||
"goToSettings": "Aller aux paramètres",
|
||||
"passwordStrengthError": "Le mot de passe ne répond pas aux exigences de sécurité",
|
||||
"passwordMinLength": "Le mot de passe doit contenir au moins 8 caractères",
|
||||
"passwordRequireLetter": "Le mot de passe doit contenir au moins une lettre",
|
||||
"passwordRequireNumber": "Le mot de passe doit contenir au moins un chiffre",
|
||||
"passwordRequireSpecial": "Le mot de passe doit contenir au moins un caractère spécial",
|
||||
"passwordStrengthHint": "Le mot de passe doit contenir au moins 8 caractères et inclure des lettres, des chiffres et des caractères spéciaux"
|
||||
},
|
||||
"server": {
|
||||
"addServer": "Ajouter un serveur",
|
||||
@@ -107,13 +116,21 @@
|
||||
"enabled": "Activé",
|
||||
"enable": "Activer",
|
||||
"disable": "Désactiver",
|
||||
"requestOptions": "Configuration",
|
||||
"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)",
|
||||
"maxTotalTimeout": "Délai d'attente total maximum",
|
||||
"maxTotalTimeoutDescription": "Délai d'attente total maximum pour les requêtes envoyées au serveur MCP (ms) (à utiliser avec les notifications de progression)",
|
||||
"resetTimeoutOnProgress": "Réinitialiser le délai d'attente en cas de progression",
|
||||
"resetTimeoutOnProgressDescription": "Réinitialiser le délai d'attente lors des notifications de progression",
|
||||
"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à",
|
||||
@@ -164,12 +181,28 @@
|
||||
"apiKeyInCookie": "Cookie",
|
||||
"passthroughHeaders": "En-têtes de transmission",
|
||||
"passthroughHeadersHelp": "Liste séparée par des virgules des noms d'en-têtes à transmettre des requêtes d'appel d'outils vers les points de terminaison OpenAPI en amont (par ex. : Authorization, X-API-Key)"
|
||||
},
|
||||
"oauth": {
|
||||
"sectionTitle": "Configuration OAuth",
|
||||
"sectionDescription": "Configurez les identifiants client pour les serveurs protégés par OAuth (optionnel).",
|
||||
"clientId": "Identifiant client",
|
||||
"clientSecret": "Secret client",
|
||||
"authorizationEndpoint": "Point de terminaison d'autorisation",
|
||||
"tokenEndpoint": "Point de terminaison de jeton",
|
||||
"scopes": "Scopes",
|
||||
"scopesPlaceholder": "scope1 scope2",
|
||||
"resource": "Ressource / Audience",
|
||||
"accessToken": "Jeton d'accès",
|
||||
"refreshToken": "Jeton d'actualisation"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"online": "En ligne",
|
||||
"offline": "Hors ligne",
|
||||
"connecting": "Connexion en cours"
|
||||
"connecting": "Connexion en cours",
|
||||
"oauthRequired": "OAuth requis",
|
||||
"clickToAuthorize": "Cliquez pour autoriser avec OAuth",
|
||||
"oauthWindowOpened": "Fenêtre d'autorisation OAuth ouverte. Veuillez compléter l'autorisation."
|
||||
},
|
||||
"errors": {
|
||||
"general": "Une erreur est survenue",
|
||||
@@ -178,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",
|
||||
@@ -188,6 +222,7 @@
|
||||
"processing": "En cours de traitement...",
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
"back": "Retour",
|
||||
"refresh": "Actualiser",
|
||||
"create": "Créer",
|
||||
"creating": "Création en cours...",
|
||||
@@ -258,7 +293,8 @@
|
||||
"appearance": "Apparence",
|
||||
"routeConfig": "Sécurité",
|
||||
"installConfig": "Installation",
|
||||
"smartRouting": "Routage intelligent"
|
||||
"smartRouting": "Routage intelligent",
|
||||
"oauthServer": "Serveur OAuth"
|
||||
},
|
||||
"market": {
|
||||
"title": "Marché Hub - Marchés locaux et Cloud"
|
||||
@@ -357,6 +393,16 @@
|
||||
"confirmVariablesMessage": "Veuillez vous assurer que ces variables sont correctement définies dans votre environnement d'exécution. Continuer l'installation du serveur ?",
|
||||
"confirmAndInstall": "Confirmer et installer"
|
||||
},
|
||||
"oauthServer": {
|
||||
"authorizeTitle": "Autoriser l'application",
|
||||
"authorizeSubtitle": "Autorisez cette application à accéder à votre compte MCPHub.",
|
||||
"buttons": {
|
||||
"approve": "Autoriser l'accès",
|
||||
"deny": "Refuser",
|
||||
"approveSubtitle": "Recommandé si vous faites confiance à cette application.",
|
||||
"denySubtitle": "Vous pourrez toujours accorder l'accès plus tard."
|
||||
}
|
||||
},
|
||||
"cloud": {
|
||||
"title": "Support Cloud",
|
||||
"subtitle": "Propulsé par MCPRouter",
|
||||
@@ -494,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",
|
||||
@@ -548,6 +596,8 @@
|
||||
"systemSettings": "Paramètres système",
|
||||
"nameSeparatorLabel": "Séparateur de noms",
|
||||
"nameSeparatorDescription": "Caractère utilisé pour séparer le nom du serveur et le nom de l'outil/prompt (par défaut : -)",
|
||||
"enableSessionRebuild": "Activer la reconstruction de session serveur",
|
||||
"enableSessionRebuildDescription": "Lorsqu'il est activé, applique le code de reconstruction de session serveur amélioré pour une meilleure expérience de gestion de session",
|
||||
"restartRequired": "Configuration enregistrée. Il est recommandé de redémarrer l'application pour s'assurer que tous les services chargent correctement les nouveaux paramètres.",
|
||||
"exportMcpSettings": "Exporter les paramètres",
|
||||
"mcpSettingsJson": "JSON des paramètres MCP",
|
||||
@@ -555,7 +605,33 @@
|
||||
"copyToClipboard": "Copier dans le presse-papiers",
|
||||
"downloadJson": "Télécharger JSON",
|
||||
"exportSuccess": "Paramètres exportés avec succès",
|
||||
"exportError": "Échec de la récupération des paramètres"
|
||||
"exportError": "Échec de la récupération des paramètres",
|
||||
"enableOauthServer": "Activer le serveur OAuth",
|
||||
"enableOauthServerDescription": "Permet à MCPHub d'émettre des jetons OAuth pour les clients externes",
|
||||
"requireClientSecret": "Exiger un secret client",
|
||||
"requireClientSecretDescription": "Lorsque activé, les clients confidentiels doivent présenter un client secret (désactivez-le pour les clients PKCE publics)",
|
||||
"requireState": "Exiger le paramètre state",
|
||||
"requireStateDescription": "Refuser les demandes d'autorisation qui n'incluent pas le paramètre state",
|
||||
"accessTokenLifetime": "Durée de vie du jeton d'accès (secondes)",
|
||||
"accessTokenLifetimeDescription": "Durée pendant laquelle les jetons d'accès émis restent valides",
|
||||
"accessTokenLifetimePlaceholder": "ex. 3600",
|
||||
"refreshTokenLifetime": "Durée de vie du jeton d'actualisation (secondes)",
|
||||
"refreshTokenLifetimeDescription": "Durée pendant laquelle les jetons d'actualisation restent valides",
|
||||
"refreshTokenLifetimePlaceholder": "ex. 1209600",
|
||||
"authorizationCodeLifetime": "Durée de vie du code d'autorisation (secondes)",
|
||||
"authorizationCodeLifetimeDescription": "Temps pendant lequel les codes d'autorisation peuvent être échangés",
|
||||
"authorizationCodeLifetimePlaceholder": "ex. 300",
|
||||
"allowedScopes": "Scopes autorisés",
|
||||
"allowedScopesDescription": "Liste séparée par des virgules des scopes que les utilisateurs peuvent approuver",
|
||||
"allowedScopesPlaceholder": "ex. read, write",
|
||||
"enableDynamicRegistration": "Activer l'enregistrement dynamique",
|
||||
"dynamicRegistrationDescription": "Autoriser les clients conformes RFC 7591 à s'enregistrer via l'endpoint public",
|
||||
"dynamicRegistrationAllowedGrantTypes": "Types de flux autorisés",
|
||||
"dynamicRegistrationAllowedGrantTypesDescription": "Liste séparée par des virgules des types de flux disponibles pour les clients enregistrés dynamiquement",
|
||||
"dynamicRegistrationAllowedGrantTypesPlaceholder": "ex. authorization_code, refresh_token",
|
||||
"dynamicRegistrationAuth": "Exiger une authentification",
|
||||
"dynamicRegistrationAuthDescription": "Protège l'endpoint d'enregistrement afin que seules les requêtes authentifiées puissent créer des clients",
|
||||
"invalidNumberInput": "Veuillez saisir un nombre valide supérieur ou égal à zéro"
|
||||
},
|
||||
"dxt": {
|
||||
"upload": "Télécharger",
|
||||
@@ -582,6 +658,21 @@
|
||||
"serverExistsConfirm": "Le serveur '{{serverName}}' existe déjà. Voulez-vous le remplacer par la nouvelle version ?",
|
||||
"override": "Remplacer"
|
||||
},
|
||||
"jsonImport": {
|
||||
"button": "Importer",
|
||||
"title": "Importer des serveurs depuis JSON",
|
||||
"inputLabel": "Configuration JSON du serveur",
|
||||
"inputHelp": "Collez votre configuration JSON de serveur. Prend en charge les types de serveurs STDIO, SSE et HTTP (streamable-http).",
|
||||
"preview": "Aperçu",
|
||||
"previewTitle": "Aperçu des serveurs à importer",
|
||||
"import": "Importer",
|
||||
"importing": "Importation en cours...",
|
||||
"invalidFormat": "Format JSON invalide. Le JSON doit contenir un objet 'mcpServers'.",
|
||||
"parseError": "Échec de l'analyse du JSON. Veuillez vérifier le format et réessayer.",
|
||||
"addFailed": "Échec de l'ajout du serveur",
|
||||
"importFailed": "Échec de l'importation des serveurs",
|
||||
"partialSuccess": "{{count}} serveur(s) sur {{total}} importé(s) avec succès. Certains serveurs ont échoué :"
|
||||
},
|
||||
"users": {
|
||||
"add": "Ajouter un utilisateur",
|
||||
"addNew": "Ajouter un nouvel utilisateur",
|
||||
@@ -593,9 +684,13 @@
|
||||
"password": "Mot de passe",
|
||||
"newPassword": "Nouveau mot de passe",
|
||||
"confirmPassword": "Confirmer le mot de passe",
|
||||
"changePassword": "Changer le mot de passe",
|
||||
"adminRole": "Administrateur",
|
||||
"admin": "Admin",
|
||||
"user": "Utilisateur",
|
||||
"role": "Rôle",
|
||||
"actions": "Actions",
|
||||
"addFirst": "Ajoutez votre premier utilisateur",
|
||||
"permissions": "Permissions",
|
||||
"adminPermissions": "Accès complet au système",
|
||||
"userPermissions": "Accès limité",
|
||||
@@ -676,5 +771,31 @@
|
||||
"serverRemovedFromGroup": "Serveur supprimé du groupe avec succès",
|
||||
"serverToolsUpdated": "Outils du serveur mis à jour avec succès"
|
||||
}
|
||||
},
|
||||
"oauthCallback": {
|
||||
"authorizationFailed": "Échec de l'autorisation",
|
||||
"authorizationFailedError": "Erreur",
|
||||
"authorizationFailedDetails": "Détails",
|
||||
"invalidRequest": "Requête invalide",
|
||||
"missingStateParameter": "Paramètre d'état OAuth requis manquant.",
|
||||
"missingCodeParameter": "Paramètre de code d'autorisation requis manquant.",
|
||||
"serverNotFound": "Serveur introuvable",
|
||||
"serverNotFoundMessage": "Impossible de trouver le serveur associé à cette demande d'autorisation.",
|
||||
"sessionExpiredMessage": "La session d'autorisation a peut-être expiré. Veuillez réessayer l'autorisation.",
|
||||
"authorizationSuccessful": "Autorisation réussie",
|
||||
"server": "Serveur",
|
||||
"status": "État",
|
||||
"connected": "Connecté",
|
||||
"successMessage": "Le serveur a été autorisé et connecté avec succès.",
|
||||
"autoCloseMessage": "Cette fenêtre se fermera automatiquement dans 3 secondes...",
|
||||
"closeNow": "Fermer maintenant",
|
||||
"connectionError": "Erreur de connexion",
|
||||
"connectionErrorMessage": "L'autorisation a réussi, mais la connexion au serveur a échoué.",
|
||||
"reconnectMessage": "Veuillez essayer de vous reconnecter à partir du tableau de bord.",
|
||||
"configurationError": "Erreur de configuration",
|
||||
"configurationErrorMessage": "Le transport du serveur ne prend pas en charge OAuth finishAuth(). Veuillez vous assurer que le serveur est configuré avec le transport streamable-http.",
|
||||
"internalError": "Erreur interne",
|
||||
"internalErrorMessage": "Une erreur inattendue s'est produite lors du traitement du callback OAuth.",
|
||||
"closeWindow": "Fermer la fenêtre"
|
||||
}
|
||||
}
|
||||
801
locales/tr.json
Normal file
801
locales/tr.json
Normal file
@@ -0,0 +1,801 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "MCPHub Kontrol Paneli",
|
||||
"error": "Hata",
|
||||
"closeButton": "Kapat",
|
||||
"noServers": "Kullanılabilir MCP sunucusu yok",
|
||||
"loading": "Yükleniyor...",
|
||||
"logout": "Çıkış Yap",
|
||||
"profile": "Profil",
|
||||
"changePassword": "Şifre Değiştir",
|
||||
"toggleSidebar": "Kenar Çubuğunu Aç/Kapat",
|
||||
"welcomeUser": "Hoş geldin, {{username}}",
|
||||
"name": "MCPHub"
|
||||
},
|
||||
"about": {
|
||||
"title": "Hakkında",
|
||||
"versionInfo": "MCPHub Sürümü: {{version}}",
|
||||
"newVersion": "Yeni sürüm mevcut!",
|
||||
"currentVersion": "Mevcut sürüm",
|
||||
"newVersionAvailable": "Yeni sürüm {{version}} mevcut",
|
||||
"viewOnGitHub": "GitHub'da Görüntüle",
|
||||
"checkForUpdates": "Güncellemeleri Kontrol Et",
|
||||
"checking": "Güncellemeler kontrol ediliyor..."
|
||||
},
|
||||
"profile": {
|
||||
"viewProfile": "Profili görüntüle",
|
||||
"userCenter": "Kullanıcı Merkezi"
|
||||
},
|
||||
"sponsor": {
|
||||
"label": "Sponsor",
|
||||
"title": "Projeyi Destekle",
|
||||
"rewardAlt": "Ödül QR Kodu",
|
||||
"supportMessage": "Bana bir kahve ısmarlayarak MCPHub'ın geliştirilmesini destekleyin!",
|
||||
"supportButton": "Ko-fi'de Destek Ol"
|
||||
},
|
||||
"wechat": {
|
||||
"label": "WeChat",
|
||||
"title": "WeChat ile Bağlan",
|
||||
"qrCodeAlt": "WeChat QR Kodu",
|
||||
"scanMessage": "WeChat'te bizimle bağlantı kurmak için bu QR kodunu tarayın"
|
||||
},
|
||||
"discord": {
|
||||
"label": "Discord",
|
||||
"title": "Discord sunucumuza katılın",
|
||||
"community": "Destek, tartışmalar ve güncellemeler için büyüyen Discord topluluğumuza katılın!"
|
||||
},
|
||||
"theme": {
|
||||
"title": "Tema",
|
||||
"light": "Açık",
|
||||
"dark": "Koyu",
|
||||
"system": "Sistem"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Giriş Yap",
|
||||
"loginTitle": "MCPHub'a Giriş Yap",
|
||||
"slogan": "Birleşik MCP sunucu yönetim platformu",
|
||||
"subtitle": "Model Context Protocol sunucuları için merkezi yönetim platformu. Esnek yönlendirme stratejileri ile birden fazla MCP sunucusunu organize edin, izleyin ve ölçeklendirin.",
|
||||
"username": "Kullanıcı Adı",
|
||||
"password": "Şifre",
|
||||
"loggingIn": "Giriş yapılıyor...",
|
||||
"emptyFields": "Kullanıcı adı ve şifre boş olamaz",
|
||||
"loginFailed": "Giriş başarısız, lütfen kullanıcı adınızı ve şifrenizi kontrol edin",
|
||||
"loginError": "Giriş sırasında bir hata oluştu",
|
||||
"currentPassword": "Mevcut Şifre",
|
||||
"newPassword": "Yeni Şifre",
|
||||
"confirmPassword": "Şifreyi Onayla",
|
||||
"passwordsNotMatch": "Yeni şifre ve onay eşleşmiyor",
|
||||
"changePasswordSuccess": "Şifre başarıyla değiştirildi",
|
||||
"changePasswordError": "Şifre değişikliği başarısız oldu",
|
||||
"changePassword": "Şifre Değiştir",
|
||||
"passwordChanged": "Şifre başarıyla değiştirildi",
|
||||
"passwordChangeError": "Şifre değişikliği başarısız oldu",
|
||||
"defaultPasswordWarning": "Varsayılan Şifre Güvenlik Uyarısı",
|
||||
"defaultPasswordMessage": "Varsayılan şifreyi (admin123) kullanıyorsunuz, bu bir güvenlik riski oluşturur. Hesabınızı korumak için lütfen şifrenizi hemen değiştirin.",
|
||||
"goToSettings": "Ayarlara Git",
|
||||
"passwordStrengthError": "Şifre güvenlik gereksinimlerini karşılamıyor",
|
||||
"passwordMinLength": "Şifre en az 8 karakter uzunluğunda olmalıdır",
|
||||
"passwordRequireLetter": "Şifre en az bir harf içermelidir",
|
||||
"passwordRequireNumber": "Şifre en az bir rakam içermelidir",
|
||||
"passwordRequireSpecial": "Şifre en az bir özel karakter içermelidir",
|
||||
"passwordStrengthHint": "Şifre en az 8 karakter olmalı ve harf, rakam ve özel karakter içermelidir"
|
||||
},
|
||||
"server": {
|
||||
"addServer": "Sunucu Ekle",
|
||||
"add": "Ekle",
|
||||
"edit": "Düzenle",
|
||||
"copy": "Kopyala",
|
||||
"delete": "Sil",
|
||||
"confirmDelete": "Bu sunucuyu silmek istediğinizden emin misiniz?",
|
||||
"deleteWarning": "'{{name}}' sunucusunu silmek, onu ve tüm verilerini kaldıracaktır. Bu işlem geri alınamaz.",
|
||||
"status": "Durum",
|
||||
"tools": "Araçlar",
|
||||
"prompts": "İstekler",
|
||||
"name": "Sunucu Adı",
|
||||
"url": "Sunucu URL'si",
|
||||
"apiKey": "API Anahtarı",
|
||||
"save": "Kaydet",
|
||||
"cancel": "İptal",
|
||||
"invalidConfig": "{{serverName}} için yapılandırma verisi bulunamadı",
|
||||
"addError": "Sunucu eklenemedi",
|
||||
"editError": "{{serverName}} sunucusu düzenlenemedi",
|
||||
"deleteError": "{{serverName}} sunucusu silinemedi",
|
||||
"updateError": "Sunucu güncellenemedi",
|
||||
"editTitle": "Sunucuyu Düzenle: {{serverName}}",
|
||||
"type": "Sunucu Türü",
|
||||
"typeStdio": "STDIO",
|
||||
"typeSse": "SSE",
|
||||
"typeStreamableHttp": "Akış Yapılabilir HTTP",
|
||||
"typeOpenapi": "OpenAPI",
|
||||
"command": "Komut",
|
||||
"arguments": "Argümanlar",
|
||||
"envVars": "Ortam Değişkenleri",
|
||||
"headers": "HTTP Başlıkları",
|
||||
"key": "anahtar",
|
||||
"value": "değer",
|
||||
"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)",
|
||||
"maxTotalTimeout": "Maksimum Toplam Zaman Aşımı",
|
||||
"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",
|
||||
"invalidData": "Geçersiz sunucu verisi sağlandı",
|
||||
"notFound": "{{serverName}} sunucusu bulunamadı",
|
||||
"namePlaceholder": "Sunucu adını girin",
|
||||
"urlPlaceholder": "Sunucu URL'sini girin",
|
||||
"commandPlaceholder": "Komutu girin",
|
||||
"argumentsPlaceholder": "Argümanları girin",
|
||||
"errorDetails": "Hata Detayları",
|
||||
"viewErrorDetails": "Hata detaylarını görüntüle",
|
||||
"copyConfig": "Yapılandırmayı Kopyala",
|
||||
"confirmVariables": "Değişken Yapılandırmasını Onayla",
|
||||
"variablesDetected": "Yapılandırmada değişkenler algılandı. Lütfen bu değişkenlerin düzgün yapılandırıldığını onaylayın:",
|
||||
"detectedVariables": "Algılanan Değişkenler",
|
||||
"confirmVariablesMessage": "Lütfen bu değişkenlerin çalışma ortamınızda düzgün tanımlandığından emin olun. Sunucu eklemeye devam edilsin mi?",
|
||||
"confirmAndAdd": "Onayla ve Ekle",
|
||||
"openapi": {
|
||||
"inputMode": "Giriş Modu",
|
||||
"inputModeUrl": "Şartname URL'si",
|
||||
"inputModeSchema": "JSON Şeması",
|
||||
"specUrl": "OpenAPI Şartname URL'si",
|
||||
"schema": "OpenAPI JSON Şeması",
|
||||
"schemaHelp": "Eksiksiz OpenAPI JSON şemanızı buraya yapıştırın",
|
||||
"security": "Güvenlik Türü",
|
||||
"securityNone": "Yok",
|
||||
"securityApiKey": "API Anahtarı",
|
||||
"securityHttp": "HTTP Kimlik Doğrulaması",
|
||||
"securityOAuth2": "OAuth 2.0",
|
||||
"securityOpenIdConnect": "OpenID Connect",
|
||||
"apiKeyConfig": "API Anahtarı Yapılandırması",
|
||||
"apiKeyName": "Başlık/Parametre Adı",
|
||||
"apiKeyIn": "Konum",
|
||||
"apiKeyValue": "API Anahtarı Değeri",
|
||||
"httpAuthConfig": "HTTP Kimlik Doğrulama Yapılandırması",
|
||||
"httpScheme": "Kimlik Doğrulama Şeması",
|
||||
"httpCredentials": "Kimlik Bilgileri",
|
||||
"httpSchemeBasic": "Basit",
|
||||
"httpSchemeBearer": "Bearer",
|
||||
"httpSchemeDigest": "Digest",
|
||||
"oauth2Config": "OAuth 2.0 Yapılandırması",
|
||||
"oauth2Token": "Erişim Anahtarı",
|
||||
"openIdConnectConfig": "OpenID Connect Yapılandırması",
|
||||
"openIdConnectUrl": "URL'yi Keşfet",
|
||||
"openIdConnectToken": "ID Token",
|
||||
"apiKeyInHeader": "Başlık",
|
||||
"apiKeyInQuery": "Sorgu",
|
||||
"apiKeyInCookie": "Çerez",
|
||||
"passthroughHeaders": "Geçiş Başlıkları",
|
||||
"passthroughHeadersHelp": "Araç çağrısı isteklerinden yukarı akış OpenAPI uç noktalarına geçirilecek başlık adlarının virgülle ayrılmış listesi (örn. Authorization, X-API-Key)"
|
||||
},
|
||||
"oauth": {
|
||||
"sectionTitle": "OAuth Yapılandırması",
|
||||
"sectionDescription": "OAuth korumalı sunucular için istemci kimlik bilgilerini yapılandırın (isteğe bağlı).",
|
||||
"clientId": "İstemci ID",
|
||||
"clientSecret": "İstemci Gizli Anahtarı",
|
||||
"authorizationEndpoint": "Yetkilendirme Uç Noktası",
|
||||
"tokenEndpoint": "Token Uç Noktası",
|
||||
"scopes": "Kapsamlar",
|
||||
"scopesPlaceholder": "scope1 scope2",
|
||||
"resource": "Kaynak / Hedef Kitle",
|
||||
"accessToken": "Erişim Tokeni",
|
||||
"refreshToken": "Yenileme Tokeni"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"online": "Çevrimiçi",
|
||||
"offline": "Çevrimdışı",
|
||||
"connecting": "Bağlanıyor",
|
||||
"oauthRequired": "OAuth Gerekli",
|
||||
"clickToAuthorize": "OAuth ile yetkilendirmek için tıklayın",
|
||||
"oauthWindowOpened": "OAuth yetkilendirme penceresi açıldı. Lütfen yetkilendirmeyi tamamlayın."
|
||||
},
|
||||
"errors": {
|
||||
"general": "Bir şeyler yanlış gitti",
|
||||
"network": "Ağ bağlantı hatası. Lütfen internet bağlantınızı kontrol edin",
|
||||
"serverConnection": "Sunucuya bağlanılamıyor. Lütfen sunucunun çalışıp çalışmadığını kontrol edin",
|
||||
"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",
|
||||
"failedToUpdateRouteConfig": "Route yapılandırması güncellenemedi",
|
||||
"failedToUpdateSmartRoutingConfig": "Akıllı yönlendirme yapılandırması güncellenemedi"
|
||||
},
|
||||
"common": {
|
||||
"processing": "İşleniyor...",
|
||||
"save": "Kaydet",
|
||||
"cancel": "İptal",
|
||||
"back": "Geri",
|
||||
"refresh": "Yenile",
|
||||
"create": "Oluştur",
|
||||
"creating": "Oluşturuluyor...",
|
||||
"update": "Güncelle",
|
||||
"updating": "Güncelleniyor...",
|
||||
"submitting": "Gönderiliyor...",
|
||||
"delete": "Sil",
|
||||
"remove": "Kaldır",
|
||||
"copy": "Kopyala",
|
||||
"copyId": "ID'yi Kopyala",
|
||||
"copyUrl": "URL'yi Kopyala",
|
||||
"copyJson": "JSON'u Kopyala",
|
||||
"copySuccess": "Panoya kopyalandı",
|
||||
"copyFailed": "Kopyalama başarısız",
|
||||
"copied": "Kopyalandı",
|
||||
"close": "Kapat",
|
||||
"confirm": "Onayla",
|
||||
"language": "Dil",
|
||||
"true": "Doğru",
|
||||
"false": "Yanlış",
|
||||
"dismiss": "Anımsatma",
|
||||
"github": "GitHub",
|
||||
"wechat": "WeChat",
|
||||
"discord": "Discord",
|
||||
"required": "Gerekli",
|
||||
"secret": "Gizli",
|
||||
"default": "Varsayılan",
|
||||
"value": "Değer",
|
||||
"type": "Tür",
|
||||
"repeated": "Tekrarlanan",
|
||||
"valueHint": "Değer İpucu",
|
||||
"choices": "Seçenekler"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Kontrol Paneli",
|
||||
"servers": "Sunucular",
|
||||
"groups": "Gruplar",
|
||||
"users": "Kullanıcılar",
|
||||
"settings": "Ayarlar",
|
||||
"changePassword": "Şifre Değiştir",
|
||||
"market": "Market",
|
||||
"cloud": "Bulut Market",
|
||||
"logs": "Günlükler"
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
"title": "Kontrol Paneli",
|
||||
"totalServers": "Toplam",
|
||||
"onlineServers": "Çevrimiçi",
|
||||
"offlineServers": "Çevrimdışı",
|
||||
"connectingServers": "Bağlanıyor",
|
||||
"recentServers": "Son Sunucular"
|
||||
},
|
||||
"servers": {
|
||||
"title": "Sunucu Yönetimi"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Grup Yönetimi"
|
||||
},
|
||||
"users": {
|
||||
"title": "Kullanıcı Yönetimi"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ayarlar",
|
||||
"language": "Dil",
|
||||
"account": "Hesap Ayarları",
|
||||
"password": "Şifre Değiştir",
|
||||
"appearance": "Görünüm",
|
||||
"routeConfig": "Güvenlik",
|
||||
"installConfig": "Kurulum",
|
||||
"smartRouting": "Akıllı Yönlendirme",
|
||||
"oauthServer": "OAuth Sunucusu"
|
||||
},
|
||||
"market": {
|
||||
"title": "Market Yönetimi - Yerel ve Bulut Marketler"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Sistem Günlükleri"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
"filters": "Filtreler",
|
||||
"search": "Günlüklerde ara...",
|
||||
"autoScroll": "Otomatik kaydır",
|
||||
"clearLogs": "Günlükleri temizle",
|
||||
"loading": "Günlükler yükleniyor...",
|
||||
"noLogs": "Kullanılabilir günlük yok.",
|
||||
"noMatch": "Mevcut filtrelerle eşleşen günlük yok.",
|
||||
"mainProcess": "Ana İşlem",
|
||||
"childProcess": "Alt İşlem",
|
||||
"main": "Ana",
|
||||
"child": "Alt"
|
||||
},
|
||||
"groups": {
|
||||
"add": "Ekle",
|
||||
"addNew": "Yeni Grup Ekle",
|
||||
"edit": "Grubu Düzenle",
|
||||
"delete": "Sil",
|
||||
"confirmDelete": "Bu grubu silmek istediğinizden emin misiniz?",
|
||||
"deleteWarning": "'{{name}}' grubunu silmek, onu ve tüm sunucu ilişkilerini kaldıracaktır. Bu işlem geri alınamaz.",
|
||||
"name": "Grup Adı",
|
||||
"namePlaceholder": "Grup adını girin",
|
||||
"nameRequired": "Grup adı gereklidir",
|
||||
"description": "Açıklama",
|
||||
"descriptionPlaceholder": "Grup açıklamasını girin (isteğe bağlı)",
|
||||
"createError": "Grup oluşturulamadı",
|
||||
"updateError": "Grup güncellenemedi",
|
||||
"deleteError": "Grup silinemedi",
|
||||
"serverAddError": "Sunucu gruba eklenemedi",
|
||||
"serverRemoveError": "Sunucu gruptan kaldırılamadı",
|
||||
"addServer": "Gruba Sunucu Ekle",
|
||||
"selectServer": "Eklenecek bir sunucu seçin",
|
||||
"servers": "Gruptaki Sunucular",
|
||||
"remove": "Kaldır",
|
||||
"noGroups": "Kullanılabilir grup yok. Başlamak için yeni bir grup oluşturun.",
|
||||
"noServers": "Bu grupta sunucu yok.",
|
||||
"noServerOptions": "Kullanılabilir sunucu yok",
|
||||
"serverCount": "{{count}} Sunucu",
|
||||
"toolSelection": "Araç Seçimi",
|
||||
"toolsSelected": "Seçildi",
|
||||
"allTools": "Tümü",
|
||||
"selectedTools": "Seçili araçlar",
|
||||
"selectAll": "Tümünü Seç",
|
||||
"selectNone": "Hiçbirini Seçme",
|
||||
"configureTools": "Araçları Yapılandır"
|
||||
},
|
||||
"market": {
|
||||
"title": "Yerel Kurulum",
|
||||
"official": "Resmi",
|
||||
"by": "Geliştirici",
|
||||
"unknown": "Bilinmeyen",
|
||||
"tools": "araçlar",
|
||||
"search": "Ara",
|
||||
"searchPlaceholder": "Sunucuları isme, kategoriye veya etiketlere göre ara",
|
||||
"clearFilters": "Temizle",
|
||||
"clearCategoryFilter": "",
|
||||
"clearTagFilter": "",
|
||||
"categories": "Kategoriler",
|
||||
"tags": "Etiketler",
|
||||
"showTags": "Etiketleri göster",
|
||||
"hideTags": "Etiketleri gizle",
|
||||
"moreTags": "",
|
||||
"noServers": "Aramanızla eşleşen sunucu bulunamadı",
|
||||
"backToList": "Listeye dön",
|
||||
"install": "Yükle",
|
||||
"installing": "Yükleniyor...",
|
||||
"installed": "Yüklendi",
|
||||
"installServer": "Sunucu Yükle: {{name}}",
|
||||
"installSuccess": "{{serverName}} sunucusu başarıyla yüklendi",
|
||||
"author": "Yazar",
|
||||
"license": "Lisans",
|
||||
"repository": "Depo",
|
||||
"examples": "Örnekler",
|
||||
"arguments": "Argümanlar",
|
||||
"argumentName": "Ad",
|
||||
"description": "Açıklama",
|
||||
"required": "Gerekli",
|
||||
"example": "Örnek",
|
||||
"viewSchema": "Şemayı görüntüle",
|
||||
"fetchError": "Market sunucuları getirilirken hata",
|
||||
"serverNotFound": "Sunucu bulunamadı",
|
||||
"searchError": "Sunucular aranırken hata",
|
||||
"filterError": "Sunucular kategoriye göre filtrelenirken hata",
|
||||
"tagFilterError": "Sunucular etikete göre filtrelenirken hata",
|
||||
"noInstallationMethod": "Bu sunucu için kullanılabilir kurulum yöntemi yok",
|
||||
"showing": "{{total}} sunucudan {{from}}-{{to}} arası gösteriliyor",
|
||||
"perPage": "Sayfa başına",
|
||||
"confirmVariablesMessage": "Lütfen bu değişkenlerin çalışma ortamınızda düzgün tanımlandığından emin olun. Sunucu yüklemeye devam edilsin mi?",
|
||||
"confirmAndInstall": "Onayla ve Yükle"
|
||||
},
|
||||
"oauthServer": {
|
||||
"authorizeTitle": "Uygulamayı Yetkilendir",
|
||||
"authorizeSubtitle": "Bu uygulamanın MCPHub hesabınıza erişmesine izin verin.",
|
||||
"buttons": {
|
||||
"approve": "Erişime izin ver",
|
||||
"deny": "Reddet",
|
||||
"approveSubtitle": "Bu uygulamaya güveniyorsanız izin vermeniz önerilir.",
|
||||
"denySubtitle": "İstediğiniz zaman daha sonra erişim verebilirsiniz."
|
||||
}
|
||||
},
|
||||
"cloud": {
|
||||
"title": "Bulut Desteği",
|
||||
"subtitle": "MCPRouter tarafından desteklenmektedir",
|
||||
"by": "Geliştirici",
|
||||
"server": "Sunucu",
|
||||
"config": "Yapılandırma",
|
||||
"created": "Oluşturuldu",
|
||||
"updated": "Güncellendi",
|
||||
"available": "Kullanılabilir",
|
||||
"description": "Açıklama",
|
||||
"details": "Detaylar",
|
||||
"tools": "Araçlar",
|
||||
"tool": "araç",
|
||||
"toolsAvailable": "{{count}} araç mevcut",
|
||||
"loadingTools": "Araçlar yükleniyor...",
|
||||
"noTools": "Bu sunucu için kullanılabilir araç yok",
|
||||
"noDescription": "Kullanılabilir açıklama yok",
|
||||
"viewDetails": "Detayları Görüntüle",
|
||||
"parameters": "Parametreler",
|
||||
"result": "Sonuç",
|
||||
"error": "Hata",
|
||||
"callTool": "Çalıştır",
|
||||
"calling": "Çalıştırılıyor...",
|
||||
"toolCallSuccess": "{{toolName}} aracı başarıyla çalıştırıldı",
|
||||
"toolCallError": "{{toolName}} aracı çalıştırılamadı: {{error}}",
|
||||
"viewSchema": "Şemayı Görüntüle",
|
||||
"backToList": "Bulut Market'e Dön",
|
||||
"search": "Ara",
|
||||
"searchPlaceholder": "Bulut sunucularını isme, başlığa veya geliştiriciye göre ara",
|
||||
"clearFilters": "Filtreleri Temizle",
|
||||
"clearCategoryFilter": "Temizle",
|
||||
"clearTagFilter": "Temizle",
|
||||
"categories": "Kategoriler",
|
||||
"tags": "Etiketler",
|
||||
"noCategories": "Kategori bulunamadı",
|
||||
"noTags": "Etiket bulunamadı",
|
||||
"noServers": "Bulut sunucusu bulunamadı",
|
||||
"fetchError": "Bulut sunucuları getirilirken hata",
|
||||
"serverNotFound": "Bulut sunucusu bulunamadı",
|
||||
"searchError": "Bulut sunucuları aranırken hata",
|
||||
"filterError": "Bulut sunucuları kategoriye göre filtrelenirken hata",
|
||||
"tagFilterError": "Bulut sunucuları etikete göre filtrelenirken hata",
|
||||
"showing": "{{total}} bulut sunucusundan {{from}}-{{to}} arası gösteriliyor",
|
||||
"perPage": "Sayfa başına",
|
||||
"apiKeyNotConfigured": "MCPRouter API anahtarı yapılandırılmamış",
|
||||
"apiKeyNotConfiguredDescription": "Bulut sunucularını kullanmak için MCPRouter API anahtarınızı yapılandırmanız gerekir.",
|
||||
"getApiKey": "API Anahtarı Al",
|
||||
"configureInSettings": "Ayarlarda Yapılandır",
|
||||
"installServer": "{{name}} Yükle",
|
||||
"installSuccess": "{{name}} sunucusu başarıyla yüklendi",
|
||||
"installError": "Sunucu yüklenemedi: {{error}}"
|
||||
},
|
||||
"registry": {
|
||||
"title": "Kayıt",
|
||||
"official": "Resmi",
|
||||
"latest": "En Son",
|
||||
"description": "Açıklama",
|
||||
"website": "Web Sitesi",
|
||||
"repository": "Depo",
|
||||
"packages": "Paketler",
|
||||
"package": "paket",
|
||||
"remotes": "Uzak Sunucular",
|
||||
"remote": "uzak sunucu",
|
||||
"published": "Yayınlandı",
|
||||
"updated": "Güncellendi",
|
||||
"install": "Yükle",
|
||||
"installing": "Yükleniyor...",
|
||||
"installed": "Yüklendi",
|
||||
"installServer": "{{name}} Yükle",
|
||||
"installSuccess": "{{name}} sunucusu başarıyla yüklendi",
|
||||
"installError": "Sunucu yüklenemedi: {{error}}",
|
||||
"noDescription": "Kullanılabilir açıklama yok",
|
||||
"viewDetails": "Detayları Görüntüle",
|
||||
"backToList": "Kayda Dön",
|
||||
"search": "Ara",
|
||||
"searchPlaceholder": "Kayıt sunucularını isme göre ara",
|
||||
"clearFilters": "Temizle",
|
||||
"noServers": "Kayıt sunucusu bulunamadı",
|
||||
"fetchError": "Kayıt sunucuları getirilirken hata",
|
||||
"serverNotFound": "Kayıt sunucusu bulunamadı",
|
||||
"showing": "{{total}} kayıt sunucusundan {{from}}-{{to}} arası gösteriliyor",
|
||||
"perPage": "Sayfa başına",
|
||||
"environmentVariables": "Ortam Değişkenleri",
|
||||
"packageArguments": "Paket Argümanları",
|
||||
"runtimeArguments": "Çalışma Zamanı Argümanları",
|
||||
"headers": "Başlıklar"
|
||||
},
|
||||
"tool": {
|
||||
"run": "Çalıştır",
|
||||
"running": "Çalıştırılıyor...",
|
||||
"runTool": "Aracı Çalıştır",
|
||||
"cancel": "İptal",
|
||||
"noDescription": "Kullanılabilir açıklama yok",
|
||||
"inputSchema": "Giriş Şeması:",
|
||||
"runToolWithName": "Aracı Çalıştır: {{name}}",
|
||||
"execution": "Araç Çalıştırma",
|
||||
"successful": "Başarılı",
|
||||
"failed": "Başarısız",
|
||||
"result": "Sonuç:",
|
||||
"error": "Hata",
|
||||
"errorDetails": "Hata Detayları:",
|
||||
"noContent": "Araç başarıyla çalıştırıldı ancak içerik döndürmedi.",
|
||||
"unknownError": "Bilinmeyen hata oluştu",
|
||||
"jsonResponse": "JSON Yanıtı:",
|
||||
"toolResult": "Araç sonucu",
|
||||
"noParameters": "Bu araç herhangi bir parametre gerektirmez.",
|
||||
"selectOption": "Bir seçenek seçin",
|
||||
"enterValue": "{{type}} değeri girin",
|
||||
"enabled": "Etkin",
|
||||
"enableSuccess": "{{name}} aracı başarıyla etkinleştirildi",
|
||||
"disableSuccess": "{{name}} aracı başarıyla devre dışı bırakıldı",
|
||||
"toggleFailed": "Araç durumu değiştirilemedi",
|
||||
"parameters": "Araç Parametreleri",
|
||||
"formMode": "Form Modu",
|
||||
"jsonMode": "JSON Modu",
|
||||
"jsonConfiguration": "JSON Yapılandırması",
|
||||
"invalidJsonFormat": "Geçersiz JSON formatı",
|
||||
"fixJsonBeforeSwitching": "Form moduna geçmeden önce lütfen JSON formatını düzeltin",
|
||||
"item": "Öğe {{index}}",
|
||||
"addItem": "{{key}} öğesi ekle",
|
||||
"enterKey": "{{key}} girin"
|
||||
},
|
||||
"prompt": {
|
||||
"run": "Getir",
|
||||
"running": "Getiriliyor...",
|
||||
"result": "İstek Sonucu",
|
||||
"error": "İstek Hatası",
|
||||
"execution": "İstek Çalıştırma",
|
||||
"successful": "Başarılı",
|
||||
"failed": "Başarısız",
|
||||
"errorDetails": "Hata Detayları:",
|
||||
"noContent": "İstek başarıyla çalıştırıldı ancak içerik döndürmedi.",
|
||||
"unknownError": "Bilinmeyen hata oluştu",
|
||||
"jsonResponse": "JSON Yanıtı:",
|
||||
"description": "Açıklama",
|
||||
"messages": "Mesajlar",
|
||||
"noDescription": "Kullanılabilir açıklama yok",
|
||||
"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",
|
||||
"enableGlobalRouteDescription": "Grup ID'si belirtmeden /sse uç noktasına bağlantıya izin ver",
|
||||
"enableGroupNameRoute": "Grup Adı Yönlendirmeyi Etkinleştir",
|
||||
"enableGroupNameRouteDescription": "Sadece grup ID'leri yerine grup adları kullanarak /sse uç noktasına bağlantıya izin ver",
|
||||
"enableBearerAuth": "Bearer Kimlik Doğrulamasını Etkinleştir",
|
||||
"enableBearerAuthDescription": "MCP istekleri için bearer token kimlik doğrulaması gerektir",
|
||||
"bearerAuthKey": "Bearer Kimlik Doğrulama Anahtarı",
|
||||
"bearerAuthKeyDescription": "Bearer token'da gerekli olacak kimlik doğrulama anahtarı",
|
||||
"bearerAuthKeyPlaceholder": "Bearer kimlik doğrulama anahtarını girin",
|
||||
"skipAuth": "Kimlik Doğrulamayı Atla",
|
||||
"skipAuthDescription": "Arayüz ve API erişimi için giriş gereksinimini atla (Güvenlik için VARSAYILAN KAPALI)",
|
||||
"pythonIndexUrl": "Python Paket Deposu URL'si",
|
||||
"pythonIndexUrlDescription": "Python paket kurulumu için UV_DEFAULT_INDEX ortam değişkenini ayarla",
|
||||
"pythonIndexUrlPlaceholder": "örn. https://pypi.org/simple",
|
||||
"npmRegistry": "NPM Kayıt URL'si",
|
||||
"npmRegistryDescription": "NPM paket kurulumu için npm_config_registry ortam değişkenini ayarla",
|
||||
"npmRegistryPlaceholder": "örn. https://registry.npmjs.org/",
|
||||
"baseUrl": "Temel URL",
|
||||
"baseUrlDescription": "MCP istekleri için temel URL",
|
||||
"baseUrlPlaceholder": "örn. http://localhost:3000",
|
||||
"installConfig": "Kurulum",
|
||||
"systemConfigUpdated": "Sistem yapılandırması başarıyla güncellendi",
|
||||
"enableSmartRouting": "Akıllı Yönlendirmeyi Etkinleştir",
|
||||
"enableSmartRoutingDescription": "Girdiye göre en uygun aracı aramak için akıllı yönlendirme özelliğini etkinleştir ($smart grup adını kullanarak)",
|
||||
"dbUrl": "PostgreSQL URL'si (pgvector desteği gerektirir)",
|
||||
"dbUrlPlaceholder": "örn. postgresql://kullanıcı:şifre@localhost:5432/veritabanıadı",
|
||||
"openaiApiBaseUrl": "OpenAI API Temel URL'si",
|
||||
"openaiApiBaseUrlPlaceholder": "https://api.openai.com/v1",
|
||||
"openaiApiKey": "OpenAI API Anahtarı",
|
||||
"openaiApiKeyPlaceholder": "OpenAI API anahtarını girin",
|
||||
"openaiApiEmbeddingModel": "OpenAI Entegrasyon Modeli",
|
||||
"openaiApiEmbeddingModelPlaceholder": "text-embedding-3-small",
|
||||
"smartRoutingConfigUpdated": "Akıllı yönlendirme yapılandırması başarıyla güncellendi",
|
||||
"smartRoutingRequiredFields": "Akıllı yönlendirmeyi etkinleştirmek için Veritabanı URL'si ve OpenAI API Anahtarı gereklidir",
|
||||
"smartRoutingValidationError": "Akıllı Yönlendirmeyi etkinleştirmeden önce lütfen gerekli alanları doldurun: {{fields}}",
|
||||
"mcpRouterConfig": "Bulut Market",
|
||||
"mcpRouterApiKey": "MCPRouter API Anahtarı",
|
||||
"mcpRouterApiKeyDescription": "MCPRouter bulut market hizmetlerine erişim için API anahtarı",
|
||||
"mcpRouterApiKeyPlaceholder": "MCPRouter API anahtarını girin",
|
||||
"mcpRouterReferer": "Yönlendiren",
|
||||
"mcpRouterRefererDescription": "MCPRouter API istekleri için Referer başlığı",
|
||||
"mcpRouterRefererPlaceholder": "https://www.mcphubx.com",
|
||||
"mcpRouterTitle": "Başlık",
|
||||
"mcpRouterTitleDescription": "MCPRouter API istekleri için Başlık başlığı",
|
||||
"mcpRouterTitlePlaceholder": "MCPHub",
|
||||
"mcpRouterBaseUrl": "Temel URL",
|
||||
"mcpRouterBaseUrlDescription": "MCPRouter API için temel URL",
|
||||
"mcpRouterBaseUrlPlaceholder": "https://api.mcprouter.to/v1",
|
||||
"systemSettings": "Sistem Ayarları",
|
||||
"nameSeparatorLabel": "İsim Ayırıcı",
|
||||
"nameSeparatorDescription": "Sunucu adı ile araç/istek adını ayırmak için kullanılan karakter (varsayılan: -)",
|
||||
"enableSessionRebuild": "Sunucu Oturum Yeniden Oluşturmayı Etkinleştir",
|
||||
"enableSessionRebuildDescription": "Etkinleştirildiğinde, daha iyi oturum yönetimi deneyimi için geliştirilmiş sunucu oturum yeniden oluşturma kodunu uygular",
|
||||
"restartRequired": "Yapılandırma kaydedildi. Tüm hizmetlerin yeni ayarları doğru şekilde yüklemesini sağlamak için uygulamayı yeniden başlatmanız önerilir.",
|
||||
"exportMcpSettings": "Ayarları Dışa Aktar",
|
||||
"mcpSettingsJson": "MCP Ayarları JSON",
|
||||
"mcpSettingsJsonDescription": "Yedekleme veya diğer araçlara taşıma için mevcut mcp_settings.json yapılandırmanızı görüntüleyin, kopyalayın veya indirin",
|
||||
"copyToClipboard": "Panoya Kopyala",
|
||||
"downloadJson": "JSON Olarak İndir",
|
||||
"exportSuccess": "Ayarlar başarıyla dışa aktarıldı",
|
||||
"exportError": "Ayarlar getirilemedi",
|
||||
"enableOauthServer": "OAuth Sunucusunu Etkinleştir",
|
||||
"enableOauthServerDescription": "MCPHub'ın harici istemciler için OAuth jetonları vermesine izin ver",
|
||||
"requireClientSecret": "İstemci Sırrı Zorunlu",
|
||||
"requireClientSecretDescription": "Etkin olduğunda gizli istemciler client secret sunmalıdır (yalnızca PKCE kullanan istemciler için kapatabilirsiniz)",
|
||||
"requireState": "State parametresi zorunlu",
|
||||
"requireStateDescription": "State parametresi olmayan yetkilendirme isteklerini reddeder",
|
||||
"accessTokenLifetime": "Erişim jetonu süresi (saniye)",
|
||||
"accessTokenLifetimeDescription": "Verilen erişim jetonlarının geçerli kalacağı süre",
|
||||
"accessTokenLifetimePlaceholder": "örn. 3600",
|
||||
"refreshTokenLifetime": "Yenileme jetonu süresi (saniye)",
|
||||
"refreshTokenLifetimeDescription": "Yenileme jetonlarının geçerli kalacağı süre",
|
||||
"refreshTokenLifetimePlaceholder": "örn. 1209600",
|
||||
"authorizationCodeLifetime": "Yetkilendirme kodu süresi (saniye)",
|
||||
"authorizationCodeLifetimeDescription": "Yetkilendirme kodlarının takas edilebileceği süre",
|
||||
"authorizationCodeLifetimePlaceholder": "örn. 300",
|
||||
"allowedScopes": "İzin verilen kapsamlar",
|
||||
"allowedScopesDescription": "Kullanıcıların onaylayabileceği kapsamların virgülle ayrılmış listesi",
|
||||
"allowedScopesPlaceholder": "örn. read, write",
|
||||
"enableDynamicRegistration": "Dinamik istemci kaydını etkinleştir",
|
||||
"dynamicRegistrationDescription": "RFC 7591 uyumlu istemcilerin herkese açık uç nokta üzerinden kayıt olmasına izin ver",
|
||||
"dynamicRegistrationAllowedGrantTypes": "İzin verilen grant türleri",
|
||||
"dynamicRegistrationAllowedGrantTypesDescription": "Dinamik olarak kaydedilen istemciler için kullanılabilecek grant türlerinin virgülle ayrılmış listesi",
|
||||
"dynamicRegistrationAllowedGrantTypesPlaceholder": "örn. authorization_code, refresh_token",
|
||||
"dynamicRegistrationAuth": "Kayıt için kimlik doğrulaması iste",
|
||||
"dynamicRegistrationAuthDescription": "Kayıt uç noktasını korur, yalnızca kimliği doğrulanmış istekler yeni istemci oluşturabilir",
|
||||
"invalidNumberInput": "Lütfen sıfırdan küçük olmayan geçerli bir sayı girin"
|
||||
},
|
||||
"dxt": {
|
||||
"upload": "Yükle",
|
||||
"uploadTitle": "DXT Uzantısı Yükle",
|
||||
"dropFileHere": ".dxt dosyanızı buraya bırakın",
|
||||
"orClickToSelect": "veya bilgisayarınızdan seçmek için tıklayın",
|
||||
"invalidFileType": "Lütfen geçerli bir .dxt dosyası seçin",
|
||||
"noFileSelected": "Lütfen yüklemek için bir .dxt dosyası seçin",
|
||||
"uploading": "Yükleniyor...",
|
||||
"uploadFailed": "DXT dosyası yüklenemedi",
|
||||
"installServer": "DXT'den MCP Sunucusu Yükle",
|
||||
"extensionInfo": "Uzantı Bilgisi",
|
||||
"name": "Ad",
|
||||
"version": "Sürüm",
|
||||
"description": "Açıklama",
|
||||
"author": "Geliştirici",
|
||||
"tools": "Araçlar",
|
||||
"serverName": "Sunucu Adı",
|
||||
"serverNamePlaceholder": "Bu sunucu için bir ad girin",
|
||||
"install": "Yükle",
|
||||
"installing": "Yükleniyor...",
|
||||
"installFailed": "DXT'den sunucu yüklenemedi",
|
||||
"serverExistsTitle": "Sunucu Zaten Mevcut",
|
||||
"serverExistsConfirm": "'{{serverName}}' sunucusu zaten mevcut. Yeni sürümle geçersiz kılmak istiyor musunuz?",
|
||||
"override": "Geçersiz Kıl"
|
||||
},
|
||||
"jsonImport": {
|
||||
"button": "İçe Aktar",
|
||||
"title": "JSON'dan Sunucuları İçe Aktar",
|
||||
"inputLabel": "Sunucu Yapılandırma JSON",
|
||||
"inputHelp": "Sunucu yapılandırma JSON'unuzu yapıştırın. STDIO, SSE ve HTTP (streamable-http) sunucu türlerini destekler.",
|
||||
"preview": "Önizle",
|
||||
"previewTitle": "İçe Aktarılacak Sunucuları Önizle",
|
||||
"import": "İçe Aktar",
|
||||
"importing": "İçe aktarılıyor...",
|
||||
"invalidFormat": "Geçersiz JSON formatı. JSON bir 'mcpServers' nesnesi içermelidir.",
|
||||
"parseError": "JSON ayrıştırılamadı. Lütfen formatı kontrol edip tekrar deneyin.",
|
||||
"addFailed": "Sunucu eklenemedi",
|
||||
"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:"
|
||||
},
|
||||
"users": {
|
||||
"add": "Kullanıcı Ekle",
|
||||
"addNew": "Yeni Kullanıcı Ekle",
|
||||
"edit": "Kullanıcıyı Düzenle",
|
||||
"delete": "Kullanıcıyı Sil",
|
||||
"create": "Kullanıcı Oluştur",
|
||||
"update": "Kullanıcıyı Güncelle",
|
||||
"username": "Kullanıcı Adı",
|
||||
"password": "Şifre",
|
||||
"newPassword": "Yeni Şifre",
|
||||
"confirmPassword": "Şifreyi Onayla",
|
||||
"changePassword": "Şifre Değiştir",
|
||||
"adminRole": "Yönetici",
|
||||
"admin": "Yönetici",
|
||||
"user": "Kullanıcı",
|
||||
"role": "Rol",
|
||||
"actions": "Eylemler",
|
||||
"addFirst": "İlk kullanıcınızı ekleyin",
|
||||
"permissions": "İzinler",
|
||||
"adminPermissions": "Tam sistem erişimi",
|
||||
"userPermissions": "Sınırlı erişim",
|
||||
"currentUser": "Siz",
|
||||
"noUsers": "Kullanıcı bulunamadı",
|
||||
"adminRequired": "Kullanıcıları yönetmek için yönetici erişimi gereklidir",
|
||||
"usernameRequired": "Kullanıcı adı gereklidir",
|
||||
"passwordRequired": "Şifre gereklidir",
|
||||
"passwordTooShort": "Şifre en az 6 karakter uzunluğunda olmalıdır",
|
||||
"passwordMismatch": "Şifreler eşleşmiyor",
|
||||
"usernamePlaceholder": "Kullanıcı adını girin",
|
||||
"passwordPlaceholder": "Şifreyi girin",
|
||||
"newPasswordPlaceholder": "Mevcut şifreyi korumak için boş bırakın",
|
||||
"confirmPasswordPlaceholder": "Yeni şifreyi onaylayın",
|
||||
"createError": "Kullanıcı oluşturulamadı",
|
||||
"updateError": "Kullanıcı güncellenemedi",
|
||||
"deleteError": "Kullanıcı silinemedi",
|
||||
"statsError": "Kullanıcı istatistikleri getirilemedi",
|
||||
"deleteConfirmation": "'{{username}}' kullanıcısını silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
|
||||
"confirmDelete": "Kullanıcıyı Sil",
|
||||
"deleteWarning": "'{{username}}' kullanıcısını silmek istediğinizden emin misiniz? Bu işlem geri alınamaz."
|
||||
},
|
||||
"api": {
|
||||
"errors": {
|
||||
"readonly": "Demo ortamı için salt okunur",
|
||||
"invalid_credentials": "Geçersiz kullanıcı adı veya şifre",
|
||||
"serverNameRequired": "Sunucu adı gereklidir",
|
||||
"serverConfigRequired": "Sunucu yapılandırması gereklidir",
|
||||
"serverConfigInvalid": "Sunucu yapılandırması bir URL, OpenAPI şartname URL'si veya şema, ya da argümanlı komut içermelidir",
|
||||
"serverTypeInvalid": "Sunucu türü şunlardan biri olmalıdır: stdio, sse, streamable-http, openapi",
|
||||
"urlRequiredForType": "{{type}} sunucu türü için URL gereklidir",
|
||||
"openapiSpecRequired": "OpenAPI sunucu türü için OpenAPI şartname URL'si veya şema gereklidir",
|
||||
"headersInvalidFormat": "Başlıklar bir nesne olmalıdır",
|
||||
"headersNotSupportedForStdio": "Başlıklar stdio sunucu türü için desteklenmez",
|
||||
"serverNotFound": "Sunucu bulunamadı",
|
||||
"failedToRemoveServer": "Sunucu bulunamadı veya kaldırılamadı",
|
||||
"internalServerError": "Dahili sunucu hatası",
|
||||
"failedToGetServers": "Sunucu bilgileri alınamadı",
|
||||
"failedToGetServerSettings": "Sunucu ayarları alınamadı",
|
||||
"failedToGetServerConfig": "Sunucu yapılandırması alınamadı",
|
||||
"failedToSaveSettings": "Ayarlar kaydedilemedi",
|
||||
"toolNameRequired": "Sunucu adı ve araç adı gereklidir",
|
||||
"descriptionMustBeString": "Açıklama bir string olmalıdır",
|
||||
"groupIdRequired": "Grup ID gereklidir",
|
||||
"groupNameRequired": "Grup adı gereklidir",
|
||||
"groupNotFound": "Grup bulunamadı",
|
||||
"groupIdAndServerNameRequired": "Grup ID ve sunucu adı gereklidir",
|
||||
"groupOrServerNotFound": "Grup veya sunucu bulunamadı",
|
||||
"toolsMustBeAllOrArray": "Araçlar \"all\" veya bir string dizisi olmalıdır",
|
||||
"serverNameAndToolNameRequired": "Sunucu adı ve araç adı gereklidir",
|
||||
"usernameRequired": "Kullanıcı adı gereklidir",
|
||||
"userNotFound": "Kullanıcı bulunamadı",
|
||||
"failedToGetUsers": "Kullanıcı bilgileri alınamadı",
|
||||
"failedToGetUserInfo": "Kullanıcı bilgisi alınamadı",
|
||||
"failedToGetUserStats": "Kullanıcı istatistikleri alınamadı",
|
||||
"marketServerNameRequired": "Sunucu adı gereklidir",
|
||||
"marketServerNotFound": "Market sunucusu bulunamadı",
|
||||
"failedToGetMarketServers": "Market sunucuları bilgisi alınamadı",
|
||||
"failedToGetMarketServer": "Market sunucusu bilgisi alınamadı",
|
||||
"failedToGetMarketCategories": "Market kategorileri alınamadı",
|
||||
"failedToGetMarketTags": "Market etiketleri alınamadı",
|
||||
"failedToSearchMarketServers": "Market sunucuları aranamadı",
|
||||
"failedToFilterMarketServers": "Market sunucuları filtrelenemedi",
|
||||
"failedToProcessDxtFile": "DXT dosyası işlenemedi"
|
||||
},
|
||||
"success": {
|
||||
"serverCreated": "Sunucu başarıyla oluşturuldu",
|
||||
"serverUpdated": "Sunucu başarıyla güncellendi",
|
||||
"serverRemoved": "Sunucu başarıyla kaldırıldı",
|
||||
"serverToggled": "Sunucu durumu başarıyla değiştirildi",
|
||||
"toolToggled": "{{name}} aracı başarıyla {{action}}",
|
||||
"toolDescriptionUpdated": "{{name}} aracının açıklaması başarıyla güncellendi",
|
||||
"systemConfigUpdated": "Sistem yapılandırması başarıyla güncellendi",
|
||||
"groupCreated": "Grup başarıyla oluşturuldu",
|
||||
"groupUpdated": "Grup başarıyla güncellendi",
|
||||
"groupDeleted": "Grup başarıyla silindi",
|
||||
"serverAddedToGroup": "Sunucu başarıyla gruba eklendi",
|
||||
"serverRemovedFromGroup": "Sunucu başarıyla gruptan kaldırıldı",
|
||||
"serverToolsUpdated": "Sunucu araçları başarıyla güncellendi"
|
||||
}
|
||||
},
|
||||
"oauthCallback": {
|
||||
"authorizationFailed": "Yetkilendirme Başarısız",
|
||||
"authorizationFailedError": "Hata",
|
||||
"authorizationFailedDetails": "Detaylar",
|
||||
"invalidRequest": "Geçersiz İstek",
|
||||
"missingStateParameter": "Gerekli OAuth durum parametresi eksik.",
|
||||
"missingCodeParameter": "Gerekli yetkilendirme kodu parametresi eksik.",
|
||||
"serverNotFound": "Sunucu Bulunamadı",
|
||||
"serverNotFoundMessage": "Bu yetkilendirme isteğiyle ilişkili sunucu bulunamadı.",
|
||||
"sessionExpiredMessage": "Yetkilendirme oturumunun süresi dolmuş olabilir. Lütfen tekrar yetkilendirmeyi deneyin.",
|
||||
"authorizationSuccessful": "Yetkilendirme Başarılı",
|
||||
"server": "Sunucu",
|
||||
"status": "Durum",
|
||||
"connected": "Bağlandı",
|
||||
"successMessage": "Sunucu başarıyla yetkilendirildi ve bağlandı.",
|
||||
"autoCloseMessage": "Bu pencere 3 saniye içinde otomatik olarak kapanacak...",
|
||||
"closeNow": "Şimdi Kapat",
|
||||
"connectionError": "Bağlantı Hatası",
|
||||
"connectionErrorMessage": "Yetkilendirme başarılı oldu, ancak sunucuya bağlanılamadı.",
|
||||
"reconnectMessage": "Lütfen kontrol panelinden yeniden bağlanmayı deneyin.",
|
||||
"configurationError": "Yapılandırma Hatası",
|
||||
"configurationErrorMessage": "Sunucu aktarımı OAuth finishAuth() desteklemiyor. Lütfen sunucunun streamable-http aktarımıyla yapılandırıldığından emin olun.",
|
||||
"internalError": "İçsel Hata",
|
||||
"internalErrorMessage": "OAuth geri araması işlenirken beklenmeyen bir hata oluştu.",
|
||||
"closeWindow": "Pencereyi Kapat"
|
||||
}
|
||||
}
|
||||
133
locales/zh.json
133
locales/zh.json
@@ -69,7 +69,16 @@
|
||||
"changePasswordError": "修改密码失败",
|
||||
"changePassword": "修改密码",
|
||||
"passwordChanged": "密码修改成功",
|
||||
"passwordChangeError": "修改密码失败"
|
||||
"passwordChangeError": "修改密码失败",
|
||||
"defaultPasswordWarning": "默认密码安全警告",
|
||||
"defaultPasswordMessage": "您正在使用默认密码(admin123),这存在安全风险。为了保护您的账户安全,请立即修改密码。",
|
||||
"goToSettings": "前往修改",
|
||||
"passwordStrengthError": "密码不符合安全要求",
|
||||
"passwordMinLength": "密码长度至少为 8 个字符",
|
||||
"passwordRequireLetter": "密码必须包含至少一个字母",
|
||||
"passwordRequireNumber": "密码必须包含至少一个数字",
|
||||
"passwordRequireSpecial": "密码必须包含至少一个特殊字符",
|
||||
"passwordStrengthHint": "密码必须至少 8 个字符,且包含字母、数字和特殊字符"
|
||||
},
|
||||
"server": {
|
||||
"addServer": "添加服务器",
|
||||
@@ -107,13 +116,21 @@
|
||||
"enabled": "已启用",
|
||||
"enable": "启用",
|
||||
"disable": "禁用",
|
||||
"requestOptions": "配置",
|
||||
"reload": "重载",
|
||||
"reloadSuccess": "服务器重载成功",
|
||||
"reloadError": "重载服务器 {{serverName}} 失败",
|
||||
"requestOptions": "连接配置",
|
||||
"timeout": "请求超时",
|
||||
"timeoutDescription": "请求超时时间(毫秒)",
|
||||
"maxTotalTimeout": "最大总超时",
|
||||
"maxTotalTimeoutDescription": "无论是否有进度通知的最大总超时时间(毫秒)",
|
||||
"resetTimeoutOnProgress": "收到进度通知时重置超时",
|
||||
"resetTimeoutOnProgressDescription": "适用于发送周期性进度更新的长时间运行操作",
|
||||
"keepAlive": "保活配置",
|
||||
"enableKeepAlive": "启用保活",
|
||||
"keepAliveDescription": "定期发送 ping 请求以维持连接。适用于可能超时的长期连接。",
|
||||
"keepAliveInterval": "间隔时间(毫秒)",
|
||||
"keepAliveIntervalDescription": "保活 ping 的时间间隔(默认:60000毫秒 = 1分钟)",
|
||||
"remove": "移除",
|
||||
"toggleError": "切换服务器 {{serverName}} 状态失败",
|
||||
"alreadyExists": "服务器 {{serverName}} 已经存在",
|
||||
@@ -164,12 +181,28 @@
|
||||
"apiKeyInCookie": "Cookie",
|
||||
"passthroughHeaders": "透传请求头",
|
||||
"passthroughHeadersHelp": "要从工具调用请求透传到上游OpenAPI接口的请求头名称列表,用逗号分隔(如:Authorization, X-API-Key)"
|
||||
},
|
||||
"oauth": {
|
||||
"sectionTitle": "OAuth 配置",
|
||||
"sectionDescription": "为需要 OAuth 的服务器配置客户端凭据(可选)。",
|
||||
"clientId": "客户端 ID",
|
||||
"clientSecret": "客户端密钥",
|
||||
"authorizationEndpoint": "授权端点",
|
||||
"tokenEndpoint": "令牌端点",
|
||||
"scopes": "权限范围(Scopes)",
|
||||
"scopesPlaceholder": "scope1 scope2",
|
||||
"resource": "资源 / 受众",
|
||||
"accessToken": "访问令牌",
|
||||
"refreshToken": "刷新令牌"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"online": "在线",
|
||||
"offline": "离线",
|
||||
"connecting": "连接中"
|
||||
"connecting": "连接中",
|
||||
"oauthRequired": "需要OAuth授权",
|
||||
"clickToAuthorize": "点击进行OAuth授权",
|
||||
"oauthWindowOpened": "OAuth授权窗口已打开,请完成授权。"
|
||||
},
|
||||
"errors": {
|
||||
"general": "发生错误",
|
||||
@@ -178,6 +211,7 @@
|
||||
"serverAdd": "添加服务器失败,请检查服务器状态",
|
||||
"serverUpdate": "编辑服务器 {{serverName}} 失败,请检查服务器状态",
|
||||
"serverFetch": "获取服务器数据失败,请稍后重试",
|
||||
"failedToReloadServer": "重载服务器失败",
|
||||
"initialStartup": "服务器可能正在启动中。首次启动可能需要一些时间,请耐心等候...",
|
||||
"serverInstall": "安装服务器失败",
|
||||
"failedToFetchSettings": "获取设置失败",
|
||||
@@ -189,6 +223,7 @@
|
||||
"processing": "处理中...",
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"back": "返回",
|
||||
"refresh": "刷新",
|
||||
"create": "创建",
|
||||
"creating": "创建中...",
|
||||
@@ -253,7 +288,8 @@
|
||||
"appearance": "外观",
|
||||
"routeConfig": "安全配置",
|
||||
"installConfig": "安装",
|
||||
"smartRouting": "智能路由"
|
||||
"smartRouting": "智能路由",
|
||||
"oauthServer": "OAuth 服务器"
|
||||
},
|
||||
"groups": {
|
||||
"title": "分组管理"
|
||||
@@ -358,6 +394,16 @@
|
||||
"confirmVariablesMessage": "请确保这些变量在运行环境中已正确定义。是否继续安装服务器?",
|
||||
"confirmAndInstall": "确认并安装"
|
||||
},
|
||||
"oauthServer": {
|
||||
"authorizeTitle": "授权应用",
|
||||
"authorizeSubtitle": "允许此应用访问您的 MCPHub 账号。",
|
||||
"buttons": {
|
||||
"approve": "允许访问",
|
||||
"deny": "拒绝",
|
||||
"approveSubtitle": "如果您信任此应用,建议选择允许。",
|
||||
"denySubtitle": "您可以在之后随时再次授权。"
|
||||
}
|
||||
},
|
||||
"cloud": {
|
||||
"title": "云端支持",
|
||||
"subtitle": "由 MCPRouter 提供支持",
|
||||
@@ -495,7 +541,9 @@
|
||||
"description": "描述",
|
||||
"messages": "消息",
|
||||
"noDescription": "无描述信息",
|
||||
"runPromptWithName": "获取提示词: {{name}}"
|
||||
"runPromptWithName": "获取提示词: {{name}}",
|
||||
"descriptionUpdateSuccess": "提示词描述更新成功",
|
||||
"descriptionUpdateFailed": "更新提示词描述失败"
|
||||
},
|
||||
"settings": {
|
||||
"enableGlobalRoute": "启用全局路由",
|
||||
@@ -550,6 +598,8 @@
|
||||
"systemSettings": "系统设置",
|
||||
"nameSeparatorLabel": "名称分隔符",
|
||||
"nameSeparatorDescription": "用于分隔服务器名称和工具/提示名称(默认:-)",
|
||||
"enableSessionRebuild": "启用服务端会话重建",
|
||||
"enableSessionRebuildDescription": "开启后会应用服务端会话重建的改进代码,提供更好的会话管理体验",
|
||||
"restartRequired": "配置已保存。为确保所有服务正确加载新设置,建议重启应用。",
|
||||
"exportMcpSettings": "导出配置",
|
||||
"mcpSettingsJson": "MCP 配置 JSON",
|
||||
@@ -557,7 +607,33 @@
|
||||
"copyToClipboard": "复制到剪贴板",
|
||||
"downloadJson": "下载 JSON",
|
||||
"exportSuccess": "配置导出成功",
|
||||
"exportError": "获取配置失败"
|
||||
"exportError": "获取配置失败",
|
||||
"enableOauthServer": "启用 OAuth 服务器",
|
||||
"enableOauthServerDescription": "允许 MCPHub 作为 OAuth 2.0 授权服务器向外部客户端签发令牌",
|
||||
"requireClientSecret": "需要客户端密钥",
|
||||
"requireClientSecretDescription": "开启后,保密客户端必须携带 client secret(如需仅使用 PKCE 的公共客户端可关闭)",
|
||||
"requireState": "要求 state 参数",
|
||||
"requireStateDescription": "拒绝未携带 state 参数的授权请求",
|
||||
"accessTokenLifetime": "访问令牌有效期(秒)",
|
||||
"accessTokenLifetimeDescription": "控制访问令牌可使用的时长",
|
||||
"accessTokenLifetimePlaceholder": "例如:3600",
|
||||
"refreshTokenLifetime": "刷新令牌有效期(秒)",
|
||||
"refreshTokenLifetimeDescription": "控制刷新令牌的过期时间",
|
||||
"refreshTokenLifetimePlaceholder": "例如:1209600",
|
||||
"authorizationCodeLifetime": "授权码有效期(秒)",
|
||||
"authorizationCodeLifetimeDescription": "授权码在被兑换前可保持有效的时间",
|
||||
"authorizationCodeLifetimePlaceholder": "例如:300",
|
||||
"allowedScopes": "允许的作用域",
|
||||
"allowedScopesDescription": "使用逗号分隔的作用域列表,在授权时展示给用户",
|
||||
"allowedScopesPlaceholder": "例如:read, write",
|
||||
"enableDynamicRegistration": "启用动态客户端注册",
|
||||
"dynamicRegistrationDescription": "允许遵循 RFC 7591 的客户端通过公共端点自行注册",
|
||||
"dynamicRegistrationAllowedGrantTypes": "允许的授权类型",
|
||||
"dynamicRegistrationAllowedGrantTypesDescription": "使用逗号分隔动态注册客户端可以使用的授权类型",
|
||||
"dynamicRegistrationAllowedGrantTypesPlaceholder": "例如:authorization_code, refresh_token",
|
||||
"dynamicRegistrationAuth": "注册需要认证",
|
||||
"dynamicRegistrationAuthDescription": "开启后,注册端点需要认证请求才能创建客户端",
|
||||
"invalidNumberInput": "请输入合法的非负数字"
|
||||
},
|
||||
"dxt": {
|
||||
"upload": "上传",
|
||||
@@ -584,6 +660,21 @@
|
||||
"serverExistsConfirm": "服务器 '{{serverName}}' 已存在。是否要用新版本覆盖它?",
|
||||
"override": "覆盖"
|
||||
},
|
||||
"jsonImport": {
|
||||
"button": "导入",
|
||||
"title": "从 JSON 导入服务器",
|
||||
"inputLabel": "服务器配置 JSON",
|
||||
"inputHelp": "粘贴您的服务器配置 JSON。支持 STDIO、SSE 和 HTTP (streamable-http) 服务器类型。",
|
||||
"preview": "预览",
|
||||
"previewTitle": "预览要导入的服务器",
|
||||
"import": "导入",
|
||||
"importing": "导入中...",
|
||||
"invalidFormat": "无效的 JSON 格式。JSON 必须包含 'mcpServers' 对象。",
|
||||
"parseError": "解析 JSON 失败。请检查格式后重试。",
|
||||
"addFailed": "添加服务器失败",
|
||||
"importFailed": "导入服务器失败",
|
||||
"partialSuccess": "成功导入 {{count}} / {{total}} 个服务器。部分服务器失败:"
|
||||
},
|
||||
"users": {
|
||||
"add": "添加",
|
||||
"addNew": "添加新用户",
|
||||
@@ -595,9 +686,13 @@
|
||||
"password": "密码",
|
||||
"newPassword": "新密码",
|
||||
"confirmPassword": "确认密码",
|
||||
"changePassword": "修改密码",
|
||||
"adminRole": "管理员",
|
||||
"admin": "管理员",
|
||||
"user": "用户",
|
||||
"role": "角色",
|
||||
"actions": "操作",
|
||||
"addFirst": "添加第一个用户",
|
||||
"permissions": "权限",
|
||||
"adminPermissions": "完全系统访问权限",
|
||||
"userPermissions": "受限访问权限",
|
||||
@@ -678,5 +773,31 @@
|
||||
"serverRemovedFromGroup": "服务器从分组移除成功",
|
||||
"serverToolsUpdated": "服务器工具更新成功"
|
||||
}
|
||||
},
|
||||
"oauthCallback": {
|
||||
"authorizationFailed": "授权失败",
|
||||
"authorizationFailedError": "错误",
|
||||
"authorizationFailedDetails": "详情",
|
||||
"invalidRequest": "无效请求",
|
||||
"missingStateParameter": "缺少必需的 OAuth 状态参数。",
|
||||
"missingCodeParameter": "缺少必需的授权码参数。",
|
||||
"serverNotFound": "服务器未找到",
|
||||
"serverNotFoundMessage": "无法找到与此授权请求关联的服务器。",
|
||||
"sessionExpiredMessage": "授权会话可能已过期。请重新进行授权。",
|
||||
"authorizationSuccessful": "授权成功",
|
||||
"server": "服务器",
|
||||
"status": "状态",
|
||||
"connected": "已连接",
|
||||
"successMessage": "服务器已成功授权并连接。",
|
||||
"autoCloseMessage": "此窗口将在 3 秒后自动关闭...",
|
||||
"closeNow": "立即关闭",
|
||||
"connectionError": "连接错误",
|
||||
"connectionErrorMessage": "授权成功,但连接服务器失败。",
|
||||
"reconnectMessage": "请尝试从控制面板重新连接。",
|
||||
"configurationError": "配置错误",
|
||||
"configurationErrorMessage": "服务器传输不支持 OAuth finishAuth()。请确保服务器配置为 streamable-http 传输。",
|
||||
"internalError": "内部错误",
|
||||
"internalErrorMessage": "处理 OAuth 回调时发生意外错误。",
|
||||
"closeWindow": "关闭窗口"
|
||||
}
|
||||
}
|
||||
@@ -41,5 +41,27 @@
|
||||
"password": "$2b$10$Vt7krIvjNgyN67LXqly0uOcTpN0LI55cYRbcKC71pUDAP0nJ7RPa.",
|
||||
"isAdmin": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"systemConfig": {
|
||||
"oauthServer": {
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
13310
package-lock.json
generated
13310
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@@ -46,7 +46,8 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@apidevtools/swagger-parser": "^12.0.0",
|
||||
"@modelcontextprotocol/sdk": "^1.18.1",
|
||||
"@modelcontextprotocol/sdk": "^1.20.2",
|
||||
"@node-oauth/oauth2-server": "^5.2.1",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/multer": "^1.4.13",
|
||||
@@ -59,13 +60,14 @@
|
||||
"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",
|
||||
"multer": "^2.0.2",
|
||||
"openai": "^4.104.0",
|
||||
"openai": "^6.7.0",
|
||||
"openapi-types": "^12.1.3",
|
||||
"openid-client": "^6.8.1",
|
||||
"pg": "^8.16.3",
|
||||
"pgvector": "^0.2.1",
|
||||
"postgres": "^3.4.7",
|
||||
@@ -104,12 +106,12 @@
|
||||
"jest": "^30.2.0",
|
||||
"jest-environment-node": "^30.0.5",
|
||||
"jest-mock-extended": "4.0.0",
|
||||
"lucide-react": "^0.486.0",
|
||||
"lucide-react": "^0.552.0",
|
||||
"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",
|
||||
@@ -130,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2348
pnpm-lock.yaml
generated
2348
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(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import dotenv from 'dotenv'
|
||||
import fs from 'fs'
|
||||
import { McpSettings, IUser } from '../types/index.js'
|
||||
import { getConfigFilePath } from '../utils/path.js'
|
||||
import { getPackageVersion } from '../utils/version.js'
|
||||
import { getDataService } from '../services/services.js'
|
||||
import { DataService } from '../services/dataService.js'
|
||||
import dotenv from 'dotenv';
|
||||
import fs from 'fs';
|
||||
import { McpSettings, IUser } from '../types/index.js';
|
||||
import { getConfigFilePath } from '../utils/path.js';
|
||||
import { getPackageVersion } from '../utils/version.js';
|
||||
import { getDataService } from '../services/services.js';
|
||||
import { DataService } from '../services/dataService.js';
|
||||
import { cloneDefaultOAuthServerConfig } from '../constants/oauthServerDefaults.js';
|
||||
|
||||
dotenv.config()
|
||||
dotenv.config();
|
||||
|
||||
const defaultConfig = {
|
||||
port: process.env.PORT || 3000,
|
||||
@@ -15,74 +16,99 @@ const defaultConfig = {
|
||||
readonly: 'true' === process.env.READONLY || false,
|
||||
mcpHubName: 'mcphub',
|
||||
mcpHubVersion: getPackageVersion(),
|
||||
}
|
||||
};
|
||||
|
||||
const dataService: DataService = getDataService()
|
||||
const dataService: DataService = getDataService();
|
||||
|
||||
const ensureOAuthServerDefaults = (settings: McpSettings): boolean => {
|
||||
if (!settings.systemConfig) {
|
||||
settings.systemConfig = {
|
||||
oauthServer: cloneDefaultOAuthServerConfig(),
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!settings.systemConfig.oauthServer) {
|
||||
settings.systemConfig.oauthServer = cloneDefaultOAuthServerConfig();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Settings cache
|
||||
let settingsCache: McpSettings | null = null
|
||||
let settingsCache: McpSettings | null = null;
|
||||
|
||||
export const getSettingsPath = (): string => {
|
||||
return getConfigFilePath('mcp_settings.json', 'Settings')
|
||||
}
|
||||
return getConfigFilePath('mcp_settings.json', 'Settings');
|
||||
};
|
||||
|
||||
export const loadOriginalSettings = (): McpSettings => {
|
||||
// If cache exists, return cached data directly
|
||||
if (settingsCache) {
|
||||
return settingsCache
|
||||
return settingsCache;
|
||||
}
|
||||
|
||||
const settingsPath = getSettingsPath()
|
||||
const settingsPath = getSettingsPath();
|
||||
// check if file exists
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
console.warn(`Settings file not found at ${settingsPath}, using default settings.`)
|
||||
const defaultSettings = { mcpServers: {}, users: [] }
|
||||
console.warn(`Settings file not found at ${settingsPath}, using default settings.`);
|
||||
const defaultSettings: McpSettings = { mcpServers: {}, users: [] };
|
||||
ensureOAuthServerDefaults(defaultSettings);
|
||||
// Cache default settings
|
||||
settingsCache = defaultSettings
|
||||
return defaultSettings
|
||||
settingsCache = defaultSettings;
|
||||
return defaultSettings;
|
||||
}
|
||||
|
||||
try {
|
||||
// Read and parse settings file
|
||||
const settingsData = fs.readFileSync(settingsPath, 'utf8')
|
||||
const settings = JSON.parse(settingsData)
|
||||
const settingsData = fs.readFileSync(settingsPath, 'utf8');
|
||||
const settings = JSON.parse(settingsData);
|
||||
const initialized = ensureOAuthServerDefaults(settings);
|
||||
if (initialized) {
|
||||
try {
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
|
||||
} catch (writeError) {
|
||||
console.error('Failed to persist default OAuth server configuration:', writeError);
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache
|
||||
settingsCache = settings
|
||||
settingsCache = settings;
|
||||
|
||||
console.log(`Loaded settings from ${settingsPath}`)
|
||||
return settings
|
||||
console.log(`Loaded settings from ${settingsPath}`);
|
||||
return settings;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to load settings from ${settingsPath}: ${error}`)
|
||||
throw new Error(`Failed to load settings from ${settingsPath}: ${error}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const loadSettings = (user?: IUser): McpSettings => {
|
||||
return dataService.filterSettings!(loadOriginalSettings(), user)
|
||||
}
|
||||
return dataService.filterSettings!(loadOriginalSettings(), user);
|
||||
};
|
||||
|
||||
export const saveSettings = (settings: McpSettings, user?: IUser): boolean => {
|
||||
const settingsPath = getSettingsPath()
|
||||
const settingsPath = getSettingsPath();
|
||||
try {
|
||||
const mergedSettings = dataService.mergeSettings!(loadOriginalSettings(), settings, user)
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(mergedSettings, null, 2), 'utf8')
|
||||
const mergedSettings = dataService.mergeSettings!(loadOriginalSettings(), settings, user);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(mergedSettings, null, 2), 'utf8');
|
||||
|
||||
// Update cache after successful save
|
||||
settingsCache = mergedSettings
|
||||
settingsCache = mergedSettings;
|
||||
|
||||
return true
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Failed to save settings to ${settingsPath}:`, error)
|
||||
return false
|
||||
console.error(`Failed to save settings to ${settingsPath}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear settings cache, force next loadSettings call to re-read from file
|
||||
*/
|
||||
export const clearSettingsCache = (): void => {
|
||||
settingsCache = null
|
||||
}
|
||||
settingsCache = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current cache status (for debugging)
|
||||
@@ -90,60 +116,71 @@ export const clearSettingsCache = (): void => {
|
||||
export const getSettingsCacheInfo = (): { hasCache: boolean } => {
|
||||
return {
|
||||
hasCache: settingsCache !== null,
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export function replaceEnvVars(input: Record<string, any>): Record<string, any>
|
||||
export function replaceEnvVars(input: string[] | undefined): string[]
|
||||
export function replaceEnvVars(input: string): string
|
||||
export function replaceEnvVars(input: Record<string, any>): Record<string, any>;
|
||||
export function replaceEnvVars(input: string[] | undefined): string[];
|
||||
export function replaceEnvVars(input: string): string;
|
||||
export function replaceEnvVars(
|
||||
input: Record<string, any> | string[] | string | undefined,
|
||||
): Record<string, any> | string[] | string {
|
||||
// Handle object input
|
||||
// Handle object input - recursively expand all nested values
|
||||
if (input && typeof input === 'object' && !Array.isArray(input)) {
|
||||
const res: Record<string, string> = {}
|
||||
const res: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (typeof value === 'string') {
|
||||
res[key] = expandEnvVars(value)
|
||||
res[key] = expandEnvVars(value);
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
// Recursively handle nested objects and arrays
|
||||
res[key] = replaceEnvVars(value as any);
|
||||
} else {
|
||||
res[key] = String(value)
|
||||
// Preserve non-string, non-object values (numbers, booleans, etc.)
|
||||
res[key] = value;
|
||||
}
|
||||
}
|
||||
return res
|
||||
return res;
|
||||
}
|
||||
|
||||
// Handle array input
|
||||
// Handle array input - recursively expand all elements
|
||||
if (Array.isArray(input)) {
|
||||
return input.map((item) => expandEnvVars(item))
|
||||
return input.map((item) => {
|
||||
if (typeof item === 'string') {
|
||||
return expandEnvVars(item);
|
||||
} else if (typeof item === 'object' && item !== null) {
|
||||
return replaceEnvVars(item as any);
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
// Handle string input
|
||||
if (typeof input === 'string') {
|
||||
return expandEnvVars(input)
|
||||
return expandEnvVars(input);
|
||||
}
|
||||
|
||||
// Handle undefined/null array input
|
||||
if (input === undefined || input === null) {
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
|
||||
return input
|
||||
return input;
|
||||
}
|
||||
|
||||
export const expandEnvVars = (value: string): string => {
|
||||
if (typeof value !== 'string') {
|
||||
return String(value)
|
||||
return String(value);
|
||||
}
|
||||
// Replace ${VAR} format
|
||||
let result = value.replace(/\$\{([^}]+)\}/g, (_, key) => process.env[key] || '')
|
||||
let result = value.replace(/\$\{([^}]+)\}/g, (_, key) => process.env[key] || '');
|
||||
// Also replace $VAR format (common on Unix-like systems)
|
||||
result = result.replace(/\$([A-Z_][A-Z0-9_]*)/g, (_, key) => process.env[key] || '')
|
||||
return result
|
||||
}
|
||||
result = result.replace(/\$([A-Z_][A-Z0-9_]*)/g, (_, key) => process.env[key] || '');
|
||||
return result;
|
||||
};
|
||||
|
||||
export default defaultConfig
|
||||
export default defaultConfig;
|
||||
|
||||
export function getNameSeparator(): string {
|
||||
const settings = loadSettings()
|
||||
return settings.systemConfig?.nameSeparator || '-'
|
||||
const settings = loadSettings();
|
||||
return settings.systemConfig?.nameSeparator || '-';
|
||||
}
|
||||
|
||||
42
src/constants/oauthServerDefaults.ts
Normal file
42
src/constants/oauthServerDefaults.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { OAuthServerConfig } from '../types/index.js';
|
||||
|
||||
export const DEFAULT_OAUTH_SERVER_CONFIG: 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,
|
||||
},
|
||||
};
|
||||
|
||||
export const cloneDefaultOAuthServerConfig = (): OAuthServerConfig => {
|
||||
const allowedScopes = DEFAULT_OAUTH_SERVER_CONFIG.allowedScopes
|
||||
? [...DEFAULT_OAUTH_SERVER_CONFIG.allowedScopes]
|
||||
: [];
|
||||
|
||||
const baseDynamicRegistration =
|
||||
DEFAULT_OAUTH_SERVER_CONFIG.dynamicRegistration ?? {
|
||||
enabled: false,
|
||||
allowedGrantTypes: [],
|
||||
requiresAuthentication: false,
|
||||
};
|
||||
|
||||
const dynamicRegistration = {
|
||||
...baseDynamicRegistration,
|
||||
allowedGrantTypes: baseDynamicRegistration.allowedGrantTypes
|
||||
? [...baseDynamicRegistration.allowedGrantTypes]
|
||||
: [],
|
||||
};
|
||||
|
||||
return {
|
||||
...DEFAULT_OAUTH_SERVER_CONFIG,
|
||||
allowedScopes,
|
||||
dynamicRegistration,
|
||||
};
|
||||
};
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
import { getDataService } from '../services/services.js';
|
||||
import { DataService } from '../services/dataService.js';
|
||||
import { JWT_SECRET } from '../config/jwt.js';
|
||||
import { validatePasswordStrength, isDefaultPassword } from '../utils/passwordValidation.js';
|
||||
import { getPackageVersion } from '../utils/version.js';
|
||||
|
||||
const dataService: DataService = getDataService();
|
||||
|
||||
@@ -35,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({
|
||||
@@ -64,6 +66,11 @@ export const login = async (req: Request, res: Response): Promise<void> => {
|
||||
},
|
||||
};
|
||||
|
||||
// Check if user is admin with default password
|
||||
const version = getPackageVersion();
|
||||
const isUsingDefaultPassword =
|
||||
user.username === 'admin' && user.isAdmin && isDefaultPassword(password) && version !== 'dev';
|
||||
|
||||
jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY }, (err, token) => {
|
||||
if (err) throw err;
|
||||
res.json({
|
||||
@@ -75,6 +82,7 @@ export const login = async (req: Request, res: Response): Promise<void> => {
|
||||
isAdmin: user.isAdmin,
|
||||
permissions: dataService.getPermissions(user),
|
||||
},
|
||||
isUsingDefaultPassword,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -172,8 +180,19 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
|
||||
const username = (req as any).user.username;
|
||||
|
||||
try {
|
||||
// Validate new password strength
|
||||
const validationResult = validatePasswordStrength(newPassword);
|
||||
if (!validationResult.isValid) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Password does not meet security requirements',
|
||||
errors: validationResult.errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 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' });
|
||||
|
||||
@@ -207,7 +207,8 @@ export const getCloudServersByTag = async (req: Request, res: Response): Promise
|
||||
// Get tools for a specific cloud server
|
||||
export const getCloudServerToolsList = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName } = req.params;
|
||||
// Decode URL-encoded parameter to handle slashes in server name
|
||||
const serverName = decodeURIComponent(req.params.serverName);
|
||||
if (!serverName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -236,7 +237,9 @@ export const getCloudServerToolsList = async (req: Request, res: Response): Prom
|
||||
// Call a tool on a cloud server
|
||||
export const callCloudTool = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName, toolName } = req.params;
|
||||
// Decode URL-encoded parameters to handle slashes in server/tool names
|
||||
const serverName = decodeURIComponent(req.params.serverName);
|
||||
const toolName = decodeURIComponent(req.params.toolName);
|
||||
const { arguments: args } = req.body;
|
||||
|
||||
if (!serverName) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { loadSettings, loadOriginalSettings } from '../config/index.js';
|
||||
import { getDataService } from '../services/services.js';
|
||||
import { DataService } from '../services/dataService.js';
|
||||
import { IUser } from '../types/index.js';
|
||||
import { getServerDao } from '../dao/DaoFactory.js';
|
||||
|
||||
const dataService: DataService = getDataService();
|
||||
|
||||
@@ -73,17 +74,39 @@ export const getPublicConfig = (req: Request, res: Response): void => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Recursively remove null values from an object
|
||||
*/
|
||||
const removeNullValues = <T>(obj: T): T => {
|
||||
if (obj === null || obj === undefined) {
|
||||
return obj;
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => removeNullValues(item)) as T;
|
||||
}
|
||||
if (typeof obj === 'object') {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (value !== null) {
|
||||
result[key] = removeNullValues(value);
|
||||
}
|
||||
}
|
||||
return result as T;
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get MCP settings in JSON format for export/copy
|
||||
* Supports both full settings and individual server configuration
|
||||
*/
|
||||
export const getMcpSettingsJson = (req: Request, res: Response): void => {
|
||||
export const getMcpSettingsJson = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName } = req.query;
|
||||
const settings = loadOriginalSettings();
|
||||
if (serverName && typeof serverName === 'string') {
|
||||
// Return individual server configuration
|
||||
const serverConfig = settings.mcpServers[serverName];
|
||||
// Return individual server configuration using DAO
|
||||
const serverDao = getServerDao();
|
||||
const serverConfig = await serverDao.findById(serverName);
|
||||
if (!serverConfig) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -92,16 +115,21 @@ export const getMcpSettingsJson = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove the 'name' field from config as it's used as the key
|
||||
const { name, ...configWithoutName } = serverConfig;
|
||||
// Remove null values from the config
|
||||
const cleanedConfig = removeNullValues(configWithoutName);
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
mcpServers: {
|
||||
[serverName]: serverConfig,
|
||||
[name]: cleanedConfig,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Return full settings
|
||||
const settings = loadOriginalSettings();
|
||||
res.json({
|
||||
success: true,
|
||||
data: settings,
|
||||
|
||||
@@ -15,9 +15,9 @@ import {
|
||||
} from '../services/groupService.js';
|
||||
|
||||
// Get all groups
|
||||
export const getGroups = (_: Request, res: Response): void => {
|
||||
export const getGroups = async (_: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const groups = getAllGroups();
|
||||
const groups = await getAllGroups();
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: groups,
|
||||
@@ -32,7 +32,7 @@ export const getGroups = (_: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Get a specific group by ID
|
||||
export const getGroup = (req: Request, res: Response): void => {
|
||||
export const getGroup = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
if (!id) {
|
||||
@@ -43,7 +43,7 @@ export const getGroup = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const group = getGroupByIdOrName(id);
|
||||
const group = await getGroupByIdOrName(id);
|
||||
if (!group) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -66,7 +66,7 @@ export const getGroup = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Create a new group
|
||||
export const createNewGroup = (req: Request, res: Response): void => {
|
||||
export const createNewGroup = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { name, description, servers } = req.body;
|
||||
if (!name) {
|
||||
@@ -83,7 +83,7 @@ export const createNewGroup = (req: Request, res: Response): void => {
|
||||
const currentUser = (req as any).user;
|
||||
const owner = currentUser?.username || 'admin';
|
||||
|
||||
const newGroup = createGroup(name, description, serverList, owner);
|
||||
const newGroup = await createGroup(name, description, serverList, owner);
|
||||
if (!newGroup) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -107,7 +107,7 @@ export const createNewGroup = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Update an existing group
|
||||
export const updateExistingGroup = (req: Request, res: Response): void => {
|
||||
export const updateExistingGroup = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, description, servers } = req.body;
|
||||
@@ -133,7 +133,7 @@ export const updateExistingGroup = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedGroup = updateGroup(id, updateData);
|
||||
const updatedGroup = await updateGroup(id, updateData);
|
||||
if (!updatedGroup) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -157,7 +157,7 @@ export const updateExistingGroup = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Update servers in a group (batch update) - supports both string[] and server config format
|
||||
export const updateGroupServersBatch = (req: Request, res: Response): void => {
|
||||
export const updateGroupServersBatch = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { servers } = req.body;
|
||||
@@ -203,7 +203,7 @@ export const updateGroupServersBatch = (req: Request, res: Response): void => {
|
||||
}
|
||||
}
|
||||
|
||||
const updatedGroup = updateGroupServers(id, servers);
|
||||
const updatedGroup = await updateGroupServers(id, servers);
|
||||
if (!updatedGroup) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -227,7 +227,7 @@ export const updateGroupServersBatch = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Delete a group
|
||||
export const deleteExistingGroup = (req: Request, res: Response): void => {
|
||||
export const deleteExistingGroup = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
if (!id) {
|
||||
@@ -238,7 +238,7 @@ export const deleteExistingGroup = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const success = deleteGroup(id);
|
||||
const success = await deleteGroup(id);
|
||||
if (!success) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -260,7 +260,7 @@ export const deleteExistingGroup = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Add server to a group
|
||||
export const addServerToExistingGroup = (req: Request, res: Response): void => {
|
||||
export const addServerToExistingGroup = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { serverName } = req.body;
|
||||
@@ -280,7 +280,7 @@ export const addServerToExistingGroup = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedGroup = addServerToGroup(id, serverName);
|
||||
const updatedGroup = await addServerToGroup(id, serverName);
|
||||
if (!updatedGroup) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -304,7 +304,7 @@ export const addServerToExistingGroup = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Remove server from a group
|
||||
export const removeServerFromExistingGroup = (req: Request, res: Response): void => {
|
||||
export const removeServerFromExistingGroup = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id, serverName } = req.params;
|
||||
if (!id || !serverName) {
|
||||
@@ -315,7 +315,7 @@ export const removeServerFromExistingGroup = (req: Request, res: Response): void
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedGroup = removeServerFromGroup(id, serverName);
|
||||
const updatedGroup = await removeServerFromGroup(id, serverName);
|
||||
if (!updatedGroup) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -339,7 +339,7 @@ export const removeServerFromExistingGroup = (req: Request, res: Response): void
|
||||
};
|
||||
|
||||
// Get servers in a group
|
||||
export const getGroupServers = (req: Request, res: Response): void => {
|
||||
export const getGroupServers = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
if (!id) {
|
||||
@@ -350,7 +350,7 @@ export const getGroupServers = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const group = getGroupByIdOrName(id);
|
||||
const group = await getGroupByIdOrName(id);
|
||||
if (!group) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -373,7 +373,7 @@ export const getGroupServers = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Get server configurations in a group (including tool selections)
|
||||
export const getGroupServerConfigs = (req: Request, res: Response): void => {
|
||||
export const getGroupServerConfigs = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
if (!id) {
|
||||
@@ -384,7 +384,7 @@ export const getGroupServerConfigs = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const serverConfigs = getServerConfigsInGroup(id);
|
||||
const serverConfigs = await getServerConfigsInGroup(id);
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: serverConfigs,
|
||||
@@ -399,7 +399,7 @@ export const getGroupServerConfigs = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Get specific server configuration in a group
|
||||
export const getGroupServerConfig = (req: Request, res: Response): void => {
|
||||
export const getGroupServerConfig = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id, serverName } = req.params;
|
||||
if (!id || !serverName) {
|
||||
@@ -410,7 +410,7 @@ export const getGroupServerConfig = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const serverConfig = getServerConfigInGroup(id, serverName);
|
||||
const serverConfig = await getServerConfigInGroup(id, serverName);
|
||||
if (!serverConfig) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -433,7 +433,7 @@ export const getGroupServerConfig = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Update tools for a specific server in a group
|
||||
export const updateGroupServerTools = (req: Request, res: Response): void => {
|
||||
export const updateGroupServerTools = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id, serverName } = req.params;
|
||||
const { tools } = req.body;
|
||||
@@ -458,7 +458,7 @@ export const updateGroupServerTools = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedGroup = updateServerToolsInGroup(id, serverName, tools);
|
||||
const updatedGroup = await updateServerToolsInGroup(id, serverName, tools);
|
||||
if (!updatedGroup) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
|
||||
388
src/controllers/oauthCallbackController.ts
Normal file
388
src/controllers/oauthCallbackController.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* OAuth Callback Controller
|
||||
*
|
||||
* Handles OAuth 2.0 authorization callbacks for upstream MCP servers.
|
||||
*
|
||||
* This controller implements a simplified callback flow that relies on the MCP SDK
|
||||
* to handle the complete OAuth token exchange:
|
||||
*
|
||||
* 1. Extract authorization code from callback URL
|
||||
* 2. Find the corresponding server using the state parameter
|
||||
* 3. Store the authorization code temporarily
|
||||
* 4. Reconnect the server - SDK's auth() function will:
|
||||
* - Automatically discover OAuth endpoints
|
||||
* - Exchange the code for tokens using PKCE
|
||||
* - Save tokens via our OAuthClientProvider.saveTokens()
|
||||
*/
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import {
|
||||
getServerByName,
|
||||
getServerByOAuthState,
|
||||
createTransportFromConfig,
|
||||
} from '../services/mcpService.js';
|
||||
import { getNameSeparator, loadSettings } from '../config/index.js';
|
||||
import type { ServerInfo } from '../types/index.js';
|
||||
|
||||
/**
|
||||
* Generate HTML response page with i18n support
|
||||
*/
|
||||
const generateHtmlResponse = (
|
||||
type: 'error' | 'success',
|
||||
title: string,
|
||||
message: string,
|
||||
details?: { label: string; value: string }[],
|
||||
autoClose: boolean = false,
|
||||
): string => {
|
||||
const backgroundColor = type === 'error' ? '#fee' : '#efe';
|
||||
const borderColor = type === 'error' ? '#fcc' : '#cfc';
|
||||
const titleColor = type === 'error' ? '#c33' : '#3c3';
|
||||
const buttonColor = type === 'error' ? '#c33' : '#3c3';
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>${title}</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
|
||||
.container { background-color: ${backgroundColor}; border: 1px solid ${borderColor}; padding: 20px; border-radius: 8px; }
|
||||
h1 { color: ${titleColor}; margin-top: 0; }
|
||||
.detail { margin-top: 10px; padding: 10px; background: #f9f9f9; border-radius: 4px; ${type === 'error' ? 'font-family: monospace; font-size: 12px; white-space: pre-wrap;' : ''} }
|
||||
.close-btn { margin-top: 20px; padding: 10px 20px; background: ${buttonColor}; color: white; border: none; border-radius: 4px; cursor: pointer; }
|
||||
</style>
|
||||
${autoClose ? '<script>setTimeout(() => { window.close(); }, 3000);</script>' : ''}
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>${type === 'success' ? '✓ ' : ''}${title}</h1>
|
||||
${details ? details.map((d) => `<div class="detail"><strong>${d.label}:</strong> ${d.value}</div>`).join('') : ''}
|
||||
<p>${message}</p>
|
||||
${autoClose ? '<p>This window will close automatically in 3 seconds...</p>' : ''}
|
||||
<button class="close-btn" onclick="window.close()">${autoClose ? 'Close Now' : 'Close Window'}</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
};
|
||||
|
||||
const normalizeQueryParam = (value: unknown): string | undefined => {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
const [first] = value;
|
||||
return typeof first === 'string' ? first : undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const extractServerNameFromState = (stateValue: string): string | undefined => {
|
||||
try {
|
||||
const normalized = stateValue.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padding = (4 - (normalized.length % 4)) % 4;
|
||||
const base64 = normalized + '='.repeat(padding);
|
||||
const decoded = Buffer.from(base64, 'base64').toString('utf8');
|
||||
const payload = JSON.parse(decoded);
|
||||
|
||||
if (payload && typeof payload.server === 'string') {
|
||||
return payload.server;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore decoding errors and fall back to delimiter-based parsing
|
||||
}
|
||||
|
||||
const separatorIndex = stateValue.indexOf(':');
|
||||
if (separatorIndex > 0) {
|
||||
return stateValue.slice(0, separatorIndex);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle OAuth callback after user authorization
|
||||
*
|
||||
* This endpoint receives the authorization code from the OAuth provider
|
||||
* and initiates the server reconnection process.
|
||||
*
|
||||
* Expected query parameters:
|
||||
* - code: Authorization code from OAuth provider
|
||||
* - state: Encoded server identifier used for OAuth session validation
|
||||
* - error: Optional error code if authorization failed
|
||||
* - error_description: Optional error description
|
||||
*/
|
||||
export const handleOAuthCallback = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { code, state, error, error_description } = req.query;
|
||||
const codeParam = normalizeQueryParam(code);
|
||||
const stateParam = normalizeQueryParam(state);
|
||||
|
||||
// Get translation function from request (set by i18n middleware)
|
||||
const t = (req as any).t || ((key: string) => key);
|
||||
|
||||
// Check for authorization errors
|
||||
if (error) {
|
||||
console.error(`OAuth authorization failed: ${error} - ${error_description || ''}`);
|
||||
return res.status(400).send(
|
||||
generateHtmlResponse('error', t('oauthCallback.authorizationFailed'), '', [
|
||||
{ label: t('oauthCallback.authorizationFailedError'), value: String(error) },
|
||||
...(error_description
|
||||
? [
|
||||
{
|
||||
label: t('oauthCallback.authorizationFailedDetails'),
|
||||
value: String(error_description),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
// Validate required parameters
|
||||
if (!stateParam) {
|
||||
console.error('OAuth callback missing state parameter');
|
||||
return res
|
||||
.status(400)
|
||||
.send(
|
||||
generateHtmlResponse(
|
||||
'error',
|
||||
t('oauthCallback.invalidRequest'),
|
||||
t('oauthCallback.missingStateParameter'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!codeParam) {
|
||||
console.error('OAuth callback missing authorization code');
|
||||
return res
|
||||
.status(400)
|
||||
.send(
|
||||
generateHtmlResponse(
|
||||
'error',
|
||||
t('oauthCallback.invalidRequest'),
|
||||
t('oauthCallback.missingCodeParameter'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`OAuth callback received - code: present, state: ${stateParam}`);
|
||||
|
||||
// Find server by state parameter
|
||||
let serverInfo: ServerInfo | undefined;
|
||||
|
||||
serverInfo = getServerByOAuthState(stateParam);
|
||||
|
||||
let decodedServerName: string | undefined;
|
||||
if (!serverInfo) {
|
||||
decodedServerName = extractServerNameFromState(stateParam);
|
||||
if (decodedServerName) {
|
||||
console.log(`State lookup failed; decoding server name from state: ${decodedServerName}`);
|
||||
serverInfo = getServerByName(decodedServerName);
|
||||
}
|
||||
}
|
||||
|
||||
if (!serverInfo) {
|
||||
console.error(
|
||||
`No server found for OAuth callback. State: ${stateParam}${
|
||||
decodedServerName ? `, decoded server: ${decodedServerName}` : ''
|
||||
}`,
|
||||
);
|
||||
return res
|
||||
.status(400)
|
||||
.send(
|
||||
generateHtmlResponse(
|
||||
'error',
|
||||
t('oauthCallback.serverNotFound'),
|
||||
`${t('oauthCallback.serverNotFoundMessage')}\n${t('oauthCallback.sessionExpiredMessage')}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Optional: Validate state parameter for additional security
|
||||
if (serverInfo.oauth?.state && serverInfo.oauth.state !== stateParam) {
|
||||
console.warn(
|
||||
`State mismatch for server ${serverInfo.name}. Expected: ${serverInfo.oauth.state}, Got: ${stateParam}`,
|
||||
);
|
||||
// Note: We log a warning but don't fail the request since we have server name as primary identifier
|
||||
}
|
||||
|
||||
console.log(`Processing OAuth callback for server: ${serverInfo.name}`);
|
||||
|
||||
// For StreamableHTTPClientTransport, we need to call finishAuth() on the transport
|
||||
// This will exchange the authorization code for tokens automatically
|
||||
if (serverInfo.transport && 'finishAuth' in serverInfo.transport) {
|
||||
try {
|
||||
console.log(`Calling transport.finishAuth() for server: ${serverInfo.name}`);
|
||||
const currentTransport = serverInfo.transport as any;
|
||||
await currentTransport.finishAuth(codeParam);
|
||||
|
||||
console.log(`Successfully exchanged authorization code for tokens: ${serverInfo.name}`);
|
||||
|
||||
// Refresh server configuration from disk to ensure we pick up newly saved tokens
|
||||
const settings = loadSettings();
|
||||
const storedConfig = settings.mcpServers?.[serverInfo.name];
|
||||
const effectiveConfig = storedConfig || serverInfo.config;
|
||||
|
||||
if (!effectiveConfig) {
|
||||
throw new Error(
|
||||
`Missing server configuration for ${serverInfo.name} after OAuth callback`,
|
||||
);
|
||||
}
|
||||
|
||||
// Keep latest configuration cached on serverInfo
|
||||
serverInfo.config = effectiveConfig;
|
||||
|
||||
// Ensure we have up-to-date request options for the reconnect attempt
|
||||
if (!serverInfo.options) {
|
||||
const requestConfig = effectiveConfig.options || {};
|
||||
serverInfo.options = {
|
||||
timeout: requestConfig.timeout || 60000,
|
||||
resetTimeoutOnProgress: requestConfig.resetTimeoutOnProgress || false,
|
||||
maxTotalTimeout: requestConfig.maxTotalTimeout,
|
||||
};
|
||||
}
|
||||
|
||||
// Replace the existing transport instance to avoid reusing a closed/aborted transport
|
||||
try {
|
||||
if (serverInfo.transport && 'close' in serverInfo.transport) {
|
||||
await (serverInfo.transport as any).close();
|
||||
}
|
||||
} catch (closeError) {
|
||||
console.warn(`Failed to close existing transport for ${serverInfo.name}:`, closeError);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Rebuilding transport with refreshed credentials for server: ${serverInfo.name}`,
|
||||
);
|
||||
const refreshedTransport = await createTransportFromConfig(
|
||||
serverInfo.name,
|
||||
effectiveConfig,
|
||||
);
|
||||
serverInfo.transport = refreshedTransport;
|
||||
|
||||
// Update server status to indicate OAuth is complete
|
||||
serverInfo.status = 'connected';
|
||||
if (serverInfo.oauth) {
|
||||
serverInfo.oauth.authorizationUrl = undefined;
|
||||
serverInfo.oauth.state = undefined;
|
||||
serverInfo.oauth.codeVerifier = undefined;
|
||||
}
|
||||
|
||||
// Check if client needs to be connected
|
||||
const isClientConnected = serverInfo.client && serverInfo.client.getServerCapabilities();
|
||||
|
||||
if (!isClientConnected) {
|
||||
// Client is not connected yet, connect it
|
||||
if (serverInfo.client && serverInfo.transport) {
|
||||
console.log(`Connecting client with refreshed transport for: ${serverInfo.name}`);
|
||||
try {
|
||||
await serverInfo.client.connect(serverInfo.transport, serverInfo.options);
|
||||
console.log(`Client connected successfully for: ${serverInfo.name}`);
|
||||
|
||||
// List tools after successful connection
|
||||
const capabilities = serverInfo.client.getServerCapabilities();
|
||||
console.log(
|
||||
`Server capabilities for ${serverInfo.name}:`,
|
||||
JSON.stringify(capabilities),
|
||||
);
|
||||
|
||||
if (capabilities?.tools) {
|
||||
console.log(`Listing tools for server: ${serverInfo.name}`);
|
||||
const toolsResult = await serverInfo.client.listTools({}, serverInfo.options);
|
||||
const separator = getNameSeparator();
|
||||
serverInfo.tools = toolsResult.tools.map((tool) => ({
|
||||
name: `${serverInfo.name}${separator}${tool.name}`,
|
||||
description: tool.description || '',
|
||||
inputSchema: tool.inputSchema || {},
|
||||
}));
|
||||
console.log(
|
||||
`Listed ${serverInfo.tools.length} tools for server: ${serverInfo.name}`,
|
||||
);
|
||||
} else {
|
||||
console.log(`Server ${serverInfo.name} does not support tools capability`);
|
||||
}
|
||||
} catch (connectError) {
|
||||
console.error(`Error connecting client for ${serverInfo.name}:`, connectError);
|
||||
if (connectError instanceof Error) {
|
||||
console.error(
|
||||
`Connect error details for ${serverInfo.name}: ${connectError.message}`,
|
||||
connectError.stack,
|
||||
);
|
||||
}
|
||||
// Even if connection fails, mark OAuth as complete
|
||||
// The user can try reconnecting from the dashboard
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`Cannot connect client for ${serverInfo.name}: client or transport missing`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(`Client already connected for server: ${serverInfo.name}`);
|
||||
}
|
||||
|
||||
console.log(`Successfully completed OAuth flow for server: ${serverInfo.name}`);
|
||||
|
||||
// Return success page
|
||||
return res.status(200).send(
|
||||
generateHtmlResponse(
|
||||
'success',
|
||||
t('oauthCallback.authorizationSuccessful'),
|
||||
`${t('oauthCallback.successMessage')}\n${t('oauthCallback.autoCloseMessage')}`,
|
||||
[
|
||||
{ label: t('oauthCallback.server'), value: serverInfo.name },
|
||||
{ label: t('oauthCallback.status'), value: t('oauthCallback.connected') },
|
||||
],
|
||||
true, // auto-close
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Failed to complete OAuth flow for server ${serverInfo.name}:`, error);
|
||||
console.error(`Error type: ${typeof error}, Error name: ${error?.constructor?.name}`);
|
||||
console.error(`Error message: ${error instanceof Error ? error.message : String(error)}`);
|
||||
console.error(`Error stack:`, error instanceof Error ? error.stack : 'No stack trace');
|
||||
|
||||
return res
|
||||
.status(500)
|
||||
.send(
|
||||
generateHtmlResponse(
|
||||
'error',
|
||||
t('oauthCallback.connectionError'),
|
||||
`${t('oauthCallback.connectionErrorMessage')}\n${t('oauthCallback.reconnectMessage')}`,
|
||||
[{ label: '', value: error instanceof Error ? error.message : String(error) }],
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// No transport available or transport doesn't support finishAuth
|
||||
console.error(`Transport for server ${serverInfo.name} does not support finishAuth()`);
|
||||
return res
|
||||
.status(500)
|
||||
.send(
|
||||
generateHtmlResponse(
|
||||
'error',
|
||||
t('oauthCallback.configurationError'),
|
||||
t('oauthCallback.configurationErrorMessage'),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Unexpected error handling OAuth callback:', error);
|
||||
|
||||
// Get translation function from request (set by i18n middleware)
|
||||
const t = (req as any).t || ((key: string) => key);
|
||||
|
||||
return res
|
||||
.status(500)
|
||||
.send(
|
||||
generateHtmlResponse(
|
||||
'error',
|
||||
t('oauthCallback.internalError'),
|
||||
t('oauthCallback.internalErrorMessage'),
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
278
src/controllers/oauthClientController.ts
Normal file
278
src/controllers/oauthClientController.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { validationResult } from 'express-validator';
|
||||
import crypto from 'crypto';
|
||||
import {
|
||||
getOAuthClients,
|
||||
findOAuthClientById,
|
||||
createOAuthClient,
|
||||
updateOAuthClient,
|
||||
deleteOAuthClient,
|
||||
} from '../models/OAuth.js';
|
||||
import { IOAuthClient } from '../types/index.js';
|
||||
|
||||
/**
|
||||
* GET /api/oauth/clients
|
||||
* Get all OAuth clients
|
||||
*/
|
||||
export const getAllClients = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const clients = await getOAuthClients();
|
||||
|
||||
// Don't expose client secrets in the list
|
||||
const sanitizedClients = clients.map((client) => ({
|
||||
clientId: client.clientId,
|
||||
name: client.name,
|
||||
redirectUris: client.redirectUris,
|
||||
grants: client.grants,
|
||||
scopes: client.scopes,
|
||||
owner: client.owner,
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
clients: sanitizedClients,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get OAuth clients error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to retrieve OAuth clients',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/oauth/clients/:clientId
|
||||
* Get a specific OAuth client
|
||||
*/
|
||||
export const getClient = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { clientId } = req.params;
|
||||
const client = await findOAuthClientById(clientId);
|
||||
|
||||
if (!client) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'OAuth client not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't expose client secret
|
||||
const sanitizedClient = {
|
||||
clientId: client.clientId,
|
||||
name: client.name,
|
||||
redirectUris: client.redirectUris,
|
||||
grants: client.grants,
|
||||
scopes: client.scopes,
|
||||
owner: client.owner,
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
client: sanitizedClient,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get OAuth client error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to retrieve OAuth client',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/oauth/clients
|
||||
* Create a new OAuth client
|
||||
*/
|
||||
export const createClient = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Validate request
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validation failed',
|
||||
errors: errors.array(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { name, redirectUris, grants, scopes, requireSecret } = req.body;
|
||||
const user = (req as any).user;
|
||||
|
||||
// Generate client ID
|
||||
const clientId = crypto.randomBytes(16).toString('hex');
|
||||
|
||||
// Generate client secret if required
|
||||
const clientSecret =
|
||||
requireSecret !== false ? crypto.randomBytes(32).toString('hex') : undefined;
|
||||
|
||||
// Create client
|
||||
const client: IOAuthClient = {
|
||||
clientId,
|
||||
clientSecret,
|
||||
name,
|
||||
redirectUris: Array.isArray(redirectUris) ? redirectUris : [redirectUris],
|
||||
grants: grants || ['authorization_code', 'refresh_token'],
|
||||
scopes: scopes || ['read', 'write'],
|
||||
owner: user?.username || 'admin',
|
||||
};
|
||||
|
||||
const createdClient = await createOAuthClient(client);
|
||||
|
||||
// Return client with secret (only shown once)
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'OAuth client created successfully',
|
||||
client: {
|
||||
clientId: createdClient.clientId,
|
||||
clientSecret: createdClient.clientSecret,
|
||||
name: createdClient.name,
|
||||
redirectUris: createdClient.redirectUris,
|
||||
grants: createdClient.grants,
|
||||
scopes: createdClient.scopes,
|
||||
owner: createdClient.owner,
|
||||
},
|
||||
warning: clientSecret
|
||||
? 'Client secret is only shown once. Please save it securely.'
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Create OAuth client error:', error);
|
||||
|
||||
if (error instanceof Error && error.message.includes('already exists')) {
|
||||
res.status(409).json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to create OAuth client',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* PUT /api/oauth/clients/:clientId
|
||||
* Update an OAuth client
|
||||
*/
|
||||
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 (grants) updates.grants = grants;
|
||||
if (scopes) updates.scopes = scopes;
|
||||
|
||||
const updatedClient = await updateOAuthClient(clientId, updates);
|
||||
|
||||
if (!updatedClient) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'OAuth client not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't expose client secret
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'OAuth client updated successfully',
|
||||
client: {
|
||||
clientId: updatedClient.clientId,
|
||||
name: updatedClient.name,
|
||||
redirectUris: updatedClient.redirectUris,
|
||||
grants: updatedClient.grants,
|
||||
scopes: updatedClient.scopes,
|
||||
owner: updatedClient.owner,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update OAuth client error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to update OAuth client',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE /api/oauth/clients/:clientId
|
||||
* Delete an OAuth client
|
||||
*/
|
||||
export const deleteClient = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { clientId } = req.params;
|
||||
const deleted = await deleteOAuthClient(clientId);
|
||||
|
||||
if (!deleted) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'OAuth client not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'OAuth client deleted successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Delete OAuth client error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to delete OAuth client',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/oauth/clients/:clientId/regenerate-secret
|
||||
* Regenerate client secret
|
||||
*/
|
||||
export const regenerateSecret = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { clientId } = req.params;
|
||||
const client = await findOAuthClientById(clientId);
|
||||
|
||||
if (!client) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'OAuth client not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate new secret
|
||||
const newSecret = crypto.randomBytes(32).toString('hex');
|
||||
const updatedClient = await updateOAuthClient(clientId, { clientSecret: newSecret });
|
||||
|
||||
if (!updatedClient) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to regenerate client secret',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Client secret regenerated successfully',
|
||||
clientSecret: newSecret,
|
||||
warning: 'Client secret is only shown once. Please save it securely.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Regenerate secret error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to regenerate client secret',
|
||||
});
|
||||
}
|
||||
};
|
||||
543
src/controllers/oauthDynamicRegistrationController.ts
Normal file
543
src/controllers/oauthDynamicRegistrationController.ts
Normal file
@@ -0,0 +1,543 @@
|
||||
import { Request, Response } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import {
|
||||
createOAuthClient,
|
||||
findOAuthClientById,
|
||||
updateOAuthClient,
|
||||
deleteOAuthClient,
|
||||
} from '../models/OAuth.js';
|
||||
import { IOAuthClient } from '../types/index.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
|
||||
// Store registration access tokens (in production, use database)
|
||||
const registrationTokens = new Map<string, { clientId: string; createdAt: Date }>();
|
||||
|
||||
/**
|
||||
* Generate registration access token
|
||||
*/
|
||||
const generateRegistrationToken = (clientId: string): string => {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
registrationTokens.set(token, {
|
||||
clientId,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
return token;
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify registration access token
|
||||
*/
|
||||
const verifyRegistrationToken = (token: string): string | null => {
|
||||
const data = registrationTokens.get(token);
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Token expires after 30 days
|
||||
const expiresAt = new Date(data.createdAt.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||
if (new Date() > expiresAt) {
|
||||
registrationTokens.delete(token);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data.clientId;
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /oauth/register
|
||||
* RFC 7591 Dynamic Client Registration
|
||||
* Public endpoint for registering new OAuth clients
|
||||
*/
|
||||
export const registerClient = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
const oauthConfig = settings.systemConfig?.oauthServer;
|
||||
|
||||
// Check if dynamic registration is enabled
|
||||
if (!oauthConfig?.dynamicRegistration?.enabled) {
|
||||
res.status(403).json({
|
||||
error: 'invalid_request',
|
||||
error_description: 'Dynamic client registration is not enabled',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
const {
|
||||
redirect_uris,
|
||||
client_name,
|
||||
grant_types,
|
||||
response_types,
|
||||
scope,
|
||||
token_endpoint_auth_method,
|
||||
application_type,
|
||||
contacts,
|
||||
logo_uri,
|
||||
client_uri,
|
||||
policy_uri,
|
||||
tos_uri,
|
||||
jwks_uri,
|
||||
jwks,
|
||||
} = req.body;
|
||||
|
||||
// redirect_uris is required
|
||||
if (!redirect_uris || !Array.isArray(redirect_uris) || redirect_uris.length === 0) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_redirect_uri',
|
||||
error_description: 'redirect_uris is required and must be a non-empty array',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate redirect URIs
|
||||
for (const uri of redirect_uris) {
|
||||
try {
|
||||
const url = new URL(uri);
|
||||
// For security, only allow https (except localhost for development)
|
||||
if (
|
||||
url.protocol !== 'https:' &&
|
||||
!url.hostname.match(/^(localhost|127\.0\.0\.1|\[::1\])$/)
|
||||
) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_redirect_uri',
|
||||
error_description: `Redirect URI must use HTTPS: ${uri}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_redirect_uri',
|
||||
error_description: `Invalid redirect URI: ${uri}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate client credentials
|
||||
const clientId = crypto.randomBytes(16).toString('hex');
|
||||
|
||||
// Determine if client secret is needed based on token_endpoint_auth_method
|
||||
const authMethod = token_endpoint_auth_method || 'client_secret_basic';
|
||||
const needsSecret = authMethod !== 'none';
|
||||
const clientSecret = needsSecret ? crypto.randomBytes(32).toString('hex') : undefined;
|
||||
|
||||
// Default grant types
|
||||
const defaultGrantTypes = ['authorization_code', 'refresh_token'];
|
||||
const clientGrantTypes = grant_types || defaultGrantTypes;
|
||||
|
||||
// Validate grant types
|
||||
const allowedGrantTypes = oauthConfig.dynamicRegistration.allowedGrantTypes || [
|
||||
'authorization_code',
|
||||
'refresh_token',
|
||||
];
|
||||
for (const grantType of clientGrantTypes) {
|
||||
if (!allowedGrantTypes.includes(grantType)) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_client_metadata',
|
||||
error_description: `Grant type not allowed: ${grantType}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate scopes
|
||||
const requestedScopes = scope ? scope.split(' ') : ['read', 'write'];
|
||||
const allowedScopes = oauthConfig.allowedScopes || ['read', 'write'];
|
||||
for (const requestedScope of requestedScopes) {
|
||||
if (!allowedScopes.includes(requestedScope)) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_client_metadata',
|
||||
error_description: `Scope not allowed: ${requestedScope}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate registration access token
|
||||
const registrationAccessToken = generateRegistrationToken(clientId);
|
||||
const baseUrl =
|
||||
settings.systemConfig?.install?.baseUrl || `${req.protocol}://${req.get('host')}`;
|
||||
const registrationClientUri = `${baseUrl}/oauth/register/${clientId}`;
|
||||
|
||||
// Create OAuth client
|
||||
const client: IOAuthClient = {
|
||||
clientId,
|
||||
clientSecret,
|
||||
name: client_name || 'Dynamically Registered Client',
|
||||
redirectUris: redirect_uris,
|
||||
grants: clientGrantTypes,
|
||||
scopes: requestedScopes,
|
||||
owner: 'dynamic-registration',
|
||||
// Store additional metadata
|
||||
metadata: {
|
||||
application_type: application_type || 'web',
|
||||
contacts,
|
||||
logo_uri,
|
||||
client_uri,
|
||||
policy_uri,
|
||||
tos_uri,
|
||||
jwks_uri,
|
||||
jwks,
|
||||
token_endpoint_auth_method: authMethod,
|
||||
response_types: response_types || ['code'],
|
||||
},
|
||||
};
|
||||
|
||||
const createdClient = await createOAuthClient(client);
|
||||
|
||||
// Build response according to RFC 7591
|
||||
const response: any = {
|
||||
client_id: createdClient.clientId,
|
||||
client_name: createdClient.name,
|
||||
redirect_uris: createdClient.redirectUris,
|
||||
grant_types: createdClient.grants,
|
||||
response_types: client.metadata?.response_types || ['code'],
|
||||
scope: (createdClient.scopes || []).join(' '),
|
||||
token_endpoint_auth_method: authMethod,
|
||||
registration_access_token: registrationAccessToken,
|
||||
registration_client_uri: registrationClientUri,
|
||||
client_id_issued_at: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
// Include client secret if generated
|
||||
if (clientSecret) {
|
||||
response.client_secret = clientSecret;
|
||||
response.client_secret_expires_at = 0; // 0 means it doesn't expire
|
||||
}
|
||||
|
||||
// Include optional metadata
|
||||
if (application_type) response.application_type = application_type;
|
||||
if (contacts) response.contacts = contacts;
|
||||
if (logo_uri) response.logo_uri = logo_uri;
|
||||
if (client_uri) response.client_uri = client_uri;
|
||||
if (policy_uri) response.policy_uri = policy_uri;
|
||||
if (tos_uri) response.tos_uri = tos_uri;
|
||||
if (jwks_uri) response.jwks_uri = jwks_uri;
|
||||
if (jwks) response.jwks = jwks;
|
||||
|
||||
res.status(201).json(response);
|
||||
} catch (error) {
|
||||
console.error('Dynamic client registration error:', error);
|
||||
|
||||
if (error instanceof Error && error.message.includes('already exists')) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_client_metadata',
|
||||
error_description: 'Client with this ID already exists',
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
error: 'server_error',
|
||||
error_description: 'Failed to register client',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /oauth/register/:clientId
|
||||
* RFC 7591 Client Configuration Endpoint
|
||||
* Read client configuration
|
||||
*/
|
||||
export const getClientConfiguration = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { clientId } = req.params;
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
res.status(401).json({
|
||||
error: 'invalid_token',
|
||||
error_description: 'Registration access token required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const tokenClientId = verifyRegistrationToken(token);
|
||||
|
||||
if (!tokenClientId || tokenClientId !== clientId) {
|
||||
res.status(401).json({
|
||||
error: 'invalid_token',
|
||||
error_description: 'Invalid or expired registration access token',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await findOAuthClientById(clientId);
|
||||
if (!client) {
|
||||
res.status(404).json({
|
||||
error: 'invalid_client',
|
||||
error_description: 'Client not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Build response
|
||||
const response: any = {
|
||||
client_id: client.clientId,
|
||||
client_name: client.name,
|
||||
redirect_uris: client.redirectUris,
|
||||
grant_types: client.grants,
|
||||
response_types: client.metadata?.response_types || ['code'],
|
||||
scope: (client.scopes || []).join(' '),
|
||||
token_endpoint_auth_method:
|
||||
client.metadata?.token_endpoint_auth_method || 'client_secret_basic',
|
||||
};
|
||||
|
||||
// Include optional metadata
|
||||
if (client.metadata) {
|
||||
if (client.metadata.application_type)
|
||||
response.application_type = client.metadata.application_type;
|
||||
if (client.metadata.contacts) response.contacts = client.metadata.contacts;
|
||||
if (client.metadata.logo_uri) response.logo_uri = client.metadata.logo_uri;
|
||||
if (client.metadata.client_uri) response.client_uri = client.metadata.client_uri;
|
||||
if (client.metadata.policy_uri) response.policy_uri = client.metadata.policy_uri;
|
||||
if (client.metadata.tos_uri) response.tos_uri = client.metadata.tos_uri;
|
||||
if (client.metadata.jwks_uri) response.jwks_uri = client.metadata.jwks_uri;
|
||||
if (client.metadata.jwks) response.jwks = client.metadata.jwks;
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
console.error('Get client configuration error:', error);
|
||||
res.status(500).json({
|
||||
error: 'server_error',
|
||||
error_description: 'Failed to retrieve client configuration',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* PUT /oauth/register/:clientId
|
||||
* RFC 7591 Client Update Endpoint
|
||||
* Update client configuration
|
||||
*/
|
||||
export const updateClientConfiguration = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { clientId } = req.params;
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
res.status(401).json({
|
||||
error: 'invalid_token',
|
||||
error_description: 'Registration access token required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const tokenClientId = verifyRegistrationToken(token);
|
||||
|
||||
if (!tokenClientId || tokenClientId !== clientId) {
|
||||
res.status(401).json({
|
||||
error: 'invalid_token',
|
||||
error_description: 'Invalid or expired registration access token',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await findOAuthClientById(clientId);
|
||||
if (!client) {
|
||||
res.status(404).json({
|
||||
error: 'invalid_client',
|
||||
error_description: 'Client not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
redirect_uris,
|
||||
client_name,
|
||||
grant_types,
|
||||
scope,
|
||||
contacts,
|
||||
logo_uri,
|
||||
client_uri,
|
||||
policy_uri,
|
||||
tos_uri,
|
||||
} = req.body;
|
||||
|
||||
const settings = loadSettings();
|
||||
const oauthConfig = settings.systemConfig?.oauthServer;
|
||||
|
||||
// Validate redirect URIs if provided
|
||||
if (redirect_uris) {
|
||||
if (!Array.isArray(redirect_uris) || redirect_uris.length === 0) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_redirect_uri',
|
||||
error_description: 'redirect_uris must be a non-empty array',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
for (const uri of redirect_uris) {
|
||||
try {
|
||||
const url = new URL(uri);
|
||||
if (
|
||||
url.protocol !== 'https:' &&
|
||||
!url.hostname.match(/^(localhost|127\.0\.0\.1|\[::1\])$/)
|
||||
) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_redirect_uri',
|
||||
error_description: `Redirect URI must use HTTPS: ${uri}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_redirect_uri',
|
||||
error_description: `Invalid redirect URI: ${uri}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate grant types if provided
|
||||
if (grant_types) {
|
||||
const allowedGrantTypes = oauthConfig?.dynamicRegistration?.allowedGrantTypes || [
|
||||
'authorization_code',
|
||||
'refresh_token',
|
||||
];
|
||||
for (const grantType of grant_types) {
|
||||
if (!allowedGrantTypes.includes(grantType)) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_client_metadata',
|
||||
error_description: `Grant type not allowed: ${grantType}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate scopes if provided
|
||||
if (scope) {
|
||||
const requestedScopes = scope.split(' ');
|
||||
const allowedScopes = oauthConfig?.allowedScopes || ['read', 'write'];
|
||||
for (const requestedScope of requestedScopes) {
|
||||
if (!allowedScopes.includes(requestedScope)) {
|
||||
res.status(400).json({
|
||||
error: 'invalid_client_metadata',
|
||||
error_description: `Scope not allowed: ${requestedScope}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build updates
|
||||
const updates: Partial<IOAuthClient> = {};
|
||||
if (client_name) updates.name = client_name;
|
||||
if (redirect_uris) updates.redirectUris = redirect_uris;
|
||||
if (grant_types) updates.grants = grant_types;
|
||||
if (scope) updates.scopes = scope.split(' ');
|
||||
|
||||
// Update metadata
|
||||
if (client.metadata || contacts || logo_uri || client_uri || policy_uri || tos_uri) {
|
||||
updates.metadata = {
|
||||
...client.metadata,
|
||||
contacts,
|
||||
logo_uri,
|
||||
client_uri,
|
||||
policy_uri,
|
||||
tos_uri,
|
||||
};
|
||||
}
|
||||
|
||||
const updatedClient = await updateOAuthClient(clientId, updates);
|
||||
|
||||
if (!updatedClient) {
|
||||
res.status(500).json({
|
||||
error: 'server_error',
|
||||
error_description: 'Failed to update client',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Build response
|
||||
const response: any = {
|
||||
client_id: updatedClient.clientId,
|
||||
client_name: updatedClient.name,
|
||||
redirect_uris: updatedClient.redirectUris,
|
||||
grant_types: updatedClient.grants,
|
||||
response_types: updatedClient.metadata?.response_types || ['code'],
|
||||
scope: (updatedClient.scopes || []).join(' '),
|
||||
token_endpoint_auth_method:
|
||||
updatedClient.metadata?.token_endpoint_auth_method || 'client_secret_basic',
|
||||
};
|
||||
|
||||
// Include optional metadata
|
||||
if (updatedClient.metadata) {
|
||||
if (updatedClient.metadata.application_type)
|
||||
response.application_type = updatedClient.metadata.application_type;
|
||||
if (updatedClient.metadata.contacts) response.contacts = updatedClient.metadata.contacts;
|
||||
if (updatedClient.metadata.logo_uri) response.logo_uri = updatedClient.metadata.logo_uri;
|
||||
if (updatedClient.metadata.client_uri)
|
||||
response.client_uri = updatedClient.metadata.client_uri;
|
||||
if (updatedClient.metadata.policy_uri)
|
||||
response.policy_uri = updatedClient.metadata.policy_uri;
|
||||
if (updatedClient.metadata.tos_uri) response.tos_uri = updatedClient.metadata.tos_uri;
|
||||
if (updatedClient.metadata.jwks_uri) response.jwks_uri = updatedClient.metadata.jwks_uri;
|
||||
if (updatedClient.metadata.jwks) response.jwks = updatedClient.metadata.jwks;
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
console.error('Update client configuration error:', error);
|
||||
res.status(500).json({
|
||||
error: 'server_error',
|
||||
error_description: 'Failed to update client configuration',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE /oauth/register/:clientId
|
||||
* RFC 7591 Client Delete Endpoint
|
||||
* Delete client registration
|
||||
*/
|
||||
export const deleteClientRegistration = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { clientId } = req.params;
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
res.status(401).json({
|
||||
error: 'invalid_token',
|
||||
error_description: 'Registration access token required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const tokenClientId = verifyRegistrationToken(token);
|
||||
|
||||
if (!tokenClientId || tokenClientId !== clientId) {
|
||||
res.status(401).json({
|
||||
error: 'invalid_token',
|
||||
error_description: 'Invalid or expired registration access token',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = await deleteOAuthClient(clientId);
|
||||
|
||||
if (!deleted) {
|
||||
res.status(404).json({
|
||||
error: 'invalid_client',
|
||||
error_description: 'Client not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up registration token
|
||||
registrationTokens.delete(token);
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
console.error('Delete client registration error:', error);
|
||||
res.status(500).json({
|
||||
error: 'server_error',
|
||||
error_description: 'Failed to delete client registration',
|
||||
});
|
||||
}
|
||||
};
|
||||
525
src/controllers/oauthServerController.ts
Normal file
525
src/controllers/oauthServerController.ts
Normal file
@@ -0,0 +1,525 @@
|
||||
import { Request, Response } from 'express';
|
||||
import {
|
||||
getOAuthServer,
|
||||
handleTokenRequest,
|
||||
handleAuthenticateRequest,
|
||||
} from '../services/oauthServerService.js';
|
||||
import { findOAuthClientById } from '../models/OAuth.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import OAuth2Server from '@node-oauth/oauth2-server';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { JWT_SECRET } from '../config/jwt.js';
|
||||
|
||||
const { Request: OAuth2Request, Response: OAuth2Response } = OAuth2Server;
|
||||
|
||||
type AuthenticatedUser = {
|
||||
username: string;
|
||||
isAdmin?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Attempt to attach a user to the request based on a JWT token present in header, query, or body.
|
||||
*/
|
||||
function resolveUserFromRequest(req: Request): AuthenticatedUser | null {
|
||||
const headerToken = req.header('x-auth-token');
|
||||
const queryToken = typeof req.query.token === 'string' ? req.query.token : undefined;
|
||||
const bodyToken =
|
||||
req.body && typeof (req.body as Record<string, unknown>).token === 'string'
|
||||
? ((req.body as Record<string, string>).token as string)
|
||||
: undefined;
|
||||
const token = headerToken || queryToken || bodyToken;
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as { user?: AuthenticatedUser };
|
||||
if (decoded?.user) {
|
||||
return decoded.user;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Invalid JWT supplied to OAuth authorize endpoint:', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to escape HTML
|
||||
*/
|
||||
function escapeHtml(unsafe: string): string {
|
||||
return unsafe
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to validate query parameters
|
||||
*/
|
||||
function validateQueryParam(value: any, name: string, pattern?: RegExp): string {
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`${name} must be a string`);
|
||||
}
|
||||
if (pattern && !pattern.test(value)) {
|
||||
throw new Error(`${name} has invalid format`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate OAuth authorization consent HTML page with i18n support
|
||||
* (keeps visual style consistent with OAuth callback pages)
|
||||
*/
|
||||
const generateAuthorizeHtml = (
|
||||
title: string,
|
||||
message: string,
|
||||
options: {
|
||||
clientName: string;
|
||||
scopes: { name: string; description: string }[];
|
||||
approveLabel: string;
|
||||
denyLabel: string;
|
||||
approveButtonLabel: string;
|
||||
denyButtonLabel: string;
|
||||
formFields: string;
|
||||
},
|
||||
): string => {
|
||||
const backgroundColor = '#eef5ff';
|
||||
const borderColor = '#c3d4ff';
|
||||
const titleColor = '#23408f';
|
||||
const approveColor = '#2563eb';
|
||||
const denyColor = '#ef4444';
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 640px; margin: 40px auto; padding: 24px; background: #f3f4f6; }
|
||||
.container { background-color: ${backgroundColor}; border: 1px solid ${borderColor}; padding: 24px 28px; border-radius: 12px; box-shadow: 0 10px 25px rgba(15, 23, 42, 0.12); }
|
||||
h1 { color: ${titleColor}; margin-top: 0; font-size: 22px; display: flex; align-items: center; gap: 8px; }
|
||||
h1 span.icon { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 999px; background: white; border: 1px solid ${borderColor}; font-size: 16px; }
|
||||
p.subtitle { margin-top: 8px; margin-bottom: 20px; color: #4b5563; font-size: 14px; }
|
||||
.client-box { margin: 16px 0 20px; padding: 14px 16px; background: #eef2ff; border-radius: 10px; border: 1px solid #e5e7eb; display: flex; flex-direction: column; gap: 4px; }
|
||||
.client-box-label { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: #6b7280; }
|
||||
.client-box-name { font-weight: 600; color: #111827; }
|
||||
.scopes { margin: 22px 0 16px; }
|
||||
.scopes-title { font-size: 13px; font-weight: 600; color: #374151; margin-bottom: 8px; }
|
||||
.scope-item { padding: 8px 0; border-bottom: 1px solid #e5e7eb; display: flex; flex-direction: column; gap: 2px; }
|
||||
.scope-item:last-child { border-bottom: none; }
|
||||
.scope-name { font-weight: 600; font-size: 13px; color: #111827; }
|
||||
.scope-description { font-size: 12px; color: #4b5563; }
|
||||
.buttons { margin-top: 26px; display: flex; gap: 12px; }
|
||||
.buttons form { flex: 1; }
|
||||
button { width: 100%; padding: 10px 14px; border-radius: 999px; cursor: pointer; font-size: 14px; font-weight: 500; border-width: 1px; border-style: solid; transition: background-color 120ms ease, box-shadow 120ms ease, transform 60ms ease; }
|
||||
button.approve { background: ${approveColor}; color: white; border-color: ${approveColor}; box-shadow: 0 4px 12px rgba(37, 99, 235, 0.35); }
|
||||
button.approve:hover { background: #1d4ed8; box-shadow: 0 6px 16px rgba(37, 99, 235, 0.45); transform: translateY(-1px); }
|
||||
button.deny { background: white; color: ${denyColor}; border-color: ${denyColor}; }
|
||||
button.deny:hover { background: #fef2f2; }
|
||||
.button-label { display: block; }
|
||||
.button-sub { display: block; font-size: 11px; opacity: 0.85; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1><span class="icon">🔐</span>${escapeHtml(title)}</h1>
|
||||
<p class="subtitle">${escapeHtml(message)}</p>
|
||||
<div class="client-box">
|
||||
<span class="client-box-label">${escapeHtml(options.clientName ? 'Application' : 'Client')}</span>
|
||||
<span class="client-box-name">${escapeHtml(options.clientName || '')}</span>
|
||||
</div>
|
||||
<div class="scopes">
|
||||
<div class="scopes-title">${escapeHtml('This application will be able to:')}</div>
|
||||
${options.scopes
|
||||
.map(
|
||||
(s) => `
|
||||
<div class="scope-item">
|
||||
<span class="scope-name">${escapeHtml(s.name)}</span>
|
||||
<span class="scope-description">${escapeHtml(s.description)}</span>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join('')}
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<form method="POST" action="/oauth/authorize">
|
||||
${options.formFields}
|
||||
<input type="hidden" name="allow" value="true" />
|
||||
<button type="submit" class="approve">
|
||||
<span class="button-label">${escapeHtml(options.approveLabel)}</span>
|
||||
<span class="button-sub">${escapeHtml(options.approveButtonLabel)}</span>
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/oauth/authorize">
|
||||
${options.formFields}
|
||||
<input type="hidden" name="allow" value="false" />
|
||||
<button type="submit" class="deny">
|
||||
<span class="button-label">${escapeHtml(options.denyLabel)}</span>
|
||||
<span class="button-sub">${escapeHtml(options.denyButtonLabel)}</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /oauth/authorize
|
||||
* Display authorization page or handle authorization
|
||||
*/
|
||||
export const getAuthorize = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const oauth = getOAuthServer();
|
||||
if (!oauth) {
|
||||
res.status(503).json({ error: 'OAuth server not available' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get and validate query parameters
|
||||
const client_id = validateQueryParam(req.query.client_id, 'client_id', /^[a-zA-Z0-9_-]+$/);
|
||||
const redirect_uri = validateQueryParam(req.query.redirect_uri, 'redirect_uri');
|
||||
const response_type = validateQueryParam(req.query.response_type, 'response_type', /^code$/);
|
||||
const scope = req.query.scope
|
||||
? validateQueryParam(req.query.scope, 'scope', /^[a-zA-Z0-9_ ]+$/)
|
||||
: undefined;
|
||||
const state = req.query.state
|
||||
? validateQueryParam(req.query.state, 'state', /^[a-zA-Z0-9_-]+$/)
|
||||
: undefined;
|
||||
const code_challenge = req.query.code_challenge
|
||||
? validateQueryParam(req.query.code_challenge, 'code_challenge', /^[a-zA-Z0-9_-]+$/)
|
||||
: undefined;
|
||||
const code_challenge_method = req.query.code_challenge_method
|
||||
? validateQueryParam(
|
||||
req.query.code_challenge_method,
|
||||
'code_challenge_method',
|
||||
/^(S256|plain)$/,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// Validate required parameters
|
||||
if (!client_id || !redirect_uri || !response_type) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: 'invalid_request', error_description: 'Missing required parameters' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify client
|
||||
const client = await findOAuthClientById(client_id as string);
|
||||
if (!client) {
|
||||
res.status(400).json({ error: 'invalid_client', error_description: 'Client not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify redirect URI
|
||||
if (!client.redirectUris.includes(redirect_uri as string)) {
|
||||
res.status(400).json({ error: 'invalid_request', error_description: 'Invalid redirect_uri' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user is authenticated (including via JWT token)
|
||||
let user = (req as any).user;
|
||||
if (!user) {
|
||||
const tokenUser = resolveUserFromRequest(req);
|
||||
if (tokenUser) {
|
||||
(req as any).user = tokenUser;
|
||||
user = tokenUser;
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
// Redirect to login page with return URL
|
||||
const returnUrl = encodeURIComponent(req.originalUrl);
|
||||
res.redirect(`/login?returnUrl=${returnUrl}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestToken = typeof req.query.token === 'string' ? req.query.token : '';
|
||||
const tokenField = requestToken
|
||||
? `<input type="hidden" name="token" value="${escapeHtml(requestToken)}">`
|
||||
: '';
|
||||
|
||||
// Get translation function from request (set by i18n middleware)
|
||||
const t = (req as any).t || ((key: string) => key);
|
||||
|
||||
const scopes = (scope || 'read write')
|
||||
.split(' ')
|
||||
.filter((s) => s)
|
||||
.map((s) => ({ name: s, description: getScopeDescription(s) }));
|
||||
|
||||
const formFields = `
|
||||
<input type="hidden" name="client_id" value="${escapeHtml(client_id)}" />
|
||||
<input type="hidden" name="redirect_uri" value="${escapeHtml(redirect_uri)}" />
|
||||
<input type="hidden" name="response_type" value="${escapeHtml(response_type)}" />
|
||||
<input type="hidden" name="scope" value="${escapeHtml(scope || '')}" />
|
||||
<input type="hidden" name="state" value="${escapeHtml(state || '')}" />
|
||||
${code_challenge ? `<input type="hidden" name="code_challenge" value="${escapeHtml(code_challenge)}" />` : ''}
|
||||
${code_challenge_method ? `<input type="hidden" name="code_challenge_method" value="${escapeHtml(code_challenge_method)}" />` : ''}
|
||||
${tokenField}
|
||||
`;
|
||||
|
||||
// Render authorization consent page with consistent, localized styling
|
||||
res.send(
|
||||
generateAuthorizeHtml(
|
||||
t('oauthServer.authorizeTitle') || 'Authorize Application',
|
||||
t('oauthServer.authorizeSubtitle') ||
|
||||
'Allow this application to access your MCPHub account.',
|
||||
{
|
||||
clientName: client.name,
|
||||
scopes,
|
||||
approveLabel: t('oauthServer.buttons.approve') || 'Allow access',
|
||||
denyLabel: t('oauthServer.buttons.deny') || 'Deny',
|
||||
approveButtonLabel:
|
||||
t('oauthServer.buttons.approveSubtitle') ||
|
||||
'Recommended if you trust this application.',
|
||||
denyButtonLabel:
|
||||
t('oauthServer.buttons.denySubtitle') || 'You can always grant access later.',
|
||||
formFields,
|
||||
},
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Authorization error:', error);
|
||||
res.status(500).json({ error: 'server_error', error_description: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /oauth/authorize
|
||||
* Handle authorization decision
|
||||
*/
|
||||
export const postAuthorize = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const oauth = getOAuthServer();
|
||||
if (!oauth) {
|
||||
res.status(503).json({ error: 'OAuth server not available' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { allow, redirect_uri, state } = req.body;
|
||||
|
||||
// If user denied
|
||||
if (allow !== 'true') {
|
||||
const redirectUrl = new URL(redirect_uri);
|
||||
redirectUrl.searchParams.set('error', 'access_denied');
|
||||
if (state) {
|
||||
redirectUrl.searchParams.set('state', state);
|
||||
}
|
||||
res.redirect(redirectUrl.toString());
|
||||
return;
|
||||
}
|
||||
|
||||
// Get authenticated user (JWT support for browser form submissions)
|
||||
let user = (req as any).user;
|
||||
if (!user) {
|
||||
const tokenUser = resolveUserFromRequest(req);
|
||||
if (tokenUser) {
|
||||
(req as any).user = tokenUser;
|
||||
user = tokenUser;
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
res.status(401).json({ error: 'unauthorized', error_description: 'User not authenticated' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Create OAuth request/response
|
||||
const request = new OAuth2Request(req);
|
||||
const response = new OAuth2Response(res);
|
||||
|
||||
// Authorize the request
|
||||
const code = await oauth.authorize(request, response, {
|
||||
authenticateHandler: {
|
||||
handle: async () => user,
|
||||
},
|
||||
});
|
||||
|
||||
// Build redirect URL with authorization code
|
||||
const redirectUrl = new URL(redirect_uri);
|
||||
redirectUrl.searchParams.set('code', code.authorizationCode);
|
||||
if (state) {
|
||||
redirectUrl.searchParams.set('state', state);
|
||||
}
|
||||
|
||||
res.redirect(redirectUrl.toString());
|
||||
} catch (error) {
|
||||
console.error('Authorization error:', error);
|
||||
|
||||
// Handle OAuth errors
|
||||
if (error instanceof Error && 'code' in error) {
|
||||
const oauthError = error as any;
|
||||
const redirect_uri = req.body.redirect_uri;
|
||||
const state = req.body.state;
|
||||
|
||||
if (redirect_uri) {
|
||||
const redirectUrl = new URL(redirect_uri);
|
||||
redirectUrl.searchParams.set('error', oauthError.name || 'server_error');
|
||||
if (oauthError.message) {
|
||||
redirectUrl.searchParams.set('error_description', oauthError.message);
|
||||
}
|
||||
if (state) {
|
||||
redirectUrl.searchParams.set('state', state);
|
||||
}
|
||||
res.redirect(redirectUrl.toString());
|
||||
} else {
|
||||
res.status(400).json({
|
||||
error: oauthError.name || 'server_error',
|
||||
error_description: oauthError.message || 'Internal server error',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
res.status(500).json({ error: 'server_error', error_description: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /oauth/token
|
||||
* Exchange authorization code for access token
|
||||
*/
|
||||
export const postToken = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const token = await handleTokenRequest(req, res);
|
||||
res.json({
|
||||
access_token: token.accessToken,
|
||||
token_type: 'Bearer',
|
||||
expires_in: Math.floor(((token.accessTokenExpiresAt?.getTime() || 0) - Date.now()) / 1000),
|
||||
refresh_token: token.refreshToken,
|
||||
scope: Array.isArray(token.scope) ? token.scope.join(' ') : token.scope,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Token error:', error);
|
||||
|
||||
if (error instanceof Error && 'code' in error) {
|
||||
const oauthError = error as any;
|
||||
res.status(oauthError.code || 400).json({
|
||||
error: oauthError.name || 'invalid_request',
|
||||
error_description: oauthError.message || 'Token request failed',
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
error: 'invalid_request',
|
||||
error_description: 'Token request failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /oauth/userinfo
|
||||
* Get user info from access token (OpenID Connect compatible)
|
||||
*/
|
||||
export const getUserInfo = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const token = await handleAuthenticateRequest(req, res);
|
||||
|
||||
res.json({
|
||||
sub: token.user.username,
|
||||
username: token.user.username,
|
||||
// Add more user info as needed
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('UserInfo error:', error);
|
||||
res.status(401).json({
|
||||
error: 'invalid_token',
|
||||
error_description: 'Invalid or expired access token',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /.well-known/oauth-authorization-server
|
||||
* OAuth 2.0 Authorization Server Metadata (RFC 8414)
|
||||
*/
|
||||
export const getMetadata = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
const oauthConfig = settings.systemConfig?.oauthServer;
|
||||
|
||||
if (!oauthConfig || !oauthConfig.enabled) {
|
||||
res.status(404).json({ error: 'OAuth server not configured' });
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl =
|
||||
settings.systemConfig?.install?.baseUrl || `${req.protocol}://${req.get('host')}`;
|
||||
const allowedScopes = oauthConfig.allowedScopes || ['read', 'write'];
|
||||
|
||||
const metadata: any = {
|
||||
issuer: baseUrl,
|
||||
authorization_endpoint: `${baseUrl}/oauth/authorize`,
|
||||
token_endpoint: `${baseUrl}/oauth/token`,
|
||||
userinfo_endpoint: `${baseUrl}/oauth/userinfo`,
|
||||
scopes_supported: allowedScopes,
|
||||
response_types_supported: ['code'],
|
||||
grant_types_supported: ['authorization_code', 'refresh_token'],
|
||||
token_endpoint_auth_methods_supported:
|
||||
oauthConfig.requireClientSecret !== false
|
||||
? ['client_secret_basic', 'client_secret_post', 'none']
|
||||
: ['none'],
|
||||
code_challenge_methods_supported: ['S256', 'plain'],
|
||||
};
|
||||
|
||||
// Add dynamic registration endpoint if enabled
|
||||
if (oauthConfig.dynamicRegistration?.enabled) {
|
||||
metadata.registration_endpoint = `${baseUrl}/oauth/register`;
|
||||
}
|
||||
|
||||
res.json(metadata);
|
||||
} catch (error) {
|
||||
console.error('Metadata error:', error);
|
||||
res.status(500).json({ error: 'server_error' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /.well-known/oauth-protected-resource
|
||||
* OAuth 2.0 Protected Resource Metadata (RFC 9728)
|
||||
* Provides information about authorization servers that protect this resource
|
||||
*/
|
||||
export const getProtectedResourceMetadata = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
const oauthConfig = settings.systemConfig?.oauthServer;
|
||||
|
||||
if (!oauthConfig || !oauthConfig.enabled) {
|
||||
res.status(404).json({ error: 'OAuth server not configured' });
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl =
|
||||
settings.systemConfig?.install?.baseUrl || `${req.protocol}://${req.get('host')}`;
|
||||
const allowedScopes = oauthConfig.allowedScopes || ['read', 'write'];
|
||||
|
||||
// Return protected resource metadata according to RFC 9728
|
||||
res.json({
|
||||
resource: baseUrl,
|
||||
authorization_servers: [baseUrl],
|
||||
scopes_supported: allowedScopes,
|
||||
bearer_methods_supported: ['header'],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Protected resource metadata error:', error);
|
||||
res.status(500).json({ error: 'server_error' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to get scope description
|
||||
*/
|
||||
function getScopeDescription(scope: string): string {
|
||||
const descriptions: Record<string, string> = {
|
||||
read: 'Read access to your MCP servers and tools',
|
||||
write: 'Execute tools and modify MCP server configurations',
|
||||
admin: 'Administrative access to all resources',
|
||||
};
|
||||
return descriptions[scope] || 'Access to MCPHub resources';
|
||||
}
|
||||
@@ -8,82 +8,13 @@ import {
|
||||
import { getServerByName } from '../services/mcpService.js';
|
||||
import { getGroupByIdOrName } from '../services/groupService.js';
|
||||
import { getNameSeparator } from '../config/index.js';
|
||||
import { convertParametersToTypes } from '../utils/parameterConversion.js';
|
||||
|
||||
/**
|
||||
* Controller for OpenAPI generation endpoints
|
||||
* Provides OpenAPI specifications for MCP tools to enable OpenWebUI integration
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convert query parameters to their proper types based on the tool's input schema
|
||||
*/
|
||||
function convertQueryParametersToTypes(
|
||||
queryParams: Record<string, any>,
|
||||
inputSchema: Record<string, any>,
|
||||
): Record<string, any> {
|
||||
if (!inputSchema || typeof inputSchema !== 'object' || !inputSchema.properties) {
|
||||
return queryParams;
|
||||
}
|
||||
|
||||
const convertedParams: Record<string, any> = {};
|
||||
const properties = inputSchema.properties;
|
||||
|
||||
for (const [key, value] of Object.entries(queryParams)) {
|
||||
const propDef = properties[key];
|
||||
if (!propDef || typeof propDef !== 'object') {
|
||||
// No schema definition found, keep as is
|
||||
convertedParams[key] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
const propType = propDef.type;
|
||||
|
||||
try {
|
||||
switch (propType) {
|
||||
case 'integer':
|
||||
case 'number':
|
||||
// Convert string to number
|
||||
if (typeof value === 'string') {
|
||||
const numValue = propType === 'integer' ? parseInt(value, 10) : parseFloat(value);
|
||||
convertedParams[key] = isNaN(numValue) ? value : numValue;
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'boolean':
|
||||
// Convert string to boolean
|
||||
if (typeof value === 'string') {
|
||||
convertedParams[key] = value.toLowerCase() === 'true' || value === '1';
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'array':
|
||||
// Handle array conversion if needed (e.g., comma-separated strings)
|
||||
if (typeof value === 'string' && value.includes(',')) {
|
||||
convertedParams[key] = value.split(',').map((item) => item.trim());
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// For string and other types, keep as is
|
||||
convertedParams[key] = value;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
// If conversion fails, keep the original value
|
||||
console.warn(`Failed to convert parameter '${key}' to type '${propType}':`, error);
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return convertedParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and return OpenAPI specification
|
||||
* GET /api/openapi.json
|
||||
@@ -167,7 +98,9 @@ export const getOpenAPIStats = async (req: Request, res: Response): Promise<void
|
||||
*/
|
||||
export const executeToolViaOpenAPI = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName, toolName } = req.params;
|
||||
// Decode URL-encoded parameters to handle slashes in server/tool names
|
||||
const serverName = decodeURIComponent(req.params.serverName);
|
||||
let toolName = decodeURIComponent(req.params.toolName);
|
||||
|
||||
// Import handleCallToolRequest function
|
||||
const { handleCallToolRequest } = await import('../services/mcpService.js');
|
||||
@@ -182,14 +115,17 @@ export const executeToolViaOpenAPI = async (req: Request, res: Response): Promis
|
||||
const tool = serverInfo.tools.find(
|
||||
(t: any) => t.name === fullToolName || t.name === toolName,
|
||||
);
|
||||
if (tool && tool.inputSchema) {
|
||||
inputSchema = tool.inputSchema as Record<string, any>;
|
||||
if (tool) {
|
||||
toolName = tool.name; // Use the matched tool's actual name (with server prefix if applicable) for the subsequent call to handleCallToolRequest.
|
||||
if (tool.inputSchema) {
|
||||
inputSchema = tool.inputSchema as Record<string, any>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare arguments from query params (GET) or body (POST)
|
||||
let args = req.method === 'GET' ? req.query : req.body || {};
|
||||
args = convertQueryParametersToTypes(args, inputSchema);
|
||||
args = convertParametersToTypes(args, inputSchema);
|
||||
|
||||
// Create a mock request structure that matches what handleCallToolRequest expects
|
||||
const mockRequest = {
|
||||
@@ -272,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;
|
||||
|
||||
@@ -7,7 +7,9 @@ import { handleGetPromptRequest } from '../services/mcpService.js';
|
||||
*/
|
||||
export const getPrompt = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName, promptName } = req.params;
|
||||
// Decode URL-encoded parameters to handle slashes in server/prompt names
|
||||
const serverName = decodeURIComponent(req.params.serverName);
|
||||
const promptName = decodeURIComponent(req.params.promptName);
|
||||
if (!serverName || !promptName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ApiResponse, AddServerRequest } from '../types/index.js';
|
||||
import { ApiResponse, AddServerRequest, McpSettings } from '../types/index.js';
|
||||
import {
|
||||
getServersInfo,
|
||||
addServer,
|
||||
@@ -8,10 +8,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 {
|
||||
@@ -30,15 +33,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',
|
||||
@@ -302,9 +335,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',
|
||||
@@ -312,18 +348,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',
|
||||
@@ -372,10 +416,38 @@ 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 {
|
||||
const { serverName, toolName } = req.params;
|
||||
// Decode URL-encoded parameters to handle slashes in server/tool names
|
||||
const serverName = decodeURIComponent(req.params.serverName);
|
||||
const toolName = decodeURIComponent(req.params.toolName);
|
||||
const { enabled } = req.body;
|
||||
|
||||
if (!serverName || !toolName) {
|
||||
@@ -394,8 +466,10 @@ export const toggleTool = async (req: Request, res: Response): Promise<void> =>
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = loadSettings();
|
||||
if (!settings.mcpServers[serverName]) {
|
||||
const serverDao = getServerDao();
|
||||
const server = await serverDao.findById(serverName);
|
||||
|
||||
if (!server) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Server not found',
|
||||
@@ -404,14 +478,15 @@ export const toggleTool = async (req: Request, res: Response): Promise<void> =>
|
||||
}
|
||||
|
||||
// Initialize tools config if it doesn't exist
|
||||
if (!settings.mcpServers[serverName].tools) {
|
||||
settings.mcpServers[serverName].tools = {};
|
||||
}
|
||||
const tools = server.tools || {};
|
||||
|
||||
// Set the tool's enabled state
|
||||
settings.mcpServers[serverName].tools![toolName] = { enabled };
|
||||
// Set the tool's enabled state (preserve existing description if any)
|
||||
tools[toolName] = { ...tools[toolName], enabled };
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
// Update via DAO (supports both file and database modes)
|
||||
const result = await serverDao.updateTools(serverName, tools);
|
||||
|
||||
if (!result) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to save settings',
|
||||
@@ -437,7 +512,9 @@ export const toggleTool = async (req: Request, res: Response): Promise<void> =>
|
||||
// Update tool description for a specific server
|
||||
export const updateToolDescription = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName, toolName } = req.params;
|
||||
// Decode URL-encoded parameters to handle slashes in server/tool names
|
||||
const serverName = decodeURIComponent(req.params.serverName);
|
||||
const toolName = decodeURIComponent(req.params.toolName);
|
||||
const { description } = req.body;
|
||||
|
||||
if (!serverName || !toolName) {
|
||||
@@ -456,8 +533,10 @@ export const updateToolDescription = async (req: Request, res: Response): Promis
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = loadSettings();
|
||||
if (!settings.mcpServers[serverName]) {
|
||||
const serverDao = getServerDao();
|
||||
const server = await serverDao.findById(serverName);
|
||||
|
||||
if (!server) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Server not found',
|
||||
@@ -466,18 +545,18 @@ export const updateToolDescription = async (req: Request, res: Response): Promis
|
||||
}
|
||||
|
||||
// Initialize tools config if it doesn't exist
|
||||
if (!settings.mcpServers[serverName].tools) {
|
||||
settings.mcpServers[serverName].tools = {};
|
||||
}
|
||||
const tools = server.tools || {};
|
||||
|
||||
// Set the tool's description
|
||||
if (!settings.mcpServers[serverName].tools![toolName]) {
|
||||
settings.mcpServers[serverName].tools![toolName] = { enabled: true };
|
||||
if (!tools[toolName]) {
|
||||
tools[toolName] = { enabled: true };
|
||||
}
|
||||
tools[toolName].description = description;
|
||||
|
||||
settings.mcpServers[serverName].tools![toolName].description = description;
|
||||
// Update via DAO (supports both file and database modes)
|
||||
const result = await serverDao.updateTools(serverName, tools);
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
if (!result) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to save settings',
|
||||
@@ -502,34 +581,73 @@ 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 } = req.body;
|
||||
const currentUser = (req as any).user;
|
||||
const {
|
||||
routing,
|
||||
install,
|
||||
smartRouting,
|
||||
mcpRouter,
|
||||
nameSeparator,
|
||||
enableSessionRebuild,
|
||||
oauthServer,
|
||||
} = req.body;
|
||||
|
||||
const hasRoutingUpdate =
|
||||
routing &&
|
||||
(typeof routing.enableGlobalRoute === 'boolean' ||
|
||||
typeof routing.enableGroupNameRoute === 'boolean' ||
|
||||
typeof routing.enableBearerAuth === 'boolean' ||
|
||||
typeof routing.bearerAuthKey === 'string' ||
|
||||
typeof routing.skipAuth === 'boolean');
|
||||
|
||||
const hasInstallUpdate =
|
||||
install &&
|
||||
(typeof install.pythonIndexUrl === 'string' ||
|
||||
typeof install.npmRegistry === 'string' ||
|
||||
typeof install.baseUrl === 'string');
|
||||
|
||||
const hasSmartRoutingUpdate =
|
||||
smartRouting &&
|
||||
(typeof smartRouting.enabled === 'boolean' ||
|
||||
typeof smartRouting.dbUrl === 'string' ||
|
||||
typeof smartRouting.openaiApiBaseUrl === 'string' ||
|
||||
typeof smartRouting.openaiApiKey === 'string' ||
|
||||
typeof smartRouting.openaiApiEmbeddingModel === 'string');
|
||||
|
||||
const hasMcpRouterUpdate =
|
||||
mcpRouter &&
|
||||
(typeof mcpRouter.apiKey === 'string' ||
|
||||
typeof mcpRouter.referer === 'string' ||
|
||||
typeof mcpRouter.title === 'string' ||
|
||||
typeof mcpRouter.baseUrl === 'string');
|
||||
|
||||
const hasNameSeparatorUpdate = typeof nameSeparator === 'string';
|
||||
|
||||
const hasSessionRebuildUpdate = typeof enableSessionRebuild === 'boolean';
|
||||
|
||||
const hasOAuthServerUpdate =
|
||||
oauthServer &&
|
||||
(typeof oauthServer.enabled === 'boolean' ||
|
||||
typeof oauthServer.accessTokenLifetime === 'number' ||
|
||||
typeof oauthServer.refreshTokenLifetime === 'number' ||
|
||||
typeof oauthServer.authorizationCodeLifetime === 'number' ||
|
||||
typeof oauthServer.requireClientSecret === 'boolean' ||
|
||||
typeof oauthServer.requireState === 'boolean' ||
|
||||
Array.isArray(oauthServer.allowedScopes) ||
|
||||
(oauthServer.dynamicRegistration &&
|
||||
(typeof oauthServer.dynamicRegistration.enabled === 'boolean' ||
|
||||
typeof oauthServer.dynamicRegistration.requiresAuthentication === 'boolean' ||
|
||||
Array.isArray(oauthServer.dynamicRegistration.allowedGrantTypes))));
|
||||
|
||||
if (
|
||||
(!routing ||
|
||||
(typeof routing.enableGlobalRoute !== 'boolean' &&
|
||||
typeof routing.enableGroupNameRoute !== 'boolean' &&
|
||||
typeof routing.enableBearerAuth !== 'boolean' &&
|
||||
typeof routing.bearerAuthKey !== 'string' &&
|
||||
typeof routing.skipAuth !== 'boolean')) &&
|
||||
(!install ||
|
||||
(typeof install.pythonIndexUrl !== 'string' &&
|
||||
typeof install.npmRegistry !== 'string' &&
|
||||
typeof install.baseUrl !== 'string')) &&
|
||||
(!smartRouting ||
|
||||
(typeof smartRouting.enabled !== 'boolean' &&
|
||||
typeof smartRouting.dbUrl !== 'string' &&
|
||||
typeof smartRouting.openaiApiBaseUrl !== 'string' &&
|
||||
typeof smartRouting.openaiApiKey !== 'string' &&
|
||||
typeof smartRouting.openaiApiEmbeddingModel !== 'string')) &&
|
||||
(!mcpRouter ||
|
||||
(typeof mcpRouter.apiKey !== 'string' &&
|
||||
typeof mcpRouter.referer !== 'string' &&
|
||||
typeof mcpRouter.title !== 'string' &&
|
||||
typeof mcpRouter.baseUrl !== 'string')) &&
|
||||
(typeof nameSeparator !== 'string')
|
||||
!hasRoutingUpdate &&
|
||||
!hasInstallUpdate &&
|
||||
!hasSmartRoutingUpdate &&
|
||||
!hasMcpRouterUpdate &&
|
||||
!hasNameSeparatorUpdate &&
|
||||
!hasSessionRebuildUpdate &&
|
||||
!hasOAuthServerUpdate
|
||||
) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -538,9 +656,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,
|
||||
@@ -566,11 +687,12 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
title: 'MCPHub',
|
||||
baseUrl: 'https://api.mcprouter.to/v1',
|
||||
},
|
||||
oauthServer: cloneDefaultOAuthServerConfig(),
|
||||
};
|
||||
}
|
||||
|
||||
if (!settings.systemConfig.routing) {
|
||||
settings.systemConfig.routing = {
|
||||
if (!systemConfig.routing) {
|
||||
systemConfig.routing = {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
@@ -579,16 +701,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: '',
|
||||
@@ -597,8 +719,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',
|
||||
@@ -606,52 +728,74 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
};
|
||||
}
|
||||
|
||||
if (!systemConfig.oauthServer) {
|
||||
systemConfig.oauthServer = cloneDefaultOAuthServerConfig();
|
||||
}
|
||||
|
||||
if (!systemConfig.oauthServer.dynamicRegistration) {
|
||||
const defaultConfig = cloneDefaultOAuthServerConfig();
|
||||
const defaultDynamic = defaultConfig.dynamicRegistration ?? {
|
||||
enabled: false,
|
||||
allowedGrantTypes: [],
|
||||
requiresAuthentication: false,
|
||||
};
|
||||
systemConfig.oauthServer.dynamicRegistration = {
|
||||
enabled: defaultDynamic.enabled ?? false,
|
||||
allowedGrantTypes: [
|
||||
...(Array.isArray(defaultDynamic.allowedGrantTypes)
|
||||
? defaultDynamic.allowedGrantTypes
|
||||
: []),
|
||||
],
|
||||
requiresAuthentication: defaultDynamic.requiresAuthentication ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
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 = [];
|
||||
@@ -665,32 +809,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);
|
||||
@@ -698,27 +840,87 @@ 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 = systemConfig.oauthServer;
|
||||
if (typeof oauthServer.enabled === 'boolean') {
|
||||
target.enabled = oauthServer.enabled;
|
||||
}
|
||||
if (typeof oauthServer.accessTokenLifetime === 'number') {
|
||||
target.accessTokenLifetime = oauthServer.accessTokenLifetime;
|
||||
}
|
||||
if (typeof oauthServer.refreshTokenLifetime === 'number') {
|
||||
target.refreshTokenLifetime = oauthServer.refreshTokenLifetime;
|
||||
}
|
||||
if (typeof oauthServer.authorizationCodeLifetime === 'number') {
|
||||
target.authorizationCodeLifetime = oauthServer.authorizationCodeLifetime;
|
||||
}
|
||||
if (typeof oauthServer.requireClientSecret === 'boolean') {
|
||||
target.requireClientSecret = oauthServer.requireClientSecret;
|
||||
}
|
||||
if (typeof oauthServer.requireState === 'boolean') {
|
||||
target.requireState = oauthServer.requireState;
|
||||
}
|
||||
if (Array.isArray(oauthServer.allowedScopes)) {
|
||||
target.allowedScopes = oauthServer.allowedScopes
|
||||
.filter((scope: any): scope is string => typeof scope === 'string')
|
||||
.map((scope: string) => scope.trim())
|
||||
.filter((scope: string) => scope.length > 0);
|
||||
}
|
||||
|
||||
if (oauthServer.dynamicRegistration) {
|
||||
const dynamicTarget = target.dynamicRegistration || {
|
||||
enabled: false,
|
||||
allowedGrantTypes: ['authorization_code', 'refresh_token'],
|
||||
requiresAuthentication: false,
|
||||
};
|
||||
|
||||
if (typeof oauthServer.dynamicRegistration.enabled === 'boolean') {
|
||||
dynamicTarget.enabled = oauthServer.dynamicRegistration.enabled;
|
||||
}
|
||||
|
||||
if (Array.isArray(oauthServer.dynamicRegistration.allowedGrantTypes)) {
|
||||
dynamicTarget.allowedGrantTypes = oauthServer.dynamicRegistration.allowedGrantTypes
|
||||
.filter((grant: any): grant is string => typeof grant === 'string')
|
||||
.map((grant: string) => grant.trim())
|
||||
.filter((grant: string) => grant.length > 0);
|
||||
}
|
||||
|
||||
if (typeof oauthServer.dynamicRegistration.requiresAuthentication === 'boolean') {
|
||||
dynamicTarget.requiresAuthentication =
|
||||
oauthServer.dynamicRegistration.requiresAuthentication;
|
||||
}
|
||||
|
||||
target.dynamicRegistration = dynamicTarget;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof nameSeparator === 'string') {
|
||||
settings.systemConfig.nameSeparator = nameSeparator;
|
||||
systemConfig.nameSeparator = nameSeparator;
|
||||
}
|
||||
|
||||
if (saveSettings(settings, currentUser)) {
|
||||
if (typeof enableSessionRebuild === 'boolean') {
|
||||
systemConfig.enableSessionRebuild = enableSessionRebuild;
|
||||
}
|
||||
|
||||
// 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',
|
||||
});
|
||||
|
||||
@@ -730,7 +932,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',
|
||||
@@ -747,7 +950,9 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
// Toggle prompt status for a specific server
|
||||
export const togglePrompt = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName, promptName } = req.params;
|
||||
// Decode URL-encoded parameters to handle slashes in server/prompt names
|
||||
const serverName = decodeURIComponent(req.params.serverName);
|
||||
const promptName = decodeURIComponent(req.params.promptName);
|
||||
const { enabled } = req.body;
|
||||
|
||||
if (!serverName || !promptName) {
|
||||
@@ -766,8 +971,10 @@ export const togglePrompt = async (req: Request, res: Response): Promise<void> =
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = loadSettings();
|
||||
if (!settings.mcpServers[serverName]) {
|
||||
const serverDao = getServerDao();
|
||||
const server = await serverDao.findById(serverName);
|
||||
|
||||
if (!server) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Server not found',
|
||||
@@ -776,14 +983,15 @@ export const togglePrompt = async (req: Request, res: Response): Promise<void> =
|
||||
}
|
||||
|
||||
// Initialize prompts config if it doesn't exist
|
||||
if (!settings.mcpServers[serverName].prompts) {
|
||||
settings.mcpServers[serverName].prompts = {};
|
||||
}
|
||||
const prompts = server.prompts || {};
|
||||
|
||||
// Set the prompt's enabled state
|
||||
settings.mcpServers[serverName].prompts![promptName] = { enabled };
|
||||
// Set the prompt's enabled state (preserve existing description if any)
|
||||
prompts[promptName] = { ...prompts[promptName], enabled };
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
// Update via DAO (supports both file and database modes)
|
||||
const result = await serverDao.updatePrompts(serverName, prompts);
|
||||
|
||||
if (!result) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to save settings',
|
||||
@@ -809,7 +1017,9 @@ export const togglePrompt = async (req: Request, res: Response): Promise<void> =
|
||||
// Update prompt description for a specific server
|
||||
export const updatePromptDescription = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName, promptName } = req.params;
|
||||
// Decode URL-encoded parameters to handle slashes in server/prompt names
|
||||
const serverName = decodeURIComponent(req.params.serverName);
|
||||
const promptName = decodeURIComponent(req.params.promptName);
|
||||
const { description } = req.body;
|
||||
|
||||
if (!serverName || !promptName) {
|
||||
@@ -828,8 +1038,10 @@ export const updatePromptDescription = async (req: Request, res: Response): Prom
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = loadSettings();
|
||||
if (!settings.mcpServers[serverName]) {
|
||||
const serverDao = getServerDao();
|
||||
const server = await serverDao.findById(serverName);
|
||||
|
||||
if (!server) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Server not found',
|
||||
@@ -838,18 +1050,18 @@ export const updatePromptDescription = async (req: Request, res: Response): Prom
|
||||
}
|
||||
|
||||
// Initialize prompts config if it doesn't exist
|
||||
if (!settings.mcpServers[serverName].prompts) {
|
||||
settings.mcpServers[serverName].prompts = {};
|
||||
}
|
||||
const prompts = server.prompts || {};
|
||||
|
||||
// Set the prompt's description
|
||||
if (!settings.mcpServers[serverName].prompts![promptName]) {
|
||||
settings.mcpServers[serverName].prompts![promptName] = { enabled: true };
|
||||
if (!prompts[promptName]) {
|
||||
prompts[promptName] = { enabled: true };
|
||||
}
|
||||
prompts[promptName].description = description;
|
||||
|
||||
settings.mcpServers[serverName].prompts![promptName].description = description;
|
||||
// Update via DAO (supports both file and database modes)
|
||||
const result = await serverDao.updatePrompts(serverName, prompts);
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
if (!result) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to save settings',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
import { handleCallToolRequest } from '../services/mcpService.js';
|
||||
import { handleCallToolRequest, getServerByName } from '../services/mcpService.js';
|
||||
import { convertParametersToTypes } from '../utils/parameterConversion.js';
|
||||
import { getNameSeparator } from '../config/index.js';
|
||||
|
||||
/**
|
||||
* Interface for tool call request
|
||||
@@ -47,13 +49,31 @@ export const callTool = async (req: Request, res: Response): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the server info to access the tool's input schema
|
||||
const serverInfo = getServerByName(server);
|
||||
let inputSchema: Record<string, any> = {};
|
||||
|
||||
if (serverInfo) {
|
||||
// Find the tool in the server's tools list
|
||||
const fullToolName = `${server}${getNameSeparator()}${toolName}`;
|
||||
const tool = serverInfo.tools.find(
|
||||
(t: any) => t.name === fullToolName || t.name === toolName,
|
||||
);
|
||||
if (tool && tool.inputSchema) {
|
||||
inputSchema = tool.inputSchema as Record<string, any>;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert parameters to proper types based on the tool's input schema
|
||||
const convertedArgs = convertParametersToTypes(toolArgs, inputSchema);
|
||||
|
||||
// Create a mock request structure for handleCallToolRequest
|
||||
const mockRequest = {
|
||||
params: {
|
||||
name: 'call_tool',
|
||||
arguments: {
|
||||
toolName,
|
||||
arguments: toolArgs,
|
||||
arguments: convertedArgs,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -71,7 +91,7 @@ export const callTool = async (req: Request, res: Response): Promise<void> => {
|
||||
data: {
|
||||
content: result.content || [],
|
||||
toolName,
|
||||
arguments: toolArgs,
|
||||
arguments: convertedArgs,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -9,12 +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;
|
||||
}
|
||||
|
||||
@@ -30,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,
|
||||
@@ -49,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;
|
||||
@@ -62,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,
|
||||
@@ -87,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;
|
||||
@@ -100,6 +102,17 @@ export const createUser = async (req: Request, res: Response): Promise<void> =>
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate password strength
|
||||
const validationResult = validatePasswordStrength(password);
|
||||
if (!validationResult.isValid) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Password does not meet security requirements',
|
||||
errors: validationResult.errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const newUser = await createNewUser(username, password, isAdmin || false);
|
||||
if (!newUser) {
|
||||
res.status(400).json({
|
||||
@@ -126,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;
|
||||
@@ -142,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,
|
||||
@@ -152,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',
|
||||
@@ -163,7 +176,19 @@ export const updateExistingUser = async (req: Request, res: Response): Promise<v
|
||||
|
||||
const updateData: any = {};
|
||||
if (isAdmin !== undefined) updateData.isAdmin = isAdmin;
|
||||
if (newPassword) updateData.newPassword = newPassword;
|
||||
if (newPassword) {
|
||||
// Validate new password strength
|
||||
const validationResult = validatePasswordStrength(newPassword);
|
||||
if (!validationResult.isValid) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Password does not meet security requirements',
|
||||
errors: validationResult.errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateData.newPassword = newPassword;
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
res.status(400).json({
|
||||
@@ -198,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;
|
||||
@@ -221,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,
|
||||
@@ -243,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;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user