mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 02:39:19 -05:00
Compare commits
10 Commits
v0.10.5
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20a1d54f8a | ||
|
|
1c42b6802b | ||
|
|
f9a12b8ed1 | ||
|
|
d5cb4c7cc2 | ||
|
|
063b081297 | ||
|
|
73ae33e777 | ||
|
|
dac0d376e8 | ||
|
|
803e35b14c | ||
|
|
a736398cd5 | ||
|
|
6de3221974 |
@@ -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
|
||||
|
||||
30
README.fr.md
30
README.fr.md
@@ -57,6 +57,36 @@ Créez un fichier `mcp_settings.json` pour personnaliser les paramètres de votr
|
||||
}
|
||||
```
|
||||
|
||||
### Mode Base de données (NOUVEAU)
|
||||
|
||||
MCPHub prend en charge le stockage de la configuration dans une base de données PostgreSQL comme alternative au fichier `mcp_settings.json`. Le mode base de données offre une persistance et une évolutivité améliorées pour les environnements de production et les déploiements d'entreprise.
|
||||
|
||||
**Avantages principaux :**
|
||||
|
||||
- ✅ **Meilleure persistance** - Configuration stockée dans une base de données professionnelle avec support des transactions et intégrité des données
|
||||
- ✅ **Haute disponibilité** - Profitez des capacités de réplication et de basculement de la base de données
|
||||
- ✅ **Prêt pour l'entreprise** - Répond aux exigences de gestion des données et de conformité d'entreprise
|
||||
- ✅ **Sauvegarde et récupération** - Utilisez des outils et stratégies de sauvegarde de base de données matures
|
||||
|
||||
**Variables d'environnement :**
|
||||
|
||||
```bash
|
||||
# Définissez simplement DB_URL pour activer automatiquement le mode base de données
|
||||
DB_URL=postgresql://user:password@host:5432/mcphub
|
||||
|
||||
# Ou contrôlez explicitement avec USE_DB (optionnel, remplace la détection automatique)
|
||||
# USE_DB=true
|
||||
```
|
||||
|
||||
> **Note** : Vous n'avez qu'à définir `DB_URL` pour activer le mode base de données. MCPHub détectera automatiquement et activera le mode base de données lorsque `DB_URL` est présent. Utilisez `USE_DB=false` pour désactiver explicitement le mode base de données même lorsque `DB_URL` est défini.
|
||||
|
||||
📖 Consultez le [Guide de configuration de la base de données](docs/configuration/database-configuration.mdx) complet pour :
|
||||
|
||||
- Instructions de configuration détaillées
|
||||
- Migration depuis la configuration basée sur fichiers
|
||||
- Procédures de sauvegarde et de restauration
|
||||
- Conseils de dépannage
|
||||
|
||||
### Déploiement avec Docker
|
||||
|
||||
**Recommandé** : Montez votre configuration personnalisée :
|
||||
|
||||
34
README.md
34
README.md
@@ -19,7 +19,7 @@ MCPHub makes it easy to manage and scale multiple MCP (Model Context Protocol) s
|
||||
- **Hot-Swappable Configuration**: Add, remove, or update MCP servers on the fly — no downtime required.
|
||||
- **Group-Based Access Control**: Organize servers into customizable groups for streamlined permissions management.
|
||||
- **Secure Authentication**: Built-in user management with role-based access powered by JWT and bcrypt.
|
||||
- **OAuth 2.0 Support**:
|
||||
- **OAuth 2.0 Support**:
|
||||
- Full OAuth support for upstream MCP servers with proxy authorization capabilities
|
||||
- **NEW**: Act as OAuth 2.0 authorization server for external clients (ChatGPT Web, custom apps)
|
||||
- **Environment Variable Expansion**: Use environment variables anywhere in your configuration for secure credential management. See [Environment Variables Guide](docs/environment-variables.md).
|
||||
@@ -129,6 +129,7 @@ MCPHub can now act as an OAuth 2.0 authorization server, allowing external appli
|
||||
```
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- Standard OAuth 2.0 authorization code flow
|
||||
- PKCE support for enhanced security
|
||||
- Token refresh capabilities
|
||||
@@ -136,6 +137,36 @@ MCPHub can now act as an OAuth 2.0 authorization server, allowing external appli
|
||||
|
||||
For detailed setup instructions, see the [OAuth Server Documentation](docs/oauth-server.md).
|
||||
|
||||
### Database Mode (NEW)
|
||||
|
||||
MCPHub supports storing configuration in a PostgreSQL database as an alternative to `mcp_settings.json`. Database mode provides enhanced persistence and scalability for production environments and enterprise deployments.
|
||||
|
||||
**Core Benefits:**
|
||||
|
||||
- ✅ **Better Persistence** - Configuration stored in a professional database with transaction support and data integrity
|
||||
- ✅ **High Availability** - Leverage database replication and failover capabilities
|
||||
- ✅ **Enterprise Ready** - Meets enterprise data management and compliance requirements
|
||||
- ✅ **Backup & Recovery** - Use mature database backup tools and strategies
|
||||
|
||||
**Environment Variables:**
|
||||
|
||||
```bash
|
||||
# Simply set DB_URL to enable database mode (auto-detected)
|
||||
DB_URL=postgresql://user:password@host:5432/mcphub
|
||||
|
||||
# Or explicitly control with USE_DB (optional, overrides auto-detection)
|
||||
# USE_DB=true
|
||||
```
|
||||
|
||||
> **Note**: You only need to set `DB_URL` to enable database mode. MCPHub will automatically detect and enable database mode when `DB_URL` is present. Use `USE_DB=false` to explicitly disable database mode even when `DB_URL` is set.
|
||||
|
||||
📖 See the complete [Database Configuration Guide](docs/configuration/database-configuration.mdx) for:
|
||||
|
||||
- Detailed setup instructions
|
||||
- Migration from file-based config
|
||||
- Backup and restore procedures
|
||||
- Troubleshooting tips
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
**Recommended**: Mount your custom config:
|
||||
@@ -223,6 +254,7 @@ http://localhost:3000/mcp/$smart/development
|
||||
```
|
||||
|
||||
This enables:
|
||||
|
||||
- **Focused Discovery**: Find tools only from relevant servers
|
||||
- **Environment Isolation**: Separate tool discovery by environment (dev, staging, prod)
|
||||
- **Team-Based Access**: Limit tool search to team-specific server groups
|
||||
|
||||
31
README.zh.md
31
README.zh.md
@@ -96,6 +96,36 @@ MCPHub 支持通过 OAuth 2.0 访问上游 MCP 服务器。完整说明请参阅
|
||||
|
||||
对于需要手动注册的提供商,请先在上游控制台创建 OAuth 应用,将回调地址设置为 `http://localhost:3000/oauth/callback`(或你的部署域名),然后在控制台或配置文件中填写凭据。
|
||||
|
||||
### 数据库模式(新功能)
|
||||
|
||||
MCPHub 支持将配置数据存储在 PostgreSQL 数据库中,作为 `mcp_settings.json` 文件配置的替代方案。数据库模式为生产环境和企业级部署提供了更强大的持久化和扩展能力。
|
||||
|
||||
**核心优势:**
|
||||
|
||||
- ✅ **更好的持久化** - 配置数据存储在专业数据库中,支持事务和数据完整性
|
||||
- ✅ **高可用性** - 利用数据库复制和故障转移能力
|
||||
- ✅ **企业级支持** - 符合企业数据管理和合规要求
|
||||
- ✅ **备份恢复** - 使用成熟的数据库备份工具和策略
|
||||
|
||||
**环境变量:**
|
||||
|
||||
```bash
|
||||
# 只需设置 DB_URL 即可自动启用数据库模式
|
||||
DB_URL=postgresql://user:password@host:5432/mcphub
|
||||
|
||||
# 或显式控制 USE_DB(可选,覆盖自动检测)
|
||||
# USE_DB=true
|
||||
```
|
||||
|
||||
> **提示**:您只需设置 `DB_URL` 即可启用数据库模式。MCPHub 会自动检测 `DB_URL` 是否存在并启用数据库模式。如果需要在设置了 `DB_URL` 的情况下禁用数据库模式,可以显式设置 `USE_DB=false`。
|
||||
|
||||
📖 查看完整的[数据库配置指南](docs/zh/configuration/database-configuration.mdx)了解:
|
||||
|
||||
- 详细的设置说明
|
||||
- 从文件配置迁移
|
||||
- 备份和恢复流程
|
||||
- 故障排除技巧
|
||||
|
||||
### Docker 部署
|
||||
|
||||
**推荐**:挂载自定义配置:
|
||||
@@ -183,6 +213,7 @@ http://localhost:3000/mcp/$smart/development
|
||||
```
|
||||
|
||||
这样可以实现:
|
||||
|
||||
- **精准发现**:仅从相关服务器查找工具
|
||||
- **环境隔离**:按环境(开发、测试、生产)分离工具发现
|
||||
- **基于团队的访问**:将工具搜索限制在特定团队的服务器分组
|
||||
|
||||
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
|
||||
328
docs/configuration/database-configuration.mdx
Normal file
328
docs/configuration/database-configuration.mdx
Normal file
@@ -0,0 +1,328 @@
|
||||
# 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
|
||||
|
||||
# Alternative: Use separate components
|
||||
# DB_HOST=localhost
|
||||
# DB_PORT=5432
|
||||
# DB_NAME=mcphub
|
||||
# DB_USER=user
|
||||
# DB_PASSWORD=password
|
||||
```
|
||||
|
||||
<Note>
|
||||
**Simplified Configuration**: You only need to set `DB_URL` to enable database mode. MCPHub will automatically detect and enable database mode when `DB_URL` is present. Use `USE_DB=false` to explicitly disable database mode even when `DB_URL` is set.
|
||||
</Note>
|
||||
|
||||
### Optional Settings
|
||||
|
||||
```bash
|
||||
# Automatic migration on startup (default: true)
|
||||
AUTO_MIGRATE=true
|
||||
|
||||
# Keep file-based config as fallback (default: false)
|
||||
KEEP_FILE_CONFIG=false
|
||||
```
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Using Docker
|
||||
|
||||
#### Option A: Using PostgreSQL as a separate service
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
#### Option B: Using External Database
|
||||
|
||||
If you already have a PostgreSQL database:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 3000:3000 \
|
||||
-e USE_DB=true \
|
||||
-e DB_URL="postgresql://user:password@your-db-host:5432/mcphub" \
|
||||
samanhappy/mcphub:latest
|
||||
```
|
||||
|
||||
### 2. Manual Setup
|
||||
|
||||
#### Step 1: Setup PostgreSQL Database
|
||||
|
||||
```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 the environment variable
|
||||
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 |
|
||||
| `DB_HOST` | No | `localhost` | Database host (if not using DB_URL) |
|
||||
| `DB_PORT` | No | `5432` | Database port (if not using DB_URL) |
|
||||
| `DB_NAME` | No | `mcphub` | Database name (if not using DB_URL) |
|
||||
| `DB_USER` | No | `mcphub` | Database user (if not using DB_URL) |
|
||||
| `DB_PASSWORD` | No | - | Database password (if not using DB_URL) |
|
||||
| `AUTO_MIGRATE` | No | `true` | Auto-migrate from file on first start |
|
||||
| `MCPHUB_SETTING_PATH` | No | - | Path to mcp_settings.json (for migration) |
|
||||
|
||||
*Required for database mode. Simply setting `DB_URL` enables database mode automatically
|
||||
|
||||
## 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
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
- GitHub Issues: https://github.com/samanhappy/mcphub/issues
|
||||
- Documentation: https://mcphub.io/docs
|
||||
@@ -37,7 +37,8 @@
|
||||
"configuration/mcp-settings",
|
||||
"configuration/environment-variables",
|
||||
"configuration/docker-setup",
|
||||
"configuration/nginx"
|
||||
"configuration/nginx",
|
||||
"configuration/database-configuration"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -68,7 +69,8 @@
|
||||
"zh/configuration/mcp-settings",
|
||||
"zh/configuration/environment-variables",
|
||||
"zh/configuration/docker-setup",
|
||||
"zh/configuration/nginx"
|
||||
"zh/configuration/nginx",
|
||||
"zh/configuration/database-configuration"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -169,4 +171,4 @@
|
||||
"discord": "https://discord.gg/qMKNsn5Q"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
328
docs/zh/configuration/database-configuration.mdx
Normal file
328
docs/zh/configuration/database-configuration.mdx
Normal file
@@ -0,0 +1,328 @@
|
||||
# MCPHub 数据库配置
|
||||
|
||||
## 概述
|
||||
|
||||
MCPHub 支持将配置数据存储在 PostgreSQL 数据库中,作为 `mcp_settings.json` 文件配置的替代方案。数据库模式为生产环境和企业级部署提供了更强大的持久化和扩展能力。
|
||||
|
||||
## 为什么使用数据库配置?
|
||||
|
||||
**核心优势:**
|
||||
- ✅ **更好的持久化** - 配置数据存储在专业数据库中,支持事务和数据完整性
|
||||
- ✅ **高可用性** - 利用数据库复制和故障转移能力
|
||||
- ✅ **企业级支持** - 符合企业数据管理和合规要求
|
||||
- ✅ **备份恢复** - 使用成熟的数据库备份工具和策略
|
||||
|
||||
## 环境变量
|
||||
|
||||
### 数据库模式必需变量
|
||||
|
||||
```bash
|
||||
# 数据库连接 URL(PostgreSQL)
|
||||
# 只需设置 DB_URL 即可自动启用数据库模式
|
||||
DB_URL=postgresql://user:password@localhost:5432/mcphub
|
||||
|
||||
# 或显式控制 USE_DB(覆盖自动检测)
|
||||
# USE_DB=true
|
||||
|
||||
# 替代方案:使用单独的配置项
|
||||
# DB_HOST=localhost
|
||||
# DB_PORT=5432
|
||||
# DB_NAME=mcphub
|
||||
# DB_USER=user
|
||||
# DB_PASSWORD=password
|
||||
```
|
||||
|
||||
<Note>
|
||||
**简化配置**:您只需设置 `DB_URL` 即可启用数据库模式。MCPHub 会自动检测 `DB_URL` 是否存在并启用数据库模式。如果需要在设置了 `DB_URL` 的情况下禁用数据库模式,可以显式设置 `USE_DB=false`。
|
||||
</Note>
|
||||
|
||||
### 可选设置
|
||||
|
||||
```bash
|
||||
# 启动时自动迁移(默认:true)
|
||||
AUTO_MIGRATE=true
|
||||
|
||||
# 保留基于文件的配置作为后备(默认:false)
|
||||
KEEP_FILE_CONFIG=false
|
||||
```
|
||||
|
||||
## 设置说明
|
||||
|
||||
### 1. 使用 Docker
|
||||
|
||||
#### 方案 A:将 PostgreSQL 作为独立服务
|
||||
|
||||
创建 `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
|
||||
```
|
||||
|
||||
#### 方案 B:使用外部数据库
|
||||
|
||||
如果您已有 PostgreSQL 数据库:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 3000:3000 \
|
||||
-e USE_DB=true \
|
||||
-e DB_URL="postgresql://user:password@your-db-host:5432/mcphub" \
|
||||
samanhappy/mcphub:latest
|
||||
```
|
||||
|
||||
### 2. 手动设置
|
||||
|
||||
#### 步骤 1:设置 PostgreSQL 数据库
|
||||
|
||||
```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` 或删除该环境变量
|
||||
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` 是否存在自动检测 |
|
||||
| `DB_HOST` | 否 | `localhost` | 数据库主机(如果不使用 DB_URL) |
|
||||
| `DB_PORT` | 否 | `5432` | 数据库端口(如果不使用 DB_URL) |
|
||||
| `DB_NAME` | 否 | `mcphub` | 数据库名称(如果不使用 DB_URL) |
|
||||
| `DB_USER` | 否 | `mcphub` | 数据库用户(如果不使用 DB_URL) |
|
||||
| `DB_PASSWORD` | 否 | - | 数据库密码(如果不使用 DB_URL) |
|
||||
| `AUTO_MIGRATE` | 否 | `true` | 首次启动时自动从文件迁移 |
|
||||
| `MCPHUB_SETTING_PATH` | 否 | - | mcp_settings.json 的路径(用于迁移) |
|
||||
|
||||
*数据库模式必需。只需设置 `DB_URL` 即可自动启用数据库模式
|
||||
|
||||
## 安全注意事项
|
||||
|
||||
1. **数据库凭据:** 安全存储数据库凭据,使用环境变量或密钥管理
|
||||
2. **网络访问:** 仅限 MCPHub 实例访问数据库
|
||||
3. **加密:** 在生产环境中使用 SSL/TLS 进行数据库连接:
|
||||
```bash
|
||||
DB_URL=postgresql://user:password@host:5432/mcphub?sslmode=require
|
||||
```
|
||||
4. **备份:** 定期备份您的数据库
|
||||
5. **访问控制:** 使用强密码并限制用户权限
|
||||
|
||||
## 性能
|
||||
|
||||
数据库模式在以下场景提供更好的性能:
|
||||
- 多个并发用户
|
||||
- 频繁的配置更改
|
||||
- 大量服务器/分组
|
||||
|
||||
文件模式可能更快的场景:
|
||||
- 单用户设置
|
||||
- 读取密集型工作负载且更改不频繁
|
||||
- 开发/测试环境
|
||||
|
||||
## 支持
|
||||
|
||||
如有问题或疑问:
|
||||
- GitHub Issues: https://github.com/samanhappy/mcphub/issues
|
||||
- 文档: https://mcphub.io/docs
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
export const PERMISSIONS = {
|
||||
// Settings page permissions
|
||||
SETTINGS_SMART_ROUTING: 'settings:smart_routing',
|
||||
SETTINGS_SKIP_AUTH: 'settings:skip_auth',
|
||||
SETTINGS_ROUTE_CONFIG: 'settings:route_config',
|
||||
SETTINGS_INSTALL_CONFIG: 'settings:install_config',
|
||||
SETTINGS_SYSTEM_CONFIG: 'settings:system_config',
|
||||
SETTINGS_OAUTH_SERVER: 'settings:oauth_server',
|
||||
SETTINGS_EXPORT_CONFIG: 'settings:export_config',
|
||||
} as const;
|
||||
|
||||
@@ -283,31 +283,29 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
const handleServerEdit = useCallback(
|
||||
async (server: Server) => {
|
||||
try {
|
||||
// Fetch settings to get the full server config before editing
|
||||
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> =
|
||||
await apiGet('/settings');
|
||||
// Fetch single server config instead of all settings
|
||||
const encodedServerName = encodeURIComponent(server.name);
|
||||
const serverData: ApiResponse<{
|
||||
name: string;
|
||||
status: string;
|
||||
tools: any[];
|
||||
config: Record<string, any>;
|
||||
}> = await apiGet(`/servers/${encodedServerName}`);
|
||||
|
||||
if (
|
||||
settingsData &&
|
||||
settingsData.success &&
|
||||
settingsData.data &&
|
||||
settingsData.data.mcpServers &&
|
||||
settingsData.data.mcpServers[server.name]
|
||||
) {
|
||||
const serverConfig = settingsData.data.mcpServers[server.name];
|
||||
if (serverData && serverData.success && serverData.data) {
|
||||
return {
|
||||
name: server.name,
|
||||
status: server.status,
|
||||
tools: server.tools || [],
|
||||
config: serverConfig,
|
||||
name: serverData.data.name,
|
||||
status: serverData.data.status,
|
||||
tools: serverData.data.tools || [],
|
||||
config: serverData.data.config,
|
||||
};
|
||||
} else {
|
||||
console.error('Failed to get server config from settings:', settingsData);
|
||||
console.error('Failed to get server config:', serverData);
|
||||
setError(t('server.invalidConfig', { serverName: server.name }));
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching server settings:', err);
|
||||
console.error('Error fetching server config:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,69 +1,69 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import ChangePasswordForm from '@/components/ChangePasswordForm'
|
||||
import { Switch } from '@/components/ui/ToggleGroup'
|
||||
import { useSettingsData } from '@/hooks/useSettingsData'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
import { generateRandomKey } from '@/utils/key'
|
||||
import { PermissionChecker } from '@/components/PermissionChecker'
|
||||
import { PERMISSIONS } from '@/constants/permissions'
|
||||
import { Copy, Check, Download } from 'lucide-react'
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ChangePasswordForm from '@/components/ChangePasswordForm';
|
||||
import { Switch } from '@/components/ui/ToggleGroup';
|
||||
import { useSettingsData } from '@/hooks/useSettingsData';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import { generateRandomKey } from '@/utils/key';
|
||||
import { PermissionChecker } from '@/components/PermissionChecker';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { Copy, Check, Download } from 'lucide-react';
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [installConfig, setInstallConfig] = useState<{
|
||||
pythonIndexUrl: string
|
||||
npmRegistry: string
|
||||
baseUrl: string
|
||||
pythonIndexUrl: string;
|
||||
npmRegistry: string;
|
||||
baseUrl: string;
|
||||
}>({
|
||||
pythonIndexUrl: '',
|
||||
npmRegistry: '',
|
||||
baseUrl: 'http://localhost:3000',
|
||||
})
|
||||
});
|
||||
|
||||
const [tempSmartRoutingConfig, setTempSmartRoutingConfig] = useState<{
|
||||
dbUrl: string
|
||||
openaiApiBaseUrl: string
|
||||
openaiApiKey: string
|
||||
openaiApiEmbeddingModel: string
|
||||
dbUrl: string;
|
||||
openaiApiBaseUrl: string;
|
||||
openaiApiKey: string;
|
||||
openaiApiEmbeddingModel: string;
|
||||
}>({
|
||||
dbUrl: '',
|
||||
openaiApiBaseUrl: '',
|
||||
openaiApiKey: '',
|
||||
openaiApiEmbeddingModel: '',
|
||||
})
|
||||
});
|
||||
|
||||
const [tempMCPRouterConfig, setTempMCPRouterConfig] = useState<{
|
||||
apiKey: string
|
||||
referer: string
|
||||
title: string
|
||||
baseUrl: string
|
||||
apiKey: string;
|
||||
referer: string;
|
||||
title: string;
|
||||
baseUrl: string;
|
||||
}>({
|
||||
apiKey: '',
|
||||
referer: 'https://www.mcphubx.com',
|
||||
title: 'MCPHub',
|
||||
baseUrl: 'https://api.mcprouter.to/v1',
|
||||
})
|
||||
});
|
||||
|
||||
const [tempOAuthServerConfig, setTempOAuthServerConfig] = useState<{
|
||||
accessTokenLifetime: string
|
||||
refreshTokenLifetime: string
|
||||
authorizationCodeLifetime: string
|
||||
allowedScopes: string
|
||||
dynamicRegistrationAllowedGrantTypes: string
|
||||
accessTokenLifetime: string;
|
||||
refreshTokenLifetime: string;
|
||||
authorizationCodeLifetime: string;
|
||||
allowedScopes: string;
|
||||
dynamicRegistrationAllowedGrantTypes: string;
|
||||
}>({
|
||||
accessTokenLifetime: '3600',
|
||||
refreshTokenLifetime: '1209600',
|
||||
authorizationCodeLifetime: '300',
|
||||
allowedScopes: 'read, write',
|
||||
dynamicRegistrationAllowedGrantTypes: 'authorization_code, refresh_token',
|
||||
})
|
||||
});
|
||||
|
||||
const [tempNameSeparator, setTempNameSeparator] = useState<string>('-')
|
||||
const [tempNameSeparator, setTempNameSeparator] = useState<string>('-');
|
||||
|
||||
const {
|
||||
routingConfig,
|
||||
@@ -86,14 +86,14 @@ const SettingsPage: React.FC = () => {
|
||||
updateNameSeparator,
|
||||
updateSessionRebuild,
|
||||
exportMCPSettings,
|
||||
} = useSettingsData()
|
||||
} = useSettingsData();
|
||||
|
||||
// Update local installConfig when savedInstallConfig changes
|
||||
useEffect(() => {
|
||||
if (savedInstallConfig) {
|
||||
setInstallConfig(savedInstallConfig)
|
||||
setInstallConfig(savedInstallConfig);
|
||||
}
|
||||
}, [savedInstallConfig])
|
||||
}, [savedInstallConfig]);
|
||||
|
||||
// Update local tempSmartRoutingConfig when smartRoutingConfig changes
|
||||
useEffect(() => {
|
||||
@@ -103,9 +103,9 @@ const SettingsPage: React.FC = () => {
|
||||
openaiApiBaseUrl: smartRoutingConfig.openaiApiBaseUrl || '',
|
||||
openaiApiKey: smartRoutingConfig.openaiApiKey || '',
|
||||
openaiApiEmbeddingModel: smartRoutingConfig.openaiApiEmbeddingModel || '',
|
||||
})
|
||||
});
|
||||
}
|
||||
}, [smartRoutingConfig])
|
||||
}, [smartRoutingConfig]);
|
||||
|
||||
// Update local tempMCPRouterConfig when mcpRouterConfig changes
|
||||
useEffect(() => {
|
||||
@@ -115,9 +115,9 @@ const SettingsPage: React.FC = () => {
|
||||
referer: mcpRouterConfig.referer || 'https://www.mcphubx.com',
|
||||
title: mcpRouterConfig.title || 'MCPHub',
|
||||
baseUrl: mcpRouterConfig.baseUrl || 'https://api.mcprouter.to/v1',
|
||||
})
|
||||
});
|
||||
}
|
||||
}, [mcpRouterConfig])
|
||||
}, [mcpRouterConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (oauthServerConfig) {
|
||||
@@ -138,18 +138,18 @@ const SettingsPage: React.FC = () => {
|
||||
oauthServerConfig.allowedScopes && oauthServerConfig.allowedScopes.length > 0
|
||||
? oauthServerConfig.allowedScopes.join(', ')
|
||||
: '',
|
||||
dynamicRegistrationAllowedGrantTypes:
|
||||
oauthServerConfig.dynamicRegistration?.allowedGrantTypes?.length
|
||||
? oauthServerConfig.dynamicRegistration.allowedGrantTypes.join(', ')
|
||||
: '',
|
||||
})
|
||||
dynamicRegistrationAllowedGrantTypes: oauthServerConfig.dynamicRegistration
|
||||
?.allowedGrantTypes?.length
|
||||
? oauthServerConfig.dynamicRegistration.allowedGrantTypes.join(', ')
|
||||
: '',
|
||||
});
|
||||
}
|
||||
}, [oauthServerConfig])
|
||||
}, [oauthServerConfig]);
|
||||
|
||||
// Update local tempNameSeparator when nameSeparator changes
|
||||
useEffect(() => {
|
||||
setTempNameSeparator(nameSeparator)
|
||||
}, [nameSeparator])
|
||||
setTempNameSeparator(nameSeparator);
|
||||
}, [nameSeparator]);
|
||||
|
||||
const [sectionsVisible, setSectionsVisible] = useState({
|
||||
routingConfig: false,
|
||||
@@ -160,7 +160,7 @@ const SettingsPage: React.FC = () => {
|
||||
nameSeparator: false,
|
||||
password: false,
|
||||
exportConfig: false,
|
||||
})
|
||||
});
|
||||
|
||||
const toggleSection = (
|
||||
section:
|
||||
@@ -176,8 +176,8 @@ const SettingsPage: React.FC = () => {
|
||||
setSectionsVisible((prev) => ({
|
||||
...prev,
|
||||
[section]: !prev[section],
|
||||
}))
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRoutingConfigChange = async (
|
||||
key:
|
||||
@@ -191,39 +191,39 @@ const SettingsPage: React.FC = () => {
|
||||
// If enableBearerAuth is turned on and there's no key, generate one first
|
||||
if (key === 'enableBearerAuth' && value === true) {
|
||||
if (!tempRoutingConfig.bearerAuthKey && !routingConfig.bearerAuthKey) {
|
||||
const newKey = generateRandomKey()
|
||||
handleBearerAuthKeyChange(newKey)
|
||||
const newKey = generateRandomKey();
|
||||
handleBearerAuthKeyChange(newKey);
|
||||
|
||||
// Update both enableBearerAuth and bearerAuthKey in a single call
|
||||
const success = await updateRoutingConfigBatch({
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: newKey,
|
||||
})
|
||||
});
|
||||
|
||||
if (success) {
|
||||
// Update tempRoutingConfig to reflect the saved values
|
||||
setTempRoutingConfig((prev) => ({
|
||||
...prev,
|
||||
bearerAuthKey: newKey,
|
||||
}))
|
||||
}));
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await updateRoutingConfig(key, value)
|
||||
}
|
||||
await updateRoutingConfig(key, value);
|
||||
};
|
||||
|
||||
const handleBearerAuthKeyChange = (value: string) => {
|
||||
setTempRoutingConfig((prev) => ({
|
||||
...prev,
|
||||
bearerAuthKey: value,
|
||||
}))
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const saveBearerAuthKey = async () => {
|
||||
await updateRoutingConfig('bearerAuthKey', tempRoutingConfig.bearerAuthKey)
|
||||
}
|
||||
await updateRoutingConfig('bearerAuthKey', tempRoutingConfig.bearerAuthKey);
|
||||
};
|
||||
|
||||
const handleInstallConfigChange = (
|
||||
key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl',
|
||||
@@ -232,12 +232,12 @@ const SettingsPage: React.FC = () => {
|
||||
setInstallConfig({
|
||||
...installConfig,
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const saveInstallConfig = async (key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl') => {
|
||||
await updateInstallConfig(key, installConfig[key])
|
||||
}
|
||||
await updateInstallConfig(key, installConfig[key]);
|
||||
};
|
||||
|
||||
const handleSmartRoutingConfigChange = (
|
||||
key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel',
|
||||
@@ -246,14 +246,14 @@ const SettingsPage: React.FC = () => {
|
||||
setTempSmartRoutingConfig({
|
||||
...tempSmartRoutingConfig,
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const saveSmartRoutingConfig = async (
|
||||
key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel',
|
||||
) => {
|
||||
await updateSmartRoutingConfig(key, tempSmartRoutingConfig[key])
|
||||
}
|
||||
await updateSmartRoutingConfig(key, tempSmartRoutingConfig[key]);
|
||||
};
|
||||
|
||||
const handleMCPRouterConfigChange = (
|
||||
key: 'apiKey' | 'referer' | 'title' | 'baseUrl',
|
||||
@@ -262,24 +262,24 @@ const SettingsPage: React.FC = () => {
|
||||
setTempMCPRouterConfig({
|
||||
...tempMCPRouterConfig,
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const saveMCPRouterConfig = async (key: 'apiKey' | 'referer' | 'title' | 'baseUrl') => {
|
||||
await updateMCPRouterConfig(key, tempMCPRouterConfig[key])
|
||||
}
|
||||
await updateMCPRouterConfig(key, tempMCPRouterConfig[key]);
|
||||
};
|
||||
|
||||
type OAuthServerNumberField =
|
||||
| 'accessTokenLifetime'
|
||||
| 'refreshTokenLifetime'
|
||||
| 'authorizationCodeLifetime'
|
||||
| 'authorizationCodeLifetime';
|
||||
|
||||
const handleOAuthServerNumberChange = (key: OAuthServerNumberField, value: string) => {
|
||||
setTempOAuthServerConfig((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}))
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const handleOAuthServerTextChange = (
|
||||
key: 'allowedScopes' | 'dynamicRegistrationAllowedGrantTypes',
|
||||
@@ -288,52 +288,52 @@ const SettingsPage: React.FC = () => {
|
||||
setTempOAuthServerConfig((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}))
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const saveOAuthServerNumberConfig = async (key: OAuthServerNumberField) => {
|
||||
const rawValue = tempOAuthServerConfig[key]
|
||||
const rawValue = tempOAuthServerConfig[key];
|
||||
if (!rawValue || rawValue.trim() === '') {
|
||||
showToast(t('settings.invalidNumberInput') || 'Please enter a valid number', 'error')
|
||||
return
|
||||
showToast(t('settings.invalidNumberInput') || 'Please enter a valid number', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedValue = Number(rawValue)
|
||||
const parsedValue = Number(rawValue);
|
||||
if (Number.isNaN(parsedValue) || parsedValue < 0) {
|
||||
showToast(t('settings.invalidNumberInput') || 'Please enter a valid number', 'error')
|
||||
return
|
||||
showToast(t('settings.invalidNumberInput') || 'Please enter a valid number', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
await updateOAuthServerConfig(key, parsedValue)
|
||||
}
|
||||
await updateOAuthServerConfig(key, parsedValue);
|
||||
};
|
||||
|
||||
const saveOAuthServerAllowedScopes = async () => {
|
||||
const scopes = tempOAuthServerConfig.allowedScopes
|
||||
.split(',')
|
||||
.map((scope) => scope.trim())
|
||||
.filter((scope) => scope.length > 0)
|
||||
.filter((scope) => scope.length > 0);
|
||||
|
||||
await updateOAuthServerConfig('allowedScopes', scopes)
|
||||
}
|
||||
await updateOAuthServerConfig('allowedScopes', scopes);
|
||||
};
|
||||
|
||||
const saveOAuthServerGrantTypes = async () => {
|
||||
const grantTypes = tempOAuthServerConfig.dynamicRegistrationAllowedGrantTypes
|
||||
.split(',')
|
||||
.map((grant) => grant.trim())
|
||||
.filter((grant) => grant.length > 0)
|
||||
.filter((grant) => grant.length > 0);
|
||||
|
||||
await updateOAuthServerConfig('dynamicRegistration', {
|
||||
...oauthServerConfig.dynamicRegistration,
|
||||
allowedGrantTypes: grantTypes,
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleOAuthServerToggle = async (
|
||||
key: 'enabled' | 'requireClientSecret' | 'requireState',
|
||||
value: boolean,
|
||||
) => {
|
||||
await updateOAuthServerConfig(key, value)
|
||||
}
|
||||
await updateOAuthServerConfig(key, value);
|
||||
};
|
||||
|
||||
const handleDynamicRegistrationToggle = async (
|
||||
updates: Partial<typeof oauthServerConfig.dynamicRegistration>,
|
||||
@@ -341,137 +341,137 @@ const SettingsPage: React.FC = () => {
|
||||
await updateOAuthServerConfig('dynamicRegistration', {
|
||||
...oauthServerConfig.dynamicRegistration,
|
||||
...updates,
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const saveNameSeparator = async () => {
|
||||
await updateNameSeparator(tempNameSeparator)
|
||||
}
|
||||
await updateNameSeparator(tempNameSeparator);
|
||||
};
|
||||
|
||||
const handleSmartRoutingEnabledChange = async (value: boolean) => {
|
||||
// If enabling Smart Routing, validate required fields and save any unsaved changes
|
||||
if (value) {
|
||||
const currentDbUrl = tempSmartRoutingConfig.dbUrl || smartRoutingConfig.dbUrl
|
||||
const currentDbUrl = tempSmartRoutingConfig.dbUrl || smartRoutingConfig.dbUrl;
|
||||
const currentOpenaiApiKey =
|
||||
tempSmartRoutingConfig.openaiApiKey || smartRoutingConfig.openaiApiKey
|
||||
tempSmartRoutingConfig.openaiApiKey || smartRoutingConfig.openaiApiKey;
|
||||
|
||||
if (!currentDbUrl || !currentOpenaiApiKey) {
|
||||
const missingFields = []
|
||||
if (!currentDbUrl) missingFields.push(t('settings.dbUrl'))
|
||||
if (!currentOpenaiApiKey) missingFields.push(t('settings.openaiApiKey'))
|
||||
const missingFields = [];
|
||||
if (!currentDbUrl) missingFields.push(t('settings.dbUrl'));
|
||||
if (!currentOpenaiApiKey) missingFields.push(t('settings.openaiApiKey'));
|
||||
|
||||
showToast(
|
||||
t('settings.smartRoutingValidationError', {
|
||||
fields: missingFields.join(', '),
|
||||
}),
|
||||
)
|
||||
return
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare updates object with unsaved changes and enabled status
|
||||
const updates: any = { enabled: value }
|
||||
const updates: any = { enabled: value };
|
||||
|
||||
// Check for unsaved changes and include them in the batch update
|
||||
if (tempSmartRoutingConfig.dbUrl !== smartRoutingConfig.dbUrl) {
|
||||
updates.dbUrl = tempSmartRoutingConfig.dbUrl
|
||||
updates.dbUrl = tempSmartRoutingConfig.dbUrl;
|
||||
}
|
||||
if (tempSmartRoutingConfig.openaiApiBaseUrl !== smartRoutingConfig.openaiApiBaseUrl) {
|
||||
updates.openaiApiBaseUrl = tempSmartRoutingConfig.openaiApiBaseUrl
|
||||
updates.openaiApiBaseUrl = tempSmartRoutingConfig.openaiApiBaseUrl;
|
||||
}
|
||||
if (tempSmartRoutingConfig.openaiApiKey !== smartRoutingConfig.openaiApiKey) {
|
||||
updates.openaiApiKey = tempSmartRoutingConfig.openaiApiKey
|
||||
updates.openaiApiKey = tempSmartRoutingConfig.openaiApiKey;
|
||||
}
|
||||
if (
|
||||
tempSmartRoutingConfig.openaiApiEmbeddingModel !==
|
||||
smartRoutingConfig.openaiApiEmbeddingModel
|
||||
) {
|
||||
updates.openaiApiEmbeddingModel = tempSmartRoutingConfig.openaiApiEmbeddingModel
|
||||
updates.openaiApiEmbeddingModel = tempSmartRoutingConfig.openaiApiEmbeddingModel;
|
||||
}
|
||||
|
||||
// Save all changes in a single batch update
|
||||
await updateSmartRoutingConfigBatch(updates)
|
||||
await updateSmartRoutingConfigBatch(updates);
|
||||
} else {
|
||||
// If disabling, just update the enabled status
|
||||
await updateSmartRoutingConfig('enabled', value)
|
||||
await updateSmartRoutingConfig('enabled', value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordChangeSuccess = () => {
|
||||
setTimeout(() => {
|
||||
navigate('/')
|
||||
}, 2000)
|
||||
}
|
||||
navigate('/');
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const [copiedConfig, setCopiedConfig] = useState(false)
|
||||
const [mcpSettingsJson, setMcpSettingsJson] = useState<string>('')
|
||||
const [copiedConfig, setCopiedConfig] = useState(false);
|
||||
const [mcpSettingsJson, setMcpSettingsJson] = useState<string>('');
|
||||
|
||||
const fetchMcpSettings = async () => {
|
||||
try {
|
||||
const result = await exportMCPSettings()
|
||||
console.log('Fetched MCP settings:', result)
|
||||
const configJson = JSON.stringify(result.data, null, 2)
|
||||
setMcpSettingsJson(configJson)
|
||||
const result = await exportMCPSettings();
|
||||
console.log('Fetched MCP settings:', result);
|
||||
const configJson = JSON.stringify(result.data, null, 2);
|
||||
setMcpSettingsJson(configJson);
|
||||
} catch (error) {
|
||||
console.error('Error fetching MCP settings:', error)
|
||||
showToast(t('settings.exportError') || 'Failed to fetch settings', 'error')
|
||||
console.error('Error fetching MCP settings:', error);
|
||||
showToast(t('settings.exportError') || 'Failed to fetch settings', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (sectionsVisible.exportConfig && !mcpSettingsJson) {
|
||||
fetchMcpSettings()
|
||||
fetchMcpSettings();
|
||||
}
|
||||
}, [sectionsVisible.exportConfig])
|
||||
}, [sectionsVisible.exportConfig]);
|
||||
|
||||
const handleCopyConfig = async () => {
|
||||
if (!mcpSettingsJson) return
|
||||
if (!mcpSettingsJson) return;
|
||||
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(mcpSettingsJson)
|
||||
setCopiedConfig(true)
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
|
||||
setTimeout(() => setCopiedConfig(false), 2000)
|
||||
await navigator.clipboard.writeText(mcpSettingsJson);
|
||||
setCopiedConfig(true);
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success');
|
||||
setTimeout(() => setCopiedConfig(false), 2000);
|
||||
} else {
|
||||
// Fallback for HTTP or unsupported clipboard API
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = mcpSettingsJson
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-9999px'
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = mcpSettingsJson;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
setCopiedConfig(true)
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
|
||||
setTimeout(() => setCopiedConfig(false), 2000)
|
||||
document.execCommand('copy');
|
||||
setCopiedConfig(true);
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success');
|
||||
setTimeout(() => setCopiedConfig(false), 2000);
|
||||
} catch (err) {
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error')
|
||||
console.error('Copy to clipboard failed:', err)
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error');
|
||||
console.error('Copy to clipboard failed:', err);
|
||||
}
|
||||
document.body.removeChild(textArea)
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error copying configuration:', error)
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error')
|
||||
console.error('Error copying configuration:', error);
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadConfig = () => {
|
||||
if (!mcpSettingsJson) return
|
||||
if (!mcpSettingsJson) return;
|
||||
|
||||
const blob = new Blob([mcpSettingsJson], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'mcp_settings.json'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
showToast(t('settings.exportSuccess') || 'Settings exported successfully', 'success')
|
||||
}
|
||||
const blob = new Blob([mcpSettingsJson], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'mcp_settings.json';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
showToast(t('settings.exportSuccess') || 'Settings exported successfully', 'success');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto">
|
||||
@@ -643,9 +643,7 @@ const SettingsPage: React.FC = () => {
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">
|
||||
{t('settings.requireClientSecret')}
|
||||
</h3>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.requireClientSecret')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.requireClientSecretDescription')}
|
||||
</p>
|
||||
@@ -673,9 +671,7 @@ const SettingsPage: React.FC = () => {
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">
|
||||
{t('settings.accessTokenLifetime')}
|
||||
</h3>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.accessTokenLifetime')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.accessTokenLifetimeDescription')}
|
||||
</p>
|
||||
@@ -764,9 +760,7 @@ const SettingsPage: React.FC = () => {
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.allowedScopes')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.allowedScopesDescription')}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{t('settings.allowedScopesDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
@@ -946,142 +940,154 @@ const SettingsPage: React.FC = () => {
|
||||
</PermissionChecker>
|
||||
|
||||
{/* System Settings */}
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('nameSeparator')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('settings.systemSettings')}</h2>
|
||||
<span className="text-gray-500">{sectionsVisible.nameSeparator ? '▼' : '►'}</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.nameSeparator && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.nameSeparatorLabel')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.nameSeparatorDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempNameSeparator}
|
||||
onChange={(e) => setTempNameSeparator(e.target.value)}
|
||||
placeholder="-"
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading}
|
||||
maxLength={5}
|
||||
/>
|
||||
<button
|
||||
onClick={saveNameSeparator}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableSessionRebuild')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.enableSessionRebuildDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={enableSessionRebuild}
|
||||
onCheckedChange={(checked) => updateSessionRebuild(checked)}
|
||||
/>
|
||||
</div>
|
||||
<PermissionChecker permissions={PERMISSIONS.SETTINGS_SYSTEM_CONFIG}>
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('nameSeparator')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('settings.systemSettings')}</h2>
|
||||
<span className="text-gray-500">{sectionsVisible.nameSeparator ? '▼' : '►'}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Route Configuration Settings */}
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('routingConfig')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('pages.settings.routeConfig')}</h2>
|
||||
<span className="text-gray-500">{sectionsVisible.routingConfig ? '▼' : '►'}</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.routingConfig && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableBearerAuth')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.enableBearerAuthDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableBearerAuth}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableBearerAuth', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{routingConfig.enableBearerAuth && (
|
||||
{sectionsVisible.nameSeparator && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.bearerAuthKey')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.bearerAuthKeyDescription')}</p>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.nameSeparatorLabel')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.nameSeparatorDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempRoutingConfig.bearerAuthKey}
|
||||
onChange={(e) => handleBearerAuthKeyChange(e.target.value)}
|
||||
placeholder={t('settings.bearerAuthKeyPlaceholder')}
|
||||
value={tempNameSeparator}
|
||||
onChange={(e) => setTempNameSeparator(e.target.value)}
|
||||
placeholder="-"
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading || !routingConfig.enableBearerAuth}
|
||||
disabled={loading}
|
||||
maxLength={5}
|
||||
/>
|
||||
<button
|
||||
onClick={saveBearerAuthKey}
|
||||
disabled={loading || !routingConfig.enableBearerAuth}
|
||||
onClick={saveNameSeparator}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableGlobalRoute')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.enableGlobalRouteDescription')}
|
||||
</p>
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">
|
||||
{t('settings.enableSessionRebuild')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.enableSessionRebuildDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={enableSessionRebuild}
|
||||
onCheckedChange={(checked) => updateSessionRebuild(checked)}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableGlobalRoute}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableGlobalRoute', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PermissionChecker>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableGroupNameRoute')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.enableGroupNameRouteDescription')}
|
||||
</p>
|
||||
{/* Route Configuration Settings */}
|
||||
<PermissionChecker permissions={PERMISSIONS.SETTINGS_ROUTE_CONFIG}>
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('routingConfig')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('pages.settings.routeConfig')}</h2>
|
||||
<span className="text-gray-500">{sectionsVisible.routingConfig ? '▼' : '►'}</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.routingConfig && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableBearerAuth')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.enableBearerAuthDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableBearerAuth}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableBearerAuth', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{routingConfig.enableBearerAuth && (
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.bearerAuthKey')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.bearerAuthKeyDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempRoutingConfig.bearerAuthKey}
|
||||
onChange={(e) => handleBearerAuthKeyChange(e.target.value)}
|
||||
placeholder={t('settings.bearerAuthKeyPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading || !routingConfig.enableBearerAuth}
|
||||
/>
|
||||
<button
|
||||
onClick={saveBearerAuthKey}
|
||||
disabled={loading || !routingConfig.enableBearerAuth}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableGlobalRoute')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.enableGlobalRouteDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableGlobalRoute}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableGlobalRoute', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">
|
||||
{t('settings.enableGroupNameRoute')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.enableGroupNameRouteDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableGroupNameRoute}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableGroupNameRoute', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableGroupNameRoute}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableGroupNameRoute', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PermissionChecker permissions={PERMISSIONS.SETTINGS_SKIP_AUTH}>
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.skipAuth')}</h3>
|
||||
@@ -1093,10 +1099,10 @@ const SettingsPage: React.FC = () => {
|
||||
onCheckedChange={(checked) => handleRoutingConfigChange('skipAuth', checked)}
|
||||
/>
|
||||
</div>
|
||||
</PermissionChecker>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PermissionChecker>
|
||||
|
||||
{/* Installation Configuration Settings */}
|
||||
<PermissionChecker permissions={PERMISSIONS.SETTINGS_INSTALL_CONFIG}>
|
||||
@@ -1188,7 +1194,10 @@ const SettingsPage: React.FC = () => {
|
||||
</PermissionChecker>
|
||||
|
||||
{/* Change Password */}
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card" data-section="password">
|
||||
<div
|
||||
className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card"
|
||||
data-section="password"
|
||||
>
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('password')}
|
||||
@@ -1258,7 +1267,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</PermissionChecker>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPage
|
||||
export default SettingsPage;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -673,9 +673,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",
|
||||
|
||||
@@ -673,9 +673,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é",
|
||||
|
||||
@@ -673,9 +673,13 @@
|
||||
"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",
|
||||
|
||||
@@ -675,9 +675,13 @@
|
||||
"password": "密码",
|
||||
"newPassword": "新密码",
|
||||
"confirmPassword": "确认密码",
|
||||
"changePassword": "修改密码",
|
||||
"adminRole": "管理员",
|
||||
"admin": "管理员",
|
||||
"user": "用户",
|
||||
"role": "角色",
|
||||
"actions": "操作",
|
||||
"addFirst": "添加第一个用户",
|
||||
"permissions": "权限",
|
||||
"adminPermissions": "完全系统访问权限",
|
||||
"userPermissions": "受限访问权限",
|
||||
|
||||
@@ -6,11 +6,11 @@ import {
|
||||
SystemConfigDao,
|
||||
UserConfigDao,
|
||||
ServerConfigWithName,
|
||||
UserDaoImpl,
|
||||
ServerDaoImpl,
|
||||
GroupDaoImpl,
|
||||
SystemConfigDaoImpl,
|
||||
UserConfigDaoImpl,
|
||||
getUserDao,
|
||||
getServerDao,
|
||||
getGroupDao,
|
||||
getSystemConfigDao,
|
||||
getUserConfigDao,
|
||||
} from '../dao/index.js';
|
||||
|
||||
/**
|
||||
@@ -252,14 +252,14 @@ export class DaoConfigService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a DaoConfigService with default DAO implementations
|
||||
* Create a DaoConfigService with DAO implementations from factory
|
||||
*/
|
||||
export function createDaoConfigService(): DaoConfigService {
|
||||
return new DaoConfigService(
|
||||
new UserDaoImpl(),
|
||||
new ServerDaoImpl(),
|
||||
new GroupDaoImpl(),
|
||||
new SystemConfigDaoImpl(),
|
||||
new UserConfigDaoImpl(),
|
||||
getUserDao(),
|
||||
getServerDao(),
|
||||
getGroupDao(),
|
||||
getSystemConfigDao(),
|
||||
getUserConfigDao(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export const login = async (req: Request, res: Response): Promise<void> => {
|
||||
|
||||
try {
|
||||
// Find user by username
|
||||
const user = findUserByUsername(username);
|
||||
const user = await findUserByUsername(username);
|
||||
|
||||
if (!user) {
|
||||
res.status(401).json({
|
||||
@@ -192,7 +192,7 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
|
||||
}
|
||||
|
||||
// Find user by username
|
||||
const user = findUserByUsername(username);
|
||||
const user = await findUserByUsername(username);
|
||||
|
||||
if (!user) {
|
||||
res.status(404).json({ success: false, message: 'User not found' });
|
||||
|
||||
@@ -15,9 +15,9 @@ import {
|
||||
} from '../services/groupService.js';
|
||||
|
||||
// Get all groups
|
||||
export const getGroups = (_: Request, res: Response): void => {
|
||||
export const getGroups = async (_: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const groups = getAllGroups();
|
||||
const groups = await getAllGroups();
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: groups,
|
||||
@@ -32,7 +32,7 @@ export const getGroups = (_: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Get a specific group by ID
|
||||
export const getGroup = (req: Request, res: Response): void => {
|
||||
export const getGroup = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
if (!id) {
|
||||
@@ -43,7 +43,7 @@ export const getGroup = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const group = getGroupByIdOrName(id);
|
||||
const group = await getGroupByIdOrName(id);
|
||||
if (!group) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -66,7 +66,7 @@ export const getGroup = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Create a new group
|
||||
export const createNewGroup = (req: Request, res: Response): void => {
|
||||
export const createNewGroup = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { name, description, servers } = req.body;
|
||||
if (!name) {
|
||||
@@ -83,7 +83,7 @@ export const createNewGroup = (req: Request, res: Response): void => {
|
||||
const currentUser = (req as any).user;
|
||||
const owner = currentUser?.username || 'admin';
|
||||
|
||||
const newGroup = createGroup(name, description, serverList, owner);
|
||||
const newGroup = await createGroup(name, description, serverList, owner);
|
||||
if (!newGroup) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -107,7 +107,7 @@ export const createNewGroup = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Update an existing group
|
||||
export const updateExistingGroup = (req: Request, res: Response): void => {
|
||||
export const updateExistingGroup = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, description, servers } = req.body;
|
||||
@@ -133,7 +133,7 @@ export const updateExistingGroup = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedGroup = updateGroup(id, updateData);
|
||||
const updatedGroup = await updateGroup(id, updateData);
|
||||
if (!updatedGroup) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -157,7 +157,7 @@ export const updateExistingGroup = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Update servers in a group (batch update) - supports both string[] and server config format
|
||||
export const updateGroupServersBatch = (req: Request, res: Response): void => {
|
||||
export const updateGroupServersBatch = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { servers } = req.body;
|
||||
@@ -203,7 +203,7 @@ export const updateGroupServersBatch = (req: Request, res: Response): void => {
|
||||
}
|
||||
}
|
||||
|
||||
const updatedGroup = updateGroupServers(id, servers);
|
||||
const updatedGroup = await updateGroupServers(id, servers);
|
||||
if (!updatedGroup) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -227,7 +227,7 @@ export const updateGroupServersBatch = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Delete a group
|
||||
export const deleteExistingGroup = (req: Request, res: Response): void => {
|
||||
export const deleteExistingGroup = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
if (!id) {
|
||||
@@ -238,7 +238,7 @@ export const deleteExistingGroup = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const success = deleteGroup(id);
|
||||
const success = await deleteGroup(id);
|
||||
if (!success) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -260,7 +260,7 @@ export const deleteExistingGroup = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Add server to a group
|
||||
export const addServerToExistingGroup = (req: Request, res: Response): void => {
|
||||
export const addServerToExistingGroup = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { serverName } = req.body;
|
||||
@@ -280,7 +280,7 @@ export const addServerToExistingGroup = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedGroup = addServerToGroup(id, serverName);
|
||||
const updatedGroup = await addServerToGroup(id, serverName);
|
||||
if (!updatedGroup) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -304,7 +304,7 @@ export const addServerToExistingGroup = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Remove server from a group
|
||||
export const removeServerFromExistingGroup = (req: Request, res: Response): void => {
|
||||
export const removeServerFromExistingGroup = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id, serverName } = req.params;
|
||||
if (!id || !serverName) {
|
||||
@@ -315,7 +315,7 @@ export const removeServerFromExistingGroup = (req: Request, res: Response): void
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedGroup = removeServerFromGroup(id, serverName);
|
||||
const updatedGroup = await removeServerFromGroup(id, serverName);
|
||||
if (!updatedGroup) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -339,7 +339,7 @@ export const removeServerFromExistingGroup = (req: Request, res: Response): void
|
||||
};
|
||||
|
||||
// Get servers in a group
|
||||
export const getGroupServers = (req: Request, res: Response): void => {
|
||||
export const getGroupServers = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
if (!id) {
|
||||
@@ -350,7 +350,7 @@ export const getGroupServers = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const group = getGroupByIdOrName(id);
|
||||
const group = await getGroupByIdOrName(id);
|
||||
if (!group) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -373,7 +373,7 @@ export const getGroupServers = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Get server configurations in a group (including tool selections)
|
||||
export const getGroupServerConfigs = (req: Request, res: Response): void => {
|
||||
export const getGroupServerConfigs = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
if (!id) {
|
||||
@@ -384,7 +384,7 @@ export const getGroupServerConfigs = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const serverConfigs = getServerConfigsInGroup(id);
|
||||
const serverConfigs = await getServerConfigsInGroup(id);
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: serverConfigs,
|
||||
@@ -399,7 +399,7 @@ export const getGroupServerConfigs = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Get specific server configuration in a group
|
||||
export const getGroupServerConfig = (req: Request, res: Response): void => {
|
||||
export const getGroupServerConfig = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id, serverName } = req.params;
|
||||
if (!id || !serverName) {
|
||||
@@ -410,7 +410,7 @@ export const getGroupServerConfig = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const serverConfig = getServerConfigInGroup(id, serverName);
|
||||
const serverConfig = await getServerConfigInGroup(id, serverName);
|
||||
if (!serverConfig) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -433,7 +433,7 @@ export const getGroupServerConfig = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Update tools for a specific server in a group
|
||||
export const updateGroupServerTools = (req: Request, res: Response): void => {
|
||||
export const updateGroupServerTools = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id, serverName } = req.params;
|
||||
const { tools } = req.body;
|
||||
@@ -458,7 +458,7 @@ export const updateGroupServerTools = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedGroup = updateServerToolsInGroup(id, serverName, tools);
|
||||
const updatedGroup = await updateServerToolsInGroup(id, serverName, tools);
|
||||
if (!updatedGroup) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
|
||||
@@ -208,7 +208,7 @@ export const getGroupOpenAPISpec = async (req: Request, res: Response): Promise<
|
||||
const { name } = req.params;
|
||||
|
||||
// Check if group exists
|
||||
const group = getGroupByIdOrName(name);
|
||||
const group = await getGroupByIdOrName(name);
|
||||
if (!group) {
|
||||
getServerOpenAPISpec(req, res);
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ApiResponse, AddServerRequest } from '../types/index.js';
|
||||
import { ApiResponse, AddServerRequest, McpSettings } from '../types/index.js';
|
||||
import {
|
||||
getServersInfo,
|
||||
addServer,
|
||||
@@ -13,6 +13,7 @@ import { loadSettings, saveSettings } from '../config/index.js';
|
||||
import { syncAllServerToolsEmbeddings } from '../services/vectorSearchService.js';
|
||||
import { createSafeJSON } from '../utils/serialization.js';
|
||||
import { cloneDefaultOAuthServerConfig } from '../constants/oauthServerDefaults.js';
|
||||
import { getServerDao, getGroupDao, getSystemConfigDao } from '../dao/DaoFactory.js';
|
||||
|
||||
export const getAllServers = async (_: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
@@ -31,15 +32,45 @@ export const getAllServers = async (_: Request, res: Response): Promise<void> =>
|
||||
}
|
||||
};
|
||||
|
||||
export const getAllSettings = (_: Request, res: Response): void => {
|
||||
export const getAllSettings = async (_: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
// Get base settings from file (for OAuth clients, tokens, users, etc.)
|
||||
const fileSettings = loadSettings();
|
||||
|
||||
// Get servers from DAO (supports both file and database modes)
|
||||
const serverDao = getServerDao();
|
||||
const servers = await serverDao.findAll();
|
||||
|
||||
// Convert servers array to mcpServers map format
|
||||
const mcpServers: McpSettings['mcpServers'] = {};
|
||||
for (const server of servers) {
|
||||
const { name, ...config } = server;
|
||||
mcpServers[name] = config;
|
||||
}
|
||||
|
||||
// Get groups from DAO
|
||||
const groupDao = getGroupDao();
|
||||
const groups = await groupDao.findAll();
|
||||
|
||||
// Get system config from DAO
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
|
||||
// Merge all data into settings object
|
||||
const settings: McpSettings = {
|
||||
...fileSettings,
|
||||
mcpServers,
|
||||
groups,
|
||||
systemConfig,
|
||||
};
|
||||
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: createSafeJSON(settings),
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
console.error('Failed to get server settings:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get server settings',
|
||||
@@ -125,11 +156,6 @@ export const createServer = async (req: Request, res: Response): Promise<void> =
|
||||
return;
|
||||
}
|
||||
|
||||
// Set default keep-alive interval for SSE servers if not specified
|
||||
if ((config.type === 'sse' || (!config.type && config.url)) && !config.keepAliveInterval) {
|
||||
config.keepAliveInterval = 60000; // Default 60 seconds for SSE servers
|
||||
}
|
||||
|
||||
// Set owner property - use current user's username, default to 'admin'
|
||||
if (!config.owner) {
|
||||
const currentUser = (req as any).user;
|
||||
@@ -268,11 +294,6 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
|
||||
return;
|
||||
}
|
||||
|
||||
// Set default keep-alive interval for SSE servers if not specified
|
||||
if ((config.type === 'sse' || (!config.type && config.url)) && !config.keepAliveInterval) {
|
||||
config.keepAliveInterval = 60000; // Default 60 seconds for SSE servers
|
||||
}
|
||||
|
||||
// Set owner property if not provided - use current user's username, default to 'admin'
|
||||
if (!config.owner) {
|
||||
const currentUser = (req as any).user;
|
||||
@@ -303,9 +324,12 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
|
||||
export const getServerConfig = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
const allServers = await getServersInfo();
|
||||
const serverInfo = allServers.find((s) => s.name === name);
|
||||
if (!serverInfo) {
|
||||
|
||||
// Get server configuration from DAO (supports both file and database modes)
|
||||
const serverDao = getServerDao();
|
||||
const serverConfig = await serverDao.findById(name);
|
||||
|
||||
if (!serverConfig) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Server not found',
|
||||
@@ -313,18 +337,26 @@ export const getServerConfig = async (req: Request, res: Response): Promise<void
|
||||
return;
|
||||
}
|
||||
|
||||
// Get runtime info (status, tools) from getServersInfo
|
||||
const allServers = await getServersInfo();
|
||||
const serverInfo = allServers.find((s) => s.name === name);
|
||||
|
||||
// Extract config without the name field
|
||||
const { name: serverName, ...config } = serverConfig;
|
||||
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: {
|
||||
name,
|
||||
status: serverInfo ? serverInfo.status : 'disconnected',
|
||||
tools: serverInfo ? serverInfo.tools : [],
|
||||
config: serverInfo,
|
||||
name: serverName,
|
||||
status: serverInfo?.status || 'disconnected',
|
||||
tools: serverInfo?.tools || [],
|
||||
config,
|
||||
},
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
console.error('Failed to get server configuration:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get server configuration',
|
||||
@@ -507,10 +539,17 @@ export const updateToolDescription = async (req: Request, res: Response): Promis
|
||||
}
|
||||
};
|
||||
|
||||
export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
export const updateSystemConfig = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { routing, install, smartRouting, mcpRouter, nameSeparator, enableSessionRebuild, oauthServer } = req.body;
|
||||
const currentUser = (req as any).user;
|
||||
const {
|
||||
routing,
|
||||
install,
|
||||
smartRouting,
|
||||
mcpRouter,
|
||||
nameSeparator,
|
||||
enableSessionRebuild,
|
||||
oauthServer,
|
||||
} = req.body;
|
||||
|
||||
const hasRoutingUpdate =
|
||||
routing &&
|
||||
@@ -542,8 +581,8 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
typeof mcpRouter.baseUrl === 'string');
|
||||
|
||||
const hasNameSeparatorUpdate = typeof nameSeparator === 'string';
|
||||
|
||||
const hasSessionRebuildUpdate = typeof enableSessionRebuild !== 'boolean';
|
||||
|
||||
const hasSessionRebuildUpdate = typeof enableSessionRebuild === 'boolean';
|
||||
|
||||
const hasOAuthServerUpdate =
|
||||
oauthServer &&
|
||||
@@ -575,9 +614,12 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = loadSettings();
|
||||
if (!settings.systemConfig) {
|
||||
settings.systemConfig = {
|
||||
// Get system config from DAO (supports both file and database modes)
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
let systemConfig = await systemConfigDao.get();
|
||||
|
||||
if (!systemConfig) {
|
||||
systemConfig = {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
@@ -607,8 +649,8 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
};
|
||||
}
|
||||
|
||||
if (!settings.systemConfig.routing) {
|
||||
settings.systemConfig.routing = {
|
||||
if (!systemConfig.routing) {
|
||||
systemConfig.routing = {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
@@ -617,16 +659,16 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
};
|
||||
}
|
||||
|
||||
if (!settings.systemConfig.install) {
|
||||
settings.systemConfig.install = {
|
||||
if (!systemConfig.install) {
|
||||
systemConfig.install = {
|
||||
pythonIndexUrl: '',
|
||||
npmRegistry: '',
|
||||
baseUrl: 'http://localhost:3000',
|
||||
};
|
||||
}
|
||||
|
||||
if (!settings.systemConfig.smartRouting) {
|
||||
settings.systemConfig.smartRouting = {
|
||||
if (!systemConfig.smartRouting) {
|
||||
systemConfig.smartRouting = {
|
||||
enabled: false,
|
||||
dbUrl: '',
|
||||
openaiApiBaseUrl: '',
|
||||
@@ -635,8 +677,8 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
};
|
||||
}
|
||||
|
||||
if (!settings.systemConfig.mcpRouter) {
|
||||
settings.systemConfig.mcpRouter = {
|
||||
if (!systemConfig.mcpRouter) {
|
||||
systemConfig.mcpRouter = {
|
||||
apiKey: '',
|
||||
referer: 'https://www.mcphubx.com',
|
||||
title: 'MCPHub',
|
||||
@@ -644,18 +686,18 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
};
|
||||
}
|
||||
|
||||
if (!settings.systemConfig.oauthServer) {
|
||||
settings.systemConfig.oauthServer = cloneDefaultOAuthServerConfig();
|
||||
if (!systemConfig.oauthServer) {
|
||||
systemConfig.oauthServer = cloneDefaultOAuthServerConfig();
|
||||
}
|
||||
|
||||
if (!settings.systemConfig.oauthServer.dynamicRegistration) {
|
||||
if (!systemConfig.oauthServer.dynamicRegistration) {
|
||||
const defaultConfig = cloneDefaultOAuthServerConfig();
|
||||
const defaultDynamic = defaultConfig.dynamicRegistration ?? {
|
||||
enabled: false,
|
||||
allowedGrantTypes: [],
|
||||
requiresAuthentication: false,
|
||||
};
|
||||
settings.systemConfig.oauthServer.dynamicRegistration = {
|
||||
systemConfig.oauthServer.dynamicRegistration = {
|
||||
enabled: defaultDynamic.enabled ?? false,
|
||||
allowedGrantTypes: [
|
||||
...(Array.isArray(defaultDynamic.allowedGrantTypes)
|
||||
@@ -668,50 +710,50 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
|
||||
if (routing) {
|
||||
if (typeof routing.enableGlobalRoute === 'boolean') {
|
||||
settings.systemConfig.routing.enableGlobalRoute = routing.enableGlobalRoute;
|
||||
systemConfig.routing.enableGlobalRoute = routing.enableGlobalRoute;
|
||||
}
|
||||
|
||||
if (typeof routing.enableGroupNameRoute === 'boolean') {
|
||||
settings.systemConfig.routing.enableGroupNameRoute = routing.enableGroupNameRoute;
|
||||
systemConfig.routing.enableGroupNameRoute = routing.enableGroupNameRoute;
|
||||
}
|
||||
|
||||
if (typeof routing.enableBearerAuth === 'boolean') {
|
||||
settings.systemConfig.routing.enableBearerAuth = routing.enableBearerAuth;
|
||||
systemConfig.routing.enableBearerAuth = routing.enableBearerAuth;
|
||||
}
|
||||
|
||||
if (typeof routing.bearerAuthKey === 'string') {
|
||||
settings.systemConfig.routing.bearerAuthKey = routing.bearerAuthKey;
|
||||
systemConfig.routing.bearerAuthKey = routing.bearerAuthKey;
|
||||
}
|
||||
|
||||
if (typeof routing.skipAuth === 'boolean') {
|
||||
settings.systemConfig.routing.skipAuth = routing.skipAuth;
|
||||
systemConfig.routing.skipAuth = routing.skipAuth;
|
||||
}
|
||||
}
|
||||
|
||||
if (install) {
|
||||
if (typeof install.pythonIndexUrl === 'string') {
|
||||
settings.systemConfig.install.pythonIndexUrl = install.pythonIndexUrl;
|
||||
systemConfig.install.pythonIndexUrl = install.pythonIndexUrl;
|
||||
}
|
||||
if (typeof install.npmRegistry === 'string') {
|
||||
settings.systemConfig.install.npmRegistry = install.npmRegistry;
|
||||
systemConfig.install.npmRegistry = install.npmRegistry;
|
||||
}
|
||||
if (typeof install.baseUrl === 'string') {
|
||||
settings.systemConfig.install.baseUrl = install.baseUrl;
|
||||
systemConfig.install.baseUrl = install.baseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// Track smartRouting state and configuration changes
|
||||
const wasSmartRoutingEnabled = settings.systemConfig.smartRouting.enabled || false;
|
||||
const previousSmartRoutingConfig = { ...settings.systemConfig.smartRouting };
|
||||
const wasSmartRoutingEnabled = systemConfig.smartRouting.enabled || false;
|
||||
const previousSmartRoutingConfig = { ...systemConfig.smartRouting };
|
||||
let needsSync = false;
|
||||
|
||||
if (smartRouting) {
|
||||
if (typeof smartRouting.enabled === 'boolean') {
|
||||
// If enabling Smart Routing, validate required fields
|
||||
if (smartRouting.enabled) {
|
||||
const currentDbUrl = smartRouting.dbUrl || settings.systemConfig.smartRouting.dbUrl;
|
||||
const currentDbUrl = smartRouting.dbUrl || systemConfig.smartRouting.dbUrl;
|
||||
const currentOpenaiApiKey =
|
||||
smartRouting.openaiApiKey || settings.systemConfig.smartRouting.openaiApiKey;
|
||||
smartRouting.openaiApiKey || systemConfig.smartRouting.openaiApiKey;
|
||||
|
||||
if (!currentDbUrl || !currentOpenaiApiKey) {
|
||||
const missingFields = [];
|
||||
@@ -725,32 +767,30 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
}
|
||||
settings.systemConfig.smartRouting.enabled = smartRouting.enabled;
|
||||
systemConfig.smartRouting.enabled = smartRouting.enabled;
|
||||
}
|
||||
if (typeof smartRouting.dbUrl === 'string') {
|
||||
settings.systemConfig.smartRouting.dbUrl = smartRouting.dbUrl;
|
||||
systemConfig.smartRouting.dbUrl = smartRouting.dbUrl;
|
||||
}
|
||||
if (typeof smartRouting.openaiApiBaseUrl === 'string') {
|
||||
settings.systemConfig.smartRouting.openaiApiBaseUrl = smartRouting.openaiApiBaseUrl;
|
||||
systemConfig.smartRouting.openaiApiBaseUrl = smartRouting.openaiApiBaseUrl;
|
||||
}
|
||||
if (typeof smartRouting.openaiApiKey === 'string') {
|
||||
settings.systemConfig.smartRouting.openaiApiKey = smartRouting.openaiApiKey;
|
||||
systemConfig.smartRouting.openaiApiKey = smartRouting.openaiApiKey;
|
||||
}
|
||||
if (typeof smartRouting.openaiApiEmbeddingModel === 'string') {
|
||||
settings.systemConfig.smartRouting.openaiApiEmbeddingModel =
|
||||
smartRouting.openaiApiEmbeddingModel;
|
||||
systemConfig.smartRouting.openaiApiEmbeddingModel = smartRouting.openaiApiEmbeddingModel;
|
||||
}
|
||||
|
||||
// Check if we need to sync embeddings
|
||||
const isNowEnabled = settings.systemConfig.smartRouting.enabled || false;
|
||||
const isNowEnabled = systemConfig.smartRouting.enabled || false;
|
||||
const hasConfigChanged =
|
||||
previousSmartRoutingConfig.dbUrl !== settings.systemConfig.smartRouting.dbUrl ||
|
||||
previousSmartRoutingConfig.dbUrl !== systemConfig.smartRouting.dbUrl ||
|
||||
previousSmartRoutingConfig.openaiApiBaseUrl !==
|
||||
settings.systemConfig.smartRouting.openaiApiBaseUrl ||
|
||||
previousSmartRoutingConfig.openaiApiKey !==
|
||||
settings.systemConfig.smartRouting.openaiApiKey ||
|
||||
systemConfig.smartRouting.openaiApiBaseUrl ||
|
||||
previousSmartRoutingConfig.openaiApiKey !== systemConfig.smartRouting.openaiApiKey ||
|
||||
previousSmartRoutingConfig.openaiApiEmbeddingModel !==
|
||||
settings.systemConfig.smartRouting.openaiApiEmbeddingModel;
|
||||
systemConfig.smartRouting.openaiApiEmbeddingModel;
|
||||
|
||||
// Sync if: first time enabling OR smart routing is enabled and any config changed
|
||||
needsSync = (!wasSmartRoutingEnabled && isNowEnabled) || (isNowEnabled && hasConfigChanged);
|
||||
@@ -758,21 +798,21 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
|
||||
if (mcpRouter) {
|
||||
if (typeof mcpRouter.apiKey === 'string') {
|
||||
settings.systemConfig.mcpRouter.apiKey = mcpRouter.apiKey;
|
||||
systemConfig.mcpRouter.apiKey = mcpRouter.apiKey;
|
||||
}
|
||||
if (typeof mcpRouter.referer === 'string') {
|
||||
settings.systemConfig.mcpRouter.referer = mcpRouter.referer;
|
||||
systemConfig.mcpRouter.referer = mcpRouter.referer;
|
||||
}
|
||||
if (typeof mcpRouter.title === 'string') {
|
||||
settings.systemConfig.mcpRouter.title = mcpRouter.title;
|
||||
systemConfig.mcpRouter.title = mcpRouter.title;
|
||||
}
|
||||
if (typeof mcpRouter.baseUrl === 'string') {
|
||||
settings.systemConfig.mcpRouter.baseUrl = mcpRouter.baseUrl;
|
||||
systemConfig.mcpRouter.baseUrl = mcpRouter.baseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
if (oauthServer) {
|
||||
const target = settings.systemConfig.oauthServer;
|
||||
const target = systemConfig.oauthServer;
|
||||
if (typeof oauthServer.enabled === 'boolean') {
|
||||
target.enabled = oauthServer.enabled;
|
||||
}
|
||||
@@ -826,17 +866,19 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
}
|
||||
|
||||
if (typeof nameSeparator === 'string') {
|
||||
settings.systemConfig.nameSeparator = nameSeparator;
|
||||
systemConfig.nameSeparator = nameSeparator;
|
||||
}
|
||||
|
||||
if (typeof enableSessionRebuild === 'boolean') {
|
||||
settings.systemConfig.enableSessionRebuild = enableSessionRebuild;
|
||||
systemConfig.enableSessionRebuild = enableSessionRebuild;
|
||||
}
|
||||
|
||||
if (saveSettings(settings, currentUser)) {
|
||||
// Save using DAO (supports both file and database modes)
|
||||
try {
|
||||
await systemConfigDao.update(systemConfig);
|
||||
res.json({
|
||||
success: true,
|
||||
data: settings.systemConfig,
|
||||
data: systemConfig,
|
||||
message: 'System configuration updated successfully',
|
||||
});
|
||||
|
||||
@@ -848,7 +890,8 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
console.error('Failed to sync server tools embeddings:', error);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
} catch (saveError) {
|
||||
console.error('Failed to save system configuration:', saveError);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to save system configuration',
|
||||
|
||||
@@ -9,13 +9,14 @@ import {
|
||||
getUserCount,
|
||||
getAdminCount,
|
||||
} from '../services/userService.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import { getSystemConfigDao } from '../dao/index.js';
|
||||
import { validatePasswordStrength } from '../utils/passwordValidation.js';
|
||||
|
||||
// Admin permission check middleware function
|
||||
const requireAdmin = (req: Request, res: Response): boolean => {
|
||||
const settings = loadSettings();
|
||||
if (settings.systemConfig?.routing?.skipAuth) {
|
||||
const requireAdmin = async (req: Request, res: Response): Promise<boolean> => {
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
if (systemConfig?.routing?.skipAuth) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -31,11 +32,11 @@ const requireAdmin = (req: Request, res: Response): boolean => {
|
||||
};
|
||||
|
||||
// Get all users (admin only)
|
||||
export const getUsers = (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
export const getUsers = async (req: Request, res: Response): Promise<void> => {
|
||||
if (!(await requireAdmin(req, res))) return;
|
||||
|
||||
try {
|
||||
const users = getAllUsers().map(({ password: _, ...user }) => user); // Remove password from response
|
||||
const users = (await getAllUsers()).map(({ password: _, ...user }) => user); // Remove password from response
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: users,
|
||||
@@ -50,8 +51,8 @@ export const getUsers = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Get a specific user by username (admin only)
|
||||
export const getUser = (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
export const getUser = async (req: Request, res: Response): Promise<void> => {
|
||||
if (!(await requireAdmin(req, res))) return;
|
||||
|
||||
try {
|
||||
const { username } = req.params;
|
||||
@@ -63,7 +64,7 @@ export const getUser = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = getUserByUsername(username);
|
||||
const user = await getUserByUsername(username);
|
||||
if (!user) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -88,7 +89,7 @@ export const getUser = (req: Request, res: Response): void => {
|
||||
|
||||
// Create a new user (admin only)
|
||||
export const createUser = async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!(await requireAdmin(req, res))) return;
|
||||
|
||||
try {
|
||||
const { username, password, isAdmin } = req.body;
|
||||
@@ -138,7 +139,7 @@ export const createUser = async (req: Request, res: Response): Promise<void> =>
|
||||
|
||||
// Update an existing user (admin only)
|
||||
export const updateExistingUser = async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!(await requireAdmin(req, res))) return;
|
||||
|
||||
try {
|
||||
const { username } = req.params;
|
||||
@@ -154,7 +155,7 @@ export const updateExistingUser = async (req: Request, res: Response): Promise<v
|
||||
|
||||
// Check if trying to change admin status
|
||||
if (isAdmin !== undefined) {
|
||||
const currentUser = getUserByUsername(username);
|
||||
const currentUser = await getUserByUsername(username);
|
||||
if (!currentUser) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
@@ -164,7 +165,7 @@ export const updateExistingUser = async (req: Request, res: Response): Promise<v
|
||||
}
|
||||
|
||||
// Prevent removing admin status from the last admin
|
||||
if (currentUser.isAdmin && !isAdmin && getAdminCount() === 1) {
|
||||
if (currentUser.isAdmin && !isAdmin && (await getAdminCount()) === 1) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Cannot remove admin status from the last admin user',
|
||||
@@ -222,8 +223,8 @@ export const updateExistingUser = async (req: Request, res: Response): Promise<v
|
||||
};
|
||||
|
||||
// Delete a user (admin only)
|
||||
export const deleteExistingUser = (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
export const deleteExistingUser = async (req: Request, res: Response): Promise<void> => {
|
||||
if (!(await requireAdmin(req, res))) return;
|
||||
|
||||
try {
|
||||
const { username } = req.params;
|
||||
@@ -245,7 +246,7 @@ export const deleteExistingUser = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const success = deleteUser(username);
|
||||
const success = await deleteUser(username);
|
||||
if (!success) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -267,12 +268,12 @@ export const deleteExistingUser = (req: Request, res: Response): void => {
|
||||
};
|
||||
|
||||
// Get user statistics (admin only)
|
||||
export const getUserStats = (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
export const getUserStats = async (req: Request, res: Response): Promise<void> => {
|
||||
if (!(await requireAdmin(req, res))) return;
|
||||
|
||||
try {
|
||||
const totalUsers = getUserCount();
|
||||
const adminUsers = getAdminCount();
|
||||
const totalUsers = await getUserCount();
|
||||
const adminUsers = await getAdminCount();
|
||||
const regularUsers = totalUsers - adminUsers;
|
||||
|
||||
const response: ApiResponse = {
|
||||
|
||||
@@ -107,6 +107,26 @@ export function getDaoFactory(): DaoFactory {
|
||||
return daoFactory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to database-backed DAOs based on environment variable
|
||||
* This is synchronous and should be called during app initialization
|
||||
*/
|
||||
export function initializeDaoFactory(): void {
|
||||
// If USE_DB is explicitly set, use its value; otherwise, auto-detect based on DB_URL presence
|
||||
const useDatabase =
|
||||
process.env.USE_DB !== undefined ? process.env.USE_DB === 'true' : !!process.env.DB_URL;
|
||||
if (useDatabase) {
|
||||
console.log('Using database-backed DAO implementations');
|
||||
// Dynamic import to avoid circular dependencies
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const DatabaseDaoFactoryModule = require('./DatabaseDaoFactory.js');
|
||||
setDaoFactory(DatabaseDaoFactoryModule.DatabaseDaoFactory.getInstance());
|
||||
} else {
|
||||
console.log('Using file-based DAO implementations');
|
||||
setDaoFactory(JsonFileDaoFactory.getInstance());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience functions to get specific DAOs
|
||||
*/
|
||||
|
||||
79
src/dao/DatabaseDaoFactory.ts
Normal file
79
src/dao/DatabaseDaoFactory.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { DaoFactory, UserDao, ServerDao, GroupDao, SystemConfigDao, UserConfigDao } from './index.js';
|
||||
import { UserDaoDbImpl } from './UserDaoDbImpl.js';
|
||||
import { ServerDaoDbImpl } from './ServerDaoDbImpl.js';
|
||||
import { GroupDaoDbImpl } from './GroupDaoDbImpl.js';
|
||||
import { SystemConfigDaoDbImpl } from './SystemConfigDaoDbImpl.js';
|
||||
import { UserConfigDaoDbImpl } from './UserConfigDaoDbImpl.js';
|
||||
|
||||
/**
|
||||
* Database-backed DAO factory implementation
|
||||
*/
|
||||
export class DatabaseDaoFactory implements DaoFactory {
|
||||
private static instance: DatabaseDaoFactory;
|
||||
|
||||
private userDao: UserDao | null = null;
|
||||
private serverDao: ServerDao | null = null;
|
||||
private groupDao: GroupDao | null = null;
|
||||
private systemConfigDao: SystemConfigDao | null = null;
|
||||
private userConfigDao: UserConfigDao | null = null;
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
*/
|
||||
public static getInstance(): DatabaseDaoFactory {
|
||||
if (!DatabaseDaoFactory.instance) {
|
||||
DatabaseDaoFactory.instance = new DatabaseDaoFactory();
|
||||
}
|
||||
return DatabaseDaoFactory.instance;
|
||||
}
|
||||
|
||||
private constructor() {
|
||||
// Private constructor for singleton
|
||||
}
|
||||
|
||||
getUserDao(): UserDao {
|
||||
if (!this.userDao) {
|
||||
this.userDao = new UserDaoDbImpl();
|
||||
}
|
||||
return this.userDao!;
|
||||
}
|
||||
|
||||
getServerDao(): ServerDao {
|
||||
if (!this.serverDao) {
|
||||
this.serverDao = new ServerDaoDbImpl();
|
||||
}
|
||||
return this.serverDao!;
|
||||
}
|
||||
|
||||
getGroupDao(): GroupDao {
|
||||
if (!this.groupDao) {
|
||||
this.groupDao = new GroupDaoDbImpl();
|
||||
}
|
||||
return this.groupDao!;
|
||||
}
|
||||
|
||||
getSystemConfigDao(): SystemConfigDao {
|
||||
if (!this.systemConfigDao) {
|
||||
this.systemConfigDao = new SystemConfigDaoDbImpl();
|
||||
}
|
||||
return this.systemConfigDao!;
|
||||
}
|
||||
|
||||
getUserConfigDao(): UserConfigDao {
|
||||
if (!this.userConfigDao) {
|
||||
this.userConfigDao = new UserConfigDaoDbImpl();
|
||||
}
|
||||
return this.userConfigDao!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all cached DAO instances (useful for testing)
|
||||
*/
|
||||
public resetInstances(): void {
|
||||
this.userDao = null;
|
||||
this.serverDao = null;
|
||||
this.groupDao = null;
|
||||
this.systemConfigDao = null;
|
||||
this.userConfigDao = null;
|
||||
}
|
||||
}
|
||||
154
src/dao/GroupDaoDbImpl.ts
Normal file
154
src/dao/GroupDaoDbImpl.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { GroupDao } from './index.js';
|
||||
import { IGroup } from '../types/index.js';
|
||||
import { GroupRepository } from '../db/repositories/GroupRepository.js';
|
||||
|
||||
/**
|
||||
* Database-backed implementation of GroupDao
|
||||
*/
|
||||
export class GroupDaoDbImpl implements GroupDao {
|
||||
private repository: GroupRepository;
|
||||
|
||||
constructor() {
|
||||
this.repository = new GroupRepository();
|
||||
}
|
||||
|
||||
async findAll(): Promise<IGroup[]> {
|
||||
const groups = await this.repository.findAll();
|
||||
return groups.map((g) => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
description: g.description,
|
||||
servers: g.servers as any,
|
||||
owner: g.owner,
|
||||
}));
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<IGroup | null> {
|
||||
const group = await this.repository.findById(id);
|
||||
if (!group) return null;
|
||||
return {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
servers: group.servers as any,
|
||||
owner: group.owner,
|
||||
};
|
||||
}
|
||||
|
||||
async create(entity: Omit<IGroup, 'id'>): Promise<IGroup> {
|
||||
const group = await this.repository.create({
|
||||
name: entity.name,
|
||||
description: entity.description,
|
||||
servers: entity.servers as any,
|
||||
owner: entity.owner,
|
||||
});
|
||||
return {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
servers: group.servers as any,
|
||||
owner: group.owner,
|
||||
};
|
||||
}
|
||||
|
||||
async update(id: string, entity: Partial<IGroup>): Promise<IGroup | null> {
|
||||
const group = await this.repository.update(id, {
|
||||
name: entity.name,
|
||||
description: entity.description,
|
||||
servers: entity.servers as any,
|
||||
owner: entity.owner,
|
||||
});
|
||||
if (!group) return null;
|
||||
return {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
servers: group.servers as any,
|
||||
owner: group.owner,
|
||||
};
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
return await this.repository.delete(id);
|
||||
}
|
||||
|
||||
async exists(id: string): Promise<boolean> {
|
||||
return await this.repository.exists(id);
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
return await this.repository.count();
|
||||
}
|
||||
|
||||
async findByOwner(owner: string): Promise<IGroup[]> {
|
||||
const groups = await this.repository.findByOwner(owner);
|
||||
return groups.map((g) => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
description: g.description,
|
||||
servers: g.servers as any,
|
||||
owner: g.owner,
|
||||
}));
|
||||
}
|
||||
|
||||
async findByServer(serverName: string): Promise<IGroup[]> {
|
||||
const allGroups = await this.repository.findAll();
|
||||
return allGroups
|
||||
.filter((g) =>
|
||||
g.servers.some((s) => (typeof s === 'string' ? s === serverName : s.name === serverName)),
|
||||
)
|
||||
.map((g) => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
description: g.description,
|
||||
servers: g.servers as any,
|
||||
owner: g.owner,
|
||||
}));
|
||||
}
|
||||
|
||||
async addServerToGroup(groupId: string, serverName: string): Promise<boolean> {
|
||||
const group = await this.repository.findById(groupId);
|
||||
if (!group) return false;
|
||||
|
||||
// Check if server already exists
|
||||
const serverExists = group.servers.some((s) =>
|
||||
typeof s === 'string' ? s === serverName : s.name === serverName,
|
||||
);
|
||||
|
||||
if (!serverExists) {
|
||||
group.servers.push(serverName);
|
||||
await this.update(groupId, { servers: group.servers as any });
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async removeServerFromGroup(groupId: string, serverName: string): Promise<boolean> {
|
||||
const group = await this.repository.findById(groupId);
|
||||
if (!group) return false;
|
||||
|
||||
group.servers = group.servers.filter((s) =>
|
||||
typeof s === 'string' ? s !== serverName : s.name !== serverName,
|
||||
) as any;
|
||||
|
||||
await this.update(groupId, { servers: group.servers as any });
|
||||
return true;
|
||||
}
|
||||
|
||||
async updateServers(groupId: string, servers: string[] | IGroup['servers']): Promise<boolean> {
|
||||
const result = await this.update(groupId, { servers: servers as any });
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
async findByName(name: string): Promise<IGroup | null> {
|
||||
const group = await this.repository.findByName(name);
|
||||
if (!group) return null;
|
||||
return {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
servers: group.servers as any,
|
||||
owner: group.owner,
|
||||
};
|
||||
}
|
||||
}
|
||||
144
src/dao/ServerDaoDbImpl.ts
Normal file
144
src/dao/ServerDaoDbImpl.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { ServerDao, ServerConfigWithName } from './index.js';
|
||||
import { ServerRepository } from '../db/repositories/ServerRepository.js';
|
||||
|
||||
/**
|
||||
* Database-backed implementation of ServerDao
|
||||
*/
|
||||
export class ServerDaoDbImpl implements ServerDao {
|
||||
private repository: ServerRepository;
|
||||
|
||||
constructor() {
|
||||
this.repository = new ServerRepository();
|
||||
}
|
||||
|
||||
async findAll(): Promise<ServerConfigWithName[]> {
|
||||
const servers = await this.repository.findAll();
|
||||
return servers.map((s) => this.mapToServerConfig(s));
|
||||
}
|
||||
|
||||
async findById(name: string): Promise<ServerConfigWithName | null> {
|
||||
const server = await this.repository.findByName(name);
|
||||
return server ? this.mapToServerConfig(server) : null;
|
||||
}
|
||||
|
||||
async create(entity: ServerConfigWithName): Promise<ServerConfigWithName> {
|
||||
const server = await this.repository.create({
|
||||
name: entity.name,
|
||||
type: entity.type,
|
||||
url: entity.url,
|
||||
command: entity.command,
|
||||
args: entity.args,
|
||||
env: entity.env,
|
||||
headers: entity.headers,
|
||||
enabled: entity.enabled !== undefined ? entity.enabled : true,
|
||||
owner: entity.owner,
|
||||
keepAliveInterval: entity.keepAliveInterval,
|
||||
tools: entity.tools,
|
||||
prompts: entity.prompts,
|
||||
options: entity.options,
|
||||
oauth: entity.oauth,
|
||||
});
|
||||
return this.mapToServerConfig(server);
|
||||
}
|
||||
|
||||
async update(name: string, entity: Partial<ServerConfigWithName>): Promise<ServerConfigWithName | null> {
|
||||
const server = await this.repository.update(name, {
|
||||
type: entity.type,
|
||||
url: entity.url,
|
||||
command: entity.command,
|
||||
args: entity.args,
|
||||
env: entity.env,
|
||||
headers: entity.headers,
|
||||
enabled: entity.enabled,
|
||||
owner: entity.owner,
|
||||
keepAliveInterval: entity.keepAliveInterval,
|
||||
tools: entity.tools,
|
||||
prompts: entity.prompts,
|
||||
options: entity.options,
|
||||
oauth: entity.oauth,
|
||||
});
|
||||
return server ? this.mapToServerConfig(server) : null;
|
||||
}
|
||||
|
||||
async delete(name: string): Promise<boolean> {
|
||||
return await this.repository.delete(name);
|
||||
}
|
||||
|
||||
async exists(name: string): Promise<boolean> {
|
||||
return await this.repository.exists(name);
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
return await this.repository.count();
|
||||
}
|
||||
|
||||
async findByOwner(owner: string): Promise<ServerConfigWithName[]> {
|
||||
const servers = await this.repository.findByOwner(owner);
|
||||
return servers.map((s) => this.mapToServerConfig(s));
|
||||
}
|
||||
|
||||
async findEnabled(): Promise<ServerConfigWithName[]> {
|
||||
const servers = await this.repository.findEnabled();
|
||||
return servers.map((s) => this.mapToServerConfig(s));
|
||||
}
|
||||
|
||||
async findByType(type: string): Promise<ServerConfigWithName[]> {
|
||||
const allServers = await this.repository.findAll();
|
||||
return allServers.filter((s) => s.type === type).map((s) => this.mapToServerConfig(s));
|
||||
}
|
||||
|
||||
async setEnabled(name: string, enabled: boolean): Promise<boolean> {
|
||||
const server = await this.repository.setEnabled(name, enabled);
|
||||
return server !== null;
|
||||
}
|
||||
|
||||
async updateTools(
|
||||
name: string,
|
||||
tools: Record<string, { enabled: boolean; description?: string }>,
|
||||
): Promise<boolean> {
|
||||
const result = await this.update(name, { tools });
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
async updatePrompts(
|
||||
name: string,
|
||||
prompts: Record<string, { enabled: boolean; description?: string }>,
|
||||
): Promise<boolean> {
|
||||
const result = await this.update(name, { prompts });
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
private mapToServerConfig(server: {
|
||||
name: string;
|
||||
type?: string;
|
||||
url?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
headers?: Record<string, string>;
|
||||
enabled: boolean;
|
||||
owner?: string;
|
||||
keepAliveInterval?: number;
|
||||
tools?: Record<string, { enabled: boolean; description?: string }>;
|
||||
prompts?: Record<string, { enabled: boolean; description?: string }>;
|
||||
options?: Record<string, any>;
|
||||
oauth?: Record<string, any>;
|
||||
}): ServerConfigWithName {
|
||||
return {
|
||||
name: server.name,
|
||||
type: server.type as 'stdio' | 'sse' | 'streamable-http' | 'openapi' | undefined,
|
||||
url: server.url,
|
||||
command: server.command,
|
||||
args: server.args,
|
||||
env: server.env,
|
||||
headers: server.headers,
|
||||
enabled: server.enabled,
|
||||
owner: server.owner,
|
||||
keepAliveInterval: server.keepAliveInterval,
|
||||
tools: server.tools,
|
||||
prompts: server.prompts,
|
||||
options: server.options,
|
||||
oauth: server.oauth,
|
||||
};
|
||||
}
|
||||
}
|
||||
68
src/dao/SystemConfigDaoDbImpl.ts
Normal file
68
src/dao/SystemConfigDaoDbImpl.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { SystemConfigDao } from './index.js';
|
||||
import { SystemConfig } from '../types/index.js';
|
||||
import { SystemConfigRepository } from '../db/repositories/SystemConfigRepository.js';
|
||||
|
||||
/**
|
||||
* Database-backed implementation of SystemConfigDao
|
||||
*/
|
||||
export class SystemConfigDaoDbImpl implements SystemConfigDao {
|
||||
private repository: SystemConfigRepository;
|
||||
|
||||
constructor() {
|
||||
this.repository = new SystemConfigRepository();
|
||||
}
|
||||
|
||||
async get(): Promise<SystemConfig> {
|
||||
const config = await this.repository.get();
|
||||
return {
|
||||
routing: config.routing as any,
|
||||
install: config.install as any,
|
||||
smartRouting: config.smartRouting as any,
|
||||
mcpRouter: config.mcpRouter as any,
|
||||
nameSeparator: config.nameSeparator,
|
||||
oauth: config.oauth as any,
|
||||
oauthServer: config.oauthServer as any,
|
||||
enableSessionRebuild: config.enableSessionRebuild,
|
||||
};
|
||||
}
|
||||
|
||||
async update(config: Partial<SystemConfig>): Promise<SystemConfig> {
|
||||
const updated = await this.repository.update(config as any);
|
||||
return {
|
||||
routing: updated.routing as any,
|
||||
install: updated.install as any,
|
||||
smartRouting: updated.smartRouting as any,
|
||||
mcpRouter: updated.mcpRouter as any,
|
||||
nameSeparator: updated.nameSeparator,
|
||||
oauth: updated.oauth as any,
|
||||
oauthServer: updated.oauthServer as any,
|
||||
enableSessionRebuild: updated.enableSessionRebuild,
|
||||
};
|
||||
}
|
||||
|
||||
async reset(): Promise<SystemConfig> {
|
||||
const config = await this.repository.reset();
|
||||
return {
|
||||
routing: config.routing as any,
|
||||
install: config.install as any,
|
||||
smartRouting: config.smartRouting as any,
|
||||
mcpRouter: config.mcpRouter as any,
|
||||
nameSeparator: config.nameSeparator,
|
||||
oauth: config.oauth as any,
|
||||
oauthServer: config.oauthServer as any,
|
||||
enableSessionRebuild: config.enableSessionRebuild,
|
||||
};
|
||||
}
|
||||
|
||||
async getSection<K extends keyof SystemConfig>(section: K): Promise<SystemConfig[K]> {
|
||||
return (await this.repository.getSection(section)) as any;
|
||||
}
|
||||
|
||||
async updateSection<K extends keyof SystemConfig>(
|
||||
section: K,
|
||||
value: SystemConfig[K],
|
||||
): Promise<boolean> {
|
||||
await this.repository.updateSection(section, value as any);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
79
src/dao/UserConfigDaoDbImpl.ts
Normal file
79
src/dao/UserConfigDaoDbImpl.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { UserConfigDao } from './index.js';
|
||||
import { UserConfig } from '../types/index.js';
|
||||
import { UserConfigRepository } from '../db/repositories/UserConfigRepository.js';
|
||||
|
||||
/**
|
||||
* Database-backed implementation of UserConfigDao
|
||||
*/
|
||||
export class UserConfigDaoDbImpl implements UserConfigDao {
|
||||
private repository: UserConfigRepository;
|
||||
|
||||
constructor() {
|
||||
this.repository = new UserConfigRepository();
|
||||
}
|
||||
|
||||
async getAll(): Promise<Record<string, UserConfig>> {
|
||||
const configs = await this.repository.getAll();
|
||||
const result: Record<string, UserConfig> = {};
|
||||
|
||||
for (const [username, config] of Object.entries(configs)) {
|
||||
result[username] = {
|
||||
routing: config.routing,
|
||||
...config.additionalConfig,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async get(username: string): Promise<UserConfig> {
|
||||
const config = await this.repository.get(username);
|
||||
if (!config) {
|
||||
return { routing: {} };
|
||||
}
|
||||
return {
|
||||
routing: config.routing,
|
||||
...config.additionalConfig,
|
||||
};
|
||||
}
|
||||
|
||||
async update(username: string, config: Partial<UserConfig>): Promise<UserConfig> {
|
||||
const { routing, ...additionalConfig } = config;
|
||||
const updated = await this.repository.update(username, {
|
||||
routing,
|
||||
additionalConfig,
|
||||
});
|
||||
return {
|
||||
routing: updated.routing,
|
||||
...updated.additionalConfig,
|
||||
};
|
||||
}
|
||||
|
||||
async delete(username: string): Promise<boolean> {
|
||||
return await this.repository.delete(username);
|
||||
}
|
||||
|
||||
async getSection<K extends keyof UserConfig>(username: string, section: K): Promise<UserConfig[K]> {
|
||||
const config = await this.get(username);
|
||||
return config[section];
|
||||
}
|
||||
|
||||
async updateSection<K extends keyof UserConfig>(
|
||||
username: string,
|
||||
section: K,
|
||||
value: UserConfig[K],
|
||||
): Promise<boolean> {
|
||||
await this.update(username, { [section]: value } as Partial<UserConfig>);
|
||||
return true;
|
||||
}
|
||||
|
||||
async exists(username: string): Promise<boolean> {
|
||||
const config = await this.repository.get(username);
|
||||
return config !== null;
|
||||
}
|
||||
|
||||
async reset(username: string): Promise<UserConfig> {
|
||||
await this.repository.delete(username);
|
||||
return { routing: {} };
|
||||
}
|
||||
}
|
||||
108
src/dao/UserDaoDbImpl.ts
Normal file
108
src/dao/UserDaoDbImpl.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import bcrypt from 'bcrypt';
|
||||
import { UserDao } from './index.js';
|
||||
import { IUser } from '../types/index.js';
|
||||
import { UserRepository } from '../db/repositories/UserRepository.js';
|
||||
|
||||
/**
|
||||
* Database-backed implementation of UserDao
|
||||
*/
|
||||
export class UserDaoDbImpl implements UserDao {
|
||||
private repository: UserRepository;
|
||||
|
||||
constructor() {
|
||||
this.repository = new UserRepository();
|
||||
}
|
||||
|
||||
async findAll(): Promise<IUser[]> {
|
||||
const users = await this.repository.findAll();
|
||||
return users.map((u) => ({
|
||||
username: u.username,
|
||||
password: u.password,
|
||||
isAdmin: u.isAdmin,
|
||||
}));
|
||||
}
|
||||
|
||||
async findById(username: string): Promise<IUser | null> {
|
||||
const user = await this.repository.findByUsername(username);
|
||||
if (!user) return null;
|
||||
return {
|
||||
username: user.username,
|
||||
password: user.password,
|
||||
isAdmin: user.isAdmin,
|
||||
};
|
||||
}
|
||||
|
||||
async findByUsername(username: string): Promise<IUser | null> {
|
||||
return await this.findById(username);
|
||||
}
|
||||
|
||||
async create(entity: Omit<IUser, 'id'>): Promise<IUser> {
|
||||
const user = await this.repository.create({
|
||||
username: entity.username,
|
||||
password: entity.password,
|
||||
isAdmin: entity.isAdmin || false,
|
||||
});
|
||||
return {
|
||||
username: user.username,
|
||||
password: user.password,
|
||||
isAdmin: user.isAdmin,
|
||||
};
|
||||
}
|
||||
|
||||
async createWithHashedPassword(
|
||||
username: string,
|
||||
password: string,
|
||||
isAdmin: boolean,
|
||||
): Promise<IUser> {
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
return await this.create({ username, password: hashedPassword, isAdmin });
|
||||
}
|
||||
|
||||
async update(username: string, entity: Partial<IUser>): Promise<IUser | null> {
|
||||
const user = await this.repository.update(username, {
|
||||
password: entity.password,
|
||||
isAdmin: entity.isAdmin,
|
||||
});
|
||||
if (!user) return null;
|
||||
return {
|
||||
username: user.username,
|
||||
password: user.password,
|
||||
isAdmin: user.isAdmin,
|
||||
};
|
||||
}
|
||||
|
||||
async delete(username: string): Promise<boolean> {
|
||||
return await this.repository.delete(username);
|
||||
}
|
||||
|
||||
async exists(username: string): Promise<boolean> {
|
||||
return await this.repository.exists(username);
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
return await this.repository.count();
|
||||
}
|
||||
|
||||
async validateCredentials(username: string, password: string): Promise<boolean> {
|
||||
const user = await this.findByUsername(username);
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
return await bcrypt.compare(password, user.password);
|
||||
}
|
||||
|
||||
async updatePassword(username: string, newPassword: string): Promise<boolean> {
|
||||
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
||||
const result = await this.update(username, { password: hashedPassword });
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
async findAdmins(): Promise<IUser[]> {
|
||||
const users = await this.repository.findAdmins();
|
||||
return users.map((u) => ({
|
||||
username: u.username,
|
||||
password: u.password,
|
||||
isAdmin: u.isAdmin,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -187,7 +187,7 @@ export async function exampleUserConfigOperations() {
|
||||
console.log('All user configs:', Object.keys(allUserConfigs));
|
||||
|
||||
// Get specific section for user
|
||||
const userRoutingConfig = await userConfigDao.getSection('admin', 'routing');
|
||||
const userRoutingConfig = await userConfigDao.getSection('admin', 'routing' as never);
|
||||
console.log('Admin routing config:', userRoutingConfig);
|
||||
|
||||
// Delete user configuration
|
||||
|
||||
@@ -7,5 +7,13 @@ export * from './GroupDao.js';
|
||||
export * from './SystemConfigDao.js';
|
||||
export * from './UserConfigDao.js';
|
||||
|
||||
// Export database implementations
|
||||
export * from './UserDaoDbImpl.js';
|
||||
export * from './ServerDaoDbImpl.js';
|
||||
export * from './GroupDaoDbImpl.js';
|
||||
export * from './SystemConfigDaoDbImpl.js';
|
||||
export * from './UserConfigDaoDbImpl.js';
|
||||
|
||||
// Export the DAO factory and convenience functions
|
||||
export * from './DaoFactory.js';
|
||||
export * from './DatabaseDaoFactory.js';
|
||||
|
||||
36
src/db/entities/Group.ts
Normal file
36
src/db/entities/Group.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* Group entity for database storage
|
||||
*/
|
||||
@Entity({ name: 'groups' })
|
||||
export class Group {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description?: string;
|
||||
|
||||
@Column({ type: 'simple-json' })
|
||||
servers: Array<string | { name: string; tools?: string[] | 'all' }>;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
owner?: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export default Group;
|
||||
66
src/db/entities/Server.ts
Normal file
66
src/db/entities/Server.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* Server configuration entity for database storage
|
||||
*/
|
||||
@Entity({ name: 'servers' })
|
||||
export class Server {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, unique: true })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||
type?: string; // 'stdio', 'sse', 'streamable-http', 'openapi'
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
url?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 500, nullable: true })
|
||||
command?: string;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
args?: string[];
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
env?: Record<string, string>;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
headers?: Record<string, string>;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
enabled: boolean;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
owner?: string;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
keepAliveInterval?: number;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
tools?: Record<string, { enabled: boolean; description?: string }>;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
prompts?: Record<string, { enabled: boolean; description?: string }>;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
options?: Record<string, any>;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
oauth?: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export default Server;
|
||||
43
src/db/entities/SystemConfig.ts
Normal file
43
src/db/entities/SystemConfig.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Entity, Column, PrimaryColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* System configuration entity for database storage
|
||||
* Using singleton pattern - only one record with id = 'default'
|
||||
*/
|
||||
@Entity({ name: 'system_config' })
|
||||
export class SystemConfig {
|
||||
@PrimaryColumn({ type: 'varchar', length: 50, default: 'default' })
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
routing?: Record<string, any>;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
install?: Record<string, any>;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
smartRouting?: Record<string, any>;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
mcpRouter?: Record<string, any>;
|
||||
|
||||
@Column({ type: 'varchar', length: 10, nullable: true })
|
||||
nameSeparator?: string;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
oauth?: Record<string, any>;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
oauthServer?: Record<string, any>;
|
||||
|
||||
@Column({ type: 'boolean', nullable: true })
|
||||
enableSessionRebuild?: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export default SystemConfig;
|
||||
33
src/db/entities/User.ts
Normal file
33
src/db/entities/User.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* User entity for database storage
|
||||
*/
|
||||
@Entity({ name: 'users' })
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, unique: true })
|
||||
username: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
password: string;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isAdmin: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export default User;
|
||||
33
src/db/entities/UserConfig.ts
Normal file
33
src/db/entities/UserConfig.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* User configuration entity for database storage
|
||||
*/
|
||||
@Entity({ name: 'user_configs' })
|
||||
export class UserConfig {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, unique: true })
|
||||
username: string;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
routing?: Record<string, any>;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
additionalConfig?: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export default UserConfig;
|
||||
@@ -1,7 +1,12 @@
|
||||
import { VectorEmbedding } from './VectorEmbedding.js';
|
||||
import User from './User.js';
|
||||
import Server from './Server.js';
|
||||
import Group from './Group.js';
|
||||
import SystemConfig from './SystemConfig.js';
|
||||
import UserConfig from './UserConfig.js';
|
||||
|
||||
// Export all entities
|
||||
export default [VectorEmbedding];
|
||||
export default [VectorEmbedding, User, Server, Group, SystemConfig, UserConfig];
|
||||
|
||||
// Export individual entities for direct use
|
||||
export { VectorEmbedding };
|
||||
export { VectorEmbedding, User, Server, Group, SystemConfig, UserConfig };
|
||||
|
||||
95
src/db/repositories/GroupRepository.ts
Normal file
95
src/db/repositories/GroupRepository.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Repository } from 'typeorm';
|
||||
import { Group } from '../entities/Group.js';
|
||||
import { getAppDataSource } from '../connection.js';
|
||||
|
||||
/**
|
||||
* Repository for Group entity
|
||||
*/
|
||||
export class GroupRepository {
|
||||
private repository: Repository<Group>;
|
||||
|
||||
constructor() {
|
||||
this.repository = getAppDataSource().getRepository(Group);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all groups
|
||||
*/
|
||||
async findAll(): Promise<Group[]> {
|
||||
return await this.repository.find();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find group by ID
|
||||
*/
|
||||
async findById(id: string): Promise<Group | null> {
|
||||
return await this.repository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find group by name
|
||||
*/
|
||||
async findByName(name: string): Promise<Group | null> {
|
||||
return await this.repository.findOne({ where: { name } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new group
|
||||
*/
|
||||
async create(group: Omit<Group, 'id' | 'createdAt' | 'updatedAt'>): Promise<Group> {
|
||||
const newGroup = this.repository.create(group);
|
||||
return await this.repository.save(newGroup);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing group
|
||||
*/
|
||||
async update(id: string, groupData: Partial<Group>): Promise<Group | null> {
|
||||
const group = await this.findById(id);
|
||||
if (!group) {
|
||||
return null;
|
||||
}
|
||||
const updated = this.repository.merge(group, groupData);
|
||||
return await this.repository.save(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a group
|
||||
*/
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const result = await this.repository.delete({ id });
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if group exists by ID
|
||||
*/
|
||||
async exists(id: string): Promise<boolean> {
|
||||
const count = await this.repository.count({ where: { id } });
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if group exists by name
|
||||
*/
|
||||
async existsByName(name: string): Promise<boolean> {
|
||||
const count = await this.repository.count({ where: { name } });
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total groups
|
||||
*/
|
||||
async count(): Promise<number> {
|
||||
return await this.repository.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find groups by owner
|
||||
*/
|
||||
async findByOwner(owner: string): Promise<Group[]> {
|
||||
return await this.repository.find({ where: { owner } });
|
||||
}
|
||||
}
|
||||
|
||||
export default GroupRepository;
|
||||
94
src/db/repositories/ServerRepository.ts
Normal file
94
src/db/repositories/ServerRepository.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Repository } from 'typeorm';
|
||||
import { Server } from '../entities/Server.js';
|
||||
import { getAppDataSource } from '../connection.js';
|
||||
|
||||
/**
|
||||
* Repository for Server entity
|
||||
*/
|
||||
export class ServerRepository {
|
||||
private repository: Repository<Server>;
|
||||
|
||||
constructor() {
|
||||
this.repository = getAppDataSource().getRepository(Server);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all servers
|
||||
*/
|
||||
async findAll(): Promise<Server[]> {
|
||||
return await this.repository.find();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find server by name
|
||||
*/
|
||||
async findByName(name: string): Promise<Server | null> {
|
||||
return await this.repository.findOne({ where: { name } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new server
|
||||
*/
|
||||
async create(server: Omit<Server, 'id' | 'createdAt' | 'updatedAt'>): Promise<Server> {
|
||||
const newServer = this.repository.create(server);
|
||||
return await this.repository.save(newServer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing server
|
||||
*/
|
||||
async update(name: string, serverData: Partial<Server>): Promise<Server | null> {
|
||||
const server = await this.findByName(name);
|
||||
if (!server) {
|
||||
return null;
|
||||
}
|
||||
const updated = this.repository.merge(server, serverData);
|
||||
return await this.repository.save(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a server
|
||||
*/
|
||||
async delete(name: string): Promise<boolean> {
|
||||
const result = await this.repository.delete({ name });
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if server exists
|
||||
*/
|
||||
async exists(name: string): Promise<boolean> {
|
||||
const count = await this.repository.count({ where: { name } });
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total servers
|
||||
*/
|
||||
async count(): Promise<number> {
|
||||
return await this.repository.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find servers by owner
|
||||
*/
|
||||
async findByOwner(owner: string): Promise<Server[]> {
|
||||
return await this.repository.find({ where: { owner } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find enabled servers
|
||||
*/
|
||||
async findEnabled(): Promise<Server[]> {
|
||||
return await this.repository.find({ where: { enabled: true } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set server enabled status
|
||||
*/
|
||||
async setEnabled(name: string, enabled: boolean): Promise<Server | null> {
|
||||
return await this.update(name, { enabled });
|
||||
}
|
||||
}
|
||||
|
||||
export default ServerRepository;
|
||||
78
src/db/repositories/SystemConfigRepository.ts
Normal file
78
src/db/repositories/SystemConfigRepository.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Repository } from 'typeorm';
|
||||
import { SystemConfig } from '../entities/SystemConfig.js';
|
||||
import { getAppDataSource } from '../connection.js';
|
||||
|
||||
/**
|
||||
* Repository for SystemConfig entity
|
||||
* Uses singleton pattern with id = 'default'
|
||||
*/
|
||||
export class SystemConfigRepository {
|
||||
private repository: Repository<SystemConfig>;
|
||||
private readonly DEFAULT_ID = 'default';
|
||||
|
||||
constructor() {
|
||||
this.repository = getAppDataSource().getRepository(SystemConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get system configuration (singleton)
|
||||
*/
|
||||
async get(): Promise<SystemConfig> {
|
||||
let config = await this.repository.findOne({ where: { id: this.DEFAULT_ID } });
|
||||
|
||||
// Create default if doesn't exist
|
||||
if (!config) {
|
||||
config = this.repository.create({
|
||||
id: this.DEFAULT_ID,
|
||||
routing: {},
|
||||
install: {},
|
||||
smartRouting: {},
|
||||
mcpRouter: {},
|
||||
nameSeparator: '-',
|
||||
oauth: {},
|
||||
oauthServer: {},
|
||||
enableSessionRebuild: false,
|
||||
});
|
||||
config = await this.repository.save(config);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update system configuration
|
||||
*/
|
||||
async update(configData: Partial<SystemConfig>): Promise<SystemConfig> {
|
||||
const config = await this.get();
|
||||
const updated = this.repository.merge(config, configData);
|
||||
return await this.repository.save(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset system configuration to defaults
|
||||
*/
|
||||
async reset(): Promise<SystemConfig> {
|
||||
await this.repository.delete({ id: this.DEFAULT_ID });
|
||||
return await this.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific configuration section
|
||||
*/
|
||||
async getSection<K extends keyof SystemConfig>(section: K): Promise<SystemConfig[K]> {
|
||||
const config = await this.get();
|
||||
return config[section];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a specific configuration section
|
||||
*/
|
||||
async updateSection<K extends keyof SystemConfig>(
|
||||
section: K,
|
||||
value: SystemConfig[K],
|
||||
): Promise<SystemConfig> {
|
||||
return await this.update({ [section]: value } as Partial<SystemConfig>);
|
||||
}
|
||||
}
|
||||
|
||||
export default SystemConfigRepository;
|
||||
84
src/db/repositories/UserConfigRepository.ts
Normal file
84
src/db/repositories/UserConfigRepository.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Repository } from 'typeorm';
|
||||
import { UserConfig } from '../entities/UserConfig.js';
|
||||
import { getAppDataSource } from '../connection.js';
|
||||
|
||||
/**
|
||||
* Repository for UserConfig entity
|
||||
*/
|
||||
export class UserConfigRepository {
|
||||
private repository: Repository<UserConfig>;
|
||||
|
||||
constructor() {
|
||||
this.repository = getAppDataSource().getRepository(UserConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all user configs
|
||||
*/
|
||||
async getAll(): Promise<Record<string, UserConfig>> {
|
||||
const configs = await this.repository.find();
|
||||
const result: Record<string, UserConfig> = {};
|
||||
for (const config of configs) {
|
||||
result[config.username] = config;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user config by username
|
||||
*/
|
||||
async get(username: string): Promise<UserConfig | null> {
|
||||
return await this.repository.findOne({ where: { username } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user config
|
||||
*/
|
||||
async update(username: string, configData: Partial<UserConfig>): Promise<UserConfig> {
|
||||
let config = await this.get(username);
|
||||
|
||||
if (!config) {
|
||||
// Create new config if doesn't exist
|
||||
config = this.repository.create({
|
||||
username,
|
||||
routing: {},
|
||||
additionalConfig: {},
|
||||
...configData,
|
||||
});
|
||||
} else {
|
||||
// Merge with existing config
|
||||
config = this.repository.merge(config, configData);
|
||||
}
|
||||
|
||||
return await this.repository.save(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete user config
|
||||
*/
|
||||
async delete(username: string): Promise<boolean> {
|
||||
const result = await this.repository.delete({ username });
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific configuration section for a user
|
||||
*/
|
||||
async getSection<K extends keyof UserConfig>(username: string, section: K): Promise<UserConfig[K] | null> {
|
||||
const config = await this.get(username);
|
||||
return config ? config[section] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a specific configuration section for a user
|
||||
*/
|
||||
async updateSection<K extends keyof UserConfig>(
|
||||
username: string,
|
||||
section: K,
|
||||
value: UserConfig[K],
|
||||
): Promise<UserConfig> {
|
||||
return await this.update(username, { [section]: value } as Partial<UserConfig>);
|
||||
}
|
||||
}
|
||||
|
||||
export default UserConfigRepository;
|
||||
80
src/db/repositories/UserRepository.ts
Normal file
80
src/db/repositories/UserRepository.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Repository } from 'typeorm';
|
||||
import { User } from '../entities/User.js';
|
||||
import { getAppDataSource } from '../connection.js';
|
||||
|
||||
/**
|
||||
* Repository for User entity
|
||||
*/
|
||||
export class UserRepository {
|
||||
private repository: Repository<User>;
|
||||
|
||||
constructor() {
|
||||
this.repository = getAppDataSource().getRepository(User);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all users
|
||||
*/
|
||||
async findAll(): Promise<User[]> {
|
||||
return await this.repository.find();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user by username
|
||||
*/
|
||||
async findByUsername(username: string): Promise<User | null> {
|
||||
return await this.repository.findOne({ where: { username } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user
|
||||
*/
|
||||
async create(user: Omit<User, 'id' | 'createdAt' | 'updatedAt'>): Promise<User> {
|
||||
const newUser = this.repository.create(user);
|
||||
return await this.repository.save(newUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing user
|
||||
*/
|
||||
async update(username: string, userData: Partial<User>): Promise<User | null> {
|
||||
const user = await this.findByUsername(username);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
const updated = this.repository.merge(user, userData);
|
||||
return await this.repository.save(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user
|
||||
*/
|
||||
async delete(username: string): Promise<boolean> {
|
||||
const result = await this.repository.delete({ username });
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user exists
|
||||
*/
|
||||
async exists(username: string): Promise<boolean> {
|
||||
const count = await this.repository.count({ where: { username } });
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total users
|
||||
*/
|
||||
async count(): Promise<number> {
|
||||
return await this.repository.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all admin users
|
||||
*/
|
||||
async findAdmins(): Promise<User[]> {
|
||||
return await this.repository.find({ where: { isAdmin: true } });
|
||||
}
|
||||
}
|
||||
|
||||
export default UserRepository;
|
||||
@@ -1,4 +1,16 @@
|
||||
import VectorEmbeddingRepository from './VectorEmbeddingRepository.js';
|
||||
import { UserRepository } from './UserRepository.js';
|
||||
import { ServerRepository } from './ServerRepository.js';
|
||||
import { GroupRepository } from './GroupRepository.js';
|
||||
import { SystemConfigRepository } from './SystemConfigRepository.js';
|
||||
import { UserConfigRepository } from './UserConfigRepository.js';
|
||||
|
||||
// Export all repositories
|
||||
export { VectorEmbeddingRepository };
|
||||
export {
|
||||
VectorEmbeddingRepository,
|
||||
UserRepository,
|
||||
ServerRepository,
|
||||
GroupRepository,
|
||||
SystemConfigRepository,
|
||||
UserConfigRepository,
|
||||
};
|
||||
|
||||
14
src/index.ts
14
src/index.ts
@@ -1,10 +1,24 @@
|
||||
import 'reflect-metadata';
|
||||
import AppServer from './server.js';
|
||||
import { initializeDatabaseMode } from './utils/migration.js';
|
||||
|
||||
const appServer = new AppServer();
|
||||
|
||||
async function boot() {
|
||||
try {
|
||||
// Check if database mode is enabled
|
||||
// If USE_DB is explicitly set, use its value; otherwise, auto-detect based on DB_URL presence
|
||||
const useDatabase =
|
||||
process.env.USE_DB !== undefined ? process.env.USE_DB === 'true' : !!process.env.DB_URL;
|
||||
if (useDatabase) {
|
||||
console.log('Database mode enabled, initializing...');
|
||||
const dbInitialized = await initializeDatabaseMode();
|
||||
if (!dbInitialized) {
|
||||
console.error('Failed to initialize database mode');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
await appServer.initialize();
|
||||
appServer.start();
|
||||
} catch (error) {
|
||||
|
||||
@@ -72,8 +72,8 @@ export const auth = async (req: Request, res: Response, next: NextFunction): Pro
|
||||
if (oauthToken && oauthToken.accessToken === accessToken) {
|
||||
// Valid OAuth token - look up user to get admin status
|
||||
const { findUserByUsername } = await import('../models/User.js');
|
||||
const user = findUserByUsername(oauthToken.username);
|
||||
|
||||
const user = await findUserByUsername(oauthToken.username);
|
||||
|
||||
// Set user context with proper admin status
|
||||
(req as any).user = {
|
||||
username: oauthToken.username,
|
||||
|
||||
@@ -76,7 +76,7 @@ export const sseUserContextMiddleware = async (
|
||||
const rawAuthHeader = Array.isArray(req.headers.authorization)
|
||||
? req.headers.authorization[0]
|
||||
: req.headers.authorization;
|
||||
const bearerUser = resolveOAuthUserFromAuthHeader(rawAuthHeader);
|
||||
const bearerUser = await resolveOAuthUserFromAuthHeader(rawAuthHeader);
|
||||
|
||||
if (bearerUser) {
|
||||
userContextService.setCurrentUser(bearerUser);
|
||||
|
||||
@@ -1,58 +1,43 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { IUser } from '../types/index.js';
|
||||
import { loadSettings, saveSettings } from '../config/index.js';
|
||||
import { getUserDao } from '../dao/index.js';
|
||||
|
||||
// Get all users
|
||||
export const getUsers = (): IUser[] => {
|
||||
export const getUsers = async (): Promise<IUser[]> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
return settings.users || [];
|
||||
const userDao = getUserDao();
|
||||
return await userDao.findAll();
|
||||
} catch (error) {
|
||||
console.error('Error reading users from settings:', error);
|
||||
console.error('Error reading users:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Save users to settings
|
||||
const saveUsers = (users: IUser[]): void => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
settings.users = users;
|
||||
saveSettings(settings);
|
||||
} catch (error) {
|
||||
console.error('Error saving users to settings:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Create a new user
|
||||
export const createUser = async (userData: IUser): Promise<IUser | null> => {
|
||||
const users = getUsers();
|
||||
|
||||
// Check if username already exists
|
||||
if (users.some((user) => user.username === userData.username)) {
|
||||
try {
|
||||
const userDao = getUserDao();
|
||||
return await userDao.createWithHashedPassword(
|
||||
userData.username,
|
||||
userData.password,
|
||||
userData.isAdmin,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error creating user:', error);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Hash the password
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
const hashedPassword = await bcrypt.hash(userData.password, salt);
|
||||
|
||||
const newUser = {
|
||||
username: userData.username,
|
||||
password: hashedPassword,
|
||||
isAdmin: userData.isAdmin || false,
|
||||
};
|
||||
|
||||
users.push(newUser);
|
||||
saveUsers(users);
|
||||
|
||||
return newUser;
|
||||
};
|
||||
|
||||
// Find user by username
|
||||
export const findUserByUsername = (username: string): IUser | undefined => {
|
||||
const users = getUsers();
|
||||
return users.find((user) => user.username === username);
|
||||
export const findUserByUsername = async (username: string): Promise<IUser | undefined> => {
|
||||
try {
|
||||
const userDao = getUserDao();
|
||||
const user = await userDao.findByUsername(username);
|
||||
return user || undefined;
|
||||
} catch (error) {
|
||||
console.error('Error finding user:', error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Verify user password
|
||||
@@ -68,34 +53,22 @@ export const updateUserPassword = async (
|
||||
username: string,
|
||||
newPassword: string,
|
||||
): Promise<boolean> => {
|
||||
const users = getUsers();
|
||||
const userIndex = users.findIndex((user) => user.username === username);
|
||||
|
||||
if (userIndex === -1) {
|
||||
try {
|
||||
const userDao = getUserDao();
|
||||
return await userDao.updatePassword(username, newPassword);
|
||||
} catch (error) {
|
||||
console.error('Error updating password:', error);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Hash the new password
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
const hashedPassword = await bcrypt.hash(newPassword, salt);
|
||||
|
||||
// Update the user's password
|
||||
users[userIndex].password = hashedPassword;
|
||||
saveUsers(users);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Initialize with default admin user if no users exist
|
||||
export const initializeDefaultUser = async (): Promise<void> => {
|
||||
const users = getUsers();
|
||||
const userDao = getUserDao();
|
||||
const users = await userDao.findAll();
|
||||
|
||||
if (users.length === 0) {
|
||||
await createUser({
|
||||
username: 'admin',
|
||||
password: 'admin123',
|
||||
isAdmin: true,
|
||||
});
|
||||
await userDao.createWithHashedPassword('admin', 'admin123', true);
|
||||
console.log('Default admin user created');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import config from '../config/index.js';
|
||||
import {
|
||||
getAllServers,
|
||||
getAllSettings,
|
||||
getServerConfig,
|
||||
createServer,
|
||||
updateServer,
|
||||
deleteServer,
|
||||
@@ -129,6 +130,7 @@ export const initRoutes = (app: express.Application): void => {
|
||||
|
||||
// API routes protected by auth middleware in middlewares/index.ts
|
||||
router.get('/servers', getAllServers);
|
||||
router.get('/servers/:name', getServerConfig);
|
||||
router.get('/settings', getAllSettings);
|
||||
router.post('/servers', createServer);
|
||||
router.put('/servers/:name', updateServer);
|
||||
|
||||
5
src/scripts/migrate-to-database.ts
Normal file
5
src/scripts/migrate-to-database.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
import 'reflect-metadata';
|
||||
import { runMigrationCli } from '../utils/migration.js';
|
||||
|
||||
runMigrationCli();
|
||||
@@ -61,7 +61,7 @@ export class AppServer {
|
||||
await initializeDefaultUser();
|
||||
|
||||
// Initialize OAuth provider if configured (for proxying upstream MCP OAuth)
|
||||
initOAuthProvider();
|
||||
await initOAuthProvider();
|
||||
const oauthRouter = getOAuthRouter();
|
||||
if (oauthRouter) {
|
||||
// Mount OAuth router at the root level (before other routes)
|
||||
@@ -71,7 +71,7 @@ export class AppServer {
|
||||
}
|
||||
|
||||
// Initialize OAuth authorization server (for MCPHub's own OAuth)
|
||||
initOAuthServer();
|
||||
await initOAuthServer();
|
||||
|
||||
initMiddlewares(this.app);
|
||||
initRoutes(this.app);
|
||||
@@ -103,8 +103,10 @@ export class AppServer {
|
||||
);
|
||||
|
||||
// User-scoped routes with user context middleware
|
||||
this.app.get(`${this.basePath}/:user/sse/:group(.*)?`, sseUserContextMiddleware, (req, res) =>
|
||||
handleSseConnection(req, res),
|
||||
this.app.get(
|
||||
`${this.basePath}/:user/sse/:group(.*)?`,
|
||||
sseUserContextMiddleware,
|
||||
(req, res) => handleSseConnection(req, res),
|
||||
);
|
||||
this.app.post(
|
||||
`${this.basePath}/:user/messages`,
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { DataService } from './dataService.js';
|
||||
import { getDataService } from './services.js';
|
||||
import './services.js';
|
||||
|
||||
describe('DataService', () => {
|
||||
test('should get default implementation and call foo method', async () => {
|
||||
const dataService: DataService = await getDataService();
|
||||
const consoleSpy = jest.spyOn(console, 'log');
|
||||
dataService.foo();
|
||||
expect(consoleSpy).toHaveBeenCalledWith('default implementation');
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,31 +1,69 @@
|
||||
import { IUser, McpSettings } from '../types/index.js';
|
||||
import { UserContextService } from './userContextService.js';
|
||||
import { UserConfig } from '../types/index.js';
|
||||
|
||||
export interface DataService {
|
||||
foo(): void;
|
||||
filterData(data: any[], user?: IUser): any[];
|
||||
filterSettings(settings: McpSettings, user?: IUser): McpSettings;
|
||||
mergeSettings(all: McpSettings, newSettings: McpSettings, user?: IUser): McpSettings;
|
||||
getPermissions(user: IUser): string[];
|
||||
}
|
||||
|
||||
export class DataServiceImpl implements DataService {
|
||||
foo() {
|
||||
console.log('default implementation');
|
||||
export class DataService {
|
||||
filterData(data: any[], user?: IUser): any[] {
|
||||
// Use passed user parameter if available, otherwise fall back to context
|
||||
const currentUser = user || UserContextService.getInstance().getCurrentUser();
|
||||
if (!currentUser || currentUser.isAdmin) {
|
||||
return data;
|
||||
} else {
|
||||
return data.filter((item) => item.owner === currentUser?.username);
|
||||
}
|
||||
}
|
||||
|
||||
filterData(data: any[], _user?: IUser): any[] {
|
||||
return data;
|
||||
filterSettings(settings: McpSettings, user?: IUser): McpSettings {
|
||||
// Use passed user parameter if available, otherwise fall back to context
|
||||
const currentUser = user || UserContextService.getInstance().getCurrentUser();
|
||||
if (!currentUser || currentUser.isAdmin) {
|
||||
const result = { ...settings };
|
||||
delete result.userConfigs;
|
||||
return result;
|
||||
} else {
|
||||
const result = { ...settings };
|
||||
// TODO: apply userConfig to filter settings as needed
|
||||
// const userConfig = settings.userConfigs?.[currentUser?.username || ''];
|
||||
delete result.userConfigs;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
filterSettings(settings: McpSettings, _user?: IUser): McpSettings {
|
||||
return settings;
|
||||
mergeSettings(all: McpSettings, newSettings: McpSettings, user?: IUser): McpSettings {
|
||||
// Use passed user parameter if available, otherwise fall back to context
|
||||
const currentUser = user || UserContextService.getInstance().getCurrentUser();
|
||||
if (!currentUser || currentUser.isAdmin) {
|
||||
const result = { ...all };
|
||||
result.mcpServers = newSettings.mcpServers;
|
||||
result.users = newSettings.users;
|
||||
result.systemConfig = newSettings.systemConfig;
|
||||
result.groups = newSettings.groups;
|
||||
result.oauthClients = newSettings.oauthClients;
|
||||
result.oauthTokens = newSettings.oauthTokens;
|
||||
return result;
|
||||
} else {
|
||||
const result = JSON.parse(JSON.stringify(all));
|
||||
if (!result.userConfigs) {
|
||||
result.userConfigs = {};
|
||||
}
|
||||
const systemConfig = newSettings.systemConfig || {};
|
||||
const userConfig: UserConfig = {
|
||||
routing: systemConfig.routing
|
||||
? {
|
||||
// TODO: only allow modifying certain fields based on userConfig permissions
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
result.userConfigs[currentUser?.username || ''] = userConfig;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
mergeSettings(all: McpSettings, newSettings: McpSettings, _user?: IUser): McpSettings {
|
||||
return newSettings;
|
||||
}
|
||||
|
||||
getPermissions(_user: IUser): string[] {
|
||||
return ['*'];
|
||||
getPermissions(user: IUser): string[] {
|
||||
if (user && user.isAdmin) {
|
||||
return ['*', 'x'];
|
||||
} else {
|
||||
return [''];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { IUser, McpSettings, UserConfig } from '../types/index.js';
|
||||
import { DataService } from './dataService.js';
|
||||
import { UserContextService } from './userContextService.js';
|
||||
|
||||
export class DataServicex implements DataService {
|
||||
foo() {
|
||||
console.log('default implementation');
|
||||
}
|
||||
|
||||
filterData(data: any[], user?: IUser): any[] {
|
||||
// Use passed user parameter if available, otherwise fall back to context
|
||||
const currentUser = user || UserContextService.getInstance().getCurrentUser();
|
||||
if (!currentUser || currentUser.isAdmin) {
|
||||
return data;
|
||||
} else {
|
||||
return data.filter((item) => item.owner === currentUser?.username);
|
||||
}
|
||||
}
|
||||
|
||||
filterSettings(settings: McpSettings, user?: IUser): McpSettings {
|
||||
// Use passed user parameter if available, otherwise fall back to context
|
||||
const currentUser = user || UserContextService.getInstance().getCurrentUser();
|
||||
if (!currentUser || currentUser.isAdmin) {
|
||||
const result = { ...settings };
|
||||
delete result.userConfigs;
|
||||
return result;
|
||||
} else {
|
||||
const result = { ...settings };
|
||||
result.systemConfig = settings.userConfigs?.[currentUser?.username || ''] || {};
|
||||
delete result.userConfigs;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
mergeSettings(all: McpSettings, newSettings: McpSettings, user?: IUser): McpSettings {
|
||||
// Use passed user parameter if available, otherwise fall back to context
|
||||
const currentUser = user || UserContextService.getInstance().getCurrentUser();
|
||||
if (!currentUser || currentUser.isAdmin) {
|
||||
const result = { ...all };
|
||||
result.mcpServers = newSettings.mcpServers;
|
||||
result.users = newSettings.users;
|
||||
result.systemConfig = newSettings.systemConfig;
|
||||
result.groups = newSettings.groups;
|
||||
result.oauthClients = newSettings.oauthClients;
|
||||
result.oauthTokens = newSettings.oauthTokens;
|
||||
return result;
|
||||
} else {
|
||||
const result = JSON.parse(JSON.stringify(all));
|
||||
if (!result.userConfigs) {
|
||||
result.userConfigs = {};
|
||||
}
|
||||
const systemConfig = newSettings.systemConfig || {};
|
||||
const userConfig: UserConfig = {
|
||||
routing: systemConfig.routing
|
||||
? {
|
||||
enableGlobalRoute: systemConfig.routing.enableGlobalRoute,
|
||||
enableGroupNameRoute: systemConfig.routing.enableGroupNameRoute,
|
||||
enableBearerAuth: systemConfig.routing.enableBearerAuth,
|
||||
bearerAuthKey: systemConfig.routing.bearerAuthKey,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
result.userConfigs[currentUser?.username || ''] = userConfig;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
getPermissions(user: IUser): string[] {
|
||||
if (user && user.isAdmin) {
|
||||
return ['*', 'x'];
|
||||
} else {
|
||||
return [''];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { IGroup, IGroupServerConfig } from '../types/index.js';
|
||||
import { loadSettings, saveSettings } from '../config/index.js';
|
||||
import { notifyToolChanged } from './mcpService.js';
|
||||
import { getDataService } from './services.js';
|
||||
import { getGroupDao, getServerDao, getSystemConfigDao } from '../dao/index.js';
|
||||
|
||||
// Helper function to normalize group servers configuration
|
||||
const normalizeGroupServers = (servers: string[] | IGroupServerConfig[]): IGroupServerConfig[] => {
|
||||
@@ -17,22 +17,24 @@ const normalizeGroupServers = (servers: string[] | IGroupServerConfig[]): IGroup
|
||||
};
|
||||
|
||||
// Get all groups
|
||||
export const getAllGroups = (): IGroup[] => {
|
||||
const settings = loadSettings();
|
||||
export const getAllGroups = async (): Promise<IGroup[]> => {
|
||||
const groupDao = getGroupDao();
|
||||
const groups = await groupDao.findAll();
|
||||
const dataService = getDataService();
|
||||
return dataService.filterData
|
||||
? dataService.filterData(settings.groups || [])
|
||||
: settings.groups || [];
|
||||
return dataService.filterData ? dataService.filterData(groups) : groups;
|
||||
};
|
||||
|
||||
// Get group by ID or name
|
||||
export const getGroupByIdOrName = (key: string): IGroup | undefined => {
|
||||
const settings = loadSettings();
|
||||
const routingConfig = settings.systemConfig?.routing || {
|
||||
export const getGroupByIdOrName = async (key: string): Promise<IGroup | undefined> => {
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
const routingConfig = systemConfig?.routing || {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
};
|
||||
const groups = getAllGroups();
|
||||
|
||||
const groups = await getAllGroups();
|
||||
return (
|
||||
groups.find(
|
||||
(group) => group.id === key || (group.name === key && routingConfig.enableGroupNameRoute),
|
||||
@@ -41,25 +43,28 @@ export const getGroupByIdOrName = (key: string): IGroup | undefined => {
|
||||
};
|
||||
|
||||
// Create a new group
|
||||
export const createGroup = (
|
||||
export const createGroup = async (
|
||||
name: string,
|
||||
description?: string,
|
||||
servers: string[] | IGroupServerConfig[] = [],
|
||||
owner?: string,
|
||||
): IGroup | null => {
|
||||
): Promise<IGroup | null> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
const groups = settings.groups || [];
|
||||
const groupDao = getGroupDao();
|
||||
const serverDao = getServerDao();
|
||||
|
||||
// Check if group with same name already exists
|
||||
if (groups.some((group) => group.name === name)) {
|
||||
const existingGroup = await groupDao.findByName(name);
|
||||
if (existingGroup) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Normalize servers configuration and filter out non-existent servers
|
||||
const normalizedServers = normalizeGroupServers(servers);
|
||||
const validServers: IGroupServerConfig[] = normalizedServers.filter(
|
||||
(serverConfig) => settings.mcpServers[serverConfig.name],
|
||||
const allServers = await serverDao.findAll();
|
||||
const serverNames = new Set(allServers.map((s) => s.name));
|
||||
const validServers: IGroupServerConfig[] = normalizedServers.filter((serverConfig) =>
|
||||
serverNames.has(serverConfig.name),
|
||||
);
|
||||
|
||||
const newGroup: IGroup = {
|
||||
@@ -70,18 +75,8 @@ export const createGroup = (
|
||||
owner: owner || 'admin',
|
||||
};
|
||||
|
||||
// Initialize groups array if it doesn't exist
|
||||
if (!settings.groups) {
|
||||
settings.groups = [];
|
||||
}
|
||||
|
||||
settings.groups.push(newGroup);
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return newGroup;
|
||||
const createdGroup = await groupDao.create(newGroup);
|
||||
return createdGroup;
|
||||
} catch (error) {
|
||||
console.error('Failed to create group:', error);
|
||||
return null;
|
||||
@@ -89,43 +84,38 @@ export const createGroup = (
|
||||
};
|
||||
|
||||
// Update an existing group
|
||||
export const updateGroup = (id: string, data: Partial<IGroup>): IGroup | null => {
|
||||
export const updateGroup = async (id: string, data: Partial<IGroup>): Promise<IGroup | null> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (!settings.groups) {
|
||||
return null;
|
||||
}
|
||||
const groupDao = getGroupDao();
|
||||
const serverDao = getServerDao();
|
||||
|
||||
const groupIndex = settings.groups.findIndex((group) => group.id === id);
|
||||
if (groupIndex === -1) {
|
||||
const existingGroup = await groupDao.findById(id);
|
||||
if (!existingGroup) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for name uniqueness if name is being updated
|
||||
if (data.name && settings.groups.some((g) => g.name === data.name && g.id !== id)) {
|
||||
return null;
|
||||
if (data.name && data.name !== existingGroup.name) {
|
||||
const groupWithName = await groupDao.findByName(data.name);
|
||||
if (groupWithName) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// If servers array is provided, validate server existence and normalize format
|
||||
if (data.servers) {
|
||||
const normalizedServers = normalizeGroupServers(data.servers);
|
||||
data.servers = normalizedServers.filter(
|
||||
(serverConfig) => settings.mcpServers[serverConfig.name],
|
||||
);
|
||||
const allServers = await serverDao.findAll();
|
||||
const serverNames = new Set(allServers.map((s) => s.name));
|
||||
data.servers = normalizedServers.filter((serverConfig) => serverNames.has(serverConfig.name));
|
||||
}
|
||||
|
||||
const updatedGroup = {
|
||||
...settings.groups[groupIndex],
|
||||
...data,
|
||||
};
|
||||
const updatedGroup = await groupDao.update(id, data);
|
||||
|
||||
settings.groups[groupIndex] = updatedGroup;
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return null;
|
||||
if (updatedGroup) {
|
||||
notifyToolChanged();
|
||||
}
|
||||
|
||||
notifyToolChanged();
|
||||
return updatedGroup;
|
||||
} catch (error) {
|
||||
console.error(`Failed to update group ${id}:`, error);
|
||||
@@ -135,35 +125,34 @@ export const updateGroup = (id: string, data: Partial<IGroup>): IGroup | null =>
|
||||
|
||||
// Update servers in a group (batch update)
|
||||
// Update group servers (maintaining backward compatibility)
|
||||
export const updateGroupServers = (
|
||||
export const updateGroupServers = async (
|
||||
groupId: string,
|
||||
servers: string[] | IGroupServerConfig[],
|
||||
): IGroup | null => {
|
||||
): Promise<IGroup | null> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (!settings.groups) {
|
||||
return null;
|
||||
}
|
||||
const groupDao = getGroupDao();
|
||||
const serverDao = getServerDao();
|
||||
|
||||
const groupIndex = settings.groups.findIndex((group) => group.id === groupId);
|
||||
if (groupIndex === -1) {
|
||||
const existingGroup = await groupDao.findById(groupId);
|
||||
if (!existingGroup) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Normalize and filter out non-existent servers
|
||||
const normalizedServers = normalizeGroupServers(servers);
|
||||
const validServers = normalizedServers.filter(
|
||||
(serverConfig) => settings.mcpServers[serverConfig.name],
|
||||
const allServers = await serverDao.findAll();
|
||||
const serverNames = new Set(allServers.map((s) => s.name));
|
||||
const validServers = normalizedServers.filter((serverConfig) =>
|
||||
serverNames.has(serverConfig.name),
|
||||
);
|
||||
|
||||
settings.groups[groupIndex].servers = validServers;
|
||||
const updatedGroup = await groupDao.update(groupId, { servers: validServers });
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return null;
|
||||
if (updatedGroup) {
|
||||
notifyToolChanged();
|
||||
}
|
||||
|
||||
notifyToolChanged();
|
||||
return settings.groups[groupIndex];
|
||||
return updatedGroup;
|
||||
} catch (error) {
|
||||
console.error(`Failed to update servers for group ${groupId}:`, error);
|
||||
return null;
|
||||
@@ -171,21 +160,10 @@ export const updateGroupServers = (
|
||||
};
|
||||
|
||||
// Delete a group
|
||||
export const deleteGroup = (id: string): boolean => {
|
||||
export const deleteGroup = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (!settings.groups) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const initialLength = settings.groups.length;
|
||||
settings.groups = settings.groups.filter((group) => group.id !== id);
|
||||
|
||||
if (settings.groups.length === initialLength) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return saveSettings(settings);
|
||||
const groupDao = getGroupDao();
|
||||
return await groupDao.delete(id);
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete group ${id}:`, error);
|
||||
return false;
|
||||
@@ -193,34 +171,37 @@ export const deleteGroup = (id: string): boolean => {
|
||||
};
|
||||
|
||||
// Add server to group
|
||||
export const addServerToGroup = (groupId: string, serverName: string): IGroup | null => {
|
||||
export const addServerToGroup = async (
|
||||
groupId: string,
|
||||
serverName: string,
|
||||
): Promise<IGroup | null> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (!settings.groups) {
|
||||
return null;
|
||||
}
|
||||
const groupDao = getGroupDao();
|
||||
const serverDao = getServerDao();
|
||||
|
||||
// Verify server exists
|
||||
if (!settings.mcpServers[serverName]) {
|
||||
const server = await serverDao.findById(serverName);
|
||||
if (!server) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const groupIndex = settings.groups.findIndex((group) => group.id === groupId);
|
||||
if (groupIndex === -1) {
|
||||
const group = await groupDao.findById(groupId);
|
||||
if (!group) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const group = settings.groups[groupIndex];
|
||||
const normalizedServers = normalizeGroupServers(group.servers);
|
||||
|
||||
// Add server to group if not already in it
|
||||
if (!normalizedServers.some((server) => server.name === serverName)) {
|
||||
if (!normalizedServers.some((s) => s.name === serverName)) {
|
||||
normalizedServers.push({ name: serverName, tools: 'all' });
|
||||
group.servers = normalizedServers;
|
||||
const updatedGroup = await groupDao.update(groupId, { servers: normalizedServers });
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return null;
|
||||
if (updatedGroup) {
|
||||
notifyToolChanged();
|
||||
}
|
||||
|
||||
return updatedGroup;
|
||||
}
|
||||
|
||||
notifyToolChanged();
|
||||
@@ -232,27 +213,22 @@ export const addServerToGroup = (groupId: string, serverName: string): IGroup |
|
||||
};
|
||||
|
||||
// Remove server from group
|
||||
export const removeServerFromGroup = (groupId: string, serverName: string): IGroup | null => {
|
||||
export const removeServerFromGroup = async (
|
||||
groupId: string,
|
||||
serverName: string,
|
||||
): Promise<IGroup | null> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (!settings.groups) {
|
||||
const groupDao = getGroupDao();
|
||||
|
||||
const group = await groupDao.findById(groupId);
|
||||
if (!group) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const groupIndex = settings.groups.findIndex((group) => group.id === groupId);
|
||||
if (groupIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const group = settings.groups[groupIndex];
|
||||
const normalizedServers = normalizeGroupServers(group.servers);
|
||||
group.servers = normalizedServers.filter((server) => server.name !== serverName);
|
||||
const filteredServers = normalizedServers.filter((server) => server.name !== serverName);
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return group;
|
||||
return await groupDao.update(groupId, { servers: filteredServers });
|
||||
} catch (error) {
|
||||
console.error(`Failed to remove server ${serverName} from group ${groupId}:`, error);
|
||||
return null;
|
||||
@@ -260,71 +236,69 @@ export const removeServerFromGroup = (groupId: string, serverName: string): IGro
|
||||
};
|
||||
|
||||
// Get all servers in a group
|
||||
export const getServersInGroup = (groupId: string): string[] => {
|
||||
const group = getGroupByIdOrName(groupId);
|
||||
export const getServersInGroup = async (groupId: string): Promise<string[]> => {
|
||||
const group = await getGroupByIdOrName(groupId);
|
||||
if (!group) return [];
|
||||
const normalizedServers = normalizeGroupServers(group.servers);
|
||||
return normalizedServers.map((server) => server.name);
|
||||
};
|
||||
|
||||
// Get server configuration from group (including tool selection)
|
||||
export const getServerConfigInGroup = (
|
||||
export const getServerConfigInGroup = async (
|
||||
groupId: string,
|
||||
serverName: string,
|
||||
): IGroupServerConfig | undefined => {
|
||||
const group = getGroupByIdOrName(groupId);
|
||||
): Promise<IGroupServerConfig | undefined> => {
|
||||
const group = await getGroupByIdOrName(groupId);
|
||||
if (!group) return undefined;
|
||||
const normalizedServers = normalizeGroupServers(group.servers);
|
||||
return normalizedServers.find((server) => server.name === serverName);
|
||||
};
|
||||
|
||||
// Get all server configurations in a group
|
||||
export const getServerConfigsInGroup = (groupId: string): IGroupServerConfig[] => {
|
||||
const group = getGroupByIdOrName(groupId);
|
||||
export const getServerConfigsInGroup = async (groupId: string): Promise<IGroupServerConfig[]> => {
|
||||
const group = await getGroupByIdOrName(groupId);
|
||||
if (!group) return [];
|
||||
return normalizeGroupServers(group.servers);
|
||||
};
|
||||
|
||||
// Update tools selection for a specific server in a group
|
||||
export const updateServerToolsInGroup = (
|
||||
export const updateServerToolsInGroup = async (
|
||||
groupId: string,
|
||||
serverName: string,
|
||||
tools: string[] | 'all',
|
||||
): IGroup | null => {
|
||||
): Promise<IGroup | null> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (!settings.groups) {
|
||||
return null;
|
||||
}
|
||||
const groupDao = getGroupDao();
|
||||
const serverDao = getServerDao();
|
||||
|
||||
const groupIndex = settings.groups.findIndex((group) => group.id === groupId);
|
||||
if (groupIndex === -1) {
|
||||
const group = await groupDao.findById(groupId);
|
||||
if (!group) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify server exists
|
||||
if (!settings.mcpServers[serverName]) {
|
||||
const server = await serverDao.findById(serverName);
|
||||
if (!server) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const group = settings.groups[groupIndex];
|
||||
const normalizedServers = normalizeGroupServers(group.servers);
|
||||
|
||||
const serverIndex = normalizedServers.findIndex((server) => server.name === serverName);
|
||||
const serverIndex = normalizedServers.findIndex((s) => s.name === serverName);
|
||||
if (serverIndex === -1) {
|
||||
return null; // Server not in group
|
||||
}
|
||||
|
||||
// Update the tools configuration for the server
|
||||
normalizedServers[serverIndex].tools = tools;
|
||||
group.servers = normalizedServers;
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return null;
|
||||
const updatedGroup = await groupDao.update(groupId, { servers: normalizedServers });
|
||||
|
||||
if (updatedGroup) {
|
||||
notifyToolChanged();
|
||||
}
|
||||
|
||||
notifyToolChanged();
|
||||
return group;
|
||||
return updatedGroup;
|
||||
} catch (error) {
|
||||
console.error(`Failed to update tools for server ${serverName} in group ${groupId}:`, error);
|
||||
return null;
|
||||
|
||||
@@ -20,7 +20,7 @@ import type {
|
||||
OAuthTokens,
|
||||
} from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
import { ServerConfig } from '../types/index.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import { getSystemConfigDao } from '../dao/index.js';
|
||||
import {
|
||||
initializeOAuthForServer,
|
||||
getRegisteredClient,
|
||||
@@ -52,15 +52,29 @@ export class MCPHubOAuthProvider implements OAuthClientProvider {
|
||||
private serverConfig: ServerConfig;
|
||||
private _codeVerifier?: string;
|
||||
private _currentState?: string;
|
||||
private _systemInstallBaseUrl?: string;
|
||||
|
||||
constructor(serverName: string, serverConfig: ServerConfig) {
|
||||
constructor(serverName: string, serverConfig: ServerConfig, systemInstallBaseUrl?: string) {
|
||||
this.serverName = serverName;
|
||||
this.serverConfig = serverConfig;
|
||||
this._systemInstallBaseUrl = systemInstallBaseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create an MCPHubOAuthProvider with async config loading
|
||||
*/
|
||||
static async create(
|
||||
serverName: string,
|
||||
serverConfig: ServerConfig,
|
||||
): Promise<MCPHubOAuthProvider> {
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
const systemInstallBaseUrl = systemConfig?.install?.baseUrl;
|
||||
return new MCPHubOAuthProvider(serverName, serverConfig, systemInstallBaseUrl);
|
||||
}
|
||||
|
||||
private getSystemInstallBaseUrl(): string | undefined {
|
||||
const settings = loadSettings();
|
||||
return settings.systemConfig?.install?.baseUrl;
|
||||
return this._systemInstallBaseUrl;
|
||||
}
|
||||
|
||||
private sanitizeRedirectUri(input?: string): string | null {
|
||||
@@ -219,18 +233,9 @@ export class MCPHubOAuthProvider implements OAuthClientProvider {
|
||||
const clientInfo = getRegisteredClient(this.serverName);
|
||||
|
||||
if (!clientInfo) {
|
||||
// Try to use static client configuration from cached serverConfig first
|
||||
let serverConfig = this.serverConfig;
|
||||
|
||||
// If cached config doesn't have clientId, reload from settings
|
||||
if (!serverConfig?.oauth?.clientId) {
|
||||
const storedConfig = loadServerConfig(this.serverName);
|
||||
|
||||
if (storedConfig) {
|
||||
this.serverConfig = storedConfig;
|
||||
serverConfig = storedConfig;
|
||||
}
|
||||
}
|
||||
// Try to use static client configuration from cached serverConfig
|
||||
// Note: we only use cache here since this is a sync method
|
||||
const serverConfig = this.serverConfig;
|
||||
|
||||
// Try to use static client configuration from serverConfig
|
||||
if (serverConfig?.oauth?.clientId) {
|
||||
@@ -288,17 +293,8 @@ export class MCPHubOAuthProvider implements OAuthClientProvider {
|
||||
* Get stored OAuth tokens
|
||||
*/
|
||||
tokens(): OAuthTokens | undefined {
|
||||
// Use cached config first, but reload if needed
|
||||
let serverConfig = this.serverConfig;
|
||||
|
||||
// If cached config doesn't have tokens, try reloading
|
||||
if (!serverConfig?.oauth?.accessToken) {
|
||||
const storedConfig = loadServerConfig(this.serverName);
|
||||
if (storedConfig) {
|
||||
this.serverConfig = storedConfig;
|
||||
serverConfig = storedConfig;
|
||||
}
|
||||
}
|
||||
// Use cached config only (tokens are updated via saveTokens which updates cache)
|
||||
const serverConfig = this.serverConfig;
|
||||
|
||||
if (!serverConfig?.oauth?.accessToken) {
|
||||
return undefined;
|
||||
@@ -441,7 +437,7 @@ export class MCPHubOAuthProvider implements OAuthClientProvider {
|
||||
return this._codeVerifier;
|
||||
}
|
||||
|
||||
const storedConfig = loadServerConfig(this.serverName);
|
||||
const storedConfig = await loadServerConfig(this.serverName);
|
||||
const storedVerifier = storedConfig?.oauth?.pendingAuthorization?.codeVerifier;
|
||||
|
||||
if (storedVerifier) {
|
||||
@@ -458,7 +454,7 @@ export class MCPHubOAuthProvider implements OAuthClientProvider {
|
||||
* This keeps stored configuration in sync and forces a fresh authorization flow.
|
||||
*/
|
||||
async invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier'): Promise<void> {
|
||||
const storedConfig = loadServerConfig(this.serverName);
|
||||
const storedConfig = await loadServerConfig(this.serverName);
|
||||
|
||||
if (!storedConfig?.oauth) {
|
||||
if (scope === 'verifier' || scope === 'all') {
|
||||
@@ -585,8 +581,8 @@ export const createOAuthProvider = async (
|
||||
// Continue anyway - the SDK might be able to handle it
|
||||
}
|
||||
|
||||
// Create and return the provider
|
||||
const provider = new MCPHubOAuthProvider(serverName, serverConfig);
|
||||
// Create and return the provider using the factory method
|
||||
const provider = await MCPHubOAuthProvider.create(serverName, serverConfig);
|
||||
|
||||
console.log(`Created OAuth provider for server: ${serverName}`);
|
||||
return provider;
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
StreamableHTTPClientTransportOptions,
|
||||
} from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { ServerInfo, ServerConfig, Tool } from '../types/index.js';
|
||||
import { loadSettings, expandEnvVars, replaceEnvVars, getNameSeparator } from '../config/index.js';
|
||||
import { expandEnvVars, replaceEnvVars, getNameSeparator } from '../config/index.js';
|
||||
import config from '../config/index.js';
|
||||
import { getGroup } from './sseService.js';
|
||||
import { getServersInGroup, getServerConfigInGroup } from './groupService.js';
|
||||
@@ -23,21 +23,24 @@ import { saveToolsAsVectorEmbeddings, searchToolsByVector } from './vectorSearch
|
||||
import { OpenAPIClient } from '../clients/openapi.js';
|
||||
import { RequestContextService } from './requestContextService.js';
|
||||
import { getDataService } from './services.js';
|
||||
import { getServerDao, ServerConfigWithName } from '../dao/index.js';
|
||||
import { getServerDao, getSystemConfigDao, ServerConfigWithName } from '../dao/index.js';
|
||||
import { initializeAllOAuthClients } from './oauthService.js';
|
||||
import { createOAuthProvider } from './mcpOAuthProvider.js';
|
||||
|
||||
const servers: { [sessionId: string]: Server } = {};
|
||||
|
||||
const serverDao = getServerDao();
|
||||
|
||||
// Helper function to set up keep-alive ping for SSE connections
|
||||
const setupKeepAlive = (serverInfo: ServerInfo, serverConfig: ServerConfig): void => {
|
||||
// Only set up keep-alive for SSE connections
|
||||
// Only applicable to SSE connections
|
||||
if (!(serverInfo.transport instanceof SSEClientTransport)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep-alive is disabled by default to avoid excessive API calls to external MCP servers
|
||||
if (serverConfig.keepAlive !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any existing interval first
|
||||
if (serverInfo.keepAliveIntervalId) {
|
||||
clearInterval(serverInfo.keepAliveIntervalId);
|
||||
@@ -215,24 +218,25 @@ export const createTransportFromConfig = async (name: string, conf: ServerConfig
|
||||
};
|
||||
env['PATH'] = expandEnvVars(process.env.PATH as string) || '';
|
||||
|
||||
const settings = loadSettings();
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
// Add UV_DEFAULT_INDEX and npm_config_registry if needed
|
||||
if (
|
||||
settings.systemConfig?.install?.pythonIndexUrl &&
|
||||
systemConfig?.install?.pythonIndexUrl &&
|
||||
(conf.command === 'uvx' || conf.command === 'uv' || conf.command === 'python')
|
||||
) {
|
||||
env['UV_DEFAULT_INDEX'] = settings.systemConfig.install.pythonIndexUrl;
|
||||
env['UV_DEFAULT_INDEX'] = systemConfig.install.pythonIndexUrl;
|
||||
}
|
||||
|
||||
if (
|
||||
settings.systemConfig?.install?.npmRegistry &&
|
||||
systemConfig?.install?.npmRegistry &&
|
||||
(conf.command === 'npm' ||
|
||||
conf.command === 'npx' ||
|
||||
conf.command === 'pnpm' ||
|
||||
conf.command === 'yarn' ||
|
||||
conf.command === 'node')
|
||||
) {
|
||||
env['npm_config_registry'] = settings.systemConfig.install.npmRegistry;
|
||||
env['npm_config_registry'] = systemConfig.install.npmRegistry;
|
||||
}
|
||||
|
||||
// Expand environment variables in command
|
||||
@@ -293,7 +297,7 @@ const callToolWithReconnect = async (
|
||||
serverInfo.client.close();
|
||||
serverInfo.transport.close();
|
||||
|
||||
const server = await serverDao.findById(serverInfo.name);
|
||||
const server = await getServerDao().findById(serverInfo.name);
|
||||
if (!server) {
|
||||
throw new Error(`Server configuration not found for: ${serverInfo.name}`);
|
||||
}
|
||||
@@ -373,7 +377,7 @@ export const initializeClientsFromSettings = async (
|
||||
isInit: boolean,
|
||||
serverName?: string,
|
||||
): Promise<ServerInfo[]> => {
|
||||
const allServers: ServerConfigWithName[] = await serverDao.findAll();
|
||||
const allServers: ServerConfigWithName[] = await getServerDao().findAll();
|
||||
const existingServerInfos = serverInfos;
|
||||
const nextServerInfos: ServerInfo[] = [];
|
||||
|
||||
@@ -650,7 +654,7 @@ export const registerAllTools = async (isInit: boolean, serverName?: string): Pr
|
||||
|
||||
// Get all server information
|
||||
export const getServersInfo = async (): Promise<Omit<ServerInfo, 'client' | 'transport'>[]> => {
|
||||
const allServers: ServerConfigWithName[] = await serverDao.findAll();
|
||||
const allServers: ServerConfigWithName[] = await getServerDao().findAll();
|
||||
const dataService = getDataService();
|
||||
const filterServerInfos: ServerInfo[] = dataService.filterData
|
||||
? dataService.filterData(serverInfos)
|
||||
@@ -756,7 +760,7 @@ export const reconnectServer = async (serverName: string): Promise<void> => {
|
||||
|
||||
// Filter tools by server configuration
|
||||
const filterToolsByConfig = async (serverName: string, tools: Tool[]): Promise<Tool[]> => {
|
||||
const serverConfig = await serverDao.findById(serverName);
|
||||
const serverConfig = await getServerDao().findById(serverName);
|
||||
if (!serverConfig || !serverConfig.tools) {
|
||||
// If no tool configuration exists, all tools are enabled by default
|
||||
return tools;
|
||||
@@ -780,7 +784,7 @@ export const addServer = async (
|
||||
config: ServerConfig,
|
||||
): Promise<{ success: boolean; message?: string }> => {
|
||||
const server: ServerConfigWithName = { name, ...config };
|
||||
const result = await serverDao.create(server);
|
||||
const result = await getServerDao().create(server);
|
||||
if (result) {
|
||||
return { success: true, message: 'Server added successfully' };
|
||||
} else {
|
||||
@@ -792,7 +796,7 @@ export const addServer = async (
|
||||
export const removeServer = async (
|
||||
name: string,
|
||||
): Promise<{ success: boolean; message?: string }> => {
|
||||
const result = await serverDao.delete(name);
|
||||
const result = await getServerDao().delete(name);
|
||||
if (!result) {
|
||||
return { success: false, message: 'Failed to remove server' };
|
||||
}
|
||||
@@ -808,7 +812,7 @@ export const addOrUpdateServer = async (
|
||||
allowOverride: boolean = false,
|
||||
): Promise<{ success: boolean; message?: string }> => {
|
||||
try {
|
||||
const exists = await serverDao.exists(name);
|
||||
const exists = await getServerDao().exists(name);
|
||||
if (exists && !allowOverride) {
|
||||
return { success: false, message: 'Server name already exists' };
|
||||
}
|
||||
@@ -823,9 +827,9 @@ export const addOrUpdateServer = async (
|
||||
}
|
||||
|
||||
if (exists) {
|
||||
await serverDao.update(name, config);
|
||||
await getServerDao().update(name, config);
|
||||
} else {
|
||||
await serverDao.create({ name, ...config });
|
||||
await getServerDao().create({ name, ...config });
|
||||
}
|
||||
|
||||
const action = exists ? 'updated' : 'added';
|
||||
@@ -860,7 +864,7 @@ export const toggleServerStatus = async (
|
||||
enabled: boolean,
|
||||
): Promise<{ success: boolean; message?: string }> => {
|
||||
try {
|
||||
await serverDao.setEnabled(name, enabled);
|
||||
await getServerDao().setEnabled(name, enabled);
|
||||
// If disabling, disconnect the server and remove from active servers
|
||||
if (!enabled) {
|
||||
closeServer(name);
|
||||
@@ -893,33 +897,33 @@ export const handleListToolsRequest = async (_: any, extra: any) => {
|
||||
if (group === '$smart' || group?.startsWith('$smart/')) {
|
||||
// Extract target group if pattern is $smart/{group}
|
||||
const targetGroup = group?.startsWith('$smart/') ? group.substring(7) : undefined;
|
||||
|
||||
|
||||
// Get info about available servers, filtered by target group if specified
|
||||
let availableServers = serverInfos.filter(
|
||||
(server) => server.status === 'connected' && server.enabled !== false,
|
||||
);
|
||||
|
||||
|
||||
// If a target group is specified, filter servers to only those in the group
|
||||
if (targetGroup) {
|
||||
const serversInGroup = getServersInGroup(targetGroup);
|
||||
const serversInGroup = await getServersInGroup(targetGroup);
|
||||
if (serversInGroup && serversInGroup.length > 0) {
|
||||
availableServers = availableServers.filter((server) =>
|
||||
serversInGroup.includes(server.name),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Create simple server information with only server names
|
||||
const serversList = availableServers
|
||||
.map((server) => {
|
||||
return `${server.name}`;
|
||||
})
|
||||
.join(', ');
|
||||
|
||||
|
||||
const scopeDescription = targetGroup
|
||||
? `servers in the "${targetGroup}" group`
|
||||
: 'all available servers';
|
||||
|
||||
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
@@ -973,36 +977,44 @@ Available servers: ${serversList}`,
|
||||
};
|
||||
}
|
||||
|
||||
const allServerInfos = getDataService()
|
||||
.filterData(serverInfos)
|
||||
.filter((serverInfo) => {
|
||||
if (serverInfo.enabled === false) return false;
|
||||
if (!group) return true;
|
||||
const serversInGroup = getServersInGroup(group);
|
||||
if (!serversInGroup || serversInGroup.length === 0) return serverInfo.name === group;
|
||||
return serversInGroup.includes(serverInfo.name);
|
||||
});
|
||||
// Need to filter servers based on group asynchronously
|
||||
const filteredServerInfos = [];
|
||||
for (const serverInfo of getDataService().filterData(serverInfos)) {
|
||||
if (serverInfo.enabled === false) continue;
|
||||
if (!group) {
|
||||
filteredServerInfos.push(serverInfo);
|
||||
continue;
|
||||
}
|
||||
const serversInGroup = await getServersInGroup(group);
|
||||
if (!serversInGroup || serversInGroup.length === 0) {
|
||||
if (serverInfo.name === group) filteredServerInfos.push(serverInfo);
|
||||
continue;
|
||||
}
|
||||
if (serversInGroup.includes(serverInfo.name)) {
|
||||
filteredServerInfos.push(serverInfo);
|
||||
}
|
||||
}
|
||||
|
||||
const allTools = [];
|
||||
for (const serverInfo of allServerInfos) {
|
||||
for (const serverInfo of filteredServerInfos) {
|
||||
if (serverInfo.tools && serverInfo.tools.length > 0) {
|
||||
// Filter tools based on server configuration
|
||||
let enabledTools = await filterToolsByConfig(serverInfo.name, serverInfo.tools);
|
||||
|
||||
// If this is a group request, apply group-level tool filtering
|
||||
if (group) {
|
||||
const serverConfig = getServerConfigInGroup(group, serverInfo.name);
|
||||
const serverConfig = await getServerConfigInGroup(group, serverInfo.name);
|
||||
if (serverConfig && serverConfig.tools !== 'all' && Array.isArray(serverConfig.tools)) {
|
||||
// Filter tools based on group configuration
|
||||
const allowedToolNames = serverConfig.tools.map(
|
||||
(toolName) => `${serverInfo.name}${getNameSeparator()}${toolName}`,
|
||||
(toolName: string) => `${serverInfo.name}${getNameSeparator()}${toolName}`,
|
||||
);
|
||||
enabledTools = enabledTools.filter((tool) => allowedToolNames.includes(tool.name));
|
||||
}
|
||||
}
|
||||
|
||||
// Apply custom descriptions from server configuration
|
||||
const serverConfig = await serverDao.findById(serverInfo.name);
|
||||
const serverConfig = await getServerDao().findById(serverInfo.name);
|
||||
const toolsWithCustomDescriptions = enabledTools.map((tool) => {
|
||||
const toolConfig = serverConfig?.tools?.[tool.name];
|
||||
return {
|
||||
@@ -1047,20 +1059,22 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
|
||||
}
|
||||
|
||||
console.log(`Using similarity threshold: ${thresholdNum} for query: "${query}"`);
|
||||
|
||||
|
||||
// Determine server filtering based on group
|
||||
const sessionId = extra.sessionId || '';
|
||||
const group = getGroup(sessionId);
|
||||
let servers: string[] | undefined = undefined; // No server filtering by default
|
||||
|
||||
|
||||
// If group is in format $smart/{group}, filter servers to that group
|
||||
if (group?.startsWith('$smart/')) {
|
||||
const targetGroup = group.substring(7);
|
||||
const serversInGroup = getServersInGroup(targetGroup);
|
||||
const serversInGroup = await getServersInGroup(targetGroup);
|
||||
if (serversInGroup !== undefined && serversInGroup !== null) {
|
||||
servers = serversInGroup;
|
||||
if (servers.length > 0) {
|
||||
console.log(`Filtering search to servers in group "${targetGroup}": ${servers.join(', ')}`);
|
||||
if (servers && servers.length > 0) {
|
||||
console.log(
|
||||
`Filtering search to servers in group "${targetGroup}": ${servers.join(', ')}`,
|
||||
);
|
||||
} else {
|
||||
console.log(`Group "${targetGroup}" has no servers, search will return no results`);
|
||||
}
|
||||
@@ -1088,7 +1102,7 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
|
||||
const enabledTools = await filterToolsByConfig(server.name, [actualTool]);
|
||||
if (enabledTools.length > 0) {
|
||||
// Apply custom description from configuration
|
||||
const serverConfig = await serverDao.findById(server.name);
|
||||
const serverConfig = await getServerDao().findById(server.name);
|
||||
const toolConfig = serverConfig?.tools?.[actualTool.name];
|
||||
|
||||
// Return the actual tool info from serverInfos with custom description
|
||||
@@ -1430,21 +1444,29 @@ export const handleListPromptsRequest = async (_: any, extra: any) => {
|
||||
const group = getGroup(sessionId);
|
||||
console.log(`Handling ListPromptsRequest for group: ${group}`);
|
||||
|
||||
const allServerInfos = getDataService()
|
||||
.filterData(serverInfos)
|
||||
.filter((serverInfo) => {
|
||||
if (serverInfo.enabled === false) return false;
|
||||
if (!group) return true;
|
||||
const serversInGroup = getServersInGroup(group);
|
||||
if (!serversInGroup || serversInGroup.length === 0) return serverInfo.name === group;
|
||||
return serversInGroup.includes(serverInfo.name);
|
||||
});
|
||||
// Need to filter servers based on group asynchronously
|
||||
const filteredServerInfos = [];
|
||||
for (const serverInfo of getDataService().filterData(serverInfos)) {
|
||||
if (serverInfo.enabled === false) continue;
|
||||
if (!group) {
|
||||
filteredServerInfos.push(serverInfo);
|
||||
continue;
|
||||
}
|
||||
const serversInGroup = await getServersInGroup(group);
|
||||
if (!serversInGroup || serversInGroup.length === 0) {
|
||||
if (serverInfo.name === group) filteredServerInfos.push(serverInfo);
|
||||
continue;
|
||||
}
|
||||
if (serversInGroup.includes(serverInfo.name)) {
|
||||
filteredServerInfos.push(serverInfo);
|
||||
}
|
||||
}
|
||||
|
||||
const allPrompts: any[] = [];
|
||||
for (const serverInfo of allServerInfos) {
|
||||
for (const serverInfo of filteredServerInfos) {
|
||||
if (serverInfo.prompts && serverInfo.prompts.length > 0) {
|
||||
// Filter prompts based on server configuration
|
||||
const serverConfig = await serverDao.findById(serverInfo.name);
|
||||
const serverConfig = await getServerDao().findById(serverInfo.name);
|
||||
|
||||
let enabledPrompts = serverInfo.prompts;
|
||||
if (serverConfig && serverConfig.prompts) {
|
||||
@@ -1457,7 +1479,7 @@ export const handleListPromptsRequest = async (_: any, extra: any) => {
|
||||
|
||||
// If this is a group request, apply group-level prompt filtering
|
||||
if (group) {
|
||||
const serverConfigInGroup = getServerConfigInGroup(group, serverInfo.name);
|
||||
const serverConfigInGroup = await getServerConfigInGroup(group, serverInfo.name);
|
||||
if (
|
||||
serverConfigInGroup &&
|
||||
serverConfigInGroup.tools !== 'all' &&
|
||||
@@ -1492,15 +1514,9 @@ export const createMcpServer = (name: string, version: string, group?: string):
|
||||
let serverName = name;
|
||||
|
||||
if (group) {
|
||||
// Check if it's a group or a single server
|
||||
const serversInGroup = getServersInGroup(group);
|
||||
if (!serversInGroup || serversInGroup.length === 0) {
|
||||
// Single server routing
|
||||
serverName = `${name}_${group}`;
|
||||
} else {
|
||||
// Group routing
|
||||
serverName = `${name}_${group}_group`;
|
||||
}
|
||||
// For createMcpServer we use sync approach since it's called synchronously
|
||||
// The actual group validation happens at request time
|
||||
serverName = `${name}_${group}_group`;
|
||||
}
|
||||
// If no group, use default name (global routing)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import OAuth2Server from '@node-oauth/oauth2-server';
|
||||
import { Request as ExpressRequest, Response as ExpressResponse } from 'express';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import { getSystemConfigDao } from '../dao/index.js';
|
||||
import { findUserByUsername, verifyPassword } from '../models/User.js';
|
||||
import {
|
||||
findOAuthClientById,
|
||||
@@ -50,8 +50,9 @@ const oauthModel: OAuth2Server.AuthorizationCodeModel & OAuth2Server.RefreshToke
|
||||
client: OAuth2Server.Client,
|
||||
user: OAuth2Server.User,
|
||||
) => {
|
||||
const settings = loadSettings();
|
||||
const oauthConfig = settings.systemConfig?.oauthServer;
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
const oauthConfig = systemConfig?.oauthServer;
|
||||
const lifetime = oauthConfig?.authorizationCodeLifetime || 300;
|
||||
|
||||
const scopeString = Array.isArray(code.scope) ? code.scope.join(' ') : code.scope;
|
||||
@@ -134,8 +135,9 @@ const oauthModel: OAuth2Server.AuthorizationCodeModel & OAuth2Server.RefreshToke
|
||||
client: OAuth2Server.Client,
|
||||
user: OAuth2Server.User,
|
||||
) => {
|
||||
const settings = loadSettings();
|
||||
const oauthConfig = settings.systemConfig?.oauthServer;
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
const oauthConfig = systemConfig?.oauthServer;
|
||||
const accessTokenLifetime = oauthConfig?.accessTokenLifetime || 3600;
|
||||
const refreshTokenLifetime = oauthConfig?.refreshTokenLifetime || 1209600;
|
||||
|
||||
@@ -252,7 +254,9 @@ const oauthModel: OAuth2Server.AuthorizationCodeModel & OAuth2Server.RefreshToke
|
||||
}
|
||||
|
||||
const requestedScopes = Array.isArray(scope) ? scope : scope.split(' ');
|
||||
const tokenScopes = Array.isArray(token.scope) ? token.scope : (token.scope as string).split(' ');
|
||||
const tokenScopes = Array.isArray(token.scope)
|
||||
? token.scope
|
||||
: (token.scope as string).split(' ');
|
||||
|
||||
return requestedScopes.every((s) => tokenScopes.includes(s));
|
||||
},
|
||||
@@ -261,8 +265,9 @@ const oauthModel: OAuth2Server.AuthorizationCodeModel & OAuth2Server.RefreshToke
|
||||
* Validate scope
|
||||
*/
|
||||
validateScope: async (user: OAuth2Server.User, client: OAuth2Server.Client, scope?: string[]) => {
|
||||
const settings = loadSettings();
|
||||
const oauthConfig = settings.systemConfig?.oauthServer;
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
const oauthConfig = systemConfig?.oauthServer;
|
||||
const allowedScopes = oauthConfig?.allowedScopes || ['read', 'write'];
|
||||
|
||||
if (!scope || scope.length === 0) {
|
||||
@@ -281,9 +286,10 @@ let oauth: OAuth2Server | null = null;
|
||||
/**
|
||||
* Initialize OAuth server
|
||||
*/
|
||||
export const initOAuthServer = (): void => {
|
||||
const settings = loadSettings();
|
||||
const oauthConfig = settings.systemConfig?.oauthServer;
|
||||
export const initOAuthServer = async (): Promise<void> => {
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
const oauthConfig = systemConfig?.oauthServer;
|
||||
const requireState = oauthConfig?.requireState === true;
|
||||
|
||||
if (!oauthConfig || !oauthConfig.enabled) {
|
||||
@@ -333,7 +339,7 @@ export const authenticateUser = async (
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<OAuth2Server.User | null> => {
|
||||
const user = findUserByUsername(username);
|
||||
const user = await findUserByUsername(username);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ProxyOAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/providers/proxyProvider.js';
|
||||
import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js';
|
||||
import { RequestHandler } from 'express';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import { getServerDao, getSystemConfigDao } from '../dao/index.js';
|
||||
import { initializeOAuthForServer, refreshAccessToken } from './oauthClientRegistration.js';
|
||||
|
||||
// Re-export for external use
|
||||
@@ -22,9 +22,10 @@ let oauthRouter: RequestHandler | null = null;
|
||||
/**
|
||||
* Initialize OAuth provider from system configuration
|
||||
*/
|
||||
export const initOAuthProvider = (): void => {
|
||||
const settings = loadSettings();
|
||||
const oauthConfig = settings.systemConfig?.oauth;
|
||||
export const initOAuthProvider = async (): Promise<void> => {
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
const oauthConfig = systemConfig?.oauth;
|
||||
|
||||
if (!oauthConfig || !oauthConfig.enabled) {
|
||||
console.log('OAuth provider is disabled or not configured');
|
||||
@@ -140,8 +141,8 @@ export const isOAuthEnabled = (): boolean => {
|
||||
* Handles both static tokens and dynamic OAuth flows with automatic token refresh
|
||||
*/
|
||||
export const getServerOAuthToken = async (serverName: string): Promise<string | undefined> => {
|
||||
const settings = loadSettings();
|
||||
const serverConfig = settings.mcpServers[serverName];
|
||||
const serverDao = getServerDao();
|
||||
const serverConfig = await serverDao.findById(serverName);
|
||||
|
||||
if (!serverConfig?.oauth) {
|
||||
return undefined;
|
||||
@@ -227,15 +228,15 @@ export const addOAuthHeader = async (
|
||||
* Call this at application startup to pre-register known OAuth servers
|
||||
*/
|
||||
export const initializeAllOAuthClients = async (): Promise<void> => {
|
||||
const settings = loadSettings();
|
||||
const serverDao = getServerDao();
|
||||
const allServers = await serverDao.findAll();
|
||||
|
||||
console.log('Initializing OAuth clients for explicitly configured servers...');
|
||||
|
||||
const serverNames = Object.keys(settings.mcpServers);
|
||||
const registrationPromises: Promise<void>[] = [];
|
||||
|
||||
for (const serverName of serverNames) {
|
||||
const serverConfig = settings.mcpServers[serverName];
|
||||
for (const serverConfig of allServers) {
|
||||
const serverName = serverConfig.name;
|
||||
|
||||
// Only initialize servers with explicitly enabled dynamic registration
|
||||
// Others will be auto-detected and registered on first 401 response
|
||||
|
||||
@@ -1,53 +1,58 @@
|
||||
import { loadSettings, saveSettings } from '../config/index.js';
|
||||
import { McpSettings, ServerConfig } from '../types/index.js';
|
||||
import { getServerDao } from '../dao/index.js';
|
||||
import { ServerConfig } from '../types/index.js';
|
||||
|
||||
type OAuthConfig = NonNullable<ServerConfig['oauth']>;
|
||||
export type ServerConfigWithOAuth = ServerConfig & { oauth: OAuthConfig };
|
||||
|
||||
export interface OAuthSettingsContext {
|
||||
settings: McpSettings;
|
||||
serverConfig: ServerConfig;
|
||||
oauth: OAuthConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the latest server configuration from disk.
|
||||
* Load the latest server configuration from DAO.
|
||||
*/
|
||||
export const loadServerConfig = (serverName: string): ServerConfig | undefined => {
|
||||
const settings = loadSettings();
|
||||
return settings.mcpServers?.[serverName];
|
||||
export const loadServerConfig = async (serverName: string): Promise<ServerConfig | undefined> => {
|
||||
const serverDao = getServerDao();
|
||||
const server = await serverDao.findById(serverName);
|
||||
if (!server) {
|
||||
return undefined;
|
||||
}
|
||||
const { name: _, ...config } = server;
|
||||
return config;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mutate OAuth configuration for a server and persist the updated settings.
|
||||
* The mutator receives the shared settings object to allow related updates when needed.
|
||||
* The mutator receives the server config to allow related updates when needed.
|
||||
*/
|
||||
export const mutateOAuthSettings = async (
|
||||
serverName: string,
|
||||
mutator: (context: OAuthSettingsContext) => void,
|
||||
): Promise<ServerConfigWithOAuth | undefined> => {
|
||||
const settings = loadSettings();
|
||||
const serverConfig = settings.mcpServers?.[serverName];
|
||||
const serverDao = getServerDao();
|
||||
const server = await serverDao.findById(serverName);
|
||||
|
||||
if (!serverConfig) {
|
||||
if (!server) {
|
||||
console.warn(`Server ${serverName} not found while updating OAuth settings`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { name: _, ...serverConfig } = server;
|
||||
|
||||
if (!serverConfig.oauth) {
|
||||
serverConfig.oauth = {};
|
||||
}
|
||||
|
||||
const context: OAuthSettingsContext = {
|
||||
settings,
|
||||
serverConfig,
|
||||
oauth: serverConfig.oauth,
|
||||
};
|
||||
|
||||
mutator(context);
|
||||
|
||||
const saved = saveSettings(settings);
|
||||
if (!saved) {
|
||||
const updated = await serverDao.update(serverName, { oauth: serverConfig.oauth });
|
||||
if (!updated) {
|
||||
throw new Error(`Failed to persist OAuth settings for server ${serverName}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { OpenAPIV3 } from 'openapi-types';
|
||||
import { Tool } from '../types/index.js';
|
||||
import { getServersInfo } from './mcpService.js';
|
||||
import config from '../config/index.js';
|
||||
import { loadSettings, getNameSeparator } from '../config/index.js';
|
||||
import config, { getNameSeparator } from '../config/index.js';
|
||||
import { getSystemConfigDao } from '../dao/index.js';
|
||||
|
||||
/**
|
||||
* Service for generating OpenAPI 3.x specifications from MCP tools
|
||||
@@ -174,7 +174,7 @@ export async function generateOpenAPISpec(
|
||||
const groupConfig: Map<string, string[] | 'all'> = new Map();
|
||||
if (options.groupFilter) {
|
||||
const { getGroupByIdOrName } = await import('./groupService.js');
|
||||
const group = getGroupByIdOrName(options.groupFilter);
|
||||
const group = await getGroupByIdOrName(options.groupFilter);
|
||||
if (group) {
|
||||
// Extract server names and their tool configurations from group
|
||||
const groupServerNames: string[] = [];
|
||||
@@ -250,12 +250,11 @@ export async function generateOpenAPISpec(
|
||||
paths[pathName][method] = operation;
|
||||
}
|
||||
|
||||
const settings = loadSettings();
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
// Get server URL
|
||||
const baseUrl =
|
||||
options.serverUrl ||
|
||||
settings.systemConfig?.install?.baseUrl ||
|
||||
`http://localhost:${config.port}`;
|
||||
options.serverUrl || systemConfig?.install?.baseUrl || `http://localhost:${config.port}`;
|
||||
const serverUrl = `${baseUrl}${config.basePath}/api`;
|
||||
|
||||
// Generate OpenAPI document
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createRequire } from 'module';
|
||||
import { join } from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
|
||||
type Class<T> = new (...args: any[]) => T;
|
||||
|
||||
@@ -11,7 +11,24 @@ interface Service<T> {
|
||||
const registry = new Map<string, Service<any>>();
|
||||
const instances = new Map<string, unknown>();
|
||||
|
||||
export function registerService<T>(key: string, entry: Service<T>) {
|
||||
async function tryLoadOverride<T>(key: string, overridePath: string): Promise<Class<T> | undefined> {
|
||||
try {
|
||||
const moduleUrl = pathToFileURL(overridePath).href;
|
||||
const mod = await import(moduleUrl);
|
||||
const override = mod[key.charAt(0).toUpperCase() + key.slice(1) + 'x'];
|
||||
if (typeof override === 'function') {
|
||||
return override as Class<T>;
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Ignore not-found errors and keep trying other paths; surface other errors for visibility
|
||||
if (error?.code !== 'ERR_MODULE_NOT_FOUND' && error?.code !== 'MODULE_NOT_FOUND') {
|
||||
console.warn(`Failed to load service override from ${overridePath}:`, error);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function registerService<T>(key: string, entry: Service<T>) {
|
||||
// Try to load override immediately during registration
|
||||
// Try multiple paths and file extensions in order
|
||||
const serviceDirs = ['src/services', 'dist/services'];
|
||||
@@ -22,18 +39,10 @@ export function registerService<T>(key: string, entry: Service<T>) {
|
||||
for (const fileExt of fileExts) {
|
||||
const overridePath = join(process.cwd(), serviceDir, overrideFileName + fileExt);
|
||||
|
||||
try {
|
||||
// Use createRequire with a stable path reference
|
||||
const require = createRequire(join(process.cwd(), 'package.json'));
|
||||
const mod = require(overridePath);
|
||||
const override = mod[key.charAt(0).toUpperCase() + key.slice(1) + 'x'];
|
||||
if (typeof override === 'function') {
|
||||
entry.override = override;
|
||||
break; // Found override, exit both loops
|
||||
}
|
||||
} catch (error) {
|
||||
// Continue trying next path/extension combination
|
||||
continue;
|
||||
const override = await tryLoadOverride<T>(key, overridePath);
|
||||
if (override) {
|
||||
entry.override = override;
|
||||
break; // Found override, exit both loops
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { registerService, getService } from './registry.js';
|
||||
import { DataService, DataServiceImpl } from './dataService.js';
|
||||
|
||||
registerService('dataService', {
|
||||
defaultImpl: DataServiceImpl,
|
||||
});
|
||||
import { DataService } from './dataService.js';
|
||||
|
||||
export function getDataService(): DataService {
|
||||
return getService<DataService>('dataService');
|
||||
return new DataService();
|
||||
}
|
||||
|
||||
@@ -10,6 +10,20 @@ import {
|
||||
transports,
|
||||
} from './sseService.js';
|
||||
|
||||
// Default mock system config
|
||||
const defaultSystemConfig = {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
enableSessionRebuild: false,
|
||||
};
|
||||
|
||||
// Mutable mock config that can be changed in tests
|
||||
let currentSystemConfig = { ...defaultSystemConfig };
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('./mcpService.js', () => ({
|
||||
deleteMcpServer: jest.fn(),
|
||||
@@ -25,21 +39,21 @@ jest.mock('../config/index.js', () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: config,
|
||||
loadSettings: jest.fn(() => ({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
enableSessionRebuild: false, // Default to false for tests
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock DAO layer
|
||||
jest.mock('../dao/index.js', () => ({
|
||||
getSystemConfigDao: jest.fn(() => ({
|
||||
get: jest.fn().mockImplementation(() => Promise.resolve(currentSystemConfig)),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock oauthBearer
|
||||
jest.mock('../utils/oauthBearer.js', () => ({
|
||||
resolveOAuthUserFromToken: jest.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
jest.mock('./userContextService.js', () => ({
|
||||
UserContextService: {
|
||||
getInstance: jest.fn(() => ({
|
||||
@@ -57,7 +71,9 @@ jest.mock('@modelcontextprotocol/sdk/server/sse.js', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({
|
||||
StreamableHTTPServerTransport: jest.fn().mockImplementation(() => mockStreamableHTTPServerTransport),
|
||||
StreamableHTTPServerTransport: jest
|
||||
.fn()
|
||||
.mockImplementation(() => mockStreamableHTTPServerTransport),
|
||||
}));
|
||||
|
||||
jest.mock('@modelcontextprotocol/sdk/types.js', () => ({
|
||||
@@ -66,11 +82,15 @@ jest.mock('@modelcontextprotocol/sdk/types.js', () => ({
|
||||
|
||||
// Import mocked modules
|
||||
import { getMcpServer } from './mcpService.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import { UserContextService } from './userContextService.js';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
|
||||
// Helper function to update the mock system config
|
||||
const setMockSystemConfig = (config: typeof defaultSystemConfig) => {
|
||||
currentSystemConfig = config;
|
||||
};
|
||||
|
||||
type MockResponse = Response & {
|
||||
status: jest.Mock;
|
||||
send: jest.Mock;
|
||||
@@ -79,8 +99,7 @@ type MockResponse = Response & {
|
||||
headersStore: Record<string, string>;
|
||||
};
|
||||
|
||||
const EXPECTED_METADATA_URL =
|
||||
'http://localhost:3000/.well-known/oauth-protected-resource/test';
|
||||
const EXPECTED_METADATA_URL = 'http://localhost:3000/.well-known/oauth-protected-resource/test';
|
||||
|
||||
// Create mock instances for testing
|
||||
const mockStreamableHTTPServerTransport = {
|
||||
@@ -156,18 +175,15 @@ describe('sseService', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Reset settings cache
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
enableSessionRebuild: false, // Default to false for tests
|
||||
// Reset settings cache to default
|
||||
setMockSystemConfig({
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
enableSessionRebuild: false, // Default to false for tests
|
||||
});
|
||||
});
|
||||
|
||||
@@ -185,15 +201,12 @@ describe('sseService', () => {
|
||||
});
|
||||
|
||||
it('should return 401 when bearer auth is enabled but no authorization header', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
setMockSystemConfig({
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -206,15 +219,12 @@ describe('sseService', () => {
|
||||
});
|
||||
|
||||
it('should return 401 when bearer auth is enabled with invalid token', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
setMockSystemConfig({
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -229,15 +239,12 @@ describe('sseService', () => {
|
||||
});
|
||||
|
||||
it('should pass when bearer auth is enabled with valid token', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
setMockSystemConfig({
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -279,15 +286,12 @@ describe('sseService', () => {
|
||||
|
||||
describe('handleSseConnection', () => {
|
||||
it('should reject global routes when disabled', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: false,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
},
|
||||
setMockSystemConfig({
|
||||
routing: {
|
||||
enableGlobalRoute: false,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -375,15 +379,12 @@ describe('sseService', () => {
|
||||
});
|
||||
|
||||
it('should return 401 when bearer auth fails', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
setMockSystemConfig({
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -400,15 +401,12 @@ describe('sseService', () => {
|
||||
|
||||
describe('handleMcpPostRequest', () => {
|
||||
it('should reject global routes when disabled', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: false,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
},
|
||||
setMockSystemConfig({
|
||||
routing: {
|
||||
enableGlobalRoute: false,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -463,17 +461,14 @@ describe('sseService', () => {
|
||||
|
||||
it('should transparently rebuild invalid session when enabled', async () => {
|
||||
// Enable session rebuild for this test
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
enableSessionRebuild: true, // Enable session rebuild
|
||||
setMockSystemConfig({
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
enableSessionRebuild: true, // Enable session rebuild
|
||||
});
|
||||
|
||||
const req = createMockRequest({
|
||||
@@ -487,20 +482,19 @@ describe('sseService', () => {
|
||||
|
||||
// With session rebuild enabled, invalid sessions should be transparently rebuilt
|
||||
expect(StreamableHTTPServerTransport).toHaveBeenCalled();
|
||||
const mockInstance = (StreamableHTTPServerTransport as jest.MockedClass<typeof StreamableHTTPServerTransport>).mock.results[0].value;
|
||||
const mockInstance = (
|
||||
StreamableHTTPServerTransport as jest.MockedClass<typeof StreamableHTTPServerTransport>
|
||||
).mock.results[0].value;
|
||||
expect(mockInstance.handleRequest).toHaveBeenCalledWith(req, res, req.body);
|
||||
});
|
||||
|
||||
it('should return 401 when bearer auth fails', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
setMockSystemConfig({
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -530,20 +524,17 @@ describe('sseService', () => {
|
||||
});
|
||||
it('should return error when session rebuild is disabled in handleMcpOtherRequest', async () => {
|
||||
// Clear transports before test
|
||||
Object.keys(transports).forEach(key => delete transports[key]);
|
||||
|
||||
Object.keys(transports).forEach((key) => delete transports[key]);
|
||||
|
||||
// Enable bearer auth for this test
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
enableSessionRebuild: false, // Disable session rebuild
|
||||
setMockSystemConfig({
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
enableSessionRebuild: false, // Disable session rebuild
|
||||
});
|
||||
|
||||
// Mock user context to exist
|
||||
@@ -555,7 +546,7 @@ describe('sseService', () => {
|
||||
const req = createMockRequest({
|
||||
headers: {
|
||||
'mcp-session-id': 'invalid-session',
|
||||
'authorization': 'Bearer test-key'
|
||||
authorization: 'Bearer test-key',
|
||||
},
|
||||
params: { group: 'test-group' },
|
||||
});
|
||||
@@ -570,23 +561,20 @@ describe('sseService', () => {
|
||||
|
||||
it('should transparently rebuild invalid session in handleMcpOtherRequest when enabled', async () => {
|
||||
// Enable bearer auth and session rebuild for this test
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
enableSessionRebuild: true, // Enable session rebuild
|
||||
setMockSystemConfig({
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
enableSessionRebuild: true, // Enable session rebuild
|
||||
});
|
||||
|
||||
const req = createMockRequest({
|
||||
headers: {
|
||||
'mcp-session-id': 'invalid-session',
|
||||
'authorization': 'Bearer test-key'
|
||||
authorization: 'Bearer test-key',
|
||||
},
|
||||
});
|
||||
const res = createMockResponse();
|
||||
@@ -596,21 +584,18 @@ describe('sseService', () => {
|
||||
// Should not return 400 error, but instead transparently rebuild the session
|
||||
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||
expect(res.send).not.toHaveBeenCalledWith('Invalid or missing session ID');
|
||||
|
||||
|
||||
// Should attempt to handle the request (session was rebuilt)
|
||||
expect(mockStreamableHTTPServerTransport.handleRequest).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 401 when bearer auth fails', async () => {
|
||||
(loadSettings as jest.MockedFunction<typeof loadSettings>).mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
setMockSystemConfig({
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { deleteMcpServer, getMcpServer } from './mcpService.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import config from '../config/index.js';
|
||||
import { getSystemConfigDao } from '../dao/index.js';
|
||||
import { UserContextService } from './userContextService.js';
|
||||
import { RequestContextService } from './requestContextService.js';
|
||||
import { IUser } from '../types/index.js';
|
||||
@@ -30,9 +30,10 @@ type BearerAuthResult =
|
||||
reason: 'missing' | 'invalid';
|
||||
};
|
||||
|
||||
const validateBearerAuth = (req: Request): BearerAuthResult => {
|
||||
const settings = loadSettings();
|
||||
const routingConfig = settings.systemConfig?.routing || {
|
||||
const validateBearerAuth = async (req: Request): Promise<BearerAuthResult> => {
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
const routingConfig = systemConfig?.routing || {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
@@ -54,7 +55,7 @@ const validateBearerAuth = (req: Request): BearerAuthResult => {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
const oauthUser = resolveOAuthUserFromToken(token);
|
||||
const oauthUser = await resolveOAuthUserFromToken(token);
|
||||
if (oauthUser) {
|
||||
return { valid: true, user: oauthUser };
|
||||
}
|
||||
@@ -170,7 +171,7 @@ export const handleSseConnection = async (req: Request, res: Response): Promise<
|
||||
const userContextService = UserContextService.getInstance();
|
||||
|
||||
// Check bearer auth using filtered settings
|
||||
const bearerAuthResult = validateBearerAuth(req);
|
||||
const bearerAuthResult = await validateBearerAuth(req);
|
||||
if (!bearerAuthResult.valid) {
|
||||
sendBearerAuthError(req, res, bearerAuthResult.reason);
|
||||
return;
|
||||
@@ -181,8 +182,9 @@ export const handleSseConnection = async (req: Request, res: Response): Promise<
|
||||
const currentUser = userContextService.getCurrentUser();
|
||||
const username = currentUser?.username;
|
||||
|
||||
const settings = loadSettings();
|
||||
const routingConfig = settings.systemConfig?.routing || {
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
const routingConfig = systemConfig?.routing || {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
@@ -248,7 +250,7 @@ export const handleSseMessage = async (req: Request, res: Response): Promise<voi
|
||||
const userContextService = UserContextService.getInstance();
|
||||
|
||||
// Check bearer auth using filtered settings
|
||||
const bearerAuthResult = validateBearerAuth(req);
|
||||
const bearerAuthResult = await validateBearerAuth(req);
|
||||
if (!bearerAuthResult.valid) {
|
||||
sendBearerAuthError(req, res, bearerAuthResult.reason);
|
||||
return;
|
||||
@@ -429,7 +431,7 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
|
||||
const userContextService = UserContextService.getInstance();
|
||||
|
||||
// Check bearer auth using filtered settings
|
||||
const bearerAuthResult = validateBearerAuth(req);
|
||||
const bearerAuthResult = await validateBearerAuth(req);
|
||||
if (!bearerAuthResult.valid) {
|
||||
sendBearerAuthError(req, res, bearerAuthResult.reason);
|
||||
return;
|
||||
@@ -448,8 +450,9 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
|
||||
);
|
||||
|
||||
// Get filtered settings based on user context (after setting user context)
|
||||
const settings = loadSettings();
|
||||
const routingConfig = settings.systemConfig?.routing || {
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
const routingConfig = systemConfig?.routing || {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
};
|
||||
@@ -473,8 +476,7 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
|
||||
transport = transportInfo.transport as StreamableHTTPServerTransport;
|
||||
} else if (sessionId) {
|
||||
// Case 2: SessionId exists but transport is missing (server restart), check if session rebuild is enabled
|
||||
const settings = loadSettings();
|
||||
const enableSessionRebuild = settings.systemConfig?.enableSessionRebuild || false;
|
||||
const enableSessionRebuild = systemConfig?.enableSessionRebuild || false;
|
||||
|
||||
if (enableSessionRebuild) {
|
||||
console.log(
|
||||
@@ -680,7 +682,7 @@ export const handleMcpOtherRequest = async (req: Request, res: Response) => {
|
||||
const userContextService = UserContextService.getInstance();
|
||||
|
||||
// Check bearer auth using filtered settings
|
||||
const bearerAuthResult = validateBearerAuth(req);
|
||||
const bearerAuthResult = await validateBearerAuth(req);
|
||||
if (!bearerAuthResult.valid) {
|
||||
sendBearerAuthError(req, res, bearerAuthResult.reason);
|
||||
return;
|
||||
@@ -703,8 +705,9 @@ export const handleMcpOtherRequest = async (req: Request, res: Response) => {
|
||||
|
||||
// If session doesn't exist, attempt transparent rebuild if enabled
|
||||
if (!transportEntry) {
|
||||
const settings = loadSettings();
|
||||
const enableSessionRebuild = settings.systemConfig?.enableSessionRebuild || false;
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
const enableSessionRebuild = systemConfig?.enableSessionRebuild || false;
|
||||
|
||||
if (enableSessionRebuild) {
|
||||
console.log(
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { IUser } from '../types/index.js';
|
||||
import { getUsers, createUser, findUserByUsername } from '../models/User.js';
|
||||
import { saveSettings, loadSettings } from '../config/index.js';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { getUserDao } from '../dao/index.js';
|
||||
|
||||
// Get all users
|
||||
export const getAllUsers = (): IUser[] => {
|
||||
return getUsers();
|
||||
export const getAllUsers = async (): Promise<IUser[]> => {
|
||||
const userDao = getUserDao();
|
||||
return await userDao.findAll();
|
||||
};
|
||||
|
||||
// Get user by username
|
||||
export const getUserByUsername = (username: string): IUser | undefined => {
|
||||
return findUserByUsername(username);
|
||||
export const getUserByUsername = async (username: string): Promise<IUser | undefined> => {
|
||||
const userDao = getUserDao();
|
||||
const user = await userDao.findByUsername(username);
|
||||
return user || undefined;
|
||||
};
|
||||
|
||||
// Create a new user
|
||||
@@ -20,18 +21,13 @@ export const createNewUser = async (
|
||||
isAdmin: boolean = false,
|
||||
): Promise<IUser | null> => {
|
||||
try {
|
||||
const existingUser = findUserByUsername(username);
|
||||
const userDao = getUserDao();
|
||||
const existingUser = await userDao.findByUsername(username);
|
||||
if (existingUser) {
|
||||
return null; // User already exists
|
||||
}
|
||||
|
||||
const userData: IUser = {
|
||||
username,
|
||||
password,
|
||||
isAdmin,
|
||||
};
|
||||
|
||||
return await createUser(userData);
|
||||
return await userDao.createWithHashedPassword(username, password, isAdmin);
|
||||
} catch (error) {
|
||||
console.error('Failed to create user:', error);
|
||||
return null;
|
||||
@@ -44,36 +40,31 @@ export const updateUser = async (
|
||||
data: { isAdmin?: boolean; newPassword?: string },
|
||||
): Promise<IUser | null> => {
|
||||
try {
|
||||
const users = getUsers();
|
||||
const userIndex = users.findIndex((user) => user.username === username);
|
||||
const userDao = getUserDao();
|
||||
const user = await userDao.findByUsername(username);
|
||||
|
||||
if (userIndex === -1) {
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = users[userIndex];
|
||||
|
||||
// Update admin status if provided
|
||||
if (data.isAdmin !== undefined) {
|
||||
user.isAdmin = data.isAdmin;
|
||||
const result = await userDao.update(username, { isAdmin: data.isAdmin });
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Update password if provided
|
||||
if (data.newPassword) {
|
||||
const salt = await bcrypt.genSalt(10);
|
||||
user.password = await bcrypt.hash(data.newPassword, salt);
|
||||
const success = await userDao.updatePassword(username, data.newPassword);
|
||||
if (!success) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Save users array back to settings
|
||||
const { saveSettings, loadSettings } = await import('../config/index.js');
|
||||
const settings = loadSettings();
|
||||
settings.users = users;
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return user;
|
||||
// Return updated user
|
||||
return await userDao.findByUsername(username);
|
||||
} catch (error) {
|
||||
console.error('Failed to update user:', error);
|
||||
return null;
|
||||
@@ -81,10 +72,12 @@ export const updateUser = async (
|
||||
};
|
||||
|
||||
// Delete a user
|
||||
export const deleteUser = (username: string): boolean => {
|
||||
export const deleteUser = async (username: string): Promise<boolean> => {
|
||||
try {
|
||||
const userDao = getUserDao();
|
||||
|
||||
// Cannot delete the last admin user
|
||||
const users = getUsers();
|
||||
const users = await userDao.findAll();
|
||||
const adminUsers = users.filter((user) => user.isAdmin);
|
||||
const userToDelete = users.find((user) => user.username === username);
|
||||
|
||||
@@ -92,17 +85,7 @@ export const deleteUser = (username: string): boolean => {
|
||||
return false; // Cannot delete the last admin
|
||||
}
|
||||
|
||||
const filteredUsers = users.filter((user) => user.username !== username);
|
||||
|
||||
if (filteredUsers.length === users.length) {
|
||||
return false; // User not found
|
||||
}
|
||||
|
||||
// Save filtered users back to settings
|
||||
const settings = loadSettings();
|
||||
settings.users = filteredUsers;
|
||||
|
||||
return saveSettings(settings);
|
||||
return await userDao.delete(username);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete user:', error);
|
||||
return false;
|
||||
@@ -110,17 +93,21 @@ export const deleteUser = (username: string): boolean => {
|
||||
};
|
||||
|
||||
// Check if user has admin permissions
|
||||
export const isUserAdmin = (username: string): boolean => {
|
||||
const user = findUserByUsername(username);
|
||||
export const isUserAdmin = async (username: string): Promise<boolean> => {
|
||||
const userDao = getUserDao();
|
||||
const user = await userDao.findByUsername(username);
|
||||
return user?.isAdmin || false;
|
||||
};
|
||||
|
||||
// Get user count
|
||||
export const getUserCount = (): number => {
|
||||
return getUsers().length;
|
||||
export const getUserCount = async (): Promise<number> => {
|
||||
const userDao = getUserDao();
|
||||
return await userDao.count();
|
||||
};
|
||||
|
||||
// Get admin count
|
||||
export const getAdminCount = (): number => {
|
||||
return getUsers().filter((user) => user.isAdmin).length;
|
||||
export const getAdminCount = async (): Promise<number> => {
|
||||
const userDao = getUserDao();
|
||||
const admins = await userDao.findAdmins();
|
||||
return admins.length;
|
||||
};
|
||||
|
||||
@@ -176,12 +176,8 @@ export interface SystemConfig {
|
||||
}
|
||||
|
||||
export interface UserConfig {
|
||||
routing?: {
|
||||
enableGlobalRoute?: boolean; // Controls whether the /sse endpoint without group is enabled
|
||||
enableGroupNameRoute?: boolean; // Controls whether group routing by name is allowed
|
||||
enableBearerAuth?: boolean; // Controls whether bearer auth is enabled for group routes
|
||||
bearerAuthKey?: string; // The bearer auth key to validate against
|
||||
};
|
||||
routing?: Record<string, any>; // User-specific routing configuration
|
||||
[key: string]: any; // Allow additional dynamic properties
|
||||
}
|
||||
|
||||
// OAuth Client for MCPHub's own authorization server
|
||||
@@ -270,7 +266,8 @@ export interface ServerConfig {
|
||||
headers?: Record<string, string>; // HTTP headers for SSE/streamable-http/openapi servers
|
||||
enabled?: boolean; // Flag to enable/disable the server
|
||||
owner?: string; // Owner of the server, defaults to 'admin' user
|
||||
keepAliveInterval?: number; // Keep-alive ping interval in milliseconds (default: 60000ms for SSE servers)
|
||||
keepAlive?: boolean; // Enable keep-alive ping for SSE servers (default: false - disabled to avoid excessive API calls)
|
||||
keepAliveInterval?: number; // Keep-alive ping interval in milliseconds (default: 60000ms when keepAlive is enabled)
|
||||
tools?: Record<string, { enabled: boolean; description?: string }>; // Tool-specific configurations with enable/disable state and custom descriptions
|
||||
prompts?: Record<string, { enabled: boolean; description?: string }>; // Prompt-specific configurations with enable/disable state and custom descriptions
|
||||
options?: Partial<Pick<RequestOptions, 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout'>>; // MCP request options configuration
|
||||
|
||||
194
src/utils/migration.ts
Normal file
194
src/utils/migration.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { loadOriginalSettings } from '../config/index.js';
|
||||
import { initializeDatabase } from '../db/connection.js';
|
||||
import { setDaoFactory } from '../dao/DaoFactory.js';
|
||||
import { DatabaseDaoFactory } from '../dao/DatabaseDaoFactory.js';
|
||||
import { UserRepository } from '../db/repositories/UserRepository.js';
|
||||
import { ServerRepository } from '../db/repositories/ServerRepository.js';
|
||||
import { GroupRepository } from '../db/repositories/GroupRepository.js';
|
||||
import { SystemConfigRepository } from '../db/repositories/SystemConfigRepository.js';
|
||||
import { UserConfigRepository } from '../db/repositories/UserConfigRepository.js';
|
||||
|
||||
/**
|
||||
* Migrate from file-based configuration to database
|
||||
*/
|
||||
export async function migrateToDatabase(): Promise<boolean> {
|
||||
try {
|
||||
console.log('Starting migration from file to database...');
|
||||
|
||||
// Initialize database connection
|
||||
await initializeDatabase();
|
||||
console.log('Database connection established');
|
||||
|
||||
// Load current settings from file
|
||||
const settings = loadOriginalSettings();
|
||||
console.log('Loaded settings from file');
|
||||
|
||||
// Create repositories
|
||||
const userRepo = new UserRepository();
|
||||
const serverRepo = new ServerRepository();
|
||||
const groupRepo = new GroupRepository();
|
||||
const systemConfigRepo = new SystemConfigRepository();
|
||||
const userConfigRepo = new UserConfigRepository();
|
||||
|
||||
// Migrate users
|
||||
if (settings.users && settings.users.length > 0) {
|
||||
console.log(`Migrating ${settings.users.length} users...`);
|
||||
for (const user of settings.users) {
|
||||
const exists = await userRepo.exists(user.username);
|
||||
if (!exists) {
|
||||
await userRepo.create({
|
||||
username: user.username,
|
||||
password: user.password,
|
||||
isAdmin: user.isAdmin || false,
|
||||
});
|
||||
console.log(` - Created user: ${user.username}`);
|
||||
} else {
|
||||
console.log(` - User already exists: ${user.username}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate servers
|
||||
if (settings.mcpServers) {
|
||||
const serverNames = Object.keys(settings.mcpServers);
|
||||
console.log(`Migrating ${serverNames.length} servers...`);
|
||||
for (const [name, config] of Object.entries(settings.mcpServers)) {
|
||||
const exists = await serverRepo.exists(name);
|
||||
if (!exists) {
|
||||
await serverRepo.create({
|
||||
name,
|
||||
type: config.type,
|
||||
url: config.url,
|
||||
command: config.command,
|
||||
args: config.args,
|
||||
env: config.env,
|
||||
headers: config.headers,
|
||||
enabled: config.enabled !== undefined ? config.enabled : true,
|
||||
owner: config.owner,
|
||||
keepAliveInterval: config.keepAliveInterval,
|
||||
tools: config.tools,
|
||||
prompts: config.prompts,
|
||||
options: config.options,
|
||||
oauth: config.oauth,
|
||||
});
|
||||
console.log(` - Created server: ${name}`);
|
||||
} else {
|
||||
console.log(` - Server already exists: ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate groups
|
||||
if (settings.groups && settings.groups.length > 0) {
|
||||
console.log(`Migrating ${settings.groups.length} groups...`);
|
||||
for (const group of settings.groups) {
|
||||
const exists = await groupRepo.existsByName(group.name);
|
||||
if (!exists) {
|
||||
await groupRepo.create({
|
||||
name: group.name,
|
||||
description: group.description,
|
||||
servers: Array.isArray(group.servers) ? group.servers : [],
|
||||
owner: group.owner,
|
||||
});
|
||||
console.log(` - Created group: ${group.name}`);
|
||||
} else {
|
||||
console.log(` - Group already exists: ${group.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate system config
|
||||
if (settings.systemConfig) {
|
||||
console.log('Migrating system configuration...');
|
||||
const systemConfig = {
|
||||
routing: settings.systemConfig.routing || {},
|
||||
install: settings.systemConfig.install || {},
|
||||
smartRouting: settings.systemConfig.smartRouting || {},
|
||||
mcpRouter: settings.systemConfig.mcpRouter || {},
|
||||
nameSeparator: settings.systemConfig.nameSeparator,
|
||||
oauth: settings.systemConfig.oauth || {},
|
||||
oauthServer: settings.systemConfig.oauthServer || {},
|
||||
enableSessionRebuild: settings.systemConfig.enableSessionRebuild,
|
||||
};
|
||||
await systemConfigRepo.update(systemConfig);
|
||||
console.log(' - System configuration updated');
|
||||
}
|
||||
|
||||
// Migrate user configs
|
||||
if (settings.userConfigs) {
|
||||
const usernames = Object.keys(settings.userConfigs);
|
||||
console.log(`Migrating ${usernames.length} user configurations...`);
|
||||
for (const [username, config] of Object.entries(settings.userConfigs)) {
|
||||
const userConfig = {
|
||||
routing: config.routing || {},
|
||||
additionalConfig: config,
|
||||
};
|
||||
await userConfigRepo.update(username, userConfig);
|
||||
console.log(` - Updated configuration for user: ${username}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ Migration completed successfully');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('❌ Migration failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize database mode
|
||||
* This function should be called during application startup when USE_DB=true
|
||||
*/
|
||||
export async function initializeDatabaseMode(): Promise<boolean> {
|
||||
try {
|
||||
console.log('Initializing database mode...');
|
||||
|
||||
// Initialize database connection
|
||||
await initializeDatabase();
|
||||
console.log('Database connection established');
|
||||
|
||||
// Switch to database factory
|
||||
setDaoFactory(DatabaseDaoFactory.getInstance());
|
||||
console.log('Switched to database-backed DAO implementations');
|
||||
|
||||
// Check if migration is needed
|
||||
const userRepo = new UserRepository();
|
||||
const userCount = await userRepo.count();
|
||||
|
||||
if (userCount === 0) {
|
||||
console.log('No users found in database, running migration...');
|
||||
const migrated = await migrateToDatabase();
|
||||
if (!migrated) {
|
||||
throw new Error('Migration failed');
|
||||
}
|
||||
} else {
|
||||
console.log(`Database already contains ${userCount} users, skipping migration`);
|
||||
}
|
||||
|
||||
console.log('✅ Database mode initialized successfully');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to initialize database mode:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI tool for migration
|
||||
*/
|
||||
export async function runMigrationCli(): Promise<void> {
|
||||
console.log('MCPHub Configuration Migration Tool');
|
||||
console.log('====================================\n');
|
||||
|
||||
const success = await migrateToDatabase();
|
||||
|
||||
if (success) {
|
||||
console.log('\n✅ Migration completed successfully!');
|
||||
console.log('You can now set USE_DB=true to use database-backed configuration');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log('\n❌ Migration failed!');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { IUser } from '../types/index.js';
|
||||
/**
|
||||
* Resolve an MCPHub user from a raw OAuth bearer token.
|
||||
*/
|
||||
export const resolveOAuthUserFromToken = (token?: string): IUser | null => {
|
||||
export const resolveOAuthUserFromToken = async (token?: string): Promise<IUser | null> => {
|
||||
if (!token || !isOAuthServerEnabled()) {
|
||||
return null;
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export const resolveOAuthUserFromToken = (token?: string): IUser | null => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dbUser = findUserByUsername(oauthToken.username);
|
||||
const dbUser = await findUserByUsername(oauthToken.username);
|
||||
|
||||
return {
|
||||
username: oauthToken.username,
|
||||
@@ -28,7 +28,9 @@ export const resolveOAuthUserFromToken = (token?: string): IUser | null => {
|
||||
/**
|
||||
* Resolve an MCPHub user from an Authorization header.
|
||||
*/
|
||||
export const resolveOAuthUserFromAuthHeader = (authHeader?: string): IUser | null => {
|
||||
export const resolveOAuthUserFromAuthHeader = async (
|
||||
authHeader?: string,
|
||||
): Promise<IUser | null> => {
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -12,31 +12,36 @@ jest.mock('openid-client', () => ({
|
||||
refreshTokenGrant: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock the DAO module
|
||||
jest.mock('../../src/dao/index.js', () => ({
|
||||
getSystemConfigDao: jest.fn(),
|
||||
getServerDao: jest.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
initOAuthProvider,
|
||||
isOAuthEnabled,
|
||||
getServerOAuthToken,
|
||||
addOAuthHeader,
|
||||
} from '../../src/services/oauthService.js';
|
||||
import * as config from '../../src/config/index.js';
|
||||
|
||||
// Mock the config module
|
||||
jest.mock('../../src/config/index.js', () => ({
|
||||
loadSettings: jest.fn(),
|
||||
}));
|
||||
import * as daoModule from '../../src/dao/index.js';
|
||||
|
||||
describe('OAuth Service', () => {
|
||||
const mockLoadSettings = config.loadSettings as jest.MockedFunction<typeof config.loadSettings>;
|
||||
const mockGetSystemConfigDao = daoModule.getSystemConfigDao as jest.MockedFunction<
|
||||
typeof daoModule.getSystemConfigDao
|
||||
>;
|
||||
const mockGetServerDao = daoModule.getServerDao as jest.MockedFunction<
|
||||
typeof daoModule.getServerDao
|
||||
>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('initOAuthProvider', () => {
|
||||
it('should not initialize OAuth when disabled', () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
it('should not initialize OAuth when disabled', async () => {
|
||||
mockGetSystemConfigDao.mockReturnValue({
|
||||
get: jest.fn().mockResolvedValue({
|
||||
oauth: {
|
||||
enabled: false,
|
||||
issuerUrl: 'http://auth.example.com',
|
||||
@@ -46,97 +51,90 @@ describe('OAuth Service', () => {
|
||||
},
|
||||
},
|
||||
enableSessionRebuild: false,
|
||||
},
|
||||
});
|
||||
}),
|
||||
} as any);
|
||||
|
||||
initOAuthProvider();
|
||||
await initOAuthProvider();
|
||||
expect(isOAuthEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should not initialize OAuth when not configured', () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
it('should not initialize OAuth when not configured', async () => {
|
||||
mockGetSystemConfigDao.mockReturnValue({
|
||||
get: jest.fn().mockResolvedValue({
|
||||
enableSessionRebuild: false,
|
||||
},
|
||||
});
|
||||
}),
|
||||
} as any);
|
||||
|
||||
initOAuthProvider();
|
||||
await initOAuthProvider();
|
||||
expect(isOAuthEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should attempt to initialize OAuth when enabled and properly configured', () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
oauth: {
|
||||
enabled: true,
|
||||
issuerUrl: 'http://auth.example.com',
|
||||
endpoints: {
|
||||
authorizationUrl: 'http://auth.example.com/authorize',
|
||||
tokenUrl: 'http://auth.example.com/token',
|
||||
},
|
||||
clients: [
|
||||
{
|
||||
client_id: 'test-client',
|
||||
redirect_uris: ['http://localhost:3000/callback'],
|
||||
},
|
||||
],
|
||||
it('should attempt to initialize OAuth when enabled and properly configured', async () => {
|
||||
const mockGet = jest.fn().mockResolvedValue({
|
||||
oauth: {
|
||||
enabled: true,
|
||||
issuerUrl: 'http://auth.example.com',
|
||||
endpoints: {
|
||||
authorizationUrl: 'http://auth.example.com/authorize',
|
||||
tokenUrl: 'http://auth.example.com/token',
|
||||
},
|
||||
enableSessionRebuild: false,
|
||||
clients: [
|
||||
{
|
||||
client_id: 'test-client',
|
||||
redirect_uris: ['http://localhost:3000/callback'],
|
||||
},
|
||||
],
|
||||
},
|
||||
enableSessionRebuild: false,
|
||||
});
|
||||
mockGetSystemConfigDao.mockReturnValue({
|
||||
get: mockGet,
|
||||
} as any);
|
||||
|
||||
// In a test environment, the ProxyOAuthServerProvider may not fully initialize
|
||||
// due to missing dependencies or network issues, which is expected
|
||||
initOAuthProvider();
|
||||
await initOAuthProvider();
|
||||
// We just verify that the function doesn't throw an error
|
||||
expect(mockLoadSettings).toHaveBeenCalled();
|
||||
expect(mockGet).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getServerOAuthToken', () => {
|
||||
it('should return undefined when server has no OAuth config', async () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
url: 'http://example.com',
|
||||
},
|
||||
},
|
||||
});
|
||||
mockGetServerDao.mockReturnValue({
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
url: 'http://example.com',
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const token = await getServerOAuthToken('test-server');
|
||||
expect(token).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when server has no access token', async () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
url: 'http://example.com',
|
||||
oauth: {
|
||||
clientId: 'test-client',
|
||||
},
|
||||
mockGetServerDao.mockReturnValue({
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
url: 'http://example.com',
|
||||
oauth: {
|
||||
clientId: 'test-client',
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const token = await getServerOAuthToken('test-server');
|
||||
expect(token).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return access token when configured', async () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
url: 'http://example.com',
|
||||
oauth: {
|
||||
clientId: 'test-client',
|
||||
accessToken: 'test-access-token',
|
||||
},
|
||||
mockGetServerDao.mockReturnValue({
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
url: 'http://example.com',
|
||||
oauth: {
|
||||
clientId: 'test-client',
|
||||
accessToken: 'test-access-token',
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const token = await getServerOAuthToken('test-server');
|
||||
expect(token).toBe('test-access-token');
|
||||
@@ -145,13 +143,11 @@ describe('OAuth Service', () => {
|
||||
|
||||
describe('addOAuthHeader', () => {
|
||||
it('should not modify headers when no OAuth token is configured', async () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
url: 'http://example.com',
|
||||
},
|
||||
},
|
||||
});
|
||||
mockGetServerDao.mockReturnValue({
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
url: 'http://example.com',
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
const result = await addOAuthHeader('test-server', headers);
|
||||
@@ -161,17 +157,15 @@ describe('OAuth Service', () => {
|
||||
});
|
||||
|
||||
it('should add Authorization header when OAuth token is configured', async () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
url: 'http://example.com',
|
||||
oauth: {
|
||||
clientId: 'test-client',
|
||||
accessToken: 'test-access-token',
|
||||
},
|
||||
mockGetServerDao.mockReturnValue({
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
url: 'http://example.com',
|
||||
oauth: {
|
||||
clientId: 'test-client',
|
||||
accessToken: 'test-access-token',
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
const result = await addOAuthHeader('test-server', headers);
|
||||
@@ -183,17 +177,15 @@ describe('OAuth Service', () => {
|
||||
});
|
||||
|
||||
it('should preserve existing headers when adding OAuth token', async () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
url: 'http://example.com',
|
||||
oauth: {
|
||||
clientId: 'test-client',
|
||||
accessToken: 'test-access-token',
|
||||
},
|
||||
mockGetServerDao.mockReturnValue({
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
url: 'http://example.com',
|
||||
oauth: {
|
||||
clientId: 'test-client',
|
||||
accessToken: 'test-access-token',
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -13,6 +13,7 @@ export const createMockSettings = (overrides: Partial<McpSettings> = {}): McpSet
|
||||
args: ['-y', 'time-mcp'],
|
||||
env: {},
|
||||
enabled: true,
|
||||
keepAlive: false,
|
||||
keepAliveInterval: 30000,
|
||||
type: 'stdio',
|
||||
} as ServerConfig,
|
||||
|
||||
Reference in New Issue
Block a user