1
0
mirror of https://github.com/samanhappy/mcphub.git synced 2026-01-11 09:07:01 -05:00

Compare commits

..

3 Commits

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

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

Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-12-31 15:17:07 +00:00
copilot-swe-agent[bot]
9319ea47e6 Initial plan 2025-12-31 14:57:01 +00:00
60 changed files with 2361 additions and 2447 deletions

View File

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

View File

@@ -106,7 +106,7 @@ services:
- NODE_ENV=production
- PORT=3000
- JWT_SECRET=${JWT_SECRET:-your-jwt-secret}
- DB_URL=postgresql://mcphub:password@postgres:5432/mcphub
- DATABASE_URL=postgresql://mcphub:password@postgres:5432/mcphub
volumes:
- ./mcp_settings.json:/app/mcp_settings.json:ro
- ./servers.json:/app/servers.json:ro
@@ -180,7 +180,7 @@ services:
- PORT=3000
- JWT_SECRET=${JWT_SECRET}
- JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-24h}
- DB_URL=postgresql://mcphub:${POSTGRES_PASSWORD}@postgres:5432/mcphub
- DATABASE_URL=postgresql://mcphub:${POSTGRES_PASSWORD}@postgres:5432/mcphub
- OPENAI_API_KEY=${OPENAI_API_KEY}
- REDIS_URL=redis://redis:6379
volumes:
@@ -293,7 +293,7 @@ services:
environment:
- NODE_ENV=development
- PORT=3000
- DB_URL=postgresql://mcphub:password@postgres:5432/mcphub
- DATABASE_URL=postgresql://mcphub:password@postgres:5432/mcphub
volumes:
- .:/app
- /app/node_modules

View File

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

View File

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

View File

