mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 02:39:19 -05:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5dd3e7978e | ||
|
|
f577351f04 | ||
|
|
62de87b1a4 | ||
|
|
bbd6c891c9 | ||
|
|
f9019545c3 | ||
|
|
d778536388 | ||
|
|
976e90679d | ||
|
|
f6ee9beed3 | ||
|
|
69a800fa7a | ||
|
|
83cbd16821 | ||
|
|
9300814994 | ||
|
|
9952927a13 |
1
.dockerignore
Normal file
1
.dockerignore
Normal file
@@ -0,0 +1 @@
|
||||
.git
|
||||
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>
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -474,7 +474,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",
|
||||
|
||||
@@ -476,7 +476,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"
|
||||
|
||||
92
package.json
92
package.json
@@ -46,81 +46,91 @@
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
3086
pnpm-lock.yaml
generated
3086
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;
|
||||
}
|
||||
}
|
||||
93
src/dao/base/JsonFileBaseDao.ts
Normal file
93
src/dao/base/JsonFileBaseDao.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { McpSettings } from '../../types/index.js';
|
||||
import { getSettingsPath } 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();
|
||||
} catch (error) {
|
||||
console.error(`Failed to save settings:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear settings cache
|
||||
*/
|
||||
protected clearCache(): void {
|
||||
this.settingsCache = null;
|
||||
this.lastModified = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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';
|
||||
@@ -2,7 +2,6 @@ import express, { Request, Response, NextFunction } from 'express';
|
||||
import { auth } from './auth.js';
|
||||
import { userContextMiddleware } from './userContext.js';
|
||||
import { i18nMiddleware } from './i18n.js';
|
||||
import { initializeDefaultUser } from '../models/User.js';
|
||||
import config from '../config/index.js';
|
||||
|
||||
export const errorHandler = (
|
||||
@@ -46,11 +45,6 @@ export const initMiddlewares = (app: express.Application): void => {
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize default admin user if no users exist
|
||||
initializeDefaultUser().catch((err) => {
|
||||
console.error('Error initializing default user:', err);
|
||||
});
|
||||
|
||||
// Protect API routes with authentication middleware, but exclude auth endpoints
|
||||
app.use(`${config.basePath}/api`, (req, res, next) => {
|
||||
// Skip authentication for login endpoint
|
||||
|
||||
@@ -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,16 @@ 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.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,70 +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}` };
|
||||
}
|
||||
};
|
||||
|
||||
// Update existing server
|
||||
export const updateMcpServer = async (
|
||||
export const removeServer = async (
|
||||
name: string,
|
||||
config: ServerConfig,
|
||||
): Promise<{ success: boolean; message?: string }> => {
|
||||
try {
|
||||
const settings = loadSettings();
|
||||
if (!settings.mcpServers[name]) {
|
||||
return { success: false, message: 'Server not found' };
|
||||
}
|
||||
|
||||
settings.mcpServers[name] = config;
|
||||
if (!saveSettings(settings)) {
|
||||
return { success: false, message: 'Failed to save settings' };
|
||||
}
|
||||
|
||||
closeServer(name);
|
||||
|
||||
serverInfos = serverInfos.filter((serverInfo) => serverInfo.name !== name);
|
||||
return { success: true, message: 'Server updated successfully' };
|
||||
} catch (error) {
|
||||
console.error(`Failed to update server: ${name}`, error);
|
||||
return { success: false, message: 'Failed to update server' };
|
||||
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)
|
||||
@@ -718,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' };
|
||||
}
|
||||
@@ -734,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';
|
||||
@@ -771,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);
|
||||
@@ -891,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) {
|
||||
@@ -906,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 {
|
||||
@@ -957,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) =>
|
||||
@@ -971,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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -992,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 = {
|
||||
@@ -1260,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