mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-29 04:59:52 -05:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a65532a50 | ||
|
|
c5aa97de50 | ||
|
|
271c9fe2c3 | ||
|
|
d59961c4d4 | ||
|
|
d0ec80303a | ||
|
|
69e92b5aa8 | ||
|
|
5acae64b29 | ||
|
|
a5fc4a429d | ||
|
|
ce15330016 | ||
|
|
621bc36560 | ||
|
|
c398223824 | ||
|
|
5dd3e7978e | ||
|
|
f577351f04 | ||
|
|
62de87b1a4 | ||
|
|
bbd6c891c9 | ||
|
|
f9019545c3 | ||
|
|
d778536388 | ||
|
|
976e90679d | ||
|
|
f6ee9beed3 | ||
|
|
69a800fa7a | ||
|
|
83cbd16821 |
235
.github/copilot-instructions.md
vendored
235
.github/copilot-instructions.md
vendored
@@ -1,50 +1,237 @@
|
||||
# MCPHub Coding Instructions
|
||||
|
||||
**ALWAYS follow these instructions first and only fallback to additional search and context gathering if the information here is incomplete or found to be in error.**
|
||||
|
||||
## Project Overview
|
||||
|
||||
MCPHub is a TypeScript/Node.js MCP server management hub that provides unified access through HTTP endpoints.
|
||||
MCPHub is a TypeScript/Node.js MCP (Model Context Protocol) server management hub that provides unified access through HTTP endpoints. It serves as a centralized dashboard for managing multiple MCP servers with real-time monitoring, authentication, and flexible routing.
|
||||
|
||||
**Core Components:**
|
||||
|
||||
- **Backend**: Express.js + TypeScript + ESM (`src/server.ts`)
|
||||
- **Frontend**: React/Vite + Tailwind CSS (`frontend/`)
|
||||
- **MCP Integration**: Connects multiple MCP servers (`src/services/mcpService.ts`)
|
||||
- **Authentication**: JWT-based with bcrypt password hashing
|
||||
- **Configuration**: JSON-based MCP server definitions (`mcp_settings.json`)
|
||||
|
||||
## Development Environment
|
||||
## Working Effectively
|
||||
|
||||
### Bootstrap and Setup (CRITICAL - Follow Exact Steps)
|
||||
|
||||
```bash
|
||||
# Install pnpm if not available
|
||||
npm install -g pnpm
|
||||
|
||||
# Install dependencies - takes ~30 seconds
|
||||
pnpm install
|
||||
pnpm dev # Start both backend and frontend
|
||||
pnpm backend:dev # Backend only
|
||||
pnpm frontend:dev # Frontend only
|
||||
|
||||
# Setup environment (optional)
|
||||
cp .env.example .env
|
||||
|
||||
# Build and test to verify setup
|
||||
pnpm lint # ~3 seconds - NEVER CANCEL
|
||||
pnpm backend:build # ~5 seconds - NEVER CANCEL
|
||||
pnpm test:ci # ~16 seconds - NEVER CANCEL. Set timeout to 60+ seconds
|
||||
pnpm frontend:build # ~5 seconds - NEVER CANCEL
|
||||
pnpm build # ~10 seconds total - NEVER CANCEL. Set timeout to 60+ seconds
|
||||
```
|
||||
|
||||
## Project Conventions
|
||||
**CRITICAL TIMING**: These commands are fast but NEVER CANCEL them. Always wait for completion.
|
||||
|
||||
### File Structure
|
||||
### Development Environment
|
||||
|
||||
- `src/services/` - Core business logic
|
||||
- `src/controllers/` - HTTP request handlers
|
||||
- `src/types/index.ts` - TypeScript type definitions
|
||||
```bash
|
||||
# Start both backend and frontend (recommended for most development)
|
||||
pnpm dev # Backend on :3001, Frontend on :5173
|
||||
|
||||
# OR start separately (required on Windows, optional on Linux/macOS)
|
||||
# Terminal 1: Backend only
|
||||
pnpm backend:dev # Runs on port 3000 (or PORT env var)
|
||||
|
||||
# Terminal 2: Frontend only
|
||||
pnpm frontend:dev # Runs on port 5173, proxies API to backend
|
||||
```
|
||||
|
||||
**NEVER CANCEL**: Development servers may take 10-15 seconds to fully initialize all MCP servers.
|
||||
|
||||
### Build Commands (Production)
|
||||
|
||||
```bash
|
||||
# Full production build - takes ~10 seconds total
|
||||
pnpm build # NEVER CANCEL - Set timeout to 60+ seconds
|
||||
|
||||
# Individual builds
|
||||
pnpm backend:build # TypeScript compilation - ~5 seconds
|
||||
pnpm frontend:build # Vite build - ~5 seconds
|
||||
|
||||
# Start production server
|
||||
pnpm start # Requires dist/ and frontend/dist/ to exist
|
||||
```
|
||||
|
||||
### Testing and Validation
|
||||
|
||||
```bash
|
||||
# Run all tests - takes ~16 seconds with 73 tests
|
||||
pnpm test:ci # NEVER CANCEL - Set timeout to 60+ seconds
|
||||
|
||||
# Development testing
|
||||
pnpm test # Interactive mode
|
||||
pnpm test:watch # Watch mode for development
|
||||
pnpm test:coverage # With coverage report
|
||||
|
||||
# Code quality
|
||||
pnpm lint # ESLint - ~3 seconds
|
||||
pnpm format # Prettier formatting - ~3 seconds
|
||||
```
|
||||
|
||||
**CRITICAL**: All tests MUST pass before committing. Do not modify tests to make them pass unless specifically required for your changes.
|
||||
|
||||
## Manual Validation Requirements
|
||||
|
||||
**ALWAYS perform these validation steps after making changes:**
|
||||
|
||||
### 1. Basic Application Functionality
|
||||
```bash
|
||||
# Start the application
|
||||
pnpm dev
|
||||
|
||||
# Verify backend responds (in another terminal)
|
||||
curl http://localhost:3000/api/health
|
||||
# Expected: Should return health status
|
||||
|
||||
# Verify frontend serves
|
||||
curl -I http://localhost:3000/
|
||||
# Expected: HTTP 200 OK with HTML content
|
||||
```
|
||||
|
||||
### 2. MCP Server Integration Test
|
||||
```bash
|
||||
# Check MCP servers are loading (look for log messages)
|
||||
# Expected log output should include:
|
||||
# - "Successfully connected client for server: [name]"
|
||||
# - "Successfully listed [N] tools for server: [name]"
|
||||
# - Some servers may fail due to missing API keys (normal in dev)
|
||||
```
|
||||
|
||||
### 3. Build Verification
|
||||
```bash
|
||||
# Verify production build works
|
||||
pnpm build
|
||||
node scripts/verify-dist.js
|
||||
# Expected: "✅ Verification passed! Frontend and backend dist files are present."
|
||||
```
|
||||
|
||||
**NEVER skip these validation steps**. If any fail, debug and fix before proceeding.
|
||||
|
||||
## Project Structure and Key Files
|
||||
|
||||
### Critical Backend Files
|
||||
- `src/index.ts` - Application entry point
|
||||
- `src/server.ts` - Express server setup and middleware
|
||||
- `src/services/mcpService.ts` - **Core MCP server management logic**
|
||||
- `src/config/index.ts` - Configuration management
|
||||
- `src/routes/` - HTTP route definitions
|
||||
- `src/controllers/` - HTTP request handlers
|
||||
- `src/dao/` - Data access layer for users, groups, servers
|
||||
- `src/types/index.ts` - TypeScript type definitions
|
||||
|
||||
### Key Notes
|
||||
### Critical Frontend Files
|
||||
- `frontend/src/` - React application source
|
||||
- `frontend/src/pages/` - Page components (development entry point)
|
||||
- `frontend/src/components/` - Reusable UI components
|
||||
|
||||
- Use ESM modules: Import with `.js` extensions, not `.ts`
|
||||
- Configuration file: `mcp_settings.json`
|
||||
- Endpoint formats: `/mcp/{group|server}` and `/mcp/$smart`
|
||||
- All code comments must be written in English
|
||||
- Frontend uses i18n with resource files in `locales/` folder
|
||||
- Server-side code should use appropriate abstraction layers for extensibility and replaceability
|
||||
### Configuration Files
|
||||
- `mcp_settings.json` - **MCP server definitions and user accounts**
|
||||
- `package.json` - Dependencies and scripts
|
||||
- `tsconfig.json` - TypeScript configuration
|
||||
- `jest.config.cjs` - Test configuration
|
||||
- `.eslintrc.json` - Linting rules
|
||||
|
||||
## Development Process
|
||||
### Docker and Deployment
|
||||
- `Dockerfile` - Multi-stage build with Python base + Node.js
|
||||
- `entrypoint.sh` - Docker startup script
|
||||
- `bin/cli.js` - NPM package CLI entry point
|
||||
|
||||
- For complex features, implement step by step and wait for confirmation before proceeding to the next step
|
||||
- After implementing features, no separate summary documentation is needed - update README.md and README.zh.md as appropriate
|
||||
## Development Process and Conventions
|
||||
|
||||
### Code Style Requirements
|
||||
- **ESM modules**: Always use `.js` extensions in imports, not `.ts`
|
||||
- **English only**: All code comments must be written in English
|
||||
- **TypeScript strict**: Follow strict type checking rules
|
||||
- **Import style**: `import { something } from './file.js'` (note .js extension)
|
||||
|
||||
### Key Configuration Notes
|
||||
- **MCP servers**: Defined in `mcp_settings.json` with command/args
|
||||
- **Endpoints**: `/mcp/{group|server}` and `/mcp/$smart` for routing
|
||||
- **i18n**: Frontend uses react-i18next with files in `locales/` folder
|
||||
- **Authentication**: JWT tokens with bcrypt password hashing
|
||||
- **Default credentials**: admin/admin123 (configured in mcp_settings.json)
|
||||
|
||||
### Development Entry Points
|
||||
- **Add MCP server**: Modify `mcp_settings.json` and restart
|
||||
- **New API endpoint**: Add route in `src/routes/`, controller in `src/controllers/`
|
||||
- **Frontend feature**: Start from `frontend/src/pages/` or `frontend/src/components/`
|
||||
- **Add tests**: Follow patterns in `tests/` directory
|
||||
|
||||
- **MCP Servers**: Modify `src/services/mcpService.ts`
|
||||
- **API Endpoints**: Add routes in `src/routes/`, controllers in `src/controllers/`
|
||||
- **Frontend Features**: Start from `frontend/src/pages/`
|
||||
- **Testing**: Follow existing patterns in `tests/`
|
||||
### Common Development Tasks
|
||||
|
||||
#### Adding a new MCP server:
|
||||
1. Add server definition to `mcp_settings.json`
|
||||
2. Restart backend to load new server
|
||||
3. Check logs for successful connection
|
||||
4. Test via dashboard or API endpoints
|
||||
|
||||
#### API development:
|
||||
1. Define route in `src/routes/`
|
||||
2. Implement controller in `src/controllers/`
|
||||
3. Add types in `src/types/index.ts` if needed
|
||||
4. Write tests in `tests/controllers/`
|
||||
|
||||
#### Frontend development:
|
||||
1. Create/modify components in `frontend/src/components/`
|
||||
2. Add pages in `frontend/src/pages/`
|
||||
3. Update routing if needed
|
||||
4. Test in development mode with `pnpm frontend:dev`
|
||||
|
||||
## Validation and CI Requirements
|
||||
|
||||
### Before Committing - ALWAYS Run:
|
||||
```bash
|
||||
pnpm lint # Must pass - ~3 seconds
|
||||
pnpm backend:build # Must compile - ~5 seconds
|
||||
pnpm test:ci # All tests must pass - ~16 seconds
|
||||
pnpm build # Full build must work - ~10 seconds
|
||||
```
|
||||
|
||||
**CRITICAL**: CI will fail if any of these commands fail. Fix issues locally first.
|
||||
|
||||
### CI Pipeline (.github/workflows/ci.yml)
|
||||
- Runs on Node.js 20.x
|
||||
- Tests: linting, type checking, unit tests with coverage
|
||||
- **NEVER CANCEL**: CI builds may take 2-3 minutes total
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
- **"uvx command not found"**: Some MCP servers require `uvx` (Python package manager) - this is expected in development
|
||||
- **Port already in use**: Change PORT environment variable or kill existing processes
|
||||
- **Frontend not loading**: Ensure frontend was built with `pnpm frontend:build`
|
||||
- **MCP server connection failed**: Check server command/args in `mcp_settings.json`
|
||||
|
||||
### Build Failures
|
||||
- **TypeScript errors**: Run `pnpm backend:build` to see compilation errors
|
||||
- **Test failures**: Run `pnpm test:verbose` for detailed test output
|
||||
- **Lint errors**: Run `pnpm lint` and fix reported issues
|
||||
|
||||
### Development Issues
|
||||
- **Backend not starting**: Check for port conflicts, verify `mcp_settings.json` syntax
|
||||
- **Frontend proxy errors**: Ensure backend is running before starting frontend
|
||||
- **Hot reload not working**: Restart development server
|
||||
|
||||
## Performance Notes
|
||||
- **Install time**: pnpm install takes ~30 seconds
|
||||
- **Build time**: Full build takes ~10 seconds
|
||||
- **Test time**: Complete test suite takes ~16 seconds
|
||||
- **Startup time**: Backend initialization takes 10-15 seconds (MCP server connections)
|
||||
|
||||
**Remember**: NEVER CANCEL any build or test commands. Always wait for completion even if they seem slow.
|
||||
|
||||
250
docs/api-reference/openapi.mdx
Normal file
250
docs/api-reference/openapi.mdx
Normal file
@@ -0,0 +1,250 @@
|
||||
---
|
||||
title: "OpenAPI Integration"
|
||||
description: "Generate OpenAPI specifications from MCP tools for seamless integration with OpenWebUI and other systems"
|
||||
---
|
||||
|
||||
# OpenAPI Generation for OpenWebUI Integration
|
||||
|
||||
MCPHub now supports generating OpenAPI 3.0.3 specifications from MCP tools, enabling seamless integration with OpenWebUI and other OpenAPI-compatible systems without requiring MCPO as an intermediary proxy.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ **Automatic OpenAPI Generation**: Converts MCP tools to OpenAPI 3.0.3 specification
|
||||
- ✅ **OpenWebUI Compatible**: Direct integration without MCPO proxy
|
||||
- ✅ **Real-time Tool Discovery**: Dynamically includes tools from connected MCP servers
|
||||
- ✅ **Dual Parameter Support**: Supports both GET (query params) and POST (JSON body) for tool execution
|
||||
- ✅ **No Authentication Required**: OpenAPI endpoints are public for easy integration
|
||||
- ✅ **Comprehensive Metadata**: Full OpenAPI specification with proper schemas and documentation
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### OpenAPI Specification
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```bash GET /api/openapi.json
|
||||
curl "http://localhost:3000/api/openapi.json"
|
||||
```
|
||||
|
||||
```bash With Parameters
|
||||
curl "http://localhost:3000/api/openapi.json?title=My MCP API&version=2.0.0"
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
Generates and returns the complete OpenAPI 3.0.3 specification for all connected MCP tools.
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
<ParamField query="title" type="string" optional>
|
||||
Custom API title
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="description" type="string" optional>
|
||||
Custom API description
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="version" type="string" optional>
|
||||
Custom API version
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="serverUrl" type="string" optional>
|
||||
Custom server URL
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="includeDisabled" type="boolean" optional default="false">
|
||||
Include disabled tools
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="servers" type="string" optional>
|
||||
Comma-separated list of server names to include
|
||||
</ParamField>
|
||||
|
||||
### Available Servers
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```bash GET /api/openapi/servers
|
||||
curl "http://localhost:3000/api/openapi/servers"
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
Returns a list of connected MCP server names.
|
||||
|
||||
<ResponseExample>
|
||||
|
||||
```json Example Response
|
||||
{
|
||||
"success": true,
|
||||
"data": ["amap", "playwright", "slack"]
|
||||
}
|
||||
```
|
||||
|
||||
</ResponseExample>
|
||||
|
||||
### Tool Statistics
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```bash GET /api/openapi/stats
|
||||
curl "http://localhost:3000/api/openapi/stats"
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
Returns statistics about available tools and servers.
|
||||
|
||||
<ResponseExample>
|
||||
|
||||
```json Example Response
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"totalServers": 3,
|
||||
"totalTools": 41,
|
||||
"serverBreakdown": [
|
||||
{"name": "amap", "toolCount": 12, "status": "connected"},
|
||||
{"name": "playwright", "toolCount": 21, "status": "connected"},
|
||||
{"name": "slack", "toolCount": 8, "status": "connected"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</ResponseExample>
|
||||
|
||||
### Tool Execution
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```bash GET /api/tools/{serverName}/{toolName}
|
||||
curl "http://localhost:3000/api/tools/amap/amap-maps_weather?city=Beijing"
|
||||
```
|
||||
|
||||
```bash POST /api/tools/{serverName}/{toolName}
|
||||
curl -X POST "http://localhost:3000/api/tools/playwright/playwright-browser_navigate" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"url": "https://example.com"}'
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
Execute MCP tools via OpenAPI-compatible endpoints.
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
<ParamField path="serverName" type="string" required>
|
||||
The name of the MCP server
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="toolName" type="string" required>
|
||||
The name of the tool to execute
|
||||
</ParamField>
|
||||
|
||||
## OpenWebUI Integration
|
||||
|
||||
To integrate MCPHub with OpenWebUI:
|
||||
|
||||
<Steps>
|
||||
<Step title="Start MCPHub">
|
||||
Ensure MCPHub is running with your MCP servers configured
|
||||
</Step>
|
||||
<Step title="Get OpenAPI Specification">
|
||||
```bash
|
||||
curl http://localhost:3000/api/openapi.json > mcphub-api.json
|
||||
```
|
||||
</Step>
|
||||
<Step title="Add to OpenWebUI">
|
||||
Import the OpenAPI specification file or point to the URL directly in OpenWebUI
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
### Configuration Example
|
||||
|
||||
In OpenWebUI, you can add MCPHub as an OpenAPI tool by using:
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="OpenAPI URL" icon="link">
|
||||
`http://localhost:3000/api/openapi.json`
|
||||
</Card>
|
||||
<Card title="Base URL" icon="server">
|
||||
`http://localhost:3000/api`
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Generated OpenAPI Structure
|
||||
|
||||
The generated OpenAPI specification includes:
|
||||
|
||||
### Tool Conversion Logic
|
||||
|
||||
- **Simple tools** (≤10 primitive parameters) → GET endpoints with query parameters
|
||||
- **Complex tools** (objects, arrays, or >10 parameters) → POST endpoints with JSON request body
|
||||
- **All tools** include comprehensive response schemas and error handling
|
||||
|
||||
### Example Generated Operation
|
||||
|
||||
```yaml
|
||||
/tools/amap/amap-maps_weather:
|
||||
get:
|
||||
summary: "根据城市名称或者标准adcode查询指定城市的天气"
|
||||
operationId: "amap_amap-maps_weather"
|
||||
tags: ["amap"]
|
||||
parameters:
|
||||
- name: city
|
||||
in: query
|
||||
required: true
|
||||
description: "城市名称或者adcode"
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: "Successful tool execution"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ToolResponse'
|
||||
```
|
||||
|
||||
### Security
|
||||
|
||||
- Bearer authentication is defined but not enforced for tool execution endpoints
|
||||
- Enables flexible integration with various OpenAPI-compatible systems
|
||||
|
||||
## Benefits over MCPO
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Direct Integration" icon="plug">
|
||||
No need for intermediate proxy
|
||||
</Card>
|
||||
<Card title="Real-time Updates" icon="refresh">
|
||||
OpenAPI spec updates automatically as MCP servers connect/disconnect
|
||||
</Card>
|
||||
<Card title="Better Performance" icon="bolt">
|
||||
Direct tool execution without proxy overhead
|
||||
</Card>
|
||||
<Card title="Simplified Architecture" icon="layer-group">
|
||||
One less component to manage
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="OpenAPI spec shows no tools">
|
||||
Ensure MCP servers are connected. Check `/api/openapi/stats` for server status.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Tool execution fails">
|
||||
Verify the tool name and parameters match the OpenAPI specification. Check server logs for details.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="OpenWebUI can't connect">
|
||||
Ensure MCPHub is accessible from OpenWebUI and the OpenAPI URL is correct.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Missing tools in specification">
|
||||
Check if tools are enabled in your MCP server configuration. Use `includeDisabled=true` to see all tools.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
210
docs/dao-implementation-summary.md
Normal file
210
docs/dao-implementation-summary.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# MCPHub DAO Layer 实现总结
|
||||
|
||||
## 项目概述
|
||||
|
||||
本次开发为MCPHub项目引入了独立的数据访问对象(DAO)层,用于管理`mcp_settings.json`中的不同类型数据的增删改查操作。
|
||||
|
||||
## 已实现的功能
|
||||
|
||||
### 1. 核心DAO层架构
|
||||
|
||||
#### 基础架构
|
||||
- **BaseDao.ts**: 定义了通用的CRUD接口和抽象实现
|
||||
- **JsonFileBaseDao.ts**: 提供JSON文件操作的基础类,包含缓存机制
|
||||
- **DaoFactory.ts**: 工厂模式实现,提供DAO实例的创建和管理
|
||||
|
||||
#### 具体DAO实现
|
||||
1. **UserDao**: 用户数据管理
|
||||
- 用户创建(含密码哈希)
|
||||
- 密码验证
|
||||
- 权限管理
|
||||
- 管理员查询
|
||||
|
||||
2. **ServerDao**: 服务器配置管理
|
||||
- 服务器CRUD操作
|
||||
- 按所有者/类型/状态查询
|
||||
- 工具和提示配置管理
|
||||
- 启用/禁用控制
|
||||
|
||||
3. **GroupDao**: 群组管理
|
||||
- 群组CRUD操作
|
||||
- 服务器成员管理
|
||||
- 按所有者查询
|
||||
- 群组-服务器关系管理
|
||||
|
||||
4. **SystemConfigDao**: 系统配置管理
|
||||
- 系统级配置的读取和更新
|
||||
- 分段配置管理
|
||||
- 配置重置功能
|
||||
|
||||
5. **UserConfigDao**: 用户个人配置管理
|
||||
- 用户个人配置的CRUD操作
|
||||
- 分段配置管理
|
||||
- 批量配置查询
|
||||
|
||||
### 2. 配置服务集成
|
||||
|
||||
#### DaoConfigService
|
||||
- 使用DAO层重新实现配置加载和保存
|
||||
- 支持用户权限过滤
|
||||
- 提供配置合并和验证功能
|
||||
|
||||
#### ConfigManager
|
||||
- 双模式支持:传统文件方式 + 新DAO层
|
||||
- 运行时切换机制
|
||||
- 环境变量控制 (`USE_DAO_LAYER`)
|
||||
- 迁移工具集成
|
||||
|
||||
### 3. 迁移和验证工具
|
||||
|
||||
#### 迁移功能
|
||||
- 从传统JSON文件格式迁移到DAO层
|
||||
- 数据完整性验证
|
||||
- 性能对比分析
|
||||
- 迁移报告生成
|
||||
|
||||
#### 测试工具
|
||||
- DAO操作完整性测试
|
||||
- 示例数据生成和清理
|
||||
- 性能基准测试
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── dao/ # DAO层核心
|
||||
│ ├── base/
|
||||
│ │ ├── BaseDao.ts # 基础DAO接口
|
||||
│ │ └── JsonFileBaseDao.ts # JSON文件基础类
|
||||
│ ├── UserDao.ts # 用户数据访问
|
||||
│ ├── ServerDao.ts # 服务器配置访问
|
||||
│ ├── GroupDao.ts # 群组数据访问
|
||||
│ ├── SystemConfigDao.ts # 系统配置访问
|
||||
│ ├── UserConfigDao.ts # 用户配置访问
|
||||
│ ├── DaoFactory.ts # DAO工厂
|
||||
│ ├── examples.ts # 使用示例
|
||||
│ └── index.ts # 统一导出
|
||||
├── config/
|
||||
│ ├── DaoConfigService.ts # DAO配置服务
|
||||
│ ├── configManager.ts # 配置管理器
|
||||
│ └── migrationUtils.ts # 迁移工具
|
||||
├── scripts/
|
||||
│ └── dao-demo.ts # 演示脚本
|
||||
└── docs/
|
||||
└── dao-layer.md # 详细文档
|
||||
```
|
||||
|
||||
## 主要特性
|
||||
|
||||
### 1. 类型安全
|
||||
- 完整的TypeScript类型定义
|
||||
- 编译时类型检查
|
||||
- 接口约束和验证
|
||||
|
||||
### 2. 模块化设计
|
||||
- 每种数据类型独立的DAO
|
||||
- 清晰的关注点分离
|
||||
- 可插拔的实现方式
|
||||
|
||||
### 3. 缓存机制
|
||||
- JSON文件读取缓存
|
||||
- 文件修改时间检测
|
||||
- 缓存失效和刷新
|
||||
|
||||
### 4. 向后兼容
|
||||
- 保持现有API不变
|
||||
- 支持传统和DAO双模式
|
||||
- 平滑迁移路径
|
||||
|
||||
### 5. 未来扩展性
|
||||
- 数据库切换准备
|
||||
- 新数据类型支持
|
||||
- 复杂查询能力
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 启用DAO层
|
||||
```bash
|
||||
# 环境变量配置
|
||||
export USE_DAO_LAYER=true
|
||||
```
|
||||
|
||||
### 基本操作示例
|
||||
```typescript
|
||||
import { getUserDao, getServerDao } from './dao/index.js';
|
||||
|
||||
// 用户操作
|
||||
const userDao = getUserDao();
|
||||
await userDao.createWithHashedPassword('admin', 'password', true);
|
||||
const user = await userDao.findByUsername('admin');
|
||||
|
||||
// 服务器操作
|
||||
const serverDao = getServerDao();
|
||||
await serverDao.create({
|
||||
name: 'my-server',
|
||||
command: 'node',
|
||||
args: ['server.js']
|
||||
});
|
||||
```
|
||||
|
||||
### 迁移操作
|
||||
```typescript
|
||||
import { migrateToDao, validateMigration } from './config/configManager.js';
|
||||
|
||||
// 执行迁移
|
||||
await migrateToDao();
|
||||
|
||||
// 验证迁移
|
||||
await validateMigration();
|
||||
```
|
||||
|
||||
## 依赖包
|
||||
|
||||
新增的依赖包:
|
||||
- `bcrypt`: 用户密码哈希
|
||||
- `@types/bcrypt`: bcrypt类型定义
|
||||
- `uuid`: UUID生成(群组ID)
|
||||
- `@types/uuid`: uuid类型定义
|
||||
|
||||
## 测试状态
|
||||
|
||||
✅ **编译测试**: 项目成功编译,无TypeScript错误
|
||||
✅ **类型检查**: 所有类型定义正确
|
||||
✅ **依赖安装**: 必要依赖包已安装
|
||||
⏳ **运行时测试**: 需要在实际环境中测试
|
||||
⏳ **迁移测试**: 需要使用真实数据测试迁移
|
||||
|
||||
## 下一步计划
|
||||
|
||||
### 短期目标
|
||||
1. 在开发环境中测试DAO层功能
|
||||
2. 完善错误处理和边界情况
|
||||
3. 添加更多单元测试
|
||||
4. 性能优化和监控
|
||||
|
||||
### 中期目标
|
||||
1. 集成到现有业务逻辑中
|
||||
2. 提供Web界面的DAO层管理
|
||||
3. 添加数据备份和恢复功能
|
||||
4. 实现配置版本控制
|
||||
|
||||
### 长期目标
|
||||
1. 实现数据库后端支持
|
||||
2. 添加分布式配置管理
|
||||
3. 实现实时配置同步
|
||||
4. 支持配置审计和日志
|
||||
|
||||
## 优势总结
|
||||
|
||||
通过引入DAO层,MCPHub获得了以下优势:
|
||||
|
||||
1. **🏗️ 架构清晰**: 数据访问逻辑与业务逻辑分离
|
||||
2. **🔄 易于扩展**: 为未来数据库支持做好准备
|
||||
3. **🧪 便于测试**: 接口可以轻松模拟和单元测试
|
||||
4. **🔒 类型安全**: 完整的TypeScript类型支持
|
||||
5. **⚡ 性能优化**: 内置缓存和批量操作
|
||||
6. **🛡️ 数据完整性**: 强制数据验证和约束
|
||||
7. **📦 模块化**: 每种数据类型独立管理
|
||||
8. **🔧 可维护性**: 代码结构清晰,易于维护
|
||||
|
||||
这个DAO层的实现为MCPHub项目提供了坚实的数据管理基础,支持项目的长期发展和扩展需求。
|
||||
254
docs/dao-layer.md
Normal file
254
docs/dao-layer.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# MCPHub DAO Layer 设计文档
|
||||
|
||||
## 概述
|
||||
|
||||
MCPHub的数据访问对象(DAO)层为项目中`mcp_settings.json`文件中的不同数据类型提供了统一的增删改查操作接口。这个设计使得未来从JSON文件存储切换到数据库存储变得更加容易。
|
||||
|
||||
## 架构设计
|
||||
|
||||
### 核心组件
|
||||
|
||||
```
|
||||
src/dao/
|
||||
├── base/
|
||||
│ ├── BaseDao.ts # 基础DAO接口和抽象实现
|
||||
│ └── JsonFileBaseDao.ts # JSON文件操作的基础类
|
||||
├── UserDao.ts # 用户数据访问对象
|
||||
├── ServerDao.ts # 服务器配置数据访问对象
|
||||
├── GroupDao.ts # 群组数据访问对象
|
||||
├── SystemConfigDao.ts # 系统配置数据访问对象
|
||||
├── UserConfigDao.ts # 用户配置数据访问对象
|
||||
├── DaoFactory.ts # DAO工厂类
|
||||
├── examples.ts # 使用示例
|
||||
└── index.ts # 统一导出
|
||||
```
|
||||
|
||||
### 数据类型映射
|
||||
|
||||
| 数据类型 | 原始位置 | DAO类 | 主要功能 |
|
||||
|---------|---------|-------|---------|
|
||||
| IUser | `settings.users[]` | UserDao | 用户管理、密码验证、权限控制 |
|
||||
| ServerConfig | `settings.mcpServers{}` | ServerDao | 服务器配置、启用/禁用、工具管理 |
|
||||
| IGroup | `settings.groups[]` | GroupDao | 群组管理、服务器分组、成员管理 |
|
||||
| SystemConfig | `settings.systemConfig` | SystemConfigDao | 系统级配置、路由设置、安装配置 |
|
||||
| UserConfig | `settings.userConfigs{}` | UserConfigDao | 用户个人配置 |
|
||||
|
||||
## 主要特性
|
||||
|
||||
### 1. 统一的CRUD接口
|
||||
|
||||
所有DAO都实现了基础的CRUD操作:
|
||||
|
||||
```typescript
|
||||
interface BaseDao<T, K = string> {
|
||||
findAll(): Promise<T[]>;
|
||||
findById(id: K): Promise<T | null>;
|
||||
create(entity: Omit<T, 'id'>): Promise<T>;
|
||||
update(id: K, entity: Partial<T>): Promise<T | null>;
|
||||
delete(id: K): Promise<boolean>;
|
||||
exists(id: K): Promise<boolean>;
|
||||
count(): Promise<number>;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 特定业务操作
|
||||
|
||||
每个DAO还提供了针对其数据类型的特定操作:
|
||||
|
||||
#### UserDao 特殊功能
|
||||
- `createWithHashedPassword()` - 创建用户时自动哈希密码
|
||||
- `validateCredentials()` - 验证用户凭据
|
||||
- `updatePassword()` - 更新用户密码
|
||||
- `findAdmins()` - 查找管理员用户
|
||||
|
||||
#### ServerDao 特殊功能
|
||||
- `findByOwner()` - 按所有者查找服务器
|
||||
- `findEnabled()` - 查找启用的服务器
|
||||
- `findByType()` - 按类型查找服务器
|
||||
- `setEnabled()` - 启用/禁用服务器
|
||||
- `updateTools()` - 更新服务器工具配置
|
||||
|
||||
#### GroupDao 特殊功能
|
||||
- `findByOwner()` - 按所有者查找群组
|
||||
- `findByServer()` - 查找包含特定服务器的群组
|
||||
- `addServerToGroup()` - 向群组添加服务器
|
||||
- `removeServerFromGroup()` - 从群组移除服务器
|
||||
- `findByName()` - 按名称查找群组
|
||||
|
||||
### 3. 配置管理特殊功能
|
||||
|
||||
#### SystemConfigDao
|
||||
- `getSection()` - 获取特定配置段
|
||||
- `updateSection()` - 更新特定配置段
|
||||
- `reset()` - 重置为默认配置
|
||||
|
||||
#### UserConfigDao
|
||||
- `getSection()` - 获取用户特定配置段
|
||||
- `updateSection()` - 更新用户特定配置段
|
||||
- `getAll()` - 获取所有用户配置
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 1. 基本使用
|
||||
|
||||
```typescript
|
||||
import { getUserDao, getServerDao, getGroupDao } from './dao/index.js';
|
||||
|
||||
// 用户操作
|
||||
const userDao = getUserDao();
|
||||
const newUser = await userDao.createWithHashedPassword('username', 'password', false);
|
||||
const user = await userDao.findByUsername('username');
|
||||
const isValid = await userDao.validateCredentials('username', 'password');
|
||||
|
||||
// 服务器操作
|
||||
const serverDao = getServerDao();
|
||||
const server = await serverDao.create({
|
||||
name: 'my-server',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
enabled: true
|
||||
});
|
||||
|
||||
// 群组操作
|
||||
const groupDao = getGroupDao();
|
||||
const group = await groupDao.create({
|
||||
name: 'my-group',
|
||||
description: 'Test group',
|
||||
servers: ['my-server']
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 配置服务集成
|
||||
|
||||
```typescript
|
||||
import { DaoConfigService, createDaoConfigService } from './config/DaoConfigService.js';
|
||||
|
||||
const daoService = createDaoConfigService();
|
||||
|
||||
// 加载完整配置
|
||||
const settings = await daoService.loadSettings();
|
||||
|
||||
// 保存配置
|
||||
await daoService.saveSettings(updatedSettings);
|
||||
```
|
||||
|
||||
### 3. 迁移管理
|
||||
|
||||
```typescript
|
||||
import { migrateToDao, switchToDao, switchToLegacy } from './config/configManager.js';
|
||||
|
||||
// 迁移到DAO层
|
||||
const success = await migrateToDao();
|
||||
|
||||
// 运行时切换
|
||||
switchToDao(); // 切换到DAO层
|
||||
switchToLegacy(); // 切换回传统方式
|
||||
```
|
||||
|
||||
## 配置选项
|
||||
|
||||
可以通过环境变量控制使用哪种数据访问方式:
|
||||
|
||||
```bash
|
||||
# 使用DAO层 (推荐)
|
||||
USE_DAO_LAYER=true
|
||||
|
||||
# 使用传统文件方式 (默认,向后兼容)
|
||||
USE_DAO_LAYER=false
|
||||
```
|
||||
|
||||
## 未来扩展
|
||||
|
||||
### 数据库支持
|
||||
|
||||
DAO层的设计使得切换到数据库变得容易,只需要:
|
||||
|
||||
1. 实现新的DAO实现类(如DatabaseUserDao)
|
||||
2. 创建新的DaoFactory
|
||||
3. 更新配置以使用新的工厂
|
||||
|
||||
```typescript
|
||||
// 未来的数据库实现示例
|
||||
class DatabaseUserDao implements UserDao {
|
||||
constructor(private db: Database) {}
|
||||
|
||||
async findAll(): Promise<IUser[]> {
|
||||
return this.db.query('SELECT * FROM users');
|
||||
}
|
||||
|
||||
// ... 其他方法
|
||||
}
|
||||
```
|
||||
|
||||
### 新数据类型
|
||||
|
||||
添加新数据类型只需要:
|
||||
|
||||
1. 定义数据接口
|
||||
2. 创建对应的DAO接口和实现
|
||||
3. 更新DaoFactory
|
||||
4. 更新配置服务
|
||||
|
||||
## 迁移指南
|
||||
|
||||
### 从传统方式迁移到DAO层
|
||||
|
||||
1. **备份数据**
|
||||
```bash
|
||||
cp mcp_settings.json mcp_settings.json.backup
|
||||
```
|
||||
|
||||
2. **运行迁移**
|
||||
```typescript
|
||||
import { performMigration } from './config/migrationUtils.js';
|
||||
await performMigration();
|
||||
```
|
||||
|
||||
3. **验证迁移**
|
||||
```typescript
|
||||
import { validateMigration } from './config/migrationUtils.js';
|
||||
const isValid = await validateMigration();
|
||||
```
|
||||
|
||||
4. **切换到DAO层**
|
||||
```bash
|
||||
export USE_DAO_LAYER=true
|
||||
```
|
||||
|
||||
### 性能对比
|
||||
|
||||
可以使用内置工具对比性能:
|
||||
|
||||
```typescript
|
||||
import { performanceComparison } from './config/migrationUtils.js';
|
||||
await performanceComparison();
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **类型安全**: 始终使用TypeScript接口确保类型安全
|
||||
2. **错误处理**: 在DAO操作周围实现适当的错误处理
|
||||
3. **事务**: 对于复杂操作,考虑使用事务(未来数据库实现)
|
||||
4. **缓存**: DAO层包含内置缓存机制
|
||||
5. **测试**: 使用DAO接口进行单元测试的模拟
|
||||
|
||||
## 示例代码
|
||||
|
||||
查看以下文件获取完整示例:
|
||||
|
||||
- `src/dao/examples.ts` - 基本DAO操作示例
|
||||
- `src/config/migrationUtils.ts` - 迁移和验证工具
|
||||
- `src/scripts/dao-demo.ts` - 交互式演示脚本
|
||||
|
||||
## 总结
|
||||
|
||||
DAO层为MCPHub提供了:
|
||||
|
||||
- 🏗️ **模块化设计**: 每种数据类型都有专门的访问层
|
||||
- 🔄 **易于迁移**: 为未来切换到数据库做好准备
|
||||
- 🧪 **可测试性**: 接口可以轻松模拟和测试
|
||||
- 🔒 **类型安全**: 完整的TypeScript类型支持
|
||||
- ⚡ **性能优化**: 内置缓存和批量操作支持
|
||||
- 🛡️ **数据完整性**: 强制数据验证和约束
|
||||
|
||||
通过引入DAO层,MCPHub的数据管理变得更加结构化、可维护和可扩展。
|
||||
@@ -83,6 +83,12 @@
|
||||
"api-reference/smart-routing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "OpenAPI Endpoints",
|
||||
"pages": [
|
||||
"api-reference/openapi"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Management Endpoints",
|
||||
"pages": [
|
||||
@@ -107,6 +113,12 @@
|
||||
"zh/api-reference/smart-routing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "OpenAPI 端点",
|
||||
"pages": [
|
||||
"zh/api-reference/openapi"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "管理端点",
|
||||
"pages": [
|
||||
|
||||
250
docs/zh/api-reference/openapi.mdx
Normal file
250
docs/zh/api-reference/openapi.mdx
Normal file
@@ -0,0 +1,250 @@
|
||||
---
|
||||
title: "OpenAPI 集成"
|
||||
description: "从 MCP 工具生成 OpenAPI 规范,与 OpenWebUI 和其他系统无缝集成"
|
||||
---
|
||||
|
||||
# OpenWebUI 集成的 OpenAPI 生成
|
||||
|
||||
MCPHub 现在支持从 MCP 工具生成 OpenAPI 3.0.3 规范,实现与 OpenWebUI 和其他 OpenAPI 兼容系统的无缝集成,无需 MCPO 作为中间代理。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ✅ **自动 OpenAPI 生成**:将 MCP 工具转换为 OpenAPI 3.0.3 规范
|
||||
- ✅ **OpenWebUI 兼容**:无需 MCPO 代理的直接集成
|
||||
- ✅ **实时工具发现**:动态包含已连接 MCP 服务器的工具
|
||||
- ✅ **双参数支持**:支持 GET(查询参数)和 POST(JSON 正文)进行工具执行
|
||||
- ✅ **无需身份验证**:OpenAPI 端点公开,便于集成
|
||||
- ✅ **完整元数据**:具有适当模式和文档的完整 OpenAPI 规范
|
||||
|
||||
## API 端点
|
||||
|
||||
### OpenAPI 规范
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```bash GET /api/openapi.json
|
||||
curl "http://localhost:3000/api/openapi.json"
|
||||
```
|
||||
|
||||
```bash 带参数
|
||||
curl "http://localhost:3000/api/openapi.json?title=我的 MCP API&version=2.0.0"
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
生成并返回所有已连接 MCP 工具的完整 OpenAPI 3.0.3 规范。
|
||||
|
||||
**查询参数:**
|
||||
|
||||
<ParamField query="title" type="string" optional>
|
||||
自定义 API 标题
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="description" type="string" optional>
|
||||
自定义 API 描述
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="version" type="string" optional>
|
||||
自定义 API 版本
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="serverUrl" type="string" optional>
|
||||
自定义服务器 URL
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="includeDisabled" type="boolean" optional default="false">
|
||||
包含禁用的工具
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="servers" type="string" optional>
|
||||
要包含的服务器名称列表(逗号分隔)
|
||||
</ParamField>
|
||||
|
||||
### 可用服务器
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```bash GET /api/openapi/servers
|
||||
curl "http://localhost:3000/api/openapi/servers"
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
返回已连接的 MCP 服务器名称列表。
|
||||
|
||||
<ResponseExample>
|
||||
|
||||
```json 示例响应
|
||||
{
|
||||
"success": true,
|
||||
"data": ["amap", "playwright", "slack"]
|
||||
}
|
||||
```
|
||||
|
||||
</ResponseExample>
|
||||
|
||||
### 工具统计
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```bash GET /api/openapi/stats
|
||||
curl "http://localhost:3000/api/openapi/stats"
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
返回有关可用工具和服务器的统计信息。
|
||||
|
||||
<ResponseExample>
|
||||
|
||||
```json 示例响应
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"totalServers": 3,
|
||||
"totalTools": 41,
|
||||
"serverBreakdown": [
|
||||
{"name": "amap", "toolCount": 12, "status": "connected"},
|
||||
{"name": "playwright", "toolCount": 21, "status": "connected"},
|
||||
{"name": "slack", "toolCount": 8, "status": "connected"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</ResponseExample>
|
||||
|
||||
### 工具执行
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```bash GET /api/tools/{serverName}/{toolName}
|
||||
curl "http://localhost:3000/api/tools/amap/amap-maps_weather?city=Beijing"
|
||||
```
|
||||
|
||||
```bash POST /api/tools/{serverName}/{toolName}
|
||||
curl -X POST "http://localhost:3000/api/tools/playwright/playwright-browser_navigate" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"url": "https://example.com"}'
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
通过 OpenAPI 兼容端点执行 MCP 工具。
|
||||
|
||||
**路径参数:**
|
||||
|
||||
<ParamField path="serverName" type="string" required>
|
||||
MCP 服务器的名称
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="toolName" type="string" required>
|
||||
要执行的工具名称
|
||||
</ParamField>
|
||||
|
||||
## OpenWebUI 集成
|
||||
|
||||
要将 MCPHub 与 OpenWebUI 集成:
|
||||
|
||||
<Steps>
|
||||
<Step title="启动 MCPHub">
|
||||
确保 MCPHub 正在运行,并且已配置 MCP 服务器
|
||||
</Step>
|
||||
<Step title="获取 OpenAPI 规范">
|
||||
```bash
|
||||
curl http://localhost:3000/api/openapi.json > mcphub-api.json
|
||||
```
|
||||
</Step>
|
||||
<Step title="添加到 OpenWebUI">
|
||||
在 OpenWebUI 中导入 OpenAPI 规范文件或直接指向 URL
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
### 配置示例
|
||||
|
||||
在 OpenWebUI 中,您可以通过以下方式将 MCPHub 添加为 OpenAPI 工具:
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="OpenAPI URL" icon="link">
|
||||
`http://localhost:3000/api/openapi.json`
|
||||
</Card>
|
||||
<Card title="基础 URL" icon="server">
|
||||
`http://localhost:3000/api`
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## 生成的 OpenAPI 结构
|
||||
|
||||
生成的 OpenAPI 规范包括:
|
||||
|
||||
### 工具转换逻辑
|
||||
|
||||
- **简单工具**(≤10 个原始参数)→ 带查询参数的 GET 端点
|
||||
- **复杂工具**(对象、数组或 >10 个参数)→ 带 JSON 请求正文的 POST 端点
|
||||
- **所有工具**都包含完整的响应模式和错误处理
|
||||
|
||||
### 生成操作示例
|
||||
|
||||
```yaml
|
||||
/tools/amap/amap-maps_weather:
|
||||
get:
|
||||
summary: "根据城市名称或者标准adcode查询指定城市的天气"
|
||||
operationId: "amap_amap-maps_weather"
|
||||
tags: ["amap"]
|
||||
parameters:
|
||||
- name: city
|
||||
in: query
|
||||
required: true
|
||||
description: "城市名称或者adcode"
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: "Successful tool execution"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ToolResponse'
|
||||
```
|
||||
|
||||
### 安全性
|
||||
|
||||
- 定义了 Bearer 身份验证但不对工具执行端点强制执行
|
||||
- 支持与各种 OpenAPI 兼容系统的灵活集成
|
||||
|
||||
## 相比 MCPO 的优势
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="直接集成" icon="plug">
|
||||
无需中间代理
|
||||
</Card>
|
||||
<Card title="实时更新" icon="refresh">
|
||||
OpenAPI 规范随着 MCP 服务器连接/断开自动更新
|
||||
</Card>
|
||||
<Card title="更好的性能" icon="bolt">
|
||||
直接工具执行,无代理开销
|
||||
</Card>
|
||||
<Card title="简化架构" icon="layer-group">
|
||||
减少一个需要管理的组件
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## 故障排除
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="OpenAPI 规范显示没有工具">
|
||||
确保 MCP 服务器已连接。检查 `/api/openapi/stats` 查看服务器状态。
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="工具执行失败">
|
||||
验证工具名称和参数是否与 OpenAPI 规范匹配。检查服务器日志以获取详细信息。
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="OpenWebUI 无法连接">
|
||||
确保 MCPHub 可从 OpenWebUI 访问,并且 OpenAPI URL 正确。
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="规范中缺少工具">
|
||||
检查您的 MCP 服务器配置中是否启用了工具。使用 `includeDisabled=true` 查看所有工具。
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
@@ -3,6 +3,7 @@ import { BrowserRouter as Router, Route, Routes, Navigate, useParams } from 'rea
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { ToastProvider } from './contexts/ToastContext';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { ServerProvider } from './contexts/ServerContext';
|
||||
import MainLayout from './layouts/MainLayout';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
@@ -26,6 +27,7 @@ function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<ServerProvider>
|
||||
<ToastProvider>
|
||||
<Router basename={basename}>
|
||||
<Routes>
|
||||
@@ -57,6 +59,7 @@ function App() {
|
||||
</Routes>
|
||||
</Router>
|
||||
</ToastProvider>
|
||||
</ServerProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
@@ -231,8 +231,8 @@ const CloudServerDetail: React.FC<CloudServerDetailProps> = ({
|
||||
className="w-full border rounded-md px-3 py-2 border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 form-input"
|
||||
>
|
||||
<option value=""></option>
|
||||
<option value="true">True</option>
|
||||
<option value="false">False</option>
|
||||
<option value="true">{t('common.true')}</option>
|
||||
<option value="false">{t('common.false')}</option>
|
||||
</select>
|
||||
);
|
||||
} else if (propSchema.type === 'number' || propSchema.type === 'integer') {
|
||||
|
||||
@@ -306,7 +306,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
onChange={() => updateServerType('stdio')}
|
||||
className="mr-1"
|
||||
/>
|
||||
<label htmlFor="command">STDIO</label>
|
||||
<label htmlFor="command">{t('server.typeStdio')}</label>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
@@ -318,7 +318,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
onChange={() => updateServerType('sse')}
|
||||
className="mr-1"
|
||||
/>
|
||||
<label htmlFor="url">SSE</label>
|
||||
<label htmlFor="url">{t('server.typeSse')}</label>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
@@ -330,7 +330,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
onChange={() => updateServerType('streamable-http')}
|
||||
className="mr-1"
|
||||
/>
|
||||
<label htmlFor="streamable-http">Streamable HTTP</label>
|
||||
<label htmlFor="streamable-http">{t('server.typeStreamableHttp')}</label>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
@@ -342,7 +342,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
onChange={() => updateServerType('openapi')}
|
||||
className="mr-1"
|
||||
/>
|
||||
<label htmlFor="openapi">OpenAPI</label>
|
||||
<label htmlFor="openapi">{t('server.typeOpenapi')}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -500,9 +500,9 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
}))}
|
||||
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
|
||||
>
|
||||
<option value="header">Header</option>
|
||||
<option value="query">Query</option>
|
||||
<option value="cookie">Cookie</option>
|
||||
<option value="header">{t('server.openapi.apiKeyInHeader')}</option>
|
||||
<option value="query">{t('server.openapi.apiKeyInQuery')}</option>
|
||||
<option value="cookie">{t('server.openapi.apiKeyInCookie')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -537,9 +537,9 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
|
||||
}))}
|
||||
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
|
||||
>
|
||||
<option value="basic">Basic</option>
|
||||
<option value="bearer">Bearer</option>
|
||||
<option value="digest">Digest</option>
|
||||
<option value="basic">{t('server.openapi.httpSchemeBasic')}</option>
|
||||
<option value="bearer">{t('server.openapi.httpSchemeBearer')}</option>
|
||||
<option value="digest">{t('server.openapi.httpSchemeDigest')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const DiscordIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
@@ -11,7 +13,7 @@ export const DiscordIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
fill="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<title>Discord</title>
|
||||
<title>{t('common.discord')}</title>
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const GitHubIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
@@ -11,7 +13,7 @@ export const GitHubIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
fill="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<title>GitHub</title>
|
||||
<title>{t('common.github')}</title>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const SponsorIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
@@ -11,7 +13,7 @@ export const SponsorIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
fill="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<title>Sponsor</title>
|
||||
<title>{t('sponsor.label')}</title>
|
||||
<path d="M17.625 1.499c-2.32 0-4.354 1.203-5.625 3.03-1.271-1.827-3.305-3.03-5.625-3.03C3.129 1.499 0 4.253 0 8.249c0 4.275 3.068 7.847 5.828 10.227a33.14 33.14 0 0 0 5.616 3.876l.028.017.008.003-.001.003c.163.085.342.126.521.125.179.001.358-.041.521-.125l-.001-.003.008-.003.028-.017a33.14 33.14 0 0 0 5.616-3.876C20.932 16.096 24 12.524 24 8.249c0-3.996-3.129-6.75-6.375-6.75zm-.919 15.275a30.766 30.766 0 0 1-4.703 3.316l-.004-.002-.004.002a30.955 30.955 0 0 1-4.703-3.316c-2.677-2.307-5.047-5.298-5.047-8.523 0-2.754 2.121-4.5 4.125-4.5 2.06 0 3.914 1.479 4.544 3.684.143.495.596.797 1.086.796.49.001.943-.302 1.085-.796.63-2.205 2.484-3.684 4.544-3.684 2.004 0 4.125 1.746 4.125 4.5 0 3.225-2.37 6.216-5.048 8.523z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const WeChatIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
@@ -11,7 +13,7 @@ export const WeChatIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
fill="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<title>WeChat</title>
|
||||
<title>{t('common.wechat')}</title>
|
||||
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,8 @@ const LanguageSwitch: React.FC = () => {
|
||||
// Available languages
|
||||
const availableLanguages = [
|
||||
{ code: 'en', label: 'English' },
|
||||
{ code: 'zh', label: '中文' }
|
||||
{ code: 'zh', label: '中文' },
|
||||
{ code: 'fr', label: 'Français' }
|
||||
];
|
||||
|
||||
// Update current language when it changes
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
@@ -19,6 +20,8 @@ const Toast: React.FC<ToastProps> = ({
|
||||
onClose,
|
||||
visible
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -83,7 +86,7 @@ const Toast: React.FC<ToastProps> = ({
|
||||
`hover:bg-${type}-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-${type}-500`
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">Dismiss</span>
|
||||
<span className="sr-only">{t('common.dismiss')}</span>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
359
frontend/src/contexts/ServerContext.tsx
Normal file
359
frontend/src/contexts/ServerContext.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
import React, { createContext, useState, useEffect, useRef, useCallback, useContext } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Server, ApiResponse } from '@/types';
|
||||
import { apiGet, apiPost, apiDelete } from '../utils/fetchInterceptor';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
// Configuration options
|
||||
const CONFIG = {
|
||||
// Initialization phase configuration
|
||||
startup: {
|
||||
maxAttempts: 60, // Maximum number of attempts during initialization
|
||||
pollingInterval: 3000, // Polling interval during initialization (3 seconds)
|
||||
},
|
||||
// Normal operation phase configuration
|
||||
normal: {
|
||||
pollingInterval: 10000, // Polling interval during normal operation (10 seconds)
|
||||
},
|
||||
};
|
||||
|
||||
// Context type definition
|
||||
interface ServerContextType {
|
||||
servers: Server[];
|
||||
error: string | null;
|
||||
setError: (error: string | null) => void;
|
||||
isLoading: boolean;
|
||||
fetchAttempts: number;
|
||||
triggerRefresh: () => void;
|
||||
refreshIfNeeded: () => void; // Smart refresh with debounce
|
||||
handleServerAdd: () => void;
|
||||
handleServerEdit: (server: Server) => Promise<any>;
|
||||
handleServerRemove: (serverName: string) => Promise<boolean>;
|
||||
handleServerToggle: (server: Server, enabled: boolean) => Promise<boolean>;
|
||||
}
|
||||
|
||||
// Create Context
|
||||
const ServerContext = createContext<ServerContextType | undefined>(undefined);
|
||||
|
||||
// Provider component
|
||||
export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { t } = useTranslation();
|
||||
const { auth } = useAuth();
|
||||
const [servers, setServers] = useState<Server[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
const [fetchAttempts, setFetchAttempts] = useState(0);
|
||||
|
||||
// Timer reference for polling
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
// Track current attempt count to avoid dependency cycles
|
||||
const attemptsRef = useRef<number>(0);
|
||||
// Track last fetch time to implement smart refresh
|
||||
const lastFetchTimeRef = useRef<number>(0);
|
||||
// Minimum interval between manual refreshes (5 seconds in dev, 3 seconds in prod)
|
||||
const MIN_REFRESH_INTERVAL = process.env.NODE_ENV === 'development' ? 5000 : 3000;
|
||||
|
||||
// Clear the timer
|
||||
const clearTimer = () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Start normal polling
|
||||
const startNormalPolling = useCallback((options?: { immediate?: boolean }) => {
|
||||
const immediate = options?.immediate ?? true;
|
||||
// Ensure no other timers are running
|
||||
clearTimer();
|
||||
|
||||
const fetchServers = async () => {
|
||||
try {
|
||||
console.log('[ServerContext] Fetching servers from API...');
|
||||
const data = await apiGet('/servers');
|
||||
|
||||
// Update last fetch time
|
||||
lastFetchTimeRef.current = Date.now();
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data);
|
||||
} else if (data && Array.isArray(data)) {
|
||||
setServers(data);
|
||||
} else {
|
||||
console.error('Invalid server data format:', data);
|
||||
setServers([]);
|
||||
}
|
||||
|
||||
// Reset error state
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error fetching servers during normal polling:', err);
|
||||
|
||||
// Use friendly error message
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'));
|
||||
} else if (
|
||||
err instanceof TypeError &&
|
||||
(err.message.includes('NetworkError') || err.message.includes('Failed to fetch'))
|
||||
) {
|
||||
setError(t('errors.serverConnection'));
|
||||
} else {
|
||||
setError(t('errors.serverFetch'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Execute immediately unless explicitly skipped
|
||||
if (immediate) {
|
||||
fetchServers();
|
||||
}
|
||||
|
||||
// Set up regular polling
|
||||
intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval);
|
||||
}, [t]);
|
||||
|
||||
// Watch for authentication status changes
|
||||
useEffect(() => {
|
||||
if (auth.isAuthenticated) {
|
||||
console.log('[ServerContext] User authenticated, triggering refresh');
|
||||
// When user logs in, trigger a refresh to load servers
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
} else {
|
||||
console.log('[ServerContext] User not authenticated, clearing data and stopping polling');
|
||||
// When user logs out, clear data and stop polling
|
||||
clearTimer();
|
||||
setServers([]);
|
||||
setIsInitialLoading(false);
|
||||
setError(null);
|
||||
}
|
||||
}, [auth.isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
// If not authenticated, don't poll
|
||||
if (!auth.isAuthenticated) {
|
||||
console.log('[ServerContext] User not authenticated, skipping polling setup');
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset attempt count
|
||||
if (refreshKey > 0) {
|
||||
attemptsRef.current = 0;
|
||||
setFetchAttempts(0);
|
||||
}
|
||||
|
||||
// Initialization phase request function
|
||||
const fetchInitialData = async () => {
|
||||
try {
|
||||
console.log('[ServerContext] Initial fetch - attempt', attemptsRef.current + 1);
|
||||
const data = await apiGet('/servers');
|
||||
|
||||
// Update last fetch time
|
||||
lastFetchTimeRef.current = Date.now();
|
||||
|
||||
// Handle API response wrapper object, extract data field
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data);
|
||||
setIsInitialLoading(false);
|
||||
// Initialization successful, start normal polling (skip immediate to avoid duplicate fetch)
|
||||
startNormalPolling({ immediate: false });
|
||||
return true;
|
||||
} else if (data && Array.isArray(data)) {
|
||||
// Compatibility handling, if API directly returns array
|
||||
setServers(data);
|
||||
setIsInitialLoading(false);
|
||||
// Initialization successful, start normal polling (skip immediate to avoid duplicate fetch)
|
||||
startNormalPolling({ immediate: false });
|
||||
return true;
|
||||
} else {
|
||||
// If data format is not as expected, set to empty array
|
||||
console.error('Invalid server data format:', data);
|
||||
setServers([]);
|
||||
setIsInitialLoading(false);
|
||||
// Initialization successful but data is empty, start normal polling (skip immediate)
|
||||
startNormalPolling({ immediate: false });
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
// Increment attempt count, use ref to avoid triggering effect rerun
|
||||
attemptsRef.current += 1;
|
||||
console.error(`Initial loading attempt ${attemptsRef.current} failed:`, err);
|
||||
|
||||
// Update state for display
|
||||
setFetchAttempts(attemptsRef.current);
|
||||
|
||||
// Set appropriate error message
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'));
|
||||
} else {
|
||||
setError(t('errors.initialStartup'));
|
||||
}
|
||||
|
||||
// If maximum attempt count is exceeded, give up initialization and switch to normal polling
|
||||
if (attemptsRef.current >= CONFIG.startup.maxAttempts) {
|
||||
console.log('Maximum startup attempts reached, switching to normal polling');
|
||||
setIsInitialLoading(false);
|
||||
// Clear initialization polling
|
||||
clearTimer();
|
||||
// Switch to normal polling mode
|
||||
startNormalPolling();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// On component mount, set appropriate polling based on current state
|
||||
if (isInitialLoading) {
|
||||
// Ensure no other timers are running
|
||||
clearTimer();
|
||||
|
||||
// Execute initial request immediately
|
||||
fetchInitialData();
|
||||
|
||||
// Set polling interval for initialization phase
|
||||
intervalRef.current = setInterval(fetchInitialData, CONFIG.startup.pollingInterval);
|
||||
console.log(`Started initial polling with interval: ${CONFIG.startup.pollingInterval}ms`);
|
||||
} else {
|
||||
// Initialization completed, start normal polling
|
||||
startNormalPolling();
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
clearTimer();
|
||||
};
|
||||
}, [refreshKey, t, isInitialLoading, startNormalPolling]);
|
||||
|
||||
// Manually trigger refresh (always refreshes)
|
||||
const triggerRefresh = useCallback(() => {
|
||||
// Clear current timer
|
||||
clearTimer();
|
||||
|
||||
// If in initialization phase, reset initialization state
|
||||
if (isInitialLoading) {
|
||||
setIsInitialLoading(true);
|
||||
attemptsRef.current = 0;
|
||||
setFetchAttempts(0);
|
||||
}
|
||||
|
||||
// Change in refreshKey will trigger useEffect to run again
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
}, [isInitialLoading]);
|
||||
|
||||
// Smart refresh with debounce (only refresh if enough time has passed)
|
||||
const refreshIfNeeded = useCallback(() => {
|
||||
const now = Date.now();
|
||||
const timeSinceLastFetch = now - lastFetchTimeRef.current;
|
||||
|
||||
// Log who is calling this
|
||||
console.log('[ServerContext] refreshIfNeeded called, time since last fetch:', timeSinceLastFetch, 'ms');
|
||||
|
||||
// Only refresh if enough time has passed since last fetch
|
||||
if (timeSinceLastFetch >= MIN_REFRESH_INTERVAL) {
|
||||
console.log('[ServerContext] Triggering refresh (exceeded MIN_REFRESH_INTERVAL:', MIN_REFRESH_INTERVAL, 'ms)');
|
||||
triggerRefresh();
|
||||
} else {
|
||||
console.log('[ServerContext] Skipping refresh (MIN_REFRESH_INTERVAL:', MIN_REFRESH_INTERVAL, 'ms, time since last:', timeSinceLastFetch, 'ms)');
|
||||
}
|
||||
}, [triggerRefresh]);
|
||||
|
||||
// Server related operations
|
||||
const handleServerAdd = useCallback(() => {
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
}, []);
|
||||
|
||||
const handleServerEdit = useCallback(async (server: Server) => {
|
||||
try {
|
||||
// Fetch settings to get the full server config before editing
|
||||
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> =
|
||||
await apiGet('/settings');
|
||||
|
||||
if (
|
||||
settingsData &&
|
||||
settingsData.success &&
|
||||
settingsData.data &&
|
||||
settingsData.data.mcpServers &&
|
||||
settingsData.data.mcpServers[server.name]
|
||||
) {
|
||||
const serverConfig = settingsData.data.mcpServers[server.name];
|
||||
return {
|
||||
name: server.name,
|
||||
status: server.status,
|
||||
tools: server.tools || [],
|
||||
config: serverConfig,
|
||||
};
|
||||
} else {
|
||||
console.error('Failed to get server config from settings:', settingsData);
|
||||
setError(t('server.invalidConfig', { serverName: server.name }));
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching server settings:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return null;
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleServerRemove = useCallback(async (serverName: string) => {
|
||||
try {
|
||||
const result = await apiDelete(`/servers/${serverName}`);
|
||||
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('server.deleteError', { serverName }));
|
||||
return false;
|
||||
}
|
||||
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(t('errors.general') + ': ' + (err instanceof Error ? err.message : String(err)));
|
||||
return false;
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleServerToggle = useCallback(async (server: Server, enabled: boolean) => {
|
||||
try {
|
||||
const result = await apiPost(`/servers/${server.name}/toggle`, { enabled });
|
||||
|
||||
if (!result || !result.success) {
|
||||
console.error('Failed to toggle server:', result);
|
||||
setError(result?.message || t('server.toggleError', { serverName: server.name }));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update the UI immediately to reflect the change
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error toggling server:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const value: ServerContextType = {
|
||||
servers,
|
||||
error,
|
||||
setError,
|
||||
isLoading: isInitialLoading,
|
||||
fetchAttempts,
|
||||
triggerRefresh,
|
||||
refreshIfNeeded,
|
||||
handleServerAdd,
|
||||
handleServerEdit,
|
||||
handleServerRemove,
|
||||
handleServerToggle,
|
||||
};
|
||||
|
||||
return <ServerContext.Provider value={value}>{children}</ServerContext.Provider>;
|
||||
};
|
||||
|
||||
// Custom hook to use the Server context
|
||||
export const useServerContext = () => {
|
||||
const context = useContext(ServerContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useServerContext must be used within a ServerProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,272 +1,19 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Server, ApiResponse } from '@/types';
|
||||
import { apiGet, apiPost, apiDelete } from '../utils/fetchInterceptor';
|
||||
// This hook now delegates to the ServerContext to avoid duplicate requests
|
||||
// All components will share the same server data and polling mechanism
|
||||
import { useServerContext } from '@/contexts/ServerContext';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
// Configuration options
|
||||
const CONFIG = {
|
||||
// Initialization phase configuration
|
||||
startup: {
|
||||
maxAttempts: 60, // Maximum number of attempts during initialization
|
||||
pollingInterval: 3000, // Polling interval during initialization (3 seconds)
|
||||
},
|
||||
// Normal operation phase configuration
|
||||
normal: {
|
||||
pollingInterval: 10000, // Polling interval during normal operation (10 seconds)
|
||||
},
|
||||
};
|
||||
|
||||
export const useServerData = () => {
|
||||
const { t } = useTranslation();
|
||||
const [servers, setServers] = useState<Server[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
const [fetchAttempts, setFetchAttempts] = useState(0);
|
||||
|
||||
// Timer reference for polling
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
// Track current attempt count to avoid dependency cycles
|
||||
const attemptsRef = useRef<number>(0);
|
||||
|
||||
// Clear the timer
|
||||
const clearTimer = () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Start normal polling
|
||||
const startNormalPolling = useCallback(() => {
|
||||
// Ensure no other timers are running
|
||||
clearTimer();
|
||||
|
||||
const fetchServers = async () => {
|
||||
try {
|
||||
const data = await apiGet('/servers');
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data);
|
||||
} else if (data && Array.isArray(data)) {
|
||||
setServers(data);
|
||||
} else {
|
||||
console.error('Invalid server data format:', data);
|
||||
setServers([]);
|
||||
}
|
||||
|
||||
// Reset error state
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error fetching servers during normal polling:', err);
|
||||
|
||||
// Use friendly error message
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'));
|
||||
} else if (
|
||||
err instanceof TypeError &&
|
||||
(err.message.includes('NetworkError') || err.message.includes('Failed to fetch'))
|
||||
) {
|
||||
setError(t('errors.serverConnection'));
|
||||
} else {
|
||||
setError(t('errors.serverFetch'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Execute immediately
|
||||
fetchServers();
|
||||
|
||||
// Set up regular polling
|
||||
intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval);
|
||||
}, [t]);
|
||||
export const useServerData = (options?: { refreshOnMount?: boolean }) => {
|
||||
const context = useServerContext();
|
||||
const { refreshIfNeeded } = context;
|
||||
|
||||
// Optionally refresh on mount for pages that need fresh data
|
||||
useEffect(() => {
|
||||
// Reset attempt count
|
||||
if (refreshKey > 0) {
|
||||
attemptsRef.current = 0;
|
||||
setFetchAttempts(0);
|
||||
if (options?.refreshOnMount) {
|
||||
refreshIfNeeded();
|
||||
}
|
||||
}, [options?.refreshOnMount, refreshIfNeeded]);
|
||||
|
||||
// Initialization phase request function
|
||||
const fetchInitialData = async () => {
|
||||
try {
|
||||
const data = await apiGet('/servers');
|
||||
|
||||
// Handle API response wrapper object, extract data field
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data);
|
||||
setIsInitialLoading(false);
|
||||
// Initialization successful, start normal polling
|
||||
startNormalPolling();
|
||||
return true;
|
||||
} else if (data && Array.isArray(data)) {
|
||||
// Compatibility handling, if API directly returns array
|
||||
setServers(data);
|
||||
setIsInitialLoading(false);
|
||||
// Initialization successful, start normal polling
|
||||
startNormalPolling();
|
||||
return true;
|
||||
} else {
|
||||
// If data format is not as expected, set to empty array
|
||||
console.error('Invalid server data format:', data);
|
||||
setServers([]);
|
||||
setIsInitialLoading(false);
|
||||
// Initialization successful but data is empty, start normal polling
|
||||
startNormalPolling();
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
// Increment attempt count, use ref to avoid triggering effect rerun
|
||||
attemptsRef.current += 1;
|
||||
console.error(`Initial loading attempt ${attemptsRef.current} failed:`, err);
|
||||
|
||||
// Update state for display
|
||||
setFetchAttempts(attemptsRef.current);
|
||||
|
||||
// Set appropriate error message
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'));
|
||||
} else {
|
||||
setError(t('errors.initialStartup'));
|
||||
}
|
||||
|
||||
// If maximum attempt count is exceeded, give up initialization and switch to normal polling
|
||||
if (attemptsRef.current >= CONFIG.startup.maxAttempts) {
|
||||
console.log('Maximum startup attempts reached, switching to normal polling');
|
||||
setIsInitialLoading(false);
|
||||
// Clear initialization polling
|
||||
clearTimer();
|
||||
// Switch to normal polling mode
|
||||
startNormalPolling();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// On component mount, set appropriate polling based on current state
|
||||
if (isInitialLoading) {
|
||||
// Ensure no other timers are running
|
||||
clearTimer();
|
||||
|
||||
// Execute initial request immediately
|
||||
fetchInitialData();
|
||||
|
||||
// Set polling interval for initialization phase
|
||||
intervalRef.current = setInterval(fetchInitialData, CONFIG.startup.pollingInterval);
|
||||
console.log(`Started initial polling with interval: ${CONFIG.startup.pollingInterval}ms`);
|
||||
} else {
|
||||
// Initialization completed, start normal polling
|
||||
startNormalPolling();
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
clearTimer();
|
||||
};
|
||||
}, [refreshKey, t, isInitialLoading, startNormalPolling]);
|
||||
|
||||
// Manually trigger refresh
|
||||
const triggerRefresh = () => {
|
||||
// Clear current timer
|
||||
clearTimer();
|
||||
|
||||
// If in initialization phase, reset initialization state
|
||||
if (isInitialLoading) {
|
||||
setIsInitialLoading(true);
|
||||
attemptsRef.current = 0;
|
||||
setFetchAttempts(0);
|
||||
}
|
||||
|
||||
// Change in refreshKey will trigger useEffect to run again
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
};
|
||||
|
||||
// Server related operations
|
||||
const handleServerAdd = () => {
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
};
|
||||
|
||||
const handleServerEdit = async (server: Server) => {
|
||||
try {
|
||||
// Fetch settings to get the full server config before editing
|
||||
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> =
|
||||
await apiGet('/settings');
|
||||
|
||||
if (
|
||||
settingsData &&
|
||||
settingsData.success &&
|
||||
settingsData.data &&
|
||||
settingsData.data.mcpServers &&
|
||||
settingsData.data.mcpServers[server.name]
|
||||
) {
|
||||
const serverConfig = settingsData.data.mcpServers[server.name];
|
||||
return {
|
||||
name: server.name,
|
||||
status: server.status,
|
||||
tools: server.tools || [],
|
||||
config: serverConfig,
|
||||
};
|
||||
} else {
|
||||
console.error('Failed to get server config from settings:', settingsData);
|
||||
setError(t('server.invalidConfig', { serverName: server.name }));
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching server settings:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleServerRemove = async (serverName: string) => {
|
||||
try {
|
||||
const result = await apiDelete(`/servers/${serverName}`);
|
||||
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('server.deleteError', { serverName }));
|
||||
return false;
|
||||
}
|
||||
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(t('errors.general') + ': ' + (err instanceof Error ? err.message : String(err)));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleServerToggle = async (server: Server, enabled: boolean) => {
|
||||
try {
|
||||
const result = await apiPost(`/servers/${server.name}/toggle`, { enabled });
|
||||
|
||||
if (!result || !result.success) {
|
||||
console.error('Failed to toggle server:', result);
|
||||
setError(result?.message || t('server.toggleError', { serverName: server.name }));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update the UI immediately to reflect the change
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error toggling server:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
servers,
|
||||
error,
|
||||
setError,
|
||||
isLoading: isInitialLoading,
|
||||
fetchAttempts,
|
||||
triggerRefresh,
|
||||
handleServerAdd,
|
||||
handleServerEdit,
|
||||
handleServerRemove,
|
||||
handleServerToggle,
|
||||
};
|
||||
return context;
|
||||
};
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ export const useSettingsData = () => {
|
||||
|
||||
const [mcpRouterConfig, setMCPRouterConfig] = useState<MCPRouterConfig>({
|
||||
apiKey: '',
|
||||
referer: 'https://mcphub.app',
|
||||
referer: 'https://www.mcphubx.com',
|
||||
title: 'MCPHub',
|
||||
baseUrl: 'https://api.mcprouter.to/v1',
|
||||
});
|
||||
@@ -130,7 +130,7 @@ export const useSettingsData = () => {
|
||||
if (data.success && data.data?.systemConfig?.mcpRouter) {
|
||||
setMCPRouterConfig({
|
||||
apiKey: data.data.systemConfig.mcpRouter.apiKey || '',
|
||||
referer: data.data.systemConfig.mcpRouter.referer || 'https://mcphub.app',
|
||||
referer: data.data.systemConfig.mcpRouter.referer || 'https://www.mcphubx.com',
|
||||
title: data.data.systemConfig.mcpRouter.title || 'MCPHub',
|
||||
baseUrl: data.data.systemConfig.mcpRouter.baseUrl || 'https://api.mcprouter.to/v1',
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
// Import shared translations from root locales directory
|
||||
import enTranslation from '../../locales/en.json';
|
||||
import zhTranslation from '../../locales/zh.json';
|
||||
import frTranslation from '../../locales/fr.json';
|
||||
|
||||
i18n
|
||||
// Detect user language
|
||||
@@ -20,6 +21,9 @@ i18n
|
||||
zh: {
|
||||
translation: zhTranslation,
|
||||
},
|
||||
fr: {
|
||||
translation: frTranslation,
|
||||
},
|
||||
},
|
||||
fallbackLng: 'en',
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useServerData } from '@/hooks/useServerData';
|
||||
import { Server } from '@/types';
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { servers, error, setError, isLoading } = useServerData();
|
||||
const { servers, error, setError, isLoading } = useServerData({ refreshOnMount: true });
|
||||
|
||||
// Calculate server statistics
|
||||
const serverStats = {
|
||||
total: servers.length,
|
||||
online: servers.filter(server => server.status === 'connected').length,
|
||||
offline: servers.filter(server => server.status === 'disconnected').length,
|
||||
connecting: servers.filter(server => server.status === 'connecting').length
|
||||
online: servers.filter((server: Server) => server.status === 'connected').length,
|
||||
offline: servers.filter((server: Server) => server.status === 'disconnected').length,
|
||||
connecting: servers.filter((server: Server) => server.status === 'connecting').length
|
||||
};
|
||||
|
||||
// Map status to translation keys
|
||||
const statusTranslations = {
|
||||
const statusTranslations: Record<string, string> = {
|
||||
connected: 'status.online',
|
||||
disconnected: 'status.offline',
|
||||
connecting: 'status.connecting'
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -17,7 +17,7 @@ const GroupsPage: React.FC = () => {
|
||||
deleteGroup,
|
||||
triggerRefresh
|
||||
} = useGroupData();
|
||||
const { servers } = useServerData();
|
||||
const { servers } = useServerData({ refreshOnMount: true });
|
||||
|
||||
const [editingGroup, setEditingGroup] = useState<Group | null>(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
|
||||
@@ -21,7 +21,7 @@ const ServersPage: React.FC = () => {
|
||||
handleServerRemove,
|
||||
handleServerToggle,
|
||||
triggerRefresh
|
||||
} = useServerData();
|
||||
} = useServerData({ refreshOnMount: true });
|
||||
const [editingServer, setEditingServer] = useState<Server | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [showDxtUpload, setShowDxtUpload] = useState(false);
|
||||
|
||||
@@ -43,7 +43,7 @@ const SettingsPage: React.FC = () => {
|
||||
baseUrl: string;
|
||||
}>({
|
||||
apiKey: '',
|
||||
referer: 'https://mcphub.app',
|
||||
referer: 'https://www.mcphubx.com',
|
||||
title: 'MCPHub',
|
||||
baseUrl: 'https://api.mcprouter.to/v1',
|
||||
});
|
||||
@@ -88,7 +88,7 @@ const SettingsPage: React.FC = () => {
|
||||
if (mcpRouterConfig) {
|
||||
setTempMCPRouterConfig({
|
||||
apiKey: mcpRouterConfig.apiKey || '',
|
||||
referer: mcpRouterConfig.referer || 'https://mcphub.app',
|
||||
referer: mcpRouterConfig.referer || 'https://www.mcphubx.com',
|
||||
title: mcpRouterConfig.title || 'MCPHub',
|
||||
baseUrl: mcpRouterConfig.baseUrl || 'https://api.mcprouter.to/v1',
|
||||
});
|
||||
@@ -399,54 +399,6 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.mcpRouterReferer')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.mcpRouterRefererDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempMCPRouterConfig.referer}
|
||||
onChange={(e) => handleMCPRouterConfigChange('referer', e.target.value)}
|
||||
placeholder={t('settings.mcpRouterRefererPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveMCPRouterConfig('referer')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.mcpRouterTitle')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.mcpRouterTitleDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempMCPRouterConfig.title}
|
||||
onChange={(e) => handleMCPRouterConfigChange('title', e.target.value)}
|
||||
placeholder={t('settings.mcpRouterTitlePlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveMCPRouterConfig('title')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.mcpRouterBaseUrl')}</h3>
|
||||
|
||||
@@ -25,22 +25,15 @@ export const callTool = async (
|
||||
): Promise<ToolCallResult> => {
|
||||
try {
|
||||
// Construct the URL with optional server parameter
|
||||
const url = server ? `/tools/call/${server}` : '/tools/call';
|
||||
const url = server ? `/tools/${server}/${request.toolName}` : '/tools/call';
|
||||
|
||||
const response = await apiPost<any>(
|
||||
url,
|
||||
{
|
||||
toolName: request.toolName,
|
||||
arguments: request.arguments,
|
||||
const response = await apiPost<any>(url, request.arguments, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('mcphub_token')}`, // Add bearer auth for MCP routing
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('mcphub_token')}`, // Add bearer auth for MCP routing
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if (!response.success) {
|
||||
if (response.success === false) {
|
||||
return {
|
||||
success: false,
|
||||
error: response.message || 'Tool call failed',
|
||||
@@ -49,7 +42,7 @@ export const callTool = async (
|
||||
|
||||
return {
|
||||
success: true,
|
||||
content: response.data?.content || [],
|
||||
content: response?.content || [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error calling tool:', error);
|
||||
|
||||
@@ -93,6 +93,10 @@
|
||||
"updateError": "Failed to update server",
|
||||
"editTitle": "Edit Server: {{serverName}}",
|
||||
"type": "Server Type",
|
||||
"typeStdio": "STDIO",
|
||||
"typeSse": "SSE",
|
||||
"typeStreamableHttp": "Streamable HTTP",
|
||||
"typeOpenapi": "OpenAPI",
|
||||
"command": "Command",
|
||||
"arguments": "Arguments",
|
||||
"envVars": "Environment Variables",
|
||||
@@ -145,11 +149,17 @@
|
||||
"httpAuthConfig": "HTTP Authentication Configuration",
|
||||
"httpScheme": "Authentication Scheme",
|
||||
"httpCredentials": "Credentials",
|
||||
"httpSchemeBasic": "Basic",
|
||||
"httpSchemeBearer": "Bearer",
|
||||
"httpSchemeDigest": "Digest",
|
||||
"oauth2Config": "OAuth 2.0 Configuration",
|
||||
"oauth2Token": "Access Token",
|
||||
"openIdConnectConfig": "OpenID Connect Configuration",
|
||||
"openIdConnectUrl": "Discovery URL",
|
||||
"openIdConnectToken": "ID Token"
|
||||
"openIdConnectToken": "ID Token",
|
||||
"apiKeyInHeader": "Header",
|
||||
"apiKeyInQuery": "Query",
|
||||
"apiKeyInCookie": "Cookie"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
@@ -190,7 +200,13 @@
|
||||
"copyFailed": "Copy failed",
|
||||
"close": "Close",
|
||||
"confirm": "Confirm",
|
||||
"language": "Language"
|
||||
"language": "Language",
|
||||
"true": "True",
|
||||
"false": "False",
|
||||
"dismiss": "Dismiss",
|
||||
"github": "GitHub",
|
||||
"wechat": "WeChat",
|
||||
"discord": "Discord"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -474,7 +490,7 @@
|
||||
"mcpRouterApiKeyPlaceholder": "Enter MCPRouter API key",
|
||||
"mcpRouterReferer": "Referer",
|
||||
"mcpRouterRefererDescription": "Referer header for MCPRouter API requests",
|
||||
"mcpRouterRefererPlaceholder": "https://mcphub.app",
|
||||
"mcpRouterRefererPlaceholder": "https://www.mcphubx.com",
|
||||
"mcpRouterTitle": "Title",
|
||||
"mcpRouterTitleDescription": "Title header for MCPRouter API requests",
|
||||
"mcpRouterTitlePlaceholder": "MCPHub",
|
||||
|
||||
621
locales/fr.json
Normal file
621
locales/fr.json
Normal file
@@ -0,0 +1,621 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "Tableau de bord MCPHub",
|
||||
"error": "Erreur",
|
||||
"closeButton": "Fermer",
|
||||
"noServers": "Aucun serveur MCP disponible",
|
||||
"loading": "Chargement...",
|
||||
"logout": "Déconnexion",
|
||||
"profile": "Profil",
|
||||
"changePassword": "Changer le mot de passe",
|
||||
"toggleSidebar": "Basculer la barre latérale",
|
||||
"welcomeUser": "Bienvenue, {{username}}",
|
||||
"name": "MCPHub"
|
||||
},
|
||||
"about": {
|
||||
"title": "À propos",
|
||||
"versionInfo": "Version MCPHub : {{version}}",
|
||||
"newVersion": "Nouvelle version disponible !",
|
||||
"currentVersion": "Version actuelle",
|
||||
"newVersionAvailable": "La nouvelle version {{version}} est disponible",
|
||||
"viewOnGitHub": "Voir sur GitHub",
|
||||
"checkForUpdates": "Vérifier les mises à jour",
|
||||
"checking": "Vérification des mises à jour..."
|
||||
},
|
||||
"profile": {
|
||||
"viewProfile": "Voir le profil",
|
||||
"userCenter": "Centre utilisateur"
|
||||
},
|
||||
"sponsor": {
|
||||
"label": "Sponsor",
|
||||
"title": "Soutenir le projet",
|
||||
"rewardAlt": "QR Code de récompense",
|
||||
"supportMessage": "Soutenez le développement de MCPHub en m'offrant un café !",
|
||||
"supportButton": "Soutenir sur Ko-fi"
|
||||
},
|
||||
"wechat": {
|
||||
"label": "WeChat",
|
||||
"title": "Se connecter via WeChat",
|
||||
"qrCodeAlt": "QR Code WeChat",
|
||||
"scanMessage": "Scannez ce QR code pour nous contacter sur WeChat"
|
||||
},
|
||||
"discord": {
|
||||
"label": "Discord",
|
||||
"title": "Rejoignez notre serveur Discord",
|
||||
"community": "Rejoignez notre communauté grandissante sur Discord pour du support, des discussions et des mises à jour !"
|
||||
},
|
||||
"theme": {
|
||||
"title": "Thème",
|
||||
"light": "Clair",
|
||||
"dark": "Sombre",
|
||||
"system": "Système"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Connexion",
|
||||
"loginTitle": "Se connecter à MCPHub",
|
||||
"slogan": "Le Hub unifié pour les serveurs MCP",
|
||||
"subtitle": "Plateforme de gestion centralisée pour les serveurs Model Context Protocol. Organisez, surveillez et mettez à l'échelle plusieurs serveurs MCP avec des stratégies de routage flexibles.",
|
||||
"username": "Nom d'utilisateur",
|
||||
"password": "Mot de passe",
|
||||
"loggingIn": "Connexion en cours...",
|
||||
"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",
|
||||
"currentPassword": "Mot de passe actuel",
|
||||
"newPassword": "Nouveau mot de passe",
|
||||
"confirmPassword": "Confirmer le mot de passe",
|
||||
"passwordsNotMatch": "Le nouveau mot de passe et la confirmation ne correspondent pas",
|
||||
"changePasswordSuccess": "Mot de passe changé avec succès",
|
||||
"changePasswordError": "Échec du changement de mot de passe",
|
||||
"changePassword": "Changer le mot de passe",
|
||||
"passwordChanged": "Mot de passe changé avec succès",
|
||||
"passwordChangeError": "Échec du changement de mot de passe"
|
||||
},
|
||||
"server": {
|
||||
"addServer": "Ajouter un serveur",
|
||||
"add": "Ajouter",
|
||||
"edit": "Modifier",
|
||||
"delete": "Supprimer",
|
||||
"confirmDelete": "Êtes-vous sûr de vouloir supprimer ce serveur ?",
|
||||
"deleteWarning": "La suppression du serveur '{{name}}' le supprimera ainsi que toutes ses données. Cette action est irréversible.",
|
||||
"status": "Statut",
|
||||
"tools": "Outils",
|
||||
"prompts": "Invites",
|
||||
"name": "Nom du serveur",
|
||||
"url": "URL du serveur",
|
||||
"apiKey": "Clé API",
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
"invalidConfig": "Impossible de trouver les données de configuration pour {{serverName}}",
|
||||
"addError": "Échec de l'ajout du serveur",
|
||||
"editError": "Échec de la modification du serveur {{serverName}}",
|
||||
"deleteError": "Échec de la suppression du serveur {{serverName}}",
|
||||
"updateError": "Échec de la mise à jour du serveur",
|
||||
"editTitle": "Modifier le serveur : {{serverName}}",
|
||||
"type": "Type de serveur",
|
||||
"typeStdio": "STDIO",
|
||||
"typeSse": "SSE",
|
||||
"typeStreamableHttp": "HTTP diffusable",
|
||||
"typeOpenapi": "OpenAPI",
|
||||
"command": "Commande",
|
||||
"arguments": "Arguments",
|
||||
"envVars": "Variables d'environnement",
|
||||
"headers": "En-têtes HTTP",
|
||||
"key": "clé",
|
||||
"value": "valeur",
|
||||
"enabled": "Activé",
|
||||
"enable": "Activer",
|
||||
"disable": "Désactiver",
|
||||
"requestOptions": "Configuration",
|
||||
"timeout": "Délai d'attente de la requête",
|
||||
"timeoutDescription": "Délai d'attente pour les requêtes vers le serveur MCP (ms)",
|
||||
"maxTotalTimeout": "Délai d'attente total maximum",
|
||||
"maxTotalTimeoutDescription": "Délai d'attente total maximum pour les requêtes envoyées au serveur MCP (ms) (à utiliser avec les notifications de progression)",
|
||||
"resetTimeoutOnProgress": "Réinitialiser le délai d'attente en cas de progression",
|
||||
"resetTimeoutOnProgressDescription": "Réinitialiser le délai d'attente lors des notifications de progression",
|
||||
"remove": "Retirer",
|
||||
"toggleError": "Échec du basculement du serveur {{serverName}}",
|
||||
"alreadyExists": "Le serveur {{serverName}} existe déjà",
|
||||
"invalidData": "Données de serveur invalides fournies",
|
||||
"notFound": "Serveur {{serverName}} non trouvé",
|
||||
"namePlaceholder": "Entrez le nom du serveur",
|
||||
"urlPlaceholder": "Entrez l'URL du serveur",
|
||||
"commandPlaceholder": "Entrez la commande",
|
||||
"argumentsPlaceholder": "Entrez les arguments",
|
||||
"errorDetails": "Détails de l'erreur",
|
||||
"viewErrorDetails": "Voir les détails de l'erreur",
|
||||
"confirmVariables": "Confirmer la configuration des variables",
|
||||
"variablesDetected": "Variables détectées dans la configuration. Veuillez confirmer que ces variables sont correctement configurées :",
|
||||
"detectedVariables": "Variables détectées",
|
||||
"confirmVariablesMessage": "Veuillez vous assurer que ces variables sont correctement définies dans votre environnement d'exécution. Continuer l'ajout du serveur ?",
|
||||
"confirmAndAdd": "Confirmer et ajouter",
|
||||
"openapi": {
|
||||
"inputMode": "Mode de saisie",
|
||||
"inputModeUrl": "URL de la spécification",
|
||||
"inputModeSchema": "Schéma JSON",
|
||||
"specUrl": "URL de la spécification OpenAPI",
|
||||
"schema": "Schéma JSON OpenAPI",
|
||||
"schemaHelp": "Collez votre schéma JSON OpenAPI complet ici",
|
||||
"security": "Type de sécurité",
|
||||
"securityNone": "Aucun",
|
||||
"securityApiKey": "Clé API",
|
||||
"securityHttp": "Authentification HTTP",
|
||||
"securityOAuth2": "OAuth 2.0",
|
||||
"securityOpenIdConnect": "OpenID Connect",
|
||||
"apiKeyConfig": "Configuration de la clé API",
|
||||
"apiKeyName": "Nom de l'en-tête/paramètre",
|
||||
"apiKeyIn": "Emplacement",
|
||||
"apiKeyValue": "Valeur de la clé API",
|
||||
"httpAuthConfig": "Configuration de l'authentification HTTP",
|
||||
"httpScheme": "Schéma d'authentification",
|
||||
"httpCredentials": "Identifiants",
|
||||
"httpSchemeBasic": "Basic",
|
||||
"httpSchemeBearer": "Bearer",
|
||||
"httpSchemeDigest": "Digest",
|
||||
"oauth2Config": "Configuration OAuth 2.0",
|
||||
"oauth2Token": "Jeton d'accès",
|
||||
"openIdConnectConfig": "Configuration OpenID Connect",
|
||||
"openIdConnectUrl": "URL de découverte",
|
||||
"openIdConnectToken": "Jeton d'identification",
|
||||
"apiKeyInHeader": "En-tête",
|
||||
"apiKeyInQuery": "Requête",
|
||||
"apiKeyInCookie": "Cookie"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"online": "En ligne",
|
||||
"offline": "Hors ligne",
|
||||
"connecting": "Connexion en cours"
|
||||
},
|
||||
"errors": {
|
||||
"general": "Une erreur est survenue",
|
||||
"network": "Erreur de connexion réseau. Veuillez vérifier votre connexion Internet",
|
||||
"serverConnection": "Impossible de se connecter au serveur. Veuillez vérifier si le serveur est en cours d'exécution",
|
||||
"serverAdd": "Échec de l'ajout du serveur. Veuillez vérifier l'état du serveur",
|
||||
"serverUpdate": "Échec de la modification du serveur {{serverName}}. Veuillez vérifier l'état du serveur",
|
||||
"serverFetch": "Échec de la récupération des données du serveur. Veuillez réessayer plus tard",
|
||||
"initialStartup": "Le serveur est peut-être en cours de démarrage. Veuillez patienter un instant car ce processus peut prendre du temps au premier lancement...",
|
||||
"serverInstall": "Échec de l'installation du serveur",
|
||||
"failedToFetchSettings": "Échec de la récupération des paramètres",
|
||||
"failedToUpdateRouteConfig": "Échec de la mise à jour de la configuration de routage",
|
||||
"failedToUpdateSmartRoutingConfig": "Échec de la mise à jour de la configuration du routage intelligent"
|
||||
},
|
||||
"common": {
|
||||
"processing": "En cours de traitement...",
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
"refresh": "Actualiser",
|
||||
"create": "Créer",
|
||||
"creating": "Création en cours...",
|
||||
"update": "Mettre à jour",
|
||||
"updating": "Mise à jour en cours...",
|
||||
"submitting": "Envoi en cours...",
|
||||
"delete": "Supprimer",
|
||||
"remove": "Retirer",
|
||||
"copy": "Copier",
|
||||
"copyId": "Copier l'ID",
|
||||
"copyUrl": "Copier l'URL",
|
||||
"copyJson": "Copier le JSON",
|
||||
"copySuccess": "Copié dans le presse-papiers",
|
||||
"copyFailed": "Échec de la copie",
|
||||
"close": "Fermer",
|
||||
"confirm": "Confirmer",
|
||||
"language": "Langue",
|
||||
"true": "Vrai",
|
||||
"false": "Faux",
|
||||
"dismiss": "Rejeter",
|
||||
"github": "GitHub",
|
||||
"wechat": "WeChat",
|
||||
"discord": "Discord"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Tableau de bord",
|
||||
"servers": "Serveurs",
|
||||
"groups": "Groupes",
|
||||
"users": "Utilisateurs",
|
||||
"settings": "Paramètres",
|
||||
"changePassword": "Changer le mot de passe",
|
||||
"market": "Marché",
|
||||
"cloud": "Marché Cloud",
|
||||
"logs": "Journaux"
|
||||
},
|
||||
"pages": {
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
"totalServers": "Total",
|
||||
"onlineServers": "En ligne",
|
||||
"offlineServers": "Hors ligne",
|
||||
"connectingServers": "En connexion",
|
||||
"recentServers": "Serveurs récents"
|
||||
},
|
||||
"servers": {
|
||||
"title": "Gestion des serveurs"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Gestion des groupes"
|
||||
},
|
||||
"users": {
|
||||
"title": "Gestion des utilisateurs"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Paramètres",
|
||||
"language": "Langue",
|
||||
"account": "Paramètres du compte",
|
||||
"password": "Changer le mot de passe",
|
||||
"appearance": "Apparence",
|
||||
"routeConfig": "Sécurité",
|
||||
"installConfig": "Installation",
|
||||
"smartRouting": "Routage intelligent"
|
||||
},
|
||||
"market": {
|
||||
"title": "Marché Hub - Marchés locaux et Cloud"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Journaux système"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
"filters": "Filtres",
|
||||
"search": "Rechercher dans les journaux...",
|
||||
"autoScroll": "Défilement automatique",
|
||||
"clearLogs": "Effacer les journaux",
|
||||
"loading": "Chargement des journaux...",
|
||||
"noLogs": "Aucun journal disponible.",
|
||||
"noMatch": "Aucun journal ne correspond aux filtres actuels.",
|
||||
"mainProcess": "Processus principal",
|
||||
"childProcess": "Processus enfant",
|
||||
"main": "Principal",
|
||||
"child": "Enfant"
|
||||
},
|
||||
"groups": {
|
||||
"add": "Ajouter",
|
||||
"addNew": "Ajouter un nouveau groupe",
|
||||
"edit": "Modifier le groupe",
|
||||
"delete": "Supprimer",
|
||||
"confirmDelete": "Êtes-vous sûr de vouloir supprimer ce groupe ?",
|
||||
"deleteWarning": "La suppression du groupe '{{name}}' le supprimera ainsi que toutes ses associations de serveurs. Cette action est irréversible.",
|
||||
"name": "Nom du groupe",
|
||||
"namePlaceholder": "Entrez le nom du groupe",
|
||||
"nameRequired": "Le nom du groupe est requis",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Entrez la description du groupe (facultatif)",
|
||||
"createError": "Échec de la création du groupe",
|
||||
"updateError": "Échec de la mise à jour du groupe",
|
||||
"deleteError": "Échec de la suppression du groupe",
|
||||
"serverAddError": "Échec de l'ajout du serveur au groupe",
|
||||
"serverRemoveError": "Échec de la suppression du serveur du groupe",
|
||||
"addServer": "Ajouter un serveur au groupe",
|
||||
"selectServer": "Sélectionnez un serveur à ajouter",
|
||||
"servers": "Serveurs dans le groupe",
|
||||
"remove": "Retirer",
|
||||
"noGroups": "Aucun groupe disponible. Créez un nouveau groupe pour commencer.",
|
||||
"noServers": "Aucun serveur dans ce groupe.",
|
||||
"noServerOptions": "Aucun serveur disponible",
|
||||
"serverCount": "{{count}} serveurs",
|
||||
"toolSelection": "Sélection d'outils",
|
||||
"toolsSelected": "Sélectionné",
|
||||
"allTools": "Tous",
|
||||
"selectedTools": "Outils sélectionnés",
|
||||
"selectAll": "Tout sélectionner",
|
||||
"selectNone": "Ne rien sélectionner",
|
||||
"configureTools": "Configurer les outils"
|
||||
},
|
||||
"market": {
|
||||
"title": "Installation locale",
|
||||
"official": "Officiel",
|
||||
"by": "Par",
|
||||
"unknown": "Inconnu",
|
||||
"tools": "outils",
|
||||
"search": "Rechercher",
|
||||
"searchPlaceholder": "Rechercher des serveurs par nom, catégorie ou tags",
|
||||
"clearFilters": "Effacer",
|
||||
"clearCategoryFilter": "",
|
||||
"clearTagFilter": "",
|
||||
"categories": "Catégories",
|
||||
"tags": "Tags",
|
||||
"showTags": "Afficher les tags",
|
||||
"hideTags": "Masquer les tags",
|
||||
"moreTags": "",
|
||||
"noServers": "Aucun serveur trouvé correspondant à votre recherche",
|
||||
"backToList": "Retour à la liste",
|
||||
"install": "Installer",
|
||||
"installing": "Installation en cours...",
|
||||
"installed": "Installé",
|
||||
"installServer": "Installer le serveur : {{name}}",
|
||||
"installSuccess": "Serveur {{serverName}} installé avec succès",
|
||||
"author": "Auteur",
|
||||
"license": "Licence",
|
||||
"repository": "Dépôt",
|
||||
"examples": "Exemples",
|
||||
"arguments": "Arguments",
|
||||
"argumentName": "Nom",
|
||||
"description": "Description",
|
||||
"required": "Requis",
|
||||
"example": "Exemple",
|
||||
"viewSchema": "Voir le schéma",
|
||||
"fetchError": "Erreur lors de la récupération des serveurs du marché",
|
||||
"serverNotFound": "Serveur non trouvé",
|
||||
"searchError": "Erreur lors de la recherche de serveurs",
|
||||
"filterError": "Erreur lors du filtrage des serveurs par catégorie",
|
||||
"tagFilterError": "Erreur lors du filtrage des serveurs par tag",
|
||||
"noInstallationMethod": "Aucune méthode d'installation disponible pour ce serveur",
|
||||
"showing": "Affichage de {{from}}-{{to}} sur {{total}} serveurs",
|
||||
"perPage": "Par page",
|
||||
"confirmVariablesMessage": "Veuillez vous assurer que ces variables sont correctement définies dans votre environnement d'exécution. Continuer l'installation du serveur ?",
|
||||
"confirmAndInstall": "Confirmer et installer"
|
||||
},
|
||||
"cloud": {
|
||||
"title": "Support Cloud",
|
||||
"subtitle": "Propulsé par MCPRouter",
|
||||
"by": "Par",
|
||||
"server": "Serveur",
|
||||
"config": "Config",
|
||||
"created": "Créé",
|
||||
"updated": "Mis à jour",
|
||||
"available": "Disponible",
|
||||
"description": "Description",
|
||||
"details": "Détails",
|
||||
"tools": "Outils",
|
||||
"tool": "outil",
|
||||
"toolsAvailable": "{{count}} outil disponible||{{count}} outils disponibles",
|
||||
"loadingTools": "Chargement des outils...",
|
||||
"noTools": "Aucun outil disponible pour ce serveur",
|
||||
"noDescription": "Aucune description disponible",
|
||||
"viewDetails": "Voir les détails",
|
||||
"parameters": "Paramètres",
|
||||
"result": "Résultat",
|
||||
"error": "Erreur",
|
||||
"callTool": "Appeler",
|
||||
"calling": "Appel en cours...",
|
||||
"toolCallSuccess": "L'outil {{toolName}} a été exécuté avec succès",
|
||||
"toolCallError": "Échec de l'appel de l'outil {{toolName}} : {{error}}",
|
||||
"viewSchema": "Voir le schéma",
|
||||
"backToList": "Retour au marché Cloud",
|
||||
"search": "Rechercher",
|
||||
"searchPlaceholder": "Rechercher des serveurs cloud par nom, titre ou auteur",
|
||||
"clearFilters": "Effacer les filtres",
|
||||
"clearCategoryFilter": "Effacer",
|
||||
"clearTagFilter": "Effacer",
|
||||
"categories": "Catégories",
|
||||
"tags": "Tags",
|
||||
"noCategories": "Aucune catégorie trouvée",
|
||||
"noTags": "Aucun tag trouvé",
|
||||
"noServers": "Aucun serveur cloud trouvé",
|
||||
"fetchError": "Erreur lors de la récupération des serveurs cloud",
|
||||
"serverNotFound": "Serveur cloud non trouvé",
|
||||
"searchError": "Erreur lors de la recherche de serveurs cloud",
|
||||
"filterError": "Erreur lors du filtrage des serveurs cloud par catégorie",
|
||||
"tagFilterError": "Erreur lors du filtrage des serveurs cloud par tag",
|
||||
"showing": "Affichage de {{from}}-{{to}} sur {{total}} serveurs cloud",
|
||||
"perPage": "Par page",
|
||||
"apiKeyNotConfigured": "Clé API MCPRouter non configurée",
|
||||
"apiKeyNotConfiguredDescription": "Pour utiliser les serveurs cloud, vous devez configurer votre clé API MCPRouter.",
|
||||
"getApiKey": "Obtenir une clé API",
|
||||
"configureInSettings": "Configurer dans les paramètres",
|
||||
"installServer": "Installer {{name}}",
|
||||
"installSuccess": "Serveur {{name}} installé avec succès",
|
||||
"installError": "Échec de l'installation du serveur : {{error}}"
|
||||
},
|
||||
"tool": {
|
||||
"run": "Exécuter",
|
||||
"running": "Exécution en cours...",
|
||||
"runTool": "Exécuter l'outil",
|
||||
"cancel": "Annuler",
|
||||
"noDescription": "Aucune description disponible",
|
||||
"inputSchema": "Schéma d'entrée :",
|
||||
"runToolWithName": "Exécuter l'outil : {{name}}",
|
||||
"execution": "Exécution de l'outil",
|
||||
"successful": "Réussi",
|
||||
"failed": "Échoué",
|
||||
"result": "Résultat :",
|
||||
"error": "Erreur",
|
||||
"errorDetails": "Détails de l'erreur :",
|
||||
"noContent": "L'outil a été exécuté avec succès mais n'a renvoyé aucun contenu.",
|
||||
"unknownError": "Une erreur inconnue est survenue",
|
||||
"jsonResponse": "Réponse JSON :",
|
||||
"toolResult": "Résultat de l'outil",
|
||||
"noParameters": "Cet outil ne nécessite aucun paramètre.",
|
||||
"selectOption": "Sélectionnez une option",
|
||||
"enterValue": "Entrez la valeur {{type}}",
|
||||
"enabled": "Activé",
|
||||
"enableSuccess": "Outil {{name}} activé avec succès",
|
||||
"disableSuccess": "Outil {{name}} désactivé avec succès",
|
||||
"toggleFailed": "Échec du basculement de l'état de l'outil",
|
||||
"parameters": "Paramètres de l'outil",
|
||||
"formMode": "Mode formulaire",
|
||||
"jsonMode": "Mode JSON",
|
||||
"jsonConfiguration": "Configuration JSON",
|
||||
"invalidJsonFormat": "Format JSON invalide",
|
||||
"fixJsonBeforeSwitching": "Veuillez corriger le format JSON avant de passer en mode formulaire",
|
||||
"item": "Élément {{index}}",
|
||||
"addItem": "Ajouter un élément {{key}}",
|
||||
"enterKey": "Entrez {{key}}"
|
||||
},
|
||||
"prompt": {
|
||||
"run": "Obtenir",
|
||||
"running": "Obtention en cours...",
|
||||
"result": "Résultat de l'invite",
|
||||
"error": "Erreur de l'invite",
|
||||
"execution": "Exécution de l'invite",
|
||||
"successful": "Réussi",
|
||||
"failed": "Échoué",
|
||||
"errorDetails": "Détails de l'erreur :",
|
||||
"noContent": "L'invite a été exécutée avec succès mais n'a renvoyé aucun contenu.",
|
||||
"unknownError": "Une erreur inconnue est survenue",
|
||||
"jsonResponse": "Réponse JSON :",
|
||||
"description": "Description",
|
||||
"messages": "Messages",
|
||||
"noDescription": "Aucune description disponible",
|
||||
"runPromptWithName": "Obtenir l'invite : {{name}}"
|
||||
},
|
||||
"settings": {
|
||||
"enableGlobalRoute": "Activer la route globale",
|
||||
"enableGlobalRouteDescription": "Autoriser les connexions au point de terminaison /sse sans spécifier d'ID de groupe",
|
||||
"enableGroupNameRoute": "Activer la route par nom de groupe",
|
||||
"enableGroupNameRouteDescription": "Autoriser les connexions au point de terminaison /sse en utilisant les noms de groupe au lieu des ID de groupe",
|
||||
"enableBearerAuth": "Activer l'authentification Bearer",
|
||||
"enableBearerAuthDescription": "Exiger une authentification par jeton Bearer pour les requêtes MCP",
|
||||
"bearerAuthKey": "Clé d'authentification Bearer",
|
||||
"bearerAuthKeyDescription": "La clé d'authentification qui sera requise dans le jeton Bearer",
|
||||
"bearerAuthKeyPlaceholder": "Entrez la clé d'authentification Bearer",
|
||||
"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",
|
||||
"pythonIndexUrlDescription": "Définir la variable d'environnement UV_DEFAULT_INDEX pour l'installation de paquets Python",
|
||||
"pythonIndexUrlPlaceholder": "ex. https://pypi.org/simple",
|
||||
"npmRegistry": "URL du registre NPM",
|
||||
"npmRegistryDescription": "Définir la variable d'environnement npm_config_registry pour l'installation de paquets NPM",
|
||||
"npmRegistryPlaceholder": "ex. https://registry.npmjs.org/",
|
||||
"baseUrl": "URL de base",
|
||||
"baseUrlDescription": "URL de base pour les requêtes MCP",
|
||||
"baseUrlPlaceholder": "ex. http://localhost:3000",
|
||||
"installConfig": "Installation",
|
||||
"systemConfigUpdated": "Configuration système mise à jour avec succès",
|
||||
"enableSmartRouting": "Activer le routage intelligent",
|
||||
"enableSmartRoutingDescription": "Activer la fonctionnalité de routage intelligent pour rechercher l'outil le plus approprié en fonction de l'entrée (en utilisant le nom de groupe $smart)",
|
||||
"dbUrl": "URL PostgreSQL (nécessite le support de pgvector)",
|
||||
"dbUrlPlaceholder": "ex. postgresql://user:password@localhost:5432/dbname",
|
||||
"openaiApiBaseUrl": "URL de base de l'API OpenAI",
|
||||
"openaiApiBaseUrlPlaceholder": "https://api.openai.com/v1",
|
||||
"openaiApiKey": "Clé API OpenAI",
|
||||
"openaiApiKeyPlaceholder": "Entrez la clé API OpenAI",
|
||||
"openaiApiEmbeddingModel": "Modèle d'intégration OpenAI",
|
||||
"openaiApiEmbeddingModelPlaceholder": "text-embedding-3-small",
|
||||
"smartRoutingConfigUpdated": "Configuration du routage intelligent mise à jour avec succès",
|
||||
"smartRoutingRequiredFields": "L'URL de la base de données et la clé API OpenAI sont requises pour activer le routage intelligent",
|
||||
"smartRoutingValidationError": "Veuillez remplir les champs obligatoires avant d'activer le routage intelligent : {{fields}}",
|
||||
"mcpRouterConfig": "Marché Cloud",
|
||||
"mcpRouterApiKey": "Clé API MCPRouter",
|
||||
"mcpRouterApiKeyDescription": "Clé API pour accéder aux services du marché cloud MCPRouter",
|
||||
"mcpRouterApiKeyPlaceholder": "Entrez la clé API MCPRouter",
|
||||
"mcpRouterReferer": "Référent",
|
||||
"mcpRouterRefererDescription": "En-tête Referer pour les requêtes API MCPRouter",
|
||||
"mcpRouterRefererPlaceholder": "https://www.mcphubx.com",
|
||||
"mcpRouterTitle": "Titre",
|
||||
"mcpRouterTitleDescription": "En-tête Title pour les requêtes API MCPRouter",
|
||||
"mcpRouterTitlePlaceholder": "MCPHub",
|
||||
"mcpRouterBaseUrl": "URL de base",
|
||||
"mcpRouterBaseUrlDescription": "URL de base pour l'API MCPRouter",
|
||||
"mcpRouterBaseUrlPlaceholder": "https://api.mcprouter.to/v1"
|
||||
},
|
||||
"dxt": {
|
||||
"upload": "Télécharger",
|
||||
"uploadTitle": "Télécharger l'extension DXT",
|
||||
"dropFileHere": "Déposez votre fichier .dxt ici",
|
||||
"orClickToSelect": "ou cliquez pour sélectionner depuis votre ordinateur",
|
||||
"invalidFileType": "Veuillez sélectionner un fichier .dxt valide",
|
||||
"noFileSelected": "Veuillez sélectionner un fichier .dxt à télécharger",
|
||||
"uploading": "Téléchargement en cours...",
|
||||
"uploadFailed": "Échec du téléchargement du fichier DXT",
|
||||
"installServer": "Installer le serveur MCP depuis DXT",
|
||||
"extensionInfo": "Informations sur l'extension",
|
||||
"name": "Nom",
|
||||
"version": "Version",
|
||||
"description": "Description",
|
||||
"author": "Auteur",
|
||||
"tools": "Outils",
|
||||
"serverName": "Nom du serveur",
|
||||
"serverNamePlaceholder": "Entrez un nom pour ce serveur",
|
||||
"install": "Installer",
|
||||
"installing": "Installation en cours...",
|
||||
"installFailed": "Échec de l'installation du serveur depuis DXT",
|
||||
"serverExistsTitle": "Le serveur existe déjà",
|
||||
"serverExistsConfirm": "Le serveur '{{serverName}}' existe déjà. Voulez-vous le remplacer par la nouvelle version ?",
|
||||
"override": "Remplacer"
|
||||
},
|
||||
"users": {
|
||||
"add": "Ajouter un utilisateur",
|
||||
"addNew": "Ajouter un nouvel utilisateur",
|
||||
"edit": "Modifier l'utilisateur",
|
||||
"delete": "Supprimer l'utilisateur",
|
||||
"create": "Créer un utilisateur",
|
||||
"update": "Mettre à jour l'utilisateur",
|
||||
"username": "Nom d'utilisateur",
|
||||
"password": "Mot de passe",
|
||||
"newPassword": "Nouveau mot de passe",
|
||||
"confirmPassword": "Confirmer le mot de passe",
|
||||
"adminRole": "Administrateur",
|
||||
"admin": "Admin",
|
||||
"user": "Utilisateur",
|
||||
"permissions": "Permissions",
|
||||
"adminPermissions": "Accès complet au système",
|
||||
"userPermissions": "Accès limité",
|
||||
"currentUser": "Vous",
|
||||
"noUsers": "Aucun utilisateur trouvé",
|
||||
"adminRequired": "Un accès administrateur est requis pour gérer les utilisateurs",
|
||||
"usernameRequired": "Le nom d'utilisateur est requis",
|
||||
"passwordRequired": "Le mot de passe est requis",
|
||||
"passwordTooShort": "Le mot de passe doit comporter au moins 6 caractères",
|
||||
"passwordMismatch": "Les mots de passe ne correspondent pas",
|
||||
"usernamePlaceholder": "Entrez le nom d'utilisateur",
|
||||
"passwordPlaceholder": "Entrez le mot de passe",
|
||||
"newPasswordPlaceholder": "Laissez vide pour conserver le mot de passe actuel",
|
||||
"confirmPasswordPlaceholder": "Confirmez le nouveau mot de passe",
|
||||
"createError": "Échec de la création de l'utilisateur",
|
||||
"updateError": "Échec de la mise à jour de l'utilisateur",
|
||||
"deleteError": "Échec de la suppression de l'utilisateur",
|
||||
"statsError": "Échec de la récupération des statistiques utilisateur",
|
||||
"deleteConfirmation": "Êtes-vous sûr de vouloir supprimer l'utilisateur '{{username}}' ? Cette action est irréversible.",
|
||||
"confirmDelete": "Supprimer l'utilisateur",
|
||||
"deleteWarning": "Êtes-vous sûr de vouloir supprimer l'utilisateur '{{username}}' ? Cette action est irréversible."
|
||||
},
|
||||
"api": {
|
||||
"errors": {
|
||||
"readonly": "Lecture seule pour l'environnement de démonstration",
|
||||
"invalid_credentials": "Nom d'utilisateur ou mot de passe invalide",
|
||||
"serverNameRequired": "Le nom du serveur est requis",
|
||||
"serverConfigRequired": "La configuration du serveur est requise",
|
||||
"serverConfigInvalid": "La configuration du serveur doit inclure une URL, une URL de spécification OpenAPI ou un schéma, ou une commande avec des arguments",
|
||||
"serverTypeInvalid": "Le type de serveur doit être l'un des suivants : stdio, sse, streamable-http, openapi",
|
||||
"urlRequiredForType": "L'URL est requise pour le type de serveur {{type}}",
|
||||
"openapiSpecRequired": "L'URL de la spécification OpenAPI ou le schéma est requis pour le type de serveur openapi",
|
||||
"headersInvalidFormat": "Les en-têtes doivent être un objet",
|
||||
"headersNotSupportedForStdio": "Les en-têtes ne sont pas pris en charge pour le type de serveur stdio",
|
||||
"serverNotFound": "Serveur non trouvé",
|
||||
"failedToRemoveServer": "Serveur non trouvé ou échec de la suppression",
|
||||
"internalServerError": "Erreur interne du serveur",
|
||||
"failedToGetServers": "Échec de la récupération des informations sur les serveurs",
|
||||
"failedToGetServerSettings": "Échec de la récupération des paramètres du serveur",
|
||||
"failedToGetServerConfig": "Échec de la récupération de la configuration du serveur",
|
||||
"failedToSaveSettings": "Échec de l'enregistrement des paramètres",
|
||||
"toolNameRequired": "Le nom du serveur et le nom de l'outil sont requis",
|
||||
"descriptionMustBeString": "La description doit être une chaîne de caractères",
|
||||
"groupIdRequired": "L'ID de groupe est requis",
|
||||
"groupNameRequired": "Le nom du groupe est requis",
|
||||
"groupNotFound": "Groupe non trouvé",
|
||||
"groupIdAndServerNameRequired": "L'ID de groupe和le nom du serveur sont requis",
|
||||
"groupOrServerNotFound": "Groupe ou serveur non trouvé",
|
||||
"toolsMustBeAllOrArray": "Les outils doivent être \"all\" ou un tableau de chaînes de caractères",
|
||||
"serverNameAndToolNameRequired": "Le nom du serveur et le nom de l'outil sont requis",
|
||||
"usernameRequired": "Le nom d'utilisateur est requis",
|
||||
"userNotFound": "Utilisateur non trouvé",
|
||||
"failedToGetUsers": "Échec de la récupération des informations sur les utilisateurs",
|
||||
"failedToGetUserInfo": "Échec de la récupération des informations sur l'utilisateur",
|
||||
"failedToGetUserStats": "Échec de la récupération des statistiques de l'utilisateur",
|
||||
"marketServerNameRequired": "Le nom du serveur du marché est requis",
|
||||
"marketServerNotFound": "Serveur du marché non trouvé",
|
||||
"failedToGetMarketServers": "Échec de la récupération des informations sur les serveurs du marché",
|
||||
"failedToGetMarketServer": "Échec de la récupération des informations sur le serveur du marché",
|
||||
"failedToGetMarketCategories": "Échec de la récupération des catégories du marché",
|
||||
"failedToGetMarketTags": "Échec de la récupération des tags du marché",
|
||||
"failedToSearchMarketServers": "Échec de la recherche des serveurs du marché",
|
||||
"failedToFilterMarketServers": "Échec du filtrage des serveurs du marché",
|
||||
"failedToProcessDxtFile": "Échec du traitement du fichier DXT"
|
||||
},
|
||||
"success": {
|
||||
"serverCreated": "Serveur créé avec succès",
|
||||
"serverUpdated": "Serveur mis à jour avec succès",
|
||||
"serverRemoved": "Serveur supprimé avec succès",
|
||||
"serverToggled": "État du serveur basculé avec succès",
|
||||
"toolToggled": "Outil {{name}} {{action}} avec succès",
|
||||
"toolDescriptionUpdated": "Description de l'outil {{name}} mise à jour avec succès",
|
||||
"systemConfigUpdated": "Configuration système mise à jour avec succès",
|
||||
"groupCreated": "Groupe créé avec succès",
|
||||
"groupUpdated": "Groupe mis à jour avec succès",
|
||||
"groupDeleted": "Groupe supprimé avec succès",
|
||||
"serverAddedToGroup": "Serveur ajouté au groupe avec succès",
|
||||
"serverRemovedFromGroup": "Serveur supprimé du groupe avec succès",
|
||||
"serverToolsUpdated": "Outils du serveur mis à jour avec succès"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,10 @@
|
||||
"updateError": "更新服务器失败",
|
||||
"editTitle": "编辑服务器: {{serverName}}",
|
||||
"type": "服务器类型",
|
||||
"typeStdio": "STDIO",
|
||||
"typeSse": "SSE",
|
||||
"typeStreamableHttp": "流式 HTTP",
|
||||
"typeOpenapi": "OpenAPI",
|
||||
"command": "命令",
|
||||
"arguments": "参数",
|
||||
"envVars": "环境变量",
|
||||
@@ -145,11 +149,17 @@
|
||||
"httpAuthConfig": "HTTP 认证配置",
|
||||
"httpScheme": "认证方案",
|
||||
"httpCredentials": "凭据",
|
||||
"httpSchemeBasic": "Basic",
|
||||
"httpSchemeBearer": "Bearer",
|
||||
"httpSchemeDigest": "Digest",
|
||||
"oauth2Config": "OAuth 2.0 配置",
|
||||
"oauth2Token": "访问令牌",
|
||||
"openIdConnectConfig": "OpenID Connect 配置",
|
||||
"openIdConnectUrl": "发现 URL",
|
||||
"openIdConnectToken": "ID 令牌"
|
||||
"openIdConnectToken": "ID 令牌",
|
||||
"apiKeyInHeader": "请求头",
|
||||
"apiKeyInQuery": "查询",
|
||||
"apiKeyInCookie": "Cookie"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
@@ -191,7 +201,13 @@
|
||||
"copyFailed": "复制失败",
|
||||
"close": "关闭",
|
||||
"confirm": "确认",
|
||||
"language": "语言"
|
||||
"language": "语言",
|
||||
"true": "是",
|
||||
"false": "否",
|
||||
"dismiss": "忽略",
|
||||
"github": "GitHub",
|
||||
"wechat": "微信",
|
||||
"discord": "Discord"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "仪表盘",
|
||||
@@ -476,7 +492,7 @@
|
||||
"mcpRouterApiKeyPlaceholder": "请输入 MCPRouter API 密钥",
|
||||
"mcpRouterReferer": "引用地址",
|
||||
"mcpRouterRefererDescription": "MCPRouter API 请求的引用地址头",
|
||||
"mcpRouterRefererPlaceholder": "https://mcphub.app",
|
||||
"mcpRouterRefererPlaceholder": "https://www.mcphubx.com",
|
||||
"mcpRouterTitle": "标题",
|
||||
"mcpRouterTitleDescription": "MCPRouter API 请求的标题头",
|
||||
"mcpRouterTitlePlaceholder": "MCPHub",
|
||||
|
||||
301
package-lock.json
generated
301
package-lock.json
generated
@@ -10,81 +10,85 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@apidevtools/swagger-parser": "^11.0.1",
|
||||
"@modelcontextprotocol/sdk": "^1.17.2",
|
||||
"@modelcontextprotocol/sdk": "^1.17.4",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/multer": "^1.4.13",
|
||||
"@types/pg": "^8.15.2",
|
||||
"@types/pg": "^8.15.5",
|
||||
"adm-zip": "^0.5.16",
|
||||
"axios": "^1.10.0",
|
||||
"axios": "^1.11.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"dotenv": "^16.3.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.6.1",
|
||||
"dotenv-expand": "^12.0.2",
|
||||
"express": "^4.21.2",
|
||||
"express-validator": "^7.2.1",
|
||||
"i18next-fs-backend": "^2.6.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.1",
|
||||
"openai": "^4.103.0",
|
||||
"multer": "^2.0.2",
|
||||
"openai": "^4.104.0",
|
||||
"openapi-types": "^12.1.3",
|
||||
"pg": "^8.16.0",
|
||||
"pg": "^8.16.3",
|
||||
"pgvector": "^0.2.1",
|
||||
"postgres": "^3.4.7",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"typeorm": "^0.3.24",
|
||||
"typeorm": "^0.3.26",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"mcphub": "bin/cli.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@radix-ui/react-accordion": "^1.2.3",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@shadcn/ui": "^0.0.4",
|
||||
"@swc/core": "^1.13.0",
|
||||
"@swc/core": "^1.13.5",
|
||||
"@swc/jest": "^0.2.39",
|
||||
"@tailwindcss/line-clamp": "^0.4.4",
|
||||
"@tailwindcss/postcss": "^4.1.3",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@tailwindcss/postcss": "^4.1.12",
|
||||
"@tailwindcss/vite": "^4.1.12",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.5",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"@types/node": "^22.15.21",
|
||||
"@types/react": "^19.0.12",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^4.17.23",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^22.17.2",
|
||||
"@types/react": "^19.1.11",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.4",
|
||||
"@typescript-eslint/parser": "^6.7.4",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"concurrently": "^9.1.2",
|
||||
"eslint": "^8.50.0",
|
||||
"concurrently": "^9.2.0",
|
||||
"eslint": "^8.57.1",
|
||||
"i18next": "^24.2.3",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-node": "^30.0.0",
|
||||
"jest-environment-node": "^30.0.5",
|
||||
"jest-mock-extended": "4.0.0-beta1",
|
||||
"lucide-react": "^0.486.0",
|
||||
"next": "^15.2.4",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.0.3",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-router-dom": "^7.6.0",
|
||||
"supertest": "^7.1.1",
|
||||
"tailwind-merge": "^3.1.0",
|
||||
"next": "^15.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.6.2",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-i18next": "^15.7.2",
|
||||
"react-router-dom": "^7.8.2",
|
||||
"supertest": "^7.1.4",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwind-scrollbar-hide": "^2.0.0",
|
||||
"tailwindcss": "^4.0.17",
|
||||
"ts-jest": "^29.1.1",
|
||||
"tailwindcss": "^4.1.12",
|
||||
"ts-jest": "^29.4.1",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.2.2",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^6.3.5",
|
||||
"zod": "^3.24.2"
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
@@ -727,7 +731,7 @@
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
||||
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "0.3.9"
|
||||
@@ -740,7 +744,7 @@
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
|
||||
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.0.3",
|
||||
@@ -2443,7 +2447,7 @@
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -2453,7 +2457,7 @@
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
@@ -2468,9 +2472,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.17.3",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.17.3.tgz",
|
||||
"integrity": "sha512-JPwUKWSsbzx+DLFznf/QZ32Qa+ptfbUlHhRLrBQBAFu9iI1iYvizM4p+zhhRDceSsPutXp4z+R/HPVphlIiclg==",
|
||||
"version": "1.17.4",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.17.4.tgz",
|
||||
"integrity": "sha512-zq24hfuAmmlNZvik0FLI58uE5sriN0WWsQzIlYnzSuKDAHFqJtBFrl/LfB1NLgJT5Y7dEBzaX4yAKqOPrcetaw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^6.12.6",
|
||||
@@ -3618,15 +3622,15 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/core": {
|
||||
"version": "1.13.3",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.3.tgz",
|
||||
"integrity": "sha512-ZaDETVWnm6FE0fc+c2UE8MHYVS3Fe91o5vkmGfgwGXFbxYvAjKSqxM/j4cRc9T7VZNSJjriXq58XkfCp3Y6f+w==",
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.5.tgz",
|
||||
"integrity": "sha512-WezcBo8a0Dg2rnR82zhwoR6aRNxeTGfK5QCD6TQ+kg3xx/zNT02s/0o+81h/3zhvFSB24NtqEr8FTw88O5W/JQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/counter": "^0.1.3",
|
||||
"@swc/types": "^0.1.23"
|
||||
"@swc/types": "^0.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
@@ -3636,16 +3640,16 @@
|
||||
"url": "https://opencollective.com/swc"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@swc/core-darwin-arm64": "1.13.3",
|
||||
"@swc/core-darwin-x64": "1.13.3",
|
||||
"@swc/core-linux-arm-gnueabihf": "1.13.3",
|
||||
"@swc/core-linux-arm64-gnu": "1.13.3",
|
||||
"@swc/core-linux-arm64-musl": "1.13.3",
|
||||
"@swc/core-linux-x64-gnu": "1.13.3",
|
||||
"@swc/core-linux-x64-musl": "1.13.3",
|
||||
"@swc/core-win32-arm64-msvc": "1.13.3",
|
||||
"@swc/core-win32-ia32-msvc": "1.13.3",
|
||||
"@swc/core-win32-x64-msvc": "1.13.3"
|
||||
"@swc/core-darwin-arm64": "1.13.5",
|
||||
"@swc/core-darwin-x64": "1.13.5",
|
||||
"@swc/core-linux-arm-gnueabihf": "1.13.5",
|
||||
"@swc/core-linux-arm64-gnu": "1.13.5",
|
||||
"@swc/core-linux-arm64-musl": "1.13.5",
|
||||
"@swc/core-linux-x64-gnu": "1.13.5",
|
||||
"@swc/core-linux-x64-musl": "1.13.5",
|
||||
"@swc/core-win32-arm64-msvc": "1.13.5",
|
||||
"@swc/core-win32-ia32-msvc": "1.13.5",
|
||||
"@swc/core-win32-x64-msvc": "1.13.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@swc/helpers": ">=0.5.17"
|
||||
@@ -3657,9 +3661,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-darwin-arm64": {
|
||||
"version": "1.13.3",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.13.3.tgz",
|
||||
"integrity": "sha512-ux0Ws4pSpBTqbDS9GlVP354MekB1DwYlbxXU3VhnDr4GBcCOimpocx62x7cFJkSpEBF8bmX8+/TTCGKh4PbyXw==",
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.13.5.tgz",
|
||||
"integrity": "sha512-lKNv7SujeXvKn16gvQqUQI5DdyY8v7xcoO3k06/FJbHJS90zEwZdQiMNRiqpYw/orU543tPaWgz7cIYWhbopiQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3674,9 +3678,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-darwin-x64": {
|
||||
"version": "1.13.3",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.13.3.tgz",
|
||||
"integrity": "sha512-p0X6yhxmNUOMZrbeZ3ZNsPige8lSlSe1llllXvpCLkKKxN/k5vZt1sULoq6Nj4eQ7KeHQVm81/+AwKZyf/e0TA==",
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.13.5.tgz",
|
||||
"integrity": "sha512-ILd38Fg/w23vHb0yVjlWvQBoE37ZJTdlLHa8LRCFDdX4WKfnVBiblsCU9ar4QTMNdeTBEX9iUF4IrbNWhaF1Ng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3691,9 +3695,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-arm-gnueabihf": {
|
||||
"version": "1.13.3",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.13.3.tgz",
|
||||
"integrity": "sha512-OmDoiexL2fVWvQTCtoh0xHMyEkZweQAlh4dRyvl8ugqIPEVARSYtaj55TBMUJIP44mSUOJ5tytjzhn2KFxFcBA==",
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.13.5.tgz",
|
||||
"integrity": "sha512-Q6eS3Pt8GLkXxqz9TAw+AUk9HpVJt8Uzm54MvPsqp2yuGmY0/sNaPPNVqctCX9fu/Nu8eaWUen0si6iEiCsazQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -3708,9 +3712,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-arm64-gnu": {
|
||||
"version": "1.13.3",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.13.3.tgz",
|
||||
"integrity": "sha512-STfKku3QfnuUj6k3g9ld4vwhtgCGYIFQmsGPPgT9MK/dI3Lwnpe5Gs5t1inoUIoGNP8sIOLlBB4HV4MmBjQuhw==",
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.13.5.tgz",
|
||||
"integrity": "sha512-aNDfeN+9af+y+M2MYfxCzCy/VDq7Z5YIbMqRI739o8Ganz6ST+27kjQFd8Y/57JN/hcnUEa9xqdS3XY7WaVtSw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3725,9 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-arm64-musl": {
|
||||
"version": "1.13.3",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.13.3.tgz",
|
||||
"integrity": "sha512-bc+CXYlFc1t8pv9yZJGus372ldzOVscBl7encUBlU1m/Sig0+NDJLz6cXXRcFyl6ABNOApWeR4Yl7iUWx6C8og==",
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.13.5.tgz",
|
||||
"integrity": "sha512-9+ZxFN5GJag4CnYnq6apKTnnezpfJhCumyz0504/JbHLo+Ue+ZtJnf3RhyA9W9TINtLE0bC4hKpWi8ZKoETyOQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3742,9 +3746,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-x64-gnu": {
|
||||
"version": "1.13.3",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.13.3.tgz",
|
||||
"integrity": "sha512-dFXoa0TEhohrKcxn/54YKs1iwNeW6tUkHJgXW33H381SvjKFUV53WR231jh1sWVJETjA3vsAwxKwR23s7UCmUA==",
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.13.5.tgz",
|
||||
"integrity": "sha512-WD530qvHrki8Ywt/PloKUjaRKgstQqNGvmZl54g06kA+hqtSE2FTG9gngXr3UJxYu/cNAjJYiBifm7+w4nbHbA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3759,9 +3763,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-x64-musl": {
|
||||
"version": "1.13.3",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.13.3.tgz",
|
||||
"integrity": "sha512-ieyjisLB+ldexiE/yD8uomaZuZIbTc8tjquYln9Quh5ykOBY7LpJJYBWvWtm1g3pHv6AXlBI8Jay7Fffb6aLfA==",
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.13.5.tgz",
|
||||
"integrity": "sha512-Luj8y4OFYx4DHNQTWjdIuKTq2f5k6uSXICqx+FSabnXptaOBAbJHNbHT/06JZh6NRUouaf0mYXN0mcsqvkhd7Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3776,9 +3780,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-win32-arm64-msvc": {
|
||||
"version": "1.13.3",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.13.3.tgz",
|
||||
"integrity": "sha512-elTQpnaX5vESSbhCEgcwXjpMsnUbqqHfEpB7ewpkAsLzKEXZaK67ihSRYAuAx6ewRQTo7DS5iTT6X5aQD3MzMw==",
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.13.5.tgz",
|
||||
"integrity": "sha512-cZ6UpumhF9SDJvv4DA2fo9WIzlNFuKSkZpZmPG1c+4PFSEMy5DFOjBSllCvnqihCabzXzpn6ykCwBmHpy31vQw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3793,9 +3797,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-win32-ia32-msvc": {
|
||||
"version": "1.13.3",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.13.3.tgz",
|
||||
"integrity": "sha512-nvehQVEOdI1BleJpuUgPLrclJ0TzbEMc+MarXDmmiRFwEUGqj+pnfkTSb7RZyS1puU74IXdK/YhTirHurtbI9w==",
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.13.5.tgz",
|
||||
"integrity": "sha512-C5Yi/xIikrFUzZcyGj9L3RpKljFvKiDMtyDzPKzlsDrKIw2EYY+bF88gB6oGY5RGmv4DAX8dbnpRAqgFD0FMEw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -3810,9 +3814,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-win32-x64-msvc": {
|
||||
"version": "1.13.3",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.13.3.tgz",
|
||||
"integrity": "sha512-A+JSKGkRbPLVV2Kwx8TaDAV0yXIXm/gc8m98hSkVDGlPBBmydgzNdWy3X7HTUBM7IDk7YlWE7w2+RUGjdgpTmg==",
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.13.5.tgz",
|
||||
"integrity": "sha512-YrKdMVxbYmlfybCSbRtrilc6UA8GF5aPmGKBdPvjrarvsmf4i7ZHGCEnLtfOMd3Lwbs2WUZq3WdMbozYeLU93Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4176,28 +4180,28 @@
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz",
|
||||
"integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node12": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
|
||||
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node14": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
|
||||
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tsconfig/node16": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
|
||||
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/adm-zip": {
|
||||
@@ -4254,6 +4258,15 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/bcrypt": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||
"integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/bcryptjs": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-3.0.0.tgz",
|
||||
@@ -4291,6 +4304,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/cors": {
|
||||
"version": "2.8.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -4465,9 +4488,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.10.tgz",
|
||||
"integrity": "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg==",
|
||||
"version": "19.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.11.tgz",
|
||||
"integrity": "sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4836,7 +4859,7 @@
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
@@ -4859,7 +4882,7 @@
|
||||
"version": "8.3.4",
|
||||
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
|
||||
"integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.11.0"
|
||||
@@ -5014,7 +5037,7 @@
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
|
||||
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
@@ -5268,6 +5291,20 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bcrypt": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-addon-api": "^8.3.0",
|
||||
"node-gyp-build": "^4.8.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz",
|
||||
@@ -6013,7 +6050,7 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
@@ -6194,7 +6231,7 @@
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
|
||||
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
@@ -9633,7 +9670,7 @@
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
|
||||
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/makeerror": {
|
||||
@@ -9966,6 +10003,15 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "8.5.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
|
||||
"integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18 || ^20 || >= 21"
|
||||
}
|
||||
},
|
||||
"node_modules/node-domexception": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||
@@ -10005,6 +10051,17 @@
|
||||
"url": "https://opencollective.com/node-fetch"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp-build": {
|
||||
"version": "4.8.4",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"node-gyp-build": "bin.js",
|
||||
"node-gyp-build-optional": "optional.js",
|
||||
"node-gyp-build-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/node-int64": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||
@@ -10956,9 +11013,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "15.6.1",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.6.1.tgz",
|
||||
"integrity": "sha512-uGrzSsOUUe2sDBG/+FJq2J1MM+Y4368/QW8OLEKSFvnDflHBbZhSd1u3UkW0Z06rMhZmnB/AQrhCpYfE5/5XNg==",
|
||||
"version": "15.7.2",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.2.tgz",
|
||||
"integrity": "sha512-xJxq7ibnhUlMvd82lNC4te1GxGUMoM1A05KKyqoqsBXVZtEvZg/fz/fnVzdlY/hhQ3SpP/79qCocZOtICGhd3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -10966,7 +11023,7 @@
|
||||
"html-parse-stringify": "^3.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 23.2.3",
|
||||
"i18next": ">= 25.4.1",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5"
|
||||
},
|
||||
@@ -11000,9 +11057,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.8.1.tgz",
|
||||
"integrity": "sha512-5cy/M8DHcG51/KUIka1nfZ2QeylS4PJRs6TT8I4PF5axVsI5JUxp0hC0NZ/AEEj8Vw7xsEoD7L/6FY+zoYaOGA==",
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.8.2.tgz",
|
||||
"integrity": "sha512-7M2fR1JbIZ/jFWqelpvSZx+7vd7UlBTfdZqf6OSdF9g6+sfdqJDAWcak6ervbHph200ePlu+7G8LdoiC3ReyAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -11023,13 +11080,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.8.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.8.1.tgz",
|
||||
"integrity": "sha512-NkgBCF3sVgCiAWIlSt89GR2PLaksMpoo3HDCorpRfnCEfdtRPLiuTf+CNXvqZMI5SJLZCLpVCvcZrTdtGW64xQ==",
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.8.2.tgz",
|
||||
"integrity": "sha512-Z4VM5mKDipal2jQ385H6UBhiiEDlnJPx6jyWsTYoZQdl5TrjxEV2a9yl3Fi60NBJxYzOTGTTHXPi0pdizvTwow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.8.1"
|
||||
"react-router": "7.8.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
@@ -12358,7 +12415,7 @@
|
||||
"version": "10.9.2",
|
||||
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
@@ -12500,9 +12557,9 @@
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.20.4",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.4.tgz",
|
||||
"integrity": "sha512-yyxBKfORQ7LuRt/BQKBXrpcq59ZvSW0XxwfjAt3w2/8PmdxaFzijtMhTawprSHhpzeM5BgU2hXHG3lklIERZXg==",
|
||||
"version": "4.20.5",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.5.tgz",
|
||||
"integrity": "sha512-+wKjMNU9w/EaQayHXb7WA7ZaHY6hN8WgfvHNQ3t1PnU91/7O8TcTnIhCDYTZwnt8JsO9IBqZ30Ln1r7pPF52Aw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -12729,7 +12786,7 @@
|
||||
"version": "5.9.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz",
|
||||
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -12850,7 +12907,7 @@
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
|
||||
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/v8-to-istanbul": {
|
||||
@@ -13212,7 +13269,7 @@
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
||||
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
|
||||
98
package.json
98
package.json
@@ -45,82 +45,92 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@apidevtools/swagger-parser": "^11.0.1",
|
||||
"@modelcontextprotocol/sdk": "^1.17.2",
|
||||
"@apidevtools/swagger-parser": "^12.0.0",
|
||||
"@modelcontextprotocol/sdk": "^1.17.4",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/multer": "^1.4.13",
|
||||
"@types/pg": "^8.15.2",
|
||||
"@types/pg": "^8.15.5",
|
||||
"adm-zip": "^0.5.16",
|
||||
"axios": "^1.10.0",
|
||||
"axios": "^1.11.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"dotenv": "^16.3.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.6.1",
|
||||
"dotenv-expand": "^12.0.2",
|
||||
"express": "^4.21.2",
|
||||
"express-validator": "^7.2.1",
|
||||
"i18next-fs-backend": "^2.6.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.1",
|
||||
"openai": "^4.103.0",
|
||||
"multer": "^2.0.2",
|
||||
"openai": "^4.104.0",
|
||||
"openapi-types": "^12.1.3",
|
||||
"pg": "^8.16.0",
|
||||
"pg": "^8.16.3",
|
||||
"pgvector": "^0.2.1",
|
||||
"postgres": "^3.4.7",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"typeorm": "^0.3.24",
|
||||
"typeorm": "^0.3.26",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@radix-ui/react-accordion": "^1.2.3",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@shadcn/ui": "^0.0.4",
|
||||
"@swc/core": "^1.13.0",
|
||||
"@swc/core": "^1.13.5",
|
||||
"@swc/jest": "^0.2.39",
|
||||
"@tailwindcss/line-clamp": "^0.4.4",
|
||||
"@tailwindcss/postcss": "^4.1.3",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@tailwindcss/postcss": "^4.1.12",
|
||||
"@tailwindcss/vite": "^4.1.12",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.5",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
"@types/node": "^22.15.21",
|
||||
"@types/react": "^19.0.12",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^4.17.23",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^22.17.2",
|
||||
"@types/react": "^19.1.11",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.4",
|
||||
"@typescript-eslint/parser": "^6.7.4",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"concurrently": "^9.1.2",
|
||||
"eslint": "^8.50.0",
|
||||
"i18next": "^24.2.3",
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"concurrently": "^9.2.0",
|
||||
"eslint": "^8.57.1",
|
||||
"i18next": "^25.5.0",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-node": "^30.0.0",
|
||||
"jest-mock-extended": "4.0.0-beta1",
|
||||
"jest-environment-node": "^30.0.5",
|
||||
"jest-mock-extended": "4.0.0",
|
||||
"lucide-react": "^0.486.0",
|
||||
"next": "^15.2.4",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.0.3",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^15.4.1",
|
||||
"react-router-dom": "^7.6.0",
|
||||
"supertest": "^7.1.1",
|
||||
"tailwind-merge": "^3.1.0",
|
||||
"next": "^15.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.6.2",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-i18next": "^15.7.2",
|
||||
"react-router-dom": "^7.8.2",
|
||||
"supertest": "^7.1.4",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwind-scrollbar-hide": "^2.0.0",
|
||||
"tailwindcss": "^4.0.17",
|
||||
"ts-jest": "^29.1.1",
|
||||
"tailwindcss": "^4.1.12",
|
||||
"ts-jest": "^29.4.1",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.2.2",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^6.3.5",
|
||||
"zod": "^3.24.2"
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.12.4+sha256.cadfd9e6c9fcc2cb76fe7c0779a5250b632898aea5f53d833a73690c77a778d9"
|
||||
"packageManager": "pnpm@10.12.4+sha256.cadfd9e6c9fcc2cb76fe7c0779a5250b632898aea5f53d833a73690c77a778d9",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"brace-expansion@1.1.11": "1.1.12",
|
||||
"brace-expansion@2.0.1": "2.0.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
3175
pnpm-lock.yaml
generated
3175
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
265
src/config/DaoConfigService.ts
Normal file
265
src/config/DaoConfigService.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { McpSettings, IUser, ServerConfig } from '../types/index.js';
|
||||
import {
|
||||
UserDao,
|
||||
ServerDao,
|
||||
GroupDao,
|
||||
SystemConfigDao,
|
||||
UserConfigDao,
|
||||
ServerConfigWithName,
|
||||
UserDaoImpl,
|
||||
ServerDaoImpl,
|
||||
GroupDaoImpl,
|
||||
SystemConfigDaoImpl,
|
||||
UserConfigDaoImpl,
|
||||
} from '../dao/index.js';
|
||||
|
||||
/**
|
||||
* Configuration service using DAO layer
|
||||
*/
|
||||
export class DaoConfigService {
|
||||
constructor(
|
||||
private userDao: UserDao,
|
||||
private serverDao: ServerDao,
|
||||
private groupDao: GroupDao,
|
||||
private systemConfigDao: SystemConfigDao,
|
||||
private userConfigDao: UserConfigDao,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Load complete settings using DAO layer
|
||||
*/
|
||||
async loadSettings(user?: IUser): Promise<McpSettings> {
|
||||
const [users, servers, groups, systemConfig, userConfigs] = await Promise.all([
|
||||
this.userDao.findAll(),
|
||||
this.serverDao.findAll(),
|
||||
this.groupDao.findAll(),
|
||||
this.systemConfigDao.get(),
|
||||
this.userConfigDao.getAll(),
|
||||
]);
|
||||
|
||||
// Convert servers back to the original format
|
||||
const mcpServers: { [key: string]: ServerConfig } = {};
|
||||
for (const server of servers) {
|
||||
const { name, ...config } = server;
|
||||
mcpServers[name] = config;
|
||||
}
|
||||
|
||||
const settings: McpSettings = {
|
||||
users,
|
||||
mcpServers,
|
||||
groups,
|
||||
systemConfig,
|
||||
userConfigs,
|
||||
};
|
||||
|
||||
// Apply user-specific filtering if needed
|
||||
if (user && !user.isAdmin) {
|
||||
return this.filterSettingsForUser(settings, user);
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save settings using DAO layer
|
||||
*/
|
||||
async saveSettings(settings: McpSettings, user?: IUser): Promise<boolean> {
|
||||
try {
|
||||
// If user is not admin, merge with existing settings
|
||||
if (user && !user.isAdmin) {
|
||||
const currentSettings = await this.loadSettings();
|
||||
settings = this.mergeSettingsForUser(currentSettings, settings, user);
|
||||
}
|
||||
|
||||
// Save each component using respective DAOs
|
||||
const promises: Promise<any>[] = [];
|
||||
|
||||
// Save users
|
||||
if (settings.users) {
|
||||
// Note: For users, we need to handle creation/updates separately
|
||||
// since passwords might need hashing
|
||||
// This is a simplified approach - in practice, you'd want more sophisticated handling
|
||||
const currentUsers = await this.userDao.findAll();
|
||||
for (const user of settings.users) {
|
||||
const existing = currentUsers.find((u: IUser) => u.username === user.username);
|
||||
if (existing) {
|
||||
promises.push(this.userDao.update(user.username, user));
|
||||
} else {
|
||||
// For new users, we'd need to handle password hashing properly
|
||||
// This is a placeholder - actual implementation would use createWithHashedPassword
|
||||
console.warn('Creating new user requires special handling for password hashing');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save servers
|
||||
if (settings.mcpServers) {
|
||||
const currentServers = await this.serverDao.findAll();
|
||||
const currentServerNames = new Set(currentServers.map((s: ServerConfigWithName) => s.name));
|
||||
|
||||
for (const [name, config] of Object.entries(settings.mcpServers)) {
|
||||
const serverWithName: ServerConfigWithName = { name, ...config };
|
||||
if (currentServerNames.has(name)) {
|
||||
promises.push(this.serverDao.update(name, serverWithName));
|
||||
} else {
|
||||
promises.push(this.serverDao.create(serverWithName));
|
||||
}
|
||||
}
|
||||
|
||||
// Remove servers that are no longer in the settings
|
||||
for (const existingServer of currentServers) {
|
||||
if (!settings.mcpServers[existingServer.name]) {
|
||||
promises.push(this.serverDao.delete(existingServer.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save groups
|
||||
if (settings.groups) {
|
||||
const currentGroups = await this.groupDao.findAll();
|
||||
const currentGroupIds = new Set(currentGroups.map((g: any) => g.id));
|
||||
|
||||
for (const group of settings.groups) {
|
||||
if (group.id && currentGroupIds.has(group.id)) {
|
||||
promises.push(this.groupDao.update(group.id, group));
|
||||
} else {
|
||||
promises.push(this.groupDao.create(group));
|
||||
}
|
||||
}
|
||||
|
||||
// Remove groups that are no longer in the settings
|
||||
const newGroupIds = new Set(settings.groups.map((g) => g.id).filter(Boolean));
|
||||
for (const existingGroup of currentGroups) {
|
||||
if (!newGroupIds.has(existingGroup.id)) {
|
||||
promises.push(this.groupDao.delete(existingGroup.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save system config
|
||||
if (settings.systemConfig) {
|
||||
promises.push(this.systemConfigDao.update(settings.systemConfig));
|
||||
}
|
||||
|
||||
// Save user configs
|
||||
if (settings.userConfigs) {
|
||||
for (const [username, config] of Object.entries(settings.userConfigs)) {
|
||||
promises.push(this.userConfigDao.update(username, config));
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings using DAO layer:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter settings for non-admin users
|
||||
*/
|
||||
private filterSettingsForUser(settings: McpSettings, user: IUser): McpSettings {
|
||||
if (user.isAdmin) {
|
||||
return settings;
|
||||
}
|
||||
|
||||
// Non-admin users can only see their own servers and groups
|
||||
const filteredServers: { [key: string]: ServerConfig } = {};
|
||||
for (const [name, config] of Object.entries(settings.mcpServers || {})) {
|
||||
if (config.owner === user.username || config.owner === undefined) {
|
||||
filteredServers[name] = config;
|
||||
}
|
||||
}
|
||||
|
||||
const filteredGroups = (settings.groups || []).filter(
|
||||
(group) => group.owner === user.username || group.owner === undefined,
|
||||
);
|
||||
|
||||
return {
|
||||
...settings,
|
||||
mcpServers: filteredServers,
|
||||
groups: filteredGroups,
|
||||
users: [], // Non-admin users can't see user list
|
||||
systemConfig: {}, // Non-admin users can't see system config
|
||||
userConfigs: { [user.username]: settings.userConfigs?.[user.username] || {} },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge settings for non-admin users
|
||||
*/
|
||||
private mergeSettingsForUser(
|
||||
currentSettings: McpSettings,
|
||||
newSettings: McpSettings,
|
||||
user: IUser,
|
||||
): McpSettings {
|
||||
if (user.isAdmin) {
|
||||
return newSettings;
|
||||
}
|
||||
|
||||
// Non-admin users can only modify their own servers, groups, and user config
|
||||
const mergedSettings = { ...currentSettings };
|
||||
|
||||
// Merge servers (only user's own servers)
|
||||
if (newSettings.mcpServers) {
|
||||
for (const [name, config] of Object.entries(newSettings.mcpServers)) {
|
||||
const existingConfig = currentSettings.mcpServers?.[name];
|
||||
if (!existingConfig || existingConfig.owner === user.username) {
|
||||
mergedSettings.mcpServers = mergedSettings.mcpServers || {};
|
||||
mergedSettings.mcpServers[name] = { ...config, owner: user.username };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge groups (only user's own groups)
|
||||
if (newSettings.groups) {
|
||||
const userGroups = newSettings.groups
|
||||
.filter((group) => !group.owner || group.owner === user.username)
|
||||
.map((group) => ({ ...group, owner: user.username }));
|
||||
|
||||
const otherGroups = (currentSettings.groups || []).filter(
|
||||
(group) => group.owner !== user.username,
|
||||
);
|
||||
|
||||
mergedSettings.groups = [...otherGroups, ...userGroups];
|
||||
}
|
||||
|
||||
// Merge user config (only user's own config)
|
||||
if (newSettings.userConfigs?.[user.username]) {
|
||||
mergedSettings.userConfigs = mergedSettings.userConfigs || {};
|
||||
mergedSettings.userConfigs[user.username] = newSettings.userConfigs[user.username];
|
||||
}
|
||||
|
||||
return mergedSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all caches
|
||||
*/
|
||||
async clearCache(): Promise<void> {
|
||||
// DAO implementations handle their own caching
|
||||
// This could be extended to clear DAO-level caches if needed
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache info for debugging
|
||||
*/
|
||||
getCacheInfo(): { hasCache: boolean } {
|
||||
// DAO implementations handle their own caching
|
||||
return { hasCache: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a DaoConfigService with default DAO implementations
|
||||
*/
|
||||
export function createDaoConfigService(): DaoConfigService {
|
||||
return new DaoConfigService(
|
||||
new UserDaoImpl(),
|
||||
new ServerDaoImpl(),
|
||||
new GroupDaoImpl(),
|
||||
new SystemConfigDaoImpl(),
|
||||
new UserConfigDaoImpl(),
|
||||
);
|
||||
}
|
||||
138
src/config/configManager.ts
Normal file
138
src/config/configManager.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import dotenv from 'dotenv';
|
||||
import { McpSettings, IUser } from '../types/index.js';
|
||||
import { getPackageVersion } from '../utils/version.js';
|
||||
import { getDataService } from '../services/services.js';
|
||||
import { DataService } from '../services/dataService.js';
|
||||
import { DaoConfigService, createDaoConfigService } from './DaoConfigService.js';
|
||||
import {
|
||||
loadOriginalSettings as legacyLoadSettings,
|
||||
saveSettings as legacySaveSettings,
|
||||
clearSettingsCache as legacyClearCache,
|
||||
} from './index.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const defaultConfig = {
|
||||
port: process.env.PORT || 3000,
|
||||
initTimeout: process.env.INIT_TIMEOUT || 300000,
|
||||
basePath: process.env.BASE_PATH || '',
|
||||
readonly: 'true' === process.env.READONLY || false,
|
||||
mcpHubName: 'mcphub',
|
||||
mcpHubVersion: getPackageVersion(),
|
||||
};
|
||||
|
||||
// Configuration for which data access method to use
|
||||
const USE_DAO_LAYER = process.env.USE_DAO_LAYER === 'true';
|
||||
|
||||
// Services
|
||||
const dataService: DataService = getDataService();
|
||||
const daoConfigService: DaoConfigService = createDaoConfigService();
|
||||
|
||||
/**
|
||||
* Load settings using either DAO layer or legacy file-based approach
|
||||
*/
|
||||
export const loadSettings = async (user?: IUser): Promise<McpSettings> => {
|
||||
if (USE_DAO_LAYER) {
|
||||
console.log('Loading settings using DAO layer');
|
||||
return await daoConfigService.loadSettings(user);
|
||||
} else {
|
||||
console.log('Loading settings using legacy approach');
|
||||
const settings = legacyLoadSettings();
|
||||
return dataService.filterSettings!(settings, user);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Save settings using either DAO layer or legacy file-based approach
|
||||
*/
|
||||
export const saveSettings = async (settings: McpSettings, user?: IUser): Promise<boolean> => {
|
||||
if (USE_DAO_LAYER) {
|
||||
console.log('Saving settings using DAO layer');
|
||||
return await daoConfigService.saveSettings(settings, user);
|
||||
} else {
|
||||
console.log('Saving settings using legacy approach');
|
||||
const mergedSettings = dataService.mergeSettings!(legacyLoadSettings(), settings, user);
|
||||
return legacySaveSettings(mergedSettings, user);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear settings cache
|
||||
*/
|
||||
export const clearSettingsCache = (): void => {
|
||||
if (USE_DAO_LAYER) {
|
||||
daoConfigService.clearCache();
|
||||
} else {
|
||||
legacyClearCache();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current cache status (for debugging)
|
||||
*/
|
||||
export const getSettingsCacheInfo = (): { hasCache: boolean; usingDao: boolean } => {
|
||||
if (USE_DAO_LAYER) {
|
||||
const daoInfo = daoConfigService.getCacheInfo();
|
||||
return {
|
||||
...daoInfo,
|
||||
usingDao: true,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
hasCache: false, // Legacy method doesn't expose cache info here
|
||||
usingDao: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Switch to DAO layer at runtime (for testing/migration purposes)
|
||||
*/
|
||||
export const switchToDao = (): void => {
|
||||
process.env.USE_DAO_LAYER = 'true';
|
||||
};
|
||||
|
||||
/**
|
||||
* Switch to legacy file-based approach at runtime (for testing/rollback purposes)
|
||||
*/
|
||||
export const switchToLegacy = (): void => {
|
||||
process.env.USE_DAO_LAYER = 'false';
|
||||
};
|
||||
|
||||
/**
|
||||
* Get DAO config service for direct access
|
||||
*/
|
||||
export const getDaoConfigService = (): DaoConfigService => {
|
||||
return daoConfigService;
|
||||
};
|
||||
|
||||
/**
|
||||
* Migration utility to migrate from legacy format to DAO layer
|
||||
*/
|
||||
export const migrateToDao = async (): Promise<boolean> => {
|
||||
try {
|
||||
console.log('Starting migration from legacy format to DAO layer...');
|
||||
|
||||
// Load data using legacy method
|
||||
const legacySettings = legacyLoadSettings();
|
||||
|
||||
// Save using DAO layer
|
||||
switchToDao();
|
||||
const success = await saveSettings(legacySettings);
|
||||
|
||||
if (success) {
|
||||
console.log('Migration completed successfully');
|
||||
return true;
|
||||
} else {
|
||||
console.error('Migration failed during save operation');
|
||||
switchToLegacy();
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Migration failed:', error);
|
||||
switchToLegacy();
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export default defaultConfig;
|
||||
241
src/config/migrationUtils.ts
Normal file
241
src/config/migrationUtils.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Migration utilities for moving from legacy file-based config to DAO layer
|
||||
*/
|
||||
|
||||
import { loadSettings, migrateToDao, switchToDao, switchToLegacy } from './configManager.js';
|
||||
import { UserDaoImpl, ServerDaoImpl, GroupDaoImpl } from '../dao/index.js';
|
||||
|
||||
/**
|
||||
* Validate data integrity after migration
|
||||
*/
|
||||
export async function validateMigration(): Promise<boolean> {
|
||||
try {
|
||||
console.log('Validating migration...');
|
||||
|
||||
// Load settings using DAO layer
|
||||
switchToDao();
|
||||
const daoSettings = await loadSettings();
|
||||
|
||||
// Load settings using legacy method
|
||||
switchToLegacy();
|
||||
const legacySettings = await loadSettings();
|
||||
|
||||
// Compare key metrics
|
||||
const daoUserCount = daoSettings.users?.length || 0;
|
||||
const legacyUserCount = legacySettings.users?.length || 0;
|
||||
|
||||
const daoServerCount = Object.keys(daoSettings.mcpServers || {}).length;
|
||||
const legacyServerCount = Object.keys(legacySettings.mcpServers || {}).length;
|
||||
|
||||
const daoGroupCount = daoSettings.groups?.length || 0;
|
||||
const legacyGroupCount = legacySettings.groups?.length || 0;
|
||||
|
||||
console.log('Data comparison:');
|
||||
console.log(`Users: DAO=${daoUserCount}, Legacy=${legacyUserCount}`);
|
||||
console.log(`Servers: DAO=${daoServerCount}, Legacy=${legacyServerCount}`);
|
||||
console.log(`Groups: DAO=${daoGroupCount}, Legacy=${legacyGroupCount}`);
|
||||
|
||||
const isValid =
|
||||
daoUserCount === legacyUserCount &&
|
||||
daoServerCount === legacyServerCount &&
|
||||
daoGroupCount === legacyGroupCount;
|
||||
|
||||
if (isValid) {
|
||||
console.log('✅ Migration validation passed');
|
||||
} else {
|
||||
console.log('❌ Migration validation failed');
|
||||
}
|
||||
|
||||
return isValid;
|
||||
} catch (error) {
|
||||
console.error('Migration validation error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a complete migration with validation
|
||||
*/
|
||||
export async function performMigration(): Promise<boolean> {
|
||||
try {
|
||||
console.log('🚀 Starting migration to DAO layer...');
|
||||
|
||||
// Step 1: Backup current data
|
||||
console.log('📁 Creating backup of current data...');
|
||||
switchToLegacy();
|
||||
const _backupData = await loadSettings();
|
||||
|
||||
// Step 2: Perform migration
|
||||
console.log('🔄 Migrating data to DAO layer...');
|
||||
const migrationSuccess = await migrateToDao();
|
||||
|
||||
if (!migrationSuccess) {
|
||||
console.error('❌ Migration failed');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Step 3: Validate migration
|
||||
console.log('🔍 Validating migration...');
|
||||
const validationSuccess = await validateMigration();
|
||||
|
||||
if (!validationSuccess) {
|
||||
console.error('❌ Migration validation failed');
|
||||
// Could implement rollback here if needed
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('✅ Migration completed successfully!');
|
||||
console.log('💡 You can now use the DAO layer by setting USE_DAO_LAYER=true');
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Migration error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test DAO operations with sample data
|
||||
*/
|
||||
export async function testDaoOperations(): Promise<boolean> {
|
||||
try {
|
||||
console.log('🧪 Testing DAO operations...');
|
||||
|
||||
switchToDao();
|
||||
const userDao = new UserDaoImpl();
|
||||
const serverDao = new ServerDaoImpl();
|
||||
const groupDao = new GroupDaoImpl();
|
||||
|
||||
// Test user operations
|
||||
console.log('Testing user operations...');
|
||||
const testUser = await userDao.createWithHashedPassword('test-dao-user', 'password123', false);
|
||||
console.log(`✅ Created test user: ${testUser.username}`);
|
||||
|
||||
const foundUser = await userDao.findByUsername('test-dao-user');
|
||||
console.log(`✅ Found user: ${foundUser?.username}`);
|
||||
|
||||
const isValidPassword = await userDao.validateCredentials('test-dao-user', 'password123');
|
||||
console.log(`✅ Password validation: ${isValidPassword}`);
|
||||
|
||||
// Test server operations
|
||||
console.log('Testing server operations...');
|
||||
const testServer = await serverDao.create({
|
||||
name: 'test-dao-server',
|
||||
command: 'node',
|
||||
args: ['test.js'],
|
||||
enabled: true,
|
||||
owner: 'test-dao-user',
|
||||
});
|
||||
console.log(`✅ Created test server: ${testServer.name}`);
|
||||
|
||||
const userServers = await serverDao.findByOwner('test-dao-user');
|
||||
console.log(`✅ Found ${userServers.length} servers for user`);
|
||||
|
||||
// Test group operations
|
||||
console.log('Testing group operations...');
|
||||
const testGroup = await groupDao.create({
|
||||
name: 'test-dao-group',
|
||||
description: 'Test group for DAO operations',
|
||||
servers: ['test-dao-server'],
|
||||
owner: 'test-dao-user',
|
||||
});
|
||||
console.log(`✅ Created test group: ${testGroup.name} (ID: ${testGroup.id})`);
|
||||
|
||||
const userGroups = await groupDao.findByOwner('test-dao-user');
|
||||
console.log(`✅ Found ${userGroups.length} groups for user`);
|
||||
|
||||
// Cleanup test data
|
||||
console.log('Cleaning up test data...');
|
||||
await groupDao.delete(testGroup.id);
|
||||
await serverDao.delete('test-dao-server');
|
||||
await userDao.delete('test-dao-user');
|
||||
console.log('✅ Test data cleaned up');
|
||||
|
||||
console.log('🎉 All DAO operations test passed!');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('DAO operations test error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performance comparison between legacy and DAO approaches
|
||||
*/
|
||||
export async function performanceComparison(): Promise<void> {
|
||||
try {
|
||||
console.log('⚡ Performance comparison...');
|
||||
|
||||
// Test legacy approach
|
||||
console.log('Testing legacy approach...');
|
||||
switchToLegacy();
|
||||
const legacyStart = Date.now();
|
||||
await loadSettings();
|
||||
const legacyTime = Date.now() - legacyStart;
|
||||
console.log(`Legacy load time: ${legacyTime}ms`);
|
||||
|
||||
// Test DAO approach
|
||||
console.log('Testing DAO approach...');
|
||||
switchToDao();
|
||||
const daoStart = Date.now();
|
||||
await loadSettings();
|
||||
const daoTime = Date.now() - daoStart;
|
||||
console.log(`DAO load time: ${daoTime}ms`);
|
||||
|
||||
// Comparison
|
||||
const difference = daoTime - legacyTime;
|
||||
const percentage = ((difference / legacyTime) * 100).toFixed(2);
|
||||
|
||||
console.log(`Performance difference: ${difference}ms (${percentage}%)`);
|
||||
|
||||
if (difference > 0) {
|
||||
console.log(`DAO approach is ${percentage}% slower`);
|
||||
} else {
|
||||
console.log(`DAO approach is ${Math.abs(parseFloat(percentage))}% faster`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Performance comparison error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate migration report
|
||||
*/
|
||||
export async function generateMigrationReport(): Promise<any> {
|
||||
try {
|
||||
console.log('📊 Generating migration report...');
|
||||
|
||||
// Collect statistics from both approaches
|
||||
switchToLegacy();
|
||||
const legacySettings = await loadSettings();
|
||||
|
||||
switchToDao();
|
||||
const daoSettings = await loadSettings();
|
||||
|
||||
const report = {
|
||||
timestamp: new Date().toISOString(),
|
||||
legacy: {
|
||||
users: legacySettings.users?.length || 0,
|
||||
servers: Object.keys(legacySettings.mcpServers || {}).length,
|
||||
groups: legacySettings.groups?.length || 0,
|
||||
systemConfigSections: Object.keys(legacySettings.systemConfig || {}).length,
|
||||
userConfigs: Object.keys(legacySettings.userConfigs || {}).length,
|
||||
},
|
||||
dao: {
|
||||
users: daoSettings.users?.length || 0,
|
||||
servers: Object.keys(daoSettings.mcpServers || {}).length,
|
||||
groups: daoSettings.groups?.length || 0,
|
||||
systemConfigSections: Object.keys(daoSettings.systemConfig || {}).length,
|
||||
userConfigs: Object.keys(daoSettings.userConfigs || {}).length,
|
||||
},
|
||||
};
|
||||
|
||||
console.log('📈 Migration Report:');
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
|
||||
return report;
|
||||
} catch (error) {
|
||||
console.error('Report generation error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export const streamLogs = (req: Request, res: Response): void => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
|
||||
// Send initial data
|
||||
@@ -52,4 +52,4 @@ export const streamLogs = (req: Request, res: Response): void => {
|
||||
console.error('Error streaming logs:', error);
|
||||
res.status(500).json({ success: false, error: 'Error streaming logs' });
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
getMarketTags,
|
||||
searchMarketServers,
|
||||
filterMarketServersByCategory,
|
||||
filterMarketServersByTag
|
||||
filterMarketServersByTag,
|
||||
} from '../services/marketService.js';
|
||||
|
||||
// Get all market servers
|
||||
@@ -100,7 +100,7 @@ export const searchMarketServersByQuery = (req: Request, res: Response): void =>
|
||||
try {
|
||||
const { query } = req.query;
|
||||
const searchQuery = typeof query === 'string' ? query : '';
|
||||
|
||||
|
||||
const servers = searchMarketServers(searchQuery);
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
@@ -119,7 +119,7 @@ export const searchMarketServersByQuery = (req: Request, res: Response): void =>
|
||||
export const getMarketServersByCategory = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { category } = req.params;
|
||||
|
||||
|
||||
const servers = filterMarketServersByCategory(category);
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
@@ -138,7 +138,7 @@ export const getMarketServersByCategory = (req: Request, res: Response): void =>
|
||||
export const getMarketServersByTag = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { tag } = req.params;
|
||||
|
||||
|
||||
const servers = filterMarketServersByTag(tag);
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
@@ -151,4 +151,4 @@ export const getMarketServersByTag = (req: Request, res: Response): void => {
|
||||
message: 'Failed to filter market servers by tag',
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
304
src/controllers/openApiController.ts
Normal file
304
src/controllers/openApiController.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import { Request, Response } from 'express';
|
||||
import {
|
||||
generateOpenAPISpec,
|
||||
getAvailableServers,
|
||||
getToolStats,
|
||||
OpenAPIGenerationOptions,
|
||||
} from '../services/openApiGeneratorService.js';
|
||||
import { getServerByName } from '../services/mcpService.js';
|
||||
import { getGroupByIdOrName } from '../services/groupService.js';
|
||||
|
||||
/**
|
||||
* Controller for OpenAPI generation endpoints
|
||||
* Provides OpenAPI specifications for MCP tools to enable OpenWebUI integration
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convert query parameters to their proper types based on the tool's input schema
|
||||
*/
|
||||
function convertQueryParametersToTypes(
|
||||
queryParams: Record<string, any>,
|
||||
inputSchema: Record<string, any>,
|
||||
): Record<string, any> {
|
||||
if (!inputSchema || typeof inputSchema !== 'object' || !inputSchema.properties) {
|
||||
return queryParams;
|
||||
}
|
||||
|
||||
const convertedParams: Record<string, any> = {};
|
||||
const properties = inputSchema.properties;
|
||||
|
||||
for (const [key, value] of Object.entries(queryParams)) {
|
||||
const propDef = properties[key];
|
||||
if (!propDef || typeof propDef !== 'object') {
|
||||
// No schema definition found, keep as is
|
||||
convertedParams[key] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
const propType = propDef.type;
|
||||
|
||||
try {
|
||||
switch (propType) {
|
||||
case 'integer':
|
||||
case 'number':
|
||||
// Convert string to number
|
||||
if (typeof value === 'string') {
|
||||
const numValue = propType === 'integer' ? parseInt(value, 10) : parseFloat(value);
|
||||
convertedParams[key] = isNaN(numValue) ? value : numValue;
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'boolean':
|
||||
// Convert string to boolean
|
||||
if (typeof value === 'string') {
|
||||
convertedParams[key] = value.toLowerCase() === 'true' || value === '1';
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'array':
|
||||
// Handle array conversion if needed (e.g., comma-separated strings)
|
||||
if (typeof value === 'string' && value.includes(',')) {
|
||||
convertedParams[key] = value.split(',').map((item) => item.trim());
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// For string and other types, keep as is
|
||||
convertedParams[key] = value;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
// If conversion fails, keep the original value
|
||||
console.warn(`Failed to convert parameter '${key}' to type '${propType}':`, error);
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return convertedParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and return OpenAPI specification
|
||||
* GET /api/openapi.json
|
||||
*/
|
||||
export const getOpenAPISpec = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const options: OpenAPIGenerationOptions = {
|
||||
title: req.query.title as string,
|
||||
description: req.query.description as string,
|
||||
version: req.query.version as string,
|
||||
serverUrl: req.query.serverUrl as string,
|
||||
includeDisabledTools: req.query.includeDisabled === 'true',
|
||||
groupFilter: req.query.group as string,
|
||||
serverFilter: req.query.servers ? (req.query.servers as string).split(',') : undefined,
|
||||
};
|
||||
|
||||
const openApiSpec = await generateOpenAPISpec(options);
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
|
||||
res.json(openApiSpec);
|
||||
} catch (error) {
|
||||
console.error('Error generating OpenAPI specification:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to generate OpenAPI specification',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get available servers for filtering
|
||||
* GET /api/openapi/servers
|
||||
*/
|
||||
export const getOpenAPIServers = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const servers = await getAvailableServers();
|
||||
res.json({
|
||||
success: true,
|
||||
data: servers,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting available servers:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get available servers',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get tool statistics
|
||||
* GET /api/openapi/stats
|
||||
*/
|
||||
export const getOpenAPIStats = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const stats = await getToolStats();
|
||||
res.json({
|
||||
success: true,
|
||||
data: stats,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting tool statistics:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get tool statistics',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute tool via OpenAPI-compatible endpoint
|
||||
* This allows OpenWebUI to call MCP tools directly
|
||||
* POST /api/tools/:serverName/:toolName
|
||||
* GET /api/tools/:serverName/:toolName (for simple tools)
|
||||
*/
|
||||
export const executeToolViaOpenAPI = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName, toolName } = req.params;
|
||||
|
||||
// Import handleCallToolRequest function
|
||||
const { handleCallToolRequest } = await import('../services/mcpService.js');
|
||||
|
||||
// Get the server info to access the tool's input schema
|
||||
const serverInfo = getServerByName(serverName);
|
||||
let inputSchema: Record<string, any> = {};
|
||||
|
||||
if (serverInfo) {
|
||||
// Find the tool in the server's tools list
|
||||
const fullToolName = `${serverName}-${toolName}`;
|
||||
const tool = serverInfo.tools.find(
|
||||
(t: any) => t.name === fullToolName || t.name === toolName,
|
||||
);
|
||||
if (tool && tool.inputSchema) {
|
||||
inputSchema = tool.inputSchema as Record<string, any>;
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare arguments from query params (GET) or body (POST)
|
||||
let args = req.method === 'GET' ? req.query : req.body || {};
|
||||
args = convertQueryParametersToTypes(args, inputSchema);
|
||||
|
||||
// Create a mock request structure that matches what handleCallToolRequest expects
|
||||
const mockRequest = {
|
||||
params: {
|
||||
name: toolName, // Just use the tool name without server prefix as it gets added by handleCallToolRequest
|
||||
arguments: args,
|
||||
},
|
||||
};
|
||||
|
||||
const extra = {
|
||||
sessionId: (req.headers['x-session-id'] as string) || 'openapi-session',
|
||||
server: serverName,
|
||||
};
|
||||
|
||||
const result = await handleCallToolRequest(mockRequest, extra);
|
||||
|
||||
// Return the result in OpenAPI format (matching MCP tool response structure)
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Error executing tool via OpenAPI:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to execute tool',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate and return OpenAPI specification for a specific server
|
||||
* GET /api/openapi/:name.json
|
||||
*/
|
||||
export const getServerOpenAPISpec = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
|
||||
// Check if server exists
|
||||
const availableServers = await getAvailableServers();
|
||||
if (!availableServers.includes(name)) {
|
||||
res.status(404).json({
|
||||
error: 'Server not found',
|
||||
message: `Server '${name}' is not connected or does not exist`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const options: OpenAPIGenerationOptions = {
|
||||
title: (req.query.title as string) || `${name} MCP API`,
|
||||
description:
|
||||
(req.query.description as string) || `OpenAPI specification for ${name} MCP server tools`,
|
||||
version: req.query.version as string,
|
||||
serverUrl: req.query.serverUrl as string,
|
||||
includeDisabledTools: req.query.includeDisabled === 'true',
|
||||
serverFilter: [name], // Filter to only this server
|
||||
};
|
||||
|
||||
const openApiSpec = await generateOpenAPISpec(options);
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
|
||||
res.json(openApiSpec);
|
||||
} catch (error) {
|
||||
console.error('Error generating server OpenAPI specification:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to generate server OpenAPI specification',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate and return OpenAPI specification for a specific group
|
||||
* GET /api/openapi/group/:groupName.json
|
||||
*/
|
||||
export const getGroupOpenAPISpec = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
|
||||
// Check if group exists
|
||||
const group = getGroupByIdOrName(name);
|
||||
if (!group) {
|
||||
getServerOpenAPISpec(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
const options: OpenAPIGenerationOptions = {
|
||||
title: (req.query.title as string) || `${group.name} Group MCP API`,
|
||||
description:
|
||||
(req.query.description as string) || `OpenAPI specification for ${group.name} group tools`,
|
||||
version: req.query.version as string,
|
||||
serverUrl: req.query.serverUrl as string,
|
||||
includeDisabledTools: req.query.includeDisabled === 'true',
|
||||
groupFilter: name, // Use existing group filter functionality
|
||||
};
|
||||
|
||||
const openApiSpec = await generateOpenAPISpec(options);
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
|
||||
res.json(openApiSpec);
|
||||
} catch (error) {
|
||||
console.error('Error generating group OpenAPI specification:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to generate group OpenAPI specification',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -17,7 +17,7 @@ export const getPrompt = async (req: Request, res: Response): Promise<void> => {
|
||||
}
|
||||
|
||||
const promptArgs = {
|
||||
params: req.body as { [key: string]: any }
|
||||
params: req.body as { [key: string]: any },
|
||||
};
|
||||
const result = await handleGetPromptRequest(promptArgs, serverName);
|
||||
if (result.isError) {
|
||||
|
||||
@@ -13,9 +13,9 @@ import { loadSettings, saveSettings } from '../config/index.js';
|
||||
import { syncAllServerToolsEmbeddings } from '../services/vectorSearchService.js';
|
||||
import { createSafeJSON } from '../utils/serialization.js';
|
||||
|
||||
export const getAllServers = (_: Request, res: Response): void => {
|
||||
export const getAllServers = async (_: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const serversInfo = getServersInfo();
|
||||
const serversInfo = await getServersInfo();
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: createSafeJSON(serversInfo),
|
||||
@@ -167,7 +167,7 @@ export const deleteServer = async (req: Request, res: Response): Promise<void> =
|
||||
return;
|
||||
}
|
||||
|
||||
const result = removeServer(name);
|
||||
const result = await removeServer(name);
|
||||
if (result.success) {
|
||||
notifyToolChanged();
|
||||
res.json({
|
||||
@@ -299,11 +299,12 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
|
||||
}
|
||||
};
|
||||
|
||||
export const getServerConfig = (req: Request, res: Response): void => {
|
||||
export const getServerConfig = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
const settings = loadSettings();
|
||||
if (!settings.mcpServers || !settings.mcpServers[name]) {
|
||||
const allServers = await getServersInfo();
|
||||
const serverInfo = allServers.find((s) => s.name === name);
|
||||
if (!serverInfo) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Server not found',
|
||||
@@ -311,15 +312,13 @@ export const getServerConfig = (req: Request, res: Response): void => {
|
||||
return;
|
||||
}
|
||||
|
||||
const serverInfo = getServersInfo().find((s) => s.name === name);
|
||||
const serverConfig = settings.mcpServers[name];
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: {
|
||||
name,
|
||||
status: serverInfo ? serverInfo.status : 'disconnected',
|
||||
tools: serverInfo ? serverInfo.tools : [],
|
||||
config: serverConfig,
|
||||
config: serverInfo,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -562,7 +561,7 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
},
|
||||
mcpRouter: {
|
||||
apiKey: '',
|
||||
referer: 'https://mcphub.app',
|
||||
referer: 'https://www.mcphubx.com',
|
||||
title: 'MCPHub',
|
||||
baseUrl: 'https://api.mcprouter.to/v1',
|
||||
},
|
||||
@@ -600,7 +599,7 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
if (!settings.systemConfig.mcpRouter) {
|
||||
settings.systemConfig.mcpRouter = {
|
||||
apiKey: '',
|
||||
referer: 'https://mcphub.app',
|
||||
referer: 'https://www.mcphubx.com',
|
||||
title: 'MCPHub',
|
||||
baseUrl: 'https://api.mcprouter.to/v1',
|
||||
};
|
||||
@@ -866,4 +865,4 @@ export const updatePromptDescription = async (req: Request, res: Response): Prom
|
||||
message: 'Internal server error',
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
131
src/dao/DaoFactory.ts
Normal file
131
src/dao/DaoFactory.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { UserDao, UserDaoImpl } from './UserDao.js';
|
||||
import { ServerDao, ServerDaoImpl } from './ServerDao.js';
|
||||
import { GroupDao, GroupDaoImpl } from './GroupDao.js';
|
||||
import { SystemConfigDao, SystemConfigDaoImpl } from './SystemConfigDao.js';
|
||||
import { UserConfigDao, UserConfigDaoImpl } from './UserConfigDao.js';
|
||||
|
||||
/**
|
||||
* DAO Factory interface for creating DAO instances
|
||||
*/
|
||||
export interface DaoFactory {
|
||||
getUserDao(): UserDao;
|
||||
getServerDao(): ServerDao;
|
||||
getGroupDao(): GroupDao;
|
||||
getSystemConfigDao(): SystemConfigDao;
|
||||
getUserConfigDao(): UserConfigDao;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default DAO factory implementation using JSON file-based DAOs
|
||||
*/
|
||||
export class JsonFileDaoFactory implements DaoFactory {
|
||||
private static instance: JsonFileDaoFactory;
|
||||
|
||||
private userDao: UserDao | null = null;
|
||||
private serverDao: ServerDao | null = null;
|
||||
private groupDao: GroupDao | null = null;
|
||||
private systemConfigDao: SystemConfigDao | null = null;
|
||||
private userConfigDao: UserConfigDao | null = null;
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
*/
|
||||
public static getInstance(): JsonFileDaoFactory {
|
||||
if (!JsonFileDaoFactory.instance) {
|
||||
JsonFileDaoFactory.instance = new JsonFileDaoFactory();
|
||||
}
|
||||
return JsonFileDaoFactory.instance;
|
||||
}
|
||||
|
||||
private constructor() {
|
||||
// Private constructor for singleton
|
||||
}
|
||||
|
||||
getUserDao(): UserDao {
|
||||
if (!this.userDao) {
|
||||
this.userDao = new UserDaoImpl();
|
||||
}
|
||||
return this.userDao;
|
||||
}
|
||||
|
||||
getServerDao(): ServerDao {
|
||||
if (!this.serverDao) {
|
||||
this.serverDao = new ServerDaoImpl();
|
||||
}
|
||||
return this.serverDao;
|
||||
}
|
||||
|
||||
getGroupDao(): GroupDao {
|
||||
if (!this.groupDao) {
|
||||
this.groupDao = new GroupDaoImpl();
|
||||
}
|
||||
return this.groupDao;
|
||||
}
|
||||
|
||||
getSystemConfigDao(): SystemConfigDao {
|
||||
if (!this.systemConfigDao) {
|
||||
this.systemConfigDao = new SystemConfigDaoImpl();
|
||||
}
|
||||
return this.systemConfigDao;
|
||||
}
|
||||
|
||||
getUserConfigDao(): UserConfigDao {
|
||||
if (!this.userConfigDao) {
|
||||
this.userConfigDao = new UserConfigDaoImpl();
|
||||
}
|
||||
return this.userConfigDao;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all cached DAO instances (useful for testing)
|
||||
*/
|
||||
public resetInstances(): void {
|
||||
this.userDao = null;
|
||||
this.serverDao = null;
|
||||
this.groupDao = null;
|
||||
this.systemConfigDao = null;
|
||||
this.userConfigDao = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global DAO factory instance
|
||||
*/
|
||||
let daoFactory: DaoFactory = JsonFileDaoFactory.getInstance();
|
||||
|
||||
/**
|
||||
* Set the global DAO factory (useful for dependency injection)
|
||||
*/
|
||||
export function setDaoFactory(factory: DaoFactory): void {
|
||||
daoFactory = factory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the global DAO factory
|
||||
*/
|
||||
export function getDaoFactory(): DaoFactory {
|
||||
return daoFactory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience functions to get specific DAOs
|
||||
*/
|
||||
export function getUserDao(): UserDao {
|
||||
return getDaoFactory().getUserDao();
|
||||
}
|
||||
|
||||
export function getServerDao(): ServerDao {
|
||||
return getDaoFactory().getServerDao();
|
||||
}
|
||||
|
||||
export function getGroupDao(): GroupDao {
|
||||
return getDaoFactory().getGroupDao();
|
||||
}
|
||||
|
||||
export function getSystemConfigDao(): SystemConfigDao {
|
||||
return getDaoFactory().getSystemConfigDao();
|
||||
}
|
||||
|
||||
export function getUserConfigDao(): UserConfigDao {
|
||||
return getDaoFactory().getUserConfigDao();
|
||||
}
|
||||
221
src/dao/GroupDao.ts
Normal file
221
src/dao/GroupDao.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { IGroup } from '../types/index.js';
|
||||
import { BaseDao } from './base/BaseDao.js';
|
||||
import { JsonFileBaseDao } from './base/JsonFileBaseDao.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
/**
|
||||
* Group DAO interface with group-specific operations
|
||||
*/
|
||||
export interface GroupDao extends BaseDao<IGroup, string> {
|
||||
/**
|
||||
* Find groups by owner
|
||||
*/
|
||||
findByOwner(owner: string): Promise<IGroup[]>;
|
||||
|
||||
/**
|
||||
* Find groups containing specific server
|
||||
*/
|
||||
findByServer(serverName: string): Promise<IGroup[]>;
|
||||
|
||||
/**
|
||||
* Add server to group
|
||||
*/
|
||||
addServerToGroup(groupId: string, serverName: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Remove server from group
|
||||
*/
|
||||
removeServerFromGroup(groupId: string, serverName: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Update group servers
|
||||
*/
|
||||
updateServers(groupId: string, servers: string[] | IGroup['servers']): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Find group by name
|
||||
*/
|
||||
findByName(name: string): Promise<IGroup | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON file-based Group DAO implementation
|
||||
*/
|
||||
export class GroupDaoImpl extends JsonFileBaseDao implements GroupDao {
|
||||
protected async getAll(): Promise<IGroup[]> {
|
||||
const settings = await this.loadSettings();
|
||||
return settings.groups || [];
|
||||
}
|
||||
|
||||
protected async saveAll(groups: IGroup[]): Promise<void> {
|
||||
const settings = await this.loadSettings();
|
||||
settings.groups = groups;
|
||||
await this.saveSettings(settings);
|
||||
}
|
||||
|
||||
protected getEntityId(group: IGroup): string {
|
||||
return group.id;
|
||||
}
|
||||
|
||||
protected createEntity(data: Omit<IGroup, 'id'>): IGroup {
|
||||
return {
|
||||
id: uuidv4(),
|
||||
owner: 'admin', // Default owner
|
||||
...data,
|
||||
servers: data.servers || [],
|
||||
};
|
||||
}
|
||||
|
||||
protected updateEntity(existing: IGroup, updates: Partial<IGroup>): IGroup {
|
||||
return {
|
||||
...existing,
|
||||
...updates,
|
||||
id: existing.id, // ID should not be updated
|
||||
};
|
||||
}
|
||||
|
||||
async findAll(): Promise<IGroup[]> {
|
||||
return this.getAll();
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<IGroup | null> {
|
||||
const groups = await this.getAll();
|
||||
return groups.find((group) => group.id === id) || null;
|
||||
}
|
||||
|
||||
async create(data: Omit<IGroup, 'id'>): Promise<IGroup> {
|
||||
const groups = await this.getAll();
|
||||
|
||||
// Check if group name already exists
|
||||
if (groups.find((group) => group.name === data.name)) {
|
||||
throw new Error(`Group with name ${data.name} already exists`);
|
||||
}
|
||||
|
||||
const newGroup = this.createEntity(data);
|
||||
groups.push(newGroup);
|
||||
await this.saveAll(groups);
|
||||
|
||||
return newGroup;
|
||||
}
|
||||
|
||||
async update(id: string, updates: Partial<IGroup>): Promise<IGroup | null> {
|
||||
const groups = await this.getAll();
|
||||
const index = groups.findIndex((group) => group.id === id);
|
||||
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if name update would cause conflict
|
||||
if (updates.name && updates.name !== groups[index].name) {
|
||||
const existingGroup = groups.find((group) => group.name === updates.name && group.id !== id);
|
||||
if (existingGroup) {
|
||||
throw new Error(`Group with name ${updates.name} already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
// Don't allow ID changes
|
||||
const { id: _, ...allowedUpdates } = updates;
|
||||
const updatedGroup = this.updateEntity(groups[index], allowedUpdates);
|
||||
groups[index] = updatedGroup;
|
||||
|
||||
await this.saveAll(groups);
|
||||
return updatedGroup;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const groups = await this.getAll();
|
||||
const index = groups.findIndex((group) => group.id === id);
|
||||
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
groups.splice(index, 1);
|
||||
await this.saveAll(groups);
|
||||
return true;
|
||||
}
|
||||
|
||||
async exists(id: string): Promise<boolean> {
|
||||
const group = await this.findById(id);
|
||||
return group !== null;
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
const groups = await this.getAll();
|
||||
return groups.length;
|
||||
}
|
||||
|
||||
async findByOwner(owner: string): Promise<IGroup[]> {
|
||||
const groups = await this.getAll();
|
||||
return groups.filter((group) => group.owner === owner);
|
||||
}
|
||||
|
||||
async findByServer(serverName: string): Promise<IGroup[]> {
|
||||
const groups = await this.getAll();
|
||||
return groups.filter((group) => {
|
||||
if (Array.isArray(group.servers)) {
|
||||
return group.servers.some((server) => {
|
||||
if (typeof server === 'string') {
|
||||
return server === serverName;
|
||||
} else {
|
||||
return server.name === serverName;
|
||||
}
|
||||
});
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
async addServerToGroup(groupId: string, serverName: string): Promise<boolean> {
|
||||
const group = await this.findById(groupId);
|
||||
if (!group) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if server already exists in group
|
||||
const serverExists = group.servers.some((server) => {
|
||||
if (typeof server === 'string') {
|
||||
return server === serverName;
|
||||
} else {
|
||||
return server.name === serverName;
|
||||
}
|
||||
});
|
||||
|
||||
if (serverExists) {
|
||||
return true; // Already exists, consider it success
|
||||
}
|
||||
|
||||
const updatedServers = [...group.servers, serverName] as IGroup['servers'];
|
||||
const result = await this.update(groupId, { servers: updatedServers });
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
async removeServerFromGroup(groupId: string, serverName: string): Promise<boolean> {
|
||||
const group = await this.findById(groupId);
|
||||
if (!group) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const updatedServers = group.servers.filter((server) => {
|
||||
if (typeof server === 'string') {
|
||||
return server !== serverName;
|
||||
} else {
|
||||
return server.name !== serverName;
|
||||
}
|
||||
}) as IGroup['servers'];
|
||||
|
||||
const result = await this.update(groupId, { servers: updatedServers });
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
async updateServers(groupId: string, servers: string[] | IGroup['servers']): Promise<boolean> {
|
||||
const result = await this.update(groupId, { servers });
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
async findByName(name: string): Promise<IGroup | null> {
|
||||
const groups = await this.getAll();
|
||||
return groups.find((group) => group.name === name) || null;
|
||||
}
|
||||
}
|
||||
210
src/dao/ServerDao.ts
Normal file
210
src/dao/ServerDao.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { ServerConfig } from '../types/index.js';
|
||||
import { BaseDao } from './base/BaseDao.js';
|
||||
import { JsonFileBaseDao } from './base/JsonFileBaseDao.js';
|
||||
|
||||
/**
|
||||
* Server DAO interface with server-specific operations
|
||||
*/
|
||||
export interface ServerDao extends BaseDao<ServerConfigWithName, string> {
|
||||
/**
|
||||
* Find servers by owner
|
||||
*/
|
||||
findByOwner(owner: string): Promise<ServerConfigWithName[]>;
|
||||
|
||||
/**
|
||||
* Find enabled servers only
|
||||
*/
|
||||
findEnabled(): Promise<ServerConfigWithName[]>;
|
||||
|
||||
/**
|
||||
* Find servers by type
|
||||
*/
|
||||
findByType(type: string): Promise<ServerConfigWithName[]>;
|
||||
|
||||
/**
|
||||
* Enable/disable server
|
||||
*/
|
||||
setEnabled(name: string, enabled: boolean): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Update server tools configuration
|
||||
*/
|
||||
updateTools(
|
||||
name: string,
|
||||
tools: Record<string, { enabled: boolean; description?: string }>,
|
||||
): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Update server prompts configuration
|
||||
*/
|
||||
updatePrompts(
|
||||
name: string,
|
||||
prompts: Record<string, { enabled: boolean; description?: string }>,
|
||||
): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server configuration with name for DAO operations
|
||||
*/
|
||||
export interface ServerConfigWithName extends ServerConfig {
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON file-based Server DAO implementation
|
||||
*/
|
||||
export class ServerDaoImpl extends JsonFileBaseDao implements ServerDao {
|
||||
protected async getAll(): Promise<ServerConfigWithName[]> {
|
||||
const settings = await this.loadSettings();
|
||||
const servers: ServerConfigWithName[] = [];
|
||||
|
||||
for (const [name, config] of Object.entries(settings.mcpServers || {})) {
|
||||
servers.push({
|
||||
name,
|
||||
...config,
|
||||
});
|
||||
}
|
||||
|
||||
return servers;
|
||||
}
|
||||
|
||||
protected async saveAll(servers: ServerConfigWithName[]): Promise<void> {
|
||||
const settings = await this.loadSettings();
|
||||
settings.mcpServers = {};
|
||||
|
||||
for (const server of servers) {
|
||||
const { name, ...config } = server;
|
||||
settings.mcpServers[name] = config;
|
||||
}
|
||||
|
||||
await this.saveSettings(settings);
|
||||
}
|
||||
|
||||
protected getEntityId(server: ServerConfigWithName): string {
|
||||
return server.name;
|
||||
}
|
||||
|
||||
protected createEntity(_data: Omit<ServerConfigWithName, 'name'>): ServerConfigWithName {
|
||||
throw new Error('Server name must be provided');
|
||||
}
|
||||
|
||||
protected updateEntity(
|
||||
existing: ServerConfigWithName,
|
||||
updates: Partial<ServerConfigWithName>,
|
||||
): ServerConfigWithName {
|
||||
return {
|
||||
...existing,
|
||||
...updates,
|
||||
name: existing.name, // Name should not be updated
|
||||
};
|
||||
}
|
||||
|
||||
async findAll(): Promise<ServerConfigWithName[]> {
|
||||
return this.getAll();
|
||||
}
|
||||
|
||||
async findById(name: string): Promise<ServerConfigWithName | null> {
|
||||
const servers = await this.getAll();
|
||||
return servers.find((server) => server.name === name) || null;
|
||||
}
|
||||
|
||||
async create(
|
||||
data: Omit<ServerConfigWithName, 'name'> & { name: string },
|
||||
): Promise<ServerConfigWithName> {
|
||||
const servers = await this.getAll();
|
||||
|
||||
// Check if server already exists
|
||||
if (servers.find((server) => server.name === data.name)) {
|
||||
throw new Error(`Server ${data.name} already exists`);
|
||||
}
|
||||
|
||||
const newServer: ServerConfigWithName = {
|
||||
enabled: true, // Default to enabled
|
||||
owner: 'admin', // Default owner
|
||||
...data,
|
||||
};
|
||||
|
||||
servers.push(newServer);
|
||||
await this.saveAll(servers);
|
||||
|
||||
return newServer;
|
||||
}
|
||||
|
||||
async update(
|
||||
name: string,
|
||||
updates: Partial<ServerConfigWithName>,
|
||||
): Promise<ServerConfigWithName | null> {
|
||||
const servers = await this.getAll();
|
||||
const index = servers.findIndex((server) => server.name === name);
|
||||
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Don't allow name changes
|
||||
const { name: _, ...allowedUpdates } = updates;
|
||||
const updatedServer = this.updateEntity(servers[index], allowedUpdates);
|
||||
servers[index] = updatedServer;
|
||||
|
||||
await this.saveAll(servers);
|
||||
return updatedServer;
|
||||
}
|
||||
|
||||
async delete(name: string): Promise<boolean> {
|
||||
const servers = await this.getAll();
|
||||
const index = servers.findIndex((server) => server.name === name);
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
servers.splice(index, 1);
|
||||
await this.saveAll(servers);
|
||||
return true;
|
||||
}
|
||||
|
||||
async exists(name: string): Promise<boolean> {
|
||||
const server = await this.findById(name);
|
||||
return server !== null;
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
const servers = await this.getAll();
|
||||
return servers.length;
|
||||
}
|
||||
|
||||
async findByOwner(owner: string): Promise<ServerConfigWithName[]> {
|
||||
const servers = await this.getAll();
|
||||
return servers.filter((server) => server.owner === owner);
|
||||
}
|
||||
|
||||
async findEnabled(): Promise<ServerConfigWithName[]> {
|
||||
const servers = await this.getAll();
|
||||
return servers.filter((server) => server.enabled !== false);
|
||||
}
|
||||
|
||||
async findByType(type: string): Promise<ServerConfigWithName[]> {
|
||||
const servers = await this.getAll();
|
||||
return servers.filter((server) => server.type === type);
|
||||
}
|
||||
|
||||
async setEnabled(name: string, enabled: boolean): Promise<boolean> {
|
||||
const result = await this.update(name, { enabled });
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
async updateTools(
|
||||
name: string,
|
||||
tools: Record<string, { enabled: boolean; description?: string }>,
|
||||
): Promise<boolean> {
|
||||
const result = await this.update(name, { tools });
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
async updatePrompts(
|
||||
name: string,
|
||||
prompts: Record<string, { enabled: boolean; description?: string }>,
|
||||
): Promise<boolean> {
|
||||
const result = await this.update(name, { prompts });
|
||||
return result !== null;
|
||||
}
|
||||
}
|
||||
98
src/dao/SystemConfigDao.ts
Normal file
98
src/dao/SystemConfigDao.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { SystemConfig } from '../types/index.js';
|
||||
import { JsonFileBaseDao } from './base/JsonFileBaseDao.js';
|
||||
|
||||
/**
|
||||
* System Configuration DAO interface
|
||||
*/
|
||||
export interface SystemConfigDao {
|
||||
/**
|
||||
* Get system configuration
|
||||
*/
|
||||
get(): Promise<SystemConfig>;
|
||||
|
||||
/**
|
||||
* Update system configuration
|
||||
*/
|
||||
update(config: Partial<SystemConfig>): Promise<SystemConfig>;
|
||||
|
||||
/**
|
||||
* Reset system configuration to defaults
|
||||
*/
|
||||
reset(): Promise<SystemConfig>;
|
||||
|
||||
/**
|
||||
* Get specific configuration section
|
||||
*/
|
||||
getSection<K extends keyof SystemConfig>(section: K): Promise<SystemConfig[K] | undefined>;
|
||||
|
||||
/**
|
||||
* Update specific configuration section
|
||||
*/
|
||||
updateSection<K extends keyof SystemConfig>(section: K, value: SystemConfig[K]): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON file-based System Configuration DAO implementation
|
||||
*/
|
||||
export class SystemConfigDaoImpl extends JsonFileBaseDao implements SystemConfigDao {
|
||||
async get(): Promise<SystemConfig> {
|
||||
const settings = await this.loadSettings();
|
||||
return settings.systemConfig || {};
|
||||
}
|
||||
|
||||
async update(config: Partial<SystemConfig>): Promise<SystemConfig> {
|
||||
const settings = await this.loadSettings();
|
||||
const currentConfig = settings.systemConfig || {};
|
||||
|
||||
// Deep merge configuration
|
||||
const updatedConfig = this.deepMerge(currentConfig, config);
|
||||
settings.systemConfig = updatedConfig;
|
||||
|
||||
await this.saveSettings(settings);
|
||||
return updatedConfig;
|
||||
}
|
||||
|
||||
async reset(): Promise<SystemConfig> {
|
||||
const settings = await this.loadSettings();
|
||||
const defaultConfig: SystemConfig = {};
|
||||
|
||||
settings.systemConfig = defaultConfig;
|
||||
await this.saveSettings(settings);
|
||||
|
||||
return defaultConfig;
|
||||
}
|
||||
|
||||
async getSection<K extends keyof SystemConfig>(section: K): Promise<SystemConfig[K] | undefined> {
|
||||
const config = await this.get();
|
||||
return config[section];
|
||||
}
|
||||
|
||||
async updateSection<K extends keyof SystemConfig>(
|
||||
section: K,
|
||||
value: SystemConfig[K],
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await this.update({ [section]: value } as Partial<SystemConfig>);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep merge two objects
|
||||
*/
|
||||
private deepMerge(target: any, source: any): any {
|
||||
const result = { ...target };
|
||||
|
||||
for (const key in source) {
|
||||
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
||||
result[key] = this.deepMerge(target[key] || {}, source[key]);
|
||||
} else {
|
||||
result[key] = source[key];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
146
src/dao/UserConfigDao.ts
Normal file
146
src/dao/UserConfigDao.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { UserConfig } from '../types/index.js';
|
||||
import { JsonFileBaseDao } from './base/JsonFileBaseDao.js';
|
||||
|
||||
/**
|
||||
* User Configuration DAO interface
|
||||
*/
|
||||
export interface UserConfigDao {
|
||||
/**
|
||||
* Get user configuration
|
||||
*/
|
||||
get(username: string): Promise<UserConfig | undefined>;
|
||||
|
||||
/**
|
||||
* Get all user configurations
|
||||
*/
|
||||
getAll(): Promise<Record<string, UserConfig>>;
|
||||
|
||||
/**
|
||||
* Update user configuration
|
||||
*/
|
||||
update(username: string, config: Partial<UserConfig>): Promise<UserConfig>;
|
||||
|
||||
/**
|
||||
* Delete user configuration
|
||||
*/
|
||||
delete(username: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Check if user configuration exists
|
||||
*/
|
||||
exists(username: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Reset user configuration to defaults
|
||||
*/
|
||||
reset(username: string): Promise<UserConfig>;
|
||||
|
||||
/**
|
||||
* Get specific configuration section for user
|
||||
*/
|
||||
getSection<K extends keyof UserConfig>(
|
||||
username: string,
|
||||
section: K,
|
||||
): Promise<UserConfig[K] | undefined>;
|
||||
|
||||
/**
|
||||
* Update specific configuration section for user
|
||||
*/
|
||||
updateSection<K extends keyof UserConfig>(
|
||||
username: string,
|
||||
section: K,
|
||||
value: UserConfig[K],
|
||||
): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON file-based User Configuration DAO implementation
|
||||
*/
|
||||
export class UserConfigDaoImpl extends JsonFileBaseDao implements UserConfigDao {
|
||||
async get(username: string): Promise<UserConfig | undefined> {
|
||||
const settings = await this.loadSettings();
|
||||
return settings.userConfigs?.[username];
|
||||
}
|
||||
|
||||
async getAll(): Promise<Record<string, UserConfig>> {
|
||||
const settings = await this.loadSettings();
|
||||
return settings.userConfigs || {};
|
||||
}
|
||||
|
||||
async update(username: string, config: Partial<UserConfig>): Promise<UserConfig> {
|
||||
const settings = await this.loadSettings();
|
||||
|
||||
if (!settings.userConfigs) {
|
||||
settings.userConfigs = {};
|
||||
}
|
||||
|
||||
const currentConfig = settings.userConfigs[username] || {};
|
||||
|
||||
// Deep merge configuration
|
||||
const updatedConfig = this.deepMerge(currentConfig, config);
|
||||
settings.userConfigs[username] = updatedConfig;
|
||||
|
||||
await this.saveSettings(settings);
|
||||
return updatedConfig;
|
||||
}
|
||||
|
||||
async delete(username: string): Promise<boolean> {
|
||||
const settings = await this.loadSettings();
|
||||
|
||||
if (!settings.userConfigs || !settings.userConfigs[username]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
delete settings.userConfigs[username];
|
||||
await this.saveSettings(settings);
|
||||
return true;
|
||||
}
|
||||
|
||||
async exists(username: string): Promise<boolean> {
|
||||
const config = await this.get(username);
|
||||
return config !== undefined;
|
||||
}
|
||||
|
||||
async reset(username: string): Promise<UserConfig> {
|
||||
const defaultConfig: UserConfig = {};
|
||||
return this.update(username, defaultConfig);
|
||||
}
|
||||
|
||||
async getSection<K extends keyof UserConfig>(
|
||||
username: string,
|
||||
section: K,
|
||||
): Promise<UserConfig[K] | undefined> {
|
||||
const config = await this.get(username);
|
||||
return config?.[section];
|
||||
}
|
||||
|
||||
async updateSection<K extends keyof UserConfig>(
|
||||
username: string,
|
||||
section: K,
|
||||
value: UserConfig[K],
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await this.update(username, { [section]: value } as Partial<UserConfig>);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep merge two objects
|
||||
*/
|
||||
private deepMerge(target: any, source: any): any {
|
||||
const result = { ...target };
|
||||
|
||||
for (const key in source) {
|
||||
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
||||
result[key] = this.deepMerge(target[key] || {}, source[key]);
|
||||
} else {
|
||||
result[key] = source[key];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
169
src/dao/UserDao.ts
Normal file
169
src/dao/UserDao.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { IUser } from '../types/index.js';
|
||||
import { BaseDao } from './base/BaseDao.js';
|
||||
import { JsonFileBaseDao } from './base/JsonFileBaseDao.js';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
/**
|
||||
* User DAO interface with user-specific operations
|
||||
*/
|
||||
export interface UserDao extends BaseDao<IUser, string> {
|
||||
/**
|
||||
* Find user by username
|
||||
*/
|
||||
findByUsername(username: string): Promise<IUser | null>;
|
||||
|
||||
/**
|
||||
* Validate user credentials
|
||||
*/
|
||||
validateCredentials(username: string, password: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Create user with hashed password
|
||||
*/
|
||||
createWithHashedPassword(username: string, password: string, isAdmin?: boolean): Promise<IUser>;
|
||||
|
||||
/**
|
||||
* Update user password
|
||||
*/
|
||||
updatePassword(username: string, newPassword: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Find all admin users
|
||||
*/
|
||||
findAdmins(): Promise<IUser[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON file-based User DAO implementation
|
||||
*/
|
||||
export class UserDaoImpl extends JsonFileBaseDao implements UserDao {
|
||||
protected async getAll(): Promise<IUser[]> {
|
||||
const settings = await this.loadSettings();
|
||||
return settings.users || [];
|
||||
}
|
||||
|
||||
protected async saveAll(users: IUser[]): Promise<void> {
|
||||
const settings = await this.loadSettings();
|
||||
settings.users = users;
|
||||
await this.saveSettings(settings);
|
||||
}
|
||||
|
||||
protected getEntityId(user: IUser): string {
|
||||
return user.username;
|
||||
}
|
||||
|
||||
protected createEntity(_data: Omit<IUser, 'username'>): IUser {
|
||||
// This method should not be called directly for users
|
||||
throw new Error('Use createWithHashedPassword instead');
|
||||
}
|
||||
|
||||
protected updateEntity(existing: IUser, updates: Partial<IUser>): IUser {
|
||||
return {
|
||||
...existing,
|
||||
...updates,
|
||||
username: existing.username, // Username should not be updated
|
||||
};
|
||||
}
|
||||
|
||||
async findAll(): Promise<IUser[]> {
|
||||
return this.getAll();
|
||||
}
|
||||
|
||||
async findById(username: string): Promise<IUser | null> {
|
||||
return this.findByUsername(username);
|
||||
}
|
||||
|
||||
async findByUsername(username: string): Promise<IUser | null> {
|
||||
const users = await this.getAll();
|
||||
return users.find((user) => user.username === username) || null;
|
||||
}
|
||||
|
||||
async create(_data: Omit<IUser, 'username'>): Promise<IUser> {
|
||||
throw new Error('Use createWithHashedPassword instead');
|
||||
}
|
||||
|
||||
async createWithHashedPassword(
|
||||
username: string,
|
||||
password: string,
|
||||
isAdmin: boolean = false,
|
||||
): Promise<IUser> {
|
||||
const users = await this.getAll();
|
||||
|
||||
// Check if user already exists
|
||||
if (users.find((user) => user.username === username)) {
|
||||
throw new Error(`User ${username} already exists`);
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
const newUser: IUser = {
|
||||
username,
|
||||
password: hashedPassword,
|
||||
isAdmin,
|
||||
};
|
||||
|
||||
users.push(newUser);
|
||||
await this.saveAll(users);
|
||||
|
||||
return newUser;
|
||||
}
|
||||
|
||||
async update(username: string, updates: Partial<IUser>): Promise<IUser | null> {
|
||||
const users = await this.getAll();
|
||||
const index = users.findIndex((user) => user.username === username);
|
||||
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Don't allow username changes
|
||||
const { username: _, ...allowedUpdates } = updates;
|
||||
const updatedUser = this.updateEntity(users[index], allowedUpdates);
|
||||
users[index] = updatedUser;
|
||||
|
||||
await this.saveAll(users);
|
||||
return updatedUser;
|
||||
}
|
||||
|
||||
async updatePassword(username: string, newPassword: string): Promise<boolean> {
|
||||
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
||||
const result = await this.update(username, { password: hashedPassword });
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
async delete(username: string): Promise<boolean> {
|
||||
const users = await this.getAll();
|
||||
const index = users.findIndex((user) => user.username === username);
|
||||
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
users.splice(index, 1);
|
||||
await this.saveAll(users);
|
||||
return true;
|
||||
}
|
||||
|
||||
async exists(username: string): Promise<boolean> {
|
||||
const user = await this.findByUsername(username);
|
||||
return user !== null;
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
const users = await this.getAll();
|
||||
return users.length;
|
||||
}
|
||||
|
||||
async validateCredentials(username: string, password: string): Promise<boolean> {
|
||||
const user = await this.findByUsername(username);
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return bcrypt.compare(password, user.password);
|
||||
}
|
||||
|
||||
async findAdmins(): Promise<IUser[]> {
|
||||
const users = await this.getAll();
|
||||
return users.filter((user) => user.isAdmin === true);
|
||||
}
|
||||
}
|
||||
107
src/dao/base/BaseDao.ts
Normal file
107
src/dao/base/BaseDao.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Base DAO interface providing common CRUD operations
|
||||
*/
|
||||
export interface BaseDao<T, K = string> {
|
||||
/**
|
||||
* Find all entities
|
||||
*/
|
||||
findAll(): Promise<T[]>;
|
||||
|
||||
/**
|
||||
* Find entity by ID
|
||||
*/
|
||||
findById(id: K): Promise<T | null>;
|
||||
|
||||
/**
|
||||
* Create new entity
|
||||
*/
|
||||
create(entity: Omit<T, 'id'>): Promise<T>;
|
||||
|
||||
/**
|
||||
* Update existing entity
|
||||
*/
|
||||
update(id: K, entity: Partial<T>): Promise<T | null>;
|
||||
|
||||
/**
|
||||
* Delete entity by ID
|
||||
*/
|
||||
delete(id: K): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Check if entity exists
|
||||
*/
|
||||
exists(id: K): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Count total entities
|
||||
*/
|
||||
count(): Promise<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base DAO implementation with common functionality
|
||||
*/
|
||||
export abstract class BaseDaoImpl<T, K = string> implements BaseDao<T, K> {
|
||||
protected abstract getAll(): Promise<T[]>;
|
||||
protected abstract saveAll(entities: T[]): Promise<void>;
|
||||
protected abstract getEntityId(entity: T): K;
|
||||
protected abstract createEntity(data: Omit<T, 'id'>): T;
|
||||
protected abstract updateEntity(existing: T, updates: Partial<T>): T;
|
||||
|
||||
async findAll(): Promise<T[]> {
|
||||
return this.getAll();
|
||||
}
|
||||
|
||||
async findById(id: K): Promise<T | null> {
|
||||
const entities = await this.getAll();
|
||||
return entities.find((entity) => this.getEntityId(entity) === id) || null;
|
||||
}
|
||||
|
||||
async create(data: Omit<T, 'id'>): Promise<T> {
|
||||
const entities = await this.getAll();
|
||||
const newEntity = this.createEntity(data);
|
||||
|
||||
entities.push(newEntity);
|
||||
await this.saveAll(entities);
|
||||
|
||||
return newEntity;
|
||||
}
|
||||
|
||||
async update(id: K, updates: Partial<T>): Promise<T | null> {
|
||||
const entities = await this.getAll();
|
||||
const index = entities.findIndex((entity) => this.getEntityId(entity) === id);
|
||||
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updatedEntity = this.updateEntity(entities[index], updates);
|
||||
entities[index] = updatedEntity;
|
||||
|
||||
await this.saveAll(entities);
|
||||
return updatedEntity;
|
||||
}
|
||||
|
||||
async delete(id: K): Promise<boolean> {
|
||||
const entities = await this.getAll();
|
||||
const index = entities.findIndex((entity) => this.getEntityId(entity) === id);
|
||||
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
entities.splice(index, 1);
|
||||
await this.saveAll(entities);
|
||||
return true;
|
||||
}
|
||||
|
||||
async exists(id: K): Promise<boolean> {
|
||||
const entity = await this.findById(id);
|
||||
return entity !== null;
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
const entities = await this.getAll();
|
||||
return entities.length;
|
||||
}
|
||||
}
|
||||
96
src/dao/base/JsonFileBaseDao.ts
Normal file
96
src/dao/base/JsonFileBaseDao.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { McpSettings } from '../../types/index.js';
|
||||
import { getSettingsPath, clearSettingsCache } from '../../config/index.js';
|
||||
|
||||
/**
|
||||
* Abstract base class for JSON file-based DAO implementations
|
||||
*/
|
||||
export abstract class JsonFileBaseDao {
|
||||
private settingsCache: McpSettings | null = null;
|
||||
private lastModified: number = 0;
|
||||
|
||||
/**
|
||||
* Load settings from JSON file with caching
|
||||
*/
|
||||
protected async loadSettings(): Promise<McpSettings> {
|
||||
try {
|
||||
const settingsPath = getSettingsPath();
|
||||
const stats = fs.statSync(settingsPath);
|
||||
const fileModified = stats.mtime.getTime();
|
||||
|
||||
// Check if cache is still valid
|
||||
if (this.settingsCache && this.lastModified >= fileModified) {
|
||||
return this.settingsCache;
|
||||
}
|
||||
|
||||
const settingsData = fs.readFileSync(settingsPath, 'utf8');
|
||||
const settings = JSON.parse(settingsData) as McpSettings;
|
||||
|
||||
// Update cache
|
||||
this.settingsCache = settings;
|
||||
this.lastModified = fileModified;
|
||||
|
||||
return settings;
|
||||
} catch (error) {
|
||||
console.error(`Failed to load settings:`, error);
|
||||
const defaultSettings: McpSettings = {
|
||||
mcpServers: {},
|
||||
users: [],
|
||||
groups: [],
|
||||
systemConfig: {},
|
||||
userConfigs: {},
|
||||
};
|
||||
|
||||
// Cache default settings
|
||||
this.settingsCache = defaultSettings;
|
||||
this.lastModified = Date.now();
|
||||
|
||||
return defaultSettings;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save settings to JSON file and update cache
|
||||
*/
|
||||
protected async saveSettings(settings: McpSettings): Promise<void> {
|
||||
try {
|
||||
// Ensure directory exists
|
||||
const settingsPath = getSettingsPath();
|
||||
const dir = path.dirname(settingsPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
|
||||
|
||||
// Update cache
|
||||
this.settingsCache = settings;
|
||||
this.lastModified = Date.now();
|
||||
|
||||
clearSettingsCache();
|
||||
} catch (error) {
|
||||
console.error(`Failed to save settings:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear settings cache
|
||||
*/
|
||||
protected clearCache(): void {
|
||||
this.settingsCache = null;
|
||||
this.lastModified = 0;
|
||||
clearSettingsCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache status for debugging
|
||||
*/
|
||||
protected getCacheInfo(): { hasCache: boolean; lastModified: number } {
|
||||
return {
|
||||
hasCache: this.settingsCache !== null,
|
||||
lastModified: this.lastModified,
|
||||
};
|
||||
}
|
||||
}
|
||||
233
src/dao/examples.ts
Normal file
233
src/dao/examples.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Data access layer example and test utilities
|
||||
*
|
||||
* This file demonstrates how to use the DAO layer for managing different types of data
|
||||
* in the MCPHub application.
|
||||
*/
|
||||
|
||||
import {
|
||||
getUserDao,
|
||||
getServerDao,
|
||||
getGroupDao,
|
||||
getSystemConfigDao,
|
||||
getUserConfigDao,
|
||||
JsonFileDaoFactory,
|
||||
setDaoFactory,
|
||||
} from './DaoFactory.js';
|
||||
|
||||
/**
|
||||
* Example usage of UserDao
|
||||
*/
|
||||
export async function exampleUserOperations() {
|
||||
const userDao = getUserDao();
|
||||
|
||||
// Create a new user
|
||||
const newUser = await userDao.createWithHashedPassword('testuser', 'password123', false);
|
||||
console.log('Created user:', newUser.username);
|
||||
|
||||
// Find user by username
|
||||
const foundUser = await userDao.findByUsername('testuser');
|
||||
console.log('Found user:', foundUser?.username);
|
||||
|
||||
// Validate credentials
|
||||
const isValid = await userDao.validateCredentials('testuser', 'password123');
|
||||
console.log('Credentials valid:', isValid);
|
||||
|
||||
// Update user
|
||||
await userDao.update('testuser', { isAdmin: true });
|
||||
console.log('Updated user to admin');
|
||||
|
||||
// Find all admin users
|
||||
const admins = await userDao.findAdmins();
|
||||
console.log(
|
||||
'Admin users:',
|
||||
admins.map((u) => u.username),
|
||||
);
|
||||
|
||||
// Delete user
|
||||
await userDao.delete('testuser');
|
||||
console.log('Deleted test user');
|
||||
}
|
||||
|
||||
/**
|
||||
* Example usage of ServerDao
|
||||
*/
|
||||
export async function exampleServerOperations() {
|
||||
const serverDao = getServerDao();
|
||||
|
||||
// Create a new server
|
||||
const newServer = await serverDao.create({
|
||||
name: 'test-server',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
enabled: true,
|
||||
owner: 'admin',
|
||||
});
|
||||
console.log('Created server:', newServer.name);
|
||||
|
||||
// Find servers by owner
|
||||
const userServers = await serverDao.findByOwner('admin');
|
||||
console.log(
|
||||
'Servers owned by admin:',
|
||||
userServers.map((s) => s.name),
|
||||
);
|
||||
|
||||
// Find enabled servers
|
||||
const enabledServers = await serverDao.findEnabled();
|
||||
console.log(
|
||||
'Enabled servers:',
|
||||
enabledServers.map((s) => s.name),
|
||||
);
|
||||
|
||||
// Update server tools
|
||||
await serverDao.updateTools('test-server', {
|
||||
tool1: { enabled: true, description: 'Test tool' },
|
||||
});
|
||||
console.log('Updated server tools');
|
||||
|
||||
// Delete server
|
||||
await serverDao.delete('test-server');
|
||||
console.log('Deleted test server');
|
||||
}
|
||||
|
||||
/**
|
||||
* Example usage of GroupDao
|
||||
*/
|
||||
export async function exampleGroupOperations() {
|
||||
const groupDao = getGroupDao();
|
||||
|
||||
// Create a new group
|
||||
const newGroup = await groupDao.create({
|
||||
name: 'test-group',
|
||||
description: 'Test group for development',
|
||||
servers: ['server1', 'server2'],
|
||||
owner: 'admin',
|
||||
});
|
||||
console.log('Created group:', newGroup.name, 'with ID:', newGroup.id);
|
||||
|
||||
// Find groups by owner
|
||||
const userGroups = await groupDao.findByOwner('admin');
|
||||
console.log(
|
||||
'Groups owned by admin:',
|
||||
userGroups.map((g) => g.name),
|
||||
);
|
||||
|
||||
// Add server to group
|
||||
await groupDao.addServerToGroup(newGroup.id, 'server3');
|
||||
console.log('Added server3 to group');
|
||||
|
||||
// Find groups containing specific server
|
||||
const groupsWithServer = await groupDao.findByServer('server1');
|
||||
console.log(
|
||||
'Groups containing server1:',
|
||||
groupsWithServer.map((g) => g.name),
|
||||
);
|
||||
|
||||
// Remove server from group
|
||||
await groupDao.removeServerFromGroup(newGroup.id, 'server2');
|
||||
console.log('Removed server2 from group');
|
||||
|
||||
// Delete group
|
||||
await groupDao.delete(newGroup.id);
|
||||
console.log('Deleted test group');
|
||||
}
|
||||
|
||||
/**
|
||||
* Example usage of SystemConfigDao
|
||||
*/
|
||||
export async function exampleSystemConfigOperations() {
|
||||
const systemConfigDao = getSystemConfigDao();
|
||||
|
||||
// Get current system config
|
||||
const currentConfig = await systemConfigDao.get();
|
||||
console.log('Current system config:', currentConfig);
|
||||
|
||||
// Update routing configuration
|
||||
await systemConfigDao.updateSection('routing', {
|
||||
enableGlobalRoute: true,
|
||||
enableGroupNameRoute: true,
|
||||
enableBearerAuth: false,
|
||||
});
|
||||
console.log('Updated routing configuration');
|
||||
|
||||
// Update install configuration
|
||||
await systemConfigDao.updateSection('install', {
|
||||
pythonIndexUrl: 'https://pypi.org/simple/',
|
||||
npmRegistry: 'https://registry.npmjs.org/',
|
||||
baseUrl: 'https://mcphub.local',
|
||||
});
|
||||
console.log('Updated install configuration');
|
||||
|
||||
// Get specific section
|
||||
const routingConfig = await systemConfigDao.getSection('routing');
|
||||
console.log('Routing config:', routingConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Example usage of UserConfigDao
|
||||
*/
|
||||
export async function exampleUserConfigOperations() {
|
||||
const userConfigDao = getUserConfigDao();
|
||||
|
||||
// Update user configuration
|
||||
await userConfigDao.update('admin', {
|
||||
routing: {
|
||||
enableGlobalRoute: false,
|
||||
enableGroupNameRoute: true,
|
||||
},
|
||||
});
|
||||
console.log('Updated admin user config');
|
||||
|
||||
// Get user configuration
|
||||
const adminConfig = await userConfigDao.get('admin');
|
||||
console.log('Admin config:', adminConfig);
|
||||
|
||||
// Get all user configurations
|
||||
const allUserConfigs = await userConfigDao.getAll();
|
||||
console.log('All user configs:', Object.keys(allUserConfigs));
|
||||
|
||||
// Get specific section for user
|
||||
const userRoutingConfig = await userConfigDao.getSection('admin', 'routing');
|
||||
console.log('Admin routing config:', userRoutingConfig);
|
||||
|
||||
// Delete user configuration
|
||||
await userConfigDao.delete('admin');
|
||||
console.log('Deleted admin user config');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test all DAO operations
|
||||
*/
|
||||
export async function testAllDaoOperations() {
|
||||
try {
|
||||
console.log('=== Testing DAO Layer ===');
|
||||
|
||||
console.log('\n--- User Operations ---');
|
||||
await exampleUserOperations();
|
||||
|
||||
console.log('\n--- Server Operations ---');
|
||||
await exampleServerOperations();
|
||||
|
||||
console.log('\n--- Group Operations ---');
|
||||
await exampleGroupOperations();
|
||||
|
||||
console.log('\n--- System Config Operations ---');
|
||||
await exampleSystemConfigOperations();
|
||||
|
||||
console.log('\n--- User Config Operations ---');
|
||||
await exampleUserConfigOperations();
|
||||
|
||||
console.log('\n=== DAO Layer Test Complete ===');
|
||||
} catch (error) {
|
||||
console.error('Error during DAO testing:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset DAO factory for testing purposes
|
||||
*/
|
||||
export function resetDaoFactory() {
|
||||
const factory = JsonFileDaoFactory.getInstance();
|
||||
factory.resetInstances();
|
||||
setDaoFactory(factory);
|
||||
}
|
||||
11
src/dao/index.ts
Normal file
11
src/dao/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// Export all DAO interfaces and implementations
|
||||
export * from './base/BaseDao.js';
|
||||
export * from './base/JsonFileBaseDao.js';
|
||||
export * from './UserDao.js';
|
||||
export * from './ServerDao.js';
|
||||
export * from './GroupDao.js';
|
||||
export * from './SystemConfigDao.js';
|
||||
export * from './UserConfigDao.js';
|
||||
|
||||
// Export the DAO factory and convenience functions
|
||||
export * from './DaoFactory.js';
|
||||
@@ -63,6 +63,13 @@ import { callTool } from '../controllers/toolController.js';
|
||||
import { getPrompt } from '../controllers/promptController.js';
|
||||
import { uploadDxtFile, uploadMiddleware } from '../controllers/dxtController.js';
|
||||
import { healthCheck } from '../controllers/healthController.js';
|
||||
import {
|
||||
getOpenAPISpec,
|
||||
getOpenAPIServers,
|
||||
getOpenAPIStats,
|
||||
executeToolViaOpenAPI,
|
||||
getGroupOpenAPISpec,
|
||||
} from '../controllers/openApiController.js';
|
||||
import { auth } from '../middlewares/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -180,6 +187,18 @@ export const initRoutes = (app: express.Application): void => {
|
||||
// Public configuration endpoint (no auth required to check skipAuth setting)
|
||||
app.get(`${config.basePath}/public-config`, getPublicConfig);
|
||||
|
||||
// OpenAPI generation endpoints
|
||||
app.get(`${config.basePath}/api/openapi.json`, getOpenAPISpec);
|
||||
app.get(`${config.basePath}/api/:name/openapi.json`, getGroupOpenAPISpec);
|
||||
app.get(`${config.basePath}/api/openapi/servers`, getOpenAPIServers);
|
||||
app.get(`${config.basePath}/api/openapi/stats`, getOpenAPIStats);
|
||||
|
||||
// OpenAPI-compatible tool execution endpoints
|
||||
app.get(`${config.basePath}/api/tools/:serverName/:toolName`, executeToolViaOpenAPI);
|
||||
app.post(`${config.basePath}/api/tools/:serverName/:toolName`, executeToolViaOpenAPI);
|
||||
app.get(`${config.basePath}/api/:name/tools/:serverName/:toolName`, executeToolViaOpenAPI);
|
||||
app.post(`${config.basePath}/api/:name/tools/:serverName/:toolName`, executeToolViaOpenAPI);
|
||||
|
||||
app.use(`${config.basePath}/api`, router);
|
||||
};
|
||||
|
||||
|
||||
259
src/scripts/dao-demo.ts
Normal file
259
src/scripts/dao-demo.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* MCPHub DAO Layer Demo Script
|
||||
*
|
||||
* This script demonstrates how to use the new DAO layer for managing
|
||||
* MCPHub configuration data.
|
||||
*/
|
||||
|
||||
import {
|
||||
loadSettings,
|
||||
switchToDao,
|
||||
switchToLegacy,
|
||||
getDaoConfigService,
|
||||
} from '../config/configManager.js';
|
||||
|
||||
import {
|
||||
performMigration,
|
||||
validateMigration,
|
||||
testDaoOperations,
|
||||
performanceComparison,
|
||||
generateMigrationReport,
|
||||
} from '../config/migrationUtils.js';
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
|
||||
switch (command) {
|
||||
case 'migrate':
|
||||
{
|
||||
console.log('🚀 Starting migration to DAO layer...');
|
||||
const success = await performMigration();
|
||||
process.exit(success ? 0 : 1);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'validate':
|
||||
{
|
||||
console.log('🔍 Validating migration...');
|
||||
const isValid = await validateMigration();
|
||||
process.exit(isValid ? 0 : 1);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'test':
|
||||
{
|
||||
console.log('🧪 Testing DAO operations...');
|
||||
const testSuccess = await testDaoOperations();
|
||||
process.exit(testSuccess ? 0 : 1);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'compare':
|
||||
{
|
||||
console.log('⚡ Comparing performance...');
|
||||
await performanceComparison();
|
||||
process.exit(0);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'report':
|
||||
{
|
||||
console.log('📊 Generating migration report...');
|
||||
await generateMigrationReport();
|
||||
process.exit(0);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'demo':
|
||||
{
|
||||
await runDemo();
|
||||
process.exit(0);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'switch-dao':
|
||||
{
|
||||
switchToDao();
|
||||
console.log('✅ Switched to DAO layer');
|
||||
process.exit(0);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'switch-legacy':
|
||||
{
|
||||
switchToLegacy();
|
||||
console.log('✅ Switched to legacy file-based approach');
|
||||
process.exit(0);
|
||||
}
|
||||
break;
|
||||
|
||||
default: {
|
||||
printHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
MCPHub DAO Layer Demo
|
||||
|
||||
Usage: node dao-demo.js <command>
|
||||
|
||||
Commands:
|
||||
migrate - Migrate from legacy format to DAO layer
|
||||
validate - Validate migration integrity
|
||||
test - Test DAO operations with sample data
|
||||
compare - Compare performance between legacy and DAO approaches
|
||||
report - Generate migration report
|
||||
demo - Run interactive demo
|
||||
switch-dao - Switch to DAO layer
|
||||
switch-legacy - Switch to legacy file-based approach
|
||||
|
||||
Examples:
|
||||
node dao-demo.js migrate
|
||||
node dao-demo.js test
|
||||
node dao-demo.js compare
|
||||
`);
|
||||
}
|
||||
|
||||
async function runDemo() {
|
||||
console.log('🎭 MCPHub DAO Layer Interactive Demo');
|
||||
console.log('=====================================\n');
|
||||
|
||||
try {
|
||||
// Step 1: Show current configuration
|
||||
console.log('📋 Step 1: Loading current configuration...');
|
||||
switchToLegacy();
|
||||
const legacySettings = await loadSettings();
|
||||
console.log(`Current data:
|
||||
- Users: ${legacySettings.users?.length || 0}
|
||||
- Servers: ${Object.keys(legacySettings.mcpServers || {}).length}
|
||||
- Groups: ${legacySettings.groups?.length || 0}
|
||||
- System Config Sections: ${Object.keys(legacySettings.systemConfig || {}).length}
|
||||
- User Configs: ${Object.keys(legacySettings.userConfigs || {}).length}
|
||||
`);
|
||||
|
||||
// Step 2: Switch to DAO and show same data
|
||||
console.log('🔄 Step 2: Switching to DAO layer...');
|
||||
switchToDao();
|
||||
const daoService = getDaoConfigService();
|
||||
|
||||
const daoSettings = await daoService.loadSettings();
|
||||
console.log(`DAO layer data:
|
||||
- Users: ${daoSettings.users?.length || 0}
|
||||
- Servers: ${Object.keys(daoSettings.mcpServers || {}).length}
|
||||
- Groups: ${daoSettings.groups?.length || 0}
|
||||
- System Config Sections: ${Object.keys(daoSettings.systemConfig || {}).length}
|
||||
- User Configs: ${Object.keys(daoSettings.userConfigs || {}).length}
|
||||
`);
|
||||
|
||||
// Step 3: Demonstrate CRUD operations
|
||||
console.log('🛠️ Step 3: Demonstrating CRUD operations...');
|
||||
|
||||
// Test user creation (if not exists)
|
||||
try {
|
||||
// Add demo data if needed
|
||||
if (!daoSettings.users?.length) {
|
||||
console.log('Creating demo user...');
|
||||
// Note: In practice, you'd use the UserDao directly for password hashing
|
||||
const demoSettings = {
|
||||
...daoSettings,
|
||||
users: [
|
||||
{
|
||||
username: 'demo-user',
|
||||
password: 'hashed-password',
|
||||
isAdmin: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
await daoService.saveSettings(demoSettings);
|
||||
console.log('✅ Demo user created');
|
||||
}
|
||||
|
||||
// Add demo server if needed
|
||||
if (!Object.keys(daoSettings.mcpServers || {}).length) {
|
||||
console.log('Creating demo server...');
|
||||
const demoSettings = {
|
||||
...daoSettings,
|
||||
mcpServers: {
|
||||
'demo-server': {
|
||||
command: 'echo',
|
||||
args: ['hello'],
|
||||
enabled: true,
|
||||
owner: 'admin',
|
||||
},
|
||||
},
|
||||
};
|
||||
await daoService.saveSettings(demoSettings);
|
||||
console.log('✅ Demo server created');
|
||||
}
|
||||
|
||||
// Add demo group if needed
|
||||
if (!daoSettings.groups?.length) {
|
||||
console.log('Creating demo group...');
|
||||
const demoSettings = {
|
||||
...daoSettings,
|
||||
groups: [
|
||||
{
|
||||
id: 'demo-group-1',
|
||||
name: 'Demo Group',
|
||||
description: 'A demo group for testing',
|
||||
servers: ['demo-server'],
|
||||
owner: 'admin',
|
||||
},
|
||||
],
|
||||
};
|
||||
await daoService.saveSettings(demoSettings);
|
||||
console.log('✅ Demo group created');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('⚠️ Some demo operations failed (this is expected for password hashing)');
|
||||
console.log('In production, you would use individual DAO methods for proper handling');
|
||||
}
|
||||
|
||||
// Step 4: Show benefits
|
||||
console.log(`
|
||||
🌟 Benefits of the DAO Layer:
|
||||
|
||||
1. 📦 Separation of Concerns
|
||||
- Data access logic is separated from business logic
|
||||
- Each data type has its own DAO with specific operations
|
||||
|
||||
2. 🔄 Easy Database Migration
|
||||
- Ready for switching from JSON files to database
|
||||
- Interface remains the same, implementation changes
|
||||
|
||||
3. 🧪 Better Testing
|
||||
- Can easily mock DAO interfaces for unit tests
|
||||
- Isolated testing of data access operations
|
||||
|
||||
4. 🔒 Type Safety
|
||||
- Strong typing for all data operations
|
||||
- Compile-time checking of data structure changes
|
||||
|
||||
5. 🚀 Enhanced Features
|
||||
- User password hashing in UserDao
|
||||
- Server filtering by owner/type in ServerDao
|
||||
- Group membership management in GroupDao
|
||||
- Section-based config updates in SystemConfigDao
|
||||
|
||||
6. 🏗️ Future Extensibility
|
||||
- Easy to add new data types
|
||||
- Consistent interface across all data operations
|
||||
- Support for complex queries and relationships
|
||||
`);
|
||||
|
||||
console.log('✅ Demo completed successfully!');
|
||||
} catch (error) {
|
||||
console.error('❌ Demo failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the main function
|
||||
if (require.main === module) {
|
||||
main().catch(console.error);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import config from './config/index.js';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
@@ -26,6 +27,7 @@ export class AppServer {
|
||||
|
||||
constructor() {
|
||||
this.app = express();
|
||||
this.app.use(cors());
|
||||
this.port = config.port;
|
||||
this.basePath = config.basePath;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ const getMCPRouterConfig = () => {
|
||||
|
||||
return {
|
||||
apiKey: mcpRouterConfig?.apiKey || process.env.MCPROUTER_API_KEY || '',
|
||||
referer: mcpRouterConfig?.referer || process.env.MCPROUTER_REFERER || 'https://mcphub.app',
|
||||
referer: mcpRouterConfig?.referer || process.env.MCPROUTER_REFERER || 'https://www.mcphubx.com',
|
||||
title: mcpRouterConfig?.title || process.env.MCPROUTER_TITLE || 'MCPHub',
|
||||
baseUrl:
|
||||
mcpRouterConfig?.baseUrl || process.env.MCPROUTER_API_BASE || DEFAULT_MCPROUTER_API_BASE,
|
||||
@@ -33,7 +33,7 @@ const getAxiosConfig = (): AxiosRequestConfig => {
|
||||
return {
|
||||
headers: {
|
||||
Authorization: mcpRouterConfig.apiKey ? `Bearer ${mcpRouterConfig.apiKey}` : '',
|
||||
'HTTP-Referer': mcpRouterConfig.referer || 'https://mcphub.app',
|
||||
'HTTP-Referer': mcpRouterConfig.referer || 'https://www.mcphubx.com',
|
||||
'X-Title': mcpRouterConfig.title || 'MCPHub',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os from 'os';
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
@@ -11,16 +12,19 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { ServerInfo, ServerConfig, Tool } from '../types/index.js';
|
||||
import { loadSettings, saveSettings, expandEnvVars, replaceEnvVars } from '../config/index.js';
|
||||
import { loadSettings, expandEnvVars, replaceEnvVars } from '../config/index.js';
|
||||
import config from '../config/index.js';
|
||||
import { getGroup } from './sseService.js';
|
||||
import { getServersInGroup, getServerConfigInGroup } from './groupService.js';
|
||||
import { saveToolsAsVectorEmbeddings, searchToolsByVector } from './vectorSearchService.js';
|
||||
import { OpenAPIClient } from '../clients/openapi.js';
|
||||
import { getDataService } from './services.js';
|
||||
import { getServerDao, ServerConfigWithName } from '../dao/index.js';
|
||||
|
||||
const servers: { [sessionId: string]: Server } = {};
|
||||
|
||||
const serverDao = getServerDao();
|
||||
|
||||
// Helper function to set up keep-alive ping for SSE connections
|
||||
const setupKeepAlive = (serverInfo: ServerInfo, serverConfig: ServerConfig): void => {
|
||||
// Only set up keep-alive for SSE connections
|
||||
@@ -202,6 +206,7 @@ const createTransportFromConfig = (name: string, conf: ServerConfig): any => {
|
||||
}
|
||||
|
||||
transport = new StdioClientTransport({
|
||||
cwd: os.homedir(),
|
||||
command: conf.command,
|
||||
args: replaceEnvVars(conf.args) as string[],
|
||||
env: env,
|
||||
@@ -253,15 +258,13 @@ const callToolWithReconnect = async (
|
||||
serverInfo.client.close();
|
||||
serverInfo.transport.close();
|
||||
|
||||
// Get server configuration to recreate transport
|
||||
const settings = loadSettings();
|
||||
const conf = settings.mcpServers[serverInfo.name];
|
||||
if (!conf) {
|
||||
const server = await serverDao.findById(serverInfo.name);
|
||||
if (!server) {
|
||||
throw new Error(`Server configuration not found for: ${serverInfo.name}`);
|
||||
}
|
||||
|
||||
// Recreate transport using helper function
|
||||
const newTransport = createTransportFromConfig(serverInfo.name, conf);
|
||||
const newTransport = createTransportFromConfig(serverInfo.name, server);
|
||||
|
||||
// Create new client
|
||||
const client = new Client(
|
||||
@@ -335,11 +338,12 @@ export const initializeClientsFromSettings = async (
|
||||
isInit: boolean,
|
||||
serverName?: string,
|
||||
): Promise<ServerInfo[]> => {
|
||||
const settings = loadSettings();
|
||||
const allServers: ServerConfigWithName[] = await serverDao.findAll();
|
||||
const existingServerInfos = serverInfos;
|
||||
serverInfos = [];
|
||||
|
||||
for (const [name, conf] of Object.entries(settings.mcpServers)) {
|
||||
for (const conf of allServers) {
|
||||
const { name } = conf;
|
||||
// Skip disabled servers
|
||||
if (conf.enabled === false) {
|
||||
console.log(`Skipping disabled server: ${name}`);
|
||||
@@ -567,14 +571,14 @@ export const registerAllTools = async (isInit: boolean, serverName?: string): Pr
|
||||
};
|
||||
|
||||
// Get all server information
|
||||
export const getServersInfo = (): Omit<ServerInfo, 'client' | 'transport'>[] => {
|
||||
const settings = loadSettings();
|
||||
export const getServersInfo = async (): Promise<Omit<ServerInfo, 'client' | 'transport'>[]> => {
|
||||
const allServers: ServerConfigWithName[] = await serverDao.findAll();
|
||||
const dataService = getDataService();
|
||||
const filterServerInfos: ServerInfo[] = dataService.filterData
|
||||
? dataService.filterData(serverInfos)
|
||||
: serverInfos;
|
||||
const infos = filterServerInfos.map(({ name, status, tools, prompts, createTime, error }) => {
|
||||
const serverConfig = settings.mcpServers[name];
|
||||
const serverConfig = allServers.find((server) => server.name === name);
|
||||
const enabled = serverConfig ? serverConfig.enabled !== false : true;
|
||||
|
||||
// Add enabled status and custom description to each tool
|
||||
@@ -614,15 +618,13 @@ export const getServersInfo = (): Omit<ServerInfo, 'client' | 'transport'>[] =>
|
||||
};
|
||||
|
||||
// Get server by name
|
||||
const getServerByName = (name: string): ServerInfo | undefined => {
|
||||
export const getServerByName = (name: string): ServerInfo | undefined => {
|
||||
return serverInfos.find((serverInfo) => serverInfo.name === name);
|
||||
};
|
||||
|
||||
// Filter tools by server configuration
|
||||
const filterToolsByConfig = (serverName: string, tools: Tool[]): Tool[] => {
|
||||
const settings = loadSettings();
|
||||
const serverConfig = settings.mcpServers[serverName];
|
||||
|
||||
const filterToolsByConfig = async (serverName: string, tools: Tool[]): Promise<Tool[]> => {
|
||||
const serverConfig = await serverDao.findById(serverName);
|
||||
if (!serverConfig || !serverConfig.tools) {
|
||||
// If no tool configuration exists, all tools are enabled by default
|
||||
return tools;
|
||||
@@ -645,44 +647,26 @@ export const addServer = async (
|
||||
name: string,
|
||||
config: ServerConfig,
|
||||
): Promise<{ success: boolean; message?: string }> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (settings.mcpServers[name]) {
|
||||
return { success: false, message: 'Server name already exists' };
|
||||
}
|
||||
|
||||
settings.mcpServers[name] = config;
|
||||
if (!saveSettings(settings)) {
|
||||
return { success: false, message: 'Failed to save settings' };
|
||||
}
|
||||
|
||||
const server: ServerConfigWithName = { name, ...config };
|
||||
const result = await serverDao.create(server);
|
||||
if (result) {
|
||||
return { success: true, message: 'Server added successfully' };
|
||||
} catch (error) {
|
||||
console.error(`Failed to add server: ${name}`, error);
|
||||
} else {
|
||||
return { success: false, message: 'Failed to add server' };
|
||||
}
|
||||
};
|
||||
|
||||
// Remove server
|
||||
export const removeServer = (name: string): { success: boolean; message?: string } => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (!settings.mcpServers[name]) {
|
||||
return { success: false, message: 'Server not found' };
|
||||
}
|
||||
|
||||
delete settings.mcpServers[name];
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return { success: false, message: 'Failed to save settings' };
|
||||
}
|
||||
|
||||
serverInfos = serverInfos.filter((serverInfo) => serverInfo.name !== name);
|
||||
return { success: true, message: 'Server removed successfully' };
|
||||
} catch (error) {
|
||||
console.error(`Failed to remove server: ${name}`, error);
|
||||
return { success: false, message: `Failed to remove server: ${error}` };
|
||||
export const removeServer = async (
|
||||
name: string,
|
||||
): Promise<{ success: boolean; message?: string }> => {
|
||||
const result = await serverDao.delete(name);
|
||||
if (!result) {
|
||||
return { success: false, message: 'Failed to remove server' };
|
||||
}
|
||||
|
||||
serverInfos = serverInfos.filter((serverInfo) => serverInfo.name !== name);
|
||||
return { success: true, message: 'Server removed successfully' };
|
||||
};
|
||||
|
||||
// Add or update server (supports overriding existing servers for DXT)
|
||||
@@ -692,9 +676,7 @@ export const addOrUpdateServer = async (
|
||||
allowOverride: boolean = false,
|
||||
): Promise<{ success: boolean; message?: string }> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
const exists = !!settings.mcpServers[name];
|
||||
|
||||
const exists = await serverDao.exists(name);
|
||||
if (exists && !allowOverride) {
|
||||
return { success: false, message: 'Server name already exists' };
|
||||
}
|
||||
@@ -708,9 +690,10 @@ export const addOrUpdateServer = async (
|
||||
serverInfos = serverInfos.filter((serverInfo) => serverInfo.name !== name);
|
||||
}
|
||||
|
||||
settings.mcpServers[name] = config;
|
||||
if (!saveSettings(settings)) {
|
||||
return { success: false, message: 'Failed to save settings' };
|
||||
if (exists) {
|
||||
await serverDao.update(name, config);
|
||||
} else {
|
||||
await serverDao.create({ name, ...config });
|
||||
}
|
||||
|
||||
const action = exists ? 'updated' : 'added';
|
||||
@@ -745,18 +728,7 @@ export const toggleServerStatus = async (
|
||||
enabled: boolean,
|
||||
): Promise<{ success: boolean; message?: string }> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (!settings.mcpServers[name]) {
|
||||
return { success: false, message: 'Server not found' };
|
||||
}
|
||||
|
||||
// Update the enabled status in settings
|
||||
settings.mcpServers[name].enabled = enabled;
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
return { success: false, message: 'Failed to save settings' };
|
||||
}
|
||||
|
||||
await serverDao.setEnabled(name, enabled);
|
||||
// If disabling, disconnect the server and remove from active servers
|
||||
if (!enabled) {
|
||||
closeServer(name);
|
||||
@@ -865,7 +837,7 @@ Available servers: ${serversList}`;
|
||||
for (const serverInfo of allServerInfos) {
|
||||
if (serverInfo.tools && serverInfo.tools.length > 0) {
|
||||
// Filter tools based on server configuration
|
||||
let enabledTools = filterToolsByConfig(serverInfo.name, serverInfo.tools);
|
||||
let enabledTools = await filterToolsByConfig(serverInfo.name, serverInfo.tools);
|
||||
|
||||
// If this is a group request, apply group-level tool filtering
|
||||
if (group) {
|
||||
@@ -880,8 +852,7 @@ Available servers: ${serversList}`;
|
||||
}
|
||||
|
||||
// Apply custom descriptions from server configuration
|
||||
const settings = loadSettings();
|
||||
const serverConfig = settings.mcpServers[serverInfo.name];
|
||||
const serverConfig = await serverDao.findById(serverInfo.name);
|
||||
const toolsWithCustomDescriptions = enabledTools.map((tool) => {
|
||||
const toolConfig = serverConfig?.tools?.[tool.name];
|
||||
return {
|
||||
@@ -931,8 +902,9 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
|
||||
const searchResults = await searchToolsByVector(query, limitNum, thresholdNum, servers);
|
||||
console.log(`Search results: ${JSON.stringify(searchResults)}`);
|
||||
// Find actual tool information from serverInfos by serverName and toolName
|
||||
const tools = searchResults
|
||||
.map((result) => {
|
||||
// First resolve all tool promises
|
||||
const resolvedTools = await Promise.all(
|
||||
searchResults.map(async (result) => {
|
||||
// Find the server in serverInfos
|
||||
const server = serverInfos.find(
|
||||
(serverInfo) =>
|
||||
@@ -945,17 +917,17 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
|
||||
const actualTool = server.tools.find((tool) => tool.name === result.toolName);
|
||||
if (actualTool) {
|
||||
// Check if the tool is enabled in configuration
|
||||
const enabledTools = filterToolsByConfig(server.name, [actualTool]);
|
||||
const enabledTools = await filterToolsByConfig(server.name, [actualTool]);
|
||||
if (enabledTools.length > 0) {
|
||||
// Apply custom description from configuration
|
||||
const settings = loadSettings();
|
||||
const serverConfig = settings.mcpServers[server.name];
|
||||
const serverConfig = await serverDao.findById(server.name);
|
||||
const toolConfig = serverConfig?.tools?.[actualTool.name];
|
||||
|
||||
// Return the actual tool info from serverInfos with custom description
|
||||
return {
|
||||
...actualTool,
|
||||
description: toolConfig?.description || actualTool.description,
|
||||
serverName: result.serverName, // Add serverName for filtering
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -966,19 +938,25 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
|
||||
name: result.toolName,
|
||||
description: result.description || '',
|
||||
inputSchema: cleanInputSchema(result.inputSchema || {}),
|
||||
serverName: result.serverName, // Add serverName for filtering
|
||||
};
|
||||
})
|
||||
.filter((tool) => {
|
||||
}),
|
||||
);
|
||||
|
||||
// Now filter the resolved tools
|
||||
const tools = await Promise.all(
|
||||
resolvedTools.filter(async (tool) => {
|
||||
// Additional filter to remove tools that are disabled
|
||||
if (tool.name) {
|
||||
const serverName = searchResults.find((r) => r.toolName === tool.name)?.serverName;
|
||||
const serverName = tool.serverName;
|
||||
if (serverName) {
|
||||
const enabledTools = filterToolsByConfig(serverName, [tool as Tool]);
|
||||
const enabledTools = await filterToolsByConfig(serverName, [tool as Tool]);
|
||||
return enabledTools.length > 0;
|
||||
}
|
||||
}
|
||||
return true; // Keep fallback results
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Add usage guidance to the response
|
||||
const response = {
|
||||
@@ -1234,8 +1212,7 @@ export const handleListPromptsRequest = async (_: any, extra: any) => {
|
||||
for (const serverInfo of allServerInfos) {
|
||||
if (serverInfo.prompts && serverInfo.prompts.length > 0) {
|
||||
// Filter prompts based on server configuration
|
||||
const settings = loadSettings();
|
||||
const serverConfig = settings.mcpServers[serverInfo.name];
|
||||
const serverConfig = await serverDao.findById(serverInfo.name);
|
||||
|
||||
let enabledPrompts = serverInfo.prompts;
|
||||
if (serverConfig && serverConfig.prompts) {
|
||||
|
||||
357
src/services/openApiGeneratorService.ts
Normal file
357
src/services/openApiGeneratorService.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
import { OpenAPIV3 } from 'openapi-types';
|
||||
import { Tool } from '../types/index.js';
|
||||
import { getServersInfo } from './mcpService.js';
|
||||
import config from '../config/index.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
|
||||
/**
|
||||
* Service for generating OpenAPI 3.x specifications from MCP tools
|
||||
* This enables integration with OpenWebUI and other OpenAPI-compatible systems
|
||||
*/
|
||||
|
||||
export interface OpenAPIGenerationOptions {
|
||||
title?: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
serverUrl?: string;
|
||||
includeDisabledTools?: boolean;
|
||||
groupFilter?: string;
|
||||
serverFilter?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert MCP tool input schema to OpenAPI parameter or request body schema
|
||||
*/
|
||||
function convertToolSchemaToOpenAPI(tool: Tool): {
|
||||
parameters?: OpenAPIV3.ParameterObject[];
|
||||
requestBody?: OpenAPIV3.RequestBodyObject;
|
||||
} {
|
||||
const schema = tool.inputSchema as any;
|
||||
|
||||
if (!schema || typeof schema !== 'object') {
|
||||
return {};
|
||||
}
|
||||
|
||||
// If schema has properties, convert them to parameters or request body
|
||||
if (schema.properties && typeof schema.properties === 'object') {
|
||||
const properties = schema.properties;
|
||||
const required = Array.isArray(schema.required) ? schema.required : [];
|
||||
|
||||
// For simple tools with only primitive parameters, use query parameters
|
||||
const hasComplexTypes = Object.values(properties).some(
|
||||
(prop: any) =>
|
||||
prop.type === 'object' ||
|
||||
prop.type === 'array' ||
|
||||
(prop.type === 'string' && prop.enum && prop.enum.length > 10),
|
||||
);
|
||||
|
||||
if (!hasComplexTypes && Object.keys(properties).length <= 10) {
|
||||
// Use query parameters for simple tools
|
||||
const parameters: OpenAPIV3.ParameterObject[] = Object.entries(properties).map(
|
||||
([name, prop]: [string, any]) => ({
|
||||
name,
|
||||
in: 'query',
|
||||
required: required.includes(name),
|
||||
description: prop.description || `Parameter ${name}`,
|
||||
schema: {
|
||||
type: prop.type || 'string',
|
||||
...(prop.enum && { enum: prop.enum }),
|
||||
...(prop.default !== undefined && { default: prop.default }),
|
||||
...(prop.format && { format: prop.format }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return { parameters };
|
||||
} else {
|
||||
// Use request body for complex tools
|
||||
const requestBody: OpenAPIV3.RequestBodyObject = {
|
||||
required: required.length > 0,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties,
|
||||
...(required.length > 0 && { required }),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return { requestBody };
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate OpenAPI operation from MCP tool
|
||||
*/
|
||||
function generateOperationFromTool(tool: Tool, serverName: string): OpenAPIV3.OperationObject {
|
||||
const { parameters, requestBody } = convertToolSchemaToOpenAPI(tool);
|
||||
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}`,
|
||||
tags: [serverName],
|
||||
...(parameters && parameters.length > 0 && { parameters }),
|
||||
...(requestBody && { requestBody }),
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Successful tool execution',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
content: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string' },
|
||||
text: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
isError: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'400': {
|
||||
description: 'Bad request - invalid parameters',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
error: { type: 'string' },
|
||||
message: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'500': {
|
||||
description: 'Internal server error',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
error: { type: 'string' },
|
||||
message: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return operation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate OpenAPI specification from MCP tools
|
||||
*/
|
||||
export async function generateOpenAPISpec(
|
||||
options: OpenAPIGenerationOptions = {},
|
||||
): Promise<OpenAPIV3.Document> {
|
||||
const serverInfos = await getServersInfo();
|
||||
|
||||
// Filter servers based on options
|
||||
let filteredServers = serverInfos.filter(
|
||||
(server) =>
|
||||
server.status === 'connected' &&
|
||||
(!options.serverFilter || options.serverFilter.includes(server.name)),
|
||||
);
|
||||
|
||||
// Apply group filter if specified
|
||||
const groupConfig: Map<string, string[] | 'all'> = new Map();
|
||||
if (options.groupFilter) {
|
||||
const { getGroupByIdOrName } = await import('./groupService.js');
|
||||
const group = getGroupByIdOrName(options.groupFilter);
|
||||
if (group) {
|
||||
// Extract server names and their tool configurations from group
|
||||
const groupServerNames: string[] = [];
|
||||
for (const server of group.servers) {
|
||||
if (typeof server === 'string') {
|
||||
groupServerNames.push(server);
|
||||
groupConfig.set(server, 'all');
|
||||
} else {
|
||||
groupServerNames.push(server.name);
|
||||
groupConfig.set(server.name, server.tools || 'all');
|
||||
}
|
||||
}
|
||||
// Filter to only servers in the group
|
||||
filteredServers = filteredServers.filter((server) => groupServerNames.includes(server.name));
|
||||
} else {
|
||||
// Group not found, return empty specification
|
||||
filteredServers = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all tools from filtered servers
|
||||
const allTools: Array<{ tool: Tool; serverName: string }> = [];
|
||||
|
||||
for (const serverInfo of filteredServers) {
|
||||
const tools = options.includeDisabledTools
|
||||
? serverInfo.tools
|
||||
: serverInfo.tools.filter((tool) => tool.enabled !== false);
|
||||
|
||||
// Apply group-specific tool filtering if group filter is specified
|
||||
let filteredTools = tools;
|
||||
if (options.groupFilter && groupConfig.has(serverInfo.name)) {
|
||||
const allowedTools = groupConfig.get(serverInfo.name);
|
||||
if (allowedTools !== 'all') {
|
||||
// Filter tools to only include those specified in the group configuration
|
||||
filteredTools = tools.filter(
|
||||
(tool) =>
|
||||
Array.isArray(allowedTools) &&
|
||||
allowedTools.includes(tool.name.replace(serverInfo.name + '-', '')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const tool of filteredTools) {
|
||||
allTools.push({ tool, serverName: serverInfo.name });
|
||||
}
|
||||
}
|
||||
|
||||
// Generate paths from tools
|
||||
const paths: OpenAPIV3.PathsObject = {};
|
||||
|
||||
for (const { tool, serverName } of allTools) {
|
||||
const operation = generateOperationFromTool(tool, serverName);
|
||||
const { requestBody } = convertToolSchemaToOpenAPI(tool);
|
||||
|
||||
// Create path for the tool
|
||||
const pathName = `/tools/${serverName}/${tool.name}`;
|
||||
const method = requestBody ? 'post' : 'get';
|
||||
|
||||
if (!paths[pathName]) {
|
||||
paths[pathName] = {};
|
||||
}
|
||||
|
||||
paths[pathName][method] = operation;
|
||||
}
|
||||
|
||||
const settings = loadSettings();
|
||||
// Get server URL
|
||||
const baseUrl =
|
||||
options.serverUrl ||
|
||||
settings.systemConfig?.install?.baseUrl ||
|
||||
`http://localhost:${config.port}`;
|
||||
const serverUrl = `${baseUrl}${config.basePath}/api`;
|
||||
|
||||
// Generate OpenAPI document
|
||||
const openApiDoc: OpenAPIV3.Document = {
|
||||
openapi: '3.0.3',
|
||||
info: {
|
||||
title: options.title || 'MCPHub API',
|
||||
description:
|
||||
options.description ||
|
||||
'OpenAPI specification for MCP tools managed by MCPHub. This enables integration with OpenWebUI and other OpenAPI-compatible systems.',
|
||||
version: options.version || '1.0.0',
|
||||
contact: {
|
||||
name: 'MCPHub',
|
||||
url: 'https://github.com/samanhappy/mcphub',
|
||||
},
|
||||
license: {
|
||||
name: 'ISC',
|
||||
url: 'https://github.com/samanhappy/mcphub/blob/main/LICENSE',
|
||||
},
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: serverUrl,
|
||||
description: 'MCPHub API Server',
|
||||
},
|
||||
],
|
||||
paths,
|
||||
components: {
|
||||
schemas: {
|
||||
ToolResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
content: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string' },
|
||||
text: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
isError: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
ErrorResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
error: { type: 'string' },
|
||||
message: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
},
|
||||
},
|
||||
},
|
||||
security: [
|
||||
{
|
||||
bearerAuth: [],
|
||||
},
|
||||
],
|
||||
tags: filteredServers.map((server) => ({
|
||||
name: server.name,
|
||||
description: `Tools from ${server.name} server`,
|
||||
})),
|
||||
};
|
||||
|
||||
return openApiDoc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available server names for filtering
|
||||
*/
|
||||
export async function getAvailableServers(): Promise<string[]> {
|
||||
const serverInfos = await getServersInfo();
|
||||
return serverInfos.filter((server) => server.status === 'connected').map((server) => server.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics about available tools
|
||||
*/
|
||||
export async function getToolStats(): Promise<{
|
||||
totalServers: number;
|
||||
totalTools: number;
|
||||
serverBreakdown: Array<{ name: string; toolCount: number; status: string }>;
|
||||
}> {
|
||||
const serverInfos = await getServersInfo();
|
||||
|
||||
const serverBreakdown = serverInfos.map((server) => ({
|
||||
name: server.name,
|
||||
toolCount: server.tools.length,
|
||||
status: server.status,
|
||||
}));
|
||||
|
||||
const totalTools = serverInfos
|
||||
.filter((server) => server.status === 'connected')
|
||||
.reduce((sum, server) => sum + server.tools.length, 0);
|
||||
|
||||
return {
|
||||
totalServers: serverInfos.filter((server) => server.status === 'connected').length,
|
||||
totalTools,
|
||||
serverBreakdown,
|
||||
};
|
||||
}
|
||||
@@ -43,7 +43,7 @@ export const handleSseConnection = async (req: Request, res: Response): Promise<
|
||||
const userContextService = UserContextService.getInstance();
|
||||
const currentUser = userContextService.getCurrentUser();
|
||||
const username = currentUser?.username;
|
||||
|
||||
|
||||
// Check bearer auth using filtered settings
|
||||
if (!validateBearerAuth(req)) {
|
||||
console.warn('Bearer authentication failed or not provided');
|
||||
@@ -74,7 +74,7 @@ export const handleSseConnection = async (req: Request, res: Response): Promise<
|
||||
}
|
||||
|
||||
// Construct the appropriate messages path based on user context
|
||||
const messagesPath = username
|
||||
const messagesPath = username
|
||||
? `${config.basePath}/${username}/messages`
|
||||
: `${config.basePath}/messages`;
|
||||
|
||||
@@ -100,7 +100,7 @@ export const handleSseMessage = async (req: Request, res: Response): Promise<voi
|
||||
const userContextService = UserContextService.getInstance();
|
||||
const currentUser = userContextService.getCurrentUser();
|
||||
const username = currentUser?.username;
|
||||
|
||||
|
||||
// Check bearer auth using filtered settings
|
||||
if (!validateBearerAuth(req)) {
|
||||
res.status(401).send('Bearer authentication required or invalid token');
|
||||
@@ -127,7 +127,9 @@ export const handleSseMessage = async (req: Request, res: Response): Promise<voi
|
||||
const { transport, group } = transportData;
|
||||
req.params.group = group;
|
||||
req.query.group = group;
|
||||
console.log(`Received message for sessionId: ${sessionId} in group: ${group}${username ? ` for user: ${username}` : ''}`);
|
||||
console.log(
|
||||
`Received message for sessionId: ${sessionId} in group: ${group}${username ? ` for user: ${username}` : ''}`,
|
||||
);
|
||||
|
||||
await (transport as SSEServerTransport).handlePostMessage(req, res);
|
||||
};
|
||||
@@ -137,14 +139,14 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
|
||||
const userContextService = UserContextService.getInstance();
|
||||
const currentUser = userContextService.getCurrentUser();
|
||||
const username = currentUser?.username;
|
||||
|
||||
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
const group = req.params.group;
|
||||
const body = req.body;
|
||||
console.log(
|
||||
`Handling MCP post request for sessionId: ${sessionId} and group: ${group}${username ? ` for user: ${username}` : ''} with body: ${JSON.stringify(body)}`,
|
||||
);
|
||||
|
||||
|
||||
// Check bearer auth using filtered settings
|
||||
if (!validateBearerAuth(req)) {
|
||||
res.status(401).send('Bearer authentication required or invalid token');
|
||||
@@ -183,7 +185,9 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
|
||||
}
|
||||
};
|
||||
|
||||
console.log(`MCP connection established: ${transport.sessionId}${username ? ` for user: ${username}` : ''}`);
|
||||
console.log(
|
||||
`MCP connection established: ${transport.sessionId}${username ? ` for user: ${username}` : ''}`,
|
||||
);
|
||||
await getMcpServer(transport.sessionId, group).connect(transport);
|
||||
} else {
|
||||
res.status(400).json({
|
||||
@@ -206,9 +210,9 @@ export const handleMcpOtherRequest = async (req: Request, res: Response) => {
|
||||
const userContextService = UserContextService.getInstance();
|
||||
const currentUser = userContextService.getCurrentUser();
|
||||
const username = currentUser?.username;
|
||||
|
||||
|
||||
console.log(`Handling MCP other request${username ? ` for user: ${username}` : ''}`);
|
||||
|
||||
|
||||
// Check bearer auth using filtered settings
|
||||
if (!validateBearerAuth(req)) {
|
||||
res.status(401).send('Bearer authentication required or invalid token');
|
||||
|
||||
@@ -499,7 +499,7 @@ export const syncAllServerToolsEmbeddings = async (): Promise<void> => {
|
||||
// Import getServersInfo to get all server information
|
||||
const { getServersInfo } = await import('./mcpService.js');
|
||||
|
||||
const servers = getServersInfo();
|
||||
const servers = await getServersInfo();
|
||||
let totalToolsSynced = 0;
|
||||
let serversSynced = 0;
|
||||
|
||||
|
||||
302
tests/controllers/openApiController.test.ts
Normal file
302
tests/controllers/openApiController.test.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
// Simple unit test to validate the type conversion logic
|
||||
describe('Parameter Type Conversion Logic', () => {
|
||||
// Extract the conversion function for testing
|
||||
function convertQueryParametersToTypes(
|
||||
queryParams: Record<string, any>,
|
||||
inputSchema: Record<string, any>
|
||||
): Record<string, any> {
|
||||
if (!inputSchema || typeof inputSchema !== 'object' || !inputSchema.properties) {
|
||||
return queryParams;
|
||||
}
|
||||
|
||||
const convertedParams: Record<string, any> = {};
|
||||
const properties = inputSchema.properties;
|
||||
|
||||
for (const [key, value] of Object.entries(queryParams)) {
|
||||
const propDef = properties[key];
|
||||
if (!propDef || typeof propDef !== 'object') {
|
||||
// No schema definition found, keep as is
|
||||
convertedParams[key] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
const propType = propDef.type;
|
||||
|
||||
try {
|
||||
switch (propType) {
|
||||
case 'integer':
|
||||
case 'number':
|
||||
// Convert string to number
|
||||
if (typeof value === 'string') {
|
||||
const numValue = propType === 'integer' ? parseInt(value, 10) : parseFloat(value);
|
||||
convertedParams[key] = isNaN(numValue) ? value : numValue;
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'boolean':
|
||||
// Convert string to boolean
|
||||
if (typeof value === 'string') {
|
||||
convertedParams[key] = value.toLowerCase() === 'true' || value === '1';
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'array':
|
||||
// Handle array conversion if needed (e.g., comma-separated strings)
|
||||
if (typeof value === 'string' && value.includes(',')) {
|
||||
convertedParams[key] = value.split(',').map(item => item.trim());
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// For string and other types, keep as is
|
||||
convertedParams[key] = value;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
// If conversion fails, keep the original value
|
||||
console.warn(`Failed to convert parameter '${key}' to type '${propType}':`, error);
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return convertedParams;
|
||||
}
|
||||
|
||||
test('should convert integer parameters correctly', () => {
|
||||
const queryParams = {
|
||||
limit: '5',
|
||||
offset: '10',
|
||||
name: 'test'
|
||||
};
|
||||
|
||||
const inputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: { type: 'integer' },
|
||||
offset: { type: 'integer' },
|
||||
name: { type: 'string' }
|
||||
}
|
||||
};
|
||||
|
||||
const result = convertQueryParametersToTypes(queryParams, inputSchema);
|
||||
|
||||
expect(result).toEqual({
|
||||
limit: 5, // Converted to integer
|
||||
offset: 10, // Converted to integer
|
||||
name: 'test' // Remains string
|
||||
});
|
||||
});
|
||||
|
||||
test('should convert number parameters correctly', () => {
|
||||
const queryParams = {
|
||||
price: '19.99',
|
||||
discount: '0.15'
|
||||
};
|
||||
|
||||
const inputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
price: { type: 'number' },
|
||||
discount: { type: 'number' }
|
||||
}
|
||||
};
|
||||
|
||||
const result = convertQueryParametersToTypes(queryParams, inputSchema);
|
||||
|
||||
expect(result).toEqual({
|
||||
price: 19.99,
|
||||
discount: 0.15
|
||||
});
|
||||
});
|
||||
|
||||
test('should convert boolean parameters correctly', () => {
|
||||
const queryParams = {
|
||||
enabled: 'true',
|
||||
disabled: 'false',
|
||||
active: '1',
|
||||
inactive: '0'
|
||||
};
|
||||
|
||||
const inputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
enabled: { type: 'boolean' },
|
||||
disabled: { type: 'boolean' },
|
||||
active: { type: 'boolean' },
|
||||
inactive: { type: 'boolean' }
|
||||
}
|
||||
};
|
||||
|
||||
const result = convertQueryParametersToTypes(queryParams, inputSchema);
|
||||
|
||||
expect(result).toEqual({
|
||||
enabled: true,
|
||||
disabled: false,
|
||||
active: true,
|
||||
inactive: false
|
||||
});
|
||||
});
|
||||
|
||||
test('should convert array parameters correctly', () => {
|
||||
const queryParams = {
|
||||
tags: 'tag1,tag2,tag3',
|
||||
ids: '1,2,3'
|
||||
};
|
||||
|
||||
const inputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tags: { type: 'array' },
|
||||
ids: { type: 'array' }
|
||||
}
|
||||
};
|
||||
|
||||
const result = convertQueryParametersToTypes(queryParams, inputSchema);
|
||||
|
||||
expect(result).toEqual({
|
||||
tags: ['tag1', 'tag2', 'tag3'],
|
||||
ids: ['1', '2', '3']
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle missing schema gracefully', () => {
|
||||
const queryParams = {
|
||||
limit: '5',
|
||||
name: 'test'
|
||||
};
|
||||
|
||||
const result = convertQueryParametersToTypes(queryParams, {});
|
||||
|
||||
expect(result).toEqual({
|
||||
limit: '5', // Should remain as string
|
||||
name: 'test' // Should remain as string
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle properties not in schema', () => {
|
||||
const queryParams = {
|
||||
limit: '5',
|
||||
unknownParam: 'value'
|
||||
};
|
||||
|
||||
const inputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: { type: 'integer' }
|
||||
}
|
||||
};
|
||||
|
||||
const result = convertQueryParametersToTypes(queryParams, inputSchema);
|
||||
|
||||
expect(result).toEqual({
|
||||
limit: 5, // Converted based on schema
|
||||
unknownParam: 'value' // Kept as is (no schema)
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle invalid number conversion gracefully', () => {
|
||||
const queryParams = {
|
||||
limit: 'not-a-number',
|
||||
price: 'invalid'
|
||||
};
|
||||
|
||||
const inputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: { type: 'integer' },
|
||||
price: { type: 'number' }
|
||||
}
|
||||
};
|
||||
|
||||
const result = convertQueryParametersToTypes(queryParams, inputSchema);
|
||||
|
||||
expect(result).toEqual({
|
||||
limit: 'not-a-number', // Should remain as string when conversion fails
|
||||
price: 'invalid' // Should remain as string when conversion fails
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Test the new OpenAPI endpoints functionality
|
||||
describe('OpenAPI Granular Endpoints', () => {
|
||||
// Mock the required services
|
||||
const mockGetAvailableServers = jest.fn();
|
||||
const mockGenerateOpenAPISpec = jest.fn();
|
||||
const mockGetGroupByIdOrName = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should generate server-specific OpenAPI spec', async () => {
|
||||
// Mock available servers
|
||||
mockGetAvailableServers.mockResolvedValue(['server1', 'server2']);
|
||||
|
||||
// Mock OpenAPI spec generation
|
||||
const mockSpec = { openapi: '3.0.3', info: { title: 'server1 MCP API' } };
|
||||
mockGenerateOpenAPISpec.mockResolvedValue(mockSpec);
|
||||
|
||||
// Test server spec generation options
|
||||
const expectedOptions = {
|
||||
title: 'server1 MCP API',
|
||||
description: 'OpenAPI specification for server1 MCP server tools',
|
||||
serverFilter: ['server1']
|
||||
};
|
||||
|
||||
// Verify that the correct options would be passed
|
||||
expect(expectedOptions.serverFilter).toEqual(['server1']);
|
||||
expect(expectedOptions.title).toBe('server1 MCP API');
|
||||
});
|
||||
|
||||
test('should generate group-specific OpenAPI spec', async () => {
|
||||
// Mock group data
|
||||
const mockGroup = {
|
||||
id: 'group1',
|
||||
name: 'webtools',
|
||||
servers: [
|
||||
{ name: 'server1', tools: 'all' },
|
||||
{ name: 'server2', tools: ['tool1', 'tool2'] }
|
||||
]
|
||||
};
|
||||
mockGetGroupByIdOrName.mockReturnValue(mockGroup);
|
||||
|
||||
// Mock OpenAPI spec generation
|
||||
const mockSpec = { openapi: '3.0.3', info: { title: 'webtools Group MCP API' } };
|
||||
mockGenerateOpenAPISpec.mockResolvedValue(mockSpec);
|
||||
|
||||
// Test group spec generation options
|
||||
const expectedOptions = {
|
||||
title: 'webtools Group MCP API',
|
||||
description: 'OpenAPI specification for webtools group tools',
|
||||
groupFilter: 'webtools'
|
||||
};
|
||||
|
||||
// Verify that the correct options would be passed
|
||||
expect(expectedOptions.groupFilter).toBe('webtools');
|
||||
expect(expectedOptions.title).toBe('webtools Group MCP API');
|
||||
});
|
||||
|
||||
test('should handle non-existent server', async () => {
|
||||
// Mock available servers (not including 'nonexistent')
|
||||
mockGetAvailableServers.mockResolvedValue(['server1', 'server2']);
|
||||
|
||||
// Verify error handling for non-existent server
|
||||
const serverExists = ['server1', 'server2'].includes('nonexistent');
|
||||
expect(serverExists).toBe(false);
|
||||
});
|
||||
|
||||
test('should handle non-existent group', async () => {
|
||||
// Mock group lookup returning null
|
||||
mockGetGroupByIdOrName.mockReturnValue(null);
|
||||
|
||||
// Verify error handling for non-existent group
|
||||
const group = mockGetGroupByIdOrName('nonexistent');
|
||||
expect(group).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -97,7 +97,7 @@ describe('Real Client Transport Integration Tests', () => {
|
||||
|
||||
expect(error).toBeNull();
|
||||
expect(isConnected).toBe(true);
|
||||
}, 30000);
|
||||
}, 60000);
|
||||
|
||||
it('should connect using real SSEClientTransport with group', async () => {
|
||||
const testGroup = 'integration-test-group';
|
||||
@@ -155,7 +155,7 @@ describe('Real Client Transport Integration Tests', () => {
|
||||
|
||||
expect(error).toBeNull();
|
||||
expect(isConnected).toBe(true);
|
||||
}, 30000);
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
describe('StreamableHTTP Client Transport Tests', () => {
|
||||
@@ -214,7 +214,7 @@ describe('Real Client Transport Integration Tests', () => {
|
||||
|
||||
expect(error).toBeNull();
|
||||
expect(isConnected).toBe(true);
|
||||
}, 30000);
|
||||
}, 60000);
|
||||
|
||||
it('should connect using real StreamableHTTPClientTransport with group', async () => {
|
||||
const testGroup = 'integration-test-group';
|
||||
@@ -272,7 +272,7 @@ describe('Real Client Transport Integration Tests', () => {
|
||||
|
||||
expect(error).toBeNull();
|
||||
expect(isConnected).toBe(true);
|
||||
}, 30000);
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
describe('Real Client Authentication Tests', () => {
|
||||
@@ -288,7 +288,7 @@ describe('Real Client Transport Integration Tests', () => {
|
||||
_authAppServer = authResult.appServer;
|
||||
_authHttpServer = authResult.httpServer;
|
||||
authBaseURL = authResult.baseURL;
|
||||
}, 30000);
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (_authHttpServer) {
|
||||
@@ -345,7 +345,7 @@ describe('Real Client Transport Integration Tests', () => {
|
||||
if (error) {
|
||||
expect(error.message).toContain('401');
|
||||
}
|
||||
}, 30000);
|
||||
}, 60000);
|
||||
|
||||
it('should connect with SSEClientTransport with valid auth', async () => {
|
||||
const sseUrl = new URL(`${authBaseURL}/sse`);
|
||||
@@ -402,7 +402,7 @@ describe('Real Client Transport Integration Tests', () => {
|
||||
|
||||
expect(error).toBeNull();
|
||||
expect(isConnected).toBe(true);
|
||||
}, 30000);
|
||||
}, 60000);
|
||||
|
||||
it('should connect with StreamableHTTPClientTransport with auth', async () => {
|
||||
const mcpUrl = new URL(`${authBaseURL}/mcp`);
|
||||
@@ -460,6 +460,6 @@ describe('Real Client Transport Integration Tests', () => {
|
||||
|
||||
expect(error).toBeNull();
|
||||
expect(isConnected).toBe(true);
|
||||
}, 30000);
|
||||
}, 60000);
|
||||
});
|
||||
});
|
||||
|
||||
69
tests/services/openApiGeneratorService.test.ts
Normal file
69
tests/services/openApiGeneratorService.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { generateOpenAPISpec, getToolStats } from '../../src/services/openApiGeneratorService';
|
||||
|
||||
describe('OpenAPI Generator Service', () => {
|
||||
describe('generateOpenAPISpec', () => {
|
||||
it('should generate a valid OpenAPI specification', async () => {
|
||||
const spec = await generateOpenAPISpec();
|
||||
|
||||
// Check basic structure
|
||||
expect(spec).toHaveProperty('openapi');
|
||||
expect(spec).toHaveProperty('info');
|
||||
expect(spec).toHaveProperty('servers');
|
||||
expect(spec).toHaveProperty('paths');
|
||||
expect(spec).toHaveProperty('components');
|
||||
|
||||
// Check OpenAPI version
|
||||
expect(spec.openapi).toBe('3.0.3');
|
||||
|
||||
// Check info section
|
||||
expect(spec.info).toHaveProperty('title');
|
||||
expect(spec.info).toHaveProperty('description');
|
||||
expect(spec.info).toHaveProperty('version');
|
||||
|
||||
// Check components
|
||||
expect(spec.components).toHaveProperty('schemas');
|
||||
expect(spec.components).toHaveProperty('securitySchemes');
|
||||
|
||||
// Check security schemes
|
||||
expect(spec.components?.securitySchemes).toHaveProperty('bearerAuth');
|
||||
});
|
||||
|
||||
it('should generate spec with custom options', async () => {
|
||||
const options = {
|
||||
title: 'Custom API',
|
||||
description: 'Custom description',
|
||||
version: '2.0.0',
|
||||
serverUrl: 'https://custom.example.com',
|
||||
};
|
||||
|
||||
const spec = await generateOpenAPISpec(options);
|
||||
|
||||
expect(spec.info.title).toBe('Custom API');
|
||||
expect(spec.info.description).toBe('Custom description');
|
||||
expect(spec.info.version).toBe('2.0.0');
|
||||
expect(spec.servers?.[0].url).toContain('https://custom.example.com');
|
||||
});
|
||||
|
||||
it('should handle empty server list gracefully', async () => {
|
||||
const spec = await generateOpenAPISpec();
|
||||
|
||||
// Should not throw and should have valid structure
|
||||
expect(spec).toHaveProperty('paths');
|
||||
expect(typeof spec.paths).toBe('object');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getToolStats', () => {
|
||||
it('should return valid tool statistics', async () => {
|
||||
const stats = await getToolStats();
|
||||
|
||||
expect(stats).toHaveProperty('totalServers');
|
||||
expect(stats).toHaveProperty('totalTools');
|
||||
expect(stats).toHaveProperty('serverBreakdown');
|
||||
|
||||
expect(typeof stats.totalServers).toBe('number');
|
||||
expect(typeof stats.totalTools).toBe('number');
|
||||
expect(Array.isArray(stats.serverBreakdown)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user