Compare commits

...

24 Commits

Author SHA1 Message Date
samanhappy
6b3a077a67 Enhance server data handling with allServers (#553) 2026-01-07 22:10:44 +08:00
samanhappy
3de56b30bd fix: update readonlyAllowPaths for correct authorization handling (#550) 2026-01-04 13:06:27 +08:00
Bryan Thompson
6a08f4bc5a feat: Add tool annotations for improved LLM tool understanding (#549)
Co-authored-by: triepod-ai <199543909+triepod-ai@users.noreply.github.com>
2026-01-03 10:20:17 +08:00
samanhappy
ef1bc0d305 fix: add localnet configuration for Proxychains4 (#547) 2026-01-02 13:42:00 +03:00
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
66 changed files with 2477 additions and 1174 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:
# postgres:
# image: postgres:15
# image: pgvector/pgvector:pg17
# env:
# POSTGRES_PASSWORD: postgres
# POSTGRES_DB: mcphub_test
@@ -106,7 +106,7 @@ jobs:
# - name: Run integration tests
# 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
# env:
# NODE_ENV: test

386
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
- Backend services live in `src`, grouped by responsibility (`controllers/`, `services/`, `dao/`, `routes/`, `utils/`), with `server.ts` orchestrating HTTP bootstrap.
- `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.
- Build artifacts and bundles are generated into `dist/`, `frontend/dist/`, and `coverage/`; never edit these manually.
### Critical Backend Files
- `src/index.ts` - Application entry point
- `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
- `pnpm dev` runs backend (`tsx watch src/index.ts`) and frontend (`vite`) together for local iteration.
- `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.
- `pnpm test`, `pnpm test:watch`, and `pnpm test:coverage` drive Jest; `pnpm lint` and `pnpm format` enforce style via ESLint and Prettier.
### 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
# 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
- TypeScript everywhere; default to 2-space indentation and single quotes, letting Prettier settle formatting. ESLint configuration assumes ES modules.
- Name services and data access layers with suffixes (`UserService`, `AuthDao`), React components and files in `PascalCase`, and utility modules in `camelCase`.
- Keep DTOs and shared types in `src/types` to avoid duplication; re-export through index files only when it clarifies imports.
- **TypeScript everywhere**: Default to 2-space indentation and single quotes, letting Prettier settle formatting
- **ESM modules**: Always use `.js` extensions in imports, not `.ts` (e.g., `import { something } from './file.js'`)
- **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
@@ -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.
- 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
- 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.
- 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
MCPHub supports **JSON file** (default) and **PostgreSQL** storage. Set `USE_DB=true` + `DB_URL` to switch.
@@ -63,16 +325,100 @@ When adding/changing fields, update **ALL** these files:
### Data Type Mapping
| Model | DAO | DB Entity | JSON Path |
| -------------- | ----------------- | -------------- | ------------------------ |
| `IUser` | `UserDao` | `User` | `settings.users[]` |
| `ServerConfig` | `ServerDao` | `Server` | `settings.mcpServers{}` |
| `IGroup` | `GroupDao` | `Group` | `settings.groups[]` |
| `SystemConfig` | `SystemConfigDao` | `SystemConfig` | `settings.systemConfig` |
| `UserConfig` | `UserConfigDao` | `UserConfig` | `settings.userConfigs{}` |
| Model | DAO | DB Entity | JSON Path |
| -------------- | ----------------- | -------------- | ------------------------- |
| `IUser` | `UserDao` | `User` | `settings.users[]` |
| `ServerConfig` | `ServerDao` | `Server` | `settings.mcpServers{}` |
| `IGroup` | `GroupDao` | `Group` | `settings.groups[]` |
| `SystemConfig` | `SystemConfigDao` | `SystemConfig` | `settings.systemConfig` |
| `UserConfig` | `UserConfigDao` | `UserConfig` | `settings.userConfigs{}` |
| `BearerKey` | `BearerKeyDao` | `BearerKey` | `settings.bearerKeys[]` |
| `IOAuthClient` | `OAuthClientDao` | `OAuthClient` | `settings.oauthClients[]` |
| `IOAuthToken` | `OAuthTokenDao` | `OAuthToken` | `settings.oauthTokens[]` |
### Common Pitfalls
- Forgetting migration script → fields won't migrate to DB
- Optional fields need `nullable: true` in entity
- 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

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

View File

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

View File

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

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
#### Local Python Server

View File

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

View File

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

View File

@@ -96,7 +96,7 @@ Optional for Smart Routing:
# Optional: PostgreSQL for Smart Routing
postgres:
image: pgvector/pgvector:pg16
image: pgvector/pgvector:pg17
environment:
POSTGRES_DB: mcphub
POSTGRES_USER: mcphub
@@ -445,7 +445,7 @@ Set the following environment variables:
```bash
# 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_KEY=your_openai_api_key
@@ -563,10 +563,10 @@ curl -X POST http://localhost:3000/mcp \
**Database connection failed:**
```bash
# Test database connection
psql $DATABASE_URL -c "SELECT 1;"
psql $DB_URL -c "SELECT 1;"
# 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:**

View File

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

View File

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

View File

@@ -290,7 +290,7 @@ MCPHub 支持使用 `${VAR_NAME}` 语法进行环境变量替换:
"command": "python",
"args": ["-m", "db_server"],
"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
# 数据库配置
DATABASE_URL=postgresql://username:password@localhost:5432/mcphub
DB_URL=postgresql://username:password@localhost:5432/mcphub
# JWT 配置
JWT_SECRET=your-secret-key

View File

@@ -480,7 +480,7 @@ docker run -d \
--name mcphub \
-p 3000:3000 \
-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 \
mcphub/server:latest
@@ -504,7 +504,7 @@ docker run -d \
--name mcphub \
-p 3000:3000 \
-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 \
mcphub/server:latest

View File

@@ -159,7 +159,7 @@ services:
- '3000:3000'
environment:
- 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'
environment:
- 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 是否正在运行
2. 检查 DATABASE_URL 格式
2. 检查 DB_URL 格式
3. 确保安装了 pgvector 扩展
4. 手动测试连接:
```bash
psql $DATABASE_URL -c "SELECT 1;"
psql $DB_URL -c "SELECT 1;"
```
</Accordion>

View File

@@ -96,7 +96,7 @@ description: '各种平台的详细安装说明'
# 可选:用于智能路由的 PostgreSQL
postgres:
image: pgvector/pgvector:pg16
image: pgvector/pgvector:pg17
environment:
POSTGRES_DB: mcphub
POSTGRES_USER: mcphub
@@ -420,7 +420,7 @@ description: '各种平台的详细安装说明'
```bash
# 数据库连接
DATABASE_URL=postgresql://mcphub:your_password@localhost:5432/mcphub
DB_URL=postgresql://mcphub:your_password@localhost:5432/mcphub
# 用于嵌入的 OpenAI API
OPENAI_API_KEY=your_openai_api_key
@@ -538,10 +538,10 @@ curl -X POST http://localhost:3000/mcp \
**数据库连接失败:**
```bash
# 测试数据库连接
psql $DATABASE_URL -c "SELECT 1;"
psql $DB_URL -c "SELECT 1;"
# 检查是否安装了 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": {
"API_KEY": "${MY_API_KEY}",
"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": {
@@ -55,7 +96,10 @@
"clientId": "${OAUTH_CLIENT_ID}",
"clientSecret": "${OAUTH_CLIENT_SECRET}",
"accessToken": "${OAUTH_ACCESS_TOKEN}",
"scopes": ["read", "write"]
"scopes": [
"read",
"write"
]
}
}
},
@@ -77,4 +121,4 @@
"baseUrl": "${MCPROUTER_BASE_URL}"
}
}
}
}

View File

@@ -1,67 +1,67 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useGroupData } from '@/hooks/useGroupData'
import { useServerData } from '@/hooks/useServerData'
import { GroupFormData, Server, IGroupServerConfig } from '@/types'
import { ServerToolConfig } from './ServerToolConfig'
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useGroupData } from '@/hooks/useGroupData';
import { useServerData } from '@/hooks/useServerData';
import { GroupFormData, Server, IGroupServerConfig } from '@/types';
import { ServerToolConfig } from './ServerToolConfig';
interface AddGroupFormProps {
onAdd: () => void
onCancel: () => void
onAdd: () => void;
onCancel: () => void;
}
const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
const { t } = useTranslation()
const { createGroup } = useGroupData()
const { servers } = useServerData()
const [availableServers, setAvailableServers] = useState<Server[]>([])
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const { t } = useTranslation();
const { createGroup } = useGroupData();
const { allServers } = useServerData();
const [availableServers, setAvailableServers] = useState<Server[]>([]);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState<GroupFormData>({
name: '',
description: '',
servers: [] as IGroupServerConfig[]
})
servers: [] as IGroupServerConfig[],
});
useEffect(() => {
// Filter available servers (enabled only)
setAvailableServers(servers.filter(server => server.enabled !== false))
}, [servers])
setAvailableServers(allServers.filter((server) => server.enabled !== false));
}, [allServers]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target
setFormData(prev => ({
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value
}))
}
[name]: value,
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
setError(null)
e.preventDefault();
setIsSubmitting(true);
setError(null);
try {
if (!formData.name.trim()) {
setError(t('groups.nameRequired'))
setIsSubmitting(false)
return
setError(t('groups.nameRequired'));
setIsSubmitting(false);
return;
}
const result = await createGroup(formData.name, formData.description, formData.servers)
const result = await createGroup(formData.name, formData.description, formData.servers);
if (!result || !result.success) {
setError(result?.message || t('groups.createError'))
setIsSubmitting(false)
return
setError(result?.message || t('groups.createError'));
setIsSubmitting(false);
return;
}
onAdd()
onAdd();
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
setIsSubmitting(false)
setError(err instanceof Error ? err.message : String(err));
setIsSubmitting(false);
}
}
};
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
@@ -102,7 +102,7 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
<ServerToolConfig
servers={availableServers}
value={formData.servers as IGroupServerConfig[]}
onChange={(servers) => setFormData(prev => ({ ...prev, servers }))}
onChange={(servers) => setFormData((prev) => ({ ...prev, servers }))}
className="border border-gray-200 rounded-lg p-4 bg-gray-50"
/>
</div>
@@ -129,7 +129,7 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
</form>
</div>
</div>
)
}
);
};
export default AddGroupForm
export default AddGroupForm;

View File

@@ -1,73 +1,73 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Group, GroupFormData, Server, IGroupServerConfig } from '@/types'
import { useGroupData } from '@/hooks/useGroupData'
import { useServerData } from '@/hooks/useServerData'
import { ServerToolConfig } from './ServerToolConfig'
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Group, GroupFormData, Server, IGroupServerConfig } from '@/types';
import { useGroupData } from '@/hooks/useGroupData';
import { useServerData } from '@/hooks/useServerData';
import { ServerToolConfig } from './ServerToolConfig';
interface EditGroupFormProps {
group: Group
onEdit: () => void
onCancel: () => void
group: Group;
onEdit: () => void;
onCancel: () => void;
}
const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
const { t } = useTranslation()
const { updateGroup } = useGroupData()
const { servers } = useServerData()
const [availableServers, setAvailableServers] = useState<Server[]>([])
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const { t } = useTranslation();
const { updateGroup } = useGroupData();
const { allServers } = useServerData();
const [availableServers, setAvailableServers] = useState<Server[]>([]);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState<GroupFormData>({
name: group.name,
description: group.description || '',
servers: group.servers || []
})
servers: group.servers || [],
});
useEffect(() => {
// Filter available servers (enabled only)
setAvailableServers(servers.filter(server => server.enabled !== false))
}, [servers])
setAvailableServers(allServers.filter((server) => server.enabled !== false));
}, [allServers]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target
setFormData(prev => ({
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value
}))
}
[name]: value,
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
setError(null)
e.preventDefault();
setIsSubmitting(true);
setError(null);
try {
if (!formData.name.trim()) {
setError(t('groups.nameRequired'))
setIsSubmitting(false)
return
setError(t('groups.nameRequired'));
setIsSubmitting(false);
return;
}
const result = await updateGroup(group.id, {
name: formData.name,
description: formData.description,
servers: formData.servers
})
servers: formData.servers,
});
if (!result || !result.success) {
setError(result?.message || t('groups.updateError'))
setIsSubmitting(false)
return
setError(result?.message || t('groups.updateError'));
setIsSubmitting(false);
return;
}
onEdit()
onEdit();
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
setIsSubmitting(false)
setError(err instanceof Error ? err.message : String(err));
setIsSubmitting(false);
}
}
};
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
@@ -108,7 +108,7 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
<ServerToolConfig
servers={availableServers}
value={formData.servers as IGroupServerConfig[]}
onChange={(servers) => setFormData(prev => ({ ...prev, servers }))}
onChange={(servers) => setFormData((prev) => ({ ...prev, servers }))}
className="border border-gray-200 rounded-lg p-4 bg-gray-50"
/>
</div>
@@ -135,7 +135,7 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
</form>
</div>
</div>
)
}
);
};
export default EditGroupForm
export default EditGroupForm;

View File

@@ -18,7 +18,17 @@ const EditServerForm = ({ server, onEdit, onCancel }: EditServerFormProps) => {
try {
setError(null);
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) {
// Use specific error message from the response if available

View File

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

View File

@@ -18,7 +18,14 @@ interface ServerCardProps {
onReload?: (server: Server) => Promise<boolean>;
}
const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh, onReload }: ServerCardProps) => {
const ServerCard = ({
server,
onRemove,
onEdit,
onToggle,
onRefresh,
onReload,
}: ServerCardProps) => {
const { t } = useTranslation();
const { showToast } = useToast();
const [isExpanded, setIsExpanded] = useState(false);
@@ -232,10 +239,10 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh, onReload }:
return (
<>
<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
className="flex justify-between items-center cursor-pointer"
className="flex justify-between items-center cursor-pointer p-4"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center space-x-3">
@@ -385,9 +392,9 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh, onReload }:
{isExpanded && (
<>
{server.tools && (
<div className="mt-6">
<div className="px-4">
<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')}
</h6>
@@ -405,9 +412,9 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh, onReload }:
)}
{server.prompts && (
<div className="mt-6">
<div className="px-4 pb-2">
<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')}
</h6>

View File

@@ -429,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"
placeholder="e.g.: time-mcp"
required
disabled={isEdit}
/>
</div>

View File

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

View File

@@ -171,9 +171,9 @@ const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCar
};
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
className="flex justify-between items-center cursor-pointer"
className="flex justify-between items-center p-2 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex-1">

View File

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

View File

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

View File

@@ -17,13 +17,29 @@ const CONFIG = {
},
};
// Pagination info type
interface PaginationInfo {
page: number;
limit: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPrevPage: boolean;
}
// Context type definition
interface ServerContextType {
servers: Server[];
allServers: Server[]; // All servers without pagination, for Dashboard, Groups, Settings
error: string | null;
setError: (error: string | null) => void;
isLoading: boolean;
fetchAttempts: number;
pagination: PaginationInfo | null;
currentPage: number;
serversPerPage: number;
setCurrentPage: (page: number) => void;
setServersPerPage: (limit: number) => void;
triggerRefresh: () => void;
refreshIfNeeded: () => void; // Smart refresh with debounce
handleServerAdd: () => void;
@@ -41,10 +57,14 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
const { t } = useTranslation();
const { auth } = useAuth();
const [servers, setServers] = useState<Server[]>([]);
const [allServers, setAllServers] = useState<Server[]>([]); // All servers without pagination
const [error, setError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const [isInitialLoading, setIsInitialLoading] = useState(true);
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
const intervalRef = useRef<NodeJS.Timeout | null>(null);
@@ -73,18 +93,46 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
const fetchServers = async () => {
try {
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());
// Fetch both paginated servers and all servers in parallel
const [paginatedData, allData] = await Promise.all([
apiGet(`/servers?${params.toString()}`),
apiGet('/servers'), // Fetch all servers without pagination
]);
// Update last fetch time
lastFetchTimeRef.current = Date.now();
if (data && data.success && Array.isArray(data.data)) {
setServers(data.data);
} else if (data && Array.isArray(data)) {
setServers(data);
// Handle paginated response
if (paginatedData && paginatedData.success && Array.isArray(paginatedData.data)) {
setServers(paginatedData.data);
// Update pagination info if available
if (paginatedData.pagination) {
setPagination(paginatedData.pagination);
} else {
setPagination(null);
}
} else if (paginatedData && Array.isArray(paginatedData)) {
// Compatibility handling for non-paginated responses
setServers(paginatedData);
setPagination(null);
} else {
console.error('Invalid server data format:', data);
console.error('Invalid server data format:', paginatedData);
setServers([]);
setPagination(null);
}
// Handle all servers response
if (allData && allData.success && Array.isArray(allData.data)) {
setAllServers(allData.data);
} else if (allData && Array.isArray(allData)) {
setAllServers(allData);
} else {
setAllServers([]);
}
// Reset error state
@@ -114,7 +162,7 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
// Set up regular polling
intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval);
},
[t],
[t, currentPage, serversPerPage],
);
// Watch for authentication status changes
@@ -128,6 +176,7 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
// When user logs out, clear data and stop polling
clearTimer();
setServers([]);
setAllServers([]);
setIsInitialLoading(false);
setError(null);
}
@@ -150,34 +199,53 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
const fetchInitialData = async () => {
try {
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());
// Fetch both paginated servers and all servers in parallel
const [paginatedData, allData] = await Promise.all([
apiGet(`/servers?${params.toString()}`),
apiGet('/servers'), // Fetch all servers without pagination
]);
// Update last fetch time
lastFetchTimeRef.current = Date.now();
// Handle API response wrapper object, extract data field
if (data && data.success && Array.isArray(data.data)) {
setServers(data.data);
setIsInitialLoading(false);
// Initialization successful, start normal polling (skip immediate to avoid duplicate fetch)
startNormalPolling({ immediate: false });
return true;
} else if (data && Array.isArray(data)) {
// Handle paginated API response wrapper object, extract data field
if (paginatedData && paginatedData.success && Array.isArray(paginatedData.data)) {
setServers(paginatedData.data);
// Update pagination info if available
if (paginatedData.pagination) {
setPagination(paginatedData.pagination);
} else {
setPagination(null);
}
} else if (paginatedData && Array.isArray(paginatedData)) {
// Compatibility handling, if API directly returns array
setServers(data);
setIsInitialLoading(false);
// Initialization successful, start normal polling (skip immediate to avoid duplicate fetch)
startNormalPolling({ immediate: false });
return true;
setServers(paginatedData);
setPagination(null);
} else {
// If data format is not as expected, set to empty array
console.error('Invalid server data format:', data);
console.error('Invalid server data format:', paginatedData);
setServers([]);
setIsInitialLoading(false);
// Initialization successful but data is empty, start normal polling (skip immediate)
startNormalPolling({ immediate: false });
return true;
setPagination(null);
}
// Handle all servers response
if (allData && allData.success && Array.isArray(allData.data)) {
setAllServers(allData.data);
} else if (allData && Array.isArray(allData)) {
setAllServers(allData);
} else {
setAllServers([]);
}
setIsInitialLoading(false);
// Initialization successful, start normal polling (skip immediate to avoid duplicate fetch)
startNormalPolling({ immediate: false });
return true;
} catch (err) {
// Increment attempt count, use ref to avoid triggering effect rerun
attemptsRef.current += 1;
@@ -227,7 +295,7 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
return () => {
clearTimer();
};
}, [refreshKey, t, isInitialLoading, startNormalPolling]);
}, [refreshKey, t, isInitialLoading, startNormalPolling, currentPage, serversPerPage]);
// Manually trigger refresh (always refreshes)
const triggerRefresh = useCallback(() => {
@@ -383,12 +451,29 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
[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 = {
servers,
allServers,
error,
setError,
isLoading: isInitialLoading,
fetchAttempts,
pagination,
currentPage,
serversPerPage,
setCurrentPage: handlePageChange,
setServersPerPage: handleServersPerPageChange,
triggerRefresh,
refreshIfNeeded,
handleServerAdd,

View File

@@ -9,6 +9,7 @@ import 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
@@ -153,6 +154,7 @@ interface SettingsProviderProps {
export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children }) => {
const { t } = useTranslation();
const { showToast } = useToast();
const { auth } = useAuth();
const [routingConfig, setRoutingConfig] = useState<RoutingConfig>({
enableGlobalRoute: true,
@@ -746,6 +748,15 @@ export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children })
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({

View File

@@ -5,15 +5,15 @@ import { Server } from '@/types';
const DashboardPage: React.FC = () => {
const { t } = useTranslation();
const { servers, error, setError, isLoading } = useServerData({ refreshOnMount: true });
const { allServers, error, setError, isLoading } = useServerData({ refreshOnMount: true });
// Calculate server statistics
// Calculate server statistics using allServers (not paginated)
const serverStats = {
total: servers.length,
online: servers.filter((server: Server) => server.status === 'connected').length,
offline: servers.filter((server: Server) => server.status === 'disconnected').length,
connecting: servers.filter((server: Server) => server.status === 'connecting').length,
oauthRequired: servers.filter((server: Server) => server.status === 'oauth_required').length,
total: allServers.length,
online: allServers.filter((server: Server) => server.status === 'connected').length,
offline: allServers.filter((server: Server) => server.status === 'disconnected').length,
connecting: allServers.filter((server: Server) => server.status === 'connecting').length,
oauthRequired: allServers.filter((server: Server) => server.status === 'oauth_required').length,
};
// Map status to translation keys
@@ -202,7 +202,7 @@ const DashboardPage: React.FC = () => {
)}
{/* Recent activity list */}
{servers.length > 0 && !isLoading && (
{allServers.length > 0 && !isLoading && (
<div className="mt-8">
<h2 className="text-xl font-semibold text-gray-900 mb-4">
{t('pages.dashboard.recentServers')}
@@ -244,7 +244,7 @@ const DashboardPage: React.FC = () => {
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{servers.slice(0, 5).map((server, index) => (
{allServers.slice(0, 5).map((server, index) => (
<tr key={index}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{server.name}

View File

@@ -18,7 +18,7 @@ const GroupsPage: React.FC = () => {
deleteGroup,
triggerRefresh,
} = useGroupData();
const { servers } = useServerData({ refreshOnMount: true });
const { allServers } = useServerData({ refreshOnMount: true });
const [editingGroup, setEditingGroup] = useState<Group | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
@@ -140,7 +140,7 @@ const GroupsPage: React.FC = () => {
<GroupCard
key={group.id}
group={group}
servers={servers}
servers={allServers}
onEdit={handleEditClick}
onDelete={handleDeleteGroup}
/>

View File

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

View File

@@ -8,6 +8,7 @@ import EditServerForm from '@/components/EditServerForm';
import { useServerData } from '@/hooks/useServerData';
import DxtUploadForm from '@/components/DxtUploadForm';
import JSONImportForm from '@/components/JSONImportForm';
import Pagination from '@/components/ui/Pagination';
const ServersPage: React.FC = () => {
const { t } = useTranslation();
@@ -17,6 +18,11 @@ const ServersPage: React.FC = () => {
error,
setError,
isLoading,
pagination,
currentPage,
serversPerPage,
setCurrentPage,
setServersPerPage,
handleServerAdd,
handleServerEdit,
handleServerRemove,
@@ -151,19 +157,66 @@ const ServersPage: React.FC = () => {
<p className="text-gray-600">{t('app.noServers')}</p>
</div>
) : (
<div className="space-y-6">
{servers.map((server, index) => (
<ServerCard
key={index}
server={server}
onRemove={handleServerRemove}
onEdit={handleEditClick}
onToggle={handleServerToggle}
onRefresh={triggerRefresh}
onReload={handleServerReload}
/>
))}
</div>
<>
<div className="space-y-6">
{servers.map((server, index) => (
<ServerCard
key={index}
server={server}
onRemove={handleServerRemove}
onEdit={handleEditClick}
onToggle={handleServerToggle}
onRefresh={triggerRefresh}
onReload={handleServerReload}
/>
))}
</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 && (

View File

@@ -25,7 +25,7 @@ interface BearerKeyRowProps {
name: string;
token: string;
enabled: boolean;
accessType: 'all' | 'groups' | 'servers';
accessType: 'all' | 'groups' | 'servers' | 'custom';
allowedGroups: string;
allowedServers: string;
},
@@ -47,7 +47,7 @@ const BearerKeyRow: React.FC<BearerKeyRowProps> = ({
const [name, setName] = useState(keyData.name);
const [token, setToken] = useState(keyData.token);
const [enabled, setEnabled] = useState<boolean>(keyData.enabled);
const [accessType, setAccessType] = useState<'all' | 'groups' | 'servers'>(
const [accessType, setAccessType] = useState<'all' | 'groups' | 'servers' | 'custom'>(
keyData.accessType || 'all',
);
const [selectedGroups, setSelectedGroups] = useState<string[]>(keyData.allowedGroups || []);
@@ -105,6 +105,13 @@ const BearerKeyRow: React.FC<BearerKeyRowProps> = ({
);
return;
}
if (accessType === 'custom' && selectedGroups.length === 0 && selectedServers.length === 0) {
showToast(
t('settings.selectAtLeastOneGroupOrServer') || 'Please select at least one group or server',
'error',
);
return;
}
setSaving(true);
try {
@@ -135,6 +142,31 @@ const BearerKeyRow: React.FC<BearerKeyRowProps> = ({
};
const isGroupsMode = accessType === 'groups';
const isCustomMode = accessType === 'custom';
// Helper function to format access type display text
const formatAccessTypeDisplay = (key: BearerKey): string => {
if (key.accessType === 'all') {
return t('settings.bearerKeyAccessAll') || 'All Resources';
}
if (key.accessType === 'groups') {
return `${t('settings.bearerKeyAccessGroups') || 'Groups'}: ${key.allowedGroups}`;
}
if (key.accessType === 'servers') {
return `${t('settings.bearerKeyAccessServers') || 'Servers'}: ${key.allowedServers}`;
}
if (key.accessType === 'custom') {
const parts: string[] = [];
if (key.allowedGroups && key.allowedGroups.length > 0) {
parts.push(`${t('settings.bearerKeyAccessGroups') || 'Groups'}: ${key.allowedGroups}`);
}
if (key.allowedServers && key.allowedServers.length > 0) {
parts.push(`${t('settings.bearerKeyAccessServers') || 'Servers'}: ${key.allowedServers}`);
}
return `${t('settings.bearerKeyAccessCustom') || 'Custom'}: ${parts.join('; ')}`;
}
return '';
};
if (isEditing) {
return (
@@ -194,7 +226,9 @@ const BearerKeyRow: React.FC<BearerKeyRowProps> = ({
<select
className="block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-select transition-shadow duration-200"
value={accessType}
onChange={(e) => setAccessType(e.target.value as 'all' | 'groups' | 'servers')}
onChange={(e) =>
setAccessType(e.target.value as 'all' | 'groups' | 'servers' | 'custom')
}
disabled={loading}
>
<option value="all">{t('settings.bearerKeyAccessAll') || 'All Resources'}</option>
@@ -204,29 +238,65 @@ const BearerKeyRow: React.FC<BearerKeyRowProps> = ({
<option value="servers">
{t('settings.bearerKeyAccessServers') || 'Specific Servers'}
</option>
<option value="custom">
{t('settings.bearerKeyAccessCustom') || 'Custom (Groups & Servers)'}
</option>
</select>
</div>
<div className="flex-1 min-w-[200px]">
<label
className={`block text-sm font-medium mb-1 ${accessType === 'all' ? 'text-gray-400' : 'text-gray-700'}`}
>
{isGroupsMode
? t('settings.bearerKeyAllowedGroups') || 'Allowed groups'
: t('settings.bearerKeyAllowedServers') || 'Allowed servers'}
</label>
<MultiSelect
options={isGroupsMode ? availableGroups : availableServers}
selected={isGroupsMode ? selectedGroups : selectedServers}
onChange={isGroupsMode ? setSelectedGroups : setSelectedServers}
placeholder={
isGroupsMode
? t('settings.selectGroups') || 'Select groups...'
: t('settings.selectServers') || 'Select servers...'
}
disabled={loading || accessType === 'all'}
/>
</div>
{/* Show single selector for groups or servers mode */}
{!isCustomMode && (
<div className="flex-1 min-w-[200px]">
<label
className={`block text-sm font-medium mb-1 ${accessType === 'all' ? 'text-gray-400' : 'text-gray-700'}`}
>
{isGroupsMode
? t('settings.bearerKeyAllowedGroups') || 'Allowed groups'
: t('settings.bearerKeyAllowedServers') || 'Allowed servers'}
</label>
<MultiSelect
options={isGroupsMode ? availableGroups : availableServers}
selected={isGroupsMode ? selectedGroups : selectedServers}
onChange={isGroupsMode ? setSelectedGroups : setSelectedServers}
placeholder={
isGroupsMode
? t('settings.selectGroups') || 'Select groups...'
: t('settings.selectServers') || 'Select servers...'
}
disabled={loading || accessType === 'all'}
/>
</div>
)}
{/* Show both selectors for custom mode */}
{isCustomMode && (
<>
<div className="flex-1 min-w-[200px]">
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('settings.bearerKeyAllowedGroups') || 'Allowed groups'}
</label>
<MultiSelect
options={availableGroups}
selected={selectedGroups}
onChange={setSelectedGroups}
placeholder={t('settings.selectGroups') || 'Select groups...'}
disabled={loading}
/>
</div>
<div className="flex-1 min-w-[200px]">
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('settings.bearerKeyAllowedServers') || 'Allowed servers'}
</label>
<MultiSelect
options={availableServers}
selected={selectedServers}
onChange={setSelectedServers}
placeholder={t('settings.selectServers') || 'Select servers...'}
disabled={loading}
/>
</div>
</>
)}
<div className="flex justify-end gap-2">
<button
@@ -281,11 +351,7 @@ const BearerKeyRow: React.FC<BearerKeyRowProps> = ({
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{keyData.accessType === 'all'
? t('settings.bearerKeyAccessAll') || 'All Resources'
: keyData.accessType === 'groups'
? `${t('settings.bearerKeyAccessGroups') || 'Groups'}: ${keyData.allowedGroups}`
: `${t('settings.bearerKeyAccessServers') || 'Servers'}: ${keyData.allowedServers}`}
{formatAccessTypeDisplay(keyData)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
@@ -312,7 +378,7 @@ const SettingsPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { showToast } = useToast();
const { servers } = useServerContext();
const { allServers: servers } = useServerContext(); // Use allServers for settings (not paginated)
const { groups } = useGroupData();
const [installConfig, setInstallConfig] = useState<{
@@ -558,12 +624,6 @@ const SettingsPage: React.FC = () => {
});
};
const saveSmartRoutingConfig = async (
key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel',
) => {
await updateSmartRoutingConfig(key, tempSmartRoutingConfig[key]);
};
const handleMCPRouterConfigChange = (
key: 'apiKey' | 'referer' | 'title' | 'baseUrl',
value: string,
@@ -705,6 +765,31 @@ const SettingsPage: React.FC = () => {
}
};
const handleSaveSmartRoutingConfig = async () => {
const updates: any = {};
if (tempSmartRoutingConfig.dbUrl !== smartRoutingConfig.dbUrl) {
updates.dbUrl = tempSmartRoutingConfig.dbUrl;
}
if (tempSmartRoutingConfig.openaiApiBaseUrl !== smartRoutingConfig.openaiApiBaseUrl) {
updates.openaiApiBaseUrl = tempSmartRoutingConfig.openaiApiBaseUrl;
}
if (tempSmartRoutingConfig.openaiApiKey !== smartRoutingConfig.openaiApiKey) {
updates.openaiApiKey = tempSmartRoutingConfig.openaiApiKey;
}
if (
tempSmartRoutingConfig.openaiApiEmbeddingModel !== smartRoutingConfig.openaiApiEmbeddingModel
) {
updates.openaiApiEmbeddingModel = tempSmartRoutingConfig.openaiApiEmbeddingModel;
}
if (Object.keys(updates).length > 0) {
await updateSmartRoutingConfigBatch(updates);
} else {
showToast(t('settings.noChanges') || 'No changes to save', 'info');
}
};
const handlePasswordChangeSuccess = () => {
setTimeout(() => {
navigate('/');
@@ -718,7 +803,7 @@ const SettingsPage: React.FC = () => {
name: string;
token: string;
enabled: boolean;
accessType: 'all' | 'groups' | 'servers';
accessType: 'all' | 'groups' | 'servers' | 'custom';
allowedGroups: string;
allowedServers: string;
}>({
@@ -746,10 +831,10 @@ const SettingsPage: React.FC = () => {
// Reset selected arrays when accessType changes
useEffect(() => {
if (newBearerKey.accessType !== 'groups') {
if (newBearerKey.accessType !== 'groups' && newBearerKey.accessType !== 'custom') {
setNewSelectedGroups([]);
}
if (newBearerKey.accessType !== 'servers') {
if (newBearerKey.accessType !== 'servers' && newBearerKey.accessType !== 'custom') {
setNewSelectedServers([]);
}
}, [newBearerKey.accessType]);
@@ -847,6 +932,17 @@ const SettingsPage: React.FC = () => {
);
return;
}
if (
newBearerKey.accessType === 'custom' &&
newSelectedGroups.length === 0 &&
newSelectedServers.length === 0
) {
showToast(
t('settings.selectAtLeastOneGroupOrServer') || 'Please select at least one group or server',
'error',
);
return;
}
await createBearerKey({
name: newBearerKey.name,
@@ -854,11 +950,13 @@ const SettingsPage: React.FC = () => {
enabled: newBearerKey.enabled,
accessType: newBearerKey.accessType,
allowedGroups:
newBearerKey.accessType === 'groups' && newSelectedGroups.length > 0
(newBearerKey.accessType === 'groups' || newBearerKey.accessType === 'custom') &&
newSelectedGroups.length > 0
? newSelectedGroups
: undefined,
allowedServers:
newBearerKey.accessType === 'servers' && newSelectedServers.length > 0
(newBearerKey.accessType === 'servers' || newBearerKey.accessType === 'custom') &&
newSelectedServers.length > 0
? newSelectedServers
: undefined,
} as any);
@@ -882,7 +980,7 @@ const SettingsPage: React.FC = () => {
name: string;
token: string;
enabled: boolean;
accessType: 'all' | 'groups' | 'servers';
accessType: 'all' | 'groups' | 'servers' | 'custom';
allowedGroups: string;
allowedServers: string;
},
@@ -1109,7 +1207,7 @@ const SettingsPage: React.FC = () => {
onChange={(e) =>
setNewBearerKey((prev) => ({
...prev,
accessType: e.target.value as 'all' | 'groups' | 'servers',
accessType: e.target.value as 'all' | 'groups' | 'servers' | 'custom',
}))
}
disabled={loading}
@@ -1123,41 +1221,75 @@ const SettingsPage: React.FC = () => {
<option value="servers">
{t('settings.bearerKeyAccessServers') || 'Specific Servers'}
</option>
<option value="custom">
{t('settings.bearerKeyAccessCustom') || 'Custom (Groups & Servers)'}
</option>
</select>
</div>
<div className="flex-1 min-w-[200px]">
<label
className={`block text-sm font-medium mb-1 ${newBearerKey.accessType === 'all' ? 'text-gray-400' : 'text-gray-700'}`}
>
{newBearerKey.accessType === 'groups'
? t('settings.bearerKeyAllowedGroups') || 'Allowed groups'
: t('settings.bearerKeyAllowedServers') || 'Allowed servers'}
</label>
<MultiSelect
options={
newBearerKey.accessType === 'groups'
? availableGroups
: availableServers
}
selected={
newBearerKey.accessType === 'groups'
? newSelectedGroups
: newSelectedServers
}
onChange={
newBearerKey.accessType === 'groups'
? setNewSelectedGroups
: setNewSelectedServers
}
placeholder={
newBearerKey.accessType === 'groups'
? t('settings.selectGroups') || 'Select groups...'
: t('settings.selectServers') || 'Select servers...'
}
disabled={loading || newBearerKey.accessType === 'all'}
/>
</div>
{newBearerKey.accessType !== 'custom' && (
<div className="flex-1 min-w-[200px]">
<label
className={`block text-sm font-medium mb-1 ${newBearerKey.accessType === 'all' ? 'text-gray-400' : 'text-gray-700'}`}
>
{newBearerKey.accessType === 'groups'
? t('settings.bearerKeyAllowedGroups') || 'Allowed groups'
: t('settings.bearerKeyAllowedServers') || 'Allowed servers'}
</label>
<MultiSelect
options={
newBearerKey.accessType === 'groups'
? availableGroups
: availableServers
}
selected={
newBearerKey.accessType === 'groups'
? newSelectedGroups
: newSelectedServers
}
onChange={
newBearerKey.accessType === 'groups'
? setNewSelectedGroups
: setNewSelectedServers
}
placeholder={
newBearerKey.accessType === 'groups'
? t('settings.selectGroups') || 'Select groups...'
: t('settings.selectServers') || 'Select servers...'
}
disabled={loading || newBearerKey.accessType === 'all'}
/>
</div>
)}
{newBearerKey.accessType === 'custom' && (
<>
<div className="flex-1 min-w-[200px]">
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('settings.bearerKeyAllowedGroups') || 'Allowed groups'}
</label>
<MultiSelect
options={availableGroups}
selected={newSelectedGroups}
onChange={setNewSelectedGroups}
placeholder={t('settings.selectGroups') || 'Select groups...'}
disabled={loading}
/>
</div>
<div className="flex-1 min-w-[200px]">
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('settings.bearerKeyAllowedServers') || 'Allowed servers'}
</label>
<MultiSelect
options={availableServers}
selected={newSelectedServers}
onChange={setNewSelectedServers}
placeholder={t('settings.selectServers') || 'Select servers...'}
disabled={loading}
/>
</div>
</>
)}
<div className="flex justify-end gap-2">
<button
@@ -1214,31 +1346,27 @@ const SettingsPage: React.FC = () => {
/>
</div>
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">
<span className="text-red-500 px-1">*</span>
{t('settings.dbUrl')}
</h3>
{/* hide when DB_URL env is set */}
{smartRoutingConfig.dbUrl !== '${DB_URL}' && (
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
<h3 className="font-medium text-gray-700">
<span className="text-red-500 px-1">*</span>
{t('settings.dbUrl')}
</h3>
</div>
<div className="flex items-center gap-3">
<input
type="text"
value={tempSmartRoutingConfig.dbUrl}
onChange={(e) => handleSmartRoutingConfigChange('dbUrl', e.target.value)}
placeholder={t('settings.dbUrlPlaceholder')}
className="flex-1 mt-1 block w-full py-2 px-3 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300 form-input"
disabled={loading}
/>
</div>
</div>
<div className="flex items-center gap-3">
<input
type="text"
value={tempSmartRoutingConfig.dbUrl}
onChange={(e) => handleSmartRoutingConfigChange('dbUrl', e.target.value)}
placeholder={t('settings.dbUrlPlaceholder')}
className="flex-1 mt-1 block w-full py-2 px-3 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300 form-input"
disabled={loading}
/>
<button
onClick={() => saveSmartRoutingConfig('dbUrl')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
>
{t('common.save')}
</button>
</div>
</div>
)}
<div className="p-3 bg-gray-50 rounded-md">
<div className="mb-2">
@@ -1256,13 +1384,6 @@ const SettingsPage: React.FC = () => {
className="flex-1 mt-1 block w-full py-2 px-3 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300"
disabled={loading}
/>
<button
onClick={() => saveSmartRoutingConfig('openaiApiKey')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
>
{t('common.save')}
</button>
</div>
</div>
@@ -1281,13 +1402,6 @@ const SettingsPage: React.FC = () => {
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
disabled={loading}
/>
<button
onClick={() => saveSmartRoutingConfig('openaiApiBaseUrl')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
>
{t('common.save')}
</button>
</div>
</div>
@@ -1308,15 +1422,18 @@ const SettingsPage: React.FC = () => {
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
disabled={loading}
/>
<button
onClick={() => saveSmartRoutingConfig('openaiApiEmbeddingModel')}
disabled={loading}
className="mt-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
>
{t('common.save')}
</button>
</div>
</div>
<div className="flex justify-end pt-2">
<button
onClick={handleSaveSmartRoutingConfig}
disabled={loading}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
>
{t('common.save')}
</button>
</div>
</div>
)}
</div>

View File

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

View File

@@ -105,6 +105,17 @@ export interface Prompt {
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
export interface ServerConfig {
type?: 'stdio' | 'sse' | 'streamable-http' | 'openapi';
@@ -123,6 +134,8 @@ export interface ServerConfig {
resetTimeoutOnProgress?: boolean; // Reset timeout on progress notifications
maxTotalTimeout?: number; // Maximum total timeout in milliseconds
}; // 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?: {
clientId?: string; // OAuth client ID
@@ -310,7 +323,7 @@ export interface ApiResponse<T = any> {
}
// Bearer authentication key configuration (frontend view model)
export type BearerKeyAccessType = 'all' | 'groups' | 'servers';
export type BearerKeyAccessType = 'all' | 'groups' | 'servers' | 'custom';
export interface BearerKey {
id: string;

View File

@@ -61,6 +61,7 @@
"emptyFields": "Username and password cannot be empty",
"loginFailed": "Login failed, please check your username and password",
"loginError": "An error occurred during login",
"serverUnavailable": "Unable to connect to the server. Please check your network connection or try again later",
"currentPassword": "Current Password",
"newPassword": "New Password",
"confirmPassword": "Confirm Password",
@@ -247,6 +248,10 @@
"wechat": "WeChat",
"discord": "Discord",
"required": "Required",
"itemsPerPage": "Items per page",
"showing": "Showing {{start}}-{{end}} of {{total}}",
"previous": "Previous",
"next": "Next",
"secret": "Secret",
"default": "Default",
"value": "Value",
@@ -567,6 +572,7 @@
"bearerKeyAccessAll": "All",
"bearerKeyAccessGroups": "Groups",
"bearerKeyAccessServers": "Servers",
"bearerKeyAccessCustom": "Custom",
"bearerKeyAllowedGroups": "Allowed groups",
"bearerKeyAllowedServers": "Allowed servers",
"addBearerKey": "Add key",

View File

@@ -61,6 +61,7 @@
"emptyFields": "Le nom d'utilisateur et le mot de passe ne peuvent pas être vides",
"loginFailed": "Échec de la connexion, veuillez vérifier votre nom d'utilisateur et votre mot de passe",
"loginError": "Une erreur est survenue lors de la connexion",
"serverUnavailable": "Impossible de se connecter au serveur. Veuillez vérifier votre connexion réseau ou réessayer plus tard",
"currentPassword": "Mot de passe actuel",
"newPassword": "Nouveau mot de passe",
"confirmPassword": "Confirmer le mot de passe",
@@ -247,6 +248,10 @@
"github": "GitHub",
"wechat": "WeChat",
"discord": "Discord",
"itemsPerPage": "Éléments par page",
"showing": "Affichage de {{start}}-{{end}} sur {{total}}",
"previous": "Précédent",
"next": "Suivant",
"required": "Requis",
"secret": "Secret",
"default": "Défaut",
@@ -568,6 +573,7 @@
"bearerKeyAccessAll": "Toutes",
"bearerKeyAccessGroups": "Groupes",
"bearerKeyAccessServers": "Serveurs",
"bearerKeyAccessCustom": "Personnalisée",
"bearerKeyAllowedGroups": "Groupes autorisés",
"bearerKeyAllowedServers": "Serveurs autorisés",
"addBearerKey": "Ajouter une clé",

View File

@@ -61,6 +61,7 @@
"emptyFields": "Kullanıcı adı ve şifre boş olamaz",
"loginFailed": "Giriş başarısız, lütfen kullanıcı adınızı ve şifrenizi kontrol edin",
"loginError": "Giriş sırasında bir hata oluştu",
"serverUnavailable": "Sunucuya bağlanılamıyor. Lütfen ağ bağlantınızı kontrol edin veya daha sonra tekrar deneyin",
"currentPassword": "Mevcut Şifre",
"newPassword": "Yeni Şifre",
"confirmPassword": "Şifreyi Onayla",
@@ -247,6 +248,10 @@
"github": "GitHub",
"wechat": "WeChat",
"discord": "Discord",
"itemsPerPage": "Sayfa başına öğe",
"showing": "{{total}} öğeden {{start}}-{{end}} gösteriliyor",
"previous": "Önceki",
"next": "Sonraki",
"required": "Gerekli",
"secret": "Gizli",
"default": "Varsayılan",
@@ -568,6 +573,7 @@
"bearerKeyAccessAll": "Tümü",
"bearerKeyAccessGroups": "Gruplar",
"bearerKeyAccessServers": "Sunucular",
"bearerKeyAccessCustom": "Özel",
"bearerKeyAllowedGroups": "İzin verilen gruplar",
"bearerKeyAllowedServers": "İzin verilen sunucular",
"addBearerKey": "Anahtar ekle",

View File

@@ -61,6 +61,7 @@
"emptyFields": "用户名和密码不能为空",
"loginFailed": "登录失败,请检查用户名和密码",
"loginError": "登录过程中出现错误",
"serverUnavailable": "无法连接到服务器,请检查网络连接或稍后再试",
"currentPassword": "当前密码",
"newPassword": "新密码",
"confirmPassword": "确认密码",
@@ -247,6 +248,10 @@
"dismiss": "忽略",
"github": "GitHub",
"wechat": "微信",
"itemsPerPage": "每页显示",
"showing": "显示第 {{start}}-{{end}} 条,共 {{total}} 条",
"previous": "上一页",
"next": "下一页",
"discord": "Discord",
"required": "必填",
"secret": "敏感",
@@ -569,6 +574,7 @@
"bearerKeyAccessAll": "全部",
"bearerKeyAccessGroups": "指定分组",
"bearerKeyAccessServers": "指定服务器",
"bearerKeyAccessCustom": "自定义",
"bearerKeyAllowedGroups": "允许访问的分组",
"bearerKeyAllowedServers": "允许访问的服务器",
"addBearerKey": "新增密钥",

View File

@@ -63,5 +63,6 @@
"requiresAuthentication": false
}
}
}
},
"bearerKeys": []
}

View File

@@ -46,7 +46,7 @@
"license": "ISC",
"dependencies": {
"@apidevtools/swagger-parser": "^12.0.0",
"@modelcontextprotocol/sdk": "^1.20.2",
"@modelcontextprotocol/sdk": "^1.25.1",
"@node-oauth/oauth2-server": "^5.2.1",
"@types/adm-zip": "^0.5.7",
"@types/bcrypt": "^6.0.0",
@@ -57,7 +57,7 @@
"bcrypt": "^6.0.0",
"bcryptjs": "^3.0.2",
"cors": "^2.8.5",
"dotenv": "^16.6.1",
"dotenv": "^17.2.3",
"dotenv-expand": "^12.0.2",
"express": "^4.21.2",
"express-validator": "^7.3.1",
@@ -108,7 +108,7 @@
"jest-environment-node": "^30.0.5",
"jest-mock-extended": "4.0.0",
"lucide-react": "^0.552.0",
"next": "^15.5.0",
"next": "^16.1.1",
"postcss": "^8.5.6",
"prettier": "^3.6.2",
"react": "19.2.1",

425
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -57,7 +57,7 @@ export const createBearerKey = async (req: Request, res: Response): Promise<void
return;
}
if (!accessType || !['all', 'groups', 'servers'].includes(accessType)) {
if (!accessType || !['all', 'groups', 'servers', 'custom'].includes(accessType)) {
res.status(400).json({ success: false, message: 'Invalid accessType' });
return;
}
@@ -104,7 +104,7 @@ export const updateBearerKey = async (req: Request, res: Response): Promise<void
if (token !== undefined) updates.token = token;
if (enabled !== undefined) updates.enabled = enabled;
if (accessType !== undefined) {
if (!['all', 'groups', 'servers'].includes(accessType)) {
if (!['all', 'groups', 'servers', 'custom'].includes(accessType)) {
res.status(400).json({ success: false, message: 'Invalid accessType' });
return;
}

View File

@@ -7,6 +7,7 @@ import {
BatchCreateServersResponse,
BatchServerResult,
ServerConfig,
ServerInfo,
} from '../types/index.js';
import {
getServersInfo,
@@ -24,13 +25,66 @@ import { createSafeJSON } from '../utils/serialization.js';
import { cloneDefaultOAuthServerConfig } from '../constants/oauthServerDefaults.js';
import { getServerDao, getGroupDao, getSystemConfigDao } from '../dao/DaoFactory.js';
import { getBearerKeyDao } from '../dao/DaoFactory.js';
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 {
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 = {
success: true,
data: createSafeJSON(serversInfo),
...(pagination && { pagination }),
};
res.json(response);
} catch (error) {
@@ -66,6 +120,20 @@ export const getAllSettings = async (_: Request, res: Response): Promise<void> =
const systemConfigDao = getSystemConfigDao();
const systemConfig = await systemConfigDao.get();
// Ensure smart routing config has DB URL set if environment variable is present
const dbUrlEnv = process.env.DB_URL || '';
if (!systemConfig.smartRouting) {
systemConfig.smartRouting = {
enabled: false,
dbUrl: dbUrlEnv ? '${DB_URL}' : '',
openaiApiBaseUrl: '',
openaiApiKey: '',
openaiApiEmbeddingModel: '',
};
} else if (!systemConfig.smartRouting.dbUrl) {
systemConfig.smartRouting.dbUrl = dbUrlEnv ? '${DB_URL}' : '';
}
// Get bearer auth keys from DAO
const bearerKeyDao = getBearerKeyDao();
const bearerKeys = await bearerKeyDao.findAll();
@@ -409,7 +477,7 @@ export const deleteServer = async (req: Request, res: Response): Promise<void> =
export const updateServer = async (req: Request, res: Response): Promise<void> => {
try {
const { name } = req.params;
const { config } = req.body;
const { config, newName } = req.body;
if (!name) {
res.status(400).json({
success: false,
@@ -496,12 +564,52 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
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) {
notifyToolChanged(name);
notifyToolChanged(finalName);
res.json({
success: true,
message: 'Server updated successfully',
message: isRenaming
? `Server renamed and updated successfully`
: 'Server updated successfully',
});
} else {
res.status(404).json({
@@ -978,7 +1086,8 @@ export const updateSystemConfig = async (req: Request, res: Response): Promise<v
if (typeof smartRouting.enabled === 'boolean') {
// If enabling Smart Routing, validate required fields
if (smartRouting.enabled) {
const currentDbUrl = smartRouting.dbUrl || systemConfig.smartRouting.dbUrl;
const currentDbUrl =
process.env.DB_URL || smartRouting.dbUrl || systemConfig.smartRouting.dbUrl;
const currentOpenaiApiKey =
smartRouting.openaiApiKey || systemConfig.smartRouting.openaiApiKey;

View File

@@ -13,6 +13,10 @@ export interface BearerKeyDao {
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>;
}
/**
@@ -122,4 +126,34 @@ export class BearerKeyDaoImpl extends JsonFileBaseDao implements BearerKeyDao {
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

@@ -74,4 +74,30 @@ export class BearerKeyDaoDbImpl implements BearerKeyDao {
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

@@ -36,6 +36,11 @@ export interface GroupDao extends BaseDao<IGroup, string> {
* Find group by name
*/
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();
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,
};
}
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;
}
}

View File

@@ -2,10 +2,31 @@ import { ServerConfig } from '../types/index.js';
import { BaseDao } from './base/BaseDao.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
*/
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
*/
@@ -41,6 +62,11 @@ export interface ServerDao extends BaseDao<ServerConfigWithName, string> {
name: string,
prompts: Record<string, { enabled: boolean; description?: string }>,
): 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 {
...existing,
...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;
}
// Don't allow name changes
const { name: _, ...allowedUpdates } = updates;
const updatedServer = this.updateEntity(servers[index], allowedUpdates);
const updatedServer = this.updateEntity(servers[index], updates);
servers[index] = updatedServer;
await this.saveAll(servers);
@@ -172,6 +197,61 @@ export class ServerDaoImpl extends JsonFileBaseDao implements ServerDao {
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[]> {
const servers = await this.getAll();
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 });
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';
/**
@@ -16,6 +16,32 @@ export class ServerDaoDbImpl implements ServerDao {
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> {
const server = await this.repository.findByName(name);
return server ? this.mapToServerConfig(server) : null;
@@ -38,6 +64,7 @@ export class ServerDaoDbImpl implements ServerDao {
prompts: entity.prompts,
options: entity.options,
oauth: entity.oauth,
proxy: entity.proxy,
openapi: entity.openapi,
});
return this.mapToServerConfig(server);
@@ -62,6 +89,7 @@ export class ServerDaoDbImpl implements ServerDao {
prompts: entity.prompts,
options: entity.options,
oauth: entity.oauth,
proxy: entity.proxy,
openapi: entity.openapi,
});
return server ? this.mapToServerConfig(server) : null;
@@ -115,6 +143,15 @@ export class ServerDaoDbImpl implements ServerDao {
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: {
name: string;
type?: string;
@@ -131,6 +168,7 @@ export class ServerDaoDbImpl implements ServerDao {
prompts?: Record<string, { enabled: boolean; description?: string }>;
options?: Record<string, any>;
oauth?: Record<string, any>;
proxy?: Record<string, any>;
openapi?: Record<string, any>;
}): ServerConfigWithName {
return {
@@ -149,6 +187,7 @@ export class ServerDaoDbImpl implements ServerDao {
prompts: server.prompts,
options: server.options,
oauth: server.oauth,
proxy: server.proxy,
openapi: server.openapi,
};
}

View File

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

View File

@@ -25,7 +25,7 @@ export class BearerKey {
enabled: boolean;
@Column({ type: 'varchar', length: 20, default: 'all' })
accessType: 'all' | 'groups' | 'servers';
accessType: 'all' | 'groups' | 'servers' | 'custom';
@Column({ type: 'simple-json', nullable: true })
allowedGroups?: string[];

View File

@@ -59,6 +59,9 @@ export class Server {
@Column({ type: 'simple-json', nullable: true })
oauth?: Record<string, any>;
@Column({ type: 'simple-json', nullable: true })
proxy?: Record<string, any>;
@Column({ type: 'simple-json', nullable: true })
openapi?: Record<string, any>;

View File

@@ -69,6 +69,41 @@ export class ServerRepository {
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
*/
@@ -89,6 +124,19 @@ export class ServerRepository {
async setEnabled(name: string, enabled: boolean): Promise<Server | null> {
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;

View File

@@ -39,7 +39,7 @@ const validateBearerAuth = async (req: Request): Promise<boolean> => {
return true;
};
const readonlyAllowPaths = ['/tools/call/'];
const readonlyAllowPaths = ['/tools/'];
const checkReadonly = (req: Request): boolean => {
if (!defaultConfig.readonly) {

View File

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

View File

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

View File

@@ -1,4 +1,6 @@
import os from 'os';
import path from 'path';
import fs from 'fs';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import {
CallToolRequestSchema,
@@ -15,7 +17,7 @@ import {
StreamableHTTPClientTransportOptions,
} from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { createFetchWithProxy, getProxyConfigFromEnv } from './proxy.js';
import { ServerInfo, ServerConfig, Tool } from '../types/index.js';
import { ServerInfo, ServerConfig, Tool, ProxychainsConfig } from '../types/index.js';
import { expandEnvVars, replaceEnvVars, getNameSeparator } from '../config/index.js';
import config from '../config/index.js';
import { getGroup } from './sseService.js';
@@ -32,6 +34,154 @@ const servers: { [sessionId: string]: Server } = {};
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
localnet 127.0.0.0/255.0.0.0
localnet 10.0.0.0/255.0.0.0
localnet 172.16.0.0/255.240.0.0
localnet 192.168.0.0/255.255.0.0
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> => {
// Initialize OAuth clients for servers with dynamic registration
await initializeAllOAuthClients();
@@ -209,11 +359,19 @@ export const createTransportFromConfig = async (name: string, conf: ServerConfig
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({
cwd: os.homedir(),
command: conf.command,
args: replaceEnvVars(conf.args) as string[],
command: finalCommand,
args: finalArgs,
env: env,
stderr: 'pipe',
});
@@ -241,6 +399,8 @@ const callToolWithReconnect = async (
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const result = await serverInfo.client.callTool(toolParams, undefined, options || {});
// Check auth error
checkAuthError(result);
return result;
} catch (error: any) {
// Check if error message starts with "Error POSTing to endpoint (HTTP 40"
@@ -616,10 +776,20 @@ export const registerAllTools = async (isInit: boolean, serverName?: string): Pr
};
// Get all server information
export const getServersInfo = async (): Promise<Omit<ServerInfo, 'client' | 'transport'>[]> => {
const allServers: ServerConfigWithName[] = await getServerDao().findAll();
export const getServersInfo = async (
page?: number,
limit?: number,
user?: any,
): Promise<Omit<ServerInfo, 'client' | 'transport'>[]> => {
const dataService = getDataService();
// Get paginated or all server configurations from DAO
// If pagination is used with a non-admin user, filtering is already done at DAO level
const isPaginated = limit !== undefined && page !== undefined;
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
@@ -627,10 +797,19 @@ export const getServersInfo = async (): Promise<Omit<ServerInfo, 'client' | 'tra
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;
combinedServerInfos.push({
filteredServerInfos.push({
name: server.name,
owner: server.owner,
// Newly created servers that are enabled should appear as "connecting"
@@ -646,12 +825,17 @@ export const getServersInfo = async (): Promise<Omit<ServerInfo, 'client' | 'tra
}
}
const filterServerInfos: ServerInfo[] = dataService.filterData
? dataService.filterData(combinedServerInfos)
: combinedServerInfos;
// 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.map(
({ name, status, tools, prompts, createTime, error, oauth }) => {
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 enabled = serverConfig ? serverConfig.enabled !== false : true;
@@ -690,12 +874,8 @@ export const getServersInfo = async (): Promise<Omit<ServerInfo, 'client' | 'tra
}
: 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;
};
@@ -830,6 +1010,25 @@ export const addOrUpdateServer = async (
}
};
// Check for authentication error in tool call result
function checkAuthError(result: any) {
if (Array.isArray(result.content) && result.content.length > 0) {
const text = result.content[0]?.text;
if (typeof text === 'string') {
let errorContent;
try {
errorContent = JSON.parse(text);
} catch (e) {
// Ignore JSON parse errors and continue
return;
}
if (errorContent.code === 401) {
throw new Error('Error POSTing to endpoint (HTTP 401 Unauthorized)');
}
}
}
}
// Close server client and transport
function closeServer(name: string) {
const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name);
@@ -942,6 +1141,10 @@ Available servers: ${serversList}`,
},
required: ['query'],
},
annotations: {
title: 'Search Tools',
readOnlyHint: true,
},
},
{
name: 'call_tool',
@@ -962,6 +1165,10 @@ Available servers: ${serversList}`,
},
required: ['toolName'],
},
annotations: {
title: 'Call Tool',
openWorldHint: true,
},
},
],
};

View File

@@ -88,6 +88,29 @@ const isBearerKeyAllowedForRequest = async (req: Request, key: BearerKey): Promi
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;
}
@@ -102,8 +125,8 @@ const isBearerKeyAllowedForRequest = async (req: Request, key: BearerKey): Promi
return false;
}
if (key.accessType === 'servers') {
// For server-scoped keys, check if the server is in allowedServers
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);
}

View File

@@ -1,13 +1,13 @@
import { getRepositoryFactory } from '../db/index.js';
import { VectorEmbeddingRepository } from '../db/repositories/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 OpenAI from 'openai';
// Get OpenAI configuration from smartRouting settings or fallback to environment variables
const getOpenAIConfig = () => {
const smartRoutingConfig = getSmartRoutingConfig();
const getOpenAIConfig = async () => {
const smartRoutingConfig = await getSmartRoutingConfig();
return {
apiKey: smartRoutingConfig.openaiApiKey,
baseURL: smartRoutingConfig.openaiApiBaseUrl,
@@ -34,8 +34,8 @@ const getDimensionsForModel = (model: string): number => {
};
// Initialize the OpenAI client with smartRouting configuration
const getOpenAIClient = () => {
const config = getOpenAIConfig();
const getOpenAIClient = async () => {
const config = await getOpenAIConfig();
return new OpenAI({
apiKey: config.apiKey, // Get API key from smartRouting settings or environment variables
baseURL: config.baseURL, // Get base URL from smartRouting settings or fallback to default
@@ -53,32 +53,26 @@ const getOpenAIClient = () => {
* @returns Promise with vector embedding as number array
*/
async function generateEmbedding(text: string): Promise<number[]> {
try {
const config = getOpenAIConfig();
const openai = getOpenAIClient();
const config = await getOpenAIConfig();
const openai = await getOpenAIClient();
// Check if API key is configured
if (!openai.apiKey) {
console.warn('OpenAI API key is not configured. Using fallback embedding method.');
return generateFallbackEmbedding(text);
}
// Truncate text if it's too long (OpenAI has token limits)
const truncatedText = text.length > 8000 ? text.substring(0, 8000) : text;
// Call OpenAI's embeddings API
const response = await openai.embeddings.create({
model: config.embeddingModel, // Modern model with better performance
input: truncatedText,
});
// Return the embedding
return response.data[0].embedding;
} catch (error) {
console.error('Error generating embedding:', error);
console.warn('Falling back to simple embedding method');
// Check if API key is configured
if (!openai.apiKey) {
console.warn('OpenAI API key is not configured. Using fallback embedding method.');
return generateFallbackEmbedding(text);
}
// Truncate text if it's too long (OpenAI has token limits)
const truncatedText = text.length > 8000 ? text.substring(0, 8000) : text;
// Call OpenAI's embeddings API
const response = await openai.embeddings.create({
model: config.embeddingModel, // Modern model with better performance
input: truncatedText,
});
// Return the embedding
return response.data[0].embedding;
}
/**
@@ -198,12 +192,18 @@ export const saveToolsAsVectorEmbeddings = async (
return;
}
const smartRoutingConfig = getSmartRoutingConfig();
const smartRoutingConfig = await getSmartRoutingConfig();
if (!smartRoutingConfig.enabled) {
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(
'vectorEmbeddings',
)() as VectorEmbeddingRepository;
@@ -227,36 +227,31 @@ export const saveToolsAsVectorEmbeddings = async (
.filter(Boolean)
.join(' ');
try {
// Generate embedding
const embedding = await generateEmbedding(searchableText);
// Generate embedding
const embedding = await generateEmbedding(searchableText);
// Check database compatibility before saving
await checkDatabaseVectorDimensions(embedding.length);
// Check database compatibility before saving
await checkDatabaseVectorDimensions(embedding.length);
// Save embedding
await vectorRepository.saveEmbedding(
'tool',
`${serverName}:${tool.name}`,
searchableText,
embedding,
{
serverName,
toolName: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
},
config.embeddingModel, // Store the model used for this embedding
);
} catch (toolError) {
console.error(`Error processing tool ${tool.name} for server ${serverName}:`, toolError);
// Continue with the next tool rather than failing the whole batch
}
// Save embedding
await vectorRepository.saveEmbedding(
'tool',
`${serverName}:${tool.name}`,
searchableText,
embedding,
{
serverName,
toolName: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
},
config.embeddingModel, // Store the model used for this embedding
);
}
console.log(`Saved ${tools.length} tool embeddings for server: ${serverName}`);
} 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 {
const config = getOpenAIConfig();
const config = await getOpenAIConfig();
const vectorRepository = getRepositoryFactory(
'vectorEmbeddings',
)() as VectorEmbeddingRepository;

View File

@@ -244,7 +244,7 @@ export interface OAuthServerConfig {
}
// Bearer authentication key configuration
export type BearerKeyAccessType = 'all' | 'groups' | 'servers';
export type BearerKeyAccessType = 'all' | 'groups' | 'servers' | 'custom';
export interface BearerKey {
id: string; // Unique identifier for the key
@@ -252,8 +252,8 @@ export interface BearerKey {
token: string; // Bearer token value
enabled: boolean; // Whether this key is enabled
accessType: BearerKeyAccessType; // Access scope type
allowedGroups?: string[]; // Allowed group names when accessType === 'groups'
allowedServers?: string[]; // Allowed server names when accessType === 'servers'
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
@@ -270,6 +270,17 @@ export interface McpSettings {
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
export interface ServerConfig {
type?: 'stdio' | 'sse' | 'streamable-http' | 'openapi'; // Type of server
@@ -285,6 +296,8 @@ export interface ServerConfig {
tools?: Record<string, { enabled: boolean; description?: string }>; // Tool-specific configurations with enable/disable state and custom descriptions
prompts?: Record<string, { enabled: boolean; description?: string }>; // Prompt-specific configurations with enable/disable state and custom descriptions
options?: Partial<Pick<RequestOptions, 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout'>>; // MCP request options configuration
// Proxychains4 proxy configuration for STDIO servers (Linux/macOS only, Windows not supported)
proxy?: ProxychainsConfig;
// OAuth authentication for upstream MCP servers
oauth?: {
// Static client configuration (traditional OAuth flow)

View File

@@ -1,4 +1,5 @@
import { loadSettings, expandEnvVars } from '../config/index.js';
import { expandEnvVars } from '../config/index.js';
import { getSystemConfigDao } from '../dao/DaoFactory.js';
/**
* Smart routing configuration interface
@@ -16,16 +17,17 @@ export interface SmartRoutingConfig {
*
* Priority order for each setting:
* 1. Specific environment variables (ENABLE_SMART_ROUTING, SMART_ROUTING_ENABLED, etc.)
* 2. Generic environment variables (OPENAI_API_KEY, DATABASE_URL, etc.)
* 2. Generic environment variables (OPENAI_API_KEY, DB_URL, etc.)
* 3. Settings configuration (systemConfig.smartRouting)
* 4. Default values
*
* @returns {SmartRoutingConfig} Complete smart routing configuration
*/
export function getSmartRoutingConfig(): SmartRoutingConfig {
const settings = loadSettings();
const smartRoutingSettings: Partial<SmartRoutingConfig> =
settings.systemConfig?.smartRouting || {};
export async function getSmartRoutingConfig(): Promise<SmartRoutingConfig> {
// Get system config from DAO
const systemConfigDao = getSystemConfigDao();
const systemConfig = await systemConfigDao.get();
const smartRoutingSettings: Partial<SmartRoutingConfig> = systemConfig.smartRouting || {};
return {
// Enabled status - check multiple environment variables

View File

@@ -5,7 +5,7 @@ import 'reflect-metadata';
Object.assign(process.env, {
NODE_ENV: 'test',
JWT_SECRET: 'test-jwt-secret-key',
DATABASE_URL: 'sqlite::memory:',
DB_URL: 'sqlite::memory:',
});
// Mock moduleDir to avoid import.meta parsing issues in Jest
@@ -40,7 +40,7 @@ expect.extend({
};
}
},
toBeValidUUID(received: any) {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const pass = typeof received === 'string' && uuidRegex.test(received);