Compare commits

..

8 Commits

Author SHA1 Message Date
samanhappy
fb1f670d88 feat: Implement OAuth 2.0 / OIDC SSO support with configuration and routing updates 2026-01-01 13:20:39 +08:00
copilot-swe-agent[bot]
93f4861953 fix: Address code review feedback and add SSO documentation
- Remove duplicate route registration
- Fix return type for OAuth callback handler
- Add OAuth SSO configuration documentation
- Add security comments for OAuth query parameters

Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-12-31 15:16:19 +00:00
copilot-swe-agent[bot]
4721146e8a feat: Add OAuth 2.0 / OIDC SSO login support
- Add OAuth SSO type definitions (OAuthSSOProvider, OAuthSSOConfig, IOAuthLink)
- Add oauthSSO field to SystemConfig for provider configuration
- Update IUser interface to support OAuth-linked accounts
- Create OAuth SSO service with provider management and token exchange
- Add SSO controller with login initiation and callback handling
- Update frontend login page with SSO provider buttons
- Add SSOCallbackPage for handling OAuth redirects
- Update database entities and DAOs for OAuth link storage
- Add i18n translations for SSO-related UI elements
- Add comprehensive unit tests for OAuth SSO service

Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-12-31 15:08:10 +00:00
copilot-swe-agent[bot]
53d3545f60 Initial plan 2025-12-31 14:51:49 +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
43 changed files with 2676 additions and 1290 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.

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

@@ -1,205 +0,0 @@
# Stream Parameter Implementation - Summary
## Overview
Successfully implemented support for a `stream` parameter that allows clients to control whether MCP requests receive Server-Sent Events (SSE) streaming responses or direct JSON responses.
## Problem Statement (Original Question)
> 分析源码,使用 http://localhost:8090/process 请求时,可以使用 stream : false 来设置非流式响应吗
>
> Translation: After analyzing the source code, when using the http://localhost:8090/process request, can we use stream: false to set non-streaming responses?
## Answer
**Yes, absolutely!** While the endpoint path is `/mcp` (not `/process`), the implementation now fully supports using a `stream` parameter to control response format.
## Implementation Details
### Core Changes
1. **Modified Functions:**
- `createSessionWithId()` - Added `enableJsonResponse` parameter
- `createNewSession()` - Added `enableJsonResponse` parameter
- `handleMcpPostRequest()` - Added robust stream parameter parsing
2. **Parameter Parsing:**
- Created `parseStreamParam()` helper function
- Handles multiple input types: boolean, string, number
- Consistent behavior for query and body parameters
- Body parameter takes priority over query parameter
3. **Supported Values:**
- **Truthy (streaming enabled):** `true`, `"true"`, `1`, `"1"`, `"yes"`, `"on"`
- **Falsy (streaming disabled):** `false`, `"false"`, `0`, `"0"`, `"no"`, `"off"`
- **Default:** `true` (streaming enabled) for backward compatibility
### Usage Examples
#### Query Parameter
```bash
# Disable streaming
curl -X POST "http://localhost:3000/mcp?stream=false" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"method": "initialize", ...}'
# Enable streaming (default)
curl -X POST "http://localhost:3000/mcp?stream=true" ...
```
#### Request Body Parameter
```json
{
"method": "initialize",
"stream": false,
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {
"name": "TestClient",
"version": "1.0.0"
}
},
"jsonrpc": "2.0",
"id": 1
}
```
#### All Route Variants
```bash
POST /mcp?stream=false # Global route
POST /mcp/{group}?stream=false # Group route
POST /mcp/{server}?stream=false # Server route
POST /mcp/$smart?stream=false # Smart routing
```
### Response Formats
#### Streaming Response (stream=true or default)
```
HTTP/1.1 200 OK
Content-Type: text/event-stream
mcp-session-id: 550e8400-e29b-41d4-a716-446655440000
data: {"jsonrpc":"2.0","result":{...},"id":1}
```
#### Non-Streaming Response (stream=false)
```
HTTP/1.1 200 OK
Content-Type: application/json
mcp-session-id: 550e8400-e29b-41d4-a716-446655440000
{
"jsonrpc": "2.0",
"result": {
"protocolVersion": "2025-03-26",
"capabilities": {...},
"serverInfo": {...}
},
"id": 1
}
```
## Testing
### Test Coverage
- **Unit Tests:** 12 tests in `src/services/sseService.test.ts`
- Basic functionality (6 tests)
- Edge cases (6 tests)
- **Integration Tests:** 4 tests in `tests/integration/stream-parameter.test.ts`
- **Total:** 207 tests passing (16 new tests added)
### Test Scenarios Covered
1. ✓ Query parameter: stream=false
2. ✓ Query parameter: stream=true
3. ✓ Body parameter: stream=false
4. ✓ Body parameter: stream=true
5. ✓ Priority: body over query
6. ✓ Default: no parameter provided
7. ✓ Edge case: string "false", "0", "no", "off"
8. ✓ Edge case: string "true", "1", "yes", "on"
9. ✓ Edge case: number 0 and 1
10. ✓ Edge case: invalid/unknown values
## Documentation
### Files Created/Updated
1. **New Documentation:**
- `docs/stream-parameter.md` - Comprehensive guide with examples and use cases
2. **Updated Documentation:**
- `README.md` - Added link to stream parameter documentation
- `README.zh.md` - Added link in Chinese README
3. **Test Documentation:**
- `tests/integration/stream-parameter.test.ts` - Demonstrates usage patterns
### Documentation Topics Covered
- Feature overview
- Usage examples (query and body parameters)
- Response format comparison
- Use cases and when to use each mode
- Technical implementation details
- Backward compatibility notes
- Route variant support
- Limitations and considerations
## Quality Assurance
### Code Review
- ✓ All code review comments addressed
- ✓ No outstanding issues
- ✓ Consistent parsing logic
- ✓ Proper edge case handling
### Validation Results
- ✓ All 207 tests passing
- ✓ TypeScript compilation successful
- ✓ ESLint checks passed
- ✓ Full build completed successfully
- ✓ No breaking changes
- ✓ Backward compatible
## Impact Analysis
### Benefits
1. **Flexibility:** Clients can choose response format based on their needs
2. **Debugging:** Easier to debug with direct JSON responses
3. **Integration:** Simpler integration with systems expecting JSON
4. **Testing:** More straightforward to test and validate
5. **Backward Compatible:** Existing clients continue to work without changes
### Performance Considerations
- No performance impact on default streaming behavior
- Non-streaming mode may have slightly less overhead for simple requests
- Session management works identically in both modes
### Backward Compatibility
- Default behavior unchanged (streaming enabled)
- All existing clients work without modification
- No breaking changes to API or protocol
## Future Considerations
### Potential Enhancements
1. Add documentation for OpenAPI specification
2. Consider adding a configuration option to set default behavior
3. Add metrics/logging for stream parameter usage
4. Consider adding response format negotiation via Accept header
### Known Limitations
1. Stream parameter only affects POST requests to /mcp endpoint
2. SSE GET requests for retrieving streams not affected
3. Session rebuild operations inherit stream setting from original request
## Conclusion
The implementation successfully adds flexible stream control to the MCP protocol implementation while maintaining full backward compatibility. The robust parsing logic handles all common value formats, and comprehensive testing ensures reliable behavior across all scenarios.
**Status:** ✅ Complete and Production Ready
---
*Implementation Date: December 25, 2025*
*Total Development Time: ~2 hours*
*Tests Added: 16*
*Lines of Code Changed: ~200*
*Documentation Pages: 1 comprehensive guide*

View File