@@ -69,7 +69,7 @@ Smart Routing requires additional setup compared to basic MCPHub usage:
ports:
- "3000:3000"
environment:
- DB_URL=postgresql://mcphub:password@postgres:5432/mcphub
- DATABASE_URL=postgresql://mcphub:password@postgres:5432/mcphub
- OPENAI_API_KEY=your_openai_api_key
- ENABLE_SMART_ROUTING=true
depends_on:
@@ -114,7 +114,7 @@ Smart Routing requires additional setup compared to basic MCPHub usage:
2. **Set Environment Variables**:
```bash
export DB_URL="postgresql://mcphub:your_password@localhost:5432/mcphub"
export DATABASE_URL="postgresql://mcphub:your_password@localhost:5432/mcphub"
export OPENAI_API_KEY="your_openai_api_key"
export ENABLE_SMART_ROUTING="true"
```
@@ -178,7 +178,7 @@ Smart Routing requires additional setup compared to basic MCPHub usage:
- name: mcphub
image: samanhappy/mcphub:latest
env:
- name: DB_URL
- name: DATABASE_URL
value: "postgresql://mcphub:password@postgres:5432/mcphub"
- name: OPENAI_API_KEY
valueFrom:
@@ -202,7 +202,7 @@ Configure Smart Routing with these environment variables:
```bash
# Required
DB_URL=postgresql://user:password@host:5432/database
DATABASE_URL=postgresql://user:password@host:5432/database
OPENAI_API_KEY=your_openai_api_key
# Optional
@@ -219,10 +219,10 @@ EMBEDDING_BATCH_SIZE=100
<Accordion title="Database Configuration">
```bash
# Full PostgreSQL connection string
DB_URL=postgresql://username:password@host:port/database?schema=public
DATABASE_URL=postgresql://username:password@host:port/database?schema=public
# SSL configuration for cloud databases
DB_URL=postgresql://user:pass@host:5432/db?sslmode=require
DATABASE_URL=postgresql://user:pass@host:5432/db?sslmode=require
# Connection pool settings
DATABASE_POOL_SIZE=20
@@ -362,129 +362,6 @@ Smart Routing now supports group-scoped searches, allowing you to limit tool dis
</Accordion>
</AccordionGroup>
### Progressive Disclosure Mode
Progressive Disclosure is an optimization feature that reduces token usage when working with Smart Routing. When enabled, the tool discovery workflow changes from a 2-step to a 3-step process.
<AccordionGroup>
<Accordion title="What is Progressive Disclosure?">
By default, Smart Routing returns full tool information including complete parameter schemas in `search_tools` results. This can consume significant tokens when dealing with tools that have complex input schemas.
**Progressive Disclosure** changes this behavior:
- `search_tools` returns only tool names and descriptions (minimal info)
- A new `describe_tool` endpoint provides full parameter schema on demand
- `call_tool` executes the tool as before
This approach is particularly useful when:
- Working with many tools with complex schemas
- Token usage optimization is important
- AI clients need to browse many tools before selecting one
</Accordion>
<Accordion title="Enabling Progressive Disclosure">
Enable Progressive Disclosure through the Settings page or environment variable:
**Via Settings UI:**
1. Navigate to Settings → Smart Routing
2. Enable the "Progressive Disclosure" toggle
3. The change takes effect immediately
**Via Environment Variable:**
```bash
SMART_ROUTING_PROGRESSIVE_DISCLOSURE=true
```
</Accordion>
<Accordion title="Standard Mode (Default)">
When Progressive Disclosure is **disabled** (default), Smart Routing provides two tools:
**Workflow:** `search_tools` → `call_tool`
| Tool | Purpose |
|------|---------|
| `search_tools` | Find tools by query, returns full tool info including `inputSchema` |
| `call_tool` | Execute a tool with the provided arguments |
This mode is simpler but uses more tokens due to full schemas in search results.
</Accordion>
<Accordion title="Progressive Disclosure Mode">
When Progressive Disclosure is **enabled**, Smart Routing provides three tools:
**Workflow:** `search_tools` → `describe_tool` → `call_tool`
| Tool | Purpose |
|------|---------|
| `search_tools` | Find tools by query, returns only name and description |
| `describe_tool` | Get full schema for a specific tool (new) |
| `call_tool` | Execute a tool with the provided arguments |
**Example workflow:**
1. AI calls `search_tools` with query "file operations"
2. Results show tool names and descriptions (minimal tokens)
3. AI calls `describe_tool` for a specific tool to get full `inputSchema`
4. AI calls `call_tool` with the correct arguments
This mode reduces token usage by only fetching full schemas when needed.
</Accordion>
<Accordion title="Response Format Comparison">
**Standard Mode search_tools response:**
```json
{
"tools": [
{
"name": "read_file",
"description": "Read contents of a file",
"inputSchema": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "File path to read" },
"encoding": { "type": "string", "default": "utf-8" }
},
"required": ["path"]
}
}
]
}
```
**Progressive Disclosure Mode search_tools response:**
```json
{
"tools": [
{
"name": "read_file",
"description": "Read contents of a file"
}
],
"metadata": {
"progressiveDisclosure": true,
"guideline": "Use describe_tool to get the full parameter schema before calling."
}
}
```
**describe_tool response:**
```json
{
"tool": {
"name": "read_file",
"description": "Read contents of a file",
"inputSchema": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "File path to read" },
"encoding": { "type": "string", "default": "utf-8" }
},
"required": ["path"]
}
}
}
```
</Accordion>
</AccordionGroup>
{/* ### Basic Usage
Connect your AI client to the Smart Routing endpoint and make natural language requests:
@@ -796,11 +673,11 @@ curl -X POST http://localhost:3000/api/smart-routing/feedback \
**Solutions:**
1. Verify PostgreSQL is running
2. Check DB_URL format
2. Check DATABASE_URL format
3. Ensure pgvector extension is installed
4. Test connection manually:
```bash
psql $DB_URL -c "SELECT 1;"
psql $DATABASE_URL -c "SELECT 1;"
```
</Accordion>

View File

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

View File

@@ -106,7 +106,7 @@ services:
- NODE_ENV=production
- PORT=3000
- JWT_SECRET=${JWT_SECRET:-your-jwt-secret}
- DB_URL=postgresql://mcphub:password@postgres:5432/mcphub
- DATABASE_URL=postgresql://mcphub:password@postgres:5432/mcphub
volumes:
- ./mcp_settings.json:/app/mcp_settings.json:ro
- ./servers.json:/app/servers.json:ro
@@ -180,7 +180,7 @@ services:
- PORT=3000
- JWT_SECRET=${JWT_SECRET}
- JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-24h}
- DB_URL=postgresql://mcphub:${POSTGRES_PASSWORD}@postgres:5432/mcphub
- DATABASE_URL=postgresql://mcphub:${POSTGRES_PASSWORD}@postgres:5432/mcphub
- OPENAI_API_KEY=${OPENAI_API_KEY}
- REDIS_URL=redis://redis:6379
volumes:
@@ -293,7 +293,7 @@ services:
environment:
- NODE_ENV=development
- PORT=3000
- DB_URL=postgresql://mcphub:password@postgres:5432/mcphub
- DATABASE_URL=postgresql://mcphub:password@postgres:5432/mcphub
volumes:
- .:/app
- /app/node_modules

View File

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

View File

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

View File

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

View File

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

View File

@@ -64,181 +64,17 @@ description: '使用向量语义搜索的 AI 工具发现系统'
<Tabs>
<Tab title="HTTP MCP">
```
# 搜索所有服务器
http://localhost:3000/mcp/$smart
# 在特定分组内搜索
http://localhost:3000/mcp/$smart/{group}
```
</Tab>
<Tab title="SSE (Legacy)">
```
# 搜索所有服务器
http://localhost:3000/sse/$smart
# 在特定分组内搜索
http://localhost:3000/sse/$smart/{group}
```
</Tab>
</Tabs>
### 分组范围的智能路由
智能路由支持分组范围的搜索,允许您将工具发现限制在特定分组内的服务器:
<AccordionGroup>
<Accordion title="使用分组范围的智能路由">
将您的 AI 客户端连接到特定分组的智能路由端点:
```
http://localhost:3000/mcp/$smart/production
```
此端点只会搜索属于 "production" 分组的服务器中的工具。
**优势:**
- **聚焦结果**:只返回相关服务器的工具
- **更好的性能**:减少搜索空间以加快查询速度
- **环境隔离**:将开发、预发布和生产工具分开
- **访问控制**:根据用户权限限制工具发现
</Accordion>
<Accordion title="工作原理">
当使用 `$smart/{group}` 时:
1. 系统识别指定的分组
2. 获取属于该分组的所有服务器
3. 将工具搜索过滤到仅限那些服务器
4. 返回限定在该分组服务器范围内的结果
如果分组不存在或没有服务器,搜索将不返回任何结果。
</Accordion>
</AccordionGroup>
### 渐进式披露模式
渐进式披露是一个优化功能,可以减少使用智能路由时的 Token 消耗。启用后,工具发现工作流从 2 步变为 3 步。
<AccordionGroup>
<Accordion title="什么是渐进式披露?">
默认情况下,智能路由在 `search_tools` 结果中返回完整的工具信息,包括完整的参数模式。当处理具有复杂输入模式的工具时,这会消耗大量 Token。
**渐进式披露** 改变了这种行为:
- `search_tools` 只返回工具名称和描述(最少信息)
- 新的 `describe_tool` 端点按需提供完整的参数模式
- `call_tool` 像以前一样执行工具
这种方法特别适用于:
- 处理具有复杂模式的大量工具
- Token 使用优化很重要的场景
- AI 客户端需要浏览多个工具后再选择
</Accordion>
<Accordion title="启用渐进式披露">
通过设置页面或环境变量启用渐进式披露:
**通过设置界面:**
1. 导航到 设置 → 智能路由
2. 启用"渐进式披露"开关
3. 更改立即生效
**通过环境变量:**
```bash
SMART_ROUTING_PROGRESSIVE_DISCLOSURE=true
```
</Accordion>
<Accordion title="标准模式(默认)">
当渐进式披露**禁用**(默认)时,智能路由提供两个工具:
**工作流:** `search_tools` → `call_tool`
| 工具 | 用途 |
|------|------|
| `search_tools` | 按查询查找工具,返回包含 `inputSchema` 的完整工具信息 |
| `call_tool` | 使用提供的参数执行工具 |
这种模式更简单,但由于搜索结果中包含完整模式,会使用更多 Token。
</Accordion>
<Accordion title="渐进式披露模式">
当渐进式披露**启用**时,智能路由提供三个工具:
**工作流:** `search_tools` → `describe_tool` → `call_tool`
| 工具 | 用途 |
|------|------|
| `search_tools` | 按查询查找工具,只返回名称和描述 |
| `describe_tool` | 获取特定工具的完整模式(新增) |
| `call_tool` | 使用提供的参数执行工具 |
**示例工作流:**
1. AI 使用查询 "文件操作" 调用 `search_tools`
2. 结果显示工具名称和描述(最少 Token
3. AI 为特定工具调用 `describe_tool` 获取完整的 `inputSchema`
4. AI 使用正确的参数调用 `call_tool`
这种模式通过仅在需要时获取完整模式来减少 Token 使用。
</Accordion>
<Accordion title="响应格式对比">
**标准模式 search_tools 响应:**
```json
{
"tools": [
{
"name": "read_file",
"description": "读取文件内容",
"inputSchema": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "要读取的文件路径" },
"encoding": { "type": "string", "default": "utf-8" }
},
"required": ["path"]
}
}
]
}
```
**渐进式披露模式 search_tools 响应:**
```json
{
"tools": [
{
"name": "read_file",
"description": "读取文件内容"
}
],
"metadata": {
"progressiveDisclosure": true,
"guideline": "使用 describe_tool 获取完整的参数模式后再调用。"
}
}
```
**describe_tool 响应:**
```json
{
"tool": {
"name": "read_file",
"description": "读取文件内容",
"inputSchema": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "要读取的文件路径" },
"encoding": { "type": "string", "default": "utf-8" }
},
"required": ["path"]
}
}
}
```
</Accordion>
</AccordionGroup>
{/* ## 性能优化
### 嵌入缓存
@@ -409,11 +245,11 @@ curl -X POST http://localhost:3000/api/smart-routing/feedback \
**解决方案:**
1. 验证 PostgreSQL 是否正在运行
2. 检查 DB_URL 格式
2. 检查 DATABASE_URL 格式
3. 确保安装了 pgvector 扩展
4. 手动测试连接:
```bash
psql $DB_URL -c "SELECT 1;"
psql $DATABASE_URL -c "SELECT 1;"
```
</Accordion>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,14 +18,7 @@ interface ServerCardProps {
onReload?: (server: Server) => Promise<boolean>;
}
const ServerCard = ({
server,
onRemove,
onEdit,
onToggle,
onRefresh,
onReload,
}: ServerCardProps) => {
const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh, onReload }: ServerCardProps) => {
const { t } = useTranslation();
const { showToast } = useToast();
const [isExpanded, setIsExpanded] = useState(false);
@@ -239,10 +232,10 @@ const ServerCard = ({
return (
<>
<div
className={`bg-white shadow rounded-lg mb-6 page-card transition-all duration-200 ${server.enabled === false ? 'opacity-60' : ''}`}
className={`bg-white shadow rounded-lg p-6 mb-6 page-card transition-all duration-200 ${server.enabled === false ? 'opacity-60' : ''}`}
>
<div
className="flex justify-between items-center cursor-pointer p-4"
className="flex justify-between items-center cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center space-x-3">
@@ -392,9 +385,9 @@ const ServerCard = ({
{isExpanded && (
<>
{server.tools && (
<div className="px-4">
<div className="mt-6">
<h6
className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-2`}
className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}
>
{t('server.tools')}
</h6>
@@ -412,9 +405,9 @@ const ServerCard = ({
)}
{server.prompts && (
<div className="px-4 pb-2">
<div className="mt-6">
<h6
className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'}`}
className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}
>
{t('server.prompts')}
</h6>

View File

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

View File

@@ -171,9 +171,9 @@ const PromptCard = ({ prompt, server, onToggle, onDescriptionUpdate }: PromptCar
};
return (
<div className="bg-white border border-gray-200 shadow rounded-lg mb-4">
<div className="bg-white border border-gray-200 shadow rounded-lg p-4 mb-4">
<div
className="flex justify-between items-center p-2 cursor-pointer"
className="flex justify-between items-center cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex-1">

View File

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

View File

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

View File

@@ -33,7 +33,6 @@ interface SmartRoutingConfig {
openaiApiBaseUrl: string;
openaiApiKey: string;
openaiApiEmbeddingModel: string;
progressiveDisclosure: boolean;
}
interface MCPRouterConfig {
@@ -181,7 +180,6 @@ export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children })
openaiApiBaseUrl: '',
openaiApiKey: '',
openaiApiEmbeddingModel: '',
progressiveDisclosure: false,
});
const [mcpRouterConfig, setMCPRouterConfig] = useState<MCPRouterConfig>({
@@ -240,7 +238,6 @@ export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children })
openaiApiKey: data.data.systemConfig.smartRouting.openaiApiKey || '',
openaiApiEmbeddingModel:
data.data.systemConfig.smartRouting.openaiApiEmbeddingModel || '',
progressiveDisclosure: data.data.systemConfig.smartRouting.progressiveDisclosure ?? false,
});
}
if (data.success && data.data?.systemConfig?.mcpRouter) {

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -378,7 +378,7 @@ const SettingsPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { showToast } = useToast();
const { allServers: servers } = useServerContext(); // Use allServers for settings (not paginated)
const { servers } = useServerContext();
const { groups } = useGroupData();
const [installConfig, setInstallConfig] = useState<{
@@ -1425,24 +1425,6 @@ const SettingsPage: React.FC = () => {
</div>
</div>
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
<div>
<h3 className="font-medium text-gray-700">
{t('settings.progressiveDisclosure')}
</h3>
<p className="text-sm text-gray-500">
{t('settings.progressiveDisclosureDescription')}
</p>
</div>
<Switch
disabled={loading || !smartRoutingConfig.enabled}
checked={smartRoutingConfig.progressiveDisclosure}
onCheckedChange={(checked) =>
updateSmartRoutingConfig('progressiveDisclosure', checked)
}
/>
</div>
<div className="flex justify-end pt-2">
<button
onClick={handleSaveSmartRoutingConfig}

View File

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

View File

@@ -105,17 +105,6 @@ export interface Prompt {
enabled?: boolean;
}
// Proxychains4 configuration for STDIO servers (Linux/macOS only)
export interface ProxychainsConfig {
enabled?: boolean; // Enable/disable proxychains4 proxy routing
type?: 'socks4' | 'socks5' | 'http'; // Proxy protocol type
host?: string; // Proxy server hostname or IP address
port?: number; // Proxy server port
username?: string; // Proxy authentication username (optional)
password?: string; // Proxy authentication password (optional)
configPath?: string; // Path to custom proxychains4 configuration file (optional)
}
// Server config types
export interface ServerConfig {
type?: 'stdio' | 'sse' | 'streamable-http' | 'openapi';
@@ -134,8 +123,6 @@ export interface ServerConfig {
resetTimeoutOnProgress?: boolean; // Reset timeout on progress notifications
maxTotalTimeout?: number; // Maximum total timeout in milliseconds
}; // MCP request options configuration
// Proxychains4 proxy configuration for STDIO servers (Linux/macOS only, Windows not supported)
proxy?: ProxychainsConfig;
// OAuth authentication for upstream MCP servers
oauth?: {
clientId?: string; // OAuth client ID
@@ -394,6 +381,21 @@ export interface AuthResponse {
isUsingDefaultPassword?: boolean;
}
// OAuth SSO types
export interface OAuthSsoProvider {
id: string;
name: string;
type: string;
icon?: string;
buttonText?: string;
}
export interface OAuthSsoConfig {
enabled: boolean;
providers: OAuthSsoProvider[];
localAuthAllowed: boolean;
}
// Official Registry types (from registry.modelcontextprotocol.io)
export interface RegistryVariable {
choices?: string[];

View File

@@ -248,10 +248,6 @@
"wechat": "WeChat",
"discord": "Discord",
"required": "Required",
"itemsPerPage": "Items per page",
"showing": "Showing {{start}}-{{end}} of {{total}}",
"previous": "Previous",
"next": "Next",
"secret": "Secret",
"default": "Default",
"value": "Value",
@@ -607,8 +603,6 @@
"openaiApiKeyPlaceholder": "Enter OpenAI API key",
"openaiApiEmbeddingModel": "OpenAI Embedding Model",
"openaiApiEmbeddingModelPlaceholder": "text-embedding-3-small",
"progressiveDisclosure": "Progressive Disclosure",
"progressiveDisclosureDescription": "When enabled, search_tools returns only tool names and descriptions. Use describe_tool to get full parameter schema, reducing token usage.",
"smartRoutingConfigUpdated": "Smart routing configuration updated successfully",
"smartRoutingRequiredFields": "Database URL and OpenAI API Key are required to enable smart routing",
"smartRoutingValidationError": "Please fill in the required fields before enabling Smart Routing: {{fields}}",
@@ -846,5 +840,25 @@
"internalError": "Internal Error",
"internalErrorMessage": "An unexpected error occurred while processing the OAuth callback.",
"closeWindow": "Close Window"
},
"oauthSso": {
"errors": {
"providerIdRequired": "Provider ID is required",
"providerNotFound": "OAuth provider not found",
"missingState": "Missing OAuth state parameter",
"missingCode": "Missing authorization code",
"invalidState": "Invalid or expired OAuth state",
"authFailed": "OAuth authentication failed",
"userNotProvisioned": "User not found and auto-provisioning is disabled"
},
"signInWith": "Sign in with {{provider}}",
"orContinueWith": "Or continue with",
"continueWithProvider": "Continue with {{provider}}",
"loginWithSso": "Login with SSO",
"providers": {
"google": "Google",
"microsoft": "Microsoft",
"github": "GitHub"
}
}
}

View File

@@ -248,10 +248,6 @@
"github": "GitHub",
"wechat": "WeChat",
"discord": "Discord",
"itemsPerPage": "Éléments par page",
"showing": "Affichage de {{start}}-{{end}} sur {{total}}",
"previous": "Précédent",
"next": "Suivant",
"required": "Requis",
"secret": "Secret",
"default": "Défaut",

View File

@@ -248,10 +248,6 @@
"github": "GitHub",
"wechat": "WeChat",
"discord": "Discord",
"itemsPerPage": "Sayfa başına öğe",
"showing": "{{total}} öğeden {{start}}-{{end}} gösteriliyor",
"previous": "Önceki",
"next": "Sonraki",
"required": "Gerekli",
"secret": "Gizli",
"default": "Varsayılan",

View File

@@ -248,10 +248,6 @@
"dismiss": "忽略",
"github": "GitHub",
"wechat": "微信",
"itemsPerPage": "每页显示",
"showing": "显示第 {{start}}-{{end}} 条,共 {{total}} 条",
"previous": "上一页",
"next": "下一页",
"discord": "Discord",
"required": "必填",
"secret": "敏感",
@@ -610,8 +606,6 @@
"openaiApiKeyPlaceholder": "请输入 OpenAI API 密钥",
"openaiApiEmbeddingModel": "OpenAI 嵌入模型",
"openaiApiEmbeddingModelPlaceholder": "text-embedding-3-small",
"progressiveDisclosure": "渐进式披露",
"progressiveDisclosureDescription": "开启后search_tools 只返回工具名称和描述,通过 describe_tool 获取完整参数定义,可减少 Token 消耗",
"smartRoutingConfigUpdated": "智能路由配置更新成功",
"smartRoutingRequiredFields": "启用智能路由需要填写数据库连接地址和 OpenAI API 密钥",
"smartRoutingValidationError": "启用智能路由前请先填写必要字段:{{fields}}",
@@ -848,5 +842,25 @@
"internalError": "内部错误",
"internalErrorMessage": "处理 OAuth 回调时发生意外错误。",
"closeWindow": "关闭窗口"
},
"oauthSso": {
"errors": {
"providerIdRequired": "需要提供身份验证提供商 ID",
"providerNotFound": "未找到 OAuth 身份验证提供商",
"missingState": "缺少 OAuth 状态参数",
"missingCode": "缺少授权码",
"invalidState": "OAuth 状态无效或已过期",
"authFailed": "OAuth 身份验证失败",
"userNotProvisioned": "用户未找到且自动创建用户已禁用"
},
"signInWith": "使用 {{provider}} 登录",
"orContinueWith": "或使用以下方式继续",
"continueWithProvider": "使用 {{provider}} 继续",
"loginWithSso": "使用 SSO 登录",
"providers": {
"google": "Google",
"microsoft": "Microsoft",
"github": "GitHub"
}
}
}

View File

@@ -46,7 +46,7 @@
"license": "ISC",
"dependencies": {
"@apidevtools/swagger-parser": "^12.0.0",
"@modelcontextprotocol/sdk": "^1.25.1",
"@modelcontextprotocol/sdk": "^1.20.2",
"@node-oauth/oauth2-server": "^5.2.1",
"@types/adm-zip": "^0.5.7",
"@types/bcrypt": "^6.0.0",
@@ -57,7 +57,7 @@
"bcrypt": "^6.0.0",
"bcryptjs": "^3.0.2",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"dotenv": "^16.6.1",
"dotenv-expand": "^12.0.2",
"express": "^4.21.2",
"express-validator": "^7.3.1",
@@ -108,13 +108,13 @@
"jest-environment-node": "^30.0.5",
"jest-mock-extended": "4.0.0",
"lucide-react": "^0.552.0",
"next": "^16.1.1",
"next": "^15.5.0",
"postcss": "^8.5.6",
"prettier": "^3.6.2",
"react": "19.2.1",
"react-dom": "19.2.1",
"react-i18next": "^15.7.2",
"react-router-dom": "^7.12.0",
"react-router-dom": "^7.8.2",
"supertest": "^7.1.4",
"tailwind-merge": "^3.3.1",
"tailwind-scrollbar-hide": "^2.0.0",
@@ -136,8 +136,7 @@
"brace-expansion@2.0.1": "2.0.2",
"glob@10.4.5": "10.5.0",
"js-yaml": "4.1.1",
"jws@3.2.2": "4.0.1",
"qs": "6.14.1"
"jws@3.2.2": "4.0.1"
}
}
}

504
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -7,7 +7,6 @@ import {
BatchCreateServersResponse,
BatchServerResult,
ServerConfig,
ServerInfo,
} from '../types/index.js';
import {
getServersInfo,
@@ -25,66 +24,13 @@ import { createSafeJSON } from '../utils/serialization.js';
import { cloneDefaultOAuthServerConfig } from '../constants/oauthServerDefaults.js';
import { getServerDao, getGroupDao, getSystemConfigDao } from '../dao/DaoFactory.js';
import { getBearerKeyDao } from '../dao/DaoFactory.js';
import { UserContextService } from '../services/userContextService.js';
export const getAllServers = async (req: Request, res: Response): Promise<void> => {
export const getAllServers = async (_: Request, res: Response): Promise<void> => {
try {
// Parse pagination parameters from query string
const page = req.query.page ? parseInt(req.query.page as string, 10) : 1;
const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : undefined;
// Validate pagination parameters
if (page < 1) {
res.status(400).json({
success: false,
message: 'Page number must be greater than 0',
});
return;
}
if (limit !== undefined && (limit < 1 || limit > 1000)) {
res.status(400).json({
success: false,
message: 'Limit must be between 1 and 1000',
});
return;
}
// Get current user for filtering
const currentUser = UserContextService.getInstance().getCurrentUser();
const isAdmin = !currentUser || currentUser.isAdmin;
// Get servers info with pagination if limit is specified
let serversInfo: Omit<ServerInfo, 'client' | 'transport'>[];
let pagination = undefined;
if (limit !== undefined) {
// Use DAO layer pagination with proper filtering
const serverDao = getServerDao();
const paginatedResult = isAdmin
? await serverDao.findAllPaginated(page, limit)
: await serverDao.findByOwnerPaginated(currentUser!.username, page, limit);
// Get runtime info for paginated servers
serversInfo = await getServersInfo(page, limit, currentUser);
pagination = {
page: paginatedResult.page,
limit: paginatedResult.limit,
total: paginatedResult.total,
totalPages: paginatedResult.totalPages,
hasNextPage: paginatedResult.page < paginatedResult.totalPages,
hasPrevPage: paginatedResult.page > 1,
};
} else {
// No pagination, get all servers (will be filtered by mcpService)
serversInfo = await getServersInfo();
}
const serversInfo = await getServersInfo();
const response: ApiResponse = {
success: true,
data: createSafeJSON(serversInfo),
...(pagination && { pagination }),
};
res.json(response);
} catch (error) {
@@ -618,9 +564,10 @@ export const updateServer = async (req: Request, res: Response): Promise<void> =
});
}
} catch (error) {
console.error('Failed to update server:', error);
res.status(500).json({
success: false,
message: 'Internal server error',
message: error instanceof Error ? error.message : 'Internal server error',
});
}
};
@@ -906,8 +853,7 @@ export const updateSystemConfig = async (req: Request, res: Response): Promise<v
typeof smartRouting.dbUrl === 'string' ||
typeof smartRouting.openaiApiBaseUrl === 'string' ||
typeof smartRouting.openaiApiKey === 'string' ||
typeof smartRouting.openaiApiEmbeddingModel === 'string' ||
typeof smartRouting.progressiveDisclosure === 'boolean');
typeof smartRouting.openaiApiEmbeddingModel === 'string');
const hasMcpRouterUpdate =
mcpRouter &&
@@ -1118,9 +1064,6 @@ export const updateSystemConfig = async (req: Request, res: Response): Promise<v
if (typeof smartRouting.openaiApiEmbeddingModel === 'string') {
systemConfig.smartRouting.openaiApiEmbeddingModel = smartRouting.openaiApiEmbeddingModel;
}
if (typeof smartRouting.progressiveDisclosure === 'boolean') {
systemConfig.smartRouting.progressiveDisclosure = smartRouting.progressiveDisclosure;
}
// Check if we need to sync embeddings
const isNowEnabled = systemConfig.smartRouting.enabled || false;

View File

@@ -2,31 +2,10 @@ import { ServerConfig } from '../types/index.js';
import { BaseDao } from './base/BaseDao.js';
import { JsonFileBaseDao } from './base/JsonFileBaseDao.js';
/**
* Pagination result interface
*/
export interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
/**
* Server DAO interface with server-specific operations
*/
export interface ServerDao extends BaseDao<ServerConfigWithName, string> {
/**
* Find all servers with pagination
*/
findAllPaginated(page: number, limit: number): Promise<PaginatedResult<ServerConfigWithName>>;
/**
* Find servers by owner with pagination
*/
findByOwnerPaginated(owner: string, page: number, limit: number): Promise<PaginatedResult<ServerConfigWithName>>;
/**
* Find servers by owner
*/
@@ -197,61 +176,6 @@ export class ServerDaoImpl extends JsonFileBaseDao implements ServerDao {
return servers.length;
}
async findAllPaginated(page: number, limit: number): Promise<PaginatedResult<ServerConfigWithName>> {
const allServers = await this.getAll();
// Sort: enabled servers first, then by creation time
const sortedServers = allServers.sort((a, b) => {
const aEnabled = a.enabled !== false;
const bEnabled = b.enabled !== false;
if (aEnabled !== bEnabled) {
return aEnabled ? -1 : 1;
}
return 0; // Keep original order for same enabled status
});
const total = sortedServers.length;
const totalPages = Math.ceil(total / limit);
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const data = sortedServers.slice(startIndex, endIndex);
return {
data,
total,
page,
limit,
totalPages,
};
}
async findByOwnerPaginated(owner: string, page: number, limit: number): Promise<PaginatedResult<ServerConfigWithName>> {
const allServers = await this.getAll();
const filteredServers = allServers.filter((server) => server.owner === owner);
// Sort: enabled servers first, then by creation time
const sortedServers = filteredServers.sort((a, b) => {
const aEnabled = a.enabled !== false;
const bEnabled = b.enabled !== false;
if (aEnabled !== bEnabled) {
return aEnabled ? -1 : 1;
}
return 0; // Keep original order for same enabled status
});
const total = sortedServers.length;
const totalPages = Math.ceil(total / limit);
const startIndex = (page - 1) * limit;
const endIndex = startIndex + limit;
const data = sortedServers.slice(startIndex, endIndex);
return {
data,
total,
page,
limit,
totalPages,
};
}
async findByOwner(owner: string): Promise<ServerConfigWithName[]> {
const servers = await this.getAll();
return servers.filter((server) => server.owner === owner);

View File

@@ -1,4 +1,4 @@
import { ServerDao, ServerConfigWithName, PaginatedResult } from './index.js';
import { ServerDao, ServerConfigWithName } from './index.js';
import { ServerRepository } from '../db/repositories/ServerRepository.js';
/**
@@ -16,32 +16,6 @@ export class ServerDaoDbImpl implements ServerDao {
return servers.map((s) => this.mapToServerConfig(s));
}
async findAllPaginated(page: number, limit: number): Promise<PaginatedResult<ServerConfigWithName>> {
const { data, total } = await this.repository.findAllPaginated(page, limit);
const totalPages = Math.ceil(total / limit);
return {
data: data.map((s) => this.mapToServerConfig(s)),
total,
page,
limit,
totalPages,
};
}
async findByOwnerPaginated(owner: string, page: number, limit: number): Promise<PaginatedResult<ServerConfigWithName>> {
const { data, total } = await this.repository.findByOwnerPaginated(owner, page, limit);
const totalPages = Math.ceil(total / limit);
return {
data: data.map((s) => this.mapToServerConfig(s)),
total,
page,
limit,
totalPages,
};
}
async findById(name: string): Promise<ServerConfigWithName | null> {
const server = await this.repository.findByName(name);
return server ? this.mapToServerConfig(server) : null;
@@ -64,7 +38,6 @@ export class ServerDaoDbImpl implements ServerDao {
prompts: entity.prompts,
options: entity.options,
oauth: entity.oauth,
proxy: entity.proxy,
openapi: entity.openapi,
});
return this.mapToServerConfig(server);
@@ -89,7 +62,6 @@ export class ServerDaoDbImpl implements ServerDao {
prompts: entity.prompts,
options: entity.options,
oauth: entity.oauth,
proxy: entity.proxy,
openapi: entity.openapi,
});
return server ? this.mapToServerConfig(server) : null;
@@ -168,7 +140,6 @@ export class ServerDaoDbImpl implements ServerDao {
prompts?: Record<string, { enabled: boolean; description?: string }>;
options?: Record<string, any>;
oauth?: Record<string, any>;
proxy?: Record<string, any>;
openapi?: Record<string, any>;
}): ServerConfigWithName {
return {
@@ -187,7 +158,6 @@ export class ServerDaoDbImpl implements ServerDao {
prompts: server.prompts,
options: server.options,
oauth: server.oauth,
proxy: server.proxy,
openapi: server.openapi,
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -69,41 +69,6 @@ export class ServerRepository {
return await this.repository.count();
}
/**
* Find servers with pagination
*/
async findAllPaginated(page: number, limit: number): Promise<{ data: Server[]; total: number }> {
const skip = (page - 1) * limit;
const [data, total] = await this.repository.findAndCount({
order: {
enabled: 'DESC', // Enabled servers first
createdAt: 'ASC' // Then by creation time
},
skip,
take: limit,
});
return { data, total };
}
/**
* Find servers by owner with pagination
*/
async findByOwnerPaginated(owner: string, page: number, limit: number): Promise<{ data: Server[]; total: number }> {
const skip = (page - 1) * limit;
const [data, total] = await this.repository.findAndCount({
where: { owner },
order: {
enabled: 'DESC', // Enabled servers first
createdAt: 'ASC' // Then by creation time
},
skip,
take: limit,
});
return { data, total };
}
/**
* Find servers by owner
*/

View File

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

View File

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

View File

@@ -7,15 +7,15 @@ import {
MCPRouterListToolsResponse,
MCPRouterCallToolResponse,
} from '../types/index.js';
import { getSystemConfigDao } from '../dao/index.js';
import { loadOriginalSettings } from '../config/index.js';
// MCPRouter API default base URL
const DEFAULT_MCPROUTER_API_BASE = 'https://api.mcprouter.to/v1';
// Get MCPRouter API config from system configuration
const getMCPRouterConfig = async () => {
const systemConfigDao = getSystemConfigDao();
const systemConfig = await systemConfigDao.get();
const mcpRouterConfig = systemConfig?.mcpRouter;
const getMCPRouterConfig = () => {
const settings = loadOriginalSettings();
const mcpRouterConfig = settings.systemConfig?.mcpRouter;
return {
apiKey: mcpRouterConfig?.apiKey || process.env.MCPROUTER_API_KEY || '',
@@ -27,8 +27,8 @@ const getMCPRouterConfig = async () => {
};
// Get axios config with MCPRouter headers
const getAxiosConfig = async (): Promise<AxiosRequestConfig> => {
const mcpRouterConfig = await getMCPRouterConfig();
const getAxiosConfig = (): AxiosRequestConfig => {
const mcpRouterConfig = getMCPRouterConfig();
return {
headers: {
@@ -43,8 +43,8 @@ const getAxiosConfig = async (): Promise<AxiosRequestConfig> => {
// List all available cloud servers
export const getCloudServers = async (): Promise<CloudServer[]> => {
try {
const axiosConfig = await getAxiosConfig();
const mcpRouterConfig = await getMCPRouterConfig();
const axiosConfig = getAxiosConfig();
const mcpRouterConfig = getMCPRouterConfig();
const response = await axios.post<MCPRouterResponse<MCPRouterListServersResponse>>(
`${mcpRouterConfig.baseUrl}/list-servers`,
@@ -79,8 +79,8 @@ export const getCloudServerByName = async (name: string): Promise<CloudServer |
// List tools for a specific cloud server
export const getCloudServerTools = async (serverKey: string): Promise<CloudTool[]> => {
try {
const axiosConfig = await getAxiosConfig();
const mcpRouterConfig = await getMCPRouterConfig();
const axiosConfig = getAxiosConfig();
const mcpRouterConfig = getMCPRouterConfig();
if (
!axiosConfig.headers?.['Authorization'] ||
@@ -116,8 +116,8 @@ export const callCloudServerTool = async (
args: Record<string, any>,
): Promise<MCPRouterCallToolResponse> => {
try {
const axiosConfig = await getAxiosConfig();
const mcpRouterConfig = await getMCPRouterConfig();
const axiosConfig = getAxiosConfig();
const mcpRouterConfig = getMCPRouterConfig();
if (
!axiosConfig.headers?.['Authorization'] ||

View File

@@ -1,6 +1,4 @@
import os from 'os';
import path from 'path';
import fs from 'fs';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import {
CallToolRequestSchema,
@@ -17,187 +15,29 @@ import {
StreamableHTTPClientTransportOptions,
} from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { createFetchWithProxy, getProxyConfigFromEnv } from './proxy.js';
import { ServerInfo, ServerConfig, Tool, ProxychainsConfig } from '../types/index.js';
import { ServerInfo, ServerConfig, Tool } from '../types/index.js';
import { expandEnvVars, replaceEnvVars, getNameSeparator } from '../config/index.js';
import config from '../config/index.js';
import { getGroup } from './sseService.js';
import { getServersInGroup, getServerConfigInGroup } from './groupService.js';
import { saveToolsAsVectorEmbeddings } from './vectorSearchService.js';
import { saveToolsAsVectorEmbeddings, searchToolsByVector } from './vectorSearchService.js';
import { OpenAPIClient } from '../clients/openapi.js';
import { RequestContextService } from './requestContextService.js';
import { getDataService } from './services.js';
import { getServerDao, getSystemConfigDao, ServerConfigWithName } from '../dao/index.js';
import { initializeAllOAuthClients } from './oauthService.js';
import { createOAuthProvider } from './mcpOAuthProvider.js';
import {
initSmartRoutingService,
getSmartRoutingTools,
handleSearchToolsRequest,
handleDescribeToolRequest,
isSmartRoutingGroup,
} from './smartRoutingService.js';
const servers: { [sessionId: string]: Server } = {};
import { setupClientKeepAlive } from './keepAliveService.js';
/**
* Check if proxychains4 is available on the system (Linux/macOS only).
* Returns the path to proxychains4 if found, null otherwise.
*/
const findProxychains4 = (): string | null => {
// Windows is not supported
if (process.platform === 'win32') {
return null;
}
// Common proxychains4 binary paths
const possiblePaths = [
'/usr/bin/proxychains4',
'/usr/local/bin/proxychains4',
'/opt/homebrew/bin/proxychains4', // macOS Homebrew ARM
'/usr/local/Cellar/proxychains-ng/*/bin/proxychains4', // macOS Homebrew Intel
];
for (const p of possiblePaths) {
if (fs.existsSync(p)) {
return p;
}
}
// Try to find in PATH
const pathEnv = process.env.PATH || '';
const pathDirs = pathEnv.split(path.delimiter);
for (const dir of pathDirs) {
const fullPath = path.join(dir, 'proxychains4');
if (fs.existsSync(fullPath)) {
return fullPath;
}
}
return null;
};
/**
* Generate a temporary proxychains4 configuration file.
* Returns the path to the generated config file.
*/
const generateProxychainsConfig = (
serverName: string,
proxyConfig: ProxychainsConfig,
): string | null => {
// If a custom config path is provided, use it directly
if (proxyConfig.configPath) {
if (fs.existsSync(proxyConfig.configPath)) {
return proxyConfig.configPath;
}
console.warn(`[${serverName}] Custom proxychains config not found: ${proxyConfig.configPath}`);
return null;
}
// Validate required fields
if (!proxyConfig.host || !proxyConfig.port) {
console.warn(`[${serverName}] Proxy host and port are required for proxychains4`);
return null;
}
const proxyType = proxyConfig.type || 'socks5';
const proxyLine =
proxyConfig.username && proxyConfig.password
? `${proxyType} ${proxyConfig.host} ${proxyConfig.port} ${proxyConfig.username} ${proxyConfig.password}`
: `${proxyType} ${proxyConfig.host} ${proxyConfig.port}`;
const configContent = `# Proxychains4 configuration for MCP server: ${serverName}
# Generated by MCPHub
localnet 127.0.0.0/255.0.0.0
localnet 10.0.0.0/255.0.0.0
localnet 172.16.0.0/255.240.0.0
localnet 192.168.0.0/255.255.0.0
strict_chain
proxy_dns
remote_dns_subnet 224
tcp_read_time_out 15000
tcp_connect_time_out 8000
[ProxyList]
${proxyLine}
`;
// Create temp directory if needed
const tempDir = path.join(os.tmpdir(), 'mcphub-proxychains');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
// Write config file
const configPath = path.join(tempDir, `${serverName.replace(/[^a-zA-Z0-9-_]/g, '_')}.conf`);
fs.writeFileSync(configPath, configContent, 'utf-8');
console.log(`[${serverName}] Generated proxychains4 config: ${configPath}`);
return configPath;
};
/**
* Wrap a command with proxychains4 if proxy is configured and available.
* Returns modified command and args if proxychains4 is used, original values otherwise.
*/
const wrapWithProxychains = (
serverName: string,
command: string,
args: string[],
proxyConfig?: ProxychainsConfig,
): { command: string; args: string[] } => {
// Skip if proxy is not enabled or not configured
if (!proxyConfig?.enabled) {
return { command, args };
}
// Check platform - Windows is not supported
if (process.platform === 'win32') {
console.warn(
`[${serverName}] proxychains4 proxy is not supported on Windows, ignoring proxy configuration`,
);
return { command, args };
}
// Find proxychains4 binary
const proxychains4Path = findProxychains4();
if (!proxychains4Path) {
console.warn(
`[${serverName}] proxychains4 not found on system, install it with: apt install proxychains4 (Debian/Ubuntu) or brew install proxychains-ng (macOS)`,
);
return { command, args };
}
// Generate or get config file
const configPath = generateProxychainsConfig(serverName, proxyConfig);
if (!configPath) {
console.warn(`[${serverName}] Failed to setup proxychains4 configuration, skipping proxy`);
return { command, args };
}
// Wrap command with proxychains4
console.log(
`[${serverName}] Using proxychains4 proxy: ${proxyConfig.type || 'socks5'}://${proxyConfig.host}:${proxyConfig.port}`,
);
return {
command: proxychains4Path,
args: ['-f', configPath, command, ...args],
};
};
export const initUpstreamServers = async (): Promise<void> => {
// Initialize OAuth clients for servers with dynamic registration
await initializeAllOAuthClients();
// Register all tools from upstream servers
await registerAllTools(true);
// Initialize smart routing service with references to mcpService functions
initSmartRoutingService(() => serverInfos, filterToolsByConfig, filterToolsByGroup);
};
export const getMcpServer = (sessionId?: string, group?: string): Server => {
@@ -369,19 +209,11 @@ export const createTransportFromConfig = async (name: string, conf: ServerConfig
env['npm_config_registry'] = systemConfig.install.npmRegistry;
}
// Apply proxychains4 wrapper if proxy is configured (Linux/macOS only)
const { command: finalCommand, args: finalArgs } = wrapWithProxychains(
name,
conf.command,
replaceEnvVars(conf.args) as string[],
conf.proxy,
);
// Create STDIO transport with potentially wrapped command
// Expand environment variables in command
transport = new StdioClientTransport({
cwd: os.homedir(),
command: finalCommand,
args: finalArgs,
command: conf.command,
args: replaceEnvVars(conf.args) as string[],
env: env,
stderr: 'pipe',
});
@@ -786,20 +618,10 @@ export const registerAllTools = async (isInit: boolean, serverName?: string): Pr
};
// Get all server information
export const getServersInfo = async (
page?: number,
limit?: number,
user?: any,
): Promise<Omit<ServerInfo, 'client' | 'transport'>[]> => {
export const getServersInfo = async (): Promise<Omit<ServerInfo, 'client' | 'transport'>[]> => {
const allServers: ServerConfigWithName[] = await getServerDao().findAll();
const dataService = getDataService();
// Get paginated or all server configurations from DAO
// If pagination is used with a non-admin user, filtering is already done at DAO level
const isPaginated = limit !== undefined && page !== undefined;
const allServers: ServerConfigWithName[] = isPaginated
? (await getServerDao().findAllPaginated(page, limit)).data
: await getServerDao().findAll();
// Ensure that servers recently added via DAO but not yet initialized in serverInfos
// are still visible in the servers list. This avoids a race condition where
// a POST /api/servers immediately followed by GET /api/servers would not
@@ -807,19 +629,10 @@ export const getServersInfo = async (
const combinedServerInfos: ServerInfo[] = [...serverInfos];
const existingNames = new Set(combinedServerInfos.map((s) => s.name));
// Create a set of server names we're interested in (for pagination)
const requestedServerNames = new Set(allServers.map((s) => s.name));
// Filter serverInfos to only include requested servers if pagination is used
const filteredServerInfos = isPaginated
? combinedServerInfos.filter((s) => requestedServerNames.has(s.name))
: combinedServerInfos;
// Add servers from DAO that don't have runtime info yet
for (const server of allServers) {
if (!existingNames.has(server.name)) {
const isEnabled = server.enabled === undefined ? true : server.enabled;
filteredServerInfos.push({
combinedServerInfos.push({
name: server.name,
owner: server.owner,
// Newly created servers that are enabled should appear as "connecting"
@@ -835,17 +648,12 @@ export const getServersInfo = async (
}
}
// Apply user filtering only when NOT using pagination (pagination already filtered at DAO level)
// Or when no pagination parameters provided (backward compatibility)
const shouldApplyUserFilter = !isPaginated;
const filterServerInfos: ServerInfo[] =
shouldApplyUserFilter && dataService.filterData
? dataService.filterData(filteredServerInfos, user)
: filteredServerInfos;
const filterServerInfos: ServerInfo[] = dataService.filterData
? dataService.filterData(combinedServerInfos)
: combinedServerInfos;
const infos = filterServerInfos
.filter((info) => requestedServerNames.has(info.name)) // Only include requested servers
.map(({ name, status, tools, prompts, createTime, error, oauth }) => {
const infos = filterServerInfos.map(
({ name, status, tools, prompts, createTime, error, oauth }) => {
const serverConfig = allServers.find((server) => server.name === name);
const enabled = serverConfig ? serverConfig.enabled !== false : true;
@@ -884,8 +692,12 @@ export const getServersInfo = async (
}
: undefined,
};
});
// Sorting is now handled at DAO layer for consistent pagination results
},
);
infos.sort((a, b) => {
if (a.enabled === b.enabled) return 0;
return a.enabled ? -1 : 1;
});
return infos;
};
@@ -1091,10 +903,89 @@ export const handleListToolsRequest = async (_: any, extra: any) => {
const group = getGroup(sessionId);
console.log(`Handling ListToolsRequest for group: ${group}`);
// Special handling for $smart group to return smart routing tools
// Special handling for $smart group to return special tools
// Support both $smart and $smart/{group} patterns
if (isSmartRoutingGroup(group)) {
return getSmartRoutingTools(group);
if (group === '$smart' || group?.startsWith('$smart/')) {
// Extract target group if pattern is $smart/{group}
const targetGroup = group?.startsWith('$smart/') ? group.substring(7) : undefined;
// Get info about available servers, filtered by target group if specified
let availableServers = serverInfos.filter(
(server) => server.status === 'connected' && server.enabled !== false,
);
// If a target group is specified, filter servers to only those in the group
if (targetGroup) {
const serversInGroup = await getServersInGroup(targetGroup);
if (serversInGroup && serversInGroup.length > 0) {
availableServers = availableServers.filter((server) =>
serversInGroup.includes(server.name),
);
}
}
// Create simple server information with only server names
const serversList = availableServers
.map((server) => {
return `${server.name}`;
})
.join(', ');
const scopeDescription = targetGroup
? `servers in the "${targetGroup}" group`
: 'all available servers';
return {
tools: [
{
name: 'search_tools',
description: `STEP 1 of 2: Use this tool FIRST to discover and search for relevant tools across ${scopeDescription}. This tool and call_tool work together as a two-step process: 1) search_tools to find what you need, 2) call_tool to execute it.
For optimal results, use specific queries matching your exact needs. Call this tool multiple times with different queries for different parts of complex tasks. Example queries: "image generation tools", "code review tools", "data analysis", "translation capabilities", etc. Results are sorted by relevance using vector similarity.
After finding relevant tools, you MUST use the call_tool to actually execute them. The search_tools only finds tools - it doesn't execute them.
Available servers: ${serversList}`,
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description:
'The search query to find relevant tools. Be specific and descriptive about the task you want to accomplish.',
},
limit: {
type: 'integer',
description:
'Maximum number of results to return. Use higher values (20-30) for broad searches and lower values (5-10) for specific searches.',
default: 10,
},
},
required: ['query'],
},
},
{
name: 'call_tool',
description:
"STEP 2 of 2: Use this tool AFTER search_tools to actually execute/invoke any tool you found. This is the execution step - search_tools finds tools, call_tool runs them.\n\nWorkflow: search_tools → examine results → call_tool with the chosen tool name and required arguments.\n\nIMPORTANT: Always check the tool's inputSchema from search_tools results before invoking to ensure you provide the correct arguments. The search results will show you exactly what parameters each tool expects.",
inputSchema: {
type: 'object',
properties: {
toolName: {
type: 'string',
description: 'The exact name of the tool to invoke (from search_tools results)',
},
arguments: {
type: 'object',
description:
'The arguments to pass to the tool based on its inputSchema (optional if tool requires no arguments)',
},
},
required: ['toolName'],
},
},
],
};
}
// Need to filter servers based on group asynchronously
@@ -1146,18 +1037,146 @@ export const handleListToolsRequest = async (_: any, extra: any) => {
export const handleCallToolRequest = async (request: any, extra: any) => {
console.log(`Handling CallToolRequest for tool: ${JSON.stringify(request.params)}`);
try {
// Special handling for smart routing tools
// Special handling for agent group tools
if (request.params.name === 'search_tools') {
const { query, limit = 10 } = request.params.arguments || {};
const sessionId = extra.sessionId || '';
return await handleSearchToolsRequest(query, limit, sessionId);
}
// Special handling for describe_tool (progressive disclosure mode)
if (request.params.name === 'describe_tool') {
const { toolName } = request.params.arguments || {};
if (!query || typeof query !== 'string') {
throw new Error('Query parameter is required and must be a string');
}
const limitNum = Math.min(Math.max(parseInt(String(limit)) || 10, 1), 100);
// Dynamically adjust threshold based on query characteristics
let thresholdNum = 0.3; // Default threshold
// For more general queries, use a lower threshold to get more diverse results
if (query.length < 10 || query.split(' ').length <= 2) {
thresholdNum = 0.2;
}
// For very specific queries, use a higher threshold for more precise results
if (query.length > 30 || query.includes('specific') || query.includes('exact')) {
thresholdNum = 0.4;
}
console.log(`Using similarity threshold: ${thresholdNum} for query: "${query}"`);
// Determine server filtering based on group
const sessionId = extra.sessionId || '';
return await handleDescribeToolRequest(toolName, sessionId);
let group = getGroup(sessionId);
let servers: string[] | undefined = undefined; // No server filtering by default
// If group is in format $smart/{group}, filter servers to that group
if (group?.startsWith('$smart/')) {
const targetGroup = group.substring(7);
if (targetGroup) {
group = targetGroup;
}
const serversInGroup = await getServersInGroup(targetGroup);
if (serversInGroup !== undefined && serversInGroup !== null) {
servers = serversInGroup;
if (servers && servers.length > 0) {
console.log(
`Filtering search to servers in group "${targetGroup}": ${servers.join(', ')}`,
);
} else {
console.log(`Group "${targetGroup}" has no servers, search will return no results`);
}
}
}
const searchResults = await searchToolsByVector(query, limitNum, thresholdNum, servers);
console.log(`Search results: ${JSON.stringify(searchResults)}`);
// Find actual tool information from serverInfos by serverName and toolName
// First resolve all tool promises
const resolvedTools = await Promise.all(
searchResults.map(async (result) => {
// Find the server in serverInfos
const server = serverInfos.find(
(serverInfo) =>
serverInfo.name === result.serverName &&
serverInfo.status === 'connected' &&
serverInfo.enabled !== false,
);
if (server && server.tools && server.tools.length > 0) {
// Find the tool in server.tools
const actualTool = server.tools.find((tool) => tool.name === result.toolName);
if (actualTool) {
// Check if the tool is enabled in configuration
const tools = await filterToolsByConfig(server.name, [actualTool]);
if (tools.length > 0) {
// Apply custom description from configuration
const serverConfig = await getServerDao().findById(server.name);
const toolConfig = serverConfig?.tools?.[actualTool.name];
// Return the actual tool info from serverInfos with custom description
return {
...actualTool,
description: toolConfig?.description || actualTool.description,
serverName: result.serverName, // Add serverName for filtering
};
}
}
}
// Fallback to search result if server or tool not found or disabled
return {
name: result.toolName,
description: result.description || '',
inputSchema: cleanInputSchema(result.inputSchema || {}),
serverName: result.serverName, // Add serverName for filtering
};
}),
);
// Now filter the resolved tools
const filterResults = await Promise.all(
resolvedTools.map(async (tool) => {
if (tool.name) {
const serverName = tool.serverName;
if (serverName) {
let tools = await filterToolsByConfig(serverName, [tool as Tool]);
if (tools.length === 0) {
return false;
}
tools = await filterToolsByGroup(group, serverName, tools);
return tools.length > 0;
}
}
return true;
}),
);
const tools = resolvedTools.filter((_, i) => filterResults[i]);
// Add usage guidance to the response
const response = {
tools,
metadata: {
query: query,
threshold: thresholdNum,
totalResults: tools.length,
guideline:
tools.length > 0
? "Found relevant tools. If these tools don't match exactly what you need, try another search with more specific keywords."
: 'No tools found. Try broadening your search or using different keywords.',
nextSteps:
tools.length > 0
? 'To use a tool, call call_tool with the toolName and required arguments.'
: 'Consider searching for related capabilities or more general terms.',
},
};
// Return in the same format as handleListToolsRequest
return {
content: [
{
type: 'text',
text: JSON.stringify(response),
},
],
};
}
// Special handling for call_tool

View File

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

View File

@@ -1,525 +0,0 @@
/**
* Smart Routing Service
*
* This service handles the $smart routing functionality, which provides
* AI-powered tool discovery using vector semantic search.
*/
import { Tool, ServerInfo } from '../types/index.js';
import { getServersInGroup } from './groupService.js';
import { searchToolsByVector } from './vectorSearchService.js';
import { getSmartRoutingConfig } from '../utils/smartRouting.js';
import { getServerDao } from '../dao/index.js';
import { getGroup } from './sseService.js';
// Reference to serverInfos from mcpService - will be set via init
let serverInfosRef: ServerInfo[] = [];
let getServerInfosFn: () => ServerInfo[] = () => serverInfosRef;
let filterToolsByConfigFn: (serverName: string, tools: Tool[]) => Promise<Tool[]>;
let filterToolsByGroupFn: (
group: string | undefined,
serverName: string,
tools: Tool[],
) => Promise<Tool[]>;
/**
* Initialize the smart routing service with references to mcpService functions
*/
export const initSmartRoutingService = (
getServerInfos: () => ServerInfo[],
filterToolsByConfig: (serverName: string, tools: Tool[]) => Promise<Tool[]>,
filterToolsByGroup: (
group: string | undefined,
serverName: string,
tools: Tool[],
) => Promise<Tool[]>,
) => {
// Store the getter to avoid stale references while staying ESM-safe
getServerInfosFn = getServerInfos;
serverInfosRef = getServerInfos();
filterToolsByConfigFn = filterToolsByConfig;
filterToolsByGroupFn = filterToolsByGroup;
};
/**
* Get current server infos (refreshed each call)
*/
const getServerInfos = (): ServerInfo[] => {
return getServerInfosFn();
};
/**
* Helper function to clean $schema field from inputSchema
*/
const cleanInputSchema = (schema: any): any => {
if (!schema || typeof schema !== 'object') {
return schema;
}
const cleanedSchema = { ...schema };
delete cleanedSchema.$schema;
return cleanedSchema;
};
/**
* Generate the list of smart routing tools based on configuration
*/
export const getSmartRoutingTools = async (
group: string | undefined,
): Promise<{ tools: any[] }> => {
// Extract target group if pattern is $smart/{group}
const targetGroup = group?.startsWith('$smart/') ? group.substring(7) : undefined;
// Get smart routing config to check progressive disclosure setting
const smartRoutingConfig = await getSmartRoutingConfig();
const progressiveDisclosure = smartRoutingConfig.progressiveDisclosure ?? false;
// Get info about available servers, filtered by target group if specified
let availableServers = getServerInfos().filter(
(server) => server.status === 'connected' && server.enabled !== false,
);
// If a target group is specified, filter servers to only those in the group
if (targetGroup) {
const serversInGroup = await getServersInGroup(targetGroup);
if (serversInGroup && serversInGroup.length > 0) {
availableServers = availableServers.filter((server) => serversInGroup.includes(server.name));
}
}
// Create simple server information with only server names
const serversList = availableServers
.map((server) => {
return `${server.name}`;
})
.join(', ');
const scopeDescription = targetGroup
? `servers in the "${targetGroup}" group`
: 'all available servers';
// Base tools that are always available
const tools: any[] = [];
if (progressiveDisclosure) {
// Progressive disclosure mode: search_tools returns minimal info,
// describe_tool provides full schema
tools.push(
{
name: 'search_tools',
description: `STEP 1 of 3: Use this tool FIRST to discover and search for relevant tools across ${scopeDescription}. Returns tool names and descriptions only - use describe_tool to get full parameter details before calling.
For optimal results, use specific queries matching your exact needs. Call this tool multiple times with different queries for different parts of complex tasks. Example queries: "image generation tools", "code review tools", "data analysis", "translation capabilities", etc. Results are sorted by relevance using vector similarity.
Workflow: search_tools → describe_tool (for parameter details) → call_tool (to execute)
Available servers: ${serversList}`,
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description:
'The search query to find relevant tools. Be specific and descriptive about the task you want to accomplish.',
},
limit: {
type: 'integer',
description:
'Maximum number of results to return. Use higher values (20-30) for broad searches and lower values (5-10) for specific searches.',
default: 10,
},
},
required: ['query'],
},
annotations: {
title: 'Search Tools',
readOnlyHint: true,
},
},
{
name: 'describe_tool',
description:
'STEP 2 of 3: Use this tool AFTER search_tools to get the full parameter schema for a specific tool. This provides the complete inputSchema needed to correctly invoke the tool with call_tool.\n\nWorkflow: search_tools → describe_tool → call_tool',
inputSchema: {
type: 'object',
properties: {
toolName: {
type: 'string',
description: 'The exact name of the tool to describe (from search_tools results)',
},
},
required: ['toolName'],
},
annotations: {
title: 'Describe Tool',
readOnlyHint: true,
},
},
{
name: 'call_tool',
description:
"STEP 3 of 3: Use this tool AFTER describe_tool to actually execute/invoke any tool you found. This is the execution step.\n\nWorkflow: search_tools → describe_tool → call_tool with the chosen tool name and required arguments.\n\nIMPORTANT: Always use describe_tool first to get the tool's inputSchema before invoking to ensure you provide the correct arguments.",
inputSchema: {
type: 'object',
properties: {
toolName: {
type: 'string',
description: 'The exact name of the tool to invoke (from search_tools results)',
},
arguments: {
type: 'object',
description:
'The arguments to pass to the tool based on its inputSchema from describe_tool (optional if tool requires no arguments)',
},
},
required: ['toolName'],
},
annotations: {
title: 'Call Tool',
openWorldHint: true,
},
},
);
} else {
// Standard mode: search_tools returns full schema
tools.push(
{
name: 'search_tools',
description: `STEP 1 of 2: Use this tool FIRST to discover and search for relevant tools across ${scopeDescription}. This tool and call_tool work together as a two-step process: 1) search_tools to find what you need, 2) call_tool to execute it.
For optimal results, use specific queries matching your exact needs. Call this tool multiple times with different queries for different parts of complex tasks. Example queries: "image generation tools", "code review tools", "data analysis", "translation capabilities", etc. Results are sorted by relevance using vector similarity.
After finding relevant tools, you MUST use the call_tool to actually execute them. The search_tools only finds tools - it doesn't execute them.
Available servers: ${serversList}`,
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description:
'The search query to find relevant tools. Be specific and descriptive about the task you want to accomplish.',
},
limit: {
type: 'integer',
description:
'Maximum number of results to return. Use higher values (20-30) for broad searches and lower values (5-10) for specific searches.',
default: 10,
},
},
required: ['query'],
},
annotations: {
title: 'Search Tools',
readOnlyHint: true,
},
},
{
name: 'call_tool',
description:
"STEP 2 of 2: Use this tool AFTER search_tools to actually execute/invoke any tool you found. This is the execution step - search_tools finds tools, call_tool runs them.\n\nWorkflow: search_tools → examine results → call_tool with the chosen tool name and required arguments.\n\nIMPORTANT: Always check the tool's inputSchema from search_tools results before invoking to ensure you provide the correct arguments. The search results will show you exactly what parameters each tool expects.",
inputSchema: {
type: 'object',
properties: {
toolName: {
type: 'string',
description: 'The exact name of the tool to invoke (from search_tools results)',
},
arguments: {
type: 'object',
description:
'The arguments to pass to the tool based on its inputSchema (optional if tool requires no arguments)',
},
},
required: ['toolName'],
},
annotations: {
title: 'Call Tool',
openWorldHint: true,
},
},
);
}
return { tools };
};
/**
* Handle the search_tools request for smart routing
*/
export const handleSearchToolsRequest = async (
query: string,
limit: number,
sessionId: string,
): Promise<any> => {
if (!query || typeof query !== 'string') {
throw new Error('Query parameter is required and must be a string');
}
const limitNum = Math.min(Math.max(parseInt(String(limit)) || 10, 1), 100);
// Dynamically adjust threshold based on query characteristics
let thresholdNum = 0.3; // Default threshold
// For more general queries, use a lower threshold to get more diverse results
if (query.length < 10 || query.split(' ').length <= 2) {
thresholdNum = 0.2;
}
// For very specific queries, use a higher threshold for more precise results
if (query.length > 30 || query.includes('specific') || query.includes('exact')) {
thresholdNum = 0.4;
}
console.log(`Using similarity threshold: ${thresholdNum} for query: "${query}"`);
// Determine server filtering based on group
let group = getGroup(sessionId);
let servers: string[] | undefined = undefined; // No server filtering by default
// If group is in format $smart/{group}, filter servers to that group
if (group?.startsWith('$smart/')) {
const targetGroup = group.substring(7);
if (targetGroup) {
group = targetGroup;
}
const serversInGroup = await getServersInGroup(targetGroup);
if (serversInGroup !== undefined && serversInGroup !== null) {
servers = serversInGroup;
if (servers && servers.length > 0) {
console.log(`Filtering search to servers in group "${targetGroup}": ${servers.join(', ')}`);
} else {
console.log(`Group "${targetGroup}" has no servers, search will return no results`);
}
}
}
const searchResults = await searchToolsByVector(query, limitNum, thresholdNum, servers);
console.log(`Search results: ${JSON.stringify(searchResults)}`);
// Get smart routing config to check progressive disclosure setting
const smartRoutingConfig = await getSmartRoutingConfig();
const progressiveDisclosure = smartRoutingConfig.progressiveDisclosure ?? false;
// Find actual tool information from serverInfos by serverName and toolName
const resolvedTools = await Promise.all(
searchResults.map(async (result) => {
// Find the server in serverInfos
const server = getServerInfos().find(
(serverInfo) =>
serverInfo.name === result.serverName &&
serverInfo.status === 'connected' &&
serverInfo.enabled !== false,
);
if (server && server.tools && server.tools.length > 0) {
// Find the tool in server.tools
const actualTool = server.tools.find((tool) => tool.name === result.toolName);
if (actualTool) {
// Check if the tool is enabled in configuration
const tools = await filterToolsByConfigFn(server.name, [actualTool]);
if (tools.length > 0) {
// Apply custom description from configuration
const serverConfig = await getServerDao().findById(server.name);
const toolConfig = serverConfig?.tools?.[actualTool.name];
// Return the actual tool info from serverInfos with custom description
if (progressiveDisclosure) {
// Progressive disclosure: return only name and description
return {
name: actualTool.name,
description: toolConfig?.description || actualTool.description,
serverName: result.serverName,
};
} else {
// Standard mode: return full tool info
return {
...actualTool,
description: toolConfig?.description || actualTool.description,
serverName: result.serverName,
};
}
}
}
}
// Fallback to search result if server or tool not found or disabled
if (progressiveDisclosure) {
return {
name: result.toolName,
description: result.description || '',
serverName: result.serverName,
};
} else {
return {
name: result.toolName,
description: result.description || '',
inputSchema: cleanInputSchema(result.inputSchema || {}),
serverName: result.serverName,
};
}
}),
);
// Filter the resolved tools
const filterResults = await Promise.all(
resolvedTools.map(async (tool) => {
if (tool.name) {
const serverName = tool.serverName;
if (serverName) {
let tools = await filterToolsByConfigFn(serverName, [tool as Tool]);
if (tools.length === 0) {
return false;
}
tools = await filterToolsByGroupFn(group, serverName, tools);
return tools.length > 0;
}
}
return true;
}),
);
const tools = resolvedTools.filter((_, i) => filterResults[i]);
// Build response based on mode
let guideline: string;
let nextSteps: string;
if (progressiveDisclosure) {
guideline =
tools.length > 0
? "Found relevant tools. Use describe_tool to get the full parameter schema before calling. If these tools don't match exactly what you need, try another search with more specific keywords."
: 'No tools found. Try broadening your search or using different keywords.';
nextSteps =
tools.length > 0
? 'Use describe_tool with the toolName to get the full inputSchema, then use call_tool to execute.'
: 'Consider searching for related capabilities or more general terms.';
} else {
guideline =
tools.length > 0
? "Found relevant tools. If these tools don't match exactly what you need, try another search with more specific keywords."
: 'No tools found. Try broadening your search or using different keywords.';
nextSteps =
tools.length > 0
? 'To use a tool, call call_tool with the toolName and required arguments.'
: 'Consider searching for related capabilities or more general terms.';
}
const response = {
tools,
metadata: {
query: query,
threshold: thresholdNum,
totalResults: tools.length,
progressiveDisclosure,
guideline,
nextSteps,
},
};
return {
content: [
{
type: 'text',
text: JSON.stringify(response),
},
],
};
};
/**
* Handle the describe_tool request for smart routing (progressive disclosure mode)
*/
export const handleDescribeToolRequest = async (
toolName: string,
sessionId: string,
): Promise<any> => {
if (!toolName || typeof toolName !== 'string') {
throw new Error('toolName parameter is required and must be a string');
}
console.log(`Handling describe_tool request for: ${toolName}`);
// Determine group filtering
let group = getGroup(sessionId);
if (group?.startsWith('$smart/')) {
group = group.substring(7);
}
// Find the tool across all connected servers
for (const serverInfo of getServerInfos()) {
if (serverInfo.status !== 'connected' || serverInfo.enabled === false) {
continue;
}
// Check if this server has the tool
const tool = serverInfo.tools?.find((t) => t.name === toolName);
if (!tool) {
continue;
}
// Check if the tool is enabled in configuration
const tools = await filterToolsByConfigFn(serverInfo.name, [tool]);
if (tools.length === 0) {
continue;
}
// Apply group filtering if applicable
if (group) {
const filteredTools = await filterToolsByGroupFn(group, serverInfo.name, tools);
if (filteredTools.length === 0) {
continue;
}
}
// Get custom description from configuration
const serverConfig = await getServerDao().findById(serverInfo.name);
const toolConfig = serverConfig?.tools?.[tool.name];
// Return full tool information
const toolInfo = {
name: tool.name,
description: toolConfig?.description || tool.description,
inputSchema: cleanInputSchema(tool.inputSchema),
serverName: serverInfo.name,
};
return {
content: [
{
type: 'text',
text: JSON.stringify({
tool: toolInfo,
metadata: {
message: `Full schema for tool '${toolName}'. Use call_tool with the toolName and arguments based on the inputSchema.`,
},
}),
},
],
};
}
// Tool not found
return {
content: [
{
type: 'text',
text: JSON.stringify({
error: `Tool '${toolName}' not found or not available`,
metadata: {
message:
'The specified tool was not found. Use search_tools to discover available tools.',
},
}),
},
],
isError: true,
};
};
/**
* Check if the given group is a smart routing group
*/
export const isSmartRoutingGroup = (group: string | undefined): boolean => {
return group === '$smart' || (group?.startsWith('$smart/') ?? false);
};

View File

@@ -10,6 +10,12 @@ export interface IUser {
username: string;
password: string;
isAdmin?: boolean;
// OAuth SSO fields
oauthProvider?: string; // OAuth provider ID (e.g., 'google', 'microsoft', 'github')
oauthSubject?: string; // OAuth subject (unique user ID from provider)
email?: string; // User email (from OAuth profile)
displayName?: string; // Display name (from OAuth profile)
avatarUrl?: string; // Avatar URL (from OAuth profile)
}
// Group interface for server grouping
@@ -124,6 +130,43 @@ export interface MCPRouterCallToolResponse {
isError: boolean;
}
// OAuth SSO Provider Configuration for user authentication
export type OAuthSsoProviderType = 'google' | 'microsoft' | 'github' | 'oidc';
export interface OAuthSsoProviderConfig {
id: string; // Unique identifier for this provider (e.g., 'google', 'my-company-sso')
type: OAuthSsoProviderType; // Provider type
name: string; // Display name (e.g., 'Google', 'Microsoft', 'Company SSO')
enabled?: boolean; // Whether this provider is enabled (default: true)
clientId: string; // OAuth client ID
clientSecret: string; // OAuth client secret
// For OIDC providers, discovery URL or explicit endpoints
issuerUrl?: string; // OIDC issuer URL for auto-discovery (e.g., 'https://accounts.google.com')
// Explicit endpoints (optional, can be auto-discovered for OIDC)
authorizationUrl?: string; // OAuth authorization endpoint
tokenUrl?: string; // OAuth token endpoint
userInfoUrl?: string; // OAuth userinfo endpoint
// Scope configuration
scopes?: string[]; // OAuth scopes to request (default varies by provider)
// Role/admin mapping
adminClaim?: string; // Claim name to check for admin role (e.g., 'groups', 'roles')
adminClaimValues?: string[]; // Values that grant admin access (e.g., ['admin', 'mcphub-admins'])
// Auto-provisioning options
autoProvision?: boolean; // Auto-create users on first login (default: true)
defaultAdmin?: boolean; // Whether auto-provisioned users are admins by default (default: false)
// UI options
icon?: string; // Icon identifier for UI (e.g., 'google', 'microsoft', 'github', 'key')
buttonText?: string; // Custom button text (e.g., 'Sign in with Google')
}
// OAuth SSO configuration in SystemConfig
export interface OAuthSsoConfig {
enabled?: boolean; // Enable/disable OAuth SSO globally
providers?: OAuthSsoProviderConfig[]; // List of configured SSO providers
allowLocalAuth?: boolean; // Allow local username/password auth alongside SSO (default: true)
callbackBaseUrl?: string; // Base URL for OAuth callbacks (auto-detected if not set)
}
// OAuth Provider Configuration for MCP Authorization Server
export interface OAuthProviderConfig {
enabled?: boolean; // Enable/disable OAuth provider
@@ -172,6 +215,7 @@ export interface SystemConfig {
nameSeparator?: string; // Separator used between server name and tool/prompt name (default: '-')
oauth?: OAuthProviderConfig; // OAuth provider configuration for upstream MCP servers
oauthServer?: OAuthServerConfig; // OAuth authorization server configuration for MCPHub itself
oauthSso?: OAuthSsoConfig; // OAuth SSO configuration for user authentication
enableSessionRebuild?: boolean; // Controls whether server session rebuild is enabled
}
@@ -270,17 +314,6 @@ export interface McpSettings {
bearerKeys?: BearerKey[]; // Bearer authentication keys (multi-key configuration)
}
// Proxychains4 configuration for STDIO servers (Linux/macOS only)
export interface ProxychainsConfig {
enabled?: boolean; // Enable/disable proxychains4 proxy routing
type?: 'socks4' | 'socks5' | 'http'; // Proxy protocol type
host?: string; // Proxy server hostname or IP address
port?: number; // Proxy server port
username?: string; // Proxy authentication username (optional)
password?: string; // Proxy authentication password (optional)
configPath?: string; // Path to custom proxychains4 configuration file (optional, overrides above settings)
}
// Configuration details for an individual server
export interface ServerConfig {
type?: 'stdio' | 'sse' | 'streamable-http' | 'openapi'; // Type of server
@@ -296,8 +329,6 @@ export interface ServerConfig {
tools?: Record<string, { enabled: boolean; description?: string }>; // Tool-specific configurations with enable/disable state and custom descriptions
prompts?: Record<string, { enabled: boolean; description?: string }>; // Prompt-specific configurations with enable/disable state and custom descriptions
options?: Partial<Pick<RequestOptions, 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout'>>; // MCP request options configuration
// Proxychains4 proxy configuration for STDIO servers (Linux/macOS only, Windows not supported)
proxy?: ProxychainsConfig;
// OAuth authentication for upstream MCP servers
oauth?: {
// Static client configuration (traditional OAuth flow)

View File

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

View File

@@ -10,13 +10,6 @@ export interface SmartRoutingConfig {
openaiApiBaseUrl: string;
openaiApiKey: string;
openaiApiEmbeddingModel: string;
/**
* When enabled, search_tools returns only tool name and description (without full inputSchema).
* A new describe_tool endpoint is provided to get the full tool schema on demand.
* This reduces token usage for AI clients that don't need all tool parameters upfront.
* Default: false (returns full tool schemas in search_tools for backward compatibility)
*/
progressiveDisclosure?: boolean;
}
/**
@@ -24,7 +17,7 @@ export interface SmartRoutingConfig {
*
* Priority order for each setting:
* 1. Specific environment variables (ENABLE_SMART_ROUTING, SMART_ROUTING_ENABLED, etc.)
* 2. Generic environment variables (OPENAI_API_KEY, DB_URL, etc.)
* 2. Generic environment variables (OPENAI_API_KEY, DATABASE_URL, etc.)
* 3. Settings configuration (systemConfig.smartRouting)
* 4. Default values
*
@@ -69,15 +62,6 @@ export async function getSmartRoutingConfig(): Promise<SmartRoutingConfig> {
'text-embedding-3-small',
expandEnvVars,
),
// Progressive disclosure - when enabled, search_tools returns minimal info
// and describe_tool is used to get full schema
progressiveDisclosure: getConfigValue(
[process.env.SMART_ROUTING_PROGRESSIVE_DISCLOSURE],
smartRoutingSettings.progressiveDisclosure,
false,
parseBooleanEnvVar,
),
};
}

View File

@@ -48,44 +48,6 @@ jest.mock('../../src/services/services.js', () => ({
})),
}));
// Mock smartRoutingService to initialize with test functions
const mockHandleSearchToolsRequest = jest.fn();
jest.mock('../../src/services/smartRoutingService.js', () => ({
initSmartRoutingService: jest.fn(),
handleSearchToolsRequest: mockHandleSearchToolsRequest,
handleDescribeToolRequest: jest.fn(),
isSmartRoutingGroup: jest.fn((group: string) => group?.startsWith('$smart')),
getSmartRoutingTools: jest.fn(async (group: string) => {
const targetGroup = group?.startsWith('$smart/') ? group.substring(7) : undefined;
const scopeDescription = targetGroup
? `servers in the "${targetGroup}" group`
: 'all available servers';
return {
tools: [
{
name: 'search_tools',
description: `Search for relevant tools across ${scopeDescription}.`,
inputSchema: {
type: 'object',
properties: { query: { type: 'string' }, limit: { type: 'integer' } },
required: ['query'],
},
},
{
name: 'call_tool',
description: 'Execute a tool by name',
inputSchema: {
type: 'object',
properties: { toolName: { type: 'string' } },
required: ['toolName'],
},
},
],
};
}),
}));
jest.mock('../../src/services/vectorSearchService.js', () => ({
searchToolsByVector: jest.fn(),
saveToolsAsVectorEmbeddings: jest.fn(),
@@ -104,21 +66,13 @@ jest.mock('../../src/config/index.js', () => ({
// Import after mocks are set up
import { handleListToolsRequest, handleCallToolRequest } from '../../src/services/mcpService.js';
import { getServersInGroup } from '../../src/services/groupService.js';
import { getGroup } from '../../src/services/sseService.js';
import { handleSearchToolsRequest } from '../../src/services/smartRoutingService.js';
import { searchToolsByVector } from '../../src/services/vectorSearchService.js';
describe('MCP Service - Smart Routing with Group Support', () => {
beforeEach(() => {
jest.clearAllMocks();
// Setup mock return for handleSearchToolsRequest
mockHandleSearchToolsRequest.mockResolvedValue({
content: [
{
type: 'text',
text: JSON.stringify({ tools: [], guideline: 'test', nextSteps: 'test' }),
},
],
});
});
describe('handleListToolsRequest', () => {
@@ -135,7 +89,7 @@ describe('MCP Service - Smart Routing with Group Support', () => {
const result = await handleListToolsRequest({}, { sessionId: 'session-smart-group' });
expect(getGroup).toHaveBeenCalledWith('session-smart-group');
// Note: getServersInGroup is now called inside the mocked getSmartRoutingTools
expect(getServersInGroup).toHaveBeenCalledWith('test-group');
expect(result.tools).toHaveLength(2);
expect(result.tools[0].name).toBe('search_tools');
@@ -147,7 +101,7 @@ describe('MCP Service - Smart Routing with Group Support', () => {
const result = await handleListToolsRequest({}, { sessionId: 'session-smart-empty' });
expect(getGroup).toHaveBeenCalledWith('session-smart-empty');
// Note: getServersInGroup is now called inside the mocked getSmartRoutingTools
expect(getServersInGroup).toHaveBeenCalledWith('empty-group');
expect(result.tools).toHaveLength(2);
expect(result.tools[0].name).toBe('search_tools');
@@ -159,6 +113,16 @@ describe('MCP Service - Smart Routing with Group Support', () => {
describe('handleCallToolRequest - search_tools', () => {
it('should search across all servers when using $smart', async () => {
const mockSearchResults = [
{
serverName: 'server1',
toolName: 'server1::tool1',
description: 'Test tool 1',
inputSchema: {},
},
];
(searchToolsByVector as jest.Mock).mockResolvedValue(mockSearchResults);
const request = {
params: {
name: 'search_tools',
@@ -171,11 +135,25 @@ describe('MCP Service - Smart Routing with Group Support', () => {
await handleCallToolRequest(request, { sessionId: 'session-smart' });
// handleSearchToolsRequest should be called with the query, limit, and sessionId
expect(handleSearchToolsRequest).toHaveBeenCalledWith('test query', 10, 'session-smart');
expect(searchToolsByVector).toHaveBeenCalledWith(
'test query',
10,
expect.any(Number),
undefined, // No server filtering
);
});
it('should filter servers when using $smart/{group}', async () => {
const mockSearchResults = [
{
serverName: 'server1',
toolName: 'server1::tool1',
description: 'Test tool 1',
inputSchema: {},
},
];
(searchToolsByVector as jest.Mock).mockResolvedValue(mockSearchResults);
const request = {
params: {
name: 'search_tools',
@@ -188,16 +166,20 @@ describe('MCP Service - Smart Routing with Group Support', () => {
await handleCallToolRequest(request, { sessionId: 'session-smart-group' });
// handleSearchToolsRequest should be called with the sessionId that contains group info
// The group filtering happens inside handleSearchToolsRequest, not in handleCallToolRequest
expect(handleSearchToolsRequest).toHaveBeenCalledWith(
expect(getGroup).toHaveBeenCalledWith('session-smart-group');
expect(getServersInGroup).toHaveBeenCalledWith('test-group');
expect(searchToolsByVector).toHaveBeenCalledWith(
'test query',
10,
'session-smart-group',
expect.any(Number),
['server1', 'server2'], // Filtered to group servers
);
});
it('should handle empty group in $smart/{group}', async () => {
const mockSearchResults: any[] = [];
(searchToolsByVector as jest.Mock).mockResolvedValue(mockSearchResults);
const request = {
params: {
name: 'search_tools',
@@ -210,19 +192,18 @@ describe('MCP Service - Smart Routing with Group Support', () => {
await handleCallToolRequest(request, { sessionId: 'session-smart-empty' });
expect(handleSearchToolsRequest).toHaveBeenCalledWith(
expect(getGroup).toHaveBeenCalledWith('session-smart-empty');
expect(getServersInGroup).toHaveBeenCalledWith('empty-group');
// Empty group returns empty array, which should still be passed to search
expect(searchToolsByVector).toHaveBeenCalledWith(
'test query',
10,
'session-smart-empty',
expect.any(Number),
[], // Empty group
);
});
it('should validate query parameter', async () => {
// Mock handleSearchToolsRequest to return an error result when query is missing
mockHandleSearchToolsRequest.mockImplementationOnce(() => {
return Promise.reject(new Error('Query parameter is required and must be a string'));
});
const request = {
params: {
name: 'search_tools',

View File

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

View File

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