mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 02:39:19 -05:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce15330016 | ||
|
|
621bc36560 | ||
|
|
c398223824 | ||
|
|
5dd3e7978e | ||
|
|
f577351f04 | ||
|
|
62de87b1a4 | ||
|
|
bbd6c891c9 | ||
|
|
f9019545c3 | ||
|
|
d778536388 | ||
|
|
976e90679d | ||
|
|
f6ee9beed3 | ||
|
|
69a800fa7a | ||
|
|
83cbd16821 | ||
|
|
9300814994 | ||
|
|
9952927a13 | ||
|
|
4547ae526a | ||
|
|
80b83bb029 | ||
|
|
fa2de88fea | ||
|
|
6020611f57 | ||
|
|
81c3091a5c | ||
|
|
6a8f246dff |
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.
|
||||
|
||||
126
QWEN.md
Normal file
126
QWEN.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# MCPHub Project Overview
|
||||
|
||||
## Project Summary
|
||||
|
||||
MCPHub is a centralized hub server for managing multiple Model Context Protocol (MCP) servers. It allows organizing these servers into flexible Streamable HTTP (SSE) endpoints, supporting access to all servers, individual servers, or logical server groups. It provides a web dashboard for monitoring and managing servers, along with features like authentication, group-based access control, and Smart Routing using vector semantic search.
|
||||
|
||||
## Technology Stack
|
||||
|
||||
### Backend
|
||||
- **Language:** TypeScript (Node.js)
|
||||
- **Framework:** Express
|
||||
- **Key Libraries:**
|
||||
- `@modelcontextprotocol/sdk`: Core library for MCP interactions.
|
||||
- `typeorm`: ORM for database interactions.
|
||||
- `pg` & `pgvector`: PostgreSQL database and vector support.
|
||||
- `jsonwebtoken` & `bcryptjs`: Authentication (JWT) and password hashing.
|
||||
- `openai`: For embedding generation in Smart Routing.
|
||||
- Various utility and validation libraries (e.g., `dotenv`, `express-validator`, `uuid`).
|
||||
|
||||
### Frontend
|
||||
- **Framework:** React (via Vite)
|
||||
- **Language:** TypeScript
|
||||
- **UI Library:** Tailwind CSS
|
||||
- **Routing:** `react-router-dom`
|
||||
- **Internationalization:** `i18next`
|
||||
- **Component Structure:** Modular components and pages within `frontend/src`.
|
||||
|
||||
### Infrastructure
|
||||
- **Build Tool:** `pnpm` (package manager and script runner).
|
||||
- **Containerization:** Docker (`Dockerfile` provided).
|
||||
- **Process Management:** Not explicitly defined in core files, but likely managed by Docker or host system.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **MCP Server Management:** Configure, start, stop, and monitor multiple upstream MCP servers via `stdio`, `SSE`, or `Streamable HTTP` protocols.
|
||||
- **Centralized Dashboard:** Web UI for server status, group management, user administration, and logs.
|
||||
- **Flexible Endpoints:**
|
||||
- Global MCP/SSE endpoint (`/mcp`, `/sse`) for all enabled servers.
|
||||
- Group-based endpoints (`/mcp/{group}`, `/sse/{group}`).
|
||||
- Server-specific endpoints (`/mcp/{server}`, `/sse/{server}`).
|
||||
- Smart Routing endpoint (`/mcp/$smart`, `/sse/$smart`) using vector search.
|
||||
- **Authentication & Authorization:** JWT-based user authentication with role-based access control (admin/user).
|
||||
- **Group Management:** Logical grouping of servers for targeted access and permission control.
|
||||
- **Smart Routing (Experimental):** Uses pgvector and OpenAI embeddings to semantically search and find relevant tools across all connected servers.
|
||||
- **Configuration:** Managed via `mcp_settings.json`.
|
||||
- **Logging:** Server logs are captured and viewable via the dashboard.
|
||||
- **Marketplace Integration:** Access to a marketplace of MCP servers (`servers.json`).
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
C:\code\mcphub\
|
||||
├───src\ # Backend source code (TypeScript)
|
||||
├───frontend\ # Frontend source code (React/TypeScript)
|
||||
│ ├───src\
|
||||
│ ├───components\ # Reusable UI components
|
||||
│ ├───pages\ # Top-level page components
|
||||
│ ├───contexts\ # React contexts (Auth, Theme, Toast)
|
||||
│ ├───layouts\ # Page layouts
|
||||
│ ├───utils\ # Frontend utilities
|
||||
│ └───...
|
||||
├───dist\ # Compiled backend output
|
||||
├───frontend\dist\ # Compiled frontend output
|
||||
├───tests\ # Backend tests
|
||||
├───docs\ # Documentation
|
||||
├───scripts\ # Utility scripts
|
||||
├───bin\ # CLI entry points
|
||||
├───assets\ # Static assets (e.g., images for README)
|
||||
├───.github\ # GitHub workflows
|
||||
├───.vscode\ # VS Code settings
|
||||
├───mcp_settings.json # Main configuration file for MCP servers and users
|
||||
├───servers.json # Marketplace server definitions
|
||||
├───package.json # Node.js project definition, dependencies, and scripts
|
||||
├───pnpm-lock.yaml # Dependency lock file
|
||||
├───tsconfig.json # TypeScript compiler configuration (Backend)
|
||||
├───README.md # Project documentation
|
||||
├───Dockerfile # Docker image definition
|
||||
└───...
|
||||
```
|
||||
|
||||
## Building and Running
|
||||
|
||||
### Prerequisites
|
||||
- Node.js (>=18.0.0 or >=20.0.0)
|
||||
- pnpm
|
||||
- Python 3.13 (for some upstream servers and uvx)
|
||||
- Docker (optional, for containerized deployment)
|
||||
- PostgreSQL with pgvector (optional, for Smart Routing)
|
||||
|
||||
### Local Development
|
||||
1. Clone the repository.
|
||||
2. Install dependencies: `pnpm install`.
|
||||
3. Start development servers: `pnpm dev`.
|
||||
- This runs `pnpm backend:dev` (Node.js with `tsx watch`) and `pnpm frontend:dev` (Vite dev server) concurrently.
|
||||
- Access the dashboard at `http://localhost:5173` (Vite default) or the configured port/path.
|
||||
|
||||
### Production Build
|
||||
1. Install dependencies: `pnpm install`.
|
||||
2. Build the project: `pnpm build`.
|
||||
- This runs `pnpm backend:build` (TypeScript compilation to `dist/`) and `pnpm frontend:build` (Vite build to `frontend/dist/`).
|
||||
3. Start the production server: `pnpm start`.
|
||||
- This runs `node dist/index.js`.
|
||||
|
||||
### Docker Deployment
|
||||
- Pull the image: `docker pull samanhappy/mcphub`.
|
||||
- Run with default settings: `docker run -p 3000:3000 samanhappy/mcphub`.
|
||||
- Run with custom config: `docker run -p 3000:3000 -v ./mcp_settings.json:/app/mcp_settings.json -v ./data:/app/data samanhappy/mcphub`.
|
||||
- Access the dashboard at `http://localhost:3000`.
|
||||
|
||||
## Configuration
|
||||
|
||||
The main configuration file is `mcp_settings.json`. It defines:
|
||||
- `mcpServers`: A map of server configurations (command, args, env, URL, etc.).
|
||||
- `users`: A list of user accounts (username, hashed password, admin status).
|
||||
- `groups`: A map of server groups.
|
||||
- `systemConfig`: System-wide settings (e.g., proxy, registry, installation options).
|
||||
|
||||
## Development Conventions
|
||||
|
||||
- **Language:** TypeScript for both backend and frontend.
|
||||
- **Backend Style:** Modular structure with clear separation of concerns (controllers, services, models, middlewares, routes, config, utils).
|
||||
- **Frontend Style:** Component-based React architecture with contexts for state management.
|
||||
- **Database:** TypeORM with PostgreSQL is used, leveraging decorators for entity definition.
|
||||
- **Testing:** Uses `jest` for backend testing.
|
||||
- **Linting/Formatting:** Uses `eslint` and `prettier`.
|
||||
- **Scripts:** Defined in `package.json` under the `scripts` section for common tasks (dev, build, start, test, lint, format).
|
||||
250
docs/api-reference/openapi.mdx
Normal file
250
docs/api-reference/openapi.mdx
Normal file
@@ -0,0 +1,250 @@
|
||||
---
|
||||
title: "OpenAPI Integration"
|
||||
description: "Generate OpenAPI specifications from MCP tools for seamless integration with OpenWebUI and other systems"
|
||||
---
|
||||
|
||||
# OpenAPI Generation for OpenWebUI Integration
|
||||
|
||||
MCPHub now supports generating OpenAPI 3.0.3 specifications from MCP tools, enabling seamless integration with OpenWebUI and other OpenAPI-compatible systems without requiring MCPO as an intermediary proxy.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ **Automatic OpenAPI Generation**: Converts MCP tools to OpenAPI 3.0.3 specification
|
||||
- ✅ **OpenWebUI Compatible**: Direct integration without MCPO proxy
|
||||
- ✅ **Real-time Tool Discovery**: Dynamically includes tools from connected MCP servers
|
||||
- ✅ **Dual Parameter Support**: Supports both GET (query params) and POST (JSON body) for tool execution
|
||||
- ✅ **No Authentication Required**: OpenAPI endpoints are public for easy integration
|
||||
- ✅ **Comprehensive Metadata**: Full OpenAPI specification with proper schemas and documentation
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### OpenAPI Specification
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```bash GET /api/openapi.json
|
||||
curl "http://localhost:3000/api/openapi.json"
|
||||
```
|
||||
|
||||
```bash With Parameters
|
||||
curl "http://localhost:3000/api/openapi.json?title=My MCP API&version=2.0.0"
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
Generates and returns the complete OpenAPI 3.0.3 specification for all connected MCP tools.
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
<ParamField query="title" type="string" optional>
|
||||
Custom API title
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="description" type="string" optional>
|
||||
Custom API description
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="version" type="string" optional>
|
||||
Custom API version
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="serverUrl" type="string" optional>
|
||||
Custom server URL
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="includeDisabled" type="boolean" optional default="false">
|
||||
Include disabled tools
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="servers" type="string" optional>
|
||||
Comma-separated list of server names to include
|
||||
</ParamField>
|
||||
|
||||
### Available Servers
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```bash GET /api/openapi/servers
|
||||
curl "http://localhost:3000/api/openapi/servers"
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
Returns a list of connected MCP server names.
|
||||
|
||||
<ResponseExample>
|
||||
|
||||
```json Example Response
|
||||
{
|
||||
"success": true,
|
||||
"data": ["amap", "playwright", "slack"]
|
||||
}
|
||||
```
|
||||
|
||||
</ResponseExample>
|
||||
|
||||
### Tool Statistics
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```bash GET /api/openapi/stats
|
||||
curl "http://localhost:3000/api/openapi/stats"
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
Returns statistics about available tools and servers.
|
||||
|
||||
<ResponseExample>
|
||||
|
||||
```json Example Response
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"totalServers": 3,
|
||||
"totalTools": 41,
|
||||
"serverBreakdown": [
|
||||
{"name": "amap", "toolCount": 12, "status": "connected"},
|
||||
{"name": "playwright", "toolCount": 21, "status": "connected"},
|
||||
{"name": "slack", "toolCount": 8, "status": "connected"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</ResponseExample>
|
||||
|
||||
### Tool Execution
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```bash GET /api/tools/{serverName}/{toolName}
|
||||
curl "http://localhost:3000/api/tools/amap/amap-maps_weather?city=Beijing"
|
||||
```
|
||||
|
||||
```bash POST /api/tools/{serverName}/{toolName}
|
||||
curl -X POST "http://localhost:3000/api/tools/playwright/playwright-browser_navigate" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"url": "https://example.com"}'
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
Execute MCP tools via OpenAPI-compatible endpoints.
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
<ParamField path="serverName" type="string" required>
|
||||
The name of the MCP server
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="toolName" type="string" required>
|
||||
The name of the tool to execute
|
||||
</ParamField>
|
||||
|
||||
## OpenWebUI Integration
|
||||
|
||||
To integrate MCPHub with OpenWebUI:
|
||||
|
||||
<Steps>
|
||||
<Step title="Start MCPHub">
|
||||
Ensure MCPHub is running with your MCP servers configured
|
||||
</Step>
|
||||
<Step title="Get OpenAPI Specification">
|
||||
```bash
|
||||
curl http://localhost:3000/api/openapi.json > mcphub-api.json
|
||||
```
|
||||
</Step>
|
||||
<Step title="Add to OpenWebUI">
|
||||
Import the OpenAPI specification file or point to the URL directly in OpenWebUI
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
### Configuration Example
|
||||
|
||||
In OpenWebUI, you can add MCPHub as an OpenAPI tool by using:
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="OpenAPI URL" icon="link">
|
||||
`http://localhost:3000/api/openapi.json`
|
||||
</Card>
|
||||
<Card title="Base URL" icon="server">
|
||||
`http://localhost:3000/api`
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Generated OpenAPI Structure
|
||||
|
||||
The generated OpenAPI specification includes:
|
||||
|
||||
### Tool Conversion Logic
|
||||
|
||||
- **Simple tools** (≤10 primitive parameters) → GET endpoints with query parameters
|
||||
- **Complex tools** (objects, arrays, or >10 parameters) → POST endpoints with JSON request body
|
||||
- **All tools** include comprehensive response schemas and error handling
|
||||
|
||||
### Example Generated Operation
|
||||
|
||||
```yaml
|
||||
/tools/amap/amap-maps_weather:
|
||||
get:
|
||||
summary: "根据城市名称或者标准adcode查询指定城市的天气"
|
||||
operationId: "amap_amap-maps_weather"
|
||||
tags: ["amap"]
|
||||
parameters:
|
||||
- name: city
|
||||
in: query
|
||||
required: true
|
||||
description: "城市名称或者adcode"
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: "Successful tool execution"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ToolResponse'
|
||||
```
|
||||
|
||||
### Security
|
||||
|
||||
- Bearer authentication is defined but not enforced for tool execution endpoints
|
||||
- Enables flexible integration with various OpenAPI-compatible systems
|
||||
|
||||
## Benefits over MCPO
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Direct Integration" icon="plug">
|
||||
No need for intermediate proxy
|
||||
</Card>
|
||||
<Card title="Real-time Updates" icon="refresh">
|
||||
OpenAPI spec updates automatically as MCP servers connect/disconnect
|
||||
</Card>
|
||||
<Card title="Better Performance" icon="bolt">
|
||||
Direct tool execution without proxy overhead
|
||||
</Card>
|
||||
<Card title="Simplified Architecture" icon="layer-group">
|
||||
One less component to manage
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="OpenAPI spec shows no tools">
|
||||
Ensure MCP servers are connected. Check `/api/openapi/stats` for server status.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Tool execution fails">
|
||||
Verify the tool name and parameters match the OpenAPI specification. Check server logs for details.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="OpenWebUI can't connect">
|
||||
Ensure MCPHub is accessible from OpenWebUI and the OpenAPI URL is correct.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Missing tools in specification">
|
||||
Check if tools are enabled in your MCP server configuration. Use `includeDisabled=true` to see all tools.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
210
docs/dao-implementation-summary.md
Normal file
210
docs/dao-implementation-summary.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# MCPHub DAO Layer 实现总结
|
||||
|
||||
## 项目概述
|
||||
|
||||
本次开发为MCPHub项目引入了独立的数据访问对象(DAO)层,用于管理`mcp_settings.json`中的不同类型数据的增删改查操作。
|
||||
|
||||
## 已实现的功能
|
||||
|
||||
### 1. 核心DAO层架构
|
||||
|
||||
#### 基础架构
|
||||
- **BaseDao.ts**: 定义了通用的CRUD接口和抽象实现
|
||||
- **JsonFileBaseDao.ts**: 提供JSON文件操作的基础类,包含缓存机制
|
||||
- **DaoFactory.ts**: 工厂模式实现,提供DAO实例的创建和管理
|
||||
|
||||
#### 具体DAO实现
|
||||
1. **UserDao**: 用户数据管理
|
||||
- 用户创建(含密码哈希)
|
||||
- 密码验证
|
||||
- 权限管理
|
||||
- 管理员查询
|
||||
|
||||
2. **ServerDao**: 服务器配置管理
|
||||
- 服务器CRUD操作
|
||||
- 按所有者/类型/状态查询
|
||||
- 工具和提示配置管理
|
||||
- 启用/禁用控制
|
||||
|
||||
3. **GroupDao**: 群组管理
|
||||
- 群组CRUD操作
|
||||
- 服务器成员管理
|
||||
- 按所有者查询
|
||||
- 群组-服务器关系管理
|
||||
|
||||
4. **SystemConfigDao**: 系统配置管理
|
||||
- 系统级配置的读取和更新
|
||||
- 分段配置管理
|
||||
- 配置重置功能
|
||||
|
||||
5. **UserConfigDao**: 用户个人配置管理
|
||||
- 用户个人配置的CRUD操作
|
||||
- 分段配置管理
|
||||
- 批量配置查询
|
||||
|
||||
### 2. 配置服务集成
|
||||
|
||||
#### DaoConfigService
|
||||
- 使用DAO层重新实现配置加载和保存
|
||||
- 支持用户权限过滤
|
||||
- 提供配置合并和验证功能
|
||||
|
||||
#### ConfigManager
|
||||
- 双模式支持:传统文件方式 + 新DAO层
|
||||
- 运行时切换机制
|
||||
- 环境变量控制 (`USE_DAO_LAYER`)
|
||||
- 迁移工具集成
|
||||
|
||||
### 3. 迁移和验证工具
|
||||
|
||||
#### 迁移功能
|
||||
- 从传统JSON文件格式迁移到DAO层
|
||||
- 数据完整性验证
|
||||
- 性能对比分析
|
||||
- 迁移报告生成
|
||||
|
||||
#### 测试工具
|
||||
- DAO操作完整性测试
|
||||
- 示例数据生成和清理
|
||||
- 性能基准测试
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── dao/ # DAO层核心
|
||||
│ ├── base/
|
||||
│ │ ├── BaseDao.ts # 基础DAO接口
|
||||
│ │ └── JsonFileBaseDao.ts # JSON文件基础类
|
||||
│ ├── UserDao.ts # 用户数据访问
|
||||
│ ├── ServerDao.ts # 服务器配置访问
|
||||
│ ├── GroupDao.ts # 群组数据访问
|
||||
│ ├── SystemConfigDao.ts # 系统配置访问
|
||||
│ ├── UserConfigDao.ts # 用户配置访问
|
||||
│ ├── DaoFactory.ts # DAO工厂
|
||||
│ ├── examples.ts # 使用示例
|
||||
│ └── index.ts # 统一导出
|
||||
├── config/
|
||||
│ ├── DaoConfigService.ts # DAO配置服务
|
||||
│ ├── configManager.ts # 配置管理器
|
||||
│ └── migrationUtils.ts # 迁移工具
|
||||
├── scripts/
|
||||
│ └── dao-demo.ts # 演示脚本
|
||||
└── docs/
|
||||
└── dao-layer.md # 详细文档
|
||||
```
|
||||
|
||||
## 主要特性
|
||||
|
||||
### 1. 类型安全
|
||||
- 完整的TypeScript类型定义
|
||||
- 编译时类型检查
|
||||
- 接口约束和验证
|
||||
|
||||
### 2. 模块化设计
|
||||
- 每种数据类型独立的DAO
|
||||
- 清晰的关注点分离
|
||||
- 可插拔的实现方式
|
||||
|
||||
### 3. 缓存机制
|
||||
- JSON文件读取缓存
|
||||
- 文件修改时间检测
|
||||
- 缓存失效和刷新
|
||||
|
||||
### 4. 向后兼容
|
||||
- 保持现有API不变
|
||||
- 支持传统和DAO双模式
|
||||
- 平滑迁移路径
|
||||
|
||||
### 5. 未来扩展性
|
||||
- 数据库切换准备
|
||||
- 新数据类型支持
|
||||
- 复杂查询能力
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 启用DAO层
|
||||
```bash
|
||||
# 环境变量配置
|
||||
export USE_DAO_LAYER=true
|
||||
```
|
||||
|
||||
### 基本操作示例
|
||||
```typescript
|
||||
import { getUserDao, getServerDao } from './dao/index.js';
|
||||
|
||||
// 用户操作
|
||||
const userDao = getUserDao();
|
||||
await userDao.createWithHashedPassword('admin', 'password', true);
|
||||
const user = await userDao.findByUsername('admin');
|
||||
|
||||
// 服务器操作
|
||||
const serverDao = getServerDao();
|
||||
await serverDao.create({
|
||||
name: 'my-server',
|
||||
command: 'node',
|
||||
args: ['server.js']
|
||||
});
|
||||
```
|
||||
|
||||
### 迁移操作
|
||||
```typescript
|
||||
import { migrateToDao, validateMigration } from './config/configManager.js';
|
||||
|
||||
// 执行迁移
|
||||
await migrateToDao();
|
||||
|
||||
// 验证迁移
|
||||
await validateMigration();
|
||||
```
|
||||
|
||||
## 依赖包
|
||||
|
||||
新增的依赖包:
|
||||
- `bcrypt`: 用户密码哈希
|
||||
- `@types/bcrypt`: bcrypt类型定义
|
||||
- `uuid`: UUID生成(群组ID)
|
||||
- `@types/uuid`: uuid类型定义
|
||||
|
||||
## 测试状态
|
||||
|
||||
✅ **编译测试**: 项目成功编译,无TypeScript错误
|
||||
✅ **类型检查**: 所有类型定义正确
|
||||
✅ **依赖安装**: 必要依赖包已安装
|
||||
⏳ **运行时测试**: 需要在实际环境中测试
|
||||
⏳ **迁移测试**: 需要使用真实数据测试迁移
|
||||
|
||||
## 下一步计划
|
||||
|
||||
### 短期目标
|
||||
1. 在开发环境中测试DAO层功能
|
||||
2. 完善错误处理和边界情况
|
||||
3. 添加更多单元测试
|
||||
4. 性能优化和监控
|
||||
|
||||
### 中期目标
|
||||
1. 集成到现有业务逻辑中
|
||||
2. 提供Web界面的DAO层管理
|
||||
3. 添加数据备份和恢复功能
|
||||
4. 实现配置版本控制
|
||||
|
||||
### 长期目标
|
||||
1. 实现数据库后端支持
|
||||
2. 添加分布式配置管理
|
||||
3. 实现实时配置同步
|
||||
4. 支持配置审计和日志
|
||||
|
||||
## 优势总结
|
||||
|
||||
通过引入DAO层,MCPHub获得了以下优势:
|
||||
|
||||
1. **🏗️ 架构清晰**: 数据访问逻辑与业务逻辑分离
|
||||
2. **🔄 易于扩展**: 为未来数据库支持做好准备
|
||||
3. **🧪 便于测试**: 接口可以轻松模拟和单元测试
|
||||
4. **🔒 类型安全**: 完整的TypeScript类型支持
|
||||
5. **⚡ 性能优化**: 内置缓存和批量操作
|
||||
6. **🛡️ 数据完整性**: 强制数据验证和约束
|
||||
7. **📦 模块化**: 每种数据类型独立管理
|
||||
8. **🔧 可维护性**: 代码结构清晰,易于维护
|
||||
|
||||
这个DAO层的实现为MCPHub项目提供了坚实的数据管理基础,支持项目的长期发展和扩展需求。
|
||||
254
docs/dao-layer.md
Normal file
254
docs/dao-layer.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# MCPHub DAO Layer 设计文档
|
||||
|
||||
## 概述
|
||||
|
||||
MCPHub的数据访问对象(DAO)层为项目中`mcp_settings.json`文件中的不同数据类型提供了统一的增删改查操作接口。这个设计使得未来从JSON文件存储切换到数据库存储变得更加容易。
|
||||
|
||||
## 架构设计
|
||||
|
||||
### 核心组件
|
||||
|
||||
```
|
||||
src/dao/
|
||||
├── base/
|
||||
│ ├── BaseDao.ts # 基础DAO接口和抽象实现
|
||||
│ └── JsonFileBaseDao.ts # JSON文件操作的基础类
|
||||
├── UserDao.ts # 用户数据访问对象
|
||||
├── ServerDao.ts # 服务器配置数据访问对象
|
||||
├── GroupDao.ts # 群组数据访问对象
|
||||
├── SystemConfigDao.ts # 系统配置数据访问对象
|
||||
├── UserConfigDao.ts # 用户配置数据访问对象
|
||||
├── DaoFactory.ts # DAO工厂类
|
||||
├── examples.ts # 使用示例
|
||||
└── index.ts # 统一导出
|
||||
```
|
||||
|
||||
### 数据类型映射
|
||||
|
||||
| 数据类型 | 原始位置 | DAO类 | 主要功能 |
|
||||
|---------|---------|-------|---------|
|
||||
| IUser | `settings.users[]` | UserDao | 用户管理、密码验证、权限控制 |
|
||||
| ServerConfig | `settings.mcpServers{}` | ServerDao | 服务器配置、启用/禁用、工具管理 |
|
||||
| IGroup | `settings.groups[]` | GroupDao | 群组管理、服务器分组、成员管理 |
|
||||
| SystemConfig | `settings.systemConfig` | SystemConfigDao | 系统级配置、路由设置、安装配置 |
|
||||
| UserConfig | `settings.userConfigs{}` | UserConfigDao | 用户个人配置 |
|
||||
|
||||
## 主要特性
|
||||
|
||||
### 1. 统一的CRUD接口
|
||||
|
||||
所有DAO都实现了基础的CRUD操作:
|
||||
|
||||
```typescript
|
||||
interface BaseDao<T, K = string> {
|
||||
findAll(): Promise<T[]>;
|
||||
findById(id: K): Promise<T | null>;
|
||||
create(entity: Omit<T, 'id'>): Promise<T>;
|
||||
update(id: K, entity: Partial<T>): Promise<T | null>;
|
||||
delete(id: K): Promise<boolean>;
|
||||
exists(id: K): Promise<boolean>;
|
||||
count(): Promise<number>;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 特定业务操作
|
||||
|
||||
每个DAO还提供了针对其数据类型的特定操作:
|
||||
|
||||
#### UserDao 特殊功能
|
||||
- `createWithHashedPassword()` - 创建用户时自动哈希密码
|
||||
- `validateCredentials()` - 验证用户凭据
|
||||
- `updatePassword()` - 更新用户密码
|
||||
- `findAdmins()` - 查找管理员用户
|
||||
|
||||
#### ServerDao 特殊功能
|
||||
- `findByOwner()` - 按所有者查找服务器
|
||||
- `findEnabled()` - 查找启用的服务器
|
||||
- `findByType()` - 按类型查找服务器
|
||||
- `setEnabled()` - 启用/禁用服务器
|
||||
- `updateTools()` - 更新服务器工具配置
|
||||
|
||||
#### GroupDao 特殊功能
|
||||
- `findByOwner()` - 按所有者查找群组
|
||||
- `findByServer()` - 查找包含特定服务器的群组
|
||||
- `addServerToGroup()` - 向群组添加服务器
|
||||
- `removeServerFromGroup()` - 从群组移除服务器
|
||||
- `findByName()` - 按名称查找群组
|
||||
|
||||
### 3. 配置管理特殊功能
|
||||
|
||||
#### SystemConfigDao
|
||||
- `getSection()` - 获取特定配置段
|
||||
- `updateSection()` - 更新特定配置段
|
||||
- `reset()` - 重置为默认配置
|
||||
|
||||
#### UserConfigDao
|
||||
- `getSection()` - 获取用户特定配置段
|
||||
- `updateSection()` - 更新用户特定配置段
|
||||
- `getAll()` - 获取所有用户配置
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 1. 基本使用
|
||||
|
||||
```typescript
|
||||
import { getUserDao, getServerDao, getGroupDao } from './dao/index.js';
|
||||
|
||||
// 用户操作
|
||||
const userDao = getUserDao();
|
||||
const newUser = await userDao.createWithHashedPassword('username', 'password', false);
|
||||
const user = await userDao.findByUsername('username');
|
||||
const isValid = await userDao.validateCredentials('username', 'password');
|
||||
|
||||
// 服务器操作
|
||||
const serverDao = getServerDao();
|
||||
const server = await serverDao.create({
|
||||
name: 'my-server',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
enabled: true
|
||||
});
|
||||
|
||||
// 群组操作
|
||||
const groupDao = getGroupDao();
|
||||
const group = await groupDao.create({
|
||||
name: 'my-group',
|
||||
description: 'Test group',
|
||||
servers: ['my-server']
|
||||
});
|
||||
```
|
||||
|
||||
### 2. 配置服务集成
|
||||
|
||||
```typescript
|
||||
import { DaoConfigService, createDaoConfigService } from './config/DaoConfigService.js';
|
||||
|
||||
const daoService = createDaoConfigService();
|
||||
|
||||
// 加载完整配置
|
||||
const settings = await daoService.loadSettings();
|
||||
|
||||
// 保存配置
|
||||
await daoService.saveSettings(updatedSettings);
|
||||
```
|
||||
|
||||
### 3. 迁移管理
|
||||
|
||||
```typescript
|
||||
import { migrateToDao, switchToDao, switchToLegacy } from './config/configManager.js';
|
||||
|
||||
// 迁移到DAO层
|
||||
const success = await migrateToDao();
|
||||
|
||||
// 运行时切换
|
||||
switchToDao(); // 切换到DAO层
|
||||
switchToLegacy(); // 切换回传统方式
|
||||
```
|
||||
|
||||
## 配置选项
|
||||
|
||||
可以通过环境变量控制使用哪种数据访问方式:
|
||||
|
||||
```bash
|
||||
# 使用DAO层 (推荐)
|
||||
USE_DAO_LAYER=true
|
||||
|
||||
# 使用传统文件方式 (默认,向后兼容)
|
||||
USE_DAO_LAYER=false
|
||||
```
|
||||
|
||||
## 未来扩展
|
||||
|
||||
### 数据库支持
|
||||
|
||||
DAO层的设计使得切换到数据库变得容易,只需要:
|
||||
|
||||
1. 实现新的DAO实现类(如DatabaseUserDao)
|
||||
2. 创建新的DaoFactory
|
||||
3. 更新配置以使用新的工厂
|
||||
|
||||
```typescript
|
||||
// 未来的数据库实现示例
|
||||
class DatabaseUserDao implements UserDao {
|
||||
constructor(private db: Database) {}
|
||||
|
||||
async findAll(): Promise<IUser[]> {
|
||||
return this.db.query('SELECT * FROM users');
|
||||
}
|
||||
|
||||
// ... 其他方法
|
||||
}
|
||||
```
|
||||
|
||||
### 新数据类型
|
||||
|
||||
添加新数据类型只需要:
|
||||
|
||||
1. 定义数据接口
|
||||
2. 创建对应的DAO接口和实现
|
||||
3. 更新DaoFactory
|
||||
4. 更新配置服务
|
||||
|
||||
## 迁移指南
|
||||
|
||||
### 从传统方式迁移到DAO层
|
||||
|
||||
1. **备份数据**
|
||||
```bash
|
||||
cp mcp_settings.json mcp_settings.json.backup
|
||||
```
|
||||
|
||||
2. **运行迁移**
|
||||
```typescript
|
||||
import { performMigration } from './config/migrationUtils.js';
|
||||
await performMigration();
|
||||
```
|
||||
|
||||
3. **验证迁移**
|
||||
```typescript
|
||||
import { validateMigration } from './config/migrationUtils.js';
|
||||
const isValid = await validateMigration();
|
||||
```
|
||||
|
||||
4. **切换到DAO层**
|
||||
```bash
|
||||
export USE_DAO_LAYER=true
|
||||
```
|
||||
|
||||
### 性能对比
|
||||
|
||||
可以使用内置工具对比性能:
|
||||
|
||||
```typescript
|
||||
import { performanceComparison } from './config/migrationUtils.js';
|
||||
await performanceComparison();
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **类型安全**: 始终使用TypeScript接口确保类型安全
|
||||
2. **错误处理**: 在DAO操作周围实现适当的错误处理
|
||||
3. **事务**: 对于复杂操作,考虑使用事务(未来数据库实现)
|
||||
4. **缓存**: DAO层包含内置缓存机制
|
||||
5. **测试**: 使用DAO接口进行单元测试的模拟
|
||||
|
||||
## 示例代码
|
||||
|
||||
查看以下文件获取完整示例:
|
||||
|
||||
- `src/dao/examples.ts` - 基本DAO操作示例
|
||||
- `src/config/migrationUtils.ts` - 迁移和验证工具
|
||||
- `src/scripts/dao-demo.ts` - 交互式演示脚本
|
||||
|
||||
## 总结
|
||||
|
||||
DAO层为MCPHub提供了:
|
||||
|
||||
- 🏗️ **模块化设计**: 每种数据类型都有专门的访问层
|
||||
- 🔄 **易于迁移**: 为未来切换到数据库做好准备
|
||||
- 🧪 **可测试性**: 接口可以轻松模拟和测试
|
||||
- 🔒 **类型安全**: 完整的TypeScript类型支持
|
||||
- ⚡ **性能优化**: 内置缓存和批量操作支持
|
||||
- 🛡️ **数据完整性**: 强制数据验证和约束
|
||||
|
||||
通过引入DAO层,MCPHub的数据管理变得更加结构化、可维护和可扩展。
|
||||
@@ -83,6 +83,12 @@
|
||||
"api-reference/smart-routing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "OpenAPI Endpoints",
|
||||
"pages": [
|
||||
"api-reference/openapi"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Management Endpoints",
|
||||
"pages": [
|
||||
@@ -107,6 +113,12 @@
|
||||
"zh/api-reference/smart-routing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "OpenAPI 端点",
|
||||
"pages": [
|
||||
"zh/api-reference/openapi"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "管理端点",
|
||||
"pages": [
|
||||
|
||||
250
docs/zh/api-reference/openapi.mdx
Normal file
250
docs/zh/api-reference/openapi.mdx
Normal file
@@ -0,0 +1,250 @@
|
||||
---
|
||||
title: "OpenAPI 集成"
|
||||
description: "从 MCP 工具生成 OpenAPI 规范,与 OpenWebUI 和其他系统无缝集成"
|
||||
---
|
||||
|
||||
# OpenWebUI 集成的 OpenAPI 生成
|
||||
|
||||
MCPHub 现在支持从 MCP 工具生成 OpenAPI 3.0.3 规范,实现与 OpenWebUI 和其他 OpenAPI 兼容系统的无缝集成,无需 MCPO 作为中间代理。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ✅ **自动 OpenAPI 生成**:将 MCP 工具转换为 OpenAPI 3.0.3 规范
|
||||
- ✅ **OpenWebUI 兼容**:无需 MCPO 代理的直接集成
|
||||
- ✅ **实时工具发现**:动态包含已连接 MCP 服务器的工具
|
||||
- ✅ **双参数支持**:支持 GET(查询参数)和 POST(JSON 正文)进行工具执行
|
||||
- ✅ **无需身份验证**:OpenAPI 端点公开,便于集成
|
||||
- ✅ **完整元数据**:具有适当模式和文档的完整 OpenAPI 规范
|
||||
|
||||
## API 端点
|
||||
|
||||
### OpenAPI 规范
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```bash GET /api/openapi.json
|
||||
curl "http://localhost:3000/api/openapi.json"
|
||||
```
|
||||
|
||||
```bash 带参数
|
||||
curl "http://localhost:3000/api/openapi.json?title=我的 MCP API&version=2.0.0"
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
生成并返回所有已连接 MCP 工具的完整 OpenAPI 3.0.3 规范。
|
||||
|
||||
**查询参数:**
|
||||
|
||||
<ParamField query="title" type="string" optional>
|
||||
自定义 API 标题
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="description" type="string" optional>
|
||||
自定义 API 描述
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="version" type="string" optional>
|
||||
自定义 API 版本
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="serverUrl" type="string" optional>
|
||||
自定义服务器 URL
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="includeDisabled" type="boolean" optional default="false">
|
||||
包含禁用的工具
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="servers" type="string" optional>
|
||||
要包含的服务器名称列表(逗号分隔)
|
||||
</ParamField>
|
||||
|
||||
### 可用服务器
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```bash GET /api/openapi/servers
|
||||
curl "http://localhost:3000/api/openapi/servers"
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
返回已连接的 MCP 服务器名称列表。
|
||||
|
||||
<ResponseExample>
|
||||
|
||||
```json 示例响应
|
||||
{
|
||||
"success": true,
|
||||
"data": ["amap", "playwright", "slack"]
|
||||
}
|
||||
```
|
||||
|
||||
</ResponseExample>
|
||||
|
||||
### 工具统计
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```bash GET /api/openapi/stats
|
||||
curl "http://localhost:3000/api/openapi/stats"
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
返回有关可用工具和服务器的统计信息。
|
||||
|
||||
<ResponseExample>
|
||||
|
||||
```json 示例响应
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"totalServers": 3,
|
||||
"totalTools": 41,
|
||||
"serverBreakdown": [
|
||||
{"name": "amap", "toolCount": 12, "status": "connected"},
|
||||
{"name": "playwright", "toolCount": 21, "status": "connected"},
|
||||
{"name": "slack", "toolCount": 8, "status": "connected"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</ResponseExample>
|
||||
|
||||
### 工具执行
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```bash GET /api/tools/{serverName}/{toolName}
|
||||
curl "http://localhost:3000/api/tools/amap/amap-maps_weather?city=Beijing"
|
||||
```
|
||||
|
||||
```bash POST /api/tools/{serverName}/{toolName}
|
||||
curl -X POST "http://localhost:3000/api/tools/playwright/playwright-browser_navigate" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"url": "https://example.com"}'
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
通过 OpenAPI 兼容端点执行 MCP 工具。
|
||||
|
||||
**路径参数:**
|
||||
|
||||
<ParamField path="serverName" type="string" required>
|
||||
MCP 服务器的名称
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="toolName" type="string" required>
|
||||
要执行的工具名称
|
||||
</ParamField>
|
||||
|
||||
## OpenWebUI 集成
|
||||
|
||||
要将 MCPHub 与 OpenWebUI 集成:
|
||||
|
||||
<Steps>
|
||||
<Step title="启动 MCPHub">
|
||||
确保 MCPHub 正在运行,并且已配置 MCP 服务器
|
||||
</Step>
|
||||
<Step title="获取 OpenAPI 规范">
|
||||
```bash
|
||||
curl http://localhost:3000/api/openapi.json > mcphub-api.json
|
||||
```
|
||||
</Step>
|
||||
<Step title="添加到 OpenWebUI">
|
||||
在 OpenWebUI 中导入 OpenAPI 规范文件或直接指向 URL
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
### 配置示例
|
||||
|
||||
在 OpenWebUI 中,您可以通过以下方式将 MCPHub 添加为 OpenAPI 工具:
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="OpenAPI URL" icon="link">
|
||||
`http://localhost:3000/api/openapi.json`
|
||||
</Card>
|
||||
<Card title="基础 URL" icon="server">
|
||||
`http://localhost:3000/api`
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## 生成的 OpenAPI 结构
|
||||
|
||||
生成的 OpenAPI 规范包括:
|
||||
|
||||
### 工具转换逻辑
|
||||
|
||||
- **简单工具**(≤10 个原始参数)→ 带查询参数的 GET 端点
|
||||
- **复杂工具**(对象、数组或 >10 个参数)→ 带 JSON 请求正文的 POST 端点
|
||||
- **所有工具**都包含完整的响应模式和错误处理
|
||||
|
||||
### 生成操作示例
|
||||
|
||||
```yaml
|
||||
/tools/amap/amap-maps_weather:
|
||||
get:
|
||||
summary: "根据城市名称或者标准adcode查询指定城市的天气"
|
||||
operationId: "amap_amap-maps_weather"
|
||||
tags: ["amap"]
|
||||
parameters:
|
||||
- name: city
|
||||
in: query
|
||||
required: true
|
||||
description: "城市名称或者adcode"
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: "Successful tool execution"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ToolResponse'
|
||||
```
|
||||
|
||||
### 安全性
|
||||
|
||||
- 定义了 Bearer 身份验证但不对工具执行端点强制执行
|
||||
- 支持与各种 OpenAPI 兼容系统的灵活集成
|
||||
|
||||
## 相比 MCPO 的优势
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="直接集成" icon="plug">
|
||||
无需中间代理
|
||||
</Card>
|
||||
<Card title="实时更新" icon="refresh">
|
||||
OpenAPI 规范随着 MCP 服务器连接/断开自动更新
|
||||
</Card>
|
||||
<Card title="更好的性能" icon="bolt">
|
||||
直接工具执行,无代理开销
|
||||
</Card>
|
||||
<Card title="简化架构" icon="layer-group">
|
||||
减少一个需要管理的组件
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## 故障排除
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="OpenAPI 规范显示没有工具">
|
||||
确保 MCP 服务器已连接。检查 `/api/openapi/stats` 查看服务器状态。
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="工具执行失败">
|
||||
验证工具名称和参数是否与 OpenAPI 规范匹配。检查服务器日志以获取详细信息。
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="OpenWebUI 无法连接">
|
||||
确保 MCPHub 可从 OpenWebUI 访问,并且 OpenAPI URL 正确。
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="规范中缺少工具">
|
||||
检查您的 MCP 服务器配置中是否启用了工具。使用 `includeDisabled=true` 查看所有工具。
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
@@ -3,6 +3,7 @@ import { BrowserRouter as Router, Route, Routes, Navigate, useParams } from 'rea
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { ToastProvider } from './contexts/ToastContext';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { ServerProvider } from './contexts/ServerContext';
|
||||
import MainLayout from './layouts/MainLayout';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
@@ -26,6 +27,7 @@ function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<ServerProvider>
|
||||
<ToastProvider>
|
||||
<Router basename={basename}>
|
||||
<Routes>
|
||||
@@ -57,6 +59,7 @@ function App() {
|
||||
</Routes>
|
||||
</Router>
|
||||
</ToastProvider>
|
||||
</ServerProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Server } from '@/types'
|
||||
import { ChevronDown, ChevronRight, AlertCircle, Copy, Check } from 'lucide-react'
|
||||
import { StatusBadge } from '@/components/ui/Badge'
|
||||
import ToolCard from '@/components/ui/ToolCard'
|
||||
import PromptCard from '@/components/ui/PromptCard'
|
||||
import DeleteDialog from '@/components/ui/DeleteDialog'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
|
||||
@@ -107,7 +108,6 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
try {
|
||||
const { toggleTool } = await import('@/services/toolService')
|
||||
const result = await toggleTool(server.name, toolName, enabled)
|
||||
|
||||
if (result.success) {
|
||||
showToast(
|
||||
t(enabled ? 'tool.enableSuccess' : 'tool.disableSuccess', { name: toolName }),
|
||||
@@ -126,6 +126,28 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
}
|
||||
}
|
||||
|
||||
const handlePromptToggle = async (promptName: string, enabled: boolean) => {
|
||||
try {
|
||||
const { togglePrompt } = await import('@/services/promptService')
|
||||
const result = await togglePrompt(server.name, promptName, enabled)
|
||||
if (result.success) {
|
||||
showToast(
|
||||
t(enabled ? 'tool.enableSuccess' : 'tool.disableSuccess', { name: promptName }),
|
||||
'success'
|
||||
)
|
||||
// Trigger refresh to update the prompt's state in the UI
|
||||
if (onRefresh) {
|
||||
onRefresh()
|
||||
}
|
||||
} else {
|
||||
showToast(result.error || t('tool.toggleFailed'), 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling prompt:', error)
|
||||
showToast(t('tool.toggleFailed'), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`bg-white shadow rounded-lg p-6 mb-6 page-card transition-all duration-200 ${server.enabled === false ? 'opacity-60' : ''}`}>
|
||||
@@ -145,6 +167,15 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
<span>{server.tools?.length || 0} {t('server.tools')}</span>
|
||||
</div>
|
||||
|
||||
{/* Prompt count display */}
|
||||
<div className="flex items-center px-2 py-1 bg-purple-50 text-purple-700 rounded-full text-sm btn-primary">
|
||||
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M2 5a2 2 0 012-2h7a2 2 0 012 2v4a2 2 0 01-2 2H9l-3 3v-3H4a2 2 0 01-2-2V5z" />
|
||||
<path d="M15 7v2a4 4 0 01-4 4H9.828l-1.766 1.767c.28.149.599.233.938.233h2l3 3v-3h2a2 2 0 002-2V9a2 2 0 00-2-2h-1z" />
|
||||
</svg>
|
||||
<span>{server.prompts?.length || 0} {t('server.prompts')}</span>
|
||||
</div>
|
||||
|
||||
{server.error && (
|
||||
<div className="relative">
|
||||
<div
|
||||
@@ -236,15 +267,35 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && server.tools && (
|
||||
<div className="mt-6">
|
||||
<h6 className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}>{t('server.tools')}</h6>
|
||||
<div className="space-y-4">
|
||||
{server.tools.map((tool, index) => (
|
||||
<ToolCard key={index} server={server.name} tool={tool} onToggle={handleToolToggle} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<>
|
||||
{server.tools && (
|
||||
<div className="mt-6">
|
||||
<h6 className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}>{t('server.tools')}</h6>
|
||||
<div className="space-y-4">
|
||||
{server.tools.map((tool, index) => (
|
||||
<ToolCard key={index} server={server.name} tool={tool} onToggle={handleToolToggle} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{server.prompts && (
|
||||
<div className="mt-6">
|
||||
<h6 className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}>{t('server.prompts')}</h6>
|
||||
<div className="space-y-4">
|
||||
{server.prompts.map((prompt, index) => (
|
||||
<PromptCard
|
||||
key={index}
|
||||
server={server.name}
|
||||
prompt={prompt}
|
||||
onToggle={handlePromptToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
300
frontend/src/components/ui/PromptCard.tsx
Normal file
300
frontend/src/components/ui/PromptCard.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Prompt } from '@/types'
|
||||
import { ChevronDown, ChevronRight, Play, Loader, Edit, Check } from '@/components/icons/LucideIcons'
|
||||
import { Switch } from './ToggleGroup'
|
||||
import { getPrompt, PromptCallResult } from '@/services/promptService'
|
||||
import DynamicForm from './DynamicForm'
|
||||
import PromptResult from './PromptResult'
|
||||
|
||||
interface PromptCardProps {
|
||||
server: string
|
||||
prompt: Prompt
|
||||
onToggle?: (promptName: string, enabled: boolean) => void
|
||||
onDescriptionUpdate?: (promptName: string, description: string) => void
|
||||
}
|
||||
|
||||
const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [showRunForm, setShowRunForm] = useState(false)
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [result, setResult] = useState<PromptCallResult | null>(null)
|
||||
const [isEditingDescription, setIsEditingDescription] = useState(false)
|
||||
const [customDescription, setCustomDescription] = useState(prompt.description || '')
|
||||
const descriptionInputRef = useRef<HTMLInputElement>(null)
|
||||
const descriptionTextRef = useRef<HTMLSpanElement>(null)
|
||||
const [textWidth, setTextWidth] = useState<number>(0)
|
||||
|
||||
// Focus the input when editing mode is activated
|
||||
useEffect(() => {
|
||||
if (isEditingDescription && descriptionInputRef.current) {
|
||||
descriptionInputRef.current.focus()
|
||||
// Set input width to match text width
|
||||
if (textWidth > 0) {
|
||||
descriptionInputRef.current.style.width = `${textWidth + 20}px` // Add some padding
|
||||
}
|
||||
}
|
||||
}, [isEditingDescription, textWidth])
|
||||
|
||||
// Measure text width when not editing
|
||||
useEffect(() => {
|
||||
if (!isEditingDescription && descriptionTextRef.current) {
|
||||
setTextWidth(descriptionTextRef.current.offsetWidth)
|
||||
}
|
||||
}, [isEditingDescription, customDescription])
|
||||
|
||||
// Generate a unique key for localStorage based on prompt name and server
|
||||
const getStorageKey = useCallback(() => {
|
||||
return `mcphub_prompt_form_${server ? `${server}_` : ''}${prompt.name}`
|
||||
}, [prompt.name, server])
|
||||
|
||||
// Clear form data from localStorage
|
||||
const clearStoredFormData = useCallback(() => {
|
||||
localStorage.removeItem(getStorageKey())
|
||||
}, [getStorageKey])
|
||||
|
||||
const handleToggle = (enabled: boolean) => {
|
||||
if (onToggle) {
|
||||
onToggle(prompt.name, enabled)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDescriptionEdit = () => {
|
||||
setIsEditingDescription(true)
|
||||
}
|
||||
|
||||
const handleDescriptionSave = async () => {
|
||||
// For now, we'll just update the local state
|
||||
// In a real implementation, you would call an API to update the description
|
||||
setIsEditingDescription(false)
|
||||
if (onDescriptionUpdate) {
|
||||
onDescriptionUpdate(prompt.name, customDescription)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDescriptionChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setCustomDescription(e.target.value)
|
||||
}
|
||||
|
||||
const handleDescriptionKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleDescriptionSave()
|
||||
} else if (e.key === 'Escape') {
|
||||
setCustomDescription(prompt.description || '')
|
||||
setIsEditingDescription(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGetPrompt = async (arguments_: Record<string, any>) => {
|
||||
setIsRunning(true)
|
||||
try {
|
||||
const result = await getPrompt({ promptName: prompt.name, arguments: arguments_ }, server)
|
||||
console.log('GetPrompt result:', result)
|
||||
setResult({
|
||||
success: result.success,
|
||||
data: result.data,
|
||||
error: result.error
|
||||
})
|
||||
// Clear form data on successful submission
|
||||
// clearStoredFormData()
|
||||
} catch (error) {
|
||||
setResult({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
})
|
||||
} finally {
|
||||
setIsRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelRun = () => {
|
||||
setShowRunForm(false)
|
||||
// Clear form data when cancelled
|
||||
clearStoredFormData()
|
||||
setResult(null)
|
||||
}
|
||||
|
||||
const handleCloseResult = () => {
|
||||
setResult(null)
|
||||
}
|
||||
|
||||
// Convert prompt arguments to ToolInputSchema format for DynamicForm
|
||||
const convertToSchema = () => {
|
||||
if (!prompt.arguments || prompt.arguments.length === 0) {
|
||||
return { type: 'object', properties: {}, required: [] }
|
||||
}
|
||||
|
||||
const properties: Record<string, any> = {}
|
||||
const required: string[] = []
|
||||
|
||||
prompt.arguments.forEach(arg => {
|
||||
properties[arg.name] = {
|
||||
type: 'string', // Default to string for prompts
|
||||
description: arg.description || ''
|
||||
}
|
||||
|
||||
if (arg.required) {
|
||||
required.push(arg.name)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
type: 'object',
|
||||
properties,
|
||||
required
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 shadow rounded-lg p-4 mb-4">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
{prompt.name.replace(server + '-', '')}
|
||||
{prompt.title && (
|
||||
<span className="ml-2 text-sm font-normal text-gray-600">
|
||||
{prompt.title}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-2 text-sm font-normal text-gray-500 inline-flex items-center">
|
||||
{isEditingDescription ? (
|
||||
<>
|
||||
<input
|
||||
ref={descriptionInputRef}
|
||||
type="text"
|
||||
className="px-2 py-1 border border-blue-300 rounded bg-white text-sm focus:outline-none form-input"
|
||||
value={customDescription}
|
||||
onChange={handleDescriptionChange}
|
||||
onKeyDown={handleDescriptionKeyDown}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
minWidth: '100px',
|
||||
width: textWidth > 0 ? `${textWidth + 20}px` : 'auto'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="ml-2 p-1 text-green-600 hover:text-green-800 cursor-pointer transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDescriptionSave()
|
||||
}}
|
||||
>
|
||||
<Check size={16} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span ref={descriptionTextRef}>{customDescription || t('tool.noDescription')}</span>
|
||||
<button
|
||||
className="ml-2 p-1 text-gray-500 hover:text-blue-600 cursor-pointer transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDescriptionEdit()
|
||||
}}
|
||||
>
|
||||
<Edit size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className="flex items-center space-x-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{prompt.enabled !== undefined && (
|
||||
<Switch
|
||||
checked={prompt.enabled}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsExpanded(true) // Ensure card is expanded when showing run form
|
||||
setShowRunForm(true)
|
||||
}}
|
||||
className="flex items-center space-x-1 px-3 py-1 text-sm text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-md transition-colors btn-primary"
|
||||
disabled={isRunning || !prompt.enabled}
|
||||
>
|
||||
{isRunning ? (
|
||||
<Loader size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Play size={14} />
|
||||
)}
|
||||
<span>{isRunning ? t('tool.running') : t('tool.run')}</span>
|
||||
</button>
|
||||
<button className="text-gray-400 hover:text-gray-600">
|
||||
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-4 space-y-4">
|
||||
{/* Run Form */}
|
||||
{showRunForm && (
|
||||
<div className="border border-gray-300 rounded-lg p-4">
|
||||
<DynamicForm
|
||||
schema={convertToSchema()}
|
||||
onSubmit={handleGetPrompt}
|
||||
onCancel={handleCancelRun}
|
||||
loading={isRunning}
|
||||
storageKey={getStorageKey()}
|
||||
title={t('prompt.runPromptWithName', { name: prompt.name.replace(server + '-', '') })}
|
||||
/>
|
||||
{/* Prompt Result */}
|
||||
{result && (
|
||||
<div className="mt-4">
|
||||
<PromptResult result={result} onClose={handleCloseResult} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Arguments Display (when not showing form) */}
|
||||
{!showRunForm && prompt.arguments && prompt.arguments.length > 0 && (
|
||||
<div className="bg-gray-50 rounded p-3 border border-gray-300">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-2">{t('tool.parameters')}</h4>
|
||||
<div className="space-y-2">
|
||||
{prompt.arguments.map((arg, index) => (
|
||||
<div key={index} className="flex items-start">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium text-gray-700">{arg.name}</span>
|
||||
{arg.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</div>
|
||||
{arg.description && (
|
||||
<p className="text-sm text-gray-600 mt-1">{arg.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 ml-2">
|
||||
{arg.title || ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result Display (when not showing form) */}
|
||||
{!showRunForm && result && (
|
||||
<div className="mt-4">
|
||||
<PromptResult result={result} onClose={handleCloseResult} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PromptCard
|
||||
158
frontend/src/components/ui/PromptResult.tsx
Normal file
158
frontend/src/components/ui/PromptResult.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CheckCircle, XCircle, AlertCircle } from '@/components/icons/LucideIcons';
|
||||
|
||||
interface PromptResultProps {
|
||||
result: {
|
||||
success: boolean;
|
||||
data?: any;
|
||||
error?: string;
|
||||
message?: string;
|
||||
};
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const PromptResult: React.FC<PromptResultProps> = ({ result, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const renderContent = (content: any): React.ReactNode => {
|
||||
if (typeof content === 'string') {
|
||||
return (
|
||||
<div className="bg-gray-50 rounded-md p-3">
|
||||
<pre className="whitespace-pre-wrap text-sm text-gray-800 font-mono">{content}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof content === 'object' && content !== null) {
|
||||
// Handle the specific prompt data structure
|
||||
if (content.description || content.messages) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{content.description && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-2">{t('prompt.description')}</h4>
|
||||
<div className="bg-gray-50 rounded-md p-3">
|
||||
<p className="text-sm text-gray-800">{content.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{content.messages && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-2">{t('prompt.messages')}</h4>
|
||||
<div className="space-y-3">
|
||||
{content.messages.map((message: any, index: number) => (
|
||||
<div key={index} className="bg-gray-50 rounded-md p-3">
|
||||
<div className="flex items-center mb-2">
|
||||
<span className="inline-block w-16 text-xs font-medium text-gray-500">
|
||||
{message.role}:
|
||||
</span>
|
||||
</div>
|
||||
{typeof message.content === 'string' ? (
|
||||
<pre className="whitespace-pre-wrap text-sm text-gray-800 font-mono">
|
||||
{message.content}
|
||||
</pre>
|
||||
) : typeof message.content === 'object' && message.content.type === 'text' ? (
|
||||
<pre className="whitespace-pre-wrap text-sm text-gray-800 font-mono">
|
||||
{message.content.text}
|
||||
</pre>
|
||||
) : (
|
||||
<pre className="text-sm text-gray-800 overflow-auto">
|
||||
{JSON.stringify(message.content, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// For other structured content, try to parse as JSON
|
||||
try {
|
||||
const parsed = typeof content === 'string' ? JSON.parse(content) : content;
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 rounded-md p-3">
|
||||
<div className="text-xs text-gray-500 mb-2">{t('prompt.jsonResponse')}</div>
|
||||
<pre className="text-sm text-gray-800 overflow-auto">{JSON.stringify(parsed, null, 2)}</pre>
|
||||
</div>
|
||||
);
|
||||
} catch {
|
||||
// If not valid JSON, show as string
|
||||
return (
|
||||
<div className="bg-gray-50 rounded-md p-3">
|
||||
<pre className="whitespace-pre-wrap text-sm text-gray-800 font-mono">{String(content)}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 rounded-md p-3">
|
||||
<pre className="whitespace-pre-wrap text-sm text-gray-800 font-mono">{String(content)}</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-gray-300 rounded-lg bg-white shadow-sm">
|
||||
<div className="border-b border-gray-300 px-4 py-3 bg-gray-50 rounded-t-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
{result.success ? (
|
||||
<CheckCircle size={20} className="text-status-green" />
|
||||
) : (
|
||||
<XCircle size={20} className="text-status-red" />
|
||||
)}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900">
|
||||
{t('prompt.execution')} {result.success ? t('prompt.successful') : t('prompt.failed')}
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 text-sm"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
{result.success ? (
|
||||
<div>
|
||||
{result.data ? (
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 mb-3">{t('prompt.result')}</div>
|
||||
{renderContent(result.data)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500 italic">
|
||||
{t('prompt.noContent')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 mb-3">
|
||||
<AlertCircle size={16} className="text-red-500" />
|
||||
<span className="text-sm font-medium text-red-700">{t('prompt.error')}</span>
|
||||
</div>
|
||||
<div className="bg-red-50 border border-red-300 rounded-md p-3">
|
||||
<pre className="text-sm text-red-800 whitespace-pre-wrap">
|
||||
{result.error || result.message || t('prompt.unknownError')}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PromptResult;
|
||||
@@ -14,6 +14,15 @@ interface ToolCardProps {
|
||||
onDescriptionUpdate?: (toolName: string, description: string) => void
|
||||
}
|
||||
|
||||
// Helper to check for "empty" values
|
||||
function isEmptyValue(value: any): boolean {
|
||||
if (value == null) return true; // null or undefined
|
||||
if (typeof value === 'string') return value.trim() === '';
|
||||
if (Array.isArray(value)) return value.length === 0;
|
||||
if (typeof value === 'object') return Object.keys(value).length === 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
@@ -100,6 +109,8 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
|
||||
const handleRunTool = async (arguments_: Record<string, any>) => {
|
||||
setIsRunning(true)
|
||||
try {
|
||||
// filter empty values
|
||||
arguments_ = Object.fromEntries(Object.entries(arguments_).filter(([_, v]) => !isEmptyValue(v)))
|
||||
const result = await callTool({
|
||||
toolName: tool.name,
|
||||
arguments: arguments_,
|
||||
|
||||
359
frontend/src/contexts/ServerContext.tsx
Normal file
359
frontend/src/contexts/ServerContext.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
import React, { createContext, useState, useEffect, useRef, useCallback, useContext } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Server, ApiResponse } from '@/types';
|
||||
import { apiGet, apiPost, apiDelete } from '../utils/fetchInterceptor';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
// Configuration options
|
||||
const CONFIG = {
|
||||
// Initialization phase configuration
|
||||
startup: {
|
||||
maxAttempts: 60, // Maximum number of attempts during initialization
|
||||
pollingInterval: 3000, // Polling interval during initialization (3 seconds)
|
||||
},
|
||||
// Normal operation phase configuration
|
||||
normal: {
|
||||
pollingInterval: 10000, // Polling interval during normal operation (10 seconds)
|
||||
},
|
||||
};
|
||||
|
||||
// Context type definition
|
||||
interface ServerContextType {
|
||||
servers: Server[];
|
||||
error: string | null;
|
||||
setError: (error: string | null) => void;
|
||||
isLoading: boolean;
|
||||
fetchAttempts: number;
|
||||
triggerRefresh: () => void;
|
||||
refreshIfNeeded: () => void; // Smart refresh with debounce
|
||||
handleServerAdd: () => void;
|
||||
handleServerEdit: (server: Server) => Promise<any>;
|
||||
handleServerRemove: (serverName: string) => Promise<boolean>;
|
||||
handleServerToggle: (server: Server, enabled: boolean) => Promise<boolean>;
|
||||
}
|
||||
|
||||
// Create Context
|
||||
const ServerContext = createContext<ServerContextType | undefined>(undefined);
|
||||
|
||||
// Provider component
|
||||
export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { t } = useTranslation();
|
||||
const { auth } = useAuth();
|
||||
const [servers, setServers] = useState<Server[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
const [fetchAttempts, setFetchAttempts] = useState(0);
|
||||
|
||||
// Timer reference for polling
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
// Track current attempt count to avoid dependency cycles
|
||||
const attemptsRef = useRef<number>(0);
|
||||
// Track last fetch time to implement smart refresh
|
||||
const lastFetchTimeRef = useRef<number>(0);
|
||||
// Minimum interval between manual refreshes (5 seconds in dev, 3 seconds in prod)
|
||||
const MIN_REFRESH_INTERVAL = process.env.NODE_ENV === 'development' ? 5000 : 3000;
|
||||
|
||||
// Clear the timer
|
||||
const clearTimer = () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Start normal polling
|
||||
const startNormalPolling = useCallback((options?: { immediate?: boolean }) => {
|
||||
const immediate = options?.immediate ?? true;
|
||||
// Ensure no other timers are running
|
||||
clearTimer();
|
||||
|
||||
const fetchServers = async () => {
|
||||
try {
|
||||
console.log('[ServerContext] Fetching servers from API...');
|
||||
const data = await apiGet('/servers');
|
||||
|
||||
// Update last fetch time
|
||||
lastFetchTimeRef.current = Date.now();
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data);
|
||||
} else if (data && Array.isArray(data)) {
|
||||
setServers(data);
|
||||
} else {
|
||||
console.error('Invalid server data format:', data);
|
||||
setServers([]);
|
||||
}
|
||||
|
||||
// Reset error state
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error fetching servers during normal polling:', err);
|
||||
|
||||
// Use friendly error message
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'));
|
||||
} else if (
|
||||
err instanceof TypeError &&
|
||||
(err.message.includes('NetworkError') || err.message.includes('Failed to fetch'))
|
||||
) {
|
||||
setError(t('errors.serverConnection'));
|
||||
} else {
|
||||
setError(t('errors.serverFetch'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Execute immediately unless explicitly skipped
|
||||
if (immediate) {
|
||||
fetchServers();
|
||||
}
|
||||
|
||||
// Set up regular polling
|
||||
intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval);
|
||||
}, [t]);
|
||||
|
||||
// Watch for authentication status changes
|
||||
useEffect(() => {
|
||||
if (auth.isAuthenticated) {
|
||||
console.log('[ServerContext] User authenticated, triggering refresh');
|
||||
// When user logs in, trigger a refresh to load servers
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
} else {
|
||||
console.log('[ServerContext] User not authenticated, clearing data and stopping polling');
|
||||
// When user logs out, clear data and stop polling
|
||||
clearTimer();
|
||||
setServers([]);
|
||||
setIsInitialLoading(false);
|
||||
setError(null);
|
||||
}
|
||||
}, [auth.isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
// If not authenticated, don't poll
|
||||
if (!auth.isAuthenticated) {
|
||||
console.log('[ServerContext] User not authenticated, skipping polling setup');
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset attempt count
|
||||
if (refreshKey > 0) {
|
||||
attemptsRef.current = 0;
|
||||
setFetchAttempts(0);
|
||||
}
|
||||
|
||||
// Initialization phase request function
|
||||
const fetchInitialData = async () => {
|
||||
try {
|
||||
console.log('[ServerContext] Initial fetch - attempt', attemptsRef.current + 1);
|
||||
const data = await apiGet('/servers');
|
||||
|
||||
// Update last fetch time
|
||||
lastFetchTimeRef.current = Date.now();
|
||||
|
||||
// Handle API response wrapper object, extract data field
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data);
|
||||
setIsInitialLoading(false);
|
||||
// Initialization successful, start normal polling (skip immediate to avoid duplicate fetch)
|
||||
startNormalPolling({ immediate: false });
|
||||
return true;
|
||||
} else if (data && Array.isArray(data)) {
|
||||
// Compatibility handling, if API directly returns array
|
||||
setServers(data);
|
||||
setIsInitialLoading(false);
|
||||
// Initialization successful, start normal polling (skip immediate to avoid duplicate fetch)
|
||||
startNormalPolling({ immediate: false });
|
||||
return true;
|
||||
} else {
|
||||
// If data format is not as expected, set to empty array
|
||||
console.error('Invalid server data format:', data);
|
||||
setServers([]);
|
||||
setIsInitialLoading(false);
|
||||
// Initialization successful but data is empty, start normal polling (skip immediate)
|
||||
startNormalPolling({ immediate: false });
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
// Increment attempt count, use ref to avoid triggering effect rerun
|
||||
attemptsRef.current += 1;
|
||||
console.error(`Initial loading attempt ${attemptsRef.current} failed:`, err);
|
||||
|
||||
// Update state for display
|
||||
setFetchAttempts(attemptsRef.current);
|
||||
|
||||
// Set appropriate error message
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'));
|
||||
} else {
|
||||
setError(t('errors.initialStartup'));
|
||||
}
|
||||
|
||||
// If maximum attempt count is exceeded, give up initialization and switch to normal polling
|
||||
if (attemptsRef.current >= CONFIG.startup.maxAttempts) {
|
||||
console.log('Maximum startup attempts reached, switching to normal polling');
|
||||
setIsInitialLoading(false);
|
||||
// Clear initialization polling
|
||||
clearTimer();
|
||||
// Switch to normal polling mode
|
||||
startNormalPolling();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// On component mount, set appropriate polling based on current state
|
||||
if (isInitialLoading) {
|
||||
// Ensure no other timers are running
|
||||
clearTimer();
|
||||
|
||||
// Execute initial request immediately
|
||||
fetchInitialData();
|
||||
|
||||
// Set polling interval for initialization phase
|
||||
intervalRef.current = setInterval(fetchInitialData, CONFIG.startup.pollingInterval);
|
||||
console.log(`Started initial polling with interval: ${CONFIG.startup.pollingInterval}ms`);
|
||||
} else {
|
||||
// Initialization completed, start normal polling
|
||||
startNormalPolling();
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
clearTimer();
|
||||
};
|
||||
}, [refreshKey, t, isInitialLoading, startNormalPolling]);
|
||||
|
||||
// Manually trigger refresh (always refreshes)
|
||||
const triggerRefresh = useCallback(() => {
|
||||
// Clear current timer
|
||||
clearTimer();
|
||||
|
||||
// If in initialization phase, reset initialization state
|
||||
if (isInitialLoading) {
|
||||
setIsInitialLoading(true);
|
||||
attemptsRef.current = 0;
|
||||
setFetchAttempts(0);
|
||||
}
|
||||
|
||||
// Change in refreshKey will trigger useEffect to run again
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
}, [isInitialLoading]);
|
||||
|
||||
// Smart refresh with debounce (only refresh if enough time has passed)
|
||||
const refreshIfNeeded = useCallback(() => {
|
||||
const now = Date.now();
|
||||
const timeSinceLastFetch = now - lastFetchTimeRef.current;
|
||||
|
||||
// Log who is calling this
|
||||
console.log('[ServerContext] refreshIfNeeded called, time since last fetch:', timeSinceLastFetch, 'ms');
|
||||
|
||||
// Only refresh if enough time has passed since last fetch
|
||||
if (timeSinceLastFetch >= MIN_REFRESH_INTERVAL) {
|
||||
console.log('[ServerContext] Triggering refresh (exceeded MIN_REFRESH_INTERVAL:', MIN_REFRESH_INTERVAL, 'ms)');
|
||||
triggerRefresh();
|
||||
} else {
|
||||
console.log('[ServerContext] Skipping refresh (MIN_REFRESH_INTERVAL:', MIN_REFRESH_INTERVAL, 'ms, time since last:', timeSinceLastFetch, 'ms)');
|
||||
}
|
||||
}, [triggerRefresh]);
|
||||
|
||||
// Server related operations
|
||||
const handleServerAdd = useCallback(() => {
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
}, []);
|
||||
|
||||
const handleServerEdit = useCallback(async (server: Server) => {
|
||||
try {
|
||||
// Fetch settings to get the full server config before editing
|
||||
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> =
|
||||
await apiGet('/settings');
|
||||
|
||||
if (
|
||||
settingsData &&
|
||||
settingsData.success &&
|
||||
settingsData.data &&
|
||||
settingsData.data.mcpServers &&
|
||||
settingsData.data.mcpServers[server.name]
|
||||
) {
|
||||
const serverConfig = settingsData.data.mcpServers[server.name];
|
||||
return {
|
||||
name: server.name,
|
||||
status: server.status,
|
||||
tools: server.tools || [],
|
||||
config: serverConfig,
|
||||
};
|
||||
} else {
|
||||
console.error('Failed to get server config from settings:', settingsData);
|
||||
setError(t('server.invalidConfig', { serverName: server.name }));
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching server settings:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return null;
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleServerRemove = useCallback(async (serverName: string) => {
|
||||
try {
|
||||
const result = await apiDelete(`/servers/${serverName}`);
|
||||
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('server.deleteError', { serverName }));
|
||||
return false;
|
||||
}
|
||||
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(t('errors.general') + ': ' + (err instanceof Error ? err.message : String(err)));
|
||||
return false;
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleServerToggle = useCallback(async (server: Server, enabled: boolean) => {
|
||||
try {
|
||||
const result = await apiPost(`/servers/${server.name}/toggle`, { enabled });
|
||||
|
||||
if (!result || !result.success) {
|
||||
console.error('Failed to toggle server:', result);
|
||||
setError(result?.message || t('server.toggleError', { serverName: server.name }));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update the UI immediately to reflect the change
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error toggling server:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const value: ServerContextType = {
|
||||
servers,
|
||||
error,
|
||||
setError,
|
||||
isLoading: isInitialLoading,
|
||||
fetchAttempts,
|
||||
triggerRefresh,
|
||||
refreshIfNeeded,
|
||||
handleServerAdd,
|
||||
handleServerEdit,
|
||||
handleServerRemove,
|
||||
handleServerToggle,
|
||||
};
|
||||
|
||||
return <ServerContext.Provider value={value}>{children}</ServerContext.Provider>;
|
||||
};
|
||||
|
||||
// Custom hook to use the Server context
|
||||
export const useServerContext = () => {
|
||||
const context = useContext(ServerContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useServerContext must be used within a ServerProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,272 +1,19 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Server, ApiResponse } from '@/types';
|
||||
import { apiGet, apiPost, apiDelete } from '../utils/fetchInterceptor';
|
||||
// This hook now delegates to the ServerContext to avoid duplicate requests
|
||||
// All components will share the same server data and polling mechanism
|
||||
import { useServerContext } from '@/contexts/ServerContext';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
// Configuration options
|
||||
const CONFIG = {
|
||||
// Initialization phase configuration
|
||||
startup: {
|
||||
maxAttempts: 60, // Maximum number of attempts during initialization
|
||||
pollingInterval: 3000, // Polling interval during initialization (3 seconds)
|
||||
},
|
||||
// Normal operation phase configuration
|
||||
normal: {
|
||||
pollingInterval: 10000, // Polling interval during normal operation (10 seconds)
|
||||
},
|
||||
};
|
||||
|
||||
export const useServerData = () => {
|
||||
const { t } = useTranslation();
|
||||
const [servers, setServers] = useState<Server[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
const [fetchAttempts, setFetchAttempts] = useState(0);
|
||||
|
||||
// Timer reference for polling
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
// Track current attempt count to avoid dependency cycles
|
||||
const attemptsRef = useRef<number>(0);
|
||||
|
||||
// Clear the timer
|
||||
const clearTimer = () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Start normal polling
|
||||
const startNormalPolling = useCallback(() => {
|
||||
// Ensure no other timers are running
|
||||
clearTimer();
|
||||
|
||||
const fetchServers = async () => {
|
||||
try {
|
||||
const data = await apiGet('/servers');
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data);
|
||||
} else if (data && Array.isArray(data)) {
|
||||
setServers(data);
|
||||
} else {
|
||||
console.error('Invalid server data format:', data);
|
||||
setServers([]);
|
||||
}
|
||||
|
||||
// Reset error state
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error fetching servers during normal polling:', err);
|
||||
|
||||
// Use friendly error message
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'));
|
||||
} else if (
|
||||
err instanceof TypeError &&
|
||||
(err.message.includes('NetworkError') || err.message.includes('Failed to fetch'))
|
||||
) {
|
||||
setError(t('errors.serverConnection'));
|
||||
} else {
|
||||
setError(t('errors.serverFetch'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Execute immediately
|
||||
fetchServers();
|
||||
|
||||
// Set up regular polling
|
||||
intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval);
|
||||
}, [t]);
|
||||
export const useServerData = (options?: { refreshOnMount?: boolean }) => {
|
||||
const context = useServerContext();
|
||||
const { refreshIfNeeded } = context;
|
||||
|
||||
// Optionally refresh on mount for pages that need fresh data
|
||||
useEffect(() => {
|
||||
// Reset attempt count
|
||||
if (refreshKey > 0) {
|
||||
attemptsRef.current = 0;
|
||||
setFetchAttempts(0);
|
||||
if (options?.refreshOnMount) {
|
||||
refreshIfNeeded();
|
||||
}
|
||||
}, [options?.refreshOnMount, refreshIfNeeded]);
|
||||
|
||||
// Initialization phase request function
|
||||
const fetchInitialData = async () => {
|
||||
try {
|
||||
const data = await apiGet('/servers');
|
||||
|
||||
// Handle API response wrapper object, extract data field
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data);
|
||||
setIsInitialLoading(false);
|
||||
// Initialization successful, start normal polling
|
||||
startNormalPolling();
|
||||
return true;
|
||||
} else if (data && Array.isArray(data)) {
|
||||
// Compatibility handling, if API directly returns array
|
||||
setServers(data);
|
||||
setIsInitialLoading(false);
|
||||
// Initialization successful, start normal polling
|
||||
startNormalPolling();
|
||||
return true;
|
||||
} else {
|
||||
// If data format is not as expected, set to empty array
|
||||
console.error('Invalid server data format:', data);
|
||||
setServers([]);
|
||||
setIsInitialLoading(false);
|
||||
// Initialization successful but data is empty, start normal polling
|
||||
startNormalPolling();
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
// Increment attempt count, use ref to avoid triggering effect rerun
|
||||
attemptsRef.current += 1;
|
||||
console.error(`Initial loading attempt ${attemptsRef.current} failed:`, err);
|
||||
|
||||
// Update state for display
|
||||
setFetchAttempts(attemptsRef.current);
|
||||
|
||||
// Set appropriate error message
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'));
|
||||
} else {
|
||||
setError(t('errors.initialStartup'));
|
||||
}
|
||||
|
||||
// If maximum attempt count is exceeded, give up initialization and switch to normal polling
|
||||
if (attemptsRef.current >= CONFIG.startup.maxAttempts) {
|
||||
console.log('Maximum startup attempts reached, switching to normal polling');
|
||||
setIsInitialLoading(false);
|
||||
// Clear initialization polling
|
||||
clearTimer();
|
||||
// Switch to normal polling mode
|
||||
startNormalPolling();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// On component mount, set appropriate polling based on current state
|
||||
if (isInitialLoading) {
|
||||
// Ensure no other timers are running
|
||||
clearTimer();
|
||||
|
||||
// Execute initial request immediately
|
||||
fetchInitialData();
|
||||
|
||||
// Set polling interval for initialization phase
|
||||
intervalRef.current = setInterval(fetchInitialData, CONFIG.startup.pollingInterval);
|
||||
console.log(`Started initial polling with interval: ${CONFIG.startup.pollingInterval}ms`);
|
||||
} else {
|
||||
// Initialization completed, start normal polling
|
||||
startNormalPolling();
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
clearTimer();
|
||||
};
|
||||
}, [refreshKey, t, isInitialLoading, startNormalPolling]);
|
||||
|
||||
// Manually trigger refresh
|
||||
const triggerRefresh = () => {
|
||||
// Clear current timer
|
||||
clearTimer();
|
||||
|
||||
// If in initialization phase, reset initialization state
|
||||
if (isInitialLoading) {
|
||||
setIsInitialLoading(true);
|
||||
attemptsRef.current = 0;
|
||||
setFetchAttempts(0);
|
||||
}
|
||||
|
||||
// Change in refreshKey will trigger useEffect to run again
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
};
|
||||
|
||||
// Server related operations
|
||||
const handleServerAdd = () => {
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
};
|
||||
|
||||
const handleServerEdit = async (server: Server) => {
|
||||
try {
|
||||
// Fetch settings to get the full server config before editing
|
||||
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> =
|
||||
await apiGet('/settings');
|
||||
|
||||
if (
|
||||
settingsData &&
|
||||
settingsData.success &&
|
||||
settingsData.data &&
|
||||
settingsData.data.mcpServers &&
|
||||
settingsData.data.mcpServers[server.name]
|
||||
) {
|
||||
const serverConfig = settingsData.data.mcpServers[server.name];
|
||||
return {
|
||||
name: server.name,
|
||||
status: server.status,
|
||||
tools: server.tools || [],
|
||||
config: serverConfig,
|
||||
};
|
||||
} else {
|
||||
console.error('Failed to get server config from settings:', settingsData);
|
||||
setError(t('server.invalidConfig', { serverName: server.name }));
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching server settings:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleServerRemove = async (serverName: string) => {
|
||||
try {
|
||||
const result = await apiDelete(`/servers/${serverName}`);
|
||||
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('server.deleteError', { serverName }));
|
||||
return false;
|
||||
}
|
||||
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(t('errors.general') + ': ' + (err instanceof Error ? err.message : String(err)));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleServerToggle = async (server: Server, enabled: boolean) => {
|
||||
try {
|
||||
const result = await apiPost(`/servers/${server.name}/toggle`, { enabled });
|
||||
|
||||
if (!result || !result.success) {
|
||||
console.error('Failed to toggle server:', result);
|
||||
setError(result?.message || t('server.toggleError', { serverName: server.name }));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update the UI immediately to reflect the change
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error toggling server:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
servers,
|
||||
error,
|
||||
setError,
|
||||
isLoading: isInitialLoading,
|
||||
fetchAttempts,
|
||||
triggerRefresh,
|
||||
handleServerAdd,
|
||||
handleServerEdit,
|
||||
handleServerRemove,
|
||||
handleServerToggle,
|
||||
};
|
||||
return context;
|
||||
};
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ export const useSettingsData = () => {
|
||||
|
||||
const [mcpRouterConfig, setMCPRouterConfig] = useState<MCPRouterConfig>({
|
||||
apiKey: '',
|
||||
referer: 'https://mcphub.app',
|
||||
referer: 'https://www.mcphubx.com',
|
||||
title: 'MCPHub',
|
||||
baseUrl: 'https://api.mcprouter.to/v1',
|
||||
});
|
||||
@@ -130,7 +130,7 @@ export const useSettingsData = () => {
|
||||
if (data.success && data.data?.systemConfig?.mcpRouter) {
|
||||
setMCPRouterConfig({
|
||||
apiKey: data.data.systemConfig.mcpRouter.apiKey || '',
|
||||
referer: data.data.systemConfig.mcpRouter.referer || 'https://mcphub.app',
|
||||
referer: data.data.systemConfig.mcpRouter.referer || 'https://www.mcphubx.com',
|
||||
title: data.data.systemConfig.mcpRouter.title || 'MCPHub',
|
||||
baseUrl: data.data.systemConfig.mcpRouter.baseUrl || 'https://api.mcprouter.to/v1',
|
||||
});
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useServerData } from '@/hooks/useServerData';
|
||||
import { Server } from '@/types';
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { servers, error, setError, isLoading } = useServerData();
|
||||
const { servers, error, setError, isLoading } = useServerData({ refreshOnMount: true });
|
||||
|
||||
// Calculate server statistics
|
||||
const serverStats = {
|
||||
total: servers.length,
|
||||
online: servers.filter(server => server.status === 'connected').length,
|
||||
offline: servers.filter(server => server.status === 'disconnected').length,
|
||||
connecting: servers.filter(server => server.status === 'connecting').length
|
||||
online: servers.filter((server: Server) => server.status === 'connected').length,
|
||||
offline: servers.filter((server: Server) => server.status === 'disconnected').length,
|
||||
connecting: servers.filter((server: Server) => server.status === 'connecting').length
|
||||
};
|
||||
|
||||
// Map status to translation keys
|
||||
const statusTranslations = {
|
||||
const statusTranslations: Record<string, string> = {
|
||||
connected: 'status.online',
|
||||
disconnected: 'status.offline',
|
||||
connecting: 'status.connecting'
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -139,6 +140,9 @@ const DashboardPage: React.FC = () => {
|
||||
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('server.tools')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('server.prompts')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('server.enabled')}
|
||||
</th>
|
||||
@@ -163,6 +167,9 @@ const DashboardPage: React.FC = () => {
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{server.tools?.length || 0}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{server.prompts?.length || 0}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{server.enabled !== false ? (
|
||||
<span className="text-green-600">✓</span>
|
||||
|
||||
@@ -17,7 +17,7 @@ const GroupsPage: React.FC = () => {
|
||||
deleteGroup,
|
||||
triggerRefresh
|
||||
} = useGroupData();
|
||||
const { servers } = useServerData();
|
||||
const { servers } = useServerData({ refreshOnMount: true });
|
||||
|
||||
const [editingGroup, setEditingGroup] = useState<Group | null>(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
|
||||
@@ -68,39 +68,22 @@ const LoginPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="relative mx-auto grid min-h-screen w-full max-w-7xl grid-cols-1 items-center gap-8 px-6 py-16 md:grid-cols-2 lg:gap-16">
|
||||
{/* Left: brand + slogan */}
|
||||
<div className="order-2 space-y-6 md:order-1">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-indigo-500/20 bg-indigo-500/10 px-3 py-1 text-xs font-medium text-indigo-700 shadow-sm backdrop-blur dark:text-indigo-300">
|
||||
<span className="h-2 w-2 animate-pulse rounded-full bg-indigo-500" />
|
||||
{t('app.name')}
|
||||
<div className="relative mx-auto flex min-h-screen w-full max-w-md items-center justify-center px-6 py-16">
|
||||
<div className="w-full space-y-6">
|
||||
{/* Centered slogan */}
|
||||
<div className="flex justify-center w-full">
|
||||
<h1 className="text-3xl font-extrabold leading-tight tracking-tight text-gray-900 dark:text-white sm:text-4xl whitespace-nowrap">
|
||||
<span className="bg-gradient-to-r from-indigo-400 via-cyan-400 to-emerald-400 bg-clip-text text-transparent">
|
||||
{t('auth.slogan')}
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
<h1 className="text-4xl font-extrabold leading-tight tracking-tight text-gray-900 dark:text-white sm:text-5xl">
|
||||
<span className="bg-gradient-to-r from-indigo-400 via-cyan-400 to-emerald-400 bg-clip-text text-transparent">
|
||||
{t('auth.slogan')}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="max-w-xl text-base text-gray-600 dark:text-gray-300">
|
||||
{t('auth.subtitle')}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-3 pt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="rounded-md border border-white/10 bg-white/5 px-2 py-1 backdrop-blur">MCP</span>
|
||||
<span className="rounded-md border border-white/10 bg-white/5 px-2 py-1 backdrop-blur">Group</span>
|
||||
<span className="rounded-md border border-white/10 bg-white/5 px-2 py-1 backdrop-blur">Market</span>
|
||||
<span className="rounded-md border border-white/10 bg-white/5 px-2 py-1 backdrop-blur">Logging</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: login card */}
|
||||
<div className="order-1 md:order-2">
|
||||
<div className="login-card relative w-full max-w-md rounded-2xl border border-white/10 bg-white/60 p-8 shadow-xl backdrop-blur-md transition dark:border-white/10 dark:bg-gray-900/60">
|
||||
{/* Centered login card */}
|
||||
<div className="login-card relative w-full rounded-2xl border border-white/10 bg-white/60 p-8 shadow-xl backdrop-blur-md transition dark:border-white/10 dark:bg-gray-900/60">
|
||||
<div className="absolute -top-24 right-12 h-40 w-40 -translate-y-6 rounded-full bg-indigo-500/30 blur-3xl" />
|
||||
<div className="absolute -bottom-24 -left-12 h-40 w-40 translate-y-6 rounded-full bg-cyan-500/20 blur-3xl" />
|
||||
<h2 className="text-center text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{t('auth.loginTitle')}
|
||||
</h2>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<form className="mt-4 space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="username" className="sr-only">
|
||||
|
||||
@@ -21,7 +21,7 @@ const ServersPage: React.FC = () => {
|
||||
handleServerRemove,
|
||||
handleServerToggle,
|
||||
triggerRefresh
|
||||
} = useServerData();
|
||||
} = useServerData({ refreshOnMount: true });
|
||||
const [editingServer, setEditingServer] = useState<Server | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [showDxtUpload, setShowDxtUpload] = useState(false);
|
||||
|
||||
@@ -43,7 +43,7 @@ const SettingsPage: React.FC = () => {
|
||||
baseUrl: string;
|
||||
}>({
|
||||
apiKey: '',
|
||||
referer: 'https://mcphub.app',
|
||||
referer: 'https://www.mcphubx.com',
|
||||
title: 'MCPHub',
|
||||
baseUrl: 'https://api.mcprouter.to/v1',
|
||||
});
|
||||
@@ -88,7 +88,7 @@ const SettingsPage: React.FC = () => {
|
||||
if (mcpRouterConfig) {
|
||||
setTempMCPRouterConfig({
|
||||
apiKey: mcpRouterConfig.apiKey || '',
|
||||
referer: mcpRouterConfig.referer || 'https://mcphub.app',
|
||||
referer: mcpRouterConfig.referer || 'https://www.mcphubx.com',
|
||||
title: mcpRouterConfig.title || 'MCPHub',
|
||||
baseUrl: mcpRouterConfig.baseUrl || 'https://api.mcprouter.to/v1',
|
||||
});
|
||||
@@ -399,54 +399,6 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.mcpRouterReferer')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.mcpRouterRefererDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempMCPRouterConfig.referer}
|
||||
onChange={(e) => handleMCPRouterConfigChange('referer', e.target.value)}
|
||||
placeholder={t('settings.mcpRouterRefererPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveMCPRouterConfig('referer')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.mcpRouterTitle')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.mcpRouterTitleDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempMCPRouterConfig.title}
|
||||
onChange={(e) => handleMCPRouterConfigChange('title', e.target.value)}
|
||||
placeholder={t('settings.mcpRouterTitlePlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={() => saveMCPRouterConfig('title')}
|
||||
disabled={loading}
|
||||
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.mcpRouterBaseUrl')}</h3>
|
||||
|
||||
144
frontend/src/services/promptService.ts
Normal file
144
frontend/src/services/promptService.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { apiPost, apiPut } from '../utils/fetchInterceptor';
|
||||
|
||||
export interface PromptCallRequest {
|
||||
promptName: string;
|
||||
arguments?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface PromptCallResult {
|
||||
success: boolean;
|
||||
data?: any;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// GetPrompt result types
|
||||
export interface GetPromptResult {
|
||||
success: boolean;
|
||||
data?: any;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a MCP prompt via the call_prompt API
|
||||
*/
|
||||
export const callPrompt = async (
|
||||
request: PromptCallRequest,
|
||||
server?: string,
|
||||
): Promise<PromptCallResult> => {
|
||||
try {
|
||||
// Construct the URL with optional server parameter
|
||||
const url = server ? `/prompts/call/${server}` : '/prompts/call';
|
||||
const response = await apiPost<any>(url, {
|
||||
promptName: request.promptName,
|
||||
arguments: request.arguments,
|
||||
});
|
||||
|
||||
if (!response.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: response.message || 'Prompt call failed',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error calling prompt:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const getPrompt = async (
|
||||
request: PromptCallRequest,
|
||||
server?: string,
|
||||
): Promise<GetPromptResult> => {
|
||||
try {
|
||||
const response = await apiPost(
|
||||
`/mcp/${server}/prompts/${encodeURIComponent(request.promptName)}`,
|
||||
{
|
||||
name: request.promptName,
|
||||
arguments: request.arguments,
|
||||
},
|
||||
);
|
||||
|
||||
// apiPost already returns parsed data, not a Response object
|
||||
if (!response.success) {
|
||||
throw new Error(`Failed to get prompt: ${response.message || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.data,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting prompt:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle a prompt's enabled state for a specific server
|
||||
*/
|
||||
export const togglePrompt = async (
|
||||
serverName: string,
|
||||
promptName: string,
|
||||
enabled: boolean,
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
const response = await apiPost<any>(`/servers/${serverName}/prompts/${promptName}/toggle`, {
|
||||
enabled,
|
||||
});
|
||||
|
||||
return {
|
||||
success: response.success,
|
||||
error: response.success ? undefined : response.message,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error toggling prompt:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update a prompt's description for a specific server
|
||||
*/
|
||||
export const updatePromptDescription = async (
|
||||
serverName: string,
|
||||
promptName: string,
|
||||
description: string,
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
const response = await apiPut<any>(
|
||||
`/servers/${serverName}/prompts/${promptName}/description`,
|
||||
{ description },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('mcphub_token')}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
success: response.success,
|
||||
error: response.success ? undefined : response.message,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error updating prompt description:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -91,6 +91,20 @@ export interface Tool {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
// Prompt types
|
||||
export interface Prompt {
|
||||
name: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
arguments?: Array<{
|
||||
name: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
}>;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
// Server config types
|
||||
export interface ServerConfig {
|
||||
type?: 'stdio' | 'sse' | 'streamable-http' | 'openapi';
|
||||
@@ -101,6 +115,7 @@ export interface ServerConfig {
|
||||
headers?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
tools?: Record<string, { enabled: boolean; description?: string }>; // Tool-specific configurations with enable/disable state and custom descriptions
|
||||
prompts?: Record<string, { enabled: boolean; description?: string }>; // Prompt-specific configurations with enable/disable state and custom descriptions
|
||||
options?: {
|
||||
timeout?: number; // Request timeout in milliseconds
|
||||
resetTimeoutOnProgress?: boolean; // Reset timeout on progress notifications
|
||||
@@ -153,6 +168,7 @@ export interface Server {
|
||||
status: ServerStatus;
|
||||
error?: string;
|
||||
tools?: Tool[];
|
||||
prompts?: Prompt[];
|
||||
config?: ServerConfig;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
"deleteWarning": "Deleting server '{{name}}' will remove it and all its data. This action cannot be undone.",
|
||||
"status": "Status",
|
||||
"tools": "Tools",
|
||||
"prompts": "Prompts",
|
||||
"name": "Server Name",
|
||||
"url": "Server URL",
|
||||
"apiKey": "API Key",
|
||||
@@ -414,6 +415,23 @@
|
||||
"addItem": "Add {{key}} item",
|
||||
"enterKey": "Enter {{key}}"
|
||||
},
|
||||
"prompt": {
|
||||
"run": "Get",
|
||||
"running": "Getting...",
|
||||
"result": "Prompt Result",
|
||||
"error": "Prompt Error",
|
||||
"execution": "Prompt Execution",
|
||||
"successful": "Successful",
|
||||
"failed": "Failed",
|
||||
"errorDetails": "Error Details:",
|
||||
"noContent": "Prompt executed successfully but returned no content.",
|
||||
"unknownError": "Unknown error occurred",
|
||||
"jsonResponse": "JSON Response:",
|
||||
"description": "Description",
|
||||
"messages": "Messages",
|
||||
"noDescription": "No description available",
|
||||
"runPromptWithName": "Get Prompt: {{name}}"
|
||||
},
|
||||
"settings": {
|
||||
"enableGlobalRoute": "Enable Global Route",
|
||||
"enableGlobalRouteDescription": "Allow connections to /sse endpoint without specifying a group ID",
|
||||
@@ -456,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",
|
||||
@@ -528,6 +546,7 @@
|
||||
"api": {
|
||||
"errors": {
|
||||
"readonly": "Readonly for demo environment",
|
||||
"invalid_credentials": "Invalid username or password",
|
||||
"serverNameRequired": "Server name is required",
|
||||
"serverConfigRequired": "Server configuration is required",
|
||||
"serverConfigInvalid": "Server configuration must include either a URL, OpenAPI specification URL or schema, or command with arguments",
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
"deleteWarning": "删除服务器 '{{name}}' 将会移除该服务器及其所有数据。此操作无法撤销。",
|
||||
"status": "状态",
|
||||
"tools": "工具",
|
||||
"prompts": "提示词",
|
||||
"name": "服务器名称",
|
||||
"url": "服务器 URL",
|
||||
"apiKey": "API 密钥",
|
||||
@@ -415,6 +416,23 @@
|
||||
"addItem": "添加 {{key}} 项目",
|
||||
"enterKey": "输入 {{key}}"
|
||||
},
|
||||
"prompt": {
|
||||
"run": "获取",
|
||||
"running": "获取中...",
|
||||
"result": "提示词结果",
|
||||
"error": "提示词错误",
|
||||
"execution": "提示词执行",
|
||||
"successful": "成功",
|
||||
"failed": "失败",
|
||||
"errorDetails": "错误详情:",
|
||||
"noContent": "提示词执行成功但未返回内容。",
|
||||
"unknownError": "发生未知错误",
|
||||
"jsonResponse": "JSON 响应:",
|
||||
"description": "描述",
|
||||
"messages": "消息",
|
||||
"noDescription": "无描述信息",
|
||||
"runPromptWithName": "获取提示词: {{name}}"
|
||||
},
|
||||
"settings": {
|
||||
"enableGlobalRoute": "启用全局路由",
|
||||
"enableGlobalRouteDescription": "允许不指定组 ID 就连接到 /sse 端点",
|
||||
@@ -458,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",
|
||||
@@ -530,6 +548,7 @@
|
||||
"api": {
|
||||
"errors": {
|
||||
"readonly": "演示环境无法修改数据",
|
||||
"invalid_credentials": "用户名或密码错误",
|
||||
"serverNameRequired": "服务器名称是必需的",
|
||||
"serverConfigRequired": "服务器配置是必需的",
|
||||
"serverConfigInvalid": "服务器配置必须包含 URL、OpenAPI 规范 URL 或模式,或者带参数的命令",
|
||||
|
||||
13310
package-lock.json
generated
Normal file
13310
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
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',
|
||||
});
|
||||
}
|
||||
};
|
||||
45
src/controllers/promptController.ts
Normal file
45
src/controllers/promptController.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
import { handleGetPromptRequest } from '../services/mcpService.js';
|
||||
|
||||
/**
|
||||
* Get a specific prompt by server and prompt name
|
||||
*/
|
||||
export const getPrompt = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName, promptName } = req.params;
|
||||
if (!serverName || !promptName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'serverName and promptName are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const promptArgs = {
|
||||
params: req.body as { [key: string]: any },
|
||||
};
|
||||
const result = await handleGetPromptRequest(promptArgs, serverName);
|
||||
if (result.isError) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get prompt',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response: ApiResponse = {
|
||||
success: true,
|
||||
data: result,
|
||||
};
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
console.error('Error getting prompt:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get prompt',
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
@@ -739,3 +738,131 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle prompt status for a specific server
|
||||
export const togglePrompt = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName, promptName } = req.params;
|
||||
const { enabled } = req.body;
|
||||
|
||||
if (!serverName || !promptName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Server name and prompt name are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof enabled !== 'boolean') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Enabled status must be a boolean',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = loadSettings();
|
||||
if (!settings.mcpServers[serverName]) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Server not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize prompts config if it doesn't exist
|
||||
if (!settings.mcpServers[serverName].prompts) {
|
||||
settings.mcpServers[serverName].prompts = {};
|
||||
}
|
||||
|
||||
// Set the prompt's enabled state
|
||||
settings.mcpServers[serverName].prompts![promptName] = { enabled };
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to save settings',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Notify that tools have changed (as prompts are part of the tool listing)
|
||||
notifyToolChanged();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Prompt ${promptName} ${enabled ? 'enabled' : 'disabled'} successfully`,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Internal server error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Update prompt description for a specific server
|
||||
export const updatePromptDescription = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName, promptName } = req.params;
|
||||
const { description } = req.body;
|
||||
|
||||
if (!serverName || !promptName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Server name and prompt name are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof description !== 'string') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Description must be a string',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = loadSettings();
|
||||
if (!settings.mcpServers[serverName]) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Server not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize prompts config if it doesn't exist
|
||||
if (!settings.mcpServers[serverName].prompts) {
|
||||
settings.mcpServers[serverName].prompts = {};
|
||||
}
|
||||
|
||||
// Set the prompt's description
|
||||
if (!settings.mcpServers[serverName].prompts![promptName]) {
|
||||
settings.mcpServers[serverName].prompts![promptName] = { enabled: true };
|
||||
}
|
||||
|
||||
settings.mcpServers[serverName].prompts![promptName].description = description;
|
||||
|
||||
if (!saveSettings(settings)) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to save settings',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Notify that tools have changed (as prompts are part of the tool listing)
|
||||
notifyToolChanged();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Prompt ${promptName} description updated successfully`,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
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
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
toggleServer,
|
||||
toggleTool,
|
||||
updateToolDescription,
|
||||
togglePrompt,
|
||||
updatePromptDescription,
|
||||
updateSystemConfig,
|
||||
} from '../controllers/serverController.js';
|
||||
import {
|
||||
@@ -58,8 +60,16 @@ import { login, register, getCurrentUser, changePassword } from '../controllers/
|
||||
import { getAllLogs, clearLogs, streamLogs } from '../controllers/logController.js';
|
||||
import { getRuntimeConfig, getPublicConfig } from '../controllers/configController.js';
|
||||
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();
|
||||
@@ -77,6 +87,8 @@ export const initRoutes = (app: express.Application): void => {
|
||||
router.post('/servers/:name/toggle', toggleServer);
|
||||
router.post('/servers/:serverName/tools/:toolName/toggle', toggleTool);
|
||||
router.put('/servers/:serverName/tools/:toolName/description', updateToolDescription);
|
||||
router.post('/servers/:serverName/prompts/:promptName/toggle', togglePrompt);
|
||||
router.put('/servers/:serverName/prompts/:promptName/description', updatePromptDescription);
|
||||
router.put('/system-config', updateSystemConfig);
|
||||
|
||||
// Group management routes
|
||||
@@ -106,6 +118,9 @@ export const initRoutes = (app: express.Application): void => {
|
||||
// Tool management routes
|
||||
router.post('/tools/call/:server', callTool);
|
||||
|
||||
// Prompt management routes
|
||||
router.post('/mcp/:serverName/prompts/:promptName', getPrompt);
|
||||
|
||||
// DXT upload routes
|
||||
router.post('/dxt/upload', uploadMiddleware, uploadDxtFile);
|
||||
|
||||
@@ -172,6 +187,18 @@ export const initRoutes = (app: express.Application): void => {
|
||||
// Public configuration endpoint (no auth required to check skipAuth setting)
|
||||
app.get(`${config.basePath}/public-config`, getPublicConfig);
|
||||
|
||||
// OpenAPI generation endpoints
|
||||
app.get(`${config.basePath}/api/openapi.json`, getOpenAPISpec);
|
||||
app.get(`${config.basePath}/api/:name/openapi.json`, getGroupOpenAPISpec);
|
||||
app.get(`${config.basePath}/api/openapi/servers`, getOpenAPIServers);
|
||||
app.get(`${config.basePath}/api/openapi/stats`, getOpenAPIStats);
|
||||
|
||||
// OpenAPI-compatible tool execution endpoints
|
||||
app.get(`${config.basePath}/api/tools/:serverName/:toolName`, executeToolViaOpenAPI);
|
||||
app.post(`${config.basePath}/api/tools/:serverName/:toolName`, executeToolViaOpenAPI);
|
||||
app.get(`${config.basePath}/api/:name/tools/:serverName/:toolName`, executeToolViaOpenAPI);
|
||||
app.post(`${config.basePath}/api/:name/tools/:serverName/:toolName`, executeToolViaOpenAPI);
|
||||
|
||||
app.use(`${config.basePath}/api`, router);
|
||||
};
|
||||
|
||||
|
||||
259
src/scripts/dao-demo.ts
Normal file
259
src/scripts/dao-demo.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* MCPHub DAO Layer Demo Script
|
||||
*
|
||||
* This script demonstrates how to use the new DAO layer for managing
|
||||
* MCPHub configuration data.
|
||||
*/
|
||||
|
||||
import {
|
||||
loadSettings,
|
||||
switchToDao,
|
||||
switchToLegacy,
|
||||
getDaoConfigService,
|
||||
} from '../config/configManager.js';
|
||||
|
||||
import {
|
||||
performMigration,
|
||||
validateMigration,
|
||||
testDaoOperations,
|
||||
performanceComparison,
|
||||
generateMigrationReport,
|
||||
} from '../config/migrationUtils.js';
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
|
||||
switch (command) {
|
||||
case 'migrate':
|
||||
{
|
||||
console.log('🚀 Starting migration to DAO layer...');
|
||||
const success = await performMigration();
|
||||
process.exit(success ? 0 : 1);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'validate':
|
||||
{
|
||||
console.log('🔍 Validating migration...');
|
||||
const isValid = await validateMigration();
|
||||
process.exit(isValid ? 0 : 1);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'test':
|
||||
{
|
||||
console.log('🧪 Testing DAO operations...');
|
||||
const testSuccess = await testDaoOperations();
|
||||
process.exit(testSuccess ? 0 : 1);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'compare':
|
||||
{
|
||||
console.log('⚡ Comparing performance...');
|
||||
await performanceComparison();
|
||||
process.exit(0);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'report':
|
||||
{
|
||||
console.log('📊 Generating migration report...');
|
||||
await generateMigrationReport();
|
||||
process.exit(0);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'demo':
|
||||
{
|
||||
await runDemo();
|
||||
process.exit(0);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'switch-dao':
|
||||
{
|
||||
switchToDao();
|
||||
console.log('✅ Switched to DAO layer');
|
||||
process.exit(0);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'switch-legacy':
|
||||
{
|
||||
switchToLegacy();
|
||||
console.log('✅ Switched to legacy file-based approach');
|
||||
process.exit(0);
|
||||
}
|
||||
break;
|
||||
|
||||
default: {
|
||||
printHelp();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`
|
||||
MCPHub DAO Layer Demo
|
||||
|
||||
Usage: node dao-demo.js <command>
|
||||
|
||||
Commands:
|
||||
migrate - Migrate from legacy format to DAO layer
|
||||
validate - Validate migration integrity
|
||||
test - Test DAO operations with sample data
|
||||
compare - Compare performance between legacy and DAO approaches
|
||||
report - Generate migration report
|
||||
demo - Run interactive demo
|
||||
switch-dao - Switch to DAO layer
|
||||
switch-legacy - Switch to legacy file-based approach
|
||||
|
||||
Examples:
|
||||
node dao-demo.js migrate
|
||||
node dao-demo.js test
|
||||
node dao-demo.js compare
|
||||
`);
|
||||
}
|
||||
|
||||
async function runDemo() {
|
||||
console.log('🎭 MCPHub DAO Layer Interactive Demo');
|
||||
console.log('=====================================\n');
|
||||
|
||||
try {
|
||||
// Step 1: Show current configuration
|
||||
console.log('📋 Step 1: Loading current configuration...');
|
||||
switchToLegacy();
|
||||
const legacySettings = await loadSettings();
|
||||
console.log(`Current data:
|
||||
- Users: ${legacySettings.users?.length || 0}
|
||||
- Servers: ${Object.keys(legacySettings.mcpServers || {}).length}
|
||||
- Groups: ${legacySettings.groups?.length || 0}
|
||||
- System Config Sections: ${Object.keys(legacySettings.systemConfig || {}).length}
|
||||
- User Configs: ${Object.keys(legacySettings.userConfigs || {}).length}
|
||||
`);
|
||||
|
||||
// Step 2: Switch to DAO and show same data
|
||||
console.log('🔄 Step 2: Switching to DAO layer...');
|
||||
switchToDao();
|
||||
const daoService = getDaoConfigService();
|
||||
|
||||
const daoSettings = await daoService.loadSettings();
|
||||
console.log(`DAO layer data:
|
||||
- Users: ${daoSettings.users?.length || 0}
|
||||
- Servers: ${Object.keys(daoSettings.mcpServers || {}).length}
|
||||
- Groups: ${daoSettings.groups?.length || 0}
|
||||
- System Config Sections: ${Object.keys(daoSettings.systemConfig || {}).length}
|
||||
- User Configs: ${Object.keys(daoSettings.userConfigs || {}).length}
|
||||
`);
|
||||
|
||||
// Step 3: Demonstrate CRUD operations
|
||||
console.log('🛠️ Step 3: Demonstrating CRUD operations...');
|
||||
|
||||
// Test user creation (if not exists)
|
||||
try {
|
||||
// Add demo data if needed
|
||||
if (!daoSettings.users?.length) {
|
||||
console.log('Creating demo user...');
|
||||
// Note: In practice, you'd use the UserDao directly for password hashing
|
||||
const demoSettings = {
|
||||
...daoSettings,
|
||||
users: [
|
||||
{
|
||||
username: 'demo-user',
|
||||
password: 'hashed-password',
|
||||
isAdmin: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
await daoService.saveSettings(demoSettings);
|
||||
console.log('✅ Demo user created');
|
||||
}
|
||||
|
||||
// Add demo server if needed
|
||||
if (!Object.keys(daoSettings.mcpServers || {}).length) {
|
||||
console.log('Creating demo server...');
|
||||
const demoSettings = {
|
||||
...daoSettings,
|
||||
mcpServers: {
|
||||
'demo-server': {
|
||||
command: 'echo',
|
||||
args: ['hello'],
|
||||
enabled: true,
|
||||
owner: 'admin',
|
||||
},
|
||||
},
|
||||
};
|
||||
await daoService.saveSettings(demoSettings);
|
||||
console.log('✅ Demo server created');
|
||||
}
|
||||
|
||||
// Add demo group if needed
|
||||
if (!daoSettings.groups?.length) {
|
||||
console.log('Creating demo group...');
|
||||
const demoSettings = {
|
||||
...daoSettings,
|
||||
groups: [
|
||||
{
|
||||
id: 'demo-group-1',
|
||||
name: 'Demo Group',
|
||||
description: 'A demo group for testing',
|
||||
servers: ['demo-server'],
|
||||
owner: 'admin',
|
||||
},
|
||||
],
|
||||
};
|
||||
await daoService.saveSettings(demoSettings);
|
||||
console.log('✅ Demo group created');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('⚠️ Some demo operations failed (this is expected for password hashing)');
|
||||
console.log('In production, you would use individual DAO methods for proper handling');
|
||||
}
|
||||
|
||||
// Step 4: Show benefits
|
||||
console.log(`
|
||||
🌟 Benefits of the DAO Layer:
|
||||
|
||||
1. 📦 Separation of Concerns
|
||||
- Data access logic is separated from business logic
|
||||
- Each data type has its own DAO with specific operations
|
||||
|
||||
2. 🔄 Easy Database Migration
|
||||
- Ready for switching from JSON files to database
|
||||
- Interface remains the same, implementation changes
|
||||
|
||||
3. 🧪 Better Testing
|
||||
- Can easily mock DAO interfaces for unit tests
|
||||
- Isolated testing of data access operations
|
||||
|
||||
4. 🔒 Type Safety
|
||||
- Strong typing for all data operations
|
||||
- Compile-time checking of data structure changes
|
||||
|
||||
5. 🚀 Enhanced Features
|
||||
- User password hashing in UserDao
|
||||
- Server filtering by owner/type in ServerDao
|
||||
- Group membership management in GroupDao
|
||||
- Section-based config updates in SystemConfigDao
|
||||
|
||||
6. 🏗️ Future Extensibility
|
||||
- Easy to add new data types
|
||||
- Consistent interface across all data operations
|
||||
- Support for complex queries and relationships
|
||||
`);
|
||||
|
||||
console.log('✅ Demo completed successfully!');
|
||||
} catch (error) {
|
||||
console.error('❌ Demo failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the main function
|
||||
if (require.main === module) {
|
||||
main().catch(console.error);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import config from './config/index.js';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
@@ -26,6 +27,7 @@ export class AppServer {
|
||||
|
||||
constructor() {
|
||||
this.app = express();
|
||||
this.app.use(cors());
|
||||
this.port = config.port;
|
||||
this.basePath = config.basePath;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ const getMCPRouterConfig = () => {
|
||||
|
||||
return {
|
||||
apiKey: mcpRouterConfig?.apiKey || process.env.MCPROUTER_API_KEY || '',
|
||||
referer: mcpRouterConfig?.referer || process.env.MCPROUTER_REFERER || 'https://mcphub.app',
|
||||
referer: mcpRouterConfig?.referer || process.env.MCPROUTER_REFERER || 'https://www.mcphubx.com',
|
||||
title: mcpRouterConfig?.title || process.env.MCPROUTER_TITLE || 'MCPHub',
|
||||
baseUrl:
|
||||
mcpRouterConfig?.baseUrl || process.env.MCPROUTER_API_BASE || DEFAULT_MCPROUTER_API_BASE,
|
||||
@@ -33,7 +33,7 @@ const getAxiosConfig = (): AxiosRequestConfig => {
|
||||
return {
|
||||
headers: {
|
||||
Authorization: mcpRouterConfig.apiKey ? `Bearer ${mcpRouterConfig.apiKey}` : '',
|
||||
'HTTP-Referer': mcpRouterConfig.referer || 'https://mcphub.app',
|
||||
'HTTP-Referer': mcpRouterConfig.referer || 'https://www.mcphubx.com',
|
||||
'X-Title': mcpRouterConfig.title || 'MCPHub',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
import os from 'os';
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
ListPromptsRequestSchema,
|
||||
GetPromptRequestSchema,
|
||||
ServerCapabilities,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
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, ToolInfo } from '../types/index.js';
|
||||
import { loadSettings, saveSettings, expandEnvVars, replaceEnvVars } from '../config/index.js';
|
||||
import { ServerInfo, ServerConfig, Tool } from '../types/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
|
||||
@@ -196,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,
|
||||
@@ -247,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(
|
||||
@@ -329,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}`);
|
||||
@@ -343,6 +353,7 @@ export const initializeClientsFromSettings = async (
|
||||
status: 'disconnected',
|
||||
error: null,
|
||||
tools: [],
|
||||
prompts: [],
|
||||
createTime: Date.now(),
|
||||
enabled: false,
|
||||
});
|
||||
@@ -376,6 +387,7 @@ export const initializeClientsFromSettings = async (
|
||||
status: 'disconnected',
|
||||
error: 'Missing OpenAPI specification URL or schema',
|
||||
tools: [],
|
||||
prompts: [],
|
||||
createTime: Date.now(),
|
||||
});
|
||||
continue;
|
||||
@@ -388,6 +400,7 @@ export const initializeClientsFromSettings = async (
|
||||
status: 'connecting',
|
||||
error: null,
|
||||
tools: [],
|
||||
prompts: [],
|
||||
createTime: Date.now(),
|
||||
enabled: conf.enabled === undefined ? true : conf.enabled,
|
||||
};
|
||||
@@ -404,7 +417,7 @@ export const initializeClientsFromSettings = async (
|
||||
|
||||
// Convert OpenAPI tools to MCP tool format
|
||||
const openApiTools = openApiClient.getTools();
|
||||
const mcpTools: ToolInfo[] = openApiTools.map((tool) => ({
|
||||
const mcpTools: Tool[] = openApiTools.map((tool) => ({
|
||||
name: `${name}-${tool.name}`,
|
||||
description: tool.description,
|
||||
inputSchema: cleanInputSchema(tool.inputSchema),
|
||||
@@ -469,6 +482,7 @@ export const initializeClientsFromSettings = async (
|
||||
status: 'connecting',
|
||||
error: null,
|
||||
tools: [],
|
||||
prompts: [],
|
||||
client,
|
||||
transport,
|
||||
options: requestOptions,
|
||||
@@ -480,32 +494,63 @@ export const initializeClientsFromSettings = async (
|
||||
.connect(transport, initRequestOptions || requestOptions)
|
||||
.then(() => {
|
||||
console.log(`Successfully connected client for server: ${name}`);
|
||||
client
|
||||
.listTools({}, initRequestOptions || requestOptions)
|
||||
.then((tools) => {
|
||||
console.log(`Successfully listed ${tools.tools.length} tools for server: ${name}`);
|
||||
const capabilities: ServerCapabilities | undefined = client.getServerCapabilities();
|
||||
console.log(`Server capabilities: ${JSON.stringify(capabilities)}`);
|
||||
|
||||
serverInfo.tools = tools.tools.map((tool) => ({
|
||||
name: `${name}-${tool.name}`,
|
||||
description: tool.description || '',
|
||||
inputSchema: cleanInputSchema(tool.inputSchema || {}),
|
||||
}));
|
||||
serverInfo.status = 'connected';
|
||||
serverInfo.error = null;
|
||||
let dataError: Error | null = null;
|
||||
if (capabilities?.tools) {
|
||||
client
|
||||
.listTools({}, initRequestOptions || requestOptions)
|
||||
.then((tools) => {
|
||||
console.log(`Successfully listed ${tools.tools.length} tools for server: ${name}`);
|
||||
serverInfo.tools = tools.tools.map((tool) => ({
|
||||
name: `${name}-${tool.name}`,
|
||||
description: tool.description || '',
|
||||
inputSchema: cleanInputSchema(tool.inputSchema || {}),
|
||||
}));
|
||||
// Save tools as vector embeddings for search
|
||||
saveToolsAsVectorEmbeddings(name, serverInfo.tools);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`Failed to list tools for server ${name} by error: ${error} with stack: ${error.stack}`,
|
||||
);
|
||||
dataError = error;
|
||||
});
|
||||
}
|
||||
|
||||
// Set up keep-alive ping for SSE connections
|
||||
setupKeepAlive(serverInfo, conf);
|
||||
if (capabilities?.prompts) {
|
||||
client
|
||||
.listPrompts({}, initRequestOptions || requestOptions)
|
||||
.then((prompts) => {
|
||||
console.log(
|
||||
`Successfully listed ${prompts.prompts.length} prompts for server: ${name}`,
|
||||
);
|
||||
serverInfo.prompts = prompts.prompts.map((prompt) => ({
|
||||
name: `${name}-${prompt.name}`,
|
||||
title: prompt.title,
|
||||
description: prompt.description,
|
||||
arguments: prompt.arguments,
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`Failed to list prompts for server ${name} by error: ${error} with stack: ${error.stack}`,
|
||||
);
|
||||
dataError = error;
|
||||
});
|
||||
}
|
||||
|
||||
// Save tools as vector embeddings for search
|
||||
saveToolsAsVectorEmbeddings(name, serverInfo.tools);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`Failed to list tools for server ${name} by error: ${error} with stack: ${error.stack}`,
|
||||
);
|
||||
serverInfo.status = 'disconnected';
|
||||
serverInfo.error = `Failed to list tools: ${error.stack} `;
|
||||
});
|
||||
if (!dataError) {
|
||||
serverInfo.status = 'connected';
|
||||
serverInfo.error = null;
|
||||
|
||||
// Set up keep-alive ping for SSE connections
|
||||
setupKeepAlive(serverInfo, conf);
|
||||
} else {
|
||||
serverInfo.status = 'disconnected';
|
||||
serverInfo.error = `Failed to list data: ${dataError} `;
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
@@ -526,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, createTime, error }) => {
|
||||
const serverConfig = settings.mcpServers[name];
|
||||
const infos = filterServerInfos.map(({ name, status, tools, prompts, createTime, error }) => {
|
||||
const serverConfig = allServers.find((server) => server.name === name);
|
||||
const enabled = serverConfig ? serverConfig.enabled !== false : true;
|
||||
|
||||
// Add enabled status and custom description to each tool
|
||||
@@ -546,11 +591,21 @@ export const getServersInfo = (): Omit<ServerInfo, 'client' | 'transport'>[] =>
|
||||
};
|
||||
});
|
||||
|
||||
const promptsWithEnabled = prompts.map((prompt) => {
|
||||
const promptConfig = serverConfig?.prompts?.[prompt.name];
|
||||
return {
|
||||
...prompt,
|
||||
description: promptConfig?.description || prompt.description, // Use custom description if available
|
||||
enabled: promptConfig?.enabled !== false, // Default to true if not explicitly disabled
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
name,
|
||||
status,
|
||||
error,
|
||||
tools: toolsWithEnabled,
|
||||
prompts: promptsWithEnabled,
|
||||
createTime,
|
||||
enabled,
|
||||
};
|
||||
@@ -563,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: ToolInfo[]): ToolInfo[] => {
|
||||
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;
|
||||
@@ -594,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)
|
||||
@@ -667,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' };
|
||||
}
|
||||
@@ -683,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';
|
||||
@@ -720,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);
|
||||
@@ -840,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) {
|
||||
@@ -855,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 {
|
||||
@@ -906,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) =>
|
||||
@@ -920,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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -941,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 ToolInfo]);
|
||||
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 = {
|
||||
@@ -1139,6 +1142,118 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const handleGetPromptRequest = async (request: any, extra: any) => {
|
||||
try {
|
||||
const { name, arguments: promptArgs } = request.params;
|
||||
let server: ServerInfo | undefined;
|
||||
if (extra && extra.server) {
|
||||
server = getServerByName(extra.server);
|
||||
} else {
|
||||
// Find the first server that has this tool
|
||||
server = serverInfos.find(
|
||||
(serverInfo) =>
|
||||
serverInfo.status === 'connected' &&
|
||||
serverInfo.enabled !== false &&
|
||||
serverInfo.prompts.find((prompt) => prompt.name === name),
|
||||
);
|
||||
}
|
||||
if (!server) {
|
||||
throw new Error(`Server not found: ${name}`);
|
||||
}
|
||||
|
||||
// Remove server prefix from prompt name if present
|
||||
const cleanPromptName = name.startsWith(`${server.name}-`)
|
||||
? name.replace(`${server.name}-`, '')
|
||||
: name;
|
||||
|
||||
const promptParams = {
|
||||
name: cleanPromptName || '',
|
||||
arguments: promptArgs,
|
||||
};
|
||||
// Log the final promptParams
|
||||
console.log(`Calling getPrompt with params: ${JSON.stringify(promptParams)}`);
|
||||
const prompt = await server.client?.getPrompt(promptParams);
|
||||
console.log(`Received prompt: ${JSON.stringify(prompt)}`);
|
||||
if (!prompt) {
|
||||
throw new Error(`Prompt not found: ${cleanPromptName}`);
|
||||
}
|
||||
|
||||
return prompt;
|
||||
} catch (error) {
|
||||
console.error(`Error handling GetPromptRequest: ${error}`);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error: ${error}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const handleListPromptsRequest = async (_: any, extra: any) => {
|
||||
const sessionId = extra.sessionId || '';
|
||||
const group = getGroup(sessionId);
|
||||
console.log(`Handling ListPromptsRequest for group: ${group}`);
|
||||
|
||||
const allServerInfos = getDataService()
|
||||
.filterData(serverInfos)
|
||||
.filter((serverInfo) => {
|
||||
if (serverInfo.enabled === false) return false;
|
||||
if (!group) return true;
|
||||
const serversInGroup = getServersInGroup(group);
|
||||
if (!serversInGroup || serversInGroup.length === 0) return serverInfo.name === group;
|
||||
return serversInGroup.includes(serverInfo.name);
|
||||
});
|
||||
|
||||
const allPrompts: any[] = [];
|
||||
for (const serverInfo of allServerInfos) {
|
||||
if (serverInfo.prompts && serverInfo.prompts.length > 0) {
|
||||
// Filter prompts based on server configuration
|
||||
const serverConfig = await serverDao.findById(serverInfo.name);
|
||||
|
||||
let enabledPrompts = serverInfo.prompts;
|
||||
if (serverConfig && serverConfig.prompts) {
|
||||
enabledPrompts = serverInfo.prompts.filter((prompt: any) => {
|
||||
const promptConfig = serverConfig.prompts?.[prompt.name];
|
||||
// If prompt is not in config, it's enabled by default
|
||||
return promptConfig?.enabled !== false;
|
||||
});
|
||||
}
|
||||
|
||||
// If this is a group request, apply group-level prompt filtering
|
||||
if (group) {
|
||||
const serverConfigInGroup = getServerConfigInGroup(group, serverInfo.name);
|
||||
if (
|
||||
serverConfigInGroup &&
|
||||
serverConfigInGroup.tools !== 'all' &&
|
||||
Array.isArray(serverConfigInGroup.tools)
|
||||
) {
|
||||
// Note: Group config uses 'tools' field but we're filtering prompts here
|
||||
// This might be a design decision to control access at the server level
|
||||
}
|
||||
}
|
||||
|
||||
// Apply custom descriptions from server configuration
|
||||
const promptsWithCustomDescriptions = enabledPrompts.map((prompt: any) => {
|
||||
const promptConfig = serverConfig?.prompts?.[prompt.name];
|
||||
return {
|
||||
...prompt,
|
||||
description: promptConfig?.description || prompt.description, // Use custom description if available
|
||||
};
|
||||
});
|
||||
|
||||
allPrompts.push(...promptsWithCustomDescriptions);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
prompts: allPrompts,
|
||||
};
|
||||
};
|
||||
|
||||
// Create McpServer instance
|
||||
export const createMcpServer = (name: string, version: string, group?: string): Server => {
|
||||
// Determine server name based on routing type
|
||||
@@ -1157,8 +1272,13 @@ export const createMcpServer = (name: string, version: string, group?: string):
|
||||
}
|
||||
// If no group, use default name (global routing)
|
||||
|
||||
const server = new Server({ name: serverName, version }, { capabilities: { tools: {} } });
|
||||
const server = new Server(
|
||||
{ name: serverName, version },
|
||||
{ capabilities: { tools: {}, prompts: {}, resources: {} } },
|
||||
);
|
||||
server.setRequestHandler(ListToolsRequestSchema, handleListToolsRequest);
|
||||
server.setRequestHandler(CallToolRequestSchema, handleCallToolRequest);
|
||||
server.setRequestHandler(GetPromptRequestSchema, handleGetPromptRequest);
|
||||
server.setRequestHandler(ListPromptsRequestSchema, handleListPromptsRequest);
|
||||
return server;
|
||||
};
|
||||
|
||||
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');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getRepositoryFactory } from '../db/index.js';
|
||||
import { VectorEmbeddingRepository } from '../db/repositories/index.js';
|
||||
import { ToolInfo } from '../types/index.js';
|
||||
import { Tool } from '../types/index.js';
|
||||
import { getAppDataSource, initializeDatabase } from '../db/connection.js';
|
||||
import { getSmartRoutingConfig } from '../utils/smartRouting.js';
|
||||
import OpenAI from 'openai';
|
||||
@@ -190,7 +190,7 @@ function generateFallbackEmbedding(text: string): number[] {
|
||||
*/
|
||||
export const saveToolsAsVectorEmbeddings = async (
|
||||
serverName: string,
|
||||
tools: ToolInfo[],
|
||||
tools: Tool[],
|
||||
): Promise<void> => {
|
||||
try {
|
||||
if (tools.length === 0) {
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -178,6 +178,7 @@ export interface ServerConfig {
|
||||
owner?: string; // Owner of the server, defaults to 'admin' user
|
||||
keepAliveInterval?: number; // Keep-alive ping interval in milliseconds (default: 60000ms for SSE servers)
|
||||
tools?: Record<string, { enabled: boolean; description?: string }>; // Tool-specific configurations with enable/disable state and custom descriptions
|
||||
prompts?: Record<string, { enabled: boolean; description?: string }>; // Prompt-specific configurations with enable/disable state and custom descriptions
|
||||
options?: Partial<Pick<RequestOptions, 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout'>>; // MCP request options configuration
|
||||
// OpenAPI specific configuration
|
||||
openapi?: {
|
||||
@@ -226,7 +227,8 @@ export interface ServerInfo {
|
||||
owner?: string; // Owner of the server, defaults to 'admin' user
|
||||
status: 'connected' | 'connecting' | 'disconnected'; // Current connection status
|
||||
error: string | null; // Error message if any
|
||||
tools: ToolInfo[]; // List of tools available on the server
|
||||
tools: Tool[]; // List of tools available on the server
|
||||
prompts: Prompt[]; // List of prompts available on the server
|
||||
client?: Client; // Client instance for communication (MCP clients)
|
||||
transport?: SSEClientTransport | StdioClientTransport | StreamableHTTPClientTransport; // Transport mechanism used
|
||||
openApiClient?: any; // OpenAPI client instance for openapi type servers
|
||||
@@ -237,13 +239,27 @@ export interface ServerInfo {
|
||||
}
|
||||
|
||||
// Details about a tool available on the server
|
||||
export interface ToolInfo {
|
||||
export interface Tool {
|
||||
name: string; // Name of the tool
|
||||
description: string; // Brief description of the tool
|
||||
inputSchema: Record<string, unknown>; // Input schema for the tool
|
||||
enabled?: boolean; // Whether the tool is enabled (optional, defaults to true)
|
||||
}
|
||||
|
||||
export interface Prompt {
|
||||
name: string; // Name of the prompt
|
||||
title?: string; // Title of the prompt
|
||||
description?: string; // Brief description of the prompt
|
||||
arguments?: PromptArgument[]; // Input schema for the prompt
|
||||
}
|
||||
|
||||
export interface PromptArgument {
|
||||
name: string; // Name of the argument
|
||||
title?: string; // Title of the argument
|
||||
description?: string; // Brief description of the argument
|
||||
required?: boolean; // Whether the argument is required
|
||||
}
|
||||
|
||||
// Standardized API response structure
|
||||
export interface ApiResponse<T = unknown> {
|
||||
success: boolean; // Indicates if the operation was successful
|
||||
|
||||
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