Compare commits

...

13 Commits

Author SHA1 Message Date
samanhappy
8ae542bdab Add server renaming functionality (#533) 2025-12-30 18:45:33 +08:00
samanhappy
88ce94b988 docs: update and expand MCPHub development guide and agent instructions (#532) 2025-12-28 14:25:28 +08:00
samanhappy
7cc330e721 fix: ensure database is initialized before saving tool embeddings (#531) 2025-12-28 12:19:21 +08:00
Copilot
ab338e80a7 Add custom access type for bearer keys to support combined group and server scoping (#530)
Co-authored-by: samanhappy <samanhappy@gmail.com>
2025-12-27 16:16:50 +08:00
cheezmil
b00e1c81fc fix: Found 1 error in src/services/keepAliveService.ts:51 #521 (#522)
Co-authored-by: cheestard <134115886+cheestard@users.noreply.github.com>
2025-12-25 09:15:07 +08:00
samanhappy
33eae50bd3 Refactor smart routing configuration and async database handling (#519) 2025-12-20 12:16:09 +08:00
samanhappy
eb1a965e45 feat: add authentication status listener to refresh settings on user login (#518) 2025-12-17 18:34:07 +08:00
samanhappy
97114dcabb feat: implement batch saving for smart routing configuration (#517) 2025-12-17 15:26:53 +08:00
samanhappy
350a022ea3 feat: enhance login error handling and add server unavailable message (#516) 2025-12-17 13:24:07 +08:00
samanhappy
292876a991 feat: update PostgreSQL images to pgvector/pgvector:pg17 across configurations (#513) 2025-12-16 15:40:06 +08:00
samanhappy
d6a9146e27 feat: enhance OAuth token logging and add authentication error handling in tool calls (#512) 2025-12-16 15:16:43 +08:00
samanhappy
1f3a6794ea feat: enhance BearerKeyDaoImpl to handle migration and caching behavior for bearer keys (#507) 2025-12-14 20:40:57 +08:00
samanhappy
c673afb97e Add HTTP/HTTPS proxy configuration and environment variable support (#506) 2025-12-14 15:44:44 +08:00
46 changed files with 1426 additions and 555 deletions

View File

@@ -1,272 +0,0 @@
# MCPHub Coding Instructions
**ALWAYS follow these instructions first and only fallback to additional search and context gathering if the information here is incomplete or found to be in error.**
## Project Overview
MCPHub is a TypeScript/Node.js MCP (Model Context Protocol) server management hub that provides unified access through HTTP endpoints. It serves as a centralized dashboard for managing multiple MCP servers with real-time monitoring, authentication, and flexible routing.
**Core Components:**
- **Backend**: Express.js + TypeScript + ESM (`src/server.ts`)
- **Frontend**: React/Vite + Tailwind CSS (`frontend/`)
- **MCP Integration**: Connects multiple MCP servers (`src/services/mcpService.ts`)
- **Authentication**: JWT-based with bcrypt password hashing
- **Configuration**: JSON-based MCP server definitions (`mcp_settings.json`)
- **Documentation**: API docs and usage instructions(`docs/`)
## Working Effectively
### Bootstrap and Setup (CRITICAL - Follow Exact Steps)
```bash
# Install pnpm if not available
npm install -g pnpm
# Install dependencies - takes ~30 seconds
pnpm install
# Setup environment (optional)
cp .env.example .env
# Build and test to verify setup
pnpm lint # ~3 seconds - NEVER CANCEL
pnpm backend:build # ~5 seconds - NEVER CANCEL
pnpm test:ci # ~16 seconds - NEVER CANCEL. Set timeout to 60+ seconds
pnpm frontend:build # ~5 seconds - NEVER CANCEL
pnpm build # ~10 seconds total - NEVER CANCEL. Set timeout to 60+ seconds
```
**CRITICAL TIMING**: These commands are fast but NEVER CANCEL them. Always wait for completion.
### Development Environment
```bash
# Start both backend and frontend (recommended for most development)
pnpm dev # Backend on :3001, Frontend on :5173
# OR start separately (required on Windows, optional on Linux/macOS)
# Terminal 1: Backend only
pnpm backend:dev # Runs on port 3000 (or PORT env var)
# Terminal 2: Frontend only
pnpm frontend:dev # Runs on port 5173, proxies API to backend
```
**NEVER CANCEL**: Development servers may take 10-15 seconds to fully initialize all MCP servers.
### Build Commands (Production)
```bash
# Full production build - takes ~10 seconds total
pnpm build # NEVER CANCEL - Set timeout to 60+ seconds
# Individual builds
pnpm backend:build # TypeScript compilation - ~5 seconds
pnpm frontend:build # Vite build - ~5 seconds
# Start production server
pnpm start # Requires dist/ and frontend/dist/ to exist
```
### Testing and Validation
```bash
# Run all tests - takes ~16 seconds with 73 tests
pnpm test:ci # NEVER CANCEL - Set timeout to 60+ seconds
# Development testing
pnpm test # Interactive mode
pnpm test:watch # Watch mode for development
pnpm test:coverage # With coverage report
# Code quality
pnpm lint # ESLint - ~3 seconds
pnpm format # Prettier formatting - ~3 seconds
```
**CRITICAL**: All tests MUST pass before committing. Do not modify tests to make them pass unless specifically required for your changes.
## Manual Validation Requirements
**ALWAYS perform these validation steps after making changes:**
### 1. Basic Application Functionality
```bash
# Start the application
pnpm dev
# Verify backend responds (in another terminal)
curl http://localhost:3000/api/health
# Expected: Should return health status
# Verify frontend serves
curl -I http://localhost:3000/
# Expected: HTTP 200 OK with HTML content
```
### 2. MCP Server Integration Test
```bash
# Check MCP servers are loading (look for log messages)
# Expected log output should include:
# - "Successfully connected client for server: [name]"
# - "Successfully listed [N] tools for server: [name]"
# - Some servers may fail due to missing API keys (normal in dev)
```
### 3. Build Verification
```bash
# Verify production build works
pnpm build
node scripts/verify-dist.js
# Expected: "✅ Verification passed! Frontend and backend dist files are present."
```
**NEVER skip these validation steps**. If any fail, debug and fix before proceeding.
## Project Structure and Key Files
### Critical Backend Files
- `src/index.ts` - Application entry point
- `src/server.ts` - Express server setup and middleware
- `src/services/mcpService.ts` - **Core MCP server management logic**
- `src/config/index.ts` - Configuration management
- `src/routes/` - HTTP route definitions
- `src/controllers/` - HTTP request handlers
- `src/dao/` - Data access layer (supports JSON file & PostgreSQL)
- `src/db/` - TypeORM entities & repositories (for PostgreSQL mode)
- `src/types/index.ts` - TypeScript type definitions
### DAO Layer (Dual Data Source)
MCPHub supports **JSON file** (default) and **PostgreSQL** storage:
- Set `USE_DB=true` + `DB_URL=postgresql://...` to use database
- When modifying data structures, update: `src/types/`, `src/dao/`, `src/db/entities/`, `src/db/repositories/`, `src/utils/migration.ts`
- See `AGENTS.md` for detailed DAO modification checklist
### Critical Frontend Files
- `frontend/src/` - React application source
- `frontend/src/pages/` - Page components (development entry point)
- `frontend/src/components/` - Reusable UI components
- `frontend/src/utils/fetchInterceptor.js` - Backend API interaction
### Configuration Files
- `mcp_settings.json` - **MCP server definitions and user accounts**
- `package.json` - Dependencies and scripts
- `tsconfig.json` - TypeScript configuration
- `jest.config.cjs` - Test configuration
- `.eslintrc.json` - Linting rules
### Docker and Deployment
- `Dockerfile` - Multi-stage build with Python base + Node.js
- `entrypoint.sh` - Docker startup script
- `bin/cli.js` - NPM package CLI entry point
## Development Process and Conventions
### Code Style Requirements
- **ESM modules**: Always use `.js` extensions in imports, not `.ts`
- **English only**: All code comments must be written in English
- **TypeScript strict**: Follow strict type checking rules
- **Import style**: `import { something } from './file.js'` (note .js extension)
### Key Configuration Notes
- **MCP servers**: Defined in `mcp_settings.json` with command/args
- **Endpoints**: `/mcp/{group|server}` and `/mcp/$smart` for routing
- **i18n**: Frontend uses react-i18next with files in `locales/` folder
- **Authentication**: JWT tokens with bcrypt password hashing
- **Default credentials**: admin/admin123 (configured in mcp_settings.json)
### Development Entry Points
- **Add MCP server**: Modify `mcp_settings.json` and restart
- **New API endpoint**: Add route in `src/routes/`, controller in `src/controllers/`
- **Frontend feature**: Start from `frontend/src/pages/` or `frontend/src/components/`
- **Add tests**: Follow patterns in `tests/` directory
### Common Development Tasks
#### Adding a new MCP server:
1. Add server definition to `mcp_settings.json`
2. Restart backend to load new server
3. Check logs for successful connection
4. Test via dashboard or API endpoints
#### API development:
1. Define route in `src/routes/`
2. Implement controller in `src/controllers/`
3. Add types in `src/types/index.ts` if needed
4. Write tests in `tests/controllers/`
#### Frontend development:
1. Create/modify components in `frontend/src/components/`
2. Add pages in `frontend/src/pages/`
3. Update routing if needed
4. Test in development mode with `pnpm frontend:dev`
#### Documentation:
1. Update or add docs in `docs/` folder
2. Ensure README.md reflects any major changes
## Validation and CI Requirements
### Before Committing - ALWAYS Run:
```bash
pnpm lint # Must pass - ~3 seconds
pnpm backend:build # Must compile - ~5 seconds
pnpm test:ci # All tests must pass - ~16 seconds
pnpm build # Full build must work - ~10 seconds
```
**CRITICAL**: CI will fail if any of these commands fail. Fix issues locally first.
### CI Pipeline (.github/workflows/ci.yml)
- Runs on Node.js 20.x
- Tests: linting, type checking, unit tests with coverage
- **NEVER CANCEL**: CI builds may take 2-3 minutes total
## Troubleshooting
### Common Issues
- **"uvx command not found"**: Some MCP servers require `uvx` (Python package manager) - this is expected in development
- **Port already in use**: Change PORT environment variable or kill existing processes
- **Frontend not loading**: Ensure frontend was built with `pnpm frontend:build`
- **MCP server connection failed**: Check server command/args in `mcp_settings.json`
### Build Failures
- **TypeScript errors**: Run `pnpm backend:build` to see compilation errors
- **Test failures**: Run `pnpm test:verbose` for detailed test output
- **Lint errors**: Run `pnpm lint` and fix reported issues
### Development Issues
- **Backend not starting**: Check for port conflicts, verify `mcp_settings.json` syntax
- **Frontend proxy errors**: Ensure backend is running before starting frontend
- **Hot reload not working**: Restart development server
## Performance Notes
- **Install time**: pnpm install takes ~30 seconds
- **Build time**: Full build takes ~10 seconds
- **Test time**: Complete test suite takes ~16 seconds
- **Startup time**: Backend initialization takes 10-15 seconds (MCP server connections)
**Remember**: NEVER CANCEL any build or test commands. Always wait for completion even if they seem slow.

View File

@@ -76,7 +76,7 @@ jobs:
# services: # services:
# postgres: # postgres:
# image: postgres:15 # image: pgvector/pgvector:pg17
# env: # env:
# POSTGRES_PASSWORD: postgres # POSTGRES_PASSWORD: postgres
# POSTGRES_DB: mcphub_test # POSTGRES_DB: mcphub_test

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 ## Project Structure & Module Organization
- Backend services live in `src`, grouped by responsibility (`controllers/`, `services/`, `dao/`, `routes/`, `utils/`), with `server.ts` orchestrating HTTP bootstrap. ### Critical Backend Files
- `frontend/src` contains the Vite + React dashboard; `frontend/public` hosts static assets and translations sit in `locales/`.
- Jest-aware test code is split between colocated specs (`src/**/*.{test,spec}.ts`) and higher-level suites in `tests/`; use `tests/utils/` helpers when exercising the CLI or SSE flows. - `src/index.ts` - Application entry point
- Build artifacts and bundles are generated into `dist/`, `frontend/dist/`, and `coverage/`; never edit these manually. - `src/server.ts` - Express server setup and middleware (orchestrating HTTP bootstrap)
- `src/services/mcpService.ts` - **Core MCP server management logic**
- `src/config/index.ts` - Configuration management
- `src/routes/` - HTTP route definitions
- `src/controllers/` - HTTP request handlers
- `src/dao/` - Data access layer (supports JSON file & PostgreSQL)
- `src/db/` - TypeORM entities & repositories (for PostgreSQL mode)
- `src/types/index.ts` - TypeScript type definitions and shared DTOs
- `src/utils/` - Utility functions and helpers
### Critical Frontend Files
- `frontend/src/` - React application source (Vite + React dashboard)
- `frontend/src/pages/` - Page components (development entry point)
- `frontend/src/components/` - Reusable UI components
- `frontend/src/utils/fetchInterceptor.js` - Backend API interaction
- `frontend/public/` - Static assets
### Configuration Files
- `mcp_settings.json` - **MCP server definitions and user accounts**
- `package.json` - Dependencies and scripts
- `tsconfig.json` - TypeScript configuration
- `jest.config.cjs` - Test configuration
- `.eslintrc.json` - Linting rules
### Test Organization
- Jest-aware test code is split between colocated specs (`src/**/*.{test,spec}.ts`) and higher-level suites in `tests/`
- Use `tests/utils/` helpers when exercising the CLI or SSE flows
- Mirror production directory names when adding new suites
- End filenames with `.test.ts` or `.spec.ts` for automatic discovery
### Build Artifacts
- `dist/` - Backend build output (TypeScript compilation)
- `frontend/dist/` - Frontend build output (Vite bundle)
- `coverage/` - Test coverage reports
- **Never edit these manually**
### Localization
- Translations sit in `locales/` (en.json, fr.json, tr.json, zh.json)
- Frontend uses react-i18next
### Docker and Deployment
- `Dockerfile` - Multi-stage build with Python base + Node.js
- `entrypoint.sh` - Docker startup script
- `bin/cli.js` - NPM package CLI entry point
## Build, Test, and Development Commands ## Build, Test, and Development Commands
- `pnpm dev` runs backend (`tsx watch src/index.ts`) and frontend (`vite`) together for local iteration. ### Development Environment
- `pnpm backend:dev`, `pnpm frontend:dev`, and `pnpm frontend:preview` target each surface independently; prefer them when debugging one stack.
- `pnpm build` executes `pnpm backend:build` (TypeScript to `dist/`) and `pnpm frontend:build`; run before release or publishing. ```bash
- `pnpm test`, `pnpm test:watch`, and `pnpm test:coverage` drive Jest; `pnpm lint` and `pnpm format` enforce style via ESLint and Prettier. # Start both backend and frontend (recommended for most development)
pnpm dev # Backend on :3001, Frontend on :5173
# OR start separately (required on Windows, optional on Linux/macOS)
# Terminal 1: Backend only
pnpm backend:dev # Runs on port 3000 (or PORT env var)
# Terminal 2: Frontend only
pnpm frontend:dev # Runs on port 5173, proxies API to backend
# Frontend preview (production build)
pnpm frontend:preview # Preview production build
```
**NEVER CANCEL**: Development servers may take 10-15 seconds to fully initialize all MCP servers.
### Production Build
```bash
# Full production build - takes ~10 seconds total
pnpm build # NEVER CANCEL - Set timeout to 60+ seconds
# Individual builds
pnpm backend:build # TypeScript compilation to dist/ - ~5 seconds
pnpm frontend:build # Vite build to frontend/dist/ - ~5 seconds
# Start production server
pnpm start # Requires dist/ and frontend/dist/ to exist
```
Run `pnpm build` before release or publishing.
### Testing and Validation
```bash
# Run all tests - takes ~16 seconds with 73 tests
pnpm test:ci # NEVER CANCEL - Set timeout to 60+ seconds
# Development testing
pnpm test # Interactive mode
pnpm test:watch # Watch mode for development
pnpm test:coverage # With coverage report
# Code quality
pnpm lint # ESLint - ~3 seconds
pnpm format # Prettier formatting - ~3 seconds
```
**CRITICAL**: All tests MUST pass before committing. Do not modify tests to make them pass unless specifically required for your changes.
### Performance Notes
- **Install time**: pnpm install takes ~30 seconds
- **Build time**: Full build takes ~10 seconds
- **Test time**: Complete test suite takes ~16 seconds
- **Startup time**: Backend initialization takes 10-15 seconds (MCP server connections)
## Coding Style & Naming Conventions ## Coding Style & Naming Conventions
- TypeScript everywhere; default to 2-space indentation and single quotes, letting Prettier settle formatting. ESLint configuration assumes ES modules. - **TypeScript everywhere**: Default to 2-space indentation and single quotes, letting Prettier settle formatting
- Name services and data access layers with suffixes (`UserService`, `AuthDao`), React components and files in `PascalCase`, and utility modules in `camelCase`. - **ESM modules**: Always use `.js` extensions in imports, not `.ts` (e.g., `import { something } from './file.js'`)
- Keep DTOs and shared types in `src/types` to avoid duplication; re-export through index files only when it clarifies imports. - **English only**: All code comments must be written in English
- **TypeScript strict**: Follow strict type checking rules
- **Naming conventions**:
- Services and data access layers: Use suffixes (`UserService`, `AuthDao`)
- React components and files: `PascalCase`
- Utility modules: `camelCase`
- **Types and DTOs**: Keep in `src/types` to avoid duplication; re-export through index files only when it clarifies imports
- **ESLint configuration**: Assumes ES modules
## Testing Guidelines ## Testing Guidelines
@@ -28,12 +216,86 @@ These notes align current contributors around the code layout, daily commands, a
- Mirror production directory names when adding new suites and end filenames with `.test.ts` or `.spec.ts` for automatic discovery. - Mirror production directory names when adding new suites and end filenames with `.test.ts` or `.spec.ts` for automatic discovery.
- Aim to maintain or raise coverage when touching critical flows (auth, OAuth, SSE); add integration tests under `tests/integration/` when touching cross-service logic. - Aim to maintain or raise coverage when touching critical flows (auth, OAuth, SSE); add integration tests under `tests/integration/` when touching cross-service logic.
## Key Configuration Notes
- **MCP servers**: Defined in `mcp_settings.json` with command/args
- **Endpoints**: `/mcp/{group|server}` and `/mcp/$smart` for routing
- **i18n**: Frontend uses react-i18next with files in `locales/` folder
- **Authentication**: JWT tokens with bcrypt password hashing
- **Default credentials**: admin/admin123 (configured in mcp_settings.json)
## Development Entry Points
### Adding a new MCP server
1. Add server definition to `mcp_settings.json`
2. Restart backend to load new server
3. Check logs for successful connection
4. Test via dashboard or API endpoints
### API development
1. Define route in `src/routes/`
2. Implement controller in `src/controllers/`
3. Add types in `src/types/index.ts` if needed
4. Write tests in `tests/controllers/`
### Frontend development
1. Create/modify components in `frontend/src/components/`
2. Add pages in `frontend/src/pages/`
3. Update routing if needed
4. Test in development mode with `pnpm frontend:dev`
### Documentation
1. Update or add docs in `docs/` folder
2. Ensure README.md reflects any major changes
## Commit & Pull Request Guidelines ## Commit & Pull Request Guidelines
- Follow the existing Conventional Commit pattern (`feat:`, `fix:`, `chore:`, etc.) with imperative, present-tense summaries and optional multi-line context. - Follow the existing Conventional Commit pattern (`feat:`, `fix:`, `chore:`, etc.) with imperative, present-tense summaries and optional multi-line context.
- Each PR should describe the behavior change, list testing performed, and link issues; include before/after screenshots or GIFs for frontend tweaks. - Each PR should describe the behavior change, list testing performed, and link issues; include before/after screenshots or GIFs for frontend tweaks.
- Re-run `pnpm build` and `pnpm test` before requesting review, and ensure generated artifacts stay out of the diff. - Re-run `pnpm build` and `pnpm test` before requesting review, and ensure generated artifacts stay out of the diff.
### Before Committing - ALWAYS Run
```bash
pnpm lint # Must pass - ~3 seconds
pnpm backend:build # Must compile - ~5 seconds
pnpm test:ci # All tests must pass - ~16 seconds
pnpm build # Full build must work - ~10 seconds
```
**CRITICAL**: CI will fail if any of these commands fail. Fix issues locally first.
### CI Pipeline (.github/workflows/ci.yml)
- Runs on Node.js 20.x
- Tests: linting, type checking, unit tests with coverage
- **NEVER CANCEL**: CI builds may take 2-3 minutes total
## Troubleshooting
### Common Issues
- **"uvx command not found"**: Some MCP servers require `uvx` (Python package manager) - this is expected in development
- **Port already in use**: Change PORT environment variable or kill existing processes
- **Frontend not loading**: Ensure frontend was built with `pnpm frontend:build`
- **MCP server connection failed**: Check server command/args in `mcp_settings.json`
### Build Failures
- **TypeScript errors**: Run `pnpm backend:build` to see compilation errors
- **Test failures**: Run `pnpm test:verbose` for detailed test output
- **Lint errors**: Run `pnpm lint` and fix reported issues
### Development Issues
- **Backend not starting**: Check for port conflicts, verify `mcp_settings.json` syntax
- **Frontend proxy errors**: Ensure backend is running before starting frontend
- **Hot reload not working**: Restart development server
## DAO Layer & Dual Data Source ## DAO Layer & Dual Data Source
MCPHub supports **JSON file** (default) and **PostgreSQL** storage. Set `USE_DB=true` + `DB_URL` to switch. MCPHub supports **JSON file** (default) and **PostgreSQL** storage. Set `USE_DB=true` + `DB_URL` to switch.
@@ -63,16 +325,100 @@ When adding/changing fields, update **ALL** these files:
### Data Type Mapping ### Data Type Mapping
| Model | DAO | DB Entity | JSON Path | | Model | DAO | DB Entity | JSON Path |
| -------------- | ----------------- | -------------- | ------------------------ | | -------------- | ----------------- | -------------- | ------------------------- |
| `IUser` | `UserDao` | `User` | `settings.users[]` | | `IUser` | `UserDao` | `User` | `settings.users[]` |
| `ServerConfig` | `ServerDao` | `Server` | `settings.mcpServers{}` | | `ServerConfig` | `ServerDao` | `Server` | `settings.mcpServers{}` |
| `IGroup` | `GroupDao` | `Group` | `settings.groups[]` | | `IGroup` | `GroupDao` | `Group` | `settings.groups[]` |
| `SystemConfig` | `SystemConfigDao` | `SystemConfig` | `settings.systemConfig` | | `SystemConfig` | `SystemConfigDao` | `SystemConfig` | `settings.systemConfig` |
| `UserConfig` | `UserConfigDao` | `UserConfig` | `settings.userConfigs{}` | | `UserConfig` | `UserConfigDao` | `UserConfig` | `settings.userConfigs{}` |
| `BearerKey` | `BearerKeyDao` | `BearerKey` | `settings.bearerKeys[]` |
| `IOAuthClient` | `OAuthClientDao` | `OAuthClient` | `settings.oauthClients[]` |
| `IOAuthToken` | `OAuthTokenDao` | `OAuthToken` | `settings.oauthTokens[]` |
### Common Pitfalls ### Common Pitfalls
- Forgetting migration script → fields won't migrate to DB - Forgetting migration script → fields won't migrate to DB
- Optional fields need `nullable: true` in entity - Optional fields need `nullable: true` in entity
- Complex objects need `simple-json` column type - Complex objects need `simple-json` column type
## Auto-Evolution Guidelines for AI Agents
**This section provides guidelines for AI agents to automatically maintain and improve this document.**
### When to Update AGENTS.md
AI agents MUST update this document in the following situations:
#### 1. Code-Documentation Mismatch Detected
When executing tasks, if you discover that:
- The actual code structure differs from descriptions in this document
- File paths, imports, or module organization has changed
- New critical files or directories exist that aren't documented
- Documented files or patterns no longer exist
**Action**: Immediately update the relevant section to reflect the current codebase state.
**Example scenarios**:
- A controller is now in `src/api/controllers/` instead of `src/controllers/`
- New middleware files exist that should be documented
- The DAO implementation has been refactored with a different structure
- Build output directories have changed
#### 2. User Preferences and Requirements
During conversation, if the user expresses:
- **Coding preferences**: Indentation style, naming conventions, code organization patterns
- **Workflow requirements**: Required validation steps, commit procedures, testing expectations
- **Tool preferences**: Preferred libraries, frameworks, or development tools
- **Quality standards**: Code review criteria, documentation requirements, error handling patterns
- **Development principles**: Architecture decisions, design patterns, best practices
**Action**: Add or update the relevant section to capture these preferences for future reference.
**Example scenarios**:
- User prefers async/await over promises → Update coding style section
- User requires specific test coverage thresholds → Update testing guidelines
- User has strong opinions about error handling → Add to development process section
- User establishes new deployment procedures → Update deployment section
### How to Update AGENTS.md
1. **Identify the Section**: Determine which section needs updating based on the type of change
2. **Make Precise Changes**: Update only the relevant content, maintaining the document structure
3. **Preserve Format**: Keep the existing markdown formatting and organization
4. **Add Context**: If adding new content, ensure it fits logically within existing sections
5. **Verify Accuracy**: After updating, ensure the new information is accurate and complete
### Update Principles
- **Accuracy First**: Documentation must reflect the actual current state
- **Clarity**: Use clear, concise language; avoid ambiguity
- **Completeness**: Include sufficient detail for agents to work effectively
- **Consistency**: Maintain consistent terminology and formatting throughout
- **Actionability**: Focus on concrete, actionable guidance rather than vague descriptions
### Self-Correction Process
Before completing any task:
1. Review relevant sections of AGENTS.md
2. During execution, note any discrepancies between documentation and reality
3. Update AGENTS.md to correct discrepancies
4. Verify the update doesn't conflict with other sections
5. Proceed with the original task using the updated information
### Meta-Update Rule
If this auto-evolution section itself needs improvement based on experience:
- Update it to better serve future agents
- Add new scenarios or principles as they emerge
- Refine the update process based on what works well
**Remember**: This document is a living guide. Keeping it accurate and current is as important as following it.

View File

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

View File

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

View File

@@ -119,7 +119,7 @@ services:
- mcphub-network - mcphub-network
postgres: postgres:
image: postgres:15-alpine image: pgvector/pgvector:pg17
container_name: mcphub-postgres container_name: mcphub-postgres
environment: environment:
- POSTGRES_DB=mcphub - POSTGRES_DB=mcphub
@@ -203,7 +203,7 @@ services:
retries: 3 retries: 3
postgres: postgres:
image: postgres:15-alpine image: pgvector/pgvector:pg17
container_name: mcphub-postgres container_name: mcphub-postgres
environment: environment:
- POSTGRES_DB=mcphub - POSTGRES_DB=mcphub
@@ -305,7 +305,7 @@ services:
- mcphub-dev - mcphub-dev
postgres: postgres:
image: postgres:15-alpine image: pgvector/pgvector:pg17
container_name: mcphub-postgres-dev container_name: mcphub-postgres-dev
environment: environment:
- POSTGRES_DB=mcphub - POSTGRES_DB=mcphub
@@ -445,7 +445,7 @@ Add backup service to your `docker-compose.yml`:
```yaml ```yaml
services: services:
backup: backup:
image: postgres:15-alpine image: pgvector/pgvector:pg17
container_name: mcphub-backup container_name: mcphub-backup
environment: environment:
- PGPASSWORD=${POSTGRES_PASSWORD} - PGPASSWORD=${POSTGRES_PASSWORD}

View File

@@ -78,7 +78,7 @@ Smart Routing requires additional setup compared to basic MCPHub usage:
- ./mcp_settings.json:/app/mcp_settings.json - ./mcp_settings.json:/app/mcp_settings.json
postgres: postgres:
image: pgvector/pgvector:pg16 image: pgvector/pgvector:pg17
environment: environment:
- POSTGRES_DB=mcphub - POSTGRES_DB=mcphub
- POSTGRES_USER=mcphub - POSTGRES_USER=mcphub
@@ -146,7 +146,7 @@ Smart Routing requires additional setup compared to basic MCPHub usage:
spec: spec:
containers: containers:
- name: postgres - name: postgres
image: pgvector/pgvector:pg16 image: pgvector/pgvector:pg17
env: env:
- name: POSTGRES_DB - name: POSTGRES_DB
value: mcphub value: mcphub

View File

@@ -96,7 +96,7 @@ Optional for Smart Routing:
# Optional: PostgreSQL for Smart Routing # Optional: PostgreSQL for Smart Routing
postgres: postgres:
image: pgvector/pgvector:pg16 image: pgvector/pgvector:pg17
environment: environment:
POSTGRES_DB: mcphub POSTGRES_DB: mcphub
POSTGRES_USER: mcphub POSTGRES_USER: mcphub

View File

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

View File

@@ -119,7 +119,7 @@ services:
- mcphub-network - mcphub-network
postgres: postgres:
image: postgres:15-alpine image: pgvector/pgvector:pg17
container_name: mcphub-postgres container_name: mcphub-postgres
environment: environment:
- POSTGRES_DB=mcphub - POSTGRES_DB=mcphub
@@ -203,7 +203,7 @@ services:
retries: 3 retries: 3
postgres: postgres:
image: postgres:15-alpine image: pgvector/pgvector:pg17
container_name: mcphub-postgres container_name: mcphub-postgres
environment: environment:
- POSTGRES_DB=mcphub - POSTGRES_DB=mcphub
@@ -305,7 +305,7 @@ services:
- mcphub-dev - mcphub-dev
postgres: postgres:
image: postgres:15-alpine image: pgvector/pgvector:pg17
container_name: mcphub-postgres-dev container_name: mcphub-postgres-dev
environment: environment:
- POSTGRES_DB=mcphub - POSTGRES_DB=mcphub
@@ -445,7 +445,7 @@ secrets:
```yaml ```yaml
services: services:
backup: backup:
image: postgres:15-alpine image: pgvector/pgvector:pg17
container_name: mcphub-backup container_name: mcphub-backup
environment: environment:
- PGPASSWORD=${POSTGRES_PASSWORD} - PGPASSWORD=${POSTGRES_PASSWORD}

View File

@@ -96,7 +96,7 @@ description: '各种平台的详细安装说明'
# 可选:用于智能路由的 PostgreSQL # 可选:用于智能路由的 PostgreSQL
postgres: postgres:
image: pgvector/pgvector:pg16 image: pgvector/pgvector:pg17
environment: environment:
POSTGRES_DB: mcphub POSTGRES_DB: mcphub
POSTGRES_USER: mcphub POSTGRES_USER: mcphub

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import React, {
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ApiResponse, BearerKey } from '@/types'; import { ApiResponse, BearerKey } from '@/types';
import { useToast } from '@/contexts/ToastContext'; import { useToast } from '@/contexts/ToastContext';
import { useAuth } from '@/contexts/AuthContext';
import { apiGet, apiPut, apiPost, apiDelete } from '@/utils/fetchInterceptor'; import { apiGet, apiPut, apiPost, apiDelete } from '@/utils/fetchInterceptor';
// Define types for the settings data // Define types for the settings data
@@ -153,6 +154,7 @@ interface SettingsProviderProps {
export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children }) => { export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { showToast } = useToast(); const { showToast } = useToast();
const { auth } = useAuth();
const [routingConfig, setRoutingConfig] = useState<RoutingConfig>({ const [routingConfig, setRoutingConfig] = useState<RoutingConfig>({
enableGlobalRoute: true, enableGlobalRoute: true,
@@ -746,6 +748,15 @@ export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children })
fetchSettings(); fetchSettings();
}, [fetchSettings, refreshKey]); }, [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(() => { useEffect(() => {
if (routingConfig) { if (routingConfig) {
setTempRoutingConfig({ setTempRoutingConfig({

View File

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

View File

@@ -25,7 +25,7 @@ interface BearerKeyRowProps {
name: string; name: string;
token: string; token: string;
enabled: boolean; enabled: boolean;
accessType: 'all' | 'groups' | 'servers'; accessType: 'all' | 'groups' | 'servers' | 'custom';
allowedGroups: string; allowedGroups: string;
allowedServers: string; allowedServers: string;
}, },
@@ -47,7 +47,7 @@ const BearerKeyRow: React.FC<BearerKeyRowProps> = ({
const [name, setName] = useState(keyData.name); const [name, setName] = useState(keyData.name);
const [token, setToken] = useState(keyData.token); const [token, setToken] = useState(keyData.token);
const [enabled, setEnabled] = useState<boolean>(keyData.enabled); 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', keyData.accessType || 'all',
); );
const [selectedGroups, setSelectedGroups] = useState<string[]>(keyData.allowedGroups || []); const [selectedGroups, setSelectedGroups] = useState<string[]>(keyData.allowedGroups || []);
@@ -105,6 +105,13 @@ const BearerKeyRow: React.FC<BearerKeyRowProps> = ({
); );
return; 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); setSaving(true);
try { try {
@@ -135,6 +142,31 @@ const BearerKeyRow: React.FC<BearerKeyRowProps> = ({
}; };
const isGroupsMode = accessType === 'groups'; 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) { if (isEditing) {
return ( return (
@@ -194,7 +226,9 @@ const BearerKeyRow: React.FC<BearerKeyRowProps> = ({
<select <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" 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} 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} disabled={loading}
> >
<option value="all">{t('settings.bearerKeyAccessAll') || 'All Resources'}</option> <option value="all">{t('settings.bearerKeyAccessAll') || 'All Resources'}</option>
@@ -204,29 +238,65 @@ const BearerKeyRow: React.FC<BearerKeyRowProps> = ({
<option value="servers"> <option value="servers">
{t('settings.bearerKeyAccessServers') || 'Specific Servers'} {t('settings.bearerKeyAccessServers') || 'Specific Servers'}
</option> </option>
<option value="custom">
{t('settings.bearerKeyAccessCustom') || 'Custom (Groups & Servers)'}
</option>
</select> </select>
</div> </div>
<div className="flex-1 min-w-[200px]"> {/* Show single selector for groups or servers mode */}
<label {!isCustomMode && (
className={`block text-sm font-medium mb-1 ${accessType === 'all' ? 'text-gray-400' : 'text-gray-700'}`} <div className="flex-1 min-w-[200px]">
> <label
{isGroupsMode className={`block text-sm font-medium mb-1 ${accessType === 'all' ? 'text-gray-400' : 'text-gray-700'}`}
? t('settings.bearerKeyAllowedGroups') || 'Allowed groups' >
: t('settings.bearerKeyAllowedServers') || 'Allowed servers'} {isGroupsMode
</label> ? t('settings.bearerKeyAllowedGroups') || 'Allowed groups'
<MultiSelect : t('settings.bearerKeyAllowedServers') || 'Allowed servers'}
options={isGroupsMode ? availableGroups : availableServers} </label>
selected={isGroupsMode ? selectedGroups : selectedServers} <MultiSelect
onChange={isGroupsMode ? setSelectedGroups : setSelectedServers} options={isGroupsMode ? availableGroups : availableServers}
placeholder={ selected={isGroupsMode ? selectedGroups : selectedServers}
isGroupsMode onChange={isGroupsMode ? setSelectedGroups : setSelectedServers}
? t('settings.selectGroups') || 'Select groups...' placeholder={
: t('settings.selectServers') || 'Select servers...' isGroupsMode
} ? t('settings.selectGroups') || 'Select groups...'
disabled={loading || accessType === 'all'} : t('settings.selectServers') || 'Select servers...'
/> }
</div> 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"> <div className="flex justify-end gap-2">
<button <button
@@ -281,11 +351,7 @@ const BearerKeyRow: React.FC<BearerKeyRowProps> = ({
</span> </span>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{keyData.accessType === 'all' {formatAccessTypeDisplay(keyData)}
? t('settings.bearerKeyAccessAll') || 'All Resources'
: keyData.accessType === 'groups'
? `${t('settings.bearerKeyAccessGroups') || 'Groups'}: ${keyData.allowedGroups}`
: `${t('settings.bearerKeyAccessServers') || 'Servers'}: ${keyData.allowedServers}`}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button <button
@@ -558,12 +624,6 @@ const SettingsPage: React.FC = () => {
}); });
}; };
const saveSmartRoutingConfig = async (
key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel',
) => {
await updateSmartRoutingConfig(key, tempSmartRoutingConfig[key]);
};
const handleMCPRouterConfigChange = ( const handleMCPRouterConfigChange = (
key: 'apiKey' | 'referer' | 'title' | 'baseUrl', key: 'apiKey' | 'referer' | 'title' | 'baseUrl',
value: string, 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 = () => { const handlePasswordChangeSuccess = () => {
setTimeout(() => { setTimeout(() => {
navigate('/'); navigate('/');
@@ -718,7 +803,7 @@ const SettingsPage: React.FC = () => {
name: string; name: string;
token: string; token: string;
enabled: boolean; enabled: boolean;
accessType: 'all' | 'groups' | 'servers'; accessType: 'all' | 'groups' | 'servers' | 'custom';
allowedGroups: string; allowedGroups: string;
allowedServers: string; allowedServers: string;
}>({ }>({
@@ -746,10 +831,10 @@ const SettingsPage: React.FC = () => {
// Reset selected arrays when accessType changes // Reset selected arrays when accessType changes
useEffect(() => { useEffect(() => {
if (newBearerKey.accessType !== 'groups') { if (newBearerKey.accessType !== 'groups' && newBearerKey.accessType !== 'custom') {
setNewSelectedGroups([]); setNewSelectedGroups([]);
} }
if (newBearerKey.accessType !== 'servers') { if (newBearerKey.accessType !== 'servers' && newBearerKey.accessType !== 'custom') {
setNewSelectedServers([]); setNewSelectedServers([]);
} }
}, [newBearerKey.accessType]); }, [newBearerKey.accessType]);
@@ -847,6 +932,17 @@ const SettingsPage: React.FC = () => {
); );
return; 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({ await createBearerKey({
name: newBearerKey.name, name: newBearerKey.name,
@@ -854,11 +950,13 @@ const SettingsPage: React.FC = () => {
enabled: newBearerKey.enabled, enabled: newBearerKey.enabled,
accessType: newBearerKey.accessType, accessType: newBearerKey.accessType,
allowedGroups: allowedGroups:
newBearerKey.accessType === 'groups' && newSelectedGroups.length > 0 (newBearerKey.accessType === 'groups' || newBearerKey.accessType === 'custom') &&
newSelectedGroups.length > 0
? newSelectedGroups ? newSelectedGroups
: undefined, : undefined,
allowedServers: allowedServers:
newBearerKey.accessType === 'servers' && newSelectedServers.length > 0 (newBearerKey.accessType === 'servers' || newBearerKey.accessType === 'custom') &&
newSelectedServers.length > 0
? newSelectedServers ? newSelectedServers
: undefined, : undefined,
} as any); } as any);
@@ -882,7 +980,7 @@ const SettingsPage: React.FC = () => {
name: string; name: string;
token: string; token: string;
enabled: boolean; enabled: boolean;
accessType: 'all' | 'groups' | 'servers'; accessType: 'all' | 'groups' | 'servers' | 'custom';
allowedGroups: string; allowedGroups: string;
allowedServers: string; allowedServers: string;
}, },
@@ -1109,7 +1207,7 @@ const SettingsPage: React.FC = () => {
onChange={(e) => onChange={(e) =>
setNewBearerKey((prev) => ({ setNewBearerKey((prev) => ({
...prev, ...prev,
accessType: e.target.value as 'all' | 'groups' | 'servers', accessType: e.target.value as 'all' | 'groups' | 'servers' | 'custom',
})) }))
} }
disabled={loading} disabled={loading}
@@ -1123,41 +1221,75 @@ const SettingsPage: React.FC = () => {
<option value="servers"> <option value="servers">
{t('settings.bearerKeyAccessServers') || 'Specific Servers'} {t('settings.bearerKeyAccessServers') || 'Specific Servers'}
</option> </option>
<option value="custom">
{t('settings.bearerKeyAccessCustom') || 'Custom (Groups & Servers)'}
</option>
</select> </select>
</div> </div>
<div className="flex-1 min-w-[200px]"> {newBearerKey.accessType !== 'custom' && (
<label <div className="flex-1 min-w-[200px]">
className={`block text-sm font-medium mb-1 ${newBearerKey.accessType === 'all' ? 'text-gray-400' : 'text-gray-700'}`} <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' {newBearerKey.accessType === 'groups'
: t('settings.bearerKeyAllowedServers') || 'Allowed servers'} ? t('settings.bearerKeyAllowedGroups') || 'Allowed groups'
</label> : t('settings.bearerKeyAllowedServers') || 'Allowed servers'}
<MultiSelect </label>
options={ <MultiSelect
newBearerKey.accessType === 'groups' options={
? availableGroups newBearerKey.accessType === 'groups'
: availableServers ? availableGroups
} : availableServers
selected={ }
newBearerKey.accessType === 'groups' selected={
? newSelectedGroups newBearerKey.accessType === 'groups'
: newSelectedServers ? newSelectedGroups
} : newSelectedServers
onChange={ }
newBearerKey.accessType === 'groups' onChange={
? setNewSelectedGroups newBearerKey.accessType === 'groups'
: setNewSelectedServers ? setNewSelectedGroups
} : setNewSelectedServers
placeholder={ }
newBearerKey.accessType === 'groups' placeholder={
? t('settings.selectGroups') || 'Select groups...' newBearerKey.accessType === 'groups'
: t('settings.selectServers') || 'Select servers...' ? t('settings.selectGroups') || 'Select groups...'
} : t('settings.selectServers') || 'Select servers...'
disabled={loading || newBearerKey.accessType === 'all'} }
/> disabled={loading || newBearerKey.accessType === 'all'}
</div> />
</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"> <div className="flex justify-end gap-2">
<button <button
@@ -1214,31 +1346,27 @@ const SettingsPage: React.FC = () => {
/> />
</div> </div>
<div className="p-3 bg-gray-50 rounded-md"> {/* hide when DB_URL env is set */}
<div className="mb-2"> {smartRoutingConfig.dbUrl !== '${DB_URL}' && (
<h3 className="font-medium text-gray-700"> <div className="p-3 bg-gray-50 rounded-md">
<span className="text-red-500 px-1">*</span> <div className="mb-2">
{t('settings.dbUrl')} <h3 className="font-medium text-gray-700">
</h3> <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>
<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="p-3 bg-gray-50 rounded-md">
<div className="mb-2"> <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" 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} 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>
</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" 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} 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>
</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" 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} 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> </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>
)} )}
</div> </div>

View File

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

View File

@@ -310,7 +310,7 @@ export interface ApiResponse<T = any> {
} }
// Bearer authentication key configuration (frontend view model) // Bearer authentication key configuration (frontend view model)
export type BearerKeyAccessType = 'all' | 'groups' | 'servers'; export type BearerKeyAccessType = 'all' | 'groups' | 'servers' | 'custom';
export interface BearerKey { export interface BearerKey {
id: string; id: string;

View File

@@ -61,6 +61,7 @@
"emptyFields": "Username and password cannot be empty", "emptyFields": "Username and password cannot be empty",
"loginFailed": "Login failed, please check your username and password", "loginFailed": "Login failed, please check your username and password",
"loginError": "An error occurred during login", "loginError": "An error occurred during login",
"serverUnavailable": "Unable to connect to the server. Please check your network connection or try again later",
"currentPassword": "Current Password", "currentPassword": "Current Password",
"newPassword": "New Password", "newPassword": "New Password",
"confirmPassword": "Confirm Password", "confirmPassword": "Confirm Password",
@@ -567,6 +568,7 @@
"bearerKeyAccessAll": "All", "bearerKeyAccessAll": "All",
"bearerKeyAccessGroups": "Groups", "bearerKeyAccessGroups": "Groups",
"bearerKeyAccessServers": "Servers", "bearerKeyAccessServers": "Servers",
"bearerKeyAccessCustom": "Custom",
"bearerKeyAllowedGroups": "Allowed groups", "bearerKeyAllowedGroups": "Allowed groups",
"bearerKeyAllowedServers": "Allowed servers", "bearerKeyAllowedServers": "Allowed servers",
"addBearerKey": "Add key", "addBearerKey": "Add key",

View File

@@ -61,6 +61,7 @@
"emptyFields": "Le nom d'utilisateur et le mot de passe ne peuvent pas être vides", "emptyFields": "Le nom d'utilisateur et le mot de passe ne peuvent pas être vides",
"loginFailed": "Échec de la connexion, veuillez vérifier votre nom d'utilisateur et votre mot de passe", "loginFailed": "Échec de la connexion, veuillez vérifier votre nom d'utilisateur et votre mot de passe",
"loginError": "Une erreur est survenue lors de la connexion", "loginError": "Une erreur est survenue lors de la connexion",
"serverUnavailable": "Impossible de se connecter au serveur. Veuillez vérifier votre connexion réseau ou réessayer plus tard",
"currentPassword": "Mot de passe actuel", "currentPassword": "Mot de passe actuel",
"newPassword": "Nouveau mot de passe", "newPassword": "Nouveau mot de passe",
"confirmPassword": "Confirmer le mot de passe", "confirmPassword": "Confirmer le mot de passe",
@@ -568,6 +569,7 @@
"bearerKeyAccessAll": "Toutes", "bearerKeyAccessAll": "Toutes",
"bearerKeyAccessGroups": "Groupes", "bearerKeyAccessGroups": "Groupes",
"bearerKeyAccessServers": "Serveurs", "bearerKeyAccessServers": "Serveurs",
"bearerKeyAccessCustom": "Personnalisée",
"bearerKeyAllowedGroups": "Groupes autorisés", "bearerKeyAllowedGroups": "Groupes autorisés",
"bearerKeyAllowedServers": "Serveurs autorisés", "bearerKeyAllowedServers": "Serveurs autorisés",
"addBearerKey": "Ajouter une clé", "addBearerKey": "Ajouter une clé",

View File

@@ -61,6 +61,7 @@
"emptyFields": "Kullanıcı adı ve şifre boş olamaz", "emptyFields": "Kullanıcı adı ve şifre boş olamaz",
"loginFailed": "Giriş başarısız, lütfen kullanıcı adınızı ve şifrenizi kontrol edin", "loginFailed": "Giriş başarısız, lütfen kullanıcı adınızı ve şifrenizi kontrol edin",
"loginError": "Giriş sırasında bir hata oluştu", "loginError": "Giriş sırasında bir hata oluştu",
"serverUnavailable": "Sunucuya bağlanılamıyor. Lütfen ağ bağlantınızı kontrol edin veya daha sonra tekrar deneyin",
"currentPassword": "Mevcut Şifre", "currentPassword": "Mevcut Şifre",
"newPassword": "Yeni Şifre", "newPassword": "Yeni Şifre",
"confirmPassword": "Şifreyi Onayla", "confirmPassword": "Şifreyi Onayla",
@@ -568,6 +569,7 @@
"bearerKeyAccessAll": "Tümü", "bearerKeyAccessAll": "Tümü",
"bearerKeyAccessGroups": "Gruplar", "bearerKeyAccessGroups": "Gruplar",
"bearerKeyAccessServers": "Sunucular", "bearerKeyAccessServers": "Sunucular",
"bearerKeyAccessCustom": "Özel",
"bearerKeyAllowedGroups": "İzin verilen gruplar", "bearerKeyAllowedGroups": "İzin verilen gruplar",
"bearerKeyAllowedServers": "İzin verilen sunucular", "bearerKeyAllowedServers": "İzin verilen sunucular",
"addBearerKey": "Anahtar ekle", "addBearerKey": "Anahtar ekle",

View File

@@ -61,6 +61,7 @@
"emptyFields": "用户名和密码不能为空", "emptyFields": "用户名和密码不能为空",
"loginFailed": "登录失败,请检查用户名和密码", "loginFailed": "登录失败,请检查用户名和密码",
"loginError": "登录过程中出现错误", "loginError": "登录过程中出现错误",
"serverUnavailable": "无法连接到服务器,请检查网络连接或稍后再试",
"currentPassword": "当前密码", "currentPassword": "当前密码",
"newPassword": "新密码", "newPassword": "新密码",
"confirmPassword": "确认密码", "confirmPassword": "确认密码",
@@ -569,6 +570,7 @@
"bearerKeyAccessAll": "全部", "bearerKeyAccessAll": "全部",
"bearerKeyAccessGroups": "指定分组", "bearerKeyAccessGroups": "指定分组",
"bearerKeyAccessServers": "指定服务器", "bearerKeyAccessServers": "指定服务器",
"bearerKeyAccessCustom": "自定义",
"bearerKeyAllowedGroups": "允许访问的分组", "bearerKeyAllowedGroups": "允许访问的分组",
"bearerKeyAllowedServers": "允许访问的服务器", "bearerKeyAllowedServers": "允许访问的服务器",
"addBearerKey": "新增密钥", "addBearerKey": "新增密钥",

View File

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

View File

@@ -73,6 +73,7 @@
"postgres": "^3.4.7", "postgres": "^3.4.7",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"typeorm": "^0.3.26", "typeorm": "^0.3.26",
"undici": "^7.16.0",
"uuid": "^11.1.0" "uuid": "^11.1.0"
}, },
"devDependencies": { "devDependencies": {

9
pnpm-lock.yaml generated
View File

@@ -99,6 +99,9 @@ importers:
typeorm: typeorm:
specifier: ^0.3.26 specifier: ^0.3.26
version: 0.3.27(pg@8.16.3)(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@24.6.2)(typescript@5.9.2)) version: 0.3.27(pg@8.16.3)(reflect-metadata@0.2.2)(ts-node@10.9.2(@swc/core@1.15.3)(@types/node@24.6.2)(typescript@5.9.2))
undici:
specifier: ^7.16.0
version: 7.16.0
uuid: uuid:
specifier: ^11.1.0 specifier: ^11.1.0
version: 11.1.0 version: 11.1.0
@@ -4431,6 +4434,10 @@ packages:
undici-types@7.13.0: undici-types@7.13.0:
resolution: {integrity: sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==} resolution: {integrity: sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==}
undici@7.16.0:
resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==}
engines: {node: '>=20.18.1'}
universalify@2.0.1: universalify@2.0.1:
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
@@ -8946,6 +8953,8 @@ snapshots:
undici-types@7.13.0: {} undici-types@7.13.0: {}
undici@7.16.0: {}
universalify@2.0.1: {} universalify@2.0.1: {}
unpipe@1.0.0: {} unpipe@1.0.0: {}

View File

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

View File

@@ -66,6 +66,20 @@ export const getAllSettings = async (_: Request, res: Response): Promise<void> =
const systemConfigDao = getSystemConfigDao(); const systemConfigDao = getSystemConfigDao();
const systemConfig = await systemConfigDao.get(); const systemConfig = await systemConfigDao.get();
// Ensure smart routing config has DB URL set if environment variable is present
const dbUrlEnv = process.env.DB_URL || '';
if (!systemConfig.smartRouting) {
systemConfig.smartRouting = {
enabled: false,
dbUrl: dbUrlEnv ? '${DB_URL}' : '',
openaiApiBaseUrl: '',
openaiApiKey: '',
openaiApiEmbeddingModel: '',
};
} else if (!systemConfig.smartRouting.dbUrl) {
systemConfig.smartRouting.dbUrl = dbUrlEnv ? '${DB_URL}' : '';
}
// Get bearer auth keys from DAO // Get bearer auth keys from DAO
const bearerKeyDao = getBearerKeyDao(); const bearerKeyDao = getBearerKeyDao();
const bearerKeys = await bearerKeyDao.findAll(); const bearerKeys = await bearerKeyDao.findAll();
@@ -409,7 +423,7 @@ export const deleteServer = async (req: Request, res: Response): Promise<void> =
export const updateServer = async (req: Request, res: Response): Promise<void> => { export const updateServer = async (req: Request, res: Response): Promise<void> => {
try { try {
const { name } = req.params; const { name } = req.params;
const { config } = req.body; const { config, newName } = req.body;
if (!name) { if (!name) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
@@ -496,12 +510,52 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
config.owner = currentUser?.username || 'admin'; config.owner = currentUser?.username || 'admin';
} }
const result = await addOrUpdateServer(name, config, true); // Allow override for updates // Check if server name is being changed
const isRenaming = newName && newName !== name;
// If renaming, validate the new name and update references
if (isRenaming) {
const serverDao = getServerDao();
// Check if new name already exists
if (await serverDao.exists(newName)) {
res.status(400).json({
success: false,
message: `Server name '${newName}' already exists`,
});
return;
}
// Rename the server
const renamed = await serverDao.rename(name, newName);
if (!renamed) {
res.status(404).json({
success: false,
message: 'Server not found',
});
return;
}
// Update references in groups
const groupDao = getGroupDao();
await groupDao.updateServerName(name, newName);
// Update references in bearer keys
const bearerKeyDao = getBearerKeyDao();
await bearerKeyDao.updateServerName(name, newName);
}
// Use the final server name (new name if renaming, otherwise original name)
const finalName = isRenaming ? newName : name;
const result = await addOrUpdateServer(finalName, config, true); // Allow override for updates
if (result.success) { if (result.success) {
notifyToolChanged(name); notifyToolChanged(finalName);
res.json({ res.json({
success: true, success: true,
message: 'Server updated successfully', message: isRenaming
? `Server renamed and updated successfully`
: 'Server updated successfully',
}); });
} else { } else {
res.status(404).json({ res.status(404).json({
@@ -510,9 +564,10 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
}); });
} }
} catch (error) { } catch (error) {
console.error('Failed to update server:', error);
res.status(500).json({ res.status(500).json({
success: false, success: false,
message: 'Internal server error', message: error instanceof Error ? error.message : 'Internal server error',
}); });
} }
}; };
@@ -978,7 +1033,8 @@ export const updateSystemConfig = async (req: Request, res: Response): Promise<v
if (typeof smartRouting.enabled === 'boolean') { if (typeof smartRouting.enabled === 'boolean') {
// If enabling Smart Routing, validate required fields // If enabling Smart Routing, validate required fields
if (smartRouting.enabled) { if (smartRouting.enabled) {
const currentDbUrl = smartRouting.dbUrl || systemConfig.smartRouting.dbUrl; const currentDbUrl =
process.env.DB_URL || smartRouting.dbUrl || systemConfig.smartRouting.dbUrl;
const currentOpenaiApiKey = const currentOpenaiApiKey =
smartRouting.openaiApiKey || systemConfig.smartRouting.openaiApiKey; smartRouting.openaiApiKey || systemConfig.smartRouting.openaiApiKey;

View File

@@ -13,6 +13,10 @@ export interface BearerKeyDao {
create(data: Omit<BearerKey, 'id'>): Promise<BearerKey>; create(data: Omit<BearerKey, 'id'>): Promise<BearerKey>;
update(id: string, data: Partial<Omit<BearerKey, 'id'>>): Promise<BearerKey | null>; update(id: string, data: Partial<Omit<BearerKey, 'id'>>): Promise<BearerKey | null>;
delete(id: string): Promise<boolean>; delete(id: string): Promise<boolean>;
/**
* Update server name in all bearer keys (when server is renamed)
*/
updateServerName(oldName: string, newName: string): Promise<number>;
} }
/** /**
@@ -24,7 +28,10 @@ export class BearerKeyDaoImpl extends JsonFileBaseDao implements BearerKeyDao {
private async loadKeysWithMigration(): Promise<BearerKey[]> { private async loadKeysWithMigration(): Promise<BearerKey[]> {
const settings = await this.loadSettings(); const settings = await this.loadSettings();
if (Array.isArray(settings.bearerKeys) && settings.bearerKeys.length > 0) { // Treat an existing array (including an empty array) as already migrated.
// Otherwise, when there are no configured keys, we'd rewrite mcp_settings.json
// on every request, which also clears the global settings cache.
if (Array.isArray(settings.bearerKeys)) {
return settings.bearerKeys; return settings.bearerKeys;
} }
@@ -119,4 +126,34 @@ export class BearerKeyDaoImpl extends JsonFileBaseDao implements BearerKeyDao {
await this.saveKeys(next); await this.saveKeys(next);
return true; 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> { async delete(id: string): Promise<boolean> {
return await this.repository.delete(id); 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 * Find group by name
*/ */
findByName(name: string): Promise<IGroup | null>; findByName(name: string): Promise<IGroup | null>;
/**
* Update server name in all groups (when server is renamed)
*/
updateServerName(oldName: string, newName: string): Promise<number>;
} }
/** /**
@@ -218,4 +223,39 @@ export class GroupDaoImpl extends JsonFileBaseDao implements GroupDao {
const groups = await this.getAll(); const groups = await this.getAll();
return groups.find((group) => group.name === name) || null; return groups.find((group) => group.name === name) || null;
} }
async updateServerName(oldName: string, newName: string): Promise<number> {
const groups = await this.getAll();
let updatedCount = 0;
for (const group of groups) {
let updated = false;
const newServers = group.servers.map((server) => {
if (typeof server === 'string') {
if (server === oldName) {
updated = true;
return newName;
}
return server;
} else {
if (server.name === oldName) {
updated = true;
return { ...server, name: newName };
}
return server;
}
}) as IGroup['servers'];
if (updated) {
group.servers = newServers;
updatedCount++;
}
}
if (updatedCount > 0) {
await this.saveAll(groups);
}
return updatedCount;
}
} }

View File

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

View File

@@ -41,6 +41,11 @@ export interface ServerDao extends BaseDao<ServerConfigWithName, string> {
name: string, name: string,
prompts: Record<string, { enabled: boolean; description?: string }>, prompts: Record<string, { enabled: boolean; description?: string }>,
): Promise<boolean>; ): Promise<boolean>;
/**
* Rename a server (change its name/key)
*/
rename(oldName: string, newName: string): Promise<boolean>;
} }
/** /**
@@ -95,7 +100,8 @@ export class ServerDaoImpl extends JsonFileBaseDao implements ServerDao {
return { return {
...existing, ...existing,
...updates, ...updates,
name: existing.name, // Name should not be updated // Keep the existing name unless explicitly updating via rename
name: updates.name ?? existing.name,
}; };
} }
@@ -141,9 +147,7 @@ export class ServerDaoImpl extends JsonFileBaseDao implements ServerDao {
return null; return null;
} }
// Don't allow name changes const updatedServer = this.updateEntity(servers[index], updates);
const { name: _, ...allowedUpdates } = updates;
const updatedServer = this.updateEntity(servers[index], allowedUpdates);
servers[index] = updatedServer; servers[index] = updatedServer;
await this.saveAll(servers); await this.saveAll(servers);
@@ -207,4 +211,22 @@ export class ServerDaoImpl extends JsonFileBaseDao implements ServerDao {
const result = await this.update(name, { prompts }); const result = await this.update(name, { prompts });
return result !== null; return result !== null;
} }
async rename(oldName: string, newName: string): Promise<boolean> {
const servers = await this.getAll();
const index = servers.findIndex((server) => server.name === oldName);
if (index === -1) {
return false;
}
// Check if newName already exists
if (servers.find((server) => server.name === newName)) {
throw new Error(`Server ${newName} already exists`);
}
servers[index] = { ...servers[index], name: newName };
await this.saveAll(servers);
return true;
}
} }

View File

@@ -115,6 +115,15 @@ export class ServerDaoDbImpl implements ServerDao {
return result !== null; return result !== null;
} }
async rename(oldName: string, newName: string): Promise<boolean> {
// Check if newName already exists
if (await this.repository.exists(newName)) {
throw new Error(`Server ${newName} already exists`);
}
return await this.repository.rename(oldName, newName);
}
private mapToServerConfig(server: { private mapToServerConfig(server: {
name: string; name: string;
type?: string; type?: string;

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

View File

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

View File

@@ -89,6 +89,19 @@ export class ServerRepository {
async setEnabled(name: string, enabled: boolean): Promise<Server | null> { async setEnabled(name: string, enabled: boolean): Promise<Server | null> {
return await this.update(name, { enabled }); return await this.update(name, { enabled });
} }
/**
* Rename a server
*/
async rename(oldName: string, newName: string): Promise<boolean> {
const server = await this.findByName(oldName);
if (!server) {
return false;
}
server.name = newName;
await this.repository.save(server);
return true;
}
} }
export default ServerRepository; export default ServerRepository;

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ import {
StreamableHTTPClientTransport, StreamableHTTPClientTransport,
StreamableHTTPClientTransportOptions, StreamableHTTPClientTransportOptions,
} from '@modelcontextprotocol/sdk/client/streamableHttp.js'; } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { createFetchWithProxy, getProxyConfigFromEnv } from './proxy.js';
import { ServerInfo, ServerConfig, Tool } from '../types/index.js'; import { ServerInfo, ServerConfig, Tool } from '../types/index.js';
import { expandEnvVars, replaceEnvVars, getNameSeparator } from '../config/index.js'; import { expandEnvVars, replaceEnvVars, getNameSeparator } from '../config/index.js';
import config from '../config/index.js'; import config from '../config/index.js';
@@ -134,6 +135,10 @@ export const cleanupAllServers = (): void => {
// Helper function to create transport based on server configuration // Helper function to create transport based on server configuration
export const createTransportFromConfig = async (name: string, conf: ServerConfig): Promise<any> => { export const createTransportFromConfig = async (name: string, conf: ServerConfig): Promise<any> => {
let transport; let transport;
const env: Record<string, string> = {
...(process.env as Record<string, string>),
...replaceEnvVars(conf.env || {}),
};
if (conf.type === 'streamable-http') { if (conf.type === 'streamable-http') {
const options: StreamableHTTPClientTransportOptions = {}; const options: StreamableHTTPClientTransportOptions = {};
@@ -152,6 +157,8 @@ export const createTransportFromConfig = async (name: string, conf: ServerConfig
console.log(`OAuth provider configured for server: ${name}`); console.log(`OAuth provider configured for server: ${name}`);
} }
options.fetch = createFetchWithProxy(getProxyConfigFromEnv(env));
transport = new StreamableHTTPClientTransport(new URL(conf.url || ''), options); transport = new StreamableHTTPClientTransport(new URL(conf.url || ''), options);
} else if (conf.url) { } else if (conf.url) {
// SSE transport // SSE transport
@@ -174,13 +181,11 @@ export const createTransportFromConfig = async (name: string, conf: ServerConfig
console.log(`OAuth provider configured for server: ${name}`); console.log(`OAuth provider configured for server: ${name}`);
} }
options.fetch = createFetchWithProxy(getProxyConfigFromEnv(env));
transport = new SSEClientTransport(new URL(conf.url), options); transport = new SSEClientTransport(new URL(conf.url), options);
} else if (conf.command && conf.args) { } else if (conf.command && conf.args) {
// Stdio transport // Stdio transport
const env: Record<string, string> = {
...(process.env as Record<string, string>),
...replaceEnvVars(conf.env || {}),
};
env['PATH'] = expandEnvVars(process.env.PATH as string) || ''; env['PATH'] = expandEnvVars(process.env.PATH as string) || '';
const systemConfigDao = getSystemConfigDao(); const systemConfigDao = getSystemConfigDao();
@@ -236,6 +241,8 @@ const callToolWithReconnect = async (
for (let attempt = 0; attempt <= maxRetries; attempt++) { for (let attempt = 0; attempt <= maxRetries; attempt++) {
try { try {
const result = await serverInfo.client.callTool(toolParams, undefined, options || {}); const result = await serverInfo.client.callTool(toolParams, undefined, options || {});
// Check auth error
checkAuthError(result);
return result; return result;
} catch (error: any) { } catch (error: any) {
// Check if error message starts with "Error POSTing to endpoint (HTTP 40" // Check if error message starts with "Error POSTing to endpoint (HTTP 40"
@@ -825,6 +832,25 @@ export const addOrUpdateServer = async (
} }
}; };
// Check for authentication error in tool call result
function checkAuthError(result: any) {
if (Array.isArray(result.content) && result.content.length > 0) {
const text = result.content[0]?.text;
if (typeof text === 'string') {
let errorContent;
try {
errorContent = JSON.parse(text);
} catch (e) {
// Ignore JSON parse errors and continue
return;
}
if (errorContent.code === 401) {
throw new Error('Error POSTing to endpoint (HTTP 401 Unauthorized)');
}
}
}
}
// Close server client and transport // Close server client and transport
function closeServer(name: string) { function closeServer(name: string) {
const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name); const serverInfo = serverInfos.find((serverInfo) => serverInfo.name === name);

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

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

View File

@@ -88,6 +88,29 @@ const isBearerKeyAllowedForRequest = async (req: Request, key: BearerKey): Promi
return groupServerNames.some((name) => allowedServers.includes(name)); return groupServerNames.some((name) => allowedServers.includes(name));
} }
if (key.accessType === 'custom') {
// For custom-scoped keys, check if the group is allowed OR if any server in the group is allowed
const allowedGroups = key.allowedGroups || [];
const allowedServers = key.allowedServers || [];
// Check if the group itself is allowed
const groupAllowed =
allowedGroups.includes(matchedGroup.name) || allowedGroups.includes(matchedGroup.id);
if (groupAllowed) {
return true;
}
// Check if any server in the group is allowed
if (allowedServers.length > 0 && Array.isArray(matchedGroup.servers)) {
const groupServerNames = matchedGroup.servers.map((server) =>
typeof server === 'string' ? server : server.name,
);
return groupServerNames.some((name) => allowedServers.includes(name));
}
return false;
}
// Unknown accessType with matched group // Unknown accessType with matched group
return false; return false;
} }
@@ -102,8 +125,8 @@ const isBearerKeyAllowedForRequest = async (req: Request, key: BearerKey): Promi
return false; return false;
} }
if (key.accessType === 'servers') { if (key.accessType === 'servers' || key.accessType === 'custom') {
// For server-scoped keys, check if the server is in allowedServers // For server-scoped or custom-scoped keys, check if the server is in allowedServers
const allowedServers = key.allowedServers || []; const allowedServers = key.allowedServers || [];
return allowedServers.includes(matchedServer.name); return allowedServers.includes(matchedServer.name);
} }

View File

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

View File

@@ -244,7 +244,7 @@ export interface OAuthServerConfig {
} }
// Bearer authentication key configuration // Bearer authentication key configuration
export type BearerKeyAccessType = 'all' | 'groups' | 'servers'; export type BearerKeyAccessType = 'all' | 'groups' | 'servers' | 'custom';
export interface BearerKey { export interface BearerKey {
id: string; // Unique identifier for the key id: string; // Unique identifier for the key
@@ -252,8 +252,8 @@ export interface BearerKey {
token: string; // Bearer token value token: string; // Bearer token value
enabled: boolean; // Whether this key is enabled enabled: boolean; // Whether this key is enabled
accessType: BearerKeyAccessType; // Access scope type accessType: BearerKeyAccessType; // Access scope type
allowedGroups?: string[]; // Allowed group names when accessType === 'groups' allowedGroups?: string[]; // Allowed group names when accessType === 'groups' or 'custom'
allowedServers?: string[]; // Allowed server names when accessType === 'servers' allowedServers?: string[]; // Allowed server names when accessType === 'servers' or 'custom'
} }
// Represents the settings for MCP servers // Represents the settings for MCP servers

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 * Smart routing configuration interface
@@ -22,10 +23,11 @@ export interface SmartRoutingConfig {
* *
* @returns {SmartRoutingConfig} Complete smart routing configuration * @returns {SmartRoutingConfig} Complete smart routing configuration
*/ */
export function getSmartRoutingConfig(): SmartRoutingConfig { export async function getSmartRoutingConfig(): Promise<SmartRoutingConfig> {
const settings = loadSettings(); // Get system config from DAO
const smartRoutingSettings: Partial<SmartRoutingConfig> = const systemConfigDao = getSystemConfigDao();
settings.systemConfig?.smartRouting || {}; const systemConfig = await systemConfigDao.get();
const smartRoutingSettings: Partial<SmartRoutingConfig> = systemConfig.smartRouting || {};
return { return {
// Enabled status - check multiple environment variables // Enabled status - check multiple environment variables

View File

@@ -0,0 +1,97 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { BearerKeyDaoImpl } from '../../src/dao/BearerKeyDao.js';
const writeSettings = (settingsPath: string, settings: unknown): void => {
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
};
describe('BearerKeyDaoImpl migration + settings caching behavior', () => {
let tmpDir: string;
let settingsPath: string;
let originalSettingsEnv: string | undefined;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcphub-bearer-keys-'));
settingsPath = path.join(tmpDir, 'mcp_settings.json');
originalSettingsEnv = process.env.MCPHUB_SETTING_PATH;
process.env.MCPHUB_SETTING_PATH = settingsPath;
});
afterEach(() => {
if (originalSettingsEnv === undefined) {
delete process.env.MCPHUB_SETTING_PATH;
} else {
process.env.MCPHUB_SETTING_PATH = originalSettingsEnv;
}
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {
// ignore cleanup errors
}
});
it('does not rewrite settings when bearerKeys exists as an empty array', async () => {
writeSettings(settingsPath, {
mcpServers: {},
users: [],
systemConfig: {
routing: {
enableBearerAuth: false,
bearerAuthKey: '',
},
},
bearerKeys: [],
});
const writeSpy = jest.spyOn(fs, 'writeFileSync');
const dao = new BearerKeyDaoImpl();
const enabled1 = await dao.findEnabled();
const enabled2 = await dao.findEnabled();
expect(enabled1).toEqual([]);
expect(enabled2).toEqual([]);
// The DAO should NOT persist anything because bearerKeys already exists.
expect(writeSpy).not.toHaveBeenCalled();
writeSpy.mockRestore();
});
it('migrates legacy bearerAuthKey only once', async () => {
writeSettings(settingsPath, {
mcpServers: {},
users: [],
systemConfig: {
routing: {
enableBearerAuth: true,
bearerAuthKey: 'legacy-token',
},
},
// bearerKeys is intentionally missing to trigger migration
});
const writeSpy = jest.spyOn(fs, 'writeFileSync');
const dao = new BearerKeyDaoImpl();
const enabled1 = await dao.findEnabled();
expect(enabled1).toHaveLength(1);
expect(enabled1[0].token).toBe('legacy-token');
expect(enabled1[0].enabled).toBe(true);
const enabled2 = await dao.findEnabled();
expect(enabled2).toHaveLength(1);
expect(enabled2[0].token).toBe('legacy-token');
// One write for the migration, no further writes on subsequent reads.
expect(writeSpy).toHaveBeenCalledTimes(1);
writeSpy.mockRestore();
});
});