Compare commits

...

14 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
f63c61db65 fix: Address code review feedback for OAuth SSO
- Add proper lifecycle management for state cleanup interval
- Fix host header injection vulnerability by validating forwarded headers
- Add type safety for GitHub API responses
- Add stopStateCleanup function for test cleanup
- Document scaling limitations of in-memory state store

Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-12-31 15:23:14 +00:00
copilot-swe-agent[bot]
7f1e4d5de1 feat: Add OAuth 2.0 / OIDC SSO login support
- Add OAuth SSO provider configuration types (OAuthSsoProviderConfig, OAuthSsoConfig)
- Create OAuth SSO service with support for Google, Microsoft, GitHub, and custom OIDC providers
- Implement OAuth SSO controller with endpoints for SSO configuration, login initiation, and callback handling
- Add routes for /api/auth/sso/* endpoints
- Update User entity and DAOs to support OAuth-linked accounts (oauthProvider, oauthSubject, email, displayName, avatarUrl)
- Update SystemConfig entity to include oauthSso field
- Update migration utility to handle OAuth SSO configuration and user fields
- Add OAuth callback page for frontend token handling
- Update LoginPage with SSO provider buttons and hybrid auth support
- Add i18n translations for OAuth SSO (English and Chinese)
- Add comprehensive tests for OAuth SSO service (13 new tests)

Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-12-31 15:17:07 +00:00
copilot-swe-agent[bot]
9319ea47e6 Initial plan 2025-12-31 14:57:01 +00:00
samanhappy
8ae542bdab Add server renaming functionality (#533) 2025-12-30 18:45:33 +08:00
samanhappy
88ce94b988 docs: update and expand MCPHub development guide and agent instructions (#532) 2025-12-28 14:25:28 +08:00
samanhappy
7cc330e721 fix: ensure database is initialized before saving tool embeddings (#531) 2025-12-28 12:19:21 +08:00
Copilot
ab338e80a7 Add custom access type for bearer keys to support combined group and server scoping (#530)
Co-authored-by: samanhappy <samanhappy@gmail.com>
2025-12-27 16:16:50 +08:00
cheezmil
b00e1c81fc fix: Found 1 error in src/services/keepAliveService.ts:51 #521 (#522)
Co-authored-by: cheestard <134115886+cheestard@users.noreply.github.com>
2025-12-25 09:15:07 +08:00
samanhappy
33eae50bd3 Refactor smart routing configuration and async database handling (#519) 2025-12-20 12:16:09 +08:00
samanhappy
eb1a965e45 feat: add authentication status listener to refresh settings on user login (#518) 2025-12-17 18:34:07 +08:00
samanhappy
97114dcabb feat: implement batch saving for smart routing configuration (#517) 2025-12-17 15:26:53 +08:00
samanhappy
350a022ea3 feat: enhance login error handling and add server unavailable message (#516) 2025-12-17 13:24:07 +08:00
samanhappy
292876a991 feat: update PostgreSQL images to pgvector/pgvector:pg17 across configurations (#513) 2025-12-16 15:40:06 +08:00
samanhappy
d6a9146e27 feat: enhance OAuth token logging and add authentication error handling in tool calls (#512) 2025-12-16 15:16:43 +08:00
53 changed files with 2511 additions and 621 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

@@ -8,6 +8,7 @@ import { SettingsProvider } from './contexts/SettingsContext';
import MainLayout from './layouts/MainLayout'; import MainLayout from './layouts/MainLayout';
import ProtectedRoute from './components/ProtectedRoute'; import ProtectedRoute from './components/ProtectedRoute';
import LoginPage from './pages/LoginPage'; import LoginPage from './pages/LoginPage';
import OAuthCallbackPage from './pages/OAuthCallbackPage';
import DashboardPage from './pages/Dashboard'; import DashboardPage from './pages/Dashboard';
import ServersPage from './pages/ServersPage'; import ServersPage from './pages/ServersPage';
import GroupsPage from './pages/GroupsPage'; import GroupsPage from './pages/GroupsPage';
@@ -35,6 +36,7 @@ function App() {
<Routes> <Routes>
{/* 公共路由 */} {/* 公共路由 */}
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/oauth-callback" element={<OAuthCallbackPage />} />
{/* 受保护的路由,使用 MainLayout 作为布局容器 */} {/* 受保护的路由,使用 MainLayout 作为布局容器 */}
<Route element={<ProtectedRoute />}> <Route element={<ProtectedRoute />}>

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

