diff --git a/.env.example b/.env.example index 766252b..45c4e7e 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.fr.md b/README.fr.md index dc27228..0970b27 100644 --- a/README.fr.md +++ b/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 : diff --git a/README.md b/README.md index deffdb0..2fed66e 100644 --- a/README.md +++ b/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 diff --git a/README.zh.md b/README.zh.md index 7327401..8048cd2 100644 --- a/README.zh.md +++ b/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 ``` 这样可以实现: + - **精准发现**:仅从相关服务器查找工具 - **环境隔离**:按环境(开发、测试、生产)分离工具发现 - **基于团队的访问**:将工具搜索限制在特定团队的服务器分组 diff --git a/docker-compose.db.yml b/docker-compose.db.yml new file mode 100644 index 0000000..ea1138e --- /dev/null +++ b/docker-compose.db.yml @@ -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 diff --git a/docs/configuration/database-configuration.mdx b/docs/configuration/database-configuration.mdx new file mode 100644 index 0000000..fcd9815 --- /dev/null +++ b/docs/configuration/database-configuration.mdx @@ -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 +``` + + +**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. + + +### 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 < 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 diff --git a/docs/docs.json b/docs/docs.json index cb69661..902d299 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -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" } } -} +} \ No newline at end of file diff --git a/docs/zh/configuration/database-configuration.mdx b/docs/zh/configuration/database-configuration.mdx new file mode 100644 index 0000000..b050b16 --- /dev/null +++ b/docs/zh/configuration/database-configuration.mdx @@ -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 +``` + + +**简化配置**:您只需设置 `DB_URL` 即可启用数据库模式。MCPHub 会自动检测 `DB_URL` 是否存在并启用数据库模式。如果需要在设置了 `DB_URL` 的情况下禁用数据库模式,可以显式设置 `USE_DB=false`。 + + +### 可选设置 + +```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 < 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 diff --git a/frontend/src/contexts/ServerContext.tsx b/frontend/src/contexts/ServerContext.tsx index 32e1ea2..9f9ed9b 100644 --- a/frontend/src/contexts/ServerContext.tsx +++ b/frontend/src/contexts/ServerContext.tsx @@ -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 }> = - 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; + }> = 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; } diff --git a/mcp_settings.json b/mcp_settings.json index c7c15b1..9a0ad00 100644 --- a/mcp_settings.json +++ b/mcp_settings.json @@ -41,5 +41,27 @@ "password": "$2b$10$Vt7krIvjNgyN67LXqly0uOcTpN0LI55cYRbcKC71pUDAP0nJ7RPa.", "isAdmin": true } - ] + ], + "systemConfig": { + "oauthServer": { + "enabled": true, + "accessTokenLifetime": 3600, + "refreshTokenLifetime": 1209600, + "authorizationCodeLifetime": 300, + "requireClientSecret": false, + "allowedScopes": [ + "read", + "write" + ], + "requireState": false, + "dynamicRegistration": { + "enabled": true, + "allowedGrantTypes": [ + "authorization_code", + "refresh_token" + ], + "requiresAuthentication": false + } + } + } } \ No newline at end of file diff --git a/src/config/DaoConfigService.ts b/src/config/DaoConfigService.ts index 84b9e52..99fa86e 100644 --- a/src/config/DaoConfigService.ts +++ b/src/config/DaoConfigService.ts @@ -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(), ); } diff --git a/src/controllers/authController.ts b/src/controllers/authController.ts index 4c080c6..34ca251 100644 --- a/src/controllers/authController.ts +++ b/src/controllers/authController.ts @@ -37,7 +37,7 @@ export const login = async (req: Request, res: Response): Promise => { 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 } // 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' }); diff --git a/src/controllers/groupController.ts b/src/controllers/groupController.ts index ed4994a..2bae18a 100644 --- a/src/controllers/groupController.ts +++ b/src/controllers/groupController.ts @@ -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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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, diff --git a/src/controllers/openApiController.ts b/src/controllers/openApiController.ts index 224d336..0628fab 100644 --- a/src/controllers/openApiController.ts +++ b/src/controllers/openApiController.ts @@ -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; diff --git a/src/controllers/serverController.ts b/src/controllers/serverController.ts index b10bc9c..f4d2da8 100644 --- a/src/controllers/serverController.ts +++ b/src/controllers/serverController.ts @@ -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 => { try { @@ -31,15 +32,45 @@ export const getAllServers = async (_: Request, res: Response): Promise => } }; -export const getAllSettings = (_: Request, res: Response): void => { +export const getAllSettings = async (_: Request, res: Response): Promise => { try { - const settings = loadSettings(); + // Get base settings from file (for OAuth clients, tokens, users, etc.) + const fileSettings = loadSettings(); + + // Get servers from DAO (supports both file and database modes) + const serverDao = getServerDao(); + const servers = await serverDao.findAll(); + + // Convert servers array to mcpServers map format + const mcpServers: McpSettings['mcpServers'] = {}; + for (const server of servers) { + const { name, ...config } = server; + mcpServers[name] = config; + } + + // Get groups from DAO + const groupDao = getGroupDao(); + const groups = await groupDao.findAll(); + + // Get system config from DAO + const systemConfigDao = getSystemConfigDao(); + const systemConfig = await systemConfigDao.get(); + + // Merge all data into settings object + const settings: McpSettings = { + ...fileSettings, + mcpServers, + groups, + systemConfig, + }; + const response: ApiResponse = { success: true, data: createSafeJSON(settings), }; res.json(response); } catch (error) { + console.error('Failed to get server settings:', error); res.status(500).json({ success: false, message: 'Failed to get server settings', @@ -303,9 +334,12 @@ export const updateServer = async (req: Request, res: Response): Promise = export const getServerConfig = async (req: Request, res: Response): Promise => { try { const { name } = req.params; - const allServers = await getServersInfo(); - const serverInfo = allServers.find((s) => s.name === name); - if (!serverInfo) { + + // Get server configuration from DAO (supports both file and database modes) + const serverDao = getServerDao(); + const serverConfig = await serverDao.findById(name); + + if (!serverConfig) { res.status(404).json({ success: false, message: 'Server not found', @@ -313,18 +347,26 @@ export const getServerConfig = async (req: Request, res: Response): Promise s.name === name); + + // Extract config without the name field + const { name: serverName, ...config } = serverConfig; + const response: ApiResponse = { success: true, data: { - name, - status: serverInfo ? serverInfo.status : 'disconnected', - tools: serverInfo ? serverInfo.tools : [], - config: serverInfo, + name: serverName, + status: serverInfo?.status || 'disconnected', + tools: serverInfo?.tools || [], + config, }, }; res.json(response); } catch (error) { + console.error('Failed to get server configuration:', error); res.status(500).json({ success: false, message: 'Failed to get server configuration', @@ -507,10 +549,17 @@ export const updateToolDescription = async (req: Request, res: Response): Promis } }; -export const updateSystemConfig = (req: Request, res: Response): void => { +export const updateSystemConfig = async (req: Request, res: Response): Promise => { 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,7 +591,7 @@ export const updateSystemConfig = (req: Request, res: Response): void => { typeof mcpRouter.baseUrl === 'string'); const hasNameSeparatorUpdate = typeof nameSeparator === 'string'; - + const hasSessionRebuildUpdate = typeof enableSessionRebuild === 'boolean'; const hasOAuthServerUpdate = @@ -575,9 +624,12 @@ export const updateSystemConfig = (req: Request, res: Response): void => { return; } - const settings = loadSettings(); - if (!settings.systemConfig) { - settings.systemConfig = { + // Get system config from DAO (supports both file and database modes) + const systemConfigDao = getSystemConfigDao(); + let systemConfig = await systemConfigDao.get(); + + if (!systemConfig) { + systemConfig = { routing: { enableGlobalRoute: true, enableGroupNameRoute: true, @@ -607,8 +659,8 @@ export const updateSystemConfig = (req: Request, res: Response): void => { }; } - if (!settings.systemConfig.routing) { - settings.systemConfig.routing = { + if (!systemConfig.routing) { + systemConfig.routing = { enableGlobalRoute: true, enableGroupNameRoute: true, enableBearerAuth: false, @@ -617,16 +669,16 @@ export const updateSystemConfig = (req: Request, res: Response): void => { }; } - if (!settings.systemConfig.install) { - settings.systemConfig.install = { + if (!systemConfig.install) { + systemConfig.install = { pythonIndexUrl: '', npmRegistry: '', baseUrl: 'http://localhost:3000', }; } - if (!settings.systemConfig.smartRouting) { - settings.systemConfig.smartRouting = { + if (!systemConfig.smartRouting) { + systemConfig.smartRouting = { enabled: false, dbUrl: '', openaiApiBaseUrl: '', @@ -635,8 +687,8 @@ export const updateSystemConfig = (req: Request, res: Response): void => { }; } - if (!settings.systemConfig.mcpRouter) { - settings.systemConfig.mcpRouter = { + if (!systemConfig.mcpRouter) { + systemConfig.mcpRouter = { apiKey: '', referer: 'https://www.mcphubx.com', title: 'MCPHub', @@ -644,18 +696,18 @@ export const updateSystemConfig = (req: Request, res: Response): void => { }; } - if (!settings.systemConfig.oauthServer) { - settings.systemConfig.oauthServer = cloneDefaultOAuthServerConfig(); + if (!systemConfig.oauthServer) { + systemConfig.oauthServer = cloneDefaultOAuthServerConfig(); } - if (!settings.systemConfig.oauthServer.dynamicRegistration) { + if (!systemConfig.oauthServer.dynamicRegistration) { const defaultConfig = cloneDefaultOAuthServerConfig(); const defaultDynamic = defaultConfig.dynamicRegistration ?? { enabled: false, allowedGrantTypes: [], requiresAuthentication: false, }; - settings.systemConfig.oauthServer.dynamicRegistration = { + systemConfig.oauthServer.dynamicRegistration = { enabled: defaultDynamic.enabled ?? false, allowedGrantTypes: [ ...(Array.isArray(defaultDynamic.allowedGrantTypes) @@ -668,50 +720,50 @@ export const updateSystemConfig = (req: Request, res: Response): void => { if (routing) { if (typeof routing.enableGlobalRoute === 'boolean') { - settings.systemConfig.routing.enableGlobalRoute = routing.enableGlobalRoute; + systemConfig.routing.enableGlobalRoute = routing.enableGlobalRoute; } if (typeof routing.enableGroupNameRoute === 'boolean') { - settings.systemConfig.routing.enableGroupNameRoute = routing.enableGroupNameRoute; + systemConfig.routing.enableGroupNameRoute = routing.enableGroupNameRoute; } if (typeof routing.enableBearerAuth === 'boolean') { - settings.systemConfig.routing.enableBearerAuth = routing.enableBearerAuth; + systemConfig.routing.enableBearerAuth = routing.enableBearerAuth; } if (typeof routing.bearerAuthKey === 'string') { - settings.systemConfig.routing.bearerAuthKey = routing.bearerAuthKey; + systemConfig.routing.bearerAuthKey = routing.bearerAuthKey; } if (typeof routing.skipAuth === 'boolean') { - settings.systemConfig.routing.skipAuth = routing.skipAuth; + systemConfig.routing.skipAuth = routing.skipAuth; } } if (install) { if (typeof install.pythonIndexUrl === 'string') { - settings.systemConfig.install.pythonIndexUrl = install.pythonIndexUrl; + systemConfig.install.pythonIndexUrl = install.pythonIndexUrl; } if (typeof install.npmRegistry === 'string') { - settings.systemConfig.install.npmRegistry = install.npmRegistry; + systemConfig.install.npmRegistry = install.npmRegistry; } if (typeof install.baseUrl === 'string') { - settings.systemConfig.install.baseUrl = install.baseUrl; + systemConfig.install.baseUrl = install.baseUrl; } } // Track smartRouting state and configuration changes - const wasSmartRoutingEnabled = settings.systemConfig.smartRouting.enabled || false; - const previousSmartRoutingConfig = { ...settings.systemConfig.smartRouting }; + const wasSmartRoutingEnabled = systemConfig.smartRouting.enabled || false; + const previousSmartRoutingConfig = { ...systemConfig.smartRouting }; let needsSync = false; if (smartRouting) { if (typeof smartRouting.enabled === 'boolean') { // If enabling Smart Routing, validate required fields if (smartRouting.enabled) { - const currentDbUrl = smartRouting.dbUrl || settings.systemConfig.smartRouting.dbUrl; + const currentDbUrl = smartRouting.dbUrl || systemConfig.smartRouting.dbUrl; const currentOpenaiApiKey = - smartRouting.openaiApiKey || settings.systemConfig.smartRouting.openaiApiKey; + smartRouting.openaiApiKey || systemConfig.smartRouting.openaiApiKey; if (!currentDbUrl || !currentOpenaiApiKey) { const missingFields = []; @@ -725,32 +777,30 @@ export const updateSystemConfig = (req: Request, res: Response): void => { return; } } - settings.systemConfig.smartRouting.enabled = smartRouting.enabled; + systemConfig.smartRouting.enabled = smartRouting.enabled; } if (typeof smartRouting.dbUrl === 'string') { - settings.systemConfig.smartRouting.dbUrl = smartRouting.dbUrl; + systemConfig.smartRouting.dbUrl = smartRouting.dbUrl; } if (typeof smartRouting.openaiApiBaseUrl === 'string') { - settings.systemConfig.smartRouting.openaiApiBaseUrl = smartRouting.openaiApiBaseUrl; + systemConfig.smartRouting.openaiApiBaseUrl = smartRouting.openaiApiBaseUrl; } if (typeof smartRouting.openaiApiKey === 'string') { - settings.systemConfig.smartRouting.openaiApiKey = smartRouting.openaiApiKey; + systemConfig.smartRouting.openaiApiKey = smartRouting.openaiApiKey; } if (typeof smartRouting.openaiApiEmbeddingModel === 'string') { - settings.systemConfig.smartRouting.openaiApiEmbeddingModel = - smartRouting.openaiApiEmbeddingModel; + systemConfig.smartRouting.openaiApiEmbeddingModel = smartRouting.openaiApiEmbeddingModel; } // Check if we need to sync embeddings - const isNowEnabled = settings.systemConfig.smartRouting.enabled || false; + const isNowEnabled = systemConfig.smartRouting.enabled || false; const hasConfigChanged = - previousSmartRoutingConfig.dbUrl !== settings.systemConfig.smartRouting.dbUrl || + previousSmartRoutingConfig.dbUrl !== systemConfig.smartRouting.dbUrl || previousSmartRoutingConfig.openaiApiBaseUrl !== - settings.systemConfig.smartRouting.openaiApiBaseUrl || - previousSmartRoutingConfig.openaiApiKey !== - settings.systemConfig.smartRouting.openaiApiKey || + systemConfig.smartRouting.openaiApiBaseUrl || + previousSmartRoutingConfig.openaiApiKey !== systemConfig.smartRouting.openaiApiKey || previousSmartRoutingConfig.openaiApiEmbeddingModel !== - settings.systemConfig.smartRouting.openaiApiEmbeddingModel; + systemConfig.smartRouting.openaiApiEmbeddingModel; // Sync if: first time enabling OR smart routing is enabled and any config changed needsSync = (!wasSmartRoutingEnabled && isNowEnabled) || (isNowEnabled && hasConfigChanged); @@ -758,21 +808,21 @@ export const updateSystemConfig = (req: Request, res: Response): void => { if (mcpRouter) { if (typeof mcpRouter.apiKey === 'string') { - settings.systemConfig.mcpRouter.apiKey = mcpRouter.apiKey; + systemConfig.mcpRouter.apiKey = mcpRouter.apiKey; } if (typeof mcpRouter.referer === 'string') { - settings.systemConfig.mcpRouter.referer = mcpRouter.referer; + systemConfig.mcpRouter.referer = mcpRouter.referer; } if (typeof mcpRouter.title === 'string') { - settings.systemConfig.mcpRouter.title = mcpRouter.title; + systemConfig.mcpRouter.title = mcpRouter.title; } if (typeof mcpRouter.baseUrl === 'string') { - settings.systemConfig.mcpRouter.baseUrl = mcpRouter.baseUrl; + systemConfig.mcpRouter.baseUrl = mcpRouter.baseUrl; } } if (oauthServer) { - const target = settings.systemConfig.oauthServer; + const target = systemConfig.oauthServer; if (typeof oauthServer.enabled === 'boolean') { target.enabled = oauthServer.enabled; } @@ -826,17 +876,19 @@ export const updateSystemConfig = (req: Request, res: Response): void => { } if (typeof nameSeparator === 'string') { - settings.systemConfig.nameSeparator = nameSeparator; + systemConfig.nameSeparator = nameSeparator; } if (typeof enableSessionRebuild === 'boolean') { - settings.systemConfig.enableSessionRebuild = enableSessionRebuild; + systemConfig.enableSessionRebuild = enableSessionRebuild; } - if (saveSettings(settings, currentUser)) { + // Save using DAO (supports both file and database modes) + try { + await systemConfigDao.update(systemConfig); res.json({ success: true, - data: settings.systemConfig, + data: systemConfig, message: 'System configuration updated successfully', }); @@ -848,7 +900,8 @@ export const updateSystemConfig = (req: Request, res: Response): void => { console.error('Failed to sync server tools embeddings:', error); }); } - } else { + } catch (saveError) { + console.error('Failed to save system configuration:', saveError); res.status(500).json({ success: false, message: 'Failed to save system configuration', diff --git a/src/controllers/userController.ts b/src/controllers/userController.ts index f84ad97..10b1960 100644 --- a/src/controllers/userController.ts +++ b/src/controllers/userController.ts @@ -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 => { + 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 => { + 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 => { + 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 => { - 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 => // Update an existing user (admin only) export const updateExistingUser = async (req: Request, res: Response): Promise => { - 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 { - if (!requireAdmin(req, res)) return; +export const deleteExistingUser = async (req: Request, res: Response): Promise => { + 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 => { + 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 = { diff --git a/src/dao/DaoFactory.ts b/src/dao/DaoFactory.ts index 780bb23..a6999ec 100644 --- a/src/dao/DaoFactory.ts +++ b/src/dao/DaoFactory.ts @@ -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 */ diff --git a/src/dao/DatabaseDaoFactory.ts b/src/dao/DatabaseDaoFactory.ts new file mode 100644 index 0000000..728138d --- /dev/null +++ b/src/dao/DatabaseDaoFactory.ts @@ -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; + } +} diff --git a/src/dao/GroupDaoDbImpl.ts b/src/dao/GroupDaoDbImpl.ts new file mode 100644 index 0000000..80b355c --- /dev/null +++ b/src/dao/GroupDaoDbImpl.ts @@ -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 { + 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 { + 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): Promise { + 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): Promise { + 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 { + return await this.repository.delete(id); + } + + async exists(id: string): Promise { + return await this.repository.exists(id); + } + + async count(): Promise { + return await this.repository.count(); + } + + async findByOwner(owner: string): Promise { + 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 { + 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 { + 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 { + 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 { + const result = await this.update(groupId, { servers: servers as any }); + return result !== null; + } + + async findByName(name: string): Promise { + 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, + }; + } +} diff --git a/src/dao/ServerDaoDbImpl.ts b/src/dao/ServerDaoDbImpl.ts new file mode 100644 index 0000000..f79f72c --- /dev/null +++ b/src/dao/ServerDaoDbImpl.ts @@ -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 { + const servers = await this.repository.findAll(); + return servers.map((s) => this.mapToServerConfig(s)); + } + + async findById(name: string): Promise { + const server = await this.repository.findByName(name); + return server ? this.mapToServerConfig(server) : null; + } + + async create(entity: ServerConfigWithName): Promise { + 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): Promise { + 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 { + return await this.repository.delete(name); + } + + async exists(name: string): Promise { + return await this.repository.exists(name); + } + + async count(): Promise { + return await this.repository.count(); + } + + async findByOwner(owner: string): Promise { + const servers = await this.repository.findByOwner(owner); + return servers.map((s) => this.mapToServerConfig(s)); + } + + async findEnabled(): Promise { + const servers = await this.repository.findEnabled(); + return servers.map((s) => this.mapToServerConfig(s)); + } + + async findByType(type: string): Promise { + 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 { + const server = await this.repository.setEnabled(name, enabled); + return server !== null; + } + + async updateTools( + name: string, + tools: Record, + ): Promise { + const result = await this.update(name, { tools }); + return result !== null; + } + + async updatePrompts( + name: string, + prompts: Record, + ): Promise { + 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; + headers?: Record; + enabled: boolean; + owner?: string; + keepAliveInterval?: number; + tools?: Record; + prompts?: Record; + options?: Record; + oauth?: Record; + }): 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, + }; + } +} diff --git a/src/dao/SystemConfigDaoDbImpl.ts b/src/dao/SystemConfigDaoDbImpl.ts new file mode 100644 index 0000000..e4c10cf --- /dev/null +++ b/src/dao/SystemConfigDaoDbImpl.ts @@ -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 { + 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): Promise { + 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 { + 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(section: K): Promise { + return (await this.repository.getSection(section)) as any; + } + + async updateSection( + section: K, + value: SystemConfig[K], + ): Promise { + await this.repository.updateSection(section, value as any); + return true; + } +} diff --git a/src/dao/UserConfigDaoDbImpl.ts b/src/dao/UserConfigDaoDbImpl.ts new file mode 100644 index 0000000..338148b --- /dev/null +++ b/src/dao/UserConfigDaoDbImpl.ts @@ -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> { + const configs = await this.repository.getAll(); + const result: Record = {}; + + for (const [username, config] of Object.entries(configs)) { + result[username] = { + routing: config.routing, + ...config.additionalConfig, + }; + } + + return result; + } + + async get(username: string): Promise { + const config = await this.repository.get(username); + if (!config) { + return { routing: {} }; + } + return { + routing: config.routing, + ...config.additionalConfig, + }; + } + + async update(username: string, config: Partial): Promise { + const { routing, ...additionalConfig } = config; + const updated = await this.repository.update(username, { + routing, + additionalConfig, + }); + return { + routing: updated.routing, + ...updated.additionalConfig, + }; + } + + async delete(username: string): Promise { + return await this.repository.delete(username); + } + + async getSection(username: string, section: K): Promise { + const config = await this.get(username); + return config[section]; + } + + async updateSection( + username: string, + section: K, + value: UserConfig[K], + ): Promise { + await this.update(username, { [section]: value } as Partial); + return true; + } + + async exists(username: string): Promise { + const config = await this.repository.get(username); + return config !== null; + } + + async reset(username: string): Promise { + await this.repository.delete(username); + return { routing: {} }; + } +} diff --git a/src/dao/UserDaoDbImpl.ts b/src/dao/UserDaoDbImpl.ts new file mode 100644 index 0000000..554c85c --- /dev/null +++ b/src/dao/UserDaoDbImpl.ts @@ -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 { + const users = await this.repository.findAll(); + return users.map((u) => ({ + username: u.username, + password: u.password, + isAdmin: u.isAdmin, + })); + } + + async findById(username: string): Promise { + 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 { + return await this.findById(username); + } + + async create(entity: Omit): Promise { + 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 { + const hashedPassword = await bcrypt.hash(password, 10); + return await this.create({ username, password: hashedPassword, isAdmin }); + } + + async update(username: string, entity: Partial): Promise { + 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 { + return await this.repository.delete(username); + } + + async exists(username: string): Promise { + return await this.repository.exists(username); + } + + async count(): Promise { + return await this.repository.count(); + } + + async validateCredentials(username: string, password: string): Promise { + const user = await this.findByUsername(username); + if (!user) { + return false; + } + return await bcrypt.compare(password, user.password); + } + + async updatePassword(username: string, newPassword: string): Promise { + const hashedPassword = await bcrypt.hash(newPassword, 10); + const result = await this.update(username, { password: hashedPassword }); + return result !== null; + } + + async findAdmins(): Promise { + const users = await this.repository.findAdmins(); + return users.map((u) => ({ + username: u.username, + password: u.password, + isAdmin: u.isAdmin, + })); + } +} diff --git a/src/dao/index.ts b/src/dao/index.ts index 4a1f32a..a3e9536 100644 --- a/src/dao/index.ts +++ b/src/dao/index.ts @@ -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'; diff --git a/src/db/entities/Group.ts b/src/db/entities/Group.ts new file mode 100644 index 0000000..643925d --- /dev/null +++ b/src/db/entities/Group.ts @@ -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; + + @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; diff --git a/src/db/entities/Server.ts b/src/db/entities/Server.ts new file mode 100644 index 0000000..3ba143a --- /dev/null +++ b/src/db/entities/Server.ts @@ -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; + + @Column({ type: 'simple-json', nullable: true }) + headers?: Record; + + @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; + + @Column({ type: 'simple-json', nullable: true }) + prompts?: Record; + + @Column({ type: 'simple-json', nullable: true }) + options?: Record; + + @Column({ type: 'simple-json', nullable: true }) + oauth?: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamp' }) + updatedAt: Date; +} + +export default Server; diff --git a/src/db/entities/SystemConfig.ts b/src/db/entities/SystemConfig.ts new file mode 100644 index 0000000..a974d6f --- /dev/null +++ b/src/db/entities/SystemConfig.ts @@ -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; + + @Column({ type: 'simple-json', nullable: true }) + install?: Record; + + @Column({ type: 'simple-json', nullable: true }) + smartRouting?: Record; + + @Column({ type: 'simple-json', nullable: true }) + mcpRouter?: Record; + + @Column({ type: 'varchar', length: 10, nullable: true }) + nameSeparator?: string; + + @Column({ type: 'simple-json', nullable: true }) + oauth?: Record; + + @Column({ type: 'simple-json', nullable: true }) + oauthServer?: Record; + + @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; diff --git a/src/db/entities/User.ts b/src/db/entities/User.ts new file mode 100644 index 0000000..86e2359 --- /dev/null +++ b/src/db/entities/User.ts @@ -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; diff --git a/src/db/entities/UserConfig.ts b/src/db/entities/UserConfig.ts new file mode 100644 index 0000000..20b3b26 --- /dev/null +++ b/src/db/entities/UserConfig.ts @@ -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; + + @Column({ type: 'simple-json', nullable: true }) + additionalConfig?: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamp' }) + updatedAt: Date; +} + +export default UserConfig; diff --git a/src/db/entities/index.ts b/src/db/entities/index.ts index 2fccf16..0ce3a09 100644 --- a/src/db/entities/index.ts +++ b/src/db/entities/index.ts @@ -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 }; diff --git a/src/db/repositories/GroupRepository.ts b/src/db/repositories/GroupRepository.ts new file mode 100644 index 0000000..39a96b2 --- /dev/null +++ b/src/db/repositories/GroupRepository.ts @@ -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; + + constructor() { + this.repository = getAppDataSource().getRepository(Group); + } + + /** + * Find all groups + */ + async findAll(): Promise { + return await this.repository.find(); + } + + /** + * Find group by ID + */ + async findById(id: string): Promise { + return await this.repository.findOne({ where: { id } }); + } + + /** + * Find group by name + */ + async findByName(name: string): Promise { + return await this.repository.findOne({ where: { name } }); + } + + /** + * Create a new group + */ + async create(group: Omit): Promise { + const newGroup = this.repository.create(group); + return await this.repository.save(newGroup); + } + + /** + * Update an existing group + */ + async update(id: string, groupData: Partial): Promise { + 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 { + const result = await this.repository.delete({ id }); + return (result.affected ?? 0) > 0; + } + + /** + * Check if group exists by ID + */ + async exists(id: string): Promise { + const count = await this.repository.count({ where: { id } }); + return count > 0; + } + + /** + * Check if group exists by name + */ + async existsByName(name: string): Promise { + const count = await this.repository.count({ where: { name } }); + return count > 0; + } + + /** + * Count total groups + */ + async count(): Promise { + return await this.repository.count(); + } + + /** + * Find groups by owner + */ + async findByOwner(owner: string): Promise { + return await this.repository.find({ where: { owner } }); + } +} + +export default GroupRepository; diff --git a/src/db/repositories/ServerRepository.ts b/src/db/repositories/ServerRepository.ts new file mode 100644 index 0000000..be8c91f --- /dev/null +++ b/src/db/repositories/ServerRepository.ts @@ -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; + + constructor() { + this.repository = getAppDataSource().getRepository(Server); + } + + /** + * Find all servers + */ + async findAll(): Promise { + return await this.repository.find(); + } + + /** + * Find server by name + */ + async findByName(name: string): Promise { + return await this.repository.findOne({ where: { name } }); + } + + /** + * Create a new server + */ + async create(server: Omit): Promise { + const newServer = this.repository.create(server); + return await this.repository.save(newServer); + } + + /** + * Update an existing server + */ + async update(name: string, serverData: Partial): Promise { + 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 { + const result = await this.repository.delete({ name }); + return (result.affected ?? 0) > 0; + } + + /** + * Check if server exists + */ + async exists(name: string): Promise { + const count = await this.repository.count({ where: { name } }); + return count > 0; + } + + /** + * Count total servers + */ + async count(): Promise { + return await this.repository.count(); + } + + /** + * Find servers by owner + */ + async findByOwner(owner: string): Promise { + return await this.repository.find({ where: { owner } }); + } + + /** + * Find enabled servers + */ + async findEnabled(): Promise { + return await this.repository.find({ where: { enabled: true } }); + } + + /** + * Set server enabled status + */ + async setEnabled(name: string, enabled: boolean): Promise { + return await this.update(name, { enabled }); + } +} + +export default ServerRepository; diff --git a/src/db/repositories/SystemConfigRepository.ts b/src/db/repositories/SystemConfigRepository.ts new file mode 100644 index 0000000..82f6ac2 --- /dev/null +++ b/src/db/repositories/SystemConfigRepository.ts @@ -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; + private readonly DEFAULT_ID = 'default'; + + constructor() { + this.repository = getAppDataSource().getRepository(SystemConfig); + } + + /** + * Get system configuration (singleton) + */ + async get(): Promise { + 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): Promise { + 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 { + await this.repository.delete({ id: this.DEFAULT_ID }); + return await this.get(); + } + + /** + * Get a specific configuration section + */ + async getSection(section: K): Promise { + const config = await this.get(); + return config[section]; + } + + /** + * Update a specific configuration section + */ + async updateSection( + section: K, + value: SystemConfig[K], + ): Promise { + return await this.update({ [section]: value } as Partial); + } +} + +export default SystemConfigRepository; diff --git a/src/db/repositories/UserConfigRepository.ts b/src/db/repositories/UserConfigRepository.ts new file mode 100644 index 0000000..63cb147 --- /dev/null +++ b/src/db/repositories/UserConfigRepository.ts @@ -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; + + constructor() { + this.repository = getAppDataSource().getRepository(UserConfig); + } + + /** + * Get all user configs + */ + async getAll(): Promise> { + const configs = await this.repository.find(); + const result: Record = {}; + for (const config of configs) { + result[config.username] = config; + } + return result; + } + + /** + * Get user config by username + */ + async get(username: string): Promise { + return await this.repository.findOne({ where: { username } }); + } + + /** + * Update user config + */ + async update(username: string, configData: Partial): Promise { + 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 { + const result = await this.repository.delete({ username }); + return (result.affected ?? 0) > 0; + } + + /** + * Get a specific configuration section for a user + */ + async getSection(username: string, section: K): Promise { + const config = await this.get(username); + return config ? config[section] : null; + } + + /** + * Update a specific configuration section for a user + */ + async updateSection( + username: string, + section: K, + value: UserConfig[K], + ): Promise { + return await this.update(username, { [section]: value } as Partial); + } +} + +export default UserConfigRepository; diff --git a/src/db/repositories/UserRepository.ts b/src/db/repositories/UserRepository.ts new file mode 100644 index 0000000..1f8041e --- /dev/null +++ b/src/db/repositories/UserRepository.ts @@ -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; + + constructor() { + this.repository = getAppDataSource().getRepository(User); + } + + /** + * Find all users + */ + async findAll(): Promise { + return await this.repository.find(); + } + + /** + * Find user by username + */ + async findByUsername(username: string): Promise { + return await this.repository.findOne({ where: { username } }); + } + + /** + * Create a new user + */ + async create(user: Omit): Promise { + const newUser = this.repository.create(user); + return await this.repository.save(newUser); + } + + /** + * Update an existing user + */ + async update(username: string, userData: Partial): Promise { + 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 { + const result = await this.repository.delete({ username }); + return (result.affected ?? 0) > 0; + } + + /** + * Check if user exists + */ + async exists(username: string): Promise { + const count = await this.repository.count({ where: { username } }); + return count > 0; + } + + /** + * Count total users + */ + async count(): Promise { + return await this.repository.count(); + } + + /** + * Find all admin users + */ + async findAdmins(): Promise { + return await this.repository.find({ where: { isAdmin: true } }); + } +} + +export default UserRepository; diff --git a/src/db/repositories/index.ts b/src/db/repositories/index.ts index b5a277d..b79d5c0 100644 --- a/src/db/repositories/index.ts +++ b/src/db/repositories/index.ts @@ -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, +}; diff --git a/src/index.ts b/src/index.ts index b29ec8b..12ca5ee 100644 --- a/src/index.ts +++ b/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) { diff --git a/src/middlewares/auth.ts b/src/middlewares/auth.ts index 2156d66..582bbbc 100644 --- a/src/middlewares/auth.ts +++ b/src/middlewares/auth.ts @@ -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, diff --git a/src/middlewares/userContext.ts b/src/middlewares/userContext.ts index 6bcf8a7..afedb5c 100644 --- a/src/middlewares/userContext.ts +++ b/src/middlewares/userContext.ts @@ -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); diff --git a/src/models/User.ts b/src/models/User.ts index 254cd8c..24a375f 100644 --- a/src/models/User.ts +++ b/src/models/User.ts @@ -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 => { 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 => { - 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 => { + 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 => { - 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 => { - 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'); } }; diff --git a/src/routes/index.ts b/src/routes/index.ts index af8a32d..6bc78b5 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -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); diff --git a/src/scripts/migrate-to-database.ts b/src/scripts/migrate-to-database.ts new file mode 100644 index 0000000..16bca75 --- /dev/null +++ b/src/scripts/migrate-to-database.ts @@ -0,0 +1,5 @@ +#!/usr/bin/env node +import 'reflect-metadata'; +import { runMigrationCli } from '../utils/migration.js'; + +runMigrationCli(); diff --git a/src/server.ts b/src/server.ts index 5a17951..739b6f4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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`, diff --git a/src/services/groupService.ts b/src/services/groupService.ts index 19781b1..544ec6a 100644 --- a/src/services/groupService.ts +++ b/src/services/groupService.ts @@ -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 => { + 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 => { + 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 => { 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 | null => { +export const updateGroup = async (id: string, data: Partial): Promise => { 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 | 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 => { 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 => { 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 => { 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 => { 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 => { + 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 => { + 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 => { + 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 => { 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; diff --git a/src/services/mcpOAuthProvider.ts b/src/services/mcpOAuthProvider.ts index 1739afd..e92b293 100644 --- a/src/services/mcpOAuthProvider.ts +++ b/src/services/mcpOAuthProvider.ts @@ -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 { + 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 { - 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; diff --git a/src/services/mcpService.ts b/src/services/mcpService.ts index 85a6729..9f8bd65 100644 --- a/src/services/mcpService.ts +++ b/src/services/mcpService.ts @@ -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,14 +23,12 @@ 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 @@ -215,24 +213,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 +292,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 +372,7 @@ export const initializeClientsFromSettings = async ( isInit: boolean, serverName?: string, ): Promise => { - const allServers: ServerConfigWithName[] = await serverDao.findAll(); + const allServers: ServerConfigWithName[] = await getServerDao().findAll(); const existingServerInfos = serverInfos; const nextServerInfos: ServerInfo[] = []; @@ -650,7 +649,7 @@ export const registerAllTools = async (isInit: boolean, serverName?: string): Pr // Get all server information export const getServersInfo = async (): Promise[]> => { - 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 +755,7 @@ export const reconnectServer = async (serverName: string): Promise => { // Filter tools by server configuration const filterToolsByConfig = async (serverName: string, tools: Tool[]): Promise => { - 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 +779,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 +791,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 +807,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 +822,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 +859,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 +892,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 +972,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 +1054,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 +1097,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 +1439,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 +1474,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 +1509,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) diff --git a/src/services/oauthServerService.ts b/src/services/oauthServerService.ts index 9cff90c..8cf6861 100644 --- a/src/services/oauthServerService.ts +++ b/src/services/oauthServerService.ts @@ -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 => { + 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 => { - const user = findUserByUsername(username); + const user = await findUserByUsername(username); if (!user) { return null; } diff --git a/src/services/oauthService.ts b/src/services/oauthService.ts index ed539b5..819f5c0 100644 --- a/src/services/oauthService.ts +++ b/src/services/oauthService.ts @@ -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 => { + 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 => { - 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 => { - 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[] = []; - 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 diff --git a/src/services/oauthSettingsStore.ts b/src/services/oauthSettingsStore.ts index cccdd40..4e87943 100644 --- a/src/services/oauthSettingsStore.ts +++ b/src/services/oauthSettingsStore.ts @@ -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; 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 => { + 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 => { - 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}`); } diff --git a/src/services/openApiGeneratorService.ts b/src/services/openApiGeneratorService.ts index d429a7a..fbe1b06 100644 --- a/src/services/openApiGeneratorService.ts +++ b/src/services/openApiGeneratorService.ts @@ -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 = 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 diff --git a/src/services/sseService.test.ts b/src/services/sseService.test.ts index 295148b..a5d2c84 100644 --- a/src/services/sseService.test.ts +++ b/src/services/sseService.test.ts @@ -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; }; -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).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).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).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).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).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).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).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).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).mock.results[0].value; + const mockInstance = ( + StreamableHTTPServerTransport as jest.MockedClass + ).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).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).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).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).mockReturnValue({ - mcpServers: {}, - systemConfig: { - routing: { - enableGlobalRoute: true, - enableGroupNameRoute: true, - enableBearerAuth: true, - bearerAuthKey: 'test-key', - }, + setMockSystemConfig({ + routing: { + enableGlobalRoute: true, + enableGroupNameRoute: true, + enableBearerAuth: true, + bearerAuthKey: 'test-key', }, }); diff --git a/src/services/sseService.ts b/src/services/sseService.ts index ec7bf5c..a59d0e5 100644 --- a/src/services/sseService.ts +++ b/src/services/sseService.ts @@ -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 => { + 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 { 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( diff --git a/src/services/userService.ts b/src/services/userService.ts index 628ac90..ebf3504 100644 --- a/src/services/userService.ts +++ b/src/services/userService.ts @@ -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 => { + 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 => { + 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 => { 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 => { 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 => { 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 => { + 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 => { + 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 => { + const userDao = getUserDao(); + const admins = await userDao.findAdmins(); + return admins.length; }; diff --git a/src/types/index.ts b/src/types/index.ts index 0c280ec..5df5602 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -175,7 +175,10 @@ export interface SystemConfig { enableSessionRebuild?: boolean; // Controls whether server session rebuild is enabled } -export interface UserConfig {} +export interface UserConfig { + routing?: Record; // User-specific routing configuration + [key: string]: any; // Allow additional dynamic properties +} // OAuth Client for MCPHub's own authorization server export interface IOAuthClient { diff --git a/src/utils/migration.ts b/src/utils/migration.ts new file mode 100644 index 0000000..088dba7 --- /dev/null +++ b/src/utils/migration.ts @@ -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 { + 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 { + 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 { + 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); + } +} diff --git a/src/utils/oauthBearer.ts b/src/utils/oauthBearer.ts index 71b7143..d962485 100644 --- a/src/utils/oauthBearer.ts +++ b/src/utils/oauthBearer.ts @@ -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 => { 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 => { if (!authHeader || !authHeader.startsWith('Bearer ')) { return null; } diff --git a/tests/services/oauthService.test.ts b/tests/services/oauthService.test.ts index 8201004..0c9ed42 100644 --- a/tests/services/oauthService.test.ts +++ b/tests/services/oauthService.test.ts @@ -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; + 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',