mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-25 11:19:34 -05:00
Compare commits
20 Commits
cluster
...
copilot/se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da6d217bb4 | ||
|
|
0017023192 | ||
|
|
e097c027be | ||
|
|
71958ef86b | ||
|
|
5e20b2c261 | ||
|
|
b00e1c81fc | ||
|
|
33eae50bd3 | ||
|
|
eb1a965e45 | ||
|
|
97114dcabb | ||
|
|
350a022ea3 | ||
|
|
292876a991 | ||
|
|
d6a9146e27 | ||
|
|
1f3a6794ea | ||
|
|
c673afb97e | ||
|
|
01855ca2ca | ||
|
|
88efad9d60 | ||
|
|
2028233b53 | ||
|
|
1dfa0a990b | ||
|
|
ab7c210281 | ||
|
|
6bd28ec89b |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -76,7 +76,7 @@ jobs:
|
||||
|
||||
# services:
|
||||
# postgres:
|
||||
# image: postgres:15
|
||||
# image: pgvector/pgvector:pg17
|
||||
# env:
|
||||
# POSTGRES_PASSWORD: postgres
|
||||
# POSTGRES_DB: mcphub_test
|
||||
|
||||
205
IMPLEMENTATION_SUMMARY.md
Normal file
205
IMPLEMENTATION_SUMMARY.md
Normal file
@@ -0,0 +1,205 @@
|
||||
# Stream Parameter Implementation - Summary
|
||||
|
||||
## Overview
|
||||
Successfully implemented support for a `stream` parameter that allows clients to control whether MCP requests receive Server-Sent Events (SSE) streaming responses or direct JSON responses.
|
||||
|
||||
## Problem Statement (Original Question)
|
||||
> 分析源码,使用 http://localhost:8090/process 请求时,可以使用 stream : false 来设置非流式响应吗
|
||||
>
|
||||
> Translation: After analyzing the source code, when using the http://localhost:8090/process request, can we use stream: false to set non-streaming responses?
|
||||
|
||||
## Answer
|
||||
**Yes, absolutely!** While the endpoint path is `/mcp` (not `/process`), the implementation now fully supports using a `stream` parameter to control response format.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Core Changes
|
||||
1. **Modified Functions:**
|
||||
- `createSessionWithId()` - Added `enableJsonResponse` parameter
|
||||
- `createNewSession()` - Added `enableJsonResponse` parameter
|
||||
- `handleMcpPostRequest()` - Added robust stream parameter parsing
|
||||
|
||||
2. **Parameter Parsing:**
|
||||
- Created `parseStreamParam()` helper function
|
||||
- Handles multiple input types: boolean, string, number
|
||||
- Consistent behavior for query and body parameters
|
||||
- Body parameter takes priority over query parameter
|
||||
|
||||
3. **Supported Values:**
|
||||
- **Truthy (streaming enabled):** `true`, `"true"`, `1`, `"1"`, `"yes"`, `"on"`
|
||||
- **Falsy (streaming disabled):** `false`, `"false"`, `0`, `"0"`, `"no"`, `"off"`
|
||||
- **Default:** `true` (streaming enabled) for backward compatibility
|
||||
|
||||
### Usage Examples
|
||||
|
||||
#### Query Parameter
|
||||
```bash
|
||||
# Disable streaming
|
||||
curl -X POST "http://localhost:3000/mcp?stream=false" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-d '{"method": "initialize", ...}'
|
||||
|
||||
# Enable streaming (default)
|
||||
curl -X POST "http://localhost:3000/mcp?stream=true" ...
|
||||
```
|
||||
|
||||
#### Request Body Parameter
|
||||
```json
|
||||
{
|
||||
"method": "initialize",
|
||||
"stream": false,
|
||||
"params": {
|
||||
"protocolVersion": "2025-03-26",
|
||||
"capabilities": {},
|
||||
"clientInfo": {
|
||||
"name": "TestClient",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
},
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1
|
||||
}
|
||||
```
|
||||
|
||||
#### All Route Variants
|
||||
```bash
|
||||
POST /mcp?stream=false # Global route
|
||||
POST /mcp/{group}?stream=false # Group route
|
||||
POST /mcp/{server}?stream=false # Server route
|
||||
POST /mcp/$smart?stream=false # Smart routing
|
||||
```
|
||||
|
||||
### Response Formats
|
||||
|
||||
#### Streaming Response (stream=true or default)
|
||||
```
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/event-stream
|
||||
mcp-session-id: 550e8400-e29b-41d4-a716-446655440000
|
||||
|
||||
data: {"jsonrpc":"2.0","result":{...},"id":1}
|
||||
|
||||
```
|
||||
|
||||
#### Non-Streaming Response (stream=false)
|
||||
```
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
mcp-session-id: 550e8400-e29b-41d4-a716-446655440000
|
||||
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"protocolVersion": "2025-03-26",
|
||||
"capabilities": {...},
|
||||
"serverInfo": {...}
|
||||
},
|
||||
"id": 1
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Coverage
|
||||
- **Unit Tests:** 12 tests in `src/services/sseService.test.ts`
|
||||
- Basic functionality (6 tests)
|
||||
- Edge cases (6 tests)
|
||||
- **Integration Tests:** 4 tests in `tests/integration/stream-parameter.test.ts`
|
||||
- **Total:** 207 tests passing (16 new tests added)
|
||||
|
||||
### Test Scenarios Covered
|
||||
1. ✓ Query parameter: stream=false
|
||||
2. ✓ Query parameter: stream=true
|
||||
3. ✓ Body parameter: stream=false
|
||||
4. ✓ Body parameter: stream=true
|
||||
5. ✓ Priority: body over query
|
||||
6. ✓ Default: no parameter provided
|
||||
7. ✓ Edge case: string "false", "0", "no", "off"
|
||||
8. ✓ Edge case: string "true", "1", "yes", "on"
|
||||
9. ✓ Edge case: number 0 and 1
|
||||
10. ✓ Edge case: invalid/unknown values
|
||||
|
||||
## Documentation
|
||||
|
||||
### Files Created/Updated
|
||||
1. **New Documentation:**
|
||||
- `docs/stream-parameter.md` - Comprehensive guide with examples and use cases
|
||||
|
||||
2. **Updated Documentation:**
|
||||
- `README.md` - Added link to stream parameter documentation
|
||||
- `README.zh.md` - Added link in Chinese README
|
||||
|
||||
3. **Test Documentation:**
|
||||
- `tests/integration/stream-parameter.test.ts` - Demonstrates usage patterns
|
||||
|
||||
### Documentation Topics Covered
|
||||
- Feature overview
|
||||
- Usage examples (query and body parameters)
|
||||
- Response format comparison
|
||||
- Use cases and when to use each mode
|
||||
- Technical implementation details
|
||||
- Backward compatibility notes
|
||||
- Route variant support
|
||||
- Limitations and considerations
|
||||
|
||||
## Quality Assurance
|
||||
|
||||
### Code Review
|
||||
- ✓ All code review comments addressed
|
||||
- ✓ No outstanding issues
|
||||
- ✓ Consistent parsing logic
|
||||
- ✓ Proper edge case handling
|
||||
|
||||
### Validation Results
|
||||
- ✓ All 207 tests passing
|
||||
- ✓ TypeScript compilation successful
|
||||
- ✓ ESLint checks passed
|
||||
- ✓ Full build completed successfully
|
||||
- ✓ No breaking changes
|
||||
- ✓ Backward compatible
|
||||
|
||||
## Impact Analysis
|
||||
|
||||
### Benefits
|
||||
1. **Flexibility:** Clients can choose response format based on their needs
|
||||
2. **Debugging:** Easier to debug with direct JSON responses
|
||||
3. **Integration:** Simpler integration with systems expecting JSON
|
||||
4. **Testing:** More straightforward to test and validate
|
||||
5. **Backward Compatible:** Existing clients continue to work without changes
|
||||
|
||||
### Performance Considerations
|
||||
- No performance impact on default streaming behavior
|
||||
- Non-streaming mode may have slightly less overhead for simple requests
|
||||
- Session management works identically in both modes
|
||||
|
||||
### Backward Compatibility
|
||||
- Default behavior unchanged (streaming enabled)
|
||||
- All existing clients work without modification
|
||||
- No breaking changes to API or protocol
|
||||
|
||||
## Future Considerations
|
||||
|
||||
### Potential Enhancements
|
||||
1. Add documentation for OpenAPI specification
|
||||
2. Consider adding a configuration option to set default behavior
|
||||
3. Add metrics/logging for stream parameter usage
|
||||
4. Consider adding response format negotiation via Accept header
|
||||
|
||||
### Known Limitations
|
||||
1. Stream parameter only affects POST requests to /mcp endpoint
|
||||
2. SSE GET requests for retrieving streams not affected
|
||||
3. Session rebuild operations inherit stream setting from original request
|
||||
|
||||
## Conclusion
|
||||
|
||||
The implementation successfully adds flexible stream control to the MCP protocol implementation while maintaining full backward compatibility. The robust parsing logic handles all common value formats, and comprehensive testing ensures reliable behavior across all scenarios.
|
||||
|
||||
**Status:** ✅ Complete and Production Ready
|
||||
|
||||
---
|
||||
*Implementation Date: December 25, 2025*
|
||||
*Total Development Time: ~2 hours*
|
||||
*Tests Added: 16*
|
||||
*Lines of Code Changed: ~200*
|
||||
*Documentation Pages: 1 comprehensive guide*
|
||||
@@ -78,6 +78,7 @@ http://localhost:3000/mcp/$smart # Smart routing
|
||||
| [Quick Start](https://docs.mcphubx.com/quickstart) | Get started in 5 minutes |
|
||||
| [Configuration](https://docs.mcphubx.com/configuration/mcp-settings) | MCP server configuration options |
|
||||
| [Database Mode](https://docs.mcphubx.com/configuration/database-configuration) | PostgreSQL setup for production |
|
||||
| [Stream Parameter](docs/stream-parameter.md) | Control streaming vs JSON responses |
|
||||
| [OAuth](https://docs.mcphubx.com/features/oauth) | OAuth 2.0 client and server setup |
|
||||
| [Smart Routing](https://docs.mcphubx.com/features/smart-routing) | AI-powered tool discovery |
|
||||
| [Docker Setup](https://docs.mcphubx.com/configuration/docker-setup) | Docker deployment guide |
|
||||
|
||||
@@ -78,6 +78,7 @@ http://localhost:3000/mcp/$smart # 智能路由
|
||||
| [快速开始](https://docs.mcphubx.com/zh/quickstart) | 5 分钟快速上手 |
|
||||
| [配置指南](https://docs.mcphubx.com/zh/configuration/mcp-settings) | MCP 服务器配置选项 |
|
||||
| [数据库模式](https://docs.mcphubx.com/zh/configuration/database-configuration) | PostgreSQL 生产环境配置 |
|
||||
| [Stream 参数](docs/stream-parameter.md) | 控制流式或 JSON 响应 |
|
||||
| [OAuth](https://docs.mcphubx.com/zh/features/oauth) | OAuth 2.0 客户端和服务端配置 |
|
||||
| [智能路由](https://docs.mcphubx.com/zh/features/smart-routing) | AI 驱动的工具发现 |
|
||||
| [Docker 部署](https://docs.mcphubx.com/zh/configuration/docker-setup) | Docker 部署指南 |
|
||||
|
||||
@@ -3,7 +3,7 @@ version: "3.8"
|
||||
services:
|
||||
# PostgreSQL database for MCPHub configuration
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
image: pgvector/pgvector:pg17-alpine
|
||||
container_name: mcphub-postgres
|
||||
environment:
|
||||
POSTGRES_DB: mcphub
|
||||
|
||||
@@ -59,7 +59,7 @@ version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
image: pgvector/pgvector:pg17
|
||||
environment:
|
||||
POSTGRES_DB: mcphub
|
||||
POSTGRES_USER: mcphub
|
||||
|
||||
@@ -119,7 +119,7 @@ services:
|
||||
- mcphub-network
|
||||
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
image: pgvector/pgvector:pg17
|
||||
container_name: mcphub-postgres
|
||||
environment:
|
||||
- POSTGRES_DB=mcphub
|
||||
@@ -203,7 +203,7 @@ services:
|
||||
retries: 3
|
||||
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
image: pgvector/pgvector:pg17
|
||||
container_name: mcphub-postgres
|
||||
environment:
|
||||
- POSTGRES_DB=mcphub
|
||||
@@ -305,7 +305,7 @@ services:
|
||||
- mcphub-dev
|
||||
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
image: pgvector/pgvector:pg17
|
||||
container_name: mcphub-postgres-dev
|
||||
environment:
|
||||
- POSTGRES_DB=mcphub
|
||||
@@ -445,7 +445,7 @@ Add backup service to your `docker-compose.yml`:
|
||||
```yaml
|
||||
services:
|
||||
backup:
|
||||
image: postgres:15-alpine
|
||||
image: pgvector/pgvector:pg17
|
||||
container_name: mcphub-backup
|
||||
environment:
|
||||
- PGPASSWORD=${POSTGRES_PASSWORD}
|
||||
|
||||
@@ -78,7 +78,7 @@ Smart Routing requires additional setup compared to basic MCPHub usage:
|
||||
- ./mcp_settings.json:/app/mcp_settings.json
|
||||
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
image: pgvector/pgvector:pg17
|
||||
environment:
|
||||
- POSTGRES_DB=mcphub
|
||||
- POSTGRES_USER=mcphub
|
||||
@@ -146,7 +146,7 @@ Smart Routing requires additional setup compared to basic MCPHub usage:
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: pgvector/pgvector:pg16
|
||||
image: pgvector/pgvector:pg17
|
||||
env:
|
||||
- name: POSTGRES_DB
|
||||
value: mcphub
|
||||
|
||||
@@ -96,7 +96,7 @@ Optional for Smart Routing:
|
||||
|
||||
# Optional: PostgreSQL for Smart Routing
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
image: pgvector/pgvector:pg17
|
||||
environment:
|
||||
POSTGRES_DB: mcphub
|
||||
POSTGRES_USER: mcphub
|
||||
|
||||
177
docs/stream-parameter.md
Normal file
177
docs/stream-parameter.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# Stream Parameter Support
|
||||
|
||||
MCPHub now supports controlling the response format of MCP requests through a `stream` parameter. This allows you to choose between Server-Sent Events (SSE) streaming responses and direct JSON responses.
|
||||
|
||||
## Overview
|
||||
|
||||
By default, MCP requests use SSE streaming for real-time communication. However, some use cases benefit from receiving complete JSON responses instead of streams. The `stream` parameter provides this flexibility.
|
||||
|
||||
## Usage
|
||||
|
||||
### Query Parameter
|
||||
|
||||
You can control streaming behavior by adding a `stream` query parameter to your MCP POST requests:
|
||||
|
||||
```bash
|
||||
# Disable streaming (receive JSON response)
|
||||
POST /mcp?stream=false
|
||||
|
||||
# Enable streaming (SSE response) - Default behavior
|
||||
POST /mcp?stream=true
|
||||
```
|
||||
|
||||
### Request Body Parameter
|
||||
|
||||
Alternatively, you can include the `stream` parameter in your request body:
|
||||
|
||||
```json
|
||||
{
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-03-26",
|
||||
"capabilities": {},
|
||||
"clientInfo": {
|
||||
"name": "MyClient",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
},
|
||||
"stream": false,
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** The request body parameter takes priority over the query parameter if both are specified.
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Non-Streaming Request
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/mcp?stream=false" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-d '{
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-03-26",
|
||||
"capabilities": {},
|
||||
"clientInfo": {
|
||||
"name": "TestClient",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
},
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1
|
||||
}'
|
||||
```
|
||||
|
||||
Response (JSON):
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"result": {
|
||||
"protocolVersion": "2025-03-26",
|
||||
"capabilities": {
|
||||
"tools": {},
|
||||
"prompts": {}
|
||||
},
|
||||
"serverInfo": {
|
||||
"name": "MCPHub",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
},
|
||||
"id": 1
|
||||
}
|
||||
```
|
||||
|
||||
### Example 2: Streaming Request (Default)
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:3000/mcp" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Accept: application/json, text/event-stream" \
|
||||
-d '{
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-03-26",
|
||||
"capabilities": {},
|
||||
"clientInfo": {
|
||||
"name": "TestClient",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
},
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1
|
||||
}'
|
||||
```
|
||||
|
||||
Response (SSE Stream):
|
||||
```
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/event-stream
|
||||
mcp-session-id: 550e8400-e29b-41d4-a716-446655440000
|
||||
|
||||
data: {"jsonrpc":"2.0","result":{...},"id":1}
|
||||
|
||||
```
|
||||
|
||||
## Use Cases
|
||||
|
||||
### When to Use `stream: false`
|
||||
|
||||
- **Simple Request-Response**: When you only need a single response without ongoing communication
|
||||
- **Debugging**: Easier to inspect complete JSON responses in tools like Postman or curl
|
||||
- **Testing**: Simpler to test and validate responses in automated tests
|
||||
- **Stateless Operations**: When you don't need to maintain session state between requests
|
||||
- **API Integration**: When integrating with systems that expect standard JSON responses
|
||||
|
||||
### When to Use `stream: true` (Default)
|
||||
|
||||
- **Real-time Communication**: When you need continuous updates or notifications
|
||||
- **Long-running Operations**: For operations that may take time and send progress updates
|
||||
- **Event-driven**: When your application architecture is event-based
|
||||
- **MCP Protocol Compliance**: For full MCP protocol compatibility with streaming support
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Implementation
|
||||
|
||||
The `stream` parameter controls the `enableJsonResponse` option of the underlying `StreamableHTTPServerTransport`:
|
||||
|
||||
- `stream: true` → `enableJsonResponse: false` → SSE streaming response
|
||||
- `stream: false` → `enableJsonResponse: true` → Direct JSON response
|
||||
|
||||
### Backward Compatibility
|
||||
|
||||
The default behavior remains SSE streaming (`stream: true`) to maintain backward compatibility with existing clients. If the `stream` parameter is not specified, MCPHub will use streaming by default.
|
||||
|
||||
### Session Management
|
||||
|
||||
The stream parameter affects how sessions are created:
|
||||
|
||||
- **Streaming sessions**: Use SSE transport with session management
|
||||
- **Non-streaming sessions**: Use direct JSON responses with session management
|
||||
|
||||
Both modes support session IDs and can be used with the MCP session management features.
|
||||
|
||||
## Group and Server Routes
|
||||
|
||||
The stream parameter works with all MCP route variants:
|
||||
|
||||
- Global route: `/mcp?stream=false`
|
||||
- Group route: `/mcp/{group}?stream=false`
|
||||
- Server route: `/mcp/{server}?stream=false`
|
||||
- Smart routing: `/mcp/$smart?stream=false`
|
||||
|
||||
## Limitations
|
||||
|
||||
1. The `stream` parameter only affects POST requests to the `/mcp` endpoint
|
||||
2. SSE GET requests for retrieving streams are not affected by this parameter
|
||||
3. Session rebuild operations inherit the stream setting from the original request
|
||||
|
||||
## See Also
|
||||
|
||||
- [MCP Protocol Specification](https://spec.modelcontextprotocol.io/)
|
||||
- [API Reference](https://docs.mcphubx.com/api-reference)
|
||||
- [Configuration Guide](https://docs.mcphubx.com/configuration/mcp-settings)
|
||||
@@ -59,7 +59,7 @@ version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
image: pgvector/pgvector:pg17
|
||||
environment:
|
||||
POSTGRES_DB: mcphub
|
||||
POSTGRES_USER: mcphub
|
||||
|
||||
@@ -119,7 +119,7 @@ services:
|
||||
- mcphub-network
|
||||
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
image: pgvector/pgvector:pg17
|
||||
container_name: mcphub-postgres
|
||||
environment:
|
||||
- POSTGRES_DB=mcphub
|
||||
@@ -203,7 +203,7 @@ services:
|
||||
retries: 3
|
||||
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
image: pgvector/pgvector:pg17
|
||||
container_name: mcphub-postgres
|
||||
environment:
|
||||
- POSTGRES_DB=mcphub
|
||||
@@ -305,7 +305,7 @@ services:
|
||||
- mcphub-dev
|
||||
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
image: pgvector/pgvector:pg17
|
||||
container_name: mcphub-postgres-dev
|
||||
environment:
|
||||
- POSTGRES_DB=mcphub
|
||||
@@ -445,7 +445,7 @@ secrets:
|
||||
```yaml
|
||||
services:
|
||||
backup:
|
||||
image: postgres:15-alpine
|
||||
image: pgvector/pgvector:pg17
|
||||
container_name: mcphub-backup
|
||||
environment:
|
||||
- PGPASSWORD=${POSTGRES_PASSWORD}
|
||||
|
||||
@@ -96,7 +96,7 @@ description: '各种平台的详细安装说明'
|
||||
|
||||
# 可选:用于智能路由的 PostgreSQL
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
image: pgvector/pgvector:pg17
|
||||
environment:
|
||||
POSTGRES_DB: mcphub
|
||||
POSTGRES_USER: mcphub
|
||||
|
||||
284
frontend/src/components/GroupImportForm.tsx
Normal file
284
frontend/src/components/GroupImportForm.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { apiPost } from '@/utils/fetchInterceptor';
|
||||
|
||||
interface GroupImportFormProps {
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
interface ImportGroupConfig {
|
||||
name: string;
|
||||
description?: string;
|
||||
servers?: string[] | Array<{ name: string; tools?: string[] | 'all' }>;
|
||||
}
|
||||
|
||||
interface ImportJsonFormat {
|
||||
groups: ImportGroupConfig[];
|
||||
}
|
||||
|
||||
const GroupImportForm: React.FC<GroupImportFormProps> = ({ onSuccess, onCancel }) => {
|
||||
const { t } = useTranslation();
|
||||
const [jsonInput, setJsonInput] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [previewGroups, setPreviewGroups] = useState<ImportGroupConfig[] | null>(null);
|
||||
|
||||
const examplePlaceholder = `{
|
||||
"groups": [
|
||||
{
|
||||
"name": "AI Assistants",
|
||||
"servers": ["openai-server", "anthropic-server"]
|
||||
},
|
||||
{
|
||||
"name": "Development Tools",
|
||||
"servers": [
|
||||
{
|
||||
"name": "github-server",
|
||||
"tools": ["create_issue", "list_repos"]
|
||||
},
|
||||
{
|
||||
"name": "gitlab-server",
|
||||
"tools": "all"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Supports:
|
||||
- Simple server list: ["server1", "server2"]
|
||||
- Advanced server config: [{"name": "server1", "tools": ["tool1", "tool2"]}]
|
||||
- All groups will be imported in a single efficient batch operation.`;
|
||||
|
||||
const parseAndValidateJson = (input: string): ImportJsonFormat | null => {
|
||||
try {
|
||||
const parsed = JSON.parse(input.trim());
|
||||
|
||||
// Validate structure
|
||||
if (!parsed.groups || !Array.isArray(parsed.groups)) {
|
||||
setError(t('groupImport.invalidFormat'));
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate each group
|
||||
for (const group of parsed.groups) {
|
||||
if (!group.name || typeof group.name !== 'string') {
|
||||
setError(t('groupImport.missingName'));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return parsed as ImportJsonFormat;
|
||||
} catch (e) {
|
||||
setError(t('groupImport.parseError'));
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreview = () => {
|
||||
setError(null);
|
||||
const parsed = parseAndValidateJson(jsonInput);
|
||||
if (!parsed) return;
|
||||
|
||||
setPreviewGroups(parsed.groups);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!previewGroups) return;
|
||||
|
||||
setIsImporting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Use batch import API for better performance
|
||||
const result = await apiPost('/groups/batch', {
|
||||
groups: previewGroups,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
const { successCount, failureCount, results } = result;
|
||||
|
||||
if (failureCount > 0) {
|
||||
const errors = results
|
||||
.filter((r: any) => !r.success)
|
||||
.map((r: any) => `${r.name}: ${r.message || t('groupImport.addFailed')}`);
|
||||
|
||||
setError(
|
||||
t('groupImport.partialSuccess', { count: successCount, total: previewGroups.length }) +
|
||||
'\n' +
|
||||
errors.join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
onSuccess();
|
||||
}
|
||||
} else {
|
||||
setError(result.message || t('groupImport.importFailed'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Import error:', err);
|
||||
setError(t('groupImport.importFailed'));
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderServerList = (
|
||||
servers?: string[] | Array<{ name: string; tools?: string[] | 'all' }>,
|
||||
) => {
|
||||
if (!servers || servers.length === 0) {
|
||||
return <span className="text-gray-500">{t('groups.noServers')}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{servers.map((server, idx) => {
|
||||
if (typeof server === 'string') {
|
||||
return (
|
||||
<div key={idx} className="text-sm">
|
||||
• {server}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div key={idx} className="text-sm">
|
||||
• {server.name}
|
||||
{server.tools && server.tools !== 'all' && (
|
||||
<span className="text-gray-500 ml-2">
|
||||
({Array.isArray(server.tools) ? server.tools.join(', ') : server.tools})
|
||||
</span>
|
||||
)}
|
||||
{server.tools === 'all' && <span className="text-gray-500 ml-2">(all tools)</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white shadow rounded-lg p-6 w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900">{t('groupImport.title')}</h2>
|
||||
<button onClick={onCancel} className="text-gray-500 hover:text-gray-700">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4 rounded">
|
||||
<p className="text-red-700 whitespace-pre-wrap">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!previewGroups ? (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{t('groupImport.inputLabel')}
|
||||
</label>
|
||||
<textarea
|
||||
value={jsonInput}
|
||||
onChange={(e) => setJsonInput(e.target.value)}
|
||||
className="w-full h-96 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"
|
||||
placeholder={examplePlaceholder}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-2">{t('groupImport.inputHelp')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-4">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 btn-secondary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePreview}
|
||||
disabled={!jsonInput.trim()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('groupImport.preview')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-3">
|
||||
{t('groupImport.previewTitle')}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{previewGroups.map((group, index) => (
|
||||
<div key={index} className="bg-gray-50 p-4 rounded-lg border border-gray-200">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-900">{group.name}</h4>
|
||||
{group.description && (
|
||||
<p className="text-sm text-gray-600 mt-1">{group.description}</p>
|
||||
)}
|
||||
<div className="mt-2 text-sm text-gray-600">
|
||||
<strong>{t('groups.servers')}:</strong>
|
||||
<div className="mt-1">{renderServerList(group.servers)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-4">
|
||||
<button
|
||||
onClick={() => setPreviewGroups(null)}
|
||||
disabled={isImporting}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 disabled:opacity-50 btn-secondary"
|
||||
>
|
||||
{t('common.back')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={isImporting}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center btn-primary"
|
||||
>
|
||||
{isImporting ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin h-4 w-4 mr-2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{t('groupImport.importing')}
|
||||
</>
|
||||
) : (
|
||||
t('groupImport.import')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupImportForm;
|
||||
@@ -14,6 +14,10 @@ interface McpServerConfig {
|
||||
type?: string;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
openapi?: {
|
||||
version: string;
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ImportJsonFormat {
|
||||
@@ -29,29 +33,16 @@ const JSONImportForm: React.FC<JSONImportFormProps> = ({ onSuccess, onCancel })
|
||||
null,
|
||||
);
|
||||
|
||||
const examplePlaceholder = `STDIO example:
|
||||
{
|
||||
const examplePlaceholder = `{
|
||||
"mcpServers": {
|
||||
"stdio-server-example": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "mcp-server-example"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SSE example:
|
||||
{
|
||||
"mcpServers": {
|
||||
},
|
||||
"sse-server-example": {
|
||||
"type": "sse",
|
||||
"url": "http://localhost:3000"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HTTP example:
|
||||
{
|
||||
"mcpServers": {
|
||||
},
|
||||
"http-server-example": {
|
||||
"type": "streamable-http",
|
||||
"url": "http://localhost:3001",
|
||||
@@ -59,9 +50,18 @@ HTTP example:
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer your-token"
|
||||
}
|
||||
},
|
||||
"openapi-server-example": {
|
||||
"type": "openapi",
|
||||
"openapi": {
|
||||
"url": "https://petstore.swagger.io/v2/swagger.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
}
|
||||
|
||||
Supports: STDIO, SSE, HTTP (streamable-http), OpenAPI
|
||||
All servers will be imported in a single efficient batch operation.`;
|
||||
|
||||
const parseAndValidateJson = (input: string): ImportJsonFormat | null => {
|
||||
try {
|
||||
@@ -95,6 +95,9 @@ HTTP example:
|
||||
if (config.headers) {
|
||||
normalizedConfig.headers = config.headers;
|
||||
}
|
||||
} else if (config.type === 'openapi') {
|
||||
normalizedConfig.type = 'openapi';
|
||||
normalizedConfig.openapi = config.openapi;
|
||||
} else {
|
||||
// Default to stdio
|
||||
normalizedConfig.type = 'stdio';
|
||||
@@ -118,38 +121,31 @@ HTTP example:
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let successCount = 0;
|
||||
const errors: string[] = [];
|
||||
// Use batch import API for better performance
|
||||
const result = await apiPost('/servers/batch', {
|
||||
servers: previewServers,
|
||||
});
|
||||
|
||||
for (const server of previewServers) {
|
||||
try {
|
||||
const result = await apiPost('/servers', {
|
||||
name: server.name,
|
||||
config: server.config,
|
||||
});
|
||||
if (result.success && result.data) {
|
||||
const { successCount, failureCount, results } = result.data;
|
||||
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
errors.push(`${server.name}: ${result.message || t('jsonImport.addFailed')}`);
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push(
|
||||
`${server.name}: ${err instanceof Error ? err.message : t('jsonImport.addFailed')}`,
|
||||
if (failureCount > 0) {
|
||||
const errors = results
|
||||
.filter((r: any) => !r.success)
|
||||
.map((r: any) => `${r.name}: ${r.message || t('jsonImport.addFailed')}`);
|
||||
|
||||
setError(
|
||||
t('jsonImport.partialSuccess', { count: successCount, total: previewServers.length }) +
|
||||
'\n' +
|
||||
errors.join('\n'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
setError(
|
||||
t('jsonImport.partialSuccess', { count: successCount, total: previewServers.length }) +
|
||||
'\n' +
|
||||
errors.join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
onSuccess();
|
||||
if (successCount > 0) {
|
||||
onSuccess();
|
||||
}
|
||||
} else {
|
||||
setError(result.message || t('jsonImport.importFailed'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Import error:', err);
|
||||
|
||||
@@ -375,6 +375,7 @@ const ServerForm = ({
|
||||
? {
|
||||
url: formData.url,
|
||||
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
||||
...(Object.keys(env).length > 0 ? { env } : {}),
|
||||
...(oauthConfig ? { oauth: oauthConfig } : {}),
|
||||
}
|
||||
: {
|
||||
@@ -978,6 +979,49 @@ const ServerForm = ({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<label className="block text-gray-700 text-sm font-bold">
|
||||
{t('server.envVars')}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addEnvVar}
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] btn-primary"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{envVars.map((envVar, index) => (
|
||||
<div key={index} className="flex items-center mb-2">
|
||||
<div className="flex items-center space-x-2 flex-grow">
|
||||
<input
|
||||
type="text"
|
||||
value={envVar.key}
|
||||
onChange={(e) => handleEnvVarChange(index, 'key', e.target.value)}
|
||||
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
|
||||
placeholder={t('server.key')}
|
||||
/>
|
||||
<span className="flex items-center">:</span>
|
||||
<input
|
||||
type="text"
|
||||
value={envVar.value}
|
||||
onChange={(e) => handleEnvVarChange(index, 'value', e.target.value)}
|
||||
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
|
||||
placeholder={t('server.value')}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeEnvVar(index)}
|
||||
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] ml-2 btn-danger"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div
|
||||
className="flex items-center justify-between cursor-pointer bg-gray-50 hover:bg-gray-100 p-3 rounded border border-gray-200"
|
||||
|
||||
@@ -21,7 +21,14 @@ interface DynamicFormProps {
|
||||
title?: string; // Optional title to display instead of default parameters title
|
||||
}
|
||||
|
||||
const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, loading = false, storageKey, title }) => {
|
||||
const DynamicForm: React.FC<DynamicFormProps> = ({
|
||||
schema,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
loading = false,
|
||||
storageKey,
|
||||
title,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [formValues, setFormValues] = useState<Record<string, any>>({});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
@@ -40,9 +47,14 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
description: obj.description,
|
||||
enum: obj.enum,
|
||||
default: obj.default,
|
||||
properties: obj.properties ? Object.fromEntries(
|
||||
Object.entries(obj.properties).map(([key, value]) => [key, convertProperty(value)])
|
||||
) : undefined,
|
||||
properties: obj.properties
|
||||
? Object.fromEntries(
|
||||
Object.entries(obj.properties).map(([key, value]) => [
|
||||
key,
|
||||
convertProperty(value),
|
||||
]),
|
||||
)
|
||||
: undefined,
|
||||
required: obj.required,
|
||||
items: obj.items ? convertProperty(obj.items) : undefined,
|
||||
};
|
||||
@@ -52,9 +64,14 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
|
||||
return {
|
||||
type: schema.type,
|
||||
properties: schema.properties ? Object.fromEntries(
|
||||
Object.entries(schema.properties).map(([key, value]) => [key, convertProperty(value)])
|
||||
) : undefined,
|
||||
properties: schema.properties
|
||||
? Object.fromEntries(
|
||||
Object.entries(schema.properties).map(([key, value]) => [
|
||||
key,
|
||||
convertProperty(value),
|
||||
]),
|
||||
)
|
||||
: undefined,
|
||||
required: schema.required,
|
||||
};
|
||||
};
|
||||
@@ -167,7 +184,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
};
|
||||
|
||||
const handleInputChange = (path: string, value: any) => {
|
||||
setFormValues(prev => {
|
||||
setFormValues((prev) => {
|
||||
const newValues = { ...prev };
|
||||
const keys = path.split('.');
|
||||
let current = newValues;
|
||||
@@ -195,7 +212,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
|
||||
// Clear error for this field
|
||||
if (errors[path]) {
|
||||
setErrors(prev => {
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[path];
|
||||
return newErrors;
|
||||
@@ -209,10 +226,16 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
if (schema.type === 'object' && schema.properties) {
|
||||
Object.entries(schema.properties).forEach(([key, propSchema]) => {
|
||||
const fullPath = path ? `${path}.${key}` : key;
|
||||
const value = getNestedValue(values, fullPath);
|
||||
const value = values?.[key];
|
||||
|
||||
// Check required fields
|
||||
if (schema.required?.includes(key) && (value === undefined || value === null || value === '')) {
|
||||
if (
|
||||
schema.required?.includes(key) &&
|
||||
(value === undefined ||
|
||||
value === null ||
|
||||
value === '' ||
|
||||
(Array.isArray(value) && value.length === 0))
|
||||
) {
|
||||
newErrors[fullPath] = `${key} is required`;
|
||||
return;
|
||||
}
|
||||
@@ -223,7 +246,10 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
newErrors[fullPath] = `${key} must be a string`;
|
||||
} else if (propSchema.type === 'number' && typeof value !== 'number') {
|
||||
newErrors[fullPath] = `${key} must be a number`;
|
||||
} else if (propSchema.type === 'integer' && (!Number.isInteger(value) || typeof value !== 'number')) {
|
||||
} else if (
|
||||
propSchema.type === 'integer' &&
|
||||
(!Number.isInteger(value) || typeof value !== 'number')
|
||||
) {
|
||||
newErrors[fullPath] = `${key} must be an integer`;
|
||||
} else if (propSchema.type === 'boolean' && typeof value !== 'boolean') {
|
||||
newErrors[fullPath] = `${key} must be a boolean`;
|
||||
@@ -260,7 +286,12 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
return path.split('.').reduce((current, key) => current?.[key], obj);
|
||||
};
|
||||
|
||||
const renderObjectField = (key: string, schema: JsonSchema, currentValue: any, onChange: (value: any) => void): React.ReactNode => {
|
||||
const renderObjectField = (
|
||||
key: string,
|
||||
schema: JsonSchema,
|
||||
currentValue: any,
|
||||
onChange: (value: any) => void,
|
||||
): React.ReactNode => {
|
||||
const value = currentValue?.[key];
|
||||
|
||||
if (schema.type === 'string') {
|
||||
@@ -299,7 +330,12 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
step={schema.type === 'integer' ? '1' : 'any'}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value === '' ? '' : schema.type === 'integer' ? parseInt(e.target.value) : parseFloat(e.target.value);
|
||||
const val =
|
||||
e.target.value === ''
|
||||
? ''
|
||||
: schema.type === 'integer'
|
||||
? parseInt(e.target.value)
|
||||
: parseFloat(e.target.value);
|
||||
onChange(val);
|
||||
}}
|
||||
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500 form-input"
|
||||
@@ -333,7 +369,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
const renderField = (key: string, propSchema: JsonSchema, path: string = ''): React.ReactNode => {
|
||||
const fullPath = path ? `${path}.${key}` : key;
|
||||
const value = getNestedValue(formValues, fullPath);
|
||||
const error = errors[fullPath]; // Handle array type
|
||||
const error = errors[fullPath]; // Handle array type
|
||||
if (propSchema.type === 'array') {
|
||||
const arrayValue = getNestedValue(formValues, fullPath) || [];
|
||||
|
||||
@@ -341,7 +377,11 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
<div key={fullPath} className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{key}
|
||||
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
|
||||
{(path
|
||||
? getNestedValue(jsonSchema, path)?.required?.includes(key)
|
||||
: jsonSchema.required?.includes(key)) && (
|
||||
<span className="text-status-red ml-1">*</span>
|
||||
)}
|
||||
</label>
|
||||
{propSchema.description && (
|
||||
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
|
||||
@@ -349,9 +389,11 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
|
||||
<div className="border border-gray-200 rounded-md p-3 bg-gray-50">
|
||||
{arrayValue.map((item: any, index: number) => (
|
||||
<div key={index} className="mb-3 p-3 bg-white border rounded-md">
|
||||
<div key={index} className="mb-3 p-3 bg-white border border-gray-200 rounded-md">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm font-medium text-gray-600">{t('tool.item', { index: index + 1 })}</span>
|
||||
<span className="text-sm font-medium text-gray-600">
|
||||
{t('tool.item', { index: index + 1 })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -388,7 +430,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
<div key={objKey}>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
||||
{objKey}
|
||||
{propSchema.items?.required?.includes(objKey) && <span className="text-status-red ml-1">*</span>}
|
||||
{propSchema.items?.required?.includes(objKey) && (
|
||||
<span className="text-status-red ml-1">*</span>
|
||||
)}
|
||||
</label>
|
||||
{renderObjectField(objKey, objSchema as JsonSchema, item, (newValue) => {
|
||||
const newArray = [...arrayValue];
|
||||
@@ -429,7 +473,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
} // Handle object type
|
||||
} // Handle object type
|
||||
if (propSchema.type === 'object') {
|
||||
if (propSchema.properties) {
|
||||
// Object with defined properties - render as nested form
|
||||
@@ -437,16 +481,20 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
<div key={fullPath} className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{key}
|
||||
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
|
||||
{(path
|
||||
? getNestedValue(jsonSchema, path)?.required?.includes(key)
|
||||
: jsonSchema.required?.includes(key)) && (
|
||||
<span className="text-status-red ml-1">*</span>
|
||||
)}
|
||||
</label>
|
||||
{propSchema.description && (
|
||||
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
|
||||
)}
|
||||
|
||||
<div className="border border-gray-200 rounded-md p-4 bg-gray-50">
|
||||
{Object.entries(propSchema.properties).map(([objKey, objSchema]) => (
|
||||
renderField(objKey, objSchema as JsonSchema, fullPath)
|
||||
))}
|
||||
{Object.entries(propSchema.properties).map(([objKey, objSchema]) =>
|
||||
renderField(objKey, objSchema as JsonSchema, fullPath),
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
|
||||
@@ -458,7 +506,11 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
<div key={fullPath} className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{key}
|
||||
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
|
||||
{(path
|
||||
? getNestedValue(jsonSchema, path)?.required?.includes(key)
|
||||
: jsonSchema.required?.includes(key)) && (
|
||||
<span className="text-status-red ml-1">*</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-500 ml-1">(JSON object)</span>
|
||||
</label>
|
||||
{propSchema.description && (
|
||||
@@ -483,13 +535,16 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} if (propSchema.type === 'string') {
|
||||
}
|
||||
if (propSchema.type === 'string') {
|
||||
if (propSchema.enum) {
|
||||
return (
|
||||
<div key={fullPath} className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{key}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && (
|
||||
<span className="text-status-red ml-1">*</span>
|
||||
)}
|
||||
</label>
|
||||
{propSchema.description && (
|
||||
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
|
||||
@@ -514,7 +569,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
<div key={fullPath} className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{key}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && (
|
||||
<span className="text-status-red ml-1">*</span>
|
||||
)}
|
||||
</label>
|
||||
{propSchema.description && (
|
||||
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
|
||||
@@ -529,12 +586,15 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} if (propSchema.type === 'number' || propSchema.type === 'integer') {
|
||||
}
|
||||
if (propSchema.type === 'number' || propSchema.type === 'integer') {
|
||||
return (
|
||||
<div key={fullPath} className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{key}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && (
|
||||
<span className="text-status-red ml-1">*</span>
|
||||
)}
|
||||
</label>
|
||||
{propSchema.description && (
|
||||
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
|
||||
@@ -544,7 +604,12 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
step={propSchema.type === 'integer' ? '1' : 'any'}
|
||||
value={value !== undefined && value !== null ? value : ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value === '' ? '' : propSchema.type === 'integer' ? parseInt(e.target.value) : parseFloat(e.target.value);
|
||||
const val =
|
||||
e.target.value === ''
|
||||
? ''
|
||||
: propSchema.type === 'integer'
|
||||
? parseInt(e.target.value)
|
||||
: parseFloat(e.target.value);
|
||||
handleInputChange(fullPath, val);
|
||||
}}
|
||||
className={`w-full border rounded-md px-3 py-2 form-input ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
@@ -566,7 +631,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
/>
|
||||
<label className="ml-2 block text-sm text-gray-700">
|
||||
{key}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && (
|
||||
<span className="text-status-red ml-1">*</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
{propSchema.description && (
|
||||
@@ -575,12 +642,14 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
{error && <p className="text-status-red text-xs mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
} // For other types, show as text input with description
|
||||
} // For other types, show as text input with description
|
||||
return (
|
||||
<div key={fullPath} className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{key}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>}
|
||||
{(path ? false : jsonSchema.required?.includes(key)) && (
|
||||
<span className="text-status-red ml-1">*</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-500 ml-1">({propSchema.type})</span>
|
||||
</label>
|
||||
{propSchema.description && (
|
||||
@@ -631,20 +700,22 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
<button
|
||||
type="button"
|
||||
onClick={switchToFormMode}
|
||||
className={`px-3 py-1 text-sm rounded-md transition-colors ${!isJsonMode
|
||||
? 'bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary'
|
||||
: 'text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary'
|
||||
}`}
|
||||
className={`px-3 py-1 text-sm rounded-md transition-colors ${
|
||||
!isJsonMode
|
||||
? 'bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary'
|
||||
: 'text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary'
|
||||
}`}
|
||||
>
|
||||
{t('tool.formMode')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={switchToJsonMode}
|
||||
className={`px-3 py-1 text-sm rounded-md transition-colors ${isJsonMode
|
||||
? 'px-4 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary'
|
||||
: 'text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary'
|
||||
}`}
|
||||
className={`px-3 py-1 text-sm rounded-md transition-colors ${
|
||||
isJsonMode
|
||||
? 'px-4 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary'
|
||||
: 'text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary'
|
||||
}`}
|
||||
>
|
||||
{t('tool.jsonMode')}
|
||||
</button>
|
||||
@@ -662,8 +733,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
value={jsonText}
|
||||
onChange={(e) => handleJsonTextChange(e.target.value)}
|
||||
placeholder={`{\n "key": "value"\n}`}
|
||||
className={`w-full h-64 border rounded-md px-3 py-2 font-mono text-sm resize-y form-input ${jsonError ? 'border-red-500' : 'border-gray-300'
|
||||
} focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
className={`w-full h-64 border rounded-md px-3 py-2 font-mono text-sm resize-y form-input ${
|
||||
jsonError ? 'border-red-500' : 'border-gray-300'
|
||||
} focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
/>
|
||||
{jsonError && <p className="text-status-red text-xs mt-1">{jsonError}</p>}
|
||||
</div>
|
||||
@@ -696,7 +768,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
|
||||
/* Form Mode */
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{Object.entries(jsonSchema.properties || {}).map(([key, propSchema]) =>
|
||||
renderField(key, propSchema)
|
||||
renderField(key, propSchema),
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-4">
|
||||
|
||||
161
frontend/src/components/ui/MultiSelect.tsx
Normal file
161
frontend/src/components/ui/MultiSelect.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Check, ChevronDown, X } from 'lucide-react';
|
||||
|
||||
interface MultiSelectProps {
|
||||
options: { value: string; label: string }[];
|
||||
selected: string[];
|
||||
onChange: (selected: string[]) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const MultiSelect: React.FC<MultiSelectProps> = ({
|
||||
options,
|
||||
selected,
|
||||
onChange,
|
||||
placeholder = 'Select items...',
|
||||
disabled = false,
|
||||
className = '',
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
setSearchTerm('');
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const filteredOptions = options.filter((option) =>
|
||||
option.label.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
const handleToggleOption = (value: string) => {
|
||||
if (disabled) return;
|
||||
|
||||
const newSelected = selected.includes(value)
|
||||
? selected.filter((item) => item !== value)
|
||||
: [...selected, value];
|
||||
|
||||
onChange(newSelected);
|
||||
};
|
||||
|
||||
const handleRemoveItem = (value: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (disabled) return;
|
||||
onChange(selected.filter((item) => item !== value));
|
||||
};
|
||||
|
||||
const handleToggleDropdown = () => {
|
||||
if (disabled) return;
|
||||
setIsOpen(!isOpen);
|
||||
if (!isOpen) {
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}
|
||||
};
|
||||
|
||||
const getSelectedLabels = () => {
|
||||
return selected
|
||||
.map((value) => options.find((opt) => opt.value === value)?.label || value)
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={dropdownRef} className={`relative ${className}`}>
|
||||
{/* Selected items display */}
|
||||
<div
|
||||
onClick={handleToggleDropdown}
|
||||
className={`
|
||||
min-h-[38px] w-full px-3 py-1.5 border rounded-md shadow-sm
|
||||
flex flex-wrap items-center gap-1.5 cursor-pointer
|
||||
transition-all duration-200
|
||||
${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white hover:border-blue-400'}
|
||||
${isOpen ? 'border-blue-500 ring-1 ring-blue-500' : 'border-gray-300'}
|
||||
`}
|
||||
>
|
||||
{selected.length > 0 ? (
|
||||
<>
|
||||
{getSelectedLabels().map((label, index) => (
|
||||
<span
|
||||
key={selected[index]}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800"
|
||||
>
|
||||
{label}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleRemoveItem(selected[index], e)}
|
||||
className="ml-1 hover:bg-blue-200 rounded-full p-0.5 transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-400 text-sm">{placeholder}</span>
|
||||
)}
|
||||
<div className="flex-1"></div>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 text-gray-400 transition-transform duration-200 ${isOpen ? 'transform rotate-180' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{isOpen && !disabled && (
|
||||
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-hidden">
|
||||
{/* Search input */}
|
||||
<div className="p-2 border-b border-gray-200">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search..."
|
||||
className="w-full px-3 py-1.5 text-sm border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Options list */}
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((option) => {
|
||||
const isSelected = selected.includes(option.value);
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
onClick={() => handleToggleOption(option.value)}
|
||||
className={`
|
||||
px-3 py-2 cursor-pointer flex items-center justify-between
|
||||
transition-colors duration-150
|
||||
${isSelected ? 'bg-blue-50 text-blue-700' : 'hover:bg-gray-100'}
|
||||
`}
|
||||
>
|
||||
<span className="text-sm">{option.label}</span>
|
||||
{isSelected && <Check className="h-4 w-4 text-blue-600" />}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="px-3 py-2 text-sm text-gray-500 text-center">
|
||||
{searchTerm ? 'No results found' : 'No options available'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -14,14 +14,17 @@ const initialState: AuthState = {
|
||||
// Create auth context
|
||||
const AuthContext = createContext<{
|
||||
auth: AuthState;
|
||||
login: (username: string, password: string) => Promise<{ success: boolean; isUsingDefaultPassword?: boolean }>;
|
||||
login: (
|
||||
username: string,
|
||||
password: string,
|
||||
) => Promise<{ success: boolean; isUsingDefaultPassword?: boolean; message?: string }>;
|
||||
register: (username: string, password: string, isAdmin?: boolean) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
}>({
|
||||
auth: initialState,
|
||||
login: async () => ({ success: false }),
|
||||
register: async () => false,
|
||||
logout: () => { },
|
||||
logout: () => {},
|
||||
});
|
||||
|
||||
// Auth provider component
|
||||
@@ -90,7 +93,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
}, []);
|
||||
|
||||
// Login function
|
||||
const login = async (username: string, password: string): Promise<{ success: boolean; isUsingDefaultPassword?: boolean }> => {
|
||||
const login = async (
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<{ success: boolean; isUsingDefaultPassword?: boolean; message?: string }> => {
|
||||
try {
|
||||
const response = await authService.login({ username, password });
|
||||
|
||||
@@ -111,7 +117,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
loading: false,
|
||||
error: response.message || 'Authentication failed',
|
||||
});
|
||||
return { success: false };
|
||||
return { success: false, message: response.message };
|
||||
}
|
||||
} catch (error) {
|
||||
setAuth({
|
||||
@@ -119,7 +125,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
loading: false,
|
||||
error: 'Authentication failed',
|
||||
});
|
||||
return { success: false };
|
||||
return { success: false, message: error instanceof Error ? error.message : undefined };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -127,7 +133,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
const register = async (
|
||||
username: string,
|
||||
password: string,
|
||||
isAdmin = false
|
||||
isAdmin = false,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const response = await authService.register({ username, password, isAdmin });
|
||||
@@ -175,4 +181,4 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
};
|
||||
|
||||
// Custom hook to use auth context
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
|
||||
@@ -7,9 +7,10 @@ import React, {
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ApiResponse } from '@/types';
|
||||
import { ApiResponse, BearerKey } from '@/types';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import { apiGet, apiPut } from '@/utils/fetchInterceptor';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { apiGet, apiPut, apiPost, apiDelete } from '@/utils/fetchInterceptor';
|
||||
|
||||
// Define types for the settings data
|
||||
interface RoutingConfig {
|
||||
@@ -66,6 +67,7 @@ interface SystemSettings {
|
||||
oauthServer?: OAuthServerConfig;
|
||||
enableSessionRebuild?: boolean;
|
||||
};
|
||||
bearerKeys?: BearerKey[];
|
||||
}
|
||||
|
||||
interface TempRoutingConfig {
|
||||
@@ -82,6 +84,7 @@ interface SettingsContextValue {
|
||||
oauthServerConfig: OAuthServerConfig;
|
||||
nameSeparator: string;
|
||||
enableSessionRebuild: boolean;
|
||||
bearerKeys: BearerKey[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
setError: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
@@ -109,6 +112,14 @@ interface SettingsContextValue {
|
||||
updateNameSeparator: (value: string) => Promise<boolean | undefined>;
|
||||
updateSessionRebuild: (value: boolean) => Promise<boolean | undefined>;
|
||||
exportMCPSettings: (serverName?: string) => Promise<any>;
|
||||
// Bearer key management
|
||||
refreshBearerKeys: () => Promise<void>;
|
||||
createBearerKey: (payload: Omit<BearerKey, 'id'>) => Promise<BearerKey | null>;
|
||||
updateBearerKey: (
|
||||
id: string,
|
||||
updates: Partial<Omit<BearerKey, 'id'>>,
|
||||
) => Promise<BearerKey | null>;
|
||||
deleteBearerKey: (id: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const getDefaultOAuthServerConfig = (): OAuthServerConfig => ({
|
||||
@@ -143,6 +154,7 @@ interface SettingsProviderProps {
|
||||
export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children }) => {
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
const { auth } = useAuth();
|
||||
|
||||
const [routingConfig, setRoutingConfig] = useState<RoutingConfig>({
|
||||
enableGlobalRoute: true,
|
||||
@@ -183,6 +195,7 @@ export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children })
|
||||
|
||||
const [nameSeparator, setNameSeparator] = useState<string>('-');
|
||||
const [enableSessionRebuild, setEnableSessionRebuild] = useState<boolean>(false);
|
||||
const [bearerKeys, setBearerKeys] = useState<BearerKey[]>([]);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -279,6 +292,10 @@ export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children })
|
||||
if (data.success && data.data?.systemConfig?.enableSessionRebuild !== undefined) {
|
||||
setEnableSessionRebuild(data.data.systemConfig.enableSessionRebuild);
|
||||
}
|
||||
|
||||
if (data.success && Array.isArray(data.data?.bearerKeys)) {
|
||||
setBearerKeys(data.data.bearerKeys);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to fetch settings');
|
||||
@@ -659,11 +676,87 @@ export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children })
|
||||
}
|
||||
};
|
||||
|
||||
// Bearer key management helpers
|
||||
const refreshBearerKeys = async () => {
|
||||
try {
|
||||
const data: ApiResponse<BearerKey[]> = await apiGet('/auth/keys');
|
||||
if (data.success && Array.isArray(data.data)) {
|
||||
setBearerKeys(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh bearer keys:', error);
|
||||
showToast(t('errors.failedToFetchSettings'));
|
||||
}
|
||||
};
|
||||
|
||||
const createBearerKey = async (payload: Omit<BearerKey, 'id'>): Promise<BearerKey | null> => {
|
||||
try {
|
||||
const data: ApiResponse<BearerKey> = await apiPost('/auth/keys', payload as any);
|
||||
if (data.success && data.data) {
|
||||
await refreshBearerKeys();
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return data.data;
|
||||
}
|
||||
showToast(data.message || t('errors.failedToUpdateRoutingConfig'));
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Failed to create bearer key:', error);
|
||||
showToast(t('errors.failedToUpdateRoutingConfig'));
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const updateBearerKey = async (
|
||||
id: string,
|
||||
updates: Partial<Omit<BearerKey, 'id'>>,
|
||||
): Promise<BearerKey | null> => {
|
||||
try {
|
||||
const data: ApiResponse<BearerKey> = await apiPut(`/auth/keys/${id}`, updates as any);
|
||||
if (data.success && data.data) {
|
||||
await refreshBearerKeys();
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return data.data;
|
||||
}
|
||||
showToast(data.message || t('errors.failedToUpdateRoutingConfig'));
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Failed to update bearer key:', error);
|
||||
showToast(t('errors.failedToUpdateRoutingConfig'));
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteBearerKey = async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const data: ApiResponse = await apiDelete(`/auth/keys/${id}`);
|
||||
if (data.success) {
|
||||
await refreshBearerKeys();
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
}
|
||||
showToast(data.message || t('errors.failedToUpdateRoutingConfig'));
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Failed to delete bearer key:', error);
|
||||
showToast(t('errors.failedToUpdateRoutingConfig'));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch settings when the component mounts or refreshKey changes
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, [fetchSettings, refreshKey]);
|
||||
|
||||
// Watch for authentication status changes - refetch settings after login
|
||||
useEffect(() => {
|
||||
if (auth.isAuthenticated) {
|
||||
console.log('[SettingsContext] User authenticated, triggering settings refresh');
|
||||
// When user logs in, trigger a refresh to load settings
|
||||
triggerRefresh();
|
||||
}
|
||||
}, [auth.isAuthenticated, triggerRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (routingConfig) {
|
||||
setTempRoutingConfig({
|
||||
@@ -682,6 +775,7 @@ export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children })
|
||||
oauthServerConfig,
|
||||
nameSeparator,
|
||||
enableSessionRebuild,
|
||||
bearerKeys,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
@@ -699,6 +793,10 @@ export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children })
|
||||
updateNameSeparator,
|
||||
updateSessionRebuild,
|
||||
exportMCPSettings,
|
||||
refreshBearerKeys,
|
||||
createBearerKey,
|
||||
updateBearerKey,
|
||||
deleteBearerKey,
|
||||
};
|
||||
|
||||
return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useServerData } from '@/hooks/useServerData';
|
||||
import AddGroupForm from '@/components/AddGroupForm';
|
||||
import EditGroupForm from '@/components/EditGroupForm';
|
||||
import GroupCard from '@/components/GroupCard';
|
||||
import GroupImportForm from '@/components/GroupImportForm';
|
||||
|
||||
const GroupsPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -15,12 +16,13 @@ const GroupsPage: React.FC = () => {
|
||||
error: groupError,
|
||||
setError: setGroupError,
|
||||
deleteGroup,
|
||||
triggerRefresh
|
||||
triggerRefresh,
|
||||
} = useGroupData();
|
||||
const { servers } = useServerData({ refreshOnMount: true });
|
||||
|
||||
const [editingGroup, setEditingGroup] = useState<Group | null>(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [showImportForm, setShowImportForm] = useState(false);
|
||||
|
||||
const handleEditClick = (group: Group) => {
|
||||
setEditingGroup(group);
|
||||
@@ -47,6 +49,11 @@ const GroupsPage: React.FC = () => {
|
||||
triggerRefresh(); // Refresh the groups list after adding
|
||||
};
|
||||
|
||||
const handleImportSuccess = () => {
|
||||
setShowImportForm(false);
|
||||
triggerRefresh(); // Refresh the groups list after import
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
@@ -56,11 +63,38 @@ const GroupsPage: React.FC = () => {
|
||||
onClick={handleAddGroup}
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 3a1 1 0 00-1 1v5H4a1 1 0 100 2h5v5a1 1 0 102 0v-5h5a1 1 0 100-2h-5V4a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 mr-2"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 3a1 1 0 00-1 1v5H4a1 1 0 100 2h5v5a1 1 0 102 0v-5h5a1 1 0 100-2h-5V4a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{t('groups.add')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowImportForm(true)}
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 mr-2"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{t('groupImport.button')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -73,9 +107,25 @@ const GroupsPage: React.FC = () => {
|
||||
{groupsLoading ? (
|
||||
<div className="bg-white shadow rounded-lg p-6 loading-container">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
<svg
|
||||
className="animate-spin h-10 w-10 text-blue-500 mb-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<p className="text-gray-600">{t('app.loading')}</p>
|
||||
</div>
|
||||
@@ -98,8 +148,13 @@ const GroupsPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAddForm && (
|
||||
<AddGroupForm onAdd={handleAddComplete} onCancel={handleAddComplete} />
|
||||
{showAddForm && <AddGroupForm onAdd={handleAddComplete} onCancel={handleAddComplete} />}
|
||||
|
||||
{showImportForm && (
|
||||
<GroupImportForm
|
||||
onSuccess={handleImportSuccess}
|
||||
onCancel={() => setShowImportForm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingGroup && (
|
||||
@@ -113,4 +168,4 @@ const GroupsPage: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupsPage;
|
||||
export default GroupsPage;
|
||||
|
||||
@@ -44,6 +44,24 @@ const LoginPage: React.FC = () => {
|
||||
return sanitizeReturnUrl(params.get('returnUrl'));
|
||||
}, [location.search]);
|
||||
|
||||
const isServerUnavailableError = useCallback((message?: string) => {
|
||||
if (!message) return false;
|
||||
const normalized = message.toLowerCase();
|
||||
|
||||
return (
|
||||
normalized.includes('failed to fetch') ||
|
||||
normalized.includes('networkerror') ||
|
||||
normalized.includes('network error') ||
|
||||
normalized.includes('connection refused') ||
|
||||
normalized.includes('unable to connect') ||
|
||||
normalized.includes('fetch error') ||
|
||||
normalized.includes('econnrefused') ||
|
||||
normalized.includes('http 500') ||
|
||||
normalized.includes('internal server error') ||
|
||||
normalized.includes('proxy error')
|
||||
);
|
||||
}, []);
|
||||
|
||||
const buildRedirectTarget = useCallback(() => {
|
||||
if (!returnUrl) {
|
||||
return '/';
|
||||
@@ -100,10 +118,20 @@ const LoginPage: React.FC = () => {
|
||||
redirectAfterLogin();
|
||||
}
|
||||
} else {
|
||||
setError(t('auth.loginFailed'));
|
||||
const message = result.message;
|
||||
if (isServerUnavailableError(message)) {
|
||||
setError(t('auth.serverUnavailable'));
|
||||
} else {
|
||||
setError(t('auth.loginFailed'));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(t('auth.loginError'));
|
||||
const message = err instanceof Error ? err.message : undefined;
|
||||
if (isServerUnavailableError(message)) {
|
||||
setError(t('auth.serverUnavailable'));
|
||||
} else {
|
||||
setError(t('auth.loginError'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -131,13 +159,21 @@ const LoginPage: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 -z-10">
|
||||
<svg className="h-full w-full opacity-[0.08] dark:opacity-[0.12]" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg
|
||||
className="h-full w-full opacity-[0.08] dark:opacity-[0.12]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
<pattern id="grid" width="32" height="32" patternUnits="userSpaceOnUse">
|
||||
<path d="M 32 0 L 0 0 0 32" fill="none" stroke="currentColor" strokeWidth="0.5" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#grid)" className="text-gray-400 dark:text-gray-300" />
|
||||
<rect
|
||||
width="100%"
|
||||
height="100%"
|
||||
fill="url(#grid)"
|
||||
className="text-gray-400 dark:text-gray-300"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,7 +29,7 @@ export const login = async (credentials: LoginCredentials): Promise<AuthResponse
|
||||
console.error('Login error:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: 'An error occurred during login',
|
||||
message: error instanceof Error ? error.message : 'An error occurred during login',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -309,6 +309,19 @@ export interface ApiResponse<T = any> {
|
||||
data?: T;
|
||||
}
|
||||
|
||||
// Bearer authentication key configuration (frontend view model)
|
||||
export type BearerKeyAccessType = 'all' | 'groups' | 'servers';
|
||||
|
||||
export interface BearerKey {
|
||||
id: string;
|
||||
name: string;
|
||||
token: string;
|
||||
enabled: boolean;
|
||||
accessType: BearerKeyAccessType;
|
||||
allowedGroups?: string[];
|
||||
allowedServers?: string[];
|
||||
}
|
||||
|
||||
// Auth types
|
||||
export interface IUser {
|
||||
username: string;
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"emptyFields": "Username and password cannot be empty",
|
||||
"loginFailed": "Login failed, please check your username and password",
|
||||
"loginError": "An error occurred during login",
|
||||
"serverUnavailable": "Unable to connect to the server. Please check your network connection or try again later",
|
||||
"currentPassword": "Current Password",
|
||||
"newPassword": "New Password",
|
||||
"confirmPassword": "Confirm Password",
|
||||
@@ -253,7 +254,11 @@
|
||||
"type": "Type",
|
||||
"repeated": "Repeated",
|
||||
"valueHint": "Value Hint",
|
||||
"choices": "Choices"
|
||||
"choices": "Choices",
|
||||
"actions": "Actions",
|
||||
"saving": "Saving...",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -276,7 +281,7 @@
|
||||
"recentServers": "Recent Servers"
|
||||
},
|
||||
"servers": {
|
||||
"title": "Servers Management"
|
||||
"title": "Server Management"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Group Management"
|
||||
@@ -553,6 +558,27 @@
|
||||
"bearerAuthKey": "Bearer Authentication Key",
|
||||
"bearerAuthKeyDescription": "The authentication key that will be required in the Bearer token",
|
||||
"bearerAuthKeyPlaceholder": "Enter bearer authentication key",
|
||||
"bearerKeysSectionTitle": "Keys",
|
||||
"bearerKeysSectionDescription": "Manage multiple keys with different access scopes.",
|
||||
"noBearerKeys": "No keys configured yet.",
|
||||
"bearerKeyName": "Name",
|
||||
"bearerKeyToken": "Token",
|
||||
"bearerKeyEnabled": "Enabled",
|
||||
"bearerKeyAccessType": "Access scope",
|
||||
"bearerKeyAccessAll": "All",
|
||||
"bearerKeyAccessGroups": "Groups",
|
||||
"bearerKeyAccessServers": "Servers",
|
||||
"bearerKeyAllowedGroups": "Allowed groups",
|
||||
"bearerKeyAllowedServers": "Allowed servers",
|
||||
"addBearerKey": "Add key",
|
||||
"addBearerKeyButton": "Create",
|
||||
"bearerKeyRequired": "Name and token are required",
|
||||
"deleteBearerKeyConfirm": "Are you sure you want to delete this key?",
|
||||
"generate": "Generate",
|
||||
"selectGroups": "Select Groups",
|
||||
"selectServers": "Select Servers",
|
||||
"selectAtLeastOneGroup": "Please select at least one group",
|
||||
"selectAtLeastOneServer": "Please select at least one server",
|
||||
"skipAuth": "Skip Authentication",
|
||||
"skipAuthDescription": "Bypass login requirement for frontend and API access (DEFAULT OFF for security)",
|
||||
"pythonIndexUrl": "Python Package Repository URL",
|
||||
@@ -672,6 +698,22 @@
|
||||
"importFailed": "Failed to import servers",
|
||||
"partialSuccess": "Imported {{count}} of {{total}} servers successfully. Some servers failed:"
|
||||
},
|
||||
"groupImport": {
|
||||
"button": "Import",
|
||||
"title": "Import Groups from JSON",
|
||||
"inputLabel": "Group Configuration JSON",
|
||||
"inputHelp": "Paste your group configuration JSON. Each group can contain a list of servers.",
|
||||
"preview": "Preview",
|
||||
"previewTitle": "Preview Groups to Import",
|
||||
"import": "Import",
|
||||
"importing": "Importing...",
|
||||
"invalidFormat": "Invalid JSON format. The JSON must contain a 'groups' array.",
|
||||
"missingName": "Each group must have a 'name' field.",
|
||||
"parseError": "Failed to parse JSON. Please check the format and try again.",
|
||||
"addFailed": "Failed to add group",
|
||||
"importFailed": "Failed to import groups",
|
||||
"partialSuccess": "Imported {{count}} of {{total}} groups successfully. Some groups failed:"
|
||||
},
|
||||
"users": {
|
||||
"add": "Add User",
|
||||
"addNew": "Add New User",
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"emptyFields": "Le nom d'utilisateur et le mot de passe ne peuvent pas être vides",
|
||||
"loginFailed": "Échec de la connexion, veuillez vérifier votre nom d'utilisateur et votre mot de passe",
|
||||
"loginError": "Une erreur est survenue lors de la connexion",
|
||||
"serverUnavailable": "Impossible de se connecter au serveur. Veuillez vérifier votre connexion réseau ou réessayer plus tard",
|
||||
"currentPassword": "Mot de passe actuel",
|
||||
"newPassword": "Nouveau mot de passe",
|
||||
"confirmPassword": "Confirmer le mot de passe",
|
||||
@@ -254,7 +255,11 @@
|
||||
"type": "Type",
|
||||
"repeated": "Répété",
|
||||
"valueHint": "Indice de valeur",
|
||||
"choices": "Choix"
|
||||
"choices": "Choix",
|
||||
"actions": "Actions",
|
||||
"saving": "Enregistrement...",
|
||||
"active": "Actif",
|
||||
"inactive": "Inactif"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Tableau de bord",
|
||||
@@ -554,6 +559,27 @@
|
||||
"bearerAuthKey": "Clé d'authentification Bearer",
|
||||
"bearerAuthKeyDescription": "La clé d'authentification qui sera requise dans le jeton Bearer",
|
||||
"bearerAuthKeyPlaceholder": "Entrez la clé d'authentification Bearer",
|
||||
"bearerKeysSectionTitle": "Clés",
|
||||
"bearerKeysSectionDescription": "Gérez plusieurs clés avec différentes portées d’accès.",
|
||||
"noBearerKeys": "Aucune clé configurée pour le moment.",
|
||||
"bearerKeyName": "Nom",
|
||||
"bearerKeyToken": "Jeton",
|
||||
"bearerKeyEnabled": "Activée",
|
||||
"bearerKeyAccessType": "Portée d’accès",
|
||||
"bearerKeyAccessAll": "Toutes",
|
||||
"bearerKeyAccessGroups": "Groupes",
|
||||
"bearerKeyAccessServers": "Serveurs",
|
||||
"bearerKeyAllowedGroups": "Groupes autorisés",
|
||||
"bearerKeyAllowedServers": "Serveurs autorisés",
|
||||
"addBearerKey": "Ajouter une clé",
|
||||
"addBearerKeyButton": "Créer",
|
||||
"bearerKeyRequired": "Le nom et le jeton sont obligatoires",
|
||||
"deleteBearerKeyConfirm": "Voulez-vous vraiment supprimer cette clé ?",
|
||||
"generate": "Générer",
|
||||
"selectGroups": "Sélectionner des groupes",
|
||||
"selectServers": "Sélectionner des serveurs",
|
||||
"selectAtLeastOneGroup": "Veuillez sélectionner au moins un groupe",
|
||||
"selectAtLeastOneServer": "Veuillez sélectionner au moins un serveur",
|
||||
"skipAuth": "Ignorer l'authentification",
|
||||
"skipAuthDescription": "Contourner l'exigence de connexion pour l'accès au frontend et à l'API (DÉSACTIVÉ PAR DÉFAUT pour des raisons de sécurité)",
|
||||
"pythonIndexUrl": "URL du dépôt de paquets Python",
|
||||
@@ -673,6 +699,22 @@
|
||||
"importFailed": "Échec de l'importation des serveurs",
|
||||
"partialSuccess": "{{count}} serveur(s) sur {{total}} importé(s) avec succès. Certains serveurs ont échoué :"
|
||||
},
|
||||
"groupImport": {
|
||||
"button": "Importer",
|
||||
"title": "Importer des groupes depuis JSON",
|
||||
"inputLabel": "Configuration JSON des groupes",
|
||||
"inputHelp": "Collez votre configuration JSON de groupes. Chaque groupe peut contenir une liste de serveurs.",
|
||||
"preview": "Aperçu",
|
||||
"previewTitle": "Aperçu des groupes à importer",
|
||||
"import": "Importer",
|
||||
"importing": "Importation en cours...",
|
||||
"invalidFormat": "Format JSON invalide. Le JSON doit contenir un tableau 'groups'.",
|
||||
"missingName": "Chaque groupe doit avoir un champ 'name'.",
|
||||
"parseError": "Échec de l'analyse du JSON. Veuillez vérifier le format et réessayer.",
|
||||
"addFailed": "Échec de l'ajout du groupe",
|
||||
"importFailed": "Échec de l'importation des groupes",
|
||||
"partialSuccess": "{{count}} groupe(s) sur {{total}} importé(s) avec succès. Certains groupes ont échoué :"
|
||||
},
|
||||
"users": {
|
||||
"add": "Ajouter un utilisateur",
|
||||
"addNew": "Ajouter un nouvel utilisateur",
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"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",
|
||||
"serverUnavailable": "Sunucuya bağlanılamıyor. Lütfen ağ bağlantınızı kontrol edin veya daha sonra tekrar deneyin",
|
||||
"currentPassword": "Mevcut Şifre",
|
||||
"newPassword": "Yeni Şifre",
|
||||
"confirmPassword": "Şifreyi Onayla",
|
||||
@@ -254,7 +255,11 @@
|
||||
"type": "Tür",
|
||||
"repeated": "Tekrarlanan",
|
||||
"valueHint": "Değer İpucu",
|
||||
"choices": "Seçenekler"
|
||||
"choices": "Seçenekler",
|
||||
"actions": "Eylemler",
|
||||
"saving": "Kaydediliyor...",
|
||||
"active": "Aktif",
|
||||
"inactive": "Pasif"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Kontrol Paneli",
|
||||
@@ -554,6 +559,27 @@
|
||||
"bearerAuthKey": "Bearer Kimlik Doğrulama Anahtarı",
|
||||
"bearerAuthKeyDescription": "Bearer token'da gerekli olacak kimlik doğrulama anahtarı",
|
||||
"bearerAuthKeyPlaceholder": "Bearer kimlik doğrulama anahtarını girin",
|
||||
"bearerKeysSectionTitle": "Anahtarlar",
|
||||
"bearerKeysSectionDescription": "Farklı erişim kapsamlarına sahip birden fazla anahtarı yönetin.",
|
||||
"noBearerKeys": "Henüz yapılandırılmış herhangi bir anahtar yok.",
|
||||
"bearerKeyName": "Ad",
|
||||
"bearerKeyToken": "Token",
|
||||
"bearerKeyEnabled": "Etkin",
|
||||
"bearerKeyAccessType": "Erişim kapsamı",
|
||||
"bearerKeyAccessAll": "Tümü",
|
||||
"bearerKeyAccessGroups": "Gruplar",
|
||||
"bearerKeyAccessServers": "Sunucular",
|
||||
"bearerKeyAllowedGroups": "İzin verilen gruplar",
|
||||
"bearerKeyAllowedServers": "İzin verilen sunucular",
|
||||
"addBearerKey": "Anahtar ekle",
|
||||
"addBearerKeyButton": "Oluştur",
|
||||
"bearerKeyRequired": "Ad ve token zorunludur",
|
||||
"deleteBearerKeyConfirm": "Bu anahtarı silmek istediğinizden emin misiniz?",
|
||||
"generate": "Oluştur",
|
||||
"selectGroups": "Grupları Seç",
|
||||
"selectServers": "Sunucuları Seç",
|
||||
"selectAtLeastOneGroup": "Lütfen en az bir grup seçin",
|
||||
"selectAtLeastOneServer": "Lütfen en az bir sunucu seçin",
|
||||
"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",
|
||||
@@ -673,6 +699,22 @@
|
||||
"importFailed": "Sunucular içe aktarılamadı",
|
||||
"partialSuccess": "{{total}} sunucudan {{count}} tanesi başarıyla içe aktarıldı. Bazı sunucular başarısız oldu:"
|
||||
},
|
||||
"groupImport": {
|
||||
"button": "İçe Aktar",
|
||||
"title": "JSON'dan Grupları İçe Aktar",
|
||||
"inputLabel": "Grup Yapılandırma JSON",
|
||||
"inputHelp": "Grup yapılandırma JSON'unuzu yapıştırın. Her grup bir sunucu listesi içerebilir.",
|
||||
"preview": "Önizle",
|
||||
"previewTitle": "İçe Aktarılacak Grupları Önizle",
|
||||
"import": "İçe Aktar",
|
||||
"importing": "İçe aktarılıyor...",
|
||||
"invalidFormat": "Geçersiz JSON formatı. JSON bir 'groups' dizisi içermelidir.",
|
||||
"missingName": "Her grubun bir 'name' alanı olmalıdır.",
|
||||
"parseError": "JSON ayrıştırılamadı. Lütfen formatı kontrol edip tekrar deneyin.",
|
||||
"addFailed": "Grup eklenemedi",
|
||||
"importFailed": "Gruplar içe aktarılamadı",
|
||||
"partialSuccess": "{{total}} gruptan {{count}} tanesi başarıyla içe aktarıldı. Bazı gruplar başarısız oldu:"
|
||||
},
|
||||
"users": {
|
||||
"add": "Kullanıcı Ekle",
|
||||
"addNew": "Yeni Kullanıcı Ekle",
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"emptyFields": "用户名和密码不能为空",
|
||||
"loginFailed": "登录失败,请检查用户名和密码",
|
||||
"loginError": "登录过程中出现错误",
|
||||
"serverUnavailable": "无法连接到服务器,请检查网络连接或稍后再试",
|
||||
"currentPassword": "当前密码",
|
||||
"newPassword": "新密码",
|
||||
"confirmPassword": "确认密码",
|
||||
@@ -255,7 +256,11 @@
|
||||
"type": "类型",
|
||||
"repeated": "可重复",
|
||||
"valueHint": "值提示",
|
||||
"choices": "可选值"
|
||||
"choices": "可选值",
|
||||
"actions": "操作",
|
||||
"saving": "保存中...",
|
||||
"active": "已激活",
|
||||
"inactive": "未激活"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "仪表盘",
|
||||
@@ -289,7 +294,7 @@
|
||||
"routeConfig": "安全配置",
|
||||
"installConfig": "安装",
|
||||
"smartRouting": "智能路由",
|
||||
"oauthServer": "OAuth 服务器"
|
||||
"oauthServer": "OAuth"
|
||||
},
|
||||
"groups": {
|
||||
"title": "分组管理"
|
||||
@@ -555,6 +560,27 @@
|
||||
"bearerAuthKey": "Bearer 认证密钥",
|
||||
"bearerAuthKeyDescription": "Bearer 令牌中需要携带的认证密钥",
|
||||
"bearerAuthKeyPlaceholder": "请输入 Bearer 认证密钥",
|
||||
"bearerKeysSectionTitle": "密钥",
|
||||
"bearerKeysSectionDescription": "管理多条密钥,并为不同密钥配置不同的访问范围。",
|
||||
"noBearerKeys": "当前还没有配置任何密钥。",
|
||||
"bearerKeyName": "名称",
|
||||
"bearerKeyToken": "密钥值",
|
||||
"bearerKeyEnabled": "启用",
|
||||
"bearerKeyAccessType": "访问范围",
|
||||
"bearerKeyAccessAll": "全部",
|
||||
"bearerKeyAccessGroups": "指定分组",
|
||||
"bearerKeyAccessServers": "指定服务器",
|
||||
"bearerKeyAllowedGroups": "允许访问的分组",
|
||||
"bearerKeyAllowedServers": "允许访问的服务器",
|
||||
"addBearerKey": "新增密钥",
|
||||
"addBearerKeyButton": "创建",
|
||||
"bearerKeyRequired": "名称和密钥值为必填项",
|
||||
"deleteBearerKeyConfirm": "确定要删除这条密钥吗?",
|
||||
"generate": "生成",
|
||||
"selectGroups": "选择分组",
|
||||
"selectServers": "选择服务器",
|
||||
"selectAtLeastOneGroup": "请至少选择一个分组",
|
||||
"selectAtLeastOneServer": "请至少选择一个服务",
|
||||
"skipAuth": "免登录开关",
|
||||
"skipAuthDescription": "跳过前端和 API 访问的登录要求(默认关闭确保安全性)",
|
||||
"pythonIndexUrl": "Python 包仓库地址",
|
||||
@@ -675,6 +701,22 @@
|
||||
"importFailed": "导入服务器失败",
|
||||
"partialSuccess": "成功导入 {{count}} / {{total}} 个服务器。部分服务器失败:"
|
||||
},
|
||||
"groupImport": {
|
||||
"button": "导入",
|
||||
"title": "从 JSON 导入分组",
|
||||
"inputLabel": "分组配置 JSON",
|
||||
"inputHelp": "粘贴您的分组配置 JSON。每个分组可以包含一个服务器列表。",
|
||||
"preview": "预览",
|
||||
"previewTitle": "预览要导入的分组",
|
||||
"import": "导入",
|
||||
"importing": "导入中...",
|
||||
"invalidFormat": "无效的 JSON 格式。JSON 必须包含 'groups' 数组。",
|
||||
"missingName": "每个分组必须有 'name' 字段。",
|
||||
"parseError": "解析 JSON 失败。请检查格式后重试。",
|
||||
"addFailed": "添加分组失败",
|
||||
"importFailed": "导入分组失败",
|
||||
"partialSuccess": "成功导入 {{count}} / {{total}} 个分组。部分分组失败:"
|
||||
},
|
||||
"users": {
|
||||
"add": "添加",
|
||||
"addNew": "添加新用户",
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"postgres": "^3.4.7",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"typeorm": "^0.3.26",
|
||||
"undici": "^7.16.0",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -110,8 +111,8 @@
|
||||
"next": "^15.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.6.2",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
"react-i18next": "^15.7.2",
|
||||
"react-router-dom": "^7.8.2",
|
||||
"supertest": "^7.1.4",
|
||||
|
||||
217
pnpm-lock.yaml
generated
217
pnpm-lock.yaml
generated
@@ -99,16 +99,19 @@ importers:
|
||||
typeorm:
|
||||
specifier: ^0.3.26
|
||||
version: 0.3.27(pg@8.16.3)(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@24.6.2)(typescript@5.9.2))
|
||||
undici:
|
||||
specifier: ^7.16.0
|
||||
version: 7.16.0
|
||||
uuid:
|
||||
specifier: ^11.1.0
|
||||
version: 11.1.0
|
||||
devDependencies:
|
||||
'@radix-ui/react-accordion':
|
||||
specifier: ^1.2.12
|
||||
version: 1.2.12(@types/react-dom@19.1.7(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
version: 1.2.12(@types/react-dom@19.1.7(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||
'@radix-ui/react-slot':
|
||||
specifier: ^1.2.3
|
||||
version: 1.2.3(@types/react@19.2.7)(react@19.2.0)
|
||||
version: 1.2.3(@types/react@19.2.7)(react@19.2.1)
|
||||
'@shadcn/ui':
|
||||
specifier: ^0.0.4
|
||||
version: 0.0.4
|
||||
@@ -195,10 +198,10 @@ importers:
|
||||
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.15.3)(@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.2.0)
|
||||
version: 0.552.0(react@19.2.1)
|
||||
next:
|
||||
specifier: ^15.5.0
|
||||
version: 15.5.7(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
version: 15.5.9(@babel/core@7.28.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||
postcss:
|
||||
specifier: ^8.5.6
|
||||
version: 8.5.6
|
||||
@@ -206,17 +209,17 @@ importers:
|
||||
specifier: ^3.6.2
|
||||
version: 3.6.2
|
||||
react:
|
||||
specifier: 19.2.0
|
||||
version: 19.2.0
|
||||
specifier: 19.2.1
|
||||
version: 19.2.1
|
||||
react-dom:
|
||||
specifier: 19.2.0
|
||||
version: 19.2.0(react@19.2.0)
|
||||
specifier: 19.2.1
|
||||
version: 19.2.1(react@19.2.1)
|
||||
react-i18next:
|
||||
specifier: ^15.7.2
|
||||
version: 15.7.2(i18next@25.6.0(typescript@5.9.2))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.2)
|
||||
version: 15.7.2(i18next@25.6.0(typescript@5.9.2))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.9.2)
|
||||
react-router-dom:
|
||||
specifier: ^7.8.2
|
||||
version: 7.8.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
version: 7.8.2(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||
supertest:
|
||||
specifier: ^7.1.4
|
||||
version: 7.1.4
|
||||
@@ -1122,8 +1125,8 @@ packages:
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||
|
||||
'@next/env@15.5.7':
|
||||
resolution: {integrity: sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==}
|
||||
'@next/env@15.5.9':
|
||||
resolution: {integrity: sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==}
|
||||
|
||||
'@next/swc-darwin-arm64@15.5.7':
|
||||
resolution: {integrity: sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==}
|
||||
@@ -2236,8 +2239,8 @@ packages:
|
||||
caniuse-lite@1.0.30001737:
|
||||
resolution: {integrity: sha512-BiloLiXtQNrY5UyF0+1nSJLXUENuhka2pzy2Fx5pGxqavdrxSCW4U6Pn/PoG3Efspi2frRbHpBV2XsrPE6EDlw==}
|
||||
|
||||
caniuse-lite@1.0.30001759:
|
||||
resolution: {integrity: sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==}
|
||||
caniuse-lite@1.0.30001760:
|
||||
resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==}
|
||||
|
||||
chalk@4.1.2:
|
||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||
@@ -3514,8 +3517,8 @@ packages:
|
||||
neo-async@2.6.2:
|
||||
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
|
||||
|
||||
next@15.5.7:
|
||||
resolution: {integrity: sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==}
|
||||
next@15.5.9:
|
||||
resolution: {integrity: sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==}
|
||||
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -3844,10 +3847,10 @@ packages:
|
||||
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
react-dom@19.2.0:
|
||||
resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==}
|
||||
react-dom@19.2.1:
|
||||
resolution: {integrity: sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==}
|
||||
peerDependencies:
|
||||
react: ^19.2.0
|
||||
react: ^19.2.1
|
||||
|
||||
react-i18next@15.7.2:
|
||||
resolution: {integrity: sha512-xJxq7ibnhUlMvd82lNC4te1GxGUMoM1A05KKyqoqsBXVZtEvZg/fz/fnVzdlY/hhQ3SpP/79qCocZOtICGhd3g==}
|
||||
@@ -3889,8 +3892,8 @@ packages:
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
react@19.2.0:
|
||||
resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==}
|
||||
react@19.2.1:
|
||||
resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
readable-stream@3.6.2:
|
||||
@@ -4431,6 +4434,10 @@ packages:
|
||||
undici-types@7.13.0:
|
||||
resolution: {integrity: sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==}
|
||||
|
||||
undici@7.16.0:
|
||||
resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==}
|
||||
engines: {node: '>=20.18.1'}
|
||||
|
||||
universalify@2.0.1:
|
||||
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
@@ -5476,7 +5483,7 @@ snapshots:
|
||||
'@tybys/wasm-util': 0.10.1
|
||||
optional: true
|
||||
|
||||
'@next/env@15.5.7': {}
|
||||
'@next/env@15.5.9': {}
|
||||
|
||||
'@next/swc-darwin-arm64@15.5.7':
|
||||
optional: true
|
||||
@@ -5535,120 +5542,120 @@ snapshots:
|
||||
|
||||
'@radix-ui/primitive@1.1.3': {}
|
||||
|
||||
'@radix-ui/react-accordion@1.2.12(@types/react-dom@19.1.7(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||
'@radix-ui/react-accordion@1.2.12(@types/react-dom@19.1.7(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.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.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.7(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.0)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.0)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.2.0)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.0)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.7(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.0)
|
||||
react: 19.2.0
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
'@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.1.7(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.7(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.1)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.1)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.7)(react@19.2.1)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.1)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.7(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.1)
|
||||
react: 19.2.1
|
||||
react-dom: 19.2.1(react@19.2.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.7
|
||||
'@types/react-dom': 19.1.7(@types/react@19.2.7)
|
||||
|
||||
'@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.1.7(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||
'@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.1.7(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.0)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.0)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.0)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.7(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.7(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.0)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.0)
|
||||
react: 19.2.0
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.1)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.1)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.7)(react@19.2.1)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.7(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.7(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.7)(react@19.2.1)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.1)
|
||||
react: 19.2.1
|
||||
react-dom: 19.2.1(react@19.2.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.7
|
||||
'@types/react-dom': 19.1.7(@types/react@19.2.7)
|
||||
|
||||
'@radix-ui/react-collection@1.1.7(@types/react-dom@19.1.7(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||
'@radix-ui/react-collection@1.1.7(@types/react-dom@19.1.7(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.0)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.0)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.7(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.2.0)
|
||||
react: 19.2.0
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.1)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.7)(react@19.2.1)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.7(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.2.1)
|
||||
react: 19.2.1
|
||||
react-dom: 19.2.1(react@19.2.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.7
|
||||
'@types/react-dom': 19.1.7(@types/react@19.2.7)
|
||||
|
||||
'@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.7)(react@19.2.0)':
|
||||
'@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.7)(react@19.2.1)':
|
||||
dependencies:
|
||||
react: 19.2.0
|
||||
react: 19.2.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.7
|
||||
|
||||
'@radix-ui/react-context@1.1.2(@types/react@19.2.7)(react@19.2.0)':
|
||||
'@radix-ui/react-context@1.1.2(@types/react@19.2.7)(react@19.2.1)':
|
||||
dependencies:
|
||||
react: 19.2.0
|
||||
react: 19.2.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.7
|
||||
|
||||
'@radix-ui/react-direction@1.1.1(@types/react@19.2.7)(react@19.2.0)':
|
||||
'@radix-ui/react-direction@1.1.1(@types/react@19.2.7)(react@19.2.1)':
|
||||
dependencies:
|
||||
react: 19.2.0
|
||||
react: 19.2.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.7
|
||||
|
||||
'@radix-ui/react-id@1.1.1(@types/react@19.2.7)(react@19.2.0)':
|
||||
'@radix-ui/react-id@1.1.1(@types/react@19.2.7)(react@19.2.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.0)
|
||||
react: 19.2.0
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.1)
|
||||
react: 19.2.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.7
|
||||
|
||||
'@radix-ui/react-presence@1.1.5(@types/react-dom@19.1.7(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||
'@radix-ui/react-presence@1.1.5(@types/react-dom@19.1.7(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.0)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.0)
|
||||
react: 19.2.0
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.1)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.1)
|
||||
react: 19.2.1
|
||||
react-dom: 19.2.1(react@19.2.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.7
|
||||
'@types/react-dom': 19.1.7(@types/react@19.2.7)
|
||||
|
||||
'@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.7(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
|
||||
'@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.7(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.2.0)
|
||||
react: 19.2.0
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.7)(react@19.2.1)
|
||||
react: 19.2.1
|
||||
react-dom: 19.2.1(react@19.2.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.7
|
||||
'@types/react-dom': 19.1.7(@types/react@19.2.7)
|
||||
|
||||
'@radix-ui/react-slot@1.2.3(@types/react@19.2.7)(react@19.2.0)':
|
||||
'@radix-ui/react-slot@1.2.3(@types/react@19.2.7)(react@19.2.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.0)
|
||||
react: 19.2.0
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.7)(react@19.2.1)
|
||||
react: 19.2.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.7
|
||||
|
||||
'@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.7)(react@19.2.0)':
|
||||
'@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.7)(react@19.2.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.7)(react@19.2.0)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.0)
|
||||
react: 19.2.0
|
||||
'@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.7)(react@19.2.1)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.1)
|
||||
react: 19.2.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.7
|
||||
|
||||
'@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.7)(react@19.2.0)':
|
||||
'@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.7)(react@19.2.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.0)
|
||||
react: 19.2.0
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.7)(react@19.2.1)
|
||||
react: 19.2.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.7
|
||||
|
||||
'@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.7)(react@19.2.0)':
|
||||
'@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.7)(react@19.2.1)':
|
||||
dependencies:
|
||||
react: 19.2.0
|
||||
react: 19.2.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.7
|
||||
|
||||
@@ -6527,7 +6534,7 @@ snapshots:
|
||||
|
||||
caniuse-lite@1.0.30001737: {}
|
||||
|
||||
caniuse-lite@1.0.30001759: {}
|
||||
caniuse-lite@1.0.30001760: {}
|
||||
|
||||
chalk@4.1.2:
|
||||
dependencies:
|
||||
@@ -7932,9 +7939,9 @@ snapshots:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
|
||||
lucide-react@0.552.0(react@19.2.0):
|
||||
lucide-react@0.552.0(react@19.2.1):
|
||||
dependencies:
|
||||
react: 19.2.0
|
||||
react: 19.2.1
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
@@ -8043,15 +8050,15 @@ snapshots:
|
||||
|
||||
neo-async@2.6.2: {}
|
||||
|
||||
next@15.5.7(@babel/core@7.28.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
||||
next@15.5.9(@babel/core@7.28.4)(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
|
||||
dependencies:
|
||||
'@next/env': 15.5.7
|
||||
'@next/env': 15.5.9
|
||||
'@swc/helpers': 0.5.15
|
||||
caniuse-lite: 1.0.30001759
|
||||
caniuse-lite: 1.0.30001760
|
||||
postcss: 8.4.31
|
||||
react: 19.2.0
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
styled-jsx: 5.1.6(@babel/core@7.28.4)(react@19.2.0)
|
||||
react: 19.2.1
|
||||
react-dom: 19.2.1(react@19.2.1)
|
||||
styled-jsx: 5.1.6(@babel/core@7.28.4)(react@19.2.1)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 15.5.7
|
||||
'@next/swc-darwin-x64': 15.5.7
|
||||
@@ -8335,40 +8342,40 @@ snapshots:
|
||||
iconv-lite: 0.7.0
|
||||
unpipe: 1.0.0
|
||||
|
||||
react-dom@19.2.0(react@19.2.0):
|
||||
react-dom@19.2.1(react@19.2.1):
|
||||
dependencies:
|
||||
react: 19.2.0
|
||||
react: 19.2.1
|
||||
scheduler: 0.27.0
|
||||
|
||||
react-i18next@15.7.2(i18next@25.6.0(typescript@5.9.2))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.2):
|
||||
react-i18next@15.7.2(i18next@25.6.0(typescript@5.9.2))(react-dom@19.2.1(react@19.2.1))(react@19.2.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)
|
||||
react: 19.2.0
|
||||
react: 19.2.1
|
||||
optionalDependencies:
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
react-dom: 19.2.1(react@19.2.1)
|
||||
typescript: 5.9.2
|
||||
|
||||
react-is@18.3.1: {}
|
||||
|
||||
react-refresh@0.17.0: {}
|
||||
|
||||
react-router-dom@7.8.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
||||
react-router-dom@7.8.2(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
|
||||
dependencies:
|
||||
react: 19.2.0
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
react-router: 7.8.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||
react: 19.2.1
|
||||
react-dom: 19.2.1(react@19.2.1)
|
||||
react-router: 7.8.2(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||
|
||||
react-router@7.8.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
||||
react-router@7.8.2(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
|
||||
dependencies:
|
||||
cookie: 1.1.1
|
||||
react: 19.2.0
|
||||
react: 19.2.1
|
||||
set-cookie-parser: 2.7.1
|
||||
optionalDependencies:
|
||||
react-dom: 19.2.0(react@19.2.0)
|
||||
react-dom: 19.2.1(react@19.2.1)
|
||||
|
||||
react@19.2.0: {}
|
||||
react@19.2.1: {}
|
||||
|
||||
readable-stream@3.6.2:
|
||||
dependencies:
|
||||
@@ -8719,10 +8726,10 @@ snapshots:
|
||||
|
||||
strip-json-comments@3.1.1: {}
|
||||
|
||||
styled-jsx@5.1.6(@babel/core@7.28.4)(react@19.2.0):
|
||||
styled-jsx@5.1.6(@babel/core@7.28.4)(react@19.2.1):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
react: 19.2.0
|
||||
react: 19.2.1
|
||||
optionalDependencies:
|
||||
'@babel/core': 7.28.4
|
||||
|
||||
@@ -8946,6 +8953,8 @@ snapshots:
|
||||
|
||||
undici-types@7.13.0: {}
|
||||
|
||||
undici@7.16.0: {}
|
||||
|
||||
universalify@2.0.1: {}
|
||||
|
||||
unpipe@1.0.0: {}
|
||||
|
||||
169
src/controllers/bearerKeyController.ts
Normal file
169
src/controllers/bearerKeyController.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ApiResponse, BearerKey } from '../types/index.js';
|
||||
import { getBearerKeyDao, getSystemConfigDao } from '../dao/index.js';
|
||||
|
||||
const requireAdmin = async (req: Request, res: Response): Promise<boolean> => {
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
if (systemConfig?.routing?.skipAuth) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const user = (req as any).user;
|
||||
if (!user || !user.isAdmin) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: 'Admin privileges required',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export const getBearerKeys = async (req: Request, res: Response): Promise<void> => {
|
||||
if (!(await requireAdmin(req, res))) return;
|
||||
|
||||
try {
|
||||
const dao = getBearerKeyDao();
|
||||
const keys = await dao.findAll();
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: keys,
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
console.error('Failed to get bearer keys:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get bearer keys',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const createBearerKey = async (req: Request, res: Response): Promise<void> => {
|
||||
if (!(await requireAdmin(req, res))) return;
|
||||
|
||||
try {
|
||||
const { name, token, enabled, accessType, allowedGroups, allowedServers } =
|
||||
req.body as Partial<BearerKey>;
|
||||
|
||||
if (!name || typeof name !== 'string') {
|
||||
res.status(400).json({ success: false, message: 'Key name is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token || typeof token !== 'string') {
|
||||
res.status(400).json({ success: false, message: 'Token value is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessType || !['all', 'groups', 'servers'].includes(accessType)) {
|
||||
res.status(400).json({ success: false, message: 'Invalid accessType' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dao = getBearerKeyDao();
|
||||
const key = await dao.create({
|
||||
name,
|
||||
token,
|
||||
enabled: enabled ?? true,
|
||||
accessType,
|
||||
allowedGroups: Array.isArray(allowedGroups) ? allowedGroups : [],
|
||||
allowedServers: Array.isArray(allowedServers) ? allowedServers : [],
|
||||
});
|
||||
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: key,
|
||||
};
|
||||
res.status(201).json(response);
|
||||
} catch (error) {
|
||||
console.error('Failed to create bearer key:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to create bearer key',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const updateBearerKey = async (req: Request, res: Response): Promise<void> => {
|
||||
if (!(await requireAdmin(req, res))) return;
|
||||
|
||||
try {
|
||||
const { id } = req.params;
|
||||
if (!id) {
|
||||
res.status(400).json({ success: false, message: 'Key id is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { name, token, enabled, accessType, allowedGroups, allowedServers } =
|
||||
req.body as Partial<BearerKey>;
|
||||
|
||||
const updates: Partial<BearerKey> = {};
|
||||
if (name !== undefined) updates.name = name;
|
||||
if (token !== undefined) updates.token = token;
|
||||
if (enabled !== undefined) updates.enabled = enabled;
|
||||
if (accessType !== undefined) {
|
||||
if (!['all', 'groups', 'servers'].includes(accessType)) {
|
||||
res.status(400).json({ success: false, message: 'Invalid accessType' });
|
||||
return;
|
||||
}
|
||||
updates.accessType = accessType as BearerKey['accessType'];
|
||||
}
|
||||
if (allowedGroups !== undefined) {
|
||||
updates.allowedGroups = Array.isArray(allowedGroups) ? allowedGroups : [];
|
||||
}
|
||||
if (allowedServers !== undefined) {
|
||||
updates.allowedServers = Array.isArray(allowedServers) ? allowedServers : [];
|
||||
}
|
||||
|
||||
const dao = getBearerKeyDao();
|
||||
const updated = await dao.update(id, updates);
|
||||
if (!updated) {
|
||||
res.status(404).json({ success: false, message: 'Bearer key not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: updated,
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
console.error('Failed to update bearer key:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to update bearer key',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteBearerKey = async (req: Request, res: Response): Promise<void> => {
|
||||
if (!(await requireAdmin(req, res))) return;
|
||||
|
||||
try {
|
||||
const { id } = req.params;
|
||||
if (!id) {
|
||||
res.status(400).json({ success: false, message: 'Key id is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const dao = getBearerKeyDao();
|
||||
const deleted = await dao.delete(id);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ success: false, message: 'Bearer key not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
};
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete bearer key:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to delete bearer key',
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,10 +1,19 @@
|
||||
import { Request, Response } from 'express';
|
||||
import config from '../config/index.js';
|
||||
import { loadSettings, loadOriginalSettings } from '../config/index.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import { getDataService } from '../services/services.js';
|
||||
import { DataService } from '../services/dataService.js';
|
||||
import { IUser } from '../types/index.js';
|
||||
import { getServerDao } from '../dao/DaoFactory.js';
|
||||
import {
|
||||
getGroupDao,
|
||||
getOAuthClientDao,
|
||||
getOAuthTokenDao,
|
||||
getServerDao,
|
||||
getSystemConfigDao,
|
||||
getUserConfigDao,
|
||||
getUserDao,
|
||||
getBearerKeyDao,
|
||||
} from '../dao/DaoFactory.js';
|
||||
|
||||
const dataService: DataService = getDataService();
|
||||
|
||||
@@ -128,8 +137,43 @@ export const getMcpSettingsJson = async (req: Request, res: Response): Promise<v
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Return full settings
|
||||
const settings = loadOriginalSettings();
|
||||
// Return full settings via DAO layer (supports both file and database modes)
|
||||
const [
|
||||
servers,
|
||||
users,
|
||||
groups,
|
||||
systemConfig,
|
||||
userConfigs,
|
||||
oauthClients,
|
||||
oauthTokens,
|
||||
bearerKeys,
|
||||
] = await Promise.all([
|
||||
getServerDao().findAll(),
|
||||
getUserDao().findAll(),
|
||||
getGroupDao().findAll(),
|
||||
getSystemConfigDao().get(),
|
||||
getUserConfigDao().getAll(),
|
||||
getOAuthClientDao().findAll(),
|
||||
getOAuthTokenDao().findAll(),
|
||||
getBearerKeyDao().findAll(),
|
||||
]);
|
||||
|
||||
const mcpServers: Record<string, any> = {};
|
||||
for (const { name: serverConfigName, ...config } of servers) {
|
||||
mcpServers[serverConfigName] = removeNullValues(config);
|
||||
}
|
||||
|
||||
const settings = {
|
||||
mcpServers,
|
||||
users,
|
||||
groups,
|
||||
systemConfig,
|
||||
userConfigs,
|
||||
oauthClients,
|
||||
oauthTokens,
|
||||
bearerKeys,
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: settings,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
import {
|
||||
ApiResponse,
|
||||
AddGroupRequest,
|
||||
BatchCreateGroupsRequest,
|
||||
BatchCreateGroupsResponse,
|
||||
BatchGroupResult,
|
||||
} from '../types/index.js';
|
||||
import {
|
||||
getAllGroups,
|
||||
getGroupByIdOrName,
|
||||
@@ -106,6 +112,143 @@ export const createNewGroup = async (req: Request, res: Response): Promise<void>
|
||||
}
|
||||
};
|
||||
|
||||
// Batch create groups - validates and creates multiple groups in one request
|
||||
export const batchCreateGroups = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { groups } = req.body as BatchCreateGroupsRequest;
|
||||
|
||||
// Validate request body
|
||||
if (!groups || !Array.isArray(groups)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Request body must contain a "groups" array',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (groups.length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Groups array cannot be empty',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Helper function to validate a single group configuration
|
||||
const validateGroupConfig = (group: AddGroupRequest): { valid: boolean; message?: string } => {
|
||||
if (!group.name || typeof group.name !== 'string') {
|
||||
return { valid: false, message: 'Group name is required and must be a string' };
|
||||
}
|
||||
|
||||
if (group.description !== undefined && typeof group.description !== 'string') {
|
||||
return { valid: false, message: 'Group description must be a string' };
|
||||
}
|
||||
|
||||
if (group.servers !== undefined && !Array.isArray(group.servers)) {
|
||||
return { valid: false, message: 'Group servers must be an array' };
|
||||
}
|
||||
|
||||
// Validate server configurations if provided in new format
|
||||
if (group.servers) {
|
||||
for (const server of group.servers) {
|
||||
if (typeof server === 'object' && server !== null) {
|
||||
if (!server.name || typeof server.name !== 'string') {
|
||||
return {
|
||||
valid: false,
|
||||
message: 'Server configuration must have a name property',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
server.tools !== undefined &&
|
||||
server.tools !== 'all' &&
|
||||
!Array.isArray(server.tools)
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
message: 'Server tools must be "all" or an array of tool names',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
// Process each group
|
||||
const results: BatchGroupResult[] = [];
|
||||
let successCount = 0;
|
||||
let failureCount = 0;
|
||||
|
||||
// Get current user for owner field
|
||||
const currentUser = (req as any).user;
|
||||
const defaultOwner = currentUser?.username || 'admin';
|
||||
|
||||
for (const groupData of groups) {
|
||||
const { name, description, servers } = groupData;
|
||||
|
||||
// Validate group configuration
|
||||
const validation = validateGroupConfig(groupData);
|
||||
if (!validation.valid) {
|
||||
results.push({
|
||||
name: name || 'unknown',
|
||||
success: false,
|
||||
message: validation.message,
|
||||
});
|
||||
failureCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const serverList = Array.isArray(servers) ? servers : [];
|
||||
const newGroup = await createGroup(name, description, serverList, defaultOwner);
|
||||
|
||||
if (newGroup) {
|
||||
results.push({
|
||||
name,
|
||||
success: true,
|
||||
message: 'Group created successfully',
|
||||
});
|
||||
successCount++;
|
||||
} else {
|
||||
results.push({
|
||||
name,
|
||||
success: false,
|
||||
message: 'Failed to create group or group name already exists',
|
||||
});
|
||||
failureCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
results.push({
|
||||
name,
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to create group',
|
||||
});
|
||||
failureCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Return response
|
||||
const response: BatchCreateGroupsResponse = {
|
||||
success: successCount > 0,
|
||||
successCount,
|
||||
failureCount,
|
||||
results,
|
||||
};
|
||||
|
||||
// Use 207 Multi-Status if there were partial failures, 200 if all succeeded
|
||||
const statusCode = failureCount > 0 && successCount > 0 ? 207 : successCount > 0 ? 200 : 400;
|
||||
res.status(statusCode).json(response);
|
||||
} catch (error) {
|
||||
console.error('Batch create groups error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Internal server error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Update an existing group
|
||||
export const updateExistingGroup = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ApiResponse, AddServerRequest, McpSettings } from '../types/index.js';
|
||||
import {
|
||||
ApiResponse,
|
||||
AddServerRequest,
|
||||
McpSettings,
|
||||
BatchCreateServersRequest,
|
||||
BatchCreateServersResponse,
|
||||
BatchServerResult,
|
||||
ServerConfig,
|
||||
} from '../types/index.js';
|
||||
import {
|
||||
getServersInfo,
|
||||
addServer,
|
||||
@@ -15,6 +23,7 @@ import { syncAllServerToolsEmbeddings } from '../services/vectorSearchService.js
|
||||
import { createSafeJSON } from '../utils/serialization.js';
|
||||
import { cloneDefaultOAuthServerConfig } from '../constants/oauthServerDefaults.js';
|
||||
import { getServerDao, getGroupDao, getSystemConfigDao } from '../dao/DaoFactory.js';
|
||||
import { getBearerKeyDao } from '../dao/DaoFactory.js';
|
||||
|
||||
export const getAllServers = async (_: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
@@ -57,12 +66,31 @@ export const getAllSettings = async (_: Request, res: Response): Promise<void> =
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
|
||||
// Ensure smart routing config has DB URL set if environment variable is present
|
||||
const dbUrlEnv = process.env.DB_URL || '';
|
||||
if (!systemConfig.smartRouting) {
|
||||
systemConfig.smartRouting = {
|
||||
enabled: false,
|
||||
dbUrl: dbUrlEnv ? '${DB_URL}' : '',
|
||||
openaiApiBaseUrl: '',
|
||||
openaiApiKey: '',
|
||||
openaiApiEmbeddingModel: '',
|
||||
};
|
||||
} else if (!systemConfig.smartRouting.dbUrl) {
|
||||
systemConfig.smartRouting.dbUrl = dbUrlEnv ? '${DB_URL}' : '';
|
||||
}
|
||||
|
||||
// Get bearer auth keys from DAO
|
||||
const bearerKeyDao = getBearerKeyDao();
|
||||
const bearerKeys = await bearerKeyDao.findAll();
|
||||
|
||||
// Merge all data into settings object
|
||||
const settings: McpSettings = {
|
||||
...fileSettings,
|
||||
mcpServers,
|
||||
groups,
|
||||
systemConfig,
|
||||
bearerKeys,
|
||||
};
|
||||
|
||||
const response: ApiResponse = {
|
||||
@@ -189,6 +217,177 @@ export const createServer = async (req: Request, res: Response): Promise<void> =
|
||||
}
|
||||
};
|
||||
|
||||
// Batch create servers - validates and creates multiple servers in one request
|
||||
export const batchCreateServers = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { servers } = req.body as BatchCreateServersRequest;
|
||||
|
||||
// Validate request body
|
||||
if (!servers || !Array.isArray(servers)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Request body must contain a "servers" array',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (servers.length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Servers array cannot be empty',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Helper function to validate a single server configuration
|
||||
const validateServerConfig = (
|
||||
name: string,
|
||||
config: ServerConfig,
|
||||
): { valid: boolean; message?: string } => {
|
||||
if (!name || typeof name !== 'string') {
|
||||
return { valid: false, message: 'Server name is required and must be a string' };
|
||||
}
|
||||
|
||||
if (!config || typeof config !== 'object') {
|
||||
return { valid: false, message: 'Server configuration is required and must be an object' };
|
||||
}
|
||||
|
||||
if (
|
||||
!config.url &&
|
||||
!config.openapi?.url &&
|
||||
!config.openapi?.schema &&
|
||||
(!config.command || !config.args)
|
||||
) {
|
||||
return {
|
||||
valid: false,
|
||||
message:
|
||||
'Server configuration must include either a URL, OpenAPI specification URL or schema, or command with arguments',
|
||||
};
|
||||
}
|
||||
|
||||
// Validate server type if specified
|
||||
if (config.type && !['stdio', 'sse', 'streamable-http', 'openapi'].includes(config.type)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: 'Server type must be one of: stdio, sse, streamable-http, openapi',
|
||||
};
|
||||
}
|
||||
|
||||
// Validate URL is provided for sse and streamable-http types
|
||||
if ((config.type === 'sse' || config.type === 'streamable-http') && !config.url) {
|
||||
return { valid: false, message: `URL is required for ${config.type} server type` };
|
||||
}
|
||||
|
||||
// Validate OpenAPI specification URL or schema is provided for openapi type
|
||||
if (config.type === 'openapi' && !config.openapi?.url && !config.openapi?.schema) {
|
||||
return {
|
||||
valid: false,
|
||||
message: 'OpenAPI specification URL or schema is required for openapi server type',
|
||||
};
|
||||
}
|
||||
|
||||
// Validate headers if provided
|
||||
if (config.headers && typeof config.headers !== 'object') {
|
||||
return { valid: false, message: 'Headers must be an object' };
|
||||
}
|
||||
|
||||
// Validate that headers are only used with sse, streamable-http, and openapi types
|
||||
if (config.headers && config.type === 'stdio') {
|
||||
return { valid: false, message: 'Headers are not supported for stdio server type' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
// Process each server
|
||||
const results: BatchServerResult[] = [];
|
||||
let successCount = 0;
|
||||
let failureCount = 0;
|
||||
|
||||
// Get current user for owner field
|
||||
const currentUser = (req as any).user;
|
||||
const defaultOwner = currentUser?.username || 'admin';
|
||||
|
||||
for (const server of servers) {
|
||||
const { name, config } = server;
|
||||
|
||||
// Validate server configuration
|
||||
const validation = validateServerConfig(name, config);
|
||||
if (!validation.valid) {
|
||||
results.push({
|
||||
name: name || 'unknown',
|
||||
success: false,
|
||||
message: validation.message,
|
||||
});
|
||||
failureCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Set default keep-alive interval for SSE servers if not specified
|
||||
if ((config.type === 'sse' || (!config.type && config.url)) && !config.keepAliveInterval) {
|
||||
config.keepAliveInterval = 60000; // Default 60 seconds for SSE servers
|
||||
}
|
||||
|
||||
// Set owner property if not provided
|
||||
if (!config.owner) {
|
||||
config.owner = defaultOwner;
|
||||
}
|
||||
|
||||
// Attempt to add server
|
||||
const result = await addServer(name, config);
|
||||
if (result.success) {
|
||||
results.push({
|
||||
name,
|
||||
success: true,
|
||||
});
|
||||
successCount++;
|
||||
} else {
|
||||
results.push({
|
||||
name,
|
||||
success: false,
|
||||
message: result.message || 'Failed to add server',
|
||||
});
|
||||
failureCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
results.push({
|
||||
name,
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Internal server error',
|
||||
});
|
||||
failureCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Notify tool changes if any server was added successfully
|
||||
if (successCount > 0) {
|
||||
notifyToolChanged();
|
||||
}
|
||||
|
||||
// Prepare response
|
||||
const response: ApiResponse<BatchCreateServersResponse> = {
|
||||
success: successCount > 0, // Success if at least one server was created
|
||||
data: {
|
||||
success: successCount > 0,
|
||||
successCount,
|
||||
failureCount,
|
||||
results,
|
||||
},
|
||||
};
|
||||
|
||||
// Return 207 Multi-Status if there were partial failures, 200 if all succeeded, 400 if all failed
|
||||
const statusCode = failureCount === 0 ? 200 : successCount === 0 ? 400 : 207;
|
||||
res.status(statusCode).json(response);
|
||||
} catch (error) {
|
||||
console.error('Batch create servers error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Internal server error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteServer = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
@@ -793,7 +992,8 @@ export const updateSystemConfig = async (req: Request, res: Response): Promise<v
|
||||
if (typeof smartRouting.enabled === 'boolean') {
|
||||
// If enabling Smart Routing, validate required fields
|
||||
if (smartRouting.enabled) {
|
||||
const currentDbUrl = smartRouting.dbUrl || systemConfig.smartRouting.dbUrl;
|
||||
const currentDbUrl =
|
||||
process.env.DB_URL || smartRouting.dbUrl || systemConfig.smartRouting.dbUrl;
|
||||
const currentOpenaiApiKey =
|
||||
smartRouting.openaiApiKey || systemConfig.smartRouting.openaiApiKey;
|
||||
|
||||
|
||||
125
src/dao/BearerKeyDao.ts
Normal file
125
src/dao/BearerKeyDao.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { BearerKey } from '../types/index.js';
|
||||
import { JsonFileBaseDao } from './base/JsonFileBaseDao.js';
|
||||
|
||||
/**
|
||||
* DAO interface for bearer authentication keys
|
||||
*/
|
||||
export interface BearerKeyDao {
|
||||
findAll(): Promise<BearerKey[]>;
|
||||
findEnabled(): Promise<BearerKey[]>;
|
||||
findById(id: string): Promise<BearerKey | undefined>;
|
||||
findByToken(token: string): Promise<BearerKey | undefined>;
|
||||
create(data: Omit<BearerKey, 'id'>): Promise<BearerKey>;
|
||||
update(id: string, data: Partial<Omit<BearerKey, 'id'>>): Promise<BearerKey | null>;
|
||||
delete(id: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON file-based BearerKey DAO implementation
|
||||
* Stores keys under the top-level `bearerKeys` field in mcp_settings.json
|
||||
* and performs one-time migration from legacy routing.enableBearerAuth/bearerAuthKey.
|
||||
*/
|
||||
export class BearerKeyDaoImpl extends JsonFileBaseDao implements BearerKeyDao {
|
||||
private async loadKeysWithMigration(): Promise<BearerKey[]> {
|
||||
const settings = await this.loadSettings();
|
||||
|
||||
// Treat an existing array (including an empty array) as already migrated.
|
||||
// Otherwise, when there are no configured keys, we'd rewrite mcp_settings.json
|
||||
// on every request, which also clears the global settings cache.
|
||||
if (Array.isArray(settings.bearerKeys)) {
|
||||
return settings.bearerKeys;
|
||||
}
|
||||
|
||||
// Perform one-time migration from legacy routing config if present
|
||||
const routing = settings.systemConfig?.routing || {};
|
||||
const enableBearerAuth: boolean = !!routing.enableBearerAuth;
|
||||
const rawKey: string = (routing.bearerAuthKey || '').trim();
|
||||
|
||||
let migrated: BearerKey[] = [];
|
||||
|
||||
if (rawKey) {
|
||||
// Cases 2 and 3 in migration rules
|
||||
migrated = [
|
||||
{
|
||||
id: randomUUID(),
|
||||
name: 'default',
|
||||
token: rawKey,
|
||||
enabled: enableBearerAuth,
|
||||
accessType: 'all',
|
||||
allowedGroups: [],
|
||||
allowedServers: [],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Cases 1 and 4 both result in empty keys list
|
||||
settings.bearerKeys = migrated;
|
||||
await this.saveSettings(settings);
|
||||
|
||||
return migrated;
|
||||
}
|
||||
|
||||
private async saveKeys(keys: BearerKey[]): Promise<void> {
|
||||
const settings = await this.loadSettings();
|
||||
settings.bearerKeys = keys;
|
||||
await this.saveSettings(settings);
|
||||
}
|
||||
|
||||
async findAll(): Promise<BearerKey[]> {
|
||||
return await this.loadKeysWithMigration();
|
||||
}
|
||||
|
||||
async findEnabled(): Promise<BearerKey[]> {
|
||||
const keys = await this.loadKeysWithMigration();
|
||||
return keys.filter((key) => key.enabled);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<BearerKey | undefined> {
|
||||
const keys = await this.loadKeysWithMigration();
|
||||
return keys.find((key) => key.id === id);
|
||||
}
|
||||
|
||||
async findByToken(token: string): Promise<BearerKey | undefined> {
|
||||
const keys = await this.loadKeysWithMigration();
|
||||
return keys.find((key) => key.token === token);
|
||||
}
|
||||
|
||||
async create(data: Omit<BearerKey, 'id'>): Promise<BearerKey> {
|
||||
const keys = await this.loadKeysWithMigration();
|
||||
const newKey: BearerKey = {
|
||||
id: randomUUID(),
|
||||
...data,
|
||||
};
|
||||
keys.push(newKey);
|
||||
await this.saveKeys(keys);
|
||||
return newKey;
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<Omit<BearerKey, 'id'>>): Promise<BearerKey | null> {
|
||||
const keys = await this.loadKeysWithMigration();
|
||||
const index = keys.findIndex((key) => key.id === id);
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updated: BearerKey = {
|
||||
...keys[index],
|
||||
...data,
|
||||
id: keys[index].id,
|
||||
};
|
||||
keys[index] = updated;
|
||||
await this.saveKeys(keys);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const keys = await this.loadKeysWithMigration();
|
||||
const next = keys.filter((key) => key.id !== id);
|
||||
if (next.length === keys.length) {
|
||||
return false;
|
||||
}
|
||||
await this.saveKeys(next);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
77
src/dao/BearerKeyDaoDbImpl.ts
Normal file
77
src/dao/BearerKeyDaoDbImpl.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { BearerKeyDao } from './BearerKeyDao.js';
|
||||
import { BearerKey as BearerKeyModel } from '../types/index.js';
|
||||
import { BearerKeyRepository } from '../db/repositories/BearerKeyRepository.js';
|
||||
|
||||
/**
|
||||
* Database-backed implementation of BearerKeyDao
|
||||
*/
|
||||
export class BearerKeyDaoDbImpl implements BearerKeyDao {
|
||||
private repository: BearerKeyRepository;
|
||||
|
||||
constructor() {
|
||||
this.repository = new BearerKeyRepository();
|
||||
}
|
||||
|
||||
private toModel(entity: import('../db/entities/BearerKey.js').BearerKey): BearerKeyModel {
|
||||
return {
|
||||
id: entity.id,
|
||||
name: entity.name,
|
||||
token: entity.token,
|
||||
enabled: entity.enabled,
|
||||
accessType: entity.accessType,
|
||||
allowedGroups: entity.allowedGroups ?? [],
|
||||
allowedServers: entity.allowedServers ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
async findAll(): Promise<BearerKeyModel[]> {
|
||||
const entities = await this.repository.findAll();
|
||||
return entities.map((e) => this.toModel(e));
|
||||
}
|
||||
|
||||
async findEnabled(): Promise<BearerKeyModel[]> {
|
||||
const entities = await this.repository.findAll();
|
||||
return entities.filter((e) => e.enabled).map((e) => this.toModel(e));
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<BearerKeyModel | undefined> {
|
||||
const entity = await this.repository.findById(id);
|
||||
return entity ? this.toModel(entity) : undefined;
|
||||
}
|
||||
|
||||
async findByToken(token: string): Promise<BearerKeyModel | undefined> {
|
||||
const entity = await this.repository.findByToken(token);
|
||||
return entity ? this.toModel(entity) : undefined;
|
||||
}
|
||||
|
||||
async create(data: Omit<BearerKeyModel, 'id'>): Promise<BearerKeyModel> {
|
||||
const entity = await this.repository.create({
|
||||
name: data.name,
|
||||
token: data.token,
|
||||
enabled: data.enabled,
|
||||
accessType: data.accessType,
|
||||
allowedGroups: data.allowedGroups ?? [],
|
||||
allowedServers: data.allowedServers ?? [],
|
||||
} as any);
|
||||
return this.toModel(entity as any);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
data: Partial<Omit<BearerKeyModel, 'id'>>,
|
||||
): Promise<BearerKeyModel | null> {
|
||||
const entity = await this.repository.update(id, {
|
||||
name: data.name,
|
||||
token: data.token,
|
||||
enabled: data.enabled,
|
||||
accessType: data.accessType,
|
||||
allowedGroups: data.allowedGroups,
|
||||
allowedServers: data.allowedServers,
|
||||
} as any);
|
||||
return entity ? this.toModel(entity as any) : null;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
return await this.repository.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { SystemConfigDao, SystemConfigDaoImpl } from './SystemConfigDao.js';
|
||||
import { UserConfigDao, UserConfigDaoImpl } from './UserConfigDao.js';
|
||||
import { OAuthClientDao, OAuthClientDaoImpl } from './OAuthClientDao.js';
|
||||
import { OAuthTokenDao, OAuthTokenDaoImpl } from './OAuthTokenDao.js';
|
||||
import { BearerKeyDao, BearerKeyDaoImpl } from './BearerKeyDao.js';
|
||||
|
||||
/**
|
||||
* DAO Factory interface for creating DAO instances
|
||||
@@ -17,6 +18,7 @@ export interface DaoFactory {
|
||||
getUserConfigDao(): UserConfigDao;
|
||||
getOAuthClientDao(): OAuthClientDao;
|
||||
getOAuthTokenDao(): OAuthTokenDao;
|
||||
getBearerKeyDao(): BearerKeyDao;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,6 +34,7 @@ export class JsonFileDaoFactory implements DaoFactory {
|
||||
private userConfigDao: UserConfigDao | null = null;
|
||||
private oauthClientDao: OAuthClientDao | null = null;
|
||||
private oauthTokenDao: OAuthTokenDao | null = null;
|
||||
private bearerKeyDao: BearerKeyDao | null = null;
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
@@ -96,6 +99,13 @@ export class JsonFileDaoFactory implements DaoFactory {
|
||||
return this.oauthTokenDao;
|
||||
}
|
||||
|
||||
getBearerKeyDao(): BearerKeyDao {
|
||||
if (!this.bearerKeyDao) {
|
||||
this.bearerKeyDao = new BearerKeyDaoImpl();
|
||||
}
|
||||
return this.bearerKeyDao;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all cached DAO instances (useful for testing)
|
||||
*/
|
||||
@@ -107,6 +117,7 @@ export class JsonFileDaoFactory implements DaoFactory {
|
||||
this.userConfigDao = null;
|
||||
this.oauthClientDao = null;
|
||||
this.oauthTokenDao = null;
|
||||
this.bearerKeyDao = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,3 +190,7 @@ export function getOAuthClientDao(): OAuthClientDao {
|
||||
export function getOAuthTokenDao(): OAuthTokenDao {
|
||||
return getDaoFactory().getOAuthTokenDao();
|
||||
}
|
||||
|
||||
export function getBearerKeyDao(): BearerKeyDao {
|
||||
return getDaoFactory().getBearerKeyDao();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
UserConfigDao,
|
||||
OAuthClientDao,
|
||||
OAuthTokenDao,
|
||||
BearerKeyDao,
|
||||
} from './index.js';
|
||||
import { UserDaoDbImpl } from './UserDaoDbImpl.js';
|
||||
import { ServerDaoDbImpl } from './ServerDaoDbImpl.js';
|
||||
@@ -15,6 +16,7 @@ import { SystemConfigDaoDbImpl } from './SystemConfigDaoDbImpl.js';
|
||||
import { UserConfigDaoDbImpl } from './UserConfigDaoDbImpl.js';
|
||||
import { OAuthClientDaoDbImpl } from './OAuthClientDaoDbImpl.js';
|
||||
import { OAuthTokenDaoDbImpl } from './OAuthTokenDaoDbImpl.js';
|
||||
import { BearerKeyDaoDbImpl } from './BearerKeyDaoDbImpl.js';
|
||||
|
||||
/**
|
||||
* Database-backed DAO factory implementation
|
||||
@@ -29,6 +31,7 @@ export class DatabaseDaoFactory implements DaoFactory {
|
||||
private userConfigDao: UserConfigDao | null = null;
|
||||
private oauthClientDao: OAuthClientDao | null = null;
|
||||
private oauthTokenDao: OAuthTokenDao | null = null;
|
||||
private bearerKeyDao: BearerKeyDao | null = null;
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
@@ -93,6 +96,13 @@ export class DatabaseDaoFactory implements DaoFactory {
|
||||
return this.oauthTokenDao!;
|
||||
}
|
||||
|
||||
getBearerKeyDao(): BearerKeyDao {
|
||||
if (!this.bearerKeyDao) {
|
||||
this.bearerKeyDao = new BearerKeyDaoDbImpl();
|
||||
}
|
||||
return this.bearerKeyDao!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all cached DAO instances (useful for testing)
|
||||
*/
|
||||
@@ -104,5 +114,6 @@ export class DatabaseDaoFactory implements DaoFactory {
|
||||
this.userConfigDao = null;
|
||||
this.oauthClientDao = null;
|
||||
this.oauthTokenDao = null;
|
||||
this.bearerKeyDao = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ export class ServerDaoDbImpl implements ServerDao {
|
||||
prompts: entity.prompts,
|
||||
options: entity.options,
|
||||
oauth: entity.oauth,
|
||||
openapi: entity.openapi,
|
||||
});
|
||||
return this.mapToServerConfig(server);
|
||||
}
|
||||
@@ -61,6 +62,7 @@ export class ServerDaoDbImpl implements ServerDao {
|
||||
prompts: entity.prompts,
|
||||
options: entity.options,
|
||||
oauth: entity.oauth,
|
||||
openapi: entity.openapi,
|
||||
});
|
||||
return server ? this.mapToServerConfig(server) : null;
|
||||
}
|
||||
@@ -129,6 +131,7 @@ export class ServerDaoDbImpl implements ServerDao {
|
||||
prompts?: Record<string, { enabled: boolean; description?: string }>;
|
||||
options?: Record<string, any>;
|
||||
oauth?: Record<string, any>;
|
||||
openapi?: Record<string, any>;
|
||||
}): ServerConfigWithName {
|
||||
return {
|
||||
name: server.name,
|
||||
@@ -146,6 +149,7 @@ export class ServerDaoDbImpl implements ServerDao {
|
||||
prompts: server.prompts,
|
||||
options: server.options,
|
||||
oauth: server.oauth,
|
||||
openapi: server.openapi,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export * from './SystemConfigDao.js';
|
||||
export * from './UserConfigDao.js';
|
||||
export * from './OAuthClientDao.js';
|
||||
export * from './OAuthTokenDao.js';
|
||||
export * from './BearerKeyDao.js';
|
||||
|
||||
// Export database implementations
|
||||
export * from './UserDaoDbImpl.js';
|
||||
@@ -17,6 +18,7 @@ export * from './SystemConfigDaoDbImpl.js';
|
||||
export * from './UserConfigDaoDbImpl.js';
|
||||
export * from './OAuthClientDaoDbImpl.js';
|
||||
export * from './OAuthTokenDaoDbImpl.js';
|
||||
export * from './BearerKeyDaoDbImpl.js';
|
||||
|
||||
// Export the DAO factory and convenience functions
|
||||
export * from './DaoFactory.js';
|
||||
|
||||
@@ -25,39 +25,44 @@ const createRequiredExtensions = async (dataSource: DataSource): Promise<void> =
|
||||
};
|
||||
|
||||
// Get database URL from smart routing config or fallback to environment variable
|
||||
const getDatabaseUrl = (): string => {
|
||||
return getSmartRoutingConfig().dbUrl;
|
||||
const getDatabaseUrl = async (): Promise<string> => {
|
||||
return (await getSmartRoutingConfig()).dbUrl;
|
||||
};
|
||||
|
||||
// Default database configuration
|
||||
const defaultConfig: DataSourceOptions = {
|
||||
type: 'postgres',
|
||||
url: getDatabaseUrl(),
|
||||
synchronize: true,
|
||||
entities: entities,
|
||||
subscribers: [VectorEmbeddingSubscriber],
|
||||
// Default database configuration (without URL - will be set during initialization)
|
||||
const getDefaultConfig = async (): Promise<DataSourceOptions> => {
|
||||
return {
|
||||
type: 'postgres',
|
||||
url: await getDatabaseUrl(),
|
||||
synchronize: true,
|
||||
entities: entities,
|
||||
subscribers: [VectorEmbeddingSubscriber],
|
||||
};
|
||||
};
|
||||
|
||||
// AppDataSource is the TypeORM data source
|
||||
let appDataSource = new DataSource(defaultConfig);
|
||||
// AppDataSource is the TypeORM data source (initialized with empty config, will be updated)
|
||||
let appDataSource: DataSource | null = null;
|
||||
|
||||
// Global promise to track initialization status
|
||||
let initializationPromise: Promise<DataSource> | null = null;
|
||||
|
||||
// Function to create a new DataSource with updated configuration
|
||||
export const updateDataSourceConfig = (): DataSource => {
|
||||
const newConfig: DataSourceOptions = {
|
||||
...defaultConfig,
|
||||
url: getDatabaseUrl(),
|
||||
};
|
||||
export const updateDataSourceConfig = async (): Promise<DataSource> => {
|
||||
const newConfig = await getDefaultConfig();
|
||||
|
||||
// If the configuration has changed, we need to create a new DataSource
|
||||
const currentUrl = (appDataSource.options as any).url;
|
||||
if (currentUrl !== newConfig.url) {
|
||||
console.log('Database URL configuration changed, updating DataSource...');
|
||||
if (appDataSource) {
|
||||
const currentUrl = (appDataSource.options as any).url;
|
||||
const newUrl = (newConfig as any).url;
|
||||
if (currentUrl !== newUrl) {
|
||||
console.log('Database URL configuration changed, updating DataSource...');
|
||||
appDataSource = new DataSource(newConfig);
|
||||
// Reset initialization promise when configuration changes
|
||||
initializationPromise = null;
|
||||
}
|
||||
} else {
|
||||
// First time initialization
|
||||
appDataSource = new DataSource(newConfig);
|
||||
// Reset initialization promise when configuration changes
|
||||
initializationPromise = null;
|
||||
}
|
||||
|
||||
return appDataSource;
|
||||
@@ -65,6 +70,9 @@ export const updateDataSourceConfig = (): DataSource => {
|
||||
|
||||
// Get the current AppDataSource instance
|
||||
export const getAppDataSource = (): DataSource => {
|
||||
if (!appDataSource) {
|
||||
throw new Error('Database not initialized. Call initializeDatabase() first.');
|
||||
}
|
||||
return appDataSource;
|
||||
};
|
||||
|
||||
@@ -72,7 +80,7 @@ export const getAppDataSource = (): DataSource => {
|
||||
export const reconnectDatabase = async (): Promise<DataSource> => {
|
||||
try {
|
||||
// Close existing connection if it exists
|
||||
if (appDataSource.isInitialized) {
|
||||
if (appDataSource && appDataSource.isInitialized) {
|
||||
console.log('Closing existing database connection...');
|
||||
await appDataSource.destroy();
|
||||
}
|
||||
@@ -81,7 +89,7 @@ export const reconnectDatabase = async (): Promise<DataSource> => {
|
||||
initializationPromise = null;
|
||||
|
||||
// Update configuration and reconnect
|
||||
appDataSource = updateDataSourceConfig();
|
||||
appDataSource = await updateDataSourceConfig();
|
||||
return await initializeDatabase();
|
||||
} catch (error) {
|
||||
console.error('Error during database reconnection:', error);
|
||||
@@ -98,7 +106,7 @@ export const initializeDatabase = async (): Promise<DataSource> => {
|
||||
}
|
||||
|
||||
// If already initialized, return the existing instance
|
||||
if (appDataSource.isInitialized) {
|
||||
if (appDataSource && appDataSource.isInitialized) {
|
||||
console.log('Database already initialized, returning existing instance');
|
||||
return Promise.resolve(appDataSource);
|
||||
}
|
||||
@@ -122,7 +130,7 @@ export const initializeDatabase = async (): Promise<DataSource> => {
|
||||
const performDatabaseInitialization = async (): Promise<DataSource> => {
|
||||
try {
|
||||
// Update configuration before initializing
|
||||
appDataSource = updateDataSourceConfig();
|
||||
appDataSource = await updateDataSourceConfig();
|
||||
|
||||
if (!appDataSource.isInitialized) {
|
||||
console.log('Initializing database connection...');
|
||||
@@ -250,7 +258,8 @@ const performDatabaseInitialization = async (): Promise<DataSource> => {
|
||||
console.log('Database connection established successfully.');
|
||||
|
||||
// Run one final setup check after schema synchronization is done
|
||||
if (defaultConfig.synchronize) {
|
||||
const config = await getDefaultConfig();
|
||||
if (config.synchronize) {
|
||||
try {
|
||||
console.log('Running final vector configuration check...');
|
||||
|
||||
@@ -325,12 +334,12 @@ const performDatabaseInitialization = async (): Promise<DataSource> => {
|
||||
|
||||
// Get database connection status
|
||||
export const isDatabaseConnected = (): boolean => {
|
||||
return appDataSource.isInitialized;
|
||||
return appDataSource ? appDataSource.isInitialized : false;
|
||||
};
|
||||
|
||||
// Close database connection
|
||||
export const closeDatabase = async (): Promise<void> => {
|
||||
if (appDataSource.isInitialized) {
|
||||
if (appDataSource && appDataSource.isInitialized) {
|
||||
await appDataSource.destroy();
|
||||
console.log('Database connection closed.');
|
||||
}
|
||||
|
||||
43
src/db/entities/BearerKey.ts
Normal file
43
src/db/entities/BearerKey.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* Bearer authentication key entity
|
||||
* Stores multiple bearer keys with per-key enable/disable and scoped access control
|
||||
*/
|
||||
@Entity({ name: 'bearer_keys' })
|
||||
export class BearerKey {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 512 })
|
||||
token: string;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
enabled: boolean;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'all' })
|
||||
accessType: 'all' | 'groups' | 'servers';
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
allowedGroups?: string[];
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
allowedServers?: string[];
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export default BearerKey;
|
||||
@@ -59,6 +59,9 @@ export class Server {
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
oauth?: Record<string, any>;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
openapi?: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
|
||||
createdAt: Date;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import SystemConfig from './SystemConfig.js';
|
||||
import UserConfig from './UserConfig.js';
|
||||
import OAuthClient from './OAuthClient.js';
|
||||
import OAuthToken from './OAuthToken.js';
|
||||
import BearerKey from './BearerKey.js';
|
||||
|
||||
// Export all entities
|
||||
export default [
|
||||
@@ -17,7 +18,18 @@ export default [
|
||||
UserConfig,
|
||||
OAuthClient,
|
||||
OAuthToken,
|
||||
BearerKey,
|
||||
];
|
||||
|
||||
// Export individual entities for direct use
|
||||
export { VectorEmbedding, User, Server, Group, SystemConfig, UserConfig, OAuthClient, OAuthToken };
|
||||
export {
|
||||
VectorEmbedding,
|
||||
User,
|
||||
Server,
|
||||
Group,
|
||||
SystemConfig,
|
||||
UserConfig,
|
||||
OAuthClient,
|
||||
OAuthToken,
|
||||
BearerKey,
|
||||
};
|
||||
|
||||
75
src/db/repositories/BearerKeyRepository.ts
Normal file
75
src/db/repositories/BearerKeyRepository.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Repository } from 'typeorm';
|
||||
import { BearerKey } from '../entities/BearerKey.js';
|
||||
import { getAppDataSource } from '../connection.js';
|
||||
|
||||
/**
|
||||
* Repository for BearerKey entity
|
||||
*/
|
||||
export class BearerKeyRepository {
|
||||
private repository: Repository<BearerKey>;
|
||||
|
||||
constructor() {
|
||||
this.repository = getAppDataSource().getRepository(BearerKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all bearer keys
|
||||
*/
|
||||
async findAll(): Promise<BearerKey[]> {
|
||||
return await this.repository.find({ order: { createdAt: 'ASC' } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Count bearer keys
|
||||
*/
|
||||
async count(): Promise<number> {
|
||||
return await this.repository.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find bearer key by id
|
||||
*/
|
||||
async findById(id: string): Promise<BearerKey | null> {
|
||||
return await this.repository.findOne({ where: { id } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find bearer key by token value
|
||||
*/
|
||||
async findByToken(token: string): Promise<BearerKey | null> {
|
||||
return await this.repository.findOne({ where: { token } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new bearer key
|
||||
*/
|
||||
async create(data: Omit<BearerKey, 'id' | 'createdAt' | 'updatedAt'>): Promise<BearerKey> {
|
||||
const entity = this.repository.create(data);
|
||||
return await this.repository.save(entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing bearer key
|
||||
*/
|
||||
async update(
|
||||
id: string,
|
||||
updates: Partial<Omit<BearerKey, 'id' | 'createdAt' | 'updatedAt'>>,
|
||||
): Promise<BearerKey | null> {
|
||||
const existing = await this.findById(id);
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
const merged = this.repository.merge(existing, updates);
|
||||
return await this.repository.save(merged);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a bearer key
|
||||
*/
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const result = await this.repository.delete({ id });
|
||||
return (result.affected ?? 0) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
export default BearerKeyRepository;
|
||||
@@ -6,6 +6,7 @@ import { SystemConfigRepository } from './SystemConfigRepository.js';
|
||||
import { UserConfigRepository } from './UserConfigRepository.js';
|
||||
import { OAuthClientRepository } from './OAuthClientRepository.js';
|
||||
import { OAuthTokenRepository } from './OAuthTokenRepository.js';
|
||||
import { BearerKeyRepository } from './BearerKeyRepository.js';
|
||||
|
||||
// Export all repositories
|
||||
export {
|
||||
@@ -17,4 +18,5 @@ export {
|
||||
UserConfigRepository,
|
||||
OAuthClientRepository,
|
||||
OAuthTokenRepository,
|
||||
BearerKeyRepository,
|
||||
};
|
||||
|
||||
@@ -5,9 +5,15 @@ 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';
|
||||
import { getBearerKeyDao } from '../dao/index.js';
|
||||
import { BearerKey } from '../types/index.js';
|
||||
|
||||
const validateBearerAuth = (req: Request, routingConfig: any): boolean => {
|
||||
if (!routingConfig.enableBearerAuth) {
|
||||
const validateBearerAuth = async (req: Request): Promise<boolean> => {
|
||||
const bearerKeyDao = getBearerKeyDao();
|
||||
const enabledKeys = await bearerKeyDao.findEnabled();
|
||||
|
||||
// If there are no enabled keys, bearer auth via static keys is disabled
|
||||
if (enabledKeys.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -16,7 +22,21 @@ const validateBearerAuth = (req: Request, routingConfig: any): boolean => {
|
||||
return false;
|
||||
}
|
||||
|
||||
return authHeader.substring(7) === routingConfig.bearerAuthKey;
|
||||
const token = authHeader.substring(7).trim();
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const matchingKey: BearerKey | undefined = enabledKeys.find((key) => key.token === token);
|
||||
if (!matchingKey) {
|
||||
console.warn('Bearer auth failed: token did not match any configured bearer key');
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Bearer auth succeeded with key id=${matchingKey.id}, name=${matchingKey.name}, accessType=${matchingKey.accessType}`,
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
const readonlyAllowPaths = ['/tools/call/'];
|
||||
@@ -47,8 +67,6 @@ export const auth = async (req: Request, res: Response, next: NextFunction): Pro
|
||||
const routingConfig = loadSettings().systemConfig?.routing || {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
skipAuth: false,
|
||||
};
|
||||
|
||||
@@ -57,8 +75,8 @@ export const auth = async (req: Request, res: Response, next: NextFunction): Pro
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if bearer auth is enabled and validate it
|
||||
if (validateBearerAuth(req, routingConfig)) {
|
||||
// Check if bearer auth via configured keys can validate this request
|
||||
if (await validateBearerAuth(req)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getAllSettings,
|
||||
getServerConfig,
|
||||
createServer,
|
||||
batchCreateServers,
|
||||
updateServer,
|
||||
deleteServer,
|
||||
toggleServer,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
getGroups,
|
||||
getGroup,
|
||||
createNewGroup,
|
||||
batchCreateGroups,
|
||||
updateExistingGroup,
|
||||
deleteExistingGroup,
|
||||
addServerToExistingGroup,
|
||||
@@ -104,6 +106,12 @@ import {
|
||||
updateClientConfiguration,
|
||||
deleteClientRegistration,
|
||||
} from '../controllers/oauthDynamicRegistrationController.js';
|
||||
import {
|
||||
getBearerKeys,
|
||||
createBearerKey,
|
||||
updateBearerKey,
|
||||
deleteBearerKey,
|
||||
} from '../controllers/bearerKeyController.js';
|
||||
import { auth } from '../middlewares/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -134,6 +142,7 @@ export const initRoutes = (app: express.Application): void => {
|
||||
router.get('/servers/:name', getServerConfig);
|
||||
router.get('/settings', getAllSettings);
|
||||
router.post('/servers', createServer);
|
||||
router.post('/servers/batch', batchCreateServers);
|
||||
router.put('/servers/:name', updateServer);
|
||||
router.delete('/servers/:name', deleteServer);
|
||||
router.post('/servers/:name/toggle', toggleServer);
|
||||
@@ -148,6 +157,7 @@ export const initRoutes = (app: express.Application): void => {
|
||||
router.get('/groups', getGroups);
|
||||
router.get('/groups/:id', getGroup);
|
||||
router.post('/groups', createNewGroup);
|
||||
router.post('/groups/batch', batchCreateGroups);
|
||||
router.put('/groups/:id', updateExistingGroup);
|
||||
router.delete('/groups/:id', deleteExistingGroup);
|
||||
router.post('/groups/:id/servers', addServerToExistingGroup);
|
||||
@@ -183,6 +193,12 @@ export const initRoutes = (app: express.Application): void => {
|
||||
router.delete('/oauth/clients/:clientId', deleteClient);
|
||||
router.post('/oauth/clients/:clientId/regenerate-secret', regenerateSecret);
|
||||
|
||||
// Bearer authentication key management (admin only)
|
||||
router.get('/auth/keys', getBearerKeys);
|
||||
router.post('/auth/keys', createBearerKey);
|
||||
router.put('/auth/keys/:id', updateBearerKey);
|
||||
router.delete('/auth/keys/:id', deleteBearerKey);
|
||||
|
||||
// Tool management routes
|
||||
router.post('/tools/call/:server', callTool);
|
||||
|
||||
|
||||
@@ -29,9 +29,9 @@ export const getGroupByIdOrName = async (key: string): Promise<IGroup | undefine
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
const routingConfig = systemConfig?.routing || {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
const routingConfig = {
|
||||
enableGlobalRoute: systemConfig?.routing?.enableGlobalRoute ?? true,
|
||||
enableGroupNameRoute: systemConfig?.routing?.enableGroupNameRoute ?? true,
|
||||
};
|
||||
|
||||
const groups = await getAllGroups();
|
||||
|
||||
@@ -48,7 +48,9 @@ export const setupClientKeepAlive = async (
|
||||
await (serverInfo.client as any).ping();
|
||||
console.log(`Keep-alive ping successful for server: ${serverInfo.name}`);
|
||||
} else {
|
||||
await serverInfo.client.listTools({ timeout: 5000 }).catch(() => void 0);
|
||||
await serverInfo.client
|
||||
.listTools({}, { ...(serverInfo.options || {}), timeout: 5000 })
|
||||
.catch(() => void 0);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -325,7 +325,7 @@ export class MCPHubOAuthProvider implements OAuthClientProvider {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Saving OAuth tokens for server: ${this.serverName}`);
|
||||
console.log(`Saving OAuth tokens: ${JSON.stringify(tokens)} for server: ${this.serverName}`);
|
||||
|
||||
const updatedConfig = await persistTokens(this.serverName, {
|
||||
accessToken: tokens.access_token,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
StreamableHTTPClientTransport,
|
||||
StreamableHTTPClientTransportOptions,
|
||||
} from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { createFetchWithProxy, getProxyConfigFromEnv } from './proxy.js';
|
||||
import { ServerInfo, ServerConfig, Tool } from '../types/index.js';
|
||||
import { expandEnvVars, replaceEnvVars, getNameSeparator } from '../config/index.js';
|
||||
import config from '../config/index.js';
|
||||
@@ -134,6 +135,10 @@ export const cleanupAllServers = (): void => {
|
||||
// Helper function to create transport based on server configuration
|
||||
export const createTransportFromConfig = async (name: string, conf: ServerConfig): Promise<any> => {
|
||||
let transport;
|
||||
const env: Record<string, string> = {
|
||||
...(process.env as Record<string, string>),
|
||||
...replaceEnvVars(conf.env || {}),
|
||||
};
|
||||
|
||||
if (conf.type === 'streamable-http') {
|
||||
const options: StreamableHTTPClientTransportOptions = {};
|
||||
@@ -152,6 +157,8 @@ export const createTransportFromConfig = async (name: string, conf: ServerConfig
|
||||
console.log(`OAuth provider configured for server: ${name}`);
|
||||
}
|
||||
|
||||
options.fetch = createFetchWithProxy(getProxyConfigFromEnv(env));
|
||||
|
||||
transport = new StreamableHTTPClientTransport(new URL(conf.url || ''), options);
|
||||
} else if (conf.url) {
|
||||
// SSE transport
|
||||
@@ -174,13 +181,11 @@ export const createTransportFromConfig = async (name: string, conf: ServerConfig
|
||||
console.log(`OAuth provider configured for server: ${name}`);
|
||||
}
|
||||
|
||||
options.fetch = createFetchWithProxy(getProxyConfigFromEnv(env));
|
||||
|
||||
transport = new SSEClientTransport(new URL(conf.url), options);
|
||||
} else if (conf.command && conf.args) {
|
||||
// Stdio transport
|
||||
const env: Record<string, string> = {
|
||||
...(process.env as Record<string, string>),
|
||||
...replaceEnvVars(conf.env || {}),
|
||||
};
|
||||
env['PATH'] = expandEnvVars(process.env.PATH as string) || '';
|
||||
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
@@ -236,6 +241,8 @@ const callToolWithReconnect = async (
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const result = await serverInfo.client.callTool(toolParams, undefined, options || {});
|
||||
// Check auth error
|
||||
checkAuthError(result);
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
// Check if error message starts with "Error POSTing to endpoint (HTTP 40"
|
||||
@@ -614,9 +621,37 @@ export const registerAllTools = async (isInit: boolean, serverName?: string): Pr
|
||||
export const getServersInfo = async (): Promise<Omit<ServerInfo, 'client' | 'transport'>[]> => {
|
||||
const allServers: ServerConfigWithName[] = await getServerDao().findAll();
|
||||
const dataService = getDataService();
|
||||
|
||||
// Ensure that servers recently added via DAO but not yet initialized in serverInfos
|
||||
// are still visible in the servers list. This avoids a race condition where
|
||||
// a POST /api/servers immediately followed by GET /api/servers would not
|
||||
// return the newly created server until background initialization completes.
|
||||
const combinedServerInfos: ServerInfo[] = [...serverInfos];
|
||||
const existingNames = new Set(combinedServerInfos.map((s) => s.name));
|
||||
|
||||
for (const server of allServers) {
|
||||
if (!existingNames.has(server.name)) {
|
||||
const isEnabled = server.enabled === undefined ? true : server.enabled;
|
||||
combinedServerInfos.push({
|
||||
name: server.name,
|
||||
owner: server.owner,
|
||||
// Newly created servers that are enabled should appear as "connecting"
|
||||
// until the MCP client initialization completes. Disabled servers remain
|
||||
// in the "disconnected" state.
|
||||
status: isEnabled ? 'connecting' : 'disconnected',
|
||||
error: null,
|
||||
tools: [],
|
||||
prompts: [],
|
||||
createTime: Date.now(),
|
||||
enabled: isEnabled,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const filterServerInfos: ServerInfo[] = dataService.filterData
|
||||
? dataService.filterData(serverInfos)
|
||||
: serverInfos;
|
||||
? dataService.filterData(combinedServerInfos)
|
||||
: combinedServerInfos;
|
||||
|
||||
const infos = filterServerInfos.map(
|
||||
({ name, status, tools, prompts, createTime, error, oauth }) => {
|
||||
const serverConfig = allServers.find((server) => server.name === name);
|
||||
@@ -797,6 +832,25 @@ export const addOrUpdateServer = async (
|
||||
}
|
||||
};
|
||||
|
||||
// Check for authentication error in tool call result
|
||||
function checkAuthError(result: any) {
|
||||
if (Array.isArray(result.content) && result.content.length > 0) {
|
||||
const text = result.content[0]?.text;
|
||||
if (typeof text === 'string') {
|
||||
let errorContent;
|
||||
try {
|
||||
errorContent = JSON.parse(text);
|
||||
} catch (e) {
|
||||
// Ignore JSON parse errors and continue
|
||||
return;
|
||||
}
|
||||
if (errorContent.code === 401) {
|
||||
throw new Error('Error POSTing to endpoint (HTTP 401 Unauthorized)');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close server client and transport
|
||||
function closeServer(name: string) {
|
||||
const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name);
|
||||
|
||||
@@ -42,7 +42,7 @@ function convertToolSchemaToOpenAPI(tool: Tool): {
|
||||
(prop: any) =>
|
||||
prop.type === 'object' ||
|
||||
prop.type === 'array' ||
|
||||
(prop.type === 'string' && prop.enum && prop.enum.length > 10),
|
||||
prop.type === 'string',
|
||||
);
|
||||
|
||||
if (!hasComplexTypes && Object.keys(properties).length <= 10) {
|
||||
@@ -93,7 +93,7 @@ function generateOperationFromTool(tool: Tool, serverName: string): OpenAPIV3.Op
|
||||
const operation: OpenAPIV3.OperationObject = {
|
||||
summary: tool.description || `Execute ${tool.name} tool`,
|
||||
description: tool.description || `Execute the ${tool.name} tool from ${serverName} server`,
|
||||
operationId: `${serverName}_${tool.name}`,
|
||||
operationId: `${tool.name}`,
|
||||
tags: [serverName],
|
||||
...(parameters && parameters.length > 0 && { parameters }),
|
||||
...(requestBody && { requestBody }),
|
||||
|
||||
167
src/services/proxy.ts
Normal file
167
src/services/proxy.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* HTTP/HTTPS proxy configuration utilities for MCP client transports.
|
||||
*
|
||||
* This module provides utilities to configure HTTP and HTTPS proxies when
|
||||
* connecting to MCP servers. Proxies are configured by providing a custom
|
||||
* fetch implementation that uses Node.js http/https agents with proxy support.
|
||||
*
|
||||
*/
|
||||
|
||||
import { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||
|
||||
/**
|
||||
* Configuration options for HTTP/HTTPS proxy settings.
|
||||
*/
|
||||
export interface ProxyConfig {
|
||||
/**
|
||||
* HTTP proxy URL (e.g., 'http://proxy.example.com:8080')
|
||||
* Can include authentication: 'http://user:pass@proxy.example.com:8080'
|
||||
*/
|
||||
httpProxy?: string;
|
||||
|
||||
/**
|
||||
* HTTPS proxy URL (e.g., 'https://proxy.example.com:8443')
|
||||
* Can include authentication: 'https://user:pass@proxy.example.com:8443'
|
||||
*/
|
||||
httpsProxy?: string;
|
||||
|
||||
/**
|
||||
* Comma-separated list of hosts that should bypass the proxy
|
||||
* (e.g., 'localhost,127.0.0.1,.example.com')
|
||||
*/
|
||||
noProxy?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a fetch function that uses the specified proxy configuration.
|
||||
*
|
||||
* This function returns a fetch implementation that routes requests through
|
||||
* the configured HTTP/HTTPS proxies using undici's ProxyAgent.
|
||||
*
|
||||
* Note: This function requires the 'undici' package to be installed.
|
||||
* Install it with: npm install undici
|
||||
*
|
||||
* @param config - Proxy configuration options
|
||||
* @returns A fetch-compatible function configured to use the specified proxies
|
||||
*
|
||||
*/
|
||||
export function createFetchWithProxy(config: ProxyConfig): FetchLike {
|
||||
// If no proxy is configured, return the default fetch
|
||||
if (!config.httpProxy && !config.httpsProxy) {
|
||||
return fetch;
|
||||
}
|
||||
|
||||
// Parse no_proxy list
|
||||
const noProxyList = parseNoProxy(config.noProxy);
|
||||
|
||||
return async (url: string | URL, init?: RequestInit): Promise<Response> => {
|
||||
const targetUrl = typeof url === 'string' ? new URL(url) : url;
|
||||
|
||||
// Check if host should bypass proxy
|
||||
if (shouldBypassProxy(targetUrl.hostname, noProxyList)) {
|
||||
return fetch(url, init);
|
||||
}
|
||||
|
||||
// Determine which proxy to use based on protocol
|
||||
const proxyUrl = targetUrl.protocol === 'https:' ? config.httpsProxy : config.httpProxy;
|
||||
|
||||
if (!proxyUrl) {
|
||||
// No proxy configured for this protocol
|
||||
return fetch(url, init);
|
||||
}
|
||||
|
||||
// Use undici for proxy support if available
|
||||
try {
|
||||
// Dynamic import - undici is an optional peer dependency
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const undici = await import('undici' as any);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const ProxyAgent = (undici as any).ProxyAgent;
|
||||
const dispatcher = new ProxyAgent(proxyUrl);
|
||||
|
||||
return fetch(url, {
|
||||
...init,
|
||||
// @ts-expect-error - dispatcher is undici-specific
|
||||
dispatcher,
|
||||
});
|
||||
} catch (error) {
|
||||
// undici not available - throw error requiring installation
|
||||
throw new Error(
|
||||
'Proxy support requires the "undici" package. ' +
|
||||
'Install it with: npm install undici\n' +
|
||||
`Original error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a NO_PROXY environment variable value into a list of patterns.
|
||||
*/
|
||||
function parseNoProxy(noProxy?: string): string[] {
|
||||
if (!noProxy) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return noProxy
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a hostname should bypass the proxy based on NO_PROXY patterns.
|
||||
*/
|
||||
function shouldBypassProxy(hostname: string, noProxyList: string[]): boolean {
|
||||
if (noProxyList.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hostnameLower = hostname.toLowerCase();
|
||||
|
||||
for (const pattern of noProxyList) {
|
||||
const patternLower = pattern.toLowerCase();
|
||||
|
||||
// Exact match
|
||||
if (hostnameLower === patternLower) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Domain suffix match (e.g., .example.com matches sub.example.com)
|
||||
if (patternLower.startsWith('.') && hostnameLower.endsWith(patternLower)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Domain suffix match without leading dot
|
||||
if (!patternLower.startsWith('.') && hostnameLower.endsWith('.' + patternLower)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Special case: "*" matches everything
|
||||
if (patternLower === '*') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a ProxyConfig from environment variables.
|
||||
*
|
||||
* This function reads standard proxy environment variables:
|
||||
* - HTTP_PROXY, http_proxy
|
||||
* - HTTPS_PROXY, https_proxy
|
||||
* - NO_PROXY, no_proxy
|
||||
*
|
||||
* Lowercase versions take precedence over uppercase versions.
|
||||
*
|
||||
* @returns A ProxyConfig object populated from environment variables
|
||||
*/
|
||||
export function getProxyConfigFromEnv(env: Record<string, string>): ProxyConfig {
|
||||
return {
|
||||
httpProxy: env.http_proxy || env.HTTP_PROXY,
|
||||
httpsProxy: env.https_proxy || env.HTTPS_PROXY,
|
||||
noProxy: env.no_proxy || env.NO_PROXY,
|
||||
};
|
||||
}
|
||||
@@ -47,6 +47,30 @@ jest.mock('../dao/index.js', () => ({
|
||||
getSystemConfigDao: jest.fn(() => ({
|
||||
get: jest.fn().mockImplementation(() => Promise.resolve(currentSystemConfig)),
|
||||
})),
|
||||
getBearerKeyDao: jest.fn(() => ({
|
||||
// Keep these unit tests aligned with legacy routing semantics:
|
||||
// enableBearerAuth + bearerAuthKey -> one enabled key (token=bearerAuthKey)
|
||||
// otherwise -> no enabled keys (bearer auth effectively disabled)
|
||||
findEnabled: jest.fn().mockImplementation(async () => {
|
||||
const routing = (currentSystemConfig as any)?.routing || {};
|
||||
const enabled = !!routing.enableBearerAuth;
|
||||
const token = String(routing.bearerAuthKey || '').trim();
|
||||
if (!enabled || !token) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: 'test-key-id',
|
||||
name: 'default',
|
||||
token,
|
||||
enabled: true,
|
||||
accessType: 'all',
|
||||
allowedGroups: [],
|
||||
allowedServers: [],
|
||||
},
|
||||
];
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock oauthBearer
|
||||
@@ -609,4 +633,274 @@ describe('sseService', () => {
|
||||
expectBearerUnauthorized(res, 'No authorization provided');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stream parameter support', () => {
|
||||
beforeEach(() => {
|
||||
// Clear transports before each test
|
||||
Object.keys(transports).forEach((key) => delete transports[key]);
|
||||
});
|
||||
|
||||
it('should create transport with enableJsonResponse=true when stream=false in body', async () => {
|
||||
const req = createMockRequest({
|
||||
params: { group: 'test-group' },
|
||||
body: {
|
||||
method: 'initialize',
|
||||
stream: false,
|
||||
},
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleMcpPostRequest(req, res);
|
||||
|
||||
// Check that StreamableHTTPServerTransport was called with enableJsonResponse: true
|
||||
expect(StreamableHTTPServerTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enableJsonResponse: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should create transport with enableJsonResponse=false when stream=true in body', async () => {
|
||||
const req = createMockRequest({
|
||||
params: { group: 'test-group' },
|
||||
body: {
|
||||
method: 'initialize',
|
||||
stream: true,
|
||||
},
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleMcpPostRequest(req, res);
|
||||
|
||||
// Check that StreamableHTTPServerTransport was called with enableJsonResponse: false
|
||||
expect(StreamableHTTPServerTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enableJsonResponse: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should create transport with enableJsonResponse=true when stream=false in query', async () => {
|
||||
const req = createMockRequest({
|
||||
params: { group: 'test-group' },
|
||||
query: { stream: 'false' },
|
||||
body: {
|
||||
method: 'initialize',
|
||||
},
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleMcpPostRequest(req, res);
|
||||
|
||||
// Check that StreamableHTTPServerTransport was called with enableJsonResponse: true
|
||||
expect(StreamableHTTPServerTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enableJsonResponse: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should default to enableJsonResponse=false when stream parameter not provided', async () => {
|
||||
const req = createMockRequest({
|
||||
params: { group: 'test-group' },
|
||||
body: {
|
||||
method: 'initialize',
|
||||
},
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleMcpPostRequest(req, res);
|
||||
|
||||
// Check that StreamableHTTPServerTransport was called with enableJsonResponse: false (default)
|
||||
expect(StreamableHTTPServerTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enableJsonResponse: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should prioritize body stream parameter over query parameter', async () => {
|
||||
const req = createMockRequest({
|
||||
params: { group: 'test-group' },
|
||||
query: { stream: 'true' },
|
||||
body: {
|
||||
method: 'initialize',
|
||||
stream: false, // body should take priority
|
||||
},
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleMcpPostRequest(req, res);
|
||||
|
||||
// Check that StreamableHTTPServerTransport was called with enableJsonResponse: true (from body)
|
||||
expect(StreamableHTTPServerTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enableJsonResponse: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass enableJsonResponse to createSessionWithId when rebuilding session', async () => {
|
||||
setMockSystemConfig({
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: 'test-key',
|
||||
},
|
||||
enableSessionRebuild: true,
|
||||
});
|
||||
|
||||
const req = createMockRequest({
|
||||
params: { group: 'test-group' },
|
||||
headers: { 'mcp-session-id': 'invalid-session' },
|
||||
body: {
|
||||
method: 'someMethod',
|
||||
stream: false,
|
||||
},
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleMcpPostRequest(req, res);
|
||||
|
||||
// Check that StreamableHTTPServerTransport was called with enableJsonResponse: true
|
||||
expect(StreamableHTTPServerTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enableJsonResponse: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle string "false" in query parameter', async () => {
|
||||
const req = createMockRequest({
|
||||
params: { group: 'test-group' },
|
||||
query: { stream: 'false' },
|
||||
body: {
|
||||
method: 'initialize',
|
||||
},
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleMcpPostRequest(req, res);
|
||||
|
||||
expect(StreamableHTTPServerTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enableJsonResponse: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle string "0" in query parameter', async () => {
|
||||
const req = createMockRequest({
|
||||
params: { group: 'test-group' },
|
||||
query: { stream: '0' },
|
||||
body: {
|
||||
method: 'initialize',
|
||||
},
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleMcpPostRequest(req, res);
|
||||
|
||||
expect(StreamableHTTPServerTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enableJsonResponse: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle number 0 in body parameter', async () => {
|
||||
const req = createMockRequest({
|
||||
params: { group: 'test-group' },
|
||||
body: {
|
||||
method: 'initialize',
|
||||
stream: 0,
|
||||
},
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleMcpPostRequest(req, res);
|
||||
|
||||
expect(StreamableHTTPServerTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enableJsonResponse: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle number 1 in body parameter', async () => {
|
||||
const req = createMockRequest({
|
||||
params: { group: 'test-group' },
|
||||
body: {
|
||||
method: 'initialize',
|
||||
stream: 1,
|
||||
},
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleMcpPostRequest(req, res);
|
||||
|
||||
expect(StreamableHTTPServerTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enableJsonResponse: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle "yes" and "no" string values', async () => {
|
||||
// Test "yes"
|
||||
const reqYes = createMockRequest({
|
||||
params: { group: 'test-group' },
|
||||
query: { stream: 'yes' },
|
||||
body: { method: 'initialize' },
|
||||
});
|
||||
const resYes = createMockResponse();
|
||||
|
||||
await handleMcpPostRequest(reqYes, resYes);
|
||||
|
||||
expect(StreamableHTTPServerTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enableJsonResponse: false,
|
||||
}),
|
||||
);
|
||||
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Test "no"
|
||||
const reqNo = createMockRequest({
|
||||
params: { group: 'test-group' },
|
||||
query: { stream: 'no' },
|
||||
body: { method: 'initialize' },
|
||||
});
|
||||
const resNo = createMockResponse();
|
||||
|
||||
await handleMcpPostRequest(reqNo, resNo);
|
||||
|
||||
expect(StreamableHTTPServerTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enableJsonResponse: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should default to streaming for invalid/unknown values', async () => {
|
||||
const req = createMockRequest({
|
||||
params: { group: 'test-group' },
|
||||
query: { stream: 'invalid-value' },
|
||||
body: {
|
||||
method: 'initialize',
|
||||
},
|
||||
});
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleMcpPostRequest(req, res);
|
||||
|
||||
// Should default to streaming (enableJsonResponse: false)
|
||||
expect(StreamableHTTPServerTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enableJsonResponse: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,10 +6,10 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
|
||||
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { deleteMcpServer, getMcpServer } from './mcpService.js';
|
||||
import config from '../config/index.js';
|
||||
import { getSystemConfigDao } from '../dao/index.js';
|
||||
import { getBearerKeyDao, getGroupDao, getServerDao, getSystemConfigDao } from '../dao/index.js';
|
||||
import { UserContextService } from './userContextService.js';
|
||||
import { RequestContextService } from './requestContextService.js';
|
||||
import { IUser } from '../types/index.js';
|
||||
import { IUser, BearerKey } from '../types/index.js';
|
||||
import { resolveOAuthUserFromToken } from '../utils/oauthBearer.js';
|
||||
|
||||
export const transports: {
|
||||
@@ -30,40 +30,164 @@ type BearerAuthResult =
|
||||
reason: 'missing' | 'invalid';
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a string is a valid UUID v4 format
|
||||
*/
|
||||
const isValidUUID = (str: string): boolean => {
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
return uuidRegex.test(str);
|
||||
};
|
||||
|
||||
const isBearerKeyAllowedForRequest = async (req: Request, key: BearerKey): Promise<boolean> => {
|
||||
const paramValue = (req.params as any)?.group as string | undefined;
|
||||
|
||||
// accessType 'all' allows all requests
|
||||
if (key.accessType === 'all') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// No parameter value means global route
|
||||
if (!paramValue) {
|
||||
// Only accessType 'all' allows global routes
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const groupDao = getGroupDao();
|
||||
const serverDao = getServerDao();
|
||||
|
||||
// Step 1: Try to match as a group (by name or id), since group has higher priority
|
||||
let matchedGroup = await groupDao.findByName(paramValue);
|
||||
if (!matchedGroup && isValidUUID(paramValue)) {
|
||||
// Only try findById if the parameter is a valid UUID
|
||||
matchedGroup = await groupDao.findById(paramValue);
|
||||
}
|
||||
|
||||
if (matchedGroup) {
|
||||
// Matched as a group
|
||||
if (key.accessType === 'groups') {
|
||||
// For group-scoped keys, check if the matched group is in allowedGroups
|
||||
const allowedGroups = key.allowedGroups || [];
|
||||
return allowedGroups.includes(matchedGroup.name) || allowedGroups.includes(matchedGroup.id);
|
||||
}
|
||||
|
||||
if (key.accessType === 'servers') {
|
||||
// For server-scoped keys, check if any server in the group is allowed
|
||||
const allowedServers = key.allowedServers || [];
|
||||
if (allowedServers.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Array.isArray(matchedGroup.servers)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const groupServerNames = matchedGroup.servers.map((server) =>
|
||||
typeof server === 'string' ? server : server.name,
|
||||
);
|
||||
return groupServerNames.some((name) => allowedServers.includes(name));
|
||||
}
|
||||
|
||||
// Unknown accessType with matched group
|
||||
return false;
|
||||
}
|
||||
|
||||
// Step 2: Not a group, try to match as a server name
|
||||
const matchedServer = await serverDao.findById(paramValue);
|
||||
|
||||
if (matchedServer) {
|
||||
// Matched as a server
|
||||
if (key.accessType === 'groups') {
|
||||
// For group-scoped keys, server access is not allowed
|
||||
return false;
|
||||
}
|
||||
|
||||
if (key.accessType === 'servers') {
|
||||
// For server-scoped keys, check if the server is in allowedServers
|
||||
const allowedServers = key.allowedServers || [];
|
||||
return allowedServers.includes(matchedServer.name);
|
||||
}
|
||||
|
||||
// Unknown accessType with matched server
|
||||
return false;
|
||||
}
|
||||
|
||||
// Step 3: Not a valid group or server, deny access
|
||||
console.warn(
|
||||
`Bearer key access denied: parameter '${paramValue}' does not match any group or server`,
|
||||
);
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Error checking bearer key request access:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const validateBearerAuth = async (req: Request): Promise<BearerAuthResult> => {
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
const routingConfig = systemConfig?.routing || {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
};
|
||||
const bearerKeyDao = getBearerKeyDao();
|
||||
const enabledKeys = await bearerKeyDao.findEnabled();
|
||||
|
||||
if (routingConfig.enableBearerAuth) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return { valid: false, reason: 'missing' };
|
||||
const authHeader = req.headers.authorization;
|
||||
const hasBearerHeader = !!authHeader && authHeader.startsWith('Bearer ');
|
||||
|
||||
// If no enabled keys are configured, bearer auth is effectively disabled.
|
||||
// We still allow OAuth bearer tokens to attach user context in this case.
|
||||
if (enabledKeys.length === 0) {
|
||||
if (!hasBearerHeader) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7); // Remove "Bearer " prefix
|
||||
if (token.trim().length === 0) {
|
||||
return { valid: false, reason: 'missing' };
|
||||
}
|
||||
|
||||
if (token === routingConfig.bearerAuthKey) {
|
||||
const token = authHeader!.substring(7).trim();
|
||||
if (!token) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
const oauthUser = await resolveOAuthUserFromToken(token);
|
||||
if (oauthUser) {
|
||||
console.log('Authenticated request using OAuth bearer token without configured keys');
|
||||
return { valid: true, user: oauthUser };
|
||||
}
|
||||
|
||||
return { valid: false, reason: 'invalid' };
|
||||
// When there are no keys, a non-OAuth bearer token should not block access
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
// When keys exist, bearer header is required
|
||||
if (!hasBearerHeader) {
|
||||
return { valid: false, reason: 'missing' };
|
||||
}
|
||||
|
||||
const token = authHeader!.substring(7).trim();
|
||||
if (!token) {
|
||||
return { valid: false, reason: 'missing' };
|
||||
}
|
||||
|
||||
// First, try to match a configured bearer key
|
||||
const matchingKey = enabledKeys.find((key) => key.token === token);
|
||||
if (matchingKey) {
|
||||
const allowed = await isBearerKeyAllowedForRequest(req, matchingKey);
|
||||
if (!allowed) {
|
||||
console.warn(
|
||||
`Bearer key rejected due to scope restrictions: id=${matchingKey.id}, name=${matchingKey.name}, accessType=${matchingKey.accessType}`,
|
||||
);
|
||||
return { valid: false, reason: 'invalid' };
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Bearer key authenticated: id=${matchingKey.id}, name=${matchingKey.name}, accessType=${matchingKey.accessType}`,
|
||||
);
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// Fallback: treat token as potential OAuth access token
|
||||
const oauthUser = await resolveOAuthUserFromToken(token);
|
||||
if (oauthUser) {
|
||||
console.log('Authenticated request using OAuth bearer token (no matching static key)');
|
||||
return { valid: true, user: oauthUser };
|
||||
}
|
||||
|
||||
console.warn('Bearer authentication failed: token did not match any key or OAuth user');
|
||||
return { valid: false, reason: 'invalid' };
|
||||
};
|
||||
|
||||
const attachUserContextFromBearer = (result: BearerAuthResult, res: Response): void => {
|
||||
@@ -284,9 +408,10 @@ async function createSessionWithId(
|
||||
sessionId: string,
|
||||
group: string,
|
||||
username?: string,
|
||||
enableJsonResponse?: boolean,
|
||||
): Promise<StreamableHTTPServerTransport> {
|
||||
console.log(
|
||||
`[SESSION REBUILD] Starting session rebuild for ID: ${sessionId}${username ? ` for user: ${username}` : ''}`,
|
||||
`[SESSION REBUILD] Starting session rebuild for ID: ${sessionId}${username ? ` for user: ${username}` : ''} with enableJsonResponse: ${enableJsonResponse}`,
|
||||
);
|
||||
|
||||
// Create a new server instance to ensure clean state
|
||||
@@ -294,6 +419,7 @@ async function createSessionWithId(
|
||||
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => sessionId, // Use the specified sessionId
|
||||
enableJsonResponse: enableJsonResponse ?? false,
|
||||
onsessioninitialized: (initializedSessionId) => {
|
||||
console.log(
|
||||
`[SESSION REBUILD] onsessioninitialized triggered for ID: ${initializedSessionId}`,
|
||||
@@ -345,14 +471,16 @@ async function createSessionWithId(
|
||||
async function createNewSession(
|
||||
group: string,
|
||||
username?: string,
|
||||
enableJsonResponse?: boolean,
|
||||
): Promise<StreamableHTTPServerTransport> {
|
||||
const newSessionId = randomUUID();
|
||||
console.log(
|
||||
`[SESSION NEW] Creating new session with ID: ${newSessionId}${username ? ` for user: ${username}` : ''}`,
|
||||
`[SESSION NEW] Creating new session with ID: ${newSessionId}${username ? ` for user: ${username}` : ''} with enableJsonResponse: ${enableJsonResponse}`,
|
||||
);
|
||||
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => newSessionId,
|
||||
enableJsonResponse: enableJsonResponse ?? false,
|
||||
onsessioninitialized: (sessionId) => {
|
||||
transports[sessionId] = { transport, group };
|
||||
console.log(
|
||||
@@ -391,16 +519,56 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
const group = req.params.group;
|
||||
const body = req.body;
|
||||
|
||||
// Parse stream parameter from query string or request body
|
||||
// Default to true (SSE streaming) for backward compatibility
|
||||
let enableStreaming = true;
|
||||
|
||||
// Helper function to parse stream parameter value
|
||||
const parseStreamParam = (value: any): boolean => {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const lowerValue = value.toLowerCase().trim();
|
||||
// Accept 'true', '1', 'yes', 'on' as truthy
|
||||
if (['true', '1', 'yes', 'on'].includes(lowerValue)) {
|
||||
return true;
|
||||
}
|
||||
// Accept 'false', '0', 'no', 'off' as falsy
|
||||
if (['false', '0', 'no', 'off'].includes(lowerValue)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value !== 0;
|
||||
}
|
||||
// Default to true for any other value (including undefined)
|
||||
return true;
|
||||
};
|
||||
|
||||
// Check query parameter first
|
||||
if (req.query.stream !== undefined) {
|
||||
enableStreaming = parseStreamParam(req.query.stream);
|
||||
}
|
||||
// Then check request body (has higher priority)
|
||||
if (body && typeof body === 'object' && 'stream' in body) {
|
||||
enableStreaming = parseStreamParam(body.stream);
|
||||
}
|
||||
|
||||
// enableJsonResponse is the inverse of enableStreaming
|
||||
const enableJsonResponse = !enableStreaming;
|
||||
|
||||
console.log(
|
||||
`Handling MCP post request for sessionId: ${sessionId} and group: ${group}${username ? ` for user: ${username}` : ''} with body: ${JSON.stringify(body)}`,
|
||||
`Handling MCP post request for sessionId: ${sessionId} and group: ${group}${username ? ` for user: ${username}` : ''} with enableStreaming: ${enableStreaming}`,
|
||||
);
|
||||
|
||||
// Get filtered settings based on user context (after setting user context)
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
const routingConfig = systemConfig?.routing || {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
const routingConfig = {
|
||||
enableGlobalRoute: systemConfig?.routing?.enableGlobalRoute ?? true,
|
||||
enableGroupNameRoute: systemConfig?.routing?.enableGroupNameRoute ?? true,
|
||||
};
|
||||
if (!group && !routingConfig.enableGlobalRoute) {
|
||||
res.status(403).send('Global routes are disabled. Please specify a group ID.');
|
||||
@@ -435,7 +603,7 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
|
||||
);
|
||||
transport = await sessionCreationLocks[sessionId];
|
||||
} else {
|
||||
sessionCreationLocks[sessionId] = createSessionWithId(sessionId, group, username);
|
||||
sessionCreationLocks[sessionId] = createSessionWithId(sessionId, group, username, enableJsonResponse);
|
||||
try {
|
||||
transport = await sessionCreationLocks[sessionId];
|
||||
console.log(
|
||||
@@ -472,7 +640,7 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
|
||||
console.log(
|
||||
`[SESSION CREATE] No session ID provided for initialize request, creating new session${username ? ` for user: ${username}` : ''}`,
|
||||
);
|
||||
transport = await createNewSession(group, username);
|
||||
transport = await createNewSession(group, username, enableJsonResponse);
|
||||
} else {
|
||||
// Case 4: No sessionId and not an initialize request, return error
|
||||
console.warn(
|
||||
|
||||
@@ -6,8 +6,8 @@ import { getSmartRoutingConfig } from '../utils/smartRouting.js';
|
||||
import OpenAI from 'openai';
|
||||
|
||||
// Get OpenAI configuration from smartRouting settings or fallback to environment variables
|
||||
const getOpenAIConfig = () => {
|
||||
const smartRoutingConfig = getSmartRoutingConfig();
|
||||
const getOpenAIConfig = async () => {
|
||||
const smartRoutingConfig = await getSmartRoutingConfig();
|
||||
return {
|
||||
apiKey: smartRoutingConfig.openaiApiKey,
|
||||
baseURL: smartRoutingConfig.openaiApiBaseUrl,
|
||||
@@ -34,8 +34,8 @@ const getDimensionsForModel = (model: string): number => {
|
||||
};
|
||||
|
||||
// Initialize the OpenAI client with smartRouting configuration
|
||||
const getOpenAIClient = () => {
|
||||
const config = getOpenAIConfig();
|
||||
const getOpenAIClient = async () => {
|
||||
const config = await getOpenAIConfig();
|
||||
return new OpenAI({
|
||||
apiKey: config.apiKey, // Get API key from smartRouting settings or environment variables
|
||||
baseURL: config.baseURL, // Get base URL from smartRouting settings or fallback to default
|
||||
@@ -53,32 +53,26 @@ const getOpenAIClient = () => {
|
||||
* @returns Promise with vector embedding as number array
|
||||
*/
|
||||
async function generateEmbedding(text: string): Promise<number[]> {
|
||||
try {
|
||||
const config = getOpenAIConfig();
|
||||
const openai = getOpenAIClient();
|
||||
const config = await getOpenAIConfig();
|
||||
const openai = await getOpenAIClient();
|
||||
|
||||
// Check if API key is configured
|
||||
if (!openai.apiKey) {
|
||||
console.warn('OpenAI API key is not configured. Using fallback embedding method.');
|
||||
return generateFallbackEmbedding(text);
|
||||
}
|
||||
|
||||
// Truncate text if it's too long (OpenAI has token limits)
|
||||
const truncatedText = text.length > 8000 ? text.substring(0, 8000) : text;
|
||||
|
||||
// Call OpenAI's embeddings API
|
||||
const response = await openai.embeddings.create({
|
||||
model: config.embeddingModel, // Modern model with better performance
|
||||
input: truncatedText,
|
||||
});
|
||||
|
||||
// Return the embedding
|
||||
return response.data[0].embedding;
|
||||
} catch (error) {
|
||||
console.error('Error generating embedding:', error);
|
||||
console.warn('Falling back to simple embedding method');
|
||||
// Check if API key is configured
|
||||
if (!openai.apiKey) {
|
||||
console.warn('OpenAI API key is not configured. Using fallback embedding method.');
|
||||
return generateFallbackEmbedding(text);
|
||||
}
|
||||
|
||||
// Truncate text if it's too long (OpenAI has token limits)
|
||||
const truncatedText = text.length > 8000 ? text.substring(0, 8000) : text;
|
||||
|
||||
// Call OpenAI's embeddings API
|
||||
const response = await openai.embeddings.create({
|
||||
model: config.embeddingModel, // Modern model with better performance
|
||||
input: truncatedText,
|
||||
});
|
||||
|
||||
// Return the embedding
|
||||
return response.data[0].embedding;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -198,12 +192,12 @@ export const saveToolsAsVectorEmbeddings = async (
|
||||
return;
|
||||
}
|
||||
|
||||
const smartRoutingConfig = getSmartRoutingConfig();
|
||||
const smartRoutingConfig = await getSmartRoutingConfig();
|
||||
if (!smartRoutingConfig.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getOpenAIConfig();
|
||||
const config = await getOpenAIConfig();
|
||||
const vectorRepository = getRepositoryFactory(
|
||||
'vectorEmbeddings',
|
||||
)() as VectorEmbeddingRepository;
|
||||
@@ -227,31 +221,26 @@ export const saveToolsAsVectorEmbeddings = async (
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
try {
|
||||
// Generate embedding
|
||||
const embedding = await generateEmbedding(searchableText);
|
||||
// Generate embedding
|
||||
const embedding = await generateEmbedding(searchableText);
|
||||
|
||||
// Check database compatibility before saving
|
||||
await checkDatabaseVectorDimensions(embedding.length);
|
||||
// Check database compatibility before saving
|
||||
await checkDatabaseVectorDimensions(embedding.length);
|
||||
|
||||
// Save embedding
|
||||
await vectorRepository.saveEmbedding(
|
||||
'tool',
|
||||
`${serverName}:${tool.name}`,
|
||||
searchableText,
|
||||
embedding,
|
||||
{
|
||||
serverName,
|
||||
toolName: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
},
|
||||
config.embeddingModel, // Store the model used for this embedding
|
||||
);
|
||||
} catch (toolError) {
|
||||
console.error(`Error processing tool ${tool.name} for server ${serverName}:`, toolError);
|
||||
// Continue with the next tool rather than failing the whole batch
|
||||
}
|
||||
// Save embedding
|
||||
await vectorRepository.saveEmbedding(
|
||||
'tool',
|
||||
`${serverName}:${tool.name}`,
|
||||
searchableText,
|
||||
embedding,
|
||||
{
|
||||
serverName,
|
||||
toolName: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
},
|
||||
config.embeddingModel, // Store the model used for this embedding
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Saved ${tools.length} tool embeddings for server: ${serverName}`);
|
||||
@@ -381,7 +370,7 @@ export const getAllVectorizedTools = async (
|
||||
}>
|
||||
> => {
|
||||
try {
|
||||
const config = getOpenAIConfig();
|
||||
const config = await getOpenAIConfig();
|
||||
const vectorRepository = getRepositoryFactory(
|
||||
'vectorEmbeddings',
|
||||
)() as VectorEmbeddingRepository;
|
||||
|
||||
@@ -243,6 +243,19 @@ export interface OAuthServerConfig {
|
||||
};
|
||||
}
|
||||
|
||||
// Bearer authentication key configuration
|
||||
export type BearerKeyAccessType = 'all' | 'groups' | 'servers';
|
||||
|
||||
export interface BearerKey {
|
||||
id: string; // Unique identifier for the key
|
||||
name: string; // Human readable key name
|
||||
token: string; // Bearer token value
|
||||
enabled: boolean; // Whether this key is enabled
|
||||
accessType: BearerKeyAccessType; // Access scope type
|
||||
allowedGroups?: string[]; // Allowed group names when accessType === 'groups'
|
||||
allowedServers?: string[]; // Allowed server names when accessType === 'servers'
|
||||
}
|
||||
|
||||
// Represents the settings for MCP servers
|
||||
export interface McpSettings {
|
||||
users?: IUser[]; // Array of user credentials and permissions
|
||||
@@ -254,6 +267,7 @@ export interface McpSettings {
|
||||
userConfigs?: Record<string, UserConfig>; // User-specific configurations
|
||||
oauthClients?: IOAuthClient[]; // OAuth clients for MCPHub's authorization server
|
||||
oauthTokens?: IOAuthToken[]; // Persisted OAuth tokens (access + refresh) for authorization server
|
||||
bearerKeys?: BearerKey[]; // Bearer authentication keys (multi-key configuration)
|
||||
}
|
||||
|
||||
// Configuration details for an individual server
|
||||
@@ -420,3 +434,50 @@ export interface AddServerRequest {
|
||||
name: string; // Name of the server to add
|
||||
config: ServerConfig; // Configuration details for the server
|
||||
}
|
||||
|
||||
// Request payload for batch creating servers
|
||||
export interface BatchCreateServersRequest {
|
||||
servers: AddServerRequest[]; // Array of servers to create
|
||||
}
|
||||
|
||||
// Result for a single server in batch operation
|
||||
export interface BatchServerResult {
|
||||
name: string; // Server name
|
||||
success: boolean; // Whether the operation succeeded
|
||||
message?: string; // Error message if failed
|
||||
}
|
||||
|
||||
// Response for batch create servers operation
|
||||
export interface BatchCreateServersResponse {
|
||||
success: boolean; // Overall operation success (true if at least one server succeeded)
|
||||
successCount: number; // Number of servers successfully created
|
||||
failureCount: number; // Number of servers that failed
|
||||
results: BatchServerResult[]; // Detailed results for each server
|
||||
}
|
||||
|
||||
// Request payload for adding a new group
|
||||
export interface AddGroupRequest {
|
||||
name: string; // Name of the group to add
|
||||
description?: string; // Optional description of the group
|
||||
servers?: string[] | IGroupServerConfig[]; // Array of server names or server configurations
|
||||
}
|
||||
|
||||
// Request payload for batch creating groups
|
||||
export interface BatchCreateGroupsRequest {
|
||||
groups: AddGroupRequest[]; // Array of groups to create
|
||||
}
|
||||
|
||||
// Result for a single group in batch operation
|
||||
export interface BatchGroupResult {
|
||||
name: string; // Group name
|
||||
success: boolean; // Whether the operation succeeded
|
||||
message?: string; // Error message if failed
|
||||
}
|
||||
|
||||
// Response for batch create groups operation
|
||||
export interface BatchCreateGroupsResponse {
|
||||
success: boolean; // Overall operation success (true if at least one group succeeded)
|
||||
successCount: number; // Number of groups successfully created
|
||||
failureCount: number; // Number of groups that failed
|
||||
results: BatchGroupResult[]; // Detailed results for each group
|
||||
}
|
||||
|
||||
122
src/utils/migration.test.ts
Normal file
122
src/utils/migration.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { jest } from '@jest/globals';
|
||||
|
||||
// Mocks must be defined before importing the module under test.
|
||||
|
||||
const initializeDatabaseMock = jest.fn(async () => undefined);
|
||||
jest.mock('../db/connection.js', () => ({
|
||||
initializeDatabase: initializeDatabaseMock,
|
||||
}));
|
||||
|
||||
const setDaoFactoryMock = jest.fn();
|
||||
jest.mock('../dao/DaoFactory.js', () => ({
|
||||
setDaoFactory: setDaoFactoryMock,
|
||||
}));
|
||||
|
||||
jest.mock('../dao/DatabaseDaoFactory.js', () => ({
|
||||
DatabaseDaoFactory: {
|
||||
getInstance: jest.fn(() => ({
|
||||
/* noop */
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
const loadOriginalSettingsMock = jest.fn(() => ({ users: [] }));
|
||||
jest.mock('../config/index.js', () => ({
|
||||
loadOriginalSettings: loadOriginalSettingsMock,
|
||||
}));
|
||||
|
||||
const userRepoCountMock = jest.fn<() => Promise<number>>();
|
||||
jest.mock('../db/repositories/UserRepository.js', () => ({
|
||||
UserRepository: jest.fn().mockImplementation(() => ({
|
||||
count: userRepoCountMock,
|
||||
})),
|
||||
}));
|
||||
|
||||
const bearerKeyCountMock = jest.fn<() => Promise<number>>();
|
||||
const bearerKeyCreateMock =
|
||||
jest.fn<
|
||||
(data: {
|
||||
name: string;
|
||||
token: string;
|
||||
enabled: boolean;
|
||||
accessType: string;
|
||||
allowedGroups: string[];
|
||||
allowedServers: string[];
|
||||
}) => Promise<unknown>
|
||||
>();
|
||||
jest.mock('../db/repositories/BearerKeyRepository.js', () => ({
|
||||
BearerKeyRepository: jest.fn().mockImplementation(() => ({
|
||||
count: bearerKeyCountMock,
|
||||
create: bearerKeyCreateMock,
|
||||
})),
|
||||
}));
|
||||
|
||||
const systemConfigGetMock = jest.fn<() => Promise<any>>();
|
||||
jest.mock('../db/repositories/SystemConfigRepository.js', () => ({
|
||||
SystemConfigRepository: jest.fn().mockImplementation(() => ({
|
||||
get: systemConfigGetMock,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('initializeDatabaseMode legacy bearer auth migration', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('skips legacy migration when bearerKeys table already has data', async () => {
|
||||
userRepoCountMock.mockResolvedValue(1);
|
||||
bearerKeyCountMock.mockResolvedValue(2);
|
||||
systemConfigGetMock.mockResolvedValue({
|
||||
routing: { enableBearerAuth: true, bearerAuthKey: 'db-key' },
|
||||
});
|
||||
|
||||
const { initializeDatabaseMode } = await import('./migration.js');
|
||||
const ok = await initializeDatabaseMode();
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(initializeDatabaseMock).toHaveBeenCalled();
|
||||
expect(loadOriginalSettingsMock).not.toHaveBeenCalled();
|
||||
expect(systemConfigGetMock).not.toHaveBeenCalled();
|
||||
expect(bearerKeyCreateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('migrates legacy routing bearerAuthKey into bearerKeys when users exist and keys table is empty', async () => {
|
||||
userRepoCountMock.mockResolvedValue(3);
|
||||
bearerKeyCountMock.mockResolvedValue(0);
|
||||
systemConfigGetMock.mockResolvedValue({
|
||||
routing: { enableBearerAuth: true, bearerAuthKey: 'db-key' },
|
||||
});
|
||||
|
||||
const { initializeDatabaseMode } = await import('./migration.js');
|
||||
const ok = await initializeDatabaseMode();
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(loadOriginalSettingsMock).not.toHaveBeenCalled();
|
||||
expect(systemConfigGetMock).toHaveBeenCalledTimes(1);
|
||||
expect(bearerKeyCreateMock).toHaveBeenCalledTimes(1);
|
||||
expect(bearerKeyCreateMock).toHaveBeenCalledWith({
|
||||
name: 'default',
|
||||
token: 'db-key',
|
||||
enabled: true,
|
||||
accessType: 'all',
|
||||
allowedGroups: [],
|
||||
allowedServers: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('does not migrate when routing has no bearerAuthKey', async () => {
|
||||
userRepoCountMock.mockResolvedValue(1);
|
||||
bearerKeyCountMock.mockResolvedValue(0);
|
||||
systemConfigGetMock.mockResolvedValue({
|
||||
routing: { enableBearerAuth: true, bearerAuthKey: ' ' },
|
||||
});
|
||||
|
||||
const { initializeDatabaseMode } = await import('./migration.js');
|
||||
const ok = await initializeDatabaseMode();
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(loadOriginalSettingsMock).not.toHaveBeenCalled();
|
||||
expect(systemConfigGetMock).toHaveBeenCalledTimes(1);
|
||||
expect(bearerKeyCreateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { SystemConfigRepository } from '../db/repositories/SystemConfigRepositor
|
||||
import { UserConfigRepository } from '../db/repositories/UserConfigRepository.js';
|
||||
import { OAuthClientRepository } from '../db/repositories/OAuthClientRepository.js';
|
||||
import { OAuthTokenRepository } from '../db/repositories/OAuthTokenRepository.js';
|
||||
import { BearerKeyRepository } from '../db/repositories/BearerKeyRepository.js';
|
||||
|
||||
/**
|
||||
* Migrate from file-based configuration to database
|
||||
@@ -33,6 +34,7 @@ export async function migrateToDatabase(): Promise<boolean> {
|
||||
const userConfigRepo = new UserConfigRepository();
|
||||
const oauthClientRepo = new OAuthClientRepository();
|
||||
const oauthTokenRepo = new OAuthTokenRepository();
|
||||
const bearerKeyRepo = new BearerKeyRepository();
|
||||
|
||||
// Migrate users
|
||||
if (settings.users && settings.users.length > 0) {
|
||||
@@ -75,6 +77,7 @@ export async function migrateToDatabase(): Promise<boolean> {
|
||||
prompts: config.prompts,
|
||||
options: config.options,
|
||||
oauth: config.oauth,
|
||||
openapi: config.openapi,
|
||||
});
|
||||
console.log(` - Created server: ${name}`);
|
||||
} else {
|
||||
@@ -119,6 +122,52 @@ export async function migrateToDatabase(): Promise<boolean> {
|
||||
console.log(' - System configuration updated');
|
||||
}
|
||||
|
||||
// Migrate bearer auth keys
|
||||
console.log('Migrating bearer authentication keys...');
|
||||
|
||||
// Prefer explicit bearerKeys if present in settings
|
||||
if (Array.isArray(settings.bearerKeys) && settings.bearerKeys.length > 0) {
|
||||
for (const key of settings.bearerKeys) {
|
||||
await bearerKeyRepo.create({
|
||||
name: key.name,
|
||||
token: key.token,
|
||||
enabled: key.enabled,
|
||||
accessType: key.accessType,
|
||||
allowedGroups: key.allowedGroups ?? [],
|
||||
allowedServers: key.allowedServers ?? [],
|
||||
} as any);
|
||||
console.log(` - Migrated bearer key: ${key.name} (${key.id ?? 'no-id'})`);
|
||||
}
|
||||
} else if (settings.systemConfig?.routing) {
|
||||
// Fallback to legacy routing.enableBearerAuth / bearerAuthKey
|
||||
const routing = settings.systemConfig.routing as any;
|
||||
const enableBearerAuth: boolean = !!routing.enableBearerAuth;
|
||||
const rawKey: string = (routing.bearerAuthKey || '').trim();
|
||||
|
||||
// Migration rules:
|
||||
// 1) enable=false, key empty -> no keys
|
||||
// 2) enable=false, key present -> one disabled key (name=default)
|
||||
// 3) enable=true, key present -> one enabled key (name=default)
|
||||
// 4) enable=true, key empty -> no keys
|
||||
if (rawKey) {
|
||||
await bearerKeyRepo.create({
|
||||
name: 'default',
|
||||
token: rawKey,
|
||||
enabled: enableBearerAuth,
|
||||
accessType: 'all',
|
||||
allowedGroups: [],
|
||||
allowedServers: [],
|
||||
} as any);
|
||||
console.log(
|
||||
` - Migrated legacy bearer auth config to key: default (enabled=${enableBearerAuth})`,
|
||||
);
|
||||
} else {
|
||||
console.log(' - No legacy bearer auth key found, skipping bearer key migration');
|
||||
}
|
||||
} else {
|
||||
console.log(' - No bearer auth configuration found, skipping bearer key migration');
|
||||
}
|
||||
|
||||
// Migrate user configs
|
||||
if (settings.userConfigs) {
|
||||
const usernames = Object.keys(settings.userConfigs);
|
||||
@@ -206,6 +255,9 @@ export async function initializeDatabaseMode(): Promise<boolean> {
|
||||
|
||||
// Check if migration is needed
|
||||
const userRepo = new UserRepository();
|
||||
const bearerKeyRepo = new BearerKeyRepository();
|
||||
const systemConfigRepo = new SystemConfigRepository();
|
||||
|
||||
const userCount = await userRepo.count();
|
||||
|
||||
if (userCount === 0) {
|
||||
@@ -216,6 +268,36 @@ export async function initializeDatabaseMode(): Promise<boolean> {
|
||||
}
|
||||
} else {
|
||||
console.log(`Database already contains ${userCount} users, skipping migration`);
|
||||
|
||||
// One-time migration for legacy bearer auth config stored inside DB routing settings.
|
||||
// If bearerKeys table already has data, do nothing.
|
||||
const bearerKeyCount = await bearerKeyRepo.count();
|
||||
if (bearerKeyCount > 0) {
|
||||
console.log(
|
||||
`Bearer keys table already contains ${bearerKeyCount} keys, skipping legacy bearer auth migration`,
|
||||
);
|
||||
} else {
|
||||
const systemConfig = await systemConfigRepo.get();
|
||||
const routing = (systemConfig as any)?.routing || {};
|
||||
const enableBearerAuth: boolean = !!routing.enableBearerAuth;
|
||||
const rawKey: string = (routing.bearerAuthKey || '').trim();
|
||||
|
||||
if (rawKey) {
|
||||
await bearerKeyRepo.create({
|
||||
name: 'default',
|
||||
token: rawKey,
|
||||
enabled: enableBearerAuth,
|
||||
accessType: 'all',
|
||||
allowedGroups: [],
|
||||
allowedServers: [],
|
||||
} as any);
|
||||
console.log(
|
||||
` - Migrated legacy DB routing bearer auth config to key: default (enabled=${enableBearerAuth})`,
|
||||
);
|
||||
} else {
|
||||
console.log('No legacy DB routing bearer auth key found, skipping bearer key migration');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ Database mode initialized successfully');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { loadSettings, expandEnvVars } from '../config/index.js';
|
||||
import { expandEnvVars } from '../config/index.js';
|
||||
import { getSystemConfigDao } from '../dao/DaoFactory.js';
|
||||
|
||||
/**
|
||||
* Smart routing configuration interface
|
||||
@@ -22,10 +23,11 @@ export interface SmartRoutingConfig {
|
||||
*
|
||||
* @returns {SmartRoutingConfig} Complete smart routing configuration
|
||||
*/
|
||||
export function getSmartRoutingConfig(): SmartRoutingConfig {
|
||||
const settings = loadSettings();
|
||||
const smartRoutingSettings: Partial<SmartRoutingConfig> =
|
||||
settings.systemConfig?.smartRouting || {};
|
||||
export async function getSmartRoutingConfig(): Promise<SmartRoutingConfig> {
|
||||
// Get system config from DAO
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
const systemConfig = await systemConfigDao.get();
|
||||
const smartRoutingSettings: Partial<SmartRoutingConfig> = systemConfig.smartRouting || {};
|
||||
|
||||
return {
|
||||
// Enabled status - check multiple environment variables
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { getMcpSettingsJson } from '../../src/controllers/configController.js';
|
||||
import * as config from '../../src/config/index.js';
|
||||
import * as DaoFactory from '../../src/dao/DaoFactory.js';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
// Mock the config module
|
||||
jest.mock('../../src/config/index.js');
|
||||
// Mock the DaoFactory module
|
||||
jest.mock('../../src/dao/DaoFactory.js');
|
||||
|
||||
describe('ConfigController - getMcpSettingsJson', () => {
|
||||
@@ -13,9 +9,18 @@ describe('ConfigController - getMcpSettingsJson', () => {
|
||||
let mockResponse: Partial<Response>;
|
||||
let mockJson: jest.Mock;
|
||||
let mockStatus: jest.Mock;
|
||||
let mockServerDao: { findById: jest.Mock };
|
||||
let mockServerDao: { findById: jest.Mock; findAll: jest.Mock };
|
||||
let mockUserDao: { findAll: jest.Mock };
|
||||
let mockGroupDao: { findAll: jest.Mock };
|
||||
let mockSystemConfigDao: { get: jest.Mock };
|
||||
let mockUserConfigDao: { getAll: jest.Mock };
|
||||
let mockOAuthClientDao: { findAll: jest.Mock };
|
||||
let mockOAuthTokenDao: { findAll: jest.Mock };
|
||||
let mockBearerKeyDao: { findAll: jest.Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockJson = jest.fn();
|
||||
mockStatus = jest.fn().mockReturnThis();
|
||||
mockRequest = {
|
||||
@@ -25,40 +30,28 @@ describe('ConfigController - getMcpSettingsJson', () => {
|
||||
json: mockJson,
|
||||
status: mockStatus,
|
||||
};
|
||||
|
||||
mockServerDao = {
|
||||
findById: jest.fn(),
|
||||
findAll: jest.fn(),
|
||||
};
|
||||
mockUserDao = { findAll: jest.fn() };
|
||||
mockGroupDao = { findAll: jest.fn() };
|
||||
mockSystemConfigDao = { get: jest.fn() };
|
||||
mockUserConfigDao = { getAll: jest.fn() };
|
||||
mockOAuthClientDao = { findAll: jest.fn() };
|
||||
mockOAuthTokenDao = { findAll: jest.fn() };
|
||||
mockBearerKeyDao = { findAll: jest.fn() };
|
||||
|
||||
// Setup ServerDao mock
|
||||
(DaoFactory.getServerDao as jest.Mock).mockReturnValue(mockServerDao);
|
||||
|
||||
// Reset mocks
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Full Settings Export', () => {
|
||||
it('should handle settings without users array', async () => {
|
||||
const mockSettings = {
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
command: 'test',
|
||||
args: ['--test'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
(config.loadOriginalSettings as jest.Mock).mockReturnValue(mockSettings);
|
||||
|
||||
await getMcpSettingsJson(mockRequest as Request, mockResponse as Response);
|
||||
|
||||
expect(mockJson).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
data: {
|
||||
mcpServers: mockSettings.mcpServers,
|
||||
users: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
// Wire DaoFactory convenience functions to our mocks
|
||||
(DaoFactory.getServerDao as unknown as jest.Mock).mockReturnValue(mockServerDao);
|
||||
(DaoFactory.getUserDao as unknown as jest.Mock).mockReturnValue(mockUserDao);
|
||||
(DaoFactory.getGroupDao as unknown as jest.Mock).mockReturnValue(mockGroupDao);
|
||||
(DaoFactory.getSystemConfigDao as unknown as jest.Mock).mockReturnValue(mockSystemConfigDao);
|
||||
(DaoFactory.getUserConfigDao as unknown as jest.Mock).mockReturnValue(mockUserConfigDao);
|
||||
(DaoFactory.getOAuthClientDao as unknown as jest.Mock).mockReturnValue(mockOAuthClientDao);
|
||||
(DaoFactory.getOAuthTokenDao as unknown as jest.Mock).mockReturnValue(mockOAuthTokenDao);
|
||||
(DaoFactory.getBearerKeyDao as unknown as jest.Mock).mockReturnValue(mockBearerKeyDao);
|
||||
});
|
||||
|
||||
describe('Individual Server Export', () => {
|
||||
@@ -146,10 +139,14 @@ describe('ConfigController - getMcpSettingsJson', () => {
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle errors gracefully and return 500', async () => {
|
||||
const errorMessage = 'Failed to load settings';
|
||||
(config.loadOriginalSettings as jest.Mock).mockImplementation(() => {
|
||||
throw new Error(errorMessage);
|
||||
});
|
||||
mockServerDao.findAll.mockRejectedValue(new Error('boom'));
|
||||
mockUserDao.findAll.mockResolvedValue([]);
|
||||
mockGroupDao.findAll.mockResolvedValue([]);
|
||||
mockSystemConfigDao.get.mockResolvedValue({});
|
||||
mockUserConfigDao.getAll.mockResolvedValue({});
|
||||
mockOAuthClientDao.findAll.mockResolvedValue([]);
|
||||
mockOAuthTokenDao.findAll.mockResolvedValue([]);
|
||||
mockBearerKeyDao.findAll.mockResolvedValue([]);
|
||||
|
||||
await getMcpSettingsJson(mockRequest as Request, mockResponse as Response);
|
||||
|
||||
|
||||
97
tests/dao/bearerKeyDao.test.ts
Normal file
97
tests/dao/bearerKeyDao.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { BearerKeyDaoImpl } from '../../src/dao/BearerKeyDao.js';
|
||||
|
||||
const writeSettings = (settingsPath: string, settings: unknown): void => {
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
|
||||
};
|
||||
|
||||
describe('BearerKeyDaoImpl migration + settings caching behavior', () => {
|
||||
let tmpDir: string;
|
||||
let settingsPath: string;
|
||||
let originalSettingsEnv: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcphub-bearer-keys-'));
|
||||
settingsPath = path.join(tmpDir, 'mcp_settings.json');
|
||||
|
||||
originalSettingsEnv = process.env.MCPHUB_SETTING_PATH;
|
||||
process.env.MCPHUB_SETTING_PATH = settingsPath;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalSettingsEnv === undefined) {
|
||||
delete process.env.MCPHUB_SETTING_PATH;
|
||||
} else {
|
||||
process.env.MCPHUB_SETTING_PATH = originalSettingsEnv;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
it('does not rewrite settings when bearerKeys exists as an empty array', async () => {
|
||||
writeSettings(settingsPath, {
|
||||
mcpServers: {},
|
||||
users: [],
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
},
|
||||
},
|
||||
bearerKeys: [],
|
||||
});
|
||||
|
||||
const writeSpy = jest.spyOn(fs, 'writeFileSync');
|
||||
|
||||
const dao = new BearerKeyDaoImpl();
|
||||
const enabled1 = await dao.findEnabled();
|
||||
const enabled2 = await dao.findEnabled();
|
||||
|
||||
expect(enabled1).toEqual([]);
|
||||
expect(enabled2).toEqual([]);
|
||||
|
||||
// The DAO should NOT persist anything because bearerKeys already exists.
|
||||
expect(writeSpy).not.toHaveBeenCalled();
|
||||
|
||||
writeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('migrates legacy bearerAuthKey only once', async () => {
|
||||
writeSettings(settingsPath, {
|
||||
mcpServers: {},
|
||||
users: [],
|
||||
systemConfig: {
|
||||
routing: {
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: 'legacy-token',
|
||||
},
|
||||
},
|
||||
// bearerKeys is intentionally missing to trigger migration
|
||||
});
|
||||
|
||||
const writeSpy = jest.spyOn(fs, 'writeFileSync');
|
||||
|
||||
const dao = new BearerKeyDaoImpl();
|
||||
|
||||
const enabled1 = await dao.findEnabled();
|
||||
expect(enabled1).toHaveLength(1);
|
||||
expect(enabled1[0].token).toBe('legacy-token');
|
||||
expect(enabled1[0].enabled).toBe(true);
|
||||
|
||||
const enabled2 = await dao.findEnabled();
|
||||
expect(enabled2).toHaveLength(1);
|
||||
expect(enabled2[0].token).toBe('legacy-token');
|
||||
|
||||
// One write for the migration, no further writes on subsequent reads.
|
||||
expect(writeSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
writeSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
152
tests/integration/stream-parameter.test.ts
Normal file
152
tests/integration/stream-parameter.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Integration test for stream parameter support
|
||||
* This test demonstrates the usage of stream parameter in MCP requests
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from '@jest/globals';
|
||||
|
||||
describe('Stream Parameter Integration Test', () => {
|
||||
it('should demonstrate stream parameter usage', () => {
|
||||
// Example 1: Using stream=false in query parameter
|
||||
const queryExample = {
|
||||
url: '/mcp?stream=false',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
},
|
||||
body: {
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2025-03-26',
|
||||
capabilities: {},
|
||||
clientInfo: {
|
||||
name: 'TestClient',
|
||||
version: '1.0.0',
|
||||
},
|
||||
},
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
},
|
||||
};
|
||||
|
||||
expect(queryExample.url).toContain('stream=false');
|
||||
|
||||
// Example 2: Using stream parameter in request body
|
||||
const bodyExample = {
|
||||
url: '/mcp',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
},
|
||||
body: {
|
||||
method: 'initialize',
|
||||
stream: false, // Body parameter
|
||||
params: {
|
||||
protocolVersion: '2025-03-26',
|
||||
capabilities: {},
|
||||
clientInfo: {
|
||||
name: 'TestClient',
|
||||
version: '1.0.0',
|
||||
},
|
||||
},
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
},
|
||||
};
|
||||
|
||||
expect(bodyExample.body.stream).toBe(false);
|
||||
|
||||
// Example 3: Default behavior (streaming enabled)
|
||||
const defaultExample = {
|
||||
url: '/mcp',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, text/event-stream',
|
||||
},
|
||||
body: {
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2025-03-26',
|
||||
capabilities: {},
|
||||
clientInfo: {
|
||||
name: 'TestClient',
|
||||
version: '1.0.0',
|
||||
},
|
||||
},
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
},
|
||||
};
|
||||
|
||||
expect(defaultExample.body).not.toHaveProperty('stream');
|
||||
});
|
||||
|
||||
it('should show expected response formats', () => {
|
||||
// Expected response format for stream=false (JSON)
|
||||
const jsonResponse = {
|
||||
jsonrpc: '2.0',
|
||||
result: {
|
||||
protocolVersion: '2025-03-26',
|
||||
capabilities: {
|
||||
tools: {},
|
||||
prompts: {},
|
||||
},
|
||||
serverInfo: {
|
||||
name: 'MCPHub',
|
||||
version: '1.0.0',
|
||||
},
|
||||
},
|
||||
id: 1,
|
||||
};
|
||||
|
||||
expect(jsonResponse).toHaveProperty('jsonrpc');
|
||||
expect(jsonResponse).toHaveProperty('result');
|
||||
|
||||
// Expected response format for stream=true (SSE)
|
||||
const sseResponse = {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'mcp-session-id': '550e8400-e29b-41d4-a716-446655440000',
|
||||
},
|
||||
body: 'data: {"jsonrpc":"2.0","result":{...},"id":1}\n\n',
|
||||
};
|
||||
|
||||
expect(sseResponse.headers['Content-Type']).toBe('text/event-stream');
|
||||
expect(sseResponse.headers).toHaveProperty('mcp-session-id');
|
||||
});
|
||||
|
||||
it('should demonstrate all route variants', () => {
|
||||
const routes = [
|
||||
{ route: '/mcp?stream=false', description: 'Global route with non-streaming' },
|
||||
{ route: '/mcp/mygroup?stream=false', description: 'Group route with non-streaming' },
|
||||
{ route: '/mcp/myserver?stream=false', description: 'Server route with non-streaming' },
|
||||
{ route: '/mcp/$smart?stream=false', description: 'Smart routing with non-streaming' },
|
||||
];
|
||||
|
||||
routes.forEach((item) => {
|
||||
expect(item.route).toContain('stream=false');
|
||||
expect(item.description).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show parameter priority', () => {
|
||||
// Body parameter takes priority over query parameter
|
||||
const mixedExample = {
|
||||
url: '/mcp?stream=true', // Query says stream=true
|
||||
body: {
|
||||
method: 'initialize',
|
||||
stream: false, // Body says stream=false - this takes priority
|
||||
params: {},
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
},
|
||||
};
|
||||
|
||||
// In this case, the effective value should be false (from body)
|
||||
expect(mixedExample.body.stream).toBe(false);
|
||||
expect(mixedExample.url).toContain('stream=true');
|
||||
});
|
||||
});
|
||||
@@ -31,14 +31,28 @@ jest.mock('../../src/utils/oauthBearer.js', () => ({
|
||||
resolveOAuthUserFromToken: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock DAO accessors used by sseService (avoid file-based DAOs and migrations)
|
||||
jest.mock('../../src/dao/index.js', () => ({
|
||||
getBearerKeyDao: jest.fn(),
|
||||
getGroupDao: jest.fn(),
|
||||
getSystemConfigDao: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock config module default export used by sseService
|
||||
jest.mock('../../src/config/index.js', () => ({
|
||||
__esModule: true,
|
||||
default: { basePath: '' },
|
||||
loadSettings: jest.fn(),
|
||||
}));
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import { handleSseConnection, transports } from '../../src/services/sseService.js';
|
||||
import * as mcpService from '../../src/services/mcpService.js';
|
||||
import * as configModule from '../../src/config/index.js';
|
||||
import * as daoIndex from '../../src/dao/index.js';
|
||||
|
||||
// Mock remaining dependencies
|
||||
jest.mock('../../src/services/mcpService.js');
|
||||
jest.mock('../../src/config/index.js');
|
||||
|
||||
// Mock UserContextService with getInstance pattern
|
||||
const mockUserContextService = {
|
||||
@@ -141,6 +155,24 @@ describe('Keepalive Functionality', () => {
|
||||
};
|
||||
(mcpService.getMcpServer as jest.Mock).mockReturnValue(mockMcpServer);
|
||||
|
||||
// Mock bearer key + system config DAOs used by sseService
|
||||
const mockBearerKeyDao = {
|
||||
findEnabled: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
(daoIndex.getBearerKeyDao as unknown as jest.Mock).mockReturnValue(mockBearerKeyDao);
|
||||
|
||||
const mockSystemConfigDao = {
|
||||
get: jest.fn().mockResolvedValue({
|
||||
routing: {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
bearerAuthKey: '',
|
||||
},
|
||||
}),
|
||||
};
|
||||
(daoIndex.getSystemConfigDao as unknown as jest.Mock).mockReturnValue(mockSystemConfigDao);
|
||||
|
||||
// Mock loadSettings
|
||||
(configModule.loadSettings as jest.Mock).mockReturnValue({
|
||||
systemConfig: {
|
||||
|
||||
Reference in New Issue
Block a user