Compare commits

..

5 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
ee301a893f Add one-click installation dialog for servers and groups
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-31 15:25:01 +00:00
copilot-swe-agent[bot]
a8852f7807 Add semantic search UI to servers management page
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-31 15:16:34 +00:00
copilot-swe-agent[bot]
d8e127d911 Add semantic search API endpoint for servers
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-31 15:12:13 +00:00
copilot-swe-agent[bot]
f782f69251 Fix circular reference issue in OpenAPI tool parameters
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-31 15:09:30 +00:00
copilot-swe-agent[bot]
1c0473183f Initial plan 2025-10-31 15:01:56 +00:00
119 changed files with 2307 additions and 11609 deletions

View File

@@ -1,8 +1,2 @@
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

View File

@@ -9,9 +9,25 @@ RUN apt-get update && apt-get install -y curl gnupg git \
RUN npm install -g pnpm
ENV PNPM_HOME=/usr/local/share/pnpm
ENV PATH=$PNPM_HOME:$PATH
RUN mkdir -p $PNPM_HOME && \
ENV MCP_DATA_DIR=/app/data
ENV MCP_SERVERS_DIR=$MCP_DATA_DIR/servers
ENV MCP_NPM_DIR=$MCP_SERVERS_DIR/npm
ENV MCP_PYTHON_DIR=$MCP_SERVERS_DIR/python
ENV PNPM_HOME=$MCP_DATA_DIR/pnpm
ENV NPM_CONFIG_PREFIX=$MCP_DATA_DIR/npm-global
ENV NPM_CONFIG_CACHE=$MCP_DATA_DIR/npm-cache
ENV UV_TOOL_DIR=$MCP_DATA_DIR/uv/tools
ENV UV_CACHE_DIR=$MCP_DATA_DIR/uv/cache
ENV PATH=$PNPM_HOME:$NPM_CONFIG_PREFIX/bin:$UV_TOOL_DIR/bin:$PATH
RUN mkdir -p \
$PNPM_HOME \
$NPM_CONFIG_PREFIX/bin \
$NPM_CONFIG_PREFIX/lib/node_modules \
$NPM_CONFIG_CACHE \
$UV_TOOL_DIR \
$UV_CACHE_DIR \
$MCP_NPM_DIR \
$MCP_PYTHON_DIR && \
pnpm add -g @amap/amap-maps-mcp-server @playwright/mcp@latest tavily-mcp@latest @modelcontextprotocol/server-github @modelcontextprotocol/server-slack
ARG INSTALL_EXT=false

View File

@@ -57,36 +57,6 @@ 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 :

View File

@@ -19,9 +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**:
- 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)
- **OAuth 2.0 Support**: Full OAuth support for upstream MCP servers with proxy authorization capabilities.
- **Environment Variable Expansion**: Use environment variables anywhere in your configuration for secure credential management. See [Environment Variables Guide](docs/environment-variables.md).
- **Docker-Ready**: Deploy instantly with our containerized setup.
@@ -100,73 +98,6 @@ Manual registration example:
For manual providers, create the OAuth App in the upstream console, set the redirect URI to `http://localhost:3000/oauth/callback` (or your deployed domain), and then plug the credentials into the dashboard or config file.
#### OAuth Authorization Server (NEW)
MCPHub can now act as an OAuth 2.0 authorization server, allowing external applications to securely access your MCP servers using standard OAuth flows. This is particularly useful for integrating with ChatGPT Web and other services that require OAuth authentication.
**Enable OAuth Server:**
```json
{
"systemConfig": {
"oauthServer": {
"enabled": true,
"accessTokenLifetime": 3600,
"refreshTokenLifetime": 1209600,
"allowedScopes": ["read", "write"]
}
},
"oauthClients": [
{
"clientId": "your-client-id",
"name": "ChatGPT Web",
"redirectUris": ["https://chatgpt.com/oauth/callback"],
"grants": ["authorization_code", "refresh_token"],
"scopes": ["read", "write"]
}
]
}
```
**Key Features:**
- Standard OAuth 2.0 authorization code flow
- PKCE support for enhanced security
- Token refresh capabilities
- Compatible with ChatGPT Web and other OAuth clients
For detailed setup instructions, see the [OAuth Server Documentation](docs/oauth-server.md).
### Database Mode (NEW)
MCPHub supports storing configuration in a PostgreSQL database as an alternative to `mcp_settings.json`. Database mode provides enhanced persistence and scalability for production environments and enterprise deployments.
**Core Benefits:**
-**Better Persistence** - Configuration stored in a professional database with transaction support and data integrity
-**High Availability** - Leverage database replication and failover capabilities
-**Enterprise Ready** - Meets enterprise data management and compliance requirements
-**Backup & Recovery** - Use mature database backup tools and strategies
**Environment Variables:**
```bash
# Simply set DB_URL to enable database mode (auto-detected)
DB_URL=postgresql://user:password@host:5432/mcphub
# Or explicitly control with USE_DB (optional, overrides auto-detection)
# USE_DB=true
```
> **Note**: You only need to set `DB_URL` to enable database mode. MCPHub will automatically detect and enable database mode when `DB_URL` is present. Use `USE_DB=false` to explicitly disable database mode even when `DB_URL` is set.
📖 See the complete [Database Configuration Guide](docs/configuration/database-configuration.mdx) for:
- Detailed setup instructions
- Migration from file-based config
- Backup and restore procedures
- Troubleshooting tips
### Docker Deployment
**Recommended**: Mount your custom config:
@@ -254,7 +185,6 @@ 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

View File

@@ -96,36 +96,6 @@ 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 部署
**推荐**:挂载自定义配置:
@@ -213,7 +183,6 @@ http://localhost:3000/mcp/$smart/development
```
这样可以实现:
- **精准发现**:仅从相关服务器查找工具
- **环境隔离**:按环境(开发、测试、生产)分离工具发现
- **基于团队的访问**:将工具搜索限制在特定团队的服务器分组

View File

@@ -1,187 +0,0 @@
# Security Summary - OAuth Authorization Server Implementation
## Overview
This document summarizes the security analysis and measures taken for the OAuth 2.0 authorization server implementation in MCPHub.
## Vulnerability Scan Results
### Dependency Vulnerabilities
**PASSED**: No vulnerabilities found in dependencies
- `@node-oauth/oauth2-server@5.2.1` - Clean scan, no known vulnerabilities
- All other dependencies scanned and verified secure
### Code Security Analysis (CodeQL)
⚠️ **ADVISORY**: 12 alerts found regarding missing rate limiting on authentication endpoints
**Details:**
- **Issue**: Authorization routes do not have rate limiting middleware
- **Impact**: Potential brute force attacks on authentication endpoints
- **Severity**: Medium
- **Status**: Documented, not critical
**Affected Endpoints:**
- `/oauth/authorize` (GET/POST)
- `/oauth/token` (POST)
- `/api/oauth/clients/*` (various methods)
**Mitigation:**
1. All endpoints require proper authentication
2. Authorization codes expire after 5 minutes by default
3. Access tokens expire after 1 hour by default
4. Failed authentication attempts are logged
5. Documentation includes rate limiting recommendations for production
**Recommended Actions for Production:**
- Implement `express-rate-limit` middleware on OAuth endpoints
- Consider using reverse proxy rate limiting (nginx, Cloudflare)
- Monitor for suspicious authentication patterns
- Set up alerting for repeated failed attempts
## Security Features Implemented
### Authentication & Authorization
**OAuth 2.0 Compliance**: Fully compliant with RFC 6749
**PKCE Support**: RFC 7636 implementation for public clients
**Token-based Authentication**: Access tokens and refresh tokens
**JWT Integration**: Backward compatible with existing JWT auth
**User Permissions**: Proper admin status lookup for OAuth users
### Input Validation
**Query Parameter Validation**: All OAuth parameters validated with regex patterns
**Client ID Validation**: Alphanumeric with hyphens/underscores only
**Redirect URI Validation**: Strict matching against registered URIs
**Scope Validation**: Only allowed scopes can be requested
**State Parameter**: CSRF protection via state validation
### Output Security
**XSS Protection**: All user input HTML-escaped in authorization page
**HTML Escaping**: Custom escapeHtml function for template rendering
**Safe Token Handling**: Tokens never exposed in URLs or logs
### Token Security
**Secure Token Generation**: Cryptographically random tokens (32 bytes)
**Token Expiration**: Configurable lifetimes for all token types
**Token Revocation**: Support for revoking access and refresh tokens
**Automatic Cleanup**: Expired tokens automatically removed from memory
### Transport Security
**HTTPS Ready**: Designed for HTTPS in production
**No Tokens in URL**: Access tokens never passed in query parameters
**Secure Headers**: Proper Content-Type and security headers
### Client Security
**Client Secret Support**: Optional for confidential clients
**Public Client Support**: PKCE for clients without secrets
**Redirect URI Whitelist**: Strict validation of redirect destinations
**Client Registration**: Secure client management API
### Code Quality
**TypeScript Strict Mode**: Full type safety
**ESLint Clean**: No linting errors
**Test Coverage**: 180 tests passing, including 11 OAuth-specific tests
**Async Safety**: Proper async/await usage throughout
**Resource Cleanup**: Graceful shutdown support with interval cleanup
## Security Best Practices Followed
1. **Defense in Depth**: Multiple layers of security (auth, validation, escaping)
2. **Principle of Least Privilege**: Scopes limit what clients can access
3. **Fail Securely**: Invalid requests rejected with appropriate errors
4. **Security by Default**: Secure settings out of the box
5. **Standard Compliance**: Following OAuth 2.0 and PKCE RFCs
6. **Code Reviews**: All changes reviewed for security implications
7. **Documentation**: Comprehensive security guidance provided
## Known Limitations
### In-Memory Token Storage
**Issue**: Tokens stored in memory, not persisted to database
**Impact**: Tokens lost on server restart
**Mitigation**: Refresh tokens allow users to re-authenticate
**Future**: Consider database storage for production deployments
### Rate Limiting
**Issue**: No built-in rate limiting on OAuth endpoints
**Impact**: Potential brute force attacks
**Mitigation**:
- Short-lived authorization codes (5 min default)
- Authentication required for authorization endpoint
- Documented recommendations for production
**Future**: Consider adding rate limiting middleware
### Token Introspection
**Issue**: No token introspection endpoint (RFC 7662)
**Impact**: Limited third-party token validation
**Mitigation**: Clients can use userinfo endpoint
**Future**: Consider implementing RFC 7662 if needed
## Production Deployment Recommendations
### Critical
1. ✅ Use HTTPS in production (SSL/TLS certificates)
2. ✅ Change default admin password immediately
3. ✅ Use strong client secrets for confidential clients
4. ⚠️ Implement rate limiting (express-rate-limit or reverse proxy)
5. ✅ Enable proper logging and monitoring
### Recommended
6. Consider using a database for token storage
7. Set up automated security scanning in CI/CD
8. Use a reverse proxy (nginx) with security headers
9. Implement IP whitelisting for admin endpoints
10. Regular security audits and dependency updates
### Optional
11. Implement token introspection endpoint
12. Add support for JWT-based access tokens
13. Integrate with external OAuth providers
14. Implement advanced scope management
15. Add OAuth client approval workflow
## Compliance & Standards
**OAuth 2.0 (RFC 6749)**: Full authorization code grant implementation
**PKCE (RFC 7636)**: Code challenge and verifier support
**OAuth Server Metadata (RFC 8414)**: Discovery endpoint available
**OpenID Connect Compatible**: Basic userinfo endpoint
## Vulnerability Disclosure
If you discover a security vulnerability in MCPHub's OAuth implementation, please:
1. **Do Not** create a public GitHub issue
2. Email the maintainers privately
3. Provide detailed reproduction steps
4. Allow time for a fix before public disclosure
## Security Update Policy
- **Critical vulnerabilities**: Patched within 24-48 hours
- **High severity**: Patched within 1 week
- **Medium severity**: Patched in next minor release
- **Low severity**: Patched in next patch release
## Conclusion
The OAuth 2.0 authorization server implementation in MCPHub follows security best practices and is production-ready with the noted limitations. The main advisory regarding rate limiting should be addressed in production deployments through application-level or reverse proxy rate limiting.
**Overall Security Assessment**: ✅ **SECURE** with production hardening recommendations
**Last Updated**: 2025-11-02
**Next Review**: Recommended quarterly or after major changes

View File

@@ -1,60 +0,0 @@
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

View File

@@ -60,32 +60,6 @@ Generates and returns the complete OpenAPI 3.0.3 specification for all connected
Comma-separated list of server names to include
</ParamField>
### Group/Server-Specific OpenAPI Specification
<CodeGroup>
```bash GET /api/:name/openapi.json
curl "http://localhost:3000/api/mygroup/openapi.json"
```
```bash With Parameters
curl "http://localhost:3000/api/myserver/openapi.json?title=My Server API&version=1.0.0"
```
</CodeGroup>
Generates and returns the OpenAPI 3.0.3 specification for a specific group or server. If a group with the given name exists, it returns the specification for all servers in that group. Otherwise, it treats the name as a server name and returns the specification for that server only.
**Path Parameters:**
<ParamField path="name" type="string" required>
Group ID/name or server name
</ParamField>
**Query Parameters:**
Same as the main OpenAPI specification endpoint (title, description, version, serverUrl, includeDisabled).
### Available Servers
<CodeGroup>

View File

@@ -1,142 +0,0 @@
---
title: "Prompts"
description: "Manage and execute MCP prompts."
---
import { Card, Cards } from 'mintlify';
<Card
title="POST /api/mcp/:serverName/prompts/:promptName"
href="#get-a-prompt"
>
Execute a prompt on an MCP server.
</Card>
<Card
title="POST /api/servers/:serverName/prompts/:promptName/toggle"
href="#toggle-a-prompt"
>
Enable or disable a prompt.
</Card>
<Card
title="PUT /api/servers/:serverName/prompts/:promptName/description"
href="#update-prompt-description"
>
Update the description of a prompt.
</Card>
---
### Get a Prompt
Execute a prompt on an MCP server and get the result.
- **Endpoint**: `/api/mcp/:serverName/prompts/:promptName`
- **Method**: `POST`
- **Authentication**: Required
- **Parameters**:
- `:serverName` (string, required): The name of the MCP server.
- `:promptName` (string, required): The name of the prompt.
- **Body**:
```json
{
"arguments": {
"arg1": "value1",
"arg2": "value2"
}
}
```
- `arguments` (object, optional): Arguments to pass to the prompt.
- **Response**:
```json
{
"success": true,
"data": {
"messages": [
{
"role": "user",
"content": {
"type": "text",
"text": "Prompt content"
}
}
]
}
}
```
**Example Request:**
```bash
curl -X POST "http://localhost:3000/api/mcp/myserver/prompts/code-review" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"arguments": {
"language": "typescript",
"code": "const x = 1;"
}
}'
```
---
### Toggle a Prompt
Enable or disable a specific prompt on a server.
- **Endpoint**: `/api/servers/:serverName/prompts/:promptName/toggle`
- **Method**: `POST`
- **Authentication**: Required
- **Parameters**:
- `:serverName` (string, required): The name of the server.
- `:promptName` (string, required): The name of the prompt.
- **Body**:
```json
{
"enabled": true
}
```
- `enabled` (boolean, required): `true` to enable the prompt, `false` to disable it.
**Example Request:**
```bash
curl -X POST "http://localhost:3000/api/servers/myserver/prompts/code-review/toggle" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{"enabled": false}'
```
---
### Update Prompt Description
Update the description of a specific prompt.
- **Endpoint**: `/api/servers/:serverName/prompts/:promptName/description`
- **Method**: `PUT`
- **Authentication**: Required
- **Parameters**:
- `:serverName` (string, required): The name of the server.
- `:promptName` (string, required): The name of the prompt.
- **Body**:
```json
{
"description": "New prompt description"
}
```
- `description` (string, required): The new description for the prompt.
**Example Request:**
```bash
curl -X PUT "http://localhost:3000/api/servers/myserver/prompts/code-review/description" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{"description": "Review code for best practices and potential issues"}'
```
**Note**: Prompts are templates that can be used to generate standardized requests to MCP servers. They are defined by the MCP server and can have arguments that are filled in when the prompt is executed.

View File

@@ -54,20 +54,6 @@ import { Card, Cards } from 'mintlify';
Update the description of a tool.
</Card>
<Card
title="PUT /api/system-config"
href="#update-system-config"
>
Update system configuration settings.
</Card>
<Card
title="GET /api/settings"
href="#get-settings"
>
Get all server settings and configurations.
</Card>
---
### Get All Servers
@@ -221,45 +207,3 @@ Updates the description of a specific tool.
}
```
- `description` (string, required): The new description for the tool.
---
### Update System Config
Updates the system-wide configuration settings.
- **Endpoint**: `/api/system-config`
- **Method**: `PUT`
- **Body**:
```json
{
"openaiApiKey": "sk-...",
"openaiBaseUrl": "https://api.openai.com/v1",
"modelName": "gpt-4",
"temperature": 0.7,
"maxTokens": 2048
}
```
- All fields are optional. Only provided fields will be updated.
---
### Get Settings
Retrieves all server settings and configurations.
- **Endpoint**: `/api/settings`
- **Method**: `GET`
- **Response**:
```json
{
"success": true,
"data": {
"servers": [...],
"groups": [...],
"systemConfig": {...}
}
}
```
**Note**: For detailed prompt management, see the [Prompts API](/api-reference/prompts) documentation.

View File

@@ -1,113 +0,0 @@
---
title: "System"
description: "System and utility endpoints."
---
import { Card, Cards } from 'mintlify';
<Card
title="GET /health"
href="#health-check"
>
Check the health status of the MCPHub server.
</Card>
<Card
title="GET /oauth/callback"
href="#oauth-callback"
>
OAuth callback endpoint for authentication flows.
</Card>
<Card
title="POST /api/dxt/upload"
href="#upload-dxt-file"
>
Upload a DXT configuration file.
</Card>
<Card
title="GET /api/mcp-settings/export"
href="#export-mcp-settings"
>
Export MCP settings as JSON.
</Card>
---
### Health Check
Check the health status of the MCPHub server.
- **Endpoint**: `/health`
- **Method**: `GET`
- **Authentication**: Not required
- **Response**:
```json
{
"status": "ok",
"timestamp": "2024-11-12T01:30:00.000Z",
"uptime": 12345
}
```
**Example Request:**
```bash
curl "http://localhost:3000/health"
```
---
### OAuth Callback
OAuth callback endpoint for handling OAuth authentication flows. This endpoint is automatically called by OAuth providers after user authorization.
- **Endpoint**: `/oauth/callback`
- **Method**: `GET`
- **Authentication**: Not required (public callback URL)
- **Query Parameters**: Varies by OAuth provider (typically includes `code`, `state`, etc.)
**Note**: This endpoint is used internally by MCPHub's OAuth integration and should not be called directly by clients.
---
### Upload DXT File
Upload a DXT (Desktop Extension) configuration file to import server configurations.
- **Endpoint**: `/api/dxt/upload`
- **Method**: `POST`
- **Authentication**: Required
- **Content-Type**: `multipart/form-data`
- **Body**:
- `file` (file, required): The DXT configuration file to upload.
**Example Request:**
```bash
curl -X POST "http://localhost:3000/api/dxt/upload" \
-H "Authorization: Bearer YOUR_TOKEN" \
-F "file=@config.dxt"
```
---
### Export MCP Settings
Export the current MCP settings configuration as a JSON file.
- **Endpoint**: `/api/mcp-settings/export`
- **Method**: `GET`
- **Authentication**: Required
- **Response**: Returns the `mcp_settings.json` configuration file.
**Example Request:**
```bash
curl "http://localhost:3000/api/mcp-settings/export" \
-H "Authorization: Bearer YOUR_TOKEN" \
-o mcp_settings.json
```
**Note**: This endpoint allows you to download a backup of your MCP settings, which can be used to restore or migrate your configuration.

View File

@@ -1,86 +0,0 @@
---
title: "Tools"
description: "Execute MCP tools programmatically."
---
import { Card, Cards } from 'mintlify';
<Card
title="POST /api/tools/call/:server"
href="#call-a-tool"
>
Call a specific tool on an MCP server.
</Card>
---
### Call a Tool
Execute a specific tool on an MCP server with given arguments.
- **Endpoint**: `/api/tools/call/:server`
- **Method**: `POST`
- **Parameters**:
- `:server` (string, required): The name of the MCP server.
- **Body**:
```json
{
"toolName": "tool-name",
"arguments": {
"param1": "value1",
"param2": "value2"
}
}
```
- `toolName` (string, required): The name of the tool to execute.
- `arguments` (object, optional): The arguments to pass to the tool. Defaults to an empty object.
- **Response**:
```json
{
"success": true,
"data": {
"content": [
{
"type": "text",
"text": "Tool execution result"
}
],
"toolName": "tool-name",
"arguments": {
"param1": "value1",
"param2": "value2"
}
}
}
```
**Example Request:**
```bash
curl -X POST "http://localhost:3000/api/tools/call/amap" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"toolName": "amap-maps_weather",
"arguments": {
"city": "Beijing"
}
}'
```
**Notes:**
- The tool arguments are automatically converted to the proper types based on the tool's input schema.
- Use the `x-session-id` header to maintain session state across multiple tool calls if needed.
- This endpoint requires authentication.
---
### Alternative: OpenAPI Tool Execution
For OpenAPI-compatible tool execution without authentication, see the [OpenAPI Integration](/api-reference/openapi#tool-execution) documentation. The OpenAPI endpoints provide:
- **GET** `/api/tools/:serverName/:toolName` - For simple tools with query parameters
- **POST** `/api/tools/:serverName/:toolName` - For complex tools with JSON body
These endpoints are designed for integration with OpenWebUI and other OpenAPI-compatible systems.

View File

@@ -1,195 +0,0 @@
---
title: "Users"
description: "Manage users in MCPHub."
---
import { Card, Cards } from 'mintlify';
<Card
title="GET /api/users"
href="#get-all-users"
>
Get a list of all users.
</Card>
<Card
title="GET /api/users/:username"
href="#get-a-user"
>
Get details of a specific user.
</Card>
<Card
title="POST /api/users"
href="#create-a-user"
>
Create a new user.
</Card>
<Card
title="PUT /api/users/:username"
href="#update-a-user"
>
Update an existing user.
</Card>
<Card
title="DELETE /api/users/:username"
href="#delete-a-user"
>
Delete a user.
</Card>
<Card
title="GET /api/users-stats"
href="#get-user-statistics"
>
Get statistics about users and their server access.
</Card>
---
### Get All Users
Retrieves a list of all users in the system.
- **Endpoint**: `/api/users`
- **Method**: `GET`
- **Authentication**: Required (Admin only)
- **Response**:
```json
{
"success": true,
"data": [
{
"username": "admin",
"role": "admin",
"servers": ["server1", "server2"],
"groups": ["group1"]
},
{
"username": "user1",
"role": "user",
"servers": ["server1"],
"groups": []
}
]
}
```
---
### Get a User
Retrieves details of a specific user.
- **Endpoint**: `/api/users/:username`
- **Method**: `GET`
- **Authentication**: Required (Admin only)
- **Parameters**:
- `:username` (string, required): The username of the user.
- **Response**:
```json
{
"success": true,
"data": {
"username": "user1",
"role": "user",
"servers": ["server1", "server2"],
"groups": ["group1"]
}
}
```
---
### Create a User
Creates a new user in the system.
- **Endpoint**: `/api/users`
- **Method**: `POST`
- **Authentication**: Required (Admin only)
- **Body**:
```json
{
"username": "newuser",
"password": "securepassword",
"role": "user",
"servers": ["server1"],
"groups": ["group1"]
}
```
- `username` (string, required): The username for the new user.
- `password` (string, required): The password for the new user. Must be at least 6 characters.
- `role` (string, optional): The role of the user. Either `"admin"` or `"user"`. Defaults to `"user"`.
- `servers` (array of strings, optional): List of server names the user has access to.
- `groups` (array of strings, optional): List of group IDs the user belongs to.
---
### Update a User
Updates an existing user's information.
- **Endpoint**: `/api/users/:username`
- **Method**: `PUT`
- **Authentication**: Required (Admin only)
- **Parameters**:
- `:username` (string, required): The username of the user to update.
- **Body**:
```json
{
"password": "newpassword",
"role": "admin",
"servers": ["server1", "server2", "server3"],
"groups": ["group1", "group2"]
}
```
- `password` (string, optional): New password for the user.
- `role` (string, optional): New role for the user.
- `servers` (array of strings, optional): Updated list of accessible servers.
- `groups` (array of strings, optional): Updated list of groups.
---
### Delete a User
Removes a user from the system.
- **Endpoint**: `/api/users/:username`
- **Method**: `DELETE`
- **Authentication**: Required (Admin only)
- **Parameters**:
- `:username` (string, required): The username of the user to delete.
---
### Get User Statistics
Retrieves statistics about users and their access to servers and groups.
- **Endpoint**: `/api/users-stats`
- **Method**: `GET`
- **Authentication**: Required (Admin only)
- **Response**:
```json
{
"success": true,
"data": {
"totalUsers": 5,
"adminUsers": 1,
"regularUsers": 4,
"usersPerServer": {
"server1": 3,
"server2": 2
},
"usersPerGroup": {
"group1": 2,
"group2": 1
}
}
}
```
**Note**: All user management endpoints require admin authentication.

View File

@@ -1,328 +0,0 @@
# Database Configuration for MCPHub
## Overview
MCPHub supports storing configuration data in a PostgreSQL database as an alternative to the `mcp_settings.json` file. Database mode provides enhanced persistence and scalability for production environments and enterprise deployments.
## Why Use Database Configuration?
**Core Benefits:**
- ✅ **Better Persistence** - Configuration stored in a professional database with transaction support and data integrity
- ✅ **High Availability** - Leverage database replication and failover capabilities
- ✅ **Enterprise Ready** - Meets enterprise data management and compliance requirements
- ✅ **Backup & Recovery** - Use mature database backup tools and strategies
## Environment Variables
### Required for Database Mode
```bash
# Database connection URL (PostgreSQL)
# Simply setting DB_URL will automatically enable database mode
DB_URL=postgresql://user:password@localhost:5432/mcphub
# Or explicitly control with USE_DB (overrides auto-detection)
# USE_DB=true
# Alternative: Use separate components
# DB_HOST=localhost
# DB_PORT=5432
# DB_NAME=mcphub
# DB_USER=user
# DB_PASSWORD=password
```
<Note>
**Simplified Configuration**: You only need to set `DB_URL` to enable database mode. MCPHub will automatically detect and enable database mode when `DB_URL` is present. Use `USE_DB=false` to explicitly disable database mode even when `DB_URL` is set.
</Note>
### Optional Settings
```bash
# Automatic migration on startup (default: true)
AUTO_MIGRATE=true
# Keep file-based config as fallback (default: false)
KEEP_FILE_CONFIG=false
```
## Setup Instructions
### 1. Using Docker
#### Option A: Using PostgreSQL as a separate service
Create a `docker-compose.yml`:
```yaml
version: '3.8'
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: mcphub
POSTGRES_USER: mcphub
POSTGRES_PASSWORD: your_secure_password
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
mcphub:
image: samanhappy/mcphub:latest
environment:
USE_DB: "true"
DB_URL: "postgresql://mcphub:your_secure_password@postgres:5432/mcphub"
PORT: 3000
ports:
- "3000:3000"
depends_on:
- postgres
volumes:
pgdata:
```
Run with:
```bash
docker-compose up -d
```
#### Option B: Using External Database
If you already have a PostgreSQL database:
```bash
docker run -d \
-p 3000:3000 \
-e USE_DB=true \
-e DB_URL="postgresql://user:password@your-db-host:5432/mcphub" \
samanhappy/mcphub:latest
```
### 2. Manual Setup
#### Step 1: Setup PostgreSQL Database
```bash
# Install PostgreSQL (if not already installed)
sudo apt-get install postgresql postgresql-contrib
# Create database and user
sudo -u postgres psql <<EOF
CREATE DATABASE mcphub;
CREATE USER mcphub WITH ENCRYPTED PASSWORD 'your_password';
GRANT ALL PRIVILEGES ON DATABASE mcphub TO mcphub;
EOF
```
#### Step 2: Install MCPHub
```bash
npm install -g @samanhappy/mcphub
```
#### Step 3: Set Environment Variables
Create a `.env` file:
```bash
# Simply set DB_URL to enable database mode (USE_DB is auto-detected)
DB_URL=postgresql://mcphub:your_password@localhost:5432/mcphub
PORT=3000
```
#### Step 4: Run Migration (Optional)
If you have an existing `mcp_settings.json` file, migrate it:
```bash
# Run migration script
npx tsx src/scripts/migrate-to-database.ts
```
Or let MCPHub auto-migrate on first startup.
#### Step 5: Start MCPHub
```bash
mcphub
```
## Migration from File-Based to Database
MCPHub provides automatic migration on first startup when database mode is enabled. However, you can also run the migration manually.
### Automatic Migration
When you start MCPHub with `USE_DB=true` for the first time:
1. MCPHub connects to the database
2. Checks if any users exist in the database
3. If no users found, automatically migrates from `mcp_settings.json`
4. Creates all tables and imports all data
### Manual Migration
Run the migration script:
```bash
# Using npx
npx tsx src/scripts/migrate-to-database.ts
# Or using Node
node dist/scripts/migrate-to-database.js
```
The migration will:
- ✅ Create database tables if they don't exist
- ✅ Import all users with hashed passwords
- ✅ Import all MCP server configurations
- ✅ Import all groups
- ✅ Import system configuration
- ✅ Import user-specific configurations
- ✅ Skip existing records (safe to run multiple times)
## Configuration After Migration
Once running in database mode, all configuration changes are stored in the database:
- User management via `/api/users`
- Server management via `/api/servers`
- Group management via `/api/groups`
- System settings via `/api/system/config`
The web dashboard works exactly the same way, but now stores changes in the database instead of the file.
## Database Schema
MCPHub creates the following tables:
- **users** - User accounts and authentication
- **servers** - MCP server configurations
- **groups** - Server groups
- **system_config** - System-wide settings
- **user_configs** - User-specific settings
- **vector_embeddings** - Vector search data (for smart routing)
## Backup and Restore
### Backup
```bash
# PostgreSQL backup
pg_dump -U mcphub mcphub > mcphub_backup.sql
# Or using Docker
docker exec postgres pg_dump -U mcphub mcphub > mcphub_backup.sql
```
### Restore
```bash
# PostgreSQL restore
psql -U mcphub mcphub < mcphub_backup.sql
# Or using Docker
docker exec -i postgres psql -U mcphub mcphub < mcphub_backup.sql
```
## Switching Back to File-Based Config
If you need to switch back to file-based configuration:
1. Set `USE_DB=false` or remove the environment variable
2. Restart MCPHub
3. MCPHub will use `mcp_settings.json` again
Note: Changes made in database mode won't be reflected in the file unless you manually export them.
## Troubleshooting
### Connection Refused
```
Error: connect ECONNREFUSED 127.0.0.1:5432
```
**Solution:** Check that PostgreSQL is running and accessible:
```bash
# Check PostgreSQL status
sudo systemctl status postgresql
# Or for Docker
docker ps | grep postgres
```
### Authentication Failed
```
Error: password authentication failed for user "mcphub"
```
**Solution:** Verify database credentials in `DB_URL` environment variable.
### Migration Failed
```
❌ Migration failed: ...
```
**Solution:**
1. Check that `mcp_settings.json` exists and is valid JSON
2. Verify database connection
3. Check logs for specific error messages
4. Ensure database user has CREATE TABLE permissions
### Tables Already Exist
Database tables are automatically created if they don't exist. If you get errors about existing tables, check:
1. Whether a previous migration partially completed
2. Manual table creation conflicts
3. Run with `synchronize: false` in database config if needed
## Environment Variables Reference
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `DB_URL` | Yes* | - | Full PostgreSQL connection URL. Setting this automatically enables database mode |
| `USE_DB` | No | auto | Explicitly enable/disable database mode. If not set, auto-detected based on `DB_URL` presence |
| `DB_HOST` | No | `localhost` | Database host (if not using DB_URL) |
| `DB_PORT` | No | `5432` | Database port (if not using DB_URL) |
| `DB_NAME` | No | `mcphub` | Database name (if not using DB_URL) |
| `DB_USER` | No | `mcphub` | Database user (if not using DB_URL) |
| `DB_PASSWORD` | No | - | Database password (if not using DB_URL) |
| `AUTO_MIGRATE` | No | `true` | Auto-migrate from file on first start |
| `MCPHUB_SETTING_PATH` | No | - | Path to mcp_settings.json (for migration) |
*Required for database mode. Simply setting `DB_URL` enables database mode automatically
## Security Considerations
1. **Database Credentials:** Store database credentials securely, use environment variables or secrets management
2. **Network Access:** Restrict database access to MCPHub instances only
3. **Encryption:** Use SSL/TLS for database connections in production:
```bash
DB_URL=postgresql://user:password@host:5432/mcphub?sslmode=require
```
4. **Backup:** Regularly backup your database
5. **Access Control:** Use strong database passwords and limit user permissions
## Performance
Database mode offers better performance for:
- Multiple concurrent users
- Frequent configuration changes
- Large number of servers/groups
File mode may be faster for:
- Single user setups
- Read-heavy workloads with infrequent changes
- Development/testing environments
## Support
For issues or questions:
- GitHub Issues: https://github.com/samanhappy/mcphub/issues
- Documentation: https://mcphub.io/docs

View File

@@ -78,7 +78,7 @@ git clone https://github.com/YOUR_USERNAME/mcphub.git
cd mcphub
# 2. Add upstream remote
git remote add upstream https://github.com/samanhappy/mcphub.git
git remote add upstream https://github.com/mcphub/mcphub.git
# 3. Install dependencies
pnpm install

View File

@@ -37,8 +37,7 @@
"configuration/mcp-settings",
"configuration/environment-variables",
"configuration/docker-setup",
"configuration/nginx",
"configuration/database-configuration"
"configuration/nginx"
]
}
]
@@ -69,8 +68,7 @@
"zh/configuration/mcp-settings",
"zh/configuration/environment-variables",
"zh/configuration/docker-setup",
"zh/configuration/nginx",
"zh/configuration/database-configuration"
"zh/configuration/nginx"
]
}
]
@@ -98,13 +96,9 @@
"pages": [
"api-reference/servers",
"api-reference/groups",
"api-reference/users",
"api-reference/tools",
"api-reference/prompts",
"api-reference/auth",
"api-reference/logs",
"api-reference/config",
"api-reference/system"
"api-reference/config"
]
}
]
@@ -132,13 +126,9 @@
"pages": [
"zh/api-reference/servers",
"zh/api-reference/groups",
"zh/api-reference/users",
"zh/api-reference/tools",
"zh/api-reference/prompts",
"zh/api-reference/auth",
"zh/api-reference/logs",
"zh/api-reference/config",
"zh/api-reference/system"
"zh/api-reference/config"
]
}
]
@@ -171,4 +161,4 @@
"discord": "https://discord.gg/qMKNsn5Q"
}
}
}
}

View File

@@ -294,47 +294,22 @@ Optional for Smart Routing:
labels:
app: mcphub
spec:
initContainers:
- name: prepare-config
image: busybox:1.28
command:
[
"sh",
"-c",
"cp /config-ro/mcp_settings.json /etc/mcphub/mcp_settings.json",
]
volumeMounts:
- name: config
mountPath: /config-ro
readOnly: true
- name: app-storage
mountPath: /etc/mcphub
containers:
- name: mcphub
image: samanhappy/mcphub:latest
ports:
- containerPort: 3000
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
env:
- name: PORT
value: "3000"
- name: MCPHUB_SETTING_PATH
value: /etc/mcphub/mcp_settings.json
volumeMounts:
- name: app-storage
mountPath: /etc/mcphub
volumes:
- name: mcphub
image: samanhappy/mcphub:latest
ports:
- containerPort: 3000
env:
- name: PORT
value: "3000"
volumeMounts:
- name: config
configMap:
name: mcphub-config
- name: app-storage
emptyDir: {}
mountPath: /app/mcp_settings.json
subPath: mcp_settings.json
volumes:
- name: config
configMap:
name: mcphub-config
```
#### 3. Service

View File

@@ -1,169 +0,0 @@
# OAuth 动态客户端注册实现总结
## 概述
成功为 MCPHub 的 OAuth 2.0 授权服务器添加了 RFC 7591 标准的动态客户端注册功能。此功能允许 OAuth 客户端在运行时自动注册,无需管理员手动配置。
## 实现的功能
### 1. 核心端点
#### POST /oauth/register - 注册新客户端
- 公开端点,支持动态客户端注册
- 自动生成 client_id 和可选的 client_secret
- 返回 registration_access_token 用于后续管理
- 支持 PKCE 流程token_endpoint_auth_method: "none"
#### GET /oauth/register/:clientId - 读取客户端配置
- 需要 registration_access_token 认证
- 返回完整的客户端元数据
#### PUT /oauth/register/:clientId - 更新客户端配置
- 需要 registration_access_token 认证
- 支持更新 redirect_uris、scopes、metadata 等
#### DELETE /oauth/register/:clientId - 删除客户端注册
- 需要 registration_access_token 认证
- 删除客户端并清理相关 tokens
### 2. 配置选项
`mcp_settings.json` 中添加:
```json
{
"systemConfig": {
"oauthServer": {
"enabled": true,
"dynamicRegistration": {
"enabled": true,
"allowedGrantTypes": ["authorization_code", "refresh_token"],
"requiresAuthentication": false
}
}
}
}
```
### 3. 客户端元数据支持
实现了 RFC 7591 定义的完整客户端元数据:
- `application_type`: "web" 或 "native"
- `response_types`: OAuth 响应类型数组
- `token_endpoint_auth_method`: 认证方法
- `contacts`: 联系邮箱数组
- `logo_uri`: 客户端 logo URL
- `client_uri`: 客户端主页 URL
- `policy_uri`: 隐私政策 URL
- `tos_uri`: 服务条款 URL
- `jwks_uri`: JSON Web Key Set URL
- `jwks`: 内联 JSON Web Key Set
### 4. 安全特性
- **Registration Access Token**: 每个注册的客户端获得唯一的访问令牌
- **Token 过期**: Registration tokens 30 天后过期
- **HTTPS 验证**: Redirect URIs 必须使用 HTTPSlocalhost 除外)
- **Scope 验证**: 只允许配置中定义的 scopes
- **Grant Type 限制**: 只允许配置中定义的 grant types
## 文件变更
### 新增文件
1. `src/controllers/oauthDynamicRegistrationController.ts` - 动态注册控制器
2. `examples/oauth-dynamic-registration-config.json` - 配置示例
### 修改文件
1. `src/types/index.ts` - 添加元数据字段到 IOAuthClient 和 OAuthServerConfig
2. `src/routes/index.ts` - 注册新的动态注册端点
3. `src/controllers/oauthServerController.ts` - 元数据端点包含 registration_endpoint
4. `docs/oauth-server.md` - 添加完整的动态注册文档
## 使用示例
### 注册新客户端
```bash
curl -X POST http://localhost:3000/oauth/register \
-H "Content-Type: application/json" \
-d '{
"client_name": "My Application",
"redirect_uris": ["https://example.com/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"scope": "read write",
"token_endpoint_auth_method": "none",
"logo_uri": "https://example.com/logo.png",
"client_uri": "https://example.com",
"contacts": ["admin@example.com"]
}'
```
响应:
```json
{
"client_id": "a1b2c3d4e5f6g7h8",
"client_name": "My Application",
"redirect_uris": ["https://example.com/callback"],
"registration_access_token": "reg_token_xyz123",
"registration_client_uri": "http://localhost:3000/oauth/register/a1b2c3d4e5f6g7h8",
"client_id_issued_at": 1699200000
}
```
### 读取客户端配置
```bash
curl http://localhost:3000/oauth/register/CLIENT_ID \
-H "Authorization: Bearer REGISTRATION_ACCESS_TOKEN"
```
### 更新客户端
```bash
curl -X PUT http://localhost:3000/oauth/register/CLIENT_ID \
-H "Authorization: Bearer REGISTRATION_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"client_name": "Updated Name",
"redirect_uris": ["https://example.com/callback", "https://example.com/callback2"]
}'
```
### 删除客户端
```bash
curl -X DELETE http://localhost:3000/oauth/register/CLIENT_ID \
-H "Authorization: Bearer REGISTRATION_ACCESS_TOKEN"
```
## 测试结果
✅ 所有 180 个测试通过
✅ TypeScript 编译成功
✅ 代码覆盖率维持在合理水平
✅ 与现有功能完全兼容
## RFC 合规性
完全遵循以下 RFC 标准:
- **RFC 7591**: OAuth 2.0 Dynamic Client Registration Protocol
- **RFC 8414**: OAuth 2.0 Authorization Server Metadata
- **RFC 7636**: Proof Key for Code Exchange (PKCE)
- **RFC 9728**: OAuth 2.0 Protected Resource Metadata
## 下一步建议
1. **持久化存储**: 当前 registration tokens 存储在内存中,生产环境应使用数据库
2. **速率限制**: 添加注册端点的速率限制以防止滥用
3. **客户端证明**: 考虑添加软件声明software_statement支持
4. **审计日志**: 记录所有注册、更新和删除操作
5. **通知机制**: 在客户端注册时通知管理员(可选)
## 兼容性
- 与 ChatGPT Web 完全兼容
- 支持所有标准 OAuth 2.0 客户端库
- 向后兼容现有的手动客户端配置方式

View File

@@ -1,538 +0,0 @@
# OAuth 2.0 Authorization Server
MCPHub can act as an OAuth 2.0 authorization server, allowing external applications like ChatGPT Web to securely authenticate and access your MCP servers.
## Overview
The OAuth 2.0 authorization server feature enables MCPHub to:
- Provide standard OAuth 2.0 authentication flows
- Issue and manage access tokens for external clients
- Support secure authorization without exposing user credentials
- Enable integration with services that require OAuth (like ChatGPT Web)
## Configuration
### Enable OAuth Server
Add the following configuration to your `mcp_settings.json`:
```json
{
"systemConfig": {
"oauthServer": {
"enabled": true,
"accessTokenLifetime": 3600,
"refreshTokenLifetime": 1209600,
"authorizationCodeLifetime": 300,
"requireClientSecret": false,
"allowedScopes": ["read", "write"],
"requireState": false
}
}
}
```
### Configuration Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `enabled` | boolean | `false` | Enable/disable OAuth authorization server |
| `accessTokenLifetime` | number | `3600` | Access token lifetime in seconds (1 hour) |
| `refreshTokenLifetime` | number | `1209600` | Refresh token lifetime in seconds (14 days) |
| `authorizationCodeLifetime` | number | `300` | Authorization code lifetime in seconds (5 minutes) |
| `requireClientSecret` | boolean | `false` | Whether client secret is required (set to false for PKCE) |
| `allowedScopes` | string[] | `["read", "write"]` | List of allowed OAuth scopes |
| `requireState` | boolean | `false` | When `true`, rejects authorization requests that omit the `state` parameter |
## OAuth Clients
### Creating OAuth Clients
#### Via API (Recommended)
Create an OAuth client using the API:
```bash
curl -X POST http://localhost:3000/api/oauth/clients \
-H "Content-Type: application/json" \
-H "x-auth-token: YOUR_JWT_TOKEN" \
-d '{
"name": "My Application",
"redirectUris": ["https://example.com/callback"],
"grants": ["authorization_code", "refresh_token"],
"scopes": ["read", "write"],
"requireSecret": false
}'
```
Response:
```json
{
"success": true,
"message": "OAuth client created successfully",
"client": {
"clientId": "a1b2c3d4e5f6g7h8",
"clientSecret": null,
"name": "My Application",
"redirectUris": ["https://example.com/callback"],
"grants": ["authorization_code", "refresh_token"],
"scopes": ["read", "write"],
"owner": "admin"
}
}
```
**Important**: If `requireSecret` is true, the `clientSecret` will be shown only once. Save it securely!
#### Via Configuration File
Alternatively, add OAuth clients directly to `mcp_settings.json`:
```json
{
"oauthClients": [
{
"clientId": "my-app-client",
"clientSecret": "optional-secret-for-confidential-clients",
"name": "My Application",
"redirectUris": ["https://example.com/callback"],
"grants": ["authorization_code", "refresh_token"],
"scopes": ["read", "write"],
"owner": "admin"
}
]
}
```
### Managing OAuth Clients
#### List All Clients
```bash
curl http://localhost:3000/api/oauth/clients \
-H "x-auth-token: YOUR_JWT_TOKEN"
```
#### Get Specific Client
```bash
curl http://localhost:3000/api/oauth/clients/CLIENT_ID \
-H "x-auth-token: YOUR_JWT_TOKEN"
```
#### Update Client
```bash
curl -X PUT http://localhost:3000/api/oauth/clients/CLIENT_ID \
-H "Content-Type: application/json" \
-H "x-auth-token: YOUR_JWT_TOKEN" \
-d '{
"name": "Updated Name",
"redirectUris": ["https://example.com/callback", "https://example.com/callback2"]
}'
```
#### Delete Client
```bash
curl -X DELETE http://localhost:3000/api/oauth/clients/CLIENT_ID \
-H "x-auth-token: YOUR_JWT_TOKEN"
```
#### Regenerate Client Secret
```bash
curl -X POST http://localhost:3000/api/oauth/clients/CLIENT_ID/regenerate-secret \
-H "x-auth-token: YOUR_JWT_TOKEN"
```
## OAuth Flow
MCPHub supports the OAuth 2.0 Authorization Code flow with PKCE (Proof Key for Code Exchange).
### 1. Authorization Request
The client application redirects the user to the authorization endpoint:
```
GET /oauth/authorize?
client_id=CLIENT_ID&
redirect_uri=REDIRECT_URI&
response_type=code&
scope=read%20write&
state=RANDOM_STATE&
code_challenge=CODE_CHALLENGE&
code_challenge_method=S256
```
Parameters:
- `client_id`: OAuth client ID
- `redirect_uri`: Redirect URI (must match registered URI)
- `response_type`: Must be `code`
- `scope`: Space-separated list of scopes (e.g., `read write`)
- `state`: Random string to prevent CSRF attacks
- `code_challenge`: PKCE code challenge (optional but recommended)
- `code_challenge_method`: PKCE method (`S256` or `plain`)
### 2. User Authorization
The user is presented with a consent page showing:
- Application name
- Requested scopes
- Approve/Deny buttons
If the user approves, they are redirected to the redirect URI with an authorization code:
```
https://example.com/callback?code=AUTHORIZATION_CODE&state=RANDOM_STATE
```
### 3. Token Exchange
The client exchanges the authorization code for an access token:
```bash
curl -X POST http://localhost:3000/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=AUTHORIZATION_CODE" \
-d "redirect_uri=REDIRECT_URI" \
-d "client_id=CLIENT_ID" \
-d "code_verifier=CODE_VERIFIER"
```
Response:
```json
{
"access_token": "ACCESS_TOKEN",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "REFRESH_TOKEN",
"scope": "read write"
}
```
### 4. Using Access Token
Use the access token to make authenticated requests:
```bash
curl http://localhost:3000/api/servers \
-H "Authorization: Bearer ACCESS_TOKEN"
```
### 5. Refreshing Token
When the access token expires, use the refresh token to get a new one:
```bash
curl -X POST http://localhost:3000/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token" \
-d "refresh_token=REFRESH_TOKEN" \
-d "client_id=CLIENT_ID"
```
## PKCE (Proof Key for Code Exchange)
PKCE is a security extension to OAuth 2.0 that prevents authorization code interception attacks. It's especially important for public clients (mobile apps, SPAs).
### Generating PKCE Parameters
1. Generate a code verifier (random string):
```javascript
const codeVerifier = crypto.randomBytes(32).toString('base64url');
```
2. Generate code challenge from verifier:
```javascript
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
```
3. Include in authorization request:
- `code_challenge`: The generated challenge
- `code_challenge_method`: `S256`
4. Include in token request:
- `code_verifier`: The original verifier
## OAuth Scopes
MCPHub supports the following default scopes:
| Scope | Description |
|-------|-------------|
| `read` | Read access to MCP servers and tools |
| `write` | Execute tools and modify MCP server configurations |
You can customize allowed scopes in the `oauthServer.allowedScopes` configuration.
## Dynamic Client Registration (RFC 7591)
MCPHub supports RFC 7591 Dynamic Client Registration, allowing OAuth clients to register themselves programmatically without manual configuration.
### Enable Dynamic Registration
Add to your `mcp_settings.json`:
```json
{
"systemConfig": {
"oauthServer": {
"enabled": true,
"dynamicRegistration": {
"enabled": true,
"allowedGrantTypes": ["authorization_code", "refresh_token"],
"requiresAuthentication": false
}
}
}
}
```
### Register a New Client
**POST /oauth/register**
```bash
curl -X POST http://localhost:3000/oauth/register \
-H "Content-Type: application/json" \
-d '{
"client_name": "My Application",
"redirect_uris": ["https://example.com/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"scope": "read write",
"token_endpoint_auth_method": "none"
}'
```
Response:
```json
{
"client_id": "a1b2c3d4e5f6g7h8",
"client_name": "My Application",
"redirect_uris": ["https://example.com/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"scope": "read write",
"token_endpoint_auth_method": "none",
"registration_access_token": "reg_token_xyz123",
"registration_client_uri": "http://localhost:3000/oauth/register/a1b2c3d4e5f6g7h8",
"client_id_issued_at": 1699200000
}
```
**Important:** Save the `registration_access_token` - it's required to read, update, or delete the client registration.
### Read Client Configuration
**GET /oauth/register/:clientId**
```bash
curl http://localhost:3000/oauth/register/CLIENT_ID \
-H "Authorization: Bearer REGISTRATION_ACCESS_TOKEN"
```
### Update Client Configuration
**PUT /oauth/register/:clientId**
```bash
curl -X PUT http://localhost:3000/oauth/register/CLIENT_ID \
-H "Authorization: Bearer REGISTRATION_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"client_name": "Updated Application Name",
"redirect_uris": ["https://example.com/callback", "https://example.com/callback2"]
}'
```
### Delete Client Registration
**DELETE /oauth/register/:clientId**
```bash
curl -X DELETE http://localhost:3000/oauth/register/CLIENT_ID \
-H "Authorization: Bearer REGISTRATION_ACCESS_TOKEN"
```
### Optional Client Metadata
When registering a client, you can include additional metadata:
- `application_type`: `"web"` or `"native"` (default: `"web"`)
- `contacts`: Array of email addresses
- `logo_uri`: URL of client logo
- `client_uri`: URL of client homepage
- `policy_uri`: URL of privacy policy
- `tos_uri`: URL of terms of service
- `jwks_uri`: URL of JSON Web Key Set
- `jwks`: Inline JSON Web Key Set
Example:
```json
{
"client_name": "My Application",
"redirect_uris": ["https://example.com/callback"],
"application_type": "web",
"contacts": ["admin@example.com"],
"logo_uri": "https://example.com/logo.png",
"client_uri": "https://example.com",
"policy_uri": "https://example.com/privacy",
"tos_uri": "https://example.com/terms"
}
```
## Server Metadata
MCPHub provides OAuth 2.0 Authorization Server Metadata (RFC 8414) at:
```
GET /.well-known/oauth-authorization-server
```
Response (with dynamic registration enabled):
```json
{
"issuer": "http://localhost:3000",
"authorization_endpoint": "http://localhost:3000/oauth/authorize",
"token_endpoint": "http://localhost:3000/oauth/token",
"userinfo_endpoint": "http://localhost:3000/oauth/userinfo",
"registration_endpoint": "http://localhost:3000/oauth/register",
"scopes_supported": ["read", "write"],
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"token_endpoint_auth_methods_supported": ["none", "client_secret_basic", "client_secret_post"],
"code_challenge_methods_supported": ["S256", "plain"]
}
```
## User Info Endpoint
Get authenticated user information (OpenID Connect compatible):
```bash
curl http://localhost:3000/oauth/userinfo \
-H "Authorization: Bearer ACCESS_TOKEN"
```
Response:
```json
{
"sub": "username",
"username": "username"
}
```
## Integration with ChatGPT Web
To integrate MCPHub with ChatGPT Web:
1. Enable OAuth server in MCPHub configuration
2. Create an OAuth client with ChatGPT's redirect URI
3. Configure ChatGPT Web MCP Connector:
- **MCP Server URL**: `http://your-mcphub-url/mcp`
- **Authentication**: OAuth
- **OAuth Client ID**: Your client ID
- **OAuth Client Secret**: Leave empty (PKCE flow)
- **Authorization URL**: `http://your-mcphub-url/oauth/authorize`
- **Token URL**: `http://your-mcphub-url/oauth/token`
- **Scopes**: `read write`
## Security Considerations
1. **HTTPS in Production**: Always use HTTPS in production to protect tokens in transit
2. **Secure Client Secrets**: If using confidential clients, store client secrets securely
3. **Token Storage**: Access tokens are stored in memory by default. For production, consider using a database
4. **Token Rotation**: Implement token rotation by using refresh tokens
5. **Scope Limitation**: Grant only necessary scopes to clients
6. **Redirect URI Validation**: Always validate redirect URIs strictly
7. **State Parameter**: Always use the state parameter to prevent CSRF attacks
8. **PKCE**: Use PKCE for public clients (strongly recommended)
9. **Rate Limiting**: For production deployments, implement rate limiting on OAuth endpoints to prevent brute force attacks. Consider using middleware like `express-rate-limit`
10. **Input Validation**: All OAuth parameters are validated, but additional application-level validation may be beneficial
11. **XSS Protection**: The authorization page escapes all user input to prevent XSS attacks
## Troubleshooting
### "OAuth server not available"
Make sure `oauthServer.enabled` is set to `true` in your configuration and restart MCPHub.
### "Invalid redirect_uri"
Ensure the redirect URI in the authorization request exactly matches one of the registered redirect URIs for the client.
### "Invalid client"
Verify the client ID is correct and the OAuth client exists in the configuration.
### Token expired
Use the refresh token to obtain a new access token, or re-authorize the application.
## Example: JavaScript Client
```javascript
// Generate PKCE parameters
const codeVerifier = crypto.randomBytes(32).toString('base64url');
const codeChallenge = crypto
.createHash('sha256')
.update(codeVerifier)
.digest('base64url');
// Store code verifier for later use
sessionStorage.setItem('codeVerifier', codeVerifier);
// Redirect to authorization endpoint
const authUrl = new URL('http://localhost:3000/oauth/authorize');
authUrl.searchParams.set('client_id', 'my-client-id');
authUrl.searchParams.set('redirect_uri', 'http://localhost:8080/callback');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', 'read write');
authUrl.searchParams.set('state', crypto.randomBytes(16).toString('hex'));
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
window.location.href = authUrl.toString();
// In callback handler:
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const codeVerifier = sessionStorage.getItem('codeVerifier');
// Exchange code for token
const tokenResponse = await fetch('http://localhost:3000/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: 'http://localhost:8080/callback',
client_id: 'my-client-id',
code_verifier: codeVerifier,
}),
});
const tokens = await tokenResponse.json();
// Store tokens securely
localStorage.setItem('accessToken', tokens.access_token);
localStorage.setItem('refreshToken', tokens.refresh_token);
// Use access token
const response = await fetch('http://localhost:3000/api/servers', {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
```
## References
- [OAuth 2.0 - RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749)
- [OAuth 2.0 Authorization Server Metadata - RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414)
- [PKCE - RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636)
- [OAuth 2.0 for Browser-Based Apps](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps)

View File

@@ -60,32 +60,6 @@ curl "http://localhost:3000/api/openapi.json?title=我的 MCP API&version=2.0.0"
要包含的服务器名称列表(逗号分隔)
</ParamField>
### 组/服务器特定的 OpenAPI 规范
<CodeGroup>
```bash GET /api/:name/openapi.json
curl "http://localhost:3000/api/mygroup/openapi.json"
```
```bash 带参数
curl "http://localhost:3000/api/myserver/openapi.json?title=我的服务器 API&version=1.0.0"
```
</CodeGroup>
为特定组或服务器生成并返回 OpenAPI 3.0.3 规范。如果存在具有给定名称的组,则返回该组中所有服务器的规范。否则,将名称视为服务器名称并仅返回该服务器的规范。
**路径参数:**
<ParamField path="name" type="string" required>
组 ID/名称或服务器名称
</ParamField>
**查询参数:**
与主 OpenAPI 规范端点相同title、description、version、serverUrl、includeDisabled
### 可用服务器
<CodeGroup>

View File

@@ -1,142 +0,0 @@
---
title: "提示词"
description: "管理和执行 MCP 提示词。"
---
import { Card, Cards } from 'mintlify';
<Card
title="POST /api/mcp/:serverName/prompts/:promptName"
href="#get-a-prompt"
>
在 MCP 服务器上执行提示词。
</Card>
<Card
title="POST /api/servers/:serverName/prompts/:promptName/toggle"
href="#toggle-a-prompt"
>
启用或禁用提示词。
</Card>
<Card
title="PUT /api/servers/:serverName/prompts/:promptName/description"
href="#update-prompt-description"
>
更新提示词的描述。
</Card>
---
### 获取提示词
在 MCP 服务器上执行提示词并获取结果。
- **端点**: `/api/mcp/:serverName/prompts/:promptName`
- **方法**: `POST`
- **身份验证**: 必需
- **参数**:
- `:serverName` (字符串, 必需): MCP 服务器的名称。
- `:promptName` (字符串, 必需): 提示词的名称。
- **请求正文**:
```json
{
"arguments": {
"arg1": "value1",
"arg2": "value2"
}
}
```
- `arguments` (对象, 可选): 传递给提示词的参数。
- **响应**:
```json
{
"success": true,
"data": {
"messages": [
{
"role": "user",
"content": {
"type": "text",
"text": "提示词内容"
}
}
]
}
}
```
**请求示例:**
```bash
curl -X POST "http://localhost:3000/api/mcp/myserver/prompts/code-review" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"arguments": {
"language": "typescript",
"code": "const x = 1;"
}
}'
```
---
### 切换提示词
启用或禁用服务器上的特定提示词。
- **端点**: `/api/servers/:serverName/prompts/:promptName/toggle`
- **方法**: `POST`
- **身份验证**: 必需
- **参数**:
- `:serverName` (字符串, 必需): 服务器的名称。
- `:promptName` (字符串, 必需): 提示词的名称。
- **请求正文**:
```json
{
"enabled": true
}
```
- `enabled` (布尔值, 必需): `true` 启用提示词, `false` 禁用提示词。
**请求示例:**
```bash
curl -X POST "http://localhost:3000/api/servers/myserver/prompts/code-review/toggle" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{"enabled": false}'
```
---
### 更新提示词描述
更新特定提示词的描述。
- **端点**: `/api/servers/:serverName/prompts/:promptName/description`
- **方法**: `PUT`
- **身份验证**: 必需
- **参数**:
- `:serverName` (字符串, 必需): 服务器的名称。
- `:promptName` (字符串, 必需): 提示词的名称。
- **请求正文**:
```json
{
"description": "新的提示词描述"
}
```
- `description` (字符串, 必需): 提示词的新描述。
**请求示例:**
```bash
curl -X PUT "http://localhost:3000/api/servers/myserver/prompts/code-review/description" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{"description": "审查代码的最佳实践和潜在问题"}'
```
**注意**: 提示词是可用于生成标准化请求到 MCP 服务器的模板。它们由 MCP 服务器定义,并且可以具有在执行提示词时填充的参数。

View File

@@ -54,20 +54,6 @@ import { Card, Cards } from 'mintlify';
更新工具的描述。
</Card>
<Card
title="PUT /api/system-config"
href="#update-system-config"
>
更新系统配置设置。
</Card>
<Card
title="GET /api/settings"
href="#get-settings"
>
获取所有服务器设置和配置。
</Card>
---
### 获取所有服务器
@@ -221,45 +207,3 @@ import { Card, Cards } from 'mintlify';
}
```
- `description` (string, 必填): 工具的新描述。
---
### 更新系统配置
更新系统范围的配置设置。
- **端点**: `/api/system-config`
- **方法**: `PUT`
- **正文**:
```json
{
"openaiApiKey": "sk-...",
"openaiBaseUrl": "https://api.openai.com/v1",
"modelName": "gpt-4",
"temperature": 0.7,
"maxTokens": 2048
}
```
- 所有字段都是可选的。只有提供的字段会被更新。
---
### 获取设置
检索所有服务器设置和配置。
- **端点**: `/api/settings`
- **方法**: `GET`
- **响应**:
```json
{
"success": true,
"data": {
"servers": [...],
"groups": [...],
"systemConfig": {...}
}
}
```
**注意**: 有关详细的提示词管理,请参阅 [提示词 API](/zh/api-reference/prompts) 文档。

View File

@@ -1,113 +0,0 @@
---
title: "系统"
description: "系统和实用程序端点。"
---
import { Card, Cards } from 'mintlify';
<Card
title="GET /health"
href="#health-check"
>
检查 MCPHub 服务器的健康状态。
</Card>
<Card
title="GET /oauth/callback"
href="#oauth-callback"
>
用于身份验证流程的 OAuth 回调端点。
</Card>
<Card
title="POST /api/dxt/upload"
href="#upload-dxt-file"
>
上传 DXT 配置文件。
</Card>
<Card
title="GET /api/mcp-settings/export"
href="#export-mcp-settings"
>
将 MCP 设置导出为 JSON。
</Card>
---
### 健康检查
检查 MCPHub 服务器的健康状态。
- **端点**: `/health`
- **方法**: `GET`
- **身份验证**: 不需要
- **响应**:
```json
{
"status": "ok",
"timestamp": "2024-11-12T01:30:00.000Z",
"uptime": 12345
}
```
**请求示例:**
```bash
curl "http://localhost:3000/health"
```
---
### OAuth 回调
用于处理 OAuth 身份验证流程的 OAuth 回调端点。此端点在用户授权后由 OAuth 提供商自动调用。
- **端点**: `/oauth/callback`
- **方法**: `GET`
- **身份验证**: 不需要(公共回调 URL
- **查询参数**: 因 OAuth 提供商而异(通常包括 `code`、`state` 等)
**注意**: 此端点由 MCPHub 的 OAuth 集成内部使用,客户端不应直接调用。
---
### 上传 DXT 文件
上传 DXT桌面扩展配置文件以导入服务器配置。
- **端点**: `/api/dxt/upload`
- **方法**: `POST`
- **身份验证**: 必需
- **Content-Type**: `multipart/form-data`
- **正文**:
- `file` (文件, 必需): 要上传的 DXT 配置文件。
**请求示例:**
```bash
curl -X POST "http://localhost:3000/api/dxt/upload" \
-H "Authorization: Bearer YOUR_TOKEN" \
-F "file=@config.dxt"
```
---
### 导出 MCP 设置
将当前 MCP 设置配置导出为 JSON 文件。
- **端点**: `/api/mcp-settings/export`
- **方法**: `GET`
- **身份验证**: 必需
- **响应**: 返回 `mcp_settings.json` 配置文件。
**请求示例:**
```bash
curl "http://localhost:3000/api/mcp-settings/export" \
-H "Authorization: Bearer YOUR_TOKEN" \
-o mcp_settings.json
```
**注意**: 此端点允许您下载 MCP 设置的备份,可用于恢复或迁移您的配置。

View File

@@ -1,86 +0,0 @@
---
title: "工具"
description: "以编程方式执行 MCP 工具。"
---
import { Card, Cards } from 'mintlify';
<Card
title="POST /api/tools/call/:server"
href="#call-a-tool"
>
在 MCP 服务器上调用特定工具。
</Card>
---
### 调用工具
使用给定参数在 MCP 服务器上执行特定工具。
- **端点**: `/api/tools/call/:server`
- **方法**: `POST`
- **参数**:
- `:server` (字符串, 必需): MCP 服务器的名称。
- **请求正文**:
```json
{
"toolName": "tool-name",
"arguments": {
"param1": "value1",
"param2": "value2"
}
}
```
- `toolName` (字符串, 必需): 要执行的工具名称。
- `arguments` (对象, 可选): 传递给工具的参数。默认为空对象。
- **响应**:
```json
{
"success": true,
"data": {
"content": [
{
"type": "text",
"text": "工具执行结果"
}
],
"toolName": "tool-name",
"arguments": {
"param1": "value1",
"param2": "value2"
}
}
}
```
**请求示例:**
```bash
curl -X POST "http://localhost:3000/api/tools/call/amap" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"toolName": "amap-maps_weather",
"arguments": {
"city": "Beijing"
}
}'
```
**注意事项:**
- 工具参数会根据工具的输入模式自动转换为适当的类型。
- 如果需要,可以使用 `x-session-id` 请求头在多个工具调用之间维护会话状态。
- 此端点需要身份验证。
---
### 替代方案OpenAPI 工具执行
有关无需身份验证的 OpenAPI 兼容工具执行,请参阅 [OpenAPI 集成](/api-reference/openapi#tool-execution) 文档。OpenAPI 端点提供:
- **GET** `/api/tools/:serverName/:toolName` - 用于带查询参数的简单工具
- **POST** `/api/tools/:serverName/:toolName` - 用于带 JSON 正文的复杂工具
这些端点专为与 OpenWebUI 和其他 OpenAPI 兼容系统集成而设计。

View File

@@ -1,195 +0,0 @@
---
title: "用户"
description: "在 MCPHub 中管理用户。"
---
import { Card, Cards } from 'mintlify';
<Card
title="GET /api/users"
href="#get-all-users"
>
获取所有用户的列表。
</Card>
<Card
title="GET /api/users/:username"
href="#get-a-user"
>
获取特定用户的详细信息。
</Card>
<Card
title="POST /api/users"
href="#create-a-user"
>
创建新用户。
</Card>
<Card
title="PUT /api/users/:username"
href="#update-a-user"
>
更新现有用户。
</Card>
<Card
title="DELETE /api/users/:username"
href="#delete-a-user"
>
删除用户。
</Card>
<Card
title="GET /api/users-stats"
href="#get-user-statistics"
>
获取有关用户及其服务器访问权限的统计信息。
</Card>
---
### 获取所有用户
检索系统中所有用户的列表。
- **端点**: `/api/users`
- **方法**: `GET`
- **身份验证**: 必需(仅管理员)
- **响应**:
```json
{
"success": true,
"data": [
{
"username": "admin",
"role": "admin",
"servers": ["server1", "server2"],
"groups": ["group1"]
},
{
"username": "user1",
"role": "user",
"servers": ["server1"],
"groups": []
}
]
}
```
---
### 获取用户
检索特定用户的详细信息。
- **端点**: `/api/users/:username`
- **方法**: `GET`
- **身份验证**: 必需(仅管理员)
- **参数**:
- `:username` (字符串, 必需): 用户的用户名。
- **响应**:
```json
{
"success": true,
"data": {
"username": "user1",
"role": "user",
"servers": ["server1", "server2"],
"groups": ["group1"]
}
}
```
---
### 创建用户
在系统中创建新用户。
- **端点**: `/api/users`
- **方法**: `POST`
- **身份验证**: 必需(仅管理员)
- **请求正文**:
```json
{
"username": "newuser",
"password": "securepassword",
"role": "user",
"servers": ["server1"],
"groups": ["group1"]
}
```
- `username` (字符串, 必需): 新用户的用户名。
- `password` (字符串, 必需): 新用户的密码。至少 6 个字符。
- `role` (字符串, 可选): 用户的角色。可以是 `"admin"` 或 `"user"`。默认为 `"user"`。
- `servers` (字符串数组, 可选): 用户可以访问的服务器名称列表。
- `groups` (字符串数组, 可选): 用户所属的组 ID 列表。
---
### 更新用户
更新现有用户的信息。
- **端点**: `/api/users/:username`
- **方法**: `PUT`
- **身份验证**: 必需(仅管理员)
- **参数**:
- `:username` (字符串, 必需): 要更新的用户的用户名。
- **请求正文**:
```json
{
"password": "newpassword",
"role": "admin",
"servers": ["server1", "server2", "server3"],
"groups": ["group1", "group2"]
}
```
- `password` (字符串, 可选): 用户的新密码。
- `role` (字符串, 可选): 用户的新角色。
- `servers` (字符串数组, 可选): 更新的可访问服务器列表。
- `groups` (字符串数组, 可选): 更新的组列表。
---
### 删除用户
从系统中删除用户。
- **端点**: `/api/users/:username`
- **方法**: `DELETE`
- **身份验证**: 必需(仅管理员)
- **参数**:
- `:username` (字符串, 必需): 要删除的用户的用户名。
---
### 获取用户统计信息
检索有关用户及其对服务器和组的访问权限的统计信息。
- **端点**: `/api/users-stats`
- **方法**: `GET`
- **身份验证**: 必需(仅管理员)
- **响应**:
```json
{
"success": true,
"data": {
"totalUsers": 5,
"adminUsers": 1,
"regularUsers": 4,
"usersPerServer": {
"server1": 3,
"server2": 2
},
"usersPerGroup": {
"group1": 2,
"group2": 1
}
}
}
```
**注意**: 所有用户管理端点都需要管理员身份验证。

View File

@@ -1,328 +0,0 @@
# MCPHub 数据库配置
## 概述
MCPHub 支持将配置数据存储在 PostgreSQL 数据库中,作为 `mcp_settings.json` 文件配置的替代方案。数据库模式为生产环境和企业级部署提供了更强大的持久化和扩展能力。
## 为什么使用数据库配置?
**核心优势:**
- ✅ **更好的持久化** - 配置数据存储在专业数据库中,支持事务和数据完整性
- ✅ **高可用性** - 利用数据库复制和故障转移能力
- ✅ **企业级支持** - 符合企业数据管理和合规要求
- ✅ **备份恢复** - 使用成熟的数据库备份工具和策略
## 环境变量
### 数据库模式必需变量
```bash
# 数据库连接 URLPostgreSQL
# 只需设置 DB_URL 即可自动启用数据库模式
DB_URL=postgresql://user:password@localhost:5432/mcphub
# 或显式控制 USE_DB覆盖自动检测
# USE_DB=true
# 替代方案:使用单独的配置项
# DB_HOST=localhost
# DB_PORT=5432
# DB_NAME=mcphub
# DB_USER=user
# DB_PASSWORD=password
```
<Note>
**简化配置**:您只需设置 `DB_URL` 即可启用数据库模式。MCPHub 会自动检测 `DB_URL` 是否存在并启用数据库模式。如果需要在设置了 `DB_URL` 的情况下禁用数据库模式,可以显式设置 `USE_DB=false`。
</Note>
### 可选设置
```bash
# 启动时自动迁移默认true
AUTO_MIGRATE=true
# 保留基于文件的配置作为后备默认false
KEEP_FILE_CONFIG=false
```
## 设置说明
### 1. 使用 Docker
#### 方案 A将 PostgreSQL 作为独立服务
创建 `docker-compose.yml` 文件:
```yaml
version: '3.8'
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: mcphub
POSTGRES_USER: mcphub
POSTGRES_PASSWORD: your_secure_password
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432"
mcphub:
image: samanhappy/mcphub:latest
environment:
USE_DB: "true"
DB_URL: "postgresql://mcphub:your_secure_password@postgres:5432/mcphub"
PORT: 3000
ports:
- "3000:3000"
depends_on:
- postgres
volumes:
pgdata:
```
运行:
```bash
docker-compose up -d
```
#### 方案 B使用外部数据库
如果您已有 PostgreSQL 数据库:
```bash
docker run -d \
-p 3000:3000 \
-e USE_DB=true \
-e DB_URL="postgresql://user:password@your-db-host:5432/mcphub" \
samanhappy/mcphub:latest
```
### 2. 手动设置
#### 步骤 1设置 PostgreSQL 数据库
```bash
# 安装 PostgreSQL如果尚未安装
sudo apt-get install postgresql postgresql-contrib
# 创建数据库和用户
sudo -u postgres psql <<EOF
CREATE DATABASE mcphub;
CREATE USER mcphub WITH ENCRYPTED PASSWORD 'your_password';
GRANT ALL PRIVILEGES ON DATABASE mcphub TO mcphub;
EOF
```
#### 步骤 2安装 MCPHub
```bash
npm install -g @samanhappy/mcphub
```
#### 步骤 3设置环境变量
创建 `.env` 文件:
```bash
# 只需设置 DB_URL 即可启用数据库模式USE_DB 会自动检测)
DB_URL=postgresql://mcphub:your_password@localhost:5432/mcphub
PORT=3000
```
#### 步骤 4运行迁移可选
如果您有现有的 `mcp_settings.json` 文件,可以进行迁移:
```bash
# 运行迁移脚本
npx tsx src/scripts/migrate-to-database.ts
```
或者让 MCPHub 在首次启动时自动迁移。
#### 步骤 5启动 MCPHub
```bash
mcphub
```
## 从基于文件迁移到数据库
MCPHub 在启用数据库模式首次启动时提供自动迁移功能。您也可以手动运行迁移。
### 自动迁移
当您首次使用 `USE_DB=true` 启动 MCPHub 时:
1. MCPHub 连接到数据库
2. 检查数据库中是否存在任何用户
3. 如果未找到用户,自动从 `mcp_settings.json` 迁移
4. 创建所有表并导入所有数据
### 手动迁移
运行迁移脚本:
```bash
# 使用 npx
npx tsx src/scripts/migrate-to-database.ts
# 或使用 Node
node dist/scripts/migrate-to-database.js
```
迁移将:
- ✅ 如果不存在则创建数据库表
- ✅ 导入所有用户(包含哈希密码)
- ✅ 导入所有 MCP 服务器配置
- ✅ 导入所有分组
- ✅ 导入系统配置
- ✅ 导入用户特定配置
- ✅ 跳过已存在的记录(可安全多次运行)
## 迁移后的配置
在数据库模式下运行时,所有配置更改都存储在数据库中:
- 通过 `/api/users` 进行用户管理
- 通过 `/api/servers` 进行服务器管理
- 通过 `/api/groups` 进行分组管理
- 通过 `/api/system/config` 进行系统设置
Web 仪表板的工作方式完全相同,但现在将更改存储在数据库中而不是文件中。
## 数据库架构
MCPHub 创建以下表:
- **users** - 用户账户和认证
- **servers** - MCP 服务器配置
- **groups** - 服务器分组
- **system_config** - 系统级设置
- **user_configs** - 用户特定设置
- **vector_embeddings** - 向量搜索数据(用于智能路由)
## 备份和恢复
### 备份
```bash
# PostgreSQL 备份
pg_dump -U mcphub mcphub > mcphub_backup.sql
# 或使用 Docker
docker exec postgres pg_dump -U mcphub mcphub > mcphub_backup.sql
```
### 恢复
```bash
# PostgreSQL 恢复
psql -U mcphub mcphub < mcphub_backup.sql
# 或使用 Docker
docker exec -i postgres psql -U mcphub mcphub < mcphub_backup.sql
```
## 切换回基于文件的配置
如果您需要切换回基于文件的配置:
1. 设置 `USE_DB=false` 或删除该环境变量
2. 重启 MCPHub
3. MCPHub 将再次使用 `mcp_settings.json`
注意:在数据库模式下所做的更改不会反映到文件中,除非您手动导出。
## 故障排除
### 连接被拒绝
```
Error: connect ECONNREFUSED 127.0.0.1:5432
```
**解决方案:** 检查 PostgreSQL 是否正在运行并可访问:
```bash
# 检查 PostgreSQL 状态
sudo systemctl status postgresql
# 或对于 Docker
docker ps | grep postgres
```
### 认证失败
```
Error: password authentication failed for user "mcphub"
```
**解决方案:** 验证 `DB_URL` 环境变量中的数据库凭据。
### 迁移失败
```
❌ Migration failed: ...
```
**解决方案:**
1. 检查 `mcp_settings.json` 是否存在且为有效的 JSON
2. 验证数据库连接
3. 检查日志获取具体错误信息
4. 确保数据库用户具有 CREATE TABLE 权限
### 表已存在
如果数据库表不存在,会自动创建。如果遇到关于已存在表的错误,请检查:
1. 之前的迁移是否部分完成
2. 手动创建表的冲突
3. 如果需要,在数据库配置中使用 `synchronize: false` 运行
## 环境变量参考
| 变量 | 必需 | 默认值 | 描述 |
|------|------|--------|------|
| `DB_URL` | 是* | - | 完整的 PostgreSQL 连接 URL。设置此变量会自动启用数据库模式 |
| `USE_DB` | 否 | 自动 | 显式启用/禁用数据库模式。如果未设置,根据 `DB_URL` 是否存在自动检测 |
| `DB_HOST` | 否 | `localhost` | 数据库主机(如果不使用 DB_URL |
| `DB_PORT` | 否 | `5432` | 数据库端口(如果不使用 DB_URL |
| `DB_NAME` | 否 | `mcphub` | 数据库名称(如果不使用 DB_URL |
| `DB_USER` | 否 | `mcphub` | 数据库用户(如果不使用 DB_URL |
| `DB_PASSWORD` | 否 | - | 数据库密码(如果不使用 DB_URL |
| `AUTO_MIGRATE` | 否 | `true` | 首次启动时自动从文件迁移 |
| `MCPHUB_SETTING_PATH` | 否 | - | mcp_settings.json 的路径(用于迁移) |
*数据库模式必需。只需设置 `DB_URL` 即可自动启用数据库模式
## 安全注意事项
1. **数据库凭据:** 安全存储数据库凭据,使用环境变量或密钥管理
2. **网络访问:** 仅限 MCPHub 实例访问数据库
3. **加密:** 在生产环境中使用 SSL/TLS 进行数据库连接:
```bash
DB_URL=postgresql://user:password@host:5432/mcphub?sslmode=require
```
4. **备份:** 定期备份您的数据库
5. **访问控制:** 使用强密码并限制用户权限
## 性能
数据库模式在以下场景提供更好的性能:
- 多个并发用户
- 频繁的配置更改
- 大量服务器/分组
文件模式可能更快的场景:
- 单用户设置
- 读取密集型工作负载且更改不频繁
- 开发/测试环境
## 支持
如有问题或疑问:
- GitHub Issues: https://github.com/samanhappy/mcphub/issues
- 文档: https://mcphub.io/docs

View File

@@ -48,7 +48,7 @@ docker --version
```bash
# 克隆主仓库
git clone https://github.com/samanhappy/mcphub.git
git clone https://github.com/mcphub/mcphub.git
cd mcphub
# 或者克隆您的 fork

View File

@@ -388,7 +388,7 @@ CMD ["node", "dist/index.js"]
````md
```bash
# 克隆 MCPHub 仓库
git clone https://github.com/samanhappy/mcphub.git
git clone https://github.com/mcphub/mcphub.git
cd mcphub
# 安装依赖
@@ -413,7 +413,7 @@ npm start
```bash
# 克隆 MCPHub 仓库
git clone https://github.com/samanhappy/mcphub.git
git clone https://github.com/mcphub/mcphub.git
cd mcphub
# 安装依赖
@@ -441,7 +441,7 @@ npm start
```powershell
# Windows PowerShell 安装步骤
# 克隆仓库
git clone https://github.com/samanhappy/mcphub.git
git clone https://github.com/mcphub/mcphub.git
Set-Location mcphub
# 安装 Node.js 依赖
@@ -458,7 +458,7 @@ npm run dev
```powershell
# Windows PowerShell 安装步骤
# 克隆仓库
git clone https://github.com/samanhappy/mcphub.git
git clone https://github.com/mcphub/mcphub.git
Set-Location mcphub
# 安装 Node.js 依赖

View File

@@ -331,7 +331,7 @@ MCPHub 文档支持以下图标库的图标:
"pages": [
{
"name": "GitHub 仓库",
"url": "https://github.com/samanhappy/mcphub",
"url": "https://github.com/mcphub/mcphub",
"icon": "github"
},
{
@@ -382,6 +382,7 @@ zh/
"pages": [
"zh/concepts/introduction",
"zh/concepts/architecture",
"zh/concepts/mcp-protocol",
"zh/concepts/routing"
]
}

View File

@@ -35,6 +35,9 @@ MCPHub 是一个现代化的 Model Context Protocol (MCP) 服务器管理平台
了解 MCPHub 的核心概念,为深入使用做好准备。
<CardGroup cols={2}>
<Card title="MCP 协议介绍" icon="network-wired" href="/zh/concepts/mcp-protocol">
深入了解 Model Context Protocol 的工作原理和最佳实践
</Card>
<Card title="智能路由机制" icon="route" href="/zh/features/smart-routing">
学习 MCPHub 的智能路由算法和配置策略
</Card>
@@ -54,6 +57,12 @@ MCPHub 支持多种部署方式,满足不同规模和场景的需求。
<Card title="Docker 部署" icon="docker" href="/zh/configuration/docker-setup">
使用 Docker 容器快速部署,支持单机和集群模式
</Card>
<Card title="云服务部署" icon="cloud" href="/zh/deployment/cloud">
在 AWS、GCP、Azure 等云平台上部署 MCPHub
</Card>
<Card title="Kubernetes" icon="dharmachakra" href="/zh/deployment/kubernetes">
在 Kubernetes 集群中部署高可用的 MCPHub 服务
</Card>
</CardGroup>
## API 和集成
@@ -64,6 +73,9 @@ MCPHub 提供完整的 RESTful API 和多语言 SDK方便与现有系统集
<Card title="API 参考文档" icon="code" href="/zh/api-reference/introduction">
完整的 API 接口文档,包含详细的请求示例和响应格式
</Card>
<Card title="SDK 和工具" icon="toolbox" href="/zh/sdk">
官方 SDK 和命令行工具,加速开发集成
</Card>
</CardGroup>
## 社区和支持
@@ -71,7 +83,7 @@ MCPHub 提供完整的 RESTful API 和多语言 SDK方便与现有系统集
加入 MCPHub 社区,获取帮助和分享经验。
<CardGroup cols={2}>
<Card title="GitHub 仓库" icon="github" href="https://github.com/samanhappy/mcphub">
<Card title="GitHub 仓库" icon="github" href="https://github.com/mcphub/mcphub">
查看源代码、提交问题和贡献代码
</Card>
<Card title="Discord 社区" icon="discord" href="https://discord.gg/mcphub">

View File

@@ -1,5 +1,27 @@
#!/bin/bash
DATA_DIR=${MCP_DATA_DIR:-/app/data}
SERVERS_DIR=${MCP_SERVERS_DIR:-$DATA_DIR/servers}
NPM_SERVER_DIR=${MCP_NPM_DIR:-$SERVERS_DIR/npm}
PYTHON_SERVER_DIR=${MCP_PYTHON_DIR:-$SERVERS_DIR/python}
PNPM_HOME=${PNPM_HOME:-$DATA_DIR/pnpm}
NPM_CONFIG_PREFIX=${NPM_CONFIG_PREFIX:-$DATA_DIR/npm-global}
NPM_CONFIG_CACHE=${NPM_CONFIG_CACHE:-$DATA_DIR/npm-cache}
UV_TOOL_DIR=${UV_TOOL_DIR:-$DATA_DIR/uv/tools}
UV_CACHE_DIR=${UV_CACHE_DIR:-$DATA_DIR/uv/cache}
mkdir -p \
"$PNPM_HOME" \
"$NPM_CONFIG_PREFIX/bin" \
"$NPM_CONFIG_PREFIX/lib/node_modules" \
"$NPM_CONFIG_CACHE" \
"$UV_TOOL_DIR" \
"$UV_CACHE_DIR" \
"$NPM_SERVER_DIR" \
"$PYTHON_SERVER_DIR"
export PATH="$PNPM_HOME:$NPM_CONFIG_PREFIX/bin:$UV_TOOL_DIR/bin:$PATH"
NPM_REGISTRY=${NPM_REGISTRY:-https://registry.npmjs.org/}
echo "Setting npm registry to ${NPM_REGISTRY}"
npm config set registry "$NPM_REGISTRY"

View File

@@ -1,25 +0,0 @@
{
"systemConfig": {
"oauthServer": {
"enabled": true,
"accessTokenLifetime": 3600,
"refreshTokenLifetime": 1209600,
"authorizationCodeLifetime": 300,
"requireClientSecret": false,
"allowedScopes": ["read", "write"],
"dynamicRegistration": {
"enabled": true,
"allowedGrantTypes": ["authorization_code", "refresh_token"],
"requiresAuthentication": false
}
}
},
"mcpServers": {},
"users": [
{
"username": "admin",
"password": "$2b$10$abcdefghijklmnopqrstuv",
"isAdmin": true
}
]
}

View File

@@ -1,76 +0,0 @@
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest",
"--headless"
]
},
"fetch": {
"command": "uvx",
"args": [
"mcp-server-fetch"
]
}
},
"users": [
{
"username": "admin",
"password": "$2b$10$Vt7krIvjNgyN67LXqly0uOcTpN0LI55cYRbcKC71pUDAP0nJ7RPa.",
"isAdmin": true
}
],
"systemConfig": {
"oauthServer": {
"enabled": true,
"accessTokenLifetime": 3600,
"refreshTokenLifetime": 1209600,
"authorizationCodeLifetime": 300,
"requireClientSecret": false,
"allowedScopes": [
"read",
"write"
]
},
"routing": {
"skipAuth": false
}
},
"oauthClients": [
{
"clientId": "chatgpt-web-client",
"name": "ChatGPT Web Integration",
"redirectUris": [
"https://chatgpt.com/oauth/callback",
"https://chat.openai.com/oauth/callback"
],
"grants": [
"authorization_code",
"refresh_token"
],
"scopes": [
"read",
"write"
],
"owner": "admin"
},
{
"clientId": "example-public-app",
"name": "Example Public Application",
"redirectUris": [
"http://localhost:8080/callback",
"http://localhost:3001/callback"
],
"grants": [
"authorization_code",
"refresh_token"
],
"scopes": [
"read",
"write"
],
"owner": "admin"
}
]
}

View File

@@ -57,28 +57,28 @@ const AddUserForm = ({ onAdd, onCancel }: AddUserFormProps) => {
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setFormData((prev) => ({
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
[name]: type === 'checkbox' ? checked : value
}));
};
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white p-8 rounded-xl shadow-2xl max-w-md w-full mx-4 border border-gray-100">
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-center justify-center z-50">
<div className="bg-white p-8 rounded-lg shadow-xl max-w-md w-full mx-4">
<form onSubmit={handleSubmit}>
<h2 className="text-xl font-bold text-gray-900 mb-6">{t('users.addNew')}</h2>
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('users.addNew')}</h2>
{error && (
<div className="bg-red-50 border-l-4 border-red-500 text-red-700 p-4 mb-6 rounded-md">
<p className="text-sm font-medium">{error}</p>
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-3 mb-4">
<p className="text-sm">{error}</p>
</div>
)}
<div className="space-y-5">
<div className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
{t('users.username')} <span className="text-red-500">*</span>
{t('users.username')} *
</label>
<input
type="text"
@@ -87,7 +87,7 @@ const AddUserForm = ({ onAdd, onCancel }: AddUserFormProps) => {
value={formData.username}
onChange={handleInputChange}
placeholder={t('users.usernamePlaceholder')}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent form-input transition-all duration-200"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
disabled={isSubmitting}
/>
@@ -95,7 +95,7 @@ const AddUserForm = ({ onAdd, onCancel }: AddUserFormProps) => {
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
{t('users.password')} <span className="text-red-500">*</span>
{t('users.password')} *
</label>
<input
type="password"
@@ -104,68 +104,43 @@ const AddUserForm = ({ onAdd, onCancel }: AddUserFormProps) => {
value={formData.password}
onChange={handleInputChange}
placeholder={t('users.passwordPlaceholder')}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent form-input transition-all duration-200"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
disabled={isSubmitting}
minLength={6}
/>
</div>
<div className="flex items-center pt-2">
<div className="flex items-center">
<input
type="checkbox"
id="isAdmin"
name="isAdmin"
checked={formData.isAdmin}
onChange={handleInputChange}
className="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded transition-colors duration-200"
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
disabled={isSubmitting}
/>
<label
htmlFor="isAdmin"
className="ml-3 block text-sm font-medium text-gray-700 cursor-pointer select-none"
>
<label htmlFor="isAdmin" className="ml-2 block text-sm text-gray-700">
{t('users.adminRole')}
</label>
</div>
</div>
<div className="flex justify-end space-x-3 mt-8">
<div className="flex justify-end space-x-3 mt-6">
<button
type="button"
onClick={onCancel}
className="px-5 py-2.5 text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-all duration-200 font-medium btn-secondary shadow-sm"
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 transition-colors duration-200"
disabled={isSubmitting}
>
{t('common.cancel')}
</button>
<button
type="submit"
className="px-5 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all duration-200 font-medium btn-primary shadow-md disabled:opacity-70 disabled:cursor-not-allowed flex items-center"
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isSubmitting}
>
{isSubmitting && (
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
)}
{isSubmitting ? t('common.creating') : t('users.create')}
</button>
</div>

View File

@@ -62,132 +62,93 @@ const EditUserForm = ({ user, onEdit, onCancel }: EditUserFormProps) => {
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setFormData((prev) => ({
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
[name]: type === 'checkbox' ? checked : value
}));
};
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white p-8 rounded-xl shadow-2xl max-w-md w-full mx-4 border border-gray-100">
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-center justify-center z-50">
<div className="bg-white p-8 rounded-lg shadow-xl max-w-md w-full mx-4">
<form onSubmit={handleSubmit}>
<h2 className="text-xl font-bold text-gray-900 mb-6">
{t('users.edit')} - <span className="text-blue-600">{user.username}</span>
<h2 className="text-xl font-semibold text-gray-800 mb-4">
{t('users.edit')} - {user.username}
</h2>
{error && (
<div className="bg-red-50 border-l-4 border-red-500 text-red-700 p-4 mb-6 rounded-md">
<p className="text-sm font-medium">{error}</p>
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-3 mb-4">
<p className="text-sm">{error}</p>
</div>
)}
<div className="space-y-5">
<div className="flex items-center pt-2">
<div className="space-y-4">
<div className="flex items-center">
<input
type="checkbox"
id="isAdmin"
name="isAdmin"
checked={formData.isAdmin}
onChange={handleInputChange}
className="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded transition-colors duration-200"
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
disabled={isSubmitting}
/>
<label
htmlFor="isAdmin"
className="ml-3 block text-sm font-medium text-gray-700 cursor-pointer select-none"
>
<label htmlFor="isAdmin" className="ml-2 block text-sm text-gray-700">
{t('users.adminRole')}
</label>
</div>
<div className="border-t border-gray-100 pt-4 mt-2">
<p className="text-xs text-gray-500 uppercase font-semibold tracking-wider mb-3">
{t('users.changePassword')}
</p>
<div className="space-y-4">
<div>
<label
htmlFor="newPassword"
className="block text-sm font-medium text-gray-700 mb-1"
>
{t('users.newPassword')}
</label>
<input
type="password"
id="newPassword"
name="newPassword"
value={formData.newPassword}
onChange={handleInputChange}
placeholder={t('users.newPasswordPlaceholder')}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent form-input transition-all duration-200"
disabled={isSubmitting}
minLength={6}
/>
</div>
{formData.newPassword && (
<div className="animate-fadeIn">
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-gray-700 mb-1"
>
{t('users.confirmPassword')}
</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleInputChange}
placeholder={t('users.confirmPasswordPlaceholder')}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent form-input transition-all duration-200"
disabled={isSubmitting}
minLength={6}
/>
</div>
)}
</div>
<div>
<label htmlFor="newPassword" className="block text-sm font-medium text-gray-700 mb-1">
{t('users.newPassword')}
</label>
<input
type="password"
id="newPassword"
name="newPassword"
value={formData.newPassword}
onChange={handleInputChange}
placeholder={t('users.newPasswordPlaceholder')}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isSubmitting}
minLength={6}
/>
</div>
{formData.newPassword && (
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-1">
{t('users.confirmPassword')}
</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleInputChange}
placeholder={t('users.confirmPasswordPlaceholder')}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isSubmitting}
minLength={6}
/>
</div>
)}
</div>
<div className="flex justify-end space-x-3 mt-8">
<div className="flex justify-end space-x-3 mt-6">
<button
type="button"
onClick={onCancel}
className="px-5 py-2.5 text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-all duration-200 font-medium btn-secondary shadow-sm"
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 transition-colors duration-200"
disabled={isSubmitting}
>
{t('common.cancel')}
</button>
<button
type="submit"
className="px-5 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all duration-200 font-medium btn-primary shadow-md disabled:opacity-70 disabled:cursor-not-allowed flex items-center"
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isSubmitting}
>
{isSubmitting && (
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
)}
{isSubmitting ? t('common.updating') : t('users.update')}
</button>
</div>

View File

@@ -1,10 +1,11 @@
import { useState, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Group, Server, IGroupServerConfig } from '@/types'
import { Edit, Trash, Copy, Check, Link, FileCode, DropdownIcon, Wrench } from '@/components/icons/LucideIcons'
import { Edit, Trash, Copy, Check, Link, FileCode, DropdownIcon, Wrench, Download } from '@/components/icons/LucideIcons'
import DeleteDialog from '@/components/ui/DeleteDialog'
import { useToast } from '@/contexts/ToastContext'
import { useSettingsData } from '@/hooks/useSettingsData'
import InstallToClientDialog from '@/components/InstallToClientDialog'
interface GroupCardProps {
group: Group
@@ -26,6 +27,7 @@ const GroupCard = ({
const [copied, setCopied] = useState(false)
const [showCopyDropdown, setShowCopyDropdown] = useState(false)
const [expandedServer, setExpandedServer] = useState<string | null>(null)
const [showInstallDialog, setShowInstallDialog] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
// Close dropdown when clicking outside
@@ -50,6 +52,10 @@ const GroupCard = ({
setShowDeleteDialog(true)
}
const handleInstall = () => {
setShowInstallDialog(true)
}
const handleConfirmDelete = () => {
onDelete(group.id)
setShowDeleteDialog(false)
@@ -183,6 +189,13 @@ const GroupCard = ({
<div className="bg-blue-50 text-blue-700 px-3 py-1 rounded-full text-sm btn-secondary">
{t('groups.serverCount', { count: group.servers.length })}
</div>
<button
onClick={handleInstall}
className="text-purple-500 hover:text-purple-700"
title={t('install.installButton')}
>
<Download size={18} />
</button>
<button
onClick={handleEdit}
className="text-gray-500 hover:text-gray-700"
@@ -277,6 +290,20 @@ const GroupCard = ({
serverName={group.name}
isGroup={true}
/>
{showInstallDialog && installConfig && (
<InstallToClientDialog
groupId={group.id}
groupName={group.name}
config={{
type: 'streamable-http',
url: `${installConfig.protocol}://${installConfig.baseUrl}${installConfig.basePath}/mcp/${group.id}`,
headers: {
Authorization: `Bearer ${installConfig.token}`
}
}}
onClose={() => setShowInstallDialog(false)}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,219 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Copy, Check } from 'lucide-react';
interface InstallToClientDialogProps {
serverName?: string;
groupId?: string;
groupName?: string;
config: any;
onClose: () => void;
}
const InstallToClientDialog: React.FC<InstallToClientDialogProps> = ({
serverName,
groupId,
groupName,
config,
onClose,
}) => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<'cursor' | 'claude-code' | 'claude-desktop'>('cursor');
const [copied, setCopied] = useState(false);
// Generate configuration based on the active tab
const generateConfig = () => {
if (groupId) {
// For groups, generate group-based configuration
return {
mcpServers: {
[`mcphub-${groupId}`]: config,
},
};
} else {
// For individual servers
return {
mcpServers: {
[serverName || 'mcp-server']: config,
},
};
}
};
const configJson = JSON.stringify(generateConfig(), null, 2);
const handleCopyConfig = () => {
navigator.clipboard.writeText(configJson).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
// Generate deep link for Cursor (if supported in the future)
const handleInstallToCursor = () => {
// For now, just copy the config since deep linking may not be widely supported
handleCopyConfig();
// In the future, this could be:
// const deepLink = `cursor://install-mcp?config=${encodeURIComponent(configJson)}`;
// window.open(deepLink, '_blank');
};
const getStepsList = () => {
const displayName = groupName || serverName || 'MCP server';
switch (activeTab) {
case 'cursor':
return [
t('install.step1Cursor'),
t('install.step2Cursor'),
t('install.step3Cursor'),
t('install.step4Cursor', { name: displayName }),
];
case 'claude-code':
return [
t('install.step1ClaudeCode'),
t('install.step2ClaudeCode'),
t('install.step3ClaudeCode'),
t('install.step4ClaudeCode', { name: displayName }),
];
case 'claude-desktop':
return [
t('install.step1ClaudeDesktop'),
t('install.step2ClaudeDesktop'),
t('install.step3ClaudeDesktop'),
t('install.step4ClaudeDesktop', { name: displayName }),
];
default:
return [];
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden">
<div className="flex justify-between items-center p-6 border-b">
<h2 className="text-2xl font-bold text-gray-900">
{groupId
? t('install.installGroupTitle', { name: groupName })
: t('install.installServerTitle', { name: serverName })}
</h2>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700 transition-colors duration-200"
aria-label={t('common.close')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div className="overflow-y-auto max-h-[calc(90vh-140px)]">
{/* Tab Navigation */}
<div className="border-b border-gray-200 px-6 pt-4">
<nav className="-mb-px flex space-x-4">
<button
onClick={() => setActiveTab('cursor')}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors duration-200 ${
activeTab === 'cursor'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Cursor
</button>
<button
onClick={() => setActiveTab('claude-code')}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors duration-200 ${
activeTab === 'claude-code'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Claude Code
</button>
<button
onClick={() => setActiveTab('claude-desktop')}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors duration-200 ${
activeTab === 'claude-desktop'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Claude Desktop
</button>
</nav>
</div>
{/* Configuration Display */}
<div className="p-6 space-y-6">
<div className="bg-gray-50 rounded-lg p-4">
<div className="flex justify-between items-center mb-2">
<h3 className="text-sm font-medium text-gray-700">{t('install.configCode')}</h3>
<button
onClick={handleCopyConfig}
className="flex items-center space-x-2 px-3 py-1.5 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 transition-colors duration-200 text-sm"
>
{copied ? <Check size={16} /> : <Copy size={16} />}
<span>{copied ? t('common.copied') : t('install.copyConfig')}</span>
</button>
</div>
<pre className="bg-white border border-gray-200 rounded p-4 text-xs overflow-x-auto">
<code>{configJson}</code>
</pre>
</div>
{/* Installation Steps */}
<div className="bg-blue-50 rounded-lg p-4">
<h3 className="text-sm font-semibold text-blue-900 mb-3">{t('install.steps')}</h3>
<ol className="space-y-3">
{getStepsList().map((step, index) => (
<li key={index} className="flex items-start space-x-3">
<span className="flex-shrink-0 w-6 h-6 bg-blue-500 text-white rounded-full flex items-center justify-center text-xs font-medium">
{index + 1}
</span>
<span className="text-sm text-blue-900 pt-0.5">{step}</span>
</li>
))}
</ol>
</div>
</div>
</div>
{/* Footer */}
<div className="flex justify-between items-center p-6 border-t bg-gray-50">
<button
onClick={onClose}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded hover:bg-gray-100 transition-colors duration-200"
>
{t('common.close')}
</button>
<button
onClick={handleInstallToCursor}
className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors duration-200 flex items-center space-x-2"
>
<Copy size={16} />
<span>
{activeTab === 'cursor' && t('install.installToCursor', { name: groupName || serverName })}
{activeTab === 'claude-code' && t('install.installToClaudeCode', { name: groupName || serverName })}
{activeTab === 'claude-desktop' && t('install.installToClaudeDesktop', { name: groupName || serverName })}
</span>
</button>
</div>
</div>
</div>
);
};
export default InstallToClientDialog;

View File

@@ -1,13 +1,14 @@
import { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Server } from '@/types';
import { ChevronDown, ChevronRight, AlertCircle, Copy, Check } from 'lucide-react';
import { ChevronDown, ChevronRight, AlertCircle, Copy, Check, Download } from 'lucide-react';
import { StatusBadge } from '@/components/ui/Badge';
import ToolCard from '@/components/ui/ToolCard';
import PromptCard from '@/components/ui/PromptCard';
import DeleteDialog from '@/components/ui/DeleteDialog';
import { useToast } from '@/contexts/ToastContext';
import { useSettingsData } from '@/hooks/useSettingsData';
import InstallToClientDialog from '@/components/InstallToClientDialog';
interface ServerCardProps {
server: Server;
@@ -25,6 +26,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
const [isToggling, setIsToggling] = useState(false);
const [showErrorPopover, setShowErrorPopover] = useState(false);
const [copied, setCopied] = useState(false);
const [showInstallDialog, setShowInstallDialog] = useState(false);
const errorPopoverRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -52,6 +54,11 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
onEdit(server);
};
const handleInstall = (e: React.MouseEvent) => {
e.stopPropagation();
setShowInstallDialog(true);
};
const handleToggle = async (e: React.MouseEvent) => {
e.stopPropagation();
if (isToggling || !onToggle) return;
@@ -310,6 +317,13 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
<button onClick={handleCopyServerConfig} className={`px-3 py-1 btn-secondary`}>
{t('server.copy')}
</button>
<button
onClick={handleInstall}
className="px-3 py-1 bg-purple-100 text-purple-800 rounded hover:bg-purple-200 text-sm btn-primary flex items-center space-x-1"
>
<Download size={14} />
<span>{t('install.installButton')}</span>
</button>
<button
onClick={handleEdit}
className="px-3 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary"
@@ -398,6 +412,13 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
onConfirm={handleConfirmDelete}
serverName={server.name}
/>
{showInstallDialog && server.config && (
<InstallToClientDialog
serverName={server.name}
config={server.config}
onClose={() => setShowInstallDialog(false)}
/>
)}
</>
);
};

View File

@@ -17,7 +17,8 @@ import {
Link,
FileCode,
ChevronDown as DropdownIcon,
Wrench
Wrench,
Download
} from 'lucide-react'
export {
@@ -39,7 +40,8 @@ export {
Link,
FileCode,
DropdownIcon,
Wrench
Wrench,
Download
}
const LucideIcons = {

View File

@@ -11,8 +11,7 @@ const LanguageSwitch: React.FC = () => {
const availableLanguages = [
{ code: 'en', label: 'English' },
{ code: 'zh', label: '中文' },
{ code: 'fr', label: 'Français' },
{ code: 'tr', label: 'Türkçe' }
{ code: 'fr', label: 'Français' }
];
// Update current language when it changes

View File

@@ -1,10 +1,9 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Tool } from '@/types'
import { ChevronDown, ChevronRight, Play, Loader, Edit, Check, Copy } from '@/components/icons/LucideIcons'
import { ChevronDown, ChevronRight, Play, Loader, Edit, Check } from '@/components/icons/LucideIcons'
import { callTool, ToolCallResult, updateToolDescription } from '@/services/toolService'
import { useSettingsData } from '@/hooks/useSettingsData'
import { useToast } from '@/contexts/ToastContext'
import { Switch } from './ToggleGroup'
import DynamicForm from './DynamicForm'
import ToolResult from './ToolResult'
@@ -27,7 +26,6 @@ function isEmptyValue(value: any): boolean {
const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps) => {
const { t } = useTranslation()
const { showToast } = useToast()
const { nameSeparator } = useSettingsData()
const [isExpanded, setIsExpanded] = useState(false)
const [showRunForm, setShowRunForm] = useState(false)
@@ -38,7 +36,6 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
const descriptionInputRef = useRef<HTMLInputElement>(null)
const descriptionTextRef = useRef<HTMLSpanElement>(null)
const [textWidth, setTextWidth] = useState<number>(0)
const [copiedToolName, setCopiedToolName] = useState(false)
// Focus the input when editing mode is activated
useEffect(() => {
@@ -111,41 +108,6 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
}
}
const handleCopyToolName = async (e: React.MouseEvent) => {
e.stopPropagation()
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(tool.name)
setCopiedToolName(true)
showToast(t('common.copySuccess'), 'success')
setTimeout(() => setCopiedToolName(false), 2000)
} else {
// Fallback for HTTP or unsupported clipboard API
const textArea = document.createElement('textarea')
textArea.value = tool.name
textArea.style.position = 'fixed'
textArea.style.left = '-9999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
try {
document.execCommand('copy')
setCopiedToolName(true)
showToast(t('common.copySuccess'), 'success')
setTimeout(() => setCopiedToolName(false), 2000)
} catch (err) {
showToast(t('common.copyFailed'), 'error')
console.error('Copy to clipboard failed:', err)
}
document.body.removeChild(textArea)
}
} catch (error) {
showToast(t('common.copyFailed'), 'error')
console.error('Copy to clipboard failed:', error)
}
}
const handleRunTool = async (arguments_: Record<string, any>) => {
setIsRunning(true)
try {
@@ -187,19 +149,8 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex-1">
<h3 className="text-lg font-medium text-gray-900 inline-flex items-center">
<h3 className="text-lg font-medium text-gray-900">
{tool.name.replace(server + nameSeparator, '')}
<button
className="ml-2 p-1 text-gray-500 hover:text-blue-600 cursor-pointer transition-colors"
onClick={handleCopyToolName}
title={t('common.copy')}
>
{copiedToolName ? (
<Check size={16} className="text-green-500" />
) : (
<Copy size={16} />
)}
</button>
<span className="ml-2 text-sm font-normal text-gray-600 inline-flex items-center">
{isEditingDescription ? (
<>

View File

@@ -2,10 +2,8 @@
export const PERMISSIONS = {
// Settings page permissions
SETTINGS_SMART_ROUTING: 'settings:smart_routing',
SETTINGS_ROUTE_CONFIG: 'settings:route_config',
SETTINGS_SKIP_AUTH: 'settings:skip_auth',
SETTINGS_INSTALL_CONFIG: 'settings:install_config',
SETTINGS_SYSTEM_CONFIG: 'settings:system_config',
SETTINGS_OAUTH_SERVER: 'settings:oauth_server',
SETTINGS_EXPORT_CONFIG: 'settings:export_config',
} as const;

View File

@@ -283,29 +283,31 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
const handleServerEdit = useCallback(
async (server: Server) => {
try {
// Fetch single server config instead of all settings
const encodedServerName = encodeURIComponent(server.name);
const serverData: ApiResponse<{
name: string;
status: string;
tools: any[];
config: Record<string, any>;
}> = await apiGet(`/servers/${encodedServerName}`);
// Fetch settings to get the full server config before editing
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> =
await apiGet('/settings');
if (serverData && serverData.success && serverData.data) {
if (
settingsData &&
settingsData.success &&
settingsData.data &&
settingsData.data.mcpServers &&
settingsData.data.mcpServers[server.name]
) {
const serverConfig = settingsData.data.mcpServers[server.name];
return {
name: serverData.data.name,
status: serverData.data.status,
tools: serverData.data.tools || [],
config: serverData.data.config,
name: server.name,
status: server.status,
tools: server.tools || [],
config: serverConfig,
};
} else {
console.error('Failed to get server config:', serverData);
console.error('Failed to get server config from settings:', settingsData);
setError(t('server.invalidConfig', { serverName: server.name }));
return null;
}
} catch (err) {
console.error('Error fetching server config:', err);
console.error('Error fetching server settings:', err);
setError(err instanceof Error ? err.message : String(err));
return null;
}

View File

@@ -34,21 +34,6 @@ interface MCPRouterConfig {
baseUrl: string;
}
interface OAuthServerConfig {
enabled: boolean;
accessTokenLifetime: number;
refreshTokenLifetime: number;
authorizationCodeLifetime: number;
requireClientSecret: boolean;
allowedScopes: string[];
requireState: boolean;
dynamicRegistration: {
enabled: boolean;
allowedGrantTypes: string[];
requiresAuthentication: boolean;
};
}
interface SystemSettings {
systemConfig?: {
routing?: RoutingConfig;
@@ -56,8 +41,6 @@ interface SystemSettings {
smartRouting?: SmartRoutingConfig;
mcpRouter?: MCPRouterConfig;
nameSeparator?: string;
oauthServer?: OAuthServerConfig;
enableSessionRebuild?: boolean;
};
}
@@ -65,21 +48,6 @@ interface TempRoutingConfig {
bearerAuthKey: string;
}
const getDefaultOAuthServerConfig = (): OAuthServerConfig => ({
enabled: true,
accessTokenLifetime: 3600,
refreshTokenLifetime: 1209600,
authorizationCodeLifetime: 300,
requireClientSecret: false,
allowedScopes: ['read', 'write'],
requireState: false,
dynamicRegistration: {
enabled: true,
allowedGrantTypes: ['authorization_code', 'refresh_token'],
requiresAuthentication: false,
},
});
export const useSettingsData = () => {
const { t } = useTranslation();
const { showToast } = useToast();
@@ -117,12 +85,7 @@ export const useSettingsData = () => {
baseUrl: 'https://api.mcprouter.to/v1',
});
const [oauthServerConfig, setOAuthServerConfig] = useState<OAuthServerConfig>(
getDefaultOAuthServerConfig(),
);
const [nameSeparator, setNameSeparator] = useState<string>('-');
const [enableSessionRebuild, setEnableSessionRebuild] = useState<boolean>(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -175,50 +138,9 @@ export const useSettingsData = () => {
baseUrl: data.data.systemConfig.mcpRouter.baseUrl || 'https://api.mcprouter.to/v1',
});
}
if (data.success) {
if (data.data?.systemConfig?.oauthServer) {
const oauth = data.data.systemConfig.oauthServer;
const defaultOauthConfig = getDefaultOAuthServerConfig();
const defaultDynamic = defaultOauthConfig.dynamicRegistration;
const allowedScopes = Array.isArray(oauth.allowedScopes)
? [...oauth.allowedScopes]
: [...defaultOauthConfig.allowedScopes];
const dynamicAllowedGrantTypes = Array.isArray(
oauth.dynamicRegistration?.allowedGrantTypes,
)
? [...oauth.dynamicRegistration!.allowedGrantTypes!]
: [...defaultDynamic.allowedGrantTypes];
setOAuthServerConfig({
enabled: oauth.enabled ?? defaultOauthConfig.enabled,
accessTokenLifetime:
oauth.accessTokenLifetime ?? defaultOauthConfig.accessTokenLifetime,
refreshTokenLifetime:
oauth.refreshTokenLifetime ?? defaultOauthConfig.refreshTokenLifetime,
authorizationCodeLifetime:
oauth.authorizationCodeLifetime ?? defaultOauthConfig.authorizationCodeLifetime,
requireClientSecret:
oauth.requireClientSecret ?? defaultOauthConfig.requireClientSecret,
requireState: oauth.requireState ?? defaultOauthConfig.requireState,
allowedScopes,
dynamicRegistration: {
enabled: oauth.dynamicRegistration?.enabled ?? defaultDynamic.enabled,
allowedGrantTypes: dynamicAllowedGrantTypes,
requiresAuthentication:
oauth.dynamicRegistration?.requiresAuthentication ??
defaultDynamic.requiresAuthentication,
},
});
} else {
setOAuthServerConfig(getDefaultOAuthServerConfig());
}
}
if (data.success && data.data?.systemConfig?.nameSeparator !== undefined) {
setNameSeparator(data.data.systemConfig.nameSeparator);
}
if (data.success && data.data?.systemConfig?.enableSessionRebuild !== undefined) {
setEnableSessionRebuild(data.data.systemConfig.enableSessionRebuild);
}
} catch (error) {
console.error('Failed to fetch settings:', error);
setError(error instanceof Error ? error.message : 'Failed to fetch settings');
@@ -468,77 +390,6 @@ export const useSettingsData = () => {
}
};
// Update OAuth server configuration
const updateOAuthServerConfig = async <T extends keyof OAuthServerConfig>(
key: T,
value: OAuthServerConfig[T],
) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
oauthServer: {
[key]: value,
},
});
if (data.success) {
setOAuthServerConfig((prev) => ({
...prev,
[key]: value,
}));
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
return false;
}
} catch (error) {
console.error('Failed to update OAuth server config:', error);
const errorMessage =
error instanceof Error ? error.message : 'Failed to update OAuth server config';
setError(errorMessage);
showToast(errorMessage);
return false;
} finally {
setLoading(false);
}
};
// Update multiple OAuth server config fields
const updateOAuthServerConfigBatch = async (updates: Partial<OAuthServerConfig>) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
oauthServer: updates,
});
if (data.success) {
setOAuthServerConfig((prev) => ({
...prev,
...updates,
}));
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
return false;
}
} catch (error) {
console.error('Failed to update OAuth server config:', error);
const errorMessage =
error instanceof Error ? error.message : 'Failed to update OAuth server config';
setError(errorMessage);
showToast(errorMessage);
return false;
} finally {
setLoading(false);
}
};
// Update name separator
const updateNameSeparator = async (value: string) => {
setLoading(true);
@@ -569,36 +420,6 @@ export const useSettingsData = () => {
}
};
// Update session rebuild setting
const updateSessionRebuild = async (value: boolean) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
enableSessionRebuild: value,
});
if (data.success) {
setEnableSessionRebuild(value);
showToast(t('settings.restartRequired'), 'info');
return true;
} else {
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
return false;
}
} catch (error) {
console.error('Failed to update session rebuild setting:', error);
const errorMessage =
error instanceof Error ? error.message : 'Failed to update session rebuild setting';
setError(errorMessage);
showToast(errorMessage);
return false;
} finally {
setLoading(false);
}
};
const exportMCPSettings = async (serverName?: string) => {
setLoading(true);
setError(null);
@@ -634,9 +455,7 @@ export const useSettingsData = () => {
installConfig,
smartRoutingConfig,
mcpRouterConfig,
oauthServerConfig,
nameSeparator,
enableSessionRebuild,
loading,
error,
setError,
@@ -649,10 +468,7 @@ export const useSettingsData = () => {
updateRoutingConfigBatch,
updateMCPRouterConfig,
updateMCPRouterConfigBatch,
updateOAuthServerConfig,
updateOAuthServerConfigBatch,
updateNameSeparator,
updateSessionRebuild,
exportMCPSettings,
};
};

View File

@@ -6,7 +6,6 @@ import LanguageDetector from 'i18next-browser-languagedetector';
import enTranslation from '../../locales/en.json';
import zhTranslation from '../../locales/zh.json';
import frTranslation from '../../locales/fr.json';
import trTranslation from '../../locales/tr.json';
i18n
// Detect user language
@@ -25,9 +24,6 @@ i18n
fr: {
translation: frTranslation,
},
tr: {
translation: trTranslation,
},
},
fallbackLng: 'en',
debug: process.env.NODE_ENV === 'development',

View File

@@ -1,34 +1,11 @@
import React, { useState, useMemo, useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../contexts/AuthContext';
import { getToken } from '../services/authService';
import ThemeSwitch from '@/components/ui/ThemeSwitch';
import LanguageSwitch from '@/components/ui/LanguageSwitch';
import DefaultPasswordWarningModal from '@/components/ui/DefaultPasswordWarningModal';
const sanitizeReturnUrl = (value: string | null): string | null => {
if (!value) {
return null;
}
try {
// Support both relative paths and absolute URLs on the same origin
const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost';
const url = new URL(value, origin);
if (url.origin !== origin) {
return null;
}
const relativePath = `${url.pathname}${url.search}${url.hash}`;
return relativePath || '/';
} catch {
if (value.startsWith('/') && !value.startsWith('//')) {
return value;
}
return null;
}
};
const LoginPage: React.FC = () => {
const { t } = useTranslation();
const [username, setUsername] = useState('');
@@ -37,46 +14,7 @@ const LoginPage: React.FC = () => {
const [loading, setLoading] = useState(false);
const [showDefaultPasswordWarning, setShowDefaultPasswordWarning] = useState(false);
const { login } = useAuth();
const location = useLocation();
const navigate = useNavigate();
const returnUrl = useMemo(() => {
const params = new URLSearchParams(location.search);
return sanitizeReturnUrl(params.get('returnUrl'));
}, [location.search]);
const buildRedirectTarget = useCallback(() => {
if (!returnUrl) {
return '/';
}
// Only attach JWT when returning to the OAuth authorize endpoint
if (!returnUrl.startsWith('/oauth/authorize')) {
return returnUrl;
}
const token = getToken();
if (!token) {
return returnUrl;
}
try {
const origin = window.location.origin;
const url = new URL(returnUrl, origin);
url.searchParams.set('token', token);
return `${url.pathname}${url.search}${url.hash}`;
} catch {
const separator = returnUrl.includes('?') ? '&' : '?';
return `${returnUrl}${separator}token=${encodeURIComponent(token)}`;
}
}, [returnUrl]);
const redirectAfterLogin = useCallback(() => {
if (returnUrl) {
window.location.assign(buildRedirectTarget());
} else {
navigate('/');
}
}, [buildRedirectTarget, navigate, returnUrl]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -97,7 +35,7 @@ const LoginPage: React.FC = () => {
// Show warning modal instead of navigating immediately
setShowDefaultPasswordWarning(true);
} else {
redirectAfterLogin();
navigate('/');
}
} else {
setError(t('auth.loginFailed'));
@@ -111,7 +49,7 @@ const LoginPage: React.FC = () => {
const handleCloseWarning = () => {
setShowDefaultPasswordWarning(false);
redirectAfterLogin();
navigate('/');
};
return (
@@ -222,4 +160,4 @@ const LoginPage: React.FC = () => {
);
};
export default LoginPage;
export default LoginPage;

View File

@@ -8,6 +8,7 @@ import EditServerForm from '@/components/EditServerForm';
import { useServerData } from '@/hooks/useServerData';
import DxtUploadForm from '@/components/DxtUploadForm';
import JSONImportForm from '@/components/JSONImportForm';
import { apiGet } from '@/utils/fetchInterceptor';
const ServersPage: React.FC = () => {
const { t } = useTranslation();
@@ -27,6 +28,10 @@ const ServersPage: React.FC = () => {
const [isRefreshing, setIsRefreshing] = useState(false);
const [showDxtUpload, setShowDxtUpload] = useState(false);
const [showJsonImport, setShowJsonImport] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [similarityThreshold, setSimilarityThreshold] = useState(0.65);
const [isSearching, setIsSearching] = useState(false);
const [searchResults, setSearchResults] = useState<Server[] | null>(null);
const handleEditClick = async (server: Server) => {
const fullServerData = await handleServerEdit(server);
@@ -63,6 +68,31 @@ const ServersPage: React.FC = () => {
triggerRefresh();
};
const handleSemanticSearch = async () => {
if (!searchQuery.trim()) {
return;
}
setIsSearching(true);
try {
const result = await apiGet(`/servers/search?query=${encodeURIComponent(searchQuery)}&threshold=${similarityThreshold}`);
if (result.success && result.data) {
setSearchResults(result.data.servers);
} else {
setError(result.message || 'Search failed');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Search failed');
} finally {
setIsSearching(false);
}
};
const handleClearSearch = () => {
setSearchQuery('');
setSearchResults(null);
};
return (
<div>
<div className="flex justify-between items-center mb-8">
@@ -116,6 +146,72 @@ const ServersPage: React.FC = () => {
</div>
</div>
{/* Semantic Search Section */}
<div className="bg-white shadow rounded-lg p-6 mb-6 page-card">
<div className="space-y-4">
<div className="flex space-x-4">
<div className="flex-grow">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleSemanticSearch()}
placeholder={t('pages.servers.semanticSearchPlaceholder')}
className="shadow appearance-none border border-gray-200 rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
/>
</div>
<button
onClick={handleSemanticSearch}
disabled={isSearching || !searchQuery.trim()}
className="px-6 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSearching ? (
<svg className="animate-spin h-4 w-4 mr-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clipRule="evenodd" />
</svg>
)}
{t('pages.servers.searchButton')}
</button>
{searchResults && (
<button
onClick={handleClearSearch}
className="border border-gray-300 text-gray-700 font-medium py-2 px-4 rounded hover:bg-gray-50 btn-secondary transition-all duration-200"
>
{t('pages.servers.clearSearch')}
</button>
)}
</div>
<div className="flex items-center space-x-4">
<label className="text-sm text-gray-700 font-medium min-w-max">{t('pages.servers.similarityThreshold')}: {similarityThreshold.toFixed(2)}</label>
<input
type="range"
min="0"
max="1"
step="0.05"
value={similarityThreshold}
onChange={(e) => setSimilarityThreshold(parseFloat(e.target.value))}
className="flex-grow h-2 bg-blue-200 rounded-lg appearance-none cursor-pointer"
/>
<span className="text-xs text-gray-500">{t('pages.servers.similarityThresholdHelp')}</span>
</div>
</div>
</div>
{searchResults && (
<div className="mb-4 bg-blue-50 border-l-4 border-blue-500 p-4 rounded">
<p className="text-blue-800">
{searchResults.length > 0
? t('pages.servers.searchResults', { count: searchResults.length })
: t('pages.servers.noSearchResults')}
</p>
</div>
)}
{error && (
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4 rounded shadow-sm error-box">
<div className="flex items-center justify-between">
@@ -145,13 +241,13 @@ const ServersPage: React.FC = () => {
<p className="text-gray-600">{t('app.loading')}</p>
</div>
</div>
) : servers.length === 0 ? (
) : (searchResults ? searchResults : servers).length === 0 ? (
<div className="bg-white shadow rounded-lg p-6 empty-state">
<p className="text-gray-600">{t('app.noServers')}</p>
<p className="text-gray-600">{searchResults ? t('pages.servers.noSearchResults') : t('app.noServers')}</p>
</div>
) : (
<div className="space-y-6">
{servers.map((server, index) => (
{(searchResults || servers).map((server, index) => (
<ServerCard
key={index}
server={server}

File diff suppressed because it is too large Load Diff

View File

@@ -5,8 +5,7 @@ import { useUserData } from '@/hooks/useUserData';
import { useAuth } from '@/contexts/AuthContext';
import AddUserForm from '@/components/AddUserForm';
import EditUserForm from '@/components/EditUserForm';
import { Edit, Trash, User as UserIcon } from 'lucide-react';
import DeleteDialog from '@/components/ui/DeleteDialog';
import UserCard from '@/components/UserCard';
const UsersPage: React.FC = () => {
const { t } = useTranslation();
@@ -23,12 +22,11 @@ const UsersPage: React.FC = () => {
const [editingUser, setEditingUser] = useState<User | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
const [userToDelete, setUserToDelete] = useState<string | null>(null);
// Check if current user is admin
if (!currentUser?.isAdmin) {
return (
<div className="bg-white shadow rounded-lg p-6 dashboard-card">
<div className="bg-white shadow rounded-lg p-6">
<p className="text-red-600">{t('users.adminRequired')}</p>
</div>
);
@@ -43,17 +41,10 @@ const UsersPage: React.FC = () => {
triggerRefresh(); // Refresh the users list after editing
};
const handleDeleteClick = (username: string) => {
setUserToDelete(username);
};
const handleConfirmDelete = async () => {
if (userToDelete) {
const result = await deleteUser(userToDelete);
if (!result?.success) {
setUserError(result?.message || t('users.deleteError'));
}
setUserToDelete(null);
const handleDeleteUser = async (username: string) => {
const result = await deleteUser(username);
if (!result?.success) {
setUserError(result?.message || t('users.deleteError'));
}
};
@@ -67,13 +58,13 @@ const UsersPage: React.FC = () => {
};
return (
<div className="container mx-auto">
<div>
<div className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold text-gray-900">{t('pages.users.title')}</h1>
<div className="flex space-x-4">
<button
onClick={handleAddUser}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 flex items-center btn-primary transition-all duration-200 shadow-sm"
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 3a1 1 0 00-1 1v5H4a1 1 0 100 2h5v5a1 1 0 102 0v-5h5a1 1 0 100-2h-5V4a1 1 0 00-1-1z" clipRule="evenodd" />
@@ -84,23 +75,13 @@ const UsersPage: React.FC = () => {
</div>
{userError && (
<div className="bg-red-50 border-l-4 border-red-500 text-red-700 p-4 mb-6 error-box rounded-lg shadow-sm">
<div className="flex justify-between items-center">
<p>{userError}</p>
<button
onClick={() => setUserError(null)}
className="text-red-500 hover:text-red-700"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 111.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</div>
<div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6 error-box rounded-lg">
<p>{userError}</p>
</div>
)}
{usersLoading ? (
<div className="bg-white shadow rounded-lg p-6 loading-container flex justify-center items-center h-64">
<div className="bg-white shadow rounded-lg p-6 loading-container">
<div className="flex flex-col items-center justify-center">
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
@@ -110,93 +91,20 @@ const UsersPage: React.FC = () => {
</div>
</div>
) : users.length === 0 ? (
<div className="bg-white shadow rounded-lg p-6 empty-state dashboard-card">
<div className="flex flex-col items-center justify-center py-12">
<div className="p-4 bg-gray-100 rounded-full mb-4">
<UserIcon className="h-8 w-8 text-gray-400" />
</div>
<p className="text-gray-600 text-lg font-medium">{t('users.noUsers')}</p>
<button
onClick={handleAddUser}
className="mt-4 text-blue-600 hover:text-blue-800 font-medium"
>
{t('users.addFirst')}
</button>
</div>
<div className="bg-white shadow rounded-lg p-6 empty-state">
<p className="text-gray-600">{t('users.noUsers')}</p>
</div>
) : (
<div className="bg-white shadow rounded-lg overflow-hidden table-container dashboard-card">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('users.username')}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('users.role')}
</th>
<th scope="col" className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
{t('users.actions')}
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{users.map((user) => {
const isCurrentUser = currentUser?.username === user.username;
return (
<tr key={user.username} className="hover:bg-gray-50 transition-colors duration-150">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
<div className="h-10 w-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-bold text-lg">
{user.username.charAt(0).toUpperCase()}
</div>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900 flex items-center">
{user.username}
{isCurrentUser && (
<span className="ml-2 px-2 py-0.5 text-xs bg-blue-100 text-blue-800 rounded-full border border-blue-200">
{t('users.currentUser')}
</span>
)}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${user.isAdmin
? 'bg-purple-100 text-purple-800 border border-purple-200'
: 'bg-gray-100 text-gray-800 border border-gray-200'
}`}>
{user.isAdmin ? t('users.admin') : t('users.user')}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex justify-end space-x-3">
<button
onClick={() => handleEditClick(user)}
className="text-blue-600 hover:text-blue-900 p-1 rounded hover:bg-blue-50 transition-colors"
title={t('users.edit')}
>
<Edit size={18} />
</button>
{!isCurrentUser && (
<button
onClick={() => handleDeleteClick(user.username)}
className="text-red-600 hover:text-red-900 p-1 rounded hover:bg-red-50 transition-colors"
title={t('users.delete')}
>
<Trash size={18} />
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
<div className="space-y-6">
{users.map((user) => (
<UserCard
key={user.username}
user={user}
currentUser={currentUser}
onEdit={handleEditClick}
onDelete={handleDeleteUser}
/>
))}
</div>
)}
@@ -211,15 +119,6 @@ const UsersPage: React.FC = () => {
onCancel={() => setEditingUser(null)}
/>
)}
<DeleteDialog
isOpen={!!userToDelete}
onClose={() => setUserToDelete(null)}
onConfirm={handleConfirmDelete}
serverName={userToDelete || ''}
isGroup={false}
isUser={true}
/>
</div>
);
};

View File

@@ -268,7 +268,15 @@
"recentServers": "Recent Servers"
},
"servers": {
"title": "Servers Management"
"title": "Servers Management",
"semanticSearch": "Intelligent search for tools...",
"semanticSearchPlaceholder": "Describe the functionality you need, e.g.: maps, weather, file processing",
"similarityThreshold": "Similarity Threshold",
"similarityThresholdHelp": "Higher values return more precise results, lower values return broader matches",
"searchButton": "Search",
"clearSearch": "Clear Search",
"searchResults": "Found {{count}} matching server(s)",
"noSearchResults": "No matching servers found"
},
"groups": {
"title": "Group Management"
@@ -284,8 +292,7 @@
"appearance": "Appearance",
"routeConfig": "Security",
"installConfig": "Installation",
"smartRouting": "Smart Routing",
"oauthServer": "OAuth Server"
"smartRouting": "Smart Routing"
},
"market": {
"title": "Market Hub - Local and Cloud Markets"
@@ -384,16 +391,6 @@
"confirmVariablesMessage": "Please ensure these variables are properly defined in your runtime environment. Continue installing server?",
"confirmAndInstall": "Confirm and Install"
},
"oauthServer": {
"authorizeTitle": "Authorize Application",
"authorizeSubtitle": "Allow this application to access your MCPHub account.",
"buttons": {
"approve": "Allow access",
"deny": "Deny",
"approveSubtitle": "Recommended if you trust this application.",
"denySubtitle": "You can always grant access later."
}
},
"cloud": {
"title": "Cloud Support",
"subtitle": "Powered by MCPRouter",
@@ -585,8 +582,6 @@
"systemSettings": "System Settings",
"nameSeparatorLabel": "Name Separator",
"nameSeparatorDescription": "Character used to separate server name and tool/prompt name (default: -)",
"enableSessionRebuild": "Enable Server Session Rebuild",
"enableSessionRebuildDescription": "When enabled, applies the improved server session rebuild code for better session management experience",
"restartRequired": "Configuration saved. It is recommended to restart the application to ensure all services load the new settings correctly.",
"exportMcpSettings": "Export Settings",
"mcpSettingsJson": "MCP Settings JSON",
@@ -594,33 +589,7 @@
"copyToClipboard": "Copy to Clipboard",
"downloadJson": "Download JSON",
"exportSuccess": "Settings exported successfully",
"exportError": "Failed to fetch settings",
"enableOauthServer": "Enable OAuth Server",
"enableOauthServerDescription": "Allow MCPHub to issue OAuth tokens for external clients",
"requireClientSecret": "Require Client Secret",
"requireClientSecretDescription": "When enabled, confidential clients must present a client secret (disable for PKCE-only clients)",
"requireState": "Require State Parameter",
"requireStateDescription": "Reject authorization requests that omit the OAuth state parameter",
"accessTokenLifetime": "Access Token Lifetime (seconds)",
"accessTokenLifetimeDescription": "How long issued access tokens remain valid",
"accessTokenLifetimePlaceholder": "e.g. 3600",
"refreshTokenLifetime": "Refresh Token Lifetime (seconds)",
"refreshTokenLifetimeDescription": "How long refresh tokens remain valid",
"refreshTokenLifetimePlaceholder": "e.g. 1209600",
"authorizationCodeLifetime": "Authorization Code Lifetime (seconds)",
"authorizationCodeLifetimeDescription": "How long authorization codes remain valid before they can be exchanged",
"authorizationCodeLifetimePlaceholder": "e.g. 300",
"allowedScopes": "Allowed Scopes",
"allowedScopesDescription": "Comma-separated list of scopes users can approve during authorization",
"allowedScopesPlaceholder": "e.g. read, write",
"enableDynamicRegistration": "Enable Dynamic Client Registration",
"dynamicRegistrationDescription": "Allow RFC 7591 compliant clients to self-register using the public endpoint",
"dynamicRegistrationAllowedGrantTypes": "Allowed Grant Types",
"dynamicRegistrationAllowedGrantTypesDescription": "Comma-separated list of grants permitted for dynamically registered clients",
"dynamicRegistrationAllowedGrantTypesPlaceholder": "e.g. authorization_code, refresh_token",
"dynamicRegistrationAuth": "Require Authentication",
"dynamicRegistrationAuthDescription": "Protect the registration endpoint so only authenticated requests can register clients",
"invalidNumberInput": "Please enter a valid non-negative number"
"exportError": "Failed to fetch settings"
},
"dxt": {
"upload": "Upload",
@@ -673,13 +642,9 @@
"password": "Password",
"newPassword": "New Password",
"confirmPassword": "Confirm Password",
"changePassword": "Change Password",
"adminRole": "Administrator",
"admin": "Admin",
"user": "User",
"role": "Role",
"actions": "Actions",
"addFirst": "Add your first user",
"permissions": "Permissions",
"adminPermissions": "Full system access",
"userPermissions": "Limited access",
@@ -786,5 +751,28 @@
"internalError": "Internal Error",
"internalErrorMessage": "An unexpected error occurred while processing the OAuth callback.",
"closeWindow": "Close Window"
},
"install": {
"installServerTitle": "Install Server to {{name}}",
"installGroupTitle": "Install Group {{name}}",
"configCode": "Configuration Code",
"copyConfig": "Copy Configuration",
"steps": "Installation Steps",
"step1Cursor": "Copy the configuration code above",
"step2Cursor": "Open Cursor, go to Settings > Features > MCP",
"step3Cursor": "Click 'Add New MCP Server' to add a new server",
"step4Cursor": "Paste the configuration in the appropriate location and restart Cursor",
"step1ClaudeCode": "Copy the configuration code above",
"step2ClaudeCode": "Open Claude Code, go to Settings > Features > MCP",
"step3ClaudeCode": "Click 'Add New MCP Server' to add a new server",
"step4ClaudeCode": "Paste the configuration in the appropriate location and restart Claude Code",
"step1ClaudeDesktop": "Copy the configuration code above",
"step2ClaudeDesktop": "Open Claude Desktop, go to Settings > Developer",
"step3ClaudeDesktop": "Click 'Edit Config' to edit the configuration file",
"step4ClaudeDesktop": "Paste the configuration in the mcpServers section and restart Claude Desktop",
"installToCursor": "Add {{name}} MCP server to Cursor",
"installToClaudeCode": "Add {{name}} MCP server to Claude Code",
"installToClaudeDesktop": "Add {{name}} MCP server to Claude Desktop",
"installButton": "Install"
}
}
}

View File

@@ -268,7 +268,15 @@
"recentServers": "Serveurs récents"
},
"servers": {
"title": "Gestion des serveurs"
"title": "Gestion des serveurs",
"semanticSearch": "Recherche intelligente d'outils...",
"semanticSearchPlaceholder": "Décrivez la fonctionnalité dont vous avez besoin, par ex. : cartes, météo, traitement de fichiers",
"similarityThreshold": "Seuil de similarité",
"similarityThresholdHelp": "Des valeurs plus élevées renvoient des résultats plus précis, des valeurs plus faibles des correspondances plus larges",
"searchButton": "Rechercher",
"clearSearch": "Effacer la recherche",
"searchResults": "{{count}} serveur(s) correspondant(s) trouvé(s)",
"noSearchResults": "Aucun serveur correspondant trouvé"
},
"groups": {
"title": "Gestion des groupes"
@@ -284,8 +292,7 @@
"appearance": "Apparence",
"routeConfig": "Sécurité",
"installConfig": "Installation",
"smartRouting": "Routage intelligent",
"oauthServer": "Serveur OAuth"
"smartRouting": "Routage intelligent"
},
"market": {
"title": "Marché Hub - Marchés locaux et Cloud"
@@ -384,16 +391,6 @@
"confirmVariablesMessage": "Veuillez vous assurer que ces variables sont correctement définies dans votre environnement d'exécution. Continuer l'installation du serveur ?",
"confirmAndInstall": "Confirmer et installer"
},
"oauthServer": {
"authorizeTitle": "Autoriser l'application",
"authorizeSubtitle": "Autorisez cette application à accéder à votre compte MCPHub.",
"buttons": {
"approve": "Autoriser l'accès",
"deny": "Refuser",
"approveSubtitle": "Recommandé si vous faites confiance à cette application.",
"denySubtitle": "Vous pourrez toujours accorder l'accès plus tard."
}
},
"cloud": {
"title": "Support Cloud",
"subtitle": "Propulsé par MCPRouter",
@@ -585,8 +582,6 @@
"systemSettings": "Paramètres système",
"nameSeparatorLabel": "Séparateur de noms",
"nameSeparatorDescription": "Caractère utilisé pour séparer le nom du serveur et le nom de l'outil/prompt (par défaut : -)",
"enableSessionRebuild": "Activer la reconstruction de session serveur",
"enableSessionRebuildDescription": "Lorsqu'il est activé, applique le code de reconstruction de session serveur amélioré pour une meilleure expérience de gestion de session",
"restartRequired": "Configuration enregistrée. Il est recommandé de redémarrer l'application pour s'assurer que tous les services chargent correctement les nouveaux paramètres.",
"exportMcpSettings": "Exporter les paramètres",
"mcpSettingsJson": "JSON des paramètres MCP",
@@ -594,33 +589,7 @@
"copyToClipboard": "Copier dans le presse-papiers",
"downloadJson": "Télécharger JSON",
"exportSuccess": "Paramètres exportés avec succès",
"exportError": "Échec de la récupération des paramètres",
"enableOauthServer": "Activer le serveur OAuth",
"enableOauthServerDescription": "Permet à MCPHub d'émettre des jetons OAuth pour les clients externes",
"requireClientSecret": "Exiger un secret client",
"requireClientSecretDescription": "Lorsque activé, les clients confidentiels doivent présenter un client secret (désactivez-le pour les clients PKCE publics)",
"requireState": "Exiger le paramètre state",
"requireStateDescription": "Refuser les demandes d'autorisation qui n'incluent pas le paramètre state",
"accessTokenLifetime": "Durée de vie du jeton d'accès (secondes)",
"accessTokenLifetimeDescription": "Durée pendant laquelle les jetons d'accès émis restent valides",
"accessTokenLifetimePlaceholder": "ex. 3600",
"refreshTokenLifetime": "Durée de vie du jeton d'actualisation (secondes)",
"refreshTokenLifetimeDescription": "Durée pendant laquelle les jetons d'actualisation restent valides",
"refreshTokenLifetimePlaceholder": "ex. 1209600",
"authorizationCodeLifetime": "Durée de vie du code d'autorisation (secondes)",
"authorizationCodeLifetimeDescription": "Temps pendant lequel les codes d'autorisation peuvent être échangés",
"authorizationCodeLifetimePlaceholder": "ex. 300",
"allowedScopes": "Scopes autorisés",
"allowedScopesDescription": "Liste séparée par des virgules des scopes que les utilisateurs peuvent approuver",
"allowedScopesPlaceholder": "ex. read, write",
"enableDynamicRegistration": "Activer l'enregistrement dynamique",
"dynamicRegistrationDescription": "Autoriser les clients conformes RFC 7591 à s'enregistrer via l'endpoint public",
"dynamicRegistrationAllowedGrantTypes": "Types de flux autorisés",
"dynamicRegistrationAllowedGrantTypesDescription": "Liste séparée par des virgules des types de flux disponibles pour les clients enregistrés dynamiquement",
"dynamicRegistrationAllowedGrantTypesPlaceholder": "ex. authorization_code, refresh_token",
"dynamicRegistrationAuth": "Exiger une authentification",
"dynamicRegistrationAuthDescription": "Protège l'endpoint d'enregistrement afin que seules les requêtes authentifiées puissent créer des clients",
"invalidNumberInput": "Veuillez saisir un nombre valide supérieur ou égal à zéro"
"exportError": "Échec de la récupération des paramètres"
},
"dxt": {
"upload": "Télécharger",
@@ -673,13 +642,9 @@
"password": "Mot de passe",
"newPassword": "Nouveau mot de passe",
"confirmPassword": "Confirmer le mot de passe",
"changePassword": "Changer le mot de passe",
"adminRole": "Administrateur",
"admin": "Admin",
"user": "Utilisateur",
"role": "Rôle",
"actions": "Actions",
"addFirst": "Ajoutez votre premier utilisateur",
"permissions": "Permissions",
"adminPermissions": "Accès complet au système",
"userPermissions": "Accès limité",
@@ -786,5 +751,28 @@
"internalError": "Erreur interne",
"internalErrorMessage": "Une erreur inattendue s'est produite lors du traitement du callback OAuth.",
"closeWindow": "Fermer la fenêtre"
},
"install": {
"installServerTitle": "Installer le serveur sur {{name}}",
"installGroupTitle": "Installer le groupe {{name}}",
"configCode": "Code de configuration",
"copyConfig": "Copier la configuration",
"steps": "Étapes d'installation",
"step1Cursor": "Copiez le code de configuration ci-dessus",
"step2Cursor": "Ouvrez Cursor, allez dans Paramètres > Features > MCP",
"step3Cursor": "Cliquez sur 'Add New MCP Server' pour ajouter un nouveau serveur",
"step4Cursor": "Collez la configuration à l'emplacement approprié et redémarrez Cursor",
"step1ClaudeCode": "Copiez le code de configuration ci-dessus",
"step2ClaudeCode": "Ouvrez Claude Code, allez dans Paramètres > Features > MCP",
"step3ClaudeCode": "Cliquez sur 'Add New MCP Server' pour ajouter un nouveau serveur",
"step4ClaudeCode": "Collez la configuration à l'emplacement approprié et redémarrez Claude Code",
"step1ClaudeDesktop": "Copiez le code de configuration ci-dessus",
"step2ClaudeDesktop": "Ouvrez Claude Desktop, allez dans Paramètres > Développeur",
"step3ClaudeDesktop": "Cliquez sur 'Edit Config' pour modifier le fichier de configuration",
"step4ClaudeDesktop": "Collez la configuration dans la section mcpServers et redémarrez Claude Desktop",
"installToCursor": "Ajouter le serveur MCP {{name}} à Cursor",
"installToClaudeCode": "Ajouter le serveur MCP {{name}} à Claude Code",
"installToClaudeDesktop": "Ajouter le serveur MCP {{name}} à Claude Desktop",
"installButton": "Installer"
}
}
}

View File

@@ -1,790 +0,0 @@
{
"app": {
"title": "MCPHub Kontrol Paneli",
"error": "Hata",
"closeButton": "Kapat",
"noServers": "Kullanılabilir MCP sunucusu yok",
"loading": "Yükleniyor...",
"logout": ıkış Yap",
"profile": "Profil",
"changePassword": "Şifre Değiştir",
"toggleSidebar": "Kenar Çubuğunu Aç/Kapat",
"welcomeUser": "Hoş geldin, {{username}}",
"name": "MCPHub"
},
"about": {
"title": "Hakkında",
"versionInfo": "MCPHub Sürümü: {{version}}",
"newVersion": "Yeni sürüm mevcut!",
"currentVersion": "Mevcut sürüm",
"newVersionAvailable": "Yeni sürüm {{version}} mevcut",
"viewOnGitHub": "GitHub'da Görüntüle",
"checkForUpdates": "Güncellemeleri Kontrol Et",
"checking": "Güncellemeler kontrol ediliyor..."
},
"profile": {
"viewProfile": "Profili görüntüle",
"userCenter": "Kullanıcı Merkezi"
},
"sponsor": {
"label": "Sponsor",
"title": "Projeyi Destekle",
"rewardAlt": "Ödül QR Kodu",
"supportMessage": "Bana bir kahve ısmarlayarak MCPHub'ın geliştirilmesini destekleyin!",
"supportButton": "Ko-fi'de Destek Ol"
},
"wechat": {
"label": "WeChat",
"title": "WeChat ile Bağlan",
"qrCodeAlt": "WeChat QR Kodu",
"scanMessage": "WeChat'te bizimle bağlantı kurmak için bu QR kodunu tarayın"
},
"discord": {
"label": "Discord",
"title": "Discord sunucumuza katılın",
"community": "Destek, tartışmalar ve güncellemeler için büyüyen Discord topluluğumuza katılın!"
},
"theme": {
"title": "Tema",
"light": "Açık",
"dark": "Koyu",
"system": "Sistem"
},
"auth": {
"login": "Giriş Yap",
"loginTitle": "MCPHub'a Giriş Yap",
"slogan": "Birleşik MCP sunucu yönetim platformu",
"subtitle": "Model Context Protocol sunucuları için merkezi yönetim platformu. Esnek yönlendirme stratejileri ile birden fazla MCP sunucusunu organize edin, izleyin ve ölçeklendirin.",
"username": "Kullanıcı Adı",
"password": "Şifre",
"loggingIn": "Giriş yapılıyor...",
"emptyFields": "Kullanıcı adı ve şifre boş olamaz",
"loginFailed": "Giriş başarısız, lütfen kullanıcı adınızı ve şifrenizi kontrol edin",
"loginError": "Giriş sırasında bir hata oluştu",
"currentPassword": "Mevcut Şifre",
"newPassword": "Yeni Şifre",
"confirmPassword": "Şifreyi Onayla",
"passwordsNotMatch": "Yeni şifre ve onay eşleşmiyor",
"changePasswordSuccess": "Şifre başarıyla değiştirildi",
"changePasswordError": "Şifre değişikliği başarısız oldu",
"changePassword": "Şifre Değiştir",
"passwordChanged": "Şifre başarıyla değiştirildi",
"passwordChangeError": "Şifre değişikliği başarısız oldu",
"defaultPasswordWarning": "Varsayılan Şifre Güvenlik Uyarısı",
"defaultPasswordMessage": "Varsayılan şifreyi (admin123) kullanıyorsunuz, bu bir güvenlik riski oluşturur. Hesabınızı korumak için lütfen şifrenizi hemen değiştirin.",
"goToSettings": "Ayarlara Git",
"passwordStrengthError": "Şifre güvenlik gereksinimlerini karşılamıyor",
"passwordMinLength": "Şifre en az 8 karakter uzunluğunda olmalıdır",
"passwordRequireLetter": "Şifre en az bir harf içermelidir",
"passwordRequireNumber": "Şifre en az bir rakam içermelidir",
"passwordRequireSpecial": "Şifre en az bir özel karakter içermelidir",
"passwordStrengthHint": "Şifre en az 8 karakter olmalı ve harf, rakam ve özel karakter içermelidir"
},
"server": {
"addServer": "Sunucu Ekle",
"add": "Ekle",
"edit": "Düzenle",
"copy": "Kopyala",
"delete": "Sil",
"confirmDelete": "Bu sunucuyu silmek istediğinizden emin misiniz?",
"deleteWarning": "'{{name}}' sunucusunu silmek, onu ve tüm verilerini kaldıracaktır. Bu işlem geri alınamaz.",
"status": "Durum",
"tools": "Araçlar",
"prompts": "İstekler",
"name": "Sunucu Adı",
"url": "Sunucu URL'si",
"apiKey": "API Anahtarı",
"save": "Kaydet",
"cancel": "İptal",
"invalidConfig": "{{serverName}} için yapılandırma verisi bulunamadı",
"addError": "Sunucu eklenemedi",
"editError": "{{serverName}} sunucusu düzenlenemedi",
"deleteError": "{{serverName}} sunucusu silinemedi",
"updateError": "Sunucu güncellenemedi",
"editTitle": "Sunucuyu Düzenle: {{serverName}}",
"type": "Sunucu Türü",
"typeStdio": "STDIO",
"typeSse": "SSE",
"typeStreamableHttp": "Akış Yapılabilir HTTP",
"typeOpenapi": "OpenAPI",
"command": "Komut",
"arguments": "Argümanlar",
"envVars": "Ortam Değişkenleri",
"headers": "HTTP Başlıkları",
"key": "anahtar",
"value": "değer",
"enabled": "Etkin",
"enable": "Etkinleştir",
"disable": "Devre Dışı Bırak",
"requestOptions": "Bağlantı Yapılandırması",
"timeout": "İstek Zaman Aşımı",
"timeoutDescription": "MCP sunucusuna yapılan istekler için zaman aşımı (ms)",
"maxTotalTimeout": "Maksimum Toplam Zaman Aşımı",
"maxTotalTimeoutDescription": "MCP sunucusuna gönderilen istekler için maksimum toplam zaman aşımı (ms) (İlerleme bildirimleriyle kullanın)",
"resetTimeoutOnProgress": "İlerlemede Zaman Aşımını Sıfırla",
"resetTimeoutOnProgressDescription": "İlerleme bildirimlerinde zaman aşımını sıfırla",
"remove": "Kaldır",
"toggleError": "{{serverName}} sunucusu açılamadı/kapatılamadı",
"alreadyExists": "{{serverName}} sunucusu zaten mevcut",
"invalidData": "Geçersiz sunucu verisi sağlandı",
"notFound": "{{serverName}} sunucusu bulunamadı",
"namePlaceholder": "Sunucu adını girin",
"urlPlaceholder": "Sunucu URL'sini girin",
"commandPlaceholder": "Komutu girin",
"argumentsPlaceholder": "Argümanları girin",
"errorDetails": "Hata Detayları",
"viewErrorDetails": "Hata detaylarını görüntüle",
"copyConfig": "Yapılandırmayı Kopyala",
"confirmVariables": "Değişken Yapılandırmasını Onayla",
"variablesDetected": "Yapılandırmada değişkenler algılandı. Lütfen bu değişkenlerin düzgün yapılandırıldığını onaylayın:",
"detectedVariables": "Algılanan Değişkenler",
"confirmVariablesMessage": "Lütfen bu değişkenlerin çalışma ortamınızda düzgün tanımlandığından emin olun. Sunucu eklemeye devam edilsin mi?",
"confirmAndAdd": "Onayla ve Ekle",
"openapi": {
"inputMode": "Giriş Modu",
"inputModeUrl": "Şartname URL'si",
"inputModeSchema": "JSON Şeması",
"specUrl": "OpenAPI Şartname URL'si",
"schema": "OpenAPI JSON Şeması",
"schemaHelp": "Eksiksiz OpenAPI JSON şemanızı buraya yapıştırın",
"security": "Güvenlik Türü",
"securityNone": "Yok",
"securityApiKey": "API Anahtarı",
"securityHttp": "HTTP Kimlik Doğrulaması",
"securityOAuth2": "OAuth 2.0",
"securityOpenIdConnect": "OpenID Connect",
"apiKeyConfig": "API Anahtarı Yapılandırması",
"apiKeyName": "Başlık/Parametre Adı",
"apiKeyIn": "Konum",
"apiKeyValue": "API Anahtarı Değeri",
"httpAuthConfig": "HTTP Kimlik Doğrulama Yapılandırması",
"httpScheme": "Kimlik Doğrulama Şeması",
"httpCredentials": "Kimlik Bilgileri",
"httpSchemeBasic": "Basit",
"httpSchemeBearer": "Bearer",
"httpSchemeDigest": "Digest",
"oauth2Config": "OAuth 2.0 Yapılandırması",
"oauth2Token": "Erişim Anahtarı",
"openIdConnectConfig": "OpenID Connect Yapılandırması",
"openIdConnectUrl": "URL'yi Keşfet",
"openIdConnectToken": "ID Token",
"apiKeyInHeader": "Başlık",
"apiKeyInQuery": "Sorgu",
"apiKeyInCookie": "Çerez",
"passthroughHeaders": "Geçiş Başlıkları",
"passthroughHeadersHelp": "Araç çağrısı isteklerinden yukarı akış OpenAPI uç noktalarına geçirilecek başlık adlarının virgülle ayrılmış listesi (örn. Authorization, X-API-Key)"
},
"oauth": {
"sectionTitle": "OAuth Yapılandırması",
"sectionDescription": "OAuth korumalı sunucular için istemci kimlik bilgilerini yapılandırın (isteğe bağlı).",
"clientId": "İstemci ID",
"clientSecret": "İstemci Gizli Anahtarı",
"authorizationEndpoint": "Yetkilendirme Uç Noktası",
"tokenEndpoint": "Token Uç Noktası",
"scopes": "Kapsamlar",
"scopesPlaceholder": "scope1 scope2",
"resource": "Kaynak / Hedef Kitle",
"accessToken": "Erişim Tokeni",
"refreshToken": "Yenileme Tokeni"
}
},
"status": {
"online": "Çevrimiçi",
"offline": "Çevrimdışı",
"connecting": "Bağlanıyor",
"oauthRequired": "OAuth Gerekli",
"clickToAuthorize": "OAuth ile yetkilendirmek için tıklayın",
"oauthWindowOpened": "OAuth yetkilendirme penceresi açıldı. Lütfen yetkilendirmeyi tamamlayın."
},
"errors": {
"general": "Bir şeyler yanlış gitti",
"network": "Ağ bağlantı hatası. Lütfen internet bağlantınızı kontrol edin",
"serverConnection": "Sunucuya bağlanılamıyor. Lütfen sunucunun çalışıp çalışmadığını kontrol edin",
"serverAdd": "Sunucu eklenemedi. Lütfen sunucu durumunu kontrol edin",
"serverUpdate": "{{serverName}} sunucusu düzenlenemedi. Lütfen sunucu durumunu kontrol edin",
"serverFetch": "Sunucu verileri alınamadı. Lütfen daha sonra tekrar deneyin",
"initialStartup": "Sunucu başlatılıyor olabilir. İlk başlatmada bu işlem biraz zaman alabileceğinden lütfen bekleyin...",
"serverInstall": "Sunucu yüklenemedi",
"failedToFetchSettings": "Ayarlar getirilemedi",
"failedToUpdateRouteConfig": "Route yapılandırması güncellenemedi",
"failedToUpdateSmartRoutingConfig": "Akıllı yönlendirme yapılandırması güncellenemedi"
},
"common": {
"processing": "İşleniyor...",
"save": "Kaydet",
"cancel": "İptal",
"back": "Geri",
"refresh": "Yenile",
"create": "Oluştur",
"creating": "Oluşturuluyor...",
"update": "Güncelle",
"updating": "Güncelleniyor...",
"submitting": "Gönderiliyor...",
"delete": "Sil",
"remove": "Kaldır",
"copy": "Kopyala",
"copyId": "ID'yi Kopyala",
"copyUrl": "URL'yi Kopyala",
"copyJson": "JSON'u Kopyala",
"copySuccess": "Panoya kopyalandı",
"copyFailed": "Kopyalama başarısız",
"copied": "Kopyalandı",
"close": "Kapat",
"confirm": "Onayla",
"language": "Dil",
"true": "Doğru",
"false": "Yanlış",
"dismiss": "Anımsatma",
"github": "GitHub",
"wechat": "WeChat",
"discord": "Discord",
"required": "Gerekli",
"secret": "Gizli",
"default": "Varsayılan",
"value": "Değer",
"type": "Tür",
"repeated": "Tekrarlanan",
"valueHint": "Değer İpucu",
"choices": "Seçenekler"
},
"nav": {
"dashboard": "Kontrol Paneli",
"servers": "Sunucular",
"groups": "Gruplar",
"users": "Kullanıcılar",
"settings": "Ayarlar",
"changePassword": "Şifre Değiştir",
"market": "Market",
"cloud": "Bulut Market",
"logs": "Günlükler"
},
"pages": {
"dashboard": {
"title": "Kontrol Paneli",
"totalServers": "Toplam",
"onlineServers": "Çevrimiçi",
"offlineServers": "Çevrimdışı",
"connectingServers": "Bağlanıyor",
"recentServers": "Son Sunucular"
},
"servers": {
"title": "Sunucu Yönetimi"
},
"groups": {
"title": "Grup Yönetimi"
},
"users": {
"title": "Kullanıcı Yönetimi"
},
"settings": {
"title": "Ayarlar",
"language": "Dil",
"account": "Hesap Ayarları",
"password": "Şifre Değiştir",
"appearance": "Görünüm",
"routeConfig": "Güvenlik",
"installConfig": "Kurulum",
"smartRouting": "Akıllı Yönlendirme",
"oauthServer": "OAuth Sunucusu"
},
"market": {
"title": "Market Yönetimi - Yerel ve Bulut Marketler"
},
"logs": {
"title": "Sistem Günlükleri"
}
},
"logs": {
"filters": "Filtreler",
"search": "Günlüklerde ara...",
"autoScroll": "Otomatik kaydır",
"clearLogs": "Günlükleri temizle",
"loading": "Günlükler yükleniyor...",
"noLogs": "Kullanılabilir günlük yok.",
"noMatch": "Mevcut filtrelerle eşleşen günlük yok.",
"mainProcess": "Ana İşlem",
"childProcess": "Alt İşlem",
"main": "Ana",
"child": "Alt"
},
"groups": {
"add": "Ekle",
"addNew": "Yeni Grup Ekle",
"edit": "Grubu Düzenle",
"delete": "Sil",
"confirmDelete": "Bu grubu silmek istediğinizden emin misiniz?",
"deleteWarning": "'{{name}}' grubunu silmek, onu ve tüm sunucu ilişkilerini kaldıracaktır. Bu işlem geri alınamaz.",
"name": "Grup Adı",
"namePlaceholder": "Grup adını girin",
"nameRequired": "Grup adı gereklidir",
"description": "Açıklama",
"descriptionPlaceholder": "Grup açıklamasını girin (isteğe bağlı)",
"createError": "Grup oluşturulamadı",
"updateError": "Grup güncellenemedi",
"deleteError": "Grup silinemedi",
"serverAddError": "Sunucu gruba eklenemedi",
"serverRemoveError": "Sunucu gruptan kaldırılamadı",
"addServer": "Gruba Sunucu Ekle",
"selectServer": "Eklenecek bir sunucu seçin",
"servers": "Gruptaki Sunucular",
"remove": "Kaldır",
"noGroups": "Kullanılabilir grup yok. Başlamak için yeni bir grup oluşturun.",
"noServers": "Bu grupta sunucu yok.",
"noServerOptions": "Kullanılabilir sunucu yok",
"serverCount": "{{count}} Sunucu",
"toolSelection": "Araç Seçimi",
"toolsSelected": "Seçildi",
"allTools": "Tümü",
"selectedTools": "Seçili araçlar",
"selectAll": "Tümünü Seç",
"selectNone": "Hiçbirini Seçme",
"configureTools": "Araçları Yapılandır"
},
"market": {
"title": "Yerel Kurulum",
"official": "Resmi",
"by": "Geliştirici",
"unknown": "Bilinmeyen",
"tools": "araçlar",
"search": "Ara",
"searchPlaceholder": "Sunucuları isme, kategoriye veya etiketlere göre ara",
"clearFilters": "Temizle",
"clearCategoryFilter": "",
"clearTagFilter": "",
"categories": "Kategoriler",
"tags": "Etiketler",
"showTags": "Etiketleri göster",
"hideTags": "Etiketleri gizle",
"moreTags": "",
"noServers": "Aramanızla eşleşen sunucu bulunamadı",
"backToList": "Listeye dön",
"install": "Yükle",
"installing": "Yükleniyor...",
"installed": "Yüklendi",
"installServer": "Sunucu Yükle: {{name}}",
"installSuccess": "{{serverName}} sunucusu başarıyla yüklendi",
"author": "Yazar",
"license": "Lisans",
"repository": "Depo",
"examples": "Örnekler",
"arguments": "Argümanlar",
"argumentName": "Ad",
"description": "Açıklama",
"required": "Gerekli",
"example": "Örnek",
"viewSchema": "Şemayı görüntüle",
"fetchError": "Market sunucuları getirilirken hata",
"serverNotFound": "Sunucu bulunamadı",
"searchError": "Sunucular aranırken hata",
"filterError": "Sunucular kategoriye göre filtrelenirken hata",
"tagFilterError": "Sunucular etikete göre filtrelenirken hata",
"noInstallationMethod": "Bu sunucu için kullanılabilir kurulum yöntemi yok",
"showing": "{{total}} sunucudan {{from}}-{{to}} arası gösteriliyor",
"perPage": "Sayfa başına",
"confirmVariablesMessage": "Lütfen bu değişkenlerin çalışma ortamınızda düzgün tanımlandığından emin olun. Sunucu yüklemeye devam edilsin mi?",
"confirmAndInstall": "Onayla ve Yükle"
},
"oauthServer": {
"authorizeTitle": "Uygulamayı Yetkilendir",
"authorizeSubtitle": "Bu uygulamanın MCPHub hesabınıza erişmesine izin verin.",
"buttons": {
"approve": "Erişime izin ver",
"deny": "Reddet",
"approveSubtitle": "Bu uygulamaya güveniyorsanız izin vermeniz önerilir.",
"denySubtitle": "İstediğiniz zaman daha sonra erişim verebilirsiniz."
}
},
"cloud": {
"title": "Bulut Desteği",
"subtitle": "MCPRouter tarafından desteklenmektedir",
"by": "Geliştirici",
"server": "Sunucu",
"config": "Yapılandırma",
"created": "Oluşturuldu",
"updated": "Güncellendi",
"available": "Kullanılabilir",
"description": "Açıklama",
"details": "Detaylar",
"tools": "Araçlar",
"tool": "araç",
"toolsAvailable": "{{count}} araç mevcut",
"loadingTools": "Araçlar yükleniyor...",
"noTools": "Bu sunucu için kullanılabilir araç yok",
"noDescription": "Kullanılabilir açıklama yok",
"viewDetails": "Detayları Görüntüle",
"parameters": "Parametreler",
"result": "Sonuç",
"error": "Hata",
"callTool": "Çalıştır",
"calling": "Çalıştırılıyor...",
"toolCallSuccess": "{{toolName}} aracı başarıyla çalıştırıldı",
"toolCallError": "{{toolName}} aracı çalıştırılamadı: {{error}}",
"viewSchema": "Şemayı Görüntüle",
"backToList": "Bulut Market'e Dön",
"search": "Ara",
"searchPlaceholder": "Bulut sunucularını isme, başlığa veya geliştiriciye göre ara",
"clearFilters": "Filtreleri Temizle",
"clearCategoryFilter": "Temizle",
"clearTagFilter": "Temizle",
"categories": "Kategoriler",
"tags": "Etiketler",
"noCategories": "Kategori bulunamadı",
"noTags": "Etiket bulunamadı",
"noServers": "Bulut sunucusu bulunamadı",
"fetchError": "Bulut sunucuları getirilirken hata",
"serverNotFound": "Bulut sunucusu bulunamadı",
"searchError": "Bulut sunucuları aranırken hata",
"filterError": "Bulut sunucuları kategoriye göre filtrelenirken hata",
"tagFilterError": "Bulut sunucuları etikete göre filtrelenirken hata",
"showing": "{{total}} bulut sunucusundan {{from}}-{{to}} arası gösteriliyor",
"perPage": "Sayfa başına",
"apiKeyNotConfigured": "MCPRouter API anahtarı yapılandırılmamış",
"apiKeyNotConfiguredDescription": "Bulut sunucularını kullanmak için MCPRouter API anahtarınızı yapılandırmanız gerekir.",
"getApiKey": "API Anahtarı Al",
"configureInSettings": "Ayarlarda Yapılandır",
"installServer": "{{name}} Yükle",
"installSuccess": "{{name}} sunucusu başarıyla yüklendi",
"installError": "Sunucu yüklenemedi: {{error}}"
},
"registry": {
"title": "Kayıt",
"official": "Resmi",
"latest": "En Son",
"description": "Açıklama",
"website": "Web Sitesi",
"repository": "Depo",
"packages": "Paketler",
"package": "paket",
"remotes": "Uzak Sunucular",
"remote": "uzak sunucu",
"published": "Yayınlandı",
"updated": "Güncellendi",
"install": "Yükle",
"installing": "Yükleniyor...",
"installed": "Yüklendi",
"installServer": "{{name}} Yükle",
"installSuccess": "{{name}} sunucusu başarıyla yüklendi",
"installError": "Sunucu yüklenemedi: {{error}}",
"noDescription": "Kullanılabilir açıklama yok",
"viewDetails": "Detayları Görüntüle",
"backToList": "Kayda Dön",
"search": "Ara",
"searchPlaceholder": "Kayıt sunucularını isme göre ara",
"clearFilters": "Temizle",
"noServers": "Kayıt sunucusu bulunamadı",
"fetchError": "Kayıt sunucuları getirilirken hata",
"serverNotFound": "Kayıt sunucusu bulunamadı",
"showing": "{{total}} kayıt sunucusundan {{from}}-{{to}} arası gösteriliyor",
"perPage": "Sayfa başına",
"environmentVariables": "Ortam Değişkenleri",
"packageArguments": "Paket Argümanları",
"runtimeArguments": "Çalışma Zamanı Argümanları",
"headers": "Başlıklar"
},
"tool": {
"run": "Çalıştır",
"running": "Çalıştırılıyor...",
"runTool": "Aracı Çalıştır",
"cancel": "İptal",
"noDescription": "Kullanılabilir açıklama yok",
"inputSchema": "Giriş Şeması:",
"runToolWithName": "Aracı Çalıştır: {{name}}",
"execution": "Araç Çalıştırma",
"successful": "Başarılı",
"failed": "Başarısız",
"result": "Sonuç:",
"error": "Hata",
"errorDetails": "Hata Detayları:",
"noContent": "Araç başarıyla çalıştırıldı ancak içerik döndürmedi.",
"unknownError": "Bilinmeyen hata oluştu",
"jsonResponse": "JSON Yanıtı:",
"toolResult": "Araç sonucu",
"noParameters": "Bu araç herhangi bir parametre gerektirmez.",
"selectOption": "Bir seçenek seçin",
"enterValue": "{{type}} değeri girin",
"enabled": "Etkin",
"enableSuccess": "{{name}} aracı başarıyla etkinleştirildi",
"disableSuccess": "{{name}} aracı başarıyla devre dışı bırakıldı",
"toggleFailed": "Araç durumu değiştirilemedi",
"parameters": "Araç Parametreleri",
"formMode": "Form Modu",
"jsonMode": "JSON Modu",
"jsonConfiguration": "JSON Yapılandırması",
"invalidJsonFormat": "Geçersiz JSON formatı",
"fixJsonBeforeSwitching": "Form moduna geçmeden önce lütfen JSON formatını düzeltin",
"item": "Öğe {{index}}",
"addItem": "{{key}} öğesi ekle",
"enterKey": "{{key}} girin"
},
"prompt": {
"run": "Getir",
"running": "Getiriliyor...",
"result": "İstek Sonucu",
"error": "İstek Hatası",
"execution": "İstek Çalıştırma",
"successful": "Başarılı",
"failed": "Başarısız",
"errorDetails": "Hata Detayları:",
"noContent": "İstek başarıyla çalıştırıldı ancak içerik döndürmedi.",
"unknownError": "Bilinmeyen hata oluştu",
"jsonResponse": "JSON Yanıtı:",
"description": "Açıklama",
"messages": "Mesajlar",
"noDescription": "Kullanılabilir açıklama yok",
"runPromptWithName": "İsteği Getir: {{name}}"
},
"settings": {
"enableGlobalRoute": "Global Yönlendirmeyi Etkinleştir",
"enableGlobalRouteDescription": "Grup ID'si belirtmeden /sse uç noktasına bağlantıya izin ver",
"enableGroupNameRoute": "Grup Adı Yönlendirmeyi Etkinleştir",
"enableGroupNameRouteDescription": "Sadece grup ID'leri yerine grup adları kullanarak /sse uç noktasına bağlantıya izin ver",
"enableBearerAuth": "Bearer Kimlik Doğrulamasını Etkinleştir",
"enableBearerAuthDescription": "MCP istekleri için bearer token kimlik doğrulaması gerektir",
"bearerAuthKey": "Bearer Kimlik Doğrulama Anahtarı",
"bearerAuthKeyDescription": "Bearer token'da gerekli olacak kimlik doğrulama anahtarı",
"bearerAuthKeyPlaceholder": "Bearer kimlik doğrulama anahtarını girin",
"skipAuth": "Kimlik Doğrulamayı Atla",
"skipAuthDescription": "Arayüz ve API erişimi için giriş gereksinimini atla (Güvenlik için VARSAYILAN KAPALI)",
"pythonIndexUrl": "Python Paket Deposu URL'si",
"pythonIndexUrlDescription": "Python paket kurulumu için UV_DEFAULT_INDEX ortam değişkenini ayarla",
"pythonIndexUrlPlaceholder": "örn. https://pypi.org/simple",
"npmRegistry": "NPM Kayıt URL'si",
"npmRegistryDescription": "NPM paket kurulumu için npm_config_registry ortam değişkenini ayarla",
"npmRegistryPlaceholder": "örn. https://registry.npmjs.org/",
"baseUrl": "Temel URL",
"baseUrlDescription": "MCP istekleri için temel URL",
"baseUrlPlaceholder": "örn. http://localhost:3000",
"installConfig": "Kurulum",
"systemConfigUpdated": "Sistem yapılandırması başarıyla güncellendi",
"enableSmartRouting": "Akıllı Yönlendirmeyi Etkinleştir",
"enableSmartRoutingDescription": "Girdiye göre en uygun aracı aramak için akıllı yönlendirme özelliğini etkinleştir ($smart grup adını kullanarak)",
"dbUrl": "PostgreSQL URL'si (pgvector desteği gerektirir)",
"dbUrlPlaceholder": "örn. postgresql://kullanıcı:şifre@localhost:5432/veritabanıadı",
"openaiApiBaseUrl": "OpenAI API Temel URL'si",
"openaiApiBaseUrlPlaceholder": "https://api.openai.com/v1",
"openaiApiKey": "OpenAI API Anahtarı",
"openaiApiKeyPlaceholder": "OpenAI API anahtarını girin",
"openaiApiEmbeddingModel": "OpenAI Entegrasyon Modeli",
"openaiApiEmbeddingModelPlaceholder": "text-embedding-3-small",
"smartRoutingConfigUpdated": "Akıllı yönlendirme yapılandırması başarıyla güncellendi",
"smartRoutingRequiredFields": "Akıllı yönlendirmeyi etkinleştirmek için Veritabanı URL'si ve OpenAI API Anahtarı gereklidir",
"smartRoutingValidationError": "Akıllı Yönlendirmeyi etkinleştirmeden önce lütfen gerekli alanları doldurun: {{fields}}",
"mcpRouterConfig": "Bulut Market",
"mcpRouterApiKey": "MCPRouter API Anahtarı",
"mcpRouterApiKeyDescription": "MCPRouter bulut market hizmetlerine erişim için API anahtarı",
"mcpRouterApiKeyPlaceholder": "MCPRouter API anahtarını girin",
"mcpRouterReferer": "Yönlendiren",
"mcpRouterRefererDescription": "MCPRouter API istekleri için Referer başlığı",
"mcpRouterRefererPlaceholder": "https://www.mcphubx.com",
"mcpRouterTitle": "Başlık",
"mcpRouterTitleDescription": "MCPRouter API istekleri için Başlık başlığı",
"mcpRouterTitlePlaceholder": "MCPHub",
"mcpRouterBaseUrl": "Temel URL",
"mcpRouterBaseUrlDescription": "MCPRouter API için temel URL",
"mcpRouterBaseUrlPlaceholder": "https://api.mcprouter.to/v1",
"systemSettings": "Sistem Ayarları",
"nameSeparatorLabel": "İsim Ayırıcı",
"nameSeparatorDescription": "Sunucu adı ile araç/istek adını ayırmak için kullanılan karakter (varsayılan: -)",
"enableSessionRebuild": "Sunucu Oturum Yeniden Oluşturmayı Etkinleştir",
"enableSessionRebuildDescription": "Etkinleştirildiğinde, daha iyi oturum yönetimi deneyimi için geliştirilmiş sunucu oturum yeniden oluşturma kodunu uygular",
"restartRequired": "Yapılandırma kaydedildi. Tüm hizmetlerin yeni ayarları doğru şekilde yüklemesini sağlamak için uygulamayı yeniden başlatmanız önerilir.",
"exportMcpSettings": "Ayarları Dışa Aktar",
"mcpSettingsJson": "MCP Ayarları JSON",
"mcpSettingsJsonDescription": "Yedekleme veya diğer araçlara taşıma için mevcut mcp_settings.json yapılandırmanızı görüntüleyin, kopyalayın veya indirin",
"copyToClipboard": "Panoya Kopyala",
"downloadJson": "JSON Olarak İndir",
"exportSuccess": "Ayarlar başarıyla dışa aktarıldı",
"exportError": "Ayarlar getirilemedi",
"enableOauthServer": "OAuth Sunucusunu Etkinleştir",
"enableOauthServerDescription": "MCPHub'ın harici istemciler için OAuth jetonları vermesine izin ver",
"requireClientSecret": "İstemci Sırrı Zorunlu",
"requireClientSecretDescription": "Etkin olduğunda gizli istemciler client secret sunmalıdır (yalnızca PKCE kullanan istemciler için kapatabilirsiniz)",
"requireState": "State parametresi zorunlu",
"requireStateDescription": "State parametresi olmayan yetkilendirme isteklerini reddeder",
"accessTokenLifetime": "Erişim jetonu süresi (saniye)",
"accessTokenLifetimeDescription": "Verilen erişim jetonlarının geçerli kalacağı süre",
"accessTokenLifetimePlaceholder": "örn. 3600",
"refreshTokenLifetime": "Yenileme jetonu süresi (saniye)",
"refreshTokenLifetimeDescription": "Yenileme jetonlarının geçerli kalacağı süre",
"refreshTokenLifetimePlaceholder": "örn. 1209600",
"authorizationCodeLifetime": "Yetkilendirme kodu süresi (saniye)",
"authorizationCodeLifetimeDescription": "Yetkilendirme kodlarının takas edilebileceği süre",
"authorizationCodeLifetimePlaceholder": "örn. 300",
"allowedScopes": "İzin verilen kapsamlar",
"allowedScopesDescription": "Kullanıcıların onaylayabileceği kapsamların virgülle ayrılmış listesi",
"allowedScopesPlaceholder": "örn. read, write",
"enableDynamicRegistration": "Dinamik istemci kaydını etkinleştir",
"dynamicRegistrationDescription": "RFC 7591 uyumlu istemcilerin herkese açık uç nokta üzerinden kayıt olmasına izin ver",
"dynamicRegistrationAllowedGrantTypes": "İzin verilen grant türleri",
"dynamicRegistrationAllowedGrantTypesDescription": "Dinamik olarak kaydedilen istemciler için kullanılabilecek grant türlerinin virgülle ayrılmış listesi",
"dynamicRegistrationAllowedGrantTypesPlaceholder": "örn. authorization_code, refresh_token",
"dynamicRegistrationAuth": "Kayıt için kimlik doğrulaması iste",
"dynamicRegistrationAuthDescription": "Kayıt uç noktasını korur, yalnızca kimliği doğrulanmış istekler yeni istemci oluşturabilir",
"invalidNumberInput": "Lütfen sıfırdan küçük olmayan geçerli bir sayı girin"
},
"dxt": {
"upload": "Yükle",
"uploadTitle": "DXT Uzantısı Yükle",
"dropFileHere": ".dxt dosyanızı buraya bırakın",
"orClickToSelect": "veya bilgisayarınızdan seçmek için tıklayın",
"invalidFileType": "Lütfen geçerli bir .dxt dosyası seçin",
"noFileSelected": "Lütfen yüklemek için bir .dxt dosyası seçin",
"uploading": "Yükleniyor...",
"uploadFailed": "DXT dosyası yüklenemedi",
"installServer": "DXT'den MCP Sunucusu Yükle",
"extensionInfo": "Uzantı Bilgisi",
"name": "Ad",
"version": "Sürüm",
"description": "Açıklama",
"author": "Geliştirici",
"tools": "Araçlar",
"serverName": "Sunucu Adı",
"serverNamePlaceholder": "Bu sunucu için bir ad girin",
"install": "Yükle",
"installing": "Yükleniyor...",
"installFailed": "DXT'den sunucu yüklenemedi",
"serverExistsTitle": "Sunucu Zaten Mevcut",
"serverExistsConfirm": "'{{serverName}}' sunucusu zaten mevcut. Yeni sürümle geçersiz kılmak istiyor musunuz?",
"override": "Geçersiz Kıl"
},
"jsonImport": {
"button": "İçe Aktar",
"title": "JSON'dan Sunucuları İçe Aktar",
"inputLabel": "Sunucu Yapılandırma JSON",
"inputHelp": "Sunucu yapılandırma JSON'unuzu yapıştırın. STDIO, SSE ve HTTP (streamable-http) sunucu türlerini destekler.",
"preview": "Önizle",
"previewTitle": "İçe Aktarılacak Sunucuları Önizle",
"import": "İçe Aktar",
"importing": "İçe aktarılıyor...",
"invalidFormat": "Geçersiz JSON formatı. JSON bir 'mcpServers' nesnesi içermelidir.",
"parseError": "JSON ayrıştırılamadı. Lütfen formatı kontrol edip tekrar deneyin.",
"addFailed": "Sunucu eklenemedi",
"importFailed": "Sunucular içe aktarılamadı",
"partialSuccess": "{{total}} sunucudan {{count}} tanesi başarıyla içe aktarıldı. Bazı sunucular başarısız oldu:"
},
"users": {
"add": "Kullanıcı Ekle",
"addNew": "Yeni Kullanıcı Ekle",
"edit": "Kullanıcıyı Düzenle",
"delete": "Kullanıcıyı Sil",
"create": "Kullanıcı Oluştur",
"update": "Kullanıcıyı Güncelle",
"username": "Kullanıcı Adı",
"password": "Şifre",
"newPassword": "Yeni Şifre",
"confirmPassword": "Şifreyi Onayla",
"changePassword": "Şifre Değiştir",
"adminRole": "Yönetici",
"admin": "Yönetici",
"user": "Kullanıcı",
"role": "Rol",
"actions": "Eylemler",
"addFirst": "İlk kullanıcınızı ekleyin",
"permissions": "İzinler",
"adminPermissions": "Tam sistem erişimi",
"userPermissions": "Sınırlı erişim",
"currentUser": "Siz",
"noUsers": "Kullanıcı bulunamadı",
"adminRequired": "Kullanıcıları yönetmek için yönetici erişimi gereklidir",
"usernameRequired": "Kullanıcı adı gereklidir",
"passwordRequired": "Şifre gereklidir",
"passwordTooShort": "Şifre en az 6 karakter uzunluğunda olmalıdır",
"passwordMismatch": "Şifreler eşleşmiyor",
"usernamePlaceholder": "Kullanıcı adını girin",
"passwordPlaceholder": "Şifreyi girin",
"newPasswordPlaceholder": "Mevcut şifreyi korumak için boş bırakın",
"confirmPasswordPlaceholder": "Yeni şifreyi onaylayın",
"createError": "Kullanıcı oluşturulamadı",
"updateError": "Kullanıcı güncellenemedi",
"deleteError": "Kullanıcı silinemedi",
"statsError": "Kullanıcı istatistikleri getirilemedi",
"deleteConfirmation": "'{{username}}' kullanıcısını silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
"confirmDelete": "Kullanıcıyı Sil",
"deleteWarning": "'{{username}}' kullanıcısını silmek istediğinizden emin misiniz? Bu işlem geri alınamaz."
},
"api": {
"errors": {
"readonly": "Demo ortamı için salt okunur",
"invalid_credentials": "Geçersiz kullanıcı adı veya şifre",
"serverNameRequired": "Sunucu adı gereklidir",
"serverConfigRequired": "Sunucu yapılandırması gereklidir",
"serverConfigInvalid": "Sunucu yapılandırması bir URL, OpenAPI şartname URL'si veya şema, ya da argümanlı komut içermelidir",
"serverTypeInvalid": "Sunucu türü şunlardan biri olmalıdır: stdio, sse, streamable-http, openapi",
"urlRequiredForType": "{{type}} sunucu türü için URL gereklidir",
"openapiSpecRequired": "OpenAPI sunucu türü için OpenAPI şartname URL'si veya şema gereklidir",
"headersInvalidFormat": "Başlıklar bir nesne olmalıdır",
"headersNotSupportedForStdio": "Başlıklar stdio sunucu türü için desteklenmez",
"serverNotFound": "Sunucu bulunamadı",
"failedToRemoveServer": "Sunucu bulunamadı veya kaldırılamadı",
"internalServerError": "Dahili sunucu hatası",
"failedToGetServers": "Sunucu bilgileri alınamadı",
"failedToGetServerSettings": "Sunucu ayarları alınamadı",
"failedToGetServerConfig": "Sunucu yapılandırması alınamadı",
"failedToSaveSettings": "Ayarlar kaydedilemedi",
"toolNameRequired": "Sunucu adı ve araç adı gereklidir",
"descriptionMustBeString": "Açıklama bir string olmalıdır",
"groupIdRequired": "Grup ID gereklidir",
"groupNameRequired": "Grup adı gereklidir",
"groupNotFound": "Grup bulunamadı",
"groupIdAndServerNameRequired": "Grup ID ve sunucu adı gereklidir",
"groupOrServerNotFound": "Grup veya sunucu bulunamadı",
"toolsMustBeAllOrArray": "Araçlar \"all\" veya bir string dizisi olmalıdır",
"serverNameAndToolNameRequired": "Sunucu adı ve araç adı gereklidir",
"usernameRequired": "Kullanıcı adı gereklidir",
"userNotFound": "Kullanıcı bulunamadı",
"failedToGetUsers": "Kullanıcı bilgileri alınamadı",
"failedToGetUserInfo": "Kullanıcı bilgisi alınamadı",
"failedToGetUserStats": "Kullanıcı istatistikleri alınamadı",
"marketServerNameRequired": "Sunucu adı gereklidir",
"marketServerNotFound": "Market sunucusu bulunamadı",
"failedToGetMarketServers": "Market sunucuları bilgisi alınamadı",
"failedToGetMarketServer": "Market sunucusu bilgisi alınamadı",
"failedToGetMarketCategories": "Market kategorileri alınamadı",
"failedToGetMarketTags": "Market etiketleri alınamadı",
"failedToSearchMarketServers": "Market sunucuları aranamadı",
"failedToFilterMarketServers": "Market sunucuları filtrelenemedi",
"failedToProcessDxtFile": "DXT dosyası işlenemedi"
},
"success": {
"serverCreated": "Sunucu başarıyla oluşturuldu",
"serverUpdated": "Sunucu başarıyla güncellendi",
"serverRemoved": "Sunucu başarıyla kaldırıldı",
"serverToggled": "Sunucu durumu başarıyla değiştirildi",
"toolToggled": "{{name}} aracı başarıyla {{action}}",
"toolDescriptionUpdated": "{{name}} aracının açıklaması başarıyla güncellendi",
"systemConfigUpdated": "Sistem yapılandırması başarıyla güncellendi",
"groupCreated": "Grup başarıyla oluşturuldu",
"groupUpdated": "Grup başarıyla güncellendi",
"groupDeleted": "Grup başarıyla silindi",
"serverAddedToGroup": "Sunucu başarıyla gruba eklendi",
"serverRemovedFromGroup": "Sunucu başarıyla gruptan kaldırıldı",
"serverToolsUpdated": "Sunucu araçları başarıyla güncellendi"
}
},
"oauthCallback": {
"authorizationFailed": "Yetkilendirme Başarısız",
"authorizationFailedError": "Hata",
"authorizationFailedDetails": "Detaylar",
"invalidRequest": "Geçersiz İstek",
"missingStateParameter": "Gerekli OAuth durum parametresi eksik.",
"missingCodeParameter": "Gerekli yetkilendirme kodu parametresi eksik.",
"serverNotFound": "Sunucu Bulunamadı",
"serverNotFoundMessage": "Bu yetkilendirme isteğiyle ilişkili sunucu bulunamadı.",
"sessionExpiredMessage": "Yetkilendirme oturumunun süresi dolmuş olabilir. Lütfen tekrar yetkilendirmeyi deneyin.",
"authorizationSuccessful": "Yetkilendirme Başarılı",
"server": "Sunucu",
"status": "Durum",
"connected": "Bağlandı",
"successMessage": "Sunucu başarıyla yetkilendirildi ve bağlandı.",
"autoCloseMessage": "Bu pencere 3 saniye içinde otomatik olarak kapanacak...",
"closeNow": "Şimdi Kapat",
"connectionError": "Bağlantı Hatası",
"connectionErrorMessage": "Yetkilendirme başarılı oldu, ancak sunucuya bağlanılamadı.",
"reconnectMessage": "Lütfen kontrol panelinden yeniden bağlanmayı deneyin.",
"configurationError": "Yapılandırma Hatası",
"configurationErrorMessage": "Sunucu aktarımı OAuth finishAuth() desteklemiyor. Lütfen sunucunun streamable-http aktarımıyla yapılandırıldığından emin olun.",
"internalError": "İçsel Hata",
"internalErrorMessage": "OAuth geri araması işlenirken beklenmeyen bir hata oluştu.",
"closeWindow": "Pencereyi Kapat"
}
}

View File

@@ -269,7 +269,15 @@
"recentServers": "最近的服务器"
},
"servers": {
"title": "服务器管理"
"title": "服务器管理",
"semanticSearch": "智能搜索工具...",
"semanticSearchPlaceholder": "描述您需要的功能,例如:地图、天气、文件处理",
"similarityThreshold": "相似度阈值",
"similarityThresholdHelp": "较高值返回更精确结果,较低值返回更广泛匹配",
"searchButton": "搜索",
"clearSearch": "清除搜索",
"searchResults": "找到 {{count}} 个匹配的服务器",
"noSearchResults": "未找到匹配的服务器"
},
"settings": {
"title": "设置",
@@ -279,8 +287,7 @@
"appearance": "外观",
"routeConfig": "安全配置",
"installConfig": "安装",
"smartRouting": "智能路由",
"oauthServer": "OAuth 服务器"
"smartRouting": "智能路由"
},
"groups": {
"title": "分组管理"
@@ -385,16 +392,6 @@
"confirmVariablesMessage": "请确保这些变量在运行环境中已正确定义。是否继续安装服务器?",
"confirmAndInstall": "确认并安装"
},
"oauthServer": {
"authorizeTitle": "授权应用",
"authorizeSubtitle": "允许此应用访问您的 MCPHub 账号。",
"buttons": {
"approve": "允许访问",
"deny": "拒绝",
"approveSubtitle": "如果您信任此应用,建议选择允许。",
"denySubtitle": "您可以在之后随时再次授权。"
}
},
"cloud": {
"title": "云端支持",
"subtitle": "由 MCPRouter 提供支持",
@@ -587,8 +584,6 @@
"systemSettings": "系统设置",
"nameSeparatorLabel": "名称分隔符",
"nameSeparatorDescription": "用于分隔服务器名称和工具/提示名称(默认:-",
"enableSessionRebuild": "启用服务端会话重建",
"enableSessionRebuildDescription": "开启后会应用服务端会话重建的改进代码,提供更好的会话管理体验",
"restartRequired": "配置已保存。为确保所有服务正确加载新设置,建议重启应用。",
"exportMcpSettings": "导出配置",
"mcpSettingsJson": "MCP 配置 JSON",
@@ -596,33 +591,7 @@
"copyToClipboard": "复制到剪贴板",
"downloadJson": "下载 JSON",
"exportSuccess": "配置导出成功",
"exportError": "获取配置失败",
"enableOauthServer": "启用 OAuth 服务器",
"enableOauthServerDescription": "允许 MCPHub 作为 OAuth 2.0 授权服务器向外部客户端签发令牌",
"requireClientSecret": "需要客户端密钥",
"requireClientSecretDescription": "开启后,保密客户端必须携带 client secret如需仅使用 PKCE 的公共客户端可关闭)",
"requireState": "要求 state 参数",
"requireStateDescription": "拒绝未携带 state 参数的授权请求",
"accessTokenLifetime": "访问令牌有效期(秒)",
"accessTokenLifetimeDescription": "控制访问令牌可使用的时长",
"accessTokenLifetimePlaceholder": "例如3600",
"refreshTokenLifetime": "刷新令牌有效期(秒)",
"refreshTokenLifetimeDescription": "控制刷新令牌的过期时间",
"refreshTokenLifetimePlaceholder": "例如1209600",
"authorizationCodeLifetime": "授权码有效期(秒)",
"authorizationCodeLifetimeDescription": "授权码在被兑换前可保持有效的时间",
"authorizationCodeLifetimePlaceholder": "例如300",
"allowedScopes": "允许的作用域",
"allowedScopesDescription": "使用逗号分隔的作用域列表,在授权时展示给用户",
"allowedScopesPlaceholder": "例如read, write",
"enableDynamicRegistration": "启用动态客户端注册",
"dynamicRegistrationDescription": "允许遵循 RFC 7591 的客户端通过公共端点自行注册",
"dynamicRegistrationAllowedGrantTypes": "允许的授权类型",
"dynamicRegistrationAllowedGrantTypesDescription": "使用逗号分隔动态注册客户端可以使用的授权类型",
"dynamicRegistrationAllowedGrantTypesPlaceholder": "例如authorization_code, refresh_token",
"dynamicRegistrationAuth": "注册需要认证",
"dynamicRegistrationAuthDescription": "开启后,注册端点需要认证请求才能创建客户端",
"invalidNumberInput": "请输入合法的非负数字"
"exportError": "获取配置失败"
},
"dxt": {
"upload": "上传",
@@ -675,13 +644,9 @@
"password": "密码",
"newPassword": "新密码",
"confirmPassword": "确认密码",
"changePassword": "修改密码",
"adminRole": "管理员",
"admin": "管理员",
"user": "用户",
"role": "角色",
"actions": "操作",
"addFirst": "添加第一个用户",
"permissions": "权限",
"adminPermissions": "完全系统访问权限",
"userPermissions": "受限访问权限",
@@ -788,5 +753,28 @@
"internalError": "内部错误",
"internalErrorMessage": "处理 OAuth 回调时发生意外错误。",
"closeWindow": "关闭窗口"
},
"install": {
"installServerTitle": "安装服务器到 {{name}}",
"installGroupTitle": "安装分组 {{name}}",
"configCode": "配置代码",
"copyConfig": "复制配置",
"steps": "安装步骤",
"step1Cursor": "复制上面的配置代码",
"step2Cursor": "打开 Cursor进入设置 > Features > MCP",
"step3Cursor": "点击 'Add New MCP Server' 添加新服务器",
"step4Cursor": "将配置粘贴到相应位置并重启 Cursor",
"step1ClaudeCode": "复制上面的配置代码",
"step2ClaudeCode": "打开 Claude Code进入设置 > Features > MCP",
"step3ClaudeCode": "点击 'Add New MCP Server' 添加新服务器",
"step4ClaudeCode": "将配置粘贴到相应位置并重启 Claude Code",
"step1ClaudeDesktop": "复制上面的配置代码",
"step2ClaudeDesktop": "打开 Claude Desktop进入设置 > Developer",
"step3ClaudeDesktop": "点击 'Edit Config' 编辑配置文件",
"step4ClaudeDesktop": "将配置粘贴到 mcpServers 部分并重启 Claude Desktop",
"installToCursor": "添加 {{name}} MCP 服务器到 Cursor",
"installToClaudeCode": "添加 {{name}} MCP 服务器到 Claude Code",
"installToClaudeDesktop": "添加 {{name}} MCP 服务器到 Claude Desktop",
"installButton": "安装"
}
}
}

View File

@@ -41,27 +41,5 @@
"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
}
}
}
]
}

View File

@@ -47,7 +47,6 @@
"dependencies": {
"@apidevtools/swagger-parser": "^12.0.0",
"@modelcontextprotocol/sdk": "^1.20.2",
"@node-oauth/oauth2-server": "^5.2.1",
"@types/adm-zip": "^0.5.7",
"@types/bcrypt": "^6.0.0",
"@types/multer": "^1.4.13",
@@ -65,7 +64,7 @@
"i18next-fs-backend": "^2.6.0",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2",
"openai": "^6.7.0",
"openai": "^4.104.0",
"openapi-types": "^12.1.3",
"openid-client": "^6.8.1",
"pg": "^8.16.3",
@@ -106,12 +105,12 @@
"jest": "^30.2.0",
"jest-environment-node": "^30.0.5",
"jest-mock-extended": "4.0.0",
"lucide-react": "^0.552.0",
"lucide-react": "^0.486.0",
"next": "^15.5.0",
"postcss": "^8.5.6",
"prettier": "^3.6.2",
"react": "19.1.1",
"react-dom": "19.1.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-i18next": "^15.7.2",
"react-router-dom": "^7.8.2",
"supertest": "^7.1.4",

331
pnpm-lock.yaml generated
View File

@@ -18,9 +18,6 @@ importers:
'@modelcontextprotocol/sdk':
specifier: ^1.20.2
version: 1.20.2
'@node-oauth/oauth2-server':
specifier: ^5.2.1
version: 5.2.1
'@types/adm-zip':
specifier: ^0.5.7
version: 0.5.7
@@ -38,7 +35,7 @@ importers:
version: 0.5.16
axios:
specifier: ^1.12.2
version: 1.13.1
version: 1.12.2
bcrypt:
specifier: ^6.0.0
version: 6.0.0
@@ -62,7 +59,7 @@ importers:
version: 7.2.1
i18next:
specifier: ^25.5.0
version: 25.6.0(typescript@5.9.2)
version: 25.5.0(typescript@5.9.2)
i18next-fs-backend:
specifier: ^2.6.0
version: 2.6.0
@@ -73,8 +70,8 @@ importers:
specifier: ^2.0.2
version: 2.0.2
openai:
specifier: ^6.7.0
version: 6.7.0(zod@3.25.76)
specifier: ^4.104.0
version: 4.104.0(zod@3.25.76)
openapi-types:
specifier: ^12.1.3
version: 12.1.3
@@ -102,10 +99,10 @@ importers:
devDependencies:
'@radix-ui/react-accordion':
specifier: ^1.2.12
version: 1.2.12(@types/react-dom@19.1.7(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
version: 1.2.12(@types/react-dom@19.1.7(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-slot':
specifier: ^1.2.3
version: 1.2.3(@types/react@19.2.2)(react@19.1.1)
version: 1.2.3(@types/react@19.1.11)(react@19.1.1)
'@shadcn/ui':
specifier: ^0.0.4
version: 0.0.4
@@ -144,10 +141,10 @@ importers:
version: 24.6.2
'@types/react':
specifier: ^19.1.11
version: 19.2.2
version: 19.1.11
'@types/react-dom':
specifier: ^19.1.7
version: 19.1.7(@types/react@19.2.2)
version: 19.1.7(@types/react@19.1.11)
'@types/supertest':
specifier: ^6.0.3
version: 6.0.3
@@ -191,8 +188,8 @@ importers:
specifier: 4.0.0
version: 4.0.0(@jest/globals@30.2.0)(jest@30.2.0(@types/node@24.6.2)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@24.6.2)(typescript@5.9.2)))(typescript@5.9.2)
lucide-react:
specifier: ^0.552.0
version: 0.552.0(react@19.1.1)
specifier: ^0.486.0
version: 0.486.0(react@19.1.1)
next:
specifier: ^15.5.0
version: 15.5.2(@babel/core@7.28.4)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
@@ -203,14 +200,14 @@ importers:
specifier: ^3.6.2
version: 3.6.2
react:
specifier: 19.1.1
specifier: ^19.1.1
version: 19.1.1
react-dom:
specifier: 19.1.1
specifier: ^19.1.1
version: 19.1.1(react@19.1.1)
react-i18next:
specifier: ^15.7.2
version: 15.7.2(i18next@25.6.0(typescript@5.9.2))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)
version: 15.7.2(i18next@25.5.0(typescript@5.9.2))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)
react-router-dom:
specifier: ^7.8.2
version: 7.8.2(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
@@ -450,10 +447,6 @@ packages:
resolution: {integrity: sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==}
engines: {node: '>=6.9.0'}
'@babel/runtime@7.28.4':
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
engines: {node: '>=6.9.0'}
'@babel/template@7.27.2':
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
engines: {node: '>=6.9.0'}
@@ -1157,13 +1150,6 @@ packages:
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16}
'@node-oauth/formats@1.0.0':
resolution: {integrity: sha512-DwSbLtdC8zC5B5gTJkFzJj5s9vr9SGzOgQvV9nH7tUVuMSScg0EswAczhjIapOmH3Y8AyP7C4Jv7b8+QJObWZA==}
'@node-oauth/oauth2-server@5.2.1':
resolution: {integrity: sha512-lTyLc7iSnSvoWu3Wzh5GkkAoqvmqZJLE1GC9o7hMiVBxvz5UCjTbbJ0OyeuNfOtQMVDoq9AEbIo6aHDrca0iRA==}
engines: {node: '>=16.0.0'}
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -1814,6 +1800,12 @@ packages:
'@types/multer@1.4.13':
resolution: {integrity: sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==}
'@types/node-fetch@2.6.13':
resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==}
'@types/node@18.19.129':
resolution: {integrity: sha512-hrmi5jWt2w60ayox3iIXwpMEnfUvOLJCRtrOPbHtH15nTjvO7uhnelvrdAs0dO0/zl5DZ3ZbahiaXEVb54ca/A==}
'@types/node@24.6.2':
resolution: {integrity: sha512-d2L25Y4j+W3ZlNAeMKcy7yDsK425ibcAOO2t7aPTz6gNMH0z2GThtwENCDc0d/Pw9wgyRqE5Px1wkV7naz8ang==}
@@ -1831,8 +1823,8 @@ packages:
peerDependencies:
'@types/react': ^19.0.0
'@types/react@19.2.2':
resolution: {integrity: sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==}
'@types/react@19.1.11':
resolution: {integrity: sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ==}
'@types/semver@7.7.0':
resolution: {integrity: sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==}
@@ -2050,6 +2042,10 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
@@ -2076,6 +2072,10 @@ packages:
resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==}
engines: {node: '>=12.0'}
agentkeepalive@4.6.0:
resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
engines: {node: '>= 8.0.0'}
ajv-draft-04@1.0.0:
resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==}
peerDependencies:
@@ -2166,8 +2166,8 @@ packages:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
axios@1.13.1:
resolution: {integrity: sha512-hU4EGxxt+j7TQijx1oYdAjw4xuIp1wRQSsbMFwSthCWeBQur1eF+qJ5iQ5sN3Tw8YRzQNKb8jszgBdMDVqwJcw==}
axios@1.12.2:
resolution: {integrity: sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==}
babel-jest@30.2.0:
resolution: {integrity: sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==}
@@ -2200,10 +2200,6 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
basic-auth@2.0.1:
resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==}
engines: {node: '>= 0.8'}
bcrypt@6.0.0:
resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==}
engines: {node: '>= 18'}
@@ -2677,6 +2673,10 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
event-target-shim@5.0.1:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
eventsource-parser@3.0.5:
resolution: {integrity: sha512-bSRG85ZrMdmWtm7qkF9He9TNRzc/Bm99gEJMaQoHJ9E6Kv9QBbsldh2oMj7iXmYNEAVvNgvv5vPorG6W+XtBhQ==}
engines: {node: '>=20.0.0'}
@@ -2805,10 +2805,17 @@ packages:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
form-data-encoder@1.7.2:
resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==}
form-data@4.0.4:
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
engines: {node: '>= 6'}
formdata-node@4.4.1:
resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==}
engines: {node: '>= 12.20'}
formdata-polyfill@4.0.10:
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
engines: {node: '>=12.20.0'}
@@ -2950,14 +2957,17 @@ packages:
resolution: {integrity: sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==}
engines: {node: '>=14.18.0'}
humanize-ms@1.2.1:
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
i18next-browser-languagedetector@8.2.0:
resolution: {integrity: sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==}
i18next-fs-backend@2.6.0:
resolution: {integrity: sha512-3ZlhNoF9yxnM8pa8bWp5120/Ob6t4lVl1l/tbLmkml/ei3ud8IWySCHt2lrY5xWRlSU5D9IV2sm5bEbGuTqwTw==}
i18next@25.6.0:
resolution: {integrity: sha512-tTn8fLrwBYtnclpL5aPXK/tAYBLWVvoHM1zdfXoRNLcI+RvtMsoZRV98ePlaW3khHYKuNh/Q65W/+NVFUeIwVw==}
i18next@25.5.0:
resolution: {integrity: sha512-Mm2CgIq0revRFbBvfzqW9kDw1r44M4VDWC+YNRx9vTo5bU/iogSdEAC2HEonDA4czEce/iSbAkK90Tw7UrRZKA==}
peerDependencies:
typescript: ^5
peerDependenciesMeta:
@@ -3445,8 +3455,8 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@0.552.0:
resolution: {integrity: sha512-g9WCjmfwqbexSnZE+2cl21PCfXOcqnGeWeMTNAOGEfpPbm/ZF4YIq77Z8qWrxbu660EKuLB4nSLggoKnCb+isw==}
lucide-react@0.486.0:
resolution: {integrity: sha512-xWop/wMsC1ikiEVLZrxXjPKw4vU/eAip33G2mZHgbWnr4Nr5Rt4Vx4s/q1D3B/rQVbxjOuqASkEZcUxDEKzecw==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -3638,6 +3648,15 @@ packages:
engines: {node: '>=10.5.0'}
deprecated: Use your platform's native DOMException instead
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
node-fetch@3.3.2:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -3694,12 +3713,12 @@ packages:
resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==}
engines: {node: '>=12'}
openai@6.7.0:
resolution: {integrity: sha512-mgSQXa3O/UXTbA8qFzoa7aydbXBJR5dbLQXCRapAOtoNT+v69sLdKMZzgiakpqhclRnhPggPAXoniVGn2kMY2A==}
openai@4.104.0:
resolution: {integrity: sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==}
hasBin: true
peerDependencies:
ws: ^8.18.0
zod: ^3.25 || ^4.0
zod: ^3.23.8
peerDependenciesMeta:
ws:
optional: true
@@ -4064,9 +4083,6 @@ packages:
rxjs@7.8.2:
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
safe-buffer@5.1.2:
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
@@ -4350,6 +4366,9 @@ packages:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
@@ -4532,6 +4551,9 @@ packages:
engines: {node: '>=0.8.0'}
hasBin: true
undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
undici-types@7.13.0:
resolution: {integrity: sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==}
@@ -4635,6 +4657,16 @@ packages:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
web-streams-polyfill@4.0.0-beta.3:
resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==}
engines: {node: '>= 14'}
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
which-typed-array@1.1.19:
resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==}
engines: {node: '>= 0.4'}
@@ -4947,8 +4979,6 @@ snapshots:
'@babel/runtime@7.28.3': {}
'@babel/runtime@7.28.4': {}
'@babel/template@7.27.2':
dependencies:
'@babel/code-frame': 7.27.1
@@ -5603,14 +5633,6 @@ snapshots:
'@noble/hashes@1.8.0': {}
'@node-oauth/formats@1.0.0': {}
'@node-oauth/oauth2-server@5.2.1':
dependencies:
'@node-oauth/formats': 1.0.0
basic-auth: 2.0.1
type-is: 2.0.1
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -5634,122 +5656,122 @@ snapshots:
'@radix-ui/primitive@1.1.3': {}
'@radix-ui/react-accordion@1.2.12(@types/react-dom@19.1.7(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
'@radix-ui/react-accordion@1.2.12(@types/react-dom@19.1.7(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.1.7(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.7(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.1)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.1)
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.1.1)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.1.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.7(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.1.1)
'@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.1.7(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.7(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.11)(react@19.1.1)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.11)(react@19.1.1)
'@radix-ui/react-direction': 1.1.1(@types/react@19.1.11)(react@19.1.1)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.11)(react@19.1.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.7(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.11)(react@19.1.1)
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
optionalDependencies:
'@types/react': 19.2.2
'@types/react-dom': 19.1.7(@types/react@19.2.2)
'@types/react': 19.1.11
'@types/react-dom': 19.1.7(@types/react@19.1.11)
'@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.1.7(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
'@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.1.7(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.1)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.1)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.1.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.7(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.7(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.1.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.1.1)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.11)(react@19.1.1)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.11)(react@19.1.1)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.11)(react@19.1.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.7(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.7(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.11)(react@19.1.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.11)(react@19.1.1)
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
optionalDependencies:
'@types/react': 19.2.2
'@types/react-dom': 19.1.7(@types/react@19.2.2)
'@types/react': 19.1.11
'@types/react-dom': 19.1.7(@types/react@19.1.11)
'@radix-ui/react-collection@1.1.7(@types/react-dom@19.1.7(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
'@radix-ui/react-collection@1.1.7(@types/react-dom@19.1.7(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.1)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.7(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.1.1)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.11)(react@19.1.1)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.11)(react@19.1.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.7(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.11)(react@19.1.1)
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
optionalDependencies:
'@types/react': 19.2.2
'@types/react-dom': 19.1.7(@types/react@19.2.2)
'@types/react': 19.1.11
'@types/react-dom': 19.1.7(@types/react@19.1.11)
'@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.2)(react@19.1.1)':
'@radix-ui/react-compose-refs@1.1.2(@types/react@19.1.11)(react@19.1.1)':
dependencies:
react: 19.1.1
optionalDependencies:
'@types/react': 19.2.2
'@types/react': 19.1.11
'@radix-ui/react-context@1.1.2(@types/react@19.2.2)(react@19.1.1)':
'@radix-ui/react-context@1.1.2(@types/react@19.1.11)(react@19.1.1)':
dependencies:
react: 19.1.1
optionalDependencies:
'@types/react': 19.2.2
'@types/react': 19.1.11
'@radix-ui/react-direction@1.1.1(@types/react@19.2.2)(react@19.1.1)':
'@radix-ui/react-direction@1.1.1(@types/react@19.1.11)(react@19.1.1)':
dependencies:
react: 19.1.1
optionalDependencies:
'@types/react': 19.2.2
'@types/react': 19.1.11
'@radix-ui/react-id@1.1.1(@types/react@19.2.2)(react@19.1.1)':
'@radix-ui/react-id@1.1.1(@types/react@19.1.11)(react@19.1.1)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.1.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.11)(react@19.1.1)
react: 19.1.1
optionalDependencies:
'@types/react': 19.2.2
'@types/react': 19.1.11
'@radix-ui/react-presence@1.1.5(@types/react-dom@19.1.7(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
'@radix-ui/react-presence@1.1.5(@types/react-dom@19.1.7(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.1.1)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.11)(react@19.1.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.11)(react@19.1.1)
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
optionalDependencies:
'@types/react': 19.2.2
'@types/react-dom': 19.1.7(@types/react@19.2.2)
'@types/react': 19.1.11
'@types/react-dom': 19.1.7(@types/react@19.1.11)
'@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.7(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
'@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.7(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.1.1)
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.11)(react@19.1.1)
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
optionalDependencies:
'@types/react': 19.2.2
'@types/react-dom': 19.1.7(@types/react@19.2.2)
'@types/react': 19.1.11
'@types/react-dom': 19.1.7(@types/react@19.1.11)
'@radix-ui/react-slot@1.2.3(@types/react@19.2.2)(react@19.1.1)':
'@radix-ui/react-slot@1.2.3(@types/react@19.1.11)(react@19.1.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.1)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.11)(react@19.1.1)
react: 19.1.1
optionalDependencies:
'@types/react': 19.2.2
'@types/react': 19.1.11
'@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.2)(react@19.1.1)':
'@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.1.11)(react@19.1.1)':
dependencies:
'@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.2)(react@19.1.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.1.1)
'@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.1.11)(react@19.1.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.11)(react@19.1.1)
react: 19.1.1
optionalDependencies:
'@types/react': 19.2.2
'@types/react': 19.1.11
'@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.2)(react@19.1.1)':
'@radix-ui/react-use-effect-event@0.0.2(@types/react@19.1.11)(react@19.1.1)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.1.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.11)(react@19.1.1)
react: 19.1.1
optionalDependencies:
'@types/react': 19.2.2
'@types/react': 19.1.11
'@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.2)(react@19.1.1)':
'@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.1.11)(react@19.1.1)':
dependencies:
react: 19.1.1
optionalDependencies:
'@types/react': 19.2.2
'@types/react': 19.1.11
'@rolldown/pluginutils@1.0.0-beta.27': {}
@@ -6163,6 +6185,15 @@ snapshots:
dependencies:
'@types/express': 4.17.23
'@types/node-fetch@2.6.13':
dependencies:
'@types/node': 24.6.2
form-data: 4.0.4
'@types/node@18.19.129':
dependencies:
undici-types: 5.26.5
'@types/node@24.6.2':
dependencies:
undici-types: 7.13.0
@@ -6177,11 +6208,11 @@ snapshots:
'@types/range-parser@1.2.7': {}
'@types/react-dom@19.1.7(@types/react@19.2.2)':
'@types/react-dom@19.1.7(@types/react@19.1.11)':
dependencies:
'@types/react': 19.2.2
'@types/react': 19.1.11
'@types/react@19.2.2':
'@types/react@19.1.11':
dependencies:
csstype: 3.1.3
@@ -6410,6 +6441,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
accepts@1.3.8:
dependencies:
mime-types: 2.1.35
@@ -6432,6 +6467,10 @@ snapshots:
adm-zip@0.5.16: {}
agentkeepalive@4.6.0:
dependencies:
humanize-ms: 1.2.1
ajv-draft-04@1.0.0(ajv@8.17.1):
optionalDependencies:
ajv: 8.17.1
@@ -6509,7 +6548,7 @@ snapshots:
dependencies:
possible-typed-array-names: 1.1.0
axios@1.13.1:
axios@1.12.2:
dependencies:
follow-redirects: 1.15.11
form-data: 4.0.4
@@ -6573,10 +6612,6 @@ snapshots:
base64-js@1.5.1: {}
basic-auth@2.0.1:
dependencies:
safe-buffer: 5.1.2
bcrypt@6.0.0:
dependencies:
node-addon-api: 8.5.0
@@ -7089,6 +7124,8 @@ snapshots:
etag@1.8.1: {}
event-target-shim@5.0.1: {}
eventsource-parser@3.0.5: {}
eventsource@3.0.7:
@@ -7302,6 +7339,8 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
form-data-encoder@1.7.2: {}
form-data@4.0.4:
dependencies:
asynckit: 0.4.0
@@ -7310,6 +7349,11 @@ snapshots:
hasown: 2.0.2
mime-types: 2.1.35
formdata-node@4.4.1:
dependencies:
node-domexception: 1.0.0
web-streams-polyfill: 4.0.0-beta.3
formdata-polyfill@4.0.10:
dependencies:
fetch-blob: 3.2.0
@@ -7459,15 +7503,19 @@ snapshots:
human-signals@4.3.1: {}
humanize-ms@1.2.1:
dependencies:
ms: 2.1.3
i18next-browser-languagedetector@8.2.0:
dependencies:
'@babel/runtime': 7.28.3
i18next-fs-backend@2.6.0: {}
i18next@25.6.0(typescript@5.9.2):
i18next@25.5.0(typescript@5.9.2):
dependencies:
'@babel/runtime': 7.28.4
'@babel/runtime': 7.28.3
optionalDependencies:
typescript: 5.9.2
@@ -8115,7 +8163,7 @@ snapshots:
dependencies:
yallist: 3.1.1
lucide-react@0.552.0(react@19.1.1):
lucide-react@0.486.0(react@19.1.1):
dependencies:
react: 19.1.1
@@ -8263,6 +8311,10 @@ snapshots:
node-domexception@1.0.0: {}
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
node-fetch@3.3.2:
dependencies:
data-uri-to-buffer: 4.0.1
@@ -8309,9 +8361,19 @@ snapshots:
dependencies:
mimic-fn: 4.0.0
openai@6.7.0(zod@3.25.76):
openai@4.104.0(zod@3.25.76):
dependencies:
'@types/node': 18.19.129
'@types/node-fetch': 2.6.13
abort-controller: 3.0.0
agentkeepalive: 4.6.0
form-data-encoder: 1.7.2
formdata-node: 4.4.1
node-fetch: 2.7.0
optionalDependencies:
zod: 3.25.76
transitivePeerDependencies:
- encoding
openapi-types@12.1.3: {}
@@ -8537,11 +8599,11 @@ snapshots:
react: 19.1.1
scheduler: 0.26.0
react-i18next@15.7.2(i18next@25.6.0(typescript@5.9.2))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2):
react-i18next@15.7.2(i18next@25.5.0(typescript@5.9.2))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2):
dependencies:
'@babel/runtime': 7.28.3
html-parse-stringify: 3.0.1
i18next: 25.6.0(typescript@5.9.2)
i18next: 25.5.0(typescript@5.9.2)
react: 19.1.1
optionalDependencies:
react-dom: 19.1.1(react@19.1.1)
@@ -8660,8 +8722,6 @@ snapshots:
dependencies:
tslib: 2.8.1
safe-buffer@5.1.2: {}
safe-buffer@5.2.1: {}
safer-buffer@2.1.2: {}
@@ -8999,6 +9059,8 @@ snapshots:
toidentifier@1.0.1: {}
tr46@0.0.3: {}
tree-kill@1.2.2: {}
ts-api-utils@1.4.3(typescript@5.9.2):
@@ -9143,6 +9205,8 @@ snapshots:
uglify-js@3.19.3:
optional: true
undici-types@5.26.5: {}
undici-types@7.13.0: {}
universalify@2.0.1: {}
@@ -9228,6 +9292,15 @@ snapshots:
web-streams-polyfill@3.3.3: {}
web-streams-polyfill@4.0.0-beta.3: {}
webidl-conversions@3.0.1: {}
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
which-typed-array@1.1.19:
dependencies:
available-typed-arrays: 1.0.7

View File

@@ -2,6 +2,7 @@ import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import SwaggerParser from '@apidevtools/swagger-parser';
import { OpenAPIV3 } from 'openapi-types';
import { ServerConfig, OpenAPISecurityConfig } from '../types/index.js';
import { createSafeJSON } from '../utils/serialization.js';
export interface OpenAPIToolInfo {
name: string;
@@ -299,6 +300,31 @@ export class OpenAPIClient {
return schema;
}
/**
* Expands parameters that may have been stringified due to circular reference handling
* This reverses the '[Circular Reference]' placeholder back to proper values when possible
*/
private expandParameter(value: unknown): unknown {
if (typeof value === 'string' && value === '[Circular Reference]') {
// Return undefined for circular references to avoid sending invalid data
return undefined;
}
if (typeof value === 'object' && value !== null) {
if (Array.isArray(value)) {
return value.map((item) => this.expandParameter(item));
}
const result: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value)) {
const expanded = this.expandParameter(val);
if (expanded !== undefined) {
result[key] = expanded;
}
}
return result;
}
return value;
}
async callTool(
toolName: string,
args: Record<string, unknown>,
@@ -310,12 +336,15 @@ export class OpenAPIClient {
}
try {
// Expand any circular reference placeholders in arguments
const expandedArgs = this.expandParameter(args) as Record<string, unknown>;
// Build the request URL with path parameters
let url = tool.path;
const pathParams = tool.parameters?.filter((p) => p.in === 'path') || [];
for (const param of pathParams) {
const value = args[param.name];
const value = expandedArgs[param.name];
if (value !== undefined) {
url = url.replace(`{${param.name}}`, String(value));
}
@@ -326,7 +355,7 @@ export class OpenAPIClient {
const queryParamDefs = tool.parameters?.filter((p) => p.in === 'query') || [];
for (const param of queryParamDefs) {
const value = args[param.name];
const value = expandedArgs[param.name];
if (value !== undefined) {
queryParams[param.name] = value;
}
@@ -340,8 +369,8 @@ export class OpenAPIClient {
};
// Add request body if applicable
if (args.body && ['post', 'put', 'patch'].includes(tool.method)) {
requestConfig.data = args.body;
if (expandedArgs.body && ['post', 'put', 'patch'].includes(tool.method)) {
requestConfig.data = expandedArgs.body;
}
// Collect all headers to be sent
@@ -350,7 +379,7 @@ export class OpenAPIClient {
// Add headers if any header parameters are defined
const headerParams = tool.parameters?.filter((p) => p.in === 'header') || [];
for (const param of headerParams) {
const value = args[param.name];
const value = expandedArgs[param.name];
if (value !== undefined) {
allHeaders[param.name] = String(value);
}
@@ -383,7 +412,8 @@ export class OpenAPIClient {
}
getTools(): OpenAPIToolInfo[] {
return this.tools;
// Return a safe copy to avoid circular reference issues
return createSafeJSON(this.tools);
}
getSpec(): OpenAPIV3.Document | null {

View File

@@ -6,11 +6,11 @@ import {
SystemConfigDao,
UserConfigDao,
ServerConfigWithName,
getUserDao,
getServerDao,
getGroupDao,
getSystemConfigDao,
getUserConfigDao,
UserDaoImpl,
ServerDaoImpl,
GroupDaoImpl,
SystemConfigDaoImpl,
UserConfigDaoImpl,
} from '../dao/index.js';
/**
@@ -252,14 +252,14 @@ export class DaoConfigService {
}
/**
* Create a DaoConfigService with DAO implementations from factory
* Create a DaoConfigService with default DAO implementations
*/
export function createDaoConfigService(): DaoConfigService {
return new DaoConfigService(
getUserDao(),
getServerDao(),
getGroupDao(),
getSystemConfigDao(),
getUserConfigDao(),
new UserDaoImpl(),
new ServerDaoImpl(),
new GroupDaoImpl(),
new SystemConfigDaoImpl(),
new UserConfigDaoImpl(),
);
}

View File

@@ -5,7 +5,6 @@ import { getConfigFilePath } from '../utils/path.js';
import { getPackageVersion } from '../utils/version.js';
import { getDataService } from '../services/services.js';
import { DataService } from '../services/dataService.js';
import { cloneDefaultOAuthServerConfig } from '../constants/oauthServerDefaults.js';
dotenv.config();
@@ -20,22 +19,6 @@ const defaultConfig = {
const dataService: DataService = getDataService();
const ensureOAuthServerDefaults = (settings: McpSettings): boolean => {
if (!settings.systemConfig) {
settings.systemConfig = {
oauthServer: cloneDefaultOAuthServerConfig(),
};
return true;
}
if (!settings.systemConfig.oauthServer) {
settings.systemConfig.oauthServer = cloneDefaultOAuthServerConfig();
return true;
}
return false;
};
// Settings cache
let settingsCache: McpSettings | null = null;
@@ -53,8 +36,7 @@ export const loadOriginalSettings = (): McpSettings => {
// check if file exists
if (!fs.existsSync(settingsPath)) {
console.warn(`Settings file not found at ${settingsPath}, using default settings.`);
const defaultSettings: McpSettings = { mcpServers: {}, users: [] };
ensureOAuthServerDefaults(defaultSettings);
const defaultSettings = { mcpServers: {}, users: [] };
// Cache default settings
settingsCache = defaultSettings;
return defaultSettings;
@@ -64,14 +46,6 @@ export const loadOriginalSettings = (): McpSettings => {
// Read and parse settings file
const settingsData = fs.readFileSync(settingsPath, 'utf8');
const settings = JSON.parse(settingsData);
const initialized = ensureOAuthServerDefaults(settings);
if (initialized) {
try {
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
} catch (writeError) {
console.error('Failed to persist default OAuth server configuration:', writeError);
}
}
// Update cache
settingsCache = settings;

View File

@@ -1,42 +0,0 @@
import { OAuthServerConfig } from '../types/index.js';
export const DEFAULT_OAUTH_SERVER_CONFIG: OAuthServerConfig = {
enabled: true,
accessTokenLifetime: 3600,
refreshTokenLifetime: 1209600,
authorizationCodeLifetime: 300,
requireClientSecret: false,
allowedScopes: ['read', 'write'],
requireState: false,
dynamicRegistration: {
enabled: true,
allowedGrantTypes: ['authorization_code', 'refresh_token'],
requiresAuthentication: false,
},
};
export const cloneDefaultOAuthServerConfig = (): OAuthServerConfig => {
const allowedScopes = DEFAULT_OAUTH_SERVER_CONFIG.allowedScopes
? [...DEFAULT_OAUTH_SERVER_CONFIG.allowedScopes]
: [];
const baseDynamicRegistration =
DEFAULT_OAUTH_SERVER_CONFIG.dynamicRegistration ?? {
enabled: false,
allowedGrantTypes: [],
requiresAuthentication: false,
};
const dynamicRegistration = {
...baseDynamicRegistration,
allowedGrantTypes: baseDynamicRegistration.allowedGrantTypes
? [...baseDynamicRegistration.allowedGrantTypes]
: [],
};
return {
...DEFAULT_OAUTH_SERVER_CONFIG,
allowedScopes,
dynamicRegistration,
};
};

View File

@@ -37,7 +37,7 @@ export const login = async (req: Request, res: Response): Promise<void> => {
try {
// Find user by username
const user = await findUserByUsername(username);
const user = findUserByUsername(username);
if (!user) {
res.status(401).json({
@@ -192,7 +192,7 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
}
// Find user by username
const user = await findUserByUsername(username);
const user = findUserByUsername(username);
if (!user) {
res.status(404).json({ success: false, message: 'User not found' });

View File

@@ -15,9 +15,9 @@ import {
} from '../services/groupService.js';
// Get all groups
export const getGroups = async (_: Request, res: Response): Promise<void> => {
export const getGroups = (_: Request, res: Response): void => {
try {
const groups = await getAllGroups();
const groups = getAllGroups();
const response: ApiResponse = {
success: true,
data: groups,
@@ -32,7 +32,7 @@ export const getGroups = async (_: Request, res: Response): Promise<void> => {
};
// Get a specific group by ID
export const getGroup = async (req: Request, res: Response): Promise<void> => {
export const getGroup = (req: Request, res: Response): void => {
try {
const { id } = req.params;
if (!id) {
@@ -43,7 +43,7 @@ export const getGroup = async (req: Request, res: Response): Promise<void> => {
return;
}
const group = await getGroupByIdOrName(id);
const group = getGroupByIdOrName(id);
if (!group) {
res.status(404).json({
success: false,
@@ -66,7 +66,7 @@ export const getGroup = async (req: Request, res: Response): Promise<void> => {
};
// Create a new group
export const createNewGroup = async (req: Request, res: Response): Promise<void> => {
export const createNewGroup = (req: Request, res: Response): void => {
try {
const { name, description, servers } = req.body;
if (!name) {
@@ -83,7 +83,7 @@ export const createNewGroup = async (req: Request, res: Response): Promise<void>
const currentUser = (req as any).user;
const owner = currentUser?.username || 'admin';
const newGroup = await createGroup(name, description, serverList, owner);
const newGroup = createGroup(name, description, serverList, owner);
if (!newGroup) {
res.status(400).json({
success: false,
@@ -107,7 +107,7 @@ export const createNewGroup = async (req: Request, res: Response): Promise<void>
};
// Update an existing group
export const updateExistingGroup = async (req: Request, res: Response): Promise<void> => {
export const updateExistingGroup = (req: Request, res: Response): void => {
try {
const { id } = req.params;
const { name, description, servers } = req.body;
@@ -133,7 +133,7 @@ export const updateExistingGroup = async (req: Request, res: Response): Promise<
return;
}
const updatedGroup = await updateGroup(id, updateData);
const updatedGroup = updateGroup(id, updateData);
if (!updatedGroup) {
res.status(404).json({
success: false,
@@ -157,7 +157,7 @@ export const updateExistingGroup = async (req: Request, res: Response): Promise<
};
// Update servers in a group (batch update) - supports both string[] and server config format
export const updateGroupServersBatch = async (req: Request, res: Response): Promise<void> => {
export const updateGroupServersBatch = (req: Request, res: Response): void => {
try {
const { id } = req.params;
const { servers } = req.body;
@@ -203,7 +203,7 @@ export const updateGroupServersBatch = async (req: Request, res: Response): Prom
}
}
const updatedGroup = await updateGroupServers(id, servers);
const updatedGroup = updateGroupServers(id, servers);
if (!updatedGroup) {
res.status(404).json({
success: false,
@@ -227,7 +227,7 @@ export const updateGroupServersBatch = async (req: Request, res: Response): Prom
};
// Delete a group
export const deleteExistingGroup = async (req: Request, res: Response): Promise<void> => {
export const deleteExistingGroup = (req: Request, res: Response): void => {
try {
const { id } = req.params;
if (!id) {
@@ -238,7 +238,7 @@ export const deleteExistingGroup = async (req: Request, res: Response): Promise<
return;
}
const success = await deleteGroup(id);
const success = deleteGroup(id);
if (!success) {
res.status(404).json({
success: false,
@@ -260,7 +260,7 @@ export const deleteExistingGroup = async (req: Request, res: Response): Promise<
};
// Add server to a group
export const addServerToExistingGroup = async (req: Request, res: Response): Promise<void> => {
export const addServerToExistingGroup = (req: Request, res: Response): void => {
try {
const { id } = req.params;
const { serverName } = req.body;
@@ -280,7 +280,7 @@ export const addServerToExistingGroup = async (req: Request, res: Response): Pro
return;
}
const updatedGroup = await addServerToGroup(id, serverName);
const updatedGroup = addServerToGroup(id, serverName);
if (!updatedGroup) {
res.status(404).json({
success: false,
@@ -304,7 +304,7 @@ export const addServerToExistingGroup = async (req: Request, res: Response): Pro
};
// Remove server from a group
export const removeServerFromExistingGroup = async (req: Request, res: Response): Promise<void> => {
export const removeServerFromExistingGroup = (req: Request, res: Response): void => {
try {
const { id, serverName } = req.params;
if (!id || !serverName) {
@@ -315,7 +315,7 @@ export const removeServerFromExistingGroup = async (req: Request, res: Response)
return;
}
const updatedGroup = await removeServerFromGroup(id, serverName);
const updatedGroup = removeServerFromGroup(id, serverName);
if (!updatedGroup) {
res.status(404).json({
success: false,
@@ -339,7 +339,7 @@ export const removeServerFromExistingGroup = async (req: Request, res: Response)
};
// Get servers in a group
export const getGroupServers = async (req: Request, res: Response): Promise<void> => {
export const getGroupServers = (req: Request, res: Response): void => {
try {
const { id } = req.params;
if (!id) {
@@ -350,7 +350,7 @@ export const getGroupServers = async (req: Request, res: Response): Promise<void
return;
}
const group = await getGroupByIdOrName(id);
const group = getGroupByIdOrName(id);
if (!group) {
res.status(404).json({
success: false,
@@ -373,7 +373,7 @@ export const getGroupServers = async (req: Request, res: Response): Promise<void
};
// Get server configurations in a group (including tool selections)
export const getGroupServerConfigs = async (req: Request, res: Response): Promise<void> => {
export const getGroupServerConfigs = (req: Request, res: Response): void => {
try {
const { id } = req.params;
if (!id) {
@@ -384,7 +384,7 @@ export const getGroupServerConfigs = async (req: Request, res: Response): Promis
return;
}
const serverConfigs = await getServerConfigsInGroup(id);
const serverConfigs = getServerConfigsInGroup(id);
const response: ApiResponse = {
success: true,
data: serverConfigs,
@@ -399,7 +399,7 @@ export const getGroupServerConfigs = async (req: Request, res: Response): Promis
};
// Get specific server configuration in a group
export const getGroupServerConfig = async (req: Request, res: Response): Promise<void> => {
export const getGroupServerConfig = (req: Request, res: Response): void => {
try {
const { id, serverName } = req.params;
if (!id || !serverName) {
@@ -410,7 +410,7 @@ export const getGroupServerConfig = async (req: Request, res: Response): Promise
return;
}
const serverConfig = await getServerConfigInGroup(id, serverName);
const serverConfig = getServerConfigInGroup(id, serverName);
if (!serverConfig) {
res.status(404).json({
success: false,
@@ -433,7 +433,7 @@ export const getGroupServerConfig = async (req: Request, res: Response): Promise
};
// Update tools for a specific server in a group
export const updateGroupServerTools = async (req: Request, res: Response): Promise<void> => {
export const updateGroupServerTools = (req: Request, res: Response): void => {
try {
const { id, serverName } = req.params;
const { tools } = req.body;
@@ -458,7 +458,7 @@ export const updateGroupServerTools = async (req: Request, res: Response): Promi
return;
}
const updatedGroup = await updateServerToolsInGroup(id, serverName, tools);
const updatedGroup = updateServerToolsInGroup(id, serverName, tools);
if (!updatedGroup) {
res.status(404).json({
success: false,

View File

@@ -1,276 +0,0 @@
import { Request, Response } from 'express';
import { validationResult } from 'express-validator';
import crypto from 'crypto';
import {
getOAuthClients,
findOAuthClientById,
createOAuthClient,
updateOAuthClient,
deleteOAuthClient,
} from '../models/OAuth.js';
import { IOAuthClient } from '../types/index.js';
/**
* GET /api/oauth/clients
* Get all OAuth clients
*/
export const getAllClients = (req: Request, res: Response): void => {
try {
const clients = getOAuthClients();
// Don't expose client secrets in the list
const sanitizedClients = clients.map((client) => ({
clientId: client.clientId,
name: client.name,
redirectUris: client.redirectUris,
grants: client.grants,
scopes: client.scopes,
owner: client.owner,
}));
res.json({
success: true,
clients: sanitizedClients,
});
} catch (error) {
console.error('Get OAuth clients error:', error);
res.status(500).json({
success: false,
message: 'Failed to retrieve OAuth clients',
});
}
};
/**
* GET /api/oauth/clients/:clientId
* Get a specific OAuth client
*/
export const getClient = (req: Request, res: Response): void => {
try {
const { clientId } = req.params;
const client = findOAuthClientById(clientId);
if (!client) {
res.status(404).json({
success: false,
message: 'OAuth client not found',
});
return;
}
// Don't expose client secret
const sanitizedClient = {
clientId: client.clientId,
name: client.name,
redirectUris: client.redirectUris,
grants: client.grants,
scopes: client.scopes,
owner: client.owner,
};
res.json({
success: true,
client: sanitizedClient,
});
} catch (error) {
console.error('Get OAuth client error:', error);
res.status(500).json({
success: false,
message: 'Failed to retrieve OAuth client',
});
}
};
/**
* POST /api/oauth/clients
* Create a new OAuth client
*/
export const createClient = (req: Request, res: Response): void => {
try {
// Validate request
const errors = validationResult(req);
if (!errors.isEmpty()) {
res.status(400).json({
success: false,
message: 'Validation failed',
errors: errors.array(),
});
return;
}
const { name, redirectUris, grants, scopes, requireSecret } = req.body;
const user = (req as any).user;
// Generate client ID
const clientId = crypto.randomBytes(16).toString('hex');
// Generate client secret if required
const clientSecret = requireSecret !== false ? crypto.randomBytes(32).toString('hex') : undefined;
// Create client
const client: IOAuthClient = {
clientId,
clientSecret,
name,
redirectUris: Array.isArray(redirectUris) ? redirectUris : [redirectUris],
grants: grants || ['authorization_code', 'refresh_token'],
scopes: scopes || ['read', 'write'],
owner: user?.username || 'admin',
};
const createdClient = createOAuthClient(client);
// Return client with secret (only shown once)
res.status(201).json({
success: true,
message: 'OAuth client created successfully',
client: {
clientId: createdClient.clientId,
clientSecret: createdClient.clientSecret,
name: createdClient.name,
redirectUris: createdClient.redirectUris,
grants: createdClient.grants,
scopes: createdClient.scopes,
owner: createdClient.owner,
},
warning: clientSecret
? 'Client secret is only shown once. Please save it securely.'
: undefined,
});
} catch (error) {
console.error('Create OAuth client error:', error);
if (error instanceof Error && error.message.includes('already exists')) {
res.status(409).json({
success: false,
message: error.message,
});
} else {
res.status(500).json({
success: false,
message: 'Failed to create OAuth client',
});
}
}
};
/**
* PUT /api/oauth/clients/:clientId
* Update an OAuth client
*/
export const updateClient = (req: Request, res: Response): void => {
try {
const { clientId } = req.params;
const { name, redirectUris, grants, scopes } = req.body;
const updates: Partial<IOAuthClient> = {};
if (name) updates.name = name;
if (redirectUris) updates.redirectUris = Array.isArray(redirectUris) ? redirectUris : [redirectUris];
if (grants) updates.grants = grants;
if (scopes) updates.scopes = scopes;
const updatedClient = updateOAuthClient(clientId, updates);
if (!updatedClient) {
res.status(404).json({
success: false,
message: 'OAuth client not found',
});
return;
}
// Don't expose client secret
res.json({
success: true,
message: 'OAuth client updated successfully',
client: {
clientId: updatedClient.clientId,
name: updatedClient.name,
redirectUris: updatedClient.redirectUris,
grants: updatedClient.grants,
scopes: updatedClient.scopes,
owner: updatedClient.owner,
},
});
} catch (error) {
console.error('Update OAuth client error:', error);
res.status(500).json({
success: false,
message: 'Failed to update OAuth client',
});
}
};
/**
* DELETE /api/oauth/clients/:clientId
* Delete an OAuth client
*/
export const deleteClient = (req: Request, res: Response): void => {
try {
const { clientId } = req.params;
const deleted = deleteOAuthClient(clientId);
if (!deleted) {
res.status(404).json({
success: false,
message: 'OAuth client not found',
});
return;
}
res.json({
success: true,
message: 'OAuth client deleted successfully',
});
} catch (error) {
console.error('Delete OAuth client error:', error);
res.status(500).json({
success: false,
message: 'Failed to delete OAuth client',
});
}
};
/**
* POST /api/oauth/clients/:clientId/regenerate-secret
* Regenerate client secret
*/
export const regenerateSecret = (req: Request, res: Response): void => {
try {
const { clientId } = req.params;
const client = findOAuthClientById(clientId);
if (!client) {
res.status(404).json({
success: false,
message: 'OAuth client not found',
});
return;
}
// Generate new secret
const newSecret = crypto.randomBytes(32).toString('hex');
const updatedClient = updateOAuthClient(clientId, { clientSecret: newSecret });
if (!updatedClient) {
res.status(500).json({
success: false,
message: 'Failed to regenerate client secret',
});
return;
}
res.json({
success: true,
message: 'Client secret regenerated successfully',
clientSecret: newSecret,
warning: 'Client secret is only shown once. Please save it securely.',
});
} catch (error) {
console.error('Regenerate secret error:', error);
res.status(500).json({
success: false,
message: 'Failed to regenerate client secret',
});
}
};

View File

@@ -1,543 +0,0 @@
import { Request, Response } from 'express';
import crypto from 'crypto';
import {
createOAuthClient,
findOAuthClientById,
updateOAuthClient,
deleteOAuthClient,
} from '../models/OAuth.js';
import { IOAuthClient } from '../types/index.js';
import { loadSettings } from '../config/index.js';
// Store registration access tokens (in production, use database)
const registrationTokens = new Map<string, { clientId: string; createdAt: Date }>();
/**
* Generate registration access token
*/
const generateRegistrationToken = (clientId: string): string => {
const token = crypto.randomBytes(32).toString('hex');
registrationTokens.set(token, {
clientId,
createdAt: new Date(),
});
return token;
};
/**
* Verify registration access token
*/
const verifyRegistrationToken = (token: string): string | null => {
const data = registrationTokens.get(token);
if (!data) {
return null;
}
// Token expires after 30 days
const expiresAt = new Date(data.createdAt.getTime() + 30 * 24 * 60 * 60 * 1000);
if (new Date() > expiresAt) {
registrationTokens.delete(token);
return null;
}
return data.clientId;
};
/**
* POST /oauth/register
* RFC 7591 Dynamic Client Registration
* Public endpoint for registering new OAuth clients
*/
export const registerClient = (req: Request, res: Response): void => {
try {
const settings = loadSettings();
const oauthConfig = settings.systemConfig?.oauthServer;
// Check if dynamic registration is enabled
if (!oauthConfig?.dynamicRegistration?.enabled) {
res.status(403).json({
error: 'invalid_request',
error_description: 'Dynamic client registration is not enabled',
});
return;
}
// Validate required fields
const {
redirect_uris,
client_name,
grant_types,
response_types,
scope,
token_endpoint_auth_method,
application_type,
contacts,
logo_uri,
client_uri,
policy_uri,
tos_uri,
jwks_uri,
jwks,
} = req.body;
// redirect_uris is required
if (!redirect_uris || !Array.isArray(redirect_uris) || redirect_uris.length === 0) {
res.status(400).json({
error: 'invalid_redirect_uri',
error_description: 'redirect_uris is required and must be a non-empty array',
});
return;
}
// Validate redirect URIs
for (const uri of redirect_uris) {
try {
const url = new URL(uri);
// For security, only allow https (except localhost for development)
if (
url.protocol !== 'https:' &&
!url.hostname.match(/^(localhost|127\.0\.0\.1|\[::1\])$/)
) {
res.status(400).json({
error: 'invalid_redirect_uri',
error_description: `Redirect URI must use HTTPS: ${uri}`,
});
return;
}
} catch (e) {
res.status(400).json({
error: 'invalid_redirect_uri',
error_description: `Invalid redirect URI: ${uri}`,
});
return;
}
}
// Generate client credentials
const clientId = crypto.randomBytes(16).toString('hex');
// Determine if client secret is needed based on token_endpoint_auth_method
const authMethod = token_endpoint_auth_method || 'client_secret_basic';
const needsSecret = authMethod !== 'none';
const clientSecret = needsSecret ? crypto.randomBytes(32).toString('hex') : undefined;
// Default grant types
const defaultGrantTypes = ['authorization_code', 'refresh_token'];
const clientGrantTypes = grant_types || defaultGrantTypes;
// Validate grant types
const allowedGrantTypes = oauthConfig.dynamicRegistration.allowedGrantTypes || [
'authorization_code',
'refresh_token',
];
for (const grantType of clientGrantTypes) {
if (!allowedGrantTypes.includes(grantType)) {
res.status(400).json({
error: 'invalid_client_metadata',
error_description: `Grant type not allowed: ${grantType}`,
});
return;
}
}
// Validate scopes
const requestedScopes = scope ? scope.split(' ') : ['read', 'write'];
const allowedScopes = oauthConfig.allowedScopes || ['read', 'write'];
for (const requestedScope of requestedScopes) {
if (!allowedScopes.includes(requestedScope)) {
res.status(400).json({
error: 'invalid_client_metadata',
error_description: `Scope not allowed: ${requestedScope}`,
});
return;
}
}
// Generate registration access token
const registrationAccessToken = generateRegistrationToken(clientId);
const baseUrl =
settings.systemConfig?.install?.baseUrl || `${req.protocol}://${req.get('host')}`;
const registrationClientUri = `${baseUrl}/oauth/register/${clientId}`;
// Create OAuth client
const client: IOAuthClient = {
clientId,
clientSecret,
name: client_name || 'Dynamically Registered Client',
redirectUris: redirect_uris,
grants: clientGrantTypes,
scopes: requestedScopes,
owner: 'dynamic-registration',
// Store additional metadata
metadata: {
application_type: application_type || 'web',
contacts,
logo_uri,
client_uri,
policy_uri,
tos_uri,
jwks_uri,
jwks,
token_endpoint_auth_method: authMethod,
response_types: response_types || ['code'],
},
};
const createdClient = createOAuthClient(client);
// Build response according to RFC 7591
const response: any = {
client_id: createdClient.clientId,
client_name: createdClient.name,
redirect_uris: createdClient.redirectUris,
grant_types: createdClient.grants,
response_types: client.metadata?.response_types || ['code'],
scope: (createdClient.scopes || []).join(' '),
token_endpoint_auth_method: authMethod,
registration_access_token: registrationAccessToken,
registration_client_uri: registrationClientUri,
client_id_issued_at: Math.floor(Date.now() / 1000),
};
// Include client secret if generated
if (clientSecret) {
response.client_secret = clientSecret;
response.client_secret_expires_at = 0; // 0 means it doesn't expire
}
// Include optional metadata
if (application_type) response.application_type = application_type;
if (contacts) response.contacts = contacts;
if (logo_uri) response.logo_uri = logo_uri;
if (client_uri) response.client_uri = client_uri;
if (policy_uri) response.policy_uri = policy_uri;
if (tos_uri) response.tos_uri = tos_uri;
if (jwks_uri) response.jwks_uri = jwks_uri;
if (jwks) response.jwks = jwks;
res.status(201).json(response);
} catch (error) {
console.error('Dynamic client registration error:', error);
if (error instanceof Error && error.message.includes('already exists')) {
res.status(400).json({
error: 'invalid_client_metadata',
error_description: 'Client with this ID already exists',
});
} else {
res.status(500).json({
error: 'server_error',
error_description: 'Failed to register client',
});
}
}
};
/**
* GET /oauth/register/:clientId
* RFC 7591 Client Configuration Endpoint
* Read client configuration
*/
export const getClientConfiguration = (req: Request, res: Response): void => {
try {
const { clientId } = req.params;
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({
error: 'invalid_token',
error_description: 'Registration access token required',
});
return;
}
const token = authHeader.substring(7);
const tokenClientId = verifyRegistrationToken(token);
if (!tokenClientId || tokenClientId !== clientId) {
res.status(401).json({
error: 'invalid_token',
error_description: 'Invalid or expired registration access token',
});
return;
}
const client = findOAuthClientById(clientId);
if (!client) {
res.status(404).json({
error: 'invalid_client',
error_description: 'Client not found',
});
return;
}
// Build response
const response: any = {
client_id: client.clientId,
client_name: client.name,
redirect_uris: client.redirectUris,
grant_types: client.grants,
response_types: client.metadata?.response_types || ['code'],
scope: (client.scopes || []).join(' '),
token_endpoint_auth_method:
client.metadata?.token_endpoint_auth_method || 'client_secret_basic',
};
// Include optional metadata
if (client.metadata) {
if (client.metadata.application_type)
response.application_type = client.metadata.application_type;
if (client.metadata.contacts) response.contacts = client.metadata.contacts;
if (client.metadata.logo_uri) response.logo_uri = client.metadata.logo_uri;
if (client.metadata.client_uri) response.client_uri = client.metadata.client_uri;
if (client.metadata.policy_uri) response.policy_uri = client.metadata.policy_uri;
if (client.metadata.tos_uri) response.tos_uri = client.metadata.tos_uri;
if (client.metadata.jwks_uri) response.jwks_uri = client.metadata.jwks_uri;
if (client.metadata.jwks) response.jwks = client.metadata.jwks;
}
res.json(response);
} catch (error) {
console.error('Get client configuration error:', error);
res.status(500).json({
error: 'server_error',
error_description: 'Failed to retrieve client configuration',
});
}
};
/**
* PUT /oauth/register/:clientId
* RFC 7591 Client Update Endpoint
* Update client configuration
*/
export const updateClientConfiguration = (req: Request, res: Response): void => {
try {
const { clientId } = req.params;
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({
error: 'invalid_token',
error_description: 'Registration access token required',
});
return;
}
const token = authHeader.substring(7);
const tokenClientId = verifyRegistrationToken(token);
if (!tokenClientId || tokenClientId !== clientId) {
res.status(401).json({
error: 'invalid_token',
error_description: 'Invalid or expired registration access token',
});
return;
}
const client = findOAuthClientById(clientId);
if (!client) {
res.status(404).json({
error: 'invalid_client',
error_description: 'Client not found',
});
return;
}
const {
redirect_uris,
client_name,
grant_types,
scope,
contacts,
logo_uri,
client_uri,
policy_uri,
tos_uri,
} = req.body;
const settings = loadSettings();
const oauthConfig = settings.systemConfig?.oauthServer;
// Validate redirect URIs if provided
if (redirect_uris) {
if (!Array.isArray(redirect_uris) || redirect_uris.length === 0) {
res.status(400).json({
error: 'invalid_redirect_uri',
error_description: 'redirect_uris must be a non-empty array',
});
return;
}
for (const uri of redirect_uris) {
try {
const url = new URL(uri);
if (
url.protocol !== 'https:' &&
!url.hostname.match(/^(localhost|127\.0\.0\.1|\[::1\])$/)
) {
res.status(400).json({
error: 'invalid_redirect_uri',
error_description: `Redirect URI must use HTTPS: ${uri}`,
});
return;
}
} catch (e) {
res.status(400).json({
error: 'invalid_redirect_uri',
error_description: `Invalid redirect URI: ${uri}`,
});
return;
}
}
}
// Validate grant types if provided
if (grant_types) {
const allowedGrantTypes = oauthConfig?.dynamicRegistration?.allowedGrantTypes || [
'authorization_code',
'refresh_token',
];
for (const grantType of grant_types) {
if (!allowedGrantTypes.includes(grantType)) {
res.status(400).json({
error: 'invalid_client_metadata',
error_description: `Grant type not allowed: ${grantType}`,
});
return;
}
}
}
// Validate scopes if provided
if (scope) {
const requestedScopes = scope.split(' ');
const allowedScopes = oauthConfig?.allowedScopes || ['read', 'write'];
for (const requestedScope of requestedScopes) {
if (!allowedScopes.includes(requestedScope)) {
res.status(400).json({
error: 'invalid_client_metadata',
error_description: `Scope not allowed: ${requestedScope}`,
});
return;
}
}
}
// Build updates
const updates: Partial<IOAuthClient> = {};
if (client_name) updates.name = client_name;
if (redirect_uris) updates.redirectUris = redirect_uris;
if (grant_types) updates.grants = grant_types;
if (scope) updates.scopes = scope.split(' ');
// Update metadata
if (client.metadata || contacts || logo_uri || client_uri || policy_uri || tos_uri) {
updates.metadata = {
...client.metadata,
contacts,
logo_uri,
client_uri,
policy_uri,
tos_uri,
};
}
const updatedClient = updateOAuthClient(clientId, updates);
if (!updatedClient) {
res.status(500).json({
error: 'server_error',
error_description: 'Failed to update client',
});
return;
}
// Build response
const response: any = {
client_id: updatedClient.clientId,
client_name: updatedClient.name,
redirect_uris: updatedClient.redirectUris,
grant_types: updatedClient.grants,
response_types: updatedClient.metadata?.response_types || ['code'],
scope: (updatedClient.scopes || []).join(' '),
token_endpoint_auth_method:
updatedClient.metadata?.token_endpoint_auth_method || 'client_secret_basic',
};
// Include optional metadata
if (updatedClient.metadata) {
if (updatedClient.metadata.application_type)
response.application_type = updatedClient.metadata.application_type;
if (updatedClient.metadata.contacts) response.contacts = updatedClient.metadata.contacts;
if (updatedClient.metadata.logo_uri) response.logo_uri = updatedClient.metadata.logo_uri;
if (updatedClient.metadata.client_uri)
response.client_uri = updatedClient.metadata.client_uri;
if (updatedClient.metadata.policy_uri)
response.policy_uri = updatedClient.metadata.policy_uri;
if (updatedClient.metadata.tos_uri) response.tos_uri = updatedClient.metadata.tos_uri;
if (updatedClient.metadata.jwks_uri) response.jwks_uri = updatedClient.metadata.jwks_uri;
if (updatedClient.metadata.jwks) response.jwks = updatedClient.metadata.jwks;
}
res.json(response);
} catch (error) {
console.error('Update client configuration error:', error);
res.status(500).json({
error: 'server_error',
error_description: 'Failed to update client configuration',
});
}
};
/**
* DELETE /oauth/register/:clientId
* RFC 7591 Client Delete Endpoint
* Delete client registration
*/
export const deleteClientRegistration = (req: Request, res: Response): void => {
try {
const { clientId } = req.params;
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({
error: 'invalid_token',
error_description: 'Registration access token required',
});
return;
}
const token = authHeader.substring(7);
const tokenClientId = verifyRegistrationToken(token);
if (!tokenClientId || tokenClientId !== clientId) {
res.status(401).json({
error: 'invalid_token',
error_description: 'Invalid or expired registration access token',
});
return;
}
const deleted = deleteOAuthClient(clientId);
if (!deleted) {
res.status(404).json({
error: 'invalid_client',
error_description: 'Client not found',
});
return;
}
// Clean up registration token
registrationTokens.delete(token);
res.status(204).send();
} catch (error) {
console.error('Delete client registration error:', error);
res.status(500).json({
error: 'server_error',
error_description: 'Failed to delete client registration',
});
}
};

View File

@@ -1,525 +0,0 @@
import { Request, Response } from 'express';
import {
getOAuthServer,
handleTokenRequest,
handleAuthenticateRequest,
} from '../services/oauthServerService.js';
import { findOAuthClientById } from '../models/OAuth.js';
import { loadSettings } from '../config/index.js';
import OAuth2Server from '@node-oauth/oauth2-server';
import jwt from 'jsonwebtoken';
import { JWT_SECRET } from '../config/jwt.js';
const { Request: OAuth2Request, Response: OAuth2Response } = OAuth2Server;
type AuthenticatedUser = {
username: string;
isAdmin?: boolean;
};
/**
* Attempt to attach a user to the request based on a JWT token present in header, query, or body.
*/
function resolveUserFromRequest(req: Request): AuthenticatedUser | null {
const headerToken = req.header('x-auth-token');
const queryToken = typeof req.query.token === 'string' ? req.query.token : undefined;
const bodyToken =
req.body && typeof (req.body as Record<string, unknown>).token === 'string'
? ((req.body as Record<string, string>).token as string)
: undefined;
const token = headerToken || queryToken || bodyToken;
if (!token) {
return null;
}
try {
const decoded = jwt.verify(token, JWT_SECRET) as { user?: AuthenticatedUser };
if (decoded?.user) {
return decoded.user;
}
} catch (error) {
console.warn('Invalid JWT supplied to OAuth authorize endpoint:', error);
}
return null;
}
/**
* Helper function to escape HTML
*/
function escapeHtml(unsafe: string): string {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
/**
* Helper function to validate query parameters
*/
function validateQueryParam(value: any, name: string, pattern?: RegExp): string {
if (typeof value !== 'string') {
throw new Error(`${name} must be a string`);
}
if (pattern && !pattern.test(value)) {
throw new Error(`${name} has invalid format`);
}
return value;
}
/**
* Generate OAuth authorization consent HTML page with i18n support
* (keeps visual style consistent with OAuth callback pages)
*/
const generateAuthorizeHtml = (
title: string,
message: string,
options: {
clientName: string;
scopes: { name: string; description: string }[];
approveLabel: string;
denyLabel: string;
approveButtonLabel: string;
denyButtonLabel: string;
formFields: string;
},
): string => {
const backgroundColor = '#eef5ff';
const borderColor = '#c3d4ff';
const titleColor = '#23408f';
const approveColor = '#2563eb';
const denyColor = '#ef4444';
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>${escapeHtml(title)}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 640px; margin: 40px auto; padding: 24px; background: #f3f4f6; }
.container { background-color: ${backgroundColor}; border: 1px solid ${borderColor}; padding: 24px 28px; border-radius: 12px; box-shadow: 0 10px 25px rgba(15, 23, 42, 0.12); }
h1 { color: ${titleColor}; margin-top: 0; font-size: 22px; display: flex; align-items: center; gap: 8px; }
h1 span.icon { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 999px; background: white; border: 1px solid ${borderColor}; font-size: 16px; }
p.subtitle { margin-top: 8px; margin-bottom: 20px; color: #4b5563; font-size: 14px; }
.client-box { margin: 16px 0 20px; padding: 14px 16px; background: #eef2ff; border-radius: 10px; border: 1px solid #e5e7eb; display: flex; flex-direction: column; gap: 4px; }
.client-box-label { font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: #6b7280; }
.client-box-name { font-weight: 600; color: #111827; }
.scopes { margin: 22px 0 16px; }
.scopes-title { font-size: 13px; font-weight: 600; color: #374151; margin-bottom: 8px; }
.scope-item { padding: 8px 0; border-bottom: 1px solid #e5e7eb; display: flex; flex-direction: column; gap: 2px; }
.scope-item:last-child { border-bottom: none; }
.scope-name { font-weight: 600; font-size: 13px; color: #111827; }
.scope-description { font-size: 12px; color: #4b5563; }
.buttons { margin-top: 26px; display: flex; gap: 12px; }
.buttons form { flex: 1; }
button { width: 100%; padding: 10px 14px; border-radius: 999px; cursor: pointer; font-size: 14px; font-weight: 500; border-width: 1px; border-style: solid; transition: background-color 120ms ease, box-shadow 120ms ease, transform 60ms ease; }
button.approve { background: ${approveColor}; color: white; border-color: ${approveColor}; box-shadow: 0 4px 12px rgba(37, 99, 235, 0.35); }
button.approve:hover { background: #1d4ed8; box-shadow: 0 6px 16px rgba(37, 99, 235, 0.45); transform: translateY(-1px); }
button.deny { background: white; color: ${denyColor}; border-color: ${denyColor}; }
button.deny:hover { background: #fef2f2; }
.button-label { display: block; }
.button-sub { display: block; font-size: 11px; opacity: 0.85; }
</style>
</head>
<body>
<div class="container">
<h1><span class="icon">🔐</span>${escapeHtml(title)}</h1>
<p class="subtitle">${escapeHtml(message)}</p>
<div class="client-box">
<span class="client-box-label">${escapeHtml(options.clientName ? 'Application' : 'Client')}</span>
<span class="client-box-name">${escapeHtml(options.clientName || '')}</span>
</div>
<div class="scopes">
<div class="scopes-title">${escapeHtml('This application will be able to:')}</div>
${options.scopes
.map(
(s) => `
<div class="scope-item">
<span class="scope-name">${escapeHtml(s.name)}</span>
<span class="scope-description">${escapeHtml(s.description)}</span>
</div>
`,
)
.join('')}
</div>
<div class="buttons">
<form method="POST" action="/oauth/authorize">
${options.formFields}
<input type="hidden" name="allow" value="true" />
<button type="submit" class="approve">
<span class="button-label">${escapeHtml(options.approveLabel)}</span>
<span class="button-sub">${escapeHtml(options.approveButtonLabel)}</span>
</button>
</form>
<form method="POST" action="/oauth/authorize">
${options.formFields}
<input type="hidden" name="allow" value="false" />
<button type="submit" class="deny">
<span class="button-label">${escapeHtml(options.denyLabel)}</span>
<span class="button-sub">${escapeHtml(options.denyButtonLabel)}</span>
</button>
</form>
</div>
</div>
</body>
</html>
`;
};
/**
* GET /oauth/authorize
* Display authorization page or handle authorization
*/
export const getAuthorize = async (req: Request, res: Response): Promise<void> => {
try {
const oauth = getOAuthServer();
if (!oauth) {
res.status(503).json({ error: 'OAuth server not available' });
return;
}
// Get and validate query parameters
const client_id = validateQueryParam(req.query.client_id, 'client_id', /^[a-zA-Z0-9_-]+$/);
const redirect_uri = validateQueryParam(req.query.redirect_uri, 'redirect_uri');
const response_type = validateQueryParam(req.query.response_type, 'response_type', /^code$/);
const scope = req.query.scope
? validateQueryParam(req.query.scope, 'scope', /^[a-zA-Z0-9_ ]+$/)
: undefined;
const state = req.query.state
? validateQueryParam(req.query.state, 'state', /^[a-zA-Z0-9_-]+$/)
: undefined;
const code_challenge = req.query.code_challenge
? validateQueryParam(req.query.code_challenge, 'code_challenge', /^[a-zA-Z0-9_-]+$/)
: undefined;
const code_challenge_method = req.query.code_challenge_method
? validateQueryParam(
req.query.code_challenge_method,
'code_challenge_method',
/^(S256|plain)$/,
)
: undefined;
// Validate required parameters
if (!client_id || !redirect_uri || !response_type) {
res
.status(400)
.json({ error: 'invalid_request', error_description: 'Missing required parameters' });
return;
}
// Verify client
const client = findOAuthClientById(client_id as string);
if (!client) {
res.status(400).json({ error: 'invalid_client', error_description: 'Client not found' });
return;
}
// Verify redirect URI
if (!client.redirectUris.includes(redirect_uri as string)) {
res.status(400).json({ error: 'invalid_request', error_description: 'Invalid redirect_uri' });
return;
}
// Check if user is authenticated (including via JWT token)
let user = (req as any).user;
if (!user) {
const tokenUser = resolveUserFromRequest(req);
if (tokenUser) {
(req as any).user = tokenUser;
user = tokenUser;
}
}
if (!user) {
// Redirect to login page with return URL
const returnUrl = encodeURIComponent(req.originalUrl);
res.redirect(`/login?returnUrl=${returnUrl}`);
return;
}
const requestToken = typeof req.query.token === 'string' ? req.query.token : '';
const tokenField = requestToken
? `<input type="hidden" name="token" value="${escapeHtml(requestToken)}">`
: '';
// Get translation function from request (set by i18n middleware)
const t = (req as any).t || ((key: string) => key);
const scopes = (scope || 'read write')
.split(' ')
.filter((s) => s)
.map((s) => ({ name: s, description: getScopeDescription(s) }));
const formFields = `
<input type="hidden" name="client_id" value="${escapeHtml(client_id)}" />
<input type="hidden" name="redirect_uri" value="${escapeHtml(redirect_uri)}" />
<input type="hidden" name="response_type" value="${escapeHtml(response_type)}" />
<input type="hidden" name="scope" value="${escapeHtml(scope || '')}" />
<input type="hidden" name="state" value="${escapeHtml(state || '')}" />
${code_challenge ? `<input type="hidden" name="code_challenge" value="${escapeHtml(code_challenge)}" />` : ''}
${code_challenge_method ? `<input type="hidden" name="code_challenge_method" value="${escapeHtml(code_challenge_method)}" />` : ''}
${tokenField}
`;
// Render authorization consent page with consistent, localized styling
res.send(
generateAuthorizeHtml(
t('oauthServer.authorizeTitle') || 'Authorize Application',
t('oauthServer.authorizeSubtitle') ||
'Allow this application to access your MCPHub account.',
{
clientName: client.name,
scopes,
approveLabel: t('oauthServer.buttons.approve') || 'Allow access',
denyLabel: t('oauthServer.buttons.deny') || 'Deny',
approveButtonLabel:
t('oauthServer.buttons.approveSubtitle') ||
'Recommended if you trust this application.',
denyButtonLabel:
t('oauthServer.buttons.denySubtitle') || 'You can always grant access later.',
formFields,
},
),
);
} catch (error) {
console.error('Authorization error:', error);
res.status(500).json({ error: 'server_error', error_description: 'Internal server error' });
}
};
/**
* POST /oauth/authorize
* Handle authorization decision
*/
export const postAuthorize = async (req: Request, res: Response): Promise<void> => {
try {
const oauth = getOAuthServer();
if (!oauth) {
res.status(503).json({ error: 'OAuth server not available' });
return;
}
const { allow, redirect_uri, state } = req.body;
// If user denied
if (allow !== 'true') {
const redirectUrl = new URL(redirect_uri);
redirectUrl.searchParams.set('error', 'access_denied');
if (state) {
redirectUrl.searchParams.set('state', state);
}
res.redirect(redirectUrl.toString());
return;
}
// Get authenticated user (JWT support for browser form submissions)
let user = (req as any).user;
if (!user) {
const tokenUser = resolveUserFromRequest(req);
if (tokenUser) {
(req as any).user = tokenUser;
user = tokenUser;
}
}
if (!user) {
res.status(401).json({ error: 'unauthorized', error_description: 'User not authenticated' });
return;
}
// Create OAuth request/response
const request = new OAuth2Request(req);
const response = new OAuth2Response(res);
// Authorize the request
const code = await oauth.authorize(request, response, {
authenticateHandler: {
handle: async () => user,
},
});
// Build redirect URL with authorization code
const redirectUrl = new URL(redirect_uri);
redirectUrl.searchParams.set('code', code.authorizationCode);
if (state) {
redirectUrl.searchParams.set('state', state);
}
res.redirect(redirectUrl.toString());
} catch (error) {
console.error('Authorization error:', error);
// Handle OAuth errors
if (error instanceof Error && 'code' in error) {
const oauthError = error as any;
const redirect_uri = req.body.redirect_uri;
const state = req.body.state;
if (redirect_uri) {
const redirectUrl = new URL(redirect_uri);
redirectUrl.searchParams.set('error', oauthError.name || 'server_error');
if (oauthError.message) {
redirectUrl.searchParams.set('error_description', oauthError.message);
}
if (state) {
redirectUrl.searchParams.set('state', state);
}
res.redirect(redirectUrl.toString());
} else {
res.status(400).json({
error: oauthError.name || 'server_error',
error_description: oauthError.message || 'Internal server error',
});
}
} else {
res.status(500).json({ error: 'server_error', error_description: 'Internal server error' });
}
}
};
/**
* POST /oauth/token
* Exchange authorization code for access token
*/
export const postToken = async (req: Request, res: Response): Promise<void> => {
try {
const token = await handleTokenRequest(req, res);
res.json({
access_token: token.accessToken,
token_type: 'Bearer',
expires_in: Math.floor(((token.accessTokenExpiresAt?.getTime() || 0) - Date.now()) / 1000),
refresh_token: token.refreshToken,
scope: Array.isArray(token.scope) ? token.scope.join(' ') : token.scope,
});
} catch (error) {
console.error('Token error:', error);
if (error instanceof Error && 'code' in error) {
const oauthError = error as any;
res.status(oauthError.code || 400).json({
error: oauthError.name || 'invalid_request',
error_description: oauthError.message || 'Token request failed',
});
} else {
res.status(400).json({
error: 'invalid_request',
error_description: 'Token request failed',
});
}
}
};
/**
* GET /oauth/userinfo
* Get user info from access token (OpenID Connect compatible)
*/
export const getUserInfo = async (req: Request, res: Response): Promise<void> => {
try {
const token = await handleAuthenticateRequest(req, res);
res.json({
sub: token.user.username,
username: token.user.username,
// Add more user info as needed
});
} catch (error) {
console.error('UserInfo error:', error);
res.status(401).json({
error: 'invalid_token',
error_description: 'Invalid or expired access token',
});
}
};
/**
* GET /.well-known/oauth-authorization-server
* OAuth 2.0 Authorization Server Metadata (RFC 8414)
*/
export const getMetadata = async (req: Request, res: Response): Promise<void> => {
try {
const settings = loadSettings();
const oauthConfig = settings.systemConfig?.oauthServer;
if (!oauthConfig || !oauthConfig.enabled) {
res.status(404).json({ error: 'OAuth server not configured' });
return;
}
const baseUrl =
settings.systemConfig?.install?.baseUrl || `${req.protocol}://${req.get('host')}`;
const allowedScopes = oauthConfig.allowedScopes || ['read', 'write'];
const metadata: any = {
issuer: baseUrl,
authorization_endpoint: `${baseUrl}/oauth/authorize`,
token_endpoint: `${baseUrl}/oauth/token`,
userinfo_endpoint: `${baseUrl}/oauth/userinfo`,
scopes_supported: allowedScopes,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
token_endpoint_auth_methods_supported:
oauthConfig.requireClientSecret !== false
? ['client_secret_basic', 'client_secret_post', 'none']
: ['none'],
code_challenge_methods_supported: ['S256', 'plain'],
};
// Add dynamic registration endpoint if enabled
if (oauthConfig.dynamicRegistration?.enabled) {
metadata.registration_endpoint = `${baseUrl}/oauth/register`;
}
res.json(metadata);
} catch (error) {
console.error('Metadata error:', error);
res.status(500).json({ error: 'server_error' });
}
};
/**
* GET /.well-known/oauth-protected-resource
* OAuth 2.0 Protected Resource Metadata (RFC 9728)
* Provides information about authorization servers that protect this resource
*/
export const getProtectedResourceMetadata = async (req: Request, res: Response): Promise<void> => {
try {
const settings = loadSettings();
const oauthConfig = settings.systemConfig?.oauthServer;
if (!oauthConfig || !oauthConfig.enabled) {
res.status(404).json({ error: 'OAuth server not configured' });
return;
}
const baseUrl =
settings.systemConfig?.install?.baseUrl || `${req.protocol}://${req.get('host')}`;
const allowedScopes = oauthConfig.allowedScopes || ['read', 'write'];
// Return protected resource metadata according to RFC 9728
res.json({
resource: baseUrl,
authorization_servers: [baseUrl],
scopes_supported: allowedScopes,
bearer_methods_supported: ['header'],
});
} catch (error) {
console.error('Protected resource metadata error:', error);
res.status(500).json({ error: 'server_error' });
}
};
/**
* Helper function to get scope description
*/
function getScopeDescription(scope: string): string {
const descriptions: Record<string, string> = {
read: 'Read access to your MCP servers and tools',
write: 'Execute tools and modify MCP server configurations',
admin: 'Administrative access to all resources',
};
return descriptions[scope] || 'Access to MCPHub resources';
}

View File

@@ -100,7 +100,7 @@ export const executeToolViaOpenAPI = async (req: Request, res: Response): Promis
try {
// Decode URL-encoded parameters to handle slashes in server/tool names
const serverName = decodeURIComponent(req.params.serverName);
let toolName = decodeURIComponent(req.params.toolName);
const toolName = decodeURIComponent(req.params.toolName);
// Import handleCallToolRequest function
const { handleCallToolRequest } = await import('../services/mcpService.js');
@@ -115,11 +115,8 @@ export const executeToolViaOpenAPI = async (req: Request, res: Response): Promis
const tool = serverInfo.tools.find(
(t: any) => t.name === fullToolName || t.name === toolName,
);
if (tool) {
toolName = tool.name; // Use the matched tool's actual name (with server prefix if applicable) for the subsequent call to handleCallToolRequest.
if (tool.inputSchema) {
inputSchema = tool.inputSchema as Record<string, any>;
}
if (tool && tool.inputSchema) {
inputSchema = tool.inputSchema as Record<string, any>;
}
}
@@ -208,7 +205,7 @@ export const getGroupOpenAPISpec = async (req: Request, res: Response): Promise<
const { name } = req.params;
// Check if group exists
const group = await getGroupByIdOrName(name);
const group = getGroupByIdOrName(name);
if (!group) {
getServerOpenAPISpec(req, res);
return;

View File

@@ -1,5 +1,5 @@
import { Request, Response } from 'express';
import { ApiResponse, AddServerRequest, McpSettings } from '../types/index.js';
import { ApiResponse, AddServerRequest } from '../types/index.js';
import {
getServersInfo,
addServer,
@@ -10,10 +10,8 @@ import {
toggleServerStatus,
} from '../services/mcpService.js';
import { loadSettings, saveSettings } from '../config/index.js';
import { syncAllServerToolsEmbeddings } from '../services/vectorSearchService.js';
import { syncAllServerToolsEmbeddings, searchToolsByVector } from '../services/vectorSearchService.js';
import { createSafeJSON } from '../utils/serialization.js';
import { cloneDefaultOAuthServerConfig } from '../constants/oauthServerDefaults.js';
import { getServerDao, getGroupDao, getSystemConfigDao } from '../dao/DaoFactory.js';
export const getAllServers = async (_: Request, res: Response): Promise<void> => {
try {
@@ -32,45 +30,15 @@ export const getAllServers = async (_: Request, res: Response): Promise<void> =>
}
};
export const getAllSettings = async (_: Request, res: Response): Promise<void> => {
export const getAllSettings = (_: Request, res: Response): void => {
try {
// 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 settings = loadSettings();
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',
@@ -156,6 +124,11 @@ export const createServer = async (req: Request, res: Response): Promise<void> =
return;
}
// Set default keep-alive interval for SSE servers if not specified
if ((config.type === 'sse' || (!config.type && config.url)) && !config.keepAliveInterval) {
config.keepAliveInterval = 60000; // Default 60 seconds for SSE servers
}
// Set owner property - use current user's username, default to 'admin'
if (!config.owner) {
const currentUser = (req as any).user;
@@ -294,6 +267,11 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
return;
}
// Set default keep-alive interval for SSE servers if not specified
if ((config.type === 'sse' || (!config.type && config.url)) && !config.keepAliveInterval) {
config.keepAliveInterval = 60000; // Default 60 seconds for SSE servers
}
// Set owner property if not provided - use current user's username, default to 'admin'
if (!config.owner) {
const currentUser = (req as any).user;
@@ -324,12 +302,9 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
export const getServerConfig = async (req: Request, res: Response): Promise<void> => {
try {
const { name } = req.params;
// Get server configuration from DAO (supports both file and database modes)
const serverDao = getServerDao();
const serverConfig = await serverDao.findById(name);
if (!serverConfig) {
const allServers = await getServersInfo();
const serverInfo = allServers.find((s) => s.name === name);
if (!serverInfo) {
res.status(404).json({
success: false,
message: 'Server not found',
@@ -337,26 +312,18 @@ export const getServerConfig = async (req: Request, res: Response): Promise<void
return;
}
// Get runtime info (status, tools) from getServersInfo
const allServers = await getServersInfo();
const serverInfo = allServers.find((s) => s.name === name);
// Extract config without the name field
const { name: serverName, ...config } = serverConfig;
const response: ApiResponse = {
success: true,
data: {
name: serverName,
status: serverInfo?.status || 'disconnected',
tools: serverInfo?.tools || [],
config,
name,
status: serverInfo ? serverInfo.status : 'disconnected',
tools: serverInfo ? serverInfo.tools : [],
config: serverInfo,
},
};
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',
@@ -539,73 +506,34 @@ export const updateToolDescription = async (req: Request, res: Response): Promis
}
};
export const updateSystemConfig = async (req: Request, res: Response): Promise<void> => {
export const updateSystemConfig = (req: Request, res: Response): void => {
try {
const {
routing,
install,
smartRouting,
mcpRouter,
nameSeparator,
enableSessionRebuild,
oauthServer,
} = req.body;
const hasRoutingUpdate =
routing &&
(typeof routing.enableGlobalRoute === 'boolean' ||
typeof routing.enableGroupNameRoute === 'boolean' ||
typeof routing.enableBearerAuth === 'boolean' ||
typeof routing.bearerAuthKey === 'string' ||
typeof routing.skipAuth === 'boolean');
const hasInstallUpdate =
install &&
(typeof install.pythonIndexUrl === 'string' ||
typeof install.npmRegistry === 'string' ||
typeof install.baseUrl === 'string');
const hasSmartRoutingUpdate =
smartRouting &&
(typeof smartRouting.enabled === 'boolean' ||
typeof smartRouting.dbUrl === 'string' ||
typeof smartRouting.openaiApiBaseUrl === 'string' ||
typeof smartRouting.openaiApiKey === 'string' ||
typeof smartRouting.openaiApiEmbeddingModel === 'string');
const hasMcpRouterUpdate =
mcpRouter &&
(typeof mcpRouter.apiKey === 'string' ||
typeof mcpRouter.referer === 'string' ||
typeof mcpRouter.title === 'string' ||
typeof mcpRouter.baseUrl === 'string');
const hasNameSeparatorUpdate = typeof nameSeparator === 'string';
const hasSessionRebuildUpdate = typeof enableSessionRebuild === 'boolean';
const hasOAuthServerUpdate =
oauthServer &&
(typeof oauthServer.enabled === 'boolean' ||
typeof oauthServer.accessTokenLifetime === 'number' ||
typeof oauthServer.refreshTokenLifetime === 'number' ||
typeof oauthServer.authorizationCodeLifetime === 'number' ||
typeof oauthServer.requireClientSecret === 'boolean' ||
typeof oauthServer.requireState === 'boolean' ||
Array.isArray(oauthServer.allowedScopes) ||
(oauthServer.dynamicRegistration &&
(typeof oauthServer.dynamicRegistration.enabled === 'boolean' ||
typeof oauthServer.dynamicRegistration.requiresAuthentication === 'boolean' ||
Array.isArray(oauthServer.dynamicRegistration.allowedGrantTypes))));
const { routing, install, smartRouting, mcpRouter, nameSeparator } = req.body;
const currentUser = (req as any).user;
if (
!hasRoutingUpdate &&
!hasInstallUpdate &&
!hasSmartRoutingUpdate &&
!hasMcpRouterUpdate &&
!hasNameSeparatorUpdate &&
!hasSessionRebuildUpdate &&
!hasOAuthServerUpdate
(!routing ||
(typeof routing.enableGlobalRoute !== 'boolean' &&
typeof routing.enableGroupNameRoute !== 'boolean' &&
typeof routing.enableBearerAuth !== 'boolean' &&
typeof routing.bearerAuthKey !== 'string' &&
typeof routing.skipAuth !== 'boolean')) &&
(!install ||
(typeof install.pythonIndexUrl !== 'string' &&
typeof install.npmRegistry !== 'string' &&
typeof install.baseUrl !== 'string')) &&
(!smartRouting ||
(typeof smartRouting.enabled !== 'boolean' &&
typeof smartRouting.dbUrl !== 'string' &&
typeof smartRouting.openaiApiBaseUrl !== 'string' &&
typeof smartRouting.openaiApiKey !== 'string' &&
typeof smartRouting.openaiApiEmbeddingModel !== 'string')) &&
(!mcpRouter ||
(typeof mcpRouter.apiKey !== 'string' &&
typeof mcpRouter.referer !== 'string' &&
typeof mcpRouter.title !== 'string' &&
typeof mcpRouter.baseUrl !== 'string')) &&
typeof nameSeparator !== 'string'
) {
res.status(400).json({
success: false,
@@ -614,12 +542,9 @@ export const updateSystemConfig = async (req: Request, res: Response): Promise<v
return;
}
// Get system config from DAO (supports both file and database modes)
const systemConfigDao = getSystemConfigDao();
let systemConfig = await systemConfigDao.get();
if (!systemConfig) {
systemConfig = {
const settings = loadSettings();
if (!settings.systemConfig) {
settings.systemConfig = {
routing: {
enableGlobalRoute: true,
enableGroupNameRoute: true,
@@ -645,12 +570,11 @@ export const updateSystemConfig = async (req: Request, res: Response): Promise<v
title: 'MCPHub',
baseUrl: 'https://api.mcprouter.to/v1',
},
oauthServer: cloneDefaultOAuthServerConfig(),
};
}
if (!systemConfig.routing) {
systemConfig.routing = {
if (!settings.systemConfig.routing) {
settings.systemConfig.routing = {
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: false,
@@ -659,16 +583,16 @@ export const updateSystemConfig = async (req: Request, res: Response): Promise<v
};
}
if (!systemConfig.install) {
systemConfig.install = {
if (!settings.systemConfig.install) {
settings.systemConfig.install = {
pythonIndexUrl: '',
npmRegistry: '',
baseUrl: 'http://localhost:3000',
};
}
if (!systemConfig.smartRouting) {
systemConfig.smartRouting = {
if (!settings.systemConfig.smartRouting) {
settings.systemConfig.smartRouting = {
enabled: false,
dbUrl: '',
openaiApiBaseUrl: '',
@@ -677,8 +601,8 @@ export const updateSystemConfig = async (req: Request, res: Response): Promise<v
};
}
if (!systemConfig.mcpRouter) {
systemConfig.mcpRouter = {
if (!settings.systemConfig.mcpRouter) {
settings.systemConfig.mcpRouter = {
apiKey: '',
referer: 'https://www.mcphubx.com',
title: 'MCPHub',
@@ -686,74 +610,52 @@ export const updateSystemConfig = async (req: Request, res: Response): Promise<v
};
}
if (!systemConfig.oauthServer) {
systemConfig.oauthServer = cloneDefaultOAuthServerConfig();
}
if (!systemConfig.oauthServer.dynamicRegistration) {
const defaultConfig = cloneDefaultOAuthServerConfig();
const defaultDynamic = defaultConfig.dynamicRegistration ?? {
enabled: false,
allowedGrantTypes: [],
requiresAuthentication: false,
};
systemConfig.oauthServer.dynamicRegistration = {
enabled: defaultDynamic.enabled ?? false,
allowedGrantTypes: [
...(Array.isArray(defaultDynamic.allowedGrantTypes)
? defaultDynamic.allowedGrantTypes
: []),
],
requiresAuthentication: defaultDynamic.requiresAuthentication ?? false,
};
}
if (routing) {
if (typeof routing.enableGlobalRoute === 'boolean') {
systemConfig.routing.enableGlobalRoute = routing.enableGlobalRoute;
settings.systemConfig.routing.enableGlobalRoute = routing.enableGlobalRoute;
}
if (typeof routing.enableGroupNameRoute === 'boolean') {
systemConfig.routing.enableGroupNameRoute = routing.enableGroupNameRoute;
settings.systemConfig.routing.enableGroupNameRoute = routing.enableGroupNameRoute;
}
if (typeof routing.enableBearerAuth === 'boolean') {
systemConfig.routing.enableBearerAuth = routing.enableBearerAuth;
settings.systemConfig.routing.enableBearerAuth = routing.enableBearerAuth;
}
if (typeof routing.bearerAuthKey === 'string') {
systemConfig.routing.bearerAuthKey = routing.bearerAuthKey;
settings.systemConfig.routing.bearerAuthKey = routing.bearerAuthKey;
}
if (typeof routing.skipAuth === 'boolean') {
systemConfig.routing.skipAuth = routing.skipAuth;
settings.systemConfig.routing.skipAuth = routing.skipAuth;
}
}
if (install) {
if (typeof install.pythonIndexUrl === 'string') {
systemConfig.install.pythonIndexUrl = install.pythonIndexUrl;
settings.systemConfig.install.pythonIndexUrl = install.pythonIndexUrl;
}
if (typeof install.npmRegistry === 'string') {
systemConfig.install.npmRegistry = install.npmRegistry;
settings.systemConfig.install.npmRegistry = install.npmRegistry;
}
if (typeof install.baseUrl === 'string') {
systemConfig.install.baseUrl = install.baseUrl;
settings.systemConfig.install.baseUrl = install.baseUrl;
}
}
// Track smartRouting state and configuration changes
const wasSmartRoutingEnabled = systemConfig.smartRouting.enabled || false;
const previousSmartRoutingConfig = { ...systemConfig.smartRouting };
const wasSmartRoutingEnabled = settings.systemConfig.smartRouting.enabled || false;
const previousSmartRoutingConfig = { ...settings.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 || systemConfig.smartRouting.dbUrl;
const currentDbUrl = smartRouting.dbUrl || settings.systemConfig.smartRouting.dbUrl;
const currentOpenaiApiKey =
smartRouting.openaiApiKey || systemConfig.smartRouting.openaiApiKey;
smartRouting.openaiApiKey || settings.systemConfig.smartRouting.openaiApiKey;
if (!currentDbUrl || !currentOpenaiApiKey) {
const missingFields = [];
@@ -767,30 +669,32 @@ export const updateSystemConfig = async (req: Request, res: Response): Promise<v
return;
}
}
systemConfig.smartRouting.enabled = smartRouting.enabled;
settings.systemConfig.smartRouting.enabled = smartRouting.enabled;
}
if (typeof smartRouting.dbUrl === 'string') {
systemConfig.smartRouting.dbUrl = smartRouting.dbUrl;
settings.systemConfig.smartRouting.dbUrl = smartRouting.dbUrl;
}
if (typeof smartRouting.openaiApiBaseUrl === 'string') {
systemConfig.smartRouting.openaiApiBaseUrl = smartRouting.openaiApiBaseUrl;
settings.systemConfig.smartRouting.openaiApiBaseUrl = smartRouting.openaiApiBaseUrl;
}
if (typeof smartRouting.openaiApiKey === 'string') {
systemConfig.smartRouting.openaiApiKey = smartRouting.openaiApiKey;
settings.systemConfig.smartRouting.openaiApiKey = smartRouting.openaiApiKey;
}
if (typeof smartRouting.openaiApiEmbeddingModel === 'string') {
systemConfig.smartRouting.openaiApiEmbeddingModel = smartRouting.openaiApiEmbeddingModel;
settings.systemConfig.smartRouting.openaiApiEmbeddingModel =
smartRouting.openaiApiEmbeddingModel;
}
// Check if we need to sync embeddings
const isNowEnabled = systemConfig.smartRouting.enabled || false;
const isNowEnabled = settings.systemConfig.smartRouting.enabled || false;
const hasConfigChanged =
previousSmartRoutingConfig.dbUrl !== systemConfig.smartRouting.dbUrl ||
previousSmartRoutingConfig.dbUrl !== settings.systemConfig.smartRouting.dbUrl ||
previousSmartRoutingConfig.openaiApiBaseUrl !==
systemConfig.smartRouting.openaiApiBaseUrl ||
previousSmartRoutingConfig.openaiApiKey !== systemConfig.smartRouting.openaiApiKey ||
settings.systemConfig.smartRouting.openaiApiBaseUrl ||
previousSmartRoutingConfig.openaiApiKey !==
settings.systemConfig.smartRouting.openaiApiKey ||
previousSmartRoutingConfig.openaiApiEmbeddingModel !==
systemConfig.smartRouting.openaiApiEmbeddingModel;
settings.systemConfig.smartRouting.openaiApiEmbeddingModel;
// Sync if: first time enabling OR smart routing is enabled and any config changed
needsSync = (!wasSmartRoutingEnabled && isNowEnabled) || (isNowEnabled && hasConfigChanged);
@@ -798,87 +702,27 @@ export const updateSystemConfig = async (req: Request, res: Response): Promise<v
if (mcpRouter) {
if (typeof mcpRouter.apiKey === 'string') {
systemConfig.mcpRouter.apiKey = mcpRouter.apiKey;
settings.systemConfig.mcpRouter.apiKey = mcpRouter.apiKey;
}
if (typeof mcpRouter.referer === 'string') {
systemConfig.mcpRouter.referer = mcpRouter.referer;
settings.systemConfig.mcpRouter.referer = mcpRouter.referer;
}
if (typeof mcpRouter.title === 'string') {
systemConfig.mcpRouter.title = mcpRouter.title;
settings.systemConfig.mcpRouter.title = mcpRouter.title;
}
if (typeof mcpRouter.baseUrl === 'string') {
systemConfig.mcpRouter.baseUrl = mcpRouter.baseUrl;
}
}
if (oauthServer) {
const target = systemConfig.oauthServer;
if (typeof oauthServer.enabled === 'boolean') {
target.enabled = oauthServer.enabled;
}
if (typeof oauthServer.accessTokenLifetime === 'number') {
target.accessTokenLifetime = oauthServer.accessTokenLifetime;
}
if (typeof oauthServer.refreshTokenLifetime === 'number') {
target.refreshTokenLifetime = oauthServer.refreshTokenLifetime;
}
if (typeof oauthServer.authorizationCodeLifetime === 'number') {
target.authorizationCodeLifetime = oauthServer.authorizationCodeLifetime;
}
if (typeof oauthServer.requireClientSecret === 'boolean') {
target.requireClientSecret = oauthServer.requireClientSecret;
}
if (typeof oauthServer.requireState === 'boolean') {
target.requireState = oauthServer.requireState;
}
if (Array.isArray(oauthServer.allowedScopes)) {
target.allowedScopes = oauthServer.allowedScopes
.filter((scope: any): scope is string => typeof scope === 'string')
.map((scope: string) => scope.trim())
.filter((scope: string) => scope.length > 0);
}
if (oauthServer.dynamicRegistration) {
const dynamicTarget = target.dynamicRegistration || {
enabled: false,
allowedGrantTypes: ['authorization_code', 'refresh_token'],
requiresAuthentication: false,
};
if (typeof oauthServer.dynamicRegistration.enabled === 'boolean') {
dynamicTarget.enabled = oauthServer.dynamicRegistration.enabled;
}
if (Array.isArray(oauthServer.dynamicRegistration.allowedGrantTypes)) {
dynamicTarget.allowedGrantTypes = oauthServer.dynamicRegistration.allowedGrantTypes
.filter((grant: any): grant is string => typeof grant === 'string')
.map((grant: string) => grant.trim())
.filter((grant: string) => grant.length > 0);
}
if (typeof oauthServer.dynamicRegistration.requiresAuthentication === 'boolean') {
dynamicTarget.requiresAuthentication =
oauthServer.dynamicRegistration.requiresAuthentication;
}
target.dynamicRegistration = dynamicTarget;
settings.systemConfig.mcpRouter.baseUrl = mcpRouter.baseUrl;
}
}
if (typeof nameSeparator === 'string') {
systemConfig.nameSeparator = nameSeparator;
settings.systemConfig.nameSeparator = nameSeparator;
}
if (typeof enableSessionRebuild === 'boolean') {
systemConfig.enableSessionRebuild = enableSessionRebuild;
}
// Save using DAO (supports both file and database modes)
try {
await systemConfigDao.update(systemConfig);
if (saveSettings(settings, currentUser)) {
res.json({
success: true,
data: systemConfig,
data: settings.systemConfig,
message: 'System configuration updated successfully',
});
@@ -890,8 +734,7 @@ export const updateSystemConfig = async (req: Request, res: Response): Promise<v
console.error('Failed to sync server tools embeddings:', error);
});
}
} catch (saveError) {
console.error('Failed to save system configuration:', saveError);
} else {
res.status(500).json({
success: false,
message: 'Failed to save system configuration',
@@ -1036,3 +879,74 @@ export const updatePromptDescription = async (req: Request, res: Response): Prom
});
}
};
/**
* Search servers by semantic query using vector embeddings
* This searches through server tools and returns servers that match the query
*/
export const searchServers = async (req: Request, res: Response): Promise<void> => {
try {
const { query, limit = 10, threshold = 0.65 } = req.query;
if (!query || typeof query !== 'string') {
res.status(400).json({
success: false,
message: 'Search query is required',
});
return;
}
// Parse limit and threshold
const limitNum = typeof limit === 'string' ? parseInt(limit, 10) : Number(limit);
const thresholdNum = typeof threshold === 'string' ? parseFloat(threshold) : Number(threshold);
// Validate limit and threshold
if (isNaN(limitNum) || limitNum < 1 || limitNum > 100) {
res.status(400).json({
success: false,
message: 'Limit must be between 1 and 100',
});
return;
}
if (isNaN(thresholdNum) || thresholdNum < 0 || thresholdNum > 1) {
res.status(400).json({
success: false,
message: 'Threshold must be between 0 and 1',
});
return;
}
// Search for tools that match the query
const searchResults = await searchToolsByVector(query, limitNum, thresholdNum);
// Extract unique server names from search results
const serverNames = Array.from(new Set(searchResults.map((result) => result.serverName)));
// Get full server information for the matching servers
const allServers = await getServersInfo();
const matchingServers = allServers.filter((server) => serverNames.includes(server.name));
const response: ApiResponse = {
success: true,
data: {
servers: createSafeJSON(matchingServers),
matches: searchResults.map((result) => ({
serverName: result.serverName,
toolName: result.toolName,
similarity: result.similarity,
})),
query,
threshold: thresholdNum,
},
};
res.json(response);
} catch (error) {
console.error('Failed to search servers:', error);
res.status(500).json({
success: false,
message: 'Failed to search servers',
});
}
};

View File

@@ -9,14 +9,13 @@ import {
getUserCount,
getAdminCount,
} from '../services/userService.js';
import { getSystemConfigDao } from '../dao/index.js';
import { loadSettings } from '../config/index.js';
import { validatePasswordStrength } from '../utils/passwordValidation.js';
// Admin permission check middleware function
const requireAdmin = async (req: Request, res: Response): Promise<boolean> => {
const systemConfigDao = getSystemConfigDao();
const systemConfig = await systemConfigDao.get();
if (systemConfig?.routing?.skipAuth) {
const requireAdmin = (req: Request, res: Response): boolean => {
const settings = loadSettings();
if (settings.systemConfig?.routing?.skipAuth) {
return true;
}
@@ -32,11 +31,11 @@ const requireAdmin = async (req: Request, res: Response): Promise<boolean> => {
};
// Get all users (admin only)
export const getUsers = async (req: Request, res: Response): Promise<void> => {
if (!(await requireAdmin(req, res))) return;
export const getUsers = (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
try {
const users = (await getAllUsers()).map(({ password: _, ...user }) => user); // Remove password from response
const users = getAllUsers().map(({ password: _, ...user }) => user); // Remove password from response
const response: ApiResponse = {
success: true,
data: users,
@@ -51,8 +50,8 @@ export const getUsers = async (req: Request, res: Response): Promise<void> => {
};
// Get a specific user by username (admin only)
export const getUser = async (req: Request, res: Response): Promise<void> => {
if (!(await requireAdmin(req, res))) return;
export const getUser = (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
try {
const { username } = req.params;
@@ -64,7 +63,7 @@ export const getUser = async (req: Request, res: Response): Promise<void> => {
return;
}
const user = await getUserByUsername(username);
const user = getUserByUsername(username);
if (!user) {
res.status(404).json({
success: false,
@@ -89,7 +88,7 @@ export const getUser = async (req: Request, res: Response): Promise<void> => {
// Create a new user (admin only)
export const createUser = async (req: Request, res: Response): Promise<void> => {
if (!(await requireAdmin(req, res))) return;
if (!requireAdmin(req, res)) return;
try {
const { username, password, isAdmin } = req.body;
@@ -139,7 +138,7 @@ export const createUser = async (req: Request, res: Response): Promise<void> =>
// Update an existing user (admin only)
export const updateExistingUser = async (req: Request, res: Response): Promise<void> => {
if (!(await requireAdmin(req, res))) return;
if (!requireAdmin(req, res)) return;
try {
const { username } = req.params;
@@ -155,7 +154,7 @@ export const updateExistingUser = async (req: Request, res: Response): Promise<v
// Check if trying to change admin status
if (isAdmin !== undefined) {
const currentUser = await getUserByUsername(username);
const currentUser = getUserByUsername(username);
if (!currentUser) {
res.status(404).json({
success: false,
@@ -165,7 +164,7 @@ export const updateExistingUser = async (req: Request, res: Response): Promise<v
}
// Prevent removing admin status from the last admin
if (currentUser.isAdmin && !isAdmin && (await getAdminCount()) === 1) {
if (currentUser.isAdmin && !isAdmin && getAdminCount() === 1) {
res.status(400).json({
success: false,
message: 'Cannot remove admin status from the last admin user',
@@ -223,8 +222,8 @@ export const updateExistingUser = async (req: Request, res: Response): Promise<v
};
// Delete a user (admin only)
export const deleteExistingUser = async (req: Request, res: Response): Promise<void> => {
if (!(await requireAdmin(req, res))) return;
export const deleteExistingUser = (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
try {
const { username } = req.params;
@@ -246,7 +245,7 @@ export const deleteExistingUser = async (req: Request, res: Response): Promise<v
return;
}
const success = await deleteUser(username);
const success = deleteUser(username);
if (!success) {
res.status(400).json({
success: false,
@@ -268,12 +267,12 @@ export const deleteExistingUser = async (req: Request, res: Response): Promise<v
};
// Get user statistics (admin only)
export const getUserStats = async (req: Request, res: Response): Promise<void> => {
if (!(await requireAdmin(req, res))) return;
export const getUserStats = (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
try {
const totalUsers = await getUserCount();
const adminUsers = await getAdminCount();
const totalUsers = getUserCount();
const adminUsers = getAdminCount();
const regularUsers = totalUsers - adminUsers;
const response: ApiResponse = {

View File

@@ -107,26 +107,6 @@ 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
*/

View File

@@ -1,79 +0,0 @@
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;
}
}

View File

@@ -1,154 +0,0 @@
import { GroupDao } from './index.js';
import { IGroup } from '../types/index.js';
import { GroupRepository } from '../db/repositories/GroupRepository.js';
/**
* Database-backed implementation of GroupDao
*/
export class GroupDaoDbImpl implements GroupDao {
private repository: GroupRepository;
constructor() {
this.repository = new GroupRepository();
}
async findAll(): Promise<IGroup[]> {
const groups = await this.repository.findAll();
return groups.map((g) => ({
id: g.id,
name: g.name,
description: g.description,
servers: g.servers as any,
owner: g.owner,
}));
}
async findById(id: string): Promise<IGroup | null> {
const group = await this.repository.findById(id);
if (!group) return null;
return {
id: group.id,
name: group.name,
description: group.description,
servers: group.servers as any,
owner: group.owner,
};
}
async create(entity: Omit<IGroup, 'id'>): Promise<IGroup> {
const group = await this.repository.create({
name: entity.name,
description: entity.description,
servers: entity.servers as any,
owner: entity.owner,
});
return {
id: group.id,
name: group.name,
description: group.description,
servers: group.servers as any,
owner: group.owner,
};
}
async update(id: string, entity: Partial<IGroup>): Promise<IGroup | null> {
const group = await this.repository.update(id, {
name: entity.name,
description: entity.description,
servers: entity.servers as any,
owner: entity.owner,
});
if (!group) return null;
return {
id: group.id,
name: group.name,
description: group.description,
servers: group.servers as any,
owner: group.owner,
};
}
async delete(id: string): Promise<boolean> {
return await this.repository.delete(id);
}
async exists(id: string): Promise<boolean> {
return await this.repository.exists(id);
}
async count(): Promise<number> {
return await this.repository.count();
}
async findByOwner(owner: string): Promise<IGroup[]> {
const groups = await this.repository.findByOwner(owner);
return groups.map((g) => ({
id: g.id,
name: g.name,
description: g.description,
servers: g.servers as any,
owner: g.owner,
}));
}
async findByServer(serverName: string): Promise<IGroup[]> {
const allGroups = await this.repository.findAll();
return allGroups
.filter((g) =>
g.servers.some((s) => (typeof s === 'string' ? s === serverName : s.name === serverName)),
)
.map((g) => ({
id: g.id,
name: g.name,
description: g.description,
servers: g.servers as any,
owner: g.owner,
}));
}
async addServerToGroup(groupId: string, serverName: string): Promise<boolean> {
const group = await this.repository.findById(groupId);
if (!group) return false;
// Check if server already exists
const serverExists = group.servers.some((s) =>
typeof s === 'string' ? s === serverName : s.name === serverName,
);
if (!serverExists) {
group.servers.push(serverName);
await this.update(groupId, { servers: group.servers as any });
}
return true;
}
async removeServerFromGroup(groupId: string, serverName: string): Promise<boolean> {
const group = await this.repository.findById(groupId);
if (!group) return false;
group.servers = group.servers.filter((s) =>
typeof s === 'string' ? s !== serverName : s.name !== serverName,
) as any;
await this.update(groupId, { servers: group.servers as any });
return true;
}
async updateServers(groupId: string, servers: string[] | IGroup['servers']): Promise<boolean> {
const result = await this.update(groupId, { servers: servers as any });
return result !== null;
}
async findByName(name: string): Promise<IGroup | null> {
const group = await this.repository.findByName(name);
if (!group) return null;
return {
id: group.id,
name: group.name,
description: group.description,
servers: group.servers as any,
owner: group.owner,
};
}
}

View File

@@ -1,144 +0,0 @@
import { ServerDao, ServerConfigWithName } from './index.js';
import { ServerRepository } from '../db/repositories/ServerRepository.js';
/**
* Database-backed implementation of ServerDao
*/
export class ServerDaoDbImpl implements ServerDao {
private repository: ServerRepository;
constructor() {
this.repository = new ServerRepository();
}
async findAll(): Promise<ServerConfigWithName[]> {
const servers = await this.repository.findAll();
return servers.map((s) => this.mapToServerConfig(s));
}
async findById(name: string): Promise<ServerConfigWithName | null> {
const server = await this.repository.findByName(name);
return server ? this.mapToServerConfig(server) : null;
}
async create(entity: ServerConfigWithName): Promise<ServerConfigWithName> {
const server = await this.repository.create({
name: entity.name,
type: entity.type,
url: entity.url,
command: entity.command,
args: entity.args,
env: entity.env,
headers: entity.headers,
enabled: entity.enabled !== undefined ? entity.enabled : true,
owner: entity.owner,
keepAliveInterval: entity.keepAliveInterval,
tools: entity.tools,
prompts: entity.prompts,
options: entity.options,
oauth: entity.oauth,
});
return this.mapToServerConfig(server);
}
async update(name: string, entity: Partial<ServerConfigWithName>): Promise<ServerConfigWithName | null> {
const server = await this.repository.update(name, {
type: entity.type,
url: entity.url,
command: entity.command,
args: entity.args,
env: entity.env,
headers: entity.headers,
enabled: entity.enabled,
owner: entity.owner,
keepAliveInterval: entity.keepAliveInterval,
tools: entity.tools,
prompts: entity.prompts,
options: entity.options,
oauth: entity.oauth,
});
return server ? this.mapToServerConfig(server) : null;
}
async delete(name: string): Promise<boolean> {
return await this.repository.delete(name);
}
async exists(name: string): Promise<boolean> {
return await this.repository.exists(name);
}
async count(): Promise<number> {
return await this.repository.count();
}
async findByOwner(owner: string): Promise<ServerConfigWithName[]> {
const servers = await this.repository.findByOwner(owner);
return servers.map((s) => this.mapToServerConfig(s));
}
async findEnabled(): Promise<ServerConfigWithName[]> {
const servers = await this.repository.findEnabled();
return servers.map((s) => this.mapToServerConfig(s));
}
async findByType(type: string): Promise<ServerConfigWithName[]> {
const allServers = await this.repository.findAll();
return allServers.filter((s) => s.type === type).map((s) => this.mapToServerConfig(s));
}
async setEnabled(name: string, enabled: boolean): Promise<boolean> {
const server = await this.repository.setEnabled(name, enabled);
return server !== null;
}
async updateTools(
name: string,
tools: Record<string, { enabled: boolean; description?: string }>,
): Promise<boolean> {
const result = await this.update(name, { tools });
return result !== null;
}
async updatePrompts(
name: string,
prompts: Record<string, { enabled: boolean; description?: string }>,
): Promise<boolean> {
const result = await this.update(name, { prompts });
return result !== null;
}
private mapToServerConfig(server: {
name: string;
type?: string;
url?: string;
command?: string;
args?: string[];
env?: Record<string, string>;
headers?: Record<string, string>;
enabled: boolean;
owner?: string;
keepAliveInterval?: number;
tools?: Record<string, { enabled: boolean; description?: string }>;
prompts?: Record<string, { enabled: boolean; description?: string }>;
options?: Record<string, any>;
oauth?: Record<string, any>;
}): ServerConfigWithName {
return {
name: server.name,
type: server.type as 'stdio' | 'sse' | 'streamable-http' | 'openapi' | undefined,
url: server.url,
command: server.command,
args: server.args,
env: server.env,
headers: server.headers,
enabled: server.enabled,
owner: server.owner,
keepAliveInterval: server.keepAliveInterval,
tools: server.tools,
prompts: server.prompts,
options: server.options,
oauth: server.oauth,
};
}
}

View File

@@ -1,68 +0,0 @@
import { SystemConfigDao } from './index.js';
import { SystemConfig } from '../types/index.js';
import { SystemConfigRepository } from '../db/repositories/SystemConfigRepository.js';
/**
* Database-backed implementation of SystemConfigDao
*/
export class SystemConfigDaoDbImpl implements SystemConfigDao {
private repository: SystemConfigRepository;
constructor() {
this.repository = new SystemConfigRepository();
}
async get(): Promise<SystemConfig> {
const config = await this.repository.get();
return {
routing: config.routing as any,
install: config.install as any,
smartRouting: config.smartRouting as any,
mcpRouter: config.mcpRouter as any,
nameSeparator: config.nameSeparator,
oauth: config.oauth as any,
oauthServer: config.oauthServer as any,
enableSessionRebuild: config.enableSessionRebuild,
};
}
async update(config: Partial<SystemConfig>): Promise<SystemConfig> {
const updated = await this.repository.update(config as any);
return {
routing: updated.routing as any,
install: updated.install as any,
smartRouting: updated.smartRouting as any,
mcpRouter: updated.mcpRouter as any,
nameSeparator: updated.nameSeparator,
oauth: updated.oauth as any,
oauthServer: updated.oauthServer as any,
enableSessionRebuild: updated.enableSessionRebuild,
};
}
async reset(): Promise<SystemConfig> {
const config = await this.repository.reset();
return {
routing: config.routing as any,
install: config.install as any,
smartRouting: config.smartRouting as any,
mcpRouter: config.mcpRouter as any,
nameSeparator: config.nameSeparator,
oauth: config.oauth as any,
oauthServer: config.oauthServer as any,
enableSessionRebuild: config.enableSessionRebuild,
};
}
async getSection<K extends keyof SystemConfig>(section: K): Promise<SystemConfig[K]> {
return (await this.repository.getSection(section)) as any;
}
async updateSection<K extends keyof SystemConfig>(
section: K,
value: SystemConfig[K],
): Promise<boolean> {
await this.repository.updateSection(section, value as any);
return true;
}
}

View File

@@ -1,79 +0,0 @@
import { UserConfigDao } from './index.js';
import { UserConfig } from '../types/index.js';
import { UserConfigRepository } from '../db/repositories/UserConfigRepository.js';
/**
* Database-backed implementation of UserConfigDao
*/
export class UserConfigDaoDbImpl implements UserConfigDao {
private repository: UserConfigRepository;
constructor() {
this.repository = new UserConfigRepository();
}
async getAll(): Promise<Record<string, UserConfig>> {
const configs = await this.repository.getAll();
const result: Record<string, UserConfig> = {};
for (const [username, config] of Object.entries(configs)) {
result[username] = {
routing: config.routing,
...config.additionalConfig,
};
}
return result;
}
async get(username: string): Promise<UserConfig> {
const config = await this.repository.get(username);
if (!config) {
return { routing: {} };
}
return {
routing: config.routing,
...config.additionalConfig,
};
}
async update(username: string, config: Partial<UserConfig>): Promise<UserConfig> {
const { routing, ...additionalConfig } = config;
const updated = await this.repository.update(username, {
routing,
additionalConfig,
});
return {
routing: updated.routing,
...updated.additionalConfig,
};
}
async delete(username: string): Promise<boolean> {
return await this.repository.delete(username);
}
async getSection<K extends keyof UserConfig>(username: string, section: K): Promise<UserConfig[K]> {
const config = await this.get(username);
return config[section];
}
async updateSection<K extends keyof UserConfig>(
username: string,
section: K,
value: UserConfig[K],
): Promise<boolean> {
await this.update(username, { [section]: value } as Partial<UserConfig>);
return true;
}
async exists(username: string): Promise<boolean> {
const config = await this.repository.get(username);
return config !== null;
}
async reset(username: string): Promise<UserConfig> {
await this.repository.delete(username);
return { routing: {} };
}
}

View File

@@ -1,108 +0,0 @@
import bcrypt from 'bcrypt';
import { UserDao } from './index.js';
import { IUser } from '../types/index.js';
import { UserRepository } from '../db/repositories/UserRepository.js';
/**
* Database-backed implementation of UserDao
*/
export class UserDaoDbImpl implements UserDao {
private repository: UserRepository;
constructor() {
this.repository = new UserRepository();
}
async findAll(): Promise<IUser[]> {
const users = await this.repository.findAll();
return users.map((u) => ({
username: u.username,
password: u.password,
isAdmin: u.isAdmin,
}));
}
async findById(username: string): Promise<IUser | null> {
const user = await this.repository.findByUsername(username);
if (!user) return null;
return {
username: user.username,
password: user.password,
isAdmin: user.isAdmin,
};
}
async findByUsername(username: string): Promise<IUser | null> {
return await this.findById(username);
}
async create(entity: Omit<IUser, 'id'>): Promise<IUser> {
const user = await this.repository.create({
username: entity.username,
password: entity.password,
isAdmin: entity.isAdmin || false,
});
return {
username: user.username,
password: user.password,
isAdmin: user.isAdmin,
};
}
async createWithHashedPassword(
username: string,
password: string,
isAdmin: boolean,
): Promise<IUser> {
const hashedPassword = await bcrypt.hash(password, 10);
return await this.create({ username, password: hashedPassword, isAdmin });
}
async update(username: string, entity: Partial<IUser>): Promise<IUser | null> {
const user = await this.repository.update(username, {
password: entity.password,
isAdmin: entity.isAdmin,
});
if (!user) return null;
return {
username: user.username,
password: user.password,
isAdmin: user.isAdmin,
};
}
async delete(username: string): Promise<boolean> {
return await this.repository.delete(username);
}
async exists(username: string): Promise<boolean> {
return await this.repository.exists(username);
}
async count(): Promise<number> {
return await this.repository.count();
}
async validateCredentials(username: string, password: string): Promise<boolean> {
const user = await this.findByUsername(username);
if (!user) {
return false;
}
return await bcrypt.compare(password, user.password);
}
async updatePassword(username: string, newPassword: string): Promise<boolean> {
const hashedPassword = await bcrypt.hash(newPassword, 10);
const result = await this.update(username, { password: hashedPassword });
return result !== null;
}
async findAdmins(): Promise<IUser[]> {
const users = await this.repository.findAdmins();
return users.map((u) => ({
username: u.username,
password: u.password,
isAdmin: u.isAdmin,
}));
}
}

View File

@@ -187,7 +187,7 @@ export async function exampleUserConfigOperations() {
console.log('All user configs:', Object.keys(allUserConfigs));
// Get specific section for user
const userRoutingConfig = await userConfigDao.getSection('admin', 'routing' as never);
const userRoutingConfig = await userConfigDao.getSection('admin', 'routing');
console.log('Admin routing config:', userRoutingConfig);
// Delete user configuration

View File

@@ -7,13 +7,5 @@ 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';

View File

@@ -1,36 +0,0 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
/**
* Group entity for database storage
*/
@Entity({ name: 'groups' })
export class Group {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 255 })
name: string;
@Column({ type: 'text', nullable: true })
description?: string;
@Column({ type: 'simple-json' })
servers: Array<string | { name: string; tools?: string[] | 'all' }>;
@Column({ type: 'varchar', length: 255, nullable: true })
owner?: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
updatedAt: Date;
}
export default Group;

View File

@@ -1,66 +0,0 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
/**
* Server configuration entity for database storage
*/
@Entity({ name: 'servers' })
export class Server {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 255, unique: true })
name: string;
@Column({ type: 'varchar', length: 50, nullable: true })
type?: string; // 'stdio', 'sse', 'streamable-http', 'openapi'
@Column({ type: 'text', nullable: true })
url?: string;
@Column({ type: 'varchar', length: 500, nullable: true })
command?: string;
@Column({ type: 'simple-json', nullable: true })
args?: string[];
@Column({ type: 'simple-json', nullable: true })
env?: Record<string, string>;
@Column({ type: 'simple-json', nullable: true })
headers?: Record<string, string>;
@Column({ type: 'boolean', default: true })
enabled: boolean;
@Column({ type: 'varchar', length: 255, nullable: true })
owner?: string;
@Column({ type: 'int', nullable: true })
keepAliveInterval?: number;
@Column({ type: 'simple-json', nullable: true })
tools?: Record<string, { enabled: boolean; description?: string }>;
@Column({ type: 'simple-json', nullable: true })
prompts?: Record<string, { enabled: boolean; description?: string }>;
@Column({ type: 'simple-json', nullable: true })
options?: Record<string, any>;
@Column({ type: 'simple-json', nullable: true })
oauth?: Record<string, any>;
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
updatedAt: Date;
}
export default Server;

View File

@@ -1,43 +0,0 @@
import { Entity, Column, PrimaryColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';
/**
* System configuration entity for database storage
* Using singleton pattern - only one record with id = 'default'
*/
@Entity({ name: 'system_config' })
export class SystemConfig {
@PrimaryColumn({ type: 'varchar', length: 50, default: 'default' })
id: string;
@Column({ type: 'simple-json', nullable: true })
routing?: Record<string, any>;
@Column({ type: 'simple-json', nullable: true })
install?: Record<string, any>;
@Column({ type: 'simple-json', nullable: true })
smartRouting?: Record<string, any>;
@Column({ type: 'simple-json', nullable: true })
mcpRouter?: Record<string, any>;
@Column({ type: 'varchar', length: 10, nullable: true })
nameSeparator?: string;
@Column({ type: 'simple-json', nullable: true })
oauth?: Record<string, any>;
@Column({ type: 'simple-json', nullable: true })
oauthServer?: Record<string, any>;
@Column({ type: 'boolean', nullable: true })
enableSessionRebuild?: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
updatedAt: Date;
}
export default SystemConfig;

View File

@@ -1,33 +0,0 @@
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;

View File

@@ -1,33 +0,0 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
/**
* User configuration entity for database storage
*/
@Entity({ name: 'user_configs' })
export class UserConfig {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 255, unique: true })
username: string;
@Column({ type: 'simple-json', nullable: true })
routing?: Record<string, any>;
@Column({ type: 'simple-json', nullable: true })
additionalConfig?: Record<string, any>;
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
updatedAt: Date;
}
export default UserConfig;

View File

@@ -1,12 +1,7 @@
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, User, Server, Group, SystemConfig, UserConfig];
export default [VectorEmbedding];
// Export individual entities for direct use
export { VectorEmbedding, User, Server, Group, SystemConfig, UserConfig };
export { VectorEmbedding };

View File

@@ -1,95 +0,0 @@
import { Repository } from 'typeorm';
import { Group } from '../entities/Group.js';
import { getAppDataSource } from '../connection.js';
/**
* Repository for Group entity
*/
export class GroupRepository {
private repository: Repository<Group>;
constructor() {
this.repository = getAppDataSource().getRepository(Group);
}
/**
* Find all groups
*/
async findAll(): Promise<Group[]> {
return await this.repository.find();
}
/**
* Find group by ID
*/
async findById(id: string): Promise<Group | null> {
return await this.repository.findOne({ where: { id } });
}
/**
* Find group by name
*/
async findByName(name: string): Promise<Group | null> {
return await this.repository.findOne({ where: { name } });
}
/**
* Create a new group
*/
async create(group: Omit<Group, 'id' | 'createdAt' | 'updatedAt'>): Promise<Group> {
const newGroup = this.repository.create(group);
return await this.repository.save(newGroup);
}
/**
* Update an existing group
*/
async update(id: string, groupData: Partial<Group>): Promise<Group | null> {
const group = await this.findById(id);
if (!group) {
return null;
}
const updated = this.repository.merge(group, groupData);
return await this.repository.save(updated);
}
/**
* Delete a group
*/
async delete(id: string): Promise<boolean> {
const result = await this.repository.delete({ id });
return (result.affected ?? 0) > 0;
}
/**
* Check if group exists by ID
*/
async exists(id: string): Promise<boolean> {
const count = await this.repository.count({ where: { id } });
return count > 0;
}
/**
* Check if group exists by name
*/
async existsByName(name: string): Promise<boolean> {
const count = await this.repository.count({ where: { name } });
return count > 0;
}
/**
* Count total groups
*/
async count(): Promise<number> {
return await this.repository.count();
}
/**
* Find groups by owner
*/
async findByOwner(owner: string): Promise<Group[]> {
return await this.repository.find({ where: { owner } });
}
}
export default GroupRepository;

View File

@@ -1,94 +0,0 @@
import { Repository } from 'typeorm';
import { Server } from '../entities/Server.js';
import { getAppDataSource } from '../connection.js';
/**
* Repository for Server entity
*/
export class ServerRepository {
private repository: Repository<Server>;
constructor() {
this.repository = getAppDataSource().getRepository(Server);
}
/**
* Find all servers
*/
async findAll(): Promise<Server[]> {
return await this.repository.find();
}
/**
* Find server by name
*/
async findByName(name: string): Promise<Server | null> {
return await this.repository.findOne({ where: { name } });
}
/**
* Create a new server
*/
async create(server: Omit<Server, 'id' | 'createdAt' | 'updatedAt'>): Promise<Server> {
const newServer = this.repository.create(server);
return await this.repository.save(newServer);
}
/**
* Update an existing server
*/
async update(name: string, serverData: Partial<Server>): Promise<Server | null> {
const server = await this.findByName(name);
if (!server) {
return null;
}
const updated = this.repository.merge(server, serverData);
return await this.repository.save(updated);
}
/**
* Delete a server
*/
async delete(name: string): Promise<boolean> {
const result = await this.repository.delete({ name });
return (result.affected ?? 0) > 0;
}
/**
* Check if server exists
*/
async exists(name: string): Promise<boolean> {
const count = await this.repository.count({ where: { name } });
return count > 0;
}
/**
* Count total servers
*/
async count(): Promise<number> {
return await this.repository.count();
}
/**
* Find servers by owner
*/
async findByOwner(owner: string): Promise<Server[]> {
return await this.repository.find({ where: { owner } });
}
/**
* Find enabled servers
*/
async findEnabled(): Promise<Server[]> {
return await this.repository.find({ where: { enabled: true } });
}
/**
* Set server enabled status
*/
async setEnabled(name: string, enabled: boolean): Promise<Server | null> {
return await this.update(name, { enabled });
}
}
export default ServerRepository;

View File

@@ -1,78 +0,0 @@
import { Repository } from 'typeorm';
import { SystemConfig } from '../entities/SystemConfig.js';
import { getAppDataSource } from '../connection.js';
/**
* Repository for SystemConfig entity
* Uses singleton pattern with id = 'default'
*/
export class SystemConfigRepository {
private repository: Repository<SystemConfig>;
private readonly DEFAULT_ID = 'default';
constructor() {
this.repository = getAppDataSource().getRepository(SystemConfig);
}
/**
* Get system configuration (singleton)
*/
async get(): Promise<SystemConfig> {
let config = await this.repository.findOne({ where: { id: this.DEFAULT_ID } });
// Create default if doesn't exist
if (!config) {
config = this.repository.create({
id: this.DEFAULT_ID,
routing: {},
install: {},
smartRouting: {},
mcpRouter: {},
nameSeparator: '-',
oauth: {},
oauthServer: {},
enableSessionRebuild: false,
});
config = await this.repository.save(config);
}
return config;
}
/**
* Update system configuration
*/
async update(configData: Partial<SystemConfig>): Promise<SystemConfig> {
const config = await this.get();
const updated = this.repository.merge(config, configData);
return await this.repository.save(updated);
}
/**
* Reset system configuration to defaults
*/
async reset(): Promise<SystemConfig> {
await this.repository.delete({ id: this.DEFAULT_ID });
return await this.get();
}
/**
* Get a specific configuration section
*/
async getSection<K extends keyof SystemConfig>(section: K): Promise<SystemConfig[K]> {
const config = await this.get();
return config[section];
}
/**
* Update a specific configuration section
*/
async updateSection<K extends keyof SystemConfig>(
section: K,
value: SystemConfig[K],
): Promise<SystemConfig> {
return await this.update({ [section]: value } as Partial<SystemConfig>);
}
}
export default SystemConfigRepository;

View File

@@ -1,84 +0,0 @@
import { Repository } from 'typeorm';
import { UserConfig } from '../entities/UserConfig.js';
import { getAppDataSource } from '../connection.js';
/**
* Repository for UserConfig entity
*/
export class UserConfigRepository {
private repository: Repository<UserConfig>;
constructor() {
this.repository = getAppDataSource().getRepository(UserConfig);
}
/**
* Get all user configs
*/
async getAll(): Promise<Record<string, UserConfig>> {
const configs = await this.repository.find();
const result: Record<string, UserConfig> = {};
for (const config of configs) {
result[config.username] = config;
}
return result;
}
/**
* Get user config by username
*/
async get(username: string): Promise<UserConfig | null> {
return await this.repository.findOne({ where: { username } });
}
/**
* Update user config
*/
async update(username: string, configData: Partial<UserConfig>): Promise<UserConfig> {
let config = await this.get(username);
if (!config) {
// Create new config if doesn't exist
config = this.repository.create({
username,
routing: {},
additionalConfig: {},
...configData,
});
} else {
// Merge with existing config
config = this.repository.merge(config, configData);
}
return await this.repository.save(config);
}
/**
* Delete user config
*/
async delete(username: string): Promise<boolean> {
const result = await this.repository.delete({ username });
return (result.affected ?? 0) > 0;
}
/**
* Get a specific configuration section for a user
*/
async getSection<K extends keyof UserConfig>(username: string, section: K): Promise<UserConfig[K] | null> {
const config = await this.get(username);
return config ? config[section] : null;
}
/**
* Update a specific configuration section for a user
*/
async updateSection<K extends keyof UserConfig>(
username: string,
section: K,
value: UserConfig[K],
): Promise<UserConfig> {
return await this.update(username, { [section]: value } as Partial<UserConfig>);
}
}
export default UserConfigRepository;

View File

@@ -1,80 +0,0 @@
import { Repository } from 'typeorm';
import { User } from '../entities/User.js';
import { getAppDataSource } from '../connection.js';
/**
* Repository for User entity
*/
export class UserRepository {
private repository: Repository<User>;
constructor() {
this.repository = getAppDataSource().getRepository(User);
}
/**
* Find all users
*/
async findAll(): Promise<User[]> {
return await this.repository.find();
}
/**
* Find user by username
*/
async findByUsername(username: string): Promise<User | null> {
return await this.repository.findOne({ where: { username } });
}
/**
* Create a new user
*/
async create(user: Omit<User, 'id' | 'createdAt' | 'updatedAt'>): Promise<User> {
const newUser = this.repository.create(user);
return await this.repository.save(newUser);
}
/**
* Update an existing user
*/
async update(username: string, userData: Partial<User>): Promise<User | null> {
const user = await this.findByUsername(username);
if (!user) {
return null;
}
const updated = this.repository.merge(user, userData);
return await this.repository.save(updated);
}
/**
* Delete a user
*/
async delete(username: string): Promise<boolean> {
const result = await this.repository.delete({ username });
return (result.affected ?? 0) > 0;
}
/**
* Check if user exists
*/
async exists(username: string): Promise<boolean> {
const count = await this.repository.count({ where: { username } });
return count > 0;
}
/**
* Count total users
*/
async count(): Promise<number> {
return await this.repository.count();
}
/**
* Find all admin users
*/
async findAdmins(): Promise<User[]> {
return await this.repository.find({ where: { isAdmin: true } });
}
}
export default UserRepository;

View File

@@ -1,16 +1,4 @@
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,
UserRepository,
ServerRepository,
GroupRepository,
SystemConfigRepository,
UserConfigRepository,
};
export { VectorEmbeddingRepository };

View File

@@ -1,24 +1,10 @@
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) {

View File

@@ -3,8 +3,6 @@ import jwt from 'jsonwebtoken';
import { loadSettings } from '../config/index.js';
import defaultConfig from '../config/index.js';
import { JWT_SECRET } from '../config/jwt.js';
import { getToken } from '../models/OAuth.js';
import { isOAuthServerEnabled } from '../services/oauthServerService.js';
const validateBearerAuth = (req: Request, routingConfig: any): boolean => {
if (!routingConfig.enableBearerAuth) {
@@ -36,7 +34,7 @@ const checkReadonly = (req: Request): boolean => {
};
// Middleware to authenticate JWT token
export const auth = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
export const auth = (req: Request, res: Response, next: NextFunction): void => {
const t = (req as any).t;
if (!checkReadonly(req)) {
res.status(403).json({ success: false, message: t('api.errors.readonly') });
@@ -63,28 +61,6 @@ export const auth = async (req: Request, res: Response, next: NextFunction): Pro
return;
}
// Check for OAuth access token in Authorization header
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ') && isOAuthServerEnabled()) {
const accessToken = authHeader.substring(7);
const oauthToken = getToken(accessToken);
if (oauthToken && oauthToken.accessToken === accessToken) {
// Valid OAuth token - look up user to get admin status
const { findUserByUsername } = await import('../models/User.js');
const user = await findUserByUsername(oauthToken.username);
// Set user context with proper admin status
(req as any).user = {
username: oauthToken.username,
isAdmin: user?.isAdmin || false,
};
(req as any).oauthToken = oauthToken;
next();
return;
}
}
// Get token from header or query parameter
const headerToken = req.header('x-auth-token');
const queryToken = req.query.token as string;
@@ -96,7 +72,7 @@ export const auth = async (req: Request, res: Response, next: NextFunction): Pro
return;
}
// Verify JWT token
// Verify token
try {
const decoded = jwt.verify(token, JWT_SECRET);

View File

@@ -1,7 +1,6 @@
import { Request, Response, NextFunction } from 'express';
import { UserContextService } from '../services/userContextService.js';
import { IUser } from '../types/index.js';
import { resolveOAuthUserFromAuthHeader } from '../utils/oauthBearer.js';
/**
* User context middleware
@@ -46,18 +45,6 @@ export const sseUserContextMiddleware = async (
try {
const userContextService = UserContextService.getInstance();
const username = req.params.user;
let cleanedUp = false;
const cleanup = () => {
if (cleanedUp) {
return;
}
cleanedUp = true;
userContextService.clearCurrentUser();
};
const attachCleanupHandlers = () => {
res.on('finish', cleanup);
res.on('close', cleanup);
};
if (username) {
// For user-scoped routes, set the user context
@@ -70,22 +57,22 @@ export const sseUserContextMiddleware = async (
};
userContextService.setCurrentUser(user);
attachCleanupHandlers();
// Clean up user context when response ends
res.on('finish', () => {
userContextService.clearCurrentUser();
});
// Also clean up on connection close for SSE
res.on('close', () => {
userContextService.clearCurrentUser();
});
console.log(`User context set for SSE/MCP endpoint: ${username}`);
} else {
const rawAuthHeader = Array.isArray(req.headers.authorization)
? req.headers.authorization[0]
: req.headers.authorization;
const bearerUser = await resolveOAuthUserFromAuthHeader(rawAuthHeader);
if (bearerUser) {
userContextService.setCurrentUser(bearerUser);
attachCleanupHandlers();
console.log(`OAuth user context set for SSE/MCP endpoint: ${bearerUser.username}`);
} else {
cleanup();
console.log('Global SSE/MCP endpoint access - no user context');
}
// For global routes, clear user context (admin access)
userContextService.clearCurrentUser();
console.log('Global SSE/MCP endpoint access - no user context');
}
next();

View File

@@ -1,347 +0,0 @@
import crypto from 'crypto';
import { loadSettings, saveSettings } from '../config/index.js';
import { IOAuthClient, IOAuthAuthorizationCode, IOAuthToken } from '../types/index.js';
// In-memory storage for authorization codes and tokens
// Authorization codes are short-lived and kept in memory only.
// Tokens are mirrored to settings (mcp_settings.json) for persistence.
const authorizationCodes = new Map<string, IOAuthAuthorizationCode>();
const tokens = new Map<string, IOAuthToken>();
// Initialize token store from settings on first import
(() => {
try {
const settings = loadSettings();
if (Array.isArray(settings.oauthTokens)) {
for (const stored of settings.oauthTokens) {
const token: IOAuthToken = {
...stored,
accessTokenExpiresAt: new Date(stored.accessTokenExpiresAt),
refreshTokenExpiresAt: stored.refreshTokenExpiresAt
? new Date(stored.refreshTokenExpiresAt)
: undefined,
};
tokens.set(token.accessToken, token);
if (token.refreshToken) {
tokens.set(token.refreshToken, token);
}
}
}
} catch (error) {
console.error('Failed to initialize OAuth tokens from settings:', error);
}
})();
/**
* Get all OAuth clients from configuration
*/
export const getOAuthClients = (): IOAuthClient[] => {
const settings = loadSettings();
return settings.oauthClients || [];
};
/**
* Find OAuth client by client ID
*/
export const findOAuthClientById = (clientId: string): IOAuthClient | undefined => {
const clients = getOAuthClients();
return clients.find((c) => c.clientId === clientId);
};
/**
* Create a new OAuth client
*/
export const createOAuthClient = (client: IOAuthClient): IOAuthClient => {
const settings = loadSettings();
if (!settings.oauthClients) {
settings.oauthClients = [];
}
// Check if client already exists
const existing = settings.oauthClients.find((c) => c.clientId === client.clientId);
if (existing) {
throw new Error(`OAuth client with ID ${client.clientId} already exists`);
}
settings.oauthClients.push(client);
saveSettings(settings);
return client;
};
/**
* Update an existing OAuth client
*/
export const updateOAuthClient = (
clientId: string,
updates: Partial<IOAuthClient>,
): IOAuthClient | null => {
const settings = loadSettings();
if (!settings.oauthClients) {
return null;
}
const index = settings.oauthClients.findIndex((c) => c.clientId === clientId);
if (index === -1) {
return null;
}
settings.oauthClients[index] = { ...settings.oauthClients[index], ...updates };
saveSettings(settings);
return settings.oauthClients[index];
};
/**
* Delete an OAuth client
*/
export const deleteOAuthClient = (clientId: string): boolean => {
const settings = loadSettings();
if (!settings.oauthClients) {
return false;
}
const index = settings.oauthClients.findIndex((c) => c.clientId === clientId);
if (index === -1) {
return false;
}
settings.oauthClients.splice(index, 1);
saveSettings(settings);
return true;
};
/**
* Generate a secure random token
*/
const generateToken = (length: number = 32): string => {
return crypto.randomBytes(length).toString('hex');
};
/**
* Save authorization code
*/
export const saveAuthorizationCode = (
code: Omit<IOAuthAuthorizationCode, 'code' | 'expiresAt'>,
expiresIn: number = 300,
): string => {
const authCode = generateToken();
const expiresAt = new Date(Date.now() + expiresIn * 1000);
authorizationCodes.set(authCode, {
code: authCode,
expiresAt,
...code,
});
return authCode;
};
/**
* Get authorization code
*/
export const getAuthorizationCode = (code: string): IOAuthAuthorizationCode | undefined => {
const authCode = authorizationCodes.get(code);
if (!authCode) {
return undefined;
}
// Check if expired
if (authCode.expiresAt < new Date()) {
authorizationCodes.delete(code);
return undefined;
}
return authCode;
};
/**
* Revoke authorization code
*/
export const revokeAuthorizationCode = (code: string): void => {
authorizationCodes.delete(code);
};
/**
* Save access token and optionally refresh token
*/
export const saveToken = (
tokenData: Omit<IOAuthToken, 'accessToken' | 'accessTokenExpiresAt'>,
accessTokenLifetime: number = 3600,
refreshTokenLifetime?: number,
): IOAuthToken => {
const accessToken = generateToken();
const accessTokenExpiresAt = new Date(Date.now() + accessTokenLifetime * 1000);
let refreshToken: string | undefined;
let refreshTokenExpiresAt: Date | undefined;
if (refreshTokenLifetime) {
refreshToken = generateToken();
refreshTokenExpiresAt = new Date(Date.now() + refreshTokenLifetime * 1000);
}
const token: IOAuthToken = {
accessToken,
accessTokenExpiresAt,
refreshToken,
refreshTokenExpiresAt,
...tokenData,
};
tokens.set(accessToken, token);
if (refreshToken) {
tokens.set(refreshToken, token);
}
// Persist tokens to settings
try {
const settings = loadSettings();
const existing = settings.oauthTokens || [];
const filtered = existing.filter(
(t) => t.accessToken !== token.accessToken && t.refreshToken !== token.refreshToken,
);
const updated = [
...filtered,
{
...token,
accessTokenExpiresAt: token.accessTokenExpiresAt,
refreshTokenExpiresAt: token.refreshTokenExpiresAt,
},
];
settings.oauthTokens = updated;
saveSettings(settings);
} catch (error) {
console.error('Failed to persist OAuth token to settings:', error);
}
return token;
};
/**
* Get token by access token or refresh token
*/
export const getToken = (token: string): IOAuthToken | undefined => {
const tokenData = tokens.get(token);
if (!tokenData) {
return undefined;
}
// Check if access token is expired
if (tokenData.accessToken === token && tokenData.accessTokenExpiresAt < new Date()) {
return undefined;
}
// Check if refresh token is expired
if (
tokenData.refreshToken === token &&
tokenData.refreshTokenExpiresAt &&
tokenData.refreshTokenExpiresAt < new Date()
) {
return undefined;
}
return tokenData;
};
/**
* Revoke token (both access and refresh tokens)
*/
export const revokeToken = (token: string): void => {
const tokenData = tokens.get(token);
if (tokenData) {
tokens.delete(tokenData.accessToken);
if (tokenData.refreshToken) {
tokens.delete(tokenData.refreshToken);
}
// Also remove from persisted settings
try {
const settings = loadSettings();
if (Array.isArray(settings.oauthTokens)) {
settings.oauthTokens = settings.oauthTokens.filter(
(t) =>
t.accessToken !== tokenData.accessToken && t.refreshToken !== tokenData.refreshToken,
);
saveSettings(settings);
}
} catch (error) {
console.error('Failed to remove OAuth token from settings:', error);
}
}
};
/**
* Clean up expired codes and tokens (should be called periodically)
*/
export const cleanupExpired = (): void => {
const now = new Date();
// Clean up expired authorization codes
for (const [code, authCode] of authorizationCodes.entries()) {
if (authCode.expiresAt < now) {
authorizationCodes.delete(code);
}
}
// Clean up expired tokens
const processedTokens = new Set<string>();
for (const [_key, token] of tokens.entries()) {
// Skip if we've already processed this token
if (processedTokens.has(token.accessToken)) {
continue;
}
processedTokens.add(token.accessToken);
const accessExpired = token.accessTokenExpiresAt < now;
const refreshExpired = token.refreshTokenExpiresAt && token.refreshTokenExpiresAt < now;
// If both are expired, remove the token
if (accessExpired && (!token.refreshToken || refreshExpired)) {
tokens.delete(token.accessToken);
if (token.refreshToken) {
tokens.delete(token.refreshToken);
}
}
}
// Sync persisted tokens: keep only non-expired ones
try {
const settings = loadSettings();
if (Array.isArray(settings.oauthTokens)) {
const validTokens: IOAuthToken[] = [];
for (const stored of settings.oauthTokens) {
const accessExpiresAt = new Date(stored.accessTokenExpiresAt);
const refreshExpiresAt = stored.refreshTokenExpiresAt
? new Date(stored.refreshTokenExpiresAt)
: undefined;
const accessExpired = accessExpiresAt < now;
const refreshExpired = refreshExpiresAt && refreshExpiresAt < now;
if (!accessExpired || (stored.refreshToken && !refreshExpired)) {
validTokens.push(stored);
}
}
settings.oauthTokens = validTokens;
saveSettings(settings);
}
} catch (error) {
console.error('Failed to cleanup persisted OAuth tokens:', error);
}
};
// Run cleanup every 5 minutes in production
let cleanupIntervalId: NodeJS.Timeout | null = null;
if (process.env.NODE_ENV !== 'test') {
cleanupIntervalId = setInterval(cleanupExpired, 5 * 60 * 1000);
// Allow the interval to not keep the process alive
cleanupIntervalId.unref();
}
/**
* Stop the cleanup interval (for graceful shutdown)
*/
export const stopCleanup = (): void => {
if (cleanupIntervalId) {
clearInterval(cleanupIntervalId);
cleanupIntervalId = null;
}
};

View File

@@ -1,43 +1,58 @@
import bcrypt from 'bcryptjs';
import { IUser } from '../types/index.js';
import { getUserDao } from '../dao/index.js';
import { loadSettings, saveSettings } from '../config/index.js';
// Get all users
export const getUsers = async (): Promise<IUser[]> => {
export const getUsers = (): IUser[] => {
try {
const userDao = getUserDao();
return await userDao.findAll();
const settings = loadSettings();
return settings.users || [];
} catch (error) {
console.error('Error reading users:', error);
console.error('Error reading users from settings:', error);
return [];
}
};
// Save users to settings
const saveUsers = (users: IUser[]): void => {
try {
const settings = loadSettings();
settings.users = users;
saveSettings(settings);
} catch (error) {
console.error('Error saving users to settings:', error);
}
};
// Create a new user
export const createUser = async (userData: IUser): Promise<IUser | null> => {
try {
const userDao = getUserDao();
return await userDao.createWithHashedPassword(
userData.username,
userData.password,
userData.isAdmin,
);
} catch (error) {
console.error('Error creating user:', error);
const users = getUsers();
// Check if username already exists
if (users.some((user) => user.username === userData.username)) {
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 = async (username: string): Promise<IUser | undefined> => {
try {
const userDao = getUserDao();
const user = await userDao.findByUsername(username);
return user || undefined;
} catch (error) {
console.error('Error finding user:', error);
return undefined;
}
export const findUserByUsername = (username: string): IUser | undefined => {
const users = getUsers();
return users.find((user) => user.username === username);
};
// Verify user password
@@ -53,22 +68,34 @@ export const updateUserPassword = async (
username: string,
newPassword: string,
): Promise<boolean> => {
try {
const userDao = getUserDao();
return await userDao.updatePassword(username, newPassword);
} catch (error) {
console.error('Error updating password:', error);
const users = getUsers();
const userIndex = users.findIndex((user) => user.username === username);
if (userIndex === -1) {
return false;
}
// Hash the new password
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(newPassword, salt);
// Update the user's password
users[userIndex].password = hashedPassword;
saveUsers(users);
return true;
};
// Initialize with default admin user if no users exist
export const initializeDefaultUser = async (): Promise<void> => {
const userDao = getUserDao();
const users = await userDao.findAll();
const users = getUsers();
if (users.length === 0) {
await userDao.createWithHashedPassword('admin', 'admin123', true);
await createUser({
username: 'admin',
password: 'admin123',
isAdmin: true,
});
console.log('Default admin user created');
}
};

View File

@@ -4,7 +4,6 @@ import config from '../config/index.js';
import {
getAllServers,
getAllSettings,
getServerConfig,
createServer,
updateServer,
deleteServer,
@@ -14,6 +13,7 @@ import {
togglePrompt,
updatePromptDescription,
updateSystemConfig,
searchServers,
} from '../controllers/serverController.js';
import {
getGroups,
@@ -81,28 +81,6 @@ import {
getGroupOpenAPISpec,
} from '../controllers/openApiController.js';
import { handleOAuthCallback } from '../controllers/oauthCallbackController.js';
import {
getAuthorize,
postAuthorize,
postToken,
getUserInfo,
getMetadata,
getProtectedResourceMetadata,
} from '../controllers/oauthServerController.js';
import {
getAllClients,
getClient,
createClient,
updateClient,
deleteClient,
regenerateSecret,
} from '../controllers/oauthClientController.js';
import {
registerClient,
getClientConfiguration,
updateClientConfiguration,
deleteClientRegistration,
} from '../controllers/oauthDynamicRegistrationController.js';
import { auth } from '../middlewares/auth.js';
const router = express.Router();
@@ -114,23 +92,9 @@ export const initRoutes = (app: express.Application): void => {
// OAuth callback endpoint (no auth required, public callback URL)
app.get('/oauth/callback', handleOAuthCallback);
// OAuth Authorization Server endpoints (no auth required for OAuth flow)
app.get('/oauth/authorize', getAuthorize);
app.post('/oauth/authorize', express.urlencoded({ extended: true }), postAuthorize);
app.post('/oauth/token', express.urlencoded({ extended: true }), postToken); // Public endpoint for token exchange
app.get('/oauth/userinfo', getUserInfo); // Validates OAuth token
app.get('/.well-known/oauth-authorization-server', getMetadata); // Public metadata endpoint
app.get('/.well-known/oauth-protected-resource', getProtectedResourceMetadata); // Public protected resource metadata
// RFC 7591 Dynamic Client Registration endpoints (public for registration)
app.post('/oauth/register', registerClient); // Register new OAuth client
app.get('/oauth/register/:clientId', getClientConfiguration); // Read client configuration
app.put('/oauth/register/:clientId', updateClientConfiguration); // Update client configuration
app.delete('/oauth/register/:clientId', deleteClientRegistration); // Delete client registration
// API routes protected by auth middleware in middlewares/index.ts
router.get('/servers', getAllServers);
router.get('/servers/:name', getServerConfig);
router.get('/servers/search', searchServers);
router.get('/settings', getAllSettings);
router.post('/servers', createServer);
router.put('/servers/:name', updateServer);
@@ -166,21 +130,6 @@ export const initRoutes = (app: express.Application): void => {
router.delete('/users/:username', deleteExistingUser);
router.get('/users-stats', getUserStats);
// OAuth Client management routes (admin only)
router.get('/oauth/clients', getAllClients);
router.get('/oauth/clients/:clientId', getClient);
router.post(
'/oauth/clients',
[
check('name', 'Client name is required').not().isEmpty(),
check('redirectUris', 'At least one redirect URI is required').isArray({ min: 1 }),
],
createClient,
);
router.put('/oauth/clients/:clientId', updateClient);
router.delete('/oauth/clients/:clientId', deleteClient);
router.post('/oauth/clients/:clientId/regenerate-secret', regenerateSecret);
// Tool management routes
router.post('/tools/call/:server', callTool);

View File

@@ -1,5 +0,0 @@
#!/usr/bin/env node
import 'reflect-metadata';
import { runMigrationCli } from '../utils/migration.js';
runMigrationCli();

View File

@@ -18,7 +18,6 @@ import { sseUserContextMiddleware } from './middlewares/userContext.js';
import { findPackageRoot } from './utils/path.js';
import { getCurrentModuleDir } from './utils/moduleDir.js';
import { initOAuthProvider, getOAuthRouter } from './services/oauthService.js';
import { initOAuthServer } from './services/oauthServerService.js';
/**
* Get the directory of the current module
@@ -60,8 +59,8 @@ export class AppServer {
// Initialize default admin user if no users exist
await initializeDefaultUser();
// Initialize OAuth provider if configured (for proxying upstream MCP OAuth)
await initOAuthProvider();
// Initialize OAuth provider if configured
initOAuthProvider();
const oauthRouter = getOAuthRouter();
if (oauthRouter) {
// Mount OAuth router at the root level (before other routes)
@@ -70,9 +69,6 @@ export class AppServer {
console.log('OAuth router mounted successfully');
}
// Initialize OAuth authorization server (for MCPHub's own OAuth)
await initOAuthServer();
initMiddlewares(this.app);
initRoutes(this.app);
console.log('Server initialized successfully');
@@ -103,10 +99,8 @@ 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`,

View File

@@ -0,0 +1,13 @@
import { DataService } from './dataService.js';
import { getDataService } from './services.js';
import './services.js';
describe('DataService', () => {
test('should get default implementation and call foo method', async () => {
const dataService: DataService = await getDataService();
const consoleSpy = jest.spyOn(console, 'log');
dataService.foo();
expect(consoleSpy).toHaveBeenCalledWith('default implementation');
consoleSpy.mockRestore();
});
});

View File

@@ -1,69 +1,31 @@
import { IUser, McpSettings } from '../types/index.js';
import { UserContextService } from './userContextService.js';
import { UserConfig } from '../types/index.js';
export class DataService {
filterData(data: any[], user?: IUser): any[] {
// Use passed user parameter if available, otherwise fall back to context
const currentUser = user || UserContextService.getInstance().getCurrentUser();
if (!currentUser || currentUser.isAdmin) {
return data;
} else {
return data.filter((item) => item.owner === currentUser?.username);
}
export interface DataService {
foo(): void;
filterData(data: any[], user?: IUser): any[];
filterSettings(settings: McpSettings, user?: IUser): McpSettings;
mergeSettings(all: McpSettings, newSettings: McpSettings, user?: IUser): McpSettings;
getPermissions(user: IUser): string[];
}
export class DataServiceImpl implements DataService {
foo() {
console.log('default implementation');
}
filterSettings(settings: McpSettings, user?: IUser): McpSettings {
// Use passed user parameter if available, otherwise fall back to context
const currentUser = user || UserContextService.getInstance().getCurrentUser();
if (!currentUser || currentUser.isAdmin) {
const result = { ...settings };
delete result.userConfigs;
return result;
} else {
const result = { ...settings };
// TODO: apply userConfig to filter settings as needed
// const userConfig = settings.userConfigs?.[currentUser?.username || ''];
delete result.userConfigs;
return result;
}
filterData(data: any[], _user?: IUser): any[] {
return data;
}
mergeSettings(all: McpSettings, newSettings: McpSettings, user?: IUser): McpSettings {
// Use passed user parameter if available, otherwise fall back to context
const currentUser = user || UserContextService.getInstance().getCurrentUser();
if (!currentUser || currentUser.isAdmin) {
const result = { ...all };
result.mcpServers = newSettings.mcpServers;
result.users = newSettings.users;
result.systemConfig = newSettings.systemConfig;
result.groups = newSettings.groups;
result.oauthClients = newSettings.oauthClients;
result.oauthTokens = newSettings.oauthTokens;
return result;
} else {
const result = JSON.parse(JSON.stringify(all));
if (!result.userConfigs) {
result.userConfigs = {};
}
const systemConfig = newSettings.systemConfig || {};
const userConfig: UserConfig = {
routing: systemConfig.routing
? {
// TODO: only allow modifying certain fields based on userConfig permissions
}
: undefined,
};
result.userConfigs[currentUser?.username || ''] = userConfig;
return result;
}
filterSettings(settings: McpSettings, _user?: IUser): McpSettings {
return settings;
}
getPermissions(user: IUser): string[] {
if (user && user.isAdmin) {
return ['*', 'x'];
} else {
return [''];
}
mergeSettings(all: McpSettings, newSettings: McpSettings, _user?: IUser): McpSettings {
return newSettings;
}
getPermissions(_user: IUser): string[] {
return ['*'];
}
}

View File

@@ -0,0 +1,72 @@
import { IUser, McpSettings, UserConfig } from '../types/index.js';
import { DataService } from './dataService.js';
import { UserContextService } from './userContextService.js';
export class DataServicex implements DataService {
foo() {
console.log('default implementation');
}
filterData(data: any[], user?: IUser): any[] {
// Use passed user parameter if available, otherwise fall back to context
const currentUser = user || UserContextService.getInstance().getCurrentUser();
if (!currentUser || currentUser.isAdmin) {
return data;
} else {
return data.filter((item) => item.owner === currentUser?.username);
}
}
filterSettings(settings: McpSettings, user?: IUser): McpSettings {
// Use passed user parameter if available, otherwise fall back to context
const currentUser = user || UserContextService.getInstance().getCurrentUser();
if (!currentUser || currentUser.isAdmin) {
const result = { ...settings };
delete result.userConfigs;
return result;
} else {
const result = { ...settings };
result.systemConfig = settings.userConfigs?.[currentUser?.username || ''] || {};
delete result.userConfigs;
return result;
}
}
mergeSettings(all: McpSettings, newSettings: McpSettings, user?: IUser): McpSettings {
// Use passed user parameter if available, otherwise fall back to context
const currentUser = user || UserContextService.getInstance().getCurrentUser();
if (!currentUser || currentUser.isAdmin) {
const result = { ...all };
result.users = newSettings.users;
result.systemConfig = newSettings.systemConfig;
result.groups = newSettings.groups;
return result;
} else {
const result = JSON.parse(JSON.stringify(all));
if (!result.userConfigs) {
result.userConfigs = {};
}
const systemConfig = newSettings.systemConfig || {};
const userConfig: UserConfig = {
routing: systemConfig.routing
? {
enableGlobalRoute: systemConfig.routing.enableGlobalRoute,
enableGroupNameRoute: systemConfig.routing.enableGroupNameRoute,
enableBearerAuth: systemConfig.routing.enableBearerAuth,
bearerAuthKey: systemConfig.routing.bearerAuthKey,
}
: undefined,
};
result.userConfigs[currentUser?.username || ''] = userConfig;
return result;
}
}
getPermissions(user: IUser): string[] {
if (user && user.isAdmin) {
return ['*', 'x'];
} else {
return [''];
}
}
}

Some files were not shown because too many files have changed in this diff Show More