@@ -78,7 +78,6 @@ http://localhost:3000/mcp/$smart # Smart routing
| [Quick Start](https://docs.mcphubx.com/quickstart) | Get started in 5 minutes | | [Quick Start](https://docs.mcphubx.com/quickstart) | Get started in 5 minutes |
| [Configuration](https://docs.mcphubx.com/configuration/mcp-settings) | MCP server configuration options | | [Configuration](https://docs.mcphubx.com/configuration/mcp-settings) | MCP server configuration options |
| [Database Mode](https://docs.mcphubx.com/configuration/database-configuration) | PostgreSQL setup for production | | [Database Mode](https://docs.mcphubx.com/configuration/database-configuration) | PostgreSQL setup for production |
| [Stream Parameter](docs/stream-parameter.md) | Control streaming vs JSON responses |
| [OAuth](https://docs.mcphubx.com/features/oauth) | OAuth 2.0 client and server setup | | [OAuth](https://docs.mcphubx.com/features/oauth) | OAuth 2.0 client and server setup |
| [Smart Routing](https://docs.mcphubx.com/features/smart-routing) | AI-powered tool discovery | | [Smart Routing](https://docs.mcphubx.com/features/smart-routing) | AI-powered tool discovery |
| [Docker Setup](https://docs.mcphubx.com/configuration/docker-setup) | Docker deployment guide | | [Docker Setup](https://docs.mcphubx.com/configuration/docker-setup) | Docker deployment guide |

View File

@@ -78,7 +78,6 @@ http://localhost:3000/mcp/$smart # 智能路由
| [快速开始](https://docs.mcphubx.com/zh/quickstart) | 5 分钟快速上手 | | [快速开始](https://docs.mcphubx.com/zh/quickstart) | 5 分钟快速上手 |
| [配置指南](https://docs.mcphubx.com/zh/configuration/mcp-settings) | MCP 服务器配置选项 | | [配置指南](https://docs.mcphubx.com/zh/configuration/mcp-settings) | MCP 服务器配置选项 |
| [数据库模式](https://docs.mcphubx.com/zh/configuration/database-configuration) | PostgreSQL 生产环境配置 | | [数据库模式](https://docs.mcphubx.com/zh/configuration/database-configuration) | PostgreSQL 生产环境配置 |
| [Stream 参数](docs/stream-parameter.md) | 控制流式或 JSON 响应 |
| [OAuth](https://docs.mcphubx.com/zh/features/oauth) | OAuth 2.0 客户端和服务端配置 | | [OAuth](https://docs.mcphubx.com/zh/features/oauth) | OAuth 2.0 客户端和服务端配置 |
| [智能路由](https://docs.mcphubx.com/zh/features/smart-routing) | AI 驱动的工具发现 | | [智能路由](https://docs.mcphubx.com/zh/features/smart-routing) | AI 驱动的工具发现 |
| [Docker 部署](https://docs.mcphubx.com/zh/configuration/docker-setup) | Docker 部署指南 | | [Docker 部署](https://docs.mcphubx.com/zh/configuration/docker-setup) | Docker 部署指南 |

View File

@@ -0,0 +1,218 @@
---
title: OAuth SSO Configuration
description: Configure OAuth 2.0 / OIDC Single Sign-On for MCPHub
---
# OAuth SSO Configuration
MCPHub supports OAuth 2.0 / OIDC Single Sign-On (SSO) for enterprise authentication, allowing users to log in using their existing identity provider accounts (Google, Microsoft, GitHub, or custom OIDC providers).
## Overview
SSO support allows:
- Login via major providers (Google, Microsoft, GitHub)
- Custom OIDC provider integration
- Auto-provisioning of new users from OAuth profiles
- Role mapping from provider claims/groups
- Hybrid auth (both SSO and local username/password)
## Configuration
Add the `oauthSSO` section to your `mcp_settings.json` under `systemConfig`:
```json
{
"systemConfig": {
"oauthSSO": {
"enabled": true,
"allowLocalAuth": true,
"callbackBaseUrl": "https://your-mcphub-domain.com",
"providers": [
{
"id": "google",
"name": "Google",
"type": "google",
"clientId": "your-google-client-id",
"clientSecret": "your-google-client-secret"
},
{
"id": "github",
"name": "GitHub",
"type": "github",
"clientId": "your-github-client-id",
"clientSecret": "your-github-client-secret"
},
{
"id": "microsoft",
"name": "Microsoft",
"type": "microsoft",
"clientId": "your-microsoft-client-id",
"clientSecret": "your-microsoft-client-secret"
}
]
}
}
}
```
## Provider Configuration
### Google
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a new project or select existing one
3. Navigate to "APIs & Services" → "Credentials"
4. Create OAuth 2.0 Client ID (Web application)
5. Add authorized redirect URI: `https://your-domain/auth/sso/google/callback`
6. Copy Client ID and Client Secret
```json
{
"id": "google",
"name": "Google",
"type": "google",
"clientId": "YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com",
"clientSecret": "YOUR_GOOGLE_CLIENT_SECRET"
}
```
### GitHub
1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
2. Click "New OAuth App"
3. Set Authorization callback URL: `https://your-domain/auth/sso/github/callback`
4. Copy Client ID and generate Client Secret
```json
{
"id": "github",
"name": "GitHub",
"type": "github",
"clientId": "YOUR_GITHUB_CLIENT_ID",
"clientSecret": "YOUR_GITHUB_CLIENT_SECRET"
}
```
### Microsoft (Azure AD)
1. Go to [Azure Portal](https://portal.azure.com/) → Azure Active Directory
2. Navigate to "App registrations" → "New registration"
3. Add redirect URI: `https://your-domain/auth/sso/microsoft/callback`
4. Under "Certificates & secrets", create a new client secret
5. Copy Application (client) ID and client secret value
```json
{
"id": "microsoft",
"name": "Microsoft",
"type": "microsoft",
"clientId": "YOUR_AZURE_CLIENT_ID",
"clientSecret": "YOUR_AZURE_CLIENT_SECRET"
}
```
### Custom OIDC Provider
For other OIDC-compatible identity providers:
```json
{
"id": "custom-idp",
"name": "Corporate SSO",
"type": "oidc",
"issuerUrl": "https://idp.example.com",
"authorizationUrl": "https://idp.example.com/oauth2/authorize",
"tokenUrl": "https://idp.example.com/oauth2/token",
"userInfoUrl": "https://idp.example.com/oauth2/userinfo",
"clientId": "YOUR_CLIENT_ID",
"clientSecret": "YOUR_CLIENT_SECRET",
"scopes": ["openid", "email", "profile"],
"attributeMapping": {
"username": "preferred_username",
"email": "email",
"name": "name"
}
}
```
## Role Mapping
Configure automatic admin role assignment based on provider claims:
```json
{
"id": "google",
"name": "Google",
"type": "google",
"clientId": "...",
"clientSecret": "...",
"roleMapping": {
"adminClaim": "groups",
"adminValues": ["mcphub-admins", "engineering-leads"],
"defaultIsAdmin": false
}
}
```
This configuration:
- Checks the `groups` claim in the user's profile
- Grants admin access if any value matches `mcphub-admins` or `engineering-leads`
- Non-matching users get regular (non-admin) access
## Configuration Options
### Global Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `enabled` | boolean | `false` | Enable/disable SSO globally |
| `allowLocalAuth` | boolean | `true` | Allow local username/password auth alongside SSO |
| `callbackBaseUrl` | string | auto-detected | Base URL for OAuth callbacks |
### Provider Options
| Option | Type | Required | Description |
|--------|------|----------|-------------|
| `id` | string | Yes | Unique identifier for the provider |
| `name` | string | Yes | Display name shown on login page |
| `type` | string | Yes | Provider type: `google`, `github`, `microsoft`, or `oidc` |
| `clientId` | string | Yes | OAuth client ID from the provider |
| `clientSecret` | string | Yes | OAuth client secret from the provider |
| `enabled` | boolean | No | Enable/disable this specific provider (default: true) |
| `scopes` | string[] | No | OAuth scopes to request |
| `autoProvision` | boolean | No | Auto-create users on first SSO login (default: true) |
| `allowLinking` | boolean | No | Allow existing users to link their accounts (default: true) |
### Custom OIDC Options (type: "oidc")
| Option | Type | Required | Description |
|--------|------|----------|-------------|
| `issuerUrl` | string | No | OIDC issuer URL for discovery |
| `authorizationUrl` | string | Yes | OAuth authorization endpoint |
| `tokenUrl` | string | Yes | OAuth token endpoint |
| `userInfoUrl` | string | Yes | OIDC userinfo endpoint |
| `attributeMapping` | object | No | Map provider claims to user attributes |
## Security Notes
1. **PKCE Support**: MCPHub uses PKCE (Proof Key for Code Exchange) for all providers except GitHub (which doesn't support it)
2. **State Parameter**: A cryptographically random state is generated for each login to prevent CSRF attacks
3. **Token Storage**: OAuth tokens from providers are not stored; only MCPHub's JWT is issued after successful authentication
4. **Rate Limiting**: Consider implementing rate limiting at infrastructure level (reverse proxy) for SSO endpoints
## Troubleshooting
### Common Issues
1. **"OAuth provider not found"**: Check that the provider is enabled and configured correctly
2. **"Invalid or expired OAuth state"**: The login attempt took too long (>10 minutes) or was a replay attack
3. **"Could not determine username"**: The provider didn't return expected user attributes; check `attributeMapping`
4. **"User account not found and auto-provisioning is disabled"**: Set `autoProvision: true` or pre-create the user
### Debug Mode
Enable debug logging by setting the `DEBUG` environment variable:
```bash
DEBUG=oauth* node dist/index.js
```

View File

@@ -1,177 +0,0 @@
# Stream Parameter Support
MCPHub now supports controlling the response format of MCP requests through a `stream` parameter. This allows you to choose between Server-Sent Events (SSE) streaming responses and direct JSON responses.
## Overview
By default, MCP requests use SSE streaming for real-time communication. However, some use cases benefit from receiving complete JSON responses instead of streams. The `stream` parameter provides this flexibility.
## Usage
### Query Parameter
You can control streaming behavior by adding a `stream` query parameter to your MCP POST requests:
```bash
# Disable streaming (receive JSON response)
POST /mcp?stream=false
# Enable streaming (SSE response) - Default behavior
POST /mcp?stream=true
```
### Request Body Parameter
Alternatively, you can include the `stream` parameter in your request body:
```json
{
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {
"name": "MyClient",
"version": "1.0.0"
}
},
"stream": false,
"jsonrpc": "2.0",
"id": 1
}
```
**Note:** The request body parameter takes priority over the query parameter if both are specified.
## Examples
### Example 1: Non-Streaming Request
```bash
curl -X POST "http://localhost:3000/mcp?stream=false" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {
"name": "TestClient",
"version": "1.0.0"
}
},
"jsonrpc": "2.0",
"id": 1
}'
```
Response (JSON):
```json
{
"jsonrpc": "2.0",
"result": {
"protocolVersion": "2025-03-26",
"capabilities": {
"tools": {},
"prompts": {}
},
"serverInfo": {
"name": "MCPHub",
"version": "1.0.0"
}
},
"id": 1
}
```
### Example 2: Streaming Request (Default)
```bash
curl -X POST "http://localhost:3000/mcp" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {
"name": "TestClient",
"version": "1.0.0"
}
},
"jsonrpc": "2.0",
"id": 1
}'
```
Response (SSE Stream):
```
HTTP/1.1 200 OK
Content-Type: text/event-stream
mcp-session-id: 550e8400-e29b-41d4-a716-446655440000
data: {"jsonrpc":"2.0","result":{...},"id":1}
```
## Use Cases
### When to Use `stream: false`
- **Simple Request-Response**: When you only need a single response without ongoing communication
- **Debugging**: Easier to inspect complete JSON responses in tools like Postman or curl
- **Testing**: Simpler to test and validate responses in automated tests
- **Stateless Operations**: When you don't need to maintain session state between requests
- **API Integration**: When integrating with systems that expect standard JSON responses
### When to Use `stream: true` (Default)
- **Real-time Communication**: When you need continuous updates or notifications
- **Long-running Operations**: For operations that may take time and send progress updates
- **Event-driven**: When your application architecture is event-based
- **MCP Protocol Compliance**: For full MCP protocol compatibility with streaming support
## Technical Details
### Implementation
The `stream` parameter controls the `enableJsonResponse` option of the underlying `StreamableHTTPServerTransport`:
- `stream: true``enableJsonResponse: false` → SSE streaming response
- `stream: false``enableJsonResponse: true` → Direct JSON response
### Backward Compatibility
The default behavior remains SSE streaming (`stream: true`) to maintain backward compatibility with existing clients. If the `stream` parameter is not specified, MCPHub will use streaming by default.
### Session Management
The stream parameter affects how sessions are created:
- **Streaming sessions**: Use SSE transport with session management
- **Non-streaming sessions**: Use direct JSON responses with session management
Both modes support session IDs and can be used with the MCP session management features.
## Group and Server Routes
The stream parameter works with all MCP route variants:
- Global route: `/mcp?stream=false`
- Group route: `/mcp/{group}?stream=false`
- Server route: `/mcp/{server}?stream=false`
- Smart routing: `/mcp/$smart?stream=false`
## Limitations
1. The `stream` parameter only affects POST requests to the `/mcp` endpoint
2. SSE GET requests for retrieving streams are not affected by this parameter
3. Session rebuild operations inherit the stream setting from the original request
## See Also
- [MCP Protocol Specification](https://spec.modelcontextprotocol.io/)
- [API Reference](https://docs.mcphubx.com/api-reference)
- [Configuration Guide](https://docs.mcphubx.com/configuration/mcp-settings)

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 SSOCallbackPage from './pages/SSOCallbackPage';
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="/sso-callback" element={<SSOCallbackPage />} />
{/* 受保护的路由,使用 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

@@ -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, getSSOConfig, initiateSSOLogin } 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 { SSOProvider } from '../types';
const sanitizeReturnUrl = (value: string | null): string | null => { const sanitizeReturnUrl = (value: string | null): string | null => {
if (!value) { if (!value) {
@@ -29,6 +30,65 @@ const sanitizeReturnUrl = (value: string | null): string | null => {
} }
}; };
// Provider icons (SVG)
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 'github':
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
</svg>
);
case 'microsoft':
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path fill="#F25022" d="M1 1h10v10H1z" />
<path fill="#00A4EF" d="M1 13h10v10H1z" />
<path fill="#7FBA00" d="M13 1h10v10H13z" />
<path fill="#FFB900" d="M13 13h10v10H13z" />
</svg>
);
default:
// Generic OAuth/OIDC icon
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" />
</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 +96,9 @@ 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 [ssoProviders, setSsoProviders] = useState<SSOProvider[]>([]);
const [ssoEnabled, setSsoEnabled] = useState(false);
const [allowLocalAuth, setAllowLocalAuth] = useState(true);
const { login } = useAuth(); const { login } = useAuth();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -44,6 +107,17 @@ const LoginPage: React.FC = () => {
return sanitizeReturnUrl(params.get('returnUrl')); return sanitizeReturnUrl(params.get('returnUrl'));
}, [location.search]); }, [location.search]);
// Load SSO configuration on mount
useEffect(() => {
const loadSSOConfig = async () => {
const config = await getSSOConfig();
setSsoEnabled(config.enabled);
setSsoProviders(config.providers);
setAllowLocalAuth(config.allowLocalAuth);
};
loadSSOConfig();
}, []);
const isServerUnavailableError = useCallback((message?: string) => { const isServerUnavailableError = useCallback((message?: string) => {
if (!message) return false; if (!message) return false;
const normalized = message.toLowerCase(); const normalized = message.toLowerCase();
@@ -137,6 +211,10 @@ const LoginPage: React.FC = () => {
} }
}; };
const handleSSOLogin = (providerId: string) => {
initiateSSOLogin(providerId, returnUrl || undefined);
};
const handleCloseWarning = () => { const handleCloseWarning = () => {
setShowDefaultPasswordWarning(false); setShowDefaultPasswordWarning(false);
redirectAfterLogin(); redirectAfterLogin();
@@ -193,58 +271,97 @@ 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">
<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>
{error && ( {/* Local auth form - only show if allowed */}
<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"> {allowLocalAuth && (
{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>
)}
{/* SSO Buttons */}
{ssoEnabled && ssoProviders.length > 0 && (
<div className="space-y-3 mb-6">
{/* Divider */}
<div className="relative my-4">
<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 className="relative flex justify-center text-sm">
<span className="px-2 bg-white/60 text-gray-500 dark:bg-gray-900/60 dark:text-gray-400">
{t('auth.orContinueWith')}
</span>
</div>
</div>
{ssoProviders.map((provider) => (
<button
key={provider.id}
type="button"
onClick={() => handleSSOLogin(provider.id)}
className="sso-button group relative flex w-full items-center justify-center gap-3 rounded-md border border-gray-300/60 bg-white/80 px-4 py-2.5 text-sm font-medium text-gray-700 shadow-sm transition-all hover:bg-gray-50 hover:border-gray-400/60 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"
>
<ProviderIcon type={provider.type} />
<span>{t('auth.continueWith', { provider: provider.name })}</span>
</button>
))}
</div> </div>
</form> )}
{/* Show message if only SSO is available and no providers configured */}
{!allowLocalAuth && ssoProviders.length === 0 && (
<div className="text-center text-gray-500 dark:text-gray-400">
{t('auth.noLoginMethodsAvailable')}
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,108 @@
import React, { useEffect, useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { handleSSOToken, getCurrentUser } from '../services/authService';
import { useAuth } from '../contexts/AuthContext';
/**
* SSO Callback Page
* Handles the redirect from OAuth SSO callback, extracts token, and redirects to destination
*/
const SSOCallbackPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const { auth } = useAuth();
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const handleCallback = async () => {
const params = new URLSearchParams(location.search);
const token = params.get('token');
const returnUrl = params.get('returnUrl') || '/';
const errorParam = params.get('error');
// Handle OAuth errors
if (errorParam) {
setError(errorParam);
setTimeout(() => {
navigate('/login');
}, 3000);
return;
}
// Handle successful SSO login
if (token) {
try {
// Store the token
handleSSOToken(token);
// Verify the token by fetching current user
const response = await getCurrentUser();
if (response.success) {
// Redirect to the return URL or dashboard
if (returnUrl.startsWith('/oauth/authorize')) {
// For OAuth authorize flow, pass the token
const url = new URL(returnUrl, window.location.origin);
url.searchParams.set('token', token);
window.location.assign(`${url.pathname}${url.search}`);
} else {
navigate(returnUrl);
}
} else {
setError(t('auth.ssoTokenInvalid'));
setTimeout(() => {
navigate('/login');
}, 3000);
}
} catch (err) {
console.error('SSO callback error:', err);
setError(t('auth.ssoCallbackError'));
setTimeout(() => {
navigate('/login');
}, 3000);
}
} else {
// No token provided
setError(t('auth.ssoNoToken'));
setTimeout(() => {
navigate('/login');
}, 3000);
}
};
// Only handle callback if not already authenticated
if (!auth.isAuthenticated) {
handleCallback();
} else {
// Already authenticated, redirect to home
navigate('/');
}
}, [location.search, navigate, auth.isAuthenticated, t]);
return (
<div className="relative min-h-screen w-full overflow-hidden bg-gray-50 dark:bg-gray-950 flex items-center justify-center">
<div className="text-center">
{error ? (
<div className="space-y-4">
<div className="text-red-600 dark:text-red-400 text-lg font-medium">
{error}
</div>
<p className="text-gray-500 dark:text-gray-400 text-sm">
{t('auth.redirectingToLogin')}
</p>
</div>
) : (
<div className="space-y-4">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
<p className="text-gray-600 dark:text-gray-300 text-lg">
{t('auth.ssoProcessing')}
</p>
</div>
)}
</div>
</div>
);
};
export default SSOCallbackPage;

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
@@ -737,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;
}>({ }>({
@@ -765,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]);
@@ -866,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,
@@ -873,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);
@@ -901,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;
}, },
@@ -1128,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}
@@ -1142,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

View File

@@ -1,15 +1,54 @@
import { getBasePath } from '@/utils/runtime';
import { import {
AuthResponse, AuthResponse,
LoginCredentials, LoginCredentials,
RegisterCredentials, RegisterCredentials,
ChangePasswordCredentials, ChangePasswordCredentials,
SSOConfig,
} from '../types'; } from '../types';
import { apiPost, apiGet } from '../utils/fetchInterceptor'; import { apiPost, apiGet, fetchWithInterceptors } from '../utils/fetchInterceptor';
import { getToken, setToken, removeToken } from '../utils/interceptors'; import { getToken, setToken, removeToken } from '../utils/interceptors';
// Export token management functions // Export token management functions
export { getToken, setToken, removeToken }; export { getToken, setToken, removeToken };
// Get SSO configuration
export const getSSOConfig = async (): Promise<SSOConfig> => {
try {
const basePath = getBasePath();
// const response = await apiGet<{ success: boolean; data: SSOConfig }>('/auth/sso/config');
const response = await fetchWithInterceptors(`${basePath}/auth/sso/config`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (response.ok) {
const data: { success: boolean; data: SSOConfig } = await response.json();
return data.data;
}
return { enabled: false, providers: [], allowLocalAuth: true };
} catch (error) {
console.error('Get SSO config error:', error);
return { enabled: false, providers: [], allowLocalAuth: true };
}
};
// Initiate SSO login (redirects to provider)
export const initiateSSOLogin = (providerId: string, returnUrl?: string): void => {
const basePath = import.meta.env.VITE_API_BASE_PATH || '';
let url = `${basePath}/auth/sso/${providerId}`;
if (returnUrl) {
url += `?returnUrl=${encodeURIComponent(returnUrl)}`;
}
window.location.href = url;
};
// Handle SSO callback token (called from SSO callback page)
export const handleSSOToken = (token: string): void => {
setToken(token);
};
// Login user // Login user
export const login = async (credentials: LoginCredentials): Promise<AuthResponse> => { export const login = async (credentials: LoginCredentials): Promise<AuthResponse> => {
try { try {

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;
@@ -329,6 +329,19 @@ export interface IUser {
permissions?: string[]; permissions?: string[];
} }
// OAuth SSO types
export interface SSOProvider {
id: string;
name: string;
type: string;
}
export interface SSOConfig {
enabled: boolean;
providers: SSOProvider[];
allowLocalAuth: boolean;
}
// User management types // User management types
export interface User { export interface User {
username: string; username: string;

View File

@@ -79,7 +79,15 @@
"passwordRequireLetter": "Password must contain at least one letter", "passwordRequireLetter": "Password must contain at least one letter",
"passwordRequireNumber": "Password must contain at least one number", "passwordRequireNumber": "Password must contain at least one number",
"passwordRequireSpecial": "Password must contain at least one special character", "passwordRequireSpecial": "Password must contain at least one special character",
"passwordStrengthHint": "Password must be at least 8 characters and contain letters, numbers, and special characters" "passwordStrengthHint": "Password must be at least 8 characters and contain letters, numbers, and special characters",
"continueWith": "Continue with {{provider}}",
"orContinueWith": "or continue with",
"noLoginMethodsAvailable": "No login methods available. Please contact your administrator.",
"ssoProcessing": "Processing login...",
"ssoTokenInvalid": "Authentication failed. Please try again.",
"ssoCallbackError": "An error occurred during authentication.",
"ssoNoToken": "No authentication token received.",
"redirectingToLogin": "Redirecting to login page..."
}, },
"server": { "server": {
"addServer": "Add Server", "addServer": "Add Server",
@@ -568,6 +576,7 @@
"bearerKeyAccessAll": "All", "bearerKeyAccessAll": "All",
"bearerKeyAccessGroups": "Groups", "bearerKeyAccessGroups": "Groups",
"bearerKeyAccessServers": "Servers", "bearerKeyAccessServers": "Servers",
"bearerKeyAccessCustom": "Custom",
"bearerKeyAllowedGroups": "Allowed groups", "bearerKeyAllowedGroups": "Allowed groups",
"bearerKeyAllowedServers": "Allowed servers", "bearerKeyAllowedServers": "Allowed servers",
"addBearerKey": "Add key", "addBearerKey": "Add key",

View File

@@ -569,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

@@ -569,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

@@ -79,7 +79,15 @@
"passwordRequireLetter": "密码必须包含至少一个字母", "passwordRequireLetter": "密码必须包含至少一个字母",
"passwordRequireNumber": "密码必须包含至少一个数字", "passwordRequireNumber": "密码必须包含至少一个数字",
"passwordRequireSpecial": "密码必须包含至少一个特殊字符", "passwordRequireSpecial": "密码必须包含至少一个特殊字符",
"passwordStrengthHint": "密码必须至少 8 个字符,且包含字母、数字和特殊字符" "passwordStrengthHint": "密码必须至少 8 个字符,且包含字母、数字和特殊字符",
"continueWith": "使用 {{provider}} 登录",
"orContinueWith": "或使用账号登录",
"noLoginMethodsAvailable": "没有可用的登录方式,请联系管理员。",
"ssoProcessing": "正在处理登录...",
"ssoTokenInvalid": "认证失败,请重试。",
"ssoCallbackError": "认证过程中发生错误。",
"ssoNoToken": "未收到认证令牌。",
"redirectingToLogin": "正在跳转到登录页面..."
}, },
"server": { "server": {
"addServer": "添加服务器", "addServer": "添加服务器",
@@ -570,6 +578,7 @@
"bearerKeyAccessAll": "全部", "bearerKeyAccessAll": "全部",
"bearerKeyAccessGroups": "指定分组", "bearerKeyAccessGroups": "指定分组",
"bearerKeyAccessServers": "指定服务器", "bearerKeyAccessServers": "指定服务器",
"bearerKeyAccessCustom": "自定义",
"bearerKeyAllowedGroups": "允许访问的分组", "bearerKeyAllowedGroups": "允许访问的分组",
"bearerKeyAllowedServers": "允许访问的服务器", "bearerKeyAllowedServers": "允许访问的服务器",
"addBearerKey": "新增密钥", "addBearerKey": "新增密钥",

View File

@@ -43,6 +43,34 @@
} }
], ],
"systemConfig": { "systemConfig": {
"oauthSSO": {
"enabled": true,
"allowLocalAuth": true,
"callbackBaseUrl": "https://your-mcphub-domain.com",
"providers": [
{
"id": "google",
"name": "Google",
"type": "google",
"clientId": "your-google-client-id",
"clientSecret": "your-google-client-secret"
},
{
"id": "github",
"name": "GitHub",
"type": "github",
"clientId": "your-github-client-id",
"clientSecret": "your-github-client-secret"
},
{
"id": "microsoft",
"name": "Microsoft",
"type": "microsoft",
"clientId": "your-microsoft-client-id",
"clientSecret": "your-microsoft-client-secret"
}
]
},
"oauthServer": { "oauthServer": {
"enabled": true, "enabled": true,
"accessTokenLifetime": 3600, "accessTokenLifetime": 3600,
@@ -63,5 +91,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,171 @@
import { Request, Response } from 'express';
import { loadSettings } from '../config/index.js';
import {
isOAuthSSOEnabled,
isLocalAuthAllowed,
getEnabledProviders,
getProviderById,
generateAuthorizationUrl,
handleOAuthCallback as handleCallback,
} from '../services/oauthSSOService.js';
/**
* Get OAuth SSO configuration for frontend
* Returns list of enabled providers and whether local auth is allowed
*/
export const getSSOConfig = async (req: Request, res: Response): Promise<void> => {
try {
const enabled = isOAuthSSOEnabled();
const providers = getEnabledProviders();
const allowLocalAuth = isLocalAuthAllowed();
res.json({
success: true,
data: {
enabled,
providers,
allowLocalAuth,
},
});
} catch (error) {
console.error('Error getting SSO config:', error);
res.status(500).json({
success: false,
message: 'Failed to get SSO configuration',
});
}
};
/**
* Initiate OAuth SSO flow for a specific provider
* Redirects user to the OAuth provider's authorization page
*/
export const initiateSSOLogin = async (req: Request, res: Response): Promise<void> => {
const { provider } = req.params;
try {
// Check if SSO is enabled
if (!isOAuthSSOEnabled()) {
res.status(400).json({
success: false,
message: 'OAuth SSO is not enabled',
});
return;
}
// Check if provider exists
const providerConfig = getProviderById(provider);
if (!providerConfig) {
res.status(404).json({
success: false,
message: `OAuth provider '${provider}' not found or disabled`,
});
return;
}
// Build redirect URI
const settings = loadSettings();
const callbackBaseUrl =
settings.systemConfig?.oauthSSO?.callbackBaseUrl || `${req.protocol}://${req.get('host')}`;
const redirectUri = `${callbackBaseUrl}/auth/sso/${provider}/callback`;
// Generate authorization URL
const result = generateAuthorizationUrl(provider, redirectUri);
if (!result) {
res.status(500).json({
success: false,
message: 'Failed to generate authorization URL',
});
return;
}
// Store the return URL in a cookie if provided (for after-login redirect)
const returnUrl = req.query.returnUrl as string;
if (returnUrl) {
res.cookie('sso_return_url', returnUrl, {
httpOnly: true,
secure: req.secure,
maxAge: 10 * 60 * 1000, // 10 minutes
sameSite: 'lax',
});
}
// Redirect to OAuth provider
res.redirect(result.url);
} catch (error) {
console.error(`Error initiating SSO login for ${provider}:`, error);
res.status(500).json({
success: false,
message: 'Failed to initiate SSO login',
});
}
};
/**
* Handle OAuth callback from provider
* Exchanges code for tokens, gets user info, creates/updates user, returns JWT
*
* Note: OAuth callback data (code, state) is received via query parameters as per OAuth 2.0 spec.
* This is secure because:
* - The authorization code is single-use and tied to a specific state
* - The state parameter prevents CSRF attacks
* - PKCE provides additional security for the token exchange
*/
export const handleSSOCallback = async (req: Request, res: Response): Promise<void> => {
const { provider } = req.params;
// lgtm[js/sensitive-get-query] - OAuth 2.0 requires code/state in query params
const { code, state, error: oauthError, error_description } = req.query;
try {
// Check for OAuth error from provider
if (oauthError) {
console.error(`OAuth SSO error from ${provider}:`, oauthError, error_description);
res.redirect(`/login?error=${encodeURIComponent(String(error_description || oauthError))}`);
return;
}
// Validate required parameters
if (!code || !state) {
res.redirect('/login?error=missing_oauth_parameters');
return;
}
// Build redirect URI (must match the one used in initiation)
const settings = loadSettings();
const callbackBaseUrl =
settings.systemConfig?.oauthSSO?.callbackBaseUrl || `${req.protocol}://${req.get('host')}`;
const redirectUri = `${callbackBaseUrl}/auth/sso/${provider}/callback`;
// Handle the callback
const result = await handleCallback(String(state), String(code), redirectUri);
if (!result.success) {
console.error(`OAuth SSO callback failed for ${provider}:`, result.error);
res.redirect(`/login?error=${encodeURIComponent(result.error || 'sso_failed')}`);
return;
}
// Get the return URL from cookie
const returnUrl = req.cookies?.sso_return_url || '/';
res.clearCookie('sso_return_url');
// Build redirect URL with token
// Note: For security, we use a short-lived token in URL and the frontend
// should immediately exchange it and store in localStorage
const redirectUrl = new URL(returnUrl, `${req.protocol}://${req.get('host')}`);
// For OAuth authorize flow, append token as query param
if (returnUrl.startsWith('/oauth/authorize')) {
redirectUrl.searchParams.set('token', result.token!);
res.redirect(redirectUrl.pathname + redirectUrl.search);
} else {
// For normal login, redirect to a special callback page that handles the token
res.redirect(
`/sso-callback?token=${encodeURIComponent(result.token!)}&returnUrl=${encodeURIComponent(returnUrl)}`,
);
}
} catch (error) {
console.error(`Error handling SSO callback for ${provider}:`, error);
res.redirect('/login?error=sso_callback_error');
}
};

View File

@@ -423,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,
@@ -510,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({
@@ -524,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',
}); });
} }
}; };

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

@@ -19,6 +19,7 @@ export class UserDaoDbImpl implements UserDao {
username: u.username, username: u.username,
password: u.password, password: u.password,
isAdmin: u.isAdmin, isAdmin: u.isAdmin,
oauthLinks: u.oauthLinks ?? undefined,
})); }));
} }
@@ -29,6 +30,7 @@ export class UserDaoDbImpl implements UserDao {
username: user.username, username: user.username,
password: user.password, password: user.password,
isAdmin: user.isAdmin, isAdmin: user.isAdmin,
oauthLinks: user.oauthLinks ?? undefined,
}; };
} }
@@ -41,11 +43,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,
oauthLinks: entity.oauthLinks ?? null,
}); });
return { return {
username: user.username, username: user.username,
password: user.password, password: user.password,
isAdmin: user.isAdmin, isAdmin: user.isAdmin,
oauthLinks: user.oauthLinks ?? undefined,
}; };
} }
@@ -62,12 +66,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,
oauthLinks: entity.oauthLinks ?? undefined,
}); });
if (!user) return null; if (!user) return null;
return { return {
username: user.username, username: user.username,
password: user.password, password: user.password,
isAdmin: user.isAdmin, isAdmin: user.isAdmin,
oauthLinks: user.oauthLinks ?? undefined,
}; };
} }
@@ -103,6 +109,7 @@ export class UserDaoDbImpl implements UserDao {
username: u.username, username: u.username,
password: u.password, password: u.password,
isAdmin: u.isAdmin, isAdmin: u.isAdmin,
oauthLinks: u.oauthLinks ?? undefined,
})); }));
} }
} }

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({ 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

@@ -5,6 +5,7 @@ import {
CreateDateColumn, CreateDateColumn,
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } from 'typeorm';
import { IOAuthLink } from '../../types/index.js';
/** /**
* User entity for database storage * User entity for database storage
@@ -23,6 +24,9 @@ export class User {
@Column({ type: 'boolean', default: false }) @Column({ type: 'boolean', default: false })
isAdmin: boolean; isAdmin: boolean;
@Column({ type: 'simple-json', nullable: true })
oauthLinks: IOAuthLink[] | null;
@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

@@ -66,6 +66,11 @@ import {
getRegistryServerVersion, getRegistryServerVersion,
} from '../controllers/registryController.js'; } from '../controllers/registryController.js';
import { login, register, getCurrentUser, changePassword } from '../controllers/authController.js'; import { login, register, getCurrentUser, changePassword } from '../controllers/authController.js';
import {
getSSOConfig,
initiateSSOLogin,
handleSSOCallback,
} from '../controllers/oauthSSOController.js';
import { getAllLogs, clearLogs, streamLogs } from '../controllers/logController.js'; import { getAllLogs, clearLogs, streamLogs } from '../controllers/logController.js';
import { import {
getRuntimeConfig, getRuntimeConfig,
@@ -273,6 +278,11 @@ export const initRoutes = (app: express.Application): void => {
changePassword, changePassword,
); );
// OAuth SSO routes (no auth required - public endpoints)
app.get(`${config.basePath}/auth/sso/config`, getSSOConfig); // Get SSO configuration for frontend
app.get(`${config.basePath}/auth/sso/:provider`, initiateSSOLogin); // Initiate SSO login
app.get(`${config.basePath}/auth/sso/:provider/callback`, handleSSOCallback); // Handle OAuth callback
// 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

@@ -0,0 +1,600 @@
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import { loadSettings } from '../config/index.js';
import { JWT_SECRET } from '../config/jwt.js';
import { OAuthSSOConfig, OAuthSSOProvider, IUser, IOAuthLink } from '../types/index.js';
import { getUserDao } from '../dao/index.js';
import { getDataService } from './services.js';
// Built-in provider configurations for Google, GitHub, Microsoft
const BUILTIN_PROVIDERS: Record<string, Omit<OAuthSSOProvider, 'clientId' | 'clientSecret' | 'id' | 'name'>> = {
google: {
type: 'google',
issuerUrl: 'https://accounts.google.com',
authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
tokenUrl: 'https://oauth2.googleapis.com/token',
userInfoUrl: 'https://openidconnect.googleapis.com/v1/userinfo',
scopes: ['openid', 'email', 'profile'],
attributeMapping: {
username: 'email',
email: 'email',
name: 'name',
},
},
github: {
type: 'github',
authorizationUrl: 'https://github.com/login/oauth/authorize',
tokenUrl: 'https://github.com/login/oauth/access_token',
userInfoUrl: 'https://api.github.com/user',
scopes: ['read:user', 'user:email'],
attributeMapping: {
username: 'login',
email: 'email',
name: 'name',
},
},
microsoft: {
type: 'microsoft',
issuerUrl: 'https://login.microsoftonline.com/common/v2.0',
authorizationUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
userInfoUrl: 'https://graph.microsoft.com/oidc/userinfo',
scopes: ['openid', 'email', 'profile'],
attributeMapping: {
username: 'email',
email: 'email',
name: 'name',
},
},
};
// In-memory store for OAuth state (should be replaced with Redis/DB in production)
const pendingStates = new Map<string, { provider: string; expiresAt: number; codeVerifier?: string }>();
// JWT token expiry for SSO logins
const TOKEN_EXPIRY = '24h';
/**
* Get OAuth SSO configuration from settings
*/
export function getOAuthSSOConfig(): OAuthSSOConfig | undefined {
const settings = loadSettings();
return settings.systemConfig?.oauthSSO;
}
/**
* Check if OAuth SSO is enabled
*/
export function isOAuthSSOEnabled(): boolean {
const config = getOAuthSSOConfig();
return config?.enabled === true && (config.providers?.length ?? 0) > 0;
}
/**
* Check if local authentication is allowed alongside SSO
*/
export function isLocalAuthAllowed(): boolean {
const config = getOAuthSSOConfig();
// Default to true - allow local auth unless explicitly disabled
return config?.allowLocalAuth !== false;
}
/**
* Get list of enabled SSO providers for frontend display
*/
export function getEnabledProviders(): Array<{ id: string; name: string; type: string }> {
const config = getOAuthSSOConfig();
if (!config?.enabled || !config.providers) {
return [];
}
return config.providers
.filter((p) => p.enabled !== false)
.map((p) => ({
id: p.id,
name: p.name,
type: p.type,
}));
}
/**
* Get provider configuration by ID
*/
export function getProviderById(providerId: string): OAuthSSOProvider | undefined {
const config = getOAuthSSOConfig();
if (!config?.enabled || !config.providers) {
return undefined;
}
return config.providers.find((p) => p.id === providerId && p.enabled !== false);
}
/**
* Generate PKCE code verifier and challenge
*/
function generatePKCE(): { codeVerifier: string; codeChallenge: string } {
const codeVerifier = crypto.randomBytes(32).toString('base64url');
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
return { codeVerifier, codeChallenge };
}
/**
* Build the complete provider configuration (merge with built-in defaults)
*/
function buildProviderConfig(provider: OAuthSSOProvider): OAuthSSOProvider {
const builtin = BUILTIN_PROVIDERS[provider.type];
if (builtin && provider.type !== 'oidc') {
return {
...builtin,
...provider,
scopes: provider.scopes ?? builtin.scopes,
attributeMapping: { ...builtin.attributeMapping, ...provider.attributeMapping },
};
}
return provider;
}
/**
* Generate OAuth authorization URL for a provider
*/
export function generateAuthorizationUrl(
providerId: string,
redirectUri: string,
): { url: string; state: string } | null {
const provider = getProviderById(providerId);
if (!provider) {
return null;
}
const config = buildProviderConfig(provider);
const authUrl = config.authorizationUrl;
if (!authUrl) {
console.error(`OAuth SSO: No authorization URL configured for provider ${providerId}`);
return null;
}
// Generate state and PKCE
const state = crypto.randomBytes(16).toString('hex');
const { codeVerifier, codeChallenge } = generatePKCE();
// Store state for validation (expires in 10 minutes)
pendingStates.set(state, {
provider: providerId,
expiresAt: Date.now() + 10 * 60 * 1000,
codeVerifier,
});
// Clean up expired states periodically
cleanupExpiredStates();
// Build authorization URL
const url = new URL(authUrl);
url.searchParams.set('client_id', config.clientId);
url.searchParams.set('redirect_uri', redirectUri);
url.searchParams.set('response_type', 'code');
url.searchParams.set('state', state);
// Add scopes
const scopes = config.scopes ?? ['openid', 'email', 'profile'];
url.searchParams.set('scope', scopes.join(' '));
// Add PKCE if not GitHub (GitHub doesn't support PKCE)
if (config.type !== 'github') {
url.searchParams.set('code_challenge', codeChallenge);
url.searchParams.set('code_challenge_method', 'S256');
}
return { url: url.toString(), state };
}
/**
* Cleanup expired OAuth states
*/
function cleanupExpiredStates(): void {
const now = Date.now();
for (const [state, data] of pendingStates.entries()) {
if (data.expiresAt < now) {
pendingStates.delete(state);
}
}
}
/**
* Validate OAuth state and get stored data
*/
function validateState(state: string): { provider: string; codeVerifier?: string } | null {
const data = pendingStates.get(state);
if (!data) {
return null;
}
// Remove state to prevent replay
pendingStates.delete(state);
// Check expiration
if (data.expiresAt < Date.now()) {
return null;
}
return { provider: data.provider, codeVerifier: data.codeVerifier };
}
/**
* Exchange authorization code for tokens
*/
async function exchangeCodeForTokens(
provider: OAuthSSOProvider,
code: string,
redirectUri: string,
codeVerifier?: string,
): Promise<{ accessToken: string; idToken?: string } | null> {
const config = buildProviderConfig(provider);
const tokenUrl = config.tokenUrl;
if (!tokenUrl) {
console.error(`OAuth SSO: No token URL configured for provider ${provider.id}`);
return null;
}
const params = new URLSearchParams();
params.set('grant_type', 'authorization_code');
params.set('code', code);
params.set('redirect_uri', redirectUri);
params.set('client_id', config.clientId);
params.set('client_secret', config.clientSecret);
// Add PKCE verifier if available (not for GitHub)
if (codeVerifier && config.type !== 'github') {
params.set('code_verifier', codeVerifier);
}
try {
const response = await fetch(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: params.toString(),
});
if (!response.ok) {
const errorText = await response.text();
console.error(`OAuth SSO: Token exchange failed for ${provider.id}:`, errorText);
return null;
}
const data = await response.json();
return {
accessToken: data.access_token,
idToken: data.id_token,
};
} catch (error) {
console.error(`OAuth SSO: Token exchange error for ${provider.id}:`, error);
return null;
}
}
/**
* Get user info from the OAuth provider
*/
async function getUserInfo(
provider: OAuthSSOProvider,
accessToken: string,
): Promise<Record<string, unknown> | null> {
const config = buildProviderConfig(provider);
const userInfoUrl = config.userInfoUrl;
if (!userInfoUrl) {
console.error(`OAuth SSO: No userinfo URL configured for provider ${provider.id}`);
return null;
}
try {
const response = await fetch(userInfoUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
});
if (!response.ok) {
const errorText = await response.text();
console.error(`OAuth SSO: UserInfo request failed for ${provider.id}:`, errorText);
return null;
}
return await response.json();
} catch (error) {
console.error(`OAuth SSO: UserInfo error for ${provider.id}:`, error);
return null;
}
}
/**
* For GitHub, we need to make a separate request to get email if not public
*/
async function getGitHubEmail(accessToken: string): Promise<string | null> {
try {
const response = await fetch('https://api.github.com/user/emails', {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
});
if (!response.ok) {
return null;
}
const emails = (await response.json()) as Array<{ email: string; primary: boolean; verified: boolean }>;
const primaryEmail = emails.find((e) => e.primary && e.verified);
return primaryEmail?.email ?? emails[0]?.email ?? null;
} catch {
return null;
}
}
/**
* Extract user attributes from provider userinfo based on attribute mapping
*/
function extractUserAttributes(
provider: OAuthSSOProvider,
userInfo: Record<string, unknown>,
): { providerId: string; username: string; email?: string; name?: string } {
const config = buildProviderConfig(provider);
const mapping = config.attributeMapping ?? {};
// Get provider user ID
let providerId: string;
if (provider.type === 'github') {
providerId = String(userInfo.id);
} else {
providerId = String(userInfo.sub ?? userInfo.id);
}
// Get username
const usernameField = mapping.username ?? 'email';
let username = String(userInfo[usernameField] ?? '');
if (!username && userInfo.email) {
username = String(userInfo.email);
}
// Get email
const emailField = mapping.email ?? 'email';
const email = userInfo[emailField] ? String(userInfo[emailField]) : undefined;
// Get display name
const nameField = mapping.name ?? 'name';
const name = userInfo[nameField] ? String(userInfo[nameField]) : undefined;
return { providerId, username, email, name };
}
/**
* Determine if user should be admin based on role mapping
*/
function determineAdminStatus(provider: OAuthSSOProvider, userInfo: Record<string, unknown>): boolean {
const config = buildProviderConfig(provider);
const roleMapping = config.roleMapping;
if (!roleMapping) {
return false;
}
// Check if admin claim is configured
if (roleMapping.adminClaim && roleMapping.adminValues?.length) {
const claimValue = userInfo[roleMapping.adminClaim];
if (claimValue) {
// Handle both single value and array claims
const values = Array.isArray(claimValue) ? claimValue : [claimValue];
for (const value of values) {
if (roleMapping.adminValues.includes(String(value))) {
return true;
}
}
}
}
return roleMapping.defaultIsAdmin ?? false;
}
/**
* Handle OAuth callback - exchange code, get user info, create/update user, return JWT
*/
export async function handleOAuthCallback(
state: string,
code: string,
redirectUri: string,
): Promise<{
success: boolean;
token?: string;
user?: { username: string; isAdmin: boolean; permissions?: string[] };
error?: string;
}> {
// Validate state
const stateData = validateState(state);
if (!stateData) {
return { success: false, error: 'Invalid or expired OAuth state' };
}
// Get provider
const provider = getProviderById(stateData.provider);
if (!provider) {
return { success: false, error: 'OAuth provider not found or disabled' };
}
// Exchange code for tokens
const tokens = await exchangeCodeForTokens(provider, code, redirectUri, stateData.codeVerifier);
if (!tokens) {
return { success: false, error: 'Failed to exchange authorization code for tokens' };
}
// Get user info
let userInfo = await getUserInfo(provider, tokens.accessToken);
if (!userInfo) {
return { success: false, error: 'Failed to get user information from provider' };
}
// For GitHub, get email separately if not in userinfo
if (provider.type === 'github' && !userInfo.email) {
const email = await getGitHubEmail(tokens.accessToken);
if (email) {
userInfo = { ...userInfo, email };
}
}
// Extract user attributes
const { providerId, username, email, name } = extractUserAttributes(provider, userInfo);
if (!username) {
return { success: false, error: 'Could not determine username from OAuth provider' };
}
// Determine admin status
const isAdmin = determineAdminStatus(provider, userInfo);
// Find or create user
const userDao = getUserDao();
const config = buildProviderConfig(provider);
// First, try to find user by OAuth link
let user = await findUserByOAuthLink(provider.id, providerId);
if (!user) {
// Try to find by username (for linking existing accounts)
user = await userDao.findByUsername(username);
if (user) {
// Existing user found - link their account if allowed
if (config.allowLinking !== false) {
const oauthLink: IOAuthLink = {
provider: provider.id,
providerId,
email,
name,
linkedAt: new Date().toISOString(),
};
user = await linkOAuthAccount(user.username, oauthLink);
}
} else if (config.autoProvision !== false) {
// Auto-provision new user
try {
// Generate a random secure password (user won't need it with SSO)
const randomPassword = crypto.randomBytes(32).toString('hex');
user = await userDao.createWithHashedPassword(username, randomPassword, isAdmin);
// Link OAuth account
const oauthLink: IOAuthLink = {
provider: provider.id,
providerId,
email,
name,
linkedAt: new Date().toISOString(),
};
user = await linkOAuthAccount(username, oauthLink);
console.log(`OAuth SSO: Auto-provisioned user ${username} via ${provider.id}`);
} catch (error) {
console.error(`OAuth SSO: Failed to create user ${username}:`, error);
return { success: false, error: 'Failed to create user account' };
}
} else {
return { success: false, error: 'User account not found and auto-provisioning is disabled' };
}
}
if (!user) {
return { success: false, error: 'Failed to find or create user account' };
}
// Generate JWT token
const payload = {
user: {
username: user.username,
isAdmin: user.isAdmin || false,
},
};
return new Promise((resolve) => {
jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY }, (err, token) => {
if (err || !token) {
console.error('OAuth SSO: Failed to generate JWT:', err);
resolve({ success: false, error: 'Failed to generate authentication token' });
return;
}
const dataService = getDataService();
resolve({
success: true,
token,
user: {
username: user!.username,
isAdmin: user!.isAdmin || false,
permissions: dataService.getPermissions(user!),
},
});
});
});
}
/**
* Find user by OAuth link
*/
async function findUserByOAuthLink(providerId: string, providerUserId: string): Promise<IUser | null> {
const userDao = getUserDao();
const users = await userDao.findAll();
for (const user of users) {
if (user.oauthLinks?.some((link) => link.provider === providerId && link.providerId === providerUserId)) {
return user;
}
}
return null;
}
/**
* Link OAuth account to existing user
*/
async function linkOAuthAccount(username: string, oauthLink: IOAuthLink): Promise<IUser | null> {
const userDao = getUserDao();
const user = await userDao.findByUsername(username);
if (!user) {
return null;
}
// Add or update OAuth link
const existingLinks = user.oauthLinks ?? [];
const linkIndex = existingLinks.findIndex((l) => l.provider === oauthLink.provider);
if (linkIndex >= 0) {
existingLinks[linkIndex] = oauthLink;
} else {
existingLinks.push(oauthLink);
}
return await userDao.update(username, { oauthLinks: existingLinks });
}
/**
* Unlink OAuth account from user
*/
export async function unlinkOAuthAccount(username: string, providerId: string): Promise<IUser | null> {
const userDao = getUserDao();
const user = await userDao.findByUsername(username);
if (!user || !user.oauthLinks) {
return null;
}
const updatedLinks = user.oauthLinks.filter((l) => l.provider !== providerId);
return await userDao.update(username, { oauthLinks: updatedLinks });
}
/**
* Get OAuth links for a user
*/
export async function getUserOAuthLinks(username: string): Promise<IOAuthLink[]> {
const userDao = getUserDao();
const user = await userDao.findByUsername(username);
return user?.oauthLinks ?? [];
}