@@ -429,7 +429,6 @@ const ServerForm = ({
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input" 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>

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

@@ -1,11 +1,12 @@
import React, { useState, useMemo, useCallback } from 'react'; import React, { useState, useMemo, useCallback, useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { getToken } from '../services/authService'; import { getToken, getOAuthSsoConfig, initiateOAuthSsoLogin } from '../services/authService';
import ThemeSwitch from '@/components/ui/ThemeSwitch'; import ThemeSwitch from '@/components/ui/ThemeSwitch';
import LanguageSwitch from '@/components/ui/LanguageSwitch'; import LanguageSwitch from '@/components/ui/LanguageSwitch';
import DefaultPasswordWarningModal from '@/components/ui/DefaultPasswordWarningModal'; import DefaultPasswordWarningModal from '@/components/ui/DefaultPasswordWarningModal';
import { OAuthSsoConfig, OAuthSsoProvider } from '../types';
const sanitizeReturnUrl = (value: string | null): string | null => { const sanitizeReturnUrl = (value: string | null): string | null => {
if (!value) { if (!value) {
@@ -29,6 +30,44 @@ const sanitizeReturnUrl = (value: string | null): string | null => {
} }
}; };
// Provider icon component
const ProviderIcon: React.FC<{ type: string; className?: string }> = ({ type, className = 'w-5 h-5' }) => {
switch (type) {
case 'google':
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
);
case 'microsoft':
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M11.4 11.4H2V2h9.4v9.4z" fill="#F25022"/>
<path d="M22 11.4h-9.4V2H22v9.4z" fill="#7FBA00"/>
<path d="M11.4 22H2v-9.4h9.4V22z" fill="#00A4EF"/>
<path d="M22 22h-9.4v-9.4H22V22z" fill="#FFB900"/>
</svg>
);
case 'github':
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path fillRule="evenodd" clipRule="evenodd" d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.009-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.464-1.11-1.464-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.115 2.504.337 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.579.688.481C19.137 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z"/>
</svg>
);
default:
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/>
<polyline points="10 17 15 12 10 7"/>
<line x1="15" y1="12" x2="3" y2="12"/>
</svg>
);
}
};
const LoginPage: React.FC = () => { const LoginPage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
@@ -36,6 +75,7 @@ const LoginPage: React.FC = () => {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [showDefaultPasswordWarning, setShowDefaultPasswordWarning] = useState(false); const [showDefaultPasswordWarning, setShowDefaultPasswordWarning] = useState(false);
const [ssoConfig, setSsoConfig] = useState<OAuthSsoConfig | null>(null);
const { login } = useAuth(); const { login } = useAuth();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -44,6 +84,43 @@ const LoginPage: React.FC = () => {
return sanitizeReturnUrl(params.get('returnUrl')); return sanitizeReturnUrl(params.get('returnUrl'));
}, [location.search]); }, [location.search]);
// Check for OAuth error in URL params
useEffect(() => {
const params = new URLSearchParams(location.search);
const oauthError = params.get('error');
const oauthMessage = params.get('message');
if (oauthError === 'oauth_failed' && oauthMessage) {
setError(oauthMessage);
}
}, [location.search]);
// Load OAuth SSO configuration
useEffect(() => {
const loadSsoConfig = async () => {
const config = await getOAuthSsoConfig();
setSsoConfig(config);
};
loadSsoConfig();
}, []);
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,20 +177,37 @@ 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);
} }
}; };
const handleSsoLogin = (provider: OAuthSsoProvider) => {
initiateOAuthSsoLogin(provider.id, returnUrl || undefined);
};
const handleCloseWarning = () => { const handleCloseWarning = () => {
setShowDefaultPasswordWarning(false); setShowDefaultPasswordWarning(false);
redirectAfterLogin(); redirectAfterLogin();
}; };
const showLocalAuth = !ssoConfig?.enabled || ssoConfig.localAuthAllowed;
const showSsoProviders = ssoConfig?.enabled && ssoConfig.providers.length > 0;
return ( return (
<div className="relative min-h-screen w-full overflow-hidden bg-gray-50 dark:bg-gray-950"> <div className="relative min-h-screen w-full overflow-hidden bg-gray-50 dark:bg-gray-950">
{/* Top-right controls */} {/* Top-right controls */}
@@ -131,13 +225,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>
@@ -157,58 +259,100 @@ const LoginPage: React.FC = () => {
<div className="login-card relative w-full rounded-2xl border border-white/10 bg-white/60 p-8 shadow-xl backdrop-blur-md transition dark:border-white/10 dark:bg-gray-900/60"> <div className="login-card relative w-full rounded-2xl border border-white/10 bg-white/60 p-8 shadow-xl backdrop-blur-md transition dark:border-white/10 dark:bg-gray-900/60">
<div className="absolute -top-24 right-12 h-40 w-40 -translate-y-6 rounded-full bg-indigo-500/30 blur-3xl" /> <div className="absolute -top-24 right-12 h-40 w-40 -translate-y-6 rounded-full bg-indigo-500/30 blur-3xl" />
<div className="absolute -bottom-24 -left-12 h-40 w-40 translate-y-6 rounded-full bg-cyan-500/20 blur-3xl" /> <div className="absolute -bottom-24 -left-12 h-40 w-40 translate-y-6 rounded-full bg-cyan-500/20 blur-3xl" />
<form className="mt-4 space-y-4" onSubmit={handleSubmit}>
<div className="space-y-4"> {/* SSO Providers */}
<div> {showSsoProviders && (
<label htmlFor="username" className="sr-only"> <div className="mt-4 space-y-3">
{t('auth.username')} {ssoConfig.providers.map((provider) => (
</label> <button
<input key={provider.id}
id="username" type="button"
name="username" onClick={() => handleSsoLogin(provider)}
type="text" className="group relative flex w-full items-center justify-center gap-3 rounded-md border border-gray-300/60 bg-white/80 px-4 py-3 text-sm font-medium text-gray-700 shadow-sm transition-all hover:bg-gray-50 hover:shadow focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 dark:border-gray-600/60 dark:bg-gray-800/80 dark:text-gray-200 dark:hover:bg-gray-700/80"
autoComplete="username" >
required <ProviderIcon type={provider.icon || provider.type} />
className="login-input appearance-none relative block w-full rounded-md border border-gray-300/60 bg-white/70 px-3 py-3 text-gray-900 shadow-sm outline-none ring-0 transition-all placeholder:text-gray-500 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 dark:border-gray-700/60 dark:bg-gray-800/70 dark:text-white dark:placeholder:text-gray-400" <span>{provider.buttonText || t('oauthSso.signInWith', { provider: provider.name })}</span>
placeholder={t('auth.username')} </button>
value={username} ))}
onChange={(e) => setUsername(e.target.value)} </div>
/> )}
{/* Divider between SSO and local auth */}
{showSsoProviders && showLocalAuth && (
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300/60 dark:border-gray-600/60" />
</div> </div>
<div> <div className="relative flex justify-center text-sm">
<label htmlFor="password" className="sr-only"> <span className="bg-white/60 px-4 text-gray-500 dark:bg-gray-900/60 dark:text-gray-400">
{t('auth.password')} {t('oauthSso.orContinueWith')}
</label> </span>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="login-input appearance-none relative block w-full rounded-md border border-gray-300/60 bg-white/70 px-3 py-3 text-gray-900 shadow-sm outline-none ring-0 transition-all placeholder:text-gray-500 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 dark:border-gray-700/60 dark:bg-gray-800/70 dark:text-white dark:placeholder:text-gray-400"
placeholder={t('auth.password')}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div> </div>
</div> </div>
)}
{error && ( {/* Local auth form */}
<div className="error-box rounded border border-red-500/20 bg-red-500/10 p-2 text-center text-sm text-red-600 dark:text-red-400"> {showLocalAuth && (
{error} <form className="mt-4 space-y-4" onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<label htmlFor="username" className="sr-only">
{t('auth.username')}
</label>
<input
id="username"
name="username"
type="text"
autoComplete="username"
required
className="login-input appearance-none relative block w-full rounded-md border border-gray-300/60 bg-white/70 px-3 py-3 text-gray-900 shadow-sm outline-none ring-0 transition-all placeholder:text-gray-500 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 dark:border-gray-700/60 dark:bg-gray-800/70 dark:text-white dark:placeholder:text-gray-400"
placeholder={t('auth.username')}
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div>
<label htmlFor="password" className="sr-only">
{t('auth.password')}
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="login-input appearance-none relative block w-full rounded-md border border-gray-300/60 bg-white/70 px-3 py-3 text-gray-900 shadow-sm outline-none ring-0 transition-all placeholder:text-gray-500 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500 dark:border-gray-700/60 dark:bg-gray-800/70 dark:text-white dark:placeholder:text-gray-400"
placeholder={t('auth.password')}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
</div> </div>
)}
<div> {error && (
<button <div className="error-box rounded border border-red-500/20 bg-red-500/10 p-2 text-center text-sm text-red-600 dark:text-red-400">
type="submit" {error}
disabled={loading} </div>
className="login-button btn-primary group relative flex w-full items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-all hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-70" )}
>
{loading ? t('auth.loggingIn') : t('auth.login')} <div>
</button> <button
type="submit"
disabled={loading}
className="login-button btn-primary group relative flex w-full items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-all hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-70"
>
{loading ? t('auth.loggingIn') : t('auth.login')}
</button>
</div>
</form>
)}
{/* Error display for SSO-only mode */}
{!showLocalAuth && error && (
<div className="mt-4 error-box rounded border border-red-500/20 bg-red-500/10 p-2 text-center text-sm text-red-600 dark:text-red-400">
{error}
</div> </div>
</form> )}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,42 @@
import React, { useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { setToken } from '../services/authService';
/**
* OAuth Callback Page
*
* This page handles the callback from OAuth SSO providers.
* It receives the JWT token as a query parameter, stores it, and redirects to the app.
*/
const OAuthCallbackPage: React.FC = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
useEffect(() => {
const token = searchParams.get('token');
const returnUrl = searchParams.get('returnUrl') || '/';
if (token) {
// Store the token
setToken(token);
// Redirect to the return URL
navigate(returnUrl, { replace: true });
} else {
// No token - redirect to login with error
navigate('/login?error=oauth_failed&message=No+token+received', { replace: true });
}
}, [searchParams, navigate]);
// Show loading state while processing
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-indigo-500 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">Completing authentication...</p>
</div>
</div>
);
};
export default OAuthCallbackPage;

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

@@ -3,6 +3,7 @@ import {
LoginCredentials, LoginCredentials,
RegisterCredentials, RegisterCredentials,
ChangePasswordCredentials, ChangePasswordCredentials,
OAuthSsoConfig,
} from '../types'; } from '../types';
import { apiPost, apiGet } from '../utils/fetchInterceptor'; import { apiPost, apiGet } from '../utils/fetchInterceptor';
import { getToken, setToken, removeToken } from '../utils/interceptors'; import { getToken, setToken, removeToken } from '../utils/interceptors';
@@ -29,7 +30,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',
}; };
} }
}; };
@@ -105,6 +106,30 @@ export const changePassword = async (
} }
}; };
// Get OAuth SSO configuration
export const getOAuthSsoConfig = async (): Promise<OAuthSsoConfig | null> => {
try {
const response = await apiGet<{ success: boolean; data: OAuthSsoConfig }>('/auth/sso/config');
if (response.success && response.data) {
return response.data;
}
return null;
} catch (error) {
console.error('Get OAuth SSO config error:', error);
return null;
}
};
// Initiate OAuth SSO login (redirects to provider)
export const initiateOAuthSsoLogin = (providerId: string, returnUrl?: string): void => {
const basePath = import.meta.env.VITE_BASE_PATH || '';
let url = `${basePath}/api/auth/sso/${providerId}`;
if (returnUrl) {
url += `?returnUrl=${encodeURIComponent(returnUrl)}`;
}
window.location.href = url;
};
// Logout user // Logout user
export const logout = (): void => { export const logout = (): void => {
removeToken(); removeToken();

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;
@@ -381,6 +381,21 @@ export interface AuthResponse {
isUsingDefaultPassword?: boolean; isUsingDefaultPassword?: boolean;
} }
// OAuth SSO types
export interface OAuthSsoProvider {
id: string;
name: string;
type: string;
icon?: string;
buttonText?: string;
}
export interface OAuthSsoConfig {
enabled: boolean;
providers: OAuthSsoProvider[];
localAuthAllowed: boolean;
}
// Official Registry types (from registry.modelcontextprotocol.io) // Official Registry types (from registry.modelcontextprotocol.io)
export interface RegistryVariable { export interface RegistryVariable {
choices?: string[]; choices?: 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",
@@ -838,5 +840,25 @@
"internalError": "Internal Error", "internalError": "Internal Error",
"internalErrorMessage": "An unexpected error occurred while processing the OAuth callback.", "internalErrorMessage": "An unexpected error occurred while processing the OAuth callback.",
"closeWindow": "Close Window" "closeWindow": "Close Window"
},
"oauthSso": {
"errors": {
"providerIdRequired": "Provider ID is required",
"providerNotFound": "OAuth provider not found",
"missingState": "Missing OAuth state parameter",
"missingCode": "Missing authorization code",
"invalidState": "Invalid or expired OAuth state",
"authFailed": "OAuth authentication failed",
"userNotProvisioned": "User not found and auto-provisioning is disabled"
},
"signInWith": "Sign in with {{provider}}",
"orContinueWith": "Or continue with",
"continueWithProvider": "Continue with {{provider}}",
"loginWithSso": "Login with SSO",
"providers": {
"google": "Google",
"microsoft": "Microsoft",
"github": "GitHub"
}
} }
} }

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": "新增密钥",
@@ -840,5 +842,25 @@
"internalError": "内部错误", "internalError": "内部错误",
"internalErrorMessage": "处理 OAuth 回调时发生意外错误。", "internalErrorMessage": "处理 OAuth 回调时发生意外错误。",
"closeWindow": "关闭窗口" "closeWindow": "关闭窗口"
},
"oauthSso": {
"errors": {
"providerIdRequired": "需要提供身份验证提供商 ID",
"providerNotFound": "未找到 OAuth 身份验证提供商",
"missingState": "缺少 OAuth 状态参数",
"missingCode": "缺少授权码",
"invalidState": "OAuth 状态无效或已过期",
"authFailed": "OAuth 身份验证失败",
"userNotProvisioned": "用户未找到且自动创建用户已禁用"
},
"signInWith": "使用 {{provider}} 登录",
"orContinueWith": "或使用以下方式继续",
"continueWithProvider": "使用 {{provider}} 继续",
"loginWithSso": "使用 SSO 登录",
"providers": {
"google": "Google",
"microsoft": "Microsoft",
"github": "GitHub"
}
} }
} }

