Compare commits

..

46 Commits

Author SHA1 Message Date
samanhappy
b279a1a62c chore: update mcp sdk dependencies to latest versions (#546) 2026-01-01 22:41:46 +08:00
dependabot[bot]
760cc462b9 chore(deps-dev): bump tsx from 4.20.5 to 4.21.0 (#541)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-01 22:01:13 +08:00
dependabot[bot]
431bc8f6f8 chore(deps-dev): bump next from 15.5.9 to 16.1.1 (#543)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-01 22:00:51 +08:00
dependabot[bot]
fb6af75f5b chore(deps-dev): bump ts-jest from 29.4.1 to 29.4.6 (#540)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-01 22:00:17 +08:00
dependabot[bot]
33b440973f chore(deps): bump axios from 1.13.1 to 1.13.2 (#544)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-01 21:59:57 +08:00
dependabot[bot]
2d248e953e chore(deps): bump dotenv from 16.6.1 to 17.2.3 (#542)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-01 21:59:38 +08:00
samanhappy
d36c6ac5ad fix: rename DATABASE_URL to DB_URL for consistency across configurations (#545) 2026-01-01 21:58:11 +08:00
Zhyim
0be6c36e12 feat: implement pagination for server list with customizable items pe… (#534) 2026-01-01 13:36:09 +08:00
samanhappy
7f2fca9636 feat: add proxy configuration support for STDIO servers on Linux and macOS (#537) 2026-01-01 12:45:50 +08:00
samanhappy
8ae542bdab Add server renaming functionality (#533) 2025-12-30 18:45:33 +08:00
samanhappy
88ce94b988 docs: update and expand MCPHub development guide and agent instructions (#532) 2025-12-28 14:25:28 +08:00
samanhappy
7cc330e721 fix: ensure database is initialized before saving tool embeddings (#531) 2025-12-28 12:19:21 +08:00
Copilot
ab338e80a7 Add custom access type for bearer keys to support combined group and server scoping (#530)
Co-authored-by: samanhappy <samanhappy@gmail.com>
2025-12-27 16:16:50 +08:00
cheezmil
b00e1c81fc fix: Found 1 error in src/services/keepAliveService.ts:51 #521 (#522)
Co-authored-by: cheestard <134115886+cheestard@users.noreply.github.com>
2025-12-25 09:15:07 +08:00
samanhappy
33eae50bd3 Refactor smart routing configuration and async database handling (#519) 2025-12-20 12:16:09 +08:00
samanhappy
eb1a965e45 feat: add authentication status listener to refresh settings on user login (#518) 2025-12-17 18:34:07 +08:00
samanhappy
97114dcabb feat: implement batch saving for smart routing configuration (#517) 2025-12-17 15:26:53 +08:00
samanhappy
350a022ea3 feat: enhance login error handling and add server unavailable message (#516) 2025-12-17 13:24:07 +08:00
samanhappy
292876a991 feat: update PostgreSQL images to pgvector/pgvector:pg17 across configurations (#513) 2025-12-16 15:40:06 +08:00
samanhappy
d6a9146e27 feat: enhance OAuth token logging and add authentication error handling in tool calls (#512) 2025-12-16 15:16:43 +08:00
samanhappy
1f3a6794ea feat: enhance BearerKeyDaoImpl to handle migration and caching behavior for bearer keys (#507) 2025-12-14 20:40:57 +08:00
samanhappy
c673afb97e Add HTTP/HTTPS proxy configuration and environment variable support (#506) 2025-12-14 15:44:44 +08:00
samanhappy
01855ca2ca feat: add bearer authentication key management with migration support (#503) 2025-12-13 16:46:58 +08:00
dependabot[bot]
88efad9d60 chore(deps-dev): bump next from 15.5.7 to 15.5.9 (#501)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-12 14:50:18 +08:00
samanhappy
2028233b53 Add OpenAPI support and enhance settings aggregation (#500) 2025-12-11 17:42:50 +08:00
samanhappy
1dfa0a990b Add batch server and group creation functionality (#499) 2025-12-11 14:21:58 +08:00
Alptekin Gülcan
ab7c210281 Optimizing API Operations: Simplified operationId Values and Large String Parameter Management (#488) 2025-12-07 13:11:35 +08:00
Copilot
6bd28ec89b Upgrade react and react-dom to 19.2.1 (#489)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-12-06 15:58:14 +08:00
Copilot
41a42f82d0 Upgrade js-yaml to 4.1.1 (#486)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-12-05 18:11:26 +08:00
Copilot
7aa3ff3bb1 Upgrade glob to version 10.5.0 (#485)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-12-05 17:52:46 +08:00
Copilot
71667dab2c Fix validator security vulnerability CVE in isLength() (#484)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-12-05 17:40:29 +08:00
Copilot
1921a0363b [WIP] Update auth0/node-jws to version 3.2.3 or 4.0.1 (#482)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-12-05 17:38:03 +08:00
Copilot
f9fe2e444b Add build-essential to Dockerfile for Python native extension compilation (#478)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-12-04 22:48:07 +08:00
samanhappy
8d420a927b fix: streamline tool filtering logic and add group-based filtering (#476)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-04 15:10:49 +08:00
dependabot[bot]
cb77593fd7 chore(deps-dev): bump next from 15.5.2 to 15.5.7 (#475)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-04 08:41:39 +08:00
dependabot[bot]
dbcebecf40 chore(deps): bump @modelcontextprotocol/sdk from 1.20.2 to 1.24.0 (#473)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-03 19:10:51 +08:00
cheestard
54e877cbd8 feat: add reload button. (#471) 2025-12-03 18:55:48 +08:00
samanhappy
61b748151f chore(deps): bump react-dom to 19.2.0 (#474) 2025-12-03 11:37:50 +08:00
dependabot[bot]
4f05815210 chore(deps-dev): bump @swc/core from 1.13.5 to 1.15.3 (#468)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-03 11:27:33 +08:00
dependabot[bot]
691d91f207 chore(deps-dev): bump @tailwindcss/vite from 4.1.12 to 4.1.17 (#469)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-03 11:27:14 +08:00
dependabot[bot]
3d58042ce5 chore(deps): bump bcryptjs from 3.0.2 to 3.0.3 (#470)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-03 11:26:35 +08:00
dependabot[bot]
81486b09df chore(deps): bump express from 4.21.2 to 4.22.0 (#472)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-03 11:26:08 +08:00
dependabot[bot]
a41707c228 chore(deps-dev): bump react and @types/react (#467)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-03 11:18:26 +08:00
dependabot[bot]
7391e57f35 chore(deps-dev): bump @tailwindcss/postcss from 4.1.14 to 4.1.17 (#466)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-03 11:18:01 +08:00
samanhappy
9d8f5ba370 Enhance MCP settings export with error handling and null value removal (#465) 2025-12-01 16:28:45 +08:00
samanhappy
764959eaca Implement OAuth client and token management with settings updates (#464) 2025-12-01 16:02:55 +08:00
107 changed files with 9013 additions and 3287 deletions

View File

@@ -1,272 +0,0 @@
# 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 (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`)
- **Documentation**: API docs and usage instructions(`docs/`)
## 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
# 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
```
**CRITICAL TIMING**: These commands are fast but NEVER CANCEL them. Always wait for completion.
### Development Environment
```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 (supports JSON file & PostgreSQL)
- `src/db/` - TypeORM entities & repositories (for PostgreSQL mode)
- `src/types/index.ts` - TypeScript type definitions
### DAO Layer (Dual Data Source)
MCPHub supports **JSON file** (default) and **PostgreSQL** storage:
- Set `USE_DB=true` + `DB_URL=postgresql://...` to use database
- When modifying data structures, update: `src/types/`, `src/dao/`, `src/db/entities/`, `src/db/repositories/`, `src/utils/migration.ts`
- See `AGENTS.md` for detailed DAO modification checklist
### Critical Frontend Files
- `frontend/src/` - React application source
- `frontend/src/pages/` - Page components (development entry point)
- `frontend/src/components/` - Reusable UI components
- `frontend/src/utils/fetchInterceptor.js` - Backend API interaction
### 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
### 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
## 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
### 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`
#### Documentation:
1. Update or add docs in `docs/` folder
2. Ensure README.md reflects any major changes
## 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.

View File

@@ -76,7 +76,7 @@ jobs:
# services: # services:
# postgres: # postgres:
# image: postgres:15 # image: pgvector/pgvector:pg17
# env: # env:
# POSTGRES_PASSWORD: postgres # POSTGRES_PASSWORD: postgres
# POSTGRES_DB: mcphub_test # POSTGRES_DB: mcphub_test
@@ -106,7 +106,7 @@ jobs:
# - name: Run integration tests # - name: Run integration tests
# run: | # run: |
# export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/mcphub_test" # export DB_URL="postgresql://postgres:postgres@localhost:5432/mcphub_test"
# node test-integration.ts # node test-integration.ts
# env: # env:
# NODE_ENV: test # NODE_ENV: test

374
AGENTS.md
View File

@@ -1,26 +1,214 @@
# Repository Guidelines # MCPHub Development Guide & Agent Instructions
These notes align current contributors around the code layout, daily commands, and collaboration habits that keep `@samanhappy/mcphub` moving quickly. **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.**
This document serves as the primary reference for all contributors and AI agents working on `@samanhappy/mcphub`. It provides comprehensive guidance on code organization, development workflow, and project conventions.
## Project Overview
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`)
- **Documentation**: API docs and usage instructions(`docs/`)
## Bootstrap and Setup (CRITICAL - Follow Exact Steps)
```bash
# Install pnpm if not available
npm install -g pnpm
# Install dependencies - takes ~30 seconds
pnpm install
# 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
```
**CRITICAL TIMING**: These commands are fast but NEVER CANCEL them. Always wait for completion.
## 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 & Module Organization ## Project Structure & Module Organization
- Backend services live in `src`, grouped by responsibility (`controllers/`, `services/`, `dao/`, `routes/`, `utils/`), with `server.ts` orchestrating HTTP bootstrap. ### Critical Backend Files
- `frontend/src` contains the Vite + React dashboard; `frontend/public` hosts static assets and translations sit in `locales/`.
- Jest-aware test code is split between colocated specs (`src/**/*.{test,spec}.ts`) and higher-level suites in `tests/`; use `tests/utils/` helpers when exercising the CLI or SSE flows. - `src/index.ts` - Application entry point
- Build artifacts and bundles are generated into `dist/`, `frontend/dist/`, and `coverage/`; never edit these manually. - `src/server.ts` - Express server setup and middleware (orchestrating HTTP bootstrap)
- `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 (supports JSON file & PostgreSQL)
- `src/db/` - TypeORM entities & repositories (for PostgreSQL mode)
- `src/types/index.ts` - TypeScript type definitions and shared DTOs
- `src/utils/` - Utility functions and helpers
### Critical Frontend Files
- `frontend/src/` - React application source (Vite + React dashboard)
- `frontend/src/pages/` - Page components (development entry point)
- `frontend/src/components/` - Reusable UI components
- `frontend/src/utils/fetchInterceptor.js` - Backend API interaction
- `frontend/public/` - Static assets
### 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
### Test Organization
- Jest-aware test code is split between colocated specs (`src/**/*.{test,spec}.ts`) and higher-level suites in `tests/`
- Use `tests/utils/` helpers when exercising the CLI or SSE flows
- Mirror production directory names when adding new suites
- End filenames with `.test.ts` or `.spec.ts` for automatic discovery
### Build Artifacts
- `dist/` - Backend build output (TypeScript compilation)
- `frontend/dist/` - Frontend build output (Vite bundle)
- `coverage/` - Test coverage reports
- **Never edit these manually**
### Localization
- Translations sit in `locales/` (en.json, fr.json, tr.json, zh.json)
- Frontend uses react-i18next
### 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
## Build, Test, and Development Commands ## Build, Test, and Development Commands
- `pnpm dev` runs backend (`tsx watch src/index.ts`) and frontend (`vite`) together for local iteration. ### Development Environment
- `pnpm backend:dev`, `pnpm frontend:dev`, and `pnpm frontend:preview` target each surface independently; prefer them when debugging one stack.
- `pnpm build` executes `pnpm backend:build` (TypeScript to `dist/`) and `pnpm frontend:build`; run before release or publishing. ```bash
- `pnpm test`, `pnpm test:watch`, and `pnpm test:coverage` drive Jest; `pnpm lint` and `pnpm format` enforce style via ESLint and Prettier. # 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
# Frontend preview (production build)
pnpm frontend:preview # Preview production build
```
**NEVER CANCEL**: Development servers may take 10-15 seconds to fully initialize all MCP servers.
### Production Build
```bash
# Full production build - takes ~10 seconds total
pnpm build # NEVER CANCEL - Set timeout to 60+ seconds
# Individual builds
pnpm backend:build # TypeScript compilation to dist/ - ~5 seconds
pnpm frontend:build # Vite build to frontend/dist/ - ~5 seconds
# Start production server
pnpm start # Requires dist/ and frontend/dist/ to exist
```
Run `pnpm build` before release or publishing.
### 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.
### 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)
## Coding Style & Naming Conventions ## Coding Style & Naming Conventions
- TypeScript everywhere; default to 2-space indentation and single quotes, letting Prettier settle formatting. ESLint configuration assumes ES modules. - **TypeScript everywhere**: Default to 2-space indentation and single quotes, letting Prettier settle formatting
- Name services and data access layers with suffixes (`UserService`, `AuthDao`), React components and files in `PascalCase`, and utility modules in `camelCase`. - **ESM modules**: Always use `.js` extensions in imports, not `.ts` (e.g., `import { something } from './file.js'`)
- Keep DTOs and shared types in `src/types` to avoid duplication; re-export through index files only when it clarifies imports. - **English only**: All code comments must be written in English
- **TypeScript strict**: Follow strict type checking rules
- **Naming conventions**:
- Services and data access layers: Use suffixes (`UserService`, `AuthDao`)
- React components and files: `PascalCase`
- Utility modules: `camelCase`
- **Types and DTOs**: Keep in `src/types` to avoid duplication; re-export through index files only when it clarifies imports
- **ESLint configuration**: Assumes ES modules
## Testing Guidelines ## Testing Guidelines
@@ -28,12 +216,86 @@ These notes align current contributors around the code layout, daily commands, a
- Mirror production directory names when adding new suites and end filenames with `.test.ts` or `.spec.ts` for automatic discovery. - Mirror production directory names when adding new suites and end filenames with `.test.ts` or `.spec.ts` for automatic discovery.
- Aim to maintain or raise coverage when touching critical flows (auth, OAuth, SSE); add integration tests under `tests/integration/` when touching cross-service logic. - Aim to maintain or raise coverage when touching critical flows (auth, OAuth, SSE); add integration tests under `tests/integration/` when touching cross-service logic.
## 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
### 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`
### Documentation
1. Update or add docs in `docs/` folder
2. Ensure README.md reflects any major changes
## Commit & Pull Request Guidelines ## Commit & Pull Request Guidelines
- Follow the existing Conventional Commit pattern (`feat:`, `fix:`, `chore:`, etc.) with imperative, present-tense summaries and optional multi-line context. - Follow the existing Conventional Commit pattern (`feat:`, `fix:`, `chore:`, etc.) with imperative, present-tense summaries and optional multi-line context.
- Each PR should describe the behavior change, list testing performed, and link issues; include before/after screenshots or GIFs for frontend tweaks. - Each PR should describe the behavior change, list testing performed, and link issues; include before/after screenshots or GIFs for frontend tweaks.
- Re-run `pnpm build` and `pnpm test` before requesting review, and ensure generated artifacts stay out of the diff. - Re-run `pnpm build` and `pnpm test` before requesting review, and ensure generated artifacts stay out of the diff.
### 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
## DAO Layer & Dual Data Source ## DAO Layer & Dual Data Source
MCPHub supports **JSON file** (default) and **PostgreSQL** storage. Set `USE_DB=true` + `DB_URL` to switch. MCPHub supports **JSON file** (default) and **PostgreSQL** storage. Set `USE_DB=true` + `DB_URL` to switch.
@@ -64,15 +326,99 @@ When adding/changing fields, update **ALL** these files:
### Data Type Mapping ### Data Type Mapping
| Model | DAO | DB Entity | JSON Path | | Model | DAO | DB Entity | JSON Path |
| -------------- | ----------------- | -------------- | ------------------------ | | -------------- | ----------------- | -------------- | ------------------------- |
| `IUser` | `UserDao` | `User` | `settings.users[]` | | `IUser` | `UserDao` | `User` | `settings.users[]` |
| `ServerConfig` | `ServerDao` | `Server` | `settings.mcpServers{}` | | `ServerConfig` | `ServerDao` | `Server` | `settings.mcpServers{}` |
| `IGroup` | `GroupDao` | `Group` | `settings.groups[]` | | `IGroup` | `GroupDao` | `Group` | `settings.groups[]` |
| `SystemConfig` | `SystemConfigDao` | `SystemConfig` | `settings.systemConfig` | | `SystemConfig` | `SystemConfigDao` | `SystemConfig` | `settings.systemConfig` |
| `UserConfig` | `UserConfigDao` | `UserConfig` | `settings.userConfigs{}` | | `UserConfig` | `UserConfigDao` | `UserConfig` | `settings.userConfigs{}` |
| `BearerKey` | `BearerKeyDao` | `BearerKey` | `settings.bearerKeys[]` |
| `IOAuthClient` | `OAuthClientDao` | `OAuthClient` | `settings.oauthClients[]` |
| `IOAuthToken` | `OAuthTokenDao` | `OAuthToken` | `settings.oauthTokens[]` |
### Common Pitfalls ### Common Pitfalls
- Forgetting migration script → fields won't migrate to DB - Forgetting migration script → fields won't migrate to DB
- Optional fields need `nullable: true` in entity - Optional fields need `nullable: true` in entity
- Complex objects need `simple-json` column type - Complex objects need `simple-json` column type
## Auto-Evolution Guidelines for AI Agents
**This section provides guidelines for AI agents to automatically maintain and improve this document.**
### When to Update AGENTS.md
AI agents MUST update this document in the following situations:
#### 1. Code-Documentation Mismatch Detected
When executing tasks, if you discover that:
- The actual code structure differs from descriptions in this document
- File paths, imports, or module organization has changed
- New critical files or directories exist that aren't documented
- Documented files or patterns no longer exist
**Action**: Immediately update the relevant section to reflect the current codebase state.
**Example scenarios**:
- A controller is now in `src/api/controllers/` instead of `src/controllers/`
- New middleware files exist that should be documented
- The DAO implementation has been refactored with a different structure
- Build output directories have changed
#### 2. User Preferences and Requirements
During conversation, if the user expresses:
- **Coding preferences**: Indentation style, naming conventions, code organization patterns
- **Workflow requirements**: Required validation steps, commit procedures, testing expectations
- **Tool preferences**: Preferred libraries, frameworks, or development tools
- **Quality standards**: Code review criteria, documentation requirements, error handling patterns
- **Development principles**: Architecture decisions, design patterns, best practices
**Action**: Add or update the relevant section to capture these preferences for future reference.
**Example scenarios**:
- User prefers async/await over promises → Update coding style section
- User requires specific test coverage thresholds → Update testing guidelines
- User has strong opinions about error handling → Add to development process section
- User establishes new deployment procedures → Update deployment section
### How to Update AGENTS.md
1. **Identify the Section**: Determine which section needs updating based on the type of change
2. **Make Precise Changes**: Update only the relevant content, maintaining the document structure
3. **Preserve Format**: Keep the existing markdown formatting and organization
4. **Add Context**: If adding new content, ensure it fits logically within existing sections
5. **Verify Accuracy**: After updating, ensure the new information is accurate and complete
### Update Principles
- **Accuracy First**: Documentation must reflect the actual current state
- **Clarity**: Use clear, concise language; avoid ambiguity
- **Completeness**: Include sufficient detail for agents to work effectively
- **Consistency**: Maintain consistent terminology and formatting throughout
- **Actionability**: Focus on concrete, actionable guidance rather than vague descriptions
### Self-Correction Process
Before completing any task:
1. Review relevant sections of AGENTS.md
2. During execution, note any discrepancies between documentation and reality
3. Update AGENTS.md to correct discrepancies
4. Verify the update doesn't conflict with other sections
5. Proceed with the original task using the updated information
### Meta-Update Rule
If this auto-evolution section itself needs improvement based on experience:
- Update it to better serve future agents
- Add new scenarios or principles as they emerge
- Refine the update process based on what works well
**Remember**: This document is a living guide. Keeping it accurate and current is as important as following it.

View File

@@ -2,7 +2,7 @@ FROM python:3.13-slim-bookworm AS base
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
RUN apt-get update && apt-get install -y curl gnupg git \ RUN apt-get update && apt-get install -y curl gnupg git build-essential \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y nodejs \ && apt-get install -y nodejs \
&& apt-get clean && rm -rf /var/lib/apt/lists/* && apt-get clean && rm -rf /var/lib/apt/lists/*

View File

@@ -3,7 +3,7 @@ version: "3.8"
services: services:
# PostgreSQL database for MCPHub configuration # PostgreSQL database for MCPHub configuration
postgres: postgres:
image: postgres:16-alpine image: pgvector/pgvector:pg17-alpine
container_name: mcphub-postgres container_name: mcphub-postgres
environment: environment:
POSTGRES_DB: mcphub POSTGRES_DB: mcphub

View File

@@ -59,7 +59,7 @@ version: '3.8'
services: services:
postgres: postgres:
image: postgres:16 image: pgvector/pgvector:pg17
environment: environment:
POSTGRES_DB: mcphub POSTGRES_DB: mcphub
POSTGRES_USER: mcphub POSTGRES_USER: mcphub

View File

@@ -106,7 +106,7 @@ services:
- NODE_ENV=production - NODE_ENV=production
- PORT=3000 - PORT=3000
- JWT_SECRET=${JWT_SECRET:-your-jwt-secret} - JWT_SECRET=${JWT_SECRET:-your-jwt-secret}
- DATABASE_URL=postgresql://mcphub:password@postgres:5432/mcphub - DB_URL=postgresql://mcphub:password@postgres:5432/mcphub
volumes: volumes:
- ./mcp_settings.json:/app/mcp_settings.json:ro - ./mcp_settings.json:/app/mcp_settings.json:ro
- ./servers.json:/app/servers.json:ro - ./servers.json:/app/servers.json:ro
@@ -119,7 +119,7 @@ services:
- mcphub-network - mcphub-network
postgres: postgres:
image: postgres:15-alpine image: pgvector/pgvector:pg17
container_name: mcphub-postgres container_name: mcphub-postgres
environment: environment:
- POSTGRES_DB=mcphub - POSTGRES_DB=mcphub
@@ -180,7 +180,7 @@ services:
- PORT=3000 - PORT=3000
- JWT_SECRET=${JWT_SECRET} - JWT_SECRET=${JWT_SECRET}
- JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-24h} - JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-24h}
- DATABASE_URL=postgresql://mcphub:${POSTGRES_PASSWORD}@postgres:5432/mcphub - DB_URL=postgresql://mcphub:${POSTGRES_PASSWORD}@postgres:5432/mcphub
- OPENAI_API_KEY=${OPENAI_API_KEY} - OPENAI_API_KEY=${OPENAI_API_KEY}
- REDIS_URL=redis://redis:6379 - REDIS_URL=redis://redis:6379
volumes: volumes:
@@ -203,7 +203,7 @@ services:
retries: 3 retries: 3
postgres: postgres:
image: postgres:15-alpine image: pgvector/pgvector:pg17
container_name: mcphub-postgres container_name: mcphub-postgres
environment: environment:
- POSTGRES_DB=mcphub - POSTGRES_DB=mcphub
@@ -293,7 +293,7 @@ services:
environment: environment:
- NODE_ENV=development - NODE_ENV=development
- PORT=3000 - PORT=3000
- DATABASE_URL=postgresql://mcphub:password@postgres:5432/mcphub - DB_URL=postgresql://mcphub:password@postgres:5432/mcphub
volumes: volumes:
- .:/app - .:/app
- /app/node_modules - /app/node_modules
@@ -305,7 +305,7 @@ services:
- mcphub-dev - mcphub-dev
postgres: postgres:
image: postgres:15-alpine image: pgvector/pgvector:pg17
container_name: mcphub-postgres-dev container_name: mcphub-postgres-dev
environment: environment:
- POSTGRES_DB=mcphub - POSTGRES_DB=mcphub
@@ -445,7 +445,7 @@ Add backup service to your `docker-compose.yml`:
```yaml ```yaml
services: services:
backup: backup:
image: postgres:15-alpine image: pgvector/pgvector:pg17
container_name: mcphub-backup container_name: mcphub-backup
environment: environment:
- PGPASSWORD=${POSTGRES_PASSWORD} - PGPASSWORD=${POSTGRES_PASSWORD}

View File

@@ -259,6 +259,92 @@ MCPHub supports environment variable substitution using `${VAR_NAME}` syntax:
} }
``` ```
### Proxy Configuration (proxychains4)
MCPHub supports routing STDIO server network traffic through a proxy using **proxychains4**. This feature is available on **Linux and macOS only** (Windows is not supported).
<Note>
To use this feature, you must have `proxychains4` installed on your system:
- **Debian/Ubuntu**: `apt install proxychains4`
- **macOS**: `brew install proxychains-ng`
- **Arch Linux**: `pacman -S proxychains-ng`
</Note>
#### Basic Proxy Configuration
```json
{
"mcpServers": {
"fetch-via-proxy": {
"command": "uvx",
"args": ["mcp-server-fetch"],
"proxy": {
"enabled": true,
"type": "socks5",
"host": "127.0.0.1",
"port": 1080
}
}
}
}
```
#### Proxy Configuration Options
| Field | Type | Default | Description |
| ------------ | ------- | --------- | ------------------------------------------------ |
| `enabled` | boolean | `false` | Enable/disable proxy routing |
| `type` | string | `socks5` | Proxy protocol: `socks4`, `socks5`, or `http` |
| `host` | string | - | Proxy server hostname or IP address |
| `port` | number | - | Proxy server port |
| `username` | string | - | Proxy authentication username (optional) |
| `password` | string | - | Proxy authentication password (optional) |
| `configPath` | string | - | Path to custom proxychains4 config file |
#### Proxy with Authentication
```json
{
"mcpServers": {
"secure-server": {
"command": "npx",
"args": ["-y", "@example/mcp-server"],
"proxy": {
"enabled": true,
"type": "http",
"host": "proxy.example.com",
"port": 8080,
"username": "${PROXY_USER}",
"password": "${PROXY_PASSWORD}"
}
}
}
}
```
#### Using Custom proxychains4 Configuration
For advanced use cases, you can provide your own proxychains4 configuration file:
```json
{
"mcpServers": {
"custom-proxy-server": {
"command": "python",
"args": ["-m", "custom_mcp_server"],
"proxy": {
"enabled": true,
"configPath": "/etc/proxychains4/custom.conf"
}
}
}
}
```
<Tip>
When `configPath` is specified, all other proxy settings (`type`, `host`, `port`, etc.) are ignored, and the custom configuration file is used directly.
</Tip>
{/* ### Custom Server Scripts {/* ### Custom Server Scripts
#### Local Python Server #### Local Python Server

View File

@@ -47,7 +47,7 @@ PORT=3000
NODE_ENV=development NODE_ENV=development
# Database Configuration # Database Configuration
DATABASE_URL=postgresql://username:password@localhost:5432/mcphub DB_URL=postgresql://username:password@localhost:5432/mcphub
# JWT Configuration # JWT Configuration
JWT_SECRET=your-secret-key JWT_SECRET=your-secret-key

View File

@@ -69,7 +69,7 @@ Smart Routing requires additional setup compared to basic MCPHub usage:
ports: ports:
- "3000:3000" - "3000:3000"
environment: environment:
- DATABASE_URL=postgresql://mcphub:password@postgres:5432/mcphub - DB_URL=postgresql://mcphub:password@postgres:5432/mcphub
- OPENAI_API_KEY=your_openai_api_key - OPENAI_API_KEY=your_openai_api_key
- ENABLE_SMART_ROUTING=true - ENABLE_SMART_ROUTING=true
depends_on: depends_on:
@@ -78,7 +78,7 @@ Smart Routing requires additional setup compared to basic MCPHub usage:
- ./mcp_settings.json:/app/mcp_settings.json - ./mcp_settings.json:/app/mcp_settings.json
postgres: postgres:
image: pgvector/pgvector:pg16 image: pgvector/pgvector:pg17
environment: environment:
- POSTGRES_DB=mcphub - POSTGRES_DB=mcphub
- POSTGRES_USER=mcphub - POSTGRES_USER=mcphub
@@ -114,7 +114,7 @@ Smart Routing requires additional setup compared to basic MCPHub usage:
2. **Set Environment Variables**: 2. **Set Environment Variables**:
```bash ```bash
export DATABASE_URL="postgresql://mcphub:your_password@localhost:5432/mcphub" export DB_URL="postgresql://mcphub:your_password@localhost:5432/mcphub"
export OPENAI_API_KEY="your_openai_api_key" export OPENAI_API_KEY="your_openai_api_key"
export ENABLE_SMART_ROUTING="true" export ENABLE_SMART_ROUTING="true"
``` ```
@@ -146,7 +146,7 @@ Smart Routing requires additional setup compared to basic MCPHub usage:
spec: spec:
containers: containers:
- name: postgres - name: postgres
image: pgvector/pgvector:pg16 image: pgvector/pgvector:pg17
env: env:
- name: POSTGRES_DB - name: POSTGRES_DB
value: mcphub value: mcphub
@@ -178,7 +178,7 @@ Smart Routing requires additional setup compared to basic MCPHub usage:
- name: mcphub - name: mcphub
image: samanhappy/mcphub:latest image: samanhappy/mcphub:latest
env: env:
- name: DATABASE_URL - name: DB_URL
value: "postgresql://mcphub:password@postgres:5432/mcphub" value: "postgresql://mcphub:password@postgres:5432/mcphub"
- name: OPENAI_API_KEY - name: OPENAI_API_KEY
valueFrom: valueFrom:
@@ -202,7 +202,7 @@ Configure Smart Routing with these environment variables:
```bash ```bash
# Required # Required
DATABASE_URL=postgresql://user:password@host:5432/database DB_URL=postgresql://user:password@host:5432/database
OPENAI_API_KEY=your_openai_api_key OPENAI_API_KEY=your_openai_api_key
# Optional # Optional
@@ -219,10 +219,10 @@ EMBEDDING_BATCH_SIZE=100
<Accordion title="Database Configuration"> <Accordion title="Database Configuration">
```bash ```bash
# Full PostgreSQL connection string # Full PostgreSQL connection string
DATABASE_URL=postgresql://username:password@host:port/database?schema=public DB_URL=postgresql://username:password@host:port/database?schema=public
# SSL configuration for cloud databases # SSL configuration for cloud databases
DATABASE_URL=postgresql://user:pass@host:5432/db?sslmode=require DB_URL=postgresql://user:pass@host:5432/db?sslmode=require
# Connection pool settings # Connection pool settings
DATABASE_POOL_SIZE=20 DATABASE_POOL_SIZE=20
@@ -673,11 +673,11 @@ curl -X POST http://localhost:3000/api/smart-routing/feedback \
**Solutions:** **Solutions:**
1. Verify PostgreSQL is running 1. Verify PostgreSQL is running
2. Check DATABASE_URL format 2. Check DB_URL format
3. Ensure pgvector extension is installed 3. Ensure pgvector extension is installed
4. Test connection manually: 4. Test connection manually:
```bash ```bash
psql $DATABASE_URL -c "SELECT 1;" psql $DB_URL -c "SELECT 1;"
``` ```
</Accordion> </Accordion>

View File

@@ -96,7 +96,7 @@ Optional for Smart Routing:
# Optional: PostgreSQL for Smart Routing # Optional: PostgreSQL for Smart Routing
postgres: postgres:
image: pgvector/pgvector:pg16 image: pgvector/pgvector:pg17
environment: environment:
POSTGRES_DB: mcphub POSTGRES_DB: mcphub
POSTGRES_USER: mcphub POSTGRES_USER: mcphub
@@ -445,7 +445,7 @@ Set the following environment variables:
```bash ```bash
# Database connection # Database connection
DATABASE_URL=postgresql://mcphub:your_password@localhost:5432/mcphub DB_URL=postgresql://mcphub:your_password@localhost:5432/mcphub
# OpenAI API for embeddings # OpenAI API for embeddings
OPENAI_API_KEY=your_openai_api_key OPENAI_API_KEY=your_openai_api_key
@@ -563,10 +563,10 @@ curl -X POST http://localhost:3000/mcp \
**Database connection failed:** **Database connection failed:**
```bash ```bash
# Test database connection # Test database connection
psql $DATABASE_URL -c "SELECT 1;" psql $DB_URL -c "SELECT 1;"
# Check if pgvector is installed # Check if pgvector is installed
psql $DATABASE_URL -c "CREATE EXTENSION IF NOT EXISTS vector;" psql $DB_URL -c "CREATE EXTENSION IF NOT EXISTS vector;"
``` ```
**Embedding service errors:** **Embedding service errors:**

View File

@@ -59,7 +59,7 @@ version: '3.8'
services: services:
postgres: postgres:
image: postgres:16 image: pgvector/pgvector:pg17
environment: environment:
POSTGRES_DB: mcphub POSTGRES_DB: mcphub
POSTGRES_USER: mcphub POSTGRES_USER: mcphub

View File

@@ -106,7 +106,7 @@ services:
- NODE_ENV=production - NODE_ENV=production
- PORT=3000 - PORT=3000
- JWT_SECRET=${JWT_SECRET:-your-jwt-secret} - JWT_SECRET=${JWT_SECRET:-your-jwt-secret}
- DATABASE_URL=postgresql://mcphub:password@postgres:5432/mcphub - DB_URL=postgresql://mcphub:password@postgres:5432/mcphub
volumes: volumes:
- ./mcp_settings.json:/app/mcp_settings.json:ro - ./mcp_settings.json:/app/mcp_settings.json:ro
- ./servers.json:/app/servers.json:ro - ./servers.json:/app/servers.json:ro
@@ -119,7 +119,7 @@ services:
- mcphub-network - mcphub-network
postgres: postgres:
image: postgres:15-alpine image: pgvector/pgvector:pg17
container_name: mcphub-postgres container_name: mcphub-postgres
environment: environment:
- POSTGRES_DB=mcphub - POSTGRES_DB=mcphub
@@ -180,7 +180,7 @@ services:
- PORT=3000 - PORT=3000
- JWT_SECRET=${JWT_SECRET} - JWT_SECRET=${JWT_SECRET}
- JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-24h} - JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-24h}
- DATABASE_URL=postgresql://mcphub:${POSTGRES_PASSWORD}@postgres:5432/mcphub - DB_URL=postgresql://mcphub:${POSTGRES_PASSWORD}@postgres:5432/mcphub
- OPENAI_API_KEY=${OPENAI_API_KEY} - OPENAI_API_KEY=${OPENAI_API_KEY}
- REDIS_URL=redis://redis:6379 - REDIS_URL=redis://redis:6379
volumes: volumes:
@@ -203,7 +203,7 @@ services:
retries: 3 retries: 3
postgres: postgres:
image: postgres:15-alpine image: pgvector/pgvector:pg17
container_name: mcphub-postgres container_name: mcphub-postgres
environment: environment:
- POSTGRES_DB=mcphub - POSTGRES_DB=mcphub
@@ -293,7 +293,7 @@ services:
environment: environment:
- NODE_ENV=development - NODE_ENV=development
- PORT=3000 - PORT=3000
- DATABASE_URL=postgresql://mcphub:password@postgres:5432/mcphub - DB_URL=postgresql://mcphub:password@postgres:5432/mcphub
volumes: volumes:
- .:/app - .:/app
- /app/node_modules - /app/node_modules
@@ -305,7 +305,7 @@ services:
- mcphub-dev - mcphub-dev
postgres: postgres:
image: postgres:15-alpine image: pgvector/pgvector:pg17
container_name: mcphub-postgres-dev container_name: mcphub-postgres-dev
environment: environment:
- POSTGRES_DB=mcphub - POSTGRES_DB=mcphub
@@ -445,7 +445,7 @@ secrets:
```yaml ```yaml
services: services:
backup: backup:
image: postgres:15-alpine image: pgvector/pgvector:pg17
container_name: mcphub-backup container_name: mcphub-backup
environment: environment:
- PGPASSWORD=${POSTGRES_PASSWORD} - PGPASSWORD=${POSTGRES_PASSWORD}

View File

@@ -290,7 +290,7 @@ MCPHub 支持使用 `${VAR_NAME}` 语法进行环境变量替换:
"command": "python", "command": "python",
"args": ["-m", "db_server"], "args": ["-m", "db_server"],
"env": { "env": {
"DB_URL": "${NODE_ENV:development == 'production' ? DATABASE_URL : DEV_DATABASE_URL}" "DB_URL": "${NODE_ENV:development == 'production' ? DB_URL : DEV_DB_URL}"
} }
} }
} }

View File

@@ -47,7 +47,7 @@ PORT=3000
NODE_ENV=development NODE_ENV=development
# 数据库配置 # 数据库配置
DATABASE_URL=postgresql://username:password@localhost:5432/mcphub DB_URL=postgresql://username:password@localhost:5432/mcphub
# JWT 配置 # JWT 配置
JWT_SECRET=your-secret-key JWT_SECRET=your-secret-key

View File

@@ -480,7 +480,7 @@ docker run -d \
--name mcphub \ --name mcphub \
-p 3000:3000 \ -p 3000:3000 \
-e NODE_ENV=production \ -e NODE_ENV=production \
-e DATABASE_URL=postgresql://user:pass@host:5432/mcphub \ -e DB_URL=postgresql://user:pass@host:5432/mcphub \
-e JWT_SECRET=your-secret-key \ -e JWT_SECRET=your-secret-key \
mcphub/server:latest mcphub/server:latest
@@ -504,7 +504,7 @@ docker run -d \
--name mcphub \ --name mcphub \
-p 3000:3000 \ -p 3000:3000 \
-e NODE_ENV=production \ -e NODE_ENV=production \
-e DATABASE_URL=postgresql://user:pass@host:5432/mcphub \ -e DB_URL=postgresql://user:pass@host:5432/mcphub \
-e JWT_SECRET=your-secret-key \ -e JWT_SECRET=your-secret-key \
mcphub/server:latest mcphub/server:latest

View File

@@ -159,7 +159,7 @@ services:
- '3000:3000' - '3000:3000'
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- DATABASE_URL=postgresql://user:pass@db:5432/mcphub - DB_URL=postgresql://user:pass@db:5432/mcphub
``` ```
```` ````
@@ -172,7 +172,7 @@ services:
- '3000:3000' - '3000:3000'
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- DATABASE_URL=postgresql://user:pass@db:5432/mcphub - DB_URL=postgresql://user:pass@db:5432/mcphub
``` ```
### 终端命令 ### 终端命令

View File

@@ -245,11 +245,11 @@ curl -X POST http://localhost:3000/api/smart-routing/feedback \
**解决方案:** **解决方案:**
1. 验证 PostgreSQL 是否正在运行 1. 验证 PostgreSQL 是否正在运行
2. 检查 DATABASE_URL 格式 2. 检查 DB_URL 格式
3. 确保安装了 pgvector 扩展 3. 确保安装了 pgvector 扩展
4. 手动测试连接: 4. 手动测试连接:
```bash ```bash
psql $DATABASE_URL -c "SELECT 1;" psql $DB_URL -c "SELECT 1;"
``` ```
</Accordion> </Accordion>

View File

@@ -96,7 +96,7 @@ description: '各种平台的详细安装说明'
# 可选:用于智能路由的 PostgreSQL # 可选:用于智能路由的 PostgreSQL
postgres: postgres:
image: pgvector/pgvector:pg16 image: pgvector/pgvector:pg17
environment: environment:
POSTGRES_DB: mcphub POSTGRES_DB: mcphub
POSTGRES_USER: mcphub POSTGRES_USER: mcphub
@@ -420,7 +420,7 @@ description: '各种平台的详细安装说明'
```bash ```bash
# 数据库连接 # 数据库连接
DATABASE_URL=postgresql://mcphub:your_password@localhost:5432/mcphub DB_URL=postgresql://mcphub:your_password@localhost:5432/mcphub
# 用于嵌入的 OpenAI API # 用于嵌入的 OpenAI API
OPENAI_API_KEY=your_openai_api_key OPENAI_API_KEY=your_openai_api_key
@@ -538,10 +538,10 @@ curl -X POST http://localhost:3000/mcp \
**数据库连接失败:** **数据库连接失败:**
```bash ```bash
# 测试数据库连接 # 测试数据库连接
psql $DATABASE_URL -c "SELECT 1;" psql $DB_URL -c "SELECT 1;"
# 检查是否安装了 pgvector # 检查是否安装了 pgvector
psql $DATABASE_URL -c "CREATE EXTENSION IF NOT EXISTS vector;" psql $DB_URL -c "CREATE EXTENSION IF NOT EXISTS vector;"
``` ```
**嵌入服务错误:** **嵌入服务错误:**

View File

@@ -28,7 +28,48 @@
"env": { "env": {
"API_KEY": "${MY_API_KEY}", "API_KEY": "${MY_API_KEY}",
"DEBUG": "${DEBUG_MODE}", "DEBUG": "${DEBUG_MODE}",
"DATABASE_URL": "${DATABASE_URL}" "DB_URL": "${DB_URL}"
}
},
"example-stdio-with-proxy": {
"type": "stdio",
"command": "uvx",
"args": [
"mcp-server-fetch"
],
"proxy": {
"enabled": true,
"type": "socks5",
"host": "${PROXY_HOST}",
"port": 1080
}
},
"example-stdio-with-auth-proxy": {
"type": "stdio",
"command": "npx",
"args": [
"-y",
"@example/mcp-server"
],
"proxy": {
"enabled": true,
"type": "http",
"host": "${HTTP_PROXY_HOST}",
"port": 8080,
"username": "${PROXY_USERNAME}",
"password": "${PROXY_PASSWORD}"
}
},
"example-stdio-with-custom-proxy-config": {
"type": "stdio",
"command": "python",
"args": [
"-m",
"custom_mcp_server"
],
"proxy": {
"enabled": true,
"configPath": "/etc/proxychains4/custom.conf"
} }
}, },
"example-openapi-server": { "example-openapi-server": {
@@ -55,7 +96,10 @@
"clientId": "${OAUTH_CLIENT_ID}", "clientId": "${OAUTH_CLIENT_ID}",
"clientSecret": "${OAUTH_CLIENT_SECRET}", "clientSecret": "${OAUTH_CLIENT_SECRET}",
"accessToken": "${OAUTH_ACCESS_TOKEN}", "accessToken": "${OAUTH_ACCESS_TOKEN}",
"scopes": ["read", "write"] "scopes": [
"read",
"write"
]
} }
} }
}, },

View File

@@ -4,6 +4,7 @@ import { AuthProvider } from './contexts/AuthContext';
import { ToastProvider } from './contexts/ToastContext'; import { ToastProvider } from './contexts/ToastContext';
import { ThemeProvider } from './contexts/ThemeContext'; import { ThemeProvider } from './contexts/ThemeContext';
import { ServerProvider } from './contexts/ServerContext'; import { ServerProvider } from './contexts/ServerContext';
import { SettingsProvider } from './contexts/SettingsContext';
import MainLayout from './layouts/MainLayout'; import MainLayout from './layouts/MainLayout';
import ProtectedRoute from './components/ProtectedRoute'; import ProtectedRoute from './components/ProtectedRoute';
import LoginPage from './pages/LoginPage'; import LoginPage from './pages/LoginPage';
@@ -29,6 +30,7 @@ function App() {
<AuthProvider> <AuthProvider>
<ServerProvider> <ServerProvider>
<ToastProvider> <ToastProvider>
<SettingsProvider>
<Router basename={basename}> <Router basename={basename}>
<Routes> <Routes>
{/* 公共路由 */} {/* 公共路由 */}
@@ -45,10 +47,7 @@ function App() {
<Route path="/market/:serverName" element={<MarketPage />} /> <Route path="/market/:serverName" element={<MarketPage />} />
{/* Legacy cloud routes redirect to market with cloud tab */} {/* Legacy cloud routes redirect to market with cloud tab */}
<Route path="/cloud" element={<Navigate to="/market?tab=cloud" replace />} /> <Route path="/cloud" element={<Navigate to="/market?tab=cloud" replace />} />
<Route <Route path="/cloud/:serverName" element={<CloudRedirect />} />
path="/cloud/:serverName"
element={<CloudRedirect />}
/>
<Route path="/logs" element={<LogsPage />} /> <Route path="/logs" element={<LogsPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
</Route> </Route>
@@ -58,6 +57,7 @@ function App() {
<Route path="*" element={<Navigate to="/" />} /> <Route path="*" element={<Navigate to="/" />} />
</Routes> </Routes>
</Router> </Router>
</SettingsProvider>
</ToastProvider> </ToastProvider>
</ServerProvider> </ServerProvider>
</AuthProvider> </AuthProvider>

View File

@@ -18,7 +18,17 @@ const EditServerForm = ({ server, onEdit, onCancel }: EditServerFormProps) => {
try { try {
setError(null); setError(null);
const encodedServerName = encodeURIComponent(server.name); const encodedServerName = encodeURIComponent(server.name);
const result = await apiPut(`/servers/${encodedServerName}`, payload);
// Check if name is being changed
const isRenaming = payload.name && payload.name !== server.name;
// Build the request body
const requestBody = {
config: payload.config,
...(isRenaming ? { newName: payload.name } : {}),
};
const result = await apiPut(`/servers/${encodedServerName}`, requestBody);
if (!result.success) { if (!result.success) {
// Use specific error message from the response if available // Use specific error message from the response if available

View File

@@ -1,99 +1,103 @@
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next';
import { Group, Server, IGroupServerConfig } from '@/types' import { Group, Server, IGroupServerConfig } from '@/types';
import { Edit, Trash, Copy, Check, Link, FileCode, DropdownIcon, Wrench } from '@/components/icons/LucideIcons' import {
import DeleteDialog from '@/components/ui/DeleteDialog' Edit,
import { useToast } from '@/contexts/ToastContext' Trash,
import { useSettingsData } from '@/hooks/useSettingsData' Copy,
Check,
Link,
FileCode,
DropdownIcon,
Wrench,
} from '@/components/icons/LucideIcons';
import DeleteDialog from '@/components/ui/DeleteDialog';
import { useToast } from '@/contexts/ToastContext';
import { useSettingsData } from '@/hooks/useSettingsData';
interface GroupCardProps { interface GroupCardProps {
group: Group group: Group;
servers: Server[] servers: Server[];
onEdit: (group: Group) => void onEdit: (group: Group) => void;
onDelete: (groupId: string) => void onDelete: (groupId: string) => void;
} }
const GroupCard = ({ const GroupCard = ({ group, servers, onEdit, onDelete }: GroupCardProps) => {
group, const { t } = useTranslation();
servers, const { showToast } = useToast();
onEdit, const { installConfig } = useSettingsData();
onDelete const [showDeleteDialog, setShowDeleteDialog] = useState(false);
}: GroupCardProps) => { const [copied, setCopied] = useState(false);
const { t } = useTranslation() const [showCopyDropdown, setShowCopyDropdown] = useState(false);
const { showToast } = useToast() const [expandedServer, setExpandedServer] = useState<string | null>(null);
const { installConfig } = useSettingsData() const dropdownRef = useRef<HTMLDivElement>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [copied, setCopied] = useState(false)
const [showCopyDropdown, setShowCopyDropdown] = useState(false)
const [expandedServer, setExpandedServer] = useState<string | null>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
// Close dropdown when clicking outside // Close dropdown when clicking outside
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setShowCopyDropdown(false) setShowCopyDropdown(false);
}
} }
};
document.addEventListener('mousedown', handleClickOutside) document.addEventListener('mousedown', handleClickOutside);
return () => { return () => {
document.removeEventListener('mousedown', handleClickOutside) document.removeEventListener('mousedown', handleClickOutside);
} };
}, []) }, []);
const handleEdit = () => { const handleEdit = () => {
onEdit(group) onEdit(group);
} };
const handleDelete = () => { const handleDelete = () => {
setShowDeleteDialog(true) setShowDeleteDialog(true);
} };
const handleConfirmDelete = () => { const handleConfirmDelete = () => {
onDelete(group.id) onDelete(group.id);
setShowDeleteDialog(false) setShowDeleteDialog(false);
} };
const copyToClipboard = (text: string) => { const copyToClipboard = (text: string) => {
if (navigator.clipboard && window.isSecureContext) { if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(() => { navigator.clipboard.writeText(text).then(() => {
setCopied(true) setCopied(true);
setShowCopyDropdown(false) setShowCopyDropdown(false);
showToast(t('common.copySuccess'), 'success') showToast(t('common.copySuccess'), 'success');
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopied(false), 2000);
}) });
} else { } else {
// Fallback for HTTP or unsupported clipboard API // Fallback for HTTP or unsupported clipboard API
const textArea = document.createElement('textarea') const textArea = document.createElement('textarea');
textArea.value = text textArea.value = text;
// Avoid scrolling to bottom // Avoid scrolling to bottom
textArea.style.position = 'fixed' textArea.style.position = 'fixed';
textArea.style.left = '-9999px' textArea.style.left = '-9999px';
document.body.appendChild(textArea) document.body.appendChild(textArea);
textArea.focus() textArea.focus();
textArea.select() textArea.select();
try { try {
document.execCommand('copy') document.execCommand('copy');
setCopied(true) setCopied(true);
setShowCopyDropdown(false) setShowCopyDropdown(false);
showToast(t('common.copySuccess'), 'success') showToast(t('common.copySuccess'), 'success');
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopied(false), 2000);
} catch (err) { } catch (err) {
showToast(t('common.copyFailed') || 'Copy failed', 'error') showToast(t('common.copyFailed') || 'Copy failed', 'error');
console.error('Copy to clipboard failed:', err) console.error('Copy to clipboard failed:', err);
}
document.body.removeChild(textArea)
} }
document.body.removeChild(textArea);
} }
};
const handleCopyId = () => { const handleCopyId = () => {
copyToClipboard(group.id) copyToClipboard(group.id);
} };
const handleCopyUrl = () => { const handleCopyUrl = () => {
copyToClipboard(`${installConfig.baseUrl}/mcp/${group.id}`) copyToClipboard(`${installConfig.baseUrl}/mcp/${group.id}`);
} };
const handleCopyJson = () => { const handleCopyJson = () => {
const jsonConfig = { const jsonConfig = {
@@ -101,23 +105,23 @@ const GroupCard = ({
mcphub: { mcphub: {
url: `${installConfig.baseUrl}/mcp/${group.id}`, url: `${installConfig.baseUrl}/mcp/${group.id}`,
headers: { headers: {
Authorization: "Bearer <your-access-token>" Authorization: 'Bearer <your-access-token>',
} },
} },
} },
} };
copyToClipboard(JSON.stringify(jsonConfig, null, 2)) copyToClipboard(JSON.stringify(jsonConfig, null, 2));
} };
// Helper function to normalize group servers to get server names // Helper function to normalize group servers to get server names
const getServerNames = (servers: string[] | IGroupServerConfig[]): string[] => { const getServerNames = (servers: string[] | IGroupServerConfig[]): string[] => {
return servers.map(server => typeof server === 'string' ? server : server.name); return servers.map((server) => (typeof server === 'string' ? server : server.name));
}; };
// Helper function to get server configuration // Helper function to get server configuration
const getServerConfig = (serverName: string): IGroupServerConfig | undefined => { const getServerConfig = (serverName: string): IGroupServerConfig | undefined => {
const server = group.servers.find(s => const server = group.servers.find((s) =>
typeof s === 'string' ? s === serverName : s.name === serverName typeof s === 'string' ? s === serverName : s.name === serverName,
); );
if (typeof server === 'string') { if (typeof server === 'string') {
return { name: server, tools: 'all' }; return { name: server, tools: 'all' };
@@ -127,11 +131,11 @@ const GroupCard = ({
// Get servers that belong to this group // Get servers that belong to this group
const serverNames = getServerNames(group.servers); const serverNames = getServerNames(group.servers);
const groupServers = servers.filter(server => serverNames.includes(server.name)); const groupServers = servers.filter((server) => serverNames.includes(server.name));
return ( return (
<div className="bg-white shadow rounded-lg p-6 "> <div className="bg-white shadow rounded-lg p-4">
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center">
<div> <div>
<div className="flex items-center"> <div className="flex items-center">
<h2 className="text-xl font-semibold text-gray-800">{group.name}</h2> <h2 className="text-xl font-semibold text-gray-800">{group.name}</h2>
@@ -175,9 +179,7 @@ const GroupCard = ({
</div> </div>
</div> </div>
</div> </div>
{group.description && ( {group.description && <p className="text-gray-600 text-sm mt-1">{group.description}</p>}
<p className="text-gray-600 text-sm mt-1">{group.description}</p>
)}
</div> </div>
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="bg-blue-50 text-blue-700 px-3 py-1 rounded-full text-sm btn-secondary"> <div className="bg-blue-50 text-blue-700 px-3 py-1 rounded-full text-sm btn-secondary">
@@ -200,17 +202,19 @@ const GroupCard = ({
</div> </div>
</div> </div>
<div className="mt-4"> <div className="">
{groupServers.length === 0 ? ( {groupServers.length === 0 ? (
<p className="text-gray-500 italic">{t('groups.noServers')}</p> <p className="text-gray-500 italic">{t('groups.noServers')}</p>
) : ( ) : (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{groupServers.map(server => { {groupServers.map((server) => {
const serverConfig = getServerConfig(server.name); const serverConfig = getServerConfig(server.name);
const hasToolRestrictions = serverConfig && serverConfig.tools !== 'all' && Array.isArray(serverConfig.tools); const hasToolRestrictions =
const toolCount = hasToolRestrictions && Array.isArray(serverConfig?.tools) serverConfig && serverConfig.tools !== 'all' && Array.isArray(serverConfig.tools);
const toolCount =
hasToolRestrictions && Array.isArray(serverConfig?.tools)
? serverConfig.tools.length ? serverConfig.tools.length
: (server.tools?.length || 0); // Show total tool count when all tools are selected : server.tools?.length || 0; // Show total tool count when all tools are selected
const isExpanded = expandedServer === server.name; const isExpanded = expandedServer === server.name;
@@ -219,7 +223,7 @@ const GroupCard = ({
if (hasToolRestrictions && Array.isArray(serverConfig?.tools)) { if (hasToolRestrictions && Array.isArray(serverConfig?.tools)) {
return serverConfig.tools; return serverConfig.tools;
} else if (server.tools && server.tools.length > 0) { } else if (server.tools && server.tools.length > 0) {
return server.tools.map(tool => tool.name); return server.tools.map((tool) => tool.name);
} }
return []; return [];
}; };
@@ -235,9 +239,15 @@ const GroupCard = ({
onClick={handleServerClick} onClick={handleServerClick}
> >
<span className="font-medium text-gray-700 text-sm">{server.name}</span> <span className="font-medium text-gray-700 text-sm">{server.name}</span>
<span className={`inline-block h-2 w-2 rounded-full ${server.status === 'connected' ? 'bg-green-500' : <span
server.status === 'connecting' ? 'bg-yellow-500' : 'bg-red-500' className={`inline-block h-2 w-2 rounded-full ${
}`}></span> server.status === 'connected'
? 'bg-green-500'
: server.status === 'connecting'
? 'bg-yellow-500'
: 'bg-red-500'
}`}
></span>
{toolCount > 0 && ( {toolCount > 0 && (
<span className="text-xs text-blue-600 bg-blue-100 px-2 py-0.5 rounded flex items-center gap-1"> <span className="text-xs text-blue-600 bg-blue-100 px-2 py-0.5 rounded flex items-center gap-1">
<Wrench size={12} /> <Wrench size={12} />
@@ -278,7 +288,7 @@ const GroupCard = ({
isGroup={true} isGroup={true}
/> />
</div> </div>
) );
} };
export default GroupCard export default GroupCard;

View File

@@ -0,0 +1,284 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { apiPost } from '@/utils/fetchInterceptor';
interface GroupImportFormProps {
onSuccess: () => void;
onCancel: () => void;
}
interface ImportGroupConfig {
name: string;
description?: string;
servers?: string[] | Array<{ name: string; tools?: string[] | 'all' }>;
}
interface ImportJsonFormat {
groups: ImportGroupConfig[];
}
const GroupImportForm: React.FC<GroupImportFormProps> = ({ onSuccess, onCancel }) => {
const { t } = useTranslation();
const [jsonInput, setJsonInput] = useState('');
const [error, setError] = useState<string | null>(null);
const [isImporting, setIsImporting] = useState(false);
const [previewGroups, setPreviewGroups] = useState<ImportGroupConfig[] | null>(null);
const examplePlaceholder = `{
"groups": [
{
"name": "AI Assistants",
"servers": ["openai-server", "anthropic-server"]
},
{
"name": "Development Tools",
"servers": [
{
"name": "github-server",
"tools": ["create_issue", "list_repos"]
},
{
"name": "gitlab-server",
"tools": "all"
}
]
}
]
}
Supports:
- Simple server list: ["server1", "server2"]
- Advanced server config: [{"name": "server1", "tools": ["tool1", "tool2"]}]
- All groups will be imported in a single efficient batch operation.`;
const parseAndValidateJson = (input: string): ImportJsonFormat | null => {
try {
const parsed = JSON.parse(input.trim());
// Validate structure
if (!parsed.groups || !Array.isArray(parsed.groups)) {
setError(t('groupImport.invalidFormat'));
return null;
}
// Validate each group
for (const group of parsed.groups) {
if (!group.name || typeof group.name !== 'string') {
setError(t('groupImport.missingName'));
return null;
}
}
return parsed as ImportJsonFormat;
} catch (e) {
setError(t('groupImport.parseError'));
return null;
}
};
const handlePreview = () => {
setError(null);
const parsed = parseAndValidateJson(jsonInput);
if (!parsed) return;
setPreviewGroups(parsed.groups);
};
const handleImport = async () => {
if (!previewGroups) return;
setIsImporting(true);
setError(null);
try {
// Use batch import API for better performance
const result = await apiPost('/groups/batch', {
groups: previewGroups,
});
if (result.success) {
const { successCount, failureCount, results } = result;
if (failureCount > 0) {
const errors = results
.filter((r: any) => !r.success)
.map((r: any) => `${r.name}: ${r.message || t('groupImport.addFailed')}`);
setError(
t('groupImport.partialSuccess', { count: successCount, total: previewGroups.length }) +
'\n' +
errors.join('\n'),
);
}
if (successCount > 0) {
onSuccess();
}
} else {
setError(result.message || t('groupImport.importFailed'));
}
} catch (err) {
console.error('Import error:', err);
setError(t('groupImport.importFailed'));
} finally {
setIsImporting(false);
}
};
const renderServerList = (
servers?: string[] | Array<{ name: string; tools?: string[] | 'all' }>,
) => {
if (!servers || servers.length === 0) {
return <span className="text-gray-500">{t('groups.noServers')}</span>;
}
return (
<div className="space-y-1">
{servers.map((server, idx) => {
if (typeof server === 'string') {
return (
<div key={idx} className="text-sm">
{server}
</div>
);
} else {
return (
<div key={idx} className="text-sm">
{server.name}
{server.tools && server.tools !== 'all' && (
<span className="text-gray-500 ml-2">
({Array.isArray(server.tools) ? server.tools.join(', ') : server.tools})
</span>
)}
{server.tools === 'all' && <span className="text-gray-500 ml-2">(all tools)</span>}
</div>
);
}
})}
</div>
);
};
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white shadow rounded-lg p-6 w-full max-w-4xl max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold text-gray-900">{t('groupImport.title')}</h2>
<button onClick={onCancel} className="text-gray-500 hover:text-gray-700">
</button>
</div>
{error && (
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4 rounded">
<p className="text-red-700 whitespace-pre-wrap">{error}</p>
</div>
)}
{!previewGroups ? (
<div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('groupImport.inputLabel')}
</label>
<textarea
value={jsonInput}
onChange={(e) => setJsonInput(e.target.value)}
className="w-full h-96 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"
placeholder={examplePlaceholder}
/>
<p className="text-xs text-gray-500 mt-2">{t('groupImport.inputHelp')}</p>
</div>
<div className="flex justify-end space-x-4">
<button
onClick={onCancel}
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 btn-secondary"
>
{t('common.cancel')}
</button>
<button
onClick={handlePreview}
disabled={!jsonInput.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 btn-primary"
>
{t('groupImport.preview')}
</button>
</div>
</div>
) : (
<div>
<div className="mb-4">
<h3 className="text-lg font-medium text-gray-900 mb-3">
{t('groupImport.previewTitle')}
</h3>
<div className="space-y-3">
{previewGroups.map((group, index) => (
<div key={index} className="bg-gray-50 p-4 rounded-lg border border-gray-200">
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-medium text-gray-900">{group.name}</h4>
{group.description && (
<p className="text-sm text-gray-600 mt-1">{group.description}</p>
)}
<div className="mt-2 text-sm text-gray-600">
<strong>{t('groups.servers')}:</strong>
<div className="mt-1">{renderServerList(group.servers)}</div>
</div>
</div>
</div>
</div>
))}
</div>
</div>
<div className="flex justify-end space-x-4">
<button
onClick={() => setPreviewGroups(null)}
disabled={isImporting}
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 disabled:opacity-50 btn-secondary"
>
{t('common.back')}
</button>
<button
onClick={handleImport}
disabled={isImporting}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center btn-primary"
>
{isImporting ? (
<>
<svg
className="animate-spin h-4 w-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{t('groupImport.importing')}
</>
) : (
t('groupImport.import')
)}
</button>
</div>
</div>
)}
</div>
</div>
);
};
export default GroupImportForm;

View File

@@ -14,6 +14,10 @@ interface McpServerConfig {
type?: string; type?: string;
url?: string; url?: string;
headers?: Record<string, string>; headers?: Record<string, string>;
openapi?: {
version: string;
url: string;
};
} }
interface ImportJsonFormat { interface ImportJsonFormat {
@@ -29,29 +33,16 @@ const JSONImportForm: React.FC<JSONImportFormProps> = ({ onSuccess, onCancel })
null, null,
); );
const examplePlaceholder = `STDIO example: const examplePlaceholder = `{
{
"mcpServers": { "mcpServers": {
"stdio-server-example": { "stdio-server-example": {
"command": "npx", "command": "npx",
"args": ["-y", "mcp-server-example"] "args": ["-y", "mcp-server-example"]
} },
}
}
SSE example:
{
"mcpServers": {
"sse-server-example": { "sse-server-example": {
"type": "sse", "type": "sse",
"url": "http://localhost:3000" "url": "http://localhost:3000"
} },
}
}
HTTP example:
{
"mcpServers": {
"http-server-example": { "http-server-example": {
"type": "streamable-http", "type": "streamable-http",
"url": "http://localhost:3001", "url": "http://localhost:3001",
@@ -59,9 +50,18 @@ HTTP example:
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": "Bearer your-token" "Authorization": "Bearer your-token"
} }
},
"openapi-server-example": {
"type": "openapi",
"openapi": {
"url": "https://petstore.swagger.io/v2/swagger.json"
} }
} }
}`; }
}
Supports: STDIO, SSE, HTTP (streamable-http), OpenAPI
All servers will be imported in a single efficient batch operation.`;
const parseAndValidateJson = (input: string): ImportJsonFormat | null => { const parseAndValidateJson = (input: string): ImportJsonFormat | null => {
try { try {
@@ -95,6 +95,9 @@ HTTP example:
if (config.headers) { if (config.headers) {
normalizedConfig.headers = config.headers; normalizedConfig.headers = config.headers;
} }
} else if (config.type === 'openapi') {
normalizedConfig.type = 'openapi';
normalizedConfig.openapi = config.openapi;
} else { } else {
// Default to stdio // Default to stdio
normalizedConfig.type = 'stdio'; normalizedConfig.type = 'stdio';
@@ -118,29 +121,19 @@ HTTP example:
setError(null); setError(null);
try { try {
let successCount = 0; // Use batch import API for better performance
const errors: string[] = []; const result = await apiPost('/servers/batch', {
servers: previewServers,
for (const server of previewServers) {
try {
const result = await apiPost('/servers', {
name: server.name,
config: server.config,
}); });
if (result.success) { if (result.success && result.data) {
successCount++; const { successCount, failureCount, results } = result.data;
} else {
errors.push(`${server.name}: ${result.message || t('jsonImport.addFailed')}`); if (failureCount > 0) {
} const errors = results
} catch (err) { .filter((r: any) => !r.success)
errors.push( .map((r: any) => `${r.name}: ${r.message || t('jsonImport.addFailed')}`);
`${server.name}: ${err instanceof Error ? err.message : t('jsonImport.addFailed')}`,
);
}
}
if (errors.length > 0) {
setError( setError(
t('jsonImport.partialSuccess', { count: successCount, total: previewServers.length }) + t('jsonImport.partialSuccess', { count: successCount, total: previewServers.length }) +
'\n' + '\n' +
@@ -151,6 +144,9 @@ HTTP example:
if (successCount > 0) { if (successCount > 0) {
onSuccess(); onSuccess();
} }
} else {
setError(result.message || t('jsonImport.importFailed'));
}
} catch (err) { } catch (err) {
console.error('Import error:', err); console.error('Import error:', err);
setError(t('jsonImport.importFailed')); setError(t('jsonImport.importFailed'));

View File

@@ -15,14 +15,23 @@ interface ServerCardProps {
onEdit: (server: Server) => void; onEdit: (server: Server) => void;
onToggle?: (server: Server, enabled: boolean) => Promise<boolean>; onToggle?: (server: Server, enabled: boolean) => Promise<boolean>;
onRefresh?: () => void; onRefresh?: () => void;
onReload?: (server: Server) => Promise<boolean>;
} }
const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCardProps) => { const ServerCard = ({
server,
onRemove,
onEdit,
onToggle,
onRefresh,
onReload,
}: ServerCardProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { showToast } = useToast(); const { showToast } = useToast();
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [isToggling, setIsToggling] = useState(false); const [isToggling, setIsToggling] = useState(false);
const [isReloading, setIsReloading] = useState(false);
const [showErrorPopover, setShowErrorPopover] = useState(false); const [showErrorPopover, setShowErrorPopover] = useState(false);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const errorPopoverRef = useRef<HTMLDivElement>(null); const errorPopoverRef = useRef<HTMLDivElement>(null);
@@ -64,6 +73,26 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
} }
}; };
const handleReload = async (e: React.MouseEvent) => {
e.stopPropagation();
if (isReloading || !onReload) return;
setIsReloading(true);
try {
const success = await onReload(server);
if (success) {
showToast(t('server.reloadSuccess') || 'Server reloaded successfully', 'success');
} else {
showToast(
t('server.reloadError', { serverName: server.name }) || 'Failed to reload server',
'error',
);
}
} finally {
setIsReloading(false);
}
};
const handleErrorIconClick = (e: React.MouseEvent) => { const handleErrorIconClick = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
setShowErrorPopover(!showErrorPopover); setShowErrorPopover(!showErrorPopover);
@@ -106,6 +135,10 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
e.stopPropagation(); e.stopPropagation();
try { try {
const result = await exportMCPSettings(server.name); const result = await exportMCPSettings(server.name);
if (!result || !result.success || !result.data) {
showToast(result?.message || t('common.copyFailed') || 'Copy failed', 'error');
return;
}
const configJson = JSON.stringify(result.data, null, 2); const configJson = JSON.stringify(result.data, null, 2);
if (navigator.clipboard && window.isSecureContext) { if (navigator.clipboard && window.isSecureContext) {
@@ -206,10 +239,10 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
return ( return (
<> <>
<div <div
className={`bg-white shadow rounded-lg p-6 mb-6 page-card transition-all duration-200 ${server.enabled === false ? 'opacity-60' : ''}`} className={`bg-white shadow rounded-lg mb-6 page-card transition-all duration-200 ${server.enabled === false ? 'opacity-60' : ''}`}
> >
<div <div
className="flex justify-between items-center cursor-pointer" className="flex justify-between items-center cursor-pointer p-4"
onClick={() => setIsExpanded(!isExpanded)} onClick={() => setIsExpanded(!isExpanded)}
> >
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
@@ -326,7 +359,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
? 'bg-green-100 text-green-800 hover:bg-green-200 btn-secondary' ? 'bg-green-100 text-green-800 hover:bg-green-200 btn-secondary'
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-primary' : 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-primary'
}`} }`}
disabled={isToggling} disabled={isToggling || isReloading}
> >
{isToggling {isToggling
? t('common.processing') ? t('common.processing')
@@ -335,6 +368,15 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
: t('server.enable')} : t('server.enable')}
</button> </button>
</div> </div>
{server.enabled !== false && onReload && (
<button
onClick={handleReload}
className="px-3 py-1 bg-purple-100 text-purple-800 rounded hover:bg-purple-200 text-sm btn-secondary disabled:opacity-70 disabled:cursor-not-allowed"
disabled={isReloading || isToggling}
>
{isReloading ? t('common.processing') : t('server.reload')}
</button>
)}
<button <button
onClick={handleRemove} onClick={handleRemove}
className="px-3 py-1 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm btn-danger" className="px-3 py-1 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm btn-danger"
@@ -350,9 +392,9 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
{isExpanded && ( {isExpanded && (
<> <>
{server.tools && ( {server.tools && (
<div className="mt-6"> <div className="px-4">
<h6 <h6
className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`} className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-2`}
> >
{t('server.tools')} {t('server.tools')}
</h6> </h6>
@@ -370,9 +412,9 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
)} )}
{server.prompts && ( {server.prompts && (
<div className="mt-6"> <div className="px-4 pb-2">
<h6 <h6
className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`} className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'}`}
> >
{t('server.prompts')} {t('server.prompts')}
</h6> </h6>

View File

@@ -375,6 +375,7 @@ const ServerForm = ({
? { ? {
url: formData.url, url: formData.url,
...(Object.keys(headers).length > 0 ? { headers } : {}), ...(Object.keys(headers).length > 0 ? { headers } : {}),
...(Object.keys(env).length > 0 ? { env } : {}),
...(oauthConfig ? { oauth: oauthConfig } : {}), ...(oauthConfig ? { oauth: oauthConfig } : {}),
} }
: { : {
@@ -428,7 +429,6 @@ const ServerForm = ({
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input" className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
placeholder="e.g.: time-mcp" placeholder="e.g.: time-mcp"
required required
disabled={isEdit}
/> />
</div> </div>
@@ -978,6 +978,49 @@ const ServerForm = ({
))} ))}
</div> </div>
<div className="mb-4">
<div className="flex justify-between items-center mb-2">
<label className="block text-gray-700 text-sm font-bold">
{t('server.envVars')}
</label>
<button
type="button"
onClick={addEnvVar}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] btn-primary"
>
+
</button>
</div>
{envVars.map((envVar, index) => (
<div key={index} className="flex items-center mb-2">
<div className="flex items-center space-x-2 flex-grow">
<input
type="text"
value={envVar.key}
onChange={(e) => handleEnvVarChange(index, 'key', e.target.value)}
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
placeholder={t('server.key')}
/>
<span className="flex items-center">:</span>
<input
type="text"
value={envVar.value}
onChange={(e) => handleEnvVarChange(index, 'value', e.target.value)}
className="shadow appearance-none border rounded py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline w-1/2 form-input"
placeholder={t('server.value')}
/>
</div>
<button
type="button"
onClick={() => removeEnvVar(index)}
className="bg-gray-200 hover:bg-gray-300 text-gray-700 font-medium py-1 px-2 rounded text-sm flex items-center justify-center min-w-[30px] min-h-[30px] ml-2 btn-danger"
>
-
</button>
</div>
))}
</div>
<div className="mb-4"> <div className="mb-4">
<div <div
className="flex items-center justify-between cursor-pointer bg-gray-50 hover:bg-gray-100 p-3 rounded border border-gray-200" className="flex items-center justify-between cursor-pointer bg-gray-50 hover:bg-gray-100 p-3 rounded border border-gray-200"

View File

@@ -21,7 +21,14 @@ interface DynamicFormProps {
title?: string; // Optional title to display instead of default parameters title title?: string; // Optional title to display instead of default parameters title
} }
const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, loading = false, storageKey, title }) => { const DynamicForm: React.FC<DynamicFormProps> = ({
schema,
onSubmit,
onCancel,
loading = false,
storageKey,
title,
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [formValues, setFormValues] = useState<Record<string, any>>({}); const [formValues, setFormValues] = useState<Record<string, any>>({});
const [errors, setErrors] = useState<Record<string, string>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
@@ -40,9 +47,14 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
description: obj.description, description: obj.description,
enum: obj.enum, enum: obj.enum,
default: obj.default, default: obj.default,
properties: obj.properties ? Object.fromEntries( properties: obj.properties
Object.entries(obj.properties).map(([key, value]) => [key, convertProperty(value)]) ? Object.fromEntries(
) : undefined, Object.entries(obj.properties).map(([key, value]) => [
key,
convertProperty(value),
]),
)
: undefined,
required: obj.required, required: obj.required,
items: obj.items ? convertProperty(obj.items) : undefined, items: obj.items ? convertProperty(obj.items) : undefined,
}; };
@@ -52,9 +64,14 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
return { return {
type: schema.type, type: schema.type,
properties: schema.properties ? Object.fromEntries( properties: schema.properties
Object.entries(schema.properties).map(([key, value]) => [key, convertProperty(value)]) ? Object.fromEntries(
) : undefined, Object.entries(schema.properties).map(([key, value]) => [
key,
convertProperty(value),
]),
)
: undefined,
required: schema.required, required: schema.required,
}; };
}; };
@@ -167,7 +184,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
}; };
const handleInputChange = (path: string, value: any) => { const handleInputChange = (path: string, value: any) => {
setFormValues(prev => { setFormValues((prev) => {
const newValues = { ...prev }; const newValues = { ...prev };
const keys = path.split('.'); const keys = path.split('.');
let current = newValues; let current = newValues;
@@ -195,7 +212,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
// Clear error for this field // Clear error for this field
if (errors[path]) { if (errors[path]) {
setErrors(prev => { setErrors((prev) => {
const newErrors = { ...prev }; const newErrors = { ...prev };
delete newErrors[path]; delete newErrors[path];
return newErrors; return newErrors;
@@ -209,10 +226,16 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
if (schema.type === 'object' && schema.properties) { if (schema.type === 'object' && schema.properties) {
Object.entries(schema.properties).forEach(([key, propSchema]) => { Object.entries(schema.properties).forEach(([key, propSchema]) => {
const fullPath = path ? `${path}.${key}` : key; const fullPath = path ? `${path}.${key}` : key;
const value = getNestedValue(values, fullPath); const value = values?.[key];
// Check required fields // Check required fields
if (schema.required?.includes(key) && (value === undefined || value === null || value === '')) { if (
schema.required?.includes(key) &&
(value === undefined ||
value === null ||
value === '' ||
(Array.isArray(value) && value.length === 0))
) {
newErrors[fullPath] = `${key} is required`; newErrors[fullPath] = `${key} is required`;
return; return;
} }
@@ -223,7 +246,10 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
newErrors[fullPath] = `${key} must be a string`; newErrors[fullPath] = `${key} must be a string`;
} else if (propSchema.type === 'number' && typeof value !== 'number') { } else if (propSchema.type === 'number' && typeof value !== 'number') {
newErrors[fullPath] = `${key} must be a number`; newErrors[fullPath] = `${key} must be a number`;
} else if (propSchema.type === 'integer' && (!Number.isInteger(value) || typeof value !== 'number')) { } else if (
propSchema.type === 'integer' &&
(!Number.isInteger(value) || typeof value !== 'number')
) {
newErrors[fullPath] = `${key} must be an integer`; newErrors[fullPath] = `${key} must be an integer`;
} else if (propSchema.type === 'boolean' && typeof value !== 'boolean') { } else if (propSchema.type === 'boolean' && typeof value !== 'boolean') {
newErrors[fullPath] = `${key} must be a boolean`; newErrors[fullPath] = `${key} must be a boolean`;
@@ -260,7 +286,12 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
return path.split('.').reduce((current, key) => current?.[key], obj); return path.split('.').reduce((current, key) => current?.[key], obj);
}; };
const renderObjectField = (key: string, schema: JsonSchema, currentValue: any, onChange: (value: any) => void): React.ReactNode => { const renderObjectField = (
key: string,
schema: JsonSchema,
currentValue: any,
onChange: (value: any) => void,
): React.ReactNode => {
const value = currentValue?.[key]; const value = currentValue?.[key];
if (schema.type === 'string') { if (schema.type === 'string') {
@@ -299,7 +330,12 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
step={schema.type === 'integer' ? '1' : 'any'} step={schema.type === 'integer' ? '1' : 'any'}
value={value ?? ''} value={value ?? ''}
onChange={(e) => { onChange={(e) => {
const val = e.target.value === '' ? '' : schema.type === 'integer' ? parseInt(e.target.value) : parseFloat(e.target.value); const val =
e.target.value === ''
? ''
: schema.type === 'integer'
? parseInt(e.target.value)
: parseFloat(e.target.value);
onChange(val); onChange(val);
}} }}
className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500 form-input" className="w-full border rounded-md px-2 py-1 text-sm border-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500 form-input"
@@ -341,7 +377,11 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-6"> <div key={fullPath} className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
{key} {key}
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>} {(path
? getNestedValue(jsonSchema, path)?.required?.includes(key)
: jsonSchema.required?.includes(key)) && (
<span className="text-status-red ml-1">*</span>
)}
</label> </label>
{propSchema.description && ( {propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p> <p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
@@ -349,9 +389,11 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div className="border border-gray-200 rounded-md p-3 bg-gray-50"> <div className="border border-gray-200 rounded-md p-3 bg-gray-50">
{arrayValue.map((item: any, index: number) => ( {arrayValue.map((item: any, index: number) => (
<div key={index} className="mb-3 p-3 bg-white border rounded-md"> <div key={index} className="mb-3 p-3 bg-white border border-gray-200 rounded-md">
<div className="flex justify-between items-center mb-2"> <div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium text-gray-600">{t('tool.item', { index: index + 1 })}</span> <span className="text-sm font-medium text-gray-600">
{t('tool.item', { index: index + 1 })}
</span>
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -388,7 +430,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={objKey}> <div key={objKey}>
<label className="block text-xs font-medium text-gray-600 mb-1"> <label className="block text-xs font-medium text-gray-600 mb-1">
{objKey} {objKey}
{propSchema.items?.required?.includes(objKey) && <span className="text-status-red ml-1">*</span>} {propSchema.items?.required?.includes(objKey) && (
<span className="text-status-red ml-1">*</span>
)}
</label> </label>
{renderObjectField(objKey, objSchema as JsonSchema, item, (newValue) => { {renderObjectField(objKey, objSchema as JsonSchema, item, (newValue) => {
const newArray = [...arrayValue]; const newArray = [...arrayValue];
@@ -437,16 +481,20 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-6"> <div key={fullPath} className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
{key} {key}
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>} {(path
? getNestedValue(jsonSchema, path)?.required?.includes(key)
: jsonSchema.required?.includes(key)) && (
<span className="text-status-red ml-1">*</span>
)}
</label> </label>
{propSchema.description && ( {propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p> <p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
)} )}
<div className="border border-gray-200 rounded-md p-4 bg-gray-50"> <div className="border border-gray-200 rounded-md p-4 bg-gray-50">
{Object.entries(propSchema.properties).map(([objKey, objSchema]) => ( {Object.entries(propSchema.properties).map(([objKey, objSchema]) =>
renderField(objKey, objSchema as JsonSchema, fullPath) renderField(objKey, objSchema as JsonSchema, fullPath),
))} )}
</div> </div>
{error && <p className="text-status-red text-xs mt-1">{error}</p>} {error && <p className="text-status-red text-xs mt-1">{error}</p>}
@@ -458,7 +506,11 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-4"> <div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
{key} {key}
{(path ? getNestedValue(jsonSchema, path)?.required?.includes(key) : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>} {(path
? getNestedValue(jsonSchema, path)?.required?.includes(key)
: jsonSchema.required?.includes(key)) && (
<span className="text-status-red ml-1">*</span>
)}
<span className="text-xs text-gray-500 ml-1">(JSON object)</span> <span className="text-xs text-gray-500 ml-1">(JSON object)</span>
</label> </label>
{propSchema.description && ( {propSchema.description && (
@@ -483,13 +535,16 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
</div> </div>
); );
} }
} if (propSchema.type === 'string') { }
if (propSchema.type === 'string') {
if (propSchema.enum) { if (propSchema.enum) {
return ( return (
<div key={fullPath} className="mb-4"> <div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
{key} {key}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>} {(path ? false : jsonSchema.required?.includes(key)) && (
<span className="text-status-red ml-1">*</span>
)}
</label> </label>
{propSchema.description && ( {propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p> <p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
@@ -514,7 +569,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-4"> <div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
{key} {key}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>} {(path ? false : jsonSchema.required?.includes(key)) && (
<span className="text-status-red ml-1">*</span>
)}
</label> </label>
{propSchema.description && ( {propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p> <p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
@@ -529,12 +586,15 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
</div> </div>
); );
} }
} if (propSchema.type === 'number' || propSchema.type === 'integer') { }
if (propSchema.type === 'number' || propSchema.type === 'integer') {
return ( return (
<div key={fullPath} className="mb-4"> <div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
{key} {key}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>} {(path ? false : jsonSchema.required?.includes(key)) && (
<span className="text-status-red ml-1">*</span>
)}
</label> </label>
{propSchema.description && ( {propSchema.description && (
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p> <p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
@@ -544,7 +604,12 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
step={propSchema.type === 'integer' ? '1' : 'any'} step={propSchema.type === 'integer' ? '1' : 'any'}
value={value !== undefined && value !== null ? value : ''} value={value !== undefined && value !== null ? value : ''}
onChange={(e) => { onChange={(e) => {
const val = e.target.value === '' ? '' : propSchema.type === 'integer' ? parseInt(e.target.value) : parseFloat(e.target.value); const val =
e.target.value === ''
? ''
: propSchema.type === 'integer'
? parseInt(e.target.value)
: parseFloat(e.target.value);
handleInputChange(fullPath, val); handleInputChange(fullPath, val);
}} }}
className={`w-full border rounded-md px-3 py-2 form-input ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`} className={`w-full border rounded-md px-3 py-2 form-input ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
@@ -566,7 +631,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
/> />
<label className="ml-2 block text-sm text-gray-700"> <label className="ml-2 block text-sm text-gray-700">
{key} {key}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>} {(path ? false : jsonSchema.required?.includes(key)) && (
<span className="text-status-red ml-1">*</span>
)}
</label> </label>
</div> </div>
{propSchema.description && ( {propSchema.description && (
@@ -580,7 +647,9 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<div key={fullPath} className="mb-4"> <div key={fullPath} className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
{key} {key}
{(path ? false : jsonSchema.required?.includes(key)) && <span className="text-status-red ml-1">*</span>} {(path ? false : jsonSchema.required?.includes(key)) && (
<span className="text-status-red ml-1">*</span>
)}
<span className="text-xs text-gray-500 ml-1">({propSchema.type})</span> <span className="text-xs text-gray-500 ml-1">({propSchema.type})</span>
</label> </label>
{propSchema.description && ( {propSchema.description && (
@@ -631,7 +700,8 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<button <button
type="button" type="button"
onClick={switchToFormMode} onClick={switchToFormMode}
className={`px-3 py-1 text-sm rounded-md transition-colors ${!isJsonMode className={`px-3 py-1 text-sm rounded-md transition-colors ${
!isJsonMode
? 'bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary' ? 'bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary'
: 'text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary' : 'text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary'
}`} }`}
@@ -641,7 +711,8 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
<button <button
type="button" type="button"
onClick={switchToJsonMode} onClick={switchToJsonMode}
className={`px-3 py-1 text-sm rounded-md transition-colors ${isJsonMode className={`px-3 py-1 text-sm rounded-md transition-colors ${
isJsonMode
? 'px-4 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary' ? 'px-4 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary'
: 'text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary' : 'text-sm text-gray-600 bg-gray-200 rounded hover:bg-gray-300 btn-secondary'
}`} }`}
@@ -662,7 +733,8 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
value={jsonText} value={jsonText}
onChange={(e) => handleJsonTextChange(e.target.value)} onChange={(e) => handleJsonTextChange(e.target.value)}
placeholder={`{\n "key": "value"\n}`} placeholder={`{\n "key": "value"\n}`}
className={`w-full h-64 border rounded-md px-3 py-2 font-mono text-sm resize-y form-input ${jsonError ? 'border-red-500' : 'border-gray-300' className={`w-full h-64 border rounded-md px-3 py-2 font-mono text-sm resize-y form-input ${
jsonError ? 'border-red-500' : 'border-gray-300'
} focus:outline-none focus:ring-2 focus:ring-blue-500`} } focus:outline-none focus:ring-2 focus:ring-blue-500`}
/> />
{jsonError && <p className="text-status-red text-xs mt-1">{jsonError}</p>} {jsonError && <p className="text-status-red text-xs mt-1">{jsonError}</p>}
@@ -696,7 +768,7 @@ const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, l
/* Form Mode */ /* Form Mode */
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{Object.entries(jsonSchema.properties || {}).map(([key, propSchema]) => {Object.entries(jsonSchema.properties || {}).map(([key, propSchema]) =>
renderField(key, propSchema) renderField(key, propSchema),
)} )}
<div className="flex justify-end space-x-2 pt-4"> <div className="flex justify-end space-x-2 pt-4">

View File

@@ -0,0 +1,161 @@
import React, { useState, useRef, useEffect } from 'react';
import { Check, ChevronDown, X } from 'lucide-react';
interface MultiSelectProps {
options: { value: string; label: string }[];
selected: string[];
onChange: (selected: string[]) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
}
export const MultiSelect: React.FC<MultiSelectProps> = ({
options,
selected,
onChange,
placeholder = 'Select items...',
disabled = false,
className = '',
}) => {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
setSearchTerm('');
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const filteredOptions = options.filter((option) =>
option.label.toLowerCase().includes(searchTerm.toLowerCase()),
);
const handleToggleOption = (value: string) => {
if (disabled) return;
const newSelected = selected.includes(value)
? selected.filter((item) => item !== value)
: [...selected, value];
onChange(newSelected);
};
const handleRemoveItem = (value: string, e: React.MouseEvent) => {
e.stopPropagation();
if (disabled) return;
onChange(selected.filter((item) => item !== value));
};
const handleToggleDropdown = () => {
if (disabled) return;
setIsOpen(!isOpen);
if (!isOpen) {
setTimeout(() => inputRef.current?.focus(), 0);
}
};
const getSelectedLabels = () => {
return selected
.map((value) => options.find((opt) => opt.value === value)?.label || value)
.filter(Boolean);
};
return (
<div ref={dropdownRef} className={`relative ${className}`}>
{/* Selected items display */}
<div
onClick={handleToggleDropdown}
className={`
min-h-[38px] w-full px-3 py-1.5 border rounded-md shadow-sm
flex flex-wrap items-center gap-1.5 cursor-pointer
transition-all duration-200
${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white hover:border-blue-400'}
${isOpen ? 'border-blue-500 ring-1 ring-blue-500' : 'border-gray-300'}
`}
>
{selected.length > 0 ? (
<>
{getSelectedLabels().map((label, index) => (
<span
key={selected[index]}
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800"
>
{label}
{!disabled && (
<button
type="button"
onClick={(e) => handleRemoveItem(selected[index], e)}
className="ml-1 hover:bg-blue-200 rounded-full p-0.5 transition-colors"
>
<X className="h-3 w-3" />
</button>
)}
</span>
))}
</>
) : (
<span className="text-gray-400 text-sm">{placeholder}</span>
)}
<div className="flex-1"></div>
<ChevronDown
className={`h-4 w-4 text-gray-400 transition-transform duration-200 ${isOpen ? 'transform rotate-180' : ''}`}
/>
</div>
{/* Dropdown menu */}
{isOpen && !disabled && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-hidden">
{/* Search input */}
<div className="p-2 border-b border-gray-200">
<input
ref={inputRef}
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search..."
className="w-full px-3 py-1.5 text-sm border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
onClick={(e) => e.stopPropagation()}
/>
</div>
{/* Options list */}
<div className="max-h-48 overflow-y-auto">
{filteredOptions.length > 0 ? (
filteredOptions.map((option) => {
const isSelected = selected.includes(option.value);
return (
<div
key={option.value}
onClick={() => handleToggleOption(option.value)}
className={`
px-3 py-2 cursor-pointer flex items-center justify-between
transition-colors duration-150
${isSelected ? 'bg-blue-50 text-blue-700' : 'hover:bg-gray-100'}
`}
>
<span className="text-sm">{option.label}</span>
{isSelected && <Check className="h-4 w-4 text-blue-600" />}
</div>
);
})
) : (
<div className="px-3 py-2 text-sm text-gray-500 text-center">
{searchTerm ? 'No results found' : 'No options available'}
</div>
)}
</div>
</div>
)}
</div>
);
};

View File

@@ -1,16 +1,20 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
interface PaginationProps { interface PaginationProps {
currentPage: number; currentPage: number;
totalPages: number; totalPages: number;
onPageChange: (page: number) => void; onPageChange: (page: number) => void;
disabled?: boolean;
} }
const Pagination: React.FC<PaginationProps> = ({ const Pagination: React.FC<PaginationProps> = ({
currentPage, currentPage,
totalPages, totalPages,
onPageChange onPageChange,
disabled = false
}) => { }) => {
const { t } = useTranslation();
// Generate page buttons // Generate page buttons
const getPageButtons = () => { const getPageButtons = () => {
const buttons = []; const buttons = [];
@@ -95,26 +99,26 @@ const Pagination: React.FC<PaginationProps> = ({
<div className="flex justify-center items-center my-6"> <div className="flex justify-center items-center my-6">
<button <button
onClick={() => onPageChange(Math.max(1, currentPage - 1))} onClick={() => onPageChange(Math.max(1, currentPage - 1))}
disabled={currentPage === 1} disabled={disabled || currentPage === 1}
className={`px-3 py-1 rounded mr-2 ${currentPage === 1 className={`px-3 py-1 rounded mr-2 ${disabled || currentPage === 1
? 'bg-gray-100 text-gray-400 cursor-not-allowed' ? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary' : 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
}`} }`}
> >
&laquo; Prev &laquo; {t('common.previous')}
</button> </button>
<div className="flex">{getPageButtons()}</div> <div className="flex">{getPageButtons()}</div>
<button <button
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))} onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages} disabled={disabled || currentPage === totalPages}
className={`px-3 py-1 rounded ml-2 ${currentPage === totalPages className={`px-3 py-1 rounded ml-2 ${disabled || currentPage === totalPages
? 'bg-gray-100 text-gray-400 cursor-not-allowed' ? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary' : 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
}`} }`}
> >
Next &raquo; {t('common.next')} &raquo;
</button> </button>
</div> </div>
); );

View File

@@ -1,166 +1,186 @@
import { useState, useCallback, useRef, useEffect } from 'react' import { useState, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next';
import { Prompt } from '@/types' import { Prompt } from '@/types';
import { ChevronDown, ChevronRight, Play, Loader, Edit, Check } from '@/components/icons/LucideIcons' import {
import { Switch } from './ToggleGroup' ChevronDown,
import { getPrompt, PromptCallResult } from '@/services/promptService' ChevronRight,
import { useSettingsData } from '@/hooks/useSettingsData' Play,
import DynamicForm from './DynamicForm' Loader,
import PromptResult from './PromptResult' Edit,
Check,
} from '@/components/icons/LucideIcons';
import { Switch } from './ToggleGroup';
import { getPrompt, updatePromptDescription, PromptCallResult } from '@/services/promptService';
import { useSettingsData } from '@/hooks/useSettingsData';
import DynamicForm from './DynamicForm';
import PromptResult from './PromptResult';
import { useToast } from '@/contexts/ToastContext';
interface PromptCardProps { interface PromptCardProps {
server: string server: string;
prompt: Prompt prompt: Prompt;
onToggle?: (promptName: string, enabled: boolean) => void onToggle?: (promptName: string, enabled: boolean) => void;
onDescriptionUpdate?: (promptName: string, description: string) => void onDescriptionUpdate?: (promptName: string, description: string) => void;
} }
const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCardProps) => { const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCardProps) => {
const { t } = useTranslation() const { t } = useTranslation();
const { nameSeparator } = useSettingsData() const { showToast } = useToast();
const [isExpanded, setIsExpanded] = useState(false) const { nameSeparator } = useSettingsData();
const [showRunForm, setShowRunForm] = useState(false) const [isExpanded, setIsExpanded] = useState(false);
const [isRunning, setIsRunning] = useState(false) const [showRunForm, setShowRunForm] = useState(false);
const [result, setResult] = useState<PromptCallResult | null>(null) const [isRunning, setIsRunning] = useState(false);
const [isEditingDescription, setIsEditingDescription] = useState(false) const [result, setResult] = useState<PromptCallResult | null>(null);
const [customDescription, setCustomDescription] = useState(prompt.description || '') const [isEditingDescription, setIsEditingDescription] = useState(false);
const descriptionInputRef = useRef<HTMLInputElement>(null) const [customDescription, setCustomDescription] = useState(prompt.description || '');
const descriptionTextRef = useRef<HTMLSpanElement>(null) const descriptionInputRef = useRef<HTMLInputElement>(null);
const [textWidth, setTextWidth] = useState<number>(0) const descriptionTextRef = useRef<HTMLSpanElement>(null);
const [textWidth, setTextWidth] = useState<number>(0);
// Focus the input when editing mode is activated // Focus the input when editing mode is activated
useEffect(() => { useEffect(() => {
if (isEditingDescription && descriptionInputRef.current) { if (isEditingDescription && descriptionInputRef.current) {
descriptionInputRef.current.focus() descriptionInputRef.current.focus();
// Set input width to match text width // Set input width to match text width
if (textWidth > 0) { if (textWidth > 0) {
descriptionInputRef.current.style.width = `${textWidth + 20}px` // Add some padding descriptionInputRef.current.style.width = `${textWidth + 20}px`; // Add some padding
} }
} }
}, [isEditingDescription, textWidth]) }, [isEditingDescription, textWidth]);
// Measure text width when not editing // Measure text width when not editing
useEffect(() => { useEffect(() => {
if (!isEditingDescription && descriptionTextRef.current) { if (!isEditingDescription && descriptionTextRef.current) {
setTextWidth(descriptionTextRef.current.offsetWidth) setTextWidth(descriptionTextRef.current.offsetWidth);
} }
}, [isEditingDescription, customDescription]) }, [isEditingDescription, customDescription]);
// Generate a unique key for localStorage based on prompt name and server // Generate a unique key for localStorage based on prompt name and server
const getStorageKey = useCallback(() => { const getStorageKey = useCallback(() => {
return `mcphub_prompt_form_${server ? `${server}_` : ''}${prompt.name}` return `mcphub_prompt_form_${server ? `${server}_` : ''}${prompt.name}`;
}, [prompt.name, server]) }, [prompt.name, server]);
// Clear form data from localStorage // Clear form data from localStorage
const clearStoredFormData = useCallback(() => { const clearStoredFormData = useCallback(() => {
localStorage.removeItem(getStorageKey()) localStorage.removeItem(getStorageKey());
}, [getStorageKey]) }, [getStorageKey]);
const handleToggle = (enabled: boolean) => { const handleToggle = (enabled: boolean) => {
if (onToggle) { if (onToggle) {
onToggle(prompt.name, enabled) onToggle(prompt.name, enabled);
}
} }
};
const handleDescriptionEdit = () => { const handleDescriptionEdit = () => {
setIsEditingDescription(true) setIsEditingDescription(true);
} };
const handleDescriptionSave = async () => { const handleDescriptionSave = async () => {
// For now, we'll just update the local state setIsEditingDescription(false);
// In a real implementation, you would call an API to update the description try {
setIsEditingDescription(false) const result = await updatePromptDescription(server, prompt.name, customDescription);
if (result.success) {
showToast(t('prompt.descriptionUpdateSuccess'), 'success');
if (onDescriptionUpdate) { if (onDescriptionUpdate) {
onDescriptionUpdate(prompt.name, customDescription) onDescriptionUpdate(prompt.name, customDescription);
} }
} else {
showToast(result.error || t('prompt.descriptionUpdateFailed'), 'error');
// Revert to original description on failure
setCustomDescription(prompt.description || '');
} }
} catch (error) {
console.error('Error updating prompt description:', error);
showToast(t('prompt.descriptionUpdateFailed'), 'error');
// Revert to original description on failure
setCustomDescription(prompt.description || '');
}
};
const handleDescriptionChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleDescriptionChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setCustomDescription(e.target.value) setCustomDescription(e.target.value);
} };
const handleDescriptionKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleDescriptionKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
handleDescriptionSave() handleDescriptionSave();
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
setCustomDescription(prompt.description || '') setCustomDescription(prompt.description || '');
setIsEditingDescription(false) setIsEditingDescription(false);
}
} }
};
const handleGetPrompt = async (arguments_: Record<string, any>) => { const handleGetPrompt = async (arguments_: Record<string, any>) => {
setIsRunning(true) setIsRunning(true);
try { try {
const result = await getPrompt({ promptName: prompt.name, arguments: arguments_ }, server) const result = await getPrompt({ promptName: prompt.name, arguments: arguments_ }, server);
console.log('GetPrompt result:', result) console.log('GetPrompt result:', result);
setResult({ setResult({
success: result.success, success: result.success,
data: result.data, data: result.data,
error: result.error error: result.error,
}) });
// Clear form data on successful submission // Clear form data on successful submission
// clearStoredFormData() // clearStoredFormData()
} catch (error) { } catch (error) {
setResult({ setResult({
success: false, success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred', error: error instanceof Error ? error.message : 'Unknown error occurred',
}) });
} finally { } finally {
setIsRunning(false) setIsRunning(false);
}
} }
};
const handleCancelRun = () => { const handleCancelRun = () => {
setShowRunForm(false) setShowRunForm(false);
// Clear form data when cancelled // Clear form data when cancelled
clearStoredFormData() clearStoredFormData();
setResult(null) setResult(null);
} };
const handleCloseResult = () => { const handleCloseResult = () => {
setResult(null) setResult(null);
} };
// Convert prompt arguments to ToolInputSchema format for DynamicForm // Convert prompt arguments to ToolInputSchema format for DynamicForm
const convertToSchema = () => { const convertToSchema = () => {
if (!prompt.arguments || prompt.arguments.length === 0) { if (!prompt.arguments || prompt.arguments.length === 0) {
return { type: 'object', properties: {}, required: [] } return { type: 'object', properties: {}, required: [] };
} }
const properties: Record<string, any> = {} const properties: Record<string, any> = {};
const required: string[] = [] const required: string[] = [];
prompt.arguments.forEach(arg => { prompt.arguments.forEach((arg) => {
properties[arg.name] = { properties[arg.name] = {
type: 'string', // Default to string for prompts type: 'string', // Default to string for prompts
description: arg.description || '' description: arg.description || '',
} };
if (arg.required) { if (arg.required) {
required.push(arg.name) required.push(arg.name);
} }
}) });
return { return {
type: 'object', type: 'object',
properties, properties,
required required,
} };
} };
return ( return (
<div className="bg-white border border-gray-200 shadow rounded-lg p-4 mb-4"> <div className="bg-white border border-gray-200 shadow rounded-lg mb-4">
<div <div
className="flex justify-between items-center cursor-pointer" className="flex justify-between items-center p-2 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)} onClick={() => setIsExpanded(!isExpanded)}
> >
<div className="flex-1"> <div className="flex-1">
<h3 className="text-lg font-medium text-gray-900"> <h3 className="text-lg font-medium text-gray-900">
{prompt.name.replace(server + nameSeparator, '')} {prompt.name.replace(server + nameSeparator, '')}
{prompt.title && ( {prompt.title && (
<span className="ml-2 text-sm font-normal text-gray-600"> <span className="ml-2 text-sm font-normal text-gray-600">{prompt.title}</span>
{prompt.title}
</span>
)} )}
<span className="ml-2 text-sm font-normal text-gray-500 inline-flex items-center"> <span className="ml-2 text-sm font-normal text-gray-500 inline-flex items-center">
{isEditingDescription ? ( {isEditingDescription ? (
@@ -175,14 +195,14 @@ const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCar
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{ style={{
minWidth: '100px', minWidth: '100px',
width: textWidth > 0 ? `${textWidth + 20}px` : 'auto' width: textWidth > 0 ? `${textWidth + 20}px` : 'auto',
}} }}
/> />
<button <button
className="ml-2 p-1 text-green-600 hover:text-green-800 cursor-pointer transition-colors" className="ml-2 p-1 text-green-600 hover:text-green-800 cursor-pointer transition-colors"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation();
handleDescriptionSave() handleDescriptionSave();
}} }}
> >
<Check size={16} /> <Check size={16} />
@@ -190,12 +210,14 @@ const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCar
</> </>
) : ( ) : (
<> <>
<span ref={descriptionTextRef}>{customDescription || t('tool.noDescription')}</span> <span ref={descriptionTextRef}>
{customDescription || t('tool.noDescription')}
</span>
<button <button
className="ml-2 p-1 text-gray-500 hover:text-blue-600 cursor-pointer transition-colors" className="ml-2 p-1 text-gray-500 hover:text-blue-600 cursor-pointer transition-colors"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation();
handleDescriptionEdit() handleDescriptionEdit();
}} }}
> >
<Edit size={14} /> <Edit size={14} />
@@ -206,10 +228,7 @@ const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCar
</h3> </h3>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div <div className="flex items-center space-x-2" onClick={(e) => e.stopPropagation()}>
className="flex items-center space-x-2"
onClick={(e) => e.stopPropagation()}
>
{prompt.enabled !== undefined && ( {prompt.enabled !== undefined && (
<Switch <Switch
checked={prompt.enabled} checked={prompt.enabled}
@@ -220,18 +239,14 @@ const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCar
</div> </div>
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation();
setIsExpanded(true) // Ensure card is expanded when showing run form setIsExpanded(true); // Ensure card is expanded when showing run form
setShowRunForm(true) 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" 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} disabled={isRunning || !prompt.enabled}
> >
{isRunning ? ( {isRunning ? <Loader size={14} className="animate-spin" /> : <Play size={14} />}
<Loader size={14} className="animate-spin" />
) : (
<Play size={14} />
)}
<span>{isRunning ? t('tool.running') : t('tool.run')}</span> <span>{isRunning ? t('tool.running') : t('tool.run')}</span>
</button> </button>
<button className="text-gray-400 hover:text-gray-600"> <button className="text-gray-400 hover:text-gray-600">
@@ -251,7 +266,9 @@ const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCar
onCancel={handleCancelRun} onCancel={handleCancelRun}
loading={isRunning} loading={isRunning}
storageKey={getStorageKey()} storageKey={getStorageKey()}
title={t('prompt.runPromptWithName', { name: prompt.name.replace(server + nameSeparator, '') })} title={t('prompt.runPromptWithName', {
name: prompt.name.replace(server + nameSeparator, ''),
})}
/> />
{/* Prompt Result */} {/* Prompt Result */}
{result && ( {result && (
@@ -278,9 +295,7 @@ const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCar
<p className="text-sm text-gray-600 mt-1">{arg.description}</p> <p className="text-sm text-gray-600 mt-1">{arg.description}</p>
)} )}
</div> </div>
<div className="text-xs text-gray-500 ml-2"> <div className="text-xs text-gray-500 ml-2">{arg.title || ''}</div>
{arg.title || ''}
</div>
</div> </div>
))} ))}
</div> </div>
@@ -296,7 +311,7 @@ const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCar
</div> </div>
)} )}
</div> </div>
) );
} };
export default PromptCard export default PromptCard;

View File

@@ -1,19 +1,27 @@
import { useState, useCallback, useRef, useEffect } from 'react' import { useState, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next';
import { Tool } from '@/types' import { Tool } from '@/types';
import { ChevronDown, ChevronRight, Play, Loader, Edit, Check, Copy } from '@/components/icons/LucideIcons' import {
import { callTool, ToolCallResult, updateToolDescription } from '@/services/toolService' ChevronDown,
import { useSettingsData } from '@/hooks/useSettingsData' ChevronRight,
import { useToast } from '@/contexts/ToastContext' Play,
import { Switch } from './ToggleGroup' Loader,
import DynamicForm from './DynamicForm' Edit,
import ToolResult from './ToolResult' Check,
Copy,
} from '@/components/icons/LucideIcons';
import { callTool, ToolCallResult, updateToolDescription } from '@/services/toolService';
import { useSettingsData } from '@/hooks/useSettingsData';
import { useToast } from '@/contexts/ToastContext';
import { Switch } from './ToggleGroup';
import DynamicForm from './DynamicForm';
import ToolResult from './ToolResult';
interface ToolCardProps { interface ToolCardProps {
server: string server: string;
tool: Tool tool: Tool;
onToggle?: (toolName: string, enabled: boolean) => void onToggle?: (toolName: string, enabled: boolean) => void;
onDescriptionUpdate?: (toolName: string, description: string) => void onDescriptionUpdate?: (toolName: string, description: string) => void;
} }
// Helper to check for "empty" values // Helper to check for "empty" values
@@ -26,165 +34,173 @@ function isEmptyValue(value: any): boolean {
} }
const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps) => { const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps) => {
const { t } = useTranslation() const { t } = useTranslation();
const { showToast } = useToast() const { showToast } = useToast();
const { nameSeparator } = useSettingsData() const { nameSeparator } = useSettingsData();
const [isExpanded, setIsExpanded] = useState(false) const [isExpanded, setIsExpanded] = useState(false);
const [showRunForm, setShowRunForm] = useState(false) const [showRunForm, setShowRunForm] = useState(false);
const [isRunning, setIsRunning] = useState(false) const [isRunning, setIsRunning] = useState(false);
const [result, setResult] = useState<ToolCallResult | null>(null) const [result, setResult] = useState<ToolCallResult | null>(null);
const [isEditingDescription, setIsEditingDescription] = useState(false) const [isEditingDescription, setIsEditingDescription] = useState(false);
const [customDescription, setCustomDescription] = useState(tool.description || '') const [customDescription, setCustomDescription] = useState(tool.description || '');
const descriptionInputRef = useRef<HTMLInputElement>(null) const descriptionInputRef = useRef<HTMLInputElement>(null);
const descriptionTextRef = useRef<HTMLSpanElement>(null) const descriptionTextRef = useRef<HTMLSpanElement>(null);
const [textWidth, setTextWidth] = useState<number>(0) const [textWidth, setTextWidth] = useState<number>(0);
const [copiedToolName, setCopiedToolName] = useState(false) const [copiedToolName, setCopiedToolName] = useState(false);
// Focus the input when editing mode is activated // Focus the input when editing mode is activated
useEffect(() => { useEffect(() => {
if (isEditingDescription && descriptionInputRef.current) { if (isEditingDescription && descriptionInputRef.current) {
descriptionInputRef.current.focus() descriptionInputRef.current.focus();
// Set input width to match text width // Set input width to match text width
if (textWidth > 0) { if (textWidth > 0) {
descriptionInputRef.current.style.width = `${textWidth + 20}px` // Add some padding descriptionInputRef.current.style.width = `${textWidth + 20}px`; // Add some padding
} }
} }
}, [isEditingDescription, textWidth]) }, [isEditingDescription, textWidth]);
// Measure text width when not editing // Measure text width when not editing
useEffect(() => { useEffect(() => {
if (!isEditingDescription && descriptionTextRef.current) { if (!isEditingDescription && descriptionTextRef.current) {
setTextWidth(descriptionTextRef.current.offsetWidth) setTextWidth(descriptionTextRef.current.offsetWidth);
} }
}, [isEditingDescription, customDescription]) }, [isEditingDescription, customDescription]);
// Generate a unique key for localStorage based on tool name and server // Generate a unique key for localStorage based on tool name and server
const getStorageKey = useCallback(() => { const getStorageKey = useCallback(() => {
return `mcphub_tool_form_${server ? `${server}_` : ''}${tool.name}` return `mcphub_tool_form_${server ? `${server}_` : ''}${tool.name}`;
}, [tool.name, server]) }, [tool.name, server]);
// Clear form data from localStorage // Clear form data from localStorage
const clearStoredFormData = useCallback(() => { const clearStoredFormData = useCallback(() => {
localStorage.removeItem(getStorageKey()) localStorage.removeItem(getStorageKey());
}, [getStorageKey]) }, [getStorageKey]);
const handleToggle = (enabled: boolean) => { const handleToggle = (enabled: boolean) => {
if (onToggle) { if (onToggle) {
onToggle(tool.name, enabled) onToggle(tool.name, enabled);
}
} }
};
const handleDescriptionEdit = () => { const handleDescriptionEdit = () => {
setIsEditingDescription(true) setIsEditingDescription(true);
} };
const handleDescriptionSave = async () => { const handleDescriptionSave = async () => {
try { try {
const result = await updateToolDescription(server, tool.name, customDescription) const result = await updateToolDescription(server, tool.name, customDescription);
if (result.success) { if (result.success) {
setIsEditingDescription(false) setIsEditingDescription(false);
if (onDescriptionUpdate) { if (onDescriptionUpdate) {
onDescriptionUpdate(tool.name, customDescription) onDescriptionUpdate(tool.name, customDescription);
} }
} else { } else {
// Revert on error // Revert on error
setCustomDescription(tool.description || '') setCustomDescription(tool.description || '');
console.error('Failed to update tool description:', result.error) console.error('Failed to update tool description:', result.error);
} }
} catch (error) { } catch (error) {
console.error('Error updating tool description:', error) console.error('Error updating tool description:', error);
setCustomDescription(tool.description || '') setCustomDescription(tool.description || '');
setIsEditingDescription(false) setIsEditingDescription(false);
}
} }
};
const handleDescriptionChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleDescriptionChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setCustomDescription(e.target.value) setCustomDescription(e.target.value);
} };
const handleDescriptionKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleDescriptionKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
handleDescriptionSave() handleDescriptionSave();
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
setCustomDescription(tool.description || '') setCustomDescription(tool.description || '');
setIsEditingDescription(false) setIsEditingDescription(false);
}
} }
};
const handleCopyToolName = async (e: React.MouseEvent) => { const handleCopyToolName = async (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation();
try { try {
if (navigator.clipboard && window.isSecureContext) { if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(tool.name) await navigator.clipboard.writeText(tool.name);
setCopiedToolName(true) setCopiedToolName(true);
showToast(t('common.copySuccess'), 'success') showToast(t('common.copySuccess'), 'success');
setTimeout(() => setCopiedToolName(false), 2000) setTimeout(() => setCopiedToolName(false), 2000);
} else { } else {
// Fallback for HTTP or unsupported clipboard API // Fallback for HTTP or unsupported clipboard API
const textArea = document.createElement('textarea') const textArea = document.createElement('textarea');
textArea.value = tool.name textArea.value = tool.name;
textArea.style.position = 'fixed' textArea.style.position = 'fixed';
textArea.style.left = '-9999px' textArea.style.left = '-9999px';
document.body.appendChild(textArea) document.body.appendChild(textArea);
textArea.focus() textArea.focus();
textArea.select() textArea.select();
try { try {
document.execCommand('copy') document.execCommand('copy');
setCopiedToolName(true) setCopiedToolName(true);
showToast(t('common.copySuccess'), 'success') showToast(t('common.copySuccess'), 'success');
setTimeout(() => setCopiedToolName(false), 2000) setTimeout(() => setCopiedToolName(false), 2000);
} catch (err) { } catch (err) {
showToast(t('common.copyFailed'), 'error') showToast(t('common.copyFailed'), 'error');
console.error('Copy to clipboard failed:', err) console.error('Copy to clipboard failed:', err);
} }
document.body.removeChild(textArea) document.body.removeChild(textArea);
} }
} catch (error) { } catch (error) {
showToast(t('common.copyFailed'), 'error') showToast(t('common.copyFailed'), 'error');
console.error('Copy to clipboard failed:', error) console.error('Copy to clipboard failed:', error);
}
} }
};
const handleRunTool = async (arguments_: Record<string, any>) => { const handleRunTool = async (arguments_: Record<string, any>) => {
setIsRunning(true) setIsRunning(true);
try { try {
// filter empty values // filter empty values
arguments_ = Object.fromEntries(Object.entries(arguments_).filter(([_, v]) => !isEmptyValue(v))) arguments_ = Object.fromEntries(
const result = await callTool({ Object.entries(arguments_).filter(([_, v]) => !isEmptyValue(v)),
);
const result = await callTool(
{
toolName: tool.name, toolName: tool.name,
arguments: arguments_, arguments: arguments_,
}, server) },
server,
);
setResult(result) setResult(result);
// Clear form data on successful submission // Clear form data on successful submission
// clearStoredFormData() // clearStoredFormData()
} catch (error) { } catch (error) {
setResult({ setResult({
success: false, success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred', error: error instanceof Error ? error.message : 'Unknown error occurred',
}) });
} finally { } finally {
setIsRunning(false) setIsRunning(false);
}
} }
};
const handleCancelRun = () => { const handleCancelRun = () => {
setShowRunForm(false) setShowRunForm(false);
// Clear form data when cancelled // Clear form data when cancelled
clearStoredFormData() clearStoredFormData();
setResult(null) setResult(null);
} };
const handleCloseResult = () => { const handleCloseResult = () => {
setResult(null) setResult(null);
} };
return ( return (
<div className="bg-white border border-gray-200 shadow rounded-lg p-4 mb-4"> <div className="bg-white border border-gray-200 shadow rounded-lg mb-4">
<div <div
className="flex justify-between items-center cursor-pointer" className="flex justify-between items-center cursor-pointer p-2"
onClick={() => setIsExpanded(!isExpanded)} onClick={(e) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
}}
> >
<div className="flex-1"> <div className="flex-1">
<h3 className="text-lg font-medium text-gray-900 inline-flex items-center"> <h3 className="text-lg font-medium text-gray-900 inline-flex items-center">
@@ -194,11 +210,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
onClick={handleCopyToolName} onClick={handleCopyToolName}
title={t('common.copy')} title={t('common.copy')}
> >
{copiedToolName ? ( {copiedToolName ? <Check size={16} className="text-green-500" /> : <Copy size={16} />}
<Check size={16} className="text-green-500" />
) : (
<Copy size={16} />
)}
</button> </button>
<span className="ml-2 text-sm font-normal text-gray-600 inline-flex items-center"> <span className="ml-2 text-sm font-normal text-gray-600 inline-flex items-center">
{isEditingDescription ? ( {isEditingDescription ? (
@@ -213,14 +225,14 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{ style={{
minWidth: '100px', minWidth: '100px',
width: textWidth > 0 ? `${textWidth + 20}px` : 'auto' width: textWidth > 0 ? `${textWidth + 20}px` : 'auto',
}} }}
/> />
<button <button
className="ml-2 p-1 text-green-600 hover:text-green-800 cursor-pointer transition-colors" className="ml-2 p-1 text-green-600 hover:text-green-800 cursor-pointer transition-colors"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation();
handleDescriptionSave() handleDescriptionSave();
}} }}
> >
<Check size={16} /> <Check size={16} />
@@ -228,12 +240,14 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
</> </>
) : ( ) : (
<> <>
<span ref={descriptionTextRef}>{customDescription || t('tool.noDescription')}</span> <span ref={descriptionTextRef}>
{customDescription || t('tool.noDescription')}
</span>
<button <button
className="ml-2 p-1 text-gray-500 hover:text-blue-600 cursor-pointer transition-colors" className="ml-2 p-1 text-gray-500 hover:text-blue-600 cursor-pointer transition-colors"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation();
handleDescriptionEdit() handleDescriptionEdit();
}} }}
> >
<Edit size={14} /> <Edit size={14} />
@@ -244,10 +258,7 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
</h3> </h3>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div <div className="flex items-center space-x-2" onClick={(e) => e.stopPropagation()}>
className="flex items-center space-x-2"
onClick={(e) => e.stopPropagation()}
>
<Switch <Switch
checked={tool.enabled ?? true} checked={tool.enabled ?? true}
onCheckedChange={handleToggle} onCheckedChange={handleToggle}
@@ -256,18 +267,14 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
</div> </div>
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation();
setIsExpanded(true) // Ensure card is expanded when showing run form setIsExpanded(true); // Ensure card is expanded when showing run form
setShowRunForm(true) 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" 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 || !tool.enabled} disabled={isRunning || !tool.enabled}
> >
{isRunning ? ( {isRunning ? <Loader size={14} className="animate-spin" /> : <Play size={14} />}
<Loader size={14} className="animate-spin" />
) : (
<Play size={14} />
)}
<span>{isRunning ? t('tool.running') : t('tool.run')}</span> <span>{isRunning ? t('tool.running') : t('tool.run')}</span>
</button> </button>
<button className="text-gray-400 hover:text-gray-600"> <button className="text-gray-400 hover:text-gray-600">
@@ -297,7 +304,9 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
onCancel={handleCancelRun} onCancel={handleCancelRun}
loading={isRunning} loading={isRunning}
storageKey={getStorageKey()} storageKey={getStorageKey()}
title={t('tool.runToolWithName', { name: tool.name.replace(server + nameSeparator, '') })} title={t('tool.runToolWithName', {
name: tool.name.replace(server + nameSeparator, ''),
})}
/> />
{/* Tool Result */} {/* Tool Result */}
{result && ( {result && (
@@ -307,12 +316,10 @@ const ToolCard = ({ tool, server, onToggle, onDescriptionUpdate }: ToolCardProps
)} )}
</div> </div>
)} )}
</div> </div>
)} )}
</div> </div>
) );
} };
export default ToolCard export default ToolCard;

View File

@@ -14,14 +14,17 @@ const initialState: AuthState = {
// Create auth context // Create auth context
const AuthContext = createContext<{ const AuthContext = createContext<{
auth: AuthState; auth: AuthState;
login: (username: string, password: string) => Promise<{ success: boolean; isUsingDefaultPassword?: boolean }>; login: (
username: string,
password: string,
) => Promise<{ success: boolean; isUsingDefaultPassword?: boolean; message?: string }>;
register: (username: string, password: string, isAdmin?: boolean) => Promise<boolean>; register: (username: string, password: string, isAdmin?: boolean) => Promise<boolean>;
logout: () => void; logout: () => void;
}>({ }>({
auth: initialState, auth: initialState,
login: async () => ({ success: false }), login: async () => ({ success: false }),
register: async () => false, register: async () => false,
logout: () => { }, logout: () => {},
}); });
// Auth provider component // Auth provider component
@@ -90,7 +93,10 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
}, []); }, []);
// Login function // Login function
const login = async (username: string, password: string): Promise<{ success: boolean; isUsingDefaultPassword?: boolean }> => { const login = async (
username: string,
password: string,
): Promise<{ success: boolean; isUsingDefaultPassword?: boolean; message?: string }> => {
try { try {
const response = await authService.login({ username, password }); const response = await authService.login({ username, password });
@@ -111,7 +117,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
loading: false, loading: false,
error: response.message || 'Authentication failed', error: response.message || 'Authentication failed',
}); });
return { success: false }; return { success: false, message: response.message };
} }
} catch (error) { } catch (error) {
setAuth({ setAuth({
@@ -119,7 +125,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
loading: false, loading: false,
error: 'Authentication failed', error: 'Authentication failed',
}); });
return { success: false }; return { success: false, message: error instanceof Error ? error.message : undefined };
} }
}; };
@@ -127,7 +133,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const register = async ( const register = async (
username: string, username: string,
password: string, password: string,
isAdmin = false isAdmin = false,
): Promise<boolean> => { ): Promise<boolean> => {
try { try {
const response = await authService.register({ username, password, isAdmin }); const response = await authService.register({ username, password, isAdmin });

View File

@@ -17,6 +17,16 @@ const CONFIG = {
}, },
}; };
// Pagination info type
interface PaginationInfo {
page: number;
limit: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPrevPage: boolean;
}
// Context type definition // Context type definition
interface ServerContextType { interface ServerContextType {
servers: Server[]; servers: Server[];
@@ -24,12 +34,18 @@ interface ServerContextType {
setError: (error: string | null) => void; setError: (error: string | null) => void;
isLoading: boolean; isLoading: boolean;
fetchAttempts: number; fetchAttempts: number;
pagination: PaginationInfo | null;
currentPage: number;
serversPerPage: number;
setCurrentPage: (page: number) => void;
setServersPerPage: (limit: number) => void;
triggerRefresh: () => void; triggerRefresh: () => void;
refreshIfNeeded: () => void; // Smart refresh with debounce refreshIfNeeded: () => void; // Smart refresh with debounce
handleServerAdd: () => void; handleServerAdd: () => void;
handleServerEdit: (server: Server) => Promise<any>; handleServerEdit: (server: Server) => Promise<any>;
handleServerRemove: (serverName: string) => Promise<boolean>; handleServerRemove: (serverName: string) => Promise<boolean>;
handleServerToggle: (server: Server, enabled: boolean) => Promise<boolean>; handleServerToggle: (server: Server, enabled: boolean) => Promise<boolean>;
handleServerReload: (server: Server) => Promise<boolean>;
} }
// Create Context // Create Context
@@ -44,6 +60,9 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
const [isInitialLoading, setIsInitialLoading] = useState(true); const [isInitialLoading, setIsInitialLoading] = useState(true);
const [fetchAttempts, setFetchAttempts] = useState(0); const [fetchAttempts, setFetchAttempts] = useState(0);
const [pagination, setPagination] = useState<PaginationInfo | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [serversPerPage, setServersPerPage] = useState(10);
// Timer reference for polling // Timer reference for polling
const intervalRef = useRef<NodeJS.Timeout | null>(null); const intervalRef = useRef<NodeJS.Timeout | null>(null);
@@ -72,18 +91,31 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
const fetchServers = async () => { const fetchServers = async () => {
try { try {
console.log('[ServerContext] Fetching servers from API...'); console.log('[ServerContext] Fetching servers from API...');
const data = await apiGet('/servers'); // Build query parameters for pagination
const params = new URLSearchParams();
params.append('page', currentPage.toString());
params.append('limit', serversPerPage.toString());
const data = await apiGet(`/servers?${params.toString()}`);
// Update last fetch time // Update last fetch time
lastFetchTimeRef.current = Date.now(); lastFetchTimeRef.current = Date.now();
if (data && data.success && Array.isArray(data.data)) { if (data && data.success && Array.isArray(data.data)) {
setServers(data.data); setServers(data.data);
// Update pagination info if available
if (data.pagination) {
setPagination(data.pagination);
} else {
setPagination(null);
}
} else if (data && Array.isArray(data)) { } else if (data && Array.isArray(data)) {
// Compatibility handling for non-paginated responses
setServers(data); setServers(data);
setPagination(null);
} else { } else {
console.error('Invalid server data format:', data); console.error('Invalid server data format:', data);
setServers([]); setServers([]);
setPagination(null);
} }
// Reset error state // Reset error state
@@ -113,7 +145,7 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
// Set up regular polling // Set up regular polling
intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval); intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval);
}, },
[t], [t, currentPage, serversPerPage],
); );
// Watch for authentication status changes // Watch for authentication status changes
@@ -149,7 +181,11 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
const fetchInitialData = async () => { const fetchInitialData = async () => {
try { try {
console.log('[ServerContext] Initial fetch - attempt', attemptsRef.current + 1); console.log('[ServerContext] Initial fetch - attempt', attemptsRef.current + 1);
const data = await apiGet('/servers'); // Build query parameters for pagination
const params = new URLSearchParams();
params.append('page', currentPage.toString());
params.append('limit', serversPerPage.toString());
const data = await apiGet(`/servers?${params.toString()}`);
// Update last fetch time // Update last fetch time
lastFetchTimeRef.current = Date.now(); lastFetchTimeRef.current = Date.now();
@@ -157,6 +193,12 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
// Handle API response wrapper object, extract data field // Handle API response wrapper object, extract data field
if (data && data.success && Array.isArray(data.data)) { if (data && data.success && Array.isArray(data.data)) {
setServers(data.data); setServers(data.data);
// Update pagination info if available
if (data.pagination) {
setPagination(data.pagination);
} else {
setPagination(null);
}
setIsInitialLoading(false); setIsInitialLoading(false);
// Initialization successful, start normal polling (skip immediate to avoid duplicate fetch) // Initialization successful, start normal polling (skip immediate to avoid duplicate fetch)
startNormalPolling({ immediate: false }); startNormalPolling({ immediate: false });
@@ -164,6 +206,7 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
} else if (data && Array.isArray(data)) { } else if (data && Array.isArray(data)) {
// Compatibility handling, if API directly returns array // Compatibility handling, if API directly returns array
setServers(data); setServers(data);
setPagination(null);
setIsInitialLoading(false); setIsInitialLoading(false);
// Initialization successful, start normal polling (skip immediate to avoid duplicate fetch) // Initialization successful, start normal polling (skip immediate to avoid duplicate fetch)
startNormalPolling({ immediate: false }); startNormalPolling({ immediate: false });
@@ -172,6 +215,7 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
// If data format is not as expected, set to empty array // If data format is not as expected, set to empty array
console.error('Invalid server data format:', data); console.error('Invalid server data format:', data);
setServers([]); setServers([]);
setPagination(null);
setIsInitialLoading(false); setIsInitialLoading(false);
// Initialization successful but data is empty, start normal polling (skip immediate) // Initialization successful but data is empty, start normal polling (skip immediate)
startNormalPolling({ immediate: false }); startNormalPolling({ immediate: false });
@@ -226,7 +270,7 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
return () => { return () => {
clearTimer(); clearTimer();
}; };
}, [refreshKey, t, isInitialLoading, startNormalPolling]); }, [refreshKey, t, isInitialLoading, startNormalPolling, currentPage, serversPerPage]);
// Manually trigger refresh (always refreshes) // Manually trigger refresh (always refreshes)
const triggerRefresh = useCallback(() => { const triggerRefresh = useCallback(() => {
@@ -358,18 +402,59 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
[t], [t],
); );
const handleServerReload = useCallback(
async (server: Server) => {
try {
const encodedServerName = encodeURIComponent(server.name);
const result = await apiPost(`/servers/${encodedServerName}/reload`, {});
if (!result || !result.success) {
console.error('Failed to reload server:', result);
setError(t('server.reloadError', { serverName: server.name }));
return false;
}
// Refresh server list after successful reload
triggerRefresh();
return true;
} catch (err) {
console.error('Error reloading server:', err);
setError(err instanceof Error ? err.message : String(err));
return false;
}
},
[t, triggerRefresh],
);
// Handle page change
const handlePageChange = useCallback((page: number) => {
setCurrentPage(page);
}, []);
// Handle servers per page change
const handleServersPerPageChange = useCallback((limit: number) => {
setServersPerPage(limit);
setCurrentPage(1); // Reset to first page when changing page size
}, []);
const value: ServerContextType = { const value: ServerContextType = {
servers, servers,
error, error,
setError, setError,
isLoading: isInitialLoading, isLoading: isInitialLoading,
fetchAttempts, fetchAttempts,
pagination,
currentPage,
serversPerPage,
setCurrentPage: handlePageChange,
setServersPerPage: handleServersPerPageChange,
triggerRefresh, triggerRefresh,
refreshIfNeeded, refreshIfNeeded,
handleServerAdd, handleServerAdd,
handleServerEdit, handleServerEdit,
handleServerRemove, handleServerRemove,
handleServerToggle, handleServerToggle,
handleServerReload,
}; };
return <ServerContext.Provider value={value}>{children}</ServerContext.Provider>; return <ServerContext.Provider value={value}>{children}</ServerContext.Provider>;

View File

@@ -0,0 +1,803 @@
import React, {
createContext,
useContext,
useState,
useCallback,
useEffect,
ReactNode,
} from 'react';
import { useTranslation } from 'react-i18next';
import { ApiResponse, BearerKey } from '@/types';
import { useToast } from '@/contexts/ToastContext';
import { useAuth } from '@/contexts/AuthContext';
import { apiGet, apiPut, apiPost, apiDelete } from '@/utils/fetchInterceptor';
// Define types for the settings data
interface RoutingConfig {
enableGlobalRoute: boolean;
enableGroupNameRoute: boolean;
enableBearerAuth: boolean;
bearerAuthKey: string;
skipAuth: boolean;
}
interface InstallConfig {
pythonIndexUrl: string;
npmRegistry: string;
baseUrl: string;
}
interface SmartRoutingConfig {
enabled: boolean;
dbUrl: string;
openaiApiBaseUrl: string;
openaiApiKey: string;
openaiApiEmbeddingModel: string;
}
interface MCPRouterConfig {
apiKey: string;
referer: string;
title: string;
baseUrl: string;
}
interface OAuthServerConfig {
enabled: boolean;
accessTokenLifetime: number;
refreshTokenLifetime: number;
authorizationCodeLifetime: number;
requireClientSecret: boolean;
allowedScopes: string[];
requireState: boolean;
dynamicRegistration: {
enabled: boolean;
allowedGrantTypes: string[];
requiresAuthentication: boolean;
};
}
interface SystemSettings {
systemConfig?: {
routing?: RoutingConfig;
install?: InstallConfig;
smartRouting?: SmartRoutingConfig;
mcpRouter?: MCPRouterConfig;
nameSeparator?: string;
oauthServer?: OAuthServerConfig;
enableSessionRebuild?: boolean;
};
bearerKeys?: BearerKey[];
}
interface TempRoutingConfig {
bearerAuthKey: string;
}
interface SettingsContextValue {
routingConfig: RoutingConfig;
tempRoutingConfig: TempRoutingConfig;
setTempRoutingConfig: React.Dispatch<React.SetStateAction<TempRoutingConfig>>;
installConfig: InstallConfig;
smartRoutingConfig: SmartRoutingConfig;
mcpRouterConfig: MCPRouterConfig;
oauthServerConfig: OAuthServerConfig;
nameSeparator: string;
enableSessionRebuild: boolean;
bearerKeys: BearerKey[];
loading: boolean;
error: string | null;
setError: React.Dispatch<React.SetStateAction<string | null>>;
triggerRefresh: () => void;
fetchSettings: () => Promise<void>;
updateRoutingConfig: (key: keyof RoutingConfig, value: any) => Promise<boolean | undefined>;
updateInstallConfig: (key: keyof InstallConfig, value: any) => Promise<boolean | undefined>;
updateSmartRoutingConfig: (
key: keyof SmartRoutingConfig,
value: any,
) => Promise<boolean | undefined>;
updateSmartRoutingConfigBatch: (
updates: Partial<SmartRoutingConfig>,
) => Promise<boolean | undefined>;
updateRoutingConfigBatch: (updates: Partial<RoutingConfig>) => Promise<boolean | undefined>;
updateMCPRouterConfig: (key: keyof MCPRouterConfig, value: any) => Promise<boolean | undefined>;
updateMCPRouterConfigBatch: (updates: Partial<MCPRouterConfig>) => Promise<boolean | undefined>;
updateOAuthServerConfig: (
key: keyof OAuthServerConfig,
value: any,
) => Promise<boolean | undefined>;
updateOAuthServerConfigBatch: (
updates: Partial<OAuthServerConfig>,
) => Promise<boolean | undefined>;
updateNameSeparator: (value: string) => Promise<boolean | undefined>;
updateSessionRebuild: (value: boolean) => Promise<boolean | undefined>;
exportMCPSettings: (serverName?: string) => Promise<any>;
// Bearer key management
refreshBearerKeys: () => Promise<void>;
createBearerKey: (payload: Omit<BearerKey, 'id'>) => Promise<BearerKey | null>;
updateBearerKey: (
id: string,
updates: Partial<Omit<BearerKey, 'id'>>,
) => Promise<BearerKey | null>;
deleteBearerKey: (id: string) => Promise<boolean>;
}
const getDefaultOAuthServerConfig = (): OAuthServerConfig => ({
enabled: true,
accessTokenLifetime: 3600,
refreshTokenLifetime: 1209600,
authorizationCodeLifetime: 300,
requireClientSecret: false,
allowedScopes: ['read', 'write'],
requireState: false,
dynamicRegistration: {
enabled: true,
allowedGrantTypes: ['authorization_code', 'refresh_token'],
requiresAuthentication: false,
},
});
const SettingsContext = createContext<SettingsContextValue | undefined>(undefined);
export const useSettings = () => {
const context = useContext(SettingsContext);
if (!context) {
throw new Error('useSettings must be used within a SettingsProvider');
}
return context;
};
interface SettingsProviderProps {
children: ReactNode;
}
export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children }) => {
const { t } = useTranslation();
const { showToast } = useToast();
const { auth } = useAuth();
const [routingConfig, setRoutingConfig] = useState<RoutingConfig>({
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: false,
bearerAuthKey: '',
skipAuth: false,
});
const [tempRoutingConfig, setTempRoutingConfig] = useState<TempRoutingConfig>({
bearerAuthKey: '',
});
const [installConfig, setInstallConfig] = useState<InstallConfig>({
pythonIndexUrl: '',
npmRegistry: '',
baseUrl: 'http://localhost:3000',
});
const [smartRoutingConfig, setSmartRoutingConfig] = useState<SmartRoutingConfig>({
enabled: false,
dbUrl: '',
openaiApiBaseUrl: '',
openaiApiKey: '',
openaiApiEmbeddingModel: '',
});
const [mcpRouterConfig, setMCPRouterConfig] = useState<MCPRouterConfig>({
apiKey: '',
referer: 'https://www.mcphubx.com',
title: 'MCPHub',
baseUrl: 'https://api.mcprouter.to/v1',
});
const [oauthServerConfig, setOAuthServerConfig] = useState<OAuthServerConfig>(
getDefaultOAuthServerConfig(),
);
const [nameSeparator, setNameSeparator] = useState<string>('-');
const [enableSessionRebuild, setEnableSessionRebuild] = useState<boolean>(false);
const [bearerKeys, setBearerKeys] = useState<BearerKey[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
// Trigger a refresh of the settings data
const triggerRefresh = useCallback(() => {
setRefreshKey((prev) => prev + 1);
}, []);
// Fetch current settings
const fetchSettings = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data: ApiResponse<SystemSettings> = await apiGet('/settings');
if (data.success && data.data?.systemConfig?.routing) {
setRoutingConfig({
enableGlobalRoute: data.data.systemConfig.routing.enableGlobalRoute ?? true,
enableGroupNameRoute: data.data.systemConfig.routing.enableGroupNameRoute ?? true,
enableBearerAuth: data.data.systemConfig.routing.enableBearerAuth ?? false,
bearerAuthKey: data.data.systemConfig.routing.bearerAuthKey || '',
skipAuth: data.data.systemConfig.routing.skipAuth ?? false,
});
}
if (data.success && data.data?.systemConfig?.install) {
setInstallConfig({
pythonIndexUrl: data.data.systemConfig.install.pythonIndexUrl || '',
npmRegistry: data.data.systemConfig.install.npmRegistry || '',
baseUrl: data.data.systemConfig.install.baseUrl || 'http://localhost:3000',
});
}
if (data.success && data.data?.systemConfig?.smartRouting) {
setSmartRoutingConfig({
enabled: data.data.systemConfig.smartRouting.enabled ?? false,
dbUrl: data.data.systemConfig.smartRouting.dbUrl || '',
openaiApiBaseUrl: data.data.systemConfig.smartRouting.openaiApiBaseUrl || '',
openaiApiKey: data.data.systemConfig.smartRouting.openaiApiKey || '',
openaiApiEmbeddingModel:
data.data.systemConfig.smartRouting.openaiApiEmbeddingModel || '',
});
}
if (data.success && data.data?.systemConfig?.mcpRouter) {
setMCPRouterConfig({
apiKey: data.data.systemConfig.mcpRouter.apiKey || '',
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',
});
}
if (data.success) {
if (data.data?.systemConfig?.oauthServer) {
const oauth = data.data.systemConfig.oauthServer;
const defaultOauthConfig = getDefaultOAuthServerConfig();
const defaultDynamic = defaultOauthConfig.dynamicRegistration;
const allowedScopes = Array.isArray(oauth.allowedScopes)
? [...oauth.allowedScopes]
: [...defaultOauthConfig.allowedScopes];
const dynamicAllowedGrantTypes = Array.isArray(
oauth.dynamicRegistration?.allowedGrantTypes,
)
? [...oauth.dynamicRegistration!.allowedGrantTypes!]
: [...defaultDynamic.allowedGrantTypes];
setOAuthServerConfig({
enabled: oauth.enabled ?? defaultOauthConfig.enabled,
accessTokenLifetime:
oauth.accessTokenLifetime ?? defaultOauthConfig.accessTokenLifetime,
refreshTokenLifetime:
oauth.refreshTokenLifetime ?? defaultOauthConfig.refreshTokenLifetime,
authorizationCodeLifetime:
oauth.authorizationCodeLifetime ?? defaultOauthConfig.authorizationCodeLifetime,
requireClientSecret:
oauth.requireClientSecret ?? defaultOauthConfig.requireClientSecret,
requireState: oauth.requireState ?? defaultOauthConfig.requireState,
allowedScopes,
dynamicRegistration: {
enabled: oauth.dynamicRegistration?.enabled ?? defaultDynamic.enabled,
allowedGrantTypes: dynamicAllowedGrantTypes,
requiresAuthentication:
oauth.dynamicRegistration?.requiresAuthentication ??
defaultDynamic.requiresAuthentication,
},
});
} else {
setOAuthServerConfig(getDefaultOAuthServerConfig());
}
}
if (data.success && data.data?.systemConfig?.nameSeparator !== undefined) {
setNameSeparator(data.data.systemConfig.nameSeparator);
}
if (data.success && data.data?.systemConfig?.enableSessionRebuild !== undefined) {
setEnableSessionRebuild(data.data.systemConfig.enableSessionRebuild);
}
if (data.success && Array.isArray(data.data?.bearerKeys)) {
setBearerKeys(data.data.bearerKeys);
}
} catch (error) {
console.error('Failed to fetch settings:', error);
setError(error instanceof Error ? error.message : 'Failed to fetch settings');
showToast(t('errors.failedToFetchSettings'));
} finally {
setLoading(false);
}
}, [t, showToast]);
// Update routing configuration
const updateRoutingConfig = async (key: keyof RoutingConfig, value: any) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
routing: {
[key]: value,
},
});
if (data.success) {
setRoutingConfig({
...routingConfig,
[key]: value,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
setError(data.error || 'Failed to update routing config');
showToast(data.error || t('errors.failedToUpdateRoutingConfig'));
return false;
}
} catch (error) {
console.error('Failed to update routing config:', error);
setError(error instanceof Error ? error.message : 'Failed to update routing config');
showToast(t('errors.failedToUpdateRoutingConfig'));
return false;
} finally {
setLoading(false);
}
};
// Update install configuration
const updateInstallConfig = async (key: keyof InstallConfig, value: any) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
install: {
[key]: value,
},
});
if (data.success) {
setInstallConfig({
...installConfig,
[key]: value,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
setError(data.error || 'Failed to update install config');
showToast(data.error || t('errors.failedToUpdateInstallConfig'));
return false;
}
} catch (error) {
console.error('Failed to update install config:', error);
setError(error instanceof Error ? error.message : 'Failed to update install config');
showToast(t('errors.failedToUpdateInstallConfig'));
return false;
} finally {
setLoading(false);
}
};
// Update smart routing configuration
const updateSmartRoutingConfig = async (key: keyof SmartRoutingConfig, value: any) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
smartRouting: {
[key]: value,
},
});
if (data.success) {
setSmartRoutingConfig({
...smartRoutingConfig,
[key]: value,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
setError(data.error || 'Failed to update smart routing config');
showToast(data.error || t('errors.failedToUpdateSmartRoutingConfig'));
return false;
}
} catch (error) {
console.error('Failed to update smart routing config:', error);
setError(error instanceof Error ? error.message : 'Failed to update smart routing config');
showToast(t('errors.failedToUpdateSmartRoutingConfig'));
return false;
} finally {
setLoading(false);
}
};
// Batch update smart routing configuration
const updateSmartRoutingConfigBatch = async (updates: Partial<SmartRoutingConfig>) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
smartRouting: updates,
});
if (data.success) {
setSmartRoutingConfig({
...smartRoutingConfig,
...updates,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
setError(data.error || 'Failed to update smart routing config');
showToast(data.error || t('errors.failedToUpdateSmartRoutingConfig'));
return false;
}
} catch (error) {
console.error('Failed to update smart routing config:', error);
setError(error instanceof Error ? error.message : 'Failed to update smart routing config');
showToast(t('errors.failedToUpdateSmartRoutingConfig'));
return false;
} finally {
setLoading(false);
}
};
// Batch update routing configuration
const updateRoutingConfigBatch = async (updates: Partial<RoutingConfig>) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
routing: updates,
});
if (data.success) {
setRoutingConfig({
...routingConfig,
...updates,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
setError(data.error || 'Failed to update routing config');
showToast(data.error || t('errors.failedToUpdateRoutingConfig'));
return false;
}
} catch (error) {
console.error('Failed to update routing config:', error);
setError(error instanceof Error ? error.message : 'Failed to update routing config');
showToast(t('errors.failedToUpdateRoutingConfig'));
return false;
} finally {
setLoading(false);
}
};
// Update MCP Router configuration
const updateMCPRouterConfig = async (key: keyof MCPRouterConfig, value: any) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
mcpRouter: {
[key]: value,
},
});
if (data.success) {
setMCPRouterConfig({
...mcpRouterConfig,
[key]: value,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
setError(data.error || 'Failed to update MCP Router config');
showToast(data.error || t('errors.failedToUpdateMCPRouterConfig'));
return false;
}
} catch (error) {
console.error('Failed to update MCP Router config:', error);
setError(error instanceof Error ? error.message : 'Failed to update MCP Router config');
showToast(t('errors.failedToUpdateMCPRouterConfig'));
return false;
} finally {
setLoading(false);
}
};
// Batch update MCP Router configuration
const updateMCPRouterConfigBatch = async (updates: Partial<MCPRouterConfig>) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
mcpRouter: updates,
});
if (data.success) {
setMCPRouterConfig({
...mcpRouterConfig,
...updates,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
setError(data.error || 'Failed to update MCP Router config');
showToast(data.error || t('errors.failedToUpdateMCPRouterConfig'));
return false;
}
} catch (error) {
console.error('Failed to update MCP Router config:', error);
setError(error instanceof Error ? error.message : 'Failed to update MCP Router config');
showToast(t('errors.failedToUpdateMCPRouterConfig'));
return false;
} finally {
setLoading(false);
}
};
// Update OAuth server configuration
const updateOAuthServerConfig = async (key: keyof OAuthServerConfig, value: any) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
oauthServer: {
[key]: value,
},
});
if (data.success) {
setOAuthServerConfig({
...oauthServerConfig,
[key]: value,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
setError(data.error || 'Failed to update OAuth server config');
showToast(data.error || t('errors.failedToUpdateOAuthServerConfig'));
return false;
}
} catch (error) {
console.error('Failed to update OAuth server config:', error);
setError(error instanceof Error ? error.message : 'Failed to update OAuth server config');
showToast(t('errors.failedToUpdateOAuthServerConfig'));
return false;
} finally {
setLoading(false);
}
};
// Batch update OAuth server configuration
const updateOAuthServerConfigBatch = async (updates: Partial<OAuthServerConfig>) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
oauthServer: updates,
});
if (data.success) {
setOAuthServerConfig({
...oauthServerConfig,
...updates,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
setError(data.error || 'Failed to update OAuth server config');
showToast(data.error || t('errors.failedToUpdateOAuthServerConfig'));
return false;
}
} catch (error) {
console.error('Failed to update OAuth server config:', error);
setError(error instanceof Error ? error.message : 'Failed to update OAuth server config');
showToast(t('errors.failedToUpdateOAuthServerConfig'));
return false;
} finally {
setLoading(false);
}
};
// Update name separator
const updateNameSeparator = async (value: string) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
nameSeparator: value,
});
if (data.success) {
setNameSeparator(value);
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
setError(data.error || 'Failed to update name separator');
showToast(data.error || t('errors.failedToUpdateNameSeparator'));
return false;
}
} catch (error) {
console.error('Failed to update name separator:', error);
setError(error instanceof Error ? error.message : 'Failed to update name separator');
showToast(t('errors.failedToUpdateNameSeparator'));
return false;
} finally {
setLoading(false);
}
};
// Update session rebuild flag
const updateSessionRebuild = async (value: boolean) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
enableSessionRebuild: value,
});
if (data.success) {
setEnableSessionRebuild(value);
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
setError(data.error || 'Failed to update session rebuild setting');
showToast(data.error || t('errors.failedToUpdateSessionRebuild'));
return false;
}
} catch (error) {
console.error('Failed to update session rebuild setting:', error);
setError(error instanceof Error ? error.message : 'Failed to update session rebuild setting');
showToast(t('errors.failedToUpdateSessionRebuild'));
return false;
} finally {
setLoading(false);
}
};
const exportMCPSettings = async (serverName?: string) => {
setLoading(true);
setError(null);
try {
return await apiGet(`/mcp-settings/export?serverName=${serverName ? serverName : ''}`);
} catch (error) {
console.error('Failed to export MCP settings:', error);
const errorMessage = error instanceof Error ? error.message : 'Failed to export MCP settings';
setError(errorMessage);
showToast(errorMessage);
} finally {
setLoading(false);
}
};
// Bearer key management helpers
const refreshBearerKeys = async () => {
try {
const data: ApiResponse<BearerKey[]> = await apiGet('/auth/keys');
if (data.success && Array.isArray(data.data)) {
setBearerKeys(data.data);
}
} catch (error) {
console.error('Failed to refresh bearer keys:', error);
showToast(t('errors.failedToFetchSettings'));
}
};
const createBearerKey = async (payload: Omit<BearerKey, 'id'>): Promise<BearerKey | null> => {
try {
const data: ApiResponse<BearerKey> = await apiPost('/auth/keys', payload as any);
if (data.success && data.data) {
await refreshBearerKeys();
showToast(t('settings.systemConfigUpdated'));
return data.data;
}
showToast(data.message || t('errors.failedToUpdateRoutingConfig'));
return null;
} catch (error) {
console.error('Failed to create bearer key:', error);
showToast(t('errors.failedToUpdateRoutingConfig'));
return null;
}
};
const updateBearerKey = async (
id: string,
updates: Partial<Omit<BearerKey, 'id'>>,
): Promise<BearerKey | null> => {
try {
const data: ApiResponse<BearerKey> = await apiPut(`/auth/keys/${id}`, updates as any);
if (data.success && data.data) {
await refreshBearerKeys();
showToast(t('settings.systemConfigUpdated'));
return data.data;
}
showToast(data.message || t('errors.failedToUpdateRoutingConfig'));
return null;
} catch (error) {
console.error('Failed to update bearer key:', error);
showToast(t('errors.failedToUpdateRoutingConfig'));
return null;
}
};
const deleteBearerKey = async (id: string): Promise<boolean> => {
try {
const data: ApiResponse = await apiDelete(`/auth/keys/${id}`);
if (data.success) {
await refreshBearerKeys();
showToast(t('settings.systemConfigUpdated'));
return true;
}
showToast(data.message || t('errors.failedToUpdateRoutingConfig'));
return false;
} catch (error) {
console.error('Failed to delete bearer key:', error);
showToast(t('errors.failedToUpdateRoutingConfig'));
return false;
}
};
// Fetch settings when the component mounts or refreshKey changes
useEffect(() => {
fetchSettings();
}, [fetchSettings, refreshKey]);
// Watch for authentication status changes - refetch settings after login
useEffect(() => {
if (auth.isAuthenticated) {
console.log('[SettingsContext] User authenticated, triggering settings refresh');
// When user logs in, trigger a refresh to load settings
triggerRefresh();
}
}, [auth.isAuthenticated, triggerRefresh]);
useEffect(() => {
if (routingConfig) {
setTempRoutingConfig({
bearerAuthKey: routingConfig.bearerAuthKey,
});
}
}, [routingConfig]);
const value: SettingsContextValue = {
routingConfig,
tempRoutingConfig,
setTempRoutingConfig,
installConfig,
smartRoutingConfig,
mcpRouterConfig,
oauthServerConfig,
nameSeparator,
enableSessionRebuild,
bearerKeys,
loading,
error,
setError,
triggerRefresh,
fetchSettings,
updateRoutingConfig,
updateInstallConfig,
updateSmartRoutingConfig,
updateSmartRoutingConfigBatch,
updateRoutingConfigBatch,
updateMCPRouterConfig,
updateMCPRouterConfigBatch,
updateOAuthServerConfig,
updateOAuthServerConfigBatch,
updateNameSeparator,
updateSessionRebuild,
exportMCPSettings,
refreshBearerKeys,
createBearerKey,
updateBearerKey,
deleteBearerKey,
};
return <SettingsContext.Provider value={value}>{children}</SettingsContext.Provider>;
};

View File

@@ -1,658 +1,10 @@
import { useState, useCallback, useEffect } from 'react'; import { useSettings } from '@/contexts/SettingsContext';
import { useTranslation } from 'react-i18next';
import { ApiResponse } from '@/types';
import { useToast } from '@/contexts/ToastContext';
import { apiGet, apiPut } from '../utils/fetchInterceptor';
// Define types for the settings data
interface RoutingConfig {
enableGlobalRoute: boolean;
enableGroupNameRoute: boolean;
enableBearerAuth: boolean;
bearerAuthKey: string;
skipAuth: boolean;
}
interface InstallConfig {
pythonIndexUrl: string;
npmRegistry: string;
baseUrl: string;
}
interface SmartRoutingConfig {
enabled: boolean;
dbUrl: string;
openaiApiBaseUrl: string;
openaiApiKey: string;
openaiApiEmbeddingModel: string;
}
interface MCPRouterConfig {
apiKey: string;
referer: string;
title: string;
baseUrl: string;
}
interface OAuthServerConfig {
enabled: boolean;
accessTokenLifetime: number;
refreshTokenLifetime: number;
authorizationCodeLifetime: number;
requireClientSecret: boolean;
allowedScopes: string[];
requireState: boolean;
dynamicRegistration: {
enabled: boolean;
allowedGrantTypes: string[];
requiresAuthentication: boolean;
};
}
interface SystemSettings {
systemConfig?: {
routing?: RoutingConfig;
install?: InstallConfig;
smartRouting?: SmartRoutingConfig;
mcpRouter?: MCPRouterConfig;
nameSeparator?: string;
oauthServer?: OAuthServerConfig;
enableSessionRebuild?: boolean;
};
}
interface TempRoutingConfig {
bearerAuthKey: string;
}
const getDefaultOAuthServerConfig = (): OAuthServerConfig => ({
enabled: true,
accessTokenLifetime: 3600,
refreshTokenLifetime: 1209600,
authorizationCodeLifetime: 300,
requireClientSecret: false,
allowedScopes: ['read', 'write'],
requireState: false,
dynamicRegistration: {
enabled: true,
allowedGrantTypes: ['authorization_code', 'refresh_token'],
requiresAuthentication: false,
},
});
/**
* Hook that provides access to settings data via SettingsContext.
* This hook is a thin wrapper around useSettings to maintain backward compatibility.
* The actual data fetching happens once in SettingsProvider, avoiding duplicate API calls.
*/
export const useSettingsData = () => { export const useSettingsData = () => {
const { t } = useTranslation(); return useSettings();
const { showToast } = useToast();
const [routingConfig, setRoutingConfig] = useState<RoutingConfig>({
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: false,
bearerAuthKey: '',
skipAuth: false,
});
const [tempRoutingConfig, setTempRoutingConfig] = useState<TempRoutingConfig>({
bearerAuthKey: '',
});
const [installConfig, setInstallConfig] = useState<InstallConfig>({
pythonIndexUrl: '',
npmRegistry: '',
baseUrl: 'http://localhost:3000',
});
const [smartRoutingConfig, setSmartRoutingConfig] = useState<SmartRoutingConfig>({
enabled: false,
dbUrl: '',
openaiApiBaseUrl: '',
openaiApiKey: '',
openaiApiEmbeddingModel: '',
});
const [mcpRouterConfig, setMCPRouterConfig] = useState<MCPRouterConfig>({
apiKey: '',
referer: 'https://www.mcphubx.com',
title: 'MCPHub',
baseUrl: 'https://api.mcprouter.to/v1',
});
const [oauthServerConfig, setOAuthServerConfig] = useState<OAuthServerConfig>(
getDefaultOAuthServerConfig(),
);
const [nameSeparator, setNameSeparator] = useState<string>('-');
const [enableSessionRebuild, setEnableSessionRebuild] = useState<boolean>(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
// Trigger a refresh of the settings data
const triggerRefresh = useCallback(() => {
setRefreshKey((prev) => prev + 1);
}, []);
// Fetch current settings
const fetchSettings = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data: ApiResponse<SystemSettings> = await apiGet('/settings');
if (data.success && data.data?.systemConfig?.routing) {
setRoutingConfig({
enableGlobalRoute: data.data.systemConfig.routing.enableGlobalRoute ?? true,
enableGroupNameRoute: data.data.systemConfig.routing.enableGroupNameRoute ?? true,
enableBearerAuth: data.data.systemConfig.routing.enableBearerAuth ?? false,
bearerAuthKey: data.data.systemConfig.routing.bearerAuthKey || '',
skipAuth: data.data.systemConfig.routing.skipAuth ?? false,
});
}
if (data.success && data.data?.systemConfig?.install) {
setInstallConfig({
pythonIndexUrl: data.data.systemConfig.install.pythonIndexUrl || '',
npmRegistry: data.data.systemConfig.install.npmRegistry || '',
baseUrl: data.data.systemConfig.install.baseUrl || 'http://localhost:3000',
});
}
if (data.success && data.data?.systemConfig?.smartRouting) {
setSmartRoutingConfig({
enabled: data.data.systemConfig.smartRouting.enabled ?? false,
dbUrl: data.data.systemConfig.smartRouting.dbUrl || '',
openaiApiBaseUrl: data.data.systemConfig.smartRouting.openaiApiBaseUrl || '',
openaiApiKey: data.data.systemConfig.smartRouting.openaiApiKey || '',
openaiApiEmbeddingModel:
data.data.systemConfig.smartRouting.openaiApiEmbeddingModel || '',
});
}
if (data.success && data.data?.systemConfig?.mcpRouter) {
setMCPRouterConfig({
apiKey: data.data.systemConfig.mcpRouter.apiKey || '',
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',
});
}
if (data.success) {
if (data.data?.systemConfig?.oauthServer) {
const oauth = data.data.systemConfig.oauthServer;
const defaultOauthConfig = getDefaultOAuthServerConfig();
const defaultDynamic = defaultOauthConfig.dynamicRegistration;
const allowedScopes = Array.isArray(oauth.allowedScopes)
? [...oauth.allowedScopes]
: [...defaultOauthConfig.allowedScopes];
const dynamicAllowedGrantTypes = Array.isArray(
oauth.dynamicRegistration?.allowedGrantTypes,
)
? [...oauth.dynamicRegistration!.allowedGrantTypes!]
: [...defaultDynamic.allowedGrantTypes];
setOAuthServerConfig({
enabled: oauth.enabled ?? defaultOauthConfig.enabled,
accessTokenLifetime:
oauth.accessTokenLifetime ?? defaultOauthConfig.accessTokenLifetime,
refreshTokenLifetime:
oauth.refreshTokenLifetime ?? defaultOauthConfig.refreshTokenLifetime,
authorizationCodeLifetime:
oauth.authorizationCodeLifetime ?? defaultOauthConfig.authorizationCodeLifetime,
requireClientSecret:
oauth.requireClientSecret ?? defaultOauthConfig.requireClientSecret,
requireState: oauth.requireState ?? defaultOauthConfig.requireState,
allowedScopes,
dynamicRegistration: {
enabled: oauth.dynamicRegistration?.enabled ?? defaultDynamic.enabled,
allowedGrantTypes: dynamicAllowedGrantTypes,
requiresAuthentication:
oauth.dynamicRegistration?.requiresAuthentication ??
defaultDynamic.requiresAuthentication,
},
});
} else {
setOAuthServerConfig(getDefaultOAuthServerConfig());
}
}
if (data.success && data.data?.systemConfig?.nameSeparator !== undefined) {
setNameSeparator(data.data.systemConfig.nameSeparator);
}
if (data.success && data.data?.systemConfig?.enableSessionRebuild !== undefined) {
setEnableSessionRebuild(data.data.systemConfig.enableSessionRebuild);
}
} catch (error) {
console.error('Failed to fetch settings:', error);
setError(error instanceof Error ? error.message : 'Failed to fetch settings');
// 使用一个稳定的 showToast 引用,避免将其加入依赖数组
showToast(t('errors.failedToFetchSettings'));
} finally {
setLoading(false);
}
}, [t]); // 移除 showToast 依赖
// Update routing configuration
const updateRoutingConfig = async (key: keyof RoutingConfig, value: any) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
routing: {
[key]: value,
},
});
if (data.success) {
setRoutingConfig({
...routingConfig,
[key]: value,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(data.message || t('errors.failedToUpdateRouteConfig'));
return false;
}
} catch (error) {
console.error('Failed to update routing config:', error);
setError(error instanceof Error ? error.message : 'Failed to update routing config');
showToast(t('errors.failedToUpdateRouteConfig'));
return false;
} finally {
setLoading(false);
}
};
// Update install configuration
const updateInstallConfig = async (key: keyof InstallConfig, value: string) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
install: {
[key]: value,
},
});
if (data.success) {
setInstallConfig({
...installConfig,
[key]: value,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
return false;
}
} catch (error) {
console.error('Failed to update system config:', error);
setError(error instanceof Error ? error.message : 'Failed to update system config');
showToast(t('errors.failedToUpdateSystemConfig'));
return false;
} finally {
setLoading(false);
}
};
// Update smart routing configuration
const updateSmartRoutingConfig = async <T extends keyof SmartRoutingConfig>(
key: T,
value: SmartRoutingConfig[T],
) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
smartRouting: {
[key]: value,
},
});
if (data.success) {
setSmartRoutingConfig({
...smartRoutingConfig,
[key]: value,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(data.message || t('errors.failedToUpdateSmartRoutingConfig'));
return false;
}
} catch (error) {
console.error('Failed to update smart routing config:', error);
const errorMessage =
error instanceof Error ? error.message : 'Failed to update smart routing config';
setError(errorMessage);
showToast(errorMessage);
return false;
} finally {
setLoading(false);
}
};
// Update multiple smart routing configuration fields at once
const updateSmartRoutingConfigBatch = async (updates: Partial<SmartRoutingConfig>) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
smartRouting: updates,
});
if (data.success) {
setSmartRoutingConfig({
...smartRoutingConfig,
...updates,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(data.message || t('errors.failedToUpdateSmartRoutingConfig'));
return false;
}
} catch (error) {
console.error('Failed to update smart routing config:', error);
const errorMessage =
error instanceof Error ? error.message : 'Failed to update smart routing config';
setError(errorMessage);
showToast(errorMessage);
return false;
} finally {
setLoading(false);
}
};
// Update multiple routing configuration fields at once
const updateRoutingConfigBatch = async (updates: Partial<RoutingConfig>) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
routing: updates,
});
if (data.success) {
setRoutingConfig({
...routingConfig,
...updates,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(data.message || t('errors.failedToUpdateRouteConfig'));
return false;
}
} catch (error) {
console.error('Failed to update routing config:', error);
setError(error instanceof Error ? error.message : 'Failed to update routing config');
showToast(t('errors.failedToUpdateRouteConfig'));
return false;
} finally {
setLoading(false);
}
};
// Update MCPRouter configuration
const updateMCPRouterConfig = async <T extends keyof MCPRouterConfig>(
key: T,
value: MCPRouterConfig[T],
) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
mcpRouter: {
[key]: value,
},
});
if (data.success) {
setMCPRouterConfig({
...mcpRouterConfig,
[key]: value,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
return false;
}
} catch (error) {
console.error('Failed to update MCPRouter config:', error);
const errorMessage =
error instanceof Error ? error.message : 'Failed to update MCPRouter config';
setError(errorMessage);
showToast(errorMessage);
return false;
} finally {
setLoading(false);
}
};
// Update multiple MCPRouter configuration fields at once
const updateMCPRouterConfigBatch = async (updates: Partial<MCPRouterConfig>) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
mcpRouter: updates,
});
if (data.success) {
setMCPRouterConfig({
...mcpRouterConfig,
...updates,
});
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
return false;
}
} catch (error) {
console.error('Failed to update MCPRouter config:', error);
const errorMessage =
error instanceof Error ? error.message : 'Failed to update MCPRouter config';
setError(errorMessage);
showToast(errorMessage);
return false;
} finally {
setLoading(false);
}
};
// Update OAuth server configuration
const updateOAuthServerConfig = async <T extends keyof OAuthServerConfig>(
key: T,
value: OAuthServerConfig[T],
) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
oauthServer: {
[key]: value,
},
});
if (data.success) {
setOAuthServerConfig((prev) => ({
...prev,
[key]: value,
}));
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
return false;
}
} catch (error) {
console.error('Failed to update OAuth server config:', error);
const errorMessage =
error instanceof Error ? error.message : 'Failed to update OAuth server config';
setError(errorMessage);
showToast(errorMessage);
return false;
} finally {
setLoading(false);
}
};
// Update multiple OAuth server config fields
const updateOAuthServerConfigBatch = async (updates: Partial<OAuthServerConfig>) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
oauthServer: updates,
});
if (data.success) {
setOAuthServerConfig((prev) => ({
...prev,
...updates,
}));
showToast(t('settings.systemConfigUpdated'));
return true;
} else {
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
return false;
}
} catch (error) {
console.error('Failed to update OAuth server config:', error);
const errorMessage =
error instanceof Error ? error.message : 'Failed to update OAuth server config';
setError(errorMessage);
showToast(errorMessage);
return false;
} finally {
setLoading(false);
}
};
// Update name separator
const updateNameSeparator = async (value: string) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
nameSeparator: value,
});
if (data.success) {
setNameSeparator(value);
showToast(t('settings.restartRequired'), 'info');
return true;
} else {
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
return false;
}
} catch (error) {
console.error('Failed to update name separator:', error);
const errorMessage =
error instanceof Error ? error.message : 'Failed to update name separator';
setError(errorMessage);
showToast(errorMessage);
return false;
} finally {
setLoading(false);
}
};
// Update session rebuild setting
const updateSessionRebuild = async (value: boolean) => {
setLoading(true);
setError(null);
try {
const data = await apiPut('/system-config', {
enableSessionRebuild: value,
});
if (data.success) {
setEnableSessionRebuild(value);
showToast(t('settings.restartRequired'), 'info');
return true;
} else {
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
return false;
}
} catch (error) {
console.error('Failed to update session rebuild setting:', error);
const errorMessage =
error instanceof Error ? error.message : 'Failed to update session rebuild setting';
setError(errorMessage);
showToast(errorMessage);
return false;
} finally {
setLoading(false);
}
};
const exportMCPSettings = async (serverName?: string) => {
setLoading(true);
setError(null);
try {
return await apiGet(`/mcp-settings/export?serverName=${serverName ? serverName : ''}`);
} catch (error) {
console.error('Failed to export MCP settings:', error);
const errorMessage = error instanceof Error ? error.message : 'Failed to export MCP settings';
setError(errorMessage);
showToast(errorMessage);
} finally {
setLoading(false);
}
};
// Fetch settings when the component mounts or refreshKey changes
useEffect(() => {
fetchSettings();
}, [fetchSettings, refreshKey]);
useEffect(() => {
if (routingConfig) {
setTempRoutingConfig({
bearerAuthKey: routingConfig.bearerAuthKey,
});
}
}, [routingConfig]);
return {
routingConfig,
tempRoutingConfig,
setTempRoutingConfig,
installConfig,
smartRoutingConfig,
mcpRouterConfig,
oauthServerConfig,
nameSeparator,
enableSessionRebuild,
loading,
error,
setError,
triggerRefresh,
fetchSettings,
updateRoutingConfig,
updateInstallConfig,
updateSmartRoutingConfig,
updateSmartRoutingConfigBatch,
updateRoutingConfigBatch,
updateMCPRouterConfig,
updateMCPRouterConfigBatch,
updateOAuthServerConfig,
updateOAuthServerConfigBatch,
updateNameSeparator,
updateSessionRebuild,
exportMCPSettings,
};
}; };

View File

@@ -6,6 +6,7 @@ import { useServerData } from '@/hooks/useServerData';
import AddGroupForm from '@/components/AddGroupForm'; import AddGroupForm from '@/components/AddGroupForm';
import EditGroupForm from '@/components/EditGroupForm'; import EditGroupForm from '@/components/EditGroupForm';
import GroupCard from '@/components/GroupCard'; import GroupCard from '@/components/GroupCard';
import GroupImportForm from '@/components/GroupImportForm';
const GroupsPage: React.FC = () => { const GroupsPage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -15,12 +16,13 @@ const GroupsPage: React.FC = () => {
error: groupError, error: groupError,
setError: setGroupError, setError: setGroupError,
deleteGroup, deleteGroup,
triggerRefresh triggerRefresh,
} = useGroupData(); } = useGroupData();
const { servers } = useServerData({ refreshOnMount: true }); const { servers } = useServerData({ refreshOnMount: true });
const [editingGroup, setEditingGroup] = useState<Group | null>(null); const [editingGroup, setEditingGroup] = useState<Group | null>(null);
const [showAddForm, setShowAddForm] = useState(false); const [showAddForm, setShowAddForm] = useState(false);
const [showImportForm, setShowImportForm] = useState(false);
const handleEditClick = (group: Group) => { const handleEditClick = (group: Group) => {
setEditingGroup(group); setEditingGroup(group);
@@ -47,6 +49,11 @@ const GroupsPage: React.FC = () => {
triggerRefresh(); // Refresh the groups list after adding triggerRefresh(); // Refresh the groups list after adding
}; };
const handleImportSuccess = () => {
setShowImportForm(false);
triggerRefresh(); // Refresh the groups list after import
};
return ( return (
<div> <div>
<div className="flex justify-between items-center mb-8"> <div className="flex justify-between items-center mb-8">
@@ -56,11 +63,38 @@ const GroupsPage: React.FC = () => {
onClick={handleAddGroup} onClick={handleAddGroup}
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200" className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
> >
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor"> <svg
<path fillRule="evenodd" d="M10 3a1 1 0 00-1 1v5H4a1 1 0 100 2h5v5a1 1 0 102 0v-5h5a1 1 0 100-2h-5V4a1 1 0 00-1-1z" clipRule="evenodd" /> xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 mr-2"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M10 3a1 1 0 00-1 1v5H4a1 1 0 100 2h5v5a1 1 0 102 0v-5h5a1 1 0 100-2h-5V4a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg> </svg>
{t('groups.add')} {t('groups.add')}
</button> </button>
<button
onClick={() => setShowImportForm(true)}
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 mr-2"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
{t('groupImport.button')}
</button>
</div> </div>
</div> </div>
@@ -73,9 +107,25 @@ const GroupsPage: React.FC = () => {
{groupsLoading ? ( {groupsLoading ? (
<div className="bg-white shadow rounded-lg p-6 loading-container"> <div className="bg-white shadow rounded-lg p-6 loading-container">
<div className="flex flex-col items-center justify-center"> <div className="flex flex-col items-center justify-center">
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <svg
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> className="animate-spin h-10 w-10 text-blue-500 mb-4"
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg> </svg>
<p className="text-gray-600">{t('app.loading')}</p> <p className="text-gray-600">{t('app.loading')}</p>
</div> </div>
@@ -98,8 +148,13 @@ const GroupsPage: React.FC = () => {
</div> </div>
)} )}
{showAddForm && ( {showAddForm && <AddGroupForm onAdd={handleAddComplete} onCancel={handleAddComplete} />}
<AddGroupForm onAdd={handleAddComplete} onCancel={handleAddComplete} />
{showImportForm && (
<GroupImportForm
onSuccess={handleImportSuccess}
onCancel={() => setShowImportForm(false)}
/>
)} )}
{editingGroup && ( {editingGroup && (

View File

@@ -44,6 +44,24 @@ const LoginPage: React.FC = () => {
return sanitizeReturnUrl(params.get('returnUrl')); return sanitizeReturnUrl(params.get('returnUrl'));
}, [location.search]); }, [location.search]);
const isServerUnavailableError = useCallback((message?: string) => {
if (!message) return false;
const normalized = message.toLowerCase();
return (
normalized.includes('failed to fetch') ||
normalized.includes('networkerror') ||
normalized.includes('network error') ||
normalized.includes('connection refused') ||
normalized.includes('unable to connect') ||
normalized.includes('fetch error') ||
normalized.includes('econnrefused') ||
normalized.includes('http 500') ||
normalized.includes('internal server error') ||
normalized.includes('proxy error')
);
}, []);
const buildRedirectTarget = useCallback(() => { const buildRedirectTarget = useCallback(() => {
if (!returnUrl) { if (!returnUrl) {
return '/'; return '/';
@@ -99,11 +117,21 @@ const LoginPage: React.FC = () => {
} else { } else {
redirectAfterLogin(); redirectAfterLogin();
} }
} else {
const message = result.message;
if (isServerUnavailableError(message)) {
setError(t('auth.serverUnavailable'));
} else { } else {
setError(t('auth.loginFailed')); setError(t('auth.loginFailed'));
} }
}
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : undefined;
if (isServerUnavailableError(message)) {
setError(t('auth.serverUnavailable'));
} else {
setError(t('auth.loginError')); setError(t('auth.loginError'));
}
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -131,13 +159,21 @@ const LoginPage: React.FC = () => {
}} }}
/> />
<div className="pointer-events-none absolute inset-0 -z-10"> <div className="pointer-events-none absolute inset-0 -z-10">
<svg className="h-full w-full opacity-[0.08] dark:opacity-[0.12]" xmlns="http://www.w3.org/2000/svg"> <svg
className="h-full w-full opacity-[0.08] dark:opacity-[0.12]"
xmlns="http://www.w3.org/2000/svg"
>
<defs> <defs>
<pattern id="grid" width="32" height="32" patternUnits="userSpaceOnUse"> <pattern id="grid" width="32" height="32" patternUnits="userSpaceOnUse">
<path d="M 32 0 L 0 0 0 32" fill="none" stroke="currentColor" strokeWidth="0.5" /> <path d="M 32 0 L 0 0 0 32" fill="none" stroke="currentColor" strokeWidth="0.5" />
</pattern> </pattern>
</defs> </defs>
<rect width="100%" height="100%" fill="url(#grid)" className="text-gray-400 dark:text-gray-300" /> <rect
width="100%"
height="100%"
fill="url(#grid)"
className="text-gray-400 dark:text-gray-300"
/>
</svg> </svg>
</div> </div>

View File

@@ -8,6 +8,7 @@ import EditServerForm from '@/components/EditServerForm';
import { useServerData } from '@/hooks/useServerData'; import { useServerData } from '@/hooks/useServerData';
import DxtUploadForm from '@/components/DxtUploadForm'; import DxtUploadForm from '@/components/DxtUploadForm';
import JSONImportForm from '@/components/JSONImportForm'; import JSONImportForm from '@/components/JSONImportForm';
import Pagination from '@/components/ui/Pagination';
const ServersPage: React.FC = () => { const ServersPage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -17,10 +18,16 @@ const ServersPage: React.FC = () => {
error, error,
setError, setError,
isLoading, isLoading,
pagination,
currentPage,
serversPerPage,
setCurrentPage,
setServersPerPage,
handleServerAdd, handleServerAdd,
handleServerEdit, handleServerEdit,
handleServerRemove, handleServerRemove,
handleServerToggle, handleServerToggle,
handleServerReload,
triggerRefresh triggerRefresh
} = useServerData({ refreshOnMount: true }); } = useServerData({ refreshOnMount: true });
const [editingServer, setEditingServer] = useState<Server | null>(null); const [editingServer, setEditingServer] = useState<Server | null>(null);
@@ -150,6 +157,7 @@ const ServersPage: React.FC = () => {
<p className="text-gray-600">{t('app.noServers')}</p> <p className="text-gray-600">{t('app.noServers')}</p>
</div> </div>
) : ( ) : (
<>
<div className="space-y-6"> <div className="space-y-6">
{servers.map((server, index) => ( {servers.map((server, index) => (
<ServerCard <ServerCard
@@ -159,9 +167,56 @@ const ServersPage: React.FC = () => {
onEdit={handleEditClick} onEdit={handleEditClick}
onToggle={handleServerToggle} onToggle={handleServerToggle}
onRefresh={triggerRefresh} onRefresh={triggerRefresh}
onReload={handleServerReload}
/> />
))} ))}
</div> </div>
<div className="flex items-center mb-4">
<div className="flex-[2] text-sm text-gray-500">
{pagination ? (
t('common.showing', {
start: (pagination.page - 1) * pagination.limit + 1,
end: Math.min(pagination.page * pagination.limit, pagination.total),
total: pagination.total
})
) : (
t('common.showing', {
start: 1,
end: servers.length,
total: servers.length
})
)}
</div>
<div className="flex-[4] flex justify-center">
{pagination && pagination.totalPages > 1 && (
<Pagination
currentPage={currentPage}
totalPages={pagination.totalPages}
onPageChange={setCurrentPage}
disabled={isLoading}
/>
)}
</div>
<div className="flex-[2] flex items-center justify-end space-x-2">
<label htmlFor="perPage" className="text-sm text-gray-600">
{t('common.itemsPerPage')}:
</label>
<select
id="perPage"
value={serversPerPage}
onChange={(e) => setServersPerPage(Number(e.target.value))}
disabled={isLoading}
className="border rounded p-1 text-sm btn-secondary outline-none disabled:opacity-50 disabled:cursor-not-allowed"
>
<option value={5}>5</option>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
</select>
</div>
</div>
</>
)} )}
{editingServer && ( {editingServer && (

File diff suppressed because it is too large Load Diff

View File

@@ -29,7 +29,7 @@ export const login = async (credentials: LoginCredentials): Promise<AuthResponse
console.error('Login error:', error); console.error('Login error:', error);
return { return {
success: false, success: false,
message: 'An error occurred during login', message: error instanceof Error ? error.message : 'An error occurred during login',
}; };
} }
}; };

View File

@@ -126,10 +126,14 @@ export const updatePromptDescription = async (
): Promise<{ success: boolean; error?: string }> => { ): Promise<{ success: boolean; error?: string }> => {
try { try {
// URL-encode server and prompt names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server") // URL-encode server and prompt names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
// Auth header is automatically added by the interceptor
const response = await apiPut<any>( const response = await apiPut<any>(
`/servers/${encodeURIComponent(serverName)}/prompts/${encodeURIComponent(promptName)}/description`, `/servers/${encodeURIComponent(serverName)}/prompts/${encodeURIComponent(promptName)}/description`,
{ description }, { description },
{
headers: {
Authorization: `Bearer ${localStorage.getItem('mcphub_token')}`,
},
},
); );
return { return {

View File

@@ -30,8 +30,11 @@ export const callTool = async (
? `/tools/${encodeURIComponent(server)}/${encodeURIComponent(request.toolName)}` ? `/tools/${encodeURIComponent(server)}/${encodeURIComponent(request.toolName)}`
: '/tools/call'; : '/tools/call';
// Auth header is automatically added by the interceptor const response = await apiPost<any>(url, request.arguments, {
const response = await apiPost<any>(url, request.arguments); headers: {
Authorization: `Bearer ${localStorage.getItem('mcphub_token')}`, // Add bearer auth for MCP routing
},
});
if (response.success === false) { if (response.success === false) {
return { return {
@@ -63,10 +66,14 @@ export const toggleTool = async (
): Promise<{ success: boolean; error?: string }> => { ): Promise<{ success: boolean; error?: string }> => {
try { try {
// URL-encode server and tool names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server") // URL-encode server and tool names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
// Auth header is automatically added by the interceptor
const response = await apiPost<any>( const response = await apiPost<any>(
`/servers/${encodeURIComponent(serverName)}/tools/${encodeURIComponent(toolName)}/toggle`, `/servers/${encodeURIComponent(serverName)}/tools/${encodeURIComponent(toolName)}/toggle`,
{ enabled }, { enabled },
{
headers: {
Authorization: `Bearer ${localStorage.getItem('mcphub_token')}`,
},
},
); );
return { return {
@@ -92,10 +99,14 @@ export const updateToolDescription = async (
): Promise<{ success: boolean; error?: string }> => { ): Promise<{ success: boolean; error?: string }> => {
try { try {
// URL-encode server and tool names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server") // URL-encode server and tool names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
// Auth header is automatically added by the interceptor
const response = await apiPut<any>( const response = await apiPut<any>(
`/servers/${encodeURIComponent(serverName)}/tools/${encodeURIComponent(toolName)}/description`, `/servers/${encodeURIComponent(serverName)}/tools/${encodeURIComponent(toolName)}/description`,
{ description }, { description },
{
headers: {
Authorization: `Bearer ${localStorage.getItem('mcphub_token')}`,
},
},
); );
return { return {

View File

@@ -105,6 +105,17 @@ export interface Prompt {
enabled?: boolean; enabled?: boolean;
} }
// Proxychains4 configuration for STDIO servers (Linux/macOS only)
export interface ProxychainsConfig {
enabled?: boolean; // Enable/disable proxychains4 proxy routing
type?: 'socks4' | 'socks5' | 'http'; // Proxy protocol type
host?: string; // Proxy server hostname or IP address
port?: number; // Proxy server port
username?: string; // Proxy authentication username (optional)
password?: string; // Proxy authentication password (optional)
configPath?: string; // Path to custom proxychains4 configuration file (optional)
}
// Server config types // Server config types
export interface ServerConfig { export interface ServerConfig {
type?: 'stdio' | 'sse' | 'streamable-http' | 'openapi'; type?: 'stdio' | 'sse' | 'streamable-http' | 'openapi';
@@ -123,6 +134,8 @@ export interface ServerConfig {
resetTimeoutOnProgress?: boolean; // Reset timeout on progress notifications resetTimeoutOnProgress?: boolean; // Reset timeout on progress notifications
maxTotalTimeout?: number; // Maximum total timeout in milliseconds maxTotalTimeout?: number; // Maximum total timeout in milliseconds
}; // MCP request options configuration }; // MCP request options configuration
// Proxychains4 proxy configuration for STDIO servers (Linux/macOS only, Windows not supported)
proxy?: ProxychainsConfig;
// OAuth authentication for upstream MCP servers // OAuth authentication for upstream MCP servers
oauth?: { oauth?: {
clientId?: string; // OAuth client ID clientId?: string; // OAuth client ID
@@ -309,6 +322,19 @@ export interface ApiResponse<T = any> {
data?: T; data?: T;
} }
// Bearer authentication key configuration (frontend view model)
export type BearerKeyAccessType = 'all' | 'groups' | 'servers' | 'custom';
export interface BearerKey {
id: string;
name: string;
token: string;
enabled: boolean;
accessType: BearerKeyAccessType;
allowedGroups?: string[];
allowedServers?: string[];
}
// Auth types // Auth types
export interface IUser { export interface IUser {
username: string; username: string;

View File

@@ -61,6 +61,7 @@
"emptyFields": "Username and password cannot be empty", "emptyFields": "Username and password cannot be empty",
"loginFailed": "Login failed, please check your username and password", "loginFailed": "Login failed, please check your username and password",
"loginError": "An error occurred during login", "loginError": "An error occurred during login",
"serverUnavailable": "Unable to connect to the server. Please check your network connection or try again later",
"currentPassword": "Current Password", "currentPassword": "Current Password",
"newPassword": "New Password", "newPassword": "New Password",
"confirmPassword": "Confirm Password", "confirmPassword": "Confirm Password",
@@ -116,6 +117,9 @@
"enabled": "Enabled", "enabled": "Enabled",
"enable": "Enable", "enable": "Enable",
"disable": "Disable", "disable": "Disable",
"reload": "Reload",
"reloadSuccess": "Server reloaded successfully",
"reloadError": "Failed to reload server {{serverName}}",
"requestOptions": "Connection Configuration", "requestOptions": "Connection Configuration",
"timeout": "Request Timeout", "timeout": "Request Timeout",
"timeoutDescription": "Timeout for requests to the MCP server (ms)", "timeoutDescription": "Timeout for requests to the MCP server (ms)",
@@ -244,13 +248,21 @@
"wechat": "WeChat", "wechat": "WeChat",
"discord": "Discord", "discord": "Discord",
"required": "Required", "required": "Required",
"itemsPerPage": "Items per page",
"showing": "Showing {{start}}-{{end}} of {{total}}",
"previous": "Previous",
"next": "Next",
"secret": "Secret", "secret": "Secret",
"default": "Default", "default": "Default",
"value": "Value", "value": "Value",
"type": "Type", "type": "Type",
"repeated": "Repeated", "repeated": "Repeated",
"valueHint": "Value Hint", "valueHint": "Value Hint",
"choices": "Choices" "choices": "Choices",
"actions": "Actions",
"saving": "Saving...",
"active": "Active",
"inactive": "Inactive"
}, },
"nav": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",
@@ -273,7 +285,7 @@
"recentServers": "Recent Servers" "recentServers": "Recent Servers"
}, },
"servers": { "servers": {
"title": "Servers Management" "title": "Server Management"
}, },
"groups": { "groups": {
"title": "Group Management" "title": "Group Management"
@@ -536,7 +548,9 @@
"description": "Description", "description": "Description",
"messages": "Messages", "messages": "Messages",
"noDescription": "No description available", "noDescription": "No description available",
"runPromptWithName": "Get Prompt: {{name}}" "runPromptWithName": "Get Prompt: {{name}}",
"descriptionUpdateSuccess": "Prompt description updated successfully",
"descriptionUpdateFailed": "Failed to update prompt description"
}, },
"settings": { "settings": {
"enableGlobalRoute": "Enable Global Route", "enableGlobalRoute": "Enable Global Route",
@@ -548,6 +562,28 @@
"bearerAuthKey": "Bearer Authentication Key", "bearerAuthKey": "Bearer Authentication Key",
"bearerAuthKeyDescription": "The authentication key that will be required in the Bearer token", "bearerAuthKeyDescription": "The authentication key that will be required in the Bearer token",
"bearerAuthKeyPlaceholder": "Enter bearer authentication key", "bearerAuthKeyPlaceholder": "Enter bearer authentication key",
"bearerKeysSectionTitle": "Keys",
"bearerKeysSectionDescription": "Manage multiple keys with different access scopes.",
"noBearerKeys": "No keys configured yet.",
"bearerKeyName": "Name",
"bearerKeyToken": "Token",
"bearerKeyEnabled": "Enabled",
"bearerKeyAccessType": "Access scope",
"bearerKeyAccessAll": "All",
"bearerKeyAccessGroups": "Groups",
"bearerKeyAccessServers": "Servers",
"bearerKeyAccessCustom": "Custom",
"bearerKeyAllowedGroups": "Allowed groups",
"bearerKeyAllowedServers": "Allowed servers",
"addBearerKey": "Add key",
"addBearerKeyButton": "Create",
"bearerKeyRequired": "Name and token are required",
"deleteBearerKeyConfirm": "Are you sure you want to delete this key?",
"generate": "Generate",
"selectGroups": "Select Groups",
"selectServers": "Select Servers",
"selectAtLeastOneGroup": "Please select at least one group",
"selectAtLeastOneServer": "Please select at least one server",
"skipAuth": "Skip Authentication", "skipAuth": "Skip Authentication",
"skipAuthDescription": "Bypass login requirement for frontend and API access (DEFAULT OFF for security)", "skipAuthDescription": "Bypass login requirement for frontend and API access (DEFAULT OFF for security)",
"pythonIndexUrl": "Python Package Repository URL", "pythonIndexUrl": "Python Package Repository URL",
@@ -667,6 +703,22 @@
"importFailed": "Failed to import servers", "importFailed": "Failed to import servers",
"partialSuccess": "Imported {{count}} of {{total}} servers successfully. Some servers failed:" "partialSuccess": "Imported {{count}} of {{total}} servers successfully. Some servers failed:"
}, },
"groupImport": {
"button": "Import",
"title": "Import Groups from JSON",
"inputLabel": "Group Configuration JSON",
"inputHelp": "Paste your group configuration JSON. Each group can contain a list of servers.",
"preview": "Preview",
"previewTitle": "Preview Groups to Import",
"import": "Import",
"importing": "Importing...",
"invalidFormat": "Invalid JSON format. The JSON must contain a 'groups' array.",
"missingName": "Each group must have a 'name' field.",
"parseError": "Failed to parse JSON. Please check the format and try again.",
"addFailed": "Failed to add group",
"importFailed": "Failed to import groups",
"partialSuccess": "Imported {{count}} of {{total}} groups successfully. Some groups failed:"
},
"users": { "users": {
"add": "Add User", "add": "Add User",
"addNew": "Add New User", "addNew": "Add New User",
@@ -723,6 +775,7 @@
"failedToRemoveServer": "Server not found or failed to remove", "failedToRemoveServer": "Server not found or failed to remove",
"internalServerError": "Internal server error", "internalServerError": "Internal server error",
"failedToGetServers": "Failed to get servers information", "failedToGetServers": "Failed to get servers information",
"failedToReloadServer": "Failed to reload server",
"failedToGetServerSettings": "Failed to get server settings", "failedToGetServerSettings": "Failed to get server settings",
"failedToGetServerConfig": "Failed to get server configuration", "failedToGetServerConfig": "Failed to get server configuration",
"failedToSaveSettings": "Failed to save settings", "failedToSaveSettings": "Failed to save settings",

View File

@@ -61,6 +61,7 @@
"emptyFields": "Le nom d'utilisateur et le mot de passe ne peuvent pas être vides", "emptyFields": "Le nom d'utilisateur et le mot de passe ne peuvent pas être vides",
"loginFailed": "Échec de la connexion, veuillez vérifier votre nom d'utilisateur et votre mot de passe", "loginFailed": "Échec de la connexion, veuillez vérifier votre nom d'utilisateur et votre mot de passe",
"loginError": "Une erreur est survenue lors de la connexion", "loginError": "Une erreur est survenue lors de la connexion",
"serverUnavailable": "Impossible de se connecter au serveur. Veuillez vérifier votre connexion réseau ou réessayer plus tard",
"currentPassword": "Mot de passe actuel", "currentPassword": "Mot de passe actuel",
"newPassword": "Nouveau mot de passe", "newPassword": "Nouveau mot de passe",
"confirmPassword": "Confirmer le mot de passe", "confirmPassword": "Confirmer le mot de passe",
@@ -116,6 +117,9 @@
"enabled": "Activé", "enabled": "Activé",
"enable": "Activer", "enable": "Activer",
"disable": "Désactiver", "disable": "Désactiver",
"reload": "Recharger",
"reloadSuccess": "Serveur rechargé avec succès",
"reloadError": "Échec du rechargement du serveur {{serverName}}",
"requestOptions": "Configuration de la connexion", "requestOptions": "Configuration de la connexion",
"timeout": "Délai d'attente de la requête", "timeout": "Délai d'attente de la requête",
"timeoutDescription": "Délai d'attente pour les requêtes vers le serveur MCP (ms)", "timeoutDescription": "Délai d'attente pour les requêtes vers le serveur MCP (ms)",
@@ -208,6 +212,7 @@
"serverAdd": "Échec de l'ajout du serveur. Veuillez vérifier l'état du serveur", "serverAdd": "Échec de l'ajout du serveur. Veuillez vérifier l'état du serveur",
"serverUpdate": "Échec de la modification du serveur {{serverName}}. Veuillez vérifier l'état du serveur", "serverUpdate": "Échec de la modification du serveur {{serverName}}. Veuillez vérifier l'état du serveur",
"serverFetch": "Échec de la récupération des données du serveur. Veuillez réessayer plus tard", "serverFetch": "Échec de la récupération des données du serveur. Veuillez réessayer plus tard",
"failedToReloadServer": "Échec du rechargement du serveur",
"initialStartup": "Le serveur est peut-être en cours de démarrage. Veuillez patienter un instant car ce processus peut prendre du temps au premier lancement...", "initialStartup": "Le serveur est peut-être en cours de démarrage. Veuillez patienter un instant car ce processus peut prendre du temps au premier lancement...",
"serverInstall": "Échec de l'installation du serveur", "serverInstall": "Échec de l'installation du serveur",
"failedToFetchSettings": "Échec de la récupération des paramètres", "failedToFetchSettings": "Échec de la récupération des paramètres",
@@ -243,6 +248,10 @@
"github": "GitHub", "github": "GitHub",
"wechat": "WeChat", "wechat": "WeChat",
"discord": "Discord", "discord": "Discord",
"itemsPerPage": "Éléments par page",
"showing": "Affichage de {{start}}-{{end}} sur {{total}}",
"previous": "Précédent",
"next": "Suivant",
"required": "Requis", "required": "Requis",
"secret": "Secret", "secret": "Secret",
"default": "Défaut", "default": "Défaut",
@@ -250,7 +259,11 @@
"type": "Type", "type": "Type",
"repeated": "Répété", "repeated": "Répété",
"valueHint": "Indice de valeur", "valueHint": "Indice de valeur",
"choices": "Choix" "choices": "Choix",
"actions": "Actions",
"saving": "Enregistrement...",
"active": "Actif",
"inactive": "Inactif"
}, },
"nav": { "nav": {
"dashboard": "Tableau de bord", "dashboard": "Tableau de bord",
@@ -536,7 +549,9 @@
"description": "Description", "description": "Description",
"messages": "Messages", "messages": "Messages",
"noDescription": "Aucune description disponible", "noDescription": "Aucune description disponible",
"runPromptWithName": "Obtenir l'invite : {{name}}" "runPromptWithName": "Obtenir l'invite : {{name}}",
"descriptionUpdateSuccess": "Description de l'invite mise à jour avec succès",
"descriptionUpdateFailed": "Échec de la mise à jour de la description de l'invite"
}, },
"settings": { "settings": {
"enableGlobalRoute": "Activer la route globale", "enableGlobalRoute": "Activer la route globale",
@@ -548,6 +563,28 @@
"bearerAuthKey": "Clé d'authentification Bearer", "bearerAuthKey": "Clé d'authentification Bearer",
"bearerAuthKeyDescription": "La clé d'authentification qui sera requise dans le jeton Bearer", "bearerAuthKeyDescription": "La clé d'authentification qui sera requise dans le jeton Bearer",
"bearerAuthKeyPlaceholder": "Entrez la clé d'authentification Bearer", "bearerAuthKeyPlaceholder": "Entrez la clé d'authentification Bearer",
"bearerKeysSectionTitle": "Clés",
"bearerKeysSectionDescription": "Gérez plusieurs clés avec différentes portées daccès.",
"noBearerKeys": "Aucune clé configurée pour le moment.",
"bearerKeyName": "Nom",
"bearerKeyToken": "Jeton",
"bearerKeyEnabled": "Activée",
"bearerKeyAccessType": "Portée daccès",
"bearerKeyAccessAll": "Toutes",
"bearerKeyAccessGroups": "Groupes",
"bearerKeyAccessServers": "Serveurs",
"bearerKeyAccessCustom": "Personnalisée",
"bearerKeyAllowedGroups": "Groupes autorisés",
"bearerKeyAllowedServers": "Serveurs autorisés",
"addBearerKey": "Ajouter une clé",
"addBearerKeyButton": "Créer",
"bearerKeyRequired": "Le nom et le jeton sont obligatoires",
"deleteBearerKeyConfirm": "Voulez-vous vraiment supprimer cette clé ?",
"generate": "Générer",
"selectGroups": "Sélectionner des groupes",
"selectServers": "Sélectionner des serveurs",
"selectAtLeastOneGroup": "Veuillez sélectionner au moins un groupe",
"selectAtLeastOneServer": "Veuillez sélectionner au moins un serveur",
"skipAuth": "Ignorer l'authentification", "skipAuth": "Ignorer l'authentification",
"skipAuthDescription": "Contourner l'exigence de connexion pour l'accès au frontend et à l'API (DÉSACTIVÉ PAR DÉFAUT pour des raisons de sécurité)", "skipAuthDescription": "Contourner l'exigence de connexion pour l'accès au frontend et à l'API (DÉSACTIVÉ PAR DÉFAUT pour des raisons de sécurité)",
"pythonIndexUrl": "URL du dépôt de paquets Python", "pythonIndexUrl": "URL du dépôt de paquets Python",
@@ -667,6 +704,22 @@
"importFailed": "Échec de l'importation des serveurs", "importFailed": "Échec de l'importation des serveurs",
"partialSuccess": "{{count}} serveur(s) sur {{total}} importé(s) avec succès. Certains serveurs ont échoué :" "partialSuccess": "{{count}} serveur(s) sur {{total}} importé(s) avec succès. Certains serveurs ont échoué :"
}, },
"groupImport": {
"button": "Importer",
"title": "Importer des groupes depuis JSON",
"inputLabel": "Configuration JSON des groupes",
"inputHelp": "Collez votre configuration JSON de groupes. Chaque groupe peut contenir une liste de serveurs.",
"preview": "Aperçu",
"previewTitle": "Aperçu des groupes à importer",
"import": "Importer",
"importing": "Importation en cours...",
"invalidFormat": "Format JSON invalide. Le JSON doit contenir un tableau 'groups'.",
"missingName": "Chaque groupe doit avoir un champ 'name'.",
"parseError": "Échec de l'analyse du JSON. Veuillez vérifier le format et réessayer.",
"addFailed": "Échec de l'ajout du groupe",
"importFailed": "Échec de l'importation des groupes",
"partialSuccess": "{{count}} groupe(s) sur {{total}} importé(s) avec succès. Certains groupes ont échoué :"
},
"users": { "users": {
"add": "Ajouter un utilisateur", "add": "Ajouter un utilisateur",
"addNew": "Ajouter un nouvel utilisateur", "addNew": "Ajouter un nouvel utilisateur",

View File

@@ -61,6 +61,7 @@
"emptyFields": "Kullanıcı adı ve şifre boş olamaz", "emptyFields": "Kullanıcı adı ve şifre boş olamaz",
"loginFailed": "Giriş başarısız, lütfen kullanıcı adınızı ve şifrenizi kontrol edin", "loginFailed": "Giriş başarısız, lütfen kullanıcı adınızı ve şifrenizi kontrol edin",
"loginError": "Giriş sırasında bir hata oluştu", "loginError": "Giriş sırasında bir hata oluştu",
"serverUnavailable": "Sunucuya bağlanılamıyor. Lütfen ağ bağlantınızı kontrol edin veya daha sonra tekrar deneyin",
"currentPassword": "Mevcut Şifre", "currentPassword": "Mevcut Şifre",
"newPassword": "Yeni Şifre", "newPassword": "Yeni Şifre",
"confirmPassword": "Şifreyi Onayla", "confirmPassword": "Şifreyi Onayla",
@@ -116,6 +117,9 @@
"enabled": "Etkin", "enabled": "Etkin",
"enable": "Etkinleştir", "enable": "Etkinleştir",
"disable": "Devre Dışı Bırak", "disable": "Devre Dışı Bırak",
"reload": "Yeniden Yükle",
"reloadSuccess": "Sunucu başarıyla yeniden yüklendi",
"reloadError": "Sunucu {{serverName}} yeniden yüklenemedi",
"requestOptions": "Bağlantı Yapılandırması", "requestOptions": "Bağlantı Yapılandırması",
"timeout": "İstek Zaman Aşımı", "timeout": "İstek Zaman Aşımı",
"timeoutDescription": "MCP sunucusuna yapılan istekler için zaman aşımı (ms)", "timeoutDescription": "MCP sunucusuna yapılan istekler için zaman aşımı (ms)",
@@ -208,6 +212,7 @@
"serverAdd": "Sunucu eklenemedi. Lütfen sunucu durumunu kontrol edin", "serverAdd": "Sunucu eklenemedi. Lütfen sunucu durumunu kontrol edin",
"serverUpdate": "{{serverName}} sunucusu düzenlenemedi. Lütfen sunucu durumunu kontrol edin", "serverUpdate": "{{serverName}} sunucusu düzenlenemedi. Lütfen sunucu durumunu kontrol edin",
"serverFetch": "Sunucu verileri alınamadı. Lütfen daha sonra tekrar deneyin", "serverFetch": "Sunucu verileri alınamadı. Lütfen daha sonra tekrar deneyin",
"failedToReloadServer": "Sunucu yeniden yüklenemedi",
"initialStartup": "Sunucu başlatılıyor olabilir. İlk başlatmada bu işlem biraz zaman alabileceğinden lütfen bekleyin...", "initialStartup": "Sunucu başlatılıyor olabilir. İlk başlatmada bu işlem biraz zaman alabileceğinden lütfen bekleyin...",
"serverInstall": "Sunucu yüklenemedi", "serverInstall": "Sunucu yüklenemedi",
"failedToFetchSettings": "Ayarlar getirilemedi", "failedToFetchSettings": "Ayarlar getirilemedi",
@@ -243,6 +248,10 @@
"github": "GitHub", "github": "GitHub",
"wechat": "WeChat", "wechat": "WeChat",
"discord": "Discord", "discord": "Discord",
"itemsPerPage": "Sayfa başına öğe",
"showing": "{{total}} öğeden {{start}}-{{end}} gösteriliyor",
"previous": "Önceki",
"next": "Sonraki",
"required": "Gerekli", "required": "Gerekli",
"secret": "Gizli", "secret": "Gizli",
"default": "Varsayılan", "default": "Varsayılan",
@@ -250,7 +259,11 @@
"type": "Tür", "type": "Tür",
"repeated": "Tekrarlanan", "repeated": "Tekrarlanan",
"valueHint": "Değer İpucu", "valueHint": "Değer İpucu",
"choices": "Seçenekler" "choices": "Seçenekler",
"actions": "Eylemler",
"saving": "Kaydediliyor...",
"active": "Aktif",
"inactive": "Pasif"
}, },
"nav": { "nav": {
"dashboard": "Kontrol Paneli", "dashboard": "Kontrol Paneli",
@@ -536,7 +549,9 @@
"description": "Açıklama", "description": "Açıklama",
"messages": "Mesajlar", "messages": "Mesajlar",
"noDescription": "Kullanılabilir açıklama yok", "noDescription": "Kullanılabilir açıklama yok",
"runPromptWithName": "İsteği Getir: {{name}}" "runPromptWithName": "İsteği Getir: {{name}}",
"descriptionUpdateSuccess": "İstek açıklaması başarıyla güncellendi",
"descriptionUpdateFailed": "İstek açıklaması güncellenemedi"
}, },
"settings": { "settings": {
"enableGlobalRoute": "Global Yönlendirmeyi Etkinleştir", "enableGlobalRoute": "Global Yönlendirmeyi Etkinleştir",
@@ -548,6 +563,28 @@
"bearerAuthKey": "Bearer Kimlik Doğrulama Anahtarı", "bearerAuthKey": "Bearer Kimlik Doğrulama Anahtarı",
"bearerAuthKeyDescription": "Bearer token'da gerekli olacak kimlik doğrulama anahtarı", "bearerAuthKeyDescription": "Bearer token'da gerekli olacak kimlik doğrulama anahtarı",
"bearerAuthKeyPlaceholder": "Bearer kimlik doğrulama anahtarını girin", "bearerAuthKeyPlaceholder": "Bearer kimlik doğrulama anahtarını girin",
"bearerKeysSectionTitle": "Anahtarlar",
"bearerKeysSectionDescription": "Farklı erişim kapsamlarına sahip birden fazla anahtarı yönetin.",
"noBearerKeys": "Henüz yapılandırılmış herhangi bir anahtar yok.",
"bearerKeyName": "Ad",
"bearerKeyToken": "Token",
"bearerKeyEnabled": "Etkin",
"bearerKeyAccessType": "Erişim kapsamı",
"bearerKeyAccessAll": "Tümü",
"bearerKeyAccessGroups": "Gruplar",
"bearerKeyAccessServers": "Sunucular",
"bearerKeyAccessCustom": "Özel",
"bearerKeyAllowedGroups": "İzin verilen gruplar",
"bearerKeyAllowedServers": "İzin verilen sunucular",
"addBearerKey": "Anahtar ekle",
"addBearerKeyButton": "Oluştur",
"bearerKeyRequired": "Ad ve token zorunludur",
"deleteBearerKeyConfirm": "Bu anahtarı silmek istediğinizden emin misiniz?",
"generate": "Oluştur",
"selectGroups": "Grupları Seç",
"selectServers": "Sunucuları Seç",
"selectAtLeastOneGroup": "Lütfen en az bir grup seçin",
"selectAtLeastOneServer": "Lütfen en az bir sunucu seçin",
"skipAuth": "Kimlik Doğrulamayı Atla", "skipAuth": "Kimlik Doğrulamayı Atla",
"skipAuthDescription": "Arayüz ve API erişimi için giriş gereksinimini atla (Güvenlik için VARSAYILAN KAPALI)", "skipAuthDescription": "Arayüz ve API erişimi için giriş gereksinimini atla (Güvenlik için VARSAYILAN KAPALI)",
"pythonIndexUrl": "Python Paket Deposu URL'si", "pythonIndexUrl": "Python Paket Deposu URL'si",
@@ -667,6 +704,22 @@
"importFailed": "Sunucular içe aktarılamadı", "importFailed": "Sunucular içe aktarılamadı",
"partialSuccess": "{{total}} sunucudan {{count}} tanesi başarıyla içe aktarıldı. Bazı sunucular başarısız oldu:" "partialSuccess": "{{total}} sunucudan {{count}} tanesi başarıyla içe aktarıldı. Bazı sunucular başarısız oldu:"
}, },
"groupImport": {
"button": "İçe Aktar",
"title": "JSON'dan Grupları İçe Aktar",
"inputLabel": "Grup Yapılandırma JSON",
"inputHelp": "Grup yapılandırma JSON'unuzu yapıştırın. Her grup bir sunucu listesi içerebilir.",
"preview": "Önizle",
"previewTitle": "İçe Aktarılacak Grupları Önizle",
"import": "İçe Aktar",
"importing": "İçe aktarılıyor...",
"invalidFormat": "Geçersiz JSON formatı. JSON bir 'groups' dizisi içermelidir.",
"missingName": "Her grubun bir 'name' alanı olmalıdır.",
"parseError": "JSON ayrıştırılamadı. Lütfen formatı kontrol edip tekrar deneyin.",
"addFailed": "Grup eklenemedi",
"importFailed": "Gruplar içe aktarılamadı",
"partialSuccess": "{{total}} gruptan {{count}} tanesi başarıyla içe aktarıldı. Bazı gruplar başarısız oldu:"
},
"users": { "users": {
"add": "Kullanıcı Ekle", "add": "Kullanıcı Ekle",
"addNew": "Yeni Kullanıcı Ekle", "addNew": "Yeni Kullanıcı Ekle",

View File

@@ -61,6 +61,7 @@
"emptyFields": "用户名和密码不能为空", "emptyFields": "用户名和密码不能为空",
"loginFailed": "登录失败,请检查用户名和密码", "loginFailed": "登录失败,请检查用户名和密码",
"loginError": "登录过程中出现错误", "loginError": "登录过程中出现错误",
"serverUnavailable": "无法连接到服务器,请检查网络连接或稍后再试",
"currentPassword": "当前密码", "currentPassword": "当前密码",
"newPassword": "新密码", "newPassword": "新密码",
"confirmPassword": "确认密码", "confirmPassword": "确认密码",
@@ -116,6 +117,9 @@
"enabled": "已启用", "enabled": "已启用",
"enable": "启用", "enable": "启用",
"disable": "禁用", "disable": "禁用",
"reload": "重载",
"reloadSuccess": "服务器重载成功",
"reloadError": "重载服务器 {{serverName}} 失败",
"requestOptions": "连接配置", "requestOptions": "连接配置",
"timeout": "请求超时", "timeout": "请求超时",
"timeoutDescription": "请求超时时间(毫秒)", "timeoutDescription": "请求超时时间(毫秒)",
@@ -208,6 +212,7 @@
"serverAdd": "添加服务器失败,请检查服务器状态", "serverAdd": "添加服务器失败,请检查服务器状态",
"serverUpdate": "编辑服务器 {{serverName}} 失败,请检查服务器状态", "serverUpdate": "编辑服务器 {{serverName}} 失败,请检查服务器状态",
"serverFetch": "获取服务器数据失败,请稍后重试", "serverFetch": "获取服务器数据失败,请稍后重试",
"failedToReloadServer": "重载服务器失败",
"initialStartup": "服务器可能正在启动中。首次启动可能需要一些时间,请耐心等候...", "initialStartup": "服务器可能正在启动中。首次启动可能需要一些时间,请耐心等候...",
"serverInstall": "安装服务器失败", "serverInstall": "安装服务器失败",
"failedToFetchSettings": "获取设置失败", "failedToFetchSettings": "获取设置失败",
@@ -243,6 +248,10 @@
"dismiss": "忽略", "dismiss": "忽略",
"github": "GitHub", "github": "GitHub",
"wechat": "微信", "wechat": "微信",
"itemsPerPage": "每页显示",
"showing": "显示第 {{start}}-{{end}} 条,共 {{total}} 条",
"previous": "上一页",
"next": "下一页",
"discord": "Discord", "discord": "Discord",
"required": "必填", "required": "必填",
"secret": "敏感", "secret": "敏感",
@@ -251,7 +260,11 @@
"type": "类型", "type": "类型",
"repeated": "可重复", "repeated": "可重复",
"valueHint": "值提示", "valueHint": "值提示",
"choices": "可选值" "choices": "可选值",
"actions": "操作",
"saving": "保存中...",
"active": "已激活",
"inactive": "未激活"
}, },
"nav": { "nav": {
"dashboard": "仪表盘", "dashboard": "仪表盘",
@@ -285,7 +298,7 @@
"routeConfig": "安全配置", "routeConfig": "安全配置",
"installConfig": "安装", "installConfig": "安装",
"smartRouting": "智能路由", "smartRouting": "智能路由",
"oauthServer": "OAuth 服务器" "oauthServer": "OAuth"
}, },
"groups": { "groups": {
"title": "分组管理" "title": "分组管理"
@@ -537,7 +550,9 @@
"description": "描述", "description": "描述",
"messages": "消息", "messages": "消息",
"noDescription": "无描述信息", "noDescription": "无描述信息",
"runPromptWithName": "获取提示词: {{name}}" "runPromptWithName": "获取提示词: {{name}}",
"descriptionUpdateSuccess": "提示词描述更新成功",
"descriptionUpdateFailed": "更新提示词描述失败"
}, },
"settings": { "settings": {
"enableGlobalRoute": "启用全局路由", "enableGlobalRoute": "启用全局路由",
@@ -549,6 +564,28 @@
"bearerAuthKey": "Bearer 认证密钥", "bearerAuthKey": "Bearer 认证密钥",
"bearerAuthKeyDescription": "Bearer 令牌中需要携带的认证密钥", "bearerAuthKeyDescription": "Bearer 令牌中需要携带的认证密钥",
"bearerAuthKeyPlaceholder": "请输入 Bearer 认证密钥", "bearerAuthKeyPlaceholder": "请输入 Bearer 认证密钥",
"bearerKeysSectionTitle": "密钥",
"bearerKeysSectionDescription": "管理多条密钥,并为不同密钥配置不同的访问范围。",
"noBearerKeys": "当前还没有配置任何密钥。",
"bearerKeyName": "名称",
"bearerKeyToken": "密钥值",
"bearerKeyEnabled": "启用",
"bearerKeyAccessType": "访问范围",
"bearerKeyAccessAll": "全部",
"bearerKeyAccessGroups": "指定分组",
"bearerKeyAccessServers": "指定服务器",
"bearerKeyAccessCustom": "自定义",
"bearerKeyAllowedGroups": "允许访问的分组",
"bearerKeyAllowedServers": "允许访问的服务器",
"addBearerKey": "新增密钥",
"addBearerKeyButton": "创建",
"bearerKeyRequired": "名称和密钥值为必填项",
"deleteBearerKeyConfirm": "确定要删除这条密钥吗?",
"generate": "生成",
"selectGroups": "选择分组",
"selectServers": "选择服务器",
"selectAtLeastOneGroup": "请至少选择一个分组",
"selectAtLeastOneServer": "请至少选择一个服务",
"skipAuth": "免登录开关", "skipAuth": "免登录开关",
"skipAuthDescription": "跳过前端和 API 访问的登录要求(默认关闭确保安全性)", "skipAuthDescription": "跳过前端和 API 访问的登录要求(默认关闭确保安全性)",
"pythonIndexUrl": "Python 包仓库地址", "pythonIndexUrl": "Python 包仓库地址",
@@ -669,6 +706,22 @@
"importFailed": "导入服务器失败", "importFailed": "导入服务器失败",
"partialSuccess": "成功导入 {{count}} / {{total}} 个服务器。部分服务器失败:" "partialSuccess": "成功导入 {{count}} / {{total}} 个服务器。部分服务器失败:"
}, },
"groupImport": {
"button": "导入",
"title": "从 JSON 导入分组",
"inputLabel": "分组配置 JSON",
"inputHelp": "粘贴您的分组配置 JSON。每个分组可以包含一个服务器列表。",
"preview": "预览",
"previewTitle": "预览要导入的分组",
"import": "导入",
"importing": "导入中...",
"invalidFormat": "无效的 JSON 格式。JSON 必须包含 'groups' 数组。",
"missingName": "每个分组必须有 'name' 字段。",
"parseError": "解析 JSON 失败。请检查格式后重试。",
"addFailed": "添加分组失败",
"importFailed": "导入分组失败",
"partialSuccess": "成功导入 {{count}} / {{total}} 个分组。部分分组失败:"
},
"users": { "users": {
"add": "添加", "add": "添加",
"addNew": "添加新用户", "addNew": "添加新用户",

View File

@@ -8,12 +8,6 @@
], ],
"env": { "env": {
"AMAP_MAPS_API_KEY": "your-api-key" "AMAP_MAPS_API_KEY": "your-api-key"
},
"tools": {
"amap-maps_regeocode": {
"enabled": true,
"description": "Updated via UI test"
}
} }
}, },
"playwright": { "playwright": {
@@ -69,5 +63,6 @@
"requiresAuthentication": false "requiresAuthentication": false
} }
} }
} },
"bearerKeys": []
} }

View File

@@ -46,7 +46,7 @@
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@apidevtools/swagger-parser": "^12.0.0", "@apidevtools/swagger-parser": "^12.0.0",
"@modelcontextprotocol/sdk": "^1.20.2", "@modelcontextprotocol/sdk": "^1.25.1",
"@node-oauth/oauth2-server": "^5.2.1", "@node-oauth/oauth2-server": "^5.2.1",
"@types/adm-zip": "^0.5.7", "@types/adm-zip": "^0.5.7",
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
@@ -57,10 +57,10 @@
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.6.1", "dotenv": "^17.2.3",
"dotenv-expand": "^12.0.2", "dotenv-expand": "^12.0.2",
"express": "^4.21.2", "express": "^4.21.2",
"express-validator": "^7.2.1", "express-validator": "^7.3.1",
"i18next": "^25.5.0", "i18next": "^25.5.0",
"i18next-fs-backend": "^2.6.0", "i18next-fs-backend": "^2.6.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
@@ -73,6 +73,7 @@
"postgres": "^3.4.7", "postgres": "^3.4.7",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"typeorm": "^0.3.26", "typeorm": "^0.3.26",
"undici": "^7.16.0",
"uuid": "^11.1.0" "uuid": "^11.1.0"
}, },
"devDependencies": { "devDependencies": {
@@ -107,11 +108,11 @@
"jest-environment-node": "^30.0.5", "jest-environment-node": "^30.0.5",
"jest-mock-extended": "4.0.0", "jest-mock-extended": "4.0.0",
"lucide-react": "^0.552.0", "lucide-react": "^0.552.0",
"next": "^15.5.0", "next": "^16.1.1",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"react": "19.1.1", "react": "19.2.1",
"react-dom": "19.1.1", "react-dom": "19.2.1",
"react-i18next": "^15.7.2", "react-i18next": "^15.7.2",
"react-router-dom": "^7.8.2", "react-router-dom": "^7.8.2",
"supertest": "^7.1.4", "supertest": "^7.1.4",
@@ -132,7 +133,10 @@
"pnpm": { "pnpm": {
"overrides": { "overrides": {
"brace-expansion@1.1.11": "1.1.12", "brace-expansion@1.1.11": "1.1.12",
"brace-expansion@2.0.1": "2.0.2" "brace-expansion@2.0.1": "2.0.2",
"glob@10.4.5": "10.5.0",
"js-yaml": "4.1.1",
"jws@3.2.2": "4.0.1"
} }
} }
} }

1999
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,169 @@
import { Request, Response } from 'express';
import { ApiResponse, BearerKey } from '../types/index.js';
import { getBearerKeyDao, getSystemConfigDao } from '../dao/index.js';
const requireAdmin = async (req: Request, res: Response): Promise<boolean> => {
const systemConfigDao = getSystemConfigDao();
const systemConfig = await systemConfigDao.get();
if (systemConfig?.routing?.skipAuth) {
return true;
}
const user = (req as any).user;
if (!user || !user.isAdmin) {
res.status(403).json({
success: false,
message: 'Admin privileges required',
});
return false;
}
return true;
};
export const getBearerKeys = async (req: Request, res: Response): Promise<void> => {
if (!(await requireAdmin(req, res))) return;
try {
const dao = getBearerKeyDao();
const keys = await dao.findAll();
const response: ApiResponse = {
success: true,
data: keys,
};
res.json(response);
} catch (error) {
console.error('Failed to get bearer keys:', error);
res.status(500).json({
success: false,
message: 'Failed to get bearer keys',
});
}
};
export const createBearerKey = async (req: Request, res: Response): Promise<void> => {
if (!(await requireAdmin(req, res))) return;
try {
const { name, token, enabled, accessType, allowedGroups, allowedServers } =
req.body as Partial<BearerKey>;
if (!name || typeof name !== 'string') {
res.status(400).json({ success: false, message: 'Key name is required' });
return;
}
if (!token || typeof token !== 'string') {
res.status(400).json({ success: false, message: 'Token value is required' });
return;
}
if (!accessType || !['all', 'groups', 'servers', 'custom'].includes(accessType)) {
res.status(400).json({ success: false, message: 'Invalid accessType' });
return;
}
const dao = getBearerKeyDao();
const key = await dao.create({
name,
token,
enabled: enabled ?? true,
accessType,
allowedGroups: Array.isArray(allowedGroups) ? allowedGroups : [],
allowedServers: Array.isArray(allowedServers) ? allowedServers : [],
});
const response: ApiResponse = {
success: true,
data: key,
};
res.status(201).json(response);
} catch (error) {
console.error('Failed to create bearer key:', error);
res.status(500).json({
success: false,
message: 'Failed to create bearer key',
});
}
};
export const updateBearerKey = async (req: Request, res: Response): Promise<void> => {
if (!(await requireAdmin(req, res))) return;
try {
const { id } = req.params;
if (!id) {
res.status(400).json({ success: false, message: 'Key id is required' });
return;
}
const { name, token, enabled, accessType, allowedGroups, allowedServers } =
req.body as Partial<BearerKey>;
const updates: Partial<BearerKey> = {};
if (name !== undefined) updates.name = name;
if (token !== undefined) updates.token = token;
if (enabled !== undefined) updates.enabled = enabled;
if (accessType !== undefined) {
if (!['all', 'groups', 'servers', 'custom'].includes(accessType)) {
res.status(400).json({ success: false, message: 'Invalid accessType' });
return;
}
updates.accessType = accessType as BearerKey['accessType'];
}
if (allowedGroups !== undefined) {
updates.allowedGroups = Array.isArray(allowedGroups) ? allowedGroups : [];
}
if (allowedServers !== undefined) {
updates.allowedServers = Array.isArray(allowedServers) ? allowedServers : [];
}
const dao = getBearerKeyDao();
const updated = await dao.update(id, updates);
if (!updated) {
res.status(404).json({ success: false, message: 'Bearer key not found' });
return;
}
const response: ApiResponse = {
success: true,
data: updated,
};
res.json(response);
} catch (error) {
console.error('Failed to update bearer key:', error);
res.status(500).json({
success: false,
message: 'Failed to update bearer key',
});
}
};
export const deleteBearerKey = async (req: Request, res: Response): Promise<void> => {
if (!(await requireAdmin(req, res))) return;
try {
const { id } = req.params;
if (!id) {
res.status(400).json({ success: false, message: 'Key id is required' });
return;
}
const dao = getBearerKeyDao();
const deleted = await dao.delete(id);
if (!deleted) {
res.status(404).json({ success: false, message: 'Bearer key not found' });
return;
}
const response: ApiResponse = {
success: true,
};
res.json(response);
} catch (error) {
console.error('Failed to delete bearer key:', error);
res.status(500).json({
success: false,
message: 'Failed to delete bearer key',
});
}
};

View File

@@ -1,9 +1,19 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import config from '../config/index.js'; import config from '../config/index.js';
import { loadSettings, loadOriginalSettings } from '../config/index.js'; import { loadSettings } from '../config/index.js';
import { getDataService } from '../services/services.js'; import { getDataService } from '../services/services.js';
import { DataService } from '../services/dataService.js'; import { DataService } from '../services/dataService.js';
import { IUser } from '../types/index.js'; import { IUser } from '../types/index.js';
import {
getGroupDao,
getOAuthClientDao,
getOAuthTokenDao,
getServerDao,
getSystemConfigDao,
getUserConfigDao,
getUserDao,
getBearerKeyDao,
} from '../dao/DaoFactory.js';
const dataService: DataService = getDataService(); const dataService: DataService = getDataService();
@@ -73,17 +83,39 @@ export const getPublicConfig = (req: Request, res: Response): void => {
} }
}; };
/**
* Recursively remove null values from an object
*/
const removeNullValues = <T>(obj: T): T => {
if (obj === null || obj === undefined) {
return obj;
}
if (Array.isArray(obj)) {
return obj.map((item) => removeNullValues(item)) as T;
}
if (typeof obj === 'object') {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (value !== null) {
result[key] = removeNullValues(value);
}
}
return result as T;
}
return obj;
};
/** /**
* Get MCP settings in JSON format for export/copy * Get MCP settings in JSON format for export/copy
* Supports both full settings and individual server configuration * Supports both full settings and individual server configuration
*/ */
export const getMcpSettingsJson = (req: Request, res: Response): void => { export const getMcpSettingsJson = async (req: Request, res: Response): Promise<void> => {
try { try {
const { serverName } = req.query; const { serverName } = req.query;
const settings = loadOriginalSettings();
if (serverName && typeof serverName === 'string') { if (serverName && typeof serverName === 'string') {
// Return individual server configuration // Return individual server configuration using DAO
const serverConfig = settings.mcpServers[serverName]; const serverDao = getServerDao();
const serverConfig = await serverDao.findById(serverName);
if (!serverConfig) { if (!serverConfig) {
res.status(404).json({ res.status(404).json({
success: false, success: false,
@@ -92,16 +124,56 @@ export const getMcpSettingsJson = (req: Request, res: Response): void => {
return; return;
} }
// Remove the 'name' field from config as it's used as the key
const { name, ...configWithoutName } = serverConfig;
// Remove null values from the config
const cleanedConfig = removeNullValues(configWithoutName);
res.json({ res.json({
success: true, success: true,
data: { data: {
mcpServers: { mcpServers: {
[serverName]: serverConfig, [name]: cleanedConfig,
}, },
}, },
}); });
} else { } else {
// Return full settings // Return full settings via DAO layer (supports both file and database modes)
const [
servers,
users,
groups,
systemConfig,
userConfigs,
oauthClients,
oauthTokens,
bearerKeys,
] = await Promise.all([
getServerDao().findAll(),
getUserDao().findAll(),
getGroupDao().findAll(),
getSystemConfigDao().get(),
getUserConfigDao().getAll(),
getOAuthClientDao().findAll(),
getOAuthTokenDao().findAll(),
getBearerKeyDao().findAll(),
]);
const mcpServers: Record<string, any> = {};
for (const { name: serverConfigName, ...config } of servers) {
mcpServers[serverConfigName] = removeNullValues(config);
}
const settings = {
mcpServers,
users,
groups,
systemConfig,
userConfigs,
oauthClients,
oauthTokens,
bearerKeys,
};
res.json({ res.json({
success: true, success: true,
data: settings, data: settings,

View File

@@ -1,5 +1,11 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { ApiResponse } from '../types/index.js'; import {
ApiResponse,
AddGroupRequest,
BatchCreateGroupsRequest,
BatchCreateGroupsResponse,
BatchGroupResult,
} from '../types/index.js';
import { import {
getAllGroups, getAllGroups,
getGroupByIdOrName, getGroupByIdOrName,
@@ -106,6 +112,143 @@ export const createNewGroup = async (req: Request, res: Response): Promise<void>
} }
}; };
// Batch create groups - validates and creates multiple groups in one request
export const batchCreateGroups = async (req: Request, res: Response): Promise<void> => {
try {
const { groups } = req.body as BatchCreateGroupsRequest;
// Validate request body
if (!groups || !Array.isArray(groups)) {
res.status(400).json({
success: false,
message: 'Request body must contain a "groups" array',
});
return;
}
if (groups.length === 0) {
res.status(400).json({
success: false,
message: 'Groups array cannot be empty',
});
return;
}
// Helper function to validate a single group configuration
const validateGroupConfig = (group: AddGroupRequest): { valid: boolean; message?: string } => {
if (!group.name || typeof group.name !== 'string') {
return { valid: false, message: 'Group name is required and must be a string' };
}
if (group.description !== undefined && typeof group.description !== 'string') {
return { valid: false, message: 'Group description must be a string' };
}
if (group.servers !== undefined && !Array.isArray(group.servers)) {
return { valid: false, message: 'Group servers must be an array' };
}
// Validate server configurations if provided in new format
if (group.servers) {
for (const server of group.servers) {
if (typeof server === 'object' && server !== null) {
if (!server.name || typeof server.name !== 'string') {
return {
valid: false,
message: 'Server configuration must have a name property',
};
}
if (
server.tools !== undefined &&
server.tools !== 'all' &&
!Array.isArray(server.tools)
) {
return {
valid: false,
message: 'Server tools must be "all" or an array of tool names',
};
}
}
}
}
return { valid: true };
};
// Process each group
const results: BatchGroupResult[] = [];
let successCount = 0;
let failureCount = 0;
// Get current user for owner field
const currentUser = (req as any).user;
const defaultOwner = currentUser?.username || 'admin';
for (const groupData of groups) {
const { name, description, servers } = groupData;
// Validate group configuration
const validation = validateGroupConfig(groupData);
if (!validation.valid) {
results.push({
name: name || 'unknown',
success: false,
message: validation.message,
});
failureCount++;
continue;
}
try {
const serverList = Array.isArray(servers) ? servers : [];
const newGroup = await createGroup(name, description, serverList, defaultOwner);
if (newGroup) {
results.push({
name,
success: true,
message: 'Group created successfully',
});
successCount++;
} else {
results.push({
name,
success: false,
message: 'Failed to create group or group name already exists',
});
failureCount++;
}
} catch (error) {
results.push({
name,
success: false,
message: error instanceof Error ? error.message : 'Failed to create group',
});
failureCount++;
}
}
// Return response
const response: BatchCreateGroupsResponse = {
success: successCount > 0,
successCount,
failureCount,
results,
};
// Use 207 Multi-Status if there were partial failures, 200 if all succeeded
const statusCode = failureCount > 0 && successCount > 0 ? 207 : successCount > 0 ? 200 : 400;
res.status(statusCode).json(response);
} catch (error) {
console.error('Batch create groups error:', error);
res.status(500).json({
success: false,
message: 'Internal server error',
});
}
};
// Update an existing group // Update an existing group
export const updateExistingGroup = async (req: Request, res: Response): Promise<void> => { export const updateExistingGroup = async (req: Request, res: Response): Promise<void> => {
try { try {

View File

@@ -14,9 +14,9 @@ import { IOAuthClient } from '../types/index.js';
* GET /api/oauth/clients * GET /api/oauth/clients
* Get all OAuth clients * Get all OAuth clients
*/ */
export const getAllClients = (req: Request, res: Response): void => { export const getAllClients = async (req: Request, res: Response): Promise<void> => {
try { try {
const clients = getOAuthClients(); const clients = await getOAuthClients();
// Don't expose client secrets in the list // Don't expose client secrets in the list
const sanitizedClients = clients.map((client) => ({ const sanitizedClients = clients.map((client) => ({
@@ -45,10 +45,10 @@ export const getAllClients = (req: Request, res: Response): void => {
* GET /api/oauth/clients/:clientId * GET /api/oauth/clients/:clientId
* Get a specific OAuth client * Get a specific OAuth client
*/ */
export const getClient = (req: Request, res: Response): void => { export const getClient = async (req: Request, res: Response): Promise<void> => {
try { try {
const { clientId } = req.params; const { clientId } = req.params;
const client = findOAuthClientById(clientId); const client = await findOAuthClientById(clientId);
if (!client) { if (!client) {
res.status(404).json({ res.status(404).json({
@@ -85,7 +85,7 @@ export const getClient = (req: Request, res: Response): void => {
* POST /api/oauth/clients * POST /api/oauth/clients
* Create a new OAuth client * Create a new OAuth client
*/ */
export const createClient = (req: Request, res: Response): void => { export const createClient = async (req: Request, res: Response): Promise<void> => {
try { try {
// Validate request // Validate request
const errors = validationResult(req); const errors = validationResult(req);
@@ -105,7 +105,8 @@ export const createClient = (req: Request, res: Response): void => {
const clientId = crypto.randomBytes(16).toString('hex'); const clientId = crypto.randomBytes(16).toString('hex');
// Generate client secret if required // Generate client secret if required
const clientSecret = requireSecret !== false ? crypto.randomBytes(32).toString('hex') : undefined; const clientSecret =
requireSecret !== false ? crypto.randomBytes(32).toString('hex') : undefined;
// Create client // Create client
const client: IOAuthClient = { const client: IOAuthClient = {
@@ -118,7 +119,7 @@ export const createClient = (req: Request, res: Response): void => {
owner: user?.username || 'admin', owner: user?.username || 'admin',
}; };
const createdClient = createOAuthClient(client); const createdClient = await createOAuthClient(client);
// Return client with secret (only shown once) // Return client with secret (only shown once)
res.status(201).json({ res.status(201).json({
@@ -158,18 +159,19 @@ export const createClient = (req: Request, res: Response): void => {
* PUT /api/oauth/clients/:clientId * PUT /api/oauth/clients/:clientId
* Update an OAuth client * Update an OAuth client
*/ */
export const updateClient = (req: Request, res: Response): void => { export const updateClient = async (req: Request, res: Response): Promise<void> => {
try { try {
const { clientId } = req.params; const { clientId } = req.params;
const { name, redirectUris, grants, scopes } = req.body; const { name, redirectUris, grants, scopes } = req.body;
const updates: Partial<IOAuthClient> = {}; const updates: Partial<IOAuthClient> = {};
if (name) updates.name = name; if (name) updates.name = name;
if (redirectUris) updates.redirectUris = Array.isArray(redirectUris) ? redirectUris : [redirectUris]; if (redirectUris)
updates.redirectUris = Array.isArray(redirectUris) ? redirectUris : [redirectUris];
if (grants) updates.grants = grants; if (grants) updates.grants = grants;
if (scopes) updates.scopes = scopes; if (scopes) updates.scopes = scopes;
const updatedClient = updateOAuthClient(clientId, updates); const updatedClient = await updateOAuthClient(clientId, updates);
if (!updatedClient) { if (!updatedClient) {
res.status(404).json({ res.status(404).json({
@@ -205,10 +207,10 @@ export const updateClient = (req: Request, res: Response): void => {
* DELETE /api/oauth/clients/:clientId * DELETE /api/oauth/clients/:clientId
* Delete an OAuth client * Delete an OAuth client
*/ */
export const deleteClient = (req: Request, res: Response): void => { export const deleteClient = async (req: Request, res: Response): Promise<void> => {
try { try {
const { clientId } = req.params; const { clientId } = req.params;
const deleted = deleteOAuthClient(clientId); const deleted = await deleteOAuthClient(clientId);
if (!deleted) { if (!deleted) {
res.status(404).json({ res.status(404).json({
@@ -235,10 +237,10 @@ export const deleteClient = (req: Request, res: Response): void => {
* POST /api/oauth/clients/:clientId/regenerate-secret * POST /api/oauth/clients/:clientId/regenerate-secret
* Regenerate client secret * Regenerate client secret
*/ */
export const regenerateSecret = (req: Request, res: Response): void => { export const regenerateSecret = async (req: Request, res: Response): Promise<void> => {
try { try {
const { clientId } = req.params; const { clientId } = req.params;
const client = findOAuthClientById(clientId); const client = await findOAuthClientById(clientId);
if (!client) { if (!client) {
res.status(404).json({ res.status(404).json({
@@ -250,7 +252,7 @@ export const regenerateSecret = (req: Request, res: Response): void => {
// Generate new secret // Generate new secret
const newSecret = crypto.randomBytes(32).toString('hex'); const newSecret = crypto.randomBytes(32).toString('hex');
const updatedClient = updateOAuthClient(clientId, { clientSecret: newSecret }); const updatedClient = await updateOAuthClient(clientId, { clientSecret: newSecret });
if (!updatedClient) { if (!updatedClient) {
res.status(500).json({ res.status(500).json({

View File

@@ -48,7 +48,7 @@ const verifyRegistrationToken = (token: string): string | null => {
* RFC 7591 Dynamic Client Registration * RFC 7591 Dynamic Client Registration
* Public endpoint for registering new OAuth clients * Public endpoint for registering new OAuth clients
*/ */
export const registerClient = (req: Request, res: Response): void => { export const registerClient = async (req: Request, res: Response): Promise<void> => {
try { try {
const settings = loadSettings(); const settings = loadSettings();
const oauthConfig = settings.systemConfig?.oauthServer; const oauthConfig = settings.systemConfig?.oauthServer;
@@ -183,7 +183,7 @@ export const registerClient = (req: Request, res: Response): void => {
}, },
}; };
const createdClient = createOAuthClient(client); const createdClient = await createOAuthClient(client);
// Build response according to RFC 7591 // Build response according to RFC 7591
const response: any = { const response: any = {
@@ -238,7 +238,7 @@ export const registerClient = (req: Request, res: Response): void => {
* RFC 7591 Client Configuration Endpoint * RFC 7591 Client Configuration Endpoint
* Read client configuration * Read client configuration
*/ */
export const getClientConfiguration = (req: Request, res: Response): void => { export const getClientConfiguration = async (req: Request, res: Response): Promise<void> => {
try { try {
const { clientId } = req.params; const { clientId } = req.params;
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
@@ -262,7 +262,7 @@ export const getClientConfiguration = (req: Request, res: Response): void => {
return; return;
} }
const client = findOAuthClientById(clientId); const client = await findOAuthClientById(clientId);
if (!client) { if (!client) {
res.status(404).json({ res.status(404).json({
error: 'invalid_client', error: 'invalid_client',
@@ -311,7 +311,7 @@ export const getClientConfiguration = (req: Request, res: Response): void => {
* RFC 7591 Client Update Endpoint * RFC 7591 Client Update Endpoint
* Update client configuration * Update client configuration
*/ */
export const updateClientConfiguration = (req: Request, res: Response): void => { export const updateClientConfiguration = async (req: Request, res: Response): Promise<void> => {
try { try {
const { clientId } = req.params; const { clientId } = req.params;
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
@@ -335,7 +335,7 @@ export const updateClientConfiguration = (req: Request, res: Response): void =>
return; return;
} }
const client = findOAuthClientById(clientId); const client = await findOAuthClientById(clientId);
if (!client) { if (!client) {
res.status(404).json({ res.status(404).json({
error: 'invalid_client', error: 'invalid_client',
@@ -443,7 +443,7 @@ export const updateClientConfiguration = (req: Request, res: Response): void =>
}; };
} }
const updatedClient = updateOAuthClient(clientId, updates); const updatedClient = await updateOAuthClient(clientId, updates);
if (!updatedClient) { if (!updatedClient) {
res.status(500).json({ res.status(500).json({
@@ -495,7 +495,7 @@ export const updateClientConfiguration = (req: Request, res: Response): void =>
* RFC 7591 Client Delete Endpoint * RFC 7591 Client Delete Endpoint
* Delete client registration * Delete client registration
*/ */
export const deleteClientRegistration = (req: Request, res: Response): void => { export const deleteClientRegistration = async (req: Request, res: Response): Promise<void> => {
try { try {
const { clientId } = req.params; const { clientId } = req.params;
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
@@ -519,7 +519,7 @@ export const deleteClientRegistration = (req: Request, res: Response): void => {
return; return;
} }
const deleted = deleteOAuthClient(clientId); const deleted = await deleteOAuthClient(clientId);
if (!deleted) { if (!deleted) {
res.status(404).json({ res.status(404).json({

View File

@@ -212,7 +212,7 @@ export const getAuthorize = async (req: Request, res: Response): Promise<void> =
} }
// Verify client // Verify client
const client = findOAuthClientById(client_id as string); const client = await findOAuthClientById(client_id as string);
if (!client) { if (!client) {
res.status(400).json({ error: 'invalid_client', error_description: 'Client not found' }); res.status(400).json({ error: 'invalid_client', error_description: 'Client not found' });
return; return;

View File

@@ -1,5 +1,14 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { ApiResponse, AddServerRequest, McpSettings } from '../types/index.js'; import {
ApiResponse,
AddServerRequest,
McpSettings,
BatchCreateServersRequest,
BatchCreateServersResponse,
BatchServerResult,
ServerConfig,
ServerInfo,
} from '../types/index.js';
import { import {
getServersInfo, getServersInfo,
addServer, addServer,
@@ -8,19 +17,74 @@ import {
notifyToolChanged, notifyToolChanged,
syncToolEmbedding, syncToolEmbedding,
toggleServerStatus, toggleServerStatus,
reconnectServer,
} from '../services/mcpService.js'; } from '../services/mcpService.js';
import { loadSettings, saveSettings } from '../config/index.js'; import { loadSettings } from '../config/index.js';
import { syncAllServerToolsEmbeddings } from '../services/vectorSearchService.js'; import { syncAllServerToolsEmbeddings } from '../services/vectorSearchService.js';
import { createSafeJSON } from '../utils/serialization.js'; import { createSafeJSON } from '../utils/serialization.js';
import { cloneDefaultOAuthServerConfig } from '../constants/oauthServerDefaults.js'; import { cloneDefaultOAuthServerConfig } from '../constants/oauthServerDefaults.js';
import { getServerDao, getGroupDao, getSystemConfigDao } from '../dao/DaoFactory.js'; import { getServerDao, getGroupDao, getSystemConfigDao } from '../dao/DaoFactory.js';
import { getBearerKeyDao } from '../dao/DaoFactory.js';
import { UserContextService } from '../services/userContextService.js';
export const getAllServers = async (_: Request, res: Response): Promise<void> => { export const getAllServers = async (req: Request, res: Response): Promise<void> => {
try { try {
const serversInfo = await getServersInfo(); // Parse pagination parameters from query string
const page = req.query.page ? parseInt(req.query.page as string, 10) : 1;
const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : undefined;
// Validate pagination parameters
if (page < 1) {
res.status(400).json({
success: false,
message: 'Page number must be greater than 0',
});
return;
}
if (limit !== undefined && (limit < 1 || limit > 1000)) {
res.status(400).json({
success: false,
message: 'Limit must be between 1 and 1000',
});
return;
}
// Get current user for filtering
const currentUser = UserContextService.getInstance().getCurrentUser();
const isAdmin = !currentUser || currentUser.isAdmin;
// Get servers info with pagination if limit is specified
let serversInfo: Omit<ServerInfo, 'client' | 'transport'>[];
let pagination = undefined;
if (limit !== undefined) {
// Use DAO layer pagination with proper filtering
const serverDao = getServerDao();
const paginatedResult = isAdmin
? await serverDao.findAllPaginated(page, limit)
: await serverDao.findByOwnerPaginated(currentUser!.username, page, limit);
// Get runtime info for paginated servers
serversInfo = await getServersInfo(page, limit, currentUser);
pagination = {
page: paginatedResult.page,
limit: paginatedResult.limit,
total: paginatedResult.total,
totalPages: paginatedResult.totalPages,
hasNextPage: paginatedResult.page < paginatedResult.totalPages,
hasPrevPage: paginatedResult.page > 1,
};
} else {
// No pagination, get all servers (will be filtered by mcpService)
serversInfo = await getServersInfo();
}
const response: ApiResponse = { const response: ApiResponse = {
success: true, success: true,
data: createSafeJSON(serversInfo), data: createSafeJSON(serversInfo),
...(pagination && { pagination }),
}; };
res.json(response); res.json(response);
} catch (error) { } catch (error) {
@@ -56,12 +120,31 @@ export const getAllSettings = async (_: Request, res: Response): Promise<void> =
const systemConfigDao = getSystemConfigDao(); const systemConfigDao = getSystemConfigDao();
const systemConfig = await systemConfigDao.get(); const systemConfig = await systemConfigDao.get();
// Ensure smart routing config has DB URL set if environment variable is present
const dbUrlEnv = process.env.DB_URL || '';
if (!systemConfig.smartRouting) {
systemConfig.smartRouting = {
enabled: false,
dbUrl: dbUrlEnv ? '${DB_URL}' : '',
openaiApiBaseUrl: '',
openaiApiKey: '',
openaiApiEmbeddingModel: '',
};
} else if (!systemConfig.smartRouting.dbUrl) {
systemConfig.smartRouting.dbUrl = dbUrlEnv ? '${DB_URL}' : '';
}
// Get bearer auth keys from DAO
const bearerKeyDao = getBearerKeyDao();
const bearerKeys = await bearerKeyDao.findAll();
// Merge all data into settings object // Merge all data into settings object
const settings: McpSettings = { const settings: McpSettings = {
...fileSettings, ...fileSettings,
mcpServers, mcpServers,
groups, groups,
systemConfig, systemConfig,
bearerKeys,
}; };
const response: ApiResponse = { const response: ApiResponse = {
@@ -188,6 +271,177 @@ export const createServer = async (req: Request, res: Response): Promise<void> =
} }
}; };
// Batch create servers - validates and creates multiple servers in one request
export const batchCreateServers = async (req: Request, res: Response): Promise<void> => {
try {
const { servers } = req.body as BatchCreateServersRequest;
// Validate request body
if (!servers || !Array.isArray(servers)) {
res.status(400).json({
success: false,
message: 'Request body must contain a "servers" array',
});
return;
}
if (servers.length === 0) {
res.status(400).json({
success: false,
message: 'Servers array cannot be empty',
});
return;
}
// Helper function to validate a single server configuration
const validateServerConfig = (
name: string,
config: ServerConfig,
): { valid: boolean; message?: string } => {
if (!name || typeof name !== 'string') {
return { valid: false, message: 'Server name is required and must be a string' };
}
if (!config || typeof config !== 'object') {
return { valid: false, message: 'Server configuration is required and must be an object' };
}
if (
!config.url &&
!config.openapi?.url &&
!config.openapi?.schema &&
(!config.command || !config.args)
) {
return {
valid: false,
message:
'Server configuration must include either a URL, OpenAPI specification URL or schema, or command with arguments',
};
}
// Validate server type if specified
if (config.type && !['stdio', 'sse', 'streamable-http', 'openapi'].includes(config.type)) {
return {
valid: false,
message: 'Server type must be one of: stdio, sse, streamable-http, openapi',
};
}
// Validate URL is provided for sse and streamable-http types
if ((config.type === 'sse' || config.type === 'streamable-http') && !config.url) {
return { valid: false, message: `URL is required for ${config.type} server type` };
}
// Validate OpenAPI specification URL or schema is provided for openapi type
if (config.type === 'openapi' && !config.openapi?.url && !config.openapi?.schema) {
return {
valid: false,
message: 'OpenAPI specification URL or schema is required for openapi server type',
};
}
// Validate headers if provided
if (config.headers && typeof config.headers !== 'object') {
return { valid: false, message: 'Headers must be an object' };
}
// Validate that headers are only used with sse, streamable-http, and openapi types
if (config.headers && config.type === 'stdio') {
return { valid: false, message: 'Headers are not supported for stdio server type' };
}
return { valid: true };
};
// Process each server
const results: BatchServerResult[] = [];
let successCount = 0;
let failureCount = 0;
// Get current user for owner field
const currentUser = (req as any).user;
const defaultOwner = currentUser?.username || 'admin';
for (const server of servers) {
const { name, config } = server;
// Validate server configuration
const validation = validateServerConfig(name, config);
if (!validation.valid) {
results.push({
name: name || 'unknown',
success: false,
message: validation.message,
});
failureCount++;
continue;
}
try {
// Set default keep-alive interval for SSE servers if not specified
if ((config.type === 'sse' || (!config.type && config.url)) && !config.keepAliveInterval) {
config.keepAliveInterval = 60000; // Default 60 seconds for SSE servers
}
// Set owner property if not provided
if (!config.owner) {
config.owner = defaultOwner;
}
// Attempt to add server
const result = await addServer(name, config);
if (result.success) {
results.push({
name,
success: true,
});
successCount++;
} else {
results.push({
name,
success: false,
message: result.message || 'Failed to add server',
});
failureCount++;
}
} catch (error) {
results.push({
name,
success: false,
message: error instanceof Error ? error.message : 'Internal server error',
});
failureCount++;
}
}
// Notify tool changes if any server was added successfully
if (successCount > 0) {
notifyToolChanged();
}
// Prepare response
const response: ApiResponse<BatchCreateServersResponse> = {
success: successCount > 0, // Success if at least one server was created
data: {
success: successCount > 0,
successCount,
failureCount,
results,
},
};
// Return 207 Multi-Status if there were partial failures, 200 if all succeeded, 400 if all failed
const statusCode = failureCount === 0 ? 200 : successCount === 0 ? 400 : 207;
res.status(statusCode).json(response);
} catch (error) {
console.error('Batch create servers error:', error);
res.status(500).json({
success: false,
message: 'Internal server error',
});
}
};
export const deleteServer = async (req: Request, res: Response): Promise<void> => { export const deleteServer = async (req: Request, res: Response): Promise<void> => {
try { try {
const { name } = req.params; const { name } = req.params;
@@ -223,7 +477,7 @@ export const deleteServer = async (req: Request, res: Response): Promise<void> =
export const updateServer = async (req: Request, res: Response): Promise<void> => { export const updateServer = async (req: Request, res: Response): Promise<void> => {
try { try {
const { name } = req.params; const { name } = req.params;
const { config } = req.body; const { config, newName } = req.body;
if (!name) { if (!name) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
@@ -310,12 +564,52 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
config.owner = currentUser?.username || 'admin'; config.owner = currentUser?.username || 'admin';
} }
const result = await addOrUpdateServer(name, config, true); // Allow override for updates // Check if server name is being changed
const isRenaming = newName && newName !== name;
// If renaming, validate the new name and update references
if (isRenaming) {
const serverDao = getServerDao();
// Check if new name already exists
if (await serverDao.exists(newName)) {
res.status(400).json({
success: false,
message: `Server name '${newName}' already exists`,
});
return;
}
// Rename the server
const renamed = await serverDao.rename(name, newName);
if (!renamed) {
res.status(404).json({
success: false,
message: 'Server not found',
});
return;
}
// Update references in groups
const groupDao = getGroupDao();
await groupDao.updateServerName(name, newName);
// Update references in bearer keys
const bearerKeyDao = getBearerKeyDao();
await bearerKeyDao.updateServerName(name, newName);
}
// Use the final server name (new name if renaming, otherwise original name)
const finalName = isRenaming ? newName : name;
const result = await addOrUpdateServer(finalName, config, true); // Allow override for updates
if (result.success) { if (result.success) {
notifyToolChanged(name); notifyToolChanged(finalName);
res.json({ res.json({
success: true, success: true,
message: 'Server updated successfully', message: isRenaming
? `Server renamed and updated successfully`
: 'Server updated successfully',
}); });
} else { } else {
res.status(404).json({ res.status(404).json({
@@ -415,6 +709,32 @@ export const toggleServer = async (req: Request, res: Response): Promise<void> =
} }
}; };
export const reloadServer = async (req: Request, res: Response): Promise<void> => {
try {
const { name } = req.params;
if (!name) {
res.status(400).json({
success: false,
message: 'Server name is required',
});
return;
}
await reconnectServer(name);
res.json({
success: true,
message: `Server ${name} reloaded successfully`,
});
} catch (error) {
console.error('Failed to reload server:', error);
res.status(500).json({
success: false,
message: 'Failed to reload server',
});
}
};
// Toggle tool status for a specific server // Toggle tool status for a specific server
export const toggleTool = async (req: Request, res: Response): Promise<void> => { export const toggleTool = async (req: Request, res: Response): Promise<void> => {
try { try {
@@ -439,8 +759,10 @@ export const toggleTool = async (req: Request, res: Response): Promise<void> =>
return; return;
} }
const settings = loadSettings(); const serverDao = getServerDao();
if (!settings.mcpServers[serverName]) { const server = await serverDao.findById(serverName);
if (!server) {
res.status(404).json({ res.status(404).json({
success: false, success: false,
message: 'Server not found', message: 'Server not found',
@@ -449,14 +771,15 @@ export const toggleTool = async (req: Request, res: Response): Promise<void> =>
} }
// Initialize tools config if it doesn't exist // Initialize tools config if it doesn't exist
if (!settings.mcpServers[serverName].tools) { const tools = server.tools || {};
settings.mcpServers[serverName].tools = {};
}
// Set the tool's enabled state // Set the tool's enabled state (preserve existing description if any)
settings.mcpServers[serverName].tools![toolName] = { enabled }; tools[toolName] = { ...tools[toolName], enabled };
if (!saveSettings(settings)) { // Update via DAO (supports both file and database modes)
const result = await serverDao.updateTools(serverName, tools);
if (!result) {
res.status(500).json({ res.status(500).json({
success: false, success: false,
message: 'Failed to save settings', message: 'Failed to save settings',
@@ -503,8 +826,10 @@ export const updateToolDescription = async (req: Request, res: Response): Promis
return; return;
} }
const settings = loadSettings(); const serverDao = getServerDao();
if (!settings.mcpServers[serverName]) { const server = await serverDao.findById(serverName);
if (!server) {
res.status(404).json({ res.status(404).json({
success: false, success: false,
message: 'Server not found', message: 'Server not found',
@@ -513,18 +838,18 @@ export const updateToolDescription = async (req: Request, res: Response): Promis
} }
// Initialize tools config if it doesn't exist // Initialize tools config if it doesn't exist
if (!settings.mcpServers[serverName].tools) { const tools = server.tools || {};
settings.mcpServers[serverName].tools = {};
}
// Set the tool's description // Set the tool's description
if (!settings.mcpServers[serverName].tools![toolName]) { if (!tools[toolName]) {
settings.mcpServers[serverName].tools![toolName] = { enabled: true }; tools[toolName] = { enabled: true };
} }
tools[toolName].description = description;
settings.mcpServers[serverName].tools![toolName].description = description; // Update via DAO (supports both file and database modes)
const result = await serverDao.updateTools(serverName, tools);
if (!saveSettings(settings)) { if (!result) {
res.status(500).json({ res.status(500).json({
success: false, success: false,
message: 'Failed to save settings', message: 'Failed to save settings',
@@ -761,7 +1086,8 @@ export const updateSystemConfig = async (req: Request, res: Response): Promise<v
if (typeof smartRouting.enabled === 'boolean') { if (typeof smartRouting.enabled === 'boolean') {
// If enabling Smart Routing, validate required fields // If enabling Smart Routing, validate required fields
if (smartRouting.enabled) { if (smartRouting.enabled) {
const currentDbUrl = smartRouting.dbUrl || systemConfig.smartRouting.dbUrl; const currentDbUrl =
process.env.DB_URL || smartRouting.dbUrl || systemConfig.smartRouting.dbUrl;
const currentOpenaiApiKey = const currentOpenaiApiKey =
smartRouting.openaiApiKey || systemConfig.smartRouting.openaiApiKey; smartRouting.openaiApiKey || systemConfig.smartRouting.openaiApiKey;
@@ -939,8 +1265,10 @@ export const togglePrompt = async (req: Request, res: Response): Promise<void> =
return; return;
} }
const settings = loadSettings(); const serverDao = getServerDao();
if (!settings.mcpServers[serverName]) { const server = await serverDao.findById(serverName);
if (!server) {
res.status(404).json({ res.status(404).json({
success: false, success: false,
message: 'Server not found', message: 'Server not found',
@@ -949,14 +1277,15 @@ export const togglePrompt = async (req: Request, res: Response): Promise<void> =
} }
// Initialize prompts config if it doesn't exist // Initialize prompts config if it doesn't exist
if (!settings.mcpServers[serverName].prompts) { const prompts = server.prompts || {};
settings.mcpServers[serverName].prompts = {};
}
// Set the prompt's enabled state // Set the prompt's enabled state (preserve existing description if any)
settings.mcpServers[serverName].prompts![promptName] = { enabled }; prompts[promptName] = { ...prompts[promptName], enabled };
if (!saveSettings(settings)) { // Update via DAO (supports both file and database modes)
const result = await serverDao.updatePrompts(serverName, prompts);
if (!result) {
res.status(500).json({ res.status(500).json({
success: false, success: false,
message: 'Failed to save settings', message: 'Failed to save settings',
@@ -1003,8 +1332,10 @@ export const updatePromptDescription = async (req: Request, res: Response): Prom
return; return;
} }
const settings = loadSettings(); const serverDao = getServerDao();
if (!settings.mcpServers[serverName]) { const server = await serverDao.findById(serverName);
if (!server) {
res.status(404).json({ res.status(404).json({
success: false, success: false,
message: 'Server not found', message: 'Server not found',
@@ -1013,18 +1344,18 @@ export const updatePromptDescription = async (req: Request, res: Response): Prom
} }
// Initialize prompts config if it doesn't exist // Initialize prompts config if it doesn't exist
if (!settings.mcpServers[serverName].prompts) { const prompts = server.prompts || {};
settings.mcpServers[serverName].prompts = {};
}
// Set the prompt's description // Set the prompt's description
if (!settings.mcpServers[serverName].prompts![promptName]) { if (!prompts[promptName]) {
settings.mcpServers[serverName].prompts![promptName] = { enabled: true }; prompts[promptName] = { enabled: true };
} }
prompts[promptName].description = description;
settings.mcpServers[serverName].prompts![promptName].description = description; // Update via DAO (supports both file and database modes)
const result = await serverDao.updatePrompts(serverName, prompts);
if (!saveSettings(settings)) { if (!result) {
res.status(500).json({ res.status(500).json({
success: false, success: false,
message: 'Failed to save settings', message: 'Failed to save settings',

159
src/dao/BearerKeyDao.ts Normal file
View File

@@ -0,0 +1,159 @@
import { randomUUID } from 'node:crypto';
import { BearerKey } from '../types/index.js';
import { JsonFileBaseDao } from './base/JsonFileBaseDao.js';
/**
* DAO interface for bearer authentication keys
*/
export interface BearerKeyDao {
findAll(): Promise<BearerKey[]>;
findEnabled(): Promise<BearerKey[]>;
findById(id: string): Promise<BearerKey | undefined>;
findByToken(token: string): Promise<BearerKey | undefined>;
create(data: Omit<BearerKey, 'id'>): Promise<BearerKey>;
update(id: string, data: Partial<Omit<BearerKey, 'id'>>): Promise<BearerKey | null>;
delete(id: string): Promise<boolean>;
/**
* Update server name in all bearer keys (when server is renamed)
*/
updateServerName(oldName: string, newName: string): Promise<number>;
}
/**
* JSON file-based BearerKey DAO implementation
* Stores keys under the top-level `bearerKeys` field in mcp_settings.json
* and performs one-time migration from legacy routing.enableBearerAuth/bearerAuthKey.
*/
export class BearerKeyDaoImpl extends JsonFileBaseDao implements BearerKeyDao {
private async loadKeysWithMigration(): Promise<BearerKey[]> {
const settings = await this.loadSettings();
// Treat an existing array (including an empty array) as already migrated.
// Otherwise, when there are no configured keys, we'd rewrite mcp_settings.json
// on every request, which also clears the global settings cache.
if (Array.isArray(settings.bearerKeys)) {
return settings.bearerKeys;
}
// Perform one-time migration from legacy routing config if present
const routing = settings.systemConfig?.routing || {};
const enableBearerAuth: boolean = !!routing.enableBearerAuth;
const rawKey: string = (routing.bearerAuthKey || '').trim();
let migrated: BearerKey[] = [];
if (rawKey) {
// Cases 2 and 3 in migration rules
migrated = [
{
id: randomUUID(),
name: 'default',
token: rawKey,
enabled: enableBearerAuth,
accessType: 'all',
allowedGroups: [],
allowedServers: [],
},
];
}
// Cases 1 and 4 both result in empty keys list
settings.bearerKeys = migrated;
await this.saveSettings(settings);
return migrated;
}
private async saveKeys(keys: BearerKey[]): Promise<void> {
const settings = await this.loadSettings();
settings.bearerKeys = keys;
await this.saveSettings(settings);
}
async findAll(): Promise<BearerKey[]> {
return await this.loadKeysWithMigration();
}
async findEnabled(): Promise<BearerKey[]> {
const keys = await this.loadKeysWithMigration();
return keys.filter((key) => key.enabled);
}
async findById(id: string): Promise<BearerKey | undefined> {
const keys = await this.loadKeysWithMigration();
return keys.find((key) => key.id === id);
}
async findByToken(token: string): Promise<BearerKey | undefined> {
const keys = await this.loadKeysWithMigration();
return keys.find((key) => key.token === token);
}
async create(data: Omit<BearerKey, 'id'>): Promise<BearerKey> {
const keys = await this.loadKeysWithMigration();
const newKey: BearerKey = {
id: randomUUID(),
...data,
};
keys.push(newKey);
await this.saveKeys(keys);
return newKey;
}
async update(id: string, data: Partial<Omit<BearerKey, 'id'>>): Promise<BearerKey | null> {
const keys = await this.loadKeysWithMigration();
const index = keys.findIndex((key) => key.id === id);
if (index === -1) {
return null;
}
const updated: BearerKey = {
...keys[index],
...data,
id: keys[index].id,
};
keys[index] = updated;
await this.saveKeys(keys);
return updated;
}
async delete(id: string): Promise<boolean> {
const keys = await this.loadKeysWithMigration();
const next = keys.filter((key) => key.id !== id);
if (next.length === keys.length) {
return false;
}
await this.saveKeys(next);
return true;
}
async updateServerName(oldName: string, newName: string): Promise<number> {
const keys = await this.loadKeysWithMigration();
let updatedCount = 0;
for (const key of keys) {
let updated = false;
if (key.allowedServers && key.allowedServers.length > 0) {
const newServers = key.allowedServers.map((server) => {
if (server === oldName) {
updated = true;
return newName;
}
return server;
});
if (updated) {
key.allowedServers = newServers;
updatedCount++;
}
}
}
if (updatedCount > 0) {
await this.saveKeys(keys);
}
return updatedCount;
}
}

View File

@@ -0,0 +1,103 @@
import { BearerKeyDao } from './BearerKeyDao.js';
import { BearerKey as BearerKeyModel } from '../types/index.js';
import { BearerKeyRepository } from '../db/repositories/BearerKeyRepository.js';
/**
* Database-backed implementation of BearerKeyDao
*/
export class BearerKeyDaoDbImpl implements BearerKeyDao {
private repository: BearerKeyRepository;
constructor() {
this.repository = new BearerKeyRepository();
}
private toModel(entity: import('../db/entities/BearerKey.js').BearerKey): BearerKeyModel {
return {
id: entity.id,
name: entity.name,
token: entity.token,
enabled: entity.enabled,
accessType: entity.accessType,
allowedGroups: entity.allowedGroups ?? [],
allowedServers: entity.allowedServers ?? [],
};
}
async findAll(): Promise<BearerKeyModel[]> {
const entities = await this.repository.findAll();
return entities.map((e) => this.toModel(e));
}
async findEnabled(): Promise<BearerKeyModel[]> {
const entities = await this.repository.findAll();
return entities.filter((e) => e.enabled).map((e) => this.toModel(e));
}
async findById(id: string): Promise<BearerKeyModel | undefined> {
const entity = await this.repository.findById(id);
return entity ? this.toModel(entity) : undefined;
}
async findByToken(token: string): Promise<BearerKeyModel | undefined> {
const entity = await this.repository.findByToken(token);
return entity ? this.toModel(entity) : undefined;
}
async create(data: Omit<BearerKeyModel, 'id'>): Promise<BearerKeyModel> {
const entity = await this.repository.create({
name: data.name,
token: data.token,
enabled: data.enabled,
accessType: data.accessType,
allowedGroups: data.allowedGroups ?? [],
allowedServers: data.allowedServers ?? [],
} as any);
return this.toModel(entity as any);
}
async update(
id: string,
data: Partial<Omit<BearerKeyModel, 'id'>>,
): Promise<BearerKeyModel | null> {
const entity = await this.repository.update(id, {
name: data.name,
token: data.token,
enabled: data.enabled,
accessType: data.accessType,
allowedGroups: data.allowedGroups,
allowedServers: data.allowedServers,
} as any);
return entity ? this.toModel(entity as any) : null;
}
async delete(id: string): Promise<boolean> {
return await this.repository.delete(id);
}
async updateServerName(oldName: string, newName: string): Promise<number> {
const allKeys = await this.repository.findAll();
let updatedCount = 0;
for (const key of allKeys) {
let updated = false;
if (key.allowedServers && key.allowedServers.length > 0) {
const newServers = key.allowedServers.map((server) => {
if (server === oldName) {
updated = true;
return newName;
}
return server;
});
if (updated) {
await this.repository.update(key.id, { allowedServers: newServers });
updatedCount++;
}
}
}
return updatedCount;
}
}

View File

@@ -3,6 +3,9 @@ import { ServerDao, ServerDaoImpl } from './ServerDao.js';
import { GroupDao, GroupDaoImpl } from './GroupDao.js'; import { GroupDao, GroupDaoImpl } from './GroupDao.js';
import { SystemConfigDao, SystemConfigDaoImpl } from './SystemConfigDao.js'; import { SystemConfigDao, SystemConfigDaoImpl } from './SystemConfigDao.js';
import { UserConfigDao, UserConfigDaoImpl } from './UserConfigDao.js'; import { UserConfigDao, UserConfigDaoImpl } from './UserConfigDao.js';
import { OAuthClientDao, OAuthClientDaoImpl } from './OAuthClientDao.js';
import { OAuthTokenDao, OAuthTokenDaoImpl } from './OAuthTokenDao.js';
import { BearerKeyDao, BearerKeyDaoImpl } from './BearerKeyDao.js';
/** /**
* DAO Factory interface for creating DAO instances * DAO Factory interface for creating DAO instances
@@ -13,6 +16,9 @@ export interface DaoFactory {
getGroupDao(): GroupDao; getGroupDao(): GroupDao;
getSystemConfigDao(): SystemConfigDao; getSystemConfigDao(): SystemConfigDao;
getUserConfigDao(): UserConfigDao; getUserConfigDao(): UserConfigDao;
getOAuthClientDao(): OAuthClientDao;
getOAuthTokenDao(): OAuthTokenDao;
getBearerKeyDao(): BearerKeyDao;
} }
/** /**
@@ -26,6 +32,9 @@ export class JsonFileDaoFactory implements DaoFactory {
private groupDao: GroupDao | null = null; private groupDao: GroupDao | null = null;
private systemConfigDao: SystemConfigDao | null = null; private systemConfigDao: SystemConfigDao | null = null;
private userConfigDao: UserConfigDao | null = null; private userConfigDao: UserConfigDao | null = null;
private oauthClientDao: OAuthClientDao | null = null;
private oauthTokenDao: OAuthTokenDao | null = null;
private bearerKeyDao: BearerKeyDao | null = null;
/** /**
* Get singleton instance * Get singleton instance
@@ -76,6 +85,27 @@ export class JsonFileDaoFactory implements DaoFactory {
return this.userConfigDao; return this.userConfigDao;
} }
getOAuthClientDao(): OAuthClientDao {
if (!this.oauthClientDao) {
this.oauthClientDao = new OAuthClientDaoImpl();
}
return this.oauthClientDao;
}
getOAuthTokenDao(): OAuthTokenDao {
if (!this.oauthTokenDao) {
this.oauthTokenDao = new OAuthTokenDaoImpl();
}
return this.oauthTokenDao;
}
getBearerKeyDao(): BearerKeyDao {
if (!this.bearerKeyDao) {
this.bearerKeyDao = new BearerKeyDaoImpl();
}
return this.bearerKeyDao;
}
/** /**
* Reset all cached DAO instances (useful for testing) * Reset all cached DAO instances (useful for testing)
*/ */
@@ -85,6 +115,9 @@ export class JsonFileDaoFactory implements DaoFactory {
this.groupDao = null; this.groupDao = null;
this.systemConfigDao = null; this.systemConfigDao = null;
this.userConfigDao = null; this.userConfigDao = null;
this.oauthClientDao = null;
this.oauthTokenDao = null;
this.bearerKeyDao = null;
} }
} }
@@ -149,3 +182,15 @@ export function getSystemConfigDao(): SystemConfigDao {
export function getUserConfigDao(): UserConfigDao { export function getUserConfigDao(): UserConfigDao {
return getDaoFactory().getUserConfigDao(); return getDaoFactory().getUserConfigDao();
} }
export function getOAuthClientDao(): OAuthClientDao {
return getDaoFactory().getOAuthClientDao();
}
export function getOAuthTokenDao(): OAuthTokenDao {
return getDaoFactory().getOAuthTokenDao();
}
export function getBearerKeyDao(): BearerKeyDao {
return getDaoFactory().getBearerKeyDao();
}

View File

@@ -1,9 +1,22 @@
import { DaoFactory, UserDao, ServerDao, GroupDao, SystemConfigDao, UserConfigDao } from './index.js'; import {
DaoFactory,
UserDao,
ServerDao,
GroupDao,
SystemConfigDao,
UserConfigDao,
OAuthClientDao,
OAuthTokenDao,
BearerKeyDao,
} from './index.js';
import { UserDaoDbImpl } from './UserDaoDbImpl.js'; import { UserDaoDbImpl } from './UserDaoDbImpl.js';
import { ServerDaoDbImpl } from './ServerDaoDbImpl.js'; import { ServerDaoDbImpl } from './ServerDaoDbImpl.js';
import { GroupDaoDbImpl } from './GroupDaoDbImpl.js'; import { GroupDaoDbImpl } from './GroupDaoDbImpl.js';
import { SystemConfigDaoDbImpl } from './SystemConfigDaoDbImpl.js'; import { SystemConfigDaoDbImpl } from './SystemConfigDaoDbImpl.js';
import { UserConfigDaoDbImpl } from './UserConfigDaoDbImpl.js'; import { UserConfigDaoDbImpl } from './UserConfigDaoDbImpl.js';
import { OAuthClientDaoDbImpl } from './OAuthClientDaoDbImpl.js';
import { OAuthTokenDaoDbImpl } from './OAuthTokenDaoDbImpl.js';
import { BearerKeyDaoDbImpl } from './BearerKeyDaoDbImpl.js';
/** /**
* Database-backed DAO factory implementation * Database-backed DAO factory implementation
@@ -16,6 +29,9 @@ export class DatabaseDaoFactory implements DaoFactory {
private groupDao: GroupDao | null = null; private groupDao: GroupDao | null = null;
private systemConfigDao: SystemConfigDao | null = null; private systemConfigDao: SystemConfigDao | null = null;
private userConfigDao: UserConfigDao | null = null; private userConfigDao: UserConfigDao | null = null;
private oauthClientDao: OAuthClientDao | null = null;
private oauthTokenDao: OAuthTokenDao | null = null;
private bearerKeyDao: BearerKeyDao | null = null;
/** /**
* Get singleton instance * Get singleton instance
@@ -66,6 +82,27 @@ export class DatabaseDaoFactory implements DaoFactory {
return this.userConfigDao!; return this.userConfigDao!;
} }
getOAuthClientDao(): OAuthClientDao {
if (!this.oauthClientDao) {
this.oauthClientDao = new OAuthClientDaoDbImpl();
}
return this.oauthClientDao!;
}
getOAuthTokenDao(): OAuthTokenDao {
if (!this.oauthTokenDao) {
this.oauthTokenDao = new OAuthTokenDaoDbImpl();
}
return this.oauthTokenDao!;
}
getBearerKeyDao(): BearerKeyDao {
if (!this.bearerKeyDao) {
this.bearerKeyDao = new BearerKeyDaoDbImpl();
}
return this.bearerKeyDao!;
}
/** /**
* Reset all cached DAO instances (useful for testing) * Reset all cached DAO instances (useful for testing)
*/ */
@@ -75,5 +112,8 @@ export class DatabaseDaoFactory implements DaoFactory {
this.groupDao = null; this.groupDao = null;
this.systemConfigDao = null; this.systemConfigDao = null;
this.userConfigDao = null; this.userConfigDao = null;
this.oauthClientDao = null;
this.oauthTokenDao = null;
this.bearerKeyDao = null;
} }
} }

View File

@@ -36,6 +36,11 @@ export interface GroupDao extends BaseDao<IGroup, string> {
* Find group by name * Find group by name
*/ */
findByName(name: string): Promise<IGroup | null>; findByName(name: string): Promise<IGroup | null>;
/**
* Update server name in all groups (when server is renamed)
*/
updateServerName(oldName: string, newName: string): Promise<number>;
} }
/** /**
@@ -218,4 +223,39 @@ export class GroupDaoImpl extends JsonFileBaseDao implements GroupDao {
const groups = await this.getAll(); const groups = await this.getAll();
return groups.find((group) => group.name === name) || null; return groups.find((group) => group.name === name) || null;
} }
async updateServerName(oldName: string, newName: string): Promise<number> {
const groups = await this.getAll();
let updatedCount = 0;
for (const group of groups) {
let updated = false;
const newServers = group.servers.map((server) => {
if (typeof server === 'string') {
if (server === oldName) {
updated = true;
return newName;
}
return server;
} else {
if (server.name === oldName) {
updated = true;
return { ...server, name: newName };
}
return server;
}
}) as IGroup['servers'];
if (updated) {
group.servers = newServers;
updatedCount++;
}
}
if (updatedCount > 0) {
await this.saveAll(groups);
}
return updatedCount;
}
} }

View File

@@ -151,4 +151,35 @@ export class GroupDaoDbImpl implements GroupDao {
owner: group.owner, owner: group.owner,
}; };
} }
async updateServerName(oldName: string, newName: string): Promise<number> {
const allGroups = await this.repository.findAll();
let updatedCount = 0;
for (const group of allGroups) {
let updated = false;
const newServers = group.servers.map((server) => {
if (typeof server === 'string') {
if (server === oldName) {
updated = true;
return newName;
}
return server;
} else {
if (server.name === oldName) {
updated = true;
return { ...server, name: newName };
}
return server;
}
});
if (updated) {
await this.update(group.id, { servers: newServers as any });
updatedCount++;
}
}
return updatedCount;
}
} }

146
src/dao/OAuthClientDao.ts Normal file
View File

@@ -0,0 +1,146 @@
import { IOAuthClient } from '../types/index.js';
import { BaseDao } from './base/BaseDao.js';
import { JsonFileBaseDao } from './base/JsonFileBaseDao.js';
/**
* OAuth Client DAO interface with OAuth client-specific operations
*/
export interface OAuthClientDao extends BaseDao<IOAuthClient, string> {
/**
* Find OAuth client by client ID
*/
findByClientId(clientId: string): Promise<IOAuthClient | null>;
/**
* Find OAuth clients by owner
*/
findByOwner(owner: string): Promise<IOAuthClient[]>;
/**
* Validate client credentials
*/
validateCredentials(clientId: string, clientSecret?: string): Promise<boolean>;
}
/**
* JSON file-based OAuth Client DAO implementation
*/
export class OAuthClientDaoImpl extends JsonFileBaseDao implements OAuthClientDao {
protected async getAll(): Promise<IOAuthClient[]> {
const settings = await this.loadSettings();
return settings.oauthClients || [];
}
protected async saveAll(clients: IOAuthClient[]): Promise<void> {
const settings = await this.loadSettings();
settings.oauthClients = clients;
await this.saveSettings(settings);
}
protected getEntityId(client: IOAuthClient): string {
return client.clientId;
}
protected createEntity(_data: Omit<IOAuthClient, 'clientId'>): IOAuthClient {
throw new Error('clientId must be provided');
}
protected updateEntity(existing: IOAuthClient, updates: Partial<IOAuthClient>): IOAuthClient {
return {
...existing,
...updates,
clientId: existing.clientId, // clientId should not be updated
};
}
async findAll(): Promise<IOAuthClient[]> {
return this.getAll();
}
async findById(clientId: string): Promise<IOAuthClient | null> {
return this.findByClientId(clientId);
}
async findByClientId(clientId: string): Promise<IOAuthClient | null> {
const clients = await this.getAll();
return clients.find((client) => client.clientId === clientId) || null;
}
async findByOwner(owner: string): Promise<IOAuthClient[]> {
const clients = await this.getAll();
return clients.filter((client) => client.owner === owner);
}
async create(data: IOAuthClient): Promise<IOAuthClient> {
const clients = await this.getAll();
// Check if client already exists
if (clients.find((client) => client.clientId === data.clientId)) {
throw new Error(`OAuth client ${data.clientId} already exists`);
}
const newClient: IOAuthClient = {
...data,
owner: data.owner || 'admin',
};
clients.push(newClient);
await this.saveAll(clients);
return newClient;
}
async update(clientId: string, updates: Partial<IOAuthClient>): Promise<IOAuthClient | null> {
const clients = await this.getAll();
const index = clients.findIndex((client) => client.clientId === clientId);
if (index === -1) {
return null;
}
// Don't allow clientId changes
const { clientId: _, ...allowedUpdates } = updates;
const updatedClient = this.updateEntity(clients[index], allowedUpdates);
clients[index] = updatedClient;
await this.saveAll(clients);
return updatedClient;
}
async delete(clientId: string): Promise<boolean> {
const clients = await this.getAll();
const index = clients.findIndex((client) => client.clientId === clientId);
if (index === -1) {
return false;
}
clients.splice(index, 1);
await this.saveAll(clients);
return true;
}
async exists(clientId: string): Promise<boolean> {
const client = await this.findByClientId(clientId);
return client !== null;
}
async count(): Promise<number> {
const clients = await this.getAll();
return clients.length;
}
async validateCredentials(clientId: string, clientSecret?: string): Promise<boolean> {
const client = await this.findByClientId(clientId);
if (!client) {
return false;
}
// If client has no secret (public client), accept if no secret provided
if (!client.clientSecret) {
return !clientSecret;
}
// If client has a secret, it must match
return client.clientSecret === clientSecret;
}
}

View File

@@ -0,0 +1,109 @@
import { OAuthClientDao } from './OAuthClientDao.js';
import { OAuthClientRepository } from '../db/repositories/OAuthClientRepository.js';
import { IOAuthClient } from '../types/index.js';
/**
* Database-backed implementation of OAuthClientDao
*/
export class OAuthClientDaoDbImpl implements OAuthClientDao {
private repository: OAuthClientRepository;
constructor() {
this.repository = new OAuthClientRepository();
}
async findAll(): Promise<IOAuthClient[]> {
const clients = await this.repository.findAll();
return clients.map((c) => this.mapToOAuthClient(c));
}
async findById(clientId: string): Promise<IOAuthClient | null> {
const client = await this.repository.findByClientId(clientId);
return client ? this.mapToOAuthClient(client) : null;
}
async findByClientId(clientId: string): Promise<IOAuthClient | null> {
return this.findById(clientId);
}
async findByOwner(owner: string): Promise<IOAuthClient[]> {
const clients = await this.repository.findByOwner(owner);
return clients.map((c) => this.mapToOAuthClient(c));
}
async create(entity: IOAuthClient): Promise<IOAuthClient> {
const client = await this.repository.create({
clientId: entity.clientId,
clientSecret: entity.clientSecret,
name: entity.name,
redirectUris: entity.redirectUris,
grants: entity.grants,
scopes: entity.scopes,
owner: entity.owner || 'admin',
metadata: entity.metadata,
});
return this.mapToOAuthClient(client);
}
async update(clientId: string, entity: Partial<IOAuthClient>): Promise<IOAuthClient | null> {
const client = await this.repository.update(clientId, {
clientSecret: entity.clientSecret,
name: entity.name,
redirectUris: entity.redirectUris,
grants: entity.grants,
scopes: entity.scopes,
owner: entity.owner,
metadata: entity.metadata,
});
return client ? this.mapToOAuthClient(client) : null;
}
async delete(clientId: string): Promise<boolean> {
return await this.repository.delete(clientId);
}
async exists(clientId: string): Promise<boolean> {
return await this.repository.exists(clientId);
}
async count(): Promise<number> {
return await this.repository.count();
}
async validateCredentials(clientId: string, clientSecret?: string): Promise<boolean> {
const client = await this.findByClientId(clientId);
if (!client) {
return false;
}
// If client has no secret (public client), accept if no secret provided
if (!client.clientSecret) {
return !clientSecret;
}
// If client has a secret, it must match
return client.clientSecret === clientSecret;
}
private mapToOAuthClient(client: {
clientId: string;
clientSecret?: string;
name: string;
redirectUris: string[];
grants: string[];
scopes?: string[];
owner?: string;
metadata?: Record<string, any>;
}): IOAuthClient {
return {
clientId: client.clientId,
clientSecret: client.clientSecret,
name: client.name,
redirectUris: client.redirectUris,
grants: client.grants,
scopes: client.scopes,
owner: client.owner,
metadata: client.metadata as IOAuthClient['metadata'],
};
}
}

259
src/dao/OAuthTokenDao.ts Normal file
View File

@@ -0,0 +1,259 @@
import { IOAuthToken } from '../types/index.js';
import { BaseDao } from './base/BaseDao.js';
import { JsonFileBaseDao } from './base/JsonFileBaseDao.js';
/**
* OAuth Token DAO interface with OAuth token-specific operations
*/
export interface OAuthTokenDao extends BaseDao<IOAuthToken, string> {
/**
* Find token by access token
*/
findByAccessToken(accessToken: string): Promise<IOAuthToken | null>;
/**
* Find token by refresh token
*/
findByRefreshToken(refreshToken: string): Promise<IOAuthToken | null>;
/**
* Find tokens by client ID
*/
findByClientId(clientId: string): Promise<IOAuthToken[]>;
/**
* Find tokens by username
*/
findByUsername(username: string): Promise<IOAuthToken[]>;
/**
* Revoke token (delete by access token or refresh token)
*/
revokeToken(token: string): Promise<boolean>;
/**
* Revoke all tokens for a user
*/
revokeUserTokens(username: string): Promise<number>;
/**
* Revoke all tokens for a client
*/
revokeClientTokens(clientId: string): Promise<number>;
/**
* Clean up expired tokens
*/
cleanupExpired(): Promise<number>;
/**
* Check if access token is valid (exists and not expired)
*/
isAccessTokenValid(accessToken: string): Promise<boolean>;
/**
* Check if refresh token is valid (exists and not expired)
*/
isRefreshTokenValid(refreshToken: string): Promise<boolean>;
}
/**
* JSON file-based OAuth Token DAO implementation
*/
export class OAuthTokenDaoImpl extends JsonFileBaseDao implements OAuthTokenDao {
protected async getAll(): Promise<IOAuthToken[]> {
const settings = await this.loadSettings();
// Convert stored dates back to Date objects
return (settings.oauthTokens || []).map((token) => ({
...token,
accessTokenExpiresAt: new Date(token.accessTokenExpiresAt),
refreshTokenExpiresAt: token.refreshTokenExpiresAt
? new Date(token.refreshTokenExpiresAt)
: undefined,
}));
}
protected async saveAll(tokens: IOAuthToken[]): Promise<void> {
const settings = await this.loadSettings();
settings.oauthTokens = tokens;
await this.saveSettings(settings);
}
protected getEntityId(token: IOAuthToken): string {
return token.accessToken;
}
protected createEntity(_data: Omit<IOAuthToken, 'accessToken'>): IOAuthToken {
throw new Error('accessToken must be provided');
}
protected updateEntity(existing: IOAuthToken, updates: Partial<IOAuthToken>): IOAuthToken {
return {
...existing,
...updates,
accessToken: existing.accessToken, // accessToken should not be updated
};
}
async findAll(): Promise<IOAuthToken[]> {
return this.getAll();
}
async findById(accessToken: string): Promise<IOAuthToken | null> {
return this.findByAccessToken(accessToken);
}
async findByAccessToken(accessToken: string): Promise<IOAuthToken | null> {
const tokens = await this.getAll();
return tokens.find((token) => token.accessToken === accessToken) || null;
}
async findByRefreshToken(refreshToken: string): Promise<IOAuthToken | null> {
const tokens = await this.getAll();
return tokens.find((token) => token.refreshToken === refreshToken) || null;
}
async findByClientId(clientId: string): Promise<IOAuthToken[]> {
const tokens = await this.getAll();
return tokens.filter((token) => token.clientId === clientId);
}
async findByUsername(username: string): Promise<IOAuthToken[]> {
const tokens = await this.getAll();
return tokens.filter((token) => token.username === username);
}
async create(data: IOAuthToken): Promise<IOAuthToken> {
const tokens = await this.getAll();
// Remove any existing tokens with the same access token or refresh token
const filteredTokens = tokens.filter(
(t) => t.accessToken !== data.accessToken && t.refreshToken !== data.refreshToken,
);
const newToken: IOAuthToken = {
...data,
};
filteredTokens.push(newToken);
await this.saveAll(filteredTokens);
return newToken;
}
async update(accessToken: string, updates: Partial<IOAuthToken>): Promise<IOAuthToken | null> {
const tokens = await this.getAll();
const index = tokens.findIndex((token) => token.accessToken === accessToken);
if (index === -1) {
return null;
}
// Don't allow accessToken changes
const { accessToken: _, ...allowedUpdates } = updates;
const updatedToken = this.updateEntity(tokens[index], allowedUpdates);
tokens[index] = updatedToken;
await this.saveAll(tokens);
return updatedToken;
}
async delete(accessToken: string): Promise<boolean> {
const tokens = await this.getAll();
const index = tokens.findIndex((token) => token.accessToken === accessToken);
if (index === -1) {
return false;
}
tokens.splice(index, 1);
await this.saveAll(tokens);
return true;
}
async exists(accessToken: string): Promise<boolean> {
const token = await this.findByAccessToken(accessToken);
return token !== null;
}
async count(): Promise<number> {
const tokens = await this.getAll();
return tokens.length;
}
async revokeToken(token: string): Promise<boolean> {
const tokens = await this.getAll();
const tokenData = tokens.find((t) => t.accessToken === token || t.refreshToken === token);
if (!tokenData) {
return false;
}
const filteredTokens = tokens.filter(
(t) => t.accessToken !== tokenData.accessToken && t.refreshToken !== tokenData.refreshToken,
);
await this.saveAll(filteredTokens);
return true;
}
async revokeUserTokens(username: string): Promise<number> {
const tokens = await this.getAll();
const userTokens = tokens.filter((token) => token.username === username);
const remainingTokens = tokens.filter((token) => token.username !== username);
await this.saveAll(remainingTokens);
return userTokens.length;
}
async revokeClientTokens(clientId: string): Promise<number> {
const tokens = await this.getAll();
const clientTokens = tokens.filter((token) => token.clientId === clientId);
const remainingTokens = tokens.filter((token) => token.clientId !== clientId);
await this.saveAll(remainingTokens);
return clientTokens.length;
}
async cleanupExpired(): Promise<number> {
const tokens = await this.getAll();
const now = new Date();
const validTokens = tokens.filter((token) => {
// Keep if access token is still valid
if (token.accessTokenExpiresAt > now) {
return true;
}
// Or if refresh token exists and is still valid
if (token.refreshToken && token.refreshTokenExpiresAt && token.refreshTokenExpiresAt > now) {
return true;
}
return false;
});
const expiredCount = tokens.length - validTokens.length;
if (expiredCount > 0) {
await this.saveAll(validTokens);
}
return expiredCount;
}
async isAccessTokenValid(accessToken: string): Promise<boolean> {
const token = await this.findByAccessToken(accessToken);
if (!token) {
return false;
}
return token.accessTokenExpiresAt > new Date();
}
async isRefreshTokenValid(refreshToken: string): Promise<boolean> {
const token = await this.findByRefreshToken(refreshToken);
if (!token) {
return false;
}
if (!token.refreshTokenExpiresAt) {
return true; // No expiration means always valid
}
return token.refreshTokenExpiresAt > new Date();
}
}

View File

@@ -0,0 +1,122 @@
import { OAuthTokenDao } from './OAuthTokenDao.js';
import { OAuthTokenRepository } from '../db/repositories/OAuthTokenRepository.js';
import { IOAuthToken } from '../types/index.js';
/**
* Database-backed implementation of OAuthTokenDao
*/
export class OAuthTokenDaoDbImpl implements OAuthTokenDao {
private repository: OAuthTokenRepository;
constructor() {
this.repository = new OAuthTokenRepository();
}
async findAll(): Promise<IOAuthToken[]> {
const tokens = await this.repository.findAll();
return tokens.map((t) => this.mapToOAuthToken(t));
}
async findById(accessToken: string): Promise<IOAuthToken | null> {
const token = await this.repository.findByAccessToken(accessToken);
return token ? this.mapToOAuthToken(token) : null;
}
async findByAccessToken(accessToken: string): Promise<IOAuthToken | null> {
return this.findById(accessToken);
}
async findByRefreshToken(refreshToken: string): Promise<IOAuthToken | null> {
const token = await this.repository.findByRefreshToken(refreshToken);
return token ? this.mapToOAuthToken(token) : null;
}
async findByClientId(clientId: string): Promise<IOAuthToken[]> {
const tokens = await this.repository.findByClientId(clientId);
return tokens.map((t) => this.mapToOAuthToken(t));
}
async findByUsername(username: string): Promise<IOAuthToken[]> {
const tokens = await this.repository.findByUsername(username);
return tokens.map((t) => this.mapToOAuthToken(t));
}
async create(entity: IOAuthToken): Promise<IOAuthToken> {
const token = await this.repository.create({
accessToken: entity.accessToken,
accessTokenExpiresAt: entity.accessTokenExpiresAt,
refreshToken: entity.refreshToken,
refreshTokenExpiresAt: entity.refreshTokenExpiresAt,
scope: entity.scope,
clientId: entity.clientId,
username: entity.username,
});
return this.mapToOAuthToken(token);
}
async update(accessToken: string, entity: Partial<IOAuthToken>): Promise<IOAuthToken | null> {
const token = await this.repository.update(accessToken, {
accessTokenExpiresAt: entity.accessTokenExpiresAt,
refreshToken: entity.refreshToken,
refreshTokenExpiresAt: entity.refreshTokenExpiresAt,
scope: entity.scope,
});
return token ? this.mapToOAuthToken(token) : null;
}
async delete(accessToken: string): Promise<boolean> {
return await this.repository.delete(accessToken);
}
async exists(accessToken: string): Promise<boolean> {
return await this.repository.exists(accessToken);
}
async count(): Promise<number> {
return await this.repository.count();
}
async revokeToken(token: string): Promise<boolean> {
return await this.repository.revokeToken(token);
}
async revokeUserTokens(username: string): Promise<number> {
return await this.repository.revokeUserTokens(username);
}
async revokeClientTokens(clientId: string): Promise<number> {
return await this.repository.revokeClientTokens(clientId);
}
async cleanupExpired(): Promise<number> {
return await this.repository.cleanupExpired();
}
async isAccessTokenValid(accessToken: string): Promise<boolean> {
return await this.repository.isAccessTokenValid(accessToken);
}
async isRefreshTokenValid(refreshToken: string): Promise<boolean> {
return await this.repository.isRefreshTokenValid(refreshToken);
}
private mapToOAuthToken(token: {
accessToken: string;
accessTokenExpiresAt: Date;
refreshToken?: string;
refreshTokenExpiresAt?: Date;
scope?: string;
clientId: string;
username: string;
}): IOAuthToken {
return {
accessToken: token.accessToken,
accessTokenExpiresAt: token.accessTokenExpiresAt,
refreshToken: token.refreshToken,
refreshTokenExpiresAt: token.refreshTokenExpiresAt,
scope: token.scope,
clientId: token.clientId,
username: token.username,
};
}
}

View File

@@ -2,10 +2,31 @@ import { ServerConfig } from '../types/index.js';
import { BaseDao } from './base/BaseDao.js'; import { BaseDao } from './base/BaseDao.js';
import { JsonFileBaseDao } from './base/JsonFileBaseDao.js'; import { JsonFileBaseDao } from './base/JsonFileBaseDao.js';
/**
* Pagination result interface
*/
export interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
/** /**
* Server DAO interface with server-specific operations * Server DAO interface with server-specific operations
*/ */
export interface ServerDao extends BaseDao<ServerConfigWithName, string> { export interface ServerDao extends BaseDao<ServerConfigWithName, string> {
/**
* Find all servers with pagination
*/
findAllPaginated(page: number, limit: number): Promise<PaginatedResult<ServerConfigWithName>>;
/**
* Find servers by owner with pagination
*/
findByOwnerPaginated(owner: string, page: number, limit: number): Promise<PaginatedResult<ServerConfigWithName>>;
/** /**
* Find servers by owner * Find servers by owner
*/ */
@@ -41,6 +62,11 @@ export interface ServerDao extends BaseDao<ServerConfigWithName, string> {
name: string, name: string,
prompts: Record<string, { enabled: boolean; description?: string }>, prompts: Record<string, { enabled: boolean; description?: string }>,
): Promise<boolean>; ): Promise<boolean>;
/**
* Rename a server (change its name/key)
*/
rename(oldName: string, newName: string): Promise<boolean>;
} }
/** /**
@@ -95,7 +121,8 @@ export class ServerDaoImpl extends JsonFileBaseDao implements ServerDao {
return { return {
...existing, ...existing,
...updates, ...updates,
name: existing.name, // Name should not be updated // Keep the existing name unless explicitly updating via rename
name: updates.name ?? existing.name,
}; };
} }
@@ -141,9 +168,7 @@ export class ServerDaoImpl extends JsonFileBaseDao implements ServerDao {
return null; return null;
} }
// Don't allow name changes const updatedServer = this.updateEntity(servers[index], updates);
const { name: _, ...allowedUpdates } = updates;
const updatedServer = this.updateEntity(servers[index], allowedUpdates);
servers[index] = updatedServer; servers[index] = updatedServer;
await this.saveAll(servers); await this.saveAll(servers);
@@ -172,6 +197,61 @@ export class ServerDaoImpl extends JsonFileBaseDao implements ServerDao {
return servers.length; return servers.length;
} }
async findAllPaginated(page: number, limit: number): Promise<PaginatedResult<ServerConfigWithName>> {
const allServers = await this.getAll();
// Sort: enabled servers first, then by creation time
const sortedServers = allServers.sort((a, b) => {
const aEnabled = a.enabled !== false;
const bEnabled = b.enabled !== false;
if (aEnabled !== bEnabled) {
return aEnabled ? -1 : 1;
}
return 0; // Keep original order for same enabled status
});
const total = sortedServers.length;
const totalPages = Math.ceil(total / limit);
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const data = sortedServers.slice(startIndex, endIndex);
return {
data,
total,
page,
limit,
totalPages,
};
}
async findByOwnerPaginated(owner: string, page: number, limit: number): Promise<PaginatedResult<ServerConfigWithName>> {
const allServers = await this.getAll();
const filteredServers = allServers.filter((server) => server.owner === owner);
// Sort: enabled servers first, then by creation time
const sortedServers = filteredServers.sort((a, b) => {
const aEnabled = a.enabled !== false;
const bEnabled = b.enabled !== false;
if (aEnabled !== bEnabled) {
return aEnabled ? -1 : 1;
}
return 0; // Keep original order for same enabled status
});
const total = sortedServers.length;
const totalPages = Math.ceil(total / limit);
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const data = sortedServers.slice(startIndex, endIndex);
return {
data,
total,
page,
limit,
totalPages,
};
}
async findByOwner(owner: string): Promise<ServerConfigWithName[]> { async findByOwner(owner: string): Promise<ServerConfigWithName[]> {
const servers = await this.getAll(); const servers = await this.getAll();
return servers.filter((server) => server.owner === owner); return servers.filter((server) => server.owner === owner);
@@ -207,4 +287,22 @@ export class ServerDaoImpl extends JsonFileBaseDao implements ServerDao {
const result = await this.update(name, { prompts }); const result = await this.update(name, { prompts });
return result !== null; return result !== null;
} }
async rename(oldName: string, newName: string): Promise<boolean> {
const servers = await this.getAll();
const index = servers.findIndex((server) => server.name === oldName);
if (index === -1) {
return false;
}
// Check if newName already exists
if (servers.find((server) => server.name === newName)) {
throw new Error(`Server ${newName} already exists`);
}
servers[index] = { ...servers[index], name: newName };
await this.saveAll(servers);
return true;
}
} }

View File

@@ -1,4 +1,4 @@
import { ServerDao, ServerConfigWithName } from './index.js'; import { ServerDao, ServerConfigWithName, PaginatedResult } from './index.js';
import { ServerRepository } from '../db/repositories/ServerRepository.js'; import { ServerRepository } from '../db/repositories/ServerRepository.js';
/** /**
@@ -16,6 +16,32 @@ export class ServerDaoDbImpl implements ServerDao {
return servers.map((s) => this.mapToServerConfig(s)); return servers.map((s) => this.mapToServerConfig(s));
} }
async findAllPaginated(page: number, limit: number): Promise<PaginatedResult<ServerConfigWithName>> {
const { data, total } = await this.repository.findAllPaginated(page, limit);
const totalPages = Math.ceil(total / limit);
return {
data: data.map((s) => this.mapToServerConfig(s)),
total,
page,
limit,
totalPages,
};
}
async findByOwnerPaginated(owner: string, page: number, limit: number): Promise<PaginatedResult<ServerConfigWithName>> {
const { data, total } = await this.repository.findByOwnerPaginated(owner, page, limit);
const totalPages = Math.ceil(total / limit);
return {
data: data.map((s) => this.mapToServerConfig(s)),
total,
page,
limit,
totalPages,
};
}
async findById(name: string): Promise<ServerConfigWithName | null> { async findById(name: string): Promise<ServerConfigWithName | null> {
const server = await this.repository.findByName(name); const server = await this.repository.findByName(name);
return server ? this.mapToServerConfig(server) : null; return server ? this.mapToServerConfig(server) : null;
@@ -38,6 +64,8 @@ export class ServerDaoDbImpl implements ServerDao {
prompts: entity.prompts, prompts: entity.prompts,
options: entity.options, options: entity.options,
oauth: entity.oauth, oauth: entity.oauth,
proxy: entity.proxy,
openapi: entity.openapi,
}); });
return this.mapToServerConfig(server); return this.mapToServerConfig(server);
} }
@@ -61,6 +89,8 @@ export class ServerDaoDbImpl implements ServerDao {
prompts: entity.prompts, prompts: entity.prompts,
options: entity.options, options: entity.options,
oauth: entity.oauth, oauth: entity.oauth,
proxy: entity.proxy,
openapi: entity.openapi,
}); });
return server ? this.mapToServerConfig(server) : null; return server ? this.mapToServerConfig(server) : null;
} }
@@ -113,6 +143,15 @@ export class ServerDaoDbImpl implements ServerDao {
return result !== null; return result !== null;
} }
async rename(oldName: string, newName: string): Promise<boolean> {
// Check if newName already exists
if (await this.repository.exists(newName)) {
throw new Error(`Server ${newName} already exists`);
}
return await this.repository.rename(oldName, newName);
}
private mapToServerConfig(server: { private mapToServerConfig(server: {
name: string; name: string;
type?: string; type?: string;
@@ -129,6 +168,8 @@ export class ServerDaoDbImpl implements ServerDao {
prompts?: Record<string, { enabled: boolean; description?: string }>; prompts?: Record<string, { enabled: boolean; description?: string }>;
options?: Record<string, any>; options?: Record<string, any>;
oauth?: Record<string, any>; oauth?: Record<string, any>;
proxy?: Record<string, any>;
openapi?: Record<string, any>;
}): ServerConfigWithName { }): ServerConfigWithName {
return { return {
name: server.name, name: server.name,
@@ -146,6 +187,8 @@ export class ServerDaoDbImpl implements ServerDao {
prompts: server.prompts, prompts: server.prompts,
options: server.options, options: server.options,
oauth: server.oauth, oauth: server.oauth,
proxy: server.proxy,
openapi: server.openapi,
}; };
} }
} }

View File

@@ -6,6 +6,9 @@ export * from './ServerDao.js';
export * from './GroupDao.js'; export * from './GroupDao.js';
export * from './SystemConfigDao.js'; export * from './SystemConfigDao.js';
export * from './UserConfigDao.js'; export * from './UserConfigDao.js';
export * from './OAuthClientDao.js';
export * from './OAuthTokenDao.js';
export * from './BearerKeyDao.js';
// Export database implementations // Export database implementations
export * from './UserDaoDbImpl.js'; export * from './UserDaoDbImpl.js';
@@ -13,6 +16,9 @@ export * from './ServerDaoDbImpl.js';
export * from './GroupDaoDbImpl.js'; export * from './GroupDaoDbImpl.js';
export * from './SystemConfigDaoDbImpl.js'; export * from './SystemConfigDaoDbImpl.js';
export * from './UserConfigDaoDbImpl.js'; export * from './UserConfigDaoDbImpl.js';
export * from './OAuthClientDaoDbImpl.js';
export * from './OAuthTokenDaoDbImpl.js';
export * from './BearerKeyDaoDbImpl.js';
// Export the DAO factory and convenience functions // Export the DAO factory and convenience functions
export * from './DaoFactory.js'; export * from './DaoFactory.js';

View File

@@ -25,46 +25,54 @@ const createRequiredExtensions = async (dataSource: DataSource): Promise<void> =
}; };
// Get database URL from smart routing config or fallback to environment variable // Get database URL from smart routing config or fallback to environment variable
const getDatabaseUrl = (): string => { const getDatabaseUrl = async (): Promise<string> => {
return getSmartRoutingConfig().dbUrl; return (await getSmartRoutingConfig()).dbUrl;
}; };
// Default database configuration // Default database configuration (without URL - will be set during initialization)
const defaultConfig: DataSourceOptions = { const getDefaultConfig = async (): Promise<DataSourceOptions> => {
return {
type: 'postgres', type: 'postgres',
url: getDatabaseUrl(), url: await getDatabaseUrl(),
synchronize: true, synchronize: true,
entities: entities, entities: entities,
subscribers: [VectorEmbeddingSubscriber], subscribers: [VectorEmbeddingSubscriber],
};
}; };
// AppDataSource is the TypeORM data source // AppDataSource is the TypeORM data source (initialized with empty config, will be updated)
let appDataSource = new DataSource(defaultConfig); let appDataSource: DataSource | null = null;
// Global promise to track initialization status // Global promise to track initialization status
let initializationPromise: Promise<DataSource> | null = null; let initializationPromise: Promise<DataSource> | null = null;
// Function to create a new DataSource with updated configuration // Function to create a new DataSource with updated configuration
export const updateDataSourceConfig = (): DataSource => { export const updateDataSourceConfig = async (): Promise<DataSource> => {
const newConfig: DataSourceOptions = { const newConfig = await getDefaultConfig();
...defaultConfig,
url: getDatabaseUrl(),
};
// If the configuration has changed, we need to create a new DataSource // If the configuration has changed, we need to create a new DataSource
if (appDataSource) {
const currentUrl = (appDataSource.options as any).url; const currentUrl = (appDataSource.options as any).url;
if (currentUrl !== newConfig.url) { const newUrl = (newConfig as any).url;
if (currentUrl !== newUrl) {
console.log('Database URL configuration changed, updating DataSource...'); console.log('Database URL configuration changed, updating DataSource...');
appDataSource = new DataSource(newConfig); appDataSource = new DataSource(newConfig);
// Reset initialization promise when configuration changes // Reset initialization promise when configuration changes
initializationPromise = null; initializationPromise = null;
} }
} else {
// First time initialization
appDataSource = new DataSource(newConfig);
}
return appDataSource; return appDataSource;
}; };
// Get the current AppDataSource instance // Get the current AppDataSource instance
export const getAppDataSource = (): DataSource => { export const getAppDataSource = (): DataSource => {
if (!appDataSource) {
throw new Error('Database not initialized. Call initializeDatabase() first.');
}
return appDataSource; return appDataSource;
}; };
@@ -72,7 +80,7 @@ export const getAppDataSource = (): DataSource => {
export const reconnectDatabase = async (): Promise<DataSource> => { export const reconnectDatabase = async (): Promise<DataSource> => {
try { try {
// Close existing connection if it exists // Close existing connection if it exists
if (appDataSource.isInitialized) { if (appDataSource && appDataSource.isInitialized) {
console.log('Closing existing database connection...'); console.log('Closing existing database connection...');
await appDataSource.destroy(); await appDataSource.destroy();
} }
@@ -81,7 +89,7 @@ export const reconnectDatabase = async (): Promise<DataSource> => {
initializationPromise = null; initializationPromise = null;
// Update configuration and reconnect // Update configuration and reconnect
appDataSource = updateDataSourceConfig(); appDataSource = await updateDataSourceConfig();
return await initializeDatabase(); return await initializeDatabase();
} catch (error) { } catch (error) {
console.error('Error during database reconnection:', error); console.error('Error during database reconnection:', error);
@@ -98,7 +106,7 @@ export const initializeDatabase = async (): Promise<DataSource> => {
} }
// If already initialized, return the existing instance // If already initialized, return the existing instance
if (appDataSource.isInitialized) { if (appDataSource && appDataSource.isInitialized) {
console.log('Database already initialized, returning existing instance'); console.log('Database already initialized, returning existing instance');
return Promise.resolve(appDataSource); return Promise.resolve(appDataSource);
} }
@@ -122,7 +130,7 @@ export const initializeDatabase = async (): Promise<DataSource> => {
const performDatabaseInitialization = async (): Promise<DataSource> => { const performDatabaseInitialization = async (): Promise<DataSource> => {
try { try {
// Update configuration before initializing // Update configuration before initializing
appDataSource = updateDataSourceConfig(); appDataSource = await updateDataSourceConfig();
if (!appDataSource.isInitialized) { if (!appDataSource.isInitialized) {
console.log('Initializing database connection...'); console.log('Initializing database connection...');
@@ -250,7 +258,8 @@ const performDatabaseInitialization = async (): Promise<DataSource> => {
console.log('Database connection established successfully.'); console.log('Database connection established successfully.');
// Run one final setup check after schema synchronization is done // Run one final setup check after schema synchronization is done
if (defaultConfig.synchronize) { const config = await getDefaultConfig();
if (config.synchronize) {
try { try {
console.log('Running final vector configuration check...'); console.log('Running final vector configuration check...');
@@ -325,12 +334,12 @@ const performDatabaseInitialization = async (): Promise<DataSource> => {
// Get database connection status // Get database connection status
export const isDatabaseConnected = (): boolean => { export const isDatabaseConnected = (): boolean => {
return appDataSource.isInitialized; return appDataSource ? appDataSource.isInitialized : false;
}; };
// Close database connection // Close database connection
export const closeDatabase = async (): Promise<void> => { export const closeDatabase = async (): Promise<void> => {
if (appDataSource.isInitialized) { if (appDataSource && appDataSource.isInitialized) {
await appDataSource.destroy(); await appDataSource.destroy();
console.log('Database connection closed.'); console.log('Database connection closed.');
} }

View File

@@ -0,0 +1,43 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
/**
* Bearer authentication key entity
* Stores multiple bearer keys with per-key enable/disable and scoped access control
*/
@Entity({ name: 'bearer_keys' })
export class BearerKey {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 100 })
name: string;
@Column({ type: 'varchar', length: 512 })
token: string;
@Column({ type: 'boolean', default: true })
enabled: boolean;
@Column({ type: 'varchar', length: 20, default: 'all' })
accessType: 'all' | 'groups' | 'servers' | 'custom';
@Column({ type: 'simple-json', nullable: true })
allowedGroups?: string[];
@Column({ type: 'simple-json', nullable: true })
allowedServers?: string[];
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
updatedAt: Date;
}
export default BearerKey;

View File

@@ -0,0 +1,60 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
/**
* OAuth Client entity for database storage
* Represents OAuth clients registered with MCPHub's authorization server
*/
@Entity({ name: 'oauth_clients' })
export class OAuthClient {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'client_id', type: 'varchar', length: 255, unique: true })
clientId: string;
@Column({ name: 'client_secret', type: 'varchar', length: 255, nullable: true })
clientSecret?: string;
@Column({ type: 'varchar', length: 255 })
name: string;
@Column({ name: 'redirect_uris', type: 'simple-json' })
redirectUris: string[];
@Column({ type: 'simple-json' })
grants: string[];
@Column({ type: 'simple-json', nullable: true })
scopes?: string[];
@Column({ type: 'varchar', length: 255, nullable: true })
owner?: string;
@Column({ type: 'simple-json', nullable: true })
metadata?: {
application_type?: 'web' | 'native';
response_types?: string[];
token_endpoint_auth_method?: string;
contacts?: string[];
logo_uri?: string;
client_uri?: string;
policy_uri?: string;
tos_uri?: string;
jwks_uri?: string;
jwks?: object;
};
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
updatedAt: Date;
}
export default OAuthClient;

View File

@@ -0,0 +1,51 @@
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
/**
* OAuth Token entity for database storage
* Represents OAuth tokens issued by MCPHub's authorization server
*/
@Entity({ name: 'oauth_tokens' })
export class OAuthToken {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'access_token', type: 'varchar', length: 512, unique: true })
accessToken: string;
@Column({ name: 'access_token_expires_at', type: 'timestamp' })
accessTokenExpiresAt: Date;
@Index()
@Column({ name: 'refresh_token', type: 'varchar', length: 512, nullable: true, unique: true })
refreshToken?: string;
@Column({ name: 'refresh_token_expires_at', type: 'timestamp', nullable: true })
refreshTokenExpiresAt?: Date;
@Column({ type: 'varchar', length: 512, nullable: true })
scope?: string;
@Index()
@Column({ name: 'client_id', type: 'varchar', length: 255 })
clientId: string;
@Index()
@Column({ type: 'varchar', length: 255 })
username: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp' })
updatedAt: Date;
}
export default OAuthToken;

View File

@@ -59,6 +59,12 @@ export class Server {
@Column({ type: 'simple-json', nullable: true }) @Column({ type: 'simple-json', nullable: true })
oauth?: Record<string, any>; oauth?: Record<string, any>;
@Column({ type: 'simple-json', nullable: true })
proxy?: Record<string, any>;
@Column({ type: 'simple-json', nullable: true })
openapi?: Record<string, any>;
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date; createdAt: Date;

View File

@@ -4,9 +4,32 @@ import Server from './Server.js';
import Group from './Group.js'; import Group from './Group.js';
import SystemConfig from './SystemConfig.js'; import SystemConfig from './SystemConfig.js';
import UserConfig from './UserConfig.js'; import UserConfig from './UserConfig.js';
import OAuthClient from './OAuthClient.js';
import OAuthToken from './OAuthToken.js';
import BearerKey from './BearerKey.js';
// Export all entities // Export all entities
export default [VectorEmbedding, User, Server, Group, SystemConfig, UserConfig]; export default [
VectorEmbedding,
User,
Server,
Group,
SystemConfig,
UserConfig,
OAuthClient,
OAuthToken,
BearerKey,
];
// Export individual entities for direct use // Export individual entities for direct use
export { VectorEmbedding, User, Server, Group, SystemConfig, UserConfig }; export {
VectorEmbedding,
User,
Server,
Group,
SystemConfig,
UserConfig,
OAuthClient,
OAuthToken,
BearerKey,
};

View File

@@ -0,0 +1,75 @@
import { Repository } from 'typeorm';
import { BearerKey } from '../entities/BearerKey.js';
import { getAppDataSource } from '../connection.js';
/**
* Repository for BearerKey entity
*/
export class BearerKeyRepository {
private repository: Repository<BearerKey>;
constructor() {
this.repository = getAppDataSource().getRepository(BearerKey);
}
/**
* Find all bearer keys
*/
async findAll(): Promise<BearerKey[]> {
return await this.repository.find({ order: { createdAt: 'ASC' } });
}
/**
* Count bearer keys
*/
async count(): Promise<number> {
return await this.repository.count();
}
/**
* Find bearer key by id
*/
async findById(id: string): Promise<BearerKey | null> {
return await this.repository.findOne({ where: { id } });
}
/**
* Find bearer key by token value
*/
async findByToken(token: string): Promise<BearerKey | null> {
return await this.repository.findOne({ where: { token } });
}
/**
* Create a new bearer key
*/
async create(data: Omit<BearerKey, 'id' | 'createdAt' | 'updatedAt'>): Promise<BearerKey> {
const entity = this.repository.create(data);
return await this.repository.save(entity);
}
/**
* Update an existing bearer key
*/
async update(
id: string,
updates: Partial<Omit<BearerKey, 'id' | 'createdAt' | 'updatedAt'>>,
): Promise<BearerKey | null> {
const existing = await this.findById(id);
if (!existing) {
return null;
}
const merged = this.repository.merge(existing, updates);
return await this.repository.save(merged);
}
/**
* Delete a bearer key
*/
async delete(id: string): Promise<boolean> {
const result = await this.repository.delete({ id });
return (result.affected ?? 0) > 0;
}
}
export default BearerKeyRepository;

View File

@@ -16,7 +16,7 @@ export class GroupRepository {
* Find all groups * Find all groups
*/ */
async findAll(): Promise<Group[]> { async findAll(): Promise<Group[]> {
return await this.repository.find(); return await this.repository.find({ order: { createdAt: 'ASC' } });
} }
/** /**
@@ -88,7 +88,7 @@ export class GroupRepository {
* Find groups by owner * Find groups by owner
*/ */
async findByOwner(owner: string): Promise<Group[]> { async findByOwner(owner: string): Promise<Group[]> {
return await this.repository.find({ where: { owner } }); return await this.repository.find({ where: { owner }, order: { createdAt: 'ASC' } });
} }
} }

View File

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

View File

@@ -0,0 +1,183 @@
import { Repository, MoreThan } from 'typeorm';
import { OAuthToken } from '../entities/OAuthToken.js';
import { getAppDataSource } from '../connection.js';
/**
* Repository for OAuthToken entity
*/
export class OAuthTokenRepository {
private repository: Repository<OAuthToken>;
constructor() {
this.repository = getAppDataSource().getRepository(OAuthToken);
}
/**
* Find all OAuth tokens
*/
async findAll(): Promise<OAuthToken[]> {
return await this.repository.find();
}
/**
* Find OAuth token by access token
*/
async findByAccessToken(accessToken: string): Promise<OAuthToken | null> {
return await this.repository.findOne({ where: { accessToken } });
}
/**
* Find OAuth token by refresh token
*/
async findByRefreshToken(refreshToken: string): Promise<OAuthToken | null> {
return await this.repository.findOne({ where: { refreshToken } });
}
/**
* Find OAuth tokens by client ID
*/
async findByClientId(clientId: string): Promise<OAuthToken[]> {
return await this.repository.find({ where: { clientId } });
}
/**
* Find OAuth tokens by username
*/
async findByUsername(username: string): Promise<OAuthToken[]> {
return await this.repository.find({ where: { username } });
}
/**
* Create a new OAuth token
*/
async create(token: Omit<OAuthToken, 'id' | 'createdAt' | 'updatedAt'>): Promise<OAuthToken> {
// Remove any existing tokens with the same access token or refresh token
if (token.accessToken) {
await this.repository.delete({ accessToken: token.accessToken });
}
if (token.refreshToken) {
await this.repository.delete({ refreshToken: token.refreshToken });
}
const newToken = this.repository.create(token);
return await this.repository.save(newToken);
}
/**
* Update an existing OAuth token
*/
async update(accessToken: string, tokenData: Partial<OAuthToken>): Promise<OAuthToken | null> {
const token = await this.findByAccessToken(accessToken);
if (!token) {
return null;
}
const updated = this.repository.merge(token, tokenData);
return await this.repository.save(updated);
}
/**
* Delete an OAuth token by access token
*/
async delete(accessToken: string): Promise<boolean> {
const result = await this.repository.delete({ accessToken });
return (result.affected ?? 0) > 0;
}
/**
* Check if OAuth token exists by access token
*/
async exists(accessToken: string): Promise<boolean> {
const count = await this.repository.count({ where: { accessToken } });
return count > 0;
}
/**
* Count total OAuth tokens
*/
async count(): Promise<number> {
return await this.repository.count();
}
/**
* Revoke token by access token or refresh token
*/
async revokeToken(token: string): Promise<boolean> {
// Try to find by access token first
let tokenEntity = await this.findByAccessToken(token);
if (!tokenEntity) {
// Try to find by refresh token
tokenEntity = await this.findByRefreshToken(token);
}
if (!tokenEntity) {
return false;
}
const result = await this.repository.delete({ id: tokenEntity.id });
return (result.affected ?? 0) > 0;
}
/**
* Revoke all tokens for a user
*/
async revokeUserTokens(username: string): Promise<number> {
const result = await this.repository.delete({ username });
return result.affected ?? 0;
}
/**
* Revoke all tokens for a client
*/
async revokeClientTokens(clientId: string): Promise<number> {
const result = await this.repository.delete({ clientId });
return result.affected ?? 0;
}
/**
* Clean up expired tokens
*/
async cleanupExpired(): Promise<number> {
const now = new Date();
// Delete tokens where both access token and refresh token are expired
// (or refresh token doesn't exist)
const result = await this.repository
.createQueryBuilder()
.delete()
.from(OAuthToken)
.where('access_token_expires_at < :now', { now })
.andWhere('(refresh_token_expires_at IS NULL OR refresh_token_expires_at < :now)', { now })
.execute();
return result.affected ?? 0;
}
/**
* Check if access token is valid (exists and not expired)
*/
async isAccessTokenValid(accessToken: string): Promise<boolean> {
const count = await this.repository.count({
where: {
accessToken,
accessTokenExpiresAt: MoreThan(new Date()),
},
});
return count > 0;
}
/**
* Check if refresh token is valid (exists and not expired)
*/
async isRefreshTokenValid(refreshToken: string): Promise<boolean> {
const token = await this.findByRefreshToken(refreshToken);
if (!token) {
return false;
}
if (!token.refreshTokenExpiresAt) {
return true; // No expiration means always valid
}
return token.refreshTokenExpiresAt > new Date();
}
}
export default OAuthTokenRepository;

View File

@@ -16,7 +16,7 @@ export class ServerRepository {
* Find all servers * Find all servers
*/ */
async findAll(): Promise<Server[]> { async findAll(): Promise<Server[]> {
return await this.repository.find(); return await this.repository.find({ order: { createdAt: 'ASC' } });
} }
/** /**
@@ -69,18 +69,53 @@ export class ServerRepository {
return await this.repository.count(); return await this.repository.count();
} }
/**
* Find servers with pagination
*/
async findAllPaginated(page: number, limit: number): Promise<{ data: Server[]; total: number }> {
const skip = (page - 1) * limit;
const [data, total] = await this.repository.findAndCount({
order: {
enabled: 'DESC', // Enabled servers first
createdAt: 'ASC' // Then by creation time
},
skip,
take: limit,
});
return { data, total };
}
/**
* Find servers by owner with pagination
*/
async findByOwnerPaginated(owner: string, page: number, limit: number): Promise<{ data: Server[]; total: number }> {
const skip = (page - 1) * limit;
const [data, total] = await this.repository.findAndCount({
where: { owner },
order: {
enabled: 'DESC', // Enabled servers first
createdAt: 'ASC' // Then by creation time
},
skip,
take: limit,
});
return { data, total };
}
/** /**
* Find servers by owner * Find servers by owner
*/ */
async findByOwner(owner: string): Promise<Server[]> { async findByOwner(owner: string): Promise<Server[]> {
return await this.repository.find({ where: { owner } }); return await this.repository.find({ where: { owner }, order: { createdAt: 'ASC' } });
} }
/** /**
* Find enabled servers * Find enabled servers
*/ */
async findEnabled(): Promise<Server[]> { async findEnabled(): Promise<Server[]> {
return await this.repository.find({ where: { enabled: true } }); return await this.repository.find({ where: { enabled: true }, order: { createdAt: 'ASC' } });
} }
/** /**
@@ -89,6 +124,19 @@ export class ServerRepository {
async setEnabled(name: string, enabled: boolean): Promise<Server | null> { async setEnabled(name: string, enabled: boolean): Promise<Server | null> {
return await this.update(name, { enabled }); return await this.update(name, { enabled });
} }
/**
* Rename a server
*/
async rename(oldName: string, newName: string): Promise<boolean> {
const server = await this.findByName(oldName);
if (!server) {
return false;
}
server.name = newName;
await this.repository.save(server);
return true;
}
} }
export default ServerRepository; export default ServerRepository;

View File

@@ -16,7 +16,7 @@ export class UserRepository {
* Find all users * Find all users
*/ */
async findAll(): Promise<User[]> { async findAll(): Promise<User[]> {
return await this.repository.find(); return await this.repository.find({ order: { createdAt: 'ASC' } });
} }
/** /**
@@ -73,7 +73,7 @@ export class UserRepository {
* Find all admin users * Find all admin users
*/ */
async findAdmins(): Promise<User[]> { async findAdmins(): Promise<User[]> {
return await this.repository.find({ where: { isAdmin: true } }); return await this.repository.find({ where: { isAdmin: true }, order: { createdAt: 'ASC' } });
} }
} }

View File

@@ -4,6 +4,9 @@ import { ServerRepository } from './ServerRepository.js';
import { GroupRepository } from './GroupRepository.js'; import { GroupRepository } from './GroupRepository.js';
import { SystemConfigRepository } from './SystemConfigRepository.js'; import { SystemConfigRepository } from './SystemConfigRepository.js';
import { UserConfigRepository } from './UserConfigRepository.js'; import { UserConfigRepository } from './UserConfigRepository.js';
import { OAuthClientRepository } from './OAuthClientRepository.js';
import { OAuthTokenRepository } from './OAuthTokenRepository.js';
import { BearerKeyRepository } from './BearerKeyRepository.js';
// Export all repositories // Export all repositories
export { export {
@@ -13,4 +16,7 @@ export {
GroupRepository, GroupRepository,
SystemConfigRepository, SystemConfigRepository,
UserConfigRepository, UserConfigRepository,
OAuthClientRepository,
OAuthTokenRepository,
BearerKeyRepository,
}; };

View File

@@ -5,9 +5,15 @@ import defaultConfig from '../config/index.js';
import { JWT_SECRET } from '../config/jwt.js'; import { JWT_SECRET } from '../config/jwt.js';
import { getToken } from '../models/OAuth.js'; import { getToken } from '../models/OAuth.js';
import { isOAuthServerEnabled } from '../services/oauthServerService.js'; import { isOAuthServerEnabled } from '../services/oauthServerService.js';
import { getBearerKeyDao } from '../dao/index.js';
import { BearerKey } from '../types/index.js';
const validateBearerAuth = (req: Request, routingConfig: any): boolean => { const validateBearerAuth = async (req: Request): Promise<boolean> => {
if (!routingConfig.enableBearerAuth) { const bearerKeyDao = getBearerKeyDao();
const enabledKeys = await bearerKeyDao.findEnabled();
// If there are no enabled keys, bearer auth via static keys is disabled
if (enabledKeys.length === 0) {
return false; return false;
} }
@@ -16,7 +22,21 @@ const validateBearerAuth = (req: Request, routingConfig: any): boolean => {
return false; return false;
} }
return authHeader.substring(7) === routingConfig.bearerAuthKey; const token = authHeader.substring(7).trim();
if (!token) {
return false;
}
const matchingKey: BearerKey | undefined = enabledKeys.find((key) => key.token === token);
if (!matchingKey) {
console.warn('Bearer auth failed: token did not match any configured bearer key');
return false;
}
console.log(
`Bearer auth succeeded with key id=${matchingKey.id}, name=${matchingKey.name}, accessType=${matchingKey.accessType}`,
);
return true;
}; };
const readonlyAllowPaths = ['/tools/call/']; const readonlyAllowPaths = ['/tools/call/'];
@@ -47,8 +67,6 @@ export const auth = async (req: Request, res: Response, next: NextFunction): Pro
const routingConfig = loadSettings().systemConfig?.routing || { const routingConfig = loadSettings().systemConfig?.routing || {
enableGlobalRoute: true, enableGlobalRoute: true,
enableGroupNameRoute: true, enableGroupNameRoute: true,
enableBearerAuth: false,
bearerAuthKey: '',
skipAuth: false, skipAuth: false,
}; };
@@ -57,8 +75,8 @@ export const auth = async (req: Request, res: Response, next: NextFunction): Pro
return; return;
} }
// Check if bearer auth is enabled and validate it // Check if bearer auth via configured keys can validate this request
if (validateBearerAuth(req, routingConfig)) { if (await validateBearerAuth(req)) {
next(); next();
return; return;
} }
@@ -67,7 +85,7 @@ export const auth = async (req: Request, res: Response, next: NextFunction): Pro
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ') && isOAuthServerEnabled()) { if (authHeader && authHeader.startsWith('Bearer ') && isOAuthServerEnabled()) {
const accessToken = authHeader.substring(7); const accessToken = authHeader.substring(7);
const oauthToken = getToken(accessToken); const oauthToken = await getToken(accessToken);
if (oauthToken && oauthToken.accessToken === accessToken) { if (oauthToken && oauthToken.accessToken === accessToken) {
// Valid OAuth token - look up user to get admin status // Valid OAuth token - look up user to get admin status

View File

@@ -1,112 +1,89 @@
import crypto from 'crypto'; import crypto from 'crypto';
import { loadSettings, saveSettings } from '../config/index.js'; import { getOAuthClientDao, getOAuthTokenDao } from '../dao/index.js';
import { IOAuthClient, IOAuthAuthorizationCode, IOAuthToken } from '../types/index.js'; import { IOAuthClient, IOAuthAuthorizationCode, IOAuthToken } from '../types/index.js';
// In-memory storage for authorization codes and tokens // In-memory storage for authorization codes (short-lived, no persistence needed)
// Authorization codes are short-lived and kept in memory only.
// Tokens are mirrored to settings (mcp_settings.json) for persistence.
const authorizationCodes = new Map<string, IOAuthAuthorizationCode>(); const authorizationCodes = new Map<string, IOAuthAuthorizationCode>();
const tokens = new Map<string, IOAuthToken>();
// Initialize token store from settings on first import // In-memory cache for tokens (also persisted via DAO)
(() => { const tokensCache = new Map<string, IOAuthToken>();
// Flag to track if we've initialized from DAO
let initialized = false;
/**
* Initialize token cache from DAO (async)
*/
const initializeTokenCache = async (): Promise<void> => {
if (initialized) return;
initialized = true;
try { try {
const settings = loadSettings(); const tokenDao = getOAuthTokenDao();
if (Array.isArray(settings.oauthTokens)) { const allTokens = await tokenDao.findAll();
for (const stored of settings.oauthTokens) { for (const token of allTokens) {
const token: IOAuthToken = { tokensCache.set(token.accessToken, token);
...stored,
accessTokenExpiresAt: new Date(stored.accessTokenExpiresAt),
refreshTokenExpiresAt: stored.refreshTokenExpiresAt
? new Date(stored.refreshTokenExpiresAt)
: undefined,
};
tokens.set(token.accessToken, token);
if (token.refreshToken) { if (token.refreshToken) {
tokens.set(token.refreshToken, token); tokensCache.set(token.refreshToken, token);
}
} }
} }
} catch (error) { } catch (error) {
console.error('Failed to initialize OAuth tokens from settings:', error); console.error('Failed to initialize OAuth tokens from DAO:', error);
} }
})(); };
// Initialize on module load (fire and forget for backward compatibility)
initializeTokenCache().catch(console.error);
/** /**
* Get all OAuth clients from configuration * Get all OAuth clients from configuration
*/ */
export const getOAuthClients = (): IOAuthClient[] => { export const getOAuthClients = async (): Promise<IOAuthClient[]> => {
const settings = loadSettings(); const clientDao = getOAuthClientDao();
return settings.oauthClients || []; return clientDao.findAll();
}; };
/** /**
* Find OAuth client by client ID * Find OAuth client by client ID
*/ */
export const findOAuthClientById = (clientId: string): IOAuthClient | undefined => { export const findOAuthClientById = async (clientId: string): Promise<IOAuthClient | undefined> => {
const clients = getOAuthClients(); const clientDao = getOAuthClientDao();
return clients.find((c) => c.clientId === clientId); const client = await clientDao.findByClientId(clientId);
return client || undefined;
}; };
/** /**
* Create a new OAuth client * Create a new OAuth client
*/ */
export const createOAuthClient = (client: IOAuthClient): IOAuthClient => { export const createOAuthClient = async (client: IOAuthClient): Promise<IOAuthClient> => {
const settings = loadSettings(); const clientDao = getOAuthClientDao();
if (!settings.oauthClients) {
settings.oauthClients = [];
}
// Check if client already exists // Check if client already exists
const existing = settings.oauthClients.find((c) => c.clientId === client.clientId); const existing = await clientDao.findByClientId(client.clientId);
if (existing) { if (existing) {
throw new Error(`OAuth client with ID ${client.clientId} already exists`); throw new Error(`OAuth client with ID ${client.clientId} already exists`);
} }
settings.oauthClients.push(client); return clientDao.create(client);
saveSettings(settings);
return client;
}; };
/** /**
* Update an existing OAuth client * Update an existing OAuth client
*/ */
export const updateOAuthClient = ( export const updateOAuthClient = async (
clientId: string, clientId: string,
updates: Partial<IOAuthClient>, updates: Partial<IOAuthClient>,
): IOAuthClient | null => { ): Promise<IOAuthClient | null> => {
const settings = loadSettings(); const clientDao = getOAuthClientDao();
if (!settings.oauthClients) { return clientDao.update(clientId, updates);
return null;
}
const index = settings.oauthClients.findIndex((c) => c.clientId === clientId);
if (index === -1) {
return null;
}
settings.oauthClients[index] = { ...settings.oauthClients[index], ...updates };
saveSettings(settings);
return settings.oauthClients[index];
}; };
/** /**
* Delete an OAuth client * Delete an OAuth client
*/ */
export const deleteOAuthClient = (clientId: string): boolean => { export const deleteOAuthClient = async (clientId: string): Promise<boolean> => {
const settings = loadSettings(); const clientDao = getOAuthClientDao();
if (!settings.oauthClients) { return clientDao.delete(clientId);
return false;
}
const index = settings.oauthClients.findIndex((c) => c.clientId === clientId);
if (index === -1) {
return false;
}
settings.oauthClients.splice(index, 1);
saveSettings(settings);
return true;
}; };
/** /**
@@ -163,11 +140,11 @@ export const revokeAuthorizationCode = (code: string): void => {
/** /**
* Save access token and optionally refresh token * Save access token and optionally refresh token
*/ */
export const saveToken = ( export const saveToken = async (
tokenData: Omit<IOAuthToken, 'accessToken' | 'accessTokenExpiresAt'>, tokenData: Omit<IOAuthToken, 'accessToken' | 'accessTokenExpiresAt'>,
accessTokenLifetime: number = 3600, accessTokenLifetime: number = 3600,
refreshTokenLifetime?: number, refreshTokenLifetime?: number,
): IOAuthToken => { ): Promise<IOAuthToken> => {
const accessToken = generateToken(); const accessToken = generateToken();
const accessTokenExpiresAt = new Date(Date.now() + accessTokenLifetime * 1000); const accessTokenExpiresAt = new Date(Date.now() + accessTokenLifetime * 1000);
@@ -187,30 +164,18 @@ export const saveToken = (
...tokenData, ...tokenData,
}; };
tokens.set(accessToken, token); // Update cache
tokensCache.set(accessToken, token);
if (refreshToken) { if (refreshToken) {
tokens.set(refreshToken, token); tokensCache.set(refreshToken, token);
} }
// Persist tokens to settings // Persist to DAO
try { try {
const settings = loadSettings(); const tokenDao = getOAuthTokenDao();
const existing = settings.oauthTokens || []; await tokenDao.create(token);
const filtered = existing.filter(
(t) => t.accessToken !== token.accessToken && t.refreshToken !== token.refreshToken,
);
const updated = [
...filtered,
{
...token,
accessTokenExpiresAt: token.accessTokenExpiresAt,
refreshTokenExpiresAt: token.refreshTokenExpiresAt,
},
];
settings.oauthTokens = updated;
saveSettings(settings);
} catch (error) { } catch (error) {
console.error('Failed to persist OAuth token to settings:', error); console.error('Failed to persist OAuth token to DAO:', error);
} }
return token; return token;
@@ -219,8 +184,27 @@ export const saveToken = (
/** /**
* Get token by access token or refresh token * Get token by access token or refresh token
*/ */
export const getToken = (token: string): IOAuthToken | undefined => { export const getToken = async (token: string): Promise<IOAuthToken | undefined> => {
const tokenData = tokens.get(token); // First check cache
let tokenData = tokensCache.get(token);
// If not in cache, try DAO
if (!tokenData) {
const tokenDao = getOAuthTokenDao();
tokenData =
(await tokenDao.findByAccessToken(token)) ||
(await tokenDao.findByRefreshToken(token)) ||
undefined;
// Update cache if found
if (tokenData) {
tokensCache.set(tokenData.accessToken, tokenData);
if (tokenData.refreshToken) {
tokensCache.set(tokenData.refreshToken, tokenData);
}
}
}
if (!tokenData) { if (!tokenData) {
return undefined; return undefined;
} }
@@ -245,34 +229,28 @@ export const getToken = (token: string): IOAuthToken | undefined => {
/** /**
* Revoke token (both access and refresh tokens) * Revoke token (both access and refresh tokens)
*/ */
export const revokeToken = (token: string): void => { export const revokeToken = async (token: string): Promise<void> => {
const tokenData = tokens.get(token); const tokenData = tokensCache.get(token);
if (tokenData) { if (tokenData) {
tokens.delete(tokenData.accessToken); tokensCache.delete(tokenData.accessToken);
if (tokenData.refreshToken) { if (tokenData.refreshToken) {
tokens.delete(tokenData.refreshToken); tokensCache.delete(tokenData.refreshToken);
}
} }
// Also remove from persisted settings // Also remove from DAO
try { try {
const settings = loadSettings(); const tokenDao = getOAuthTokenDao();
if (Array.isArray(settings.oauthTokens)) { await tokenDao.revokeToken(token);
settings.oauthTokens = settings.oauthTokens.filter(
(t) =>
t.accessToken !== tokenData.accessToken && t.refreshToken !== tokenData.refreshToken,
);
saveSettings(settings);
}
} catch (error) { } catch (error) {
console.error('Failed to remove OAuth token from settings:', error); console.error('Failed to remove OAuth token from DAO:', error);
}
} }
}; };
/** /**
* Clean up expired codes and tokens (should be called periodically) * Clean up expired codes and tokens (should be called periodically)
*/ */
export const cleanupExpired = (): void => { export const cleanupExpired = async (): Promise<void> => {
const now = new Date(); const now = new Date();
// Clean up expired authorization codes // Clean up expired authorization codes
@@ -282,9 +260,9 @@ export const cleanupExpired = (): void => {
} }
} }
// Clean up expired tokens // Clean up expired tokens from cache
const processedTokens = new Set<string>(); const processedTokens = new Set<string>();
for (const [_key, token] of tokens.entries()) { for (const [_key, token] of tokensCache.entries()) {
// Skip if we've already processed this token // Skip if we've already processed this token
if (processedTokens.has(token.accessToken)) { if (processedTokens.has(token.accessToken)) {
continue; continue;
@@ -294,35 +272,19 @@ export const cleanupExpired = (): void => {
const accessExpired = token.accessTokenExpiresAt < now; const accessExpired = token.accessTokenExpiresAt < now;
const refreshExpired = token.refreshTokenExpiresAt && token.refreshTokenExpiresAt < now; const refreshExpired = token.refreshTokenExpiresAt && token.refreshTokenExpiresAt < now;
// If both are expired, remove the token // If both are expired, remove from cache
if (accessExpired && (!token.refreshToken || refreshExpired)) { if (accessExpired && (!token.refreshToken || refreshExpired)) {
tokens.delete(token.accessToken); tokensCache.delete(token.accessToken);
if (token.refreshToken) { if (token.refreshToken) {
tokens.delete(token.refreshToken); tokensCache.delete(token.refreshToken);
} }
} }
} }
// Sync persisted tokens: keep only non-expired ones // Clean up expired tokens from DAO
try { try {
const settings = loadSettings(); const tokenDao = getOAuthTokenDao();
if (Array.isArray(settings.oauthTokens)) { await tokenDao.cleanupExpired();
const validTokens: IOAuthToken[] = [];
for (const stored of settings.oauthTokens) {
const accessExpiresAt = new Date(stored.accessTokenExpiresAt);
const refreshExpiresAt = stored.refreshTokenExpiresAt
? new Date(stored.refreshTokenExpiresAt)
: undefined;
const accessExpired = accessExpiresAt < now;
const refreshExpired = refreshExpiresAt && refreshExpiresAt < now;
if (!accessExpired || (stored.refreshToken && !refreshExpired)) {
validTokens.push(stored);
}
}
settings.oauthTokens = validTokens;
saveSettings(settings);
}
} catch (error) { } catch (error) {
console.error('Failed to cleanup persisted OAuth tokens:', error); console.error('Failed to cleanup persisted OAuth tokens:', error);
} }
@@ -331,7 +293,12 @@ export const cleanupExpired = (): void => {
// Run cleanup every 5 minutes in production // Run cleanup every 5 minutes in production
let cleanupIntervalId: NodeJS.Timeout | null = null; let cleanupIntervalId: NodeJS.Timeout | null = null;
if (process.env.NODE_ENV !== 'test') { if (process.env.NODE_ENV !== 'test') {
cleanupIntervalId = setInterval(cleanupExpired, 5 * 60 * 1000); cleanupIntervalId = setInterval(
() => {
cleanupExpired().catch(console.error);
},
5 * 60 * 1000,
);
// Allow the interval to not keep the process alive // Allow the interval to not keep the process alive
cleanupIntervalId.unref(); cleanupIntervalId.unref();
} }

View File

@@ -6,9 +6,11 @@ import {
getAllSettings, getAllSettings,
getServerConfig, getServerConfig,
createServer, createServer,
batchCreateServers,
updateServer, updateServer,
deleteServer, deleteServer,
toggleServer, toggleServer,
reloadServer,
toggleTool, toggleTool,
updateToolDescription, updateToolDescription,
togglePrompt, togglePrompt,
@@ -19,6 +21,7 @@ import {
getGroups, getGroups,
getGroup, getGroup,
createNewGroup, createNewGroup,
batchCreateGroups,
updateExistingGroup, updateExistingGroup,
deleteExistingGroup, deleteExistingGroup,
addServerToExistingGroup, addServerToExistingGroup,
@@ -103,6 +106,12 @@ import {
updateClientConfiguration, updateClientConfiguration,
deleteClientRegistration, deleteClientRegistration,
} from '../controllers/oauthDynamicRegistrationController.js'; } from '../controllers/oauthDynamicRegistrationController.js';
import {
getBearerKeys,
createBearerKey,
updateBearerKey,
deleteBearerKey,
} from '../controllers/bearerKeyController.js';
import { auth } from '../middlewares/auth.js'; import { auth } from '../middlewares/auth.js';
const router = express.Router(); const router = express.Router();
@@ -133,9 +142,11 @@ export const initRoutes = (app: express.Application): void => {
router.get('/servers/:name', getServerConfig); router.get('/servers/:name', getServerConfig);
router.get('/settings', getAllSettings); router.get('/settings', getAllSettings);
router.post('/servers', createServer); router.post('/servers', createServer);
router.post('/servers/batch', batchCreateServers);
router.put('/servers/:name', updateServer); router.put('/servers/:name', updateServer);
router.delete('/servers/:name', deleteServer); router.delete('/servers/:name', deleteServer);
router.post('/servers/:name/toggle', toggleServer); router.post('/servers/:name/toggle', toggleServer);
router.post('/servers/:name/reload', reloadServer);
router.post('/servers/:serverName/tools/:toolName/toggle', toggleTool); router.post('/servers/:serverName/tools/:toolName/toggle', toggleTool);
router.put('/servers/:serverName/tools/:toolName/description', updateToolDescription); router.put('/servers/:serverName/tools/:toolName/description', updateToolDescription);
router.post('/servers/:serverName/prompts/:promptName/toggle', togglePrompt); router.post('/servers/:serverName/prompts/:promptName/toggle', togglePrompt);
@@ -146,6 +157,7 @@ export const initRoutes = (app: express.Application): void => {
router.get('/groups', getGroups); router.get('/groups', getGroups);
router.get('/groups/:id', getGroup); router.get('/groups/:id', getGroup);
router.post('/groups', createNewGroup); router.post('/groups', createNewGroup);
router.post('/groups/batch', batchCreateGroups);
router.put('/groups/:id', updateExistingGroup); router.put('/groups/:id', updateExistingGroup);
router.delete('/groups/:id', deleteExistingGroup); router.delete('/groups/:id', deleteExistingGroup);
router.post('/groups/:id/servers', addServerToExistingGroup); router.post('/groups/:id/servers', addServerToExistingGroup);
@@ -181,6 +193,12 @@ export const initRoutes = (app: express.Application): void => {
router.delete('/oauth/clients/:clientId', deleteClient); router.delete('/oauth/clients/:clientId', deleteClient);
router.post('/oauth/clients/:clientId/regenerate-secret', regenerateSecret); router.post('/oauth/clients/:clientId/regenerate-secret', regenerateSecret);
// Bearer authentication key management (admin only)
router.get('/auth/keys', getBearerKeys);
router.post('/auth/keys', createBearerKey);
router.put('/auth/keys/:id', updateBearerKey);
router.delete('/auth/keys/:id', deleteBearerKey);
// Tool management routes // Tool management routes
router.post('/tools/call/:server', callTool); router.post('/tools/call/:server', callTool);

View File

@@ -29,9 +29,9 @@ export const getGroupByIdOrName = async (key: string): Promise<IGroup | undefine
const systemConfigDao = getSystemConfigDao(); const systemConfigDao = getSystemConfigDao();
const systemConfig = await systemConfigDao.get(); const systemConfig = await systemConfigDao.get();
const routingConfig = systemConfig?.routing || { const routingConfig = {
enableGlobalRoute: true, enableGlobalRoute: systemConfig?.routing?.enableGlobalRoute ?? true,
enableGroupNameRoute: true, enableGroupNameRoute: systemConfig?.routing?.enableGroupNameRoute ?? true,
}; };
const groups = await getAllGroups(); const groups = await getAllGroups();

View File

@@ -48,7 +48,9 @@ export const setupClientKeepAlive = async (
await (serverInfo.client as any).ping(); await (serverInfo.client as any).ping();
console.log(`Keep-alive ping successful for server: ${serverInfo.name}`); console.log(`Keep-alive ping successful for server: ${serverInfo.name}`);
} else { } else {
await serverInfo.client.listTools({ timeout: 5000 }).catch(() => void 0); await serverInfo.client
.listTools({}, { ...(serverInfo.options || {}), timeout: 5000 })
.catch(() => void 0);
} }
} }
} catch (error) { } catch (error) {

View File

@@ -325,7 +325,7 @@ export class MCPHubOAuthProvider implements OAuthClientProvider {
return; return;
} }
console.log(`Saving OAuth tokens for server: ${this.serverName}`); console.log(`Saving OAuth tokens: ${JSON.stringify(tokens)} for server: ${this.serverName}`);
const updatedConfig = await persistTokens(this.serverName, { const updatedConfig = await persistTokens(this.serverName, {
accessToken: tokens.access_token, accessToken: tokens.access_token,

View File

@@ -1,4 +1,6 @@
import os from 'os'; import os from 'os';
import path from 'path';
import fs from 'fs';
import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { import {
CallToolRequestSchema, CallToolRequestSchema,
@@ -14,7 +16,8 @@ import {
StreamableHTTPClientTransport, StreamableHTTPClientTransport,
StreamableHTTPClientTransportOptions, StreamableHTTPClientTransportOptions,
} from '@modelcontextprotocol/sdk/client/streamableHttp.js'; } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { ServerInfo, ServerConfig, Tool } from '../types/index.js'; import { createFetchWithProxy, getProxyConfigFromEnv } from './proxy.js';
import { ServerInfo, ServerConfig, Tool, ProxychainsConfig } from '../types/index.js';
import { expandEnvVars, replaceEnvVars, getNameSeparator } from '../config/index.js'; import { expandEnvVars, replaceEnvVars, getNameSeparator } from '../config/index.js';
import config from '../config/index.js'; import config from '../config/index.js';
import { getGroup } from './sseService.js'; import { getGroup } from './sseService.js';
@@ -31,6 +34,150 @@ const servers: { [sessionId: string]: Server } = {};
import { setupClientKeepAlive } from './keepAliveService.js'; import { setupClientKeepAlive } from './keepAliveService.js';
/**
* Check if proxychains4 is available on the system (Linux/macOS only).
* Returns the path to proxychains4 if found, null otherwise.
*/
const findProxychains4 = (): string | null => {
// Windows is not supported
if (process.platform === 'win32') {
return null;
}
// Common proxychains4 binary paths
const possiblePaths = [
'/usr/bin/proxychains4',
'/usr/local/bin/proxychains4',
'/opt/homebrew/bin/proxychains4', // macOS Homebrew ARM
'/usr/local/Cellar/proxychains-ng/*/bin/proxychains4', // macOS Homebrew Intel
];
for (const p of possiblePaths) {
if (fs.existsSync(p)) {
return p;
}
}
// Try to find in PATH
const pathEnv = process.env.PATH || '';
const pathDirs = pathEnv.split(path.delimiter);
for (const dir of pathDirs) {
const fullPath = path.join(dir, 'proxychains4');
if (fs.existsSync(fullPath)) {
return fullPath;
}
}
return null;
};
/**
* Generate a temporary proxychains4 configuration file.
* Returns the path to the generated config file.
*/
const generateProxychainsConfig = (
serverName: string,
proxyConfig: ProxychainsConfig,
): string | null => {
// If a custom config path is provided, use it directly
if (proxyConfig.configPath) {
if (fs.existsSync(proxyConfig.configPath)) {
return proxyConfig.configPath;
}
console.warn(
`[${serverName}] Custom proxychains config not found: ${proxyConfig.configPath}`,
);
return null;
}
// Validate required fields
if (!proxyConfig.host || !proxyConfig.port) {
console.warn(`[${serverName}] Proxy host and port are required for proxychains4`);
return null;
}
const proxyType = proxyConfig.type || 'socks5';
const proxyLine = proxyConfig.username && proxyConfig.password
? `${proxyType} ${proxyConfig.host} ${proxyConfig.port} ${proxyConfig.username} ${proxyConfig.password}`
: `${proxyType} ${proxyConfig.host} ${proxyConfig.port}`;
const configContent = `# Proxychains4 configuration for MCP server: ${serverName}
# Generated by MCPHub
strict_chain
proxy_dns
remote_dns_subnet 224
tcp_read_time_out 15000
tcp_connect_time_out 8000
[ProxyList]
${proxyLine}
`;
// Create temp directory if needed
const tempDir = path.join(os.tmpdir(), 'mcphub-proxychains');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
// Write config file
const configPath = path.join(tempDir, `${serverName.replace(/[^a-zA-Z0-9-_]/g, '_')}.conf`);
fs.writeFileSync(configPath, configContent, 'utf-8');
console.log(`[${serverName}] Generated proxychains4 config: ${configPath}`);
return configPath;
};
/**
* Wrap a command with proxychains4 if proxy is configured and available.
* Returns modified command and args if proxychains4 is used, original values otherwise.
*/
const wrapWithProxychains = (
serverName: string,
command: string,
args: string[],
proxyConfig?: ProxychainsConfig,
): { command: string; args: string[] } => {
// Skip if proxy is not enabled or not configured
if (!proxyConfig?.enabled) {
return { command, args };
}
// Check platform - Windows is not supported
if (process.platform === 'win32') {
console.warn(
`[${serverName}] proxychains4 proxy is not supported on Windows, ignoring proxy configuration`,
);
return { command, args };
}
// Find proxychains4 binary
const proxychains4Path = findProxychains4();
if (!proxychains4Path) {
console.warn(
`[${serverName}] proxychains4 not found on system, install it with: apt install proxychains4 (Debian/Ubuntu) or brew install proxychains-ng (macOS)`,
);
return { command, args };
}
// Generate or get config file
const configPath = generateProxychainsConfig(serverName, proxyConfig);
if (!configPath) {
console.warn(`[${serverName}] Failed to setup proxychains4 configuration, skipping proxy`);
return { command, args };
}
// Wrap command with proxychains4
console.log(
`[${serverName}] Using proxychains4 proxy: ${proxyConfig.type || 'socks5'}://${proxyConfig.host}:${proxyConfig.port}`,
);
return {
command: proxychains4Path,
args: ['-f', configPath, command, ...args],
};
};
export const initUpstreamServers = async (): Promise<void> => { export const initUpstreamServers = async (): Promise<void> => {
// Initialize OAuth clients for servers with dynamic registration // Initialize OAuth clients for servers with dynamic registration
await initializeAllOAuthClients(); await initializeAllOAuthClients();
@@ -134,6 +281,10 @@ export const cleanupAllServers = (): void => {
// Helper function to create transport based on server configuration // Helper function to create transport based on server configuration
export const createTransportFromConfig = async (name: string, conf: ServerConfig): Promise<any> => { export const createTransportFromConfig = async (name: string, conf: ServerConfig): Promise<any> => {
let transport; let transport;
const env: Record<string, string> = {
...(process.env as Record<string, string>),
...replaceEnvVars(conf.env || {}),
};
if (conf.type === 'streamable-http') { if (conf.type === 'streamable-http') {
const options: StreamableHTTPClientTransportOptions = {}; const options: StreamableHTTPClientTransportOptions = {};
@@ -152,6 +303,8 @@ export const createTransportFromConfig = async (name: string, conf: ServerConfig
console.log(`OAuth provider configured for server: ${name}`); console.log(`OAuth provider configured for server: ${name}`);
} }
options.fetch = createFetchWithProxy(getProxyConfigFromEnv(env));
transport = new StreamableHTTPClientTransport(new URL(conf.url || ''), options); transport = new StreamableHTTPClientTransport(new URL(conf.url || ''), options);
} else if (conf.url) { } else if (conf.url) {
// SSE transport // SSE transport
@@ -174,13 +327,11 @@ export const createTransportFromConfig = async (name: string, conf: ServerConfig
console.log(`OAuth provider configured for server: ${name}`); console.log(`OAuth provider configured for server: ${name}`);
} }
options.fetch = createFetchWithProxy(getProxyConfigFromEnv(env));
transport = new SSEClientTransport(new URL(conf.url), options); transport = new SSEClientTransport(new URL(conf.url), options);
} else if (conf.command && conf.args) { } else if (conf.command && conf.args) {
// Stdio transport // Stdio transport
const env: Record<string, string> = {
...(process.env as Record<string, string>),
...replaceEnvVars(conf.env || {}),
};
env['PATH'] = expandEnvVars(process.env.PATH as string) || ''; env['PATH'] = expandEnvVars(process.env.PATH as string) || '';
const systemConfigDao = getSystemConfigDao(); const systemConfigDao = getSystemConfigDao();
@@ -204,11 +355,19 @@ export const createTransportFromConfig = async (name: string, conf: ServerConfig
env['npm_config_registry'] = systemConfig.install.npmRegistry; env['npm_config_registry'] = systemConfig.install.npmRegistry;
} }
// Expand environment variables in command // Apply proxychains4 wrapper if proxy is configured (Linux/macOS only)
const { command: finalCommand, args: finalArgs } = wrapWithProxychains(
name,
conf.command,
replaceEnvVars(conf.args) as string[],
conf.proxy,
);
// Create STDIO transport with potentially wrapped command
transport = new StdioClientTransport({ transport = new StdioClientTransport({
cwd: os.homedir(), cwd: os.homedir(),
command: conf.command, command: finalCommand,
args: replaceEnvVars(conf.args) as string[], args: finalArgs,
env: env, env: env,
stderr: 'pipe', stderr: 'pipe',
}); });
@@ -236,6 +395,8 @@ const callToolWithReconnect = async (
for (let attempt = 0; attempt <= maxRetries; attempt++) { for (let attempt = 0; attempt <= maxRetries; attempt++) {
try { try {
const result = await serverInfo.client.callTool(toolParams, undefined, options || {}); const result = await serverInfo.client.callTool(toolParams, undefined, options || {});
// Check auth error
checkAuthError(result);
return result; return result;
} catch (error: any) { } catch (error: any) {
// Check if error message starts with "Error POSTing to endpoint (HTTP 40" // Check if error message starts with "Error POSTing to endpoint (HTTP 40"
@@ -277,11 +438,7 @@ const callToolWithReconnect = async (
version: '1.0.0', version: '1.0.0',
}, },
{ {
capabilities: { capabilities: {},
prompts: {},
resources: {},
tools: {},
},
}, },
); );
@@ -463,11 +620,7 @@ export const initializeClientsFromSettings = async (
version: '1.0.0', version: '1.0.0',
}, },
{ {
capabilities: { capabilities: {},
prompts: {},
resources: {},
tools: {},
},
}, },
); );
@@ -619,14 +772,65 @@ export const registerAllTools = async (isInit: boolean, serverName?: string): Pr
}; };
// Get all server information // Get all server information
export const getServersInfo = async (): Promise<Omit<ServerInfo, 'client' | 'transport'>[]> => { export const getServersInfo = async (
const allServers: ServerConfigWithName[] = await getServerDao().findAll(); page?: number,
limit?: number,
user?: any,
): Promise<Omit<ServerInfo, 'client' | 'transport'>[]> => {
const dataService = getDataService(); const dataService = getDataService();
const filterServerInfos: ServerInfo[] = dataService.filterData
? dataService.filterData(serverInfos) // Get paginated or all server configurations from DAO
: serverInfos; // If pagination is used with a non-admin user, filtering is already done at DAO level
const infos = filterServerInfos.map( const isPaginated = limit !== undefined && page !== undefined;
({ name, status, tools, prompts, createTime, error, oauth }) => { const allServers: ServerConfigWithName[] = isPaginated
? (await getServerDao().findAllPaginated(page, limit)).data
: await getServerDao().findAll();
// Ensure that servers recently added via DAO but not yet initialized in serverInfos
// are still visible in the servers list. This avoids a race condition where
// a POST /api/servers immediately followed by GET /api/servers would not
// return the newly created server until background initialization completes.
const combinedServerInfos: ServerInfo[] = [...serverInfos];
const existingNames = new Set(combinedServerInfos.map((s) => s.name));
// Create a set of server names we're interested in (for pagination)
const requestedServerNames = new Set(allServers.map((s) => s.name));
// Filter serverInfos to only include requested servers if pagination is used
const filteredServerInfos = isPaginated
? combinedServerInfos.filter((s) => requestedServerNames.has(s.name))
: combinedServerInfos;
// Add servers from DAO that don't have runtime info yet
for (const server of allServers) {
if (!existingNames.has(server.name)) {
const isEnabled = server.enabled === undefined ? true : server.enabled;
filteredServerInfos.push({
name: server.name,
owner: server.owner,
// Newly created servers that are enabled should appear as "connecting"
// until the MCP client initialization completes. Disabled servers remain
// in the "disconnected" state.
status: isEnabled ? 'connecting' : 'disconnected',
error: null,
tools: [],
prompts: [],
createTime: Date.now(),
enabled: isEnabled,
});
}
}
// Apply user filtering only when NOT using pagination (pagination already filtered at DAO level)
// Or when no pagination parameters provided (backward compatibility)
const shouldApplyUserFilter = !isPaginated;
const filterServerInfos: ServerInfo[] = shouldApplyUserFilter && dataService.filterData
? dataService.filterData(filteredServerInfos, user)
: filteredServerInfos;
const infos = filterServerInfos
.filter((info) => requestedServerNames.has(info.name)) // Only include requested servers
.map(({ name, status, tools, prompts, createTime, error, oauth }) => {
const serverConfig = allServers.find((server) => server.name === name); const serverConfig = allServers.find((server) => server.name === name);
const enabled = serverConfig ? serverConfig.enabled !== false : true; const enabled = serverConfig ? serverConfig.enabled !== false : true;
@@ -665,12 +869,8 @@ export const getServersInfo = async (): Promise<Omit<ServerInfo, 'client' | 'tra
} }
: undefined, : undefined,
}; };
},
);
infos.sort((a, b) => {
if (a.enabled === b.enabled) return 0;
return a.enabled ? -1 : 1;
}); });
// Sorting is now handled at DAO layer for consistent pagination results
return infos; return infos;
}; };
@@ -805,6 +1005,25 @@ export const addOrUpdateServer = async (
} }
}; };
// Check for authentication error in tool call result
function checkAuthError(result: any) {
if (Array.isArray(result.content) && result.content.length > 0) {
const text = result.content[0]?.text;
if (typeof text === 'string') {
let errorContent;
try {
errorContent = JSON.parse(text);
} catch (e) {
// Ignore JSON parse errors and continue
return;
}
if (errorContent.code === 401) {
throw new Error('Error POSTing to endpoint (HTTP 401 Unauthorized)');
}
}
}
}
// Close server client and transport // Close server client and transport
function closeServer(name: string) { function closeServer(name: string) {
const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name); const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name);
@@ -964,23 +1183,14 @@ Available servers: ${serversList}`,
for (const serverInfo of filteredServerInfos) { for (const serverInfo of filteredServerInfos) {
if (serverInfo.tools && serverInfo.tools.length > 0) { if (serverInfo.tools && serverInfo.tools.length > 0) {
// Filter tools based on server configuration // Filter tools based on server configuration
let enabledTools = await filterToolsByConfig(serverInfo.name, serverInfo.tools); let tools = await filterToolsByConfig(serverInfo.name, serverInfo.tools);
// If this is a group request, apply group-level tool filtering // If this is a group request, apply group-level tool filtering
if (group) { tools = await filterToolsByGroup(group, serverInfo.name, tools);
const serverConfig = await getServerConfigInGroup(group, serverInfo.name);
if (serverConfig && serverConfig.tools !== 'all' && Array.isArray(serverConfig.tools)) {
// Filter tools based on group configuration
const allowedToolNames = serverConfig.tools.map(
(toolName: string) => `${serverInfo.name}${getNameSeparator()}${toolName}`,
);
enabledTools = enabledTools.filter((tool) => allowedToolNames.includes(tool.name));
}
}
// Apply custom descriptions from server configuration // Apply custom descriptions from server configuration
const serverConfig = await getServerDao().findById(serverInfo.name); const serverConfig = await getServerDao().findById(serverInfo.name);
const toolsWithCustomDescriptions = enabledTools.map((tool) => { const toolsWithCustomDescriptions = tools.map((tool) => {
const toolConfig = serverConfig?.tools?.[tool.name]; const toolConfig = serverConfig?.tools?.[tool.name];
return { return {
...tool, ...tool,
@@ -1027,12 +1237,15 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
// Determine server filtering based on group // Determine server filtering based on group
const sessionId = extra.sessionId || ''; const sessionId = extra.sessionId || '';
const group = getGroup(sessionId); let group = getGroup(sessionId);
let servers: string[] | undefined = undefined; // No server filtering by default let servers: string[] | undefined = undefined; // No server filtering by default
// If group is in format $smart/{group}, filter servers to that group // If group is in format $smart/{group}, filter servers to that group
if (group?.startsWith('$smart/')) { if (group?.startsWith('$smart/')) {
const targetGroup = group.substring(7); const targetGroup = group.substring(7);
if (targetGroup) {
group = targetGroup;
}
const serversInGroup = await getServersInGroup(targetGroup); const serversInGroup = await getServersInGroup(targetGroup);
if (serversInGroup !== undefined && serversInGroup !== null) { if (serversInGroup !== undefined && serversInGroup !== null) {
servers = serversInGroup; servers = serversInGroup;
@@ -1064,8 +1277,8 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
const actualTool = server.tools.find((tool) => tool.name === result.toolName); const actualTool = server.tools.find((tool) => tool.name === result.toolName);
if (actualTool) { if (actualTool) {
// Check if the tool is enabled in configuration // Check if the tool is enabled in configuration
const enabledTools = await filterToolsByConfig(server.name, [actualTool]); const tools = await filterToolsByConfig(server.name, [actualTool]);
if (enabledTools.length > 0) { if (tools.length > 0) {
// Apply custom description from configuration // Apply custom description from configuration
const serverConfig = await getServerDao().findById(server.name); const serverConfig = await getServerDao().findById(server.name);
const toolConfig = serverConfig?.tools?.[actualTool.name]; const toolConfig = serverConfig?.tools?.[actualTool.name];
@@ -1091,19 +1304,24 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
); );
// Now filter the resolved tools // Now filter the resolved tools
const tools = await Promise.all( const filterResults = await Promise.all(
resolvedTools.filter(async (tool) => { resolvedTools.map(async (tool) => {
// Additional filter to remove tools that are disabled
if (tool.name) { if (tool.name) {
const serverName = tool.serverName; const serverName = tool.serverName;
if (serverName) { if (serverName) {
const enabledTools = await filterToolsByConfig(serverName, [tool as Tool]); let tools = await filterToolsByConfig(serverName, [tool as Tool]);
return enabledTools.length > 0; if (tools.length === 0) {
return false;
}
tools = await filterToolsByGroup(group, serverName, tools);
return tools.length > 0;
} }
} }
return true; // Keep fallback results return true;
}), }),
); );
const tools = resolvedTools.filter((_, i) => filterResults[i]);
// Add usage guidance to the response // Add usage guidance to the response
const response = { const response = {
@@ -1495,3 +1713,18 @@ export const createMcpServer = (name: string, version: string, group?: string):
server.setRequestHandler(ListPromptsRequestSchema, handleListPromptsRequest); server.setRequestHandler(ListPromptsRequestSchema, handleListPromptsRequest);
return server; return server;
}; };
// Filter tools based on group configuration
async function filterToolsByGroup(group: string | undefined, serverName: string, tools: Tool[]) {
if (group) {
const serverConfig = await getServerConfigInGroup(group, serverName);
if (serverConfig && serverConfig.tools !== 'all' && Array.isArray(serverConfig.tools)) {
// Filter tools based on group configuration
const allowedToolNames = serverConfig.tools.map(
(toolName: string) => `${serverName}${getNameSeparator()}${toolName}`,
);
tools = tools.filter((tool) => allowedToolNames.includes(tool.name));
}
}
return tools;
}

View File

@@ -21,7 +21,7 @@ const oauthModel: OAuth2Server.AuthorizationCodeModel & OAuth2Server.RefreshToke
* Get client by client ID * Get client by client ID
*/ */
getClient: async (clientId: string, clientSecret?: string) => { getClient: async (clientId: string, clientSecret?: string) => {
const client = findOAuthClientById(clientId); const client = await findOAuthClientById(clientId);
if (!client) { if (!client) {
return false; return false;
} }
@@ -92,7 +92,7 @@ const oauthModel: OAuth2Server.AuthorizationCodeModel & OAuth2Server.RefreshToke
return false; return false;
} }
const client = findOAuthClientById(code.clientId); const client = await findOAuthClientById(code.clientId);
if (!client) { if (!client) {
return false; return false;
} }
@@ -143,7 +143,7 @@ const oauthModel: OAuth2Server.AuthorizationCodeModel & OAuth2Server.RefreshToke
const scopeString = Array.isArray(token.scope) ? token.scope.join(' ') : token.scope; const scopeString = Array.isArray(token.scope) ? token.scope.join(' ') : token.scope;
const savedToken = saveToken( const savedToken = await saveToken(
{ {
scope: scopeString, scope: scopeString,
clientId: client.id, clientId: client.id,
@@ -172,12 +172,12 @@ const oauthModel: OAuth2Server.AuthorizationCodeModel & OAuth2Server.RefreshToke
* Get access token * Get access token
*/ */
getAccessToken: async (accessToken: string) => { getAccessToken: async (accessToken: string) => {
const token = getToken(accessToken); const token = await getToken(accessToken);
if (!token) { if (!token) {
return false; return false;
} }
const client = findOAuthClientById(token.clientId); const client = await findOAuthClientById(token.clientId);
if (!client) { if (!client) {
return false; return false;
} }
@@ -205,12 +205,12 @@ const oauthModel: OAuth2Server.AuthorizationCodeModel & OAuth2Server.RefreshToke
* Get refresh token * Get refresh token
*/ */
getRefreshToken: async (refreshToken: string) => { getRefreshToken: async (refreshToken: string) => {
const token = getToken(refreshToken); const token = await getToken(refreshToken);
if (!token || token.refreshToken !== refreshToken) { if (!token || token.refreshToken !== refreshToken) {
return false; return false;
} }
const client = findOAuthClientById(token.clientId); const client = await findOAuthClientById(token.clientId);
if (!client) { if (!client) {
return false; return false;
} }
@@ -240,7 +240,7 @@ const oauthModel: OAuth2Server.AuthorizationCodeModel & OAuth2Server.RefreshToke
revokeToken: async (token: OAuth2Server.Token | OAuth2Server.RefreshToken) => { revokeToken: async (token: OAuth2Server.Token | OAuth2Server.RefreshToken) => {
const refreshToken = 'refreshToken' in token ? token.refreshToken : undefined; const refreshToken = 'refreshToken' in token ? token.refreshToken : undefined;
if (refreshToken) { if (refreshToken) {
revokeToken(refreshToken); await revokeToken(refreshToken);
} }
return true; return true;
}, },

View File

@@ -42,7 +42,7 @@ function convertToolSchemaToOpenAPI(tool: Tool): {
(prop: any) => (prop: any) =>
prop.type === 'object' || prop.type === 'object' ||
prop.type === 'array' || prop.type === 'array' ||
(prop.type === 'string' && prop.enum && prop.enum.length > 10), prop.type === 'string',
); );
if (!hasComplexTypes && Object.keys(properties).length <= 10) { if (!hasComplexTypes && Object.keys(properties).length <= 10) {
@@ -93,7 +93,7 @@ function generateOperationFromTool(tool: Tool, serverName: string): OpenAPIV3.Op
const operation: OpenAPIV3.OperationObject = { const operation: OpenAPIV3.OperationObject = {
summary: tool.description || `Execute ${tool.name} tool`, summary: tool.description || `Execute ${tool.name} tool`,
description: tool.description || `Execute the ${tool.name} tool from ${serverName} server`, description: tool.description || `Execute the ${tool.name} tool from ${serverName} server`,
operationId: `${serverName}_${tool.name}`, operationId: `${tool.name}`,
tags: [serverName], tags: [serverName],
...(parameters && parameters.length > 0 && { parameters }), ...(parameters && parameters.length > 0 && { parameters }),
...(requestBody && { requestBody }), ...(requestBody && { requestBody }),

167
src/services/proxy.ts Normal file
View File

@@ -0,0 +1,167 @@
/**
* HTTP/HTTPS proxy configuration utilities for MCP client transports.
*
* This module provides utilities to configure HTTP and HTTPS proxies when
* connecting to MCP servers. Proxies are configured by providing a custom
* fetch implementation that uses Node.js http/https agents with proxy support.
*
*/
import { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js';
/**
* Configuration options for HTTP/HTTPS proxy settings.
*/
export interface ProxyConfig {
/**
* HTTP proxy URL (e.g., 'http://proxy.example.com:8080')
* Can include authentication: 'http://user:pass@proxy.example.com:8080'
*/
httpProxy?: string;
/**
* HTTPS proxy URL (e.g., 'https://proxy.example.com:8443')
* Can include authentication: 'https://user:pass@proxy.example.com:8443'
*/
httpsProxy?: string;
/**
* Comma-separated list of hosts that should bypass the proxy
* (e.g., 'localhost,127.0.0.1,.example.com')
*/
noProxy?: string;
}
/**
* Creates a fetch function that uses the specified proxy configuration.
*
* This function returns a fetch implementation that routes requests through
* the configured HTTP/HTTPS proxies using undici's ProxyAgent.
*
* Note: This function requires the 'undici' package to be installed.
* Install it with: npm install undici
*
* @param config - Proxy configuration options
* @returns A fetch-compatible function configured to use the specified proxies
*
*/
export function createFetchWithProxy(config: ProxyConfig): FetchLike {
// If no proxy is configured, return the default fetch
if (!config.httpProxy && !config.httpsProxy) {
return fetch;
}
// Parse no_proxy list
const noProxyList = parseNoProxy(config.noProxy);
return async (url: string | URL, init?: RequestInit): Promise<Response> => {
const targetUrl = typeof url === 'string' ? new URL(url) : url;
// Check if host should bypass proxy
if (shouldBypassProxy(targetUrl.hostname, noProxyList)) {
return fetch(url, init);
}
// Determine which proxy to use based on protocol
const proxyUrl = targetUrl.protocol === 'https:' ? config.httpsProxy : config.httpProxy;
if (!proxyUrl) {
// No proxy configured for this protocol
return fetch(url, init);
}
// Use undici for proxy support if available
try {
// Dynamic import - undici is an optional peer dependency
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const undici = await import('undici' as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ProxyAgent = (undici as any).ProxyAgent;
const dispatcher = new ProxyAgent(proxyUrl);
return fetch(url, {
...init,
// @ts-expect-error - dispatcher is undici-specific
dispatcher,
});
} catch (error) {
// undici not available - throw error requiring installation
throw new Error(
'Proxy support requires the "undici" package. ' +
'Install it with: npm install undici\n' +
`Original error: ${error instanceof Error ? error.message : String(error)}`,
);
}
};
}
/**
* Parses a NO_PROXY environment variable value into a list of patterns.
*/
function parseNoProxy(noProxy?: string): string[] {
if (!noProxy) {
return [];
}
return noProxy
.split(',')
.map((item) => item.trim())
.filter((item) => item.length > 0);
}
/**
* Checks if a hostname should bypass the proxy based on NO_PROXY patterns.
*/
function shouldBypassProxy(hostname: string, noProxyList: string[]): boolean {
if (noProxyList.length === 0) {
return false;
}
const hostnameLower = hostname.toLowerCase();
for (const pattern of noProxyList) {
const patternLower = pattern.toLowerCase();
// Exact match
if (hostnameLower === patternLower) {
return true;
}
// Domain suffix match (e.g., .example.com matches sub.example.com)
if (patternLower.startsWith('.') && hostnameLower.endsWith(patternLower)) {
return true;
}
// Domain suffix match without leading dot
if (!patternLower.startsWith('.') && hostnameLower.endsWith('.' + patternLower)) {
return true;
}
// Special case: "*" matches everything
if (patternLower === '*') {
return true;
}
}
return false;
}
/**
* Creates a ProxyConfig from environment variables.
*
* This function reads standard proxy environment variables:
* - HTTP_PROXY, http_proxy
* - HTTPS_PROXY, https_proxy
* - NO_PROXY, no_proxy
*
* Lowercase versions take precedence over uppercase versions.
*
* @returns A ProxyConfig object populated from environment variables
*/
export function getProxyConfigFromEnv(env: Record<string, string>): ProxyConfig {
return {
httpProxy: env.http_proxy || env.HTTP_PROXY,
httpsProxy: env.https_proxy || env.HTTPS_PROXY,
noProxy: env.no_proxy || env.NO_PROXY,
};
}

View File

@@ -47,6 +47,30 @@ jest.mock('../dao/index.js', () => ({
getSystemConfigDao: jest.fn(() => ({ getSystemConfigDao: jest.fn(() => ({
get: jest.fn().mockImplementation(() => Promise.resolve(currentSystemConfig)), get: jest.fn().mockImplementation(() => Promise.resolve(currentSystemConfig)),
})), })),
getBearerKeyDao: jest.fn(() => ({
// Keep these unit tests aligned with legacy routing semantics:
// enableBearerAuth + bearerAuthKey -> one enabled key (token=bearerAuthKey)
// otherwise -> no enabled keys (bearer auth effectively disabled)
findEnabled: jest.fn().mockImplementation(async () => {
const routing = (currentSystemConfig as any)?.routing || {};
const enabled = !!routing.enableBearerAuth;
const token = String(routing.bearerAuthKey || '').trim();
if (!enabled || !token) {
return [];
}
return [
{
id: 'test-key-id',
name: 'default',
token,
enabled: true,
accessType: 'all',
allowedGroups: [],
allowedServers: [],
},
];
}),
})),
})); }));
// Mock oauthBearer // Mock oauthBearer

View File

@@ -6,10 +6,10 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
import { deleteMcpServer, getMcpServer } from './mcpService.js'; import { deleteMcpServer, getMcpServer } from './mcpService.js';
import config from '../config/index.js'; import config from '../config/index.js';
import { getSystemConfigDao } from '../dao/index.js'; import { getBearerKeyDao, getGroupDao, getServerDao, getSystemConfigDao } from '../dao/index.js';
import { UserContextService } from './userContextService.js'; import { UserContextService } from './userContextService.js';
import { RequestContextService } from './requestContextService.js'; import { RequestContextService } from './requestContextService.js';
import { IUser } from '../types/index.js'; import { IUser, BearerKey } from '../types/index.js';
import { resolveOAuthUserFromToken } from '../utils/oauthBearer.js'; import { resolveOAuthUserFromToken } from '../utils/oauthBearer.js';
export const transports: { export const transports: {
@@ -30,40 +30,187 @@ type BearerAuthResult =
reason: 'missing' | 'invalid'; reason: 'missing' | 'invalid';
}; };
/**
* Check if a string is a valid UUID v4 format
*/
const isValidUUID = (str: string): boolean => {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidRegex.test(str);
};
const isBearerKeyAllowedForRequest = async (req: Request, key: BearerKey): Promise<boolean> => {
const paramValue = (req.params as any)?.group as string | undefined;
// accessType 'all' allows all requests
if (key.accessType === 'all') {
return true;
}
// No parameter value means global route
if (!paramValue) {
// Only accessType 'all' allows global routes
return false;
}
try {
const groupDao = getGroupDao();
const serverDao = getServerDao();
// Step 1: Try to match as a group (by name or id), since group has higher priority
let matchedGroup = await groupDao.findByName(paramValue);
if (!matchedGroup && isValidUUID(paramValue)) {
// Only try findById if the parameter is a valid UUID
matchedGroup = await groupDao.findById(paramValue);
}
if (matchedGroup) {
// Matched as a group
if (key.accessType === 'groups') {
// For group-scoped keys, check if the matched group is in allowedGroups
const allowedGroups = key.allowedGroups || [];
return allowedGroups.includes(matchedGroup.name) || allowedGroups.includes(matchedGroup.id);
}
if (key.accessType === 'servers') {
// For server-scoped keys, check if any server in the group is allowed
const allowedServers = key.allowedServers || [];
if (allowedServers.length === 0) {
return false;
}
if (!Array.isArray(matchedGroup.servers)) {
return false;
}
const groupServerNames = matchedGroup.servers.map((server) =>
typeof server === 'string' ? server : server.name,
);
return groupServerNames.some((name) => allowedServers.includes(name));
}
if (key.accessType === 'custom') {
// For custom-scoped keys, check if the group is allowed OR if any server in the group is allowed
const allowedGroups = key.allowedGroups || [];
const allowedServers = key.allowedServers || [];
// Check if the group itself is allowed
const groupAllowed =
allowedGroups.includes(matchedGroup.name) || allowedGroups.includes(matchedGroup.id);
if (groupAllowed) {
return true;
}
// Check if any server in the group is allowed
if (allowedServers.length > 0 && Array.isArray(matchedGroup.servers)) {
const groupServerNames = matchedGroup.servers.map((server) =>
typeof server === 'string' ? server : server.name,
);
return groupServerNames.some((name) => allowedServers.includes(name));
}
return false;
}
// Unknown accessType with matched group
return false;
}
// Step 2: Not a group, try to match as a server name
const matchedServer = await serverDao.findById(paramValue);
if (matchedServer) {
// Matched as a server
if (key.accessType === 'groups') {
// For group-scoped keys, server access is not allowed
return false;
}
if (key.accessType === 'servers' || key.accessType === 'custom') {
// For server-scoped or custom-scoped keys, check if the server is in allowedServers
const allowedServers = key.allowedServers || [];
return allowedServers.includes(matchedServer.name);
}
// Unknown accessType with matched server
return false;
}
// Step 3: Not a valid group or server, deny access
console.warn(
`Bearer key access denied: parameter '${paramValue}' does not match any group or server`,
);
return false;
} catch (error) {
console.error('Error checking bearer key request access:', error);
return false;
}
};
const validateBearerAuth = async (req: Request): Promise<BearerAuthResult> => { const validateBearerAuth = async (req: Request): Promise<BearerAuthResult> => {
const systemConfigDao = getSystemConfigDao(); const bearerKeyDao = getBearerKeyDao();
const systemConfig = await systemConfigDao.get(); const enabledKeys = await bearerKeyDao.findEnabled();
const routingConfig = systemConfig?.routing || {
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: false,
bearerAuthKey: '',
};
if (routingConfig.enableBearerAuth) {
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) { const hasBearerHeader = !!authHeader && authHeader.startsWith('Bearer ');
return { valid: false, reason: 'missing' };
// If no enabled keys are configured, bearer auth is effectively disabled.
// We still allow OAuth bearer tokens to attach user context in this case.
if (enabledKeys.length === 0) {
if (!hasBearerHeader) {
return { valid: true };
} }
const token = authHeader.substring(7); // Remove "Bearer " prefix const token = authHeader!.substring(7).trim();
if (token.trim().length === 0) { if (!token) {
return { valid: false, reason: 'missing' };
}
if (token === routingConfig.bearerAuthKey) {
return { valid: true }; return { valid: true };
} }
const oauthUser = await resolveOAuthUserFromToken(token); const oauthUser = await resolveOAuthUserFromToken(token);
if (oauthUser) { if (oauthUser) {
console.log('Authenticated request using OAuth bearer token without configured keys');
return { valid: true, user: oauthUser }; return { valid: true, user: oauthUser };
} }
// When there are no keys, a non-OAuth bearer token should not block access
return { valid: true };
}
// When keys exist, bearer header is required
if (!hasBearerHeader) {
return { valid: false, reason: 'missing' };
}
const token = authHeader!.substring(7).trim();
if (!token) {
return { valid: false, reason: 'missing' };
}
// First, try to match a configured bearer key
const matchingKey = enabledKeys.find((key) => key.token === token);
if (matchingKey) {
const allowed = await isBearerKeyAllowedForRequest(req, matchingKey);
if (!allowed) {
console.warn(
`Bearer key rejected due to scope restrictions: id=${matchingKey.id}, name=${matchingKey.name}, accessType=${matchingKey.accessType}`,
);
return { valid: false, reason: 'invalid' }; return { valid: false, reason: 'invalid' };
} }
console.log(
`Bearer key authenticated: id=${matchingKey.id}, name=${matchingKey.name}, accessType=${matchingKey.accessType}`,
);
return { valid: true }; return { valid: true };
}
// Fallback: treat token as potential OAuth access token
const oauthUser = await resolveOAuthUserFromToken(token);
if (oauthUser) {
console.log('Authenticated request using OAuth bearer token (no matching static key)');
return { valid: true, user: oauthUser };
}
console.warn('Bearer authentication failed: token did not match any key or OAuth user');
return { valid: false, reason: 'invalid' };
}; };
const attachUserContextFromBearer = (result: BearerAuthResult, res: Response): void => { const attachUserContextFromBearer = (result: BearerAuthResult, res: Response): void => {
@@ -398,9 +545,9 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
// Get filtered settings based on user context (after setting user context) // Get filtered settings based on user context (after setting user context)
const systemConfigDao = getSystemConfigDao(); const systemConfigDao = getSystemConfigDao();
const systemConfig = await systemConfigDao.get(); const systemConfig = await systemConfigDao.get();
const routingConfig = systemConfig?.routing || { const routingConfig = {
enableGlobalRoute: true, enableGlobalRoute: systemConfig?.routing?.enableGlobalRoute ?? true,
enableGroupNameRoute: true, enableGroupNameRoute: systemConfig?.routing?.enableGroupNameRoute ?? true,
}; };
if (!group && !routingConfig.enableGlobalRoute) { if (!group && !routingConfig.enableGlobalRoute) {
res.status(403).send('Global routes are disabled. Please specify a group ID.'); res.status(403).send('Global routes are disabled. Please specify a group ID.');

View File

@@ -1,13 +1,13 @@
import { getRepositoryFactory } from '../db/index.js'; import { getRepositoryFactory } from '../db/index.js';
import { VectorEmbeddingRepository } from '../db/repositories/index.js'; import { VectorEmbeddingRepository } from '../db/repositories/index.js';
import { Tool } from '../types/index.js'; import { Tool } from '../types/index.js';
import { getAppDataSource, initializeDatabase } from '../db/connection.js'; import { getAppDataSource, isDatabaseConnected, initializeDatabase } from '../db/connection.js';
import { getSmartRoutingConfig } from '../utils/smartRouting.js'; import { getSmartRoutingConfig } from '../utils/smartRouting.js';
import OpenAI from 'openai'; import OpenAI from 'openai';
// Get OpenAI configuration from smartRouting settings or fallback to environment variables // Get OpenAI configuration from smartRouting settings or fallback to environment variables
const getOpenAIConfig = () => { const getOpenAIConfig = async () => {
const smartRoutingConfig = getSmartRoutingConfig(); const smartRoutingConfig = await getSmartRoutingConfig();
return { return {
apiKey: smartRoutingConfig.openaiApiKey, apiKey: smartRoutingConfig.openaiApiKey,
baseURL: smartRoutingConfig.openaiApiBaseUrl, baseURL: smartRoutingConfig.openaiApiBaseUrl,
@@ -34,8 +34,8 @@ const getDimensionsForModel = (model: string): number => {
}; };
// Initialize the OpenAI client with smartRouting configuration // Initialize the OpenAI client with smartRouting configuration
const getOpenAIClient = () => { const getOpenAIClient = async () => {
const config = getOpenAIConfig(); const config = await getOpenAIConfig();
return new OpenAI({ return new OpenAI({
apiKey: config.apiKey, // Get API key from smartRouting settings or environment variables apiKey: config.apiKey, // Get API key from smartRouting settings or environment variables
baseURL: config.baseURL, // Get base URL from smartRouting settings or fallback to default baseURL: config.baseURL, // Get base URL from smartRouting settings or fallback to default
@@ -53,9 +53,8 @@ const getOpenAIClient = () => {
* @returns Promise with vector embedding as number array * @returns Promise with vector embedding as number array
*/ */
async function generateEmbedding(text: string): Promise<number[]> { async function generateEmbedding(text: string): Promise<number[]> {
try { const config = await getOpenAIConfig();
const config = getOpenAIConfig(); const openai = await getOpenAIClient();
const openai = getOpenAIClient();
// Check if API key is configured // Check if API key is configured
if (!openai.apiKey) { if (!openai.apiKey) {
@@ -74,11 +73,6 @@ async function generateEmbedding(text: string): Promise<number[]> {
// Return the embedding // Return the embedding
return response.data[0].embedding; return response.data[0].embedding;
} catch (error) {
console.error('Error generating embedding:', error);
console.warn('Falling back to simple embedding method');
return generateFallbackEmbedding(text);
}
} }
/** /**
@@ -198,12 +192,18 @@ export const saveToolsAsVectorEmbeddings = async (
return; return;
} }
const smartRoutingConfig = getSmartRoutingConfig(); const smartRoutingConfig = await getSmartRoutingConfig();
if (!smartRoutingConfig.enabled) { if (!smartRoutingConfig.enabled) {
return; return;
} }
const config = getOpenAIConfig(); // Ensure database is initialized before using repository
if (!isDatabaseConnected()) {
console.info('Database not initialized, initializing...');
await initializeDatabase();
}
const config = await getOpenAIConfig();
const vectorRepository = getRepositoryFactory( const vectorRepository = getRepositoryFactory(
'vectorEmbeddings', 'vectorEmbeddings',
)() as VectorEmbeddingRepository; )() as VectorEmbeddingRepository;
@@ -227,7 +227,6 @@ export const saveToolsAsVectorEmbeddings = async (
.filter(Boolean) .filter(Boolean)
.join(' '); .join(' ');
try {
// Generate embedding // Generate embedding
const embedding = await generateEmbedding(searchableText); const embedding = await generateEmbedding(searchableText);
@@ -248,15 +247,11 @@ export const saveToolsAsVectorEmbeddings = async (
}, },
config.embeddingModel, // Store the model used for this embedding config.embeddingModel, // Store the model used for this embedding
); );
} catch (toolError) {
console.error(`Error processing tool ${tool.name} for server ${serverName}:`, toolError);
// Continue with the next tool rather than failing the whole batch
}
} }
console.log(`Saved ${tools.length} tool embeddings for server: ${serverName}`); console.log(`Saved ${tools.length} tool embeddings for server: ${serverName}`);
} catch (error) { } catch (error) {
console.error(`Error saving tool embeddings for server ${serverName}:`, error); console.error(`Error saving tool embeddings for server ${serverName}:${error}`);
} }
}; };
@@ -381,7 +376,7 @@ export const getAllVectorizedTools = async (
}> }>
> => { > => {
try { try {
const config = getOpenAIConfig(); const config = await getOpenAIConfig();
const vectorRepository = getRepositoryFactory( const vectorRepository = getRepositoryFactory(
'vectorEmbeddings', 'vectorEmbeddings',
)() as VectorEmbeddingRepository; )() as VectorEmbeddingRepository;

View File

@@ -243,6 +243,19 @@ export interface OAuthServerConfig {
}; };
} }
// Bearer authentication key configuration
export type BearerKeyAccessType = 'all' | 'groups' | 'servers' | 'custom';
export interface BearerKey {
id: string; // Unique identifier for the key
name: string; // Human readable key name
token: string; // Bearer token value
enabled: boolean; // Whether this key is enabled
accessType: BearerKeyAccessType; // Access scope type
allowedGroups?: string[]; // Allowed group names when accessType === 'groups' or 'custom'
allowedServers?: string[]; // Allowed server names when accessType === 'servers' or 'custom'
}
// Represents the settings for MCP servers // Represents the settings for MCP servers
export interface McpSettings { export interface McpSettings {
users?: IUser[]; // Array of user credentials and permissions users?: IUser[]; // Array of user credentials and permissions
@@ -254,6 +267,18 @@ export interface McpSettings {
userConfigs?: Record<string, UserConfig>; // User-specific configurations userConfigs?: Record<string, UserConfig>; // User-specific configurations
oauthClients?: IOAuthClient[]; // OAuth clients for MCPHub's authorization server oauthClients?: IOAuthClient[]; // OAuth clients for MCPHub's authorization server
oauthTokens?: IOAuthToken[]; // Persisted OAuth tokens (access + refresh) for authorization server oauthTokens?: IOAuthToken[]; // Persisted OAuth tokens (access + refresh) for authorization server
bearerKeys?: BearerKey[]; // Bearer authentication keys (multi-key configuration)
}
// Proxychains4 configuration for STDIO servers (Linux/macOS only)
export interface ProxychainsConfig {
enabled?: boolean; // Enable/disable proxychains4 proxy routing
type?: 'socks4' | 'socks5' | 'http'; // Proxy protocol type
host?: string; // Proxy server hostname or IP address
port?: number; // Proxy server port
username?: string; // Proxy authentication username (optional)
password?: string; // Proxy authentication password (optional)
configPath?: string; // Path to custom proxychains4 configuration file (optional, overrides above settings)
} }
// Configuration details for an individual server // Configuration details for an individual server
@@ -271,6 +296,8 @@ export interface ServerConfig {
tools?: Record<string, { enabled: boolean; description?: string }>; // Tool-specific configurations with enable/disable state and custom descriptions 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 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 options?: Partial<Pick<RequestOptions, 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout'>>; // MCP request options configuration
// Proxychains4 proxy configuration for STDIO servers (Linux/macOS only, Windows not supported)
proxy?: ProxychainsConfig;
// OAuth authentication for upstream MCP servers // OAuth authentication for upstream MCP servers
oauth?: { oauth?: {
// Static client configuration (traditional OAuth flow) // Static client configuration (traditional OAuth flow)
@@ -420,3 +447,50 @@ export interface AddServerRequest {
name: string; // Name of the server to add name: string; // Name of the server to add
config: ServerConfig; // Configuration details for the server config: ServerConfig; // Configuration details for the server
} }
// Request payload for batch creating servers
export interface BatchCreateServersRequest {
servers: AddServerRequest[]; // Array of servers to create
}
// Result for a single server in batch operation
export interface BatchServerResult {
name: string; // Server name
success: boolean; // Whether the operation succeeded
message?: string; // Error message if failed
}
// Response for batch create servers operation
export interface BatchCreateServersResponse {
success: boolean; // Overall operation success (true if at least one server succeeded)
successCount: number; // Number of servers successfully created
failureCount: number; // Number of servers that failed
results: BatchServerResult[]; // Detailed results for each server
}
// Request payload for adding a new group
export interface AddGroupRequest {
name: string; // Name of the group to add
description?: string; // Optional description of the group
servers?: string[] | IGroupServerConfig[]; // Array of server names or server configurations
}
// Request payload for batch creating groups
export interface BatchCreateGroupsRequest {
groups: AddGroupRequest[]; // Array of groups to create
}
// Result for a single group in batch operation
export interface BatchGroupResult {
name: string; // Group name
success: boolean; // Whether the operation succeeded
message?: string; // Error message if failed
}
// Response for batch create groups operation
export interface BatchCreateGroupsResponse {
success: boolean; // Overall operation success (true if at least one group succeeded)
successCount: number; // Number of groups successfully created
failureCount: number; // Number of groups that failed
results: BatchGroupResult[]; // Detailed results for each group
}

122
src/utils/migration.test.ts Normal file
View File

@@ -0,0 +1,122 @@
import { jest } from '@jest/globals';
// Mocks must be defined before importing the module under test.
const initializeDatabaseMock = jest.fn(async () => undefined);
jest.mock('../db/connection.js', () => ({
initializeDatabase: initializeDatabaseMock,
}));
const setDaoFactoryMock = jest.fn();
jest.mock('../dao/DaoFactory.js', () => ({
setDaoFactory: setDaoFactoryMock,
}));
jest.mock('../dao/DatabaseDaoFactory.js', () => ({
DatabaseDaoFactory: {
getInstance: jest.fn(() => ({
/* noop */
})),
},
}));
const loadOriginalSettingsMock = jest.fn(() => ({ users: [] }));
jest.mock('../config/index.js', () => ({
loadOriginalSettings: loadOriginalSettingsMock,
}));
const userRepoCountMock = jest.fn<() => Promise<number>>();
jest.mock('../db/repositories/UserRepository.js', () => ({
UserRepository: jest.fn().mockImplementation(() => ({
count: userRepoCountMock,
})),
}));
const bearerKeyCountMock = jest.fn<() => Promise<number>>();
const bearerKeyCreateMock =
jest.fn<
(data: {
name: string;
token: string;
enabled: boolean;
accessType: string;
allowedGroups: string[];
allowedServers: string[];
}) => Promise<unknown>
>();
jest.mock('../db/repositories/BearerKeyRepository.js', () => ({
BearerKeyRepository: jest.fn().mockImplementation(() => ({
count: bearerKeyCountMock,
create: bearerKeyCreateMock,
})),
}));
const systemConfigGetMock = jest.fn<() => Promise<any>>();
jest.mock('../db/repositories/SystemConfigRepository.js', () => ({
SystemConfigRepository: jest.fn().mockImplementation(() => ({
get: systemConfigGetMock,
})),
}));
describe('initializeDatabaseMode legacy bearer auth migration', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('skips legacy migration when bearerKeys table already has data', async () => {
userRepoCountMock.mockResolvedValue(1);
bearerKeyCountMock.mockResolvedValue(2);
systemConfigGetMock.mockResolvedValue({
routing: { enableBearerAuth: true, bearerAuthKey: 'db-key' },
});
const { initializeDatabaseMode } = await import('./migration.js');
const ok = await initializeDatabaseMode();
expect(ok).toBe(true);
expect(initializeDatabaseMock).toHaveBeenCalled();
expect(loadOriginalSettingsMock).not.toHaveBeenCalled();
expect(systemConfigGetMock).not.toHaveBeenCalled();
expect(bearerKeyCreateMock).not.toHaveBeenCalled();
});
it('migrates legacy routing bearerAuthKey into bearerKeys when users exist and keys table is empty', async () => {
userRepoCountMock.mockResolvedValue(3);
bearerKeyCountMock.mockResolvedValue(0);
systemConfigGetMock.mockResolvedValue({
routing: { enableBearerAuth: true, bearerAuthKey: 'db-key' },
});
const { initializeDatabaseMode } = await import('./migration.js');
const ok = await initializeDatabaseMode();
expect(ok).toBe(true);
expect(loadOriginalSettingsMock).not.toHaveBeenCalled();
expect(systemConfigGetMock).toHaveBeenCalledTimes(1);
expect(bearerKeyCreateMock).toHaveBeenCalledTimes(1);
expect(bearerKeyCreateMock).toHaveBeenCalledWith({
name: 'default',
token: 'db-key',
enabled: true,
accessType: 'all',
allowedGroups: [],
allowedServers: [],
});
});
it('does not migrate when routing has no bearerAuthKey', async () => {
userRepoCountMock.mockResolvedValue(1);
bearerKeyCountMock.mockResolvedValue(0);
systemConfigGetMock.mockResolvedValue({
routing: { enableBearerAuth: true, bearerAuthKey: ' ' },
});
const { initializeDatabaseMode } = await import('./migration.js');
const ok = await initializeDatabaseMode();
expect(ok).toBe(true);
expect(loadOriginalSettingsMock).not.toHaveBeenCalled();
expect(systemConfigGetMock).toHaveBeenCalledTimes(1);
expect(bearerKeyCreateMock).not.toHaveBeenCalled();
});
});

View File

@@ -7,6 +7,9 @@ import { ServerRepository } from '../db/repositories/ServerRepository.js';
import { GroupRepository } from '../db/repositories/GroupRepository.js'; import { GroupRepository } from '../db/repositories/GroupRepository.js';
import { SystemConfigRepository } from '../db/repositories/SystemConfigRepository.js'; import { SystemConfigRepository } from '../db/repositories/SystemConfigRepository.js';
import { UserConfigRepository } from '../db/repositories/UserConfigRepository.js'; import { UserConfigRepository } from '../db/repositories/UserConfigRepository.js';
import { OAuthClientRepository } from '../db/repositories/OAuthClientRepository.js';
import { OAuthTokenRepository } from '../db/repositories/OAuthTokenRepository.js';
import { BearerKeyRepository } from '../db/repositories/BearerKeyRepository.js';
/** /**
* Migrate from file-based configuration to database * Migrate from file-based configuration to database
@@ -29,6 +32,9 @@ export async function migrateToDatabase(): Promise<boolean> {
const groupRepo = new GroupRepository(); const groupRepo = new GroupRepository();
const systemConfigRepo = new SystemConfigRepository(); const systemConfigRepo = new SystemConfigRepository();
const userConfigRepo = new UserConfigRepository(); const userConfigRepo = new UserConfigRepository();
const oauthClientRepo = new OAuthClientRepository();
const oauthTokenRepo = new OAuthTokenRepository();
const bearerKeyRepo = new BearerKeyRepository();
// Migrate users // Migrate users
if (settings.users && settings.users.length > 0) { if (settings.users && settings.users.length > 0) {
@@ -71,6 +77,7 @@ export async function migrateToDatabase(): Promise<boolean> {
prompts: config.prompts, prompts: config.prompts,
options: config.options, options: config.options,
oauth: config.oauth, oauth: config.oauth,
openapi: config.openapi,
}); });
console.log(` - Created server: ${name}`); console.log(` - Created server: ${name}`);
} else { } else {
@@ -115,6 +122,52 @@ export async function migrateToDatabase(): Promise<boolean> {
console.log(' - System configuration updated'); console.log(' - System configuration updated');
} }
// Migrate bearer auth keys
console.log('Migrating bearer authentication keys...');
// Prefer explicit bearerKeys if present in settings
if (Array.isArray(settings.bearerKeys) && settings.bearerKeys.length > 0) {
for (const key of settings.bearerKeys) {
await bearerKeyRepo.create({
name: key.name,
token: key.token,
enabled: key.enabled,
accessType: key.accessType,
allowedGroups: key.allowedGroups ?? [],
allowedServers: key.allowedServers ?? [],
} as any);
console.log(` - Migrated bearer key: ${key.name} (${key.id ?? 'no-id'})`);
}
} else if (settings.systemConfig?.routing) {
// Fallback to legacy routing.enableBearerAuth / bearerAuthKey
const routing = settings.systemConfig.routing as any;
const enableBearerAuth: boolean = !!routing.enableBearerAuth;
const rawKey: string = (routing.bearerAuthKey || '').trim();
// Migration rules:
// 1) enable=false, key empty -> no keys
// 2) enable=false, key present -> one disabled key (name=default)
// 3) enable=true, key present -> one enabled key (name=default)
// 4) enable=true, key empty -> no keys
if (rawKey) {
await bearerKeyRepo.create({
name: 'default',
token: rawKey,
enabled: enableBearerAuth,
accessType: 'all',
allowedGroups: [],
allowedServers: [],
} as any);
console.log(
` - Migrated legacy bearer auth config to key: default (enabled=${enableBearerAuth})`,
);
} else {
console.log(' - No legacy bearer auth key found, skipping bearer key migration');
}
} else {
console.log(' - No bearer auth configuration found, skipping bearer key migration');
}
// Migrate user configs // Migrate user configs
if (settings.userConfigs) { if (settings.userConfigs) {
const usernames = Object.keys(settings.userConfigs); const usernames = Object.keys(settings.userConfigs);
@@ -129,6 +182,53 @@ export async function migrateToDatabase(): Promise<boolean> {
} }
} }
// Migrate OAuth clients
if (settings.oauthClients && settings.oauthClients.length > 0) {
console.log(`Migrating ${settings.oauthClients.length} OAuth clients...`);
for (const client of settings.oauthClients) {
const exists = await oauthClientRepo.exists(client.clientId);
if (!exists) {
await oauthClientRepo.create({
clientId: client.clientId,
clientSecret: client.clientSecret,
name: client.name,
redirectUris: client.redirectUris,
grants: client.grants,
scopes: client.scopes,
owner: client.owner,
metadata: client.metadata,
});
console.log(` - Created OAuth client: ${client.clientId}`);
} else {
console.log(` - OAuth client already exists: ${client.clientId}`);
}
}
}
// Migrate OAuth tokens
if (settings.oauthTokens && settings.oauthTokens.length > 0) {
console.log(`Migrating ${settings.oauthTokens.length} OAuth tokens...`);
for (const token of settings.oauthTokens) {
const exists = await oauthTokenRepo.exists(token.accessToken);
if (!exists) {
await oauthTokenRepo.create({
accessToken: token.accessToken,
refreshToken: token.refreshToken,
accessTokenExpiresAt: new Date(token.accessTokenExpiresAt),
refreshTokenExpiresAt: token.refreshTokenExpiresAt
? new Date(token.refreshTokenExpiresAt)
: undefined,
scope: token.scope,
clientId: token.clientId,
username: token.username,
});
console.log(` - Created OAuth token for client: ${token.clientId}`);
} else {
console.log(` - OAuth token already exists: ${token.accessToken.substring(0, 8)}...`);
}
}
}
console.log('✅ Migration completed successfully'); console.log('✅ Migration completed successfully');
return true; return true;
} catch (error) { } catch (error) {
@@ -155,6 +255,9 @@ export async function initializeDatabaseMode(): Promise<boolean> {
// Check if migration is needed // Check if migration is needed
const userRepo = new UserRepository(); const userRepo = new UserRepository();
const bearerKeyRepo = new BearerKeyRepository();
const systemConfigRepo = new SystemConfigRepository();
const userCount = await userRepo.count(); const userCount = await userRepo.count();
if (userCount === 0) { if (userCount === 0) {
@@ -165,6 +268,36 @@ export async function initializeDatabaseMode(): Promise<boolean> {
} }
} else { } else {
console.log(`Database already contains ${userCount} users, skipping migration`); console.log(`Database already contains ${userCount} users, skipping migration`);
// One-time migration for legacy bearer auth config stored inside DB routing settings.
// If bearerKeys table already has data, do nothing.
const bearerKeyCount = await bearerKeyRepo.count();
if (bearerKeyCount > 0) {
console.log(
`Bearer keys table already contains ${bearerKeyCount} keys, skipping legacy bearer auth migration`,
);
} else {
const systemConfig = await systemConfigRepo.get();
const routing = (systemConfig as any)?.routing || {};
const enableBearerAuth: boolean = !!routing.enableBearerAuth;
const rawKey: string = (routing.bearerAuthKey || '').trim();
if (rawKey) {
await bearerKeyRepo.create({
name: 'default',
token: rawKey,
enabled: enableBearerAuth,
accessType: 'all',
allowedGroups: [],
allowedServers: [],
} as any);
console.log(
` - Migrated legacy DB routing bearer auth config to key: default (enabled=${enableBearerAuth})`,
);
} else {
console.log('No legacy DB routing bearer auth key found, skipping bearer key migration');
}
}
} }
console.log('✅ Database mode initialized successfully'); console.log('✅ Database mode initialized successfully');

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