View File

@@ -633,274 +633,4 @@ describe('sseService', () => {
expectBearerUnauthorized(res, 'No authorization provided'); expectBearerUnauthorized(res, 'No authorization provided');
}); });
}); });
describe('stream parameter support', () => {
beforeEach(() => {
// Clear transports before each test
Object.keys(transports).forEach((key) => delete transports[key]);
});
it('should create transport with enableJsonResponse=true when stream=false in body', async () => {
const req = createMockRequest({
params: { group: 'test-group' },
body: {
method: 'initialize',
stream: false,
},
});
const res = createMockResponse();
await handleMcpPostRequest(req, res);
// Check that StreamableHTTPServerTransport was called with enableJsonResponse: true
expect(StreamableHTTPServerTransport).toHaveBeenCalledWith(
expect.objectContaining({
enableJsonResponse: true,
}),
);
});
it('should create transport with enableJsonResponse=false when stream=true in body', async () => {
const req = createMockRequest({
params: { group: 'test-group' },
body: {
method: 'initialize',
stream: true,
},
});
const res = createMockResponse();
await handleMcpPostRequest(req, res);
// Check that StreamableHTTPServerTransport was called with enableJsonResponse: false
expect(StreamableHTTPServerTransport).toHaveBeenCalledWith(
expect.objectContaining({
enableJsonResponse: false,
}),
);
});
it('should create transport with enableJsonResponse=true when stream=false in query', async () => {
const req = createMockRequest({
params: { group: 'test-group' },
query: { stream: 'false' },
body: {
method: 'initialize',
},
});
const res = createMockResponse();
await handleMcpPostRequest(req, res);
// Check that StreamableHTTPServerTransport was called with enableJsonResponse: true
expect(StreamableHTTPServerTransport).toHaveBeenCalledWith(
expect.objectContaining({
enableJsonResponse: true,
}),
);
});
it('should default to enableJsonResponse=false when stream parameter not provided', async () => {
const req = createMockRequest({
params: { group: 'test-group' },
body: {
method: 'initialize',
},
});
const res = createMockResponse();
await handleMcpPostRequest(req, res);
// Check that StreamableHTTPServerTransport was called with enableJsonResponse: false (default)
expect(StreamableHTTPServerTransport).toHaveBeenCalledWith(
expect.objectContaining({
enableJsonResponse: false,
}),
);
});
it('should prioritize body stream parameter over query parameter', async () => {
const req = createMockRequest({
params: { group: 'test-group' },
query: { stream: 'true' },
body: {
method: 'initialize',
stream: false, // body should take priority
},
});
const res = createMockResponse();
await handleMcpPostRequest(req, res);
// Check that StreamableHTTPServerTransport was called with enableJsonResponse: true (from body)
expect(StreamableHTTPServerTransport).toHaveBeenCalledWith(
expect.objectContaining({
enableJsonResponse: true,
}),
);
});
it('should pass enableJsonResponse to createSessionWithId when rebuilding session', async () => {
setMockSystemConfig({
routing: {
enableGlobalRoute: true,
enableGroupNameRoute: true,
enableBearerAuth: false,
bearerAuthKey: 'test-key',
},
enableSessionRebuild: true,
});
const req = createMockRequest({
params: { group: 'test-group' },
headers: { 'mcp-session-id': 'invalid-session' },
body: {
method: 'someMethod',
stream: false,
},
});
const res = createMockResponse();
await handleMcpPostRequest(req, res);
// Check that StreamableHTTPServerTransport was called with enableJsonResponse: true
expect(StreamableHTTPServerTransport).toHaveBeenCalledWith(
expect.objectContaining({
enableJsonResponse: true,
}),
);
});
it('should handle string "false" in query parameter', async () => {
const req = createMockRequest({
params: { group: 'test-group' },
query: { stream: 'false' },
body: {
method: 'initialize',
},
});
const res = createMockResponse();
await handleMcpPostRequest(req, res);
expect(StreamableHTTPServerTransport).toHaveBeenCalledWith(
expect.objectContaining({
enableJsonResponse: true,
}),
);
});
it('should handle string "0" in query parameter', async () => {
const req = createMockRequest({
params: { group: 'test-group' },
query: { stream: '0' },
body: {
method: 'initialize',
},
});
const res = createMockResponse();
await handleMcpPostRequest(req, res);
expect(StreamableHTTPServerTransport).toHaveBeenCalledWith(
expect.objectContaining({
enableJsonResponse: true,
}),
);
});
it('should handle number 0 in body parameter', async () => {
const req = createMockRequest({
params: { group: 'test-group' },
body: {
method: 'initialize',
stream: 0,
},
});
const res = createMockResponse();
await handleMcpPostRequest(req, res);
expect(StreamableHTTPServerTransport).toHaveBeenCalledWith(
expect.objectContaining({
enableJsonResponse: true,
}),
);
});
it('should handle number 1 in body parameter', async () => {
const req = createMockRequest({
params: { group: 'test-group' },
body: {
method: 'initialize',
stream: 1,
},
});
const res = createMockResponse();
await handleMcpPostRequest(req, res);
expect(StreamableHTTPServerTransport).toHaveBeenCalledWith(
expect.objectContaining({
enableJsonResponse: false,
}),
);
});
it('should handle "yes" and "no" string values', async () => {
// Test "yes"
const reqYes = createMockRequest({
params: { group: 'test-group' },
query: { stream: 'yes' },
body: { method: 'initialize' },
});
const resYes = createMockResponse();
await handleMcpPostRequest(reqYes, resYes);
expect(StreamableHTTPServerTransport).toHaveBeenCalledWith(
expect.objectContaining({
enableJsonResponse: false,
}),
);
jest.clearAllMocks();
// Test "no"
const reqNo = createMockRequest({
params: { group: 'test-group' },
query: { stream: 'no' },
body: { method: 'initialize' },
});
const resNo = createMockResponse();
await handleMcpPostRequest(reqNo, resNo);
expect(StreamableHTTPServerTransport).toHaveBeenCalledWith(
expect.objectContaining({
enableJsonResponse: true,
}),
);
});
it('should default to streaming for invalid/unknown values', async () => {
const req = createMockRequest({
params: { group: 'test-group' },
query: { stream: 'invalid-value' },
body: {
method: 'initialize',
},
});
const res = createMockResponse();
await handleMcpPostRequest(req, res);
// Should default to streaming (enableJsonResponse: false)
expect(StreamableHTTPServerTransport).toHaveBeenCalledWith(
expect.objectContaining({
enableJsonResponse: false,
}),
);
});
});
}); });

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);
} }
@@ -408,10 +431,9 @@ async function createSessionWithId(
sessionId: string, sessionId: string,
group: string, group: string,
username?: string, username?: string,
enableJsonResponse?: boolean,
): Promise<StreamableHTTPServerTransport> { ): Promise<StreamableHTTPServerTransport> {
console.log( console.log(
`[SESSION REBUILD] Starting session rebuild for ID: ${sessionId}${username ? ` for user: ${username}` : ''} with enableJsonResponse: ${enableJsonResponse}`, `[SESSION REBUILD] Starting session rebuild for ID: ${sessionId}${username ? ` for user: ${username}` : ''}`,
); );
// Create a new server instance to ensure clean state // Create a new server instance to ensure clean state
@@ -419,7 +441,6 @@ async function createSessionWithId(
const transport = new StreamableHTTPServerTransport({ const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => sessionId, // Use the specified sessionId sessionIdGenerator: () => sessionId, // Use the specified sessionId
enableJsonResponse: enableJsonResponse ?? false,
onsessioninitialized: (initializedSessionId) => { onsessioninitialized: (initializedSessionId) => {
console.log( console.log(
`[SESSION REBUILD] onsessioninitialized triggered for ID: ${initializedSessionId}`, `[SESSION REBUILD] onsessioninitialized triggered for ID: ${initializedSessionId}`,
@@ -471,16 +492,14 @@ async function createSessionWithId(
async function createNewSession( async function createNewSession(
group: string, group: string,
username?: string, username?: string,
enableJsonResponse?: boolean,
): Promise<StreamableHTTPServerTransport> { ): Promise<StreamableHTTPServerTransport> {
const newSessionId = randomUUID(); const newSessionId = randomUUID();
console.log( console.log(
`[SESSION NEW] Creating new session with ID: ${newSessionId}${username ? ` for user: ${username}` : ''} with enableJsonResponse: ${enableJsonResponse}`, `[SESSION NEW] Creating new session with ID: ${newSessionId}${username ? ` for user: ${username}` : ''}`,
); );
const transport = new StreamableHTTPServerTransport({ const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => newSessionId, sessionIdGenerator: () => newSessionId,
enableJsonResponse: enableJsonResponse ?? false,
onsessioninitialized: (sessionId) => { onsessioninitialized: (sessionId) => {
transports[sessionId] = { transport, group }; transports[sessionId] = { transport, group };
console.log( console.log(
@@ -519,48 +538,8 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
const sessionId = req.headers['mcp-session-id'] as string | undefined; const sessionId = req.headers['mcp-session-id'] as string | undefined;
const group = req.params.group; const group = req.params.group;
const body = req.body; const body = req.body;
// Parse stream parameter from query string or request body
// Default to true (SSE streaming) for backward compatibility
let enableStreaming = true;
// Helper function to parse stream parameter value
const parseStreamParam = (value: any): boolean => {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'string') {
const lowerValue = value.toLowerCase().trim();
// Accept 'true', '1', 'yes', 'on' as truthy
if (['true', '1', 'yes', 'on'].includes(lowerValue)) {
return true;
}
// Accept 'false', '0', 'no', 'off' as falsy
if (['false', '0', 'no', 'off'].includes(lowerValue)) {
return false;
}
}
if (typeof value === 'number') {
return value !== 0;
}
// Default to true for any other value (including undefined)
return true;
};
// Check query parameter first
if (req.query.stream !== undefined) {
enableStreaming = parseStreamParam(req.query.stream);
}
// Then check request body (has higher priority)
if (body && typeof body === 'object' && 'stream' in body) {
enableStreaming = parseStreamParam(body.stream);
}
// enableJsonResponse is the inverse of enableStreaming
const enableJsonResponse = !enableStreaming;
console.log( console.log(
`Handling MCP post request for sessionId: ${sessionId} and group: ${group}${username ? ` for user: ${username}` : ''} with enableStreaming: ${enableStreaming}`, `Handling MCP post request for sessionId: ${sessionId} and group: ${group}${username ? ` for user: ${username}` : ''} with body: ${JSON.stringify(body)}`,
); );
// Get filtered settings based on user context (after setting user context) // Get filtered settings based on user context (after setting user context)
@@ -603,7 +582,7 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
); );
transport = await sessionCreationLocks[sessionId]; transport = await sessionCreationLocks[sessionId];
} else { } else {
sessionCreationLocks[sessionId] = createSessionWithId(sessionId, group, username, enableJsonResponse); sessionCreationLocks[sessionId] = createSessionWithId(sessionId, group, username);
try { try {
transport = await sessionCreationLocks[sessionId]; transport = await sessionCreationLocks[sessionId];
console.log( console.log(
@@ -640,7 +619,7 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
console.log( console.log(
`[SESSION CREATE] No session ID provided for initialize request, creating new session${username ? ` for user: ${username}` : ''}`, `[SESSION CREATE] No session ID provided for initialize request, creating new session${username ? ` for user: ${username}` : ''}`,
); );
transport = await createNewSession(group, username, enableJsonResponse); transport = await createNewSession(group, username);
} else { } else {
// Case 4: No sessionId and not an initialize request, return error // Case 4: No sessionId and not an initialize request, return error
console.warn( console.warn(

View File

@@ -1,7 +1,7 @@
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';
@@ -197,6 +197,12 @@ export const saveToolsAsVectorEmbeddings = async (
return; return;
} }
// Ensure database is initialized before using repository
if (!isDatabaseConnected()) {
console.info('Database not initialized, initializing...');
await initializeDatabase();
}
const config = await getOpenAIConfig(); const config = await getOpenAIConfig();
const vectorRepository = getRepositoryFactory( const vectorRepository = getRepositoryFactory(
'vectorEmbeddings', 'vectorEmbeddings',
@@ -245,7 +251,7 @@ export const saveToolsAsVectorEmbeddings = async (
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}`);
} }
}; };

View File

@@ -5,11 +5,21 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/
import { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js'; import { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js';
import { SmartRoutingConfig } from '../utils/smartRouting.js'; import { SmartRoutingConfig } from '../utils/smartRouting.js';
// OAuth SSO linked account information
export interface IOAuthLink {
provider: string; // Provider ID (e.g., 'google', 'github', 'microsoft', or custom OIDC provider name)
providerId: string; // User ID from the OAuth provider
email?: string; // Email from the OAuth provider
name?: string; // Display name from the OAuth provider
linkedAt?: string; // ISO timestamp when the account was linked
}
// User interface // User interface
export interface IUser { export interface IUser {
username: string; username: string;
password: string; password: string;
isAdmin?: boolean; isAdmin?: boolean;
oauthLinks?: IOAuthLink[]; // Linked OAuth accounts for SSO
} }
// Group interface for server grouping // Group interface for server grouping
@@ -149,6 +159,55 @@ export interface OAuthProviderConfig {
}>; }>;
} }
// OAuth SSO Provider Configuration for external identity providers (Google, Microsoft, GitHub, custom OIDC)
export interface OAuthSSOProvider {
id: string; // Unique identifier for this provider (e.g., 'google', 'github', 'microsoft', 'custom-oidc')
name: string; // Display name shown on login page (e.g., 'Google', 'GitHub')
enabled?: boolean; // Enable/disable this provider (default: true)
type: 'google' | 'github' | 'microsoft' | 'oidc'; // Provider type for built-in or custom OIDC
// OAuth/OIDC endpoints (required for 'oidc' type, auto-discovered for built-in types)
issuerUrl?: string; // OIDC issuer URL for discovery (e.g., 'https://accounts.google.com')
authorizationUrl?: string; // OAuth authorization endpoint
tokenUrl?: string; // OAuth token endpoint
userInfoUrl?: string; // OIDC userinfo endpoint
// Client credentials
clientId: string; // OAuth client ID from the provider
clientSecret: string; // OAuth client secret from the provider
// Scope configuration
scopes?: string[]; // Scopes to request (default: ['openid', 'email', 'profile'])
// Role/admin mapping configuration
roleMapping?: {
// Map provider claims/groups to MCPHub admin role
adminClaim?: string; // Claim name to check for admin status (e.g., 'groups', 'roles')
adminValues?: string[]; // Values that grant admin access (e.g., ['admin', 'mcphub-admin'])
// Default role for new users (if not matched by adminValues)
defaultIsAdmin?: boolean; // Default admin status for auto-provisioned users (default: false)
};
// User attribute mapping (for custom OIDC providers)
attributeMapping?: {
username?: string; // Claim to use as username (default: 'email' or 'preferred_username')
email?: string; // Claim to use as email (default: 'email')
name?: string; // Claim to use as display name (default: 'name')
};
// Auto-provisioning settings
autoProvision?: boolean; // Auto-create users on first SSO login (default: true)
allowLinking?: boolean; // Allow existing users to link their accounts (default: true)
}
// OAuth SSO Configuration (stored in systemConfig.oauthSSO)
export interface OAuthSSOConfig {
enabled?: boolean; // Enable/disable SSO functionality globally (default: false)
providers?: OAuthSSOProvider[]; // Array of configured SSO providers
callbackBaseUrl?: string; // Base URL for OAuth callbacks (auto-detected if not set)
allowLocalAuth?: boolean; // Allow local username/password auth alongside SSO (default: true)
}
export interface SystemConfig { export interface SystemConfig {
routing?: { routing?: {
enableGlobalRoute?: boolean; // Controls whether the /sse endpoint without group is enabled enableGlobalRoute?: boolean; // Controls whether the /sse endpoint without group is enabled
@@ -172,6 +231,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 external identity providers (Google, Microsoft, GitHub, OIDC)
enableSessionRebuild?: boolean; // Controls whether server session rebuild is enabled enableSessionRebuild?: boolean; // Controls whether server session rebuild is enabled
} }
@@ -244,7 +304,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 +312,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,7 @@ 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,
oauthLinks: user.oauthLinks ?? null,
}); });
console.log(` - Created user: ${user.username}`); console.log(` - Created user: ${user.username}`);
} else { } else {

View File

@@ -1,152 +0,0 @@
/**
* Integration test for stream parameter support
* This test demonstrates the usage of stream parameter in MCP requests
*/
import { describe, it, expect } from '@jest/globals';
describe('Stream Parameter Integration Test', () => {
it('should demonstrate stream parameter usage', () => {
// Example 1: Using stream=false in query parameter
const queryExample = {
url: '/mcp?stream=false',
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
},
body: {
method: 'initialize',
params: {
protocolVersion: '2025-03-26',
capabilities: {},
clientInfo: {
name: 'TestClient',
version: '1.0.0',
},
},
jsonrpc: '2.0',
id: 1,
},
};
expect(queryExample.url).toContain('stream=false');
// Example 2: Using stream parameter in request body
const bodyExample = {
url: '/mcp',
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
},
body: {
method: 'initialize',
stream: false, // Body parameter
params: {
protocolVersion: '2025-03-26',
capabilities: {},
clientInfo: {
name: 'TestClient',
version: '1.0.0',
},
},
jsonrpc: '2.0',
id: 1,
},
};
expect(bodyExample.body.stream).toBe(false);
// Example 3: Default behavior (streaming enabled)
const defaultExample = {
url: '/mcp',
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
},
body: {
method: 'initialize',
params: {
protocolVersion: '2025-03-26',
capabilities: {},
clientInfo: {
name: 'TestClient',
version: '1.0.0',
},
},
jsonrpc: '2.0',
id: 1,
},
};
expect(defaultExample.body).not.toHaveProperty('stream');
});
it('should show expected response formats', () => {
// Expected response format for stream=false (JSON)
const jsonResponse = {
jsonrpc: '2.0',
result: {
protocolVersion: '2025-03-26',
capabilities: {
tools: {},
prompts: {},
},
serverInfo: {
name: 'MCPHub',
version: '1.0.0',
},
},
id: 1,
};
expect(jsonResponse).toHaveProperty('jsonrpc');
expect(jsonResponse).toHaveProperty('result');
// Expected response format for stream=true (SSE)
const sseResponse = {
headers: {
'Content-Type': 'text/event-stream',
'mcp-session-id': '550e8400-e29b-41d4-a716-446655440000',
},
body: 'data: {"jsonrpc":"2.0","result":{...},"id":1}\n\n',
};
expect(sseResponse.headers['Content-Type']).toBe('text/event-stream');
expect(sseResponse.headers).toHaveProperty('mcp-session-id');
});
it('should demonstrate all route variants', () => {
const routes = [
{ route: '/mcp?stream=false', description: 'Global route with non-streaming' },
{ route: '/mcp/mygroup?stream=false', description: 'Group route with non-streaming' },
{ route: '/mcp/myserver?stream=false', description: 'Server route with non-streaming' },
{ route: '/mcp/$smart?stream=false', description: 'Smart routing with non-streaming' },
];
routes.forEach((item) => {
expect(item.route).toContain('stream=false');
expect(item.description).toBeTruthy();
});
});
it('should show parameter priority', () => {
// Body parameter takes priority over query parameter
const mixedExample = {
url: '/mcp?stream=true', // Query says stream=true
body: {
method: 'initialize',
stream: false, // Body says stream=false - this takes priority
params: {},
jsonrpc: '2.0',
id: 1,
},
};
// In this case, the effective value should be false (from body)
expect(mixedExample.body.stream).toBe(false);
expect(mixedExample.url).toContain('stream=true');
});
});

View File

@@ -0,0 +1,393 @@
// Tests for OAuth SSO Service
import {
isOAuthSSOEnabled,
isLocalAuthAllowed,
getEnabledProviders,
getProviderById,
generateAuthorizationUrl,
} from '../../src/services/oauthSSOService.js';
// Mock the config loading
jest.mock('../../src/config/index.js', () => ({
loadSettings: jest.fn(),
}));
import { loadSettings } from '../../src/config/index.js';
const mockLoadSettings = loadSettings as jest.MockedFunction<typeof loadSettings>;
describe('OAuth SSO Service', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('isOAuthSSOEnabled', () => {
it('should return false when oauthSSO is not configured', () => {
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {},
});
expect(isOAuthSSOEnabled()).toBe(false);
});
it('should return false when oauthSSO.enabled is false', () => {
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {
oauthSSO: {
enabled: false,
providers: [
{
id: 'google',
name: 'Google',
type: 'google',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
},
],
},
},
});
expect(isOAuthSSOEnabled()).toBe(false);
});
it('should return false when no providers are configured', () => {
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {
oauthSSO: {
enabled: true,
providers: [],
},
},
});
expect(isOAuthSSOEnabled()).toBe(false);
});
it('should return true when enabled and providers exist', () => {
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {
oauthSSO: {
enabled: true,
providers: [
{
id: 'google',
name: 'Google',
type: 'google',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
},
],
},
},
});
expect(isOAuthSSOEnabled()).toBe(true);
});
});
describe('isLocalAuthAllowed', () => {
it('should return true by default when not configured', () => {
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {},
});
expect(isLocalAuthAllowed()).toBe(true);
});
it('should return true when allowLocalAuth is not explicitly set', () => {
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {
oauthSSO: {
enabled: true,
providers: [],
},
},
});
expect(isLocalAuthAllowed()).toBe(true);
});
it('should return false when allowLocalAuth is false', () => {
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {
oauthSSO: {
enabled: true,
allowLocalAuth: false,
providers: [],
},
},
});
expect(isLocalAuthAllowed()).toBe(false);
});
it('should return true when allowLocalAuth is true', () => {
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {
oauthSSO: {
enabled: true,
allowLocalAuth: true,
providers: [],
},
},
});
expect(isLocalAuthAllowed()).toBe(true);
});
});
describe('getEnabledProviders', () => {
it('should return empty array when SSO is not enabled', () => {
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {},
});
expect(getEnabledProviders()).toEqual([]);
});
it('should return only enabled providers', () => {
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {
oauthSSO: {
enabled: true,
providers: [
{
id: 'google',
name: 'Google',
type: 'google',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
enabled: true,
},
{
id: 'github',
name: 'GitHub',
type: 'github',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
enabled: false,
},
{
id: 'microsoft',
name: 'Microsoft',
type: 'microsoft',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
// enabled is undefined, defaults to true
},
],
},
},
});
const providers = getEnabledProviders();
expect(providers).toHaveLength(2);
expect(providers[0]).toEqual({ id: 'google', name: 'Google', type: 'google' });
expect(providers[1]).toEqual({ id: 'microsoft', name: 'Microsoft', type: 'microsoft' });
});
});
describe('getProviderById', () => {
it('should return undefined when provider not found', () => {
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {
oauthSSO: {
enabled: true,
providers: [
{
id: 'google',
name: 'Google',
type: 'google',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
},
],
},
},
});
expect(getProviderById('github')).toBeUndefined();
});
it('should return undefined when provider is disabled', () => {
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {
oauthSSO: {
enabled: true,
providers: [
{
id: 'google',
name: 'Google',
type: 'google',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
enabled: false,
},
],
},
},
});
expect(getProviderById('google')).toBeUndefined();
});
it('should return provider when found and enabled', () => {
const provider = {
id: 'google',
name: 'Google',
type: 'google' as const,
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
};
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {
oauthSSO: {
enabled: true,
providers: [provider],
},
},
});
expect(getProviderById('google')).toEqual(provider);
});
});
describe('generateAuthorizationUrl', () => {
it('should return null when provider not found', () => {
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {
oauthSSO: {
enabled: true,
providers: [],
},
},
});
expect(generateAuthorizationUrl('google', 'http://localhost/callback')).toBeNull();
});
it('should generate authorization URL for Google provider', () => {
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {
oauthSSO: {
enabled: true,
providers: [
{
id: 'google',
name: 'Google',
type: 'google',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
},
],
},
},
});
const result = generateAuthorizationUrl('google', 'http://localhost/callback');
expect(result).not.toBeNull();
expect(result!.url).toContain('https://accounts.google.com/o/oauth2/v2/auth');
expect(result!.url).toContain('client_id=test-client-id');
expect(result!.url).toContain('redirect_uri=http%3A%2F%2Flocalhost%2Fcallback');
expect(result!.url).toContain('response_type=code');
expect(result!.url).toContain('scope=openid+email+profile');
expect(result!.url).toContain('code_challenge=');
expect(result!.state).toBeDefined();
});
it('should generate authorization URL for GitHub provider without PKCE', () => {
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {
oauthSSO: {
enabled: true,
providers: [
{
id: 'github',
name: 'GitHub',
type: 'github',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
},
],
},
},
});
const result = generateAuthorizationUrl('github', 'http://localhost/callback');
expect(result).not.toBeNull();
expect(result!.url).toContain('https://github.com/login/oauth/authorize');
expect(result!.url).not.toContain('code_challenge=');
expect(result!.state).toBeDefined();
});
it('should generate authorization URL for Microsoft provider', () => {
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {
oauthSSO: {
enabled: true,
providers: [
{
id: 'microsoft',
name: 'Microsoft',
type: 'microsoft',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
},
],
},
},
});
const result = generateAuthorizationUrl('microsoft', 'http://localhost/callback');
expect(result).not.toBeNull();
expect(result!.url).toContain('https://login.microsoftonline.com/common/oauth2/v2.0/authorize');
expect(result!.url).toContain('code_challenge=');
expect(result!.state).toBeDefined();
});
it('should include custom scopes when configured', () => {
mockLoadSettings.mockReturnValue({
mcpServers: {},
systemConfig: {
oauthSSO: {
enabled: true,
providers: [
{
id: 'google',
name: 'Google',
type: 'google',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
scopes: ['custom-scope', 'another-scope'],
},
],
},
},
});
const result = generateAuthorizationUrl('google', 'http://localhost/callback');
expect(result).not.toBeNull();
expect(result!.url).toContain('scope=custom-scope+another-scope');
});
});
});