View File

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

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

@@ -0,0 +1,245 @@
/**
* OAuth SSO Controller
*
* Handles OAuth SSO authentication endpoints.
*/
import { Request, Response } from 'express';
import jwt from 'jsonwebtoken';
import {
generateAuthorizationUrl,
handleCallback,
getPublicProviderInfo,
isLocalAuthAllowed,
isOAuthSsoEnabled,
getOAuthSsoConfig as getSsoConfigFromService,
} from '../services/oauthSsoService.js';
import { JWT_SECRET } from '../config/jwt.js';
import config from '../config/index.js';
const TOKEN_EXPIRY = '24h';
/**
* Get the base URL for OAuth callbacks
* Uses configured callbackBaseUrl if available, otherwise derives from request
* This approach is more secure than blindly trusting forwarded headers
*/
async function getCallbackBaseUrl(req: Request): Promise<string> {
// First, check if a callback base URL is configured (most secure option)
const ssoConfig = await getSsoConfigFromService();
if (ssoConfig?.callbackBaseUrl) {
return ssoConfig.callbackBaseUrl;
}
// Fall back to deriving from request (less secure, but works in simpler setups)
// Only trust forwarded headers if app is configured to trust proxy
if (req.app.get('trust proxy') && req.headers['x-forwarded-proto'] && req.headers['x-forwarded-host']) {
const proto = Array.isArray(req.headers['x-forwarded-proto'])
? req.headers['x-forwarded-proto'][0]
: req.headers['x-forwarded-proto'];
const host = Array.isArray(req.headers['x-forwarded-host'])
? req.headers['x-forwarded-host'][0]
: req.headers['x-forwarded-host'];
return `${proto}://${host}`;
}
return `${req.protocol}://${req.get('host')}`;
}
/**
* Get OAuth SSO configuration for frontend
* Returns enabled providers and whether local auth is allowed
*/
export const getOAuthSsoConfig = async (req: Request, res: Response): Promise<void> => {
try {
const enabled = await isOAuthSsoEnabled();
const providers = await getPublicProviderInfo();
const localAuthAllowed = await isLocalAuthAllowed();
res.json({
success: true,
data: {
enabled,
providers,
localAuthAllowed,
},
});
} catch (error) {
console.error('Error getting OAuth SSO config:', error);
res.status(500).json({
success: false,
message: 'Failed to get OAuth SSO configuration',
});
}
};
/**
* Initiate OAuth SSO login
* Redirects user to the OAuth provider's authorization page
*/
export const initiateOAuthLogin = async (req: Request, res: Response): Promise<void> => {
const t = (req as any).t || ((key: string) => key);
try {
const { providerId } = req.params;
const { returnUrl } = req.query;
if (!providerId) {
res.status(400).json({
success: false,
message: t('oauthSso.errors.providerIdRequired'),
});
return;
}
// Build callback URL
// Note: Use configured callback base URL from oauthSso config if available
// This avoids relying on potentially untrusted forwarded headers
const baseUrl = await getCallbackBaseUrl(req);
const callbackUrl = `${baseUrl}${config.basePath}/api/auth/sso/${providerId}/callback`;
// Generate authorization URL
const { url } = await generateAuthorizationUrl(
providerId,
callbackUrl,
typeof returnUrl === 'string' ? returnUrl : undefined,
);
// Redirect to OAuth provider
res.redirect(url);
} catch (error) {
console.error('Error initiating OAuth login:', error);
const errorMessage = error instanceof Error ? error.message : 'Failed to initiate OAuth login';
res.status(500).json({
success: false,
message: errorMessage,
});
}
};
/**
* Handle OAuth callback from provider
* Exchanges code for tokens and creates/updates user
*/
export const handleOAuthCallback = async (req: Request, res: Response): Promise<void> => {
const t = (req as any).t || ((key: string) => key);
try {
const { providerId } = req.params;
const { code, state, error, error_description } = req.query;
// Handle OAuth errors
if (error) {
console.error(`OAuth error from provider ${providerId}:`, error, error_description);
const errorUrl = buildErrorRedirectUrl(String(error_description || error), req);
return res.redirect(errorUrl);
}
// Validate required parameters
if (!state) {
const errorUrl = buildErrorRedirectUrl(t('oauthSso.errors.missingState'), req);
return res.redirect(errorUrl);
}
if (!code) {
const errorUrl = buildErrorRedirectUrl(t('oauthSso.errors.missingCode'), req);
return res.redirect(errorUrl);
}
// Build callback URL (same as used in initiate)
const baseUrl = await getCallbackBaseUrl(req);
const callbackUrl = `${baseUrl}${config.basePath}/api/auth/sso/${providerId}/callback`;
// Full current URL with query params
const currentUrl = `${callbackUrl}?${new URLSearchParams(req.query as Record<string, string>).toString()}`;
// Exchange code for tokens and get user
const { user, returnUrl } = await handleCallback(
callbackUrl,
currentUrl,
String(state),
);
// Generate JWT token
const payload = {
user: {
username: user.username,
isAdmin: user.isAdmin || false,
},
};
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY });
// Redirect to frontend with token
const redirectUrl = buildSuccessRedirectUrl(token, returnUrl, req);
res.redirect(redirectUrl);
} catch (error) {
console.error('Error handling OAuth callback:', error);
const errorMessage =
error instanceof Error ? error.message : 'Authentication failed';
const errorUrl = buildErrorRedirectUrl(errorMessage, req);
res.redirect(errorUrl);
}
};
/**
* Get list of available OAuth providers
*/
export const listOAuthProviders = async (req: Request, res: Response): Promise<void> => {
try {
const providers = await getPublicProviderInfo();
res.json({
success: true,
data: providers,
});
} catch (error) {
console.error('Error listing OAuth providers:', error);
res.status(500).json({
success: false,
message: 'Failed to list OAuth providers',
});
}
};
/**
* Build redirect URL for successful authentication
*/
function buildSuccessRedirectUrl(token: string, returnUrl: string | undefined, req: Request): string {
const baseUrl = getBaseUrl(req);
const targetPath = returnUrl || '/';
// Use a special OAuth callback page that stores the token
const callbackPath = `${config.basePath}/oauth-callback`;
const params = new URLSearchParams({
token,
returnUrl: targetPath,
});
return `${baseUrl}${callbackPath}?${params.toString()}`;
}
/**
* Build redirect URL for authentication errors
*/
function buildErrorRedirectUrl(error: string, req: Request): string {
const baseUrl = getBaseUrl(req);
const loginPath = `${config.basePath}/login`;
const params = new URLSearchParams({
error: 'oauth_failed',
message: error,
});
return `${baseUrl}${loginPath}?${params.toString()}`;
}
/**
* Get base URL from request
*/
function getBaseUrl(req: Request): string {
if (req.headers['x-forwarded-proto'] && req.headers['x-forwarded-host']) {
return `${req.headers['x-forwarded-proto']}://${req.headers['x-forwarded-host']}`;
}
return `${req.protocol}://${req.get('host')}`;
}

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>;
} }
/** /**
@@ -122,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

@@ -22,6 +22,7 @@ export class SystemConfigDaoDbImpl implements SystemConfigDao {
nameSeparator: config.nameSeparator, nameSeparator: config.nameSeparator,
oauth: config.oauth as any, oauth: config.oauth as any,
oauthServer: config.oauthServer as any, oauthServer: config.oauthServer as any,
oauthSso: config.oauthSso as any,
enableSessionRebuild: config.enableSessionRebuild, enableSessionRebuild: config.enableSessionRebuild,
}; };
} }
@@ -36,6 +37,7 @@ export class SystemConfigDaoDbImpl implements SystemConfigDao {
nameSeparator: updated.nameSeparator, nameSeparator: updated.nameSeparator,
oauth: updated.oauth as any, oauth: updated.oauth as any,
oauthServer: updated.oauthServer as any, oauthServer: updated.oauthServer as any,
oauthSso: updated.oauthSso as any,
enableSessionRebuild: updated.enableSessionRebuild, enableSessionRebuild: updated.enableSessionRebuild,
}; };
} }
@@ -50,6 +52,7 @@ export class SystemConfigDaoDbImpl implements SystemConfigDao {
nameSeparator: config.nameSeparator, nameSeparator: config.nameSeparator,
oauth: config.oauth as any, oauth: config.oauth as any,
oauthServer: config.oauthServer as any, oauthServer: config.oauthServer as any,
oauthSso: config.oauthSso as any,
enableSessionRebuild: config.enableSessionRebuild, enableSessionRebuild: config.enableSessionRebuild,
}; };
} }

View File

@@ -13,23 +13,28 @@ export class UserDaoDbImpl implements UserDao {
this.repository = new UserRepository(); this.repository = new UserRepository();
} }
async findAll(): Promise<IUser[]> { private mapToIUser(u: any): IUser {
const users = await this.repository.findAll(); return {
return users.map((u) => ({
username: u.username, username: u.username,
password: u.password, password: u.password,
isAdmin: u.isAdmin, isAdmin: u.isAdmin,
})); oauthProvider: u.oauthProvider,
oauthSubject: u.oauthSubject,
email: u.email,
displayName: u.displayName,
avatarUrl: u.avatarUrl,
};
}
async findAll(): Promise<IUser[]> {
const users = await this.repository.findAll();
return users.map(this.mapToIUser);
} }
async findById(username: string): Promise<IUser | null> { async findById(username: string): Promise<IUser | null> {
const user = await this.repository.findByUsername(username); const user = await this.repository.findByUsername(username);
if (!user) return null; if (!user) return null;
return { return this.mapToIUser(user);
username: user.username,
password: user.password,
isAdmin: user.isAdmin,
};
} }
async findByUsername(username: string): Promise<IUser | null> { async findByUsername(username: string): Promise<IUser | null> {
@@ -41,12 +46,13 @@ export class UserDaoDbImpl implements UserDao {
username: entity.username, username: entity.username,
password: entity.password, password: entity.password,
isAdmin: entity.isAdmin || false, isAdmin: entity.isAdmin || false,
oauthProvider: entity.oauthProvider,
oauthSubject: entity.oauthSubject,
email: entity.email,
displayName: entity.displayName,
avatarUrl: entity.avatarUrl,
}); });
return { return this.mapToIUser(user);
username: user.username,
password: user.password,
isAdmin: user.isAdmin,
};
} }
async createWithHashedPassword( async createWithHashedPassword(
@@ -62,13 +68,14 @@ export class UserDaoDbImpl implements UserDao {
const user = await this.repository.update(username, { const user = await this.repository.update(username, {
password: entity.password, password: entity.password,
isAdmin: entity.isAdmin, isAdmin: entity.isAdmin,
oauthProvider: entity.oauthProvider,
oauthSubject: entity.oauthSubject,
email: entity.email,
displayName: entity.displayName,
avatarUrl: entity.avatarUrl,
}); });
if (!user) return null; if (!user) return null;
return { return this.mapToIUser(user);
username: user.username,
password: user.password,
isAdmin: user.isAdmin,
};
} }
async delete(username: string): Promise<boolean> { async delete(username: string): Promise<boolean> {
@@ -99,10 +106,6 @@ export class UserDaoDbImpl implements UserDao {
async findAdmins(): Promise<IUser[]> { async findAdmins(): Promise<IUser[]> {
const users = await this.repository.findAdmins(); const users = await this.repository.findAdmins();
return users.map((u) => ({ return users.map(this.mapToIUser);
username: u.username,
password: u.password,
isAdmin: u.isAdmin,
}));
} }
} }

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

@@ -30,6 +30,9 @@ export class SystemConfig {
@Column({ type: 'simple-json', nullable: true }) @Column({ type: 'simple-json', nullable: true })
oauthServer?: Record<string, any>; oauthServer?: Record<string, any>;
@Column({ name: 'oauth_sso', type: 'simple-json', nullable: true })
oauthSso?: Record<string, any>;
@Column({ type: 'boolean', nullable: true }) @Column({ type: 'boolean', nullable: true })
enableSessionRebuild?: boolean; enableSessionRebuild?: boolean;

View File

@@ -23,6 +23,22 @@ export class User {
@Column({ type: 'boolean', default: false }) @Column({ type: 'boolean', default: false })
isAdmin: boolean; isAdmin: boolean;
// OAuth SSO fields
@Column({ name: 'oauth_provider', type: 'varchar', length: 100, nullable: true })
oauthProvider?: string;
@Column({ name: 'oauth_subject', type: 'varchar', length: 255, nullable: true })
oauthSubject?: string;
@Column({ type: 'varchar', length: 255, nullable: true })
email?: string;
@Column({ name: 'display_name', type: 'varchar', length: 255, nullable: true })
displayName?: string;
@Column({ name: 'avatar_url', type: 'text', nullable: true })
avatarUrl?: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamp' }) @CreateDateColumn({ name: 'created_at', type: 'timestamp' })
createdAt: Date; createdAt: Date;

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

@@ -112,6 +112,12 @@ import {
updateBearerKey, updateBearerKey,
deleteBearerKey, deleteBearerKey,
} from '../controllers/bearerKeyController.js'; } from '../controllers/bearerKeyController.js';
import {
getOAuthSsoConfig,
initiateOAuthLogin,
handleOAuthCallback as handleOAuthSsoCallback,
listOAuthProviders,
} from '../controllers/oauthSsoController.js';
import { auth } from '../middlewares/auth.js'; import { auth } from '../middlewares/auth.js';
const router = express.Router(); const router = express.Router();
@@ -273,6 +279,12 @@ export const initRoutes = (app: express.Application): void => {
changePassword, changePassword,
); );
// OAuth SSO routes (no auth required - these are for logging in)
router.get('/auth/sso/config', getOAuthSsoConfig);
router.get('/auth/sso/providers', listOAuthProviders);
router.get('/auth/sso/:providerId', initiateOAuthLogin);
router.get('/auth/sso/:providerId/callback', handleOAuthSsoCallback);
// Runtime configuration endpoint (no auth required for frontend initialization) // Runtime configuration endpoint (no auth required for frontend initialization)
app.get(`${config.basePath}/config`, getRuntimeConfig); app.get(`${config.basePath}/config`, getRuntimeConfig);

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

@@ -241,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"
@@ -830,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);

View File

@@ -0,0 +1,546 @@
/**
* OAuth SSO Service
*
* Handles OAuth 2.0 / OIDC SSO authentication for user login.
* Supports Google, Microsoft, GitHub, and custom OIDC providers.
*/
import * as client from 'openid-client';
import crypto from 'crypto';
import { getSystemConfigDao, getUserDao } from '../dao/index.js';
import { IUser, OAuthSsoProviderConfig, OAuthSsoConfig } from '../types/index.js';
// In-memory store for OAuth state (code verifier, state, etc.)
// NOTE: This implementation uses in-memory storage which is suitable for single-instance deployments.
// For multi-instance/scaled deployments, implement Redis or database-backed state storage
// to ensure OAuth callbacks reach the correct instance where the state was stored.
interface OAuthStateEntry {
codeVerifier: string;
providerId: string;
returnUrl?: string;
createdAt: number;
}
const stateStore = new Map<string, OAuthStateEntry>();
const STATE_TTL_MS = 10 * 60 * 1000; // 10 minutes
// Cleanup old state entries periodically
let cleanupInterval: ReturnType<typeof setInterval> | null = null;
function startStateCleanup(): void {
if (cleanupInterval) return;
cleanupInterval = setInterval(() => {
const now = Date.now();
for (const [state, entry] of stateStore.entries()) {
if (now - entry.createdAt > STATE_TTL_MS) {
stateStore.delete(state);
}
}
}, 60 * 1000); // Cleanup every minute
}
// Start cleanup on module load
startStateCleanup();
/**
* Stop the state cleanup interval (useful for tests and graceful shutdown)
*/
export function stopStateCleanup(): void {
if (cleanupInterval) {
clearInterval(cleanupInterval);
cleanupInterval = null;
}
}
// GitHub API response types for type safety
interface GitHubUserResponse {
id: number;
login: string;
name?: string;
email?: string;
avatar_url?: string;
}
interface GitHubEmailResponse {
email: string;
primary: boolean;
verified: boolean;
visibility?: string;
}
// Provider configurations cache
const providerConfigsCache = new Map<
string,
{
config: client.Configuration;
provider: OAuthSsoProviderConfig;
}
>();
/**
* Get OAuth SSO configuration from system config
*/
export async function getOAuthSsoConfig(): Promise<OAuthSsoConfig | undefined> {
const systemConfigDao = getSystemConfigDao();
const systemConfig = await systemConfigDao.get();
return systemConfig?.oauthSso;
}
/**
* Check if OAuth SSO is enabled
*/
export async function isOAuthSsoEnabled(): Promise<boolean> {
const config = await getOAuthSsoConfig();
return config?.enabled === true && (config.providers?.length ?? 0) > 0;
}
/**
* Get enabled OAuth SSO providers
*/
export async function getEnabledProviders(): Promise<OAuthSsoProviderConfig[]> {
const config = await getOAuthSsoConfig();
if (!config?.enabled || !config.providers) {
return [];
}
return config.providers.filter((p) => p.enabled !== false);
}
/**
* Get a specific provider by ID
*/
export async function getProviderById(providerId: string): Promise<OAuthSsoProviderConfig | undefined> {
const providers = await getEnabledProviders();
return providers.find((p) => p.id === providerId);
}
/**
* Get default scopes for a provider type
*/
function getDefaultScopes(type: OAuthSsoProviderConfig['type']): string[] {
switch (type) {
case 'google':
return ['openid', 'email', 'profile'];
case 'microsoft':
return ['openid', 'email', 'profile', 'User.Read'];
case 'github':
return ['read:user', 'user:email'];
case 'oidc':
default:
return ['openid', 'email', 'profile'];
}
}
/**
* Get provider discovery URL
*/
function getDiscoveryUrl(provider: OAuthSsoProviderConfig): string | undefined {
if (provider.issuerUrl) {
return provider.issuerUrl;
}
switch (provider.type) {
case 'google':
return 'https://accounts.google.com';
case 'microsoft':
// Using common endpoint for multi-tenant
return 'https://login.microsoftonline.com/common/v2.0';
case 'github':
// GitHub doesn't support OIDC discovery, we'll use explicit endpoints
return undefined;
default:
return undefined;
}
}
/**
* Get explicit OAuth endpoints for providers without OIDC discovery
*/
function getExplicitEndpoints(provider: OAuthSsoProviderConfig): {
authorizationUrl: string;
tokenUrl: string;
userInfoUrl: string;
} | undefined {
if (provider.type === 'github') {
return {
authorizationUrl: provider.authorizationUrl || 'https://github.com/login/oauth/authorize',
tokenUrl: provider.tokenUrl || 'https://github.com/login/oauth/access_token',
userInfoUrl: provider.userInfoUrl || 'https://api.github.com/user',
};
}
// For custom providers with explicit endpoints
if (provider.authorizationUrl && provider.tokenUrl && provider.userInfoUrl) {
return {
authorizationUrl: provider.authorizationUrl,
tokenUrl: provider.tokenUrl,
userInfoUrl: provider.userInfoUrl,
};
}
return undefined;
}
/**
* Initialize and cache openid-client configuration for a provider
*/
async function getClientConfig(
provider: OAuthSsoProviderConfig,
_callbackUrl: string,
): Promise<client.Configuration> {
const cacheKey = provider.id;
const cached = providerConfigsCache.get(cacheKey);
if (cached) {
return cached.config;
}
let config: client.Configuration;
const discoveryUrl = getDiscoveryUrl(provider);
if (discoveryUrl) {
// Use OIDC discovery
config = await client.discovery(new URL(discoveryUrl), provider.clientId, provider.clientSecret);
} else {
// Use explicit endpoints for providers like GitHub
const endpoints = getExplicitEndpoints(provider);
if (!endpoints) {
throw new Error(
`Provider ${provider.id} requires either issuerUrl for OIDC discovery or explicit endpoints`,
);
}
// Create a manual server metadata configuration
const serverMetadata: client.ServerMetadata = {
issuer: provider.issuerUrl || `https://${provider.type}.oauth`,
authorization_endpoint: endpoints.authorizationUrl,
token_endpoint: endpoints.tokenUrl,
userinfo_endpoint: endpoints.userInfoUrl,
};
config = new client.Configuration(serverMetadata, provider.clientId, provider.clientSecret);
}
providerConfigsCache.set(cacheKey, { config, provider });
return config;
}
/**
* Generate the authorization URL for a provider
*/
export async function generateAuthorizationUrl(
providerId: string,
callbackUrl: string,
returnUrl?: string,
): Promise<{ url: string; state: string }> {
const provider = await getProviderById(providerId);
if (!provider) {
throw new Error(`OAuth SSO provider not found: ${providerId}`);
}
const config = await getClientConfig(provider, callbackUrl);
const scopes = provider.scopes || getDefaultScopes(provider.type);
// Generate PKCE code verifier and challenge
const codeVerifier = client.randomPKCECodeVerifier();
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
// Generate state
const state = crypto.randomBytes(32).toString('base64url');
// Store state for callback verification
stateStore.set(state, {
codeVerifier,
providerId,
returnUrl,
createdAt: Date.now(),
});
// Build authorization URL parameters
const parameters: Record<string, string> = {
redirect_uri: callbackUrl,
scope: scopes.join(' '),
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
};
// GitHub-specific: request user email access
if (provider.type === 'github') {
// GitHub doesn't use PKCE, but we'll still store the state
delete parameters.code_challenge;
delete parameters.code_challenge_method;
}
const url = client.buildAuthorizationUrl(config, parameters);
return { url: url.toString(), state };
}
/**
* Exchange authorization code for tokens and user info
*/
export async function handleCallback(
callbackUrl: string,
currentUrl: string,
state: string,
): Promise<{
user: IUser;
isNewUser: boolean;
returnUrl?: string;
}> {
// Verify and retrieve state
const stateEntry = stateStore.get(state);
if (!stateEntry) {
throw new Error('Invalid or expired OAuth state');
}
// Remove used state
stateStore.delete(state);
const provider = await getProviderById(stateEntry.providerId);
if (!provider) {
throw new Error(`OAuth SSO provider not found: ${stateEntry.providerId}`);
}
const config = await getClientConfig(provider, callbackUrl);
// Exchange code for tokens
let tokens: client.TokenEndpointResponse;
if (provider.type === 'github') {
// GitHub doesn't use PKCE
tokens = await client.authorizationCodeGrant(config, new URL(currentUrl), {
expectedState: state,
});
} else {
// OIDC providers with PKCE
tokens = await client.authorizationCodeGrant(config, new URL(currentUrl), {
pkceCodeVerifier: stateEntry.codeVerifier,
expectedState: state,
});
}
// Get user info
const userInfo = await getUserInfo(provider, config, tokens);
// Find or create user
const { user, isNewUser } = await findOrCreateUser(provider, userInfo);
return {
user,
isNewUser,
returnUrl: stateEntry.returnUrl,
};
}
/**
* Fetch user info from the provider
*/
async function getUserInfo(
provider: OAuthSsoProviderConfig,
config: client.Configuration,
tokens: client.TokenEndpointResponse,
): Promise<{
sub: string;
email?: string;
name?: string;
picture?: string;
groups?: string[];
roles?: string[];
[key: string]: unknown;
}> {
if (provider.type === 'github') {
// GitHub uses a different API for user info
const response = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${tokens.access_token}`,
Accept: 'application/json',
},
});
if (!response.ok) {
throw new Error(`Failed to fetch GitHub user info: ${response.statusText}`);
}
const data = (await response.json()) as GitHubUserResponse;
// Fetch email separately if not public
let email = data.email;
if (!email) {
const emailResponse = await fetch('https://api.github.com/user/emails', {
headers: {
Authorization: `Bearer ${tokens.access_token}`,
Accept: 'application/json',
},
});
if (emailResponse.ok) {
const emails = (await emailResponse.json()) as GitHubEmailResponse[];
const primaryEmail = emails.find((e) => e.primary);
email = primaryEmail?.email || emails[0]?.email;
}
}
return {
sub: String(data.id),
email,
name: data.name || data.login,
picture: data.avatar_url,
};
}
// Standard OIDC userinfo endpoint
const userInfoResponse = await client.fetchUserInfo(config, tokens.access_token!, client.skipSubjectCheck);
return {
sub: userInfoResponse.sub,
email: userInfoResponse.email as string | undefined,
name: userInfoResponse.name as string | undefined,
picture: userInfoResponse.picture as string | undefined,
groups: userInfoResponse.groups as string[] | undefined,
roles: userInfoResponse.roles as string[] | undefined,
};
}
/**
* Find existing user or create new one based on OAuth profile
*/
async function findOrCreateUser(
provider: OAuthSsoProviderConfig,
userInfo: {
sub: string;
email?: string;
name?: string;
picture?: string;
groups?: string[];
roles?: string[];
[key: string]: unknown;
},
): Promise<{ user: IUser; isNewUser: boolean }> {
const userDao = getUserDao();
// Generate a unique username based on provider and subject
const oauthUsername = `${provider.id}:${userInfo.sub}`;
// Try to find existing user by OAuth identity
let user = await userDao.findByUsername(oauthUsername);
if (user) {
// Update user info if changed
const updates: Partial<IUser> = {};
if (userInfo.email && userInfo.email !== user.email) {
updates.email = userInfo.email;
}
if (userInfo.name && userInfo.name !== user.displayName) {
updates.displayName = userInfo.name;
}
if (userInfo.picture && userInfo.picture !== user.avatarUrl) {
updates.avatarUrl = userInfo.picture;
}
// Check admin status based on claims
const isAdmin = checkAdminClaim(provider, userInfo);
if (isAdmin !== user.isAdmin) {
updates.isAdmin = isAdmin;
}
if (Object.keys(updates).length > 0) {
await userDao.update(oauthUsername, updates);
user = { ...user, ...updates };
}
return { user, isNewUser: false };
}
// Check if auto-provisioning is enabled
if (provider.autoProvision === false) {
throw new Error(
`User not found and auto-provisioning is disabled for provider: ${provider.name}`,
);
}
// Create new user
const isAdmin = checkAdminClaim(provider, userInfo) || provider.defaultAdmin === true;
// Generate a random password for OAuth users (they won't use it)
const randomPassword = crypto.randomBytes(32).toString('hex');
const newUser = await userDao.createWithHashedPassword(oauthUsername, randomPassword, isAdmin);
// Update with OAuth-specific fields
const updatedUser = await userDao.update(oauthUsername, {
oauthProvider: provider.id,
oauthSubject: userInfo.sub,
email: userInfo.email,
displayName: userInfo.name,
avatarUrl: userInfo.picture,
});
return { user: updatedUser || newUser, isNewUser: true };
}
/**
* Check if user should be granted admin based on provider claims
*/
function checkAdminClaim(
provider: OAuthSsoProviderConfig,
userInfo: { groups?: string[]; roles?: string[]; [key: string]: unknown },
): boolean {
if (!provider.adminClaim || !provider.adminClaimValues?.length) {
return false;
}
const claimValue = userInfo[provider.adminClaim];
if (!claimValue) {
return false;
}
// Handle array claims (groups, roles)
if (Array.isArray(claimValue)) {
return claimValue.some((v) => provider.adminClaimValues!.includes(String(v)));
}
// Handle string claims
return provider.adminClaimValues.includes(String(claimValue));
}
/**
* Get public provider info for frontend
*/
export async function getPublicProviderInfo(): Promise<
Array<{
id: string;
name: string;
type: string;
icon?: string;
buttonText?: string;
}>
> {
const providers = await getEnabledProviders();
return providers.map((p) => ({
id: p.id,
name: p.name,
type: p.type,
icon: p.icon || p.type,
buttonText: p.buttonText,
}));
}
/**
* Check if local auth is allowed
*/
export async function isLocalAuthAllowed(): Promise<boolean> {
const config = await getOAuthSsoConfig();
// Default to true if not configured or SSO is disabled
if (!config?.enabled) {
return true;
}
return config.allowLocalAuth !== false;
}
/**
* Clear provider configuration cache
*/
export function clearProviderCache(): void {
providerConfigsCache.clear();
}

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

@@ -10,6 +10,12 @@ export interface IUser {
username: string; username: string;
password: string; password: string;
isAdmin?: boolean; isAdmin?: boolean;
// OAuth SSO fields
oauthProvider?: string; // OAuth provider ID (e.g., 'google', 'microsoft', 'github')
oauthSubject?: string; // OAuth subject (unique user ID from provider)
email?: string; // User email (from OAuth profile)
displayName?: string; // Display name (from OAuth profile)
avatarUrl?: string; // Avatar URL (from OAuth profile)
} }
// Group interface for server grouping // Group interface for server grouping
@@ -124,6 +130,43 @@ export interface MCPRouterCallToolResponse {
isError: boolean; isError: boolean;
} }
// OAuth SSO Provider Configuration for user authentication
export type OAuthSsoProviderType = 'google' | 'microsoft' | 'github' | 'oidc';
export interface OAuthSsoProviderConfig {
id: string; // Unique identifier for this provider (e.g., 'google', 'my-company-sso')
type: OAuthSsoProviderType; // Provider type
name: string; // Display name (e.g., 'Google', 'Microsoft', 'Company SSO')
enabled?: boolean; // Whether this provider is enabled (default: true)
clientId: string; // OAuth client ID
clientSecret: string; // OAuth client secret
// For OIDC providers, discovery URL or explicit endpoints
issuerUrl?: string; // OIDC issuer URL for auto-discovery (e.g., 'https://accounts.google.com')
// Explicit endpoints (optional, can be auto-discovered for OIDC)
authorizationUrl?: string; // OAuth authorization endpoint
tokenUrl?: string; // OAuth token endpoint
userInfoUrl?: string; // OAuth userinfo endpoint
// Scope configuration
scopes?: string[]; // OAuth scopes to request (default varies by provider)
// Role/admin mapping
adminClaim?: string; // Claim name to check for admin role (e.g., 'groups', 'roles')
adminClaimValues?: string[]; // Values that grant admin access (e.g., ['admin', 'mcphub-admins'])
// Auto-provisioning options
autoProvision?: boolean; // Auto-create users on first login (default: true)
defaultAdmin?: boolean; // Whether auto-provisioned users are admins by default (default: false)
// UI options
icon?: string; // Icon identifier for UI (e.g., 'google', 'microsoft', 'github', 'key')
buttonText?: string; // Custom button text (e.g., 'Sign in with Google')
}
// OAuth SSO configuration in SystemConfig
export interface OAuthSsoConfig {
enabled?: boolean; // Enable/disable OAuth SSO globally
providers?: OAuthSsoProviderConfig[]; // List of configured SSO providers
allowLocalAuth?: boolean; // Allow local username/password auth alongside SSO (default: true)
callbackBaseUrl?: string; // Base URL for OAuth callbacks (auto-detected if not set)
}
// OAuth Provider Configuration for MCP Authorization Server // OAuth Provider Configuration for MCP Authorization Server
export interface OAuthProviderConfig { export interface OAuthProviderConfig {
enabled?: boolean; // Enable/disable OAuth provider enabled?: boolean; // Enable/disable OAuth provider
@@ -172,6 +215,7 @@ export interface SystemConfig {
nameSeparator?: string; // Separator used between server name and tool/prompt name (default: '-') nameSeparator?: string; // Separator used between server name and tool/prompt name (default: '-')
oauth?: OAuthProviderConfig; // OAuth provider configuration for upstream MCP servers oauth?: OAuthProviderConfig; // OAuth provider configuration for upstream MCP servers
oauthServer?: OAuthServerConfig; // OAuth authorization server configuration for MCPHub itself oauthServer?: OAuthServerConfig; // OAuth authorization server configuration for MCPHub itself
oauthSso?: OAuthSsoConfig; // OAuth SSO configuration for user authentication
enableSessionRebuild?: boolean; // Controls whether server session rebuild is enabled enableSessionRebuild?: boolean; // Controls whether server session rebuild is enabled
} }
@@ -244,7 +288,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 +296,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

@@ -46,6 +46,11 @@ export async function migrateToDatabase(): Promise<boolean> {
username: user.username, username: user.username,
password: user.password, password: user.password,
isAdmin: user.isAdmin || false, isAdmin: user.isAdmin || false,
oauthProvider: user.oauthProvider,
oauthSubject: user.oauthSubject,
email: user.email,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
}); });
console.log(` - Created user: ${user.username}`); console.log(` - Created user: ${user.username}`);
} else { } else {
@@ -116,6 +121,7 @@ export async function migrateToDatabase(): Promise<boolean> {
nameSeparator: settings.systemConfig.nameSeparator, nameSeparator: settings.systemConfig.nameSeparator,
oauth: settings.systemConfig.oauth || {}, oauth: settings.systemConfig.oauth || {},
oauthServer: settings.systemConfig.oauthServer || {}, oauthServer: settings.systemConfig.oauthServer || {},
oauthSso: settings.systemConfig.oauthSso || {},
enableSessionRebuild: settings.systemConfig.enableSessionRebuild, enableSessionRebuild: settings.systemConfig.enableSessionRebuild,
}; };
await systemConfigRepo.update(systemConfig); await systemConfigRepo.update(systemConfig);

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,235 @@
// Mock openid-client before importing services
jest.mock('openid-client', () => ({
discovery: jest.fn(),
Configuration: jest.fn(),
randomPKCECodeVerifier: jest.fn(() => 'test-verifier'),
calculatePKCECodeChallenge: jest.fn(() => Promise.resolve('test-challenge')),
buildAuthorizationUrl: jest.fn(() => new URL('https://example.com/authorize')),
authorizationCodeGrant: jest.fn(),
fetchUserInfo: jest.fn(),
skipSubjectCheck: Symbol('skipSubjectCheck'),
}));
// Mock the DAO module
jest.mock('../../src/dao/index.js', () => ({
getSystemConfigDao: jest.fn(),
getUserDao: jest.fn(),
}));
import * as daoModule from '../../src/dao/index.js';
import {
isOAuthSsoEnabled,
getEnabledProviders,
getProviderById,
isLocalAuthAllowed,
getPublicProviderInfo,
clearProviderCache,
stopStateCleanup,
} from '../../src/services/oauthSsoService.js';
describe('OAuth SSO Service', () => {
const mockGetSystemConfigDao = daoModule.getSystemConfigDao as jest.MockedFunction<
typeof daoModule.getSystemConfigDao
>;
const mockGetUserDao = daoModule.getUserDao as jest.MockedFunction<typeof daoModule.getUserDao>;
// Stop the cleanup interval to prevent Jest from hanging
afterAll(() => {
stopStateCleanup();
});
const defaultSsoConfig = {
enabled: true,
allowLocalAuth: true,
providers: [
{
id: 'google',
type: 'google' as const,
name: 'Google',
enabled: true,
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
scopes: ['openid', 'email', 'profile'],
},
{
id: 'github',
type: 'github' as const,
name: 'GitHub',
enabled: true,
clientId: 'test-github-client',
clientSecret: 'test-github-secret',
},
{
id: 'disabled-provider',
type: 'oidc' as const,
name: 'Disabled',
enabled: false,
clientId: 'disabled-client',
clientSecret: 'disabled-secret',
},
],
};
beforeEach(() => {
jest.clearAllMocks();
clearProviderCache();
mockGetSystemConfigDao.mockReturnValue({
get: jest.fn().mockResolvedValue({
oauthSso: defaultSsoConfig,
}),
} as any);
mockGetUserDao.mockReturnValue({
findByUsername: jest.fn().mockResolvedValue(null),
createWithHashedPassword: jest.fn().mockResolvedValue({
username: 'google:12345',
password: 'hashed',
isAdmin: false,
}),
update: jest.fn().mockImplementation((username: string, data: any) =>
Promise.resolve({
username,
password: 'hashed',
isAdmin: false,
...data,
})
),
} as any);
});
describe('isOAuthSsoEnabled', () => {
it('should return true when OAuth SSO is enabled with providers', async () => {
const enabled = await isOAuthSsoEnabled();
expect(enabled).toBe(true);
});
it('should return false when OAuth SSO is disabled', async () => {
mockGetSystemConfigDao.mockReturnValue({
get: jest.fn().mockResolvedValue({
oauthSso: { ...defaultSsoConfig, enabled: false },
}),
} as any);
const enabled = await isOAuthSsoEnabled();
expect(enabled).toBe(false);
});
it('should return false when no providers are configured', async () => {
mockGetSystemConfigDao.mockReturnValue({
get: jest.fn().mockResolvedValue({
oauthSso: { ...defaultSsoConfig, providers: [] },
}),
} as any);
const enabled = await isOAuthSsoEnabled();
expect(enabled).toBe(false);
});
});
describe('getEnabledProviders', () => {
it('should return only enabled providers', async () => {
const providers = await getEnabledProviders();
expect(providers).toHaveLength(2);
expect(providers.map((p) => p.id)).toContain('google');
expect(providers.map((p) => p.id)).toContain('github');
expect(providers.map((p) => p.id)).not.toContain('disabled-provider');
});
it('should return empty array when SSO is disabled', async () => {
mockGetSystemConfigDao.mockReturnValue({
get: jest.fn().mockResolvedValue({
oauthSso: { ...defaultSsoConfig, enabled: false },
}),
} as any);
const providers = await getEnabledProviders();
expect(providers).toHaveLength(0);
});
});
describe('getProviderById', () => {
it('should return the correct provider by ID', async () => {
const provider = await getProviderById('google');
expect(provider).toBeDefined();
expect(provider?.id).toBe('google');
expect(provider?.type).toBe('google');
expect(provider?.name).toBe('Google');
});
it('should return undefined for non-existent provider', async () => {
const provider = await getProviderById('non-existent');
expect(provider).toBeUndefined();
});
it('should return undefined for disabled provider', async () => {
const provider = await getProviderById('disabled-provider');
expect(provider).toBeUndefined();
});
});
describe('isLocalAuthAllowed', () => {
it('should return true when local auth is allowed', async () => {
const allowed = await isLocalAuthAllowed();
expect(allowed).toBe(true);
});
it('should return false when local auth is disabled', async () => {
mockGetSystemConfigDao.mockReturnValue({
get: jest.fn().mockResolvedValue({
oauthSso: { ...defaultSsoConfig, allowLocalAuth: false },
}),
} as any);
const allowed = await isLocalAuthAllowed();
expect(allowed).toBe(false);
});
it('should return true when SSO is disabled (fallback)', async () => {
mockGetSystemConfigDao.mockReturnValue({
get: jest.fn().mockResolvedValue({
oauthSso: undefined,
}),
} as any);
const allowed = await isLocalAuthAllowed();
expect(allowed).toBe(true);
});
});
describe('getPublicProviderInfo', () => {
it('should return public info for enabled providers only', async () => {
const info = await getPublicProviderInfo();
expect(info).toHaveLength(2);
const googleInfo = info.find((p) => p.id === 'google');
expect(googleInfo).toBeDefined();
expect(googleInfo?.name).toBe('Google');
expect(googleInfo?.type).toBe('google');
expect(googleInfo?.icon).toBe('google');
// Ensure sensitive data is not exposed
expect((googleInfo as any)?.clientSecret).toBeUndefined();
expect((googleInfo as any)?.clientId).toBeUndefined();
});
it('should include buttonText when specified', async () => {
mockGetSystemConfigDao.mockReturnValue({
get: jest.fn().mockResolvedValue({
oauthSso: {
...defaultSsoConfig,
providers: [
{
...defaultSsoConfig.providers[0],
buttonText: 'Login with Google',
},
],
},
}),
} as any);
const info = await getPublicProviderInfo();
expect(info[0].buttonText).toBe('Login with Google');
});
});
});