mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 02:39:19 -05:00
Compare commits
18 Commits
v0.9.16
...
copilot/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16112a78c9 | ||
|
|
8945b583a7 | ||
|
|
1f535c3d7f | ||
|
|
f4bac3adc0 | ||
|
|
fbff212005 | ||
|
|
44e0309fd4 | ||
|
|
7e570a900a | ||
|
|
6268a02c0e | ||
|
|
695d663939 | ||
|
|
d595e5d874 | ||
|
|
ff797b4ab9 | ||
|
|
9105507722 | ||
|
|
f79028ed64 | ||
|
|
5ca5e2ad47 | ||
|
|
2f7726b008 | ||
|
|
26b26a5fb1 | ||
|
|
7dbd6c386e | ||
|
|
c1fee91142 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,6 +2,7 @@
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
package-lock.json
|
||||
|
||||
# production
|
||||
dist
|
||||
|
||||
30
AGENTS.md
Normal file
30
AGENTS.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Repository Guidelines
|
||||
|
||||
These notes align current contributors around the code layout, daily commands, and collaboration habits that keep `@samanhappy/mcphub` moving quickly.
|
||||
|
||||
## Project Structure & Module Organization
|
||||
- Backend services live in `src`, grouped by responsibility (`controllers/`, `services/`, `dao/`, `routes/`, `utils/`), with `server.ts` orchestrating HTTP bootstrap.
|
||||
- `frontend/src` contains the Vite + React dashboard; `frontend/public` hosts static assets and translations sit in `locales/`.
|
||||
- Jest-aware test code is split between colocated specs (`src/**/*.{test,spec}.ts`) and higher-level suites in `tests/`; use `tests/utils/` helpers when exercising the CLI or SSE flows.
|
||||
- Build artifacts and bundles are generated into `dist/`, `frontend/dist/`, and `coverage/`; never edit these manually.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- `pnpm dev` runs backend (`tsx watch src/index.ts`) and frontend (`vite`) together for local iteration.
|
||||
- `pnpm backend:dev`, `pnpm frontend:dev`, and `pnpm frontend:preview` target each surface independently; prefer them when debugging one stack.
|
||||
- `pnpm build` executes `pnpm backend:build` (TypeScript to `dist/`) and `pnpm frontend:build`; run before release or publishing.
|
||||
- `pnpm test`, `pnpm test:watch`, and `pnpm test:coverage` drive Jest; `pnpm lint` and `pnpm format` enforce style via ESLint and Prettier.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
- TypeScript everywhere; default to 2-space indentation and single quotes, letting Prettier settle formatting. ESLint configuration assumes ES modules.
|
||||
- Name services and data access layers with suffixes (`UserService`, `AuthDao`), React components and files in `PascalCase`, and utility modules in `camelCase`.
|
||||
- Keep DTOs and shared types in `src/types` to avoid duplication; re-export through index files only when it clarifies imports.
|
||||
|
||||
## Testing Guidelines
|
||||
- Use Jest with the `ts-jest` ESM preset; place shared setup in `tests/setup.ts` and mock helpers under `tests/utils/`.
|
||||
- Mirror production directory names when adding new suites and end filenames with `.test.ts` or `.spec.ts` for automatic discovery.
|
||||
- Aim to maintain or raise coverage when touching critical flows (auth, OAuth, SSE); add integration tests under `tests/integration/` when touching cross-service logic.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
- Follow the existing Conventional Commit pattern (`feat:`, `fix:`, `chore:`, etc.) with imperative, present-tense summaries and optional multi-line context.
|
||||
- Each PR should describe the behavior change, list testing performed, and link issues; include before/after screenshots or GIFs for frontend tweaks.
|
||||
- Re-run `pnpm build` and `pnpm test` before requesting review, and ensure generated artifacts stay out of the diff.
|
||||
22
Dockerfile
22
Dockerfile
@@ -9,9 +9,25 @@ RUN apt-get update && apt-get install -y curl gnupg git \
|
||||
|
||||
RUN npm install -g pnpm
|
||||
|
||||
ENV PNPM_HOME=/usr/local/share/pnpm
|
||||
ENV PATH=$PNPM_HOME:$PATH
|
||||
RUN mkdir -p $PNPM_HOME && \
|
||||
ENV MCP_DATA_DIR=/app/data
|
||||
ENV MCP_SERVERS_DIR=$MCP_DATA_DIR/servers
|
||||
ENV MCP_NPM_DIR=$MCP_SERVERS_DIR/npm
|
||||
ENV MCP_PYTHON_DIR=$MCP_SERVERS_DIR/python
|
||||
ENV PNPM_HOME=$MCP_DATA_DIR/pnpm
|
||||
ENV NPM_CONFIG_PREFIX=$MCP_DATA_DIR/npm-global
|
||||
ENV NPM_CONFIG_CACHE=$MCP_DATA_DIR/npm-cache
|
||||
ENV UV_TOOL_DIR=$MCP_DATA_DIR/uv/tools
|
||||
ENV UV_CACHE_DIR=$MCP_DATA_DIR/uv/cache
|
||||
ENV PATH=$PNPM_HOME:$NPM_CONFIG_PREFIX/bin:$UV_TOOL_DIR/bin:$PATH
|
||||
RUN mkdir -p \
|
||||
$PNPM_HOME \
|
||||
$NPM_CONFIG_PREFIX/bin \
|
||||
$NPM_CONFIG_PREFIX/lib/node_modules \
|
||||
$NPM_CONFIG_CACHE \
|
||||
$UV_TOOL_DIR \
|
||||
$UV_CACHE_DIR \
|
||||
$MCP_NPM_DIR \
|
||||
$MCP_PYTHON_DIR && \
|
||||
pnpm add -g @amap/amap-maps-mcp-server @playwright/mcp@latest tavily-mcp@latest @modelcontextprotocol/server-github @modelcontextprotocol/server-slack
|
||||
|
||||
ARG INSTALL_EXT=false
|
||||
|
||||
68
README.md
68
README.md
@@ -19,6 +19,9 @@ MCPHub makes it easy to manage and scale multiple MCP (Model Context Protocol) s
|
||||
- **Hot-Swappable Configuration**: Add, remove, or update MCP servers on the fly — no downtime required.
|
||||
- **Group-Based Access Control**: Organize servers into customizable groups for streamlined permissions management.
|
||||
- **Secure Authentication**: Built-in user management with role-based access powered by JWT and bcrypt.
|
||||
- **OAuth 2.0 Support**: Full OAuth support for upstream MCP servers with proxy authorization capabilities.
|
||||
- **Environment Variable Expansion**: Use environment variables anywhere in your configuration for secure credential management. See [Environment Variables Guide](docs/environment-variables.md).
|
||||
- **Cluster Deployment**: Deploy multiple nodes for high availability and load distribution with sticky session support. See [Cluster Deployment Guide](docs/cluster-deployment.md).
|
||||
- **Docker-Ready**: Deploy instantly with our containerized setup.
|
||||
|
||||
## 🔧 Quick Start
|
||||
@@ -57,6 +60,45 @@ Create a `mcp_settings.json` file to customize your server settings:
|
||||
}
|
||||
```
|
||||
|
||||
#### OAuth Configuration (Optional)
|
||||
|
||||
MCPHub supports OAuth 2.0 for authenticating with upstream MCP servers. See the [OAuth feature guide](docs/features/oauth.mdx) for a full walkthrough. In practice you will run into two configuration patterns:
|
||||
|
||||
- **Dynamic registration servers** (e.g., Vercel, Linear) publish all metadata and allow MCPHub to self-register. Simply declare the server URL and MCPHub handles the rest.
|
||||
- **Manually provisioned servers** (e.g., GitHub Copilot) require you to create an OAuth App and provide the issued client ID/secret to MCPHub.
|
||||
|
||||
Dynamic registration example:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"vercel": {
|
||||
"type": "sse",
|
||||
"url": "https://mcp.vercel.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Manual registration example:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
"type": "sse",
|
||||
"url": "https://api.githubcopilot.com/mcp/",
|
||||
"oauth": {
|
||||
"clientId": "${GITHUB_OAUTH_APP_ID}",
|
||||
"clientSecret": "${GITHUB_OAUTH_APP_SECRET}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For manual providers, create the OAuth App in the upstream console, set the redirect URI to `http://localhost:3000/oauth/callback` (or your deployed domain), and then plug the credentials into the dashboard or config file.
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
**Recommended**: Mount your custom config:
|
||||
@@ -106,7 +148,11 @@ This endpoint provides a unified streamable HTTP interface for all your MCP serv
|
||||
Smart Routing is MCPHub's intelligent tool discovery system that uses vector semantic search to automatically find the most relevant tools for any given task.
|
||||
|
||||
```
|
||||
# Search across all servers
|
||||
http://localhost:3000/mcp/$smart
|
||||
|
||||
# Search within a specific group
|
||||
http://localhost:3000/mcp/$smart/{group}
|
||||
```
|
||||
|
||||
**How it Works:**
|
||||
@@ -115,6 +161,7 @@ http://localhost:3000/mcp/$smart
|
||||
2. **Semantic Search**: User queries are converted to vectors and matched against tool embeddings using cosine similarity
|
||||
3. **Intelligent Filtering**: Dynamic thresholds ensure relevant results without noise
|
||||
4. **Precise Execution**: Found tools can be directly executed with proper parameter validation
|
||||
5. **Group Scoping**: Optionally limit searches to servers within a specific group for focused results
|
||||
|
||||
**Setup Requirements:**
|
||||
|
||||
@@ -126,6 +173,23 @@ To enable Smart Routing, you need:
|
||||
- OpenAI API key (or compatible embedding service)
|
||||
- Enable Smart Routing in MCPHub settings
|
||||
|
||||
**Group-Scoped Smart Routing**:
|
||||
|
||||
You can combine Smart Routing with group filtering to search only within specific server groups:
|
||||
|
||||
```
|
||||
# Search only within production servers
|
||||
http://localhost:3000/mcp/$smart/production
|
||||
|
||||
# Search only within development servers
|
||||
http://localhost:3000/mcp/$smart/development
|
||||
```
|
||||
|
||||
This enables:
|
||||
- **Focused Discovery**: Find tools only from relevant servers
|
||||
- **Environment Isolation**: Separate tool discovery by environment (dev, staging, prod)
|
||||
- **Team-Based Access**: Limit tool search to team-specific server groups
|
||||
|
||||
**Group-Specific Endpoints (Recommended)**:
|
||||
|
||||

|
||||
@@ -164,7 +228,11 @@ http://localhost:3000/sse
|
||||
For smart routing, use:
|
||||
|
||||
```
|
||||
# Search across all servers
|
||||
http://localhost:3000/sse/$smart
|
||||
|
||||
# Search within a specific group
|
||||
http://localhost:3000/sse/$smart/{group}
|
||||
```
|
||||
|
||||
For targeted access to specific server groups, use the group-based SSE endpoint:
|
||||
|
||||
68
README.zh.md
68
README.zh.md
@@ -19,6 +19,9 @@ MCPHub 通过将多个 MCP(Model Context Protocol)服务器组织为灵活
|
||||
- **热插拔式配置**:在运行时动态添加、移除或更新服务器配置,无需停机。
|
||||
- **基于分组的访问控制**:自定义分组并管理服务器访问权限。
|
||||
- **安全认证机制**:内置用户管理,基于 JWT 和 bcrypt,实现角色权限控制。
|
||||
- **OAuth 2.0 支持**:完整的 OAuth 支持,用于上游 MCP 服务器的代理授权功能。
|
||||
- **环境变量扩展**:在配置中的任何位置使用环境变量,实现安全凭证管理。参见[环境变量指南](docs/environment-variables.md)。
|
||||
- **集群部署**:部署多个节点实现高可用性和负载分配,支持会话粘性。参见[集群部署指南](docs/cluster-deployment.zh.md)。
|
||||
- **Docker 就绪**:提供容器化镜像,快速部署。
|
||||
|
||||
## 🔧 快速开始
|
||||
@@ -57,6 +60,45 @@ MCPHub 通过将多个 MCP(Model Context Protocol)服务器组织为灵活
|
||||
}
|
||||
```
|
||||
|
||||
#### OAuth 配置(可选)
|
||||
|
||||
MCPHub 支持通过 OAuth 2.0 访问上游 MCP 服务器。完整说明请参阅[《OAuth 功能指南》](docs/zh/features/oauth.mdx)。实际使用中通常会遇到两类配置:
|
||||
|
||||
- **支持动态注册的服务器**(如 Vercel、Linear):会公开全部元数据,MCPHub 可自动注册并完成授权,仅需声明服务器地址。
|
||||
- **需要手动配置客户端的服务器**(如 GitHub Copilot):需要在提供商后台创建 OAuth 应用,并将获得的 Client ID/Secret 写入 MCPHub。
|
||||
|
||||
动态注册示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"vercel": {
|
||||
"type": "sse",
|
||||
"url": "https://mcp.vercel.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
手动注册示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
"type": "sse",
|
||||
"url": "https://api.githubcopilot.com/mcp/",
|
||||
"oauth": {
|
||||
"clientId": "${GITHUB_OAUTH_APP_ID}",
|
||||
"clientSecret": "${GITHUB_OAUTH_APP_SECRET}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
对于需要手动注册的提供商,请先在上游控制台创建 OAuth 应用,将回调地址设置为 `http://localhost:3000/oauth/callback`(或你的部署域名),然后在控制台或配置文件中填写凭据。
|
||||
|
||||
### Docker 部署
|
||||
|
||||
**推荐**:挂载自定义配置:
|
||||
@@ -106,7 +148,11 @@ http://localhost:3000/mcp
|
||||
智能路由是 MCPHub 的智能工具发现系统,使用向量语义搜索自动为任何给定任务找到最相关的工具。
|
||||
|
||||
```
|
||||
# 在所有服务器中搜索
|
||||
http://localhost:3000/mcp/$smart
|
||||
|
||||
# 在特定分组中搜索
|
||||
http://localhost:3000/mcp/$smart/{group}
|
||||
```
|
||||
|
||||
**工作原理:**
|
||||
@@ -115,6 +161,7 @@ http://localhost:3000/mcp/$smart
|
||||
2. **语义搜索**:用户查询转换为向量并使用余弦相似度与工具嵌入匹配
|
||||
3. **智能筛选**:动态阈值确保相关结果且无噪声
|
||||
4. **精确执行**:找到的工具可以直接执行并进行适当的参数验证
|
||||
5. **分组限定**:可选择将搜索限制在特定分组的服务器内以获得更精确的结果
|
||||
|
||||
**设置要求:**
|
||||
|
||||
@@ -126,6 +173,23 @@ http://localhost:3000/mcp/$smart
|
||||
- OpenAI API 密钥(或兼容的嵌入服务)
|
||||
- 在 MCPHub 设置中启用智能路由
|
||||
|
||||
**分组限定的智能路由**:
|
||||
|
||||
您可以将智能路由与分组筛选结合使用,仅在特定服务器分组内搜索:
|
||||
|
||||
```
|
||||
# 仅在生产服务器中搜索
|
||||
http://localhost:3000/mcp/$smart/production
|
||||
|
||||
# 仅在开发服务器中搜索
|
||||
http://localhost:3000/mcp/$smart/development
|
||||
```
|
||||
|
||||
这样可以实现:
|
||||
- **精准发现**:仅从相关服务器查找工具
|
||||
- **环境隔离**:按环境(开发、测试、生产)分离工具发现
|
||||
- **基于团队的访问**:将工具搜索限制在特定团队的服务器分组
|
||||
|
||||
**基于分组的 HTTP 端点(推荐)**:
|
||||

|
||||
要针对特定服务器分组进行访问,请使用基于分组的 HTTP 端点:
|
||||
@@ -164,7 +228,11 @@ http://localhost:3000/sse
|
||||
要启用智能路由,请使用:
|
||||
|
||||
```
|
||||
# 在所有服务器中搜索
|
||||
http://localhost:3000/sse/$smart
|
||||
|
||||
# 在特定分组中搜索
|
||||
http://localhost:3000/sse/$smart/{group}
|
||||
```
|
||||
|
||||
要针对特定服务器分组进行访问,请使用基于分组的 SSE 端点:
|
||||
|
||||
516
docs/cluster-deployment.md
Normal file
516
docs/cluster-deployment.md
Normal file
@@ -0,0 +1,516 @@
|
||||
# Cluster Deployment Guide
|
||||
|
||||
MCPHub supports cluster deployment, allowing you to run multiple nodes that work together as a unified system. This enables:
|
||||
|
||||
- **High Availability**: Distribute MCP servers across multiple nodes for redundancy
|
||||
- **Load Distribution**: Balance requests across multiple replicas of the same MCP server
|
||||
- **Sticky Sessions**: Ensure client sessions are routed to the same node consistently
|
||||
- **Centralized Management**: One coordinator manages the entire cluster
|
||||
|
||||
## Architecture
|
||||
|
||||
MCPHub cluster has three operating modes:
|
||||
|
||||
1. **Standalone Mode** (Default): Single node operation, no cluster features
|
||||
2. **Coordinator Mode**: Central node that manages the cluster, routes requests, and maintains session affinity
|
||||
3. **Node Mode**: Worker nodes that register with the coordinator and run MCP servers
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Coordinator Node │
|
||||
│ - Manages cluster state │
|
||||
│ - Routes client requests │
|
||||
│ - Maintains session affinity │
|
||||
│ - Health monitoring │
|
||||
└───────────┬─────────────────────────────┘
|
||||
│
|
||||
┌───────┴───────────────────┐
|
||||
│ │
|
||||
┌───▼────────┐ ┌────────▼────┐
|
||||
│ Node 1 │ │ Node 2 │
|
||||
│ - MCP A │ │ - MCP A │
|
||||
│ - MCP B │ │ - MCP C │
|
||||
└────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Coordinator Configuration
|
||||
|
||||
Create or update `mcp_settings.json` on the coordinator node:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
// Optional: coordinator can also run MCP servers
|
||||
"example": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "example-mcp-server"]
|
||||
}
|
||||
},
|
||||
"systemConfig": {
|
||||
"cluster": {
|
||||
"enabled": true,
|
||||
"mode": "coordinator",
|
||||
"coordinator": {
|
||||
"nodeTimeout": 15000,
|
||||
"cleanupInterval": 30000,
|
||||
"stickySessionTimeout": 3600000
|
||||
},
|
||||
"stickySession": {
|
||||
"enabled": true,
|
||||
"strategy": "consistent-hash",
|
||||
"cookieName": "MCPHUB_NODE",
|
||||
"headerName": "X-MCPHub-Node"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Configuration Options:**
|
||||
|
||||
- `nodeTimeout`: Time (ms) before marking a node as unhealthy (default: 15000)
|
||||
- `cleanupInterval`: Interval (ms) for cleaning up inactive nodes (default: 30000)
|
||||
- `stickySessionTimeout`: Session affinity timeout (ms) (default: 3600000 - 1 hour)
|
||||
- `stickySession.enabled`: Enable sticky session routing (default: true)
|
||||
- `stickySession.strategy`: Session affinity strategy:
|
||||
- `consistent-hash`: Hash-based routing (default)
|
||||
- `cookie`: Cookie-based routing
|
||||
- `header`: Header-based routing
|
||||
|
||||
### Node Configuration
|
||||
|
||||
Create or update `mcp_settings.json` on each worker node:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"amap": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@amap/amap-maps-mcp-server"]
|
||||
},
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--headless"]
|
||||
}
|
||||
},
|
||||
"systemConfig": {
|
||||
"cluster": {
|
||||
"enabled": true,
|
||||
"mode": "node",
|
||||
"node": {
|
||||
"id": "node-1",
|
||||
"name": "Worker Node 1",
|
||||
"coordinatorUrl": "http://coordinator:3000",
|
||||
"heartbeatInterval": 5000,
|
||||
"registerOnStartup": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Configuration Options:**
|
||||
|
||||
- `node.id`: Unique node identifier (auto-generated if not provided)
|
||||
- `node.name`: Human-readable node name (defaults to hostname)
|
||||
- `node.coordinatorUrl`: URL of the coordinator node (required)
|
||||
- `node.heartbeatInterval`: Heartbeat interval (ms) (default: 5000)
|
||||
- `node.registerOnStartup`: Auto-register on startup (default: true)
|
||||
|
||||
## Deployment Scenarios
|
||||
|
||||
### Scenario 1: Docker Compose
|
||||
|
||||
Create a `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
coordinator:
|
||||
image: samanhappy/mcphub:latest
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./coordinator-config.json:/app/mcp_settings.json
|
||||
- coordinator-data:/app/data
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
|
||||
node1:
|
||||
image: samanhappy/mcphub:latest
|
||||
volumes:
|
||||
- ./node1-config.json:/app/mcp_settings.json
|
||||
- node1-data:/app/data
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
depends_on:
|
||||
- coordinator
|
||||
|
||||
node2:
|
||||
image: samanhappy/mcphub:latest
|
||||
volumes:
|
||||
- ./node2-config.json:/app/mcp_settings.json
|
||||
- node2-data:/app/data
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
depends_on:
|
||||
- coordinator
|
||||
|
||||
volumes:
|
||||
coordinator-data:
|
||||
node1-data:
|
||||
node2-data:
|
||||
```
|
||||
|
||||
Start the cluster:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Scenario 2: Kubernetes
|
||||
|
||||
Create Kubernetes manifests:
|
||||
|
||||
**Coordinator Deployment:**
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: mcphub-coordinator
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: mcphub-coordinator
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: mcphub-coordinator
|
||||
spec:
|
||||
containers:
|
||||
- name: mcphub
|
||||
image: samanhappy/mcphub:latest
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /app/mcp_settings.json
|
||||
subPath: mcp_settings.json
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: mcphub-coordinator-config
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: mcphub-coordinator
|
||||
spec:
|
||||
selector:
|
||||
app: mcphub-coordinator
|
||||
ports:
|
||||
- port: 3000
|
||||
targetPort: 3000
|
||||
type: LoadBalancer
|
||||
```
|
||||
|
||||
**Worker Node Deployment:**
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: mcphub-node
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: mcphub-node
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: mcphub-node
|
||||
spec:
|
||||
containers:
|
||||
- name: mcphub
|
||||
image: samanhappy/mcphub:latest
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /app/mcp_settings.json
|
||||
subPath: mcp_settings.json
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: mcphub-node-config
|
||||
```
|
||||
|
||||
Apply the manifests:
|
||||
|
||||
```bash
|
||||
kubectl apply -f coordinator.yaml
|
||||
kubectl apply -f nodes.yaml
|
||||
```
|
||||
|
||||
### Scenario 3: Manual Deployment
|
||||
|
||||
**On Coordinator (192.168.1.100):**
|
||||
|
||||
```bash
|
||||
# Install MCPHub
|
||||
npm install -g @samanhappy/mcphub
|
||||
|
||||
# Configure as coordinator
|
||||
cat > mcp_settings.json <<EOF
|
||||
{
|
||||
"systemConfig": {
|
||||
"cluster": {
|
||||
"enabled": true,
|
||||
"mode": "coordinator"
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Start coordinator
|
||||
PORT=3000 mcphub
|
||||
```
|
||||
|
||||
**On Node 1 (192.168.1.101):**
|
||||
|
||||
```bash
|
||||
# Install MCPHub
|
||||
npm install -g @samanhappy/mcphub
|
||||
|
||||
# Configure as node
|
||||
cat > mcp_settings.json <<EOF
|
||||
{
|
||||
"mcpServers": {
|
||||
"server1": { "command": "..." }
|
||||
},
|
||||
"systemConfig": {
|
||||
"cluster": {
|
||||
"enabled": true,
|
||||
"mode": "node",
|
||||
"node": {
|
||||
"coordinatorUrl": "http://192.168.1.100:3000"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Start node
|
||||
PORT=3001 mcphub
|
||||
```
|
||||
|
||||
**On Node 2 (192.168.1.102):**
|
||||
|
||||
```bash
|
||||
# Similar to Node 1, but with PORT=3002
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Accessing the Cluster
|
||||
|
||||
Once the cluster is running, connect AI clients to the coordinator's endpoint:
|
||||
|
||||
```
|
||||
http://coordinator:3000/mcp
|
||||
http://coordinator:3000/sse
|
||||
```
|
||||
|
||||
The coordinator will:
|
||||
1. Route requests to appropriate nodes based on session affinity
|
||||
2. Load balance across multiple replicas of the same server
|
||||
3. Automatically failover to healthy nodes
|
||||
|
||||
### Sticky Sessions
|
||||
|
||||
Sticky sessions ensure that a client's requests are routed to the same node throughout their session. This is important for:
|
||||
|
||||
- Maintaining conversation context
|
||||
- Preserving temporary state
|
||||
- Consistent tool execution
|
||||
|
||||
The default strategy is **consistent-hash**, which uses the session ID to determine the target node. Alternative strategies:
|
||||
|
||||
- **Cookie-based**: Uses `MCPHUB_NODE` cookie
|
||||
- **Header-based**: Uses `X-MCPHub-Node` header
|
||||
|
||||
### Multiple Replicas
|
||||
|
||||
You can deploy the same MCP server on multiple nodes for:
|
||||
|
||||
- **Load balancing**: Distribute requests across replicas
|
||||
- **High availability**: Failover if one node goes down
|
||||
|
||||
Example configuration:
|
||||
|
||||
**Node 1:**
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Node 2:**
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The coordinator will automatically load balance requests to `playwright` across both nodes.
|
||||
|
||||
## Management API
|
||||
|
||||
The coordinator exposes cluster management endpoints:
|
||||
|
||||
### Get Cluster Status
|
||||
|
||||
```bash
|
||||
curl http://coordinator:3000/api/cluster/status
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"enabled": true,
|
||||
"mode": "coordinator",
|
||||
"nodeId": "coordinator",
|
||||
"stats": {
|
||||
"nodes": 3,
|
||||
"activeNodes": 3,
|
||||
"servers": 5,
|
||||
"sessions": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Get All Nodes
|
||||
|
||||
```bash
|
||||
curl http://coordinator:3000/api/cluster/nodes
|
||||
```
|
||||
|
||||
### Get Server Replicas
|
||||
|
||||
```bash
|
||||
curl http://coordinator:3000/api/cluster/servers/playwright/replicas
|
||||
```
|
||||
|
||||
### Get Session Affinity
|
||||
|
||||
```bash
|
||||
curl http://coordinator:3000/api/cluster/sessions/{sessionId}
|
||||
```
|
||||
|
||||
## Monitoring and Troubleshooting
|
||||
|
||||
### Check Node Health
|
||||
|
||||
Monitor coordinator logs for heartbeat messages:
|
||||
|
||||
```
|
||||
Node registered: Worker Node 1 (node-1) with 2 servers
|
||||
```
|
||||
|
||||
If a node becomes unhealthy:
|
||||
|
||||
```
|
||||
Marking node node-1 as unhealthy (last heartbeat: 2024-01-01T10:00:00.000Z)
|
||||
```
|
||||
|
||||
### Verify Registration
|
||||
|
||||
Check if nodes are registered:
|
||||
|
||||
```bash
|
||||
curl http://coordinator:3000/api/cluster/nodes?active=true
|
||||
```
|
||||
|
||||
### Session Affinity Issues
|
||||
|
||||
If sessions aren't sticking to the same node:
|
||||
|
||||
1. Verify sticky sessions are enabled in coordinator config
|
||||
2. Check that session IDs are being passed correctly
|
||||
3. Review coordinator logs for session affinity errors
|
||||
|
||||
### Network Connectivity
|
||||
|
||||
Ensure worker nodes can reach the coordinator:
|
||||
|
||||
```bash
|
||||
# From worker node
|
||||
curl http://coordinator:3000/health
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Coordinator Load
|
||||
|
||||
The coordinator handles:
|
||||
- Request routing
|
||||
- Node heartbeats
|
||||
- Session tracking
|
||||
|
||||
For very large clusters (>50 nodes), consider:
|
||||
- Increasing coordinator resources
|
||||
- Tuning heartbeat intervals
|
||||
- Using header-based sticky sessions (lower overhead)
|
||||
|
||||
### Network Latency
|
||||
|
||||
Minimize latency between coordinator and nodes:
|
||||
- Deploy in the same datacenter/region
|
||||
- Use low-latency networking
|
||||
- Consider coordinator placement near clients
|
||||
|
||||
### Session Timeout
|
||||
|
||||
Balance session timeout with resource usage:
|
||||
- Shorter timeout: Less memory, more re-routing
|
||||
- Longer timeout: Better stickiness, more memory
|
||||
|
||||
Default is 1 hour, adjust based on your use case.
|
||||
|
||||
## Limitations
|
||||
|
||||
1. **Stateful Sessions**: Node-local state is lost if a node fails. Use external storage for persistent state.
|
||||
2. **Single Coordinator**: Currently supports one coordinator. Consider load balancing at the infrastructure level.
|
||||
3. **Network Partitions**: Nodes that lose connection to coordinator will be marked unhealthy.
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use Groups**: Organize MCP servers into groups for easier management
|
||||
2. **Monitor Health**: Set up alerts for unhealthy nodes
|
||||
3. **Version Consistency**: Run the same MCPHub version across all nodes
|
||||
4. **Resource Planning**: Allocate appropriate resources based on MCP server requirements
|
||||
5. **Backup Configuration**: Keep coordinator config backed up
|
||||
6. **Gradual Rollout**: Test cluster configuration with a small number of nodes first
|
||||
|
||||
## See Also
|
||||
|
||||
- [Docker Deployment](../deployment/docker.md)
|
||||
- [Kubernetes Deployment](../deployment/kubernetes.md)
|
||||
- [High Availability Setup](../deployment/high-availability.md)
|
||||
510
docs/cluster-deployment.zh.md
Normal file
510
docs/cluster-deployment.zh.md
Normal file
@@ -0,0 +1,510 @@
|
||||
# 集群部署指南
|
||||
|
||||
MCPHub 支持集群部署,允许多个节点协同工作组成一个统一的系统。这提供了:
|
||||
|
||||
- **高可用性**:将 MCP 服务器分布在多个节点上实现冗余
|
||||
- **负载分配**:在同一 MCP 服务器的多个副本之间平衡请求
|
||||
- **会话粘性**:确保客户端会话一致性地路由到同一节点
|
||||
- **集中管理**:一个协调器管理整个集群
|
||||
|
||||
## 架构
|
||||
|
||||
MCPHub 集群有三种运行模式:
|
||||
|
||||
1. **独立模式**(默认):单节点运行,无集群功能
|
||||
2. **协调器模式**:管理集群、路由请求、维护会话亲和性的中心节点
|
||||
3. **节点模式**:向协调器注册并运行 MCP 服务器的工作节点
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 协调器节点 │
|
||||
│ - 管理集群状态 │
|
||||
│ - 路由客户端请求 │
|
||||
│ - 维护会话亲和性 │
|
||||
│ - 健康监控 │
|
||||
└───────────┬─────────────────────────────┘
|
||||
│
|
||||
┌───────┴───────────────────┐
|
||||
│ │
|
||||
┌───▼────────┐ ┌────────▼────┐
|
||||
│ 节点 1 │ │ 节点 2 │
|
||||
│ - MCP A │ │ - MCP A │
|
||||
│ - MCP B │ │ - MCP C │
|
||||
└────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
### 协调器配置
|
||||
|
||||
在协调器节点上创建或更新 `mcp_settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
// 可选:协调器也可以运行 MCP 服务器
|
||||
"example": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "example-mcp-server"]
|
||||
}
|
||||
},
|
||||
"systemConfig": {
|
||||
"cluster": {
|
||||
"enabled": true,
|
||||
"mode": "coordinator",
|
||||
"coordinator": {
|
||||
"nodeTimeout": 15000,
|
||||
"cleanupInterval": 30000,
|
||||
"stickySessionTimeout": 3600000
|
||||
},
|
||||
"stickySession": {
|
||||
"enabled": true,
|
||||
"strategy": "consistent-hash",
|
||||
"cookieName": "MCPHUB_NODE",
|
||||
"headerName": "X-MCPHub-Node"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**配置选项:**
|
||||
|
||||
- `nodeTimeout`: 将节点标记为不健康之前的时间(毫秒)(默认:15000)
|
||||
- `cleanupInterval`: 清理不活跃节点的间隔(毫秒)(默认:30000)
|
||||
- `stickySessionTimeout`: 会话亲和性超时(毫秒)(默认:3600000 - 1小时)
|
||||
- `stickySession.enabled`: 启用会话粘性路由(默认:true)
|
||||
- `stickySession.strategy`: 会话亲和性策略:
|
||||
- `consistent-hash`: 基于哈希的路由(默认)
|
||||
- `cookie`: 基于 Cookie 的路由
|
||||
- `header`: 基于请求头的路由
|
||||
|
||||
### 节点配置
|
||||
|
||||
在每个工作节点上创建或更新 `mcp_settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"amap": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@amap/amap-maps-mcp-server"]
|
||||
},
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--headless"]
|
||||
}
|
||||
},
|
||||
"systemConfig": {
|
||||
"cluster": {
|
||||
"enabled": true,
|
||||
"mode": "node",
|
||||
"node": {
|
||||
"id": "node-1",
|
||||
"name": "工作节点 1",
|
||||
"coordinatorUrl": "http://coordinator:3000",
|
||||
"heartbeatInterval": 5000,
|
||||
"registerOnStartup": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**配置选项:**
|
||||
|
||||
- `node.id`: 唯一节点标识符(如未提供则自动生成)
|
||||
- `node.name`: 人类可读的节点名称(默认为主机名)
|
||||
- `node.coordinatorUrl`: 协调器节点的 URL(必需)
|
||||
- `node.heartbeatInterval`: 心跳间隔(毫秒)(默认:5000)
|
||||
- `node.registerOnStartup`: 启动时自动注册(默认:true)
|
||||
|
||||
## 部署场景
|
||||
|
||||
### 场景 1:Docker Compose
|
||||
|
||||
创建 `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
coordinator:
|
||||
image: samanhappy/mcphub:latest
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./coordinator-config.json:/app/mcp_settings.json
|
||||
- coordinator-data:/app/data
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
|
||||
node1:
|
||||
image: samanhappy/mcphub:latest
|
||||
volumes:
|
||||
- ./node1-config.json:/app/mcp_settings.json
|
||||
- node1-data:/app/data
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
depends_on:
|
||||
- coordinator
|
||||
|
||||
node2:
|
||||
image: samanhappy/mcphub:latest
|
||||
volumes:
|
||||
- ./node2-config.json:/app/mcp_settings.json
|
||||
- node2-data:/app/data
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
depends_on:
|
||||
- coordinator
|
||||
|
||||
volumes:
|
||||
coordinator-data:
|
||||
node1-data:
|
||||
node2-data:
|
||||
```
|
||||
|
||||
启动集群:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### 场景 2:Kubernetes
|
||||
|
||||
创建 Kubernetes 清单:
|
||||
|
||||
**协调器部署:**
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: mcphub-coordinator
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: mcphub-coordinator
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: mcphub-coordinator
|
||||
spec:
|
||||
containers:
|
||||
- name: mcphub
|
||||
image: samanhappy/mcphub:latest
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /app/mcp_settings.json
|
||||
subPath: mcp_settings.json
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: mcphub-coordinator-config
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: mcphub-coordinator
|
||||
spec:
|
||||
selector:
|
||||
app: mcphub-coordinator
|
||||
ports:
|
||||
- port: 3000
|
||||
targetPort: 3000
|
||||
type: LoadBalancer
|
||||
```
|
||||
|
||||
**工作节点部署:**
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: mcphub-node
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: mcphub-node
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: mcphub-node
|
||||
spec:
|
||||
containers:
|
||||
- name: mcphub
|
||||
image: samanhappy/mcphub:latest
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /app/mcp_settings.json
|
||||
subPath: mcp_settings.json
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: mcphub-node-config
|
||||
```
|
||||
|
||||
应用清单:
|
||||
|
||||
```bash
|
||||
kubectl apply -f coordinator.yaml
|
||||
kubectl apply -f nodes.yaml
|
||||
```
|
||||
|
||||
### 场景 3:手动部署
|
||||
|
||||
**在协调器上(192.168.1.100):**
|
||||
|
||||
```bash
|
||||
# 安装 MCPHub
|
||||
npm install -g @samanhappy/mcphub
|
||||
|
||||
# 配置为协调器
|
||||
cat > mcp_settings.json <<EOF
|
||||
{
|
||||
"systemConfig": {
|
||||
"cluster": {
|
||||
"enabled": true,
|
||||
"mode": "coordinator"
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# 启动协调器
|
||||
PORT=3000 mcphub
|
||||
```
|
||||
|
||||
**在节点 1 上(192.168.1.101):**
|
||||
|
||||
```bash
|
||||
# 安装 MCPHub
|
||||
npm install -g @samanhappy/mcphub
|
||||
|
||||
# 配置为节点
|
||||
cat > mcp_settings.json <<EOF
|
||||
{
|
||||
"mcpServers": {
|
||||
"server1": { "command": "..." }
|
||||
},
|
||||
"systemConfig": {
|
||||
"cluster": {
|
||||
"enabled": true,
|
||||
"mode": "node",
|
||||
"node": {
|
||||
"coordinatorUrl": "http://192.168.1.100:3000"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# 启动节点
|
||||
PORT=3001 mcphub
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 访问集群
|
||||
|
||||
集群运行后,将 AI 客户端连接到协调器的端点:
|
||||
|
||||
```
|
||||
http://coordinator:3000/mcp
|
||||
http://coordinator:3000/sse
|
||||
```
|
||||
|
||||
协调器将:
|
||||
1. 根据会话亲和性将请求路由到适当的节点
|
||||
2. 在同一服务器的多个副本之间进行负载均衡
|
||||
3. 自动故障转移到健康的节点
|
||||
|
||||
### 会话粘性
|
||||
|
||||
会话粘性确保客户端的请求在整个会话期间路由到同一节点。这对于以下场景很重要:
|
||||
|
||||
- 维护对话上下文
|
||||
- 保持临时状态
|
||||
- 一致的工具执行
|
||||
|
||||
默认策略是 **consistent-hash**,使用会话 ID 来确定目标节点。替代策略:
|
||||
|
||||
- **Cookie-based**: 使用 `MCPHUB_NODE` cookie
|
||||
- **Header-based**: 使用 `X-MCPHub-Node` 请求头
|
||||
|
||||
### 多副本
|
||||
|
||||
您可以在多个节点上部署相同的 MCP 服务器以实现:
|
||||
|
||||
- **负载均衡**:在副本之间分配请求
|
||||
- **高可用性**:如果一个节点宕机则故障转移
|
||||
|
||||
配置示例:
|
||||
|
||||
**节点 1:**
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**节点 2:**
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
协调器将自动在两个节点之间对 `playwright` 的请求进行负载均衡。
|
||||
|
||||
## 管理 API
|
||||
|
||||
协调器公开集群管理端点:
|
||||
|
||||
### 获取集群状态
|
||||
|
||||
```bash
|
||||
curl http://coordinator:3000/api/cluster/status
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"enabled": true,
|
||||
"mode": "coordinator",
|
||||
"nodeId": "coordinator",
|
||||
"stats": {
|
||||
"nodes": 3,
|
||||
"activeNodes": 3,
|
||||
"servers": 5,
|
||||
"sessions": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 获取所有节点
|
||||
|
||||
```bash
|
||||
curl http://coordinator:3000/api/cluster/nodes
|
||||
```
|
||||
|
||||
### 获取服务器副本
|
||||
|
||||
```bash
|
||||
curl http://coordinator:3000/api/cluster/servers/playwright/replicas
|
||||
```
|
||||
|
||||
### 获取会话亲和性
|
||||
|
||||
```bash
|
||||
curl http://coordinator:3000/api/cluster/sessions/{sessionId}
|
||||
```
|
||||
|
||||
## 监控和故障排除
|
||||
|
||||
### 检查节点健康
|
||||
|
||||
监控协调器日志以查看心跳消息:
|
||||
|
||||
```
|
||||
Node registered: Worker Node 1 (node-1) with 2 servers
|
||||
```
|
||||
|
||||
如果节点变得不健康:
|
||||
|
||||
```
|
||||
Marking node node-1 as unhealthy (last heartbeat: 2024-01-01T10:00:00.000Z)
|
||||
```
|
||||
|
||||
### 验证注册
|
||||
|
||||
检查节点是否已注册:
|
||||
|
||||
```bash
|
||||
curl http://coordinator:3000/api/cluster/nodes?active=true
|
||||
```
|
||||
|
||||
### 会话亲和性问题
|
||||
|
||||
如果会话没有粘性到同一节点:
|
||||
|
||||
1. 验证协调器配置中是否启用了会话粘性
|
||||
2. 检查会话 ID 是否正确传递
|
||||
3. 查看协调器日志以查找会话亲和性错误
|
||||
|
||||
### 网络连接
|
||||
|
||||
确保工作节点可以访问协调器:
|
||||
|
||||
```bash
|
||||
# 从工作节点
|
||||
curl http://coordinator:3000/health
|
||||
```
|
||||
|
||||
## 性能考虑
|
||||
|
||||
### 协调器负载
|
||||
|
||||
协调器处理:
|
||||
- 请求路由
|
||||
- 节点心跳
|
||||
- 会话跟踪
|
||||
|
||||
对于非常大的集群(>50个节点),考虑:
|
||||
- 增加协调器资源
|
||||
- 调整心跳间隔
|
||||
- 使用基于请求头的会话粘性(开销更低)
|
||||
|
||||
### 网络延迟
|
||||
|
||||
最小化协调器和节点之间的延迟:
|
||||
- 在同一数据中心/地区部署
|
||||
- 使用低延迟网络
|
||||
- 考虑协调器放置在接近客户端的位置
|
||||
|
||||
### 会话超时
|
||||
|
||||
平衡会话超时与资源使用:
|
||||
- 较短超时:更少内存,更多重新路由
|
||||
- 较长超时:更好的粘性,更多内存
|
||||
|
||||
默认为 1 小时,根据您的用例进行调整。
|
||||
|
||||
## 限制
|
||||
|
||||
1. **有状态会话**:如果节点失败,节点本地状态会丢失。使用外部存储实现持久状态。
|
||||
2. **单协调器**:当前支持一个协调器。考虑在基础设施级别进行负载均衡。
|
||||
3. **网络分区**:失去与协调器连接的节点将被标记为不健康。
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **使用分组**:将 MCP 服务器组织到分组中以便更容易管理
|
||||
2. **监控健康**:为不健康的节点设置告警
|
||||
3. **版本一致性**:在所有节点上运行相同的 MCPHub 版本
|
||||
4. **资源规划**:根据 MCP 服务器要求分配适当的资源
|
||||
5. **备份配置**:保持协调器配置的备份
|
||||
6. **逐步推出**:首先使用少量节点测试集群配置
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [Docker 部署](../deployment/docker.md)
|
||||
- [Kubernetes 部署](../deployment/kubernetes.md)
|
||||
- [高可用性设置](../deployment/high-availability.md)
|
||||
@@ -27,7 +27,8 @@
|
||||
"pages": [
|
||||
"features/server-management",
|
||||
"features/group-management",
|
||||
"features/smart-routing"
|
||||
"features/smart-routing",
|
||||
"features/oauth"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -57,7 +58,8 @@
|
||||
"pages": [
|
||||
"zh/features/server-management",
|
||||
"zh/features/group-management",
|
||||
"zh/features/smart-routing"
|
||||
"zh/features/smart-routing",
|
||||
"zh/features/oauth"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -159,4 +161,4 @@
|
||||
"discord": "https://discord.gg/qMKNsn5Q"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
267
docs/environment-variables.md
Normal file
267
docs/environment-variables.md
Normal file
@@ -0,0 +1,267 @@
|
||||
# Environment Variable Expansion in mcp_settings.json
|
||||
|
||||
## Overview
|
||||
|
||||
MCPHub now supports comprehensive environment variable expansion throughout the entire `mcp_settings.json` configuration file. This allows you to externalize sensitive information and configuration values, making your setup more secure and flexible.
|
||||
|
||||
## Supported Formats
|
||||
|
||||
MCPHub supports two environment variable formats:
|
||||
|
||||
1. **${VAR}** - Standard format (recommended)
|
||||
2. **$VAR** - Unix-style format (variable name must start with an uppercase letter or underscore, followed by uppercase letters, numbers, or underscores)
|
||||
|
||||
## What Can Be Expanded
|
||||
|
||||
Environment variables can now be used in **ANY** string value throughout your configuration:
|
||||
|
||||
- Server URLs
|
||||
- Commands and arguments
|
||||
- Headers
|
||||
- Environment variables passed to child processes
|
||||
- OpenAPI specifications and security configurations
|
||||
- OAuth credentials
|
||||
- System configuration values
|
||||
- Any other string fields
|
||||
|
||||
## Examples
|
||||
|
||||
### 1. SSE/HTTP Server Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-api-server": {
|
||||
"type": "sse",
|
||||
"url": "${MCP_SERVER_URL}",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${API_TOKEN}",
|
||||
"X-Custom-Header": "${CUSTOM_VALUE}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
```bash
|
||||
export MCP_SERVER_URL="https://api.example.com/mcp"
|
||||
export API_TOKEN="secret-token-123"
|
||||
export CUSTOM_VALUE="my-custom-value"
|
||||
```
|
||||
|
||||
### 2. Stdio Server Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-python-server": {
|
||||
"type": "stdio",
|
||||
"command": "${PYTHON_PATH}",
|
||||
"args": ["-m", "${MODULE_NAME}", "--api-key", "${API_KEY}"],
|
||||
"env": {
|
||||
"DATABASE_URL": "${DATABASE_URL}",
|
||||
"DEBUG": "${DEBUG_MODE}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
```bash
|
||||
export PYTHON_PATH="/usr/bin/python3"
|
||||
export MODULE_NAME="my_mcp_server"
|
||||
export API_KEY="secret-api-key"
|
||||
export DATABASE_URL="postgresql://localhost/mydb"
|
||||
export DEBUG_MODE="true"
|
||||
```
|
||||
|
||||
### 3. OpenAPI Server Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"openapi-service": {
|
||||
"type": "openapi",
|
||||
"openapi": {
|
||||
"url": "${OPENAPI_SPEC_URL}",
|
||||
"security": {
|
||||
"type": "apiKey",
|
||||
"apiKey": {
|
||||
"name": "X-API-Key",
|
||||
"in": "header",
|
||||
"value": "${OPENAPI_API_KEY}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
```bash
|
||||
export OPENAPI_SPEC_URL="https://api.example.com/openapi.json"
|
||||
export OPENAPI_API_KEY="your-api-key-here"
|
||||
```
|
||||
|
||||
### 4. OAuth Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"oauth-server": {
|
||||
"type": "sse",
|
||||
"url": "${OAUTH_SERVER_URL}",
|
||||
"oauth": {
|
||||
"clientId": "${OAUTH_CLIENT_ID}",
|
||||
"clientSecret": "${OAUTH_CLIENT_SECRET}",
|
||||
"accessToken": "${OAUTH_ACCESS_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
```bash
|
||||
export OAUTH_SERVER_URL="https://oauth.example.com/mcp"
|
||||
export OAUTH_CLIENT_ID="my-client-id"
|
||||
export OAUTH_CLIENT_SECRET="my-client-secret"
|
||||
export OAUTH_ACCESS_TOKEN="my-access-token"
|
||||
```
|
||||
|
||||
### 5. System Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"systemConfig": {
|
||||
"install": {
|
||||
"pythonIndexUrl": "${PYTHON_INDEX_URL}",
|
||||
"npmRegistry": "${NPM_REGISTRY}"
|
||||
},
|
||||
"mcpRouter": {
|
||||
"apiKey": "${MCPROUTER_API_KEY}",
|
||||
"referer": "${MCPROUTER_REFERER}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
```bash
|
||||
export PYTHON_INDEX_URL="https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||
export NPM_REGISTRY="https://registry.npmmirror.com"
|
||||
export MCPROUTER_API_KEY="router-api-key"
|
||||
export MCPROUTER_REFERER="https://myapp.com"
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
See [examples/mcp_settings_with_env_vars.json](../examples/mcp_settings_with_env_vars.json) for a comprehensive example configuration using environment variables.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Security
|
||||
|
||||
1. **Never commit sensitive values to version control** - Use environment variables for all secrets
|
||||
2. **Use .env files for local development** - MCPHub automatically loads `.env` files
|
||||
3. **Use secure secret management in production** - Consider using Docker secrets, Kubernetes secrets, or cloud provider secret managers
|
||||
|
||||
### Organization
|
||||
|
||||
1. **Group related variables** - Use prefixes for related configuration (e.g., `API_`, `DB_`, `OAUTH_`)
|
||||
2. **Document required variables** - Maintain a list of required environment variables in your README
|
||||
3. **Provide example .env file** - Create a `.env.example` file with placeholder values
|
||||
|
||||
### Example .env File
|
||||
|
||||
```bash
|
||||
# Server Configuration
|
||||
MCP_SERVER_URL=https://api.example.com/mcp
|
||||
API_TOKEN=your-api-token-here
|
||||
|
||||
# Python Server
|
||||
PYTHON_PATH=/usr/bin/python3
|
||||
MODULE_NAME=my_mcp_server
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://localhost/mydb
|
||||
|
||||
# OpenAPI
|
||||
OPENAPI_SPEC_URL=https://api.example.com/openapi.json
|
||||
OPENAPI_API_KEY=your-openapi-key
|
||||
|
||||
# OAuth
|
||||
OAUTH_CLIENT_ID=your-client-id
|
||||
OAUTH_CLIENT_SECRET=your-client-secret
|
||||
OAUTH_ACCESS_TOKEN=your-access-token
|
||||
```
|
||||
|
||||
## Docker Usage
|
||||
|
||||
When using Docker, pass environment variables using `-e` flag or `--env-file`:
|
||||
|
||||
```bash
|
||||
# Using individual variables
|
||||
docker run -e API_TOKEN=secret -e SERVER_URL=https://api.example.com mcphub
|
||||
|
||||
# Using env file
|
||||
docker run --env-file .env mcphub
|
||||
```
|
||||
|
||||
Or in docker-compose.yml:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
mcphub:
|
||||
image: mcphub
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- MCP_SERVER_URL=${MCP_SERVER_URL}
|
||||
- API_TOKEN=${API_TOKEN}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Variable Not Expanding
|
||||
|
||||
If a variable is not expanding:
|
||||
|
||||
1. Check that the variable is set: `echo $VAR_NAME`
|
||||
2. Verify the variable name matches exactly (case-sensitive)
|
||||
3. Ensure the variable is exported: `export VAR_NAME=value`
|
||||
4. Restart MCPHub after setting environment variables
|
||||
|
||||
### Empty Values
|
||||
|
||||
If an environment variable is not set, it will be replaced with an empty string. Make sure all required variables are set before starting MCPHub.
|
||||
|
||||
### Nested Variables
|
||||
|
||||
Environment variables in nested objects and arrays are fully supported:
|
||||
|
||||
```json
|
||||
{
|
||||
"nested": {
|
||||
"deep": {
|
||||
"value": "${MY_VAR}"
|
||||
}
|
||||
},
|
||||
"array": ["${VAR1}", "${VAR2}"]
|
||||
}
|
||||
```
|
||||
|
||||
## Migration from Previous Version
|
||||
|
||||
If you were previously using environment variables only in headers, no changes are needed. The new implementation is backward compatible and simply extends support to all configuration fields.
|
||||
|
||||
## Technical Details
|
||||
|
||||
- Environment variables are expanded once when the configuration is loaded
|
||||
- Expansion is recursive and handles nested objects and arrays
|
||||
- Non-string values (booleans, numbers, null) are preserved as-is
|
||||
- Empty string is used when an environment variable is not set
|
||||
141
docs/features/oauth.mdx
Normal file
141
docs/features/oauth.mdx
Normal file
@@ -0,0 +1,141 @@
|
||||
# OAuth Support
|
||||
|
||||
## At a Glance
|
||||
- Covers end-to-end OAuth 2.0 Authorization Code with PKCE for upstream MCP servers.
|
||||
- Supports automatic discovery from `WWW-Authenticate` responses and RFC 8414 metadata.
|
||||
- Implements dynamic client registration (RFC 7591) and resource indicators (RFC 8707).
|
||||
- Persists client credentials and tokens to `mcp_settings.json` for reconnects.
|
||||
|
||||
## When MCPHub Switches to OAuth
|
||||
1. MCPHub calls an MCP server that requires authorization and receives `401 Unauthorized`.
|
||||
2. The response exposes a `WWW-Authenticate` header pointing to protected resource metadata (`authorization_server` or `as_uri`).
|
||||
3. MCPHub discovers the authorization server metadata, registers (if needed), and opens the browser so the user can authorize once.
|
||||
4. After the callback is handled, MCPHub reconnects with fresh tokens and resumes requests transparently.
|
||||
|
||||
> MCPHub logs each stage (discovery, registration, authorization URL, token exchange) in the server detail view and the backend logs.
|
||||
|
||||
## Quick Start by Server Type
|
||||
|
||||
### Servers with Dynamic Registration Support
|
||||
Some servers expose complete OAuth metadata and allow dynamic client registration. For example, Vercel and Linear MCP servers only need their SSE endpoint configured:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"vercel": {
|
||||
"type": "sse",
|
||||
"url": "https://mcp.vercel.com"
|
||||
},
|
||||
"linear": {
|
||||
"type": "sse",
|
||||
"url": "https://mcp.linear.app/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- MCPHub discovers the authorization server, registers the client, and handles PKCE automatically.
|
||||
- Tokens are stored in `mcp_settings.json`; no additional dashboard configuration is needed.
|
||||
|
||||
### Servers Requiring Manual Client Provisioning
|
||||
Other providers do not support dynamic registration. GitHub’s MCP endpoint (`https://api.githubcopilot.com/mcp/`) is one example. To connect:
|
||||
|
||||
1. Create an OAuth App in the provider’s console (for GitHub, go to **Settings → Developer settings → OAuth Apps**).
|
||||
2. Set the callback/redirect URL to `http://localhost:3000/oauth/callback` (or your deployed dashboard domain).
|
||||
3. Copy the issued client ID and client secret.
|
||||
4. Supply the credentials through the MCPHub dashboard or by editing `mcp_settings.json` as shown below.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
"type": "sse",
|
||||
"url": "https://api.githubcopilot.com/mcp/",
|
||||
"oauth": {
|
||||
"clientId": "${GITHUB_OAUTH_APP_ID}",
|
||||
"clientSecret": "${GITHUB_OAUTH_APP_SECRET}",
|
||||
"scopes": ["replace-with-provider-scope"],
|
||||
"resource": "https://api.githubcopilot.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- MCPHub skips dynamic registration and uses the credentials you provide to complete the OAuth exchange.
|
||||
- Update the dashboard or configuration file whenever you rotate secrets.
|
||||
- Replace `scopes` with the exact scope strings required by the provider.
|
||||
|
||||
## Configuration Options
|
||||
You can rely on auto-detection for most servers or declare OAuth settings explicitly in `mcp_settings.json`. Only populate the fields you need.
|
||||
|
||||
### Basic Auto Detection (Minimal Config)
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"secured-sse": {
|
||||
"type": "sse",
|
||||
"url": "https://mcp.example.com/sse",
|
||||
"oauth": {
|
||||
"scopes": ["mcp.tools", "mcp.prompts"],
|
||||
"resource": "https://mcp.example.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- MCPHub will discover the authorization server from challenge headers and walk the user through authorization automatically.
|
||||
- Tokens (including refresh tokens) are stored on disk and reused on restart.
|
||||
|
||||
### Static Client Credentials (Bring Your Own Client)
|
||||
```json
|
||||
{
|
||||
"oauth": {
|
||||
"clientId": "mcphub-client",
|
||||
"clientSecret": "replace-me-if-required",
|
||||
"authorizationEndpoint": "https://auth.example.com/oauth/authorize",
|
||||
"tokenEndpoint": "https://auth.example.com/oauth/token",
|
||||
"redirectUri": "http://localhost:3000/oauth/callback"
|
||||
}
|
||||
}
|
||||
```
|
||||
- Use this when the authorization server requires manual client provisioning.
|
||||
- `redirectUri` defaults to `http://localhost:3000/oauth/callback`; override it when running behind a custom domain.
|
||||
|
||||
### Dynamic Client Registration (RFC 7591)
|
||||
```json
|
||||
{
|
||||
"oauth": {
|
||||
"dynamicRegistration": {
|
||||
"enabled": true,
|
||||
"issuer": "https://auth.example.com",
|
||||
"metadata": {
|
||||
"client_name": "MCPHub",
|
||||
"redirect_uris": [
|
||||
"http://localhost:3000/oauth/callback",
|
||||
"https://mcphub.example.com/oauth/callback"
|
||||
],
|
||||
"scope": "mcp.tools mcp.prompts",
|
||||
"grant_types": ["authorization_code", "refresh_token"]
|
||||
},
|
||||
"initialAccessToken": "optional-token-if-required"
|
||||
},
|
||||
"scopes": ["mcp.tools", "mcp.prompts"],
|
||||
"resource": "https://mcp.example.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
- MCPHub discovers endpoints via `issuer`, registers itself, and persists the issued `client_id`/`client_secret`.
|
||||
- Provide `initialAccessToken` only when the registration endpoint is protected.
|
||||
|
||||
## Authorization Flow
|
||||
1. **Initialization** – On startup MCPHub processes every server entry, discovers metadata, and registers the client if `dynamicRegistration.enabled` is true.
|
||||
2. **User Authorization** – Initiating a connection launches the system browser to the server’s authorize page with PKCE parameters.
|
||||
3. **Callback Handling** – The built-in route (`/oauth/callback`) verifies the `state`, completes the token exchange, and saves the tokens via the MCP SDK.
|
||||
4. **Token Lifecycle** – Access and refresh tokens are cached in memory, refreshed automatically, and written back to `mcp_settings.json`.
|
||||
|
||||
## Tips & Troubleshooting
|
||||
- Confirm that the redirect URI used during authorization exactly matches one of the `redirect_uris` registered with the authorization server.
|
||||
- When running behind HTTPS, expose the callback URL publicly or configure a reverse proxy at `/oauth/callback`.
|
||||
- If discovery fails, supply `authorizationEndpoint` and `tokenEndpoint` explicitly to bypass metadata lookup.
|
||||
- Remove stale tokens from `mcp_settings.json` if an authorization server revokes access—MCPHub will prompt for a fresh login on the next request.
|
||||
@@ -276,17 +276,92 @@ Access Smart Routing through the special `$smart` endpoint:
|
||||
<Tabs>
|
||||
<Tab title="HTTP MCP">
|
||||
```
|
||||
# Search across all servers
|
||||
http://localhost:3000/mcp/$smart
|
||||
|
||||
# Search within a specific group
|
||||
http://localhost:3000/mcp/$smart/{group}
|
||||
```
|
||||
</Tab>
|
||||
|
||||
<Tab title="SSE (Legacy)">
|
||||
```
|
||||
# Search across all servers
|
||||
http://localhost:3000/sse/$smart
|
||||
|
||||
# Search within a specific group
|
||||
http://localhost:3000/sse/$smart/{group}
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Group-Scoped Smart Routing
|
||||
|
||||
Smart Routing now supports group-scoped searches, allowing you to limit tool discovery to servers within a specific group:
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Using Group-Scoped Smart Routing">
|
||||
Connect your AI client to a group-specific Smart Routing endpoint:
|
||||
|
||||
```
|
||||
http://localhost:3000/mcp/$smart/production
|
||||
```
|
||||
|
||||
This endpoint will only search for tools within servers that belong to the "production" group.
|
||||
|
||||
**Benefits:**
|
||||
- **Focused Results**: Only tools from relevant servers are returned
|
||||
- **Better Performance**: Reduced search space for faster queries
|
||||
- **Environment Isolation**: Keep development, staging, and production tools separate
|
||||
- **Access Control**: Limit tool discovery based on user permissions
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Example: Environment-Based Groups">
|
||||
Create groups for different environments:
|
||||
|
||||
```bash
|
||||
# Development environment
|
||||
http://localhost:3000/mcp/$smart/development
|
||||
|
||||
# Staging environment
|
||||
http://localhost:3000/mcp/$smart/staging
|
||||
|
||||
# Production environment
|
||||
http://localhost:3000/mcp/$smart/production
|
||||
```
|
||||
|
||||
Each endpoint will only return tools from servers in that specific environment group.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Example: Team-Based Groups">
|
||||
Organize tools by team or department:
|
||||
|
||||
```bash
|
||||
# Backend team tools
|
||||
http://localhost:3000/mcp/$smart/backend-team
|
||||
|
||||
# Frontend team tools
|
||||
http://localhost:3000/mcp/$smart/frontend-team
|
||||
|
||||
# DevOps team tools
|
||||
http://localhost:3000/mcp/$smart/devops-team
|
||||
```
|
||||
|
||||
This enables teams to have focused access to their relevant toolsets.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="How It Works">
|
||||
When using `$smart/{group}`:
|
||||
|
||||
1. The system identifies the specified group
|
||||
2. Retrieves all servers belonging to that group
|
||||
3. Filters the tool search to only those servers
|
||||
4. Returns results scoped to the group's servers
|
||||
|
||||
If the group doesn't exist or has no servers, the search will return no results.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
{/* ### Basic Usage
|
||||
|
||||
Connect your AI client to the Smart Routing endpoint and make natural language requests:
|
||||
|
||||
141
docs/zh/features/oauth.mdx
Normal file
141
docs/zh/features/oauth.mdx
Normal file
@@ -0,0 +1,141 @@
|
||||
# OAuth 支持
|
||||
|
||||
## 核心亮点
|
||||
- 覆盖上游 MCP 服务器的 OAuth 2.0 授权码(PKCE)全流程。
|
||||
- 支持从 `WWW-Authenticate` 响应和 RFC 8414 元数据自动发现。
|
||||
- 实现动态客户端注册(RFC 7591)以及资源指示(RFC 8707)。
|
||||
- 会将客户端凭据与令牌持久化到 `mcp_settings.json`,重启后直接复用。
|
||||
|
||||
## MCPHub 何时启用 OAuth
|
||||
1. MCPHub 调用需要授权的 MCP 服务器并收到 `401 Unauthorized`。
|
||||
2. 响应通过 `WWW-Authenticate` header 暴露受保护资源的元数据(`authorization_server` 或 `as_uri`)。
|
||||
3. MCPHub 自动发现授权服务器、按需注册客户端,并引导用户完成一次授权。
|
||||
4. 回调处理完成后,MCPHub 使用新令牌重新连接并继续请求。
|
||||
|
||||
> MCPHub 会在服务器详情视图和后端日志中记录发现、注册、授权链接、换取令牌等关键步骤。
|
||||
|
||||
## 按服务器类型快速上手
|
||||
|
||||
### 支持动态注册的服务器
|
||||
部分服务器会公开完整的 OAuth 元数据,并允许客户端动态注册。例如 Vercel 与 Linear 的 MCP 服务器只需配置 SSE 地址即可:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"vercel": {
|
||||
"type": "sse",
|
||||
"url": "https://mcp.vercel.com"
|
||||
},
|
||||
"linear": {
|
||||
"type": "sse",
|
||||
"url": "https://mcp.linear.app/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- MCPHub 会自动发现授权服务器、完成注册,并处理整个 PKCE 流程。
|
||||
- 所有凭据与令牌会写入 `mcp_settings.json`,无须在控制台额外配置。
|
||||
|
||||
### 需要手动配置客户端的服务器
|
||||
另有一些服务端并不支持动态注册。GitHub 的 MCP 端点(`https://api.githubcopilot.com/mcp/`)就是典型例子,接入步骤如下:
|
||||
|
||||
1. 在服务提供商控制台创建 OAuth 应用(GitHub 路径为 **Settings → Developer settings → OAuth Apps**)。
|
||||
2. 将回调/重定向地址设置为 `http://localhost:3000/oauth/callback`(或你的部署域名)。
|
||||
3. 复制生成的 Client ID 与 Client Secret。
|
||||
4. 通过 MCPHub 控制台或直接编辑 `mcp_settings.json` 写入如下配置。
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
"type": "sse",
|
||||
"url": "https://api.githubcopilot.com/mcp/",
|
||||
"oauth": {
|
||||
"clientId": "${GITHUB_OAUTH_APP_ID}",
|
||||
"clientSecret": "${GITHUB_OAUTH_APP_SECRET}",
|
||||
"scopes": ["replace-with-provider-scope"],
|
||||
"resource": "https://api.githubcopilot.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- MCPHub 会跳过动态注册,直接使用你提供的凭据完成授权流程。
|
||||
- 凭据轮换时需要同步更新控制台或配置文件。
|
||||
- 将 `scopes` 替换为服务端要求的具体 Scope。
|
||||
|
||||
## 配置方式
|
||||
大多数场景可依赖自动检测,也可以在 `mcp_settings.json` 中显式声明 OAuth 配置。只填写确实需要的字段。
|
||||
|
||||
### 自动检测(最小配置)
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"secured-sse": {
|
||||
"type": "sse",
|
||||
"url": "https://mcp.example.com/sse",
|
||||
"oauth": {
|
||||
"scopes": ["mcp.tools", "mcp.prompts"],
|
||||
"resource": "https://mcp.example.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- MCPHub 会根据挑战头自动发现授权服务器,并引导用户完成授权。
|
||||
- 令牌(包含刷新令牌)会写入磁盘,重启后自动复用。
|
||||
|
||||
### 静态客户端凭据(自带 Client)
|
||||
```json
|
||||
{
|
||||
"oauth": {
|
||||
"clientId": "mcphub-client",
|
||||
"clientSecret": "replace-me-if-required",
|
||||
"authorizationEndpoint": "https://auth.example.com/oauth/authorize",
|
||||
"tokenEndpoint": "https://auth.example.com/oauth/token",
|
||||
"redirectUri": "http://localhost:3000/oauth/callback"
|
||||
}
|
||||
}
|
||||
```
|
||||
- 适用于需要手动注册客户端的授权服务器。
|
||||
- `redirectUri` 默认是 `http://localhost:3000/oauth/callback`,如在自定义域部署请同步更新。
|
||||
|
||||
### 动态客户端注册(RFC 7591)
|
||||
```json
|
||||
{
|
||||
"oauth": {
|
||||
"dynamicRegistration": {
|
||||
"enabled": true,
|
||||
"issuer": "https://auth.example.com",
|
||||
"metadata": {
|
||||
"client_name": "MCPHub",
|
||||
"redirect_uris": [
|
||||
"http://localhost:3000/oauth/callback",
|
||||
"https://mcphub.example.com/oauth/callback"
|
||||
],
|
||||
"scope": "mcp.tools mcp.prompts",
|
||||
"grant_types": ["authorization_code", "refresh_token"]
|
||||
},
|
||||
"initialAccessToken": "optional-token-if-required"
|
||||
},
|
||||
"scopes": ["mcp.tools", "mcp.prompts"],
|
||||
"resource": "https://mcp.example.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
- MCPHub 会通过 `issuer` 发现端点、完成注册,并持久化下发的 `client_id`/`client_secret`。
|
||||
- 只有当注册端点受保护时才需要提供 `initialAccessToken`。
|
||||
|
||||
## 授权流程
|
||||
1. **初始化**:启动时遍历服务器配置,发现元数据并在启用 `dynamicRegistration` 时注册客户端。
|
||||
2. **用户授权**:建立连接时自动打开系统浏览器,携带 PKCE 参数访问授权页。
|
||||
3. **回调处理**:内置路径 `/oauth/callback` 校验 `state`、完成换取令牌,并通过 MCP SDK 保存结果。
|
||||
4. **令牌生命周期**:访问令牌与刷新令牌会缓存于内存,自动刷新,并写回 `mcp_settings.json`。
|
||||
|
||||
## 提示与排障
|
||||
- 确保授权过程中使用的回调地址与已注册的 `redirect_uris` 完全一致。
|
||||
- 若部署在 HTTPS 域名下,请对外暴露 `/oauth/callback` 或通过反向代理转发。
|
||||
- 如无法完成自动发现,可显式提供 `authorizationEndpoint` 与 `tokenEndpoint`。
|
||||
- 授权服务器吊销令牌后,可手动清理 `mcp_settings.json` 中的旧令牌,MCPHub 会在下一次请求时重新触发授权。
|
||||
@@ -1,5 +1,27 @@
|
||||
#!/bin/bash
|
||||
|
||||
DATA_DIR=${MCP_DATA_DIR:-/app/data}
|
||||
SERVERS_DIR=${MCP_SERVERS_DIR:-$DATA_DIR/servers}
|
||||
NPM_SERVER_DIR=${MCP_NPM_DIR:-$SERVERS_DIR/npm}
|
||||
PYTHON_SERVER_DIR=${MCP_PYTHON_DIR:-$SERVERS_DIR/python}
|
||||
PNPM_HOME=${PNPM_HOME:-$DATA_DIR/pnpm}
|
||||
NPM_CONFIG_PREFIX=${NPM_CONFIG_PREFIX:-$DATA_DIR/npm-global}
|
||||
NPM_CONFIG_CACHE=${NPM_CONFIG_CACHE:-$DATA_DIR/npm-cache}
|
||||
UV_TOOL_DIR=${UV_TOOL_DIR:-$DATA_DIR/uv/tools}
|
||||
UV_CACHE_DIR=${UV_CACHE_DIR:-$DATA_DIR/uv/cache}
|
||||
|
||||
mkdir -p \
|
||||
"$PNPM_HOME" \
|
||||
"$NPM_CONFIG_PREFIX/bin" \
|
||||
"$NPM_CONFIG_PREFIX/lib/node_modules" \
|
||||
"$NPM_CONFIG_CACHE" \
|
||||
"$UV_TOOL_DIR" \
|
||||
"$UV_CACHE_DIR" \
|
||||
"$NPM_SERVER_DIR" \
|
||||
"$PYTHON_SERVER_DIR"
|
||||
|
||||
export PATH="$PNPM_HOME:$NPM_CONFIG_PREFIX/bin:$UV_TOOL_DIR/bin:$PATH"
|
||||
|
||||
NPM_REGISTRY=${NPM_REGISTRY:-https://registry.npmjs.org/}
|
||||
echo "Setting npm registry to ${NPM_REGISTRY}"
|
||||
npm config set registry "$NPM_REGISTRY"
|
||||
|
||||
444
examples/cluster-config-examples.md
Normal file
444
examples/cluster-config-examples.md
Normal file
@@ -0,0 +1,444 @@
|
||||
# Cluster Configuration Examples
|
||||
|
||||
## Coordinator Node Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"fetch": {
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server-fetch"],
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"users": [
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "$2b$10$Vt7krIvjNgyN67LXqly0uOcTpN0LI55cYRbcKC71pUDAP0nJ7RPa.",
|
||||
"isAdmin": true
|
||||
}
|
||||
],
|
||||
"systemConfig": {
|
||||
"cluster": {
|
||||
"enabled": true,
|
||||
"mode": "coordinator",
|
||||
"coordinator": {
|
||||
"nodeTimeout": 15000,
|
||||
"cleanupInterval": 30000,
|
||||
"stickySessionTimeout": 3600000
|
||||
},
|
||||
"stickySession": {
|
||||
"enabled": true,
|
||||
"strategy": "consistent-hash",
|
||||
"cookieName": "MCPHUB_NODE",
|
||||
"headerName": "X-MCPHub-Node"
|
||||
}
|
||||
},
|
||||
"routing": {
|
||||
"enableGlobalRoute": true,
|
||||
"enableGroupNameRoute": true,
|
||||
"enableBearerAuth": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Worker Node 1 Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"amap": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@amap/amap-maps-mcp-server"],
|
||||
"env": {
|
||||
"AMAP_MAPS_API_KEY": "${AMAP_MAPS_API_KEY}"
|
||||
},
|
||||
"enabled": true
|
||||
},
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--headless"],
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"systemConfig": {
|
||||
"cluster": {
|
||||
"enabled": true,
|
||||
"mode": "node",
|
||||
"node": {
|
||||
"id": "node-1",
|
||||
"name": "Worker Node 1",
|
||||
"coordinatorUrl": "http://coordinator:3000",
|
||||
"heartbeatInterval": 5000,
|
||||
"registerOnStartup": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Worker Node 2 Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--headless"],
|
||||
"enabled": true
|
||||
},
|
||||
"slack": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-slack"],
|
||||
"env": {
|
||||
"SLACK_BOT_TOKEN": "${SLACK_BOT_TOKEN}",
|
||||
"SLACK_TEAM_ID": "${SLACK_TEAM_ID}"
|
||||
},
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"systemConfig": {
|
||||
"cluster": {
|
||||
"enabled": true,
|
||||
"mode": "node",
|
||||
"node": {
|
||||
"id": "node-2",
|
||||
"name": "Worker Node 2",
|
||||
"coordinatorUrl": "http://coordinator:3000",
|
||||
"heartbeatInterval": 5000,
|
||||
"registerOnStartup": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Docker Compose Example
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
coordinator:
|
||||
image: samanhappy/mcphub:latest
|
||||
container_name: mcphub-coordinator
|
||||
hostname: coordinator
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./examples/coordinator-config.json:/app/mcp_settings.json
|
||||
- coordinator-data:/app/data
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
networks:
|
||||
- mcphub-cluster
|
||||
restart: unless-stopped
|
||||
|
||||
node1:
|
||||
image: samanhappy/mcphub:latest
|
||||
container_name: mcphub-node1
|
||||
hostname: node1
|
||||
volumes:
|
||||
- ./examples/node1-config.json:/app/mcp_settings.json
|
||||
- node1-data:/app/data
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3001
|
||||
- AMAP_MAPS_API_KEY=${AMAP_MAPS_API_KEY}
|
||||
networks:
|
||||
- mcphub-cluster
|
||||
depends_on:
|
||||
- coordinator
|
||||
restart: unless-stopped
|
||||
|
||||
node2:
|
||||
image: samanhappy/mcphub:latest
|
||||
container_name: mcphub-node2
|
||||
hostname: node2
|
||||
volumes:
|
||||
- ./examples/node2-config.json:/app/mcp_settings.json
|
||||
- node2-data:/app/data
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3002
|
||||
- SLACK_BOT_TOKEN=${SLACK_BOT_TOKEN}
|
||||
- SLACK_TEAM_ID=${SLACK_TEAM_ID}
|
||||
networks:
|
||||
- mcphub-cluster
|
||||
depends_on:
|
||||
- coordinator
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
mcphub-cluster:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
coordinator-data:
|
||||
node1-data:
|
||||
node2-data:
|
||||
```
|
||||
|
||||
## Kubernetes Example
|
||||
|
||||
### ConfigMaps
|
||||
|
||||
**coordinator-config.yaml:**
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: mcphub-coordinator-config
|
||||
namespace: mcphub
|
||||
data:
|
||||
mcp_settings.json: |
|
||||
{
|
||||
"mcpServers": {
|
||||
"fetch": {
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server-fetch"],
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"users": [
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "$2b$10$Vt7krIvjNgyN67LXqly0uOcTpN0LI55cYRbcKC71pUDAP0nJ7RPa.",
|
||||
"isAdmin": true
|
||||
}
|
||||
],
|
||||
"systemConfig": {
|
||||
"cluster": {
|
||||
"enabled": true,
|
||||
"mode": "coordinator",
|
||||
"coordinator": {
|
||||
"nodeTimeout": 15000,
|
||||
"cleanupInterval": 30000,
|
||||
"stickySessionTimeout": 3600000
|
||||
},
|
||||
"stickySession": {
|
||||
"enabled": true,
|
||||
"strategy": "consistent-hash"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**node-config.yaml:**
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: mcphub-node-config
|
||||
namespace: mcphub
|
||||
data:
|
||||
mcp_settings.json: |
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--headless"],
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"systemConfig": {
|
||||
"cluster": {
|
||||
"enabled": true,
|
||||
"mode": "node",
|
||||
"node": {
|
||||
"coordinatorUrl": "http://mcphub-coordinator:3000",
|
||||
"heartbeatInterval": 5000,
|
||||
"registerOnStartup": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Deployments
|
||||
|
||||
**coordinator.yaml:**
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: mcphub-coordinator
|
||||
namespace: mcphub
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: mcphub-coordinator
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: mcphub-coordinator
|
||||
spec:
|
||||
containers:
|
||||
- name: mcphub
|
||||
image: samanhappy/mcphub:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
name: http
|
||||
env:
|
||||
- name: NODE_ENV
|
||||
value: production
|
||||
- name: PORT
|
||||
value: "3000"
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /app/mcp_settings.json
|
||||
subPath: mcp_settings.json
|
||||
- name: data
|
||||
mountPath: /app/data
|
||||
resources:
|
||||
requests:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
limits:
|
||||
memory: "1Gi"
|
||||
cpu: "1000m"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 3000
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 3000
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: mcphub-coordinator-config
|
||||
- name: data
|
||||
emptyDir: {}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: mcphub-coordinator
|
||||
namespace: mcphub
|
||||
spec:
|
||||
selector:
|
||||
app: mcphub-coordinator
|
||||
ports:
|
||||
- port: 3000
|
||||
targetPort: 3000
|
||||
name: http
|
||||
type: LoadBalancer
|
||||
```
|
||||
|
||||
**nodes.yaml:**
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: mcphub-node
|
||||
namespace: mcphub
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: mcphub-node
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: mcphub-node
|
||||
spec:
|
||||
containers:
|
||||
- name: mcphub
|
||||
image: samanhappy/mcphub:latest
|
||||
imagePullPolicy: Always
|
||||
env:
|
||||
- name: NODE_ENV
|
||||
value: production
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /app/mcp_settings.json
|
||||
subPath: mcp_settings.json
|
||||
- name: data
|
||||
mountPath: /app/data
|
||||
resources:
|
||||
requests:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
limits:
|
||||
memory: "2Gi"
|
||||
cpu: "2000m"
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: mcphub-node-config
|
||||
- name: data
|
||||
emptyDir: {}
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Create a `.env` file for sensitive values:
|
||||
|
||||
```bash
|
||||
# API Keys
|
||||
AMAP_MAPS_API_KEY=your-amap-api-key
|
||||
SLACK_BOT_TOKEN=xoxb-your-slack-bot-token
|
||||
SLACK_TEAM_ID=T01234567
|
||||
|
||||
# Optional: Custom ports
|
||||
COORDINATOR_PORT=3000
|
||||
NODE1_PORT=3001
|
||||
NODE2_PORT=3002
|
||||
```
|
||||
|
||||
## Testing the Cluster
|
||||
|
||||
After starting the cluster, test connectivity:
|
||||
|
||||
```bash
|
||||
# Check coordinator health
|
||||
curl http://localhost:3000/health
|
||||
|
||||
# Get cluster status
|
||||
curl http://localhost:3000/api/cluster/status
|
||||
|
||||
# List all nodes
|
||||
curl http://localhost:3000/api/cluster/nodes
|
||||
|
||||
# Test MCP endpoint
|
||||
curl -X POST http://localhost:3000/mcp \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {
|
||||
"name": "test-client",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
},
|
||||
"id": 1
|
||||
}'
|
||||
```
|
||||
|
||||
## Scaling
|
||||
|
||||
### Scale worker nodes (Docker Compose):
|
||||
|
||||
```bash
|
||||
docker-compose up -d --scale node1=3
|
||||
```
|
||||
|
||||
### Scale worker nodes (Kubernetes):
|
||||
|
||||
```bash
|
||||
kubectl scale deployment mcphub-node --replicas=5 -n mcphub
|
||||
```
|
||||
80
examples/mcp_settings_with_env_vars.json
Normal file
80
examples/mcp_settings_with_env_vars.json
Normal file
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"example-sse-server": {
|
||||
"type": "sse",
|
||||
"url": "${MCP_SERVER_URL}",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${API_TOKEN}",
|
||||
"X-Custom-Header": "${CUSTOM_HEADER_VALUE}"
|
||||
},
|
||||
"enabled": true
|
||||
},
|
||||
"example-streamable-http": {
|
||||
"type": "streamable-http",
|
||||
"url": "https://${SERVER_HOST}/mcp",
|
||||
"headers": {
|
||||
"API-Key": "${API_KEY}"
|
||||
}
|
||||
},
|
||||
"example-stdio-server": {
|
||||
"type": "stdio",
|
||||
"command": "${PYTHON_PATH}",
|
||||
"args": [
|
||||
"-m",
|
||||
"${MODULE_NAME}",
|
||||
"--config",
|
||||
"${CONFIG_PATH}"
|
||||
],
|
||||
"env": {
|
||||
"API_KEY": "${MY_API_KEY}",
|
||||
"DEBUG": "${DEBUG_MODE}",
|
||||
"DATABASE_URL": "${DATABASE_URL}"
|
||||
}
|
||||
},
|
||||
"example-openapi-server": {
|
||||
"type": "openapi",
|
||||
"openapi": {
|
||||
"url": "${OPENAPI_SPEC_URL}",
|
||||
"security": {
|
||||
"type": "apiKey",
|
||||
"apiKey": {
|
||||
"name": "X-API-Key",
|
||||
"in": "header",
|
||||
"value": "${OPENAPI_API_KEY}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"headers": {
|
||||
"User-Agent": "MCPHub/${VERSION}"
|
||||
}
|
||||
},
|
||||
"example-oauth-server": {
|
||||
"type": "sse",
|
||||
"url": "${OAUTH_SERVER_URL}",
|
||||
"oauth": {
|
||||
"clientId": "${OAUTH_CLIENT_ID}",
|
||||
"clientSecret": "${OAUTH_CLIENT_SECRET}",
|
||||
"accessToken": "${OAUTH_ACCESS_TOKEN}",
|
||||
"scopes": ["read", "write"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"users": [
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "${ADMIN_PASSWORD_HASH}",
|
||||
"isAdmin": true
|
||||
}
|
||||
],
|
||||
"systemConfig": {
|
||||
"install": {
|
||||
"pythonIndexUrl": "${PYTHON_INDEX_URL}",
|
||||
"npmRegistry": "${NPM_REGISTRY}"
|
||||
},
|
||||
"mcpRouter": {
|
||||
"apiKey": "${MCPROUTER_API_KEY}",
|
||||
"referer": "${MCPROUTER_REFERER}",
|
||||
"baseUrl": "${MCPROUTER_BASE_URL}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChangePasswordCredentials } from '../types';
|
||||
import { changePassword } from '../services/authService';
|
||||
import { validatePasswordStrength } from '../utils/passwordValidation';
|
||||
|
||||
interface ChangePasswordFormProps {
|
||||
onSuccess?: () => void;
|
||||
@@ -18,6 +19,7 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [passwordErrors, setPasswordErrors] = useState<string[]>([]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
@@ -25,6 +27,12 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
setConfirmPassword(value);
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
|
||||
// Validate password strength on change for new password
|
||||
if (name === 'newPassword') {
|
||||
const validation = validatePasswordStrength(value);
|
||||
setPasswordErrors(validation.errors);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -32,6 +40,14 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
// Validate password strength
|
||||
const validation = validatePasswordStrength(formData.newPassword);
|
||||
if (!validation.isValid) {
|
||||
setError(t('auth.passwordStrengthError'));
|
||||
setPasswordErrors(validation.errors);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate passwords match
|
||||
if (formData.newPassword !== confirmPassword) {
|
||||
setError(t('auth.passwordsNotMatch'));
|
||||
@@ -100,8 +116,24 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
value={formData.newPassword}
|
||||
onChange={handleChange}
|
||||
required
|
||||
minLength={6}
|
||||
minLength={8}
|
||||
/>
|
||||
{/* Password strength hints */}
|
||||
{formData.newPassword && passwordErrors.length > 0 && (
|
||||
<div className="mt-2 text-sm text-gray-600">
|
||||
<p className="font-semibold mb-1">{t('auth.passwordStrengthHint')}</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
{passwordErrors.map((errorKey) => (
|
||||
<li key={errorKey} className="text-red-600">
|
||||
{t(`auth.${errorKey}`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{formData.newPassword && passwordErrors.length === 0 && (
|
||||
<p className="mt-2 text-sm text-green-600">✓ {t('auth.passwordStrengthHint')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
@@ -116,7 +148,7 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
value={confirmPassword}
|
||||
onChange={handleChange}
|
||||
required
|
||||
minLength={6}
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
311
frontend/src/components/JSONImportForm.tsx
Normal file
311
frontend/src/components/JSONImportForm.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { apiPost } from '@/utils/fetchInterceptor';
|
||||
|
||||
interface JSONImportFormProps {
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
interface McpServerConfig {
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
type?: string;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ImportJsonFormat {
|
||||
mcpServers: Record<string, McpServerConfig>;
|
||||
}
|
||||
|
||||
const JSONImportForm: React.FC<JSONImportFormProps> = ({ onSuccess, onCancel }) => {
|
||||
const { t } = useTranslation();
|
||||
const [jsonInput, setJsonInput] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [previewServers, setPreviewServers] = useState<Array<{ name: string; config: any }> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const examplePlaceholder = `STDIO example:
|
||||
{
|
||||
"mcpServers": {
|
||||
"stdio-server-example": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "mcp-server-example"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SSE example:
|
||||
{
|
||||
"mcpServers": {
|
||||
"sse-server-example": {
|
||||
"type": "sse",
|
||||
"url": "http://localhost:3000"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HTTP example:
|
||||
{
|
||||
"mcpServers": {
|
||||
"http-server-example": {
|
||||
"type": "streamable-http",
|
||||
"url": "http://localhost:3001",
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer your-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const parseAndValidateJson = (input: string): ImportJsonFormat | null => {
|
||||
try {
|
||||
const parsed = JSON.parse(input.trim());
|
||||
|
||||
// Validate structure
|
||||
if (!parsed.mcpServers || typeof parsed.mcpServers !== 'object') {
|
||||
setError(t('jsonImport.invalidFormat'));
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed as ImportJsonFormat;
|
||||
} catch (e) {
|
||||
setError(t('jsonImport.parseError'));
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreview = () => {
|
||||
setError(null);
|
||||
const parsed = parseAndValidateJson(jsonInput);
|
||||
if (!parsed) return;
|
||||
|
||||
const servers = Object.entries(parsed.mcpServers).map(([name, config]) => {
|
||||
// Normalize config to MCPHub format
|
||||
const normalizedConfig: any = {};
|
||||
|
||||
if (config.type === 'sse' || config.type === 'streamable-http') {
|
||||
normalizedConfig.type = config.type;
|
||||
normalizedConfig.url = config.url;
|
||||
if (config.headers) {
|
||||
normalizedConfig.headers = config.headers;
|
||||
}
|
||||
} else {
|
||||
// Default to stdio
|
||||
normalizedConfig.type = 'stdio';
|
||||
normalizedConfig.command = config.command;
|
||||
normalizedConfig.args = config.args || [];
|
||||
if (config.env) {
|
||||
normalizedConfig.env = config.env;
|
||||
}
|
||||
}
|
||||
|
||||
return { name, config: normalizedConfig };
|
||||
});
|
||||
|
||||
setPreviewServers(servers);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!previewServers) return;
|
||||
|
||||
setIsImporting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let successCount = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const server of previewServers) {
|
||||
try {
|
||||
const result = await apiPost('/servers', {
|
||||
name: server.name,
|
||||
config: server.config,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
errors.push(`${server.name}: ${result.message || t('jsonImport.addFailed')}`);
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push(
|
||||
`${server.name}: ${err instanceof Error ? err.message : t('jsonImport.addFailed')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
setError(
|
||||
t('jsonImport.partialSuccess', { count: successCount, total: previewServers.length }) +
|
||||
'\n' +
|
||||
errors.join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Import error:', err);
|
||||
setError(t('jsonImport.importFailed'));
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white shadow rounded-lg p-6 w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900">{t('jsonImport.title')}</h2>
|
||||
<button onClick={onCancel} className="text-gray-500 hover:text-gray-700">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4 rounded">
|
||||
<p className="text-red-700 whitespace-pre-wrap">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!previewServers ? (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{t('jsonImport.inputLabel')}
|
||||
</label>
|
||||
<textarea
|
||||
value={jsonInput}
|
||||
onChange={(e) => setJsonInput(e.target.value)}
|
||||
className="w-full h-96 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"
|
||||
placeholder={examplePlaceholder}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-2">{t('jsonImport.inputHelp')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-4">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 btn-secondary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePreview}
|
||||
disabled={!jsonInput.trim()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('jsonImport.preview')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-3">
|
||||
{t('jsonImport.previewTitle')}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{previewServers.map((server, index) => (
|
||||
<div key={index} className="bg-gray-50 p-4 rounded-lg border border-gray-200">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-900">{server.name}</h4>
|
||||
<div className="mt-2 space-y-1 text-sm text-gray-600">
|
||||
<div>
|
||||
<strong>{t('server.type')}:</strong> {server.config.type || 'stdio'}
|
||||
</div>
|
||||
{server.config.command && (
|
||||
<div>
|
||||
<strong>{t('server.command')}:</strong> {server.config.command}
|
||||
</div>
|
||||
)}
|
||||
{server.config.args && server.config.args.length > 0 && (
|
||||
<div>
|
||||
<strong>{t('server.arguments')}:</strong>{' '}
|
||||
{server.config.args.join(' ')}
|
||||
</div>
|
||||
)}
|
||||
{server.config.url && (
|
||||
<div>
|
||||
<strong>{t('server.url')}:</strong> {server.config.url}
|
||||
</div>
|
||||
)}
|
||||
{server.config.env && Object.keys(server.config.env).length > 0 && (
|
||||
<div>
|
||||
<strong>{t('server.envVars')}:</strong>{' '}
|
||||
{Object.keys(server.config.env).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{server.config.headers &&
|
||||
Object.keys(server.config.headers).length > 0 && (
|
||||
<div>
|
||||
<strong>{t('server.headers')}:</strong>{' '}
|
||||
{Object.keys(server.config.headers).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-4">
|
||||
<button
|
||||
onClick={() => setPreviewServers(null)}
|
||||
disabled={isImporting}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 disabled:opacity-50 btn-secondary"
|
||||
>
|
||||
{t('common.back')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={isImporting}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center btn-primary"
|
||||
>
|
||||
{isImporting ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin h-4 w-4 mr-2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{t('jsonImport.importing')}
|
||||
</>
|
||||
) : (
|
||||
t('jsonImport.import')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JSONImportForm;
|
||||
@@ -19,7 +19,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
onBack,
|
||||
onInstall,
|
||||
installing = false,
|
||||
isInstalled = false
|
||||
isInstalled = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
@@ -32,21 +32,23 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
const getButtonProps = () => {
|
||||
if (isInstalled) {
|
||||
return {
|
||||
className: "bg-green-600 cursor-default px-4 py-2 rounded text-sm font-medium text-white",
|
||||
className: 'bg-green-600 cursor-default px-4 py-2 rounded text-sm font-medium text-white',
|
||||
disabled: true,
|
||||
text: t('market.installed')
|
||||
text: t('market.installed'),
|
||||
};
|
||||
} else if (installing) {
|
||||
return {
|
||||
className: "bg-gray-400 cursor-not-allowed px-4 py-2 rounded text-sm font-medium text-white",
|
||||
className:
|
||||
'bg-gray-400 cursor-not-allowed px-4 py-2 rounded text-sm font-medium text-white',
|
||||
disabled: true,
|
||||
text: t('market.installing')
|
||||
text: t('market.installing'),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
className: "bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm font-medium text-white btn-primary",
|
||||
className:
|
||||
'bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm font-medium text-white btn-primary',
|
||||
disabled: false,
|
||||
text: t('market.install')
|
||||
text: t('market.install'),
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -133,12 +135,18 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="mb-4">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-gray-600 hover:text-gray-900 flex items-center"
|
||||
>
|
||||
<svg className="h-5 w-5 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
|
||||
<button onClick={onBack} className="text-gray-600 hover:text-gray-900 flex items-center">
|
||||
<svg
|
||||
className="h-5 w-5 mr-1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{t('market.backToList')}
|
||||
</button>
|
||||
@@ -150,7 +158,8 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
{server.display_name}
|
||||
<span className="text-sm font-normal text-gray-500 ml-2">({server.name})</span>
|
||||
<span className="text-sm font-normal text-gray-600 ml-4">
|
||||
{t('market.author')}: {server.author.name} • {t('market.license')}: {server.license} •
|
||||
{t('market.author')}: {server.author?.name || t('market.unknown')} •{' '}
|
||||
{t('market.license')}: {server.license} •
|
||||
<a
|
||||
href={server.repository.url}
|
||||
target="_blank"
|
||||
@@ -182,18 +191,24 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
<p className="text-gray-700 mb-6">{server.description}</p>
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold mb-3">{t('market.categories')} & {t('market.tags')}</h3>
|
||||
<h3 className="text-lg font-semibold mb-3">
|
||||
{t('market.categories')} & {t('market.tags')}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{server.categories?.map((category, index) => (
|
||||
<span key={`cat-${index}`} className="bg-gray-100 text-gray-800 px-3 py-1 rounded">
|
||||
{category}
|
||||
</span>
|
||||
))}
|
||||
{server.tags && server.tags.map((tag, index) => (
|
||||
<span key={`tag-${index}`} className="bg-gray-100 text-green-700 px-2 py-1 rounded text-sm">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
{server.tags &&
|
||||
server.tags.map((tag, index) => (
|
||||
<span
|
||||
key={`tag-${index}`}
|
||||
className="bg-gray-100 text-green-700 px-2 py-1 rounded text-sm"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -224,9 +239,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{name}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">
|
||||
{arg.description}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{arg.description}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{arg.required ? (
|
||||
<span className="text-green-600">✓</span>
|
||||
@@ -268,7 +281,10 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
</h4>
|
||||
<p className="text-gray-600 mb-2">{tool.description}</p>
|
||||
<div className="mt-2">
|
||||
<pre id={`schema-${index}`} className="hidden bg-gray-50 p-3 rounded text-sm overflow-auto mt-2">
|
||||
<pre
|
||||
id={`schema-${index}`}
|
||||
className="hidden bg-gray-50 p-3 rounded text-sm overflow-auto mt-2"
|
||||
>
|
||||
{JSON.stringify(tool.inputSchema, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -285,9 +301,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
<div key={index} className="border border-gray-200 rounded p-4">
|
||||
<h4 className="font-medium mb-2">{example.title}</h4>
|
||||
<p className="text-gray-600 mb-2">{example.description}</p>
|
||||
<pre className="bg-gray-50 p-3 rounded text-sm overflow-auto">
|
||||
{example.prompt}
|
||||
</pre>
|
||||
<pre className="bg-gray-50 p-3 rounded text-sm overflow-auto">{example.prompt}</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -316,11 +330,11 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
status: 'disconnected',
|
||||
config: preferredInstallation
|
||||
? {
|
||||
command: preferredInstallation.command || '',
|
||||
args: preferredInstallation.args || [],
|
||||
env: preferredInstallation.env || {}
|
||||
}
|
||||
: undefined
|
||||
command: preferredInstallation.command || '',
|
||||
args: preferredInstallation.args || [],
|
||||
env: preferredInstallation.env || {},
|
||||
}
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -332,14 +346,16 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
{t('server.confirmVariables')}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
{t('server.variablesDetected')}
|
||||
</p>
|
||||
<p className="text-gray-600 mb-4">{t('server.variablesDetected')}</p>
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 mb-4">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
@@ -356,14 +372,12 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm mb-6">
|
||||
{t('market.confirmVariablesMessage')}
|
||||
</p>
|
||||
<p className="text-gray-600 text-sm mb-6">{t('market.confirmVariablesMessage')}</p>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setConfirmationVisible(false)
|
||||
setPendingPayload(null)
|
||||
setConfirmationVisible(false);
|
||||
setPendingPayload(null);
|
||||
}}
|
||||
className="px-4 py-2 text-gray-600 border border-gray-300 rounded hover:bg-gray-50 btn-secondary"
|
||||
>
|
||||
|
||||
@@ -1,188 +1,207 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Server } from '@/types'
|
||||
import { ChevronDown, ChevronRight, AlertCircle, Copy, Check } from 'lucide-react'
|
||||
import { StatusBadge } from '@/components/ui/Badge'
|
||||
import ToolCard from '@/components/ui/ToolCard'
|
||||
import PromptCard from '@/components/ui/PromptCard'
|
||||
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 { Server } from '@/types';
|
||||
import { ChevronDown, ChevronRight, AlertCircle, Copy, Check } from 'lucide-react';
|
||||
import { StatusBadge } from '@/components/ui/Badge';
|
||||
import ToolCard from '@/components/ui/ToolCard';
|
||||
import PromptCard from '@/components/ui/PromptCard';
|
||||
import DeleteDialog from '@/components/ui/DeleteDialog';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import { useSettingsData } from '@/hooks/useSettingsData';
|
||||
|
||||
interface ServerCardProps {
|
||||
server: Server
|
||||
onRemove: (serverName: string) => void
|
||||
onEdit: (server: Server) => void
|
||||
onToggle?: (server: Server, enabled: boolean) => Promise<boolean>
|
||||
onRefresh?: () => void
|
||||
server: Server;
|
||||
onRemove: (serverName: string) => void;
|
||||
onEdit: (server: Server) => void;
|
||||
onToggle?: (server: Server, enabled: boolean) => Promise<boolean>;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { showToast } = useToast()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isToggling, setIsToggling] = useState(false)
|
||||
const [showErrorPopover, setShowErrorPopover] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const errorPopoverRef = useRef<HTMLDivElement>(null)
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [isToggling, setIsToggling] = useState(false);
|
||||
const [showErrorPopover, setShowErrorPopover] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const errorPopoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (errorPopoverRef.current && !errorPopoverRef.current.contains(event.target as Node)) {
|
||||
setShowErrorPopover(false)
|
||||
setShowErrorPopover(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [])
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { exportMCPSettings } = useSettingsData()
|
||||
const { exportMCPSettings } = useSettingsData();
|
||||
|
||||
const handleRemove = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setShowDeleteDialog(true)
|
||||
}
|
||||
e.stopPropagation();
|
||||
setShowDeleteDialog(true);
|
||||
};
|
||||
|
||||
const handleEdit = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onEdit(server)
|
||||
}
|
||||
e.stopPropagation();
|
||||
onEdit(server);
|
||||
};
|
||||
|
||||
const handleToggle = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (isToggling || !onToggle) return
|
||||
e.stopPropagation();
|
||||
if (isToggling || !onToggle) return;
|
||||
|
||||
setIsToggling(true)
|
||||
setIsToggling(true);
|
||||
try {
|
||||
await onToggle(server, !(server.enabled !== false))
|
||||
await onToggle(server, !(server.enabled !== false));
|
||||
} finally {
|
||||
setIsToggling(false)
|
||||
setIsToggling(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleErrorIconClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setShowErrorPopover(!showErrorPopover)
|
||||
}
|
||||
e.stopPropagation();
|
||||
setShowErrorPopover(!showErrorPopover);
|
||||
};
|
||||
|
||||
const copyToClipboard = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!server.error) return
|
||||
e.stopPropagation();
|
||||
if (!server.error) return;
|
||||
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(server.error).then(() => {
|
||||
setCopied(true)
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
setCopied(true);
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
} else {
|
||||
// Fallback for HTTP or unsupported clipboard API
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = server.error
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = server.error;
|
||||
// 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)
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
document.execCommand('copy');
|
||||
setCopied(true);
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', '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 handleCopyServerConfig = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const result = await exportMCPSettings(server.name)
|
||||
const configJson = JSON.stringify(result.data, null, 2)
|
||||
const result = await exportMCPSettings(server.name);
|
||||
const configJson = JSON.stringify(result.data, null, 2);
|
||||
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(configJson)
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
|
||||
await navigator.clipboard.writeText(configJson);
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success');
|
||||
} else {
|
||||
// Fallback for HTTP or unsupported clipboard API
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = configJson
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-9999px'
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = configJson;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
|
||||
document.execCommand('copy');
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success');
|
||||
} 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);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error copying server configuration:', error)
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error')
|
||||
console.error('Error copying server configuration:', error);
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
onRemove(server.name)
|
||||
setShowDeleteDialog(false)
|
||||
}
|
||||
onRemove(server.name);
|
||||
setShowDeleteDialog(false);
|
||||
};
|
||||
|
||||
const handleToolToggle = async (toolName: string, enabled: boolean) => {
|
||||
try {
|
||||
const { toggleTool } = await import('@/services/toolService')
|
||||
const result = await toggleTool(server.name, toolName, enabled)
|
||||
const { toggleTool } = await import('@/services/toolService');
|
||||
const result = await toggleTool(server.name, toolName, enabled);
|
||||
if (result.success) {
|
||||
showToast(
|
||||
t(enabled ? 'tool.enableSuccess' : 'tool.disableSuccess', { name: toolName }),
|
||||
'success',
|
||||
)
|
||||
);
|
||||
// Trigger refresh to update the tool's state in the UI
|
||||
if (onRefresh) {
|
||||
onRefresh()
|
||||
onRefresh();
|
||||
}
|
||||
} else {
|
||||
showToast(result.error || t('tool.toggleFailed'), 'error')
|
||||
showToast(result.error || t('tool.toggleFailed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling tool:', error)
|
||||
showToast(t('tool.toggleFailed'), 'error')
|
||||
console.error('Error toggling tool:', error);
|
||||
showToast(t('tool.toggleFailed'), 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePromptToggle = async (promptName: string, enabled: boolean) => {
|
||||
try {
|
||||
const { togglePrompt } = await import('@/services/promptService')
|
||||
const result = await togglePrompt(server.name, promptName, enabled)
|
||||
const { togglePrompt } = await import('@/services/promptService');
|
||||
const result = await togglePrompt(server.name, promptName, enabled);
|
||||
if (result.success) {
|
||||
showToast(
|
||||
t(enabled ? 'tool.enableSuccess' : 'tool.disableSuccess', { name: promptName }),
|
||||
'success',
|
||||
)
|
||||
);
|
||||
// Trigger refresh to update the prompt's state in the UI
|
||||
if (onRefresh) {
|
||||
onRefresh()
|
||||
onRefresh();
|
||||
}
|
||||
} else {
|
||||
showToast(result.error || t('tool.toggleFailed'), 'error')
|
||||
showToast(result.error || t('tool.toggleFailed'), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling prompt:', error)
|
||||
showToast(t('tool.toggleFailed'), 'error')
|
||||
console.error('Error toggling prompt:', error);
|
||||
showToast(t('tool.toggleFailed'), 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleOAuthAuthorization = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
// Open the OAuth authorization URL in a new window
|
||||
if (server.oauth?.authorizationUrl) {
|
||||
const width = 600;
|
||||
const height = 700;
|
||||
const left = window.screen.width / 2 - width / 2;
|
||||
const top = window.screen.height / 2 - height / 2;
|
||||
|
||||
window.open(
|
||||
server.oauth.authorizationUrl,
|
||||
'OAuth Authorization',
|
||||
`width=${width},height=${height},left=${left},top=${top}`,
|
||||
);
|
||||
|
||||
showToast(t('status.oauthWindowOpened'), 'info');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -199,7 +218,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
>
|
||||
{server.name}
|
||||
</h2>
|
||||
<StatusBadge status={server.status} />
|
||||
<StatusBadge status={server.status} onAuthClick={handleOAuthAuthorization} />
|
||||
|
||||
{/* Tool count display */}
|
||||
<div className="flex items-center px-2 py-1 bg-blue-50 text-blue-700 rounded-full text-sm btn-primary">
|
||||
@@ -269,8 +288,8 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowErrorPopover(false)
|
||||
e.stopPropagation();
|
||||
setShowErrorPopover(false);
|
||||
}}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
@@ -380,7 +399,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
serverName={server.name}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerCard
|
||||
export default ServerCard;
|
||||
|
||||
@@ -42,6 +42,20 @@ const ServerForm = ({
|
||||
}));
|
||||
};
|
||||
|
||||
const getInitialOAuthConfig = (data: Server | null): ServerFormData['oauth'] => {
|
||||
const oauth = data?.config?.oauth;
|
||||
return {
|
||||
clientId: oauth?.clientId || '',
|
||||
clientSecret: oauth?.clientSecret || '',
|
||||
scopes: oauth?.scopes ? oauth.scopes.join(' ') : '',
|
||||
accessToken: oauth?.accessToken || '',
|
||||
refreshToken: oauth?.refreshToken || '',
|
||||
authorizationEndpoint: oauth?.authorizationEndpoint || '',
|
||||
tokenEndpoint: oauth?.tokenEndpoint || '',
|
||||
resource: oauth?.resource || '',
|
||||
};
|
||||
};
|
||||
|
||||
const [serverType, setServerType] = useState<'stdio' | 'sse' | 'streamable-http' | 'openapi'>(
|
||||
getInitialServerType(),
|
||||
);
|
||||
@@ -80,6 +94,7 @@ const ServerForm = ({
|
||||
initialData.config.options.maxTotalTimeout) ||
|
||||
undefined,
|
||||
},
|
||||
oauth: getInitialOAuthConfig(initialData),
|
||||
// OpenAPI configuration initialization
|
||||
openapi:
|
||||
initialData && initialData.config && initialData.config.openapi
|
||||
@@ -135,6 +150,7 @@ const ServerForm = ({
|
||||
);
|
||||
|
||||
const [isRequestOptionsExpanded, setIsRequestOptionsExpanded] = useState<boolean>(false);
|
||||
const [isOAuthSectionExpanded, setIsOAuthSectionExpanded] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isEdit = !!initialData;
|
||||
|
||||
@@ -186,6 +202,19 @@ const ServerForm = ({
|
||||
setHeaderVars(newHeaderVars);
|
||||
};
|
||||
|
||||
const handleOAuthChange = <K extends keyof NonNullable<ServerFormData['oauth']>>(
|
||||
field: K,
|
||||
value: string,
|
||||
) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
oauth: {
|
||||
...(prev.oauth || {}),
|
||||
[field]: value,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
// Handle options changes
|
||||
const handleOptionsChange = (
|
||||
field: 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout',
|
||||
@@ -232,6 +261,42 @@ const ServerForm = ({
|
||||
options.maxTotalTimeout = formData.options.maxTotalTimeout;
|
||||
}
|
||||
|
||||
const oauthConfig = (() => {
|
||||
if (!formData.oauth) return undefined;
|
||||
const {
|
||||
clientId,
|
||||
clientSecret,
|
||||
scopes,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
authorizationEndpoint,
|
||||
tokenEndpoint,
|
||||
resource,
|
||||
} = formData.oauth;
|
||||
|
||||
const oauth: Record<string, unknown> = {};
|
||||
if (clientId && clientId.trim()) oauth.clientId = clientId.trim();
|
||||
if (clientSecret && clientSecret.trim()) oauth.clientSecret = clientSecret.trim();
|
||||
if (scopes && scopes.trim()) {
|
||||
const parsedScopes = scopes
|
||||
.split(/[\s,]+/)
|
||||
.map((scope) => scope.trim())
|
||||
.filter((scope) => scope.length > 0);
|
||||
if (parsedScopes.length > 0) {
|
||||
oauth.scopes = parsedScopes;
|
||||
}
|
||||
}
|
||||
if (accessToken && accessToken.trim()) oauth.accessToken = accessToken.trim();
|
||||
if (refreshToken && refreshToken.trim()) oauth.refreshToken = refreshToken.trim();
|
||||
if (authorizationEndpoint && authorizationEndpoint.trim()) {
|
||||
oauth.authorizationEndpoint = authorizationEndpoint.trim();
|
||||
}
|
||||
if (tokenEndpoint && tokenEndpoint.trim()) oauth.tokenEndpoint = tokenEndpoint.trim();
|
||||
if (resource && resource.trim()) oauth.resource = resource.trim();
|
||||
|
||||
return Object.keys(oauth).length > 0 ? oauth : undefined;
|
||||
})();
|
||||
|
||||
const payload = {
|
||||
name: formData.name,
|
||||
config: {
|
||||
@@ -304,6 +369,7 @@ const ServerForm = ({
|
||||
? {
|
||||
url: formData.url,
|
||||
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
||||
...(oauthConfig ? { oauth: oauthConfig } : {}),
|
||||
}
|
||||
: {
|
||||
command: formData.command,
|
||||
@@ -896,6 +962,132 @@ const ServerForm = ({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div
|
||||
className="flex items-center justify-between cursor-pointer bg-gray-50 hover:bg-gray-100 p-3 rounded border border-gray-200"
|
||||
onClick={() => setIsOAuthSectionExpanded(!isOAuthSectionExpanded)}
|
||||
>
|
||||
<label className="text-gray-700 text-sm font-bold">
|
||||
{t('server.oauth.sectionTitle')}
|
||||
</label>
|
||||
<span className="text-gray-500 text-sm">{isOAuthSectionExpanded ? '▼' : '▶'}</span>
|
||||
</div>
|
||||
|
||||
{isOAuthSectionExpanded && (
|
||||
<div className="border border-gray-200 rounded-b p-4 bg-gray-50 border-t-0">
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
{t('server.oauth.sectionDescription')}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">
|
||||
{t('server.oauth.clientId')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.oauth?.clientId || ''}
|
||||
onChange={(e) => handleOAuthChange('clientId', e.target.value)}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
placeholder="client id"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">
|
||||
{t('server.oauth.clientSecret')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.oauth?.clientSecret || ''}
|
||||
onChange={(e) => handleOAuthChange('clientSecret', e.target.value)}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
placeholder="client secret"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
{/*
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">
|
||||
{t('server.oauth.authorizationEndpoint')}
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.oauth?.authorizationEndpoint || ''}
|
||||
onChange={(e) => handleOAuthChange('authorizationEndpoint', e.target.value)}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
placeholder="https://auth.example.com/authorize"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">
|
||||
{t('server.oauth.tokenEndpoint')}
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.oauth?.tokenEndpoint || ''}
|
||||
onChange={(e) => handleOAuthChange('tokenEndpoint', e.target.value)}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
placeholder="https://auth.example.com/token"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">
|
||||
{t('server.oauth.scopes')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.oauth?.scopes || ''}
|
||||
onChange={(e) => handleOAuthChange('scopes', e.target.value)}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
placeholder={t('server.oauth.scopesPlaceholder')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">
|
||||
{t('server.oauth.resource')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.oauth?.resource || ''}
|
||||
onChange={(e) => handleOAuthChange('resource', e.target.value)}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
placeholder="https://mcp.example.com/mcp"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">
|
||||
{t('server.oauth.accessToken')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.oauth?.accessToken || ''}
|
||||
onChange={(e) => handleOAuthChange('accessToken', e.target.value)}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
placeholder="access-token"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">
|
||||
{t('server.oauth.refreshToken')}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.oauth?.refreshToken || ''}
|
||||
onChange={(e) => handleOAuthChange('refreshToken', e.target.value)}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
placeholder="refresh-token"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
*/}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -13,24 +13,21 @@ type BadgeProps = {
|
||||
|
||||
const badgeVariants = {
|
||||
default: 'bg-blue-500 text-white hover:bg-blue-600',
|
||||
secondary: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600',
|
||||
outline: 'bg-transparent border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800',
|
||||
secondary:
|
||||
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600',
|
||||
outline:
|
||||
'bg-transparent border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800',
|
||||
destructive: 'bg-red-500 text-white hover:bg-red-600',
|
||||
};
|
||||
|
||||
export function Badge({
|
||||
children,
|
||||
variant = 'default',
|
||||
className,
|
||||
onClick
|
||||
}: BadgeProps) {
|
||||
export function Badge({ children, variant = 'default', className, onClick }: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors',
|
||||
badgeVariants[variant],
|
||||
onClick ? 'cursor-pointer' : '',
|
||||
className
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
@@ -40,27 +37,40 @@ export function Badge({
|
||||
}
|
||||
|
||||
// For backward compatibility with existing code
|
||||
export const StatusBadge = ({ status }: { status: 'connected' | 'disconnected' | 'connecting' }) => {
|
||||
export const StatusBadge = ({
|
||||
status,
|
||||
onAuthClick,
|
||||
}: {
|
||||
status: 'connected' | 'disconnected' | 'connecting' | 'oauth_required';
|
||||
onAuthClick?: (e: React.MouseEvent) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const colors = {
|
||||
connecting: 'status-badge-connecting',
|
||||
connected: 'status-badge-online',
|
||||
disconnected: 'status-badge-offline',
|
||||
oauth_required: 'status-badge-oauth-required',
|
||||
};
|
||||
|
||||
// Map status to translation keys
|
||||
const statusTranslations = {
|
||||
connected: 'status.online',
|
||||
disconnected: 'status.offline',
|
||||
connecting: 'status.connecting'
|
||||
connecting: 'status.connecting',
|
||||
oauth_required: 'status.oauthRequired',
|
||||
};
|
||||
|
||||
const isOAuthRequired = status === 'oauth_required';
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${colors[status]}`}
|
||||
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${colors[status]} ${isOAuthRequired && onAuthClick ? 'cursor-pointer hover:opacity-80' : ''}`}
|
||||
onClick={isOAuthRequired && onAuthClick ? (e) => onAuthClick(e) : undefined}
|
||||
title={isOAuthRequired ? t('status.clickToAuthorize') : undefined}
|
||||
>
|
||||
{isOAuthRequired && '🔐 '}
|
||||
{t(statusTranslations[status] || status)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
116
frontend/src/components/ui/DefaultPasswordWarningModal.tsx
Normal file
116
frontend/src/components/ui/DefaultPasswordWarningModal.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface DefaultPasswordWarningModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const DefaultPasswordWarningModal: React.FC<DefaultPasswordWarningModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleGoToSettings = () => {
|
||||
onClose();
|
||||
navigate('/settings');
|
||||
// Auto-scroll to password section after a small delay to ensure page is loaded
|
||||
setTimeout(() => {
|
||||
const passwordSection = document.querySelector('[data-section="password"]');
|
||||
if (passwordSection) {
|
||||
passwordSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
// If the section is collapsed, expand it
|
||||
const clickTarget = passwordSection.querySelector('[role="button"]');
|
||||
if (clickTarget && !passwordSection.querySelector('.mt-4')) {
|
||||
(clickTarget as HTMLElement).click();
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-[100] flex items-center justify-center p-4"
|
||||
onClick={handleBackdropClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full transform transition-all duration-200 ease-out"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="password-warning-title"
|
||||
aria-describedby="password-warning-message"
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="flex-shrink-0">
|
||||
<svg
|
||||
className="w-6 h-6 text-yellow-600 dark:text-yellow-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3
|
||||
id="password-warning-title"
|
||||
className="text-lg font-medium text-gray-900 dark:text-white mb-2"
|
||||
>
|
||||
{t('auth.defaultPasswordWarning')}
|
||||
</h3>
|
||||
<p
|
||||
id="password-warning-message"
|
||||
className="text-gray-600 dark:text-gray-300 leading-relaxed"
|
||||
>
|
||||
{t('auth.defaultPasswordMessage')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors duration-150 btn-secondary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGoToSettings}
|
||||
className="px-4 py-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded-md transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yellow-500 btn-warning"
|
||||
autoFocus
|
||||
>
|
||||
{t('auth.goToSettings')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DefaultPasswordWarningModal;
|
||||
@@ -5,6 +5,7 @@ export const PERMISSIONS = {
|
||||
SETTINGS_SKIP_AUTH: 'settings:skip_auth',
|
||||
SETTINGS_INSTALL_CONFIG: 'settings:install_config',
|
||||
SETTINGS_EXPORT_CONFIG: 'settings:export_config',
|
||||
SETTINGS_CLUSTER_CONFIG: 'settings:cluster_config',
|
||||
} as const;
|
||||
|
||||
export default PERMISSIONS;
|
||||
|
||||
@@ -14,12 +14,12 @@ const initialState: AuthState = {
|
||||
// Create auth context
|
||||
const AuthContext = createContext<{
|
||||
auth: AuthState;
|
||||
login: (username: string, password: string) => Promise<boolean>;
|
||||
login: (username: string, password: string) => Promise<{ success: boolean; isUsingDefaultPassword?: boolean }>;
|
||||
register: (username: string, password: string, isAdmin?: boolean) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
}>({
|
||||
auth: initialState,
|
||||
login: async () => false,
|
||||
login: async () => ({ success: false }),
|
||||
register: async () => false,
|
||||
logout: () => { },
|
||||
});
|
||||
@@ -90,7 +90,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
}, []);
|
||||
|
||||
// Login function
|
||||
const login = async (username: string, password: string): Promise<boolean> => {
|
||||
const login = async (username: string, password: string): Promise<{ success: boolean; isUsingDefaultPassword?: boolean }> => {
|
||||
try {
|
||||
const response = await authService.login({ username, password });
|
||||
|
||||
@@ -101,14 +101,17 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
user: response.user,
|
||||
error: null,
|
||||
});
|
||||
return true;
|
||||
return {
|
||||
success: true,
|
||||
isUsingDefaultPassword: response.isUsingDefaultPassword,
|
||||
};
|
||||
} else {
|
||||
setAuth({
|
||||
...initialState,
|
||||
loading: false,
|
||||
error: response.message || 'Authentication failed',
|
||||
});
|
||||
return false;
|
||||
return { success: false };
|
||||
}
|
||||
} catch (error) {
|
||||
setAuth({
|
||||
@@ -116,7 +119,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
loading: false,
|
||||
error: 'Authentication failed',
|
||||
});
|
||||
return false;
|
||||
return { success: false };
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -287,9 +287,13 @@ export const useCloudData = () => {
|
||||
const callServerTool = useCallback(
|
||||
async (serverName: string, toolName: string, args: Record<string, any>) => {
|
||||
try {
|
||||
const data = await apiPost(`/cloud/servers/${serverName}/tools/${toolName}/call`, {
|
||||
arguments: args,
|
||||
});
|
||||
// URL-encode server and tool names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
|
||||
const data = await apiPost(
|
||||
`/cloud/servers/${encodeURIComponent(serverName)}/tools/${encodeURIComponent(toolName)}/call`,
|
||||
{
|
||||
arguments: args,
|
||||
},
|
||||
);
|
||||
|
||||
if (data && data.success) {
|
||||
return data.data;
|
||||
|
||||
@@ -34,6 +34,35 @@ interface MCPRouterConfig {
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
interface ClusterNodeConfig {
|
||||
id?: string;
|
||||
name?: string;
|
||||
coordinatorUrl: string;
|
||||
heartbeatInterval?: number;
|
||||
registerOnStartup?: boolean;
|
||||
}
|
||||
|
||||
interface ClusterCoordinatorConfig {
|
||||
nodeTimeout?: number;
|
||||
cleanupInterval?: number;
|
||||
stickySessionTimeout?: number;
|
||||
}
|
||||
|
||||
interface ClusterStickySessionConfig {
|
||||
enabled: boolean;
|
||||
strategy: 'consistent-hash' | 'cookie' | 'header';
|
||||
cookieName?: string;
|
||||
headerName?: string;
|
||||
}
|
||||
|
||||
interface ClusterConfig {
|
||||
enabled: boolean;
|
||||
mode: 'standalone' | 'node' | 'coordinator';
|
||||
node?: ClusterNodeConfig;
|
||||
coordinator?: ClusterCoordinatorConfig;
|
||||
stickySession?: ClusterStickySessionConfig;
|
||||
}
|
||||
|
||||
interface SystemSettings {
|
||||
systemConfig?: {
|
||||
routing?: RoutingConfig;
|
||||
@@ -41,6 +70,7 @@ interface SystemSettings {
|
||||
smartRouting?: SmartRoutingConfig;
|
||||
mcpRouter?: MCPRouterConfig;
|
||||
nameSeparator?: string;
|
||||
cluster?: ClusterConfig;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,6 +115,27 @@ export const useSettingsData = () => {
|
||||
baseUrl: 'https://api.mcprouter.to/v1',
|
||||
});
|
||||
|
||||
const [clusterConfig, setClusterConfig] = useState<ClusterConfig>({
|
||||
enabled: false,
|
||||
mode: 'standalone',
|
||||
node: {
|
||||
coordinatorUrl: '',
|
||||
heartbeatInterval: 5000,
|
||||
registerOnStartup: true,
|
||||
},
|
||||
coordinator: {
|
||||
nodeTimeout: 15000,
|
||||
cleanupInterval: 30000,
|
||||
stickySessionTimeout: 3600000,
|
||||
},
|
||||
stickySession: {
|
||||
enabled: true,
|
||||
strategy: 'consistent-hash',
|
||||
cookieName: 'MCPHUB_NODE',
|
||||
headerName: 'X-MCPHub-Node',
|
||||
},
|
||||
});
|
||||
|
||||
const [nameSeparator, setNameSeparator] = useState<string>('-');
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -141,6 +192,28 @@ export const useSettingsData = () => {
|
||||
if (data.success && data.data?.systemConfig?.nameSeparator !== undefined) {
|
||||
setNameSeparator(data.data.systemConfig.nameSeparator);
|
||||
}
|
||||
if (data.success && data.data?.systemConfig?.cluster) {
|
||||
setClusterConfig({
|
||||
enabled: data.data.systemConfig.cluster.enabled ?? false,
|
||||
mode: data.data.systemConfig.cluster.mode || 'standalone',
|
||||
node: data.data.systemConfig.cluster.node || {
|
||||
coordinatorUrl: '',
|
||||
heartbeatInterval: 5000,
|
||||
registerOnStartup: true,
|
||||
},
|
||||
coordinator: data.data.systemConfig.cluster.coordinator || {
|
||||
nodeTimeout: 15000,
|
||||
cleanupInterval: 30000,
|
||||
stickySessionTimeout: 3600000,
|
||||
},
|
||||
stickySession: data.data.systemConfig.cluster.stickySession || {
|
||||
enabled: true,
|
||||
strategy: 'consistent-hash',
|
||||
cookieName: 'MCPHUB_NODE',
|
||||
headerName: 'X-MCPHub-Node',
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings:', error);
|
||||
setError(error instanceof Error ? error.message : 'Failed to fetch settings');
|
||||
@@ -420,6 +493,39 @@ export const useSettingsData = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Update cluster configuration
|
||||
const updateClusterConfig = async (updates: Partial<ClusterConfig>) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await apiPut('/system-config', {
|
||||
cluster: updates,
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
setClusterConfig({
|
||||
...clusterConfig,
|
||||
...updates,
|
||||
});
|
||||
showToast(t('settings.systemConfigUpdated'));
|
||||
return true;
|
||||
} else {
|
||||
showToast(data.message || t('errors.failedToUpdateSystemConfig'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update cluster config:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to update cluster config';
|
||||
setError(errorMessage);
|
||||
showToast(errorMessage);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportMCPSettings = async (serverName?: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -455,6 +561,7 @@ export const useSettingsData = () => {
|
||||
installConfig,
|
||||
smartRoutingConfig,
|
||||
mcpRouterConfig,
|
||||
clusterConfig,
|
||||
nameSeparator,
|
||||
loading,
|
||||
error,
|
||||
@@ -468,6 +575,7 @@ export const useSettingsData = () => {
|
||||
updateRoutingConfigBatch,
|
||||
updateMCPRouterConfig,
|
||||
updateMCPRouterConfigBatch,
|
||||
updateClusterConfig,
|
||||
updateNameSeparator,
|
||||
exportMCPSettings,
|
||||
};
|
||||
|
||||
@@ -144,6 +144,18 @@ body {
|
||||
border: 1px solid rgba(255, 193, 7, 0.3);
|
||||
}
|
||||
|
||||
.status-badge-oauth-required {
|
||||
background-color: white !important;
|
||||
color: rgba(156, 39, 176, 0.9) !important;
|
||||
border: 1px solid #ba68c8;
|
||||
}
|
||||
|
||||
.dark .status-badge-oauth-required {
|
||||
background-color: rgba(156, 39, 176, 0.15) !important;
|
||||
color: rgba(186, 104, 200, 0.9) !important;
|
||||
border: 1px solid rgba(156, 39, 176, 0.3);
|
||||
}
|
||||
|
||||
/* Enhanced status icons for dark theme */
|
||||
.dark .status-icon-blue {
|
||||
background-color: rgba(59, 130, 246, 0.15) !important;
|
||||
|
||||
@@ -12,14 +12,16 @@ const DashboardPage: React.FC = () => {
|
||||
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
|
||||
connecting: servers.filter((server: Server) => server.status === 'connecting').length,
|
||||
oauthRequired: servers.filter((server: Server) => server.status === 'oauth_required').length,
|
||||
};
|
||||
|
||||
// Map status to translation keys
|
||||
const statusTranslations: Record<string, string> = {
|
||||
connected: 'status.online',
|
||||
disconnected: 'status.offline',
|
||||
connecting: 'status.connecting'
|
||||
connecting: 'status.connecting',
|
||||
oauth_required: 'status.oauthRequired',
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -38,8 +40,17 @@ const DashboardPage: React.FC = () => {
|
||||
className="ml-4 text-gray-500 hover:text-gray-700 transition-colors duration-200"
|
||||
aria-label={t('app.closeButton')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 111.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z" clipRule="evenodd" />
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 111.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -49,9 +60,25 @@ const DashboardPage: React.FC = () => {
|
||||
{isLoading && (
|
||||
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center loading-container">
|
||||
<div className="flex flex-col items-center">
|
||||
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
<svg
|
||||
className="animate-spin h-10 w-10 text-blue-500 mb-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<p className="text-gray-600">{t('app.loading')}</p>
|
||||
</div>
|
||||
@@ -64,12 +91,25 @@ const DashboardPage: React.FC = () => {
|
||||
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-blue-100 text-blue-800 icon-container status-icon-blue">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-8 w-8"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h2 className="text-xl font-semibold text-gray-700">{t('pages.dashboard.totalServers')}</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-700">
|
||||
{t('pages.dashboard.totalServers')}
|
||||
</h2>
|
||||
<p className="text-3xl font-bold text-gray-900">{serverStats.total}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -79,12 +119,25 @@ const DashboardPage: React.FC = () => {
|
||||
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-green-100 text-green-800 icon-container status-icon-green">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-8 w-8"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h2 className="text-xl font-semibold text-gray-700">{t('pages.dashboard.onlineServers')}</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-700">
|
||||
{t('pages.dashboard.onlineServers')}
|
||||
</h2>
|
||||
<p className="text-3xl font-bold text-gray-900">{serverStats.online}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,12 +147,25 @@ const DashboardPage: React.FC = () => {
|
||||
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-red-100 text-red-800 icon-container status-icon-red">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-8 w-8"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h2 className="text-xl font-semibold text-gray-700">{t('pages.dashboard.offlineServers')}</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-700">
|
||||
{t('pages.dashboard.offlineServers')}
|
||||
</h2>
|
||||
<p className="text-3xl font-bold text-gray-900">{serverStats.offline}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,16 +175,28 @@ const DashboardPage: React.FC = () => {
|
||||
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-yellow-100 text-yellow-800 icon-container status-icon-yellow">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-8 w-8"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h2 className="text-xl font-semibold text-gray-700">{t('pages.dashboard.connectingServers')}</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-700">
|
||||
{t('pages.dashboard.connectingServers')}
|
||||
</h2>
|
||||
<p className="text-3xl font-bold text-gray-900">{serverStats.connecting}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -126,24 +204,41 @@ const DashboardPage: React.FC = () => {
|
||||
{/* Recent activity list */}
|
||||
{servers.length > 0 && !isLoading && (
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">{t('pages.dashboard.recentServers')}</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">
|
||||
{t('pages.dashboard.recentServers')}
|
||||
</h2>
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden table-container">
|
||||
<table className="min-w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
{t('server.name')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
{t('server.status')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
{t('server.tools')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
{t('server.prompts')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
{t('server.enabled')}
|
||||
</th>
|
||||
</tr>
|
||||
@@ -155,12 +250,18 @@ const DashboardPage: React.FC = () => {
|
||||
{server.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${server.status === 'connected'
|
||||
? 'status-badge-online'
|
||||
: server.status === 'disconnected'
|
||||
? 'status-badge-offline'
|
||||
: 'status-badge-connecting'
|
||||
}`}>
|
||||
<span
|
||||
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||||
server.status === 'connected'
|
||||
? 'status-badge-online'
|
||||
: server.status === 'disconnected'
|
||||
? 'status-badge-offline'
|
||||
: server.status === 'oauth_required'
|
||||
? 'status-badge-oauth-required'
|
||||
: 'status-badge-connecting'
|
||||
}`}
|
||||
>
|
||||
{server.status === 'oauth_required' && '🔐 '}
|
||||
{t(statusTranslations[server.status] || server.status)}
|
||||
</span>
|
||||
</td>
|
||||
@@ -188,4 +289,4 @@ const DashboardPage: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
export default DashboardPage;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ThemeSwitch from '@/components/ui/ThemeSwitch';
|
||||
import LanguageSwitch from '@/components/ui/LanguageSwitch';
|
||||
import DefaultPasswordWarningModal from '@/components/ui/DefaultPasswordWarningModal';
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -11,6 +12,7 @@ const LoginPage: React.FC = () => {
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showDefaultPasswordWarning, setShowDefaultPasswordWarning] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -26,10 +28,15 @@ const LoginPage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await login(username, password);
|
||||
const result = await login(username, password);
|
||||
|
||||
if (success) {
|
||||
navigate('/');
|
||||
if (result.success) {
|
||||
if (result.isUsingDefaultPassword) {
|
||||
// Show warning modal instead of navigating immediately
|
||||
setShowDefaultPasswordWarning(true);
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
} else {
|
||||
setError(t('auth.loginFailed'));
|
||||
}
|
||||
@@ -40,6 +47,11 @@ const LoginPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseWarning = () => {
|
||||
setShowDefaultPasswordWarning(false);
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen w-full overflow-hidden bg-gray-50 dark:bg-gray-950">
|
||||
{/* Top-right controls */}
|
||||
@@ -138,6 +150,12 @@ const LoginPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Default Password Warning Modal */}
|
||||
<DefaultPasswordWarningModal
|
||||
isOpen={showDefaultPasswordWarning}
|
||||
onClose={handleCloseWarning}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import AddServerForm from '@/components/AddServerForm';
|
||||
import EditServerForm from '@/components/EditServerForm';
|
||||
import { useServerData } from '@/hooks/useServerData';
|
||||
import DxtUploadForm from '@/components/DxtUploadForm';
|
||||
import JSONImportForm from '@/components/JSONImportForm';
|
||||
|
||||
const ServersPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -25,6 +26,7 @@ const ServersPage: React.FC = () => {
|
||||
const [editingServer, setEditingServer] = useState<Server | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [showDxtUpload, setShowDxtUpload] = useState(false);
|
||||
const [showJsonImport, setShowJsonImport] = useState(false);
|
||||
|
||||
const handleEditClick = async (server: Server) => {
|
||||
const fullServerData = await handleServerEdit(server);
|
||||
@@ -55,6 +57,12 @@ const ServersPage: React.FC = () => {
|
||||
triggerRefresh();
|
||||
};
|
||||
|
||||
const handleJsonImportSuccess = () => {
|
||||
// Close import dialog and refresh servers
|
||||
setShowJsonImport(false);
|
||||
triggerRefresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
@@ -70,6 +78,15 @@ const ServersPage: React.FC = () => {
|
||||
{t('nav.market')}
|
||||
</button>
|
||||
<AddServerForm onAdd={handleServerAdd} />
|
||||
<button
|
||||
onClick={() => setShowJsonImport(true)}
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM6.293 6.707a1 1 0 010-1.414l3-3a1 1 0 011.414 0l3 3a1 1 0 01-1.414 1.414L11 5.414V13a1 1 0 11-2 0V5.414L7.707 6.707a1 1 0 01-1.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{t('jsonImport.button')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDxtUpload(true)}
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
|
||||
@@ -161,6 +178,13 @@ const ServersPage: React.FC = () => {
|
||||
onCancel={() => setShowDxtUpload(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showJsonImport && (
|
||||
<JSONImportForm
|
||||
onSuccess={handleJsonImportSuccess}
|
||||
onCancel={() => setShowJsonImport(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,55 +1,99 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import ChangePasswordForm from '@/components/ChangePasswordForm'
|
||||
import { Switch } from '@/components/ui/ToggleGroup'
|
||||
import { useSettingsData } from '@/hooks/useSettingsData'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
import { generateRandomKey } from '@/utils/key'
|
||||
import { PermissionChecker } from '@/components/PermissionChecker'
|
||||
import { PERMISSIONS } from '@/constants/permissions'
|
||||
import { Copy, Check, Download } from 'lucide-react'
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ChangePasswordForm from '@/components/ChangePasswordForm';
|
||||
import { Switch } from '@/components/ui/ToggleGroup';
|
||||
import { useSettingsData } from '@/hooks/useSettingsData';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import { generateRandomKey } from '@/utils/key';
|
||||
import { PermissionChecker } from '@/components/PermissionChecker';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
import { Copy, Check, Download } from 'lucide-react';
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [installConfig, setInstallConfig] = useState<{
|
||||
pythonIndexUrl: string
|
||||
npmRegistry: string
|
||||
baseUrl: string
|
||||
pythonIndexUrl: string;
|
||||
npmRegistry: string;
|
||||
baseUrl: string;
|
||||
}>({
|
||||
pythonIndexUrl: '',
|
||||
npmRegistry: '',
|
||||
baseUrl: 'http://localhost:3000',
|
||||
})
|
||||
});
|
||||
|
||||
const [tempSmartRoutingConfig, setTempSmartRoutingConfig] = useState<{
|
||||
dbUrl: string
|
||||
openaiApiBaseUrl: string
|
||||
openaiApiKey: string
|
||||
openaiApiEmbeddingModel: string
|
||||
dbUrl: string;
|
||||
openaiApiBaseUrl: string;
|
||||
openaiApiKey: string;
|
||||
openaiApiEmbeddingModel: string;
|
||||
}>({
|
||||
dbUrl: '',
|
||||
openaiApiBaseUrl: '',
|
||||
openaiApiKey: '',
|
||||
openaiApiEmbeddingModel: '',
|
||||
})
|
||||
});
|
||||
|
||||
const [tempMCPRouterConfig, setTempMCPRouterConfig] = useState<{
|
||||
apiKey: string
|
||||
referer: string
|
||||
title: string
|
||||
baseUrl: string
|
||||
apiKey: string;
|
||||
referer: string;
|
||||
title: string;
|
||||
baseUrl: string;
|
||||
}>({
|
||||
apiKey: '',
|
||||
referer: 'https://www.mcphubx.com',
|
||||
title: 'MCPHub',
|
||||
baseUrl: 'https://api.mcprouter.to/v1',
|
||||
})
|
||||
});
|
||||
|
||||
const [tempNameSeparator, setTempNameSeparator] = useState<string>('-')
|
||||
const [tempNameSeparator, setTempNameSeparator] = useState<string>('-');
|
||||
|
||||
const [tempClusterConfig, setTempClusterConfig] = useState<{
|
||||
enabled: boolean;
|
||||
mode: 'standalone' | 'node' | 'coordinator';
|
||||
node: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
coordinatorUrl: string;
|
||||
heartbeatInterval?: number;
|
||||
registerOnStartup?: boolean;
|
||||
};
|
||||
coordinator: {
|
||||
nodeTimeout?: number;
|
||||
cleanupInterval?: number;
|
||||
stickySessionTimeout?: number;
|
||||
};
|
||||
stickySession: {
|
||||
enabled: boolean;
|
||||
strategy: 'consistent-hash' | 'cookie' | 'header';
|
||||
cookieName?: string;
|
||||
headerName?: string;
|
||||
};
|
||||
}>({
|
||||
enabled: false,
|
||||
mode: 'standalone',
|
||||
node: {
|
||||
id: '',
|
||||
name: '',
|
||||
coordinatorUrl: '',
|
||||
heartbeatInterval: 5000,
|
||||
registerOnStartup: true,
|
||||
},
|
||||
coordinator: {
|
||||
nodeTimeout: 15000,
|
||||
cleanupInterval: 30000,
|
||||
stickySessionTimeout: 3600000,
|
||||
},
|
||||
stickySession: {
|
||||
enabled: true,
|
||||
strategy: 'consistent-hash',
|
||||
cookieName: 'MCPHUB_NODE',
|
||||
headerName: 'X-MCPHub-Node',
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
routingConfig,
|
||||
@@ -58,6 +102,7 @@ const SettingsPage: React.FC = () => {
|
||||
installConfig: savedInstallConfig,
|
||||
smartRoutingConfig,
|
||||
mcpRouterConfig,
|
||||
clusterConfig,
|
||||
nameSeparator,
|
||||
loading,
|
||||
updateRoutingConfig,
|
||||
@@ -66,16 +111,17 @@ const SettingsPage: React.FC = () => {
|
||||
updateSmartRoutingConfig,
|
||||
updateSmartRoutingConfigBatch,
|
||||
updateMCPRouterConfig,
|
||||
updateClusterConfig,
|
||||
updateNameSeparator,
|
||||
exportMCPSettings,
|
||||
} = useSettingsData()
|
||||
} = useSettingsData();
|
||||
|
||||
// Update local installConfig when savedInstallConfig changes
|
||||
useEffect(() => {
|
||||
if (savedInstallConfig) {
|
||||
setInstallConfig(savedInstallConfig)
|
||||
setInstallConfig(savedInstallConfig);
|
||||
}
|
||||
}, [savedInstallConfig])
|
||||
}, [savedInstallConfig]);
|
||||
|
||||
// Update local tempSmartRoutingConfig when smartRoutingConfig changes
|
||||
useEffect(() => {
|
||||
@@ -85,9 +131,9 @@ const SettingsPage: React.FC = () => {
|
||||
openaiApiBaseUrl: smartRoutingConfig.openaiApiBaseUrl || '',
|
||||
openaiApiKey: smartRoutingConfig.openaiApiKey || '',
|
||||
openaiApiEmbeddingModel: smartRoutingConfig.openaiApiEmbeddingModel || '',
|
||||
})
|
||||
});
|
||||
}
|
||||
}, [smartRoutingConfig])
|
||||
}, [smartRoutingConfig]);
|
||||
|
||||
// Update local tempMCPRouterConfig when mcpRouterConfig changes
|
||||
useEffect(() => {
|
||||
@@ -97,24 +143,53 @@ const SettingsPage: React.FC = () => {
|
||||
referer: mcpRouterConfig.referer || 'https://www.mcphubx.com',
|
||||
title: mcpRouterConfig.title || 'MCPHub',
|
||||
baseUrl: mcpRouterConfig.baseUrl || 'https://api.mcprouter.to/v1',
|
||||
})
|
||||
});
|
||||
}
|
||||
}, [mcpRouterConfig])
|
||||
}, [mcpRouterConfig]);
|
||||
|
||||
// Update local tempNameSeparator when nameSeparator changes
|
||||
useEffect(() => {
|
||||
setTempNameSeparator(nameSeparator)
|
||||
}, [nameSeparator])
|
||||
setTempNameSeparator(nameSeparator);
|
||||
}, [nameSeparator]);
|
||||
|
||||
// Update local tempClusterConfig when clusterConfig changes
|
||||
useEffect(() => {
|
||||
if (clusterConfig) {
|
||||
setTempClusterConfig({
|
||||
enabled: clusterConfig.enabled ?? false,
|
||||
mode: clusterConfig.mode || 'standalone',
|
||||
node: clusterConfig.node || {
|
||||
id: '',
|
||||
name: '',
|
||||
coordinatorUrl: '',
|
||||
heartbeatInterval: 5000,
|
||||
registerOnStartup: true,
|
||||
},
|
||||
coordinator: clusterConfig.coordinator || {
|
||||
nodeTimeout: 15000,
|
||||
cleanupInterval: 30000,
|
||||
stickySessionTimeout: 3600000,
|
||||
},
|
||||
stickySession: clusterConfig.stickySession || {
|
||||
enabled: true,
|
||||
strategy: 'consistent-hash',
|
||||
cookieName: 'MCPHUB_NODE',
|
||||
headerName: 'X-MCPHub-Node',
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [clusterConfig]);
|
||||
|
||||
const [sectionsVisible, setSectionsVisible] = useState({
|
||||
routingConfig: false,
|
||||
installConfig: false,
|
||||
smartRoutingConfig: false,
|
||||
mcpRouterConfig: false,
|
||||
clusterConfig: false,
|
||||
nameSeparator: false,
|
||||
password: false,
|
||||
exportConfig: false,
|
||||
})
|
||||
});
|
||||
|
||||
const toggleSection = (
|
||||
section:
|
||||
@@ -122,6 +197,7 @@ const SettingsPage: React.FC = () => {
|
||||
| 'installConfig'
|
||||
| 'smartRoutingConfig'
|
||||
| 'mcpRouterConfig'
|
||||
| 'clusterConfig'
|
||||
| 'nameSeparator'
|
||||
| 'password'
|
||||
| 'exportConfig',
|
||||
@@ -129,8 +205,8 @@ const SettingsPage: React.FC = () => {
|
||||
setSectionsVisible((prev) => ({
|
||||
...prev,
|
||||
[section]: !prev[section],
|
||||
}))
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRoutingConfigChange = async (
|
||||
key:
|
||||
@@ -144,39 +220,39 @@ const SettingsPage: React.FC = () => {
|
||||
// If enableBearerAuth is turned on and there's no key, generate one first
|
||||
if (key === 'enableBearerAuth' && value === true) {
|
||||
if (!tempRoutingConfig.bearerAuthKey && !routingConfig.bearerAuthKey) {
|
||||
const newKey = generateRandomKey()
|
||||
handleBearerAuthKeyChange(newKey)
|
||||
const newKey = generateRandomKey();
|
||||
handleBearerAuthKeyChange(newKey);
|
||||
|
||||
// Update both enableBearerAuth and bearerAuthKey in a single call
|
||||
const success = await updateRoutingConfigBatch({
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: newKey,
|
||||
})
|
||||
});
|
||||
|
||||
if (success) {
|
||||
// Update tempRoutingConfig to reflect the saved values
|
||||
setTempRoutingConfig((prev) => ({
|
||||
...prev,
|
||||
bearerAuthKey: newKey,
|
||||
}))
|
||||
}));
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await updateRoutingConfig(key, value)
|
||||
}
|
||||
await updateRoutingConfig(key, value);
|
||||
};
|
||||
|
||||
const handleBearerAuthKeyChange = (value: string) => {
|
||||
setTempRoutingConfig((prev) => ({
|
||||
...prev,
|
||||
bearerAuthKey: value,
|
||||
}))
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const saveBearerAuthKey = async () => {
|
||||
await updateRoutingConfig('bearerAuthKey', tempRoutingConfig.bearerAuthKey)
|
||||
}
|
||||
await updateRoutingConfig('bearerAuthKey', tempRoutingConfig.bearerAuthKey);
|
||||
};
|
||||
|
||||
const handleInstallConfigChange = (
|
||||
key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl',
|
||||
@@ -185,12 +261,12 @@ const SettingsPage: React.FC = () => {
|
||||
setInstallConfig({
|
||||
...installConfig,
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const saveInstallConfig = async (key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl') => {
|
||||
await updateInstallConfig(key, installConfig[key])
|
||||
}
|
||||
await updateInstallConfig(key, installConfig[key]);
|
||||
};
|
||||
|
||||
const handleSmartRoutingConfigChange = (
|
||||
key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel',
|
||||
@@ -199,14 +275,14 @@ const SettingsPage: React.FC = () => {
|
||||
setTempSmartRoutingConfig({
|
||||
...tempSmartRoutingConfig,
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const saveSmartRoutingConfig = async (
|
||||
key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel',
|
||||
) => {
|
||||
await updateSmartRoutingConfig(key, tempSmartRoutingConfig[key])
|
||||
}
|
||||
await updateSmartRoutingConfig(key, tempSmartRoutingConfig[key]);
|
||||
};
|
||||
|
||||
const handleMCPRouterConfigChange = (
|
||||
key: 'apiKey' | 'referer' | 'title' | 'baseUrl',
|
||||
@@ -215,141 +291,141 @@ const SettingsPage: React.FC = () => {
|
||||
setTempMCPRouterConfig({
|
||||
...tempMCPRouterConfig,
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const saveMCPRouterConfig = async (key: 'apiKey' | 'referer' | 'title' | 'baseUrl') => {
|
||||
await updateMCPRouterConfig(key, tempMCPRouterConfig[key])
|
||||
}
|
||||
await updateMCPRouterConfig(key, tempMCPRouterConfig[key]);
|
||||
};
|
||||
|
||||
const saveNameSeparator = async () => {
|
||||
await updateNameSeparator(tempNameSeparator)
|
||||
}
|
||||
await updateNameSeparator(tempNameSeparator);
|
||||
};
|
||||
|
||||
const handleSmartRoutingEnabledChange = async (value: boolean) => {
|
||||
// If enabling Smart Routing, validate required fields and save any unsaved changes
|
||||
if (value) {
|
||||
const currentDbUrl = tempSmartRoutingConfig.dbUrl || smartRoutingConfig.dbUrl
|
||||
const currentDbUrl = tempSmartRoutingConfig.dbUrl || smartRoutingConfig.dbUrl;
|
||||
const currentOpenaiApiKey =
|
||||
tempSmartRoutingConfig.openaiApiKey || smartRoutingConfig.openaiApiKey
|
||||
tempSmartRoutingConfig.openaiApiKey || smartRoutingConfig.openaiApiKey;
|
||||
|
||||
if (!currentDbUrl || !currentOpenaiApiKey) {
|
||||
const missingFields = []
|
||||
if (!currentDbUrl) missingFields.push(t('settings.dbUrl'))
|
||||
if (!currentOpenaiApiKey) missingFields.push(t('settings.openaiApiKey'))
|
||||
const missingFields = [];
|
||||
if (!currentDbUrl) missingFields.push(t('settings.dbUrl'));
|
||||
if (!currentOpenaiApiKey) missingFields.push(t('settings.openaiApiKey'));
|
||||
|
||||
showToast(
|
||||
t('settings.smartRoutingValidationError', {
|
||||
fields: missingFields.join(', '),
|
||||
}),
|
||||
)
|
||||
return
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare updates object with unsaved changes and enabled status
|
||||
const updates: any = { enabled: value }
|
||||
const updates: any = { enabled: value };
|
||||
|
||||
// Check for unsaved changes and include them in the batch update
|
||||
if (tempSmartRoutingConfig.dbUrl !== smartRoutingConfig.dbUrl) {
|
||||
updates.dbUrl = tempSmartRoutingConfig.dbUrl
|
||||
updates.dbUrl = tempSmartRoutingConfig.dbUrl;
|
||||
}
|
||||
if (tempSmartRoutingConfig.openaiApiBaseUrl !== smartRoutingConfig.openaiApiBaseUrl) {
|
||||
updates.openaiApiBaseUrl = tempSmartRoutingConfig.openaiApiBaseUrl
|
||||
updates.openaiApiBaseUrl = tempSmartRoutingConfig.openaiApiBaseUrl;
|
||||
}
|
||||
if (tempSmartRoutingConfig.openaiApiKey !== smartRoutingConfig.openaiApiKey) {
|
||||
updates.openaiApiKey = tempSmartRoutingConfig.openaiApiKey
|
||||
updates.openaiApiKey = tempSmartRoutingConfig.openaiApiKey;
|
||||
}
|
||||
if (
|
||||
tempSmartRoutingConfig.openaiApiEmbeddingModel !==
|
||||
smartRoutingConfig.openaiApiEmbeddingModel
|
||||
) {
|
||||
updates.openaiApiEmbeddingModel = tempSmartRoutingConfig.openaiApiEmbeddingModel
|
||||
updates.openaiApiEmbeddingModel = tempSmartRoutingConfig.openaiApiEmbeddingModel;
|
||||
}
|
||||
|
||||
// Save all changes in a single batch update
|
||||
await updateSmartRoutingConfigBatch(updates)
|
||||
await updateSmartRoutingConfigBatch(updates);
|
||||
} else {
|
||||
// If disabling, just update the enabled status
|
||||
await updateSmartRoutingConfig('enabled', value)
|
||||
await updateSmartRoutingConfig('enabled', value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordChangeSuccess = () => {
|
||||
setTimeout(() => {
|
||||
navigate('/')
|
||||
}, 2000)
|
||||
}
|
||||
navigate('/');
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const [copiedConfig, setCopiedConfig] = useState(false)
|
||||
const [mcpSettingsJson, setMcpSettingsJson] = useState<string>('')
|
||||
const [copiedConfig, setCopiedConfig] = useState(false);
|
||||
const [mcpSettingsJson, setMcpSettingsJson] = useState<string>('');
|
||||
|
||||
const fetchMcpSettings = async () => {
|
||||
try {
|
||||
const result = await exportMCPSettings()
|
||||
console.log('Fetched MCP settings:', result)
|
||||
const configJson = JSON.stringify(result.data, null, 2)
|
||||
setMcpSettingsJson(configJson)
|
||||
const result = await exportMCPSettings();
|
||||
console.log('Fetched MCP settings:', result);
|
||||
const configJson = JSON.stringify(result.data, null, 2);
|
||||
setMcpSettingsJson(configJson);
|
||||
} catch (error) {
|
||||
console.error('Error fetching MCP settings:', error)
|
||||
showToast(t('settings.exportError') || 'Failed to fetch settings', 'error')
|
||||
console.error('Error fetching MCP settings:', error);
|
||||
showToast(t('settings.exportError') || 'Failed to fetch settings', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (sectionsVisible.exportConfig && !mcpSettingsJson) {
|
||||
fetchMcpSettings()
|
||||
fetchMcpSettings();
|
||||
}
|
||||
}, [sectionsVisible.exportConfig])
|
||||
}, [sectionsVisible.exportConfig]);
|
||||
|
||||
const handleCopyConfig = async () => {
|
||||
if (!mcpSettingsJson) return
|
||||
if (!mcpSettingsJson) return;
|
||||
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(mcpSettingsJson)
|
||||
setCopiedConfig(true)
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
|
||||
setTimeout(() => setCopiedConfig(false), 2000)
|
||||
await navigator.clipboard.writeText(mcpSettingsJson);
|
||||
setCopiedConfig(true);
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success');
|
||||
setTimeout(() => setCopiedConfig(false), 2000);
|
||||
} else {
|
||||
// Fallback for HTTP or unsupported clipboard API
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = mcpSettingsJson
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-9999px'
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = mcpSettingsJson;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
setCopiedConfig(true)
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
|
||||
setTimeout(() => setCopiedConfig(false), 2000)
|
||||
document.execCommand('copy');
|
||||
setCopiedConfig(true);
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success');
|
||||
setTimeout(() => setCopiedConfig(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);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error copying configuration:', error)
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error')
|
||||
console.error('Error copying configuration:', error);
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadConfig = () => {
|
||||
if (!mcpSettingsJson) return
|
||||
if (!mcpSettingsJson) return;
|
||||
|
||||
const blob = new Blob([mcpSettingsJson], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'mcp_settings.json'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
showToast(t('settings.exportSuccess') || 'Settings exported successfully', 'success')
|
||||
}
|
||||
const blob = new Blob([mcpSettingsJson], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'mcp_settings.json';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
showToast(t('settings.exportSuccess') || 'Settings exported successfully', 'success');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto">
|
||||
@@ -563,6 +639,432 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</PermissionChecker>
|
||||
|
||||
{/* Cluster Configuration Settings */}
|
||||
<PermissionChecker permissions={PERMISSIONS.SETTINGS_CLUSTER_CONFIG}>
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 page-card dashboard-card">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer transition-colors duration-200 hover:text-blue-600"
|
||||
onClick={() => toggleSection('clusterConfig')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('settings.clusterConfig')}</h2>
|
||||
<span className="text-gray-500 transition-transform duration-200">
|
||||
{sectionsVisible.clusterConfig ? '▼' : '►'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.clusterConfig && (
|
||||
<div className="space-y-4 mt-4">
|
||||
{/* Enable Cluster Mode */}
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.clusterEnabled')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.clusterEnabledDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={tempClusterConfig.enabled}
|
||||
onCheckedChange={(checked) => {
|
||||
setTempClusterConfig((prev) => ({ ...prev, enabled: checked }));
|
||||
updateClusterConfig({ enabled: checked });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cluster Mode Selection */}
|
||||
{tempClusterConfig.enabled && (
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.clusterMode')}</h3>
|
||||
<p className="text-sm text-gray-500">{t('settings.clusterModeDescription')}</p>
|
||||
</div>
|
||||
<select
|
||||
value={tempClusterConfig.mode}
|
||||
onChange={(e) => {
|
||||
const mode = e.target.value as 'standalone' | 'node' | 'coordinator';
|
||||
setTempClusterConfig((prev) => ({ ...prev, mode }));
|
||||
updateClusterConfig({ mode });
|
||||
}}
|
||||
className="mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="standalone">{t('settings.clusterModeStandalone')}</option>
|
||||
<option value="node">{t('settings.clusterModeNode')}</option>
|
||||
<option value="coordinator">{t('settings.clusterModeCoordinator')}</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Node Configuration */}
|
||||
{tempClusterConfig.enabled && tempClusterConfig.mode === 'node' && (
|
||||
<div className="p-3 bg-blue-50 border border-blue-200 rounded-md space-y-3">
|
||||
<h3 className="font-semibold text-gray-800 mb-2">{t('settings.nodeConfig')}</h3>
|
||||
|
||||
{/* Coordinator URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('settings.coordinatorUrl')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
{t('settings.coordinatorUrlDescription')}
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={tempClusterConfig.node.coordinatorUrl}
|
||||
onChange={(e) => {
|
||||
const coordinatorUrl = e.target.value;
|
||||
setTempClusterConfig((prev) => ({
|
||||
...prev,
|
||||
node: { ...prev.node, coordinatorUrl },
|
||||
}));
|
||||
}}
|
||||
onBlur={() => updateClusterConfig({ node: { ...tempClusterConfig.node } })}
|
||||
placeholder={t('settings.coordinatorUrlPlaceholder')}
|
||||
className="block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Node ID */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('settings.nodeId')}
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mb-2">{t('settings.nodeIdDescription')}</p>
|
||||
<input
|
||||
type="text"
|
||||
value={tempClusterConfig.node.id || ''}
|
||||
onChange={(e) => {
|
||||
const id = e.target.value;
|
||||
setTempClusterConfig((prev) => ({
|
||||
...prev,
|
||||
node: { ...prev.node, id },
|
||||
}));
|
||||
}}
|
||||
onBlur={() => updateClusterConfig({ node: { ...tempClusterConfig.node } })}
|
||||
placeholder={t('settings.nodeIdPlaceholder')}
|
||||
className="block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Node Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('settings.nodeName')}
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
{t('settings.nodeNameDescription')}
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={tempClusterConfig.node.name || ''}
|
||||
onChange={(e) => {
|
||||
const name = e.target.value;
|
||||
setTempClusterConfig((prev) => ({
|
||||
...prev,
|
||||
node: { ...prev.node, name },
|
||||
}));
|
||||
}}
|
||||
onBlur={() => updateClusterConfig({ node: { ...tempClusterConfig.node } })}
|
||||
placeholder={t('settings.nodeNamePlaceholder')}
|
||||
className="block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Heartbeat Interval */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('settings.heartbeatInterval')}
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
{t('settings.heartbeatIntervalDescription')}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
value={tempClusterConfig.node.heartbeatInterval || 5000}
|
||||
onChange={(e) => {
|
||||
const heartbeatInterval = parseInt(e.target.value);
|
||||
setTempClusterConfig((prev) => ({
|
||||
...prev,
|
||||
node: { ...prev.node, heartbeatInterval },
|
||||
}));
|
||||
}}
|
||||
onBlur={() => updateClusterConfig({ node: { ...tempClusterConfig.node } })}
|
||||
placeholder={t('settings.heartbeatIntervalPlaceholder')}
|
||||
className="block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
disabled={loading}
|
||||
min="1000"
|
||||
step="1000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Register on Startup */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('settings.registerOnStartup')}
|
||||
</label>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t('settings.registerOnStartupDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={tempClusterConfig.node.registerOnStartup ?? true}
|
||||
onCheckedChange={(checked) => {
|
||||
setTempClusterConfig((prev) => ({
|
||||
...prev,
|
||||
node: { ...prev.node, registerOnStartup: checked },
|
||||
}));
|
||||
updateClusterConfig({
|
||||
node: { ...tempClusterConfig.node, registerOnStartup: checked },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Coordinator Configuration */}
|
||||
{tempClusterConfig.enabled && tempClusterConfig.mode === 'coordinator' && (
|
||||
<div className="p-3 bg-purple-50 border border-purple-200 rounded-md space-y-3">
|
||||
<h3 className="font-semibold text-gray-800 mb-2">
|
||||
{t('settings.coordinatorConfig')}
|
||||
</h3>
|
||||
|
||||
{/* Node Timeout */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('settings.nodeTimeout')}
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
{t('settings.nodeTimeoutDescription')}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
value={tempClusterConfig.coordinator.nodeTimeout || 15000}
|
||||
onChange={(e) => {
|
||||
const nodeTimeout = parseInt(e.target.value);
|
||||
setTempClusterConfig((prev) => ({
|
||||
...prev,
|
||||
coordinator: { ...prev.coordinator, nodeTimeout },
|
||||
}));
|
||||
}}
|
||||
onBlur={() =>
|
||||
updateClusterConfig({ coordinator: { ...tempClusterConfig.coordinator } })
|
||||
}
|
||||
placeholder={t('settings.nodeTimeoutPlaceholder')}
|
||||
className="block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
disabled={loading}
|
||||
min="5000"
|
||||
step="1000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cleanup Interval */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('settings.cleanupInterval')}
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
{t('settings.cleanupIntervalDescription')}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
value={tempClusterConfig.coordinator.cleanupInterval || 30000}
|
||||
onChange={(e) => {
|
||||
const cleanupInterval = parseInt(e.target.value);
|
||||
setTempClusterConfig((prev) => ({
|
||||
...prev,
|
||||
coordinator: { ...prev.coordinator, cleanupInterval },
|
||||
}));
|
||||
}}
|
||||
onBlur={() =>
|
||||
updateClusterConfig({ coordinator: { ...tempClusterConfig.coordinator } })
|
||||
}
|
||||
placeholder={t('settings.cleanupIntervalPlaceholder')}
|
||||
className="block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
disabled={loading}
|
||||
min="10000"
|
||||
step="5000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sticky Session Timeout */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('settings.stickySessionTimeout')}
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
{t('settings.stickySessionTimeoutDescription')}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
value={tempClusterConfig.coordinator.stickySessionTimeout || 3600000}
|
||||
onChange={(e) => {
|
||||
const stickySessionTimeout = parseInt(e.target.value);
|
||||
setTempClusterConfig((prev) => ({
|
||||
...prev,
|
||||
coordinator: { ...prev.coordinator, stickySessionTimeout },
|
||||
}));
|
||||
}}
|
||||
onBlur={() =>
|
||||
updateClusterConfig({ coordinator: { ...tempClusterConfig.coordinator } })
|
||||
}
|
||||
placeholder={t('settings.stickySessionTimeoutPlaceholder')}
|
||||
className="block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
disabled={loading}
|
||||
min="60000"
|
||||
step="60000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sticky Session Configuration */}
|
||||
{tempClusterConfig.enabled &&
|
||||
(tempClusterConfig.mode === 'coordinator' || tempClusterConfig.mode === 'node') && (
|
||||
<div className="p-3 bg-green-50 border border-green-200 rounded-md space-y-3">
|
||||
<h3 className="font-semibold text-gray-800 mb-2">
|
||||
{t('settings.stickySessionConfig')}
|
||||
</h3>
|
||||
|
||||
{/* Enable Sticky Sessions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('settings.stickySessionEnabled')}
|
||||
</label>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t('settings.stickySessionEnabledDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={tempClusterConfig.stickySession.enabled}
|
||||
onCheckedChange={(checked) => {
|
||||
setTempClusterConfig((prev) => ({
|
||||
...prev,
|
||||
stickySession: { ...prev.stickySession, enabled: checked },
|
||||
}));
|
||||
updateClusterConfig({
|
||||
stickySession: { ...tempClusterConfig.stickySession, enabled: checked },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{tempClusterConfig.stickySession.enabled && (
|
||||
<>
|
||||
{/* Session Strategy */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('settings.stickySessionStrategy')}
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
{t('settings.stickySessionStrategyDescription')}
|
||||
</p>
|
||||
<select
|
||||
value={tempClusterConfig.stickySession.strategy}
|
||||
onChange={(e) => {
|
||||
const strategy = e.target.value as
|
||||
| 'consistent-hash'
|
||||
| 'cookie'
|
||||
| 'header';
|
||||
setTempClusterConfig((prev) => ({
|
||||
...prev,
|
||||
stickySession: { ...prev.stickySession, strategy },
|
||||
}));
|
||||
updateClusterConfig({
|
||||
stickySession: { ...tempClusterConfig.stickySession, strategy },
|
||||
});
|
||||
}}
|
||||
className="block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="consistent-hash">
|
||||
{t('settings.stickySessionStrategyConsistentHash')}
|
||||
</option>
|
||||
<option value="cookie">
|
||||
{t('settings.stickySessionStrategyCookie')}
|
||||
</option>
|
||||
<option value="header">
|
||||
{t('settings.stickySessionStrategyHeader')}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Cookie Name (only for cookie strategy) */}
|
||||
{tempClusterConfig.stickySession.strategy === 'cookie' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('settings.cookieName')}
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
{t('settings.cookieNameDescription')}
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={tempClusterConfig.stickySession.cookieName || 'MCPHUB_NODE'}
|
||||
onChange={(e) => {
|
||||
const cookieName = e.target.value;
|
||||
setTempClusterConfig((prev) => ({
|
||||
...prev,
|
||||
stickySession: { ...prev.stickySession, cookieName },
|
||||
}));
|
||||
}}
|
||||
onBlur={() =>
|
||||
updateClusterConfig({
|
||||
stickySession: { ...tempClusterConfig.stickySession },
|
||||
})
|
||||
}
|
||||
placeholder={t('settings.cookieNamePlaceholder')}
|
||||
className="block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header Name (only for header strategy) */}
|
||||
{tempClusterConfig.stickySession.strategy === 'header' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('settings.headerName')}
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
{t('settings.headerNameDescription')}
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={tempClusterConfig.stickySession.headerName || 'X-MCPHub-Node'}
|
||||
onChange={(e) => {
|
||||
const headerName = e.target.value;
|
||||
setTempClusterConfig((prev) => ({
|
||||
...prev,
|
||||
stickySession: { ...prev.stickySession, headerName },
|
||||
}));
|
||||
}}
|
||||
onBlur={() =>
|
||||
updateClusterConfig({
|
||||
stickySession: { ...tempClusterConfig.stickySession },
|
||||
})
|
||||
}
|
||||
placeholder={t('settings.headerNamePlaceholder')}
|
||||
className="block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PermissionChecker>
|
||||
|
||||
{/* System Settings */}
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card">
|
||||
<div
|
||||
@@ -794,10 +1296,14 @@ const SettingsPage: React.FC = () => {
|
||||
</PermissionChecker>
|
||||
|
||||
{/* Change Password */}
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card">
|
||||
<div
|
||||
className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card"
|
||||
data-section="password"
|
||||
>
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('password')}
|
||||
role="button"
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('auth.changePassword')}</h2>
|
||||
<span className="text-gray-500">{sectionsVisible.password ? '▼' : '►'}</span>
|
||||
@@ -863,7 +1369,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
</PermissionChecker>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPage
|
||||
export default SettingsPage;
|
||||
|
||||
@@ -59,8 +59,9 @@ export const getPrompt = async (
|
||||
server?: string,
|
||||
): Promise<GetPromptResult> => {
|
||||
try {
|
||||
// URL-encode server and prompt names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
|
||||
const response = await apiPost(
|
||||
`/mcp/${server}/prompts/${encodeURIComponent(request.promptName)}`,
|
||||
`/mcp/${encodeURIComponent(server || '')}/prompts/${encodeURIComponent(request.promptName)}`,
|
||||
{
|
||||
name: request.promptName,
|
||||
arguments: request.arguments,
|
||||
@@ -94,9 +95,13 @@ export const togglePrompt = async (
|
||||
enabled: boolean,
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
const response = await apiPost<any>(`/servers/${serverName}/prompts/${promptName}/toggle`, {
|
||||
enabled,
|
||||
});
|
||||
// URL-encode server and prompt names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
|
||||
const response = await apiPost<any>(
|
||||
`/servers/${encodeURIComponent(serverName)}/prompts/${encodeURIComponent(promptName)}/toggle`,
|
||||
{
|
||||
enabled,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
success: response.success,
|
||||
@@ -120,8 +125,9 @@ export const updatePromptDescription = async (
|
||||
description: string,
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
// URL-encode server and prompt names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
|
||||
const response = await apiPut<any>(
|
||||
`/servers/${serverName}/prompts/${promptName}/description`,
|
||||
`/servers/${encodeURIComponent(serverName)}/prompts/${encodeURIComponent(promptName)}/description`,
|
||||
{ description },
|
||||
{
|
||||
headers: {
|
||||
|
||||
@@ -25,7 +25,10 @@ export const callTool = async (
|
||||
): Promise<ToolCallResult> => {
|
||||
try {
|
||||
// Construct the URL with optional server parameter
|
||||
const url = server ? `/tools/${server}/${request.toolName}` : '/tools/call';
|
||||
// URL-encode server and tool names to handle slashes in names (e.g., "com.atlassian/atlassian-mcp-server")
|
||||
const url = server
|
||||
? `/tools/${encodeURIComponent(server)}/${encodeURIComponent(request.toolName)}`
|
||||
: '/tools/call';
|
||||
|
||||
const response = await apiPost<any>(url, request.arguments, {
|
||||
headers: {
|
||||
@@ -62,8 +65,9 @@ export const toggleTool = async (
|
||||
enabled: boolean,
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
// URL-encode server and tool names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
|
||||
const response = await apiPost<any>(
|
||||
`/servers/${serverName}/tools/${toolName}/toggle`,
|
||||
`/servers/${encodeURIComponent(serverName)}/tools/${encodeURIComponent(toolName)}/toggle`,
|
||||
{ enabled },
|
||||
{
|
||||
headers: {
|
||||
@@ -94,8 +98,9 @@ export const updateToolDescription = async (
|
||||
description: string,
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
// URL-encode server and tool names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
|
||||
const response = await apiPut<any>(
|
||||
`/servers/${serverName}/tools/${toolName}/description`,
|
||||
`/servers/${encodeURIComponent(serverName)}/tools/${encodeURIComponent(toolName)}/description`,
|
||||
{ description },
|
||||
{
|
||||
headers: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Server status types
|
||||
export type ServerStatus = 'connecting' | 'connected' | 'disconnected';
|
||||
export type ServerStatus = 'connecting' | 'connected' | 'disconnected' | 'oauth_required';
|
||||
|
||||
// Market server types
|
||||
export interface MarketServerRepository {
|
||||
@@ -121,6 +121,43 @@ export interface ServerConfig {
|
||||
resetTimeoutOnProgress?: boolean; // Reset timeout on progress notifications
|
||||
maxTotalTimeout?: number; // Maximum total timeout in milliseconds
|
||||
}; // MCP request options configuration
|
||||
// OAuth authentication for upstream MCP servers
|
||||
oauth?: {
|
||||
clientId?: string; // OAuth client ID
|
||||
clientSecret?: string; // OAuth client secret
|
||||
scopes?: string[]; // Required OAuth scopes
|
||||
accessToken?: string; // Pre-obtained access token (if available)
|
||||
refreshToken?: string; // Refresh token for renewing access
|
||||
dynamicRegistration?: {
|
||||
enabled?: boolean; // Enable/disable dynamic registration
|
||||
issuer?: string; // OAuth issuer URL for discovery
|
||||
registrationEndpoint?: string; // Direct registration endpoint URL
|
||||
metadata?: {
|
||||
client_name?: string;
|
||||
client_uri?: string;
|
||||
logo_uri?: string;
|
||||
scope?: string;
|
||||
redirect_uris?: string[];
|
||||
grant_types?: string[];
|
||||
response_types?: string[];
|
||||
token_endpoint_auth_method?: string;
|
||||
contacts?: string[];
|
||||
software_id?: string;
|
||||
software_version?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
initialAccessToken?: string;
|
||||
};
|
||||
resource?: string; // OAuth resource parameter (RFC8707)
|
||||
authorizationEndpoint?: string; // Authorization endpoint (authorization code flow)
|
||||
tokenEndpoint?: string; // Token endpoint for exchanging authorization codes for tokens
|
||||
pendingAuthorization?: {
|
||||
authorizationUrl?: string;
|
||||
state?: string;
|
||||
codeVerifier?: string;
|
||||
createdAt?: number;
|
||||
};
|
||||
};
|
||||
// OpenAPI specific configuration
|
||||
openapi?: {
|
||||
url?: string; // OpenAPI specification URL
|
||||
@@ -172,6 +209,10 @@ export interface Server {
|
||||
prompts?: Prompt[];
|
||||
config?: ServerConfig;
|
||||
enabled?: boolean;
|
||||
oauth?: {
|
||||
authorizationUrl?: string;
|
||||
state?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Group types
|
||||
@@ -209,6 +250,16 @@ export interface ServerFormData {
|
||||
resetTimeoutOnProgress?: boolean;
|
||||
maxTotalTimeout?: number;
|
||||
};
|
||||
oauth?: {
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
scopes?: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
authorizationEndpoint?: string;
|
||||
tokenEndpoint?: string;
|
||||
resource?: string;
|
||||
};
|
||||
// OpenAPI specific fields
|
||||
openapi?: {
|
||||
url?: string;
|
||||
@@ -308,6 +359,7 @@ export interface AuthResponse {
|
||||
token?: string;
|
||||
user?: IUser;
|
||||
message?: string;
|
||||
isUsingDefaultPassword?: boolean;
|
||||
}
|
||||
|
||||
// Official Registry types (from registry.modelcontextprotocol.io)
|
||||
|
||||
38
frontend/src/utils/passwordValidation.ts
Normal file
38
frontend/src/utils/passwordValidation.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Frontend password strength validation utility
|
||||
* Should match backend validation rules
|
||||
*/
|
||||
|
||||
export interface PasswordValidationResult {
|
||||
isValid: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export const validatePasswordStrength = (password: string): PasswordValidationResult => {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Check minimum length
|
||||
if (password.length < 8) {
|
||||
errors.push('passwordMinLength');
|
||||
}
|
||||
|
||||
// Check for at least one letter
|
||||
if (!/[a-zA-Z]/.test(password)) {
|
||||
errors.push('passwordRequireLetter');
|
||||
}
|
||||
|
||||
// Check for at least one number
|
||||
if (!/\d/.test(password)) {
|
||||
errors.push('passwordRequireNumber');
|
||||
}
|
||||
|
||||
// Check for at least one special character
|
||||
if (!/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password)) {
|
||||
errors.push('passwordRequireSpecial');
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
};
|
||||
@@ -40,7 +40,7 @@ module.exports = {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||
},
|
||||
transformIgnorePatterns: ['node_modules/(?!(@modelcontextprotocol|other-esm-packages)/)'],
|
||||
transformIgnorePatterns: ['node_modules/(?!(@modelcontextprotocol|openid-client|oauth4webapi)/)'],
|
||||
extensionsToTreatAsEsm: ['.ts'],
|
||||
testTimeout: 30000,
|
||||
verbose: true,
|
||||
|
||||
120
locales/en.json
120
locales/en.json
@@ -69,7 +69,16 @@
|
||||
"changePasswordError": "Failed to change password",
|
||||
"changePassword": "Change Password",
|
||||
"passwordChanged": "Password changed successfully",
|
||||
"passwordChangeError": "Failed to change password"
|
||||
"passwordChangeError": "Failed to change password",
|
||||
"defaultPasswordWarning": "Default Password Security Warning",
|
||||
"defaultPasswordMessage": "You are using the default password (admin123), which poses a security risk. Please change your password immediately to protect your account.",
|
||||
"goToSettings": "Go to Settings",
|
||||
"passwordStrengthError": "Password does not meet security requirements",
|
||||
"passwordMinLength": "Password must be at least 8 characters long",
|
||||
"passwordRequireLetter": "Password must contain at least one letter",
|
||||
"passwordRequireNumber": "Password must contain at least one number",
|
||||
"passwordRequireSpecial": "Password must contain at least one special character",
|
||||
"passwordStrengthHint": "Password must be at least 8 characters and contain letters, numbers, and special characters"
|
||||
},
|
||||
"server": {
|
||||
"addServer": "Add Server",
|
||||
@@ -107,7 +116,7 @@
|
||||
"enabled": "Enabled",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"requestOptions": "Configuration",
|
||||
"requestOptions": "Connection Configuration",
|
||||
"timeout": "Request Timeout",
|
||||
"timeoutDescription": "Timeout for requests to the MCP server (ms)",
|
||||
"maxTotalTimeout": "Maximum Total Timeout",
|
||||
@@ -164,12 +173,28 @@
|
||||
"apiKeyInCookie": "Cookie",
|
||||
"passthroughHeaders": "Passthrough Headers",
|
||||
"passthroughHeadersHelp": "Comma-separated list of header names to pass through from tool call requests to upstream OpenAPI endpoints (e.g., Authorization, X-API-Key)"
|
||||
},
|
||||
"oauth": {
|
||||
"sectionTitle": "OAuth Configuration",
|
||||
"sectionDescription": "Configure client credentials for OAuth-protected servers (optional).",
|
||||
"clientId": "Client ID",
|
||||
"clientSecret": "Client Secret",
|
||||
"authorizationEndpoint": "Authorization Endpoint",
|
||||
"tokenEndpoint": "Token Endpoint",
|
||||
"scopes": "Scopes",
|
||||
"scopesPlaceholder": "scope1 scope2",
|
||||
"resource": "Resource / Audience",
|
||||
"accessToken": "Access Token",
|
||||
"refreshToken": "Refresh Token"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"connecting": "Connecting"
|
||||
"connecting": "Connecting",
|
||||
"oauthRequired": "OAuth Required",
|
||||
"clickToAuthorize": "Click to authorize with OAuth",
|
||||
"oauthWindowOpened": "OAuth authorization window opened. Please complete the authorization."
|
||||
},
|
||||
"errors": {
|
||||
"general": "Something went wrong",
|
||||
@@ -188,6 +213,7 @@
|
||||
"processing": "Processing...",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"back": "Back",
|
||||
"refresh": "Refresh",
|
||||
"create": "Create",
|
||||
"creating": "Creating...",
|
||||
@@ -548,6 +574,53 @@
|
||||
"systemSettings": "System Settings",
|
||||
"nameSeparatorLabel": "Name Separator",
|
||||
"nameSeparatorDescription": "Character used to separate server name and tool/prompt name (default: -)",
|
||||
"clusterConfig": "Cluster Configuration",
|
||||
"clusterEnabled": "Enable Cluster Mode",
|
||||
"clusterEnabledDescription": "Enable distributed cluster deployment for high availability and scalability",
|
||||
"clusterMode": "Cluster Mode",
|
||||
"clusterModeDescription": "Select the operating mode for this instance",
|
||||
"clusterModeStandalone": "Standalone",
|
||||
"clusterModeNode": "Node",
|
||||
"clusterModeCoordinator": "Coordinator",
|
||||
"nodeConfig": "Node Configuration",
|
||||
"nodeId": "Node ID",
|
||||
"nodeIdDescription": "Unique identifier for this node (auto-generated if not provided)",
|
||||
"nodeIdPlaceholder": "e.g. node-1",
|
||||
"nodeName": "Node Name",
|
||||
"nodeNameDescription": "Human-readable name for this node (defaults to hostname)",
|
||||
"nodeNamePlaceholder": "e.g. mcp-node-1",
|
||||
"coordinatorUrl": "Coordinator URL",
|
||||
"coordinatorUrlDescription": "URL of the coordinator node to register with",
|
||||
"coordinatorUrlPlaceholder": "http://coordinator:3000",
|
||||
"heartbeatInterval": "Heartbeat Interval (ms)",
|
||||
"heartbeatIntervalDescription": "Interval in milliseconds between heartbeat signals (default: 5000)",
|
||||
"heartbeatIntervalPlaceholder": "5000",
|
||||
"registerOnStartup": "Register on Startup",
|
||||
"registerOnStartupDescription": "Automatically register with coordinator when node starts (default: true)",
|
||||
"coordinatorConfig": "Coordinator Configuration",
|
||||
"nodeTimeout": "Node Timeout (ms)",
|
||||
"nodeTimeoutDescription": "Time in milliseconds before marking a node as unhealthy (default: 15000)",
|
||||
"nodeTimeoutPlaceholder": "15000",
|
||||
"cleanupInterval": "Cleanup Interval (ms)",
|
||||
"cleanupIntervalDescription": "Interval for cleaning up inactive nodes in milliseconds (default: 30000)",
|
||||
"cleanupIntervalPlaceholder": "30000",
|
||||
"stickySessionTimeout": "Sticky Session Timeout (ms)",
|
||||
"stickySessionTimeoutDescription": "Session timeout in milliseconds (default: 3600000 = 1 hour)",
|
||||
"stickySessionTimeoutPlaceholder": "3600000",
|
||||
"stickySessionConfig": "Sticky Session Configuration",
|
||||
"stickySessionEnabled": "Enable Sticky Sessions",
|
||||
"stickySessionEnabledDescription": "Enable session affinity to route requests from the same client to the same node",
|
||||
"stickySessionStrategy": "Session Strategy",
|
||||
"stickySessionStrategyDescription": "Strategy for maintaining session affinity",
|
||||
"stickySessionStrategyConsistentHash": "Consistent Hash",
|
||||
"stickySessionStrategyCookie": "Cookie",
|
||||
"stickySessionStrategyHeader": "Header",
|
||||
"cookieName": "Cookie Name",
|
||||
"cookieNameDescription": "Cookie name for cookie-based sticky sessions (default: MCPHUB_NODE)",
|
||||
"cookieNamePlaceholder": "MCPHUB_NODE",
|
||||
"headerName": "Header Name",
|
||||
"headerNameDescription": "Header name for header-based sticky sessions (default: X-MCPHub-Node)",
|
||||
"headerNamePlaceholder": "X-MCPHub-Node",
|
||||
"restartRequired": "Configuration saved. It is recommended to restart the application to ensure all services load the new settings correctly.",
|
||||
"exportMcpSettings": "Export Settings",
|
||||
"mcpSettingsJson": "MCP Settings JSON",
|
||||
@@ -582,6 +655,21 @@
|
||||
"serverExistsConfirm": "Server '{{serverName}}' already exists. Do you want to override it with the new version?",
|
||||
"override": "Override"
|
||||
},
|
||||
"jsonImport": {
|
||||
"button": "Import",
|
||||
"title": "Import Servers from JSON",
|
||||
"inputLabel": "Server Configuration JSON",
|
||||
"inputHelp": "Paste your server configuration JSON. Supports STDIO, SSE, and HTTP (streamable-http) server types.",
|
||||
"preview": "Preview",
|
||||
"previewTitle": "Preview Servers to Import",
|
||||
"import": "Import",
|
||||
"importing": "Importing...",
|
||||
"invalidFormat": "Invalid JSON format. The JSON must contain an 'mcpServers' object.",
|
||||
"parseError": "Failed to parse JSON. Please check the format and try again.",
|
||||
"addFailed": "Failed to add server",
|
||||
"importFailed": "Failed to import servers",
|
||||
"partialSuccess": "Imported {{count}} of {{total}} servers successfully. Some servers failed:"
|
||||
},
|
||||
"users": {
|
||||
"add": "Add User",
|
||||
"addNew": "Add New User",
|
||||
@@ -676,5 +764,31 @@
|
||||
"serverRemovedFromGroup": "Server removed from group successfully",
|
||||
"serverToolsUpdated": "Server tools updated successfully"
|
||||
}
|
||||
},
|
||||
"oauthCallback": {
|
||||
"authorizationFailed": "Authorization Failed",
|
||||
"authorizationFailedError": "Error",
|
||||
"authorizationFailedDetails": "Details",
|
||||
"invalidRequest": "Invalid Request",
|
||||
"missingStateParameter": "Missing required OAuth state parameter.",
|
||||
"missingCodeParameter": "Missing required authorization code parameter.",
|
||||
"serverNotFound": "Server Not Found",
|
||||
"serverNotFoundMessage": "Could not find server associated with this authorization request.",
|
||||
"sessionExpiredMessage": "The authorization session may have expired. Please try authorizing again.",
|
||||
"authorizationSuccessful": "Authorization Successful",
|
||||
"server": "Server",
|
||||
"status": "Status",
|
||||
"connected": "Connected",
|
||||
"successMessage": "The server has been successfully authorized and connected.",
|
||||
"autoCloseMessage": "This window will close automatically in 3 seconds...",
|
||||
"closeNow": "Close Now",
|
||||
"connectionError": "Connection Error",
|
||||
"connectionErrorMessage": "Authorization was successful, but failed to connect to the server.",
|
||||
"reconnectMessage": "Please try reconnecting from the dashboard.",
|
||||
"configurationError": "Configuration Error",
|
||||
"configurationErrorMessage": "Server transport does not support OAuth finishAuth(). Please ensure the server is configured with streamable-http transport.",
|
||||
"internalError": "Internal Error",
|
||||
"internalErrorMessage": "An unexpected error occurred while processing the OAuth callback.",
|
||||
"closeWindow": "Close Window"
|
||||
}
|
||||
}
|
||||
120
locales/fr.json
120
locales/fr.json
@@ -69,7 +69,16 @@
|
||||
"changePasswordError": "Échec du changement de mot de passe",
|
||||
"changePassword": "Changer le mot de passe",
|
||||
"passwordChanged": "Mot de passe changé avec succès",
|
||||
"passwordChangeError": "Échec du changement de mot de passe"
|
||||
"passwordChangeError": "Échec du changement de mot de passe",
|
||||
"defaultPasswordWarning": "Avertissement de sécurité du mot de passe par défaut",
|
||||
"defaultPasswordMessage": "Vous utilisez le mot de passe par défaut (admin123), ce qui présente un risque de sécurité. Veuillez changer votre mot de passe immédiatement pour protéger votre compte.",
|
||||
"goToSettings": "Aller aux paramètres",
|
||||
"passwordStrengthError": "Le mot de passe ne répond pas aux exigences de sécurité",
|
||||
"passwordMinLength": "Le mot de passe doit contenir au moins 8 caractères",
|
||||
"passwordRequireLetter": "Le mot de passe doit contenir au moins une lettre",
|
||||
"passwordRequireNumber": "Le mot de passe doit contenir au moins un chiffre",
|
||||
"passwordRequireSpecial": "Le mot de passe doit contenir au moins un caractère spécial",
|
||||
"passwordStrengthHint": "Le mot de passe doit contenir au moins 8 caractères et inclure des lettres, des chiffres et des caractères spéciaux"
|
||||
},
|
||||
"server": {
|
||||
"addServer": "Ajouter un serveur",
|
||||
@@ -107,7 +116,7 @@
|
||||
"enabled": "Activé",
|
||||
"enable": "Activer",
|
||||
"disable": "Désactiver",
|
||||
"requestOptions": "Configuration",
|
||||
"requestOptions": "Configuration de la connexion",
|
||||
"timeout": "Délai d'attente de la requête",
|
||||
"timeoutDescription": "Délai d'attente pour les requêtes vers le serveur MCP (ms)",
|
||||
"maxTotalTimeout": "Délai d'attente total maximum",
|
||||
@@ -164,12 +173,28 @@
|
||||
"apiKeyInCookie": "Cookie",
|
||||
"passthroughHeaders": "En-têtes de transmission",
|
||||
"passthroughHeadersHelp": "Liste séparée par des virgules des noms d'en-têtes à transmettre des requêtes d'appel d'outils vers les points de terminaison OpenAPI en amont (par ex. : Authorization, X-API-Key)"
|
||||
},
|
||||
"oauth": {
|
||||
"sectionTitle": "Configuration OAuth",
|
||||
"sectionDescription": "Configurez les identifiants client pour les serveurs protégés par OAuth (optionnel).",
|
||||
"clientId": "Identifiant client",
|
||||
"clientSecret": "Secret client",
|
||||
"authorizationEndpoint": "Point de terminaison d'autorisation",
|
||||
"tokenEndpoint": "Point de terminaison de jeton",
|
||||
"scopes": "Scopes",
|
||||
"scopesPlaceholder": "scope1 scope2",
|
||||
"resource": "Ressource / Audience",
|
||||
"accessToken": "Jeton d'accès",
|
||||
"refreshToken": "Jeton d'actualisation"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"online": "En ligne",
|
||||
"offline": "Hors ligne",
|
||||
"connecting": "Connexion en cours"
|
||||
"connecting": "Connexion en cours",
|
||||
"oauthRequired": "OAuth requis",
|
||||
"clickToAuthorize": "Cliquez pour autoriser avec OAuth",
|
||||
"oauthWindowOpened": "Fenêtre d'autorisation OAuth ouverte. Veuillez compléter l'autorisation."
|
||||
},
|
||||
"errors": {
|
||||
"general": "Une erreur est survenue",
|
||||
@@ -188,6 +213,7 @@
|
||||
"processing": "En cours de traitement...",
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
"back": "Retour",
|
||||
"refresh": "Actualiser",
|
||||
"create": "Créer",
|
||||
"creating": "Création en cours...",
|
||||
@@ -548,6 +574,53 @@
|
||||
"systemSettings": "Paramètres système",
|
||||
"nameSeparatorLabel": "Séparateur de noms",
|
||||
"nameSeparatorDescription": "Caractère utilisé pour séparer le nom du serveur et le nom de l'outil/prompt (par défaut : -)",
|
||||
"clusterConfig": "Configuration du cluster",
|
||||
"clusterEnabled": "Activer le mode cluster",
|
||||
"clusterEnabledDescription": "Activer le déploiement en cluster distribué pour la haute disponibilité et l'évolutivité",
|
||||
"clusterMode": "Mode cluster",
|
||||
"clusterModeDescription": "Sélectionnez le mode de fonctionnement pour cette instance",
|
||||
"clusterModeStandalone": "Autonome",
|
||||
"clusterModeNode": "Nœud",
|
||||
"clusterModeCoordinator": "Coordinateur",
|
||||
"nodeConfig": "Configuration du nœud",
|
||||
"nodeId": "ID du nœud",
|
||||
"nodeIdDescription": "Identifiant unique pour ce nœud (généré automatiquement si non fourni)",
|
||||
"nodeIdPlaceholder": "ex. node-1",
|
||||
"nodeName": "Nom du nœud",
|
||||
"nodeNameDescription": "Nom lisible par l'homme pour ce nœud (par défaut, nom d'hôte)",
|
||||
"nodeNamePlaceholder": "ex. mcp-node-1",
|
||||
"coordinatorUrl": "URL du coordinateur",
|
||||
"coordinatorUrlDescription": "URL du nœud coordinateur auquel s'inscrire",
|
||||
"coordinatorUrlPlaceholder": "http://coordinator:3000",
|
||||
"heartbeatInterval": "Intervalle de battement de cœur (ms)",
|
||||
"heartbeatIntervalDescription": "Intervalle en millisecondes entre les signaux de battement de cœur (par défaut : 5000)",
|
||||
"heartbeatIntervalPlaceholder": "5000",
|
||||
"registerOnStartup": "S'inscrire au démarrage",
|
||||
"registerOnStartupDescription": "S'inscrire automatiquement auprès du coordinateur au démarrage du nœud (par défaut : true)",
|
||||
"coordinatorConfig": "Configuration du coordinateur",
|
||||
"nodeTimeout": "Délai d'expiration du nœud (ms)",
|
||||
"nodeTimeoutDescription": "Temps en millisecondes avant de marquer un nœud comme non sain (par défaut : 15000)",
|
||||
"nodeTimeoutPlaceholder": "15000",
|
||||
"cleanupInterval": "Intervalle de nettoyage (ms)",
|
||||
"cleanupIntervalDescription": "Intervalle de nettoyage des nœuds inactifs en millisecondes (par défaut : 30000)",
|
||||
"cleanupIntervalPlaceholder": "30000",
|
||||
"stickySessionTimeout": "Délai d'expiration de la session persistante (ms)",
|
||||
"stickySessionTimeoutDescription": "Délai d'expiration de la session en millisecondes (par défaut : 3600000 = 1 heure)",
|
||||
"stickySessionTimeoutPlaceholder": "3600000",
|
||||
"stickySessionConfig": "Configuration de la session persistante",
|
||||
"stickySessionEnabled": "Activer les sessions persistantes",
|
||||
"stickySessionEnabledDescription": "Activer l'affinité de session pour acheminer les requêtes du même client vers le même nœud",
|
||||
"stickySessionStrategy": "Stratégie de session",
|
||||
"stickySessionStrategyDescription": "Stratégie pour maintenir l'affinité de session",
|
||||
"stickySessionStrategyConsistentHash": "Hachage cohérent",
|
||||
"stickySessionStrategyCookie": "Cookie",
|
||||
"stickySessionStrategyHeader": "En-tête",
|
||||
"cookieName": "Nom du cookie",
|
||||
"cookieNameDescription": "Nom du cookie pour les sessions persistantes basées sur les cookies (par défaut : MCPHUB_NODE)",
|
||||
"cookieNamePlaceholder": "MCPHUB_NODE",
|
||||
"headerName": "Nom de l'en-tête",
|
||||
"headerNameDescription": "Nom de l'en-tête pour les sessions persistantes basées sur les en-têtes (par défaut : X-MCPHub-Node)",
|
||||
"headerNamePlaceholder": "X-MCPHub-Node",
|
||||
"restartRequired": "Configuration enregistrée. Il est recommandé de redémarrer l'application pour s'assurer que tous les services chargent correctement les nouveaux paramètres.",
|
||||
"exportMcpSettings": "Exporter les paramètres",
|
||||
"mcpSettingsJson": "JSON des paramètres MCP",
|
||||
@@ -582,6 +655,21 @@
|
||||
"serverExistsConfirm": "Le serveur '{{serverName}}' existe déjà. Voulez-vous le remplacer par la nouvelle version ?",
|
||||
"override": "Remplacer"
|
||||
},
|
||||
"jsonImport": {
|
||||
"button": "Importer",
|
||||
"title": "Importer des serveurs depuis JSON",
|
||||
"inputLabel": "Configuration JSON du serveur",
|
||||
"inputHelp": "Collez votre configuration JSON de serveur. Prend en charge les types de serveurs STDIO, SSE et HTTP (streamable-http).",
|
||||
"preview": "Aperçu",
|
||||
"previewTitle": "Aperçu des serveurs à importer",
|
||||
"import": "Importer",
|
||||
"importing": "Importation en cours...",
|
||||
"invalidFormat": "Format JSON invalide. Le JSON doit contenir un objet 'mcpServers'.",
|
||||
"parseError": "Échec de l'analyse du JSON. Veuillez vérifier le format et réessayer.",
|
||||
"addFailed": "Échec de l'ajout du serveur",
|
||||
"importFailed": "Échec de l'importation des serveurs",
|
||||
"partialSuccess": "{{count}} serveur(s) sur {{total}} importé(s) avec succès. Certains serveurs ont échoué :"
|
||||
},
|
||||
"users": {
|
||||
"add": "Ajouter un utilisateur",
|
||||
"addNew": "Ajouter un nouvel utilisateur",
|
||||
@@ -676,5 +764,31 @@
|
||||
"serverRemovedFromGroup": "Serveur supprimé du groupe avec succès",
|
||||
"serverToolsUpdated": "Outils du serveur mis à jour avec succès"
|
||||
}
|
||||
},
|
||||
"oauthCallback": {
|
||||
"authorizationFailed": "Échec de l'autorisation",
|
||||
"authorizationFailedError": "Erreur",
|
||||
"authorizationFailedDetails": "Détails",
|
||||
"invalidRequest": "Requête invalide",
|
||||
"missingStateParameter": "Paramètre d'état OAuth requis manquant.",
|
||||
"missingCodeParameter": "Paramètre de code d'autorisation requis manquant.",
|
||||
"serverNotFound": "Serveur introuvable",
|
||||
"serverNotFoundMessage": "Impossible de trouver le serveur associé à cette demande d'autorisation.",
|
||||
"sessionExpiredMessage": "La session d'autorisation a peut-être expiré. Veuillez réessayer l'autorisation.",
|
||||
"authorizationSuccessful": "Autorisation réussie",
|
||||
"server": "Serveur",
|
||||
"status": "État",
|
||||
"connected": "Connecté",
|
||||
"successMessage": "Le serveur a été autorisé et connecté avec succès.",
|
||||
"autoCloseMessage": "Cette fenêtre se fermera automatiquement dans 3 secondes...",
|
||||
"closeNow": "Fermer maintenant",
|
||||
"connectionError": "Erreur de connexion",
|
||||
"connectionErrorMessage": "L'autorisation a réussi, mais la connexion au serveur a échoué.",
|
||||
"reconnectMessage": "Veuillez essayer de vous reconnecter à partir du tableau de bord.",
|
||||
"configurationError": "Erreur de configuration",
|
||||
"configurationErrorMessage": "Le transport du serveur ne prend pas en charge OAuth finishAuth(). Veuillez vous assurer que le serveur est configuré avec le transport streamable-http.",
|
||||
"internalError": "Erreur interne",
|
||||
"internalErrorMessage": "Une erreur inattendue s'est produite lors du traitement du callback OAuth.",
|
||||
"closeWindow": "Fermer la fenêtre"
|
||||
}
|
||||
}
|
||||
120
locales/zh.json
120
locales/zh.json
@@ -69,7 +69,16 @@
|
||||
"changePasswordError": "修改密码失败",
|
||||
"changePassword": "修改密码",
|
||||
"passwordChanged": "密码修改成功",
|
||||
"passwordChangeError": "修改密码失败"
|
||||
"passwordChangeError": "修改密码失败",
|
||||
"defaultPasswordWarning": "默认密码安全警告",
|
||||
"defaultPasswordMessage": "您正在使用默认密码(admin123),这存在安全风险。为了保护您的账户安全,请立即修改密码。",
|
||||
"goToSettings": "前往修改",
|
||||
"passwordStrengthError": "密码不符合安全要求",
|
||||
"passwordMinLength": "密码长度至少为 8 个字符",
|
||||
"passwordRequireLetter": "密码必须包含至少一个字母",
|
||||
"passwordRequireNumber": "密码必须包含至少一个数字",
|
||||
"passwordRequireSpecial": "密码必须包含至少一个特殊字符",
|
||||
"passwordStrengthHint": "密码必须至少 8 个字符,且包含字母、数字和特殊字符"
|
||||
},
|
||||
"server": {
|
||||
"addServer": "添加服务器",
|
||||
@@ -107,7 +116,7 @@
|
||||
"enabled": "已启用",
|
||||
"enable": "启用",
|
||||
"disable": "禁用",
|
||||
"requestOptions": "配置",
|
||||
"requestOptions": "连接配置",
|
||||
"timeout": "请求超时",
|
||||
"timeoutDescription": "请求超时时间(毫秒)",
|
||||
"maxTotalTimeout": "最大总超时",
|
||||
@@ -164,12 +173,28 @@
|
||||
"apiKeyInCookie": "Cookie",
|
||||
"passthroughHeaders": "透传请求头",
|
||||
"passthroughHeadersHelp": "要从工具调用请求透传到上游OpenAPI接口的请求头名称列表,用逗号分隔(如:Authorization, X-API-Key)"
|
||||
},
|
||||
"oauth": {
|
||||
"sectionTitle": "OAuth 配置",
|
||||
"sectionDescription": "为需要 OAuth 的服务器配置客户端凭据(可选)。",
|
||||
"clientId": "客户端 ID",
|
||||
"clientSecret": "客户端密钥",
|
||||
"authorizationEndpoint": "授权端点",
|
||||
"tokenEndpoint": "令牌端点",
|
||||
"scopes": "权限范围(Scopes)",
|
||||
"scopesPlaceholder": "scope1 scope2",
|
||||
"resource": "资源 / 受众",
|
||||
"accessToken": "访问令牌",
|
||||
"refreshToken": "刷新令牌"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"online": "在线",
|
||||
"offline": "离线",
|
||||
"connecting": "连接中"
|
||||
"connecting": "连接中",
|
||||
"oauthRequired": "需要OAuth授权",
|
||||
"clickToAuthorize": "点击进行OAuth授权",
|
||||
"oauthWindowOpened": "OAuth授权窗口已打开,请完成授权。"
|
||||
},
|
||||
"errors": {
|
||||
"general": "发生错误",
|
||||
@@ -189,6 +214,7 @@
|
||||
"processing": "处理中...",
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"back": "返回",
|
||||
"refresh": "刷新",
|
||||
"create": "创建",
|
||||
"creating": "创建中...",
|
||||
@@ -550,6 +576,53 @@
|
||||
"systemSettings": "系统设置",
|
||||
"nameSeparatorLabel": "名称分隔符",
|
||||
"nameSeparatorDescription": "用于分隔服务器名称和工具/提示名称(默认:-)",
|
||||
"clusterConfig": "集群配置",
|
||||
"clusterEnabled": "启用集群模式",
|
||||
"clusterEnabledDescription": "启用分布式集群部署,实现高可用和可扩展性",
|
||||
"clusterMode": "集群模式",
|
||||
"clusterModeDescription": "选择此实例的运行模式",
|
||||
"clusterModeStandalone": "独立模式",
|
||||
"clusterModeNode": "节点模式",
|
||||
"clusterModeCoordinator": "协调器模式",
|
||||
"nodeConfig": "节点配置",
|
||||
"nodeId": "节点 ID",
|
||||
"nodeIdDescription": "节点的唯一标识符(如果未提供则自动生成)",
|
||||
"nodeIdPlaceholder": "例如: node-1",
|
||||
"nodeName": "节点名称",
|
||||
"nodeNameDescription": "节点的可读名称(默认为主机名)",
|
||||
"nodeNamePlaceholder": "例如: mcp-node-1",
|
||||
"coordinatorUrl": "协调器地址",
|
||||
"coordinatorUrlDescription": "要注册的协调器节点的地址",
|
||||
"coordinatorUrlPlaceholder": "http://coordinator:3000",
|
||||
"heartbeatInterval": "心跳间隔(毫秒)",
|
||||
"heartbeatIntervalDescription": "心跳信号的发送间隔,单位为毫秒(默认:5000)",
|
||||
"heartbeatIntervalPlaceholder": "5000",
|
||||
"registerOnStartup": "启动时注册",
|
||||
"registerOnStartupDescription": "节点启动时自动向协调器注册(默认:true)",
|
||||
"coordinatorConfig": "协调器配置",
|
||||
"nodeTimeout": "节点超时(毫秒)",
|
||||
"nodeTimeoutDescription": "将节点标记为不健康之前的超时时间,单位为毫秒(默认:15000)",
|
||||
"nodeTimeoutPlaceholder": "15000",
|
||||
"cleanupInterval": "清理间隔(毫秒)",
|
||||
"cleanupIntervalDescription": "清理非活动节点的间隔时间,单位为毫秒(默认:30000)",
|
||||
"cleanupIntervalPlaceholder": "30000",
|
||||
"stickySessionTimeout": "会话超时(毫秒)",
|
||||
"stickySessionTimeoutDescription": "会话的超时时间,单位为毫秒(默认:3600000 = 1 小时)",
|
||||
"stickySessionTimeoutPlaceholder": "3600000",
|
||||
"stickySessionConfig": "会话保持配置",
|
||||
"stickySessionEnabled": "启用会话保持",
|
||||
"stickySessionEnabledDescription": "启用会话亲和性,将来自同一客户端的请求路由到同一节点",
|
||||
"stickySessionStrategy": "会话策略",
|
||||
"stickySessionStrategyDescription": "维护会话亲和性的策略",
|
||||
"stickySessionStrategyConsistentHash": "一致性哈希",
|
||||
"stickySessionStrategyCookie": "Cookie",
|
||||
"stickySessionStrategyHeader": "Header",
|
||||
"cookieName": "Cookie 名称",
|
||||
"cookieNameDescription": "基于 Cookie 的会话保持使用的 Cookie 名称(默认:MCPHUB_NODE)",
|
||||
"cookieNamePlaceholder": "MCPHUB_NODE",
|
||||
"headerName": "Header 名称",
|
||||
"headerNameDescription": "基于 Header 的会话保持使用的 Header 名称(默认:X-MCPHub-Node)",
|
||||
"headerNamePlaceholder": "X-MCPHub-Node",
|
||||
"restartRequired": "配置已保存。为确保所有服务正确加载新设置,建议重启应用。",
|
||||
"exportMcpSettings": "导出配置",
|
||||
"mcpSettingsJson": "MCP 配置 JSON",
|
||||
@@ -584,6 +657,21 @@
|
||||
"serverExistsConfirm": "服务器 '{{serverName}}' 已存在。是否要用新版本覆盖它?",
|
||||
"override": "覆盖"
|
||||
},
|
||||
"jsonImport": {
|
||||
"button": "导入",
|
||||
"title": "从 JSON 导入服务器",
|
||||
"inputLabel": "服务器配置 JSON",
|
||||
"inputHelp": "粘贴您的服务器配置 JSON。支持 STDIO、SSE 和 HTTP (streamable-http) 服务器类型。",
|
||||
"preview": "预览",
|
||||
"previewTitle": "预览要导入的服务器",
|
||||
"import": "导入",
|
||||
"importing": "导入中...",
|
||||
"invalidFormat": "无效的 JSON 格式。JSON 必须包含 'mcpServers' 对象。",
|
||||
"parseError": "解析 JSON 失败。请检查格式后重试。",
|
||||
"addFailed": "添加服务器失败",
|
||||
"importFailed": "导入服务器失败",
|
||||
"partialSuccess": "成功导入 {{count}} / {{total}} 个服务器。部分服务器失败:"
|
||||
},
|
||||
"users": {
|
||||
"add": "添加",
|
||||
"addNew": "添加新用户",
|
||||
@@ -678,5 +766,31 @@
|
||||
"serverRemovedFromGroup": "服务器从分组移除成功",
|
||||
"serverToolsUpdated": "服务器工具更新成功"
|
||||
}
|
||||
},
|
||||
"oauthCallback": {
|
||||
"authorizationFailed": "授权失败",
|
||||
"authorizationFailedError": "错误",
|
||||
"authorizationFailedDetails": "详情",
|
||||
"invalidRequest": "无效请求",
|
||||
"missingStateParameter": "缺少必需的 OAuth 状态参数。",
|
||||
"missingCodeParameter": "缺少必需的授权码参数。",
|
||||
"serverNotFound": "服务器未找到",
|
||||
"serverNotFoundMessage": "无法找到与此授权请求关联的服务器。",
|
||||
"sessionExpiredMessage": "授权会话可能已过期。请重新进行授权。",
|
||||
"authorizationSuccessful": "授权成功",
|
||||
"server": "服务器",
|
||||
"status": "状态",
|
||||
"connected": "已连接",
|
||||
"successMessage": "服务器已成功授权并连接。",
|
||||
"autoCloseMessage": "此窗口将在 3 秒后自动关闭...",
|
||||
"closeNow": "立即关闭",
|
||||
"connectionError": "连接错误",
|
||||
"connectionErrorMessage": "授权成功,但连接服务器失败。",
|
||||
"reconnectMessage": "请尝试从控制面板重新连接。",
|
||||
"configurationError": "配置错误",
|
||||
"configurationErrorMessage": "服务器传输不支持 OAuth finishAuth()。请确保服务器配置为 streamable-http 传输。",
|
||||
"internalError": "内部错误",
|
||||
"internalErrorMessage": "处理 OAuth 回调时发生意外错误。",
|
||||
"closeWindow": "关闭窗口"
|
||||
}
|
||||
}
|
||||
13310
package-lock.json
generated
13310
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -46,7 +46,7 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@apidevtools/swagger-parser": "^12.0.0",
|
||||
"@modelcontextprotocol/sdk": "^1.18.1",
|
||||
"@modelcontextprotocol/sdk": "^1.20.2",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/multer": "^1.4.13",
|
||||
@@ -66,6 +66,7 @@
|
||||
"multer": "^2.0.2",
|
||||
"openai": "^4.104.0",
|
||||
"openapi-types": "^12.1.3",
|
||||
"openid-client": "^6.8.1",
|
||||
"pg": "^8.16.3",
|
||||
"pgvector": "^0.2.1",
|
||||
"postgres": "^3.4.7",
|
||||
|
||||
31
pnpm-lock.yaml
generated
31
pnpm-lock.yaml
generated
@@ -16,8 +16,8 @@ importers:
|
||||
specifier: ^12.0.0
|
||||
version: 12.0.0(openapi-types@12.1.3)
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: ^1.18.1
|
||||
version: 1.18.1
|
||||
specifier: ^1.20.2
|
||||
version: 1.20.2
|
||||
'@types/adm-zip':
|
||||
specifier: ^0.5.7
|
||||
version: 0.5.7
|
||||
@@ -75,6 +75,9 @@ importers:
|
||||
openapi-types:
|
||||
specifier: ^12.1.3
|
||||
version: 12.1.3
|
||||
openid-client:
|
||||
specifier: ^6.8.1
|
||||
version: 6.8.1
|
||||
pg:
|
||||
specifier: ^8.16.3
|
||||
version: 8.16.3
|
||||
@@ -1085,8 +1088,8 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.9':
|
||||
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
|
||||
|
||||
'@modelcontextprotocol/sdk@1.18.1':
|
||||
resolution: {integrity: sha512-d//GE8/Yh7aC3e7p+kZG8JqqEAwwDUmAfvH1quogtbk+ksS6E0RR6toKKESPYYZVre0meqkJb27zb+dhqE9Sgw==}
|
||||
'@modelcontextprotocol/sdk@1.20.2':
|
||||
resolution: {integrity: sha512-6rqTdFt67AAAzln3NOKsXRmv5ZzPkgbfaebKBqUbts7vK1GZudqnrun5a8d3M/h955cam9RHZ6Jb4Y1XhnmFPg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
@@ -3267,6 +3270,9 @@ packages:
|
||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||
hasBin: true
|
||||
|
||||
jose@6.1.0:
|
||||
resolution: {integrity: sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
@@ -3681,6 +3687,9 @@ packages:
|
||||
resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
oauth4webapi@3.8.2:
|
||||
resolution: {integrity: sha512-FzZZ+bht5X0FKe7Mwz3DAVAmlH1BV5blSak/lHMBKz0/EBMhX6B10GlQYI51+oRp8ObJaX0g6pXrAxZh5s8rjw==}
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -3719,6 +3728,9 @@ packages:
|
||||
openapi-types@12.1.3:
|
||||
resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==}
|
||||
|
||||
openid-client@6.8.1:
|
||||
resolution: {integrity: sha512-VoYT6enBo6Vj2j3Q5Ec0AezS+9YGzQo1f5Xc42lreMGlfP4ljiXPKVDvCADh+XHCV/bqPu/wWSiCVXbJKvrODw==}
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -5569,7 +5581,7 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@modelcontextprotocol/sdk@1.18.1':
|
||||
'@modelcontextprotocol/sdk@1.20.2':
|
||||
dependencies:
|
||||
ajv: 6.12.6
|
||||
content-type: 1.0.5
|
||||
@@ -7993,6 +8005,8 @@ snapshots:
|
||||
|
||||
jiti@2.6.1: {}
|
||||
|
||||
jose@6.1.0: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-yaml@3.14.1:
|
||||
@@ -8325,6 +8339,8 @@ snapshots:
|
||||
dependencies:
|
||||
path-key: 4.0.0
|
||||
|
||||
oauth4webapi@3.8.2: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
@@ -8361,6 +8377,11 @@ snapshots:
|
||||
|
||||
openapi-types@12.1.3: {}
|
||||
|
||||
openid-client@6.8.1:
|
||||
dependencies:
|
||||
jose: 6.1.0
|
||||
oauth4webapi: 3.8.2
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import dotenv from 'dotenv'
|
||||
import fs from 'fs'
|
||||
import { McpSettings, IUser } from '../types/index.js'
|
||||
import { getConfigFilePath } from '../utils/path.js'
|
||||
import { getPackageVersion } from '../utils/version.js'
|
||||
import { getDataService } from '../services/services.js'
|
||||
import { DataService } from '../services/dataService.js'
|
||||
import dotenv from 'dotenv';
|
||||
import fs from 'fs';
|
||||
import { McpSettings, IUser } from '../types/index.js';
|
||||
import { getConfigFilePath } from '../utils/path.js';
|
||||
import { getPackageVersion } from '../utils/version.js';
|
||||
import { getDataService } from '../services/services.js';
|
||||
import { DataService } from '../services/dataService.js';
|
||||
|
||||
dotenv.config()
|
||||
dotenv.config();
|
||||
|
||||
const defaultConfig = {
|
||||
port: process.env.PORT || 3000,
|
||||
@@ -15,74 +15,74 @@ const defaultConfig = {
|
||||
readonly: 'true' === process.env.READONLY || false,
|
||||
mcpHubName: 'mcphub',
|
||||
mcpHubVersion: getPackageVersion(),
|
||||
}
|
||||
};
|
||||
|
||||
const dataService: DataService = getDataService()
|
||||
const dataService: DataService = getDataService();
|
||||
|
||||
// Settings cache
|
||||
let settingsCache: McpSettings | null = null
|
||||
let settingsCache: McpSettings | null = null;
|
||||
|
||||
export const getSettingsPath = (): string => {
|
||||
return getConfigFilePath('mcp_settings.json', 'Settings')
|
||||
}
|
||||
return getConfigFilePath('mcp_settings.json', 'Settings');
|
||||
};
|
||||
|
||||
export const loadOriginalSettings = (): McpSettings => {
|
||||
// If cache exists, return cached data directly
|
||||
if (settingsCache) {
|
||||
return settingsCache
|
||||
return settingsCache;
|
||||
}
|
||||
|
||||
const settingsPath = getSettingsPath()
|
||||
const settingsPath = getSettingsPath();
|
||||
// check if file exists
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
console.warn(`Settings file not found at ${settingsPath}, using default settings.`)
|
||||
const defaultSettings = { mcpServers: {}, users: [] }
|
||||
console.warn(`Settings file not found at ${settingsPath}, using default settings.`);
|
||||
const defaultSettings = { mcpServers: {}, users: [] };
|
||||
// Cache default settings
|
||||
settingsCache = defaultSettings
|
||||
return defaultSettings
|
||||
settingsCache = defaultSettings;
|
||||
return defaultSettings;
|
||||
}
|
||||
|
||||
try {
|
||||
// Read and parse settings file
|
||||
const settingsData = fs.readFileSync(settingsPath, 'utf8')
|
||||
const settings = JSON.parse(settingsData)
|
||||
const settingsData = fs.readFileSync(settingsPath, 'utf8');
|
||||
const settings = JSON.parse(settingsData);
|
||||
|
||||
// Update cache
|
||||
settingsCache = settings
|
||||
settingsCache = settings;
|
||||
|
||||
console.log(`Loaded settings from ${settingsPath}`)
|
||||
return settings
|
||||
console.log(`Loaded settings from ${settingsPath}`);
|
||||
return settings;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to load settings from ${settingsPath}: ${error}`)
|
||||
throw new Error(`Failed to load settings from ${settingsPath}: ${error}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const loadSettings = (user?: IUser): McpSettings => {
|
||||
return dataService.filterSettings!(loadOriginalSettings(), user)
|
||||
}
|
||||
return dataService.filterSettings!(loadOriginalSettings(), user);
|
||||
};
|
||||
|
||||
export const saveSettings = (settings: McpSettings, user?: IUser): boolean => {
|
||||
const settingsPath = getSettingsPath()
|
||||
const settingsPath = getSettingsPath();
|
||||
try {
|
||||
const mergedSettings = dataService.mergeSettings!(loadOriginalSettings(), settings, user)
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(mergedSettings, null, 2), 'utf8')
|
||||
const mergedSettings = dataService.mergeSettings!(loadOriginalSettings(), settings, user);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(mergedSettings, null, 2), 'utf8');
|
||||
|
||||
// Update cache after successful save
|
||||
settingsCache = mergedSettings
|
||||
settingsCache = mergedSettings;
|
||||
|
||||
return true
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Failed to save settings to ${settingsPath}:`, error)
|
||||
return false
|
||||
console.error(`Failed to save settings to ${settingsPath}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear settings cache, force next loadSettings call to re-read from file
|
||||
*/
|
||||
export const clearSettingsCache = (): void => {
|
||||
settingsCache = null
|
||||
}
|
||||
settingsCache = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current cache status (for debugging)
|
||||
@@ -90,60 +90,71 @@ export const clearSettingsCache = (): void => {
|
||||
export const getSettingsCacheInfo = (): { hasCache: boolean } => {
|
||||
return {
|
||||
hasCache: settingsCache !== null,
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export function replaceEnvVars(input: Record<string, any>): Record<string, any>
|
||||
export function replaceEnvVars(input: string[] | undefined): string[]
|
||||
export function replaceEnvVars(input: string): string
|
||||
export function replaceEnvVars(input: Record<string, any>): Record<string, any>;
|
||||
export function replaceEnvVars(input: string[] | undefined): string[];
|
||||
export function replaceEnvVars(input: string): string;
|
||||
export function replaceEnvVars(
|
||||
input: Record<string, any> | string[] | string | undefined,
|
||||
): Record<string, any> | string[] | string {
|
||||
// Handle object input
|
||||
// Handle object input - recursively expand all nested values
|
||||
if (input && typeof input === 'object' && !Array.isArray(input)) {
|
||||
const res: Record<string, string> = {}
|
||||
const res: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (typeof value === 'string') {
|
||||
res[key] = expandEnvVars(value)
|
||||
res[key] = expandEnvVars(value);
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
// Recursively handle nested objects and arrays
|
||||
res[key] = replaceEnvVars(value as any);
|
||||
} else {
|
||||
res[key] = String(value)
|
||||
// Preserve non-string, non-object values (numbers, booleans, etc.)
|
||||
res[key] = value;
|
||||
}
|
||||
}
|
||||
return res
|
||||
return res;
|
||||
}
|
||||
|
||||
// Handle array input
|
||||
// Handle array input - recursively expand all elements
|
||||
if (Array.isArray(input)) {
|
||||
return input.map((item) => expandEnvVars(item))
|
||||
return input.map((item) => {
|
||||
if (typeof item === 'string') {
|
||||
return expandEnvVars(item);
|
||||
} else if (typeof item === 'object' && item !== null) {
|
||||
return replaceEnvVars(item as any);
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
// Handle string input
|
||||
if (typeof input === 'string') {
|
||||
return expandEnvVars(input)
|
||||
return expandEnvVars(input);
|
||||
}
|
||||
|
||||
// Handle undefined/null array input
|
||||
if (input === undefined || input === null) {
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
|
||||
return input
|
||||
return input;
|
||||
}
|
||||
|
||||
export const expandEnvVars = (value: string): string => {
|
||||
if (typeof value !== 'string') {
|
||||
return String(value)
|
||||
return String(value);
|
||||
}
|
||||
// Replace ${VAR} format
|
||||
let result = value.replace(/\$\{([^}]+)\}/g, (_, key) => process.env[key] || '')
|
||||
let result = value.replace(/\$\{([^}]+)\}/g, (_, key) => process.env[key] || '');
|
||||
// Also replace $VAR format (common on Unix-like systems)
|
||||
result = result.replace(/\$([A-Z_][A-Z0-9_]*)/g, (_, key) => process.env[key] || '')
|
||||
return result
|
||||
}
|
||||
result = result.replace(/\$([A-Z_][A-Z0-9_]*)/g, (_, key) => process.env[key] || '');
|
||||
return result;
|
||||
};
|
||||
|
||||
export default defaultConfig
|
||||
export default defaultConfig;
|
||||
|
||||
export function getNameSeparator(): string {
|
||||
const settings = loadSettings()
|
||||
return settings.systemConfig?.nameSeparator || '-'
|
||||
const settings = loadSettings();
|
||||
return settings.systemConfig?.nameSeparator || '-';
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
import { getDataService } from '../services/services.js';
|
||||
import { DataService } from '../services/dataService.js';
|
||||
import { JWT_SECRET } from '../config/jwt.js';
|
||||
import { validatePasswordStrength, isDefaultPassword } from '../utils/passwordValidation.js';
|
||||
import { getPackageVersion } from '../utils/version.js';
|
||||
|
||||
const dataService: DataService = getDataService();
|
||||
|
||||
@@ -64,6 +66,11 @@ export const login = async (req: Request, res: Response): Promise<void> => {
|
||||
},
|
||||
};
|
||||
|
||||
// Check if user is admin with default password
|
||||
const version = getPackageVersion();
|
||||
const isUsingDefaultPassword =
|
||||
user.username === 'admin' && user.isAdmin && isDefaultPassword(password) && version !== 'dev';
|
||||
|
||||
jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY }, (err, token) => {
|
||||
if (err) throw err;
|
||||
res.json({
|
||||
@@ -75,6 +82,7 @@ export const login = async (req: Request, res: Response): Promise<void> => {
|
||||
isAdmin: user.isAdmin,
|
||||
permissions: dataService.getPermissions(user),
|
||||
},
|
||||
isUsingDefaultPassword,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -172,6 +180,17 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
|
||||
const username = (req as any).user.username;
|
||||
|
||||
try {
|
||||
// Validate new password strength
|
||||
const validationResult = validatePasswordStrength(newPassword);
|
||||
if (!validationResult.isValid) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Password does not meet security requirements',
|
||||
errors: validationResult.errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Find user by username
|
||||
const user = findUserByUsername(username);
|
||||
|
||||
|
||||
@@ -207,7 +207,8 @@ export const getCloudServersByTag = async (req: Request, res: Response): Promise
|
||||
// Get tools for a specific cloud server
|
||||
export const getCloudServerToolsList = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName } = req.params;
|
||||
// Decode URL-encoded parameter to handle slashes in server name
|
||||
const serverName = decodeURIComponent(req.params.serverName);
|
||||
if (!serverName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -236,7 +237,9 @@ export const getCloudServerToolsList = async (req: Request, res: Response): Prom
|
||||
// Call a tool on a cloud server
|
||||
export const callCloudTool = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName, toolName } = req.params;
|
||||
// Decode URL-encoded parameters to handle slashes in server/tool names
|
||||
const serverName = decodeURIComponent(req.params.serverName);
|
||||
const toolName = decodeURIComponent(req.params.toolName);
|
||||
const { arguments: args } = req.body;
|
||||
|
||||
if (!serverName) {
|
||||
|
||||
240
src/controllers/clusterController.ts
Normal file
240
src/controllers/clusterController.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Cluster Controller
|
||||
*
|
||||
* Handles cluster-related API endpoints:
|
||||
* - Node registration
|
||||
* - Heartbeat updates
|
||||
* - Cluster status queries
|
||||
* - Session affinity management
|
||||
*/
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import {
|
||||
getClusterMode,
|
||||
isClusterEnabled,
|
||||
getCurrentNodeId,
|
||||
registerNode,
|
||||
updateNodeHeartbeat,
|
||||
getActiveNodes,
|
||||
getAllNodes,
|
||||
getServerReplicas,
|
||||
getSessionAffinity,
|
||||
getClusterStats,
|
||||
} from '../services/clusterService.js';
|
||||
import { ClusterNode } from '../types/index.js';
|
||||
|
||||
/**
|
||||
* Get cluster status
|
||||
* GET /api/cluster/status
|
||||
*/
|
||||
export const getClusterStatus = (_req: Request, res: Response): void => {
|
||||
try {
|
||||
const enabled = isClusterEnabled();
|
||||
const mode = getClusterMode();
|
||||
const nodeId = getCurrentNodeId();
|
||||
const stats = getClusterStats();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
enabled,
|
||||
mode,
|
||||
nodeId,
|
||||
stats,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting cluster status:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get cluster status',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Register a node (coordinator only)
|
||||
* POST /api/cluster/register
|
||||
*/
|
||||
export const registerNodeEndpoint = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const mode = getClusterMode();
|
||||
|
||||
if (mode !== 'coordinator') {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: 'This endpoint is only available on coordinator nodes',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeInfo: ClusterNode = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!nodeInfo.id || !nodeInfo.name || !nodeInfo.url) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Missing required fields: id, name, url',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
registerNode(nodeInfo);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Node registered successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error registering node:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to register node',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update node heartbeat (coordinator only)
|
||||
* POST /api/cluster/heartbeat
|
||||
*/
|
||||
export const updateHeartbeat = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const mode = getClusterMode();
|
||||
|
||||
if (mode !== 'coordinator') {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: 'This endpoint is only available on coordinator nodes',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { id, servers } = req.body;
|
||||
|
||||
if (!id) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Missing required field: id',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
updateNodeHeartbeat(id, servers || []);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Heartbeat updated successfully',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating heartbeat:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to update heartbeat',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all nodes (coordinator only)
|
||||
* GET /api/cluster/nodes
|
||||
*/
|
||||
export const getNodes = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const mode = getClusterMode();
|
||||
|
||||
if (mode !== 'coordinator') {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: 'This endpoint is only available on coordinator nodes',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const activeOnly = req.query.active === 'true';
|
||||
const nodes = activeOnly ? getActiveNodes() : getAllNodes();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: nodes,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting nodes:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get nodes',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get server replicas (coordinator only)
|
||||
* GET /api/cluster/servers/:serverId/replicas
|
||||
*/
|
||||
export const getReplicasForServer = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const mode = getClusterMode();
|
||||
|
||||
if (mode !== 'coordinator') {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: 'This endpoint is only available on coordinator nodes',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { serverId } = req.params;
|
||||
const replicas = getServerReplicas(serverId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: replicas,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting server replicas:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get server replicas',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get session affinity information (coordinator only)
|
||||
* GET /api/cluster/sessions/:sessionId
|
||||
*/
|
||||
export const getSessionAffinityInfo = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const mode = getClusterMode();
|
||||
|
||||
if (mode !== 'coordinator') {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: 'This endpoint is only available on coordinator nodes',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { sessionId } = req.params;
|
||||
const affinity = getSessionAffinity(sessionId);
|
||||
|
||||
if (!affinity) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Session affinity not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: affinity,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting session affinity:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get session affinity',
|
||||
});
|
||||
}
|
||||
};
|
||||
388
src/controllers/oauthCallbackController.ts
Normal file
388
src/controllers/oauthCallbackController.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* OAuth Callback Controller
|
||||
*
|
||||
* Handles OAuth 2.0 authorization callbacks for upstream MCP servers.
|
||||
*
|
||||
* This controller implements a simplified callback flow that relies on the MCP SDK
|
||||
* to handle the complete OAuth token exchange:
|
||||
*
|
||||
* 1. Extract authorization code from callback URL
|
||||
* 2. Find the corresponding server using the state parameter
|
||||
* 3. Store the authorization code temporarily
|
||||
* 4. Reconnect the server - SDK's auth() function will:
|
||||
* - Automatically discover OAuth endpoints
|
||||
* - Exchange the code for tokens using PKCE
|
||||
* - Save tokens via our OAuthClientProvider.saveTokens()
|
||||
*/
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import {
|
||||
getServerByName,
|
||||
getServerByOAuthState,
|
||||
createTransportFromConfig,
|
||||
} from '../services/mcpService.js';
|
||||
import { getNameSeparator, loadSettings } from '../config/index.js';
|
||||
import type { ServerInfo } from '../types/index.js';
|
||||
|
||||
/**
|
||||
* Generate HTML response page with i18n support
|
||||
*/
|
||||
const generateHtmlResponse = (
|
||||
type: 'error' | 'success',
|
||||
title: string,
|
||||
message: string,
|
||||
details?: { label: string; value: string }[],
|
||||
autoClose: boolean = false,
|
||||
): string => {
|
||||
const backgroundColor = type === 'error' ? '#fee' : '#efe';
|
||||
const borderColor = type === 'error' ? '#fcc' : '#cfc';
|
||||
const titleColor = type === 'error' ? '#c33' : '#3c3';
|
||||
const buttonColor = type === 'error' ? '#c33' : '#3c3';
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>${title}</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
|
||||
.container { background-color: ${backgroundColor}; border: 1px solid ${borderColor}; padding: 20px; border-radius: 8px; }
|
||||
h1 { color: ${titleColor}; margin-top: 0; }
|
||||
.detail { margin-top: 10px; padding: 10px; background: #f9f9f9; border-radius: 4px; ${type === 'error' ? 'font-family: monospace; font-size: 12px; white-space: pre-wrap;' : ''} }
|
||||
.close-btn { margin-top: 20px; padding: 10px 20px; background: ${buttonColor}; color: white; border: none; border-radius: 4px; cursor: pointer; }
|
||||
</style>
|
||||
${autoClose ? '<script>setTimeout(() => { window.close(); }, 3000);</script>' : ''}
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>${type === 'success' ? '✓ ' : ''}${title}</h1>
|
||||
${details ? details.map((d) => `<div class="detail"><strong>${d.label}:</strong> ${d.value}</div>`).join('') : ''}
|
||||
<p>${message}</p>
|
||||
${autoClose ? '<p>This window will close automatically in 3 seconds...</p>' : ''}
|
||||
<button class="close-btn" onclick="window.close()">${autoClose ? 'Close Now' : 'Close Window'}</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
};
|
||||
|
||||
const normalizeQueryParam = (value: unknown): string | undefined => {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
const [first] = value;
|
||||
return typeof first === 'string' ? first : undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const extractServerNameFromState = (stateValue: string): string | undefined => {
|
||||
try {
|
||||
const normalized = stateValue.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padding = (4 - (normalized.length % 4)) % 4;
|
||||
const base64 = normalized + '='.repeat(padding);
|
||||
const decoded = Buffer.from(base64, 'base64').toString('utf8');
|
||||
const payload = JSON.parse(decoded);
|
||||
|
||||
if (payload && typeof payload.server === 'string') {
|
||||
return payload.server;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore decoding errors and fall back to delimiter-based parsing
|
||||
}
|
||||
|
||||
const separatorIndex = stateValue.indexOf(':');
|
||||
if (separatorIndex > 0) {
|
||||
return stateValue.slice(0, separatorIndex);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle OAuth callback after user authorization
|
||||
*
|
||||
* This endpoint receives the authorization code from the OAuth provider
|
||||
* and initiates the server reconnection process.
|
||||
*
|
||||
* Expected query parameters:
|
||||
* - code: Authorization code from OAuth provider
|
||||
* - state: Encoded server identifier used for OAuth session validation
|
||||
* - error: Optional error code if authorization failed
|
||||
* - error_description: Optional error description
|
||||
*/
|
||||
export const handleOAuthCallback = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { code, state, error, error_description } = req.query;
|
||||
const codeParam = normalizeQueryParam(code);
|
||||
const stateParam = normalizeQueryParam(state);
|
||||
|
||||
// Get translation function from request (set by i18n middleware)
|
||||
const t = (req as any).t || ((key: string) => key);
|
||||
|
||||
// Check for authorization errors
|
||||
if (error) {
|
||||
console.error(`OAuth authorization failed: ${error} - ${error_description || ''}`);
|
||||
return res.status(400).send(
|
||||
generateHtmlResponse('error', t('oauthCallback.authorizationFailed'), '', [
|
||||
{ label: t('oauthCallback.authorizationFailedError'), value: String(error) },
|
||||
...(error_description
|
||||
? [
|
||||
{
|
||||
label: t('oauthCallback.authorizationFailedDetails'),
|
||||
value: String(error_description),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
// Validate required parameters
|
||||
if (!stateParam) {
|
||||
console.error('OAuth callback missing state parameter');
|
||||
return res
|
||||
.status(400)
|
||||
.send(
|
||||
generateHtmlResponse(
|
||||
'error',
|
||||
t('oauthCallback.invalidRequest'),
|
||||
t('oauthCallback.missingStateParameter'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!codeParam) {
|
||||
console.error('OAuth callback missing authorization code');
|
||||
return res
|
||||
.status(400)
|
||||
.send(
|
||||
generateHtmlResponse(
|
||||
'error',
|
||||
t('oauthCallback.invalidRequest'),
|
||||
t('oauthCallback.missingCodeParameter'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`OAuth callback received - code: present, state: ${stateParam}`);
|
||||
|
||||
// Find server by state parameter
|
||||
let serverInfo: ServerInfo | undefined;
|
||||
|
||||
serverInfo = getServerByOAuthState(stateParam);
|
||||
|
||||
let decodedServerName: string | undefined;
|
||||
if (!serverInfo) {
|
||||
decodedServerName = extractServerNameFromState(stateParam);
|
||||
if (decodedServerName) {
|
||||
console.log(`State lookup failed; decoding server name from state: ${decodedServerName}`);
|
||||
serverInfo = getServerByName(decodedServerName);
|
||||
}
|
||||
}
|
||||
|
||||
if (!serverInfo) {
|
||||
console.error(
|
||||
`No server found for OAuth callback. State: ${stateParam}${
|
||||
decodedServerName ? `, decoded server: ${decodedServerName}` : ''
|
||||
}`,
|
||||
);
|
||||
return res
|
||||
.status(400)
|
||||
.send(
|
||||
generateHtmlResponse(
|
||||
'error',
|
||||
t('oauthCallback.serverNotFound'),
|
||||
`${t('oauthCallback.serverNotFoundMessage')}\n${t('oauthCallback.sessionExpiredMessage')}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Optional: Validate state parameter for additional security
|
||||
if (serverInfo.oauth?.state && serverInfo.oauth.state !== stateParam) {
|
||||
console.warn(
|
||||
`State mismatch for server ${serverInfo.name}. Expected: ${serverInfo.oauth.state}, Got: ${stateParam}`,
|
||||
);
|
||||
// Note: We log a warning but don't fail the request since we have server name as primary identifier
|
||||
}
|
||||
|
||||
console.log(`Processing OAuth callback for server: ${serverInfo.name}`);
|
||||
|
||||
// For StreamableHTTPClientTransport, we need to call finishAuth() on the transport
|
||||
// This will exchange the authorization code for tokens automatically
|
||||
if (serverInfo.transport && 'finishAuth' in serverInfo.transport) {
|
||||
try {
|
||||
console.log(`Calling transport.finishAuth() for server: ${serverInfo.name}`);
|
||||
const currentTransport = serverInfo.transport as any;
|
||||
await currentTransport.finishAuth(codeParam);
|
||||
|
||||
console.log(`Successfully exchanged authorization code for tokens: ${serverInfo.name}`);
|
||||
|
||||
// Refresh server configuration from disk to ensure we pick up newly saved tokens
|
||||
const settings = loadSettings();
|
||||
const storedConfig = settings.mcpServers?.[serverInfo.name];
|
||||
const effectiveConfig = storedConfig || serverInfo.config;
|
||||
|
||||
if (!effectiveConfig) {
|
||||
throw new Error(
|
||||
`Missing server configuration for ${serverInfo.name} after OAuth callback`,
|
||||
);
|
||||
}
|
||||
|
||||
// Keep latest configuration cached on serverInfo
|
||||
serverInfo.config = effectiveConfig;
|
||||
|
||||
// Ensure we have up-to-date request options for the reconnect attempt
|
||||
if (!serverInfo.options) {
|
||||
const requestConfig = effectiveConfig.options || {};
|
||||
serverInfo.options = {
|
||||
timeout: requestConfig.timeout || 60000,
|
||||
resetTimeoutOnProgress: requestConfig.resetTimeoutOnProgress || false,
|
||||
maxTotalTimeout: requestConfig.maxTotalTimeout,
|
||||
};
|
||||
}
|
||||
|
||||
// Replace the existing transport instance to avoid reusing a closed/aborted transport
|
||||
try {
|
||||
if (serverInfo.transport && 'close' in serverInfo.transport) {
|
||||
await (serverInfo.transport as any).close();
|
||||
}
|
||||
} catch (closeError) {
|
||||
console.warn(`Failed to close existing transport for ${serverInfo.name}:`, closeError);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Rebuilding transport with refreshed credentials for server: ${serverInfo.name}`,
|
||||
);
|
||||
const refreshedTransport = await createTransportFromConfig(
|
||||
serverInfo.name,
|
||||
effectiveConfig,
|
||||
);
|
||||
serverInfo.transport = refreshedTransport;
|
||||
|
||||
// Update server status to indicate OAuth is complete
|
||||
serverInfo.status = 'connected';
|
||||
if (serverInfo.oauth) {
|
||||
serverInfo.oauth.authorizationUrl = undefined;
|
||||
serverInfo.oauth.state = undefined;
|
||||
serverInfo.oauth.codeVerifier = undefined;
|
||||
}
|
||||
|
||||
// Check if client needs to be connected
|
||||
const isClientConnected = serverInfo.client && serverInfo.client.getServerCapabilities();
|
||||
|
||||
if (!isClientConnected) {
|
||||
// Client is not connected yet, connect it
|
||||
if (serverInfo.client && serverInfo.transport) {
|
||||
console.log(`Connecting client with refreshed transport for: ${serverInfo.name}`);
|
||||
try {
|
||||
await serverInfo.client.connect(serverInfo.transport, serverInfo.options);
|
||||
console.log(`Client connected successfully for: ${serverInfo.name}`);
|
||||
|
||||
// List tools after successful connection
|
||||
const capabilities = serverInfo.client.getServerCapabilities();
|
||||
console.log(
|
||||
`Server capabilities for ${serverInfo.name}:`,
|
||||
JSON.stringify(capabilities),
|
||||
);
|
||||
|
||||
if (capabilities?.tools) {
|
||||
console.log(`Listing tools for server: ${serverInfo.name}`);
|
||||
const toolsResult = await serverInfo.client.listTools({}, serverInfo.options);
|
||||
const separator = getNameSeparator();
|
||||
serverInfo.tools = toolsResult.tools.map((tool) => ({
|
||||
name: `${serverInfo.name}${separator}${tool.name}`,
|
||||
description: tool.description || '',
|
||||
inputSchema: tool.inputSchema || {},
|
||||
}));
|
||||
console.log(
|
||||
`Listed ${serverInfo.tools.length} tools for server: ${serverInfo.name}`,
|
||||
);
|
||||
} else {
|
||||
console.log(`Server ${serverInfo.name} does not support tools capability`);
|
||||
}
|
||||
} catch (connectError) {
|
||||
console.error(`Error connecting client for ${serverInfo.name}:`, connectError);
|
||||
if (connectError instanceof Error) {
|
||||
console.error(
|
||||
`Connect error details for ${serverInfo.name}: ${connectError.message}`,
|
||||
connectError.stack,
|
||||
);
|
||||
}
|
||||
// Even if connection fails, mark OAuth as complete
|
||||
// The user can try reconnecting from the dashboard
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`Cannot connect client for ${serverInfo.name}: client or transport missing`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(`Client already connected for server: ${serverInfo.name}`);
|
||||
}
|
||||
|
||||
console.log(`Successfully completed OAuth flow for server: ${serverInfo.name}`);
|
||||
|
||||
// Return success page
|
||||
return res.status(200).send(
|
||||
generateHtmlResponse(
|
||||
'success',
|
||||
t('oauthCallback.authorizationSuccessful'),
|
||||
`${t('oauthCallback.successMessage')}\n${t('oauthCallback.autoCloseMessage')}`,
|
||||
[
|
||||
{ label: t('oauthCallback.server'), value: serverInfo.name },
|
||||
{ label: t('oauthCallback.status'), value: t('oauthCallback.connected') },
|
||||
],
|
||||
true, // auto-close
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Failed to complete OAuth flow for server ${serverInfo.name}:`, error);
|
||||
console.error(`Error type: ${typeof error}, Error name: ${error?.constructor?.name}`);
|
||||
console.error(`Error message: ${error instanceof Error ? error.message : String(error)}`);
|
||||
console.error(`Error stack:`, error instanceof Error ? error.stack : 'No stack trace');
|
||||
|
||||
return res
|
||||
.status(500)
|
||||
.send(
|
||||
generateHtmlResponse(
|
||||
'error',
|
||||
t('oauthCallback.connectionError'),
|
||||
`${t('oauthCallback.connectionErrorMessage')}\n${t('oauthCallback.reconnectMessage')}`,
|
||||
[{ label: '', value: error instanceof Error ? error.message : String(error) }],
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// No transport available or transport doesn't support finishAuth
|
||||
console.error(`Transport for server ${serverInfo.name} does not support finishAuth()`);
|
||||
return res
|
||||
.status(500)
|
||||
.send(
|
||||
generateHtmlResponse(
|
||||
'error',
|
||||
t('oauthCallback.configurationError'),
|
||||
t('oauthCallback.configurationErrorMessage'),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Unexpected error handling OAuth callback:', error);
|
||||
|
||||
// Get translation function from request (set by i18n middleware)
|
||||
const t = (req as any).t || ((key: string) => key);
|
||||
|
||||
return res
|
||||
.status(500)
|
||||
.send(
|
||||
generateHtmlResponse(
|
||||
'error',
|
||||
t('oauthCallback.internalError'),
|
||||
t('oauthCallback.internalErrorMessage'),
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -8,82 +8,13 @@ import {
|
||||
import { getServerByName } from '../services/mcpService.js';
|
||||
import { getGroupByIdOrName } from '../services/groupService.js';
|
||||
import { getNameSeparator } from '../config/index.js';
|
||||
import { convertParametersToTypes } from '../utils/parameterConversion.js';
|
||||
|
||||
/**
|
||||
* Controller for OpenAPI generation endpoints
|
||||
* Provides OpenAPI specifications for MCP tools to enable OpenWebUI integration
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convert query parameters to their proper types based on the tool's input schema
|
||||
*/
|
||||
function convertQueryParametersToTypes(
|
||||
queryParams: Record<string, any>,
|
||||
inputSchema: Record<string, any>,
|
||||
): Record<string, any> {
|
||||
if (!inputSchema || typeof inputSchema !== 'object' || !inputSchema.properties) {
|
||||
return queryParams;
|
||||
}
|
||||
|
||||
const convertedParams: Record<string, any> = {};
|
||||
const properties = inputSchema.properties;
|
||||
|
||||
for (const [key, value] of Object.entries(queryParams)) {
|
||||
const propDef = properties[key];
|
||||
if (!propDef || typeof propDef !== 'object') {
|
||||
// No schema definition found, keep as is
|
||||
convertedParams[key] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
const propType = propDef.type;
|
||||
|
||||
try {
|
||||
switch (propType) {
|
||||
case 'integer':
|
||||
case 'number':
|
||||
// Convert string to number
|
||||
if (typeof value === 'string') {
|
||||
const numValue = propType === 'integer' ? parseInt(value, 10) : parseFloat(value);
|
||||
convertedParams[key] = isNaN(numValue) ? value : numValue;
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'boolean':
|
||||
// Convert string to boolean
|
||||
if (typeof value === 'string') {
|
||||
convertedParams[key] = value.toLowerCase() === 'true' || value === '1';
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'array':
|
||||
// Handle array conversion if needed (e.g., comma-separated strings)
|
||||
if (typeof value === 'string' && value.includes(',')) {
|
||||
convertedParams[key] = value.split(',').map((item) => item.trim());
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// For string and other types, keep as is
|
||||
convertedParams[key] = value;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
// If conversion fails, keep the original value
|
||||
console.warn(`Failed to convert parameter '${key}' to type '${propType}':`, error);
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return convertedParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and return OpenAPI specification
|
||||
* GET /api/openapi.json
|
||||
@@ -167,7 +98,9 @@ export const getOpenAPIStats = async (req: Request, res: Response): Promise<void
|
||||
*/
|
||||
export const executeToolViaOpenAPI = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName, toolName } = req.params;
|
||||
// Decode URL-encoded parameters to handle slashes in server/tool names
|
||||
const serverName = decodeURIComponent(req.params.serverName);
|
||||
const toolName = decodeURIComponent(req.params.toolName);
|
||||
|
||||
// Import handleCallToolRequest function
|
||||
const { handleCallToolRequest } = await import('../services/mcpService.js');
|
||||
@@ -189,7 +122,7 @@ export const executeToolViaOpenAPI = async (req: Request, res: Response): Promis
|
||||
|
||||
// Prepare arguments from query params (GET) or body (POST)
|
||||
let args = req.method === 'GET' ? req.query : req.body || {};
|
||||
args = convertQueryParametersToTypes(args, inputSchema);
|
||||
args = convertParametersToTypes(args, inputSchema);
|
||||
|
||||
// Create a mock request structure that matches what handleCallToolRequest expects
|
||||
const mockRequest = {
|
||||
|
||||
@@ -7,7 +7,9 @@ import { handleGetPromptRequest } from '../services/mcpService.js';
|
||||
*/
|
||||
export const getPrompt = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName, promptName } = req.params;
|
||||
// Decode URL-encoded parameters to handle slashes in server/prompt names
|
||||
const serverName = decodeURIComponent(req.params.serverName);
|
||||
const promptName = decodeURIComponent(req.params.promptName);
|
||||
if (!serverName || !promptName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
|
||||
@@ -375,7 +375,9 @@ export const toggleServer = async (req: Request, res: Response): Promise<void> =
|
||||
// Toggle tool status for a specific server
|
||||
export const toggleTool = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName, toolName } = req.params;
|
||||
// Decode URL-encoded parameters to handle slashes in server/tool names
|
||||
const serverName = decodeURIComponent(req.params.serverName);
|
||||
const toolName = decodeURIComponent(req.params.toolName);
|
||||
const { enabled } = req.body;
|
||||
|
||||
if (!serverName || !toolName) {
|
||||
@@ -437,7 +439,9 @@ export const toggleTool = async (req: Request, res: Response): Promise<void> =>
|
||||
// Update tool description for a specific server
|
||||
export const updateToolDescription = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName, toolName } = req.params;
|
||||
// Decode URL-encoded parameters to handle slashes in server/tool names
|
||||
const serverName = decodeURIComponent(req.params.serverName);
|
||||
const toolName = decodeURIComponent(req.params.toolName);
|
||||
const { description } = req.body;
|
||||
|
||||
if (!serverName || !toolName) {
|
||||
@@ -504,7 +508,7 @@ export const updateToolDescription = async (req: Request, res: Response): Promis
|
||||
|
||||
export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { routing, install, smartRouting, mcpRouter, nameSeparator } = req.body;
|
||||
const { routing, install, smartRouting, mcpRouter, nameSeparator, cluster } = req.body;
|
||||
const currentUser = (req as any).user;
|
||||
|
||||
if (
|
||||
@@ -529,7 +533,8 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
typeof mcpRouter.referer !== 'string' &&
|
||||
typeof mcpRouter.title !== 'string' &&
|
||||
typeof mcpRouter.baseUrl !== 'string')) &&
|
||||
(typeof nameSeparator !== 'string')
|
||||
typeof nameSeparator !== 'string' &&
|
||||
!cluster
|
||||
) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -606,6 +611,13 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
};
|
||||
}
|
||||
|
||||
if (!settings.systemConfig.cluster) {
|
||||
settings.systemConfig.cluster = {
|
||||
enabled: false,
|
||||
mode: 'standalone',
|
||||
};
|
||||
}
|
||||
|
||||
if (routing) {
|
||||
if (typeof routing.enableGlobalRoute === 'boolean') {
|
||||
settings.systemConfig.routing.enableGlobalRoute = routing.enableGlobalRoute;
|
||||
@@ -715,6 +727,88 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
settings.systemConfig.nameSeparator = nameSeparator;
|
||||
}
|
||||
|
||||
if (cluster) {
|
||||
if (typeof cluster.enabled === 'boolean') {
|
||||
settings.systemConfig.cluster.enabled = cluster.enabled;
|
||||
}
|
||||
if (
|
||||
typeof cluster.mode === 'string' &&
|
||||
['standalone', 'node', 'coordinator'].includes(cluster.mode)
|
||||
) {
|
||||
settings.systemConfig.cluster.mode = cluster.mode as 'standalone' | 'node' | 'coordinator';
|
||||
}
|
||||
|
||||
// Node configuration
|
||||
if (cluster.node) {
|
||||
if (!settings.systemConfig.cluster.node) {
|
||||
settings.systemConfig.cluster.node = {
|
||||
coordinatorUrl: '',
|
||||
};
|
||||
}
|
||||
if (typeof cluster.node.id === 'string') {
|
||||
settings.systemConfig.cluster.node.id = cluster.node.id;
|
||||
}
|
||||
if (typeof cluster.node.name === 'string') {
|
||||
settings.systemConfig.cluster.node.name = cluster.node.name;
|
||||
}
|
||||
if (typeof cluster.node.coordinatorUrl === 'string') {
|
||||
settings.systemConfig.cluster.node.coordinatorUrl = cluster.node.coordinatorUrl;
|
||||
}
|
||||
if (typeof cluster.node.heartbeatInterval === 'number') {
|
||||
settings.systemConfig.cluster.node.heartbeatInterval = cluster.node.heartbeatInterval;
|
||||
}
|
||||
if (typeof cluster.node.registerOnStartup === 'boolean') {
|
||||
settings.systemConfig.cluster.node.registerOnStartup = cluster.node.registerOnStartup;
|
||||
}
|
||||
}
|
||||
|
||||
// Coordinator configuration
|
||||
if (cluster.coordinator) {
|
||||
if (!settings.systemConfig.cluster.coordinator) {
|
||||
settings.systemConfig.cluster.coordinator = {};
|
||||
}
|
||||
if (typeof cluster.coordinator.nodeTimeout === 'number') {
|
||||
settings.systemConfig.cluster.coordinator.nodeTimeout = cluster.coordinator.nodeTimeout;
|
||||
}
|
||||
if (typeof cluster.coordinator.cleanupInterval === 'number') {
|
||||
settings.systemConfig.cluster.coordinator.cleanupInterval =
|
||||
cluster.coordinator.cleanupInterval;
|
||||
}
|
||||
if (typeof cluster.coordinator.stickySessionTimeout === 'number') {
|
||||
settings.systemConfig.cluster.coordinator.stickySessionTimeout =
|
||||
cluster.coordinator.stickySessionTimeout;
|
||||
}
|
||||
}
|
||||
|
||||
// Sticky session configuration
|
||||
if (cluster.stickySession) {
|
||||
if (!settings.systemConfig.cluster.stickySession) {
|
||||
settings.systemConfig.cluster.stickySession = {
|
||||
enabled: true,
|
||||
strategy: 'consistent-hash',
|
||||
};
|
||||
}
|
||||
if (typeof cluster.stickySession.enabled === 'boolean') {
|
||||
settings.systemConfig.cluster.stickySession.enabled = cluster.stickySession.enabled;
|
||||
}
|
||||
if (
|
||||
typeof cluster.stickySession.strategy === 'string' &&
|
||||
['consistent-hash', 'cookie', 'header'].includes(cluster.stickySession.strategy)
|
||||
) {
|
||||
settings.systemConfig.cluster.stickySession.strategy = cluster.stickySession.strategy as
|
||||
| 'consistent-hash'
|
||||
| 'cookie'
|
||||
| 'header';
|
||||
}
|
||||
if (typeof cluster.stickySession.cookieName === 'string') {
|
||||
settings.systemConfig.cluster.stickySession.cookieName = cluster.stickySession.cookieName;
|
||||
}
|
||||
if (typeof cluster.stickySession.headerName === 'string') {
|
||||
settings.systemConfig.cluster.stickySession.headerName = cluster.stickySession.headerName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (saveSettings(settings, currentUser)) {
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -747,7 +841,9 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
// Toggle prompt status for a specific server
|
||||
export const togglePrompt = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName, promptName } = req.params;
|
||||
// Decode URL-encoded parameters to handle slashes in server/prompt names
|
||||
const serverName = decodeURIComponent(req.params.serverName);
|
||||
const promptName = decodeURIComponent(req.params.promptName);
|
||||
const { enabled } = req.body;
|
||||
|
||||
if (!serverName || !promptName) {
|
||||
@@ -809,7 +905,9 @@ export const togglePrompt = async (req: Request, res: Response): Promise<void> =
|
||||
// Update prompt description for a specific server
|
||||
export const updatePromptDescription = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName, promptName } = req.params;
|
||||
// Decode URL-encoded parameters to handle slashes in server/prompt names
|
||||
const serverName = decodeURIComponent(req.params.serverName);
|
||||
const promptName = decodeURIComponent(req.params.promptName);
|
||||
const { description } = req.body;
|
||||
|
||||
if (!serverName || !promptName) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
import { handleCallToolRequest } from '../services/mcpService.js';
|
||||
import { handleCallToolRequest, getServerByName } from '../services/mcpService.js';
|
||||
import { convertParametersToTypes } from '../utils/parameterConversion.js';
|
||||
import { getNameSeparator } from '../config/index.js';
|
||||
|
||||
/**
|
||||
* Interface for tool call request
|
||||
@@ -47,13 +49,31 @@ export const callTool = async (req: Request, res: Response): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the server info to access the tool's input schema
|
||||
const serverInfo = getServerByName(server);
|
||||
let inputSchema: Record<string, any> = {};
|
||||
|
||||
if (serverInfo) {
|
||||
// Find the tool in the server's tools list
|
||||
const fullToolName = `${server}${getNameSeparator()}${toolName}`;
|
||||
const tool = serverInfo.tools.find(
|
||||
(t: any) => t.name === fullToolName || t.name === toolName,
|
||||
);
|
||||
if (tool && tool.inputSchema) {
|
||||
inputSchema = tool.inputSchema as Record<string, any>;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert parameters to proper types based on the tool's input schema
|
||||
const convertedArgs = convertParametersToTypes(toolArgs, inputSchema);
|
||||
|
||||
// Create a mock request structure for handleCallToolRequest
|
||||
const mockRequest = {
|
||||
params: {
|
||||
name: 'call_tool',
|
||||
arguments: {
|
||||
toolName,
|
||||
arguments: toolArgs,
|
||||
arguments: convertedArgs,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -71,7 +91,7 @@ export const callTool = async (req: Request, res: Response): Promise<void> => {
|
||||
data: {
|
||||
content: result.content || [],
|
||||
toolName,
|
||||
arguments: toolArgs,
|
||||
arguments: convertedArgs,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getAdminCount,
|
||||
} from '../services/userService.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import { validatePasswordStrength } from '../utils/passwordValidation.js';
|
||||
|
||||
// Admin permission check middleware function
|
||||
const requireAdmin = (req: Request, res: Response): boolean => {
|
||||
@@ -100,6 +101,17 @@ export const createUser = async (req: Request, res: Response): Promise<void> =>
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate password strength
|
||||
const validationResult = validatePasswordStrength(password);
|
||||
if (!validationResult.isValid) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Password does not meet security requirements',
|
||||
errors: validationResult.errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const newUser = await createNewUser(username, password, isAdmin || false);
|
||||
if (!newUser) {
|
||||
res.status(400).json({
|
||||
@@ -163,7 +175,19 @@ export const updateExistingUser = async (req: Request, res: Response): Promise<v
|
||||
|
||||
const updateData: any = {};
|
||||
if (isAdmin !== undefined) updateData.isAdmin = isAdmin;
|
||||
if (newPassword) updateData.newPassword = newPassword;
|
||||
if (newPassword) {
|
||||
// Validate new password strength
|
||||
const validationResult = validatePasswordStrength(newPassword);
|
||||
if (!validationResult.isValid) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Password does not meet security requirements',
|
||||
errors: validationResult.errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateData.newPassword = newPassword;
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
res.status(400).json({
|
||||
|
||||
176
src/middlewares/clusterRouting.ts
Normal file
176
src/middlewares/clusterRouting.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Cluster Routing Middleware
|
||||
*
|
||||
* Handles routing of MCP requests in cluster mode:
|
||||
* - Determines target node based on session affinity
|
||||
* - Proxies requests to appropriate nodes
|
||||
* - Maintains sticky sessions
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
isClusterEnabled,
|
||||
getClusterMode,
|
||||
getNodeForSession,
|
||||
getCurrentNodeId,
|
||||
} from '../services/clusterService.js';
|
||||
|
||||
/**
|
||||
* Cluster routing middleware for SSE connections
|
||||
*/
|
||||
export const clusterSseRouting = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
// If cluster is not enabled or we're in standalone mode, proceed normally
|
||||
if (!isClusterEnabled() || getClusterMode() === 'standalone') {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Coordinator should handle all requests normally
|
||||
if (getClusterMode() === 'coordinator') {
|
||||
// For coordinator, we need to route to appropriate node
|
||||
await routeToNode(req, res, next);
|
||||
return;
|
||||
}
|
||||
|
||||
// For regular nodes, proceed normally (they handle their own servers)
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Cluster routing middleware for MCP HTTP requests
|
||||
*/
|
||||
export const clusterMcpRouting = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
// If cluster is not enabled or we're in standalone mode, proceed normally
|
||||
if (!isClusterEnabled() || getClusterMode() === 'standalone') {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Coordinator should route requests to appropriate nodes
|
||||
if (getClusterMode() === 'coordinator') {
|
||||
await routeToNode(req, res, next);
|
||||
return;
|
||||
}
|
||||
|
||||
// For regular nodes, proceed normally
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Route request to appropriate node based on session affinity
|
||||
*/
|
||||
const routeToNode = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
// Extract session ID from headers or generate new one
|
||||
const sessionId =
|
||||
(req.headers['mcp-session-id'] as string) ||
|
||||
(req.query.sessionId as string) ||
|
||||
generateSessionId(req);
|
||||
|
||||
// Determine target node
|
||||
const group = req.params.group;
|
||||
const targetNode = getNodeForSession(sessionId, group, req.headers);
|
||||
|
||||
if (!targetNode) {
|
||||
// No available nodes, return error
|
||||
res.status(503).json({
|
||||
success: false,
|
||||
message: 'No available nodes to handle request',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is the current node
|
||||
const currentNodeId = getCurrentNodeId();
|
||||
if (currentNodeId && targetNode.id === currentNodeId) {
|
||||
// Handle locally
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Proxy request to target node
|
||||
await proxyRequest(req, res, targetNode.url);
|
||||
} catch (error) {
|
||||
console.error('Error in cluster routing:', error);
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate session ID from request
|
||||
*/
|
||||
const generateSessionId = (req: Request): string => {
|
||||
// Use IP address and user agent as seed for consistent hashing
|
||||
const seed = `${req.ip}-${req.get('user-agent') || 'unknown'}`;
|
||||
return Buffer.from(seed).toString('base64');
|
||||
};
|
||||
|
||||
/**
|
||||
* Proxy request to another node
|
||||
*/
|
||||
const proxyRequest = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
targetUrl: string,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
// Build target URL
|
||||
const url = new URL(req.originalUrl || req.url, targetUrl);
|
||||
|
||||
// Prepare headers (excluding host and connection headers)
|
||||
const headers: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (
|
||||
key.toLowerCase() !== 'host' &&
|
||||
key.toLowerCase() !== 'connection' &&
|
||||
value
|
||||
) {
|
||||
headers[key] = Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
}
|
||||
|
||||
// Forward request to target node
|
||||
const response = await axios({
|
||||
method: req.method,
|
||||
url: url.toString(),
|
||||
headers,
|
||||
data: req.body,
|
||||
responseType: 'stream',
|
||||
timeout: 30000,
|
||||
validateStatus: () => true, // Don't throw on any status
|
||||
});
|
||||
|
||||
// Forward response headers
|
||||
for (const [key, value] of Object.entries(response.headers)) {
|
||||
if (
|
||||
key.toLowerCase() !== 'connection' &&
|
||||
key.toLowerCase() !== 'transfer-encoding'
|
||||
) {
|
||||
res.setHeader(key, value as string);
|
||||
}
|
||||
}
|
||||
|
||||
// Forward status code and stream response
|
||||
res.status(response.status);
|
||||
response.data.pipe(res);
|
||||
} catch (error) {
|
||||
console.error('Error proxying request:', error);
|
||||
res.status(502).json({
|
||||
success: false,
|
||||
message: 'Failed to proxy request to target node',
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -79,6 +79,15 @@ import {
|
||||
executeToolViaOpenAPI,
|
||||
getGroupOpenAPISpec,
|
||||
} from '../controllers/openApiController.js';
|
||||
import { handleOAuthCallback } from '../controllers/oauthCallbackController.js';
|
||||
import {
|
||||
getClusterStatus,
|
||||
registerNodeEndpoint,
|
||||
updateHeartbeat,
|
||||
getNodes,
|
||||
getReplicasForServer,
|
||||
getSessionAffinityInfo,
|
||||
} from '../controllers/clusterController.js';
|
||||
import { auth } from '../middlewares/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -87,6 +96,9 @@ export const initRoutes = (app: express.Application): void => {
|
||||
// Health check endpoint (no auth required, accessible at /health)
|
||||
app.get('/health', healthCheck);
|
||||
|
||||
// OAuth callback endpoint (no auth required, public callback URL)
|
||||
app.get('/oauth/callback', handleOAuthCallback);
|
||||
|
||||
// API routes protected by auth middleware in middlewares/index.ts
|
||||
router.get('/servers', getAllServers);
|
||||
router.get('/settings', getAllSettings);
|
||||
@@ -163,6 +175,14 @@ export const initRoutes = (app: express.Application): void => {
|
||||
router.delete('/logs', clearLogs);
|
||||
router.get('/logs/stream', streamLogs);
|
||||
|
||||
// Cluster management routes
|
||||
router.get('/cluster/status', getClusterStatus);
|
||||
router.post('/cluster/register', registerNodeEndpoint);
|
||||
router.post('/cluster/heartbeat', updateHeartbeat);
|
||||
router.get('/cluster/nodes', getNodes);
|
||||
router.get('/cluster/servers/:serverId/replicas', getReplicasForServer);
|
||||
router.get('/cluster/sessions/:sessionId', getSessionAffinityInfo);
|
||||
|
||||
// MCP settings export route
|
||||
router.get('/mcp-settings/export', getMcpSettingsJson);
|
||||
|
||||
|
||||
@@ -15,8 +15,11 @@ import {
|
||||
} from './services/sseService.js';
|
||||
import { initializeDefaultUser } from './models/User.js';
|
||||
import { sseUserContextMiddleware } from './middlewares/userContext.js';
|
||||
import { clusterSseRouting, clusterMcpRouting } from './middlewares/clusterRouting.js';
|
||||
import { findPackageRoot } from './utils/path.js';
|
||||
import { getCurrentModuleDir } from './utils/moduleDir.js';
|
||||
import { initOAuthProvider, getOAuthRouter } from './services/oauthService.js';
|
||||
import { initClusterService, shutdownClusterService } from './services/clusterService.js';
|
||||
|
||||
/**
|
||||
* Get the directory of the current module
|
||||
@@ -27,7 +30,7 @@ function getCurrentFileDir(): string {
|
||||
if (process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined) {
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
return getCurrentModuleDir();
|
||||
} catch {
|
||||
@@ -58,57 +61,88 @@ export class AppServer {
|
||||
// Initialize default admin user if no users exist
|
||||
await initializeDefaultUser();
|
||||
|
||||
// Initialize OAuth provider if configured
|
||||
initOAuthProvider();
|
||||
const oauthRouter = getOAuthRouter();
|
||||
if (oauthRouter) {
|
||||
// Mount OAuth router at the root level (before other routes)
|
||||
// This must be at root level as per MCP OAuth specification
|
||||
this.app.use(oauthRouter);
|
||||
console.log('OAuth router mounted successfully');
|
||||
}
|
||||
|
||||
initMiddlewares(this.app);
|
||||
initRoutes(this.app);
|
||||
console.log('Server initialized successfully');
|
||||
|
||||
// Initialize cluster service
|
||||
await initClusterService();
|
||||
|
||||
initUpstreamServers()
|
||||
.then(() => {
|
||||
console.log('MCP server initialized successfully');
|
||||
|
||||
// Original routes (global and group-based)
|
||||
this.app.get(`${this.basePath}/sse/:group?`, sseUserContextMiddleware, (req, res) =>
|
||||
handleSseConnection(req, res),
|
||||
);
|
||||
this.app.post(`${this.basePath}/messages`, sseUserContextMiddleware, handleSseMessage);
|
||||
this.app.post(
|
||||
`${this.basePath}/mcp/:group?`,
|
||||
// Original routes (global and group-based) with cluster routing
|
||||
this.app.get(
|
||||
`${this.basePath}/sse/:group(.*)?`,
|
||||
sseUserContextMiddleware,
|
||||
clusterSseRouting,
|
||||
(req, res) => handleSseConnection(req, res),
|
||||
);
|
||||
this.app.post(
|
||||
`${this.basePath}/messages`,
|
||||
sseUserContextMiddleware,
|
||||
clusterSseRouting,
|
||||
handleSseMessage,
|
||||
);
|
||||
this.app.post(
|
||||
`${this.basePath}/mcp/:group(.*)?`,
|
||||
sseUserContextMiddleware,
|
||||
clusterMcpRouting,
|
||||
handleMcpPostRequest,
|
||||
);
|
||||
this.app.get(
|
||||
`${this.basePath}/mcp/:group?`,
|
||||
`${this.basePath}/mcp/:group(.*)?`,
|
||||
sseUserContextMiddleware,
|
||||
clusterMcpRouting,
|
||||
handleMcpOtherRequest,
|
||||
);
|
||||
this.app.delete(
|
||||
`${this.basePath}/mcp/:group?`,
|
||||
`${this.basePath}/mcp/:group(.*)?`,
|
||||
sseUserContextMiddleware,
|
||||
clusterMcpRouting,
|
||||
handleMcpOtherRequest,
|
||||
);
|
||||
|
||||
// User-scoped routes with user context middleware
|
||||
this.app.get(`${this.basePath}/:user/sse/:group?`, sseUserContextMiddleware, (req, res) =>
|
||||
handleSseConnection(req, res),
|
||||
// User-scoped routes with user context middleware and cluster routing
|
||||
this.app.get(
|
||||
`${this.basePath}/:user/sse/:group(.*)?`,
|
||||
sseUserContextMiddleware,
|
||||
clusterSseRouting,
|
||||
(req, res) => handleSseConnection(req, res),
|
||||
);
|
||||
this.app.post(
|
||||
`${this.basePath}/:user/messages`,
|
||||
sseUserContextMiddleware,
|
||||
clusterSseRouting,
|
||||
handleSseMessage,
|
||||
);
|
||||
this.app.post(
|
||||
`${this.basePath}/:user/mcp/:group?`,
|
||||
`${this.basePath}/:user/mcp/:group(.*)?`,
|
||||
sseUserContextMiddleware,
|
||||
clusterMcpRouting,
|
||||
handleMcpPostRequest,
|
||||
);
|
||||
this.app.get(
|
||||
`${this.basePath}/:user/mcp/:group?`,
|
||||
`${this.basePath}/:user/mcp/:group(.*)?`,
|
||||
sseUserContextMiddleware,
|
||||
clusterMcpRouting,
|
||||
handleMcpOtherRequest,
|
||||
);
|
||||
this.app.delete(
|
||||
`${this.basePath}/:user/mcp/:group?`,
|
||||
`${this.basePath}/:user/mcp/:group(.*)?`,
|
||||
sseUserContextMiddleware,
|
||||
clusterMcpRouting,
|
||||
handleMcpOtherRequest,
|
||||
);
|
||||
})
|
||||
@@ -180,6 +214,11 @@ export class AppServer {
|
||||
return this.app;
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
console.log('Shutting down cluster service...');
|
||||
shutdownClusterService();
|
||||
}
|
||||
|
||||
// Helper method to find frontend dist path in different environments
|
||||
private findFrontendDistPath(): string | null {
|
||||
// Debug flag for detailed logging
|
||||
|
||||
538
src/services/clusterService.ts
Normal file
538
src/services/clusterService.ts
Normal file
@@ -0,0 +1,538 @@
|
||||
/**
|
||||
* Cluster Service
|
||||
*
|
||||
* Manages cluster functionality including:
|
||||
* - Node registration and discovery
|
||||
* - Health checking and heartbeats
|
||||
* - Session affinity (sticky sessions)
|
||||
* - Load balancing across replicas
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'crypto';
|
||||
import os from 'os';
|
||||
import crypto from 'crypto';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
ClusterNode,
|
||||
ClusterConfig,
|
||||
ServerReplica,
|
||||
SessionAffinity,
|
||||
} from '../types/index.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
|
||||
// In-memory storage for cluster state
|
||||
const nodes: Map<string, ClusterNode> = new Map();
|
||||
const sessionAffinities: Map<string, SessionAffinity> = new Map();
|
||||
const serverReplicas: Map<string, ServerReplica[]> = new Map();
|
||||
let currentNodeId: string | null = null;
|
||||
let heartbeatIntervalId: NodeJS.Timeout | null = null;
|
||||
let cleanupIntervalId: NodeJS.Timeout | null = null;
|
||||
|
||||
/**
|
||||
* Get cluster configuration from settings
|
||||
*/
|
||||
export const getClusterConfig = (): ClusterConfig | null => {
|
||||
const settings = loadSettings();
|
||||
return settings.systemConfig?.cluster || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if cluster mode is enabled
|
||||
*/
|
||||
export const isClusterEnabled = (): boolean => {
|
||||
const config = getClusterConfig();
|
||||
return config?.enabled === true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the current node's operating mode
|
||||
*/
|
||||
export const getClusterMode = (): 'standalone' | 'node' | 'coordinator' => {
|
||||
const config = getClusterConfig();
|
||||
if (!config?.enabled) {
|
||||
return 'standalone';
|
||||
}
|
||||
return config.mode || 'standalone';
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the current node ID
|
||||
*/
|
||||
export const getCurrentNodeId = (): string | null => {
|
||||
return currentNodeId;
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize cluster service based on configuration
|
||||
*/
|
||||
export const initClusterService = async (): Promise<void> => {
|
||||
const config = getClusterConfig();
|
||||
|
||||
if (!config?.enabled) {
|
||||
console.log('Cluster mode is disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Initializing cluster service in ${config.mode} mode`);
|
||||
|
||||
switch (config.mode) {
|
||||
case 'node':
|
||||
await initAsNode(config);
|
||||
break;
|
||||
case 'coordinator':
|
||||
await initAsCoordinator(config);
|
||||
break;
|
||||
case 'standalone':
|
||||
default:
|
||||
console.log('Running in standalone mode');
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize this instance as a cluster node
|
||||
*/
|
||||
const initAsNode = async (config: ClusterConfig): Promise<void> => {
|
||||
if (!config.node) {
|
||||
throw new Error('Node configuration is required for cluster node mode');
|
||||
}
|
||||
|
||||
// Generate or use provided node ID
|
||||
currentNodeId = config.node.id || randomUUID();
|
||||
|
||||
const nodeName = config.node.name || os.hostname();
|
||||
const port = process.env.PORT || 3000;
|
||||
|
||||
console.log(`Initializing as cluster node: ${nodeName} (${currentNodeId})`);
|
||||
|
||||
// Register with coordinator if enabled
|
||||
if (config.node.registerOnStartup !== false) {
|
||||
await registerWithCoordinator(config, nodeName, Number(port));
|
||||
}
|
||||
|
||||
// Start heartbeat to coordinator
|
||||
const heartbeatInterval = config.node.heartbeatInterval || 5000;
|
||||
heartbeatIntervalId = setInterval(async () => {
|
||||
await sendHeartbeat(config, nodeName, Number(port));
|
||||
}, heartbeatInterval);
|
||||
|
||||
console.log(`Node registered with coordinator at ${config.node.coordinatorUrl}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize this instance as the coordinator
|
||||
*/
|
||||
const initAsCoordinator = async (config: ClusterConfig): Promise<void> => {
|
||||
currentNodeId = 'coordinator';
|
||||
|
||||
console.log('Initializing as cluster coordinator');
|
||||
|
||||
// Start cleanup interval for inactive nodes
|
||||
const cleanupInterval = config.coordinator?.cleanupInterval || 30000;
|
||||
cleanupIntervalId = setInterval(() => {
|
||||
cleanupInactiveNodes(config);
|
||||
}, cleanupInterval);
|
||||
|
||||
console.log('Cluster coordinator initialized');
|
||||
};
|
||||
|
||||
/**
|
||||
* Register this node with the coordinator
|
||||
*/
|
||||
const registerWithCoordinator = async (
|
||||
config: ClusterConfig,
|
||||
nodeName: string,
|
||||
port: number,
|
||||
): Promise<void> => {
|
||||
if (!config.node?.coordinatorUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hostname = os.hostname();
|
||||
const nodeUrl = `http://${hostname}:${port}`;
|
||||
|
||||
// Get list of local MCP servers
|
||||
const settings = loadSettings();
|
||||
const servers = Object.keys(settings.mcpServers || {});
|
||||
|
||||
const nodeInfo: ClusterNode = {
|
||||
id: currentNodeId!,
|
||||
name: nodeName,
|
||||
host: hostname,
|
||||
port,
|
||||
url: nodeUrl,
|
||||
status: 'active',
|
||||
lastHeartbeat: Date.now(),
|
||||
servers,
|
||||
};
|
||||
|
||||
try {
|
||||
await axios.post(
|
||||
`${config.node.coordinatorUrl}/api/cluster/register`,
|
||||
nodeInfo,
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
console.log('Successfully registered with coordinator');
|
||||
} catch (error) {
|
||||
console.error('Failed to register with coordinator:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Send heartbeat to coordinator
|
||||
*/
|
||||
const sendHeartbeat = async (
|
||||
config: ClusterConfig,
|
||||
nodeName: string,
|
||||
port: number,
|
||||
): Promise<void> => {
|
||||
if (!config.node?.coordinatorUrl || !currentNodeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hostname = os.hostname();
|
||||
const settings = loadSettings();
|
||||
const servers = Object.keys(settings.mcpServers || {});
|
||||
|
||||
try {
|
||||
await axios.post(
|
||||
`${config.node.coordinatorUrl}/api/cluster/heartbeat`,
|
||||
{
|
||||
id: currentNodeId,
|
||||
name: nodeName,
|
||||
host: hostname,
|
||||
port,
|
||||
servers,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('Failed to send heartbeat to coordinator:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Cleanup inactive nodes (coordinator only)
|
||||
*/
|
||||
const cleanupInactiveNodes = (config: ClusterConfig): void => {
|
||||
const timeout = config.coordinator?.nodeTimeout || 15000;
|
||||
const now = Date.now();
|
||||
|
||||
for (const [nodeId, node] of nodes.entries()) {
|
||||
if (now - node.lastHeartbeat > timeout) {
|
||||
console.log(`Marking node ${nodeId} as unhealthy (last heartbeat: ${new Date(node.lastHeartbeat).toISOString()})`);
|
||||
node.status = 'unhealthy';
|
||||
|
||||
// Remove server replicas for this node
|
||||
for (const [serverId, replicas] of serverReplicas.entries()) {
|
||||
const updatedReplicas = replicas.filter(r => r.nodeId !== nodeId);
|
||||
if (updatedReplicas.length === 0) {
|
||||
serverReplicas.delete(serverId);
|
||||
} else {
|
||||
serverReplicas.set(serverId, updatedReplicas);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up expired session affinities
|
||||
const _sessionTimeout = config.coordinator?.stickySessionTimeout || 3600000; // 1 hour
|
||||
for (const [sessionId, affinity] of sessionAffinities.entries()) {
|
||||
if (now > affinity.expiresAt) {
|
||||
sessionAffinities.delete(sessionId);
|
||||
console.log(`Removed expired session affinity: ${sessionId}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Register a node (coordinator endpoint)
|
||||
*/
|
||||
export const registerNode = (nodeInfo: ClusterNode): void => {
|
||||
nodes.set(nodeInfo.id, {
|
||||
...nodeInfo,
|
||||
status: 'active',
|
||||
lastHeartbeat: Date.now(),
|
||||
});
|
||||
|
||||
// Update server replicas
|
||||
for (const serverId of nodeInfo.servers) {
|
||||
const replicas = serverReplicas.get(serverId) || [];
|
||||
|
||||
// Check if replica already exists
|
||||
const existingIndex = replicas.findIndex(r => r.nodeId === nodeInfo.id);
|
||||
const replica: ServerReplica = {
|
||||
serverId,
|
||||
nodeId: nodeInfo.id,
|
||||
nodeUrl: nodeInfo.url,
|
||||
status: 'active',
|
||||
weight: 1,
|
||||
};
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
replicas[existingIndex] = replica;
|
||||
} else {
|
||||
replicas.push(replica);
|
||||
}
|
||||
|
||||
serverReplicas.set(serverId, replicas);
|
||||
}
|
||||
|
||||
console.log(`Node registered: ${nodeInfo.name} (${nodeInfo.id}) with ${nodeInfo.servers.length} servers`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update node heartbeat (coordinator endpoint)
|
||||
*/
|
||||
export const updateNodeHeartbeat = (nodeId: string, servers: string[]): void => {
|
||||
const node = nodes.get(nodeId);
|
||||
if (!node) {
|
||||
console.warn(`Received heartbeat from unknown node: ${nodeId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
node.lastHeartbeat = Date.now();
|
||||
node.status = 'active';
|
||||
node.servers = servers;
|
||||
|
||||
// Update server replicas
|
||||
const currentReplicas = new Set<string>();
|
||||
for (const [serverId, replicas] of serverReplicas.entries()) {
|
||||
for (const replica of replicas) {
|
||||
if (replica.nodeId === nodeId) {
|
||||
currentReplicas.add(serverId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add new servers
|
||||
for (const serverId of servers) {
|
||||
if (!currentReplicas.has(serverId)) {
|
||||
const replicas = serverReplicas.get(serverId) || [];
|
||||
replicas.push({
|
||||
serverId,
|
||||
nodeId,
|
||||
nodeUrl: node.url,
|
||||
status: 'active',
|
||||
weight: 1,
|
||||
});
|
||||
serverReplicas.set(serverId, replicas);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove servers that are no longer on this node
|
||||
for (const serverId of currentReplicas) {
|
||||
if (!servers.includes(serverId)) {
|
||||
const replicas = serverReplicas.get(serverId) || [];
|
||||
const updatedReplicas = replicas.filter(r => r.nodeId !== nodeId);
|
||||
if (updatedReplicas.length === 0) {
|
||||
serverReplicas.delete(serverId);
|
||||
} else {
|
||||
serverReplicas.set(serverId, updatedReplicas);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all active nodes (coordinator)
|
||||
*/
|
||||
export const getActiveNodes = (): ClusterNode[] => {
|
||||
return Array.from(nodes.values()).filter(n => n.status === 'active');
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all nodes including unhealthy ones (coordinator)
|
||||
*/
|
||||
export const getAllNodes = (): ClusterNode[] => {
|
||||
return Array.from(nodes.values());
|
||||
};
|
||||
|
||||
/**
|
||||
* Get replicas for a specific server
|
||||
*/
|
||||
export const getServerReplicas = (serverId: string): ServerReplica[] => {
|
||||
return serverReplicas.get(serverId) || [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Get node for a session using sticky session strategy
|
||||
*/
|
||||
export const getNodeForSession = (
|
||||
sessionId: string,
|
||||
serverId?: string,
|
||||
headers?: Record<string, string | string[] | undefined>
|
||||
): ClusterNode | null => {
|
||||
const config = getClusterConfig();
|
||||
|
||||
if (!config?.enabled || !config.stickySession?.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if session already has affinity
|
||||
const existingAffinity = sessionAffinities.get(sessionId);
|
||||
if (existingAffinity) {
|
||||
const node = nodes.get(existingAffinity.nodeId);
|
||||
if (node && node.status === 'active') {
|
||||
// Update last accessed time
|
||||
existingAffinity.lastAccessed = Date.now();
|
||||
return node;
|
||||
} else {
|
||||
// Node is no longer active, remove affinity
|
||||
sessionAffinities.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine which node to use based on strategy
|
||||
const strategy = config.stickySession.strategy || 'consistent-hash';
|
||||
let targetNode: ClusterNode | null = null;
|
||||
|
||||
switch (strategy) {
|
||||
case 'consistent-hash':
|
||||
targetNode = getNodeByConsistentHash(sessionId, serverId);
|
||||
break;
|
||||
case 'cookie':
|
||||
targetNode = getNodeByCookie(headers, serverId);
|
||||
break;
|
||||
case 'header':
|
||||
targetNode = getNodeByHeader(headers, serverId);
|
||||
break;
|
||||
}
|
||||
|
||||
if (targetNode) {
|
||||
// Create session affinity
|
||||
const timeout = config.coordinator?.stickySessionTimeout || 3600000;
|
||||
const affinity: SessionAffinity = {
|
||||
sessionId,
|
||||
nodeId: targetNode.id,
|
||||
serverId,
|
||||
createdAt: Date.now(),
|
||||
lastAccessed: Date.now(),
|
||||
expiresAt: Date.now() + timeout,
|
||||
};
|
||||
sessionAffinities.set(sessionId, affinity);
|
||||
}
|
||||
|
||||
return targetNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get node using consistent hashing
|
||||
*/
|
||||
const getNodeByConsistentHash = (sessionId: string, serverId?: string): ClusterNode | null => {
|
||||
let availableNodes = getActiveNodes();
|
||||
|
||||
// Filter nodes that have the server if serverId is specified
|
||||
if (serverId) {
|
||||
const replicas = getServerReplicas(serverId);
|
||||
const nodeIds = new Set(replicas.filter(r => r.status === 'active').map(r => r.nodeId));
|
||||
availableNodes = availableNodes.filter(n => nodeIds.has(n.id));
|
||||
}
|
||||
|
||||
if (availableNodes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Simple consistent hash: hash session ID and mod by node count
|
||||
const hash = crypto.createHash('md5').update(sessionId).digest('hex');
|
||||
const hashNum = parseInt(hash.substring(0, 8), 16);
|
||||
const index = hashNum % availableNodes.length;
|
||||
|
||||
return availableNodes[index];
|
||||
};
|
||||
|
||||
/**
|
||||
* Get node from cookie
|
||||
*/
|
||||
const getNodeByCookie = (
|
||||
headers?: Record<string, string | string[] | undefined>,
|
||||
serverId?: string
|
||||
): ClusterNode | null => {
|
||||
if (!headers?.cookie) {
|
||||
return getNodeByConsistentHash(randomUUID(), serverId);
|
||||
}
|
||||
|
||||
const config = getClusterConfig();
|
||||
const cookieName = config?.stickySession?.cookieName || 'MCPHUB_NODE';
|
||||
|
||||
const cookies = (Array.isArray(headers.cookie) ? headers.cookie[0] : headers.cookie) || '';
|
||||
const cookieMatch = cookies.match(new RegExp(`${cookieName}=([^;]+)`));
|
||||
|
||||
if (cookieMatch) {
|
||||
const nodeId = cookieMatch[1];
|
||||
const node = nodes.get(nodeId);
|
||||
if (node && node.status === 'active') {
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
return getNodeByConsistentHash(randomUUID(), serverId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get node from header
|
||||
*/
|
||||
const getNodeByHeader = (
|
||||
headers?: Record<string, string | string[] | undefined>,
|
||||
serverId?: string
|
||||
): ClusterNode | null => {
|
||||
const config = getClusterConfig();
|
||||
const headerName = (config?.stickySession?.headerName || 'X-MCPHub-Node').toLowerCase();
|
||||
|
||||
if (headers) {
|
||||
const nodeId = headers[headerName];
|
||||
if (nodeId) {
|
||||
const nodeIdStr = Array.isArray(nodeId) ? nodeId[0] : nodeId;
|
||||
const node = nodes.get(nodeIdStr);
|
||||
if (node && node.status === 'active') {
|
||||
return node;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return getNodeByConsistentHash(randomUUID(), serverId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get session affinity info for a session
|
||||
*/
|
||||
export const getSessionAffinity = (sessionId: string): SessionAffinity | null => {
|
||||
return sessionAffinities.get(sessionId) || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove session affinity
|
||||
*/
|
||||
export const removeSessionAffinity = (sessionId: string): void => {
|
||||
sessionAffinities.delete(sessionId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Shutdown cluster service
|
||||
*/
|
||||
export const shutdownClusterService = (): void => {
|
||||
if (heartbeatIntervalId) {
|
||||
clearInterval(heartbeatIntervalId);
|
||||
heartbeatIntervalId = null;
|
||||
}
|
||||
|
||||
if (cleanupIntervalId) {
|
||||
clearInterval(cleanupIntervalId);
|
||||
cleanupIntervalId = null;
|
||||
}
|
||||
|
||||
console.log('Cluster service shut down');
|
||||
};
|
||||
|
||||
/**
|
||||
* Get cluster statistics
|
||||
*/
|
||||
export const getClusterStats = () => {
|
||||
return {
|
||||
nodes: nodes.size,
|
||||
activeNodes: getActiveNodes().length,
|
||||
servers: serverReplicas.size,
|
||||
sessions: sessionAffinities.size,
|
||||
};
|
||||
};
|
||||
593
src/services/mcpOAuthProvider.ts
Normal file
593
src/services/mcpOAuthProvider.ts
Normal file
@@ -0,0 +1,593 @@
|
||||
/**
|
||||
* MCP OAuth Provider Implementation
|
||||
*
|
||||
* Implements OAuthClientProvider interface from @modelcontextprotocol/sdk/client/auth.js
|
||||
* to handle OAuth 2.0 authentication for upstream MCP servers using the SDK's built-in
|
||||
* OAuth support.
|
||||
*
|
||||
* This provider integrates with our existing OAuth infrastructure:
|
||||
* - Dynamic client registration (RFC7591)
|
||||
* - Token storage and refresh
|
||||
* - Authorization flow handling
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
|
||||
import type {
|
||||
OAuthClientInformation,
|
||||
OAuthClientInformationFull,
|
||||
OAuthClientMetadata,
|
||||
OAuthTokens,
|
||||
} from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
import { ServerConfig } from '../types/index.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import {
|
||||
initializeOAuthForServer,
|
||||
getRegisteredClient,
|
||||
removeRegisteredClient,
|
||||
fetchScopesFromServer,
|
||||
} from './oauthClientRegistration.js';
|
||||
import {
|
||||
clearOAuthData,
|
||||
loadServerConfig,
|
||||
mutateOAuthSettings,
|
||||
persistClientCredentials,
|
||||
persistTokens,
|
||||
updatePendingAuthorization,
|
||||
ServerConfigWithOAuth,
|
||||
} from './oauthSettingsStore.js';
|
||||
|
||||
// Import getServerByName to access ServerInfo
|
||||
import { getServerByName } from './mcpService.js';
|
||||
|
||||
/**
|
||||
* MCPHub OAuth Provider for server-side OAuth flows
|
||||
*
|
||||
* This provider handles OAuth authentication for upstream MCP servers.
|
||||
* Unlike browser-based providers, this runs in a Node.js server environment,
|
||||
* so the authorization flow requires external handling (e.g., via web UI).
|
||||
*/
|
||||
export class MCPHubOAuthProvider implements OAuthClientProvider {
|
||||
private serverName: string;
|
||||
private serverConfig: ServerConfig;
|
||||
private _codeVerifier?: string;
|
||||
private _currentState?: string;
|
||||
|
||||
constructor(serverName: string, serverConfig: ServerConfig) {
|
||||
this.serverName = serverName;
|
||||
this.serverConfig = serverConfig;
|
||||
}
|
||||
|
||||
private getSystemInstallBaseUrl(): string | undefined {
|
||||
const settings = loadSettings();
|
||||
return settings.systemConfig?.install?.baseUrl;
|
||||
}
|
||||
|
||||
private sanitizeRedirectUri(input?: string): string | null {
|
||||
if (!input) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(input);
|
||||
url.searchParams.delete('server');
|
||||
const params = url.searchParams.toString();
|
||||
url.search = params ? `?${params}` : '';
|
||||
return url.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private buildRedirectUriFromBase(baseUrl?: string): string | null {
|
||||
if (!baseUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = baseUrl.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const normalizedBase = trimmed.endsWith('/') ? trimmed : `${trimmed}/`;
|
||||
const redirect = new URL('oauth/callback', normalizedBase);
|
||||
return this.sanitizeRedirectUri(redirect.toString());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get redirect URL for OAuth callback
|
||||
*/
|
||||
get redirectUrl(): string {
|
||||
const dynamicConfig = this.serverConfig.oauth?.dynamicRegistration;
|
||||
const metadata = dynamicConfig?.metadata || {};
|
||||
const fallback = 'http://localhost:3000/oauth/callback';
|
||||
const systemConfigured = this.buildRedirectUriFromBase(this.getSystemInstallBaseUrl());
|
||||
const metadataConfigured = this.sanitizeRedirectUri(metadata.redirect_uris?.[0]);
|
||||
|
||||
return systemConfigured ?? metadataConfigured ?? fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client metadata for dynamic registration or static configuration
|
||||
*/
|
||||
get clientMetadata(): OAuthClientMetadata {
|
||||
const dynamicConfig = this.serverConfig.oauth?.dynamicRegistration;
|
||||
const metadata = dynamicConfig?.metadata || {};
|
||||
|
||||
// Use redirectUrl getter to ensure consistent callback URL
|
||||
const redirectUri = this.redirectUrl;
|
||||
const systemConfigured = this.buildRedirectUriFromBase(this.getSystemInstallBaseUrl());
|
||||
const metadataRedirects =
|
||||
metadata.redirect_uris && metadata.redirect_uris.length > 0
|
||||
? metadata.redirect_uris
|
||||
.map((uri) => this.sanitizeRedirectUri(uri))
|
||||
.filter((uri): uri is string => Boolean(uri))
|
||||
: [];
|
||||
const redirectUris: string[] = [];
|
||||
|
||||
if (systemConfigured) {
|
||||
redirectUris.push(systemConfigured);
|
||||
}
|
||||
|
||||
for (const uri of metadataRedirects) {
|
||||
if (!redirectUris.includes(uri)) {
|
||||
redirectUris.push(uri);
|
||||
}
|
||||
}
|
||||
|
||||
if (!redirectUris.includes(redirectUri)) {
|
||||
redirectUris.push(redirectUri);
|
||||
}
|
||||
|
||||
const tokenEndpointAuthMethod =
|
||||
metadata.token_endpoint_auth_method && metadata.token_endpoint_auth_method !== ''
|
||||
? metadata.token_endpoint_auth_method
|
||||
: this.serverConfig.oauth?.clientSecret
|
||||
? 'client_secret_post'
|
||||
: 'none';
|
||||
|
||||
return {
|
||||
...metadata, // Include any additional custom metadata
|
||||
client_name: metadata.client_name || `MCPHub - ${this.serverName}`,
|
||||
redirect_uris: redirectUris,
|
||||
grant_types: metadata.grant_types || ['authorization_code', 'refresh_token'],
|
||||
response_types: metadata.response_types || ['code'],
|
||||
token_endpoint_auth_method: tokenEndpointAuthMethod,
|
||||
scope: metadata.scope || this.serverConfig.oauth?.scopes?.join(' ') || 'openid',
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureScopesFromServer(): Promise<string[] | undefined> {
|
||||
const serverUrl = this.serverConfig.url;
|
||||
const existingScopes = this.serverConfig.oauth?.scopes;
|
||||
|
||||
if (!serverUrl) {
|
||||
return existingScopes;
|
||||
}
|
||||
|
||||
if (existingScopes && existingScopes.length > 0) {
|
||||
return existingScopes;
|
||||
}
|
||||
|
||||
try {
|
||||
const scopes = await fetchScopesFromServer(serverUrl);
|
||||
if (scopes && scopes.length > 0) {
|
||||
const updatedConfig = await mutateOAuthSettings(this.serverName, ({ oauth }) => {
|
||||
oauth.scopes = scopes;
|
||||
});
|
||||
if (updatedConfig) {
|
||||
this.serverConfig = updatedConfig;
|
||||
}
|
||||
console.log(`Stored auto-detected scopes for ${this.serverName}: ${scopes.join(', ')}`);
|
||||
return scopes;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to auto-detect scopes for ${this.serverName}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
return existingScopes;
|
||||
}
|
||||
|
||||
private generateState(): string {
|
||||
const payload = {
|
||||
server: this.serverName,
|
||||
nonce: randomBytes(16).toString('hex'),
|
||||
};
|
||||
const base64 = Buffer.from(JSON.stringify(payload)).toString('base64');
|
||||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
async state(): Promise<string> {
|
||||
if (!this._currentState) {
|
||||
this._currentState = this.generateState();
|
||||
}
|
||||
return this._currentState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get previously registered client information
|
||||
*/
|
||||
clientInformation(): OAuthClientInformation | undefined {
|
||||
const clientInfo = getRegisteredClient(this.serverName);
|
||||
|
||||
if (!clientInfo) {
|
||||
// Try to use static client configuration from cached serverConfig first
|
||||
let serverConfig = this.serverConfig;
|
||||
|
||||
// If cached config doesn't have clientId, reload from settings
|
||||
if (!serverConfig?.oauth?.clientId) {
|
||||
const storedConfig = loadServerConfig(this.serverName);
|
||||
|
||||
if (storedConfig) {
|
||||
this.serverConfig = storedConfig;
|
||||
serverConfig = storedConfig;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to use static client configuration from serverConfig
|
||||
if (serverConfig?.oauth?.clientId) {
|
||||
return {
|
||||
client_id: serverConfig.oauth.clientId,
|
||||
client_secret: serverConfig.oauth.clientSecret,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
client_id: clientInfo.clientId,
|
||||
client_secret: clientInfo.clientSecret,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save registered client information
|
||||
* Called by SDK after successful dynamic registration
|
||||
*/
|
||||
async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
|
||||
console.log(`Saving OAuth client information for server: ${this.serverName}`);
|
||||
|
||||
const scopeString = info.scope?.trim();
|
||||
const scopes =
|
||||
scopeString && scopeString.length > 0
|
||||
? scopeString.split(/\s+/).filter((value) => value.length > 0)
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const updatedConfig = await persistClientCredentials(this.serverName, {
|
||||
clientId: info.client_id,
|
||||
clientSecret: info.client_secret,
|
||||
scopes,
|
||||
});
|
||||
|
||||
if (updatedConfig) {
|
||||
this.serverConfig = updatedConfig;
|
||||
}
|
||||
|
||||
if (!scopes || scopes.length === 0) {
|
||||
await this.ensureScopesFromServer();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to persist OAuth client credentials for server ${this.serverName}:`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored OAuth tokens
|
||||
*/
|
||||
tokens(): OAuthTokens | undefined {
|
||||
// Use cached config first, but reload if needed
|
||||
let serverConfig = this.serverConfig;
|
||||
|
||||
// If cached config doesn't have tokens, try reloading
|
||||
if (!serverConfig?.oauth?.accessToken) {
|
||||
const storedConfig = loadServerConfig(this.serverName);
|
||||
if (storedConfig) {
|
||||
this.serverConfig = storedConfig;
|
||||
serverConfig = storedConfig;
|
||||
}
|
||||
}
|
||||
|
||||
if (!serverConfig?.oauth?.accessToken) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
access_token: serverConfig.oauth.accessToken,
|
||||
token_type: 'Bearer',
|
||||
refresh_token: serverConfig.oauth.refreshToken,
|
||||
// Note: expires_in is not typically stored, only the token itself
|
||||
// The SDK will handle token refresh when needed
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save OAuth tokens
|
||||
* Called by SDK after successful token exchange or refresh
|
||||
*/
|
||||
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||
const currentOAuth = this.serverConfig.oauth;
|
||||
const accessTokenChanged = currentOAuth?.accessToken !== tokens.access_token;
|
||||
const refreshTokenProvided = tokens.refresh_token !== undefined;
|
||||
const refreshTokenChanged =
|
||||
refreshTokenProvided && currentOAuth?.refreshToken !== tokens.refresh_token;
|
||||
const hadPending = Boolean(currentOAuth?.pendingAuthorization);
|
||||
|
||||
if (!accessTokenChanged && !refreshTokenChanged && !hadPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Saving OAuth tokens for server: ${this.serverName}`);
|
||||
|
||||
const updatedConfig = await persistTokens(this.serverName, {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: refreshTokenProvided ? (tokens.refresh_token ?? null) : undefined,
|
||||
clearPendingAuthorization: hadPending,
|
||||
});
|
||||
|
||||
if (updatedConfig) {
|
||||
this.serverConfig = updatedConfig;
|
||||
}
|
||||
|
||||
this._codeVerifier = undefined;
|
||||
this._currentState = undefined;
|
||||
|
||||
const serverInfo = getServerByName(this.serverName);
|
||||
if (serverInfo) {
|
||||
serverInfo.oauth = undefined;
|
||||
}
|
||||
|
||||
console.log(`Saved OAuth tokens for server: ${this.serverName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to authorization URL
|
||||
* In a server environment, we can't directly redirect the user
|
||||
* Instead, we store the URL in ServerInfo for the frontend to access
|
||||
*/
|
||||
async redirectToAuthorization(url: URL): Promise<void> {
|
||||
console.log('='.repeat(80));
|
||||
console.log(`OAuth Authorization Required for server: ${this.serverName}`);
|
||||
console.log(`Authorization URL: ${url.toString()}`);
|
||||
console.log('='.repeat(80));
|
||||
let state = url.searchParams.get('state') || undefined;
|
||||
|
||||
if (!state) {
|
||||
state = await this.state();
|
||||
url.searchParams.set('state', state);
|
||||
} else {
|
||||
this._currentState = state;
|
||||
}
|
||||
|
||||
const authorizationUrl = url.toString();
|
||||
|
||||
try {
|
||||
const pendingUpdate: Partial<NonNullable<ServerConfig['oauth']>['pendingAuthorization']> = {
|
||||
authorizationUrl,
|
||||
state,
|
||||
};
|
||||
|
||||
if (this._codeVerifier) {
|
||||
pendingUpdate.codeVerifier = this._codeVerifier;
|
||||
}
|
||||
|
||||
const updatedConfig = await updatePendingAuthorization(this.serverName, pendingUpdate);
|
||||
if (updatedConfig) {
|
||||
this.serverConfig = updatedConfig;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to persist pending OAuth authorization state for ${this.serverName}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
// Store the authorization URL in ServerInfo for the frontend to access
|
||||
const serverInfo = getServerByName(this.serverName);
|
||||
if (serverInfo) {
|
||||
serverInfo.status = 'oauth_required';
|
||||
serverInfo.oauth = {
|
||||
authorizationUrl,
|
||||
state,
|
||||
codeVerifier: this._codeVerifier,
|
||||
};
|
||||
console.log(`Stored OAuth authorization URL in ServerInfo for server: ${this.serverName}`);
|
||||
} else {
|
||||
console.warn(`ServerInfo not found for ${this.serverName}, cannot store authorization URL`);
|
||||
}
|
||||
|
||||
// Throw error to indicate authorization is needed
|
||||
// The error will be caught in the connection flow and handled appropriately
|
||||
throw new Error(
|
||||
`OAuth authorization required for server ${this.serverName}. Please complete OAuth flow via web UI.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save PKCE code verifier for later use in token exchange
|
||||
*/
|
||||
async saveCodeVerifier(verifier: string): Promise<void> {
|
||||
this._codeVerifier = verifier;
|
||||
try {
|
||||
const updatedConfig = await updatePendingAuthorization(this.serverName, {
|
||||
codeVerifier: verifier,
|
||||
});
|
||||
if (updatedConfig) {
|
||||
this.serverConfig = updatedConfig;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to persist OAuth code verifier for ${this.serverName}:`, error);
|
||||
}
|
||||
console.log(`Saved code verifier for server: ${this.serverName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve PKCE code verifier for token exchange
|
||||
*/
|
||||
async codeVerifier(): Promise<string> {
|
||||
if (this._codeVerifier) {
|
||||
return this._codeVerifier;
|
||||
}
|
||||
|
||||
const storedConfig = loadServerConfig(this.serverName);
|
||||
const storedVerifier = storedConfig?.oauth?.pendingAuthorization?.codeVerifier;
|
||||
|
||||
if (storedVerifier) {
|
||||
this.serverConfig = storedConfig || this.serverConfig;
|
||||
this._codeVerifier = storedVerifier;
|
||||
return storedVerifier;
|
||||
}
|
||||
|
||||
throw new Error(`No code verifier stored for server: ${this.serverName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cached OAuth credentials when the SDK detects they are no longer valid.
|
||||
* This keeps stored configuration in sync and forces a fresh authorization flow.
|
||||
*/
|
||||
async invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier'): Promise<void> {
|
||||
const storedConfig = loadServerConfig(this.serverName);
|
||||
|
||||
if (!storedConfig?.oauth) {
|
||||
if (scope === 'verifier' || scope === 'all') {
|
||||
this._codeVerifier = undefined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let currentConfig = storedConfig as ServerConfigWithOAuth;
|
||||
const assignUpdatedConfig = (updated?: ServerConfigWithOAuth) => {
|
||||
if (updated) {
|
||||
currentConfig = updated;
|
||||
this.serverConfig = updated;
|
||||
} else {
|
||||
this.serverConfig = currentConfig;
|
||||
}
|
||||
};
|
||||
|
||||
assignUpdatedConfig(currentConfig);
|
||||
let changed = false;
|
||||
|
||||
if (scope === 'tokens' || scope === 'all') {
|
||||
if (currentConfig.oauth.accessToken || currentConfig.oauth.refreshToken) {
|
||||
const updated = await clearOAuthData(this.serverName, 'tokens');
|
||||
assignUpdatedConfig(updated);
|
||||
changed = true;
|
||||
console.warn(`Cleared OAuth tokens for server: ${this.serverName}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (scope === 'client' || scope === 'all') {
|
||||
const supportsDynamicClient = currentConfig.oauth.dynamicRegistration?.enabled === true;
|
||||
|
||||
if (
|
||||
supportsDynamicClient &&
|
||||
(currentConfig.oauth.clientId || currentConfig.oauth.clientSecret)
|
||||
) {
|
||||
removeRegisteredClient(this.serverName);
|
||||
const updated = await clearOAuthData(this.serverName, 'client');
|
||||
assignUpdatedConfig(updated);
|
||||
changed = true;
|
||||
console.warn(`Cleared OAuth client registration for server: ${this.serverName}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (scope === 'verifier' || scope === 'all') {
|
||||
this._codeVerifier = undefined;
|
||||
this._currentState = undefined;
|
||||
if (currentConfig.oauth.pendingAuthorization) {
|
||||
const updated = await clearOAuthData(this.serverName, 'verifier');
|
||||
assignUpdatedConfig(updated);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
this._currentState = undefined;
|
||||
const serverInfo = getServerByName(this.serverName);
|
||||
if (serverInfo) {
|
||||
serverInfo.status = 'oauth_required';
|
||||
serverInfo.oauth = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const prepopulateScopesIfMissing = async (
|
||||
serverName: string,
|
||||
serverConfig: ServerConfig,
|
||||
): Promise<void> => {
|
||||
if (!serverConfig.oauth || serverConfig.oauth.scopes?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!serverConfig.url) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const scopes = await fetchScopesFromServer(serverConfig.url);
|
||||
if (scopes && scopes.length > 0) {
|
||||
const updatedConfig = await mutateOAuthSettings(serverName, ({ oauth }) => {
|
||||
oauth.scopes = scopes;
|
||||
});
|
||||
|
||||
if (!serverConfig.oauth) {
|
||||
serverConfig.oauth = {};
|
||||
}
|
||||
serverConfig.oauth.scopes = scopes;
|
||||
|
||||
if (updatedConfig) {
|
||||
console.log(`Stored auto-detected scopes for ${serverName}: ${scopes.join(', ')}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to auto-detect scopes for ${serverName} during provider initialization: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an OAuth provider for a server if OAuth is configured
|
||||
*
|
||||
* @param serverName - Name of the server
|
||||
* @param serverConfig - Server configuration
|
||||
* @returns OAuthClientProvider instance or undefined if OAuth not configured
|
||||
*/
|
||||
export const createOAuthProvider = async (
|
||||
serverName: string,
|
||||
serverConfig: ServerConfig,
|
||||
): Promise<OAuthClientProvider | undefined> => {
|
||||
// Ensure scopes are pre-populated if dynamic registration already ran previously
|
||||
await prepopulateScopesIfMissing(serverName, serverConfig);
|
||||
|
||||
// Initialize OAuth for the server (performs registration if needed)
|
||||
// This ensures the client is registered before the SDK tries to use it
|
||||
try {
|
||||
await initializeOAuthForServer(serverName, serverConfig);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to initialize OAuth for server ${serverName}:`, error);
|
||||
// Continue anyway - the SDK might be able to handle it
|
||||
}
|
||||
|
||||
// Create and return the provider
|
||||
const provider = new MCPHubOAuthProvider(serverName, serverConfig);
|
||||
|
||||
console.log(`Created OAuth provider for server: ${serverName}`);
|
||||
return provider;
|
||||
};
|
||||
@@ -1,4 +1,6 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
@@ -10,7 +12,10 @@ import {
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import {
|
||||
StreamableHTTPClientTransport,
|
||||
StreamableHTTPClientTransportOptions,
|
||||
} from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import { ServerInfo, ServerConfig, Tool } from '../types/index.js';
|
||||
import { loadSettings, expandEnvVars, replaceEnvVars, getNameSeparator } from '../config/index.js';
|
||||
import config from '../config/index.js';
|
||||
@@ -21,11 +26,84 @@ import { OpenAPIClient } from '../clients/openapi.js';
|
||||
import { RequestContextService } from './requestContextService.js';
|
||||
import { getDataService } from './services.js';
|
||||
import { getServerDao, ServerConfigWithName } from '../dao/index.js';
|
||||
import { initializeAllOAuthClients } from './oauthService.js';
|
||||
import { createOAuthProvider } from './mcpOAuthProvider.js';
|
||||
|
||||
const servers: { [sessionId: string]: Server } = {};
|
||||
|
||||
const serverDao = getServerDao();
|
||||
|
||||
const ensureDirExists = (dir: string | undefined): string => {
|
||||
if (!dir) {
|
||||
throw new Error('Directory path is undefined');
|
||||
}
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
return dir;
|
||||
};
|
||||
|
||||
const getDataRootDir = (): string => {
|
||||
return ensureDirExists(process.env.MCP_DATA_DIR || path.join(process.cwd(), 'data'));
|
||||
};
|
||||
|
||||
const getServersStorageRoot = (): string => {
|
||||
return ensureDirExists(process.env.MCP_SERVERS_DIR || path.join(getDataRootDir(), 'servers'));
|
||||
};
|
||||
|
||||
const getNpmBaseDir = (): string => {
|
||||
return ensureDirExists(process.env.MCP_NPM_DIR || path.join(getServersStorageRoot(), 'npm'));
|
||||
};
|
||||
|
||||
const getPythonBaseDir = (): string => {
|
||||
return ensureDirExists(
|
||||
process.env.MCP_PYTHON_DIR || path.join(getServersStorageRoot(), 'python'),
|
||||
);
|
||||
};
|
||||
|
||||
const getNpmCacheDir = (): string => {
|
||||
return ensureDirExists(process.env.NPM_CONFIG_CACHE || path.join(getDataRootDir(), 'npm-cache'));
|
||||
};
|
||||
|
||||
const getNpmPrefixDir = (): string => {
|
||||
const dir = ensureDirExists(
|
||||
process.env.NPM_CONFIG_PREFIX || path.join(getDataRootDir(), 'npm-global'),
|
||||
);
|
||||
ensureDirExists(path.join(dir, 'bin'));
|
||||
ensureDirExists(path.join(dir, 'lib', 'node_modules'));
|
||||
return dir;
|
||||
};
|
||||
|
||||
const getUvCacheDir = (): string => {
|
||||
return ensureDirExists(process.env.UV_CACHE_DIR || path.join(getDataRootDir(), 'uv', 'cache'));
|
||||
};
|
||||
|
||||
const getUvToolDir = (): string => {
|
||||
const dir = ensureDirExists(process.env.UV_TOOL_DIR || path.join(getDataRootDir(), 'uv', 'tools'));
|
||||
ensureDirExists(path.join(dir, 'bin'));
|
||||
return dir;
|
||||
};
|
||||
|
||||
const getServerInstallDir = (serverName: string, kind: 'npm' | 'python'): string => {
|
||||
const baseDir = kind === 'npm' ? getNpmBaseDir() : getPythonBaseDir();
|
||||
return ensureDirExists(path.join(baseDir, serverName));
|
||||
};
|
||||
|
||||
const prependToPath = (currentPath: string, dir: string): string => {
|
||||
if (!dir) {
|
||||
return currentPath;
|
||||
}
|
||||
const delimiter = path.delimiter;
|
||||
const segments = currentPath ? currentPath.split(delimiter) : [];
|
||||
if (segments.includes(dir)) {
|
||||
return currentPath;
|
||||
}
|
||||
return currentPath ? `${dir}${delimiter}${currentPath}` : dir;
|
||||
};
|
||||
|
||||
const NODE_COMMANDS = new Set(['npm', 'npx', 'pnpm', 'yarn', 'node', 'bun', 'bunx']);
|
||||
const PYTHON_COMMANDS = new Set(['uv', 'uvx', 'python', 'pip', 'pip3', 'pipx']);
|
||||
|
||||
// Helper function to set up keep-alive ping for SSE connections
|
||||
const setupKeepAlive = (serverInfo: ServerInfo, serverConfig: ServerConfig): void => {
|
||||
// Only set up keep-alive for SSE connections
|
||||
@@ -59,6 +137,10 @@ const setupKeepAlive = (serverInfo: ServerInfo, serverConfig: ServerConfig): voi
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -155,28 +237,48 @@ export const cleanupAllServers = (): void => {
|
||||
};
|
||||
|
||||
// Helper function to create transport based on server configuration
|
||||
const createTransportFromConfig = (name: string, conf: ServerConfig): any => {
|
||||
export const createTransportFromConfig = async (name: string, conf: ServerConfig): Promise<any> => {
|
||||
let transport;
|
||||
|
||||
if (conf.type === 'streamable-http') {
|
||||
const options: any = {};
|
||||
if (conf.headers && Object.keys(conf.headers).length > 0) {
|
||||
const options: StreamableHTTPClientTransportOptions = {};
|
||||
const headers = conf.headers ? replaceEnvVars(conf.headers) : {};
|
||||
|
||||
if (Object.keys(headers).length > 0) {
|
||||
options.requestInit = {
|
||||
headers: conf.headers,
|
||||
headers,
|
||||
};
|
||||
}
|
||||
|
||||
// Create OAuth provider if configured - SDK will handle authentication automatically
|
||||
const authProvider = await createOAuthProvider(name, conf);
|
||||
if (authProvider) {
|
||||
options.authProvider = authProvider;
|
||||
console.log(`OAuth provider configured for server: ${name}`);
|
||||
}
|
||||
|
||||
transport = new StreamableHTTPClientTransport(new URL(conf.url || ''), options);
|
||||
} else if (conf.url) {
|
||||
// SSE transport
|
||||
const options: any = {};
|
||||
if (conf.headers && Object.keys(conf.headers).length > 0) {
|
||||
const headers = conf.headers ? replaceEnvVars(conf.headers) : {};
|
||||
|
||||
if (Object.keys(headers).length > 0) {
|
||||
options.eventSourceInit = {
|
||||
headers: conf.headers,
|
||||
headers,
|
||||
};
|
||||
options.requestInit = {
|
||||
headers: conf.headers,
|
||||
headers,
|
||||
};
|
||||
}
|
||||
|
||||
// Create OAuth provider if configured - SDK will handle authentication automatically
|
||||
const authProvider = await createOAuthProvider(name, conf);
|
||||
if (authProvider) {
|
||||
options.authProvider = authProvider;
|
||||
console.log(`OAuth provider configured for server: ${name}`);
|
||||
}
|
||||
|
||||
transport = new SSEClientTransport(new URL(conf.url), options);
|
||||
} else if (conf.command && conf.args) {
|
||||
// Stdio transport
|
||||
@@ -184,7 +286,7 @@ const createTransportFromConfig = (name: string, conf: ServerConfig): any => {
|
||||
...(process.env as Record<string, string>),
|
||||
...replaceEnvVars(conf.env || {}),
|
||||
};
|
||||
env['PATH'] = expandEnvVars(process.env.PATH as string) || '';
|
||||
env['PATH'] = expandEnvVars(env['PATH'] || process.env.PATH || '');
|
||||
|
||||
const settings = loadSettings();
|
||||
// Add UV_DEFAULT_INDEX and npm_config_registry if needed
|
||||
@@ -206,8 +308,52 @@ const createTransportFromConfig = (name: string, conf: ServerConfig): any => {
|
||||
env['npm_config_registry'] = settings.systemConfig.install.npmRegistry;
|
||||
}
|
||||
|
||||
// Ensure stdio servers use persistent directories under /app/data (or configured override)
|
||||
let workingDirectory = os.homedir();
|
||||
const commandLower = conf.command.toLowerCase();
|
||||
|
||||
if (NODE_COMMANDS.has(commandLower)) {
|
||||
const serverDir = getServerInstallDir(name, 'npm');
|
||||
workingDirectory = serverDir;
|
||||
|
||||
const npmCacheDir = getNpmCacheDir();
|
||||
const npmPrefixDir = getNpmPrefixDir();
|
||||
|
||||
if (!env['npm_config_cache']) {
|
||||
env['npm_config_cache'] = npmCacheDir;
|
||||
}
|
||||
if (!env['NPM_CONFIG_CACHE']) {
|
||||
env['NPM_CONFIG_CACHE'] = env['npm_config_cache'];
|
||||
}
|
||||
|
||||
if (!env['npm_config_prefix']) {
|
||||
env['npm_config_prefix'] = npmPrefixDir;
|
||||
}
|
||||
if (!env['NPM_CONFIG_PREFIX']) {
|
||||
env['NPM_CONFIG_PREFIX'] = env['npm_config_prefix'];
|
||||
}
|
||||
|
||||
env['PATH'] = prependToPath(env['PATH'], path.join(env['npm_config_prefix'], 'bin'));
|
||||
} else if (PYTHON_COMMANDS.has(commandLower)) {
|
||||
const serverDir = getServerInstallDir(name, 'python');
|
||||
workingDirectory = serverDir;
|
||||
|
||||
const uvCacheDir = getUvCacheDir();
|
||||
const uvToolDir = getUvToolDir();
|
||||
|
||||
if (!env['UV_CACHE_DIR']) {
|
||||
env['UV_CACHE_DIR'] = uvCacheDir;
|
||||
}
|
||||
if (!env['UV_TOOL_DIR']) {
|
||||
env['UV_TOOL_DIR'] = uvToolDir;
|
||||
}
|
||||
|
||||
env['PATH'] = prependToPath(env['PATH'], path.join(env['UV_TOOL_DIR'], 'bin'));
|
||||
}
|
||||
|
||||
// Expand environment variables in command
|
||||
transport = new StdioClientTransport({
|
||||
cwd: os.homedir(),
|
||||
cwd: workingDirectory,
|
||||
command: conf.command,
|
||||
args: replaceEnvVars(conf.args) as string[],
|
||||
env: env,
|
||||
@@ -269,7 +415,7 @@ const callToolWithReconnect = async (
|
||||
}
|
||||
|
||||
// Recreate transport using helper function
|
||||
const newTransport = createTransportFromConfig(serverInfo.name, server);
|
||||
const newTransport = await createTransportFromConfig(serverInfo.name, server);
|
||||
|
||||
// Create new client
|
||||
const client = new Client(
|
||||
@@ -345,230 +491,271 @@ export const initializeClientsFromSettings = async (
|
||||
): Promise<ServerInfo[]> => {
|
||||
const allServers: ServerConfigWithName[] = await serverDao.findAll();
|
||||
const existingServerInfos = serverInfos;
|
||||
serverInfos = [];
|
||||
const nextServerInfos: ServerInfo[] = [];
|
||||
|
||||
for (const conf of allServers) {
|
||||
const { name } = conf;
|
||||
// Skip disabled servers
|
||||
if (conf.enabled === false) {
|
||||
console.log(`Skipping disabled server: ${name}`);
|
||||
serverInfos.push({
|
||||
name,
|
||||
owner: conf.owner,
|
||||
status: 'disconnected',
|
||||
error: null,
|
||||
tools: [],
|
||||
prompts: [],
|
||||
createTime: Date.now(),
|
||||
enabled: false,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
for (const conf of allServers) {
|
||||
const { name } = conf;
|
||||
|
||||
// Check if server is already connected
|
||||
const existingServer = existingServerInfos.find(
|
||||
(s) => s.name === name && s.status === 'connected',
|
||||
);
|
||||
if (existingServer && (!serverName || serverName !== name)) {
|
||||
serverInfos.push({
|
||||
...existingServer,
|
||||
enabled: conf.enabled === undefined ? true : conf.enabled,
|
||||
});
|
||||
console.log(`Server '${name}' is already connected.`);
|
||||
continue;
|
||||
}
|
||||
// Expand environment variables in all configuration values
|
||||
const expandedConf = replaceEnvVars(conf as any) as ServerConfigWithName;
|
||||
|
||||
let transport;
|
||||
let openApiClient;
|
||||
if (conf.type === 'openapi') {
|
||||
// Handle OpenAPI type servers
|
||||
if (!conf.openapi?.url && !conf.openapi?.schema) {
|
||||
console.warn(
|
||||
`Skipping OpenAPI server '${name}': missing OpenAPI specification URL or schema`,
|
||||
);
|
||||
serverInfos.push({
|
||||
// Skip disabled servers
|
||||
if (expandedConf.enabled === false) {
|
||||
console.log(`Skipping disabled server: ${name}`);
|
||||
nextServerInfos.push({
|
||||
name,
|
||||
owner: conf.owner,
|
||||
owner: expandedConf.owner,
|
||||
status: 'disconnected',
|
||||
error: 'Missing OpenAPI specification URL or schema',
|
||||
error: null,
|
||||
tools: [],
|
||||
prompts: [],
|
||||
createTime: Date.now(),
|
||||
enabled: false,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if server is already connected
|
||||
const existingServer = existingServerInfos.find(
|
||||
(s) => s.name === name && s.status === 'connected',
|
||||
);
|
||||
if (existingServer && (!serverName || serverName !== name)) {
|
||||
nextServerInfos.push({
|
||||
...existingServer,
|
||||
enabled: expandedConf.enabled === undefined ? true : expandedConf.enabled,
|
||||
});
|
||||
console.log(`Server '${name}' is already connected.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let transport;
|
||||
let openApiClient;
|
||||
if (expandedConf.type === 'openapi') {
|
||||
// Handle OpenAPI type servers
|
||||
if (!expandedConf.openapi?.url && !expandedConf.openapi?.schema) {
|
||||
console.warn(
|
||||
`Skipping OpenAPI server '${name}': missing OpenAPI specification URL or schema`,
|
||||
);
|
||||
nextServerInfos.push({
|
||||
name,
|
||||
owner: expandedConf.owner,
|
||||
status: 'disconnected',
|
||||
error: 'Missing OpenAPI specification URL or schema',
|
||||
tools: [],
|
||||
prompts: [],
|
||||
createTime: Date.now(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create server info first and keep reference to it
|
||||
const serverInfo: ServerInfo = {
|
||||
name,
|
||||
owner: expandedConf.owner,
|
||||
status: 'connecting',
|
||||
error: null,
|
||||
tools: [],
|
||||
prompts: [],
|
||||
createTime: Date.now(),
|
||||
enabled: expandedConf.enabled === undefined ? true : expandedConf.enabled,
|
||||
config: expandedConf, // Store reference to expanded config for OpenAPI passthrough headers
|
||||
};
|
||||
nextServerInfos.push(serverInfo);
|
||||
|
||||
try {
|
||||
// Create OpenAPI client instance
|
||||
openApiClient = new OpenAPIClient(expandedConf);
|
||||
|
||||
console.log(`Initializing OpenAPI server: ${name}...`);
|
||||
|
||||
// Perform async initialization
|
||||
await openApiClient.initialize();
|
||||
|
||||
// Convert OpenAPI tools to MCP tool format
|
||||
const openApiTools = openApiClient.getTools();
|
||||
const mcpTools: Tool[] = openApiTools.map((tool) => ({
|
||||
name: `${name}${getNameSeparator()}${tool.name}`,
|
||||
description: tool.description,
|
||||
inputSchema: cleanInputSchema(tool.inputSchema),
|
||||
}));
|
||||
|
||||
// Update server info with successful initialization
|
||||
serverInfo.status = 'connected';
|
||||
serverInfo.tools = mcpTools;
|
||||
serverInfo.openApiClient = openApiClient;
|
||||
|
||||
console.log(
|
||||
`Successfully initialized OpenAPI server: ${name} with ${mcpTools.length} tools`,
|
||||
);
|
||||
|
||||
// Save tools as vector embeddings for search
|
||||
saveToolsAsVectorEmbeddings(name, mcpTools);
|
||||
continue;
|
||||
} catch (error) {
|
||||
console.error(`Failed to initialize OpenAPI server ${name}:`, error);
|
||||
|
||||
// Update the already pushed server info with error status
|
||||
serverInfo.status = 'disconnected';
|
||||
serverInfo.error = `Failed to initialize OpenAPI server: ${error}`;
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
transport = await createTransportFromConfig(name, expandedConf);
|
||||
}
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: `mcp-client-${name}`,
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
prompts: {},
|
||||
resources: {},
|
||||
tools: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const initRequestOptions = isInit
|
||||
? {
|
||||
timeout: Number(config.initTimeout) || 60000,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Get request options from server configuration, with fallbacks
|
||||
const serverRequestOptions = expandedConf.options || {};
|
||||
const requestOptions = {
|
||||
timeout: serverRequestOptions.timeout || 60000,
|
||||
resetTimeoutOnProgress: serverRequestOptions.resetTimeoutOnProgress || false,
|
||||
maxTotalTimeout: serverRequestOptions.maxTotalTimeout,
|
||||
};
|
||||
|
||||
// Create server info first and keep reference to it
|
||||
const serverInfo: ServerInfo = {
|
||||
name,
|
||||
owner: conf.owner,
|
||||
owner: expandedConf.owner,
|
||||
status: 'connecting',
|
||||
error: null,
|
||||
tools: [],
|
||||
prompts: [],
|
||||
client,
|
||||
transport,
|
||||
options: requestOptions,
|
||||
createTime: Date.now(),
|
||||
enabled: conf.enabled === undefined ? true : conf.enabled,
|
||||
config: conf, // Store reference to original config for OpenAPI passthrough headers
|
||||
config: expandedConf, // Store reference to expanded config
|
||||
};
|
||||
serverInfos.push(serverInfo);
|
||||
|
||||
try {
|
||||
// Create OpenAPI client instance
|
||||
openApiClient = new OpenAPIClient(conf);
|
||||
|
||||
console.log(`Initializing OpenAPI server: ${name}...`);
|
||||
|
||||
// Perform async initialization
|
||||
await openApiClient.initialize();
|
||||
|
||||
// Convert OpenAPI tools to MCP tool format
|
||||
const openApiTools = openApiClient.getTools();
|
||||
const mcpTools: Tool[] = openApiTools.map((tool) => ({
|
||||
name: `${name}${getNameSeparator()}${tool.name}`,
|
||||
description: tool.description,
|
||||
inputSchema: cleanInputSchema(tool.inputSchema),
|
||||
}));
|
||||
|
||||
// Update server info with successful initialization
|
||||
serverInfo.status = 'connected';
|
||||
serverInfo.tools = mcpTools;
|
||||
serverInfo.openApiClient = openApiClient;
|
||||
|
||||
console.log(
|
||||
`Successfully initialized OpenAPI server: ${name} with ${mcpTools.length} tools`,
|
||||
);
|
||||
|
||||
// Save tools as vector embeddings for search
|
||||
saveToolsAsVectorEmbeddings(name, mcpTools);
|
||||
continue;
|
||||
} catch (error) {
|
||||
console.error(`Failed to initialize OpenAPI server ${name}:`, error);
|
||||
|
||||
// Update the already pushed server info with error status
|
||||
serverInfo.status = 'disconnected';
|
||||
serverInfo.error = `Failed to initialize OpenAPI server: ${error}`;
|
||||
continue;
|
||||
const pendingAuth = expandedConf.oauth?.pendingAuthorization;
|
||||
if (pendingAuth) {
|
||||
serverInfo.status = 'oauth_required';
|
||||
serverInfo.error = null;
|
||||
serverInfo.oauth = {
|
||||
authorizationUrl: pendingAuth.authorizationUrl,
|
||||
state: pendingAuth.state,
|
||||
codeVerifier: pendingAuth.codeVerifier,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
transport = createTransportFromConfig(name, conf);
|
||||
nextServerInfos.push(serverInfo);
|
||||
|
||||
client
|
||||
.connect(transport, initRequestOptions || requestOptions)
|
||||
.then(() => {
|
||||
console.log(`Successfully connected client for server: ${name}`);
|
||||
const capabilities: ServerCapabilities | undefined = client.getServerCapabilities();
|
||||
console.log(`Server capabilities: ${JSON.stringify(capabilities)}`);
|
||||
|
||||
let dataError: Error | null = null;
|
||||
if (capabilities?.tools) {
|
||||
client
|
||||
.listTools({}, initRequestOptions || requestOptions)
|
||||
.then((tools) => {
|
||||
console.log(`Successfully listed ${tools.tools.length} tools for server: ${name}`);
|
||||
serverInfo.tools = tools.tools.map((tool) => ({
|
||||
name: `${name}${getNameSeparator()}${tool.name}`,
|
||||
description: tool.description || '',
|
||||
inputSchema: cleanInputSchema(tool.inputSchema || {}),
|
||||
}));
|
||||
// Save tools as vector embeddings for search
|
||||
saveToolsAsVectorEmbeddings(name, serverInfo.tools);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`Failed to list tools for server ${name} by error: ${error} with stack: ${error.stack}`,
|
||||
);
|
||||
dataError = error;
|
||||
});
|
||||
}
|
||||
|
||||
if (capabilities?.prompts) {
|
||||
client
|
||||
.listPrompts({}, initRequestOptions || requestOptions)
|
||||
.then((prompts) => {
|
||||
console.log(
|
||||
`Successfully listed ${prompts.prompts.length} prompts for server: ${name}`,
|
||||
);
|
||||
serverInfo.prompts = prompts.prompts.map((prompt) => ({
|
||||
name: `${name}${getNameSeparator()}${prompt.name}`,
|
||||
title: prompt.title,
|
||||
description: prompt.description,
|
||||
arguments: prompt.arguments,
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`Failed to list prompts for server ${name} by error: ${error} with stack: ${error.stack}`,
|
||||
);
|
||||
dataError = error;
|
||||
});
|
||||
}
|
||||
|
||||
if (!dataError) {
|
||||
serverInfo.status = 'connected';
|
||||
serverInfo.error = null;
|
||||
|
||||
// Set up keep-alive ping for SSE connections
|
||||
setupKeepAlive(serverInfo, expandedConf);
|
||||
} else {
|
||||
serverInfo.status = 'disconnected';
|
||||
serverInfo.error = `Failed to list data: ${dataError} `;
|
||||
}
|
||||
})
|
||||
.catch(async (error) => {
|
||||
// Check if this is an OAuth authorization error
|
||||
const isOAuthError =
|
||||
error?.message?.includes('OAuth authorization required') ||
|
||||
error?.message?.includes('Authorization required');
|
||||
|
||||
if (isOAuthError) {
|
||||
// OAuth provider should have already set the status to 'oauth_required'
|
||||
// and stored the authorization URL in serverInfo.oauth
|
||||
console.log(
|
||||
`OAuth authorization required for server ${name}. Status should be set to 'oauth_required'.`,
|
||||
);
|
||||
// Make sure status is set correctly
|
||||
if (serverInfo.status !== 'oauth_required') {
|
||||
serverInfo.status = 'oauth_required';
|
||||
}
|
||||
serverInfo.error = null;
|
||||
} else {
|
||||
console.error(
|
||||
`Failed to connect client for server ${name} by error: ${error} with stack: ${error.stack}`,
|
||||
);
|
||||
// Other connection errors
|
||||
serverInfo.status = 'disconnected';
|
||||
serverInfo.error = `Failed to connect: ${error.stack} `;
|
||||
}
|
||||
});
|
||||
console.log(`Initialized client for server: ${name}`);
|
||||
}
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: `mcp-client-${name}`,
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
prompts: {},
|
||||
resources: {},
|
||||
tools: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const initRequestOptions = isInit
|
||||
? {
|
||||
timeout: Number(config.initTimeout) || 60000,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Get request options from server configuration, with fallbacks
|
||||
const serverRequestOptions = conf.options || {};
|
||||
const requestOptions = {
|
||||
timeout: serverRequestOptions.timeout || 60000,
|
||||
resetTimeoutOnProgress: serverRequestOptions.resetTimeoutOnProgress || false,
|
||||
maxTotalTimeout: serverRequestOptions.maxTotalTimeout,
|
||||
};
|
||||
|
||||
// Create server info first and keep reference to it
|
||||
const serverInfo: ServerInfo = {
|
||||
name,
|
||||
owner: conf.owner,
|
||||
status: 'connecting',
|
||||
error: null,
|
||||
tools: [],
|
||||
prompts: [],
|
||||
client,
|
||||
transport,
|
||||
options: requestOptions,
|
||||
createTime: Date.now(),
|
||||
config: conf, // Store reference to original config
|
||||
};
|
||||
serverInfos.push(serverInfo);
|
||||
|
||||
client
|
||||
.connect(transport, initRequestOptions || requestOptions)
|
||||
.then(() => {
|
||||
console.log(`Successfully connected client for server: ${name}`);
|
||||
const capabilities: ServerCapabilities | undefined = client.getServerCapabilities();
|
||||
console.log(`Server capabilities: ${JSON.stringify(capabilities)}`);
|
||||
|
||||
let dataError: Error | null = null;
|
||||
if (capabilities?.tools) {
|
||||
client
|
||||
.listTools({}, initRequestOptions || requestOptions)
|
||||
.then((tools) => {
|
||||
console.log(`Successfully listed ${tools.tools.length} tools for server: ${name}`);
|
||||
serverInfo.tools = tools.tools.map((tool) => ({
|
||||
name: `${name}${getNameSeparator()}${tool.name}`,
|
||||
description: tool.description || '',
|
||||
inputSchema: cleanInputSchema(tool.inputSchema || {}),
|
||||
}));
|
||||
// Save tools as vector embeddings for search
|
||||
saveToolsAsVectorEmbeddings(name, serverInfo.tools);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`Failed to list tools for server ${name} by error: ${error} with stack: ${error.stack}`,
|
||||
);
|
||||
dataError = error;
|
||||
});
|
||||
}
|
||||
|
||||
if (capabilities?.prompts) {
|
||||
client
|
||||
.listPrompts({}, initRequestOptions || requestOptions)
|
||||
.then((prompts) => {
|
||||
console.log(
|
||||
`Successfully listed ${prompts.prompts.length} prompts for server: ${name}`,
|
||||
);
|
||||
serverInfo.prompts = prompts.prompts.map((prompt) => ({
|
||||
name: `${name}${getNameSeparator()}${prompt.name}`,
|
||||
title: prompt.title,
|
||||
description: prompt.description,
|
||||
arguments: prompt.arguments,
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`Failed to list prompts for server ${name} by error: ${error} with stack: ${error.stack}`,
|
||||
);
|
||||
dataError = error;
|
||||
});
|
||||
}
|
||||
|
||||
if (!dataError) {
|
||||
serverInfo.status = 'connected';
|
||||
serverInfo.error = null;
|
||||
|
||||
// Set up keep-alive ping for SSE connections
|
||||
setupKeepAlive(serverInfo, conf);
|
||||
} else {
|
||||
serverInfo.status = 'disconnected';
|
||||
serverInfo.error = `Failed to list data: ${dataError} `;
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`Failed to connect client for server ${name} by error: ${error} with stack: ${error.stack}`,
|
||||
);
|
||||
serverInfo.status = 'disconnected';
|
||||
serverInfo.error = `Failed to connect: ${error.stack} `;
|
||||
});
|
||||
console.log(`Initialized client for server: ${name}`);
|
||||
} catch (error) {
|
||||
// Restore previous state if initialization fails to avoid exposing an empty server list
|
||||
serverInfos = existingServerInfos;
|
||||
throw error;
|
||||
}
|
||||
|
||||
serverInfos = nextServerInfos;
|
||||
return serverInfos;
|
||||
};
|
||||
|
||||
@@ -584,39 +771,48 @@ export const getServersInfo = async (): Promise<Omit<ServerInfo, 'client' | 'tra
|
||||
const filterServerInfos: ServerInfo[] = dataService.filterData
|
||||
? dataService.filterData(serverInfos)
|
||||
: serverInfos;
|
||||
const infos = filterServerInfos.map(({ name, status, tools, prompts, createTime, error }) => {
|
||||
const serverConfig = allServers.find((server) => server.name === name);
|
||||
const enabled = serverConfig ? serverConfig.enabled !== false : true;
|
||||
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;
|
||||
|
||||
// Add enabled status and custom description to each tool
|
||||
const toolsWithEnabled = tools.map((tool) => {
|
||||
const toolConfig = serverConfig?.tools?.[tool.name];
|
||||
return {
|
||||
...tool,
|
||||
description: toolConfig?.description || tool.description, // Use custom description if available
|
||||
enabled: toolConfig?.enabled !== false, // Default to true if not explicitly disabled
|
||||
};
|
||||
});
|
||||
|
||||
const promptsWithEnabled = prompts.map((prompt) => {
|
||||
const promptConfig = serverConfig?.prompts?.[prompt.name];
|
||||
return {
|
||||
...prompt,
|
||||
description: promptConfig?.description || prompt.description, // Use custom description if available
|
||||
enabled: promptConfig?.enabled !== false, // Default to true if not explicitly disabled
|
||||
};
|
||||
});
|
||||
|
||||
// Add enabled status and custom description to each tool
|
||||
const toolsWithEnabled = tools.map((tool) => {
|
||||
const toolConfig = serverConfig?.tools?.[tool.name];
|
||||
return {
|
||||
...tool,
|
||||
description: toolConfig?.description || tool.description, // Use custom description if available
|
||||
enabled: toolConfig?.enabled !== false, // Default to true if not explicitly disabled
|
||||
name,
|
||||
status,
|
||||
error,
|
||||
tools: toolsWithEnabled,
|
||||
prompts: promptsWithEnabled,
|
||||
createTime,
|
||||
enabled,
|
||||
oauth: oauth
|
||||
? {
|
||||
authorizationUrl: oauth.authorizationUrl,
|
||||
state: oauth.state,
|
||||
// Don't expose codeVerifier to frontend for security
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const promptsWithEnabled = prompts.map((prompt) => {
|
||||
const promptConfig = serverConfig?.prompts?.[prompt.name];
|
||||
return {
|
||||
...prompt,
|
||||
description: promptConfig?.description || prompt.description, // Use custom description if available
|
||||
enabled: promptConfig?.enabled !== false, // Default to true if not explicitly disabled
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
name,
|
||||
status,
|
||||
error,
|
||||
tools: toolsWithEnabled,
|
||||
prompts: promptsWithEnabled,
|
||||
createTime,
|
||||
enabled,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
infos.sort((a, b) => {
|
||||
if (a.enabled === b.enabled) return 0;
|
||||
return a.enabled ? -1 : 1;
|
||||
@@ -629,6 +825,51 @@ export const getServerByName = (name: string): ServerInfo | undefined => {
|
||||
return serverInfos.find((serverInfo) => serverInfo.name === name);
|
||||
};
|
||||
|
||||
// Get server by OAuth state parameter
|
||||
export const getServerByOAuthState = (state: string): ServerInfo | undefined => {
|
||||
return serverInfos.find((serverInfo) => serverInfo.oauth?.state === state);
|
||||
};
|
||||
|
||||
/**
|
||||
* Reconnect a server after OAuth authorization or configuration change
|
||||
* This will close the existing connection and reinitialize the server
|
||||
*/
|
||||
export const reconnectServer = async (serverName: string): Promise<void> => {
|
||||
console.log(`Reconnecting server: ${serverName}`);
|
||||
|
||||
const serverInfo = getServerByName(serverName);
|
||||
if (!serverInfo) {
|
||||
throw new Error(`Server not found: ${serverName}`);
|
||||
}
|
||||
|
||||
// Close existing connection if any
|
||||
if (serverInfo.client) {
|
||||
try {
|
||||
serverInfo.client.close();
|
||||
} catch (error) {
|
||||
console.warn(`Error closing client for server ${serverName}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (serverInfo.transport) {
|
||||
try {
|
||||
serverInfo.transport.close();
|
||||
} catch (error) {
|
||||
console.warn(`Error closing transport for server ${serverName}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (serverInfo.keepAliveIntervalId) {
|
||||
clearInterval(serverInfo.keepAliveIntervalId);
|
||||
serverInfo.keepAliveIntervalId = undefined;
|
||||
}
|
||||
|
||||
// Reinitialize the server
|
||||
await initializeClientsFromSettings(false, serverName);
|
||||
|
||||
console.log(`Successfully reconnected server: ${serverName}`);
|
||||
};
|
||||
|
||||
// Filter tools by server configuration
|
||||
const filterToolsByConfig = async (serverName: string, tools: Tool[]): Promise<Tool[]> => {
|
||||
const serverConfig = await serverDao.findById(serverName);
|
||||
@@ -764,30 +1005,48 @@ export const handleListToolsRequest = async (_: any, extra: any) => {
|
||||
console.log(`Handling ListToolsRequest for group: ${group}`);
|
||||
|
||||
// Special handling for $smart group to return special tools
|
||||
if (group === '$smart') {
|
||||
// Support both $smart and $smart/{group} patterns
|
||||
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 = 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: (() => {
|
||||
// Get info about available servers
|
||||
const availableServers = serverInfos.filter(
|
||||
(server) => server.status === 'connected' && server.enabled !== false,
|
||||
);
|
||||
// Create simple server information with only server names
|
||||
const serversList = availableServers
|
||||
.map((server) => {
|
||||
return `${server.name}`;
|
||||
})
|
||||
.join(', ');
|
||||
return `STEP 1 of 2: Use this tool FIRST to discover and search for relevant tools across all available servers. 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.
|
||||
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}`;
|
||||
})(),
|
||||
Available servers: ${serversList}`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -904,7 +1163,25 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
|
||||
}
|
||||
|
||||
console.log(`Using similarity threshold: ${thresholdNum} for query: "${query}"`);
|
||||
const servers = undefined; // No server filtering
|
||||
|
||||
// Determine server filtering based on group
|
||||
const sessionId = extra.sessionId || '';
|
||||
const 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);
|
||||
const serversInGroup = getServersInGroup(targetGroup);
|
||||
if (serversInGroup !== undefined && serversInGroup !== null) {
|
||||
servers = serversInGroup;
|
||||
if (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)}`);
|
||||
|
||||
584
src/services/oauthClientRegistration.ts
Normal file
584
src/services/oauthClientRegistration.ts
Normal file
@@ -0,0 +1,584 @@
|
||||
/**
|
||||
* OAuth 2.0 Dynamic Client Registration Service
|
||||
*
|
||||
* Implements dynamic client registration for upstream MCP servers based on:
|
||||
* - RFC7591: OAuth 2.0 Dynamic Client Registration Protocol
|
||||
* - RFC8414: OAuth 2.0 Authorization Server Metadata
|
||||
* - MCP Authorization Specification
|
||||
*
|
||||
* Uses the standard openid-client library for OAuth operations.
|
||||
*/
|
||||
|
||||
import * as client from 'openid-client';
|
||||
import { ServerConfig } from '../types/index.js';
|
||||
import {
|
||||
mutateOAuthSettings,
|
||||
persistClientCredentials,
|
||||
persistTokens,
|
||||
} from './oauthSettingsStore.js';
|
||||
|
||||
interface RegisteredClientInfo {
|
||||
config: client.Configuration;
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
registrationAccessToken?: string;
|
||||
registrationClientUri?: string;
|
||||
expiresAt?: number;
|
||||
metadata: any;
|
||||
}
|
||||
|
||||
// Cache for registered clients to avoid re-registering on every restart
|
||||
const registeredClients = new Map<string, RegisteredClientInfo>();
|
||||
|
||||
export const removeRegisteredClient = (serverName: string): void => {
|
||||
registeredClients.delete(serverName);
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse WWW-Authenticate header to extract resource server metadata URL
|
||||
* Following RFC9728 Protected Resource Metadata specification
|
||||
*
|
||||
* Example header: WWW-Authenticate: Bearer resource="https://mcp.example.com/.well-known/oauth-protected-resource"
|
||||
*/
|
||||
export const parseWWWAuthenticateHeader = (header: string): string | null => {
|
||||
if (!header || !header.toLowerCase().startsWith('bearer ')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract resource parameter from WWW-Authenticate header
|
||||
const resourceMatch = header.match(/resource="([^"]+)"/i);
|
||||
if (resourceMatch && resourceMatch[1]) {
|
||||
return resourceMatch[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch protected resource metadata from MCP server
|
||||
* Following RFC9728 section 3
|
||||
*
|
||||
* @param resourceMetadataUrl - URL to fetch resource metadata (from WWW-Authenticate header)
|
||||
* @returns Authorization server URLs and other metadata
|
||||
*/
|
||||
export const fetchProtectedResourceMetadata = async (
|
||||
resourceMetadataUrl: string,
|
||||
): Promise<{
|
||||
authorization_servers: string[];
|
||||
resource?: string;
|
||||
[key: string]: any;
|
||||
}> => {
|
||||
try {
|
||||
console.log(`Fetching protected resource metadata from: ${resourceMetadataUrl}`);
|
||||
|
||||
const response = await fetch(resourceMetadataUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch resource metadata: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const metadata = await response.json();
|
||||
|
||||
if (!metadata.authorization_servers || !Array.isArray(metadata.authorization_servers)) {
|
||||
throw new Error('Invalid resource metadata: missing authorization_servers field');
|
||||
}
|
||||
|
||||
console.log(`Found ${metadata.authorization_servers.length} authorization server(s)`);
|
||||
return metadata;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to fetch protected resource metadata:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch scopes from protected resource metadata by trying the well-known URL
|
||||
*
|
||||
* @param serverUrl - The MCP server URL
|
||||
* @returns Array of supported scopes or undefined if not available
|
||||
*/
|
||||
export const fetchScopesFromServer = async (serverUrl: string): Promise<string[] | undefined> => {
|
||||
try {
|
||||
// Construct the well-known protected resource metadata URL
|
||||
// Format: https://example.com/.well-known/oauth-protected-resource/path/to/resource
|
||||
const url = new URL(serverUrl);
|
||||
const resourcePath = url.pathname + url.search;
|
||||
const wellKnownUrl = `${url.origin}/.well-known/oauth-protected-resource${resourcePath}`;
|
||||
|
||||
console.log(`Attempting to fetch scopes from: ${wellKnownUrl}`);
|
||||
|
||||
const metadata = await fetchProtectedResourceMetadata(wellKnownUrl);
|
||||
|
||||
if (metadata.scopes_supported && Array.isArray(metadata.scopes_supported)) {
|
||||
console.log(`Fetched scopes from server: ${metadata.scopes_supported.join(', ')}`);
|
||||
return metadata.scopes_supported as string[];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`Could not fetch scopes from server (this is normal if not using OAuth discovery): ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Auto-detect OAuth configuration from 401 response
|
||||
* Following MCP Authorization Specification for automatic discovery
|
||||
*
|
||||
* @param wwwAuthenticateHeader - The WWW-Authenticate header value from 401 response
|
||||
* @param serverUrl - The MCP server URL that returned 401
|
||||
* @returns Issuer URL and resource URL for OAuth configuration
|
||||
*/
|
||||
export const autoDetectOAuthConfig = async (
|
||||
wwwAuthenticateHeader: string,
|
||||
serverUrl: string,
|
||||
): Promise<{ issuer: string; resource: string; scopes?: string[] } | null> => {
|
||||
try {
|
||||
// Step 1: Parse WWW-Authenticate header to get resource metadata URL
|
||||
const resourceMetadataUrl = parseWWWAuthenticateHeader(wwwAuthenticateHeader);
|
||||
|
||||
if (!resourceMetadataUrl) {
|
||||
console.log('No resource metadata URL found in WWW-Authenticate header');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Step 2: Fetch protected resource metadata
|
||||
const resourceMetadata = await fetchProtectedResourceMetadata(resourceMetadataUrl);
|
||||
|
||||
// Step 3: Select first authorization server (TODO: implement proper selection logic)
|
||||
const issuer = resourceMetadata.authorization_servers[0];
|
||||
|
||||
if (!issuer) {
|
||||
throw new Error('No authorization servers found in resource metadata');
|
||||
}
|
||||
|
||||
// Step 4: Determine resource URL (canonical URI of MCP server)
|
||||
const resource = resourceMetadata.resource || new URL(serverUrl).origin;
|
||||
|
||||
// Step 5: Extract supported scopes from resource metadata
|
||||
const scopes = resourceMetadata.scopes_supported as string[] | undefined;
|
||||
|
||||
console.log(`Auto-detected OAuth configuration:`);
|
||||
console.log(` Issuer: ${issuer}`);
|
||||
console.log(` Resource: ${resource}`);
|
||||
if (scopes && scopes.length > 0) {
|
||||
console.log(` Scopes: ${scopes.join(', ')}`);
|
||||
}
|
||||
|
||||
return { issuer, resource, scopes };
|
||||
} catch (error) {
|
||||
console.error('Failed to auto-detect OAuth configuration:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Perform OAuth 2.0 issuer discovery to get authorization server metadata
|
||||
*/
|
||||
export const discoverIssuer = async (
|
||||
issuerUrl: string,
|
||||
clientId: string = 'mcphub-temp',
|
||||
clientSecret?: string,
|
||||
): Promise<client.Configuration> => {
|
||||
try {
|
||||
console.log(`Discovering OAuth issuer: ${issuerUrl}`);
|
||||
const server = new URL(issuerUrl);
|
||||
|
||||
const clientAuth = clientSecret ? client.ClientSecretPost(clientSecret) : client.None();
|
||||
|
||||
const config = await client.discovery(server, clientId, undefined, clientAuth);
|
||||
console.log(`Successfully discovered OAuth issuer: ${issuerUrl}`);
|
||||
return config;
|
||||
} catch (error) {
|
||||
console.error(`Failed to discover OAuth issuer ${issuerUrl}:`, error);
|
||||
throw new Error(
|
||||
`OAuth issuer discovery failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Register a new OAuth client dynamically using RFC7591
|
||||
* Can be called with auto-detected configuration from 401 response
|
||||
*/
|
||||
export const registerClient = async (
|
||||
serverName: string,
|
||||
serverConfig: ServerConfig,
|
||||
autoDetectedIssuer?: string,
|
||||
autoDetectedScopes?: string[],
|
||||
): Promise<RegisteredClientInfo> => {
|
||||
// Check if we already have a registered client for this server
|
||||
const cached = registeredClients.get(serverName);
|
||||
if (cached && (!cached.expiresAt || cached.expiresAt > Date.now())) {
|
||||
console.log(`Using cached OAuth client for server: ${serverName}`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const dynamicConfig = serverConfig.oauth?.dynamicRegistration;
|
||||
|
||||
try {
|
||||
let serverUrl: URL;
|
||||
|
||||
// Step 1: Determine the authorization server URL
|
||||
// Priority: autoDetectedIssuer > configured issuer > registration endpoint
|
||||
const issuerUrl = autoDetectedIssuer || dynamicConfig?.issuer;
|
||||
|
||||
if (issuerUrl) {
|
||||
serverUrl = new URL(issuerUrl);
|
||||
} else if (dynamicConfig?.registrationEndpoint) {
|
||||
// Extract server URL from registration endpoint
|
||||
const regUrl = new URL(dynamicConfig.registrationEndpoint);
|
||||
serverUrl = new URL(`${regUrl.protocol}//${regUrl.host}`);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Cannot register OAuth client: no issuer URL available. Either provide 'issuer' in configuration or ensure server returns proper 401 with WWW-Authenticate header.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: Prepare client metadata for registration
|
||||
const metadata = dynamicConfig?.metadata || {};
|
||||
|
||||
// Determine scopes: priority is metadata.scope > autoDetectedScopes > configured scopes > 'openid'
|
||||
let scopeValue: string;
|
||||
if (metadata.scope) {
|
||||
scopeValue = metadata.scope;
|
||||
} else if (autoDetectedScopes && autoDetectedScopes.length > 0) {
|
||||
scopeValue = autoDetectedScopes.join(' ');
|
||||
} else if (serverConfig.oauth?.scopes) {
|
||||
scopeValue = serverConfig.oauth.scopes.join(' ');
|
||||
} else {
|
||||
scopeValue = 'openid';
|
||||
}
|
||||
|
||||
const clientMetadata: Partial<client.ClientMetadata> = {
|
||||
client_name: metadata.client_name || `MCPHub - ${serverName}`,
|
||||
redirect_uris: metadata.redirect_uris || ['http://localhost:3000/oauth/callback'],
|
||||
grant_types: metadata.grant_types || ['authorization_code', 'refresh_token'],
|
||||
response_types: metadata.response_types || ['code'],
|
||||
token_endpoint_auth_method: metadata.token_endpoint_auth_method || 'client_secret_post',
|
||||
scope: scopeValue,
|
||||
...metadata, // Include any additional custom metadata
|
||||
};
|
||||
|
||||
console.log(`Registering OAuth client for server: ${serverName}`);
|
||||
console.log(`Server URL: ${serverUrl}`);
|
||||
console.log(`Client metadata:`, JSON.stringify(clientMetadata, null, 2));
|
||||
|
||||
// Step 3: Perform dynamic client registration
|
||||
const clientAuth = dynamicConfig?.initialAccessToken
|
||||
? client.ClientSecretPost(dynamicConfig.initialAccessToken)
|
||||
: client.None();
|
||||
|
||||
const config = await client.dynamicClientRegistration(serverUrl, clientMetadata, clientAuth);
|
||||
|
||||
console.log(`Successfully registered OAuth client for server: ${serverName}`);
|
||||
|
||||
// Extract client ID from the configuration
|
||||
const clientId = (config as any).client_id || (config as any).clientId;
|
||||
console.log(`Client ID: ${clientId}`);
|
||||
|
||||
// Step 4: Store registered client information
|
||||
const clientInfo: RegisteredClientInfo = {
|
||||
config,
|
||||
clientId,
|
||||
clientSecret: (config as any).client_secret, // Access client secret if available
|
||||
registrationAccessToken: (config as any).registrationAccessToken,
|
||||
registrationClientUri: (config as any).registrationClientUri,
|
||||
expiresAt: (config as any).client_secret_expires_at
|
||||
? (config as any).client_secret_expires_at * 1000
|
||||
: undefined,
|
||||
metadata: config,
|
||||
};
|
||||
|
||||
// Cache the registered client
|
||||
registeredClients.set(serverName, clientInfo);
|
||||
|
||||
// Persist the client credentials and scopes to configuration
|
||||
const persistedConfig = await persistClientCredentials(serverName, {
|
||||
clientId,
|
||||
clientSecret: clientInfo.clientSecret,
|
||||
scopes: autoDetectedScopes,
|
||||
authorizationEndpoint: clientInfo.config.serverMetadata().authorization_endpoint,
|
||||
tokenEndpoint: clientInfo.config.serverMetadata().token_endpoint,
|
||||
});
|
||||
|
||||
if (persistedConfig) {
|
||||
serverConfig.oauth = {
|
||||
...(serverConfig.oauth || {}),
|
||||
...persistedConfig.oauth,
|
||||
};
|
||||
}
|
||||
|
||||
return clientInfo;
|
||||
} catch (error) {
|
||||
console.error(`Failed to register OAuth client for server ${serverName}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get authorization URL for user authorization (OAuth 2.0 authorization code flow)
|
||||
*/
|
||||
export const getAuthorizationUrl = async (
|
||||
serverName: string,
|
||||
serverConfig: ServerConfig,
|
||||
clientInfo: RegisteredClientInfo,
|
||||
redirectUri: string,
|
||||
state: string,
|
||||
codeVerifier: string,
|
||||
): Promise<string> => {
|
||||
try {
|
||||
// Generate code challenge for PKCE (required by MCP spec)
|
||||
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
|
||||
|
||||
// Build authorization parameters
|
||||
const params: Record<string, string> = {
|
||||
redirect_uri: redirectUri,
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
scope: serverConfig.oauth?.scopes?.join(' ') || 'openid',
|
||||
};
|
||||
|
||||
// Add resource parameter for MCP (RFC8707)
|
||||
if (serverConfig.oauth?.resource) {
|
||||
params.resource = serverConfig.oauth.resource;
|
||||
}
|
||||
|
||||
const authUrl = client.buildAuthorizationUrl(clientInfo.config, params);
|
||||
return authUrl.toString();
|
||||
} catch (error) {
|
||||
console.error(`Failed to generate authorization URL for server ${serverName}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Exchange authorization code for access token
|
||||
*/
|
||||
export const exchangeCodeForToken = async (
|
||||
serverName: string,
|
||||
serverConfig: ServerConfig,
|
||||
clientInfo: RegisteredClientInfo,
|
||||
currentUrl: string,
|
||||
codeVerifier: string,
|
||||
): Promise<{ accessToken: string; refreshToken?: string; expiresIn?: number }> => {
|
||||
try {
|
||||
console.log(`Exchanging authorization code for access token for server: ${serverName}`);
|
||||
|
||||
// Prepare token endpoint parameters
|
||||
const tokenParams: Record<string, string> = {
|
||||
code_verifier: codeVerifier,
|
||||
};
|
||||
|
||||
// Add resource parameter for MCP (RFC8707)
|
||||
if (serverConfig.oauth?.resource) {
|
||||
tokenParams.resource = serverConfig.oauth.resource;
|
||||
}
|
||||
|
||||
const tokens = await client.authorizationCodeGrant(
|
||||
clientInfo.config,
|
||||
new URL(currentUrl),
|
||||
{ expectedState: undefined }, // State is already validated
|
||||
tokenParams,
|
||||
);
|
||||
|
||||
console.log(`Successfully obtained access token for server: ${serverName}`);
|
||||
|
||||
await persistTokens(serverName, {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token ?? undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Failed to exchange code for token for server ${serverName}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh token
|
||||
*/
|
||||
export const refreshAccessToken = async (
|
||||
serverName: string,
|
||||
serverConfig: ServerConfig,
|
||||
clientInfo: RegisteredClientInfo,
|
||||
refreshToken: string,
|
||||
): Promise<{ accessToken: string; refreshToken?: string; expiresIn?: number }> => {
|
||||
try {
|
||||
console.log(`Refreshing access token for server: ${serverName}`);
|
||||
|
||||
// Prepare refresh token parameters
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
// Add resource parameter for MCP (RFC8707)
|
||||
if (serverConfig.oauth?.resource) {
|
||||
params.resource = serverConfig.oauth.resource;
|
||||
}
|
||||
|
||||
const tokens = await client.refreshTokenGrant(clientInfo.config, refreshToken, params);
|
||||
|
||||
console.log(`Successfully refreshed access token for server: ${serverName}`);
|
||||
|
||||
await persistTokens(serverName, {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token ?? undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Failed to refresh access token for server ${serverName}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate PKCE code verifier
|
||||
*/
|
||||
export const generateCodeVerifier = (): string => {
|
||||
return client.randomPKCECodeVerifier();
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate PKCE code challenge from verifier
|
||||
*/
|
||||
export const calculateCodeChallenge = async (codeVerifier: string): Promise<string> => {
|
||||
return client.calculatePKCECodeChallenge(codeVerifier);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get registered client info from cache
|
||||
*/
|
||||
export const getRegisteredClient = (serverName: string): RegisteredClientInfo | undefined => {
|
||||
return registeredClients.get(serverName);
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize OAuth for a server (performs registration if needed)
|
||||
* Now supports auto-detection via 401 responses with WWW-Authenticate header
|
||||
*
|
||||
* @param serverName - Name of the server
|
||||
* @param serverConfig - Server configuration
|
||||
* @param autoDetectedIssuer - Optional issuer URL from auto-detection
|
||||
* @param autoDetectedScopes - Optional scopes from auto-detection
|
||||
* @returns RegisteredClientInfo or null
|
||||
*/
|
||||
export const initializeOAuthForServer = async (
|
||||
serverName: string,
|
||||
serverConfig: ServerConfig,
|
||||
autoDetectedIssuer?: string,
|
||||
autoDetectedScopes?: string[],
|
||||
): Promise<RegisteredClientInfo | null> => {
|
||||
if (!serverConfig.oauth) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if dynamic registration should be attempted
|
||||
const shouldAttemptRegistration =
|
||||
autoDetectedIssuer || // Auto-detected from 401 response
|
||||
serverConfig.oauth.dynamicRegistration?.enabled === true || // Explicitly enabled
|
||||
(serverConfig.oauth.dynamicRegistration && !serverConfig.oauth.clientId); // Configured but no static client
|
||||
|
||||
if (shouldAttemptRegistration) {
|
||||
try {
|
||||
// Perform dynamic client registration
|
||||
const clientInfo = await registerClient(
|
||||
serverName,
|
||||
serverConfig,
|
||||
autoDetectedIssuer,
|
||||
autoDetectedScopes,
|
||||
);
|
||||
return clientInfo;
|
||||
} catch (error) {
|
||||
console.error(`Failed to initialize OAuth for server ${serverName}:`, error);
|
||||
// If auto-detection failed, don't throw - allow fallback to static config
|
||||
if (!autoDetectedIssuer) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Static client configuration - create Configuration from static values
|
||||
if (serverConfig.oauth.clientId) {
|
||||
// Try to fetch and store scopes if not already configured
|
||||
if (!serverConfig.oauth.scopes && serverConfig.url) {
|
||||
try {
|
||||
const fetchedScopes = await fetchScopesFromServer(serverConfig.url);
|
||||
if (fetchedScopes && fetchedScopes.length > 0) {
|
||||
await mutateOAuthSettings(serverName, ({ oauth }) => {
|
||||
oauth.scopes = fetchedScopes;
|
||||
});
|
||||
|
||||
if (!serverConfig.oauth) {
|
||||
serverConfig.oauth = {};
|
||||
}
|
||||
serverConfig.oauth.scopes = fetchedScopes;
|
||||
console.log(`Stored fetched scopes for ${serverName}: ${fetchedScopes.join(', ')}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Failed to fetch scopes for ${serverName}, will use defaults`);
|
||||
}
|
||||
}
|
||||
|
||||
// For static config, we need the authorization server URL
|
||||
let serverUrl: URL;
|
||||
|
||||
if (serverConfig.oauth.authorizationEndpoint) {
|
||||
const authUrl = new URL(serverConfig.oauth.authorizationEndpoint!);
|
||||
serverUrl = new URL(`${authUrl.protocol}//${authUrl.host}`);
|
||||
} else if (serverConfig.oauth.tokenEndpoint) {
|
||||
const tokenUrl = new URL(serverConfig.oauth.tokenEndpoint!);
|
||||
serverUrl = new URL(`${tokenUrl.protocol}//${tokenUrl.host}`);
|
||||
} else {
|
||||
console.warn(`Server ${serverName} has static OAuth config but missing endpoints`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Discover the server configuration
|
||||
const clientAuth = serverConfig.oauth.clientSecret
|
||||
? client.ClientSecretPost(serverConfig.oauth.clientSecret)
|
||||
: client.None();
|
||||
|
||||
const config = await client.discovery(
|
||||
serverUrl,
|
||||
serverConfig.oauth.clientId!,
|
||||
undefined,
|
||||
clientAuth,
|
||||
);
|
||||
|
||||
const clientInfo: RegisteredClientInfo = {
|
||||
config,
|
||||
clientId: serverConfig.oauth.clientId!,
|
||||
clientSecret: serverConfig.oauth.clientSecret,
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
registeredClients.set(serverName, clientInfo);
|
||||
return clientInfo;
|
||||
} catch (error) {
|
||||
console.error(`Failed to discover OAuth server for ${serverName}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
271
src/services/oauthService.ts
Normal file
271
src/services/oauthService.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { ProxyOAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/providers/proxyProvider.js';
|
||||
import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js';
|
||||
import { RequestHandler } from 'express';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import { initializeOAuthForServer, refreshAccessToken } from './oauthClientRegistration.js';
|
||||
|
||||
// Re-export for external use
|
||||
export {
|
||||
getRegisteredClient,
|
||||
getAuthorizationUrl,
|
||||
exchangeCodeForToken,
|
||||
generateCodeVerifier,
|
||||
calculateCodeChallenge,
|
||||
autoDetectOAuthConfig,
|
||||
parseWWWAuthenticateHeader,
|
||||
fetchProtectedResourceMetadata,
|
||||
} from './oauthClientRegistration.js';
|
||||
|
||||
let oauthProvider: ProxyOAuthServerProvider | null = null;
|
||||
let oauthRouter: RequestHandler | null = null;
|
||||
|
||||
/**
|
||||
* Initialize OAuth provider from system configuration
|
||||
*/
|
||||
export const initOAuthProvider = (): void => {
|
||||
const settings = loadSettings();
|
||||
const oauthConfig = settings.systemConfig?.oauth;
|
||||
|
||||
if (!oauthConfig || !oauthConfig.enabled) {
|
||||
console.log('OAuth provider is disabled or not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create proxy OAuth provider
|
||||
oauthProvider = new ProxyOAuthServerProvider({
|
||||
endpoints: {
|
||||
authorizationUrl: oauthConfig.endpoints.authorizationUrl,
|
||||
tokenUrl: oauthConfig.endpoints.tokenUrl,
|
||||
revocationUrl: oauthConfig.endpoints.revocationUrl,
|
||||
},
|
||||
verifyAccessToken: async (token: string) => {
|
||||
// If a verification endpoint is configured, use it
|
||||
if (oauthConfig.verifyAccessToken?.endpoint) {
|
||||
const response = await fetch(oauthConfig.verifyAccessToken.endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...oauthConfig.verifyAccessToken.headers,
|
||||
},
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Token verification failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return {
|
||||
token,
|
||||
clientId: result.client_id || result.clientId || 'unknown',
|
||||
scopes: result.scopes || result.scope?.split(' ') || [],
|
||||
};
|
||||
}
|
||||
|
||||
// Default verification - just extract basic info from token
|
||||
// In production, you should decode/verify JWT or call an introspection endpoint
|
||||
return {
|
||||
token,
|
||||
clientId: 'default',
|
||||
scopes: oauthConfig.scopesSupported || [],
|
||||
};
|
||||
},
|
||||
getClient: async (clientId: string) => {
|
||||
// Find client in configuration
|
||||
const client = oauthConfig.clients?.find((c) => c.client_id === clientId);
|
||||
|
||||
if (!client) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
client_id: client.client_id,
|
||||
redirect_uris: client.redirect_uris,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Create OAuth router
|
||||
const issuerUrl = new URL(oauthConfig.issuerUrl);
|
||||
const baseUrl = oauthConfig.baseUrl ? new URL(oauthConfig.baseUrl) : issuerUrl;
|
||||
|
||||
oauthRouter = mcpAuthRouter({
|
||||
provider: oauthProvider,
|
||||
issuerUrl,
|
||||
baseUrl,
|
||||
serviceDocumentationUrl: oauthConfig.serviceDocumentationUrl
|
||||
? new URL(oauthConfig.serviceDocumentationUrl)
|
||||
: undefined,
|
||||
scopesSupported: oauthConfig.scopesSupported,
|
||||
});
|
||||
|
||||
console.log('OAuth provider initialized successfully');
|
||||
console.log(`OAuth issuer URL: ${issuerUrl.origin}`);
|
||||
// Only log endpoint URLs, not full config which might contain sensitive data
|
||||
console.log(
|
||||
'OAuth endpoints configured: authorization, token' +
|
||||
(oauthConfig.endpoints.revocationUrl ? ', revocation' : ''),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize OAuth provider:', error);
|
||||
oauthProvider = null;
|
||||
oauthRouter = null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the OAuth router if available
|
||||
*/
|
||||
export const getOAuthRouter = (): RequestHandler | null => {
|
||||
return oauthRouter;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the OAuth provider if available
|
||||
*/
|
||||
export const getOAuthProvider = (): ProxyOAuthServerProvider | null => {
|
||||
return oauthProvider;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if OAuth is enabled
|
||||
*/
|
||||
export const isOAuthEnabled = (): boolean => {
|
||||
return oauthProvider !== null && oauthRouter !== null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get OAuth access token for a server if configured
|
||||
* Handles both static tokens and dynamic OAuth flows with automatic token refresh
|
||||
*/
|
||||
export const getServerOAuthToken = async (serverName: string): Promise<string | undefined> => {
|
||||
const settings = loadSettings();
|
||||
const serverConfig = settings.mcpServers[serverName];
|
||||
|
||||
if (!serverConfig?.oauth) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// If a pre-configured access token exists, use it
|
||||
if (serverConfig.oauth.accessToken) {
|
||||
// TODO: In a production system, check if token is expired and refresh if needed
|
||||
// For now, just return the configured token
|
||||
return serverConfig.oauth.accessToken;
|
||||
}
|
||||
|
||||
// If dynamic registration is enabled, initialize OAuth and get token
|
||||
if (serverConfig.oauth.dynamicRegistration?.enabled) {
|
||||
try {
|
||||
// Initialize OAuth for this server (registers client if needed)
|
||||
const clientInfo = await initializeOAuthForServer(serverName, serverConfig);
|
||||
|
||||
if (!clientInfo) {
|
||||
console.warn(`Failed to initialize OAuth for server: ${serverName}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// If we have a refresh token, try to get a new access token
|
||||
if (serverConfig.oauth.refreshToken) {
|
||||
try {
|
||||
const tokens = await refreshAccessToken(
|
||||
serverName,
|
||||
serverConfig,
|
||||
clientInfo,
|
||||
serverConfig.oauth.refreshToken,
|
||||
);
|
||||
return tokens.accessToken;
|
||||
} catch (error) {
|
||||
console.error(`Failed to refresh token for server ${serverName}:`, error);
|
||||
// Token refresh failed - user needs to re-authorize
|
||||
// In a production system, you would trigger a new authorization flow here
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// No access token and no refresh token available
|
||||
// User needs to go through the authorization flow
|
||||
// This would typically be triggered by an API endpoint that initiates the OAuth flow
|
||||
console.log(`Server ${serverName} requires user authorization via OAuth flow`);
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
console.error(`Failed to get OAuth token for server ${serverName}:`, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Static client configuration - check for existing token
|
||||
if (serverConfig.oauth.clientId && serverConfig.oauth.accessToken) {
|
||||
return serverConfig.oauth.accessToken;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Add OAuth authorization header to request headers if token is available
|
||||
*/
|
||||
export const addOAuthHeader = async (
|
||||
serverName: string,
|
||||
headers: Record<string, string>,
|
||||
): Promise<Record<string, string>> => {
|
||||
const token = await getServerOAuthToken(serverName);
|
||||
|
||||
if (token) {
|
||||
return {
|
||||
...headers,
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
}
|
||||
|
||||
return headers;
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize OAuth for all configured servers with explicit dynamic registration enabled
|
||||
* Servers without explicit configuration will be registered on-demand when receiving 401
|
||||
* Call this at application startup to pre-register known OAuth servers
|
||||
*/
|
||||
export const initializeAllOAuthClients = async (): Promise<void> => {
|
||||
const settings = loadSettings();
|
||||
|
||||
console.log('Initializing OAuth clients for explicitly configured servers...');
|
||||
|
||||
const serverNames = Object.keys(settings.mcpServers);
|
||||
const registrationPromises: Promise<void>[] = [];
|
||||
|
||||
for (const serverName of serverNames) {
|
||||
const serverConfig = settings.mcpServers[serverName];
|
||||
|
||||
// Only initialize servers with explicitly enabled dynamic registration
|
||||
// Others will be auto-detected and registered on first 401 response
|
||||
if (serverConfig.oauth?.dynamicRegistration?.enabled === true) {
|
||||
registrationPromises.push(
|
||||
initializeOAuthForServer(serverName, serverConfig)
|
||||
.then((clientInfo) => {
|
||||
if (clientInfo) {
|
||||
console.log(`✓ OAuth client pre-registered for server: ${serverName}`);
|
||||
} else {
|
||||
console.warn(`✗ Failed to pre-register OAuth client for server: ${serverName}`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`✗ Error pre-registering OAuth client for server ${serverName}:`,
|
||||
error.message,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all registrations to complete
|
||||
if (registrationPromises.length > 0) {
|
||||
await Promise.all(registrationPromises);
|
||||
console.log(
|
||||
`OAuth client pre-registration completed for ${registrationPromises.length} server(s)`,
|
||||
);
|
||||
} else {
|
||||
console.log('No servers configured for pre-registration (will auto-detect on 401 responses)');
|
||||
}
|
||||
};
|
||||
158
src/services/oauthSettingsStore.ts
Normal file
158
src/services/oauthSettingsStore.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { loadSettings, saveSettings } from '../config/index.js';
|
||||
import { McpSettings, ServerConfig } from '../types/index.js';
|
||||
|
||||
type OAuthConfig = NonNullable<ServerConfig['oauth']>;
|
||||
export type ServerConfigWithOAuth = ServerConfig & { oauth: OAuthConfig };
|
||||
|
||||
export interface OAuthSettingsContext {
|
||||
settings: McpSettings;
|
||||
serverConfig: ServerConfig;
|
||||
oauth: OAuthConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the latest server configuration from disk.
|
||||
*/
|
||||
export const loadServerConfig = (serverName: string): ServerConfig | undefined => {
|
||||
const settings = loadSettings();
|
||||
return settings.mcpServers?.[serverName];
|
||||
};
|
||||
|
||||
/**
|
||||
* Mutate OAuth configuration for a server and persist the updated settings.
|
||||
* The mutator receives the shared settings object to allow related updates when needed.
|
||||
*/
|
||||
export const mutateOAuthSettings = async (
|
||||
serverName: string,
|
||||
mutator: (context: OAuthSettingsContext) => void,
|
||||
): Promise<ServerConfigWithOAuth | undefined> => {
|
||||
const settings = loadSettings();
|
||||
const serverConfig = settings.mcpServers?.[serverName];
|
||||
|
||||
if (!serverConfig) {
|
||||
console.warn(`Server ${serverName} not found while updating OAuth settings`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!serverConfig.oauth) {
|
||||
serverConfig.oauth = {};
|
||||
}
|
||||
|
||||
const context: OAuthSettingsContext = {
|
||||
settings,
|
||||
serverConfig,
|
||||
oauth: serverConfig.oauth,
|
||||
};
|
||||
|
||||
mutator(context);
|
||||
|
||||
const saved = saveSettings(settings);
|
||||
if (!saved) {
|
||||
throw new Error(`Failed to persist OAuth settings for server ${serverName}`);
|
||||
}
|
||||
|
||||
return context.serverConfig as ServerConfigWithOAuth;
|
||||
};
|
||||
|
||||
export const persistClientCredentials = async (
|
||||
serverName: string,
|
||||
credentials: {
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
scopes?: string[];
|
||||
authorizationEndpoint?: string;
|
||||
tokenEndpoint?: string;
|
||||
},
|
||||
): Promise<ServerConfigWithOAuth | undefined> => {
|
||||
const updated = await mutateOAuthSettings(serverName, ({ oauth }) => {
|
||||
oauth.clientId = credentials.clientId;
|
||||
oauth.clientSecret = credentials.clientSecret;
|
||||
|
||||
if (credentials.scopes && credentials.scopes.length > 0) {
|
||||
oauth.scopes = credentials.scopes;
|
||||
}
|
||||
if (credentials.authorizationEndpoint) {
|
||||
oauth.authorizationEndpoint = credentials.authorizationEndpoint;
|
||||
}
|
||||
if (credentials.tokenEndpoint) {
|
||||
oauth.tokenEndpoint = credentials.tokenEndpoint;
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Persisted OAuth client credentials for server: ${serverName}`);
|
||||
if (credentials.scopes && credentials.scopes.length > 0) {
|
||||
console.log(`Stored OAuth scopes for ${serverName}: ${credentials.scopes.join(', ')}`);
|
||||
}
|
||||
|
||||
return updated;
|
||||
};
|
||||
|
||||
/**
|
||||
* Persist OAuth tokens and optionally replace the stored refresh token.
|
||||
*/
|
||||
export const persistTokens = async (
|
||||
serverName: string,
|
||||
tokens: {
|
||||
accessToken: string;
|
||||
refreshToken?: string | null;
|
||||
clearPendingAuthorization?: boolean;
|
||||
},
|
||||
): Promise<ServerConfigWithOAuth | undefined> => {
|
||||
return mutateOAuthSettings(serverName, ({ oauth }) => {
|
||||
oauth.accessToken = tokens.accessToken;
|
||||
|
||||
if (tokens.refreshToken !== undefined) {
|
||||
if (tokens.refreshToken) {
|
||||
oauth.refreshToken = tokens.refreshToken;
|
||||
} else {
|
||||
delete oauth.refreshToken;
|
||||
}
|
||||
}
|
||||
|
||||
if (tokens.clearPendingAuthorization && oauth.pendingAuthorization) {
|
||||
delete oauth.pendingAuthorization;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Update or create a pending authorization record.
|
||||
*/
|
||||
export const updatePendingAuthorization = async (
|
||||
serverName: string,
|
||||
pending: Partial<NonNullable<OAuthConfig['pendingAuthorization']>>,
|
||||
): Promise<ServerConfigWithOAuth | undefined> => {
|
||||
return mutateOAuthSettings(serverName, ({ oauth }) => {
|
||||
oauth.pendingAuthorization = {
|
||||
...(oauth.pendingAuthorization || {}),
|
||||
...pending,
|
||||
createdAt: pending.createdAt ?? Date.now(),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear cached OAuth data using shared helpers.
|
||||
*/
|
||||
export const clearOAuthData = async (
|
||||
serverName: string,
|
||||
scope: 'all' | 'client' | 'tokens' | 'verifier',
|
||||
): Promise<ServerConfigWithOAuth | undefined> => {
|
||||
return mutateOAuthSettings(serverName, ({ oauth }) => {
|
||||
if (scope === 'tokens' || scope === 'all') {
|
||||
delete oauth.accessToken;
|
||||
delete oauth.refreshToken;
|
||||
}
|
||||
|
||||
if (scope === 'client' || scope === 'all') {
|
||||
delete oauth.clientId;
|
||||
delete oauth.clientSecret;
|
||||
}
|
||||
|
||||
if (scope === 'verifier' || scope === 'all') {
|
||||
if (oauth.pendingAuthorization) {
|
||||
delete oauth.pendingAuthorization;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -225,13 +225,22 @@ export async function generateOpenAPISpec(
|
||||
|
||||
// Generate paths from tools
|
||||
const paths: OpenAPIV3.PathsObject = {};
|
||||
const separator = getNameSeparator();
|
||||
|
||||
for (const { tool, serverName } of allTools) {
|
||||
const operation = generateOperationFromTool(tool, serverName);
|
||||
const { requestBody } = convertToolSchemaToOpenAPI(tool);
|
||||
|
||||
// Create path for the tool
|
||||
const pathName = `/tools/${serverName}/${tool.name}`;
|
||||
// Extract the tool name without server prefix
|
||||
// Tool names are in format: serverName + separator + toolName
|
||||
const prefix = `${serverName}${separator}`;
|
||||
const toolNameOnly = tool.name.startsWith(prefix)
|
||||
? tool.name.substring(prefix.length)
|
||||
: tool.name;
|
||||
|
||||
// Create path for the tool with URL-encoded server and tool names
|
||||
// This handles cases where names contain slashes (e.g., "com.atlassian/atlassian-mcp-server")
|
||||
const pathName = `/tools/${encodeURIComponent(serverName)}/${encodeURIComponent(toolNameOnly)}`;
|
||||
const method = requestBody ? 'post' : 'get';
|
||||
|
||||
if (!paths[pathName]) {
|
||||
|
||||
@@ -124,6 +124,31 @@ export interface MCPRouterCallToolResponse {
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
// OAuth Provider Configuration for MCP Authorization Server
|
||||
export interface OAuthProviderConfig {
|
||||
enabled?: boolean; // Enable/disable OAuth provider
|
||||
issuerUrl: string; // Authorization server's issuer identifier (e.g., 'http://auth.external.com')
|
||||
baseUrl?: string; // Base URL for the authorization server metadata endpoints (defaults to issuerUrl)
|
||||
serviceDocumentationUrl?: string; // URL for human-readable OAuth documentation
|
||||
scopesSupported?: string[]; // List of OAuth scopes supported
|
||||
endpoints: {
|
||||
authorizationUrl: string; // External OAuth authorization endpoint
|
||||
tokenUrl: string; // External OAuth token endpoint
|
||||
revocationUrl?: string; // External OAuth revocation endpoint (optional)
|
||||
};
|
||||
// Token verification function details
|
||||
verifyAccessToken?: {
|
||||
endpoint?: string; // Optional: External endpoint to verify access tokens
|
||||
headers?: Record<string, string>; // Optional: Headers for token verification requests
|
||||
};
|
||||
// Client management
|
||||
clients?: Array<{
|
||||
client_id: string; // Client identifier
|
||||
redirect_uris: string[]; // Allowed redirect URIs for this client
|
||||
scopes?: string[]; // Scopes this client can request
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface SystemConfig {
|
||||
routing?: {
|
||||
enableGlobalRoute?: boolean; // Controls whether the /sse endpoint without group is enabled
|
||||
@@ -145,6 +170,8 @@ export interface SystemConfig {
|
||||
baseUrl?: string; // Base URL for MCPRouter API (default: https://api.mcprouter.to/v1)
|
||||
};
|
||||
nameSeparator?: string; // Separator used between server name and tool/prompt name (default: '-')
|
||||
oauth?: OAuthProviderConfig; // OAuth provider configuration for upstream MCP servers
|
||||
cluster?: ClusterConfig; // Cluster configuration for distributed deployment
|
||||
}
|
||||
|
||||
export interface UserConfig {
|
||||
@@ -181,6 +208,55 @@ 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
|
||||
// OAuth authentication for upstream MCP servers
|
||||
oauth?: {
|
||||
// Static client configuration (traditional OAuth flow)
|
||||
clientId?: string; // OAuth client ID
|
||||
clientSecret?: string; // OAuth client secret
|
||||
scopes?: string[]; // Required OAuth scopes
|
||||
accessToken?: string; // Pre-obtained access token (if available)
|
||||
refreshToken?: string; // Refresh token for renewing access
|
||||
|
||||
// Dynamic client registration (RFC7591)
|
||||
// If not explicitly configured, will auto-detect via WWW-Authenticate header on 401 responses
|
||||
dynamicRegistration?: {
|
||||
enabled?: boolean; // Enable/disable dynamic registration (default: auto-detect on 401)
|
||||
issuer?: string; // OAuth issuer URL for discovery (e.g., 'https://auth.example.com')
|
||||
registrationEndpoint?: string; // Direct registration endpoint URL (if discovery is not used)
|
||||
metadata?: {
|
||||
// Client metadata for registration (RFC7591 section 2)
|
||||
client_name?: string; // Human-readable client name
|
||||
client_uri?: string; // URL of client's home page
|
||||
logo_uri?: string; // URL of client's logo
|
||||
scope?: string; // Space-separated list of scope values
|
||||
redirect_uris?: string[]; // Array of redirect URIs
|
||||
grant_types?: string[]; // Array of OAuth 2.0 grant types (e.g., ['authorization_code', 'refresh_token'])
|
||||
response_types?: string[]; // Array of OAuth 2.0 response types (e.g., ['code'])
|
||||
token_endpoint_auth_method?: string; // Token endpoint authentication method (e.g., 'client_secret_basic', 'none')
|
||||
contacts?: string[]; // Array of contact email addresses
|
||||
software_id?: string; // Unique identifier for the client software
|
||||
software_version?: string; // Version of the client software
|
||||
[key: string]: any; // Additional metadata fields
|
||||
};
|
||||
// Optional: Initial access token for protected registration endpoints
|
||||
initialAccessToken?: string;
|
||||
};
|
||||
|
||||
// MCP resource parameter (RFC8707) - the canonical URI of the MCP server
|
||||
resource?: string; // e.g., 'https://mcp.example.com/mcp'
|
||||
|
||||
// Authorization endpoint for user authorization (for authorization code flow)
|
||||
authorizationEndpoint?: string;
|
||||
// Token endpoint for exchanging authorization codes for tokens
|
||||
tokenEndpoint?: string;
|
||||
// Pending OAuth session metadata for PKCE/state recovery between restarts
|
||||
pendingAuthorization?: {
|
||||
authorizationUrl?: string;
|
||||
state?: string;
|
||||
codeVerifier?: string;
|
||||
createdAt?: number;
|
||||
};
|
||||
};
|
||||
// OpenAPI specific configuration
|
||||
openapi?: {
|
||||
url?: string; // OpenAPI specification URL
|
||||
@@ -227,7 +303,7 @@ export interface OpenAPISecurityConfig {
|
||||
export interface ServerInfo {
|
||||
name: string; // Unique name of the server
|
||||
owner?: string; // Owner of the server, defaults to 'admin' user
|
||||
status: 'connected' | 'connecting' | 'disconnected'; // Current connection status
|
||||
status: 'connected' | 'connecting' | 'disconnected' | 'oauth_required'; // Current connection status
|
||||
error: string | null; // Error message if any
|
||||
tools: Tool[]; // List of tools available on the server
|
||||
prompts: Prompt[]; // List of prompts available on the server
|
||||
@@ -239,6 +315,12 @@ export interface ServerInfo {
|
||||
enabled?: boolean; // Flag to indicate if the server is enabled
|
||||
keepAliveIntervalId?: NodeJS.Timeout; // Timer ID for keep-alive ping interval
|
||||
config?: ServerConfig; // Reference to the original server configuration for OpenAPI passthrough headers
|
||||
oauth?: {
|
||||
// OAuth authorization state
|
||||
authorizationUrl?: string; // OAuth authorization URL for user to visit
|
||||
state?: string; // OAuth state parameter for CSRF protection
|
||||
codeVerifier?: string; // PKCE code verifier
|
||||
};
|
||||
}
|
||||
|
||||
// Details about a tool available on the server
|
||||
@@ -275,3 +357,63 @@ export interface AddServerRequest {
|
||||
name: string; // Name of the server to add
|
||||
config: ServerConfig; // Configuration details for the server
|
||||
}
|
||||
|
||||
// Cluster-related types
|
||||
|
||||
// Cluster node information
|
||||
export interface ClusterNode {
|
||||
id: string; // Unique identifier for the node (e.g., UUID)
|
||||
name: string; // Human-readable name of the node
|
||||
host: string; // Hostname or IP address
|
||||
port: number; // Port number the node is running on
|
||||
url: string; // Full URL to access the node (e.g., 'http://node1:3000')
|
||||
status: 'active' | 'inactive' | 'unhealthy'; // Current status of the node
|
||||
lastHeartbeat: number; // Timestamp of last heartbeat
|
||||
servers: string[]; // List of MCP server names hosted on this node
|
||||
metadata?: Record<string, any>; // Additional metadata about the node
|
||||
}
|
||||
|
||||
// Cluster configuration
|
||||
export interface ClusterConfig {
|
||||
enabled: boolean; // Whether cluster mode is enabled
|
||||
mode: 'standalone' | 'node' | 'coordinator'; // Cluster operating mode
|
||||
node?: {
|
||||
// Configuration when running as a cluster node
|
||||
id?: string; // Node ID (generated if not provided)
|
||||
name?: string; // Node name (defaults to hostname)
|
||||
coordinatorUrl: string; // URL of the coordinator node
|
||||
heartbeatInterval?: number; // Heartbeat interval in milliseconds (default: 5000)
|
||||
registerOnStartup?: boolean; // Whether to register with coordinator on startup (default: true)
|
||||
};
|
||||
coordinator?: {
|
||||
// Configuration when running as coordinator
|
||||
nodeTimeout?: number; // Time in ms before marking a node as unhealthy (default: 15000)
|
||||
cleanupInterval?: number; // Interval for cleaning up inactive nodes (default: 30000)
|
||||
stickySessionTimeout?: number; // Sticky session timeout in milliseconds (default: 3600000, 1 hour)
|
||||
};
|
||||
stickySession?: {
|
||||
enabled: boolean; // Whether sticky sessions are enabled (default: true for cluster mode)
|
||||
strategy: 'consistent-hash' | 'cookie' | 'header'; // Strategy for session affinity (default: consistent-hash)
|
||||
cookieName?: string; // Cookie name for cookie-based sticky sessions (default: 'MCPHUB_NODE')
|
||||
headerName?: string; // Header name for header-based sticky sessions (default: 'X-MCPHub-Node')
|
||||
};
|
||||
}
|
||||
|
||||
// Cluster server replica configuration
|
||||
export interface ServerReplica {
|
||||
serverId: string; // MCP server identifier
|
||||
nodeId: string; // Node hosting this replica
|
||||
nodeUrl: string; // URL to access this replica
|
||||
status: 'active' | 'inactive'; // Status of this replica
|
||||
weight?: number; // Load balancing weight (default: 1)
|
||||
}
|
||||
|
||||
// Session affinity information
|
||||
export interface SessionAffinity {
|
||||
sessionId: string; // Session identifier
|
||||
nodeId: string; // Node ID for this session
|
||||
serverId?: string; // Optional: specific server this session is bound to
|
||||
createdAt: number; // Timestamp when session was created
|
||||
lastAccessed: number; // Timestamp of last access
|
||||
expiresAt: number; // Timestamp when session expires
|
||||
}
|
||||
|
||||
93
src/utils/parameterConversion.ts
Normal file
93
src/utils/parameterConversion.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Utility functions for converting parameter types based on JSON schema definitions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convert parameters to their proper types based on the tool's input schema
|
||||
* This ensures that form-submitted string values are converted to the correct types
|
||||
* (e.g., numbers, booleans, arrays) before being passed to MCP tools.
|
||||
*
|
||||
* @param params - The parameters to convert (typically from form submission)
|
||||
* @param inputSchema - The JSON schema definition for the tool's input
|
||||
* @returns The converted parameters with proper types
|
||||
*/
|
||||
export function convertParametersToTypes(
|
||||
params: Record<string, any>,
|
||||
inputSchema: Record<string, any>,
|
||||
): Record<string, any> {
|
||||
if (!inputSchema || typeof inputSchema !== 'object' || !inputSchema.properties) {
|
||||
return params;
|
||||
}
|
||||
|
||||
const convertedParams: Record<string, any> = {};
|
||||
const properties = inputSchema.properties;
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
const propDef = properties[key];
|
||||
if (!propDef || typeof propDef !== 'object') {
|
||||
// No schema definition found, keep as is
|
||||
convertedParams[key] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
const propType = propDef.type;
|
||||
|
||||
try {
|
||||
switch (propType) {
|
||||
case 'integer':
|
||||
case 'number':
|
||||
// Convert string to number
|
||||
if (typeof value === 'string') {
|
||||
const numValue = propType === 'integer' ? parseInt(value, 10) : parseFloat(value);
|
||||
convertedParams[key] = isNaN(numValue) ? value : numValue;
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'boolean':
|
||||
// Convert string to boolean
|
||||
if (typeof value === 'string') {
|
||||
convertedParams[key] = value.toLowerCase() === 'true' || value === '1';
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'array':
|
||||
// Handle array conversion if needed (e.g., comma-separated strings)
|
||||
if (typeof value === 'string' && value.includes(',')) {
|
||||
convertedParams[key] = value.split(',').map((item) => item.trim());
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'object':
|
||||
// Handle object conversion if needed
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
convertedParams[key] = JSON.parse(value);
|
||||
} catch {
|
||||
// If parsing fails, keep as is
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// For string and other types, keep as is
|
||||
convertedParams[key] = value;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
// If conversion fails, keep the original value
|
||||
console.warn(`Failed to convert parameter '${key}' to type '${propType}':`, error);
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return convertedParams;
|
||||
}
|
||||
49
src/utils/passwordValidation.ts
Normal file
49
src/utils/passwordValidation.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Password strength validation utility
|
||||
* Requirements:
|
||||
* - At least 8 characters
|
||||
* - Contains at least one letter
|
||||
* - Contains at least one number
|
||||
* - Contains at least one special character
|
||||
*/
|
||||
|
||||
export interface PasswordValidationResult {
|
||||
isValid: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export const validatePasswordStrength = (password: string): PasswordValidationResult => {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Check minimum length
|
||||
if (password.length < 8) {
|
||||
errors.push('Password must be at least 8 characters long');
|
||||
}
|
||||
|
||||
// Check for at least one letter
|
||||
if (!/[a-zA-Z]/.test(password)) {
|
||||
errors.push('Password must contain at least one letter');
|
||||
}
|
||||
|
||||
// Check for at least one number
|
||||
if (!/\d/.test(password)) {
|
||||
errors.push('Password must contain at least one number');
|
||||
}
|
||||
|
||||
// Check for at least one special character
|
||||
if (!/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password)) {
|
||||
errors.push('Password must contain at least one special character');
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a password is the default password (admin123)
|
||||
*/
|
||||
export const isDefaultPassword = (plainPassword: string): boolean => {
|
||||
return plainPassword === 'admin123';
|
||||
};
|
||||
@@ -18,18 +18,18 @@ function initializePackageRoot(): void {
|
||||
if (process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// Try to get the current module's directory
|
||||
const currentModuleDir = getCurrentModuleDir();
|
||||
|
||||
|
||||
// This file is in src/utils/path.ts (or dist/utils/path.js when compiled)
|
||||
// So package.json should be 2 levels up
|
||||
const possibleRoots = [
|
||||
path.resolve(currentModuleDir, '..', '..'), // dist -> package root
|
||||
path.resolve(currentModuleDir, '..'), // dist/utils -> dist -> package root
|
||||
];
|
||||
|
||||
|
||||
for (const root of possibleRoots) {
|
||||
const packageJsonPath = path.join(root, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
@@ -66,10 +66,10 @@ export const findPackageRoot = (startPath?: string): string | null => {
|
||||
}
|
||||
|
||||
const debug = process.env.DEBUG === 'true';
|
||||
|
||||
|
||||
// Possible locations for package.json relative to the search path
|
||||
const possibleRoots: string[] = [];
|
||||
|
||||
|
||||
if (startPath) {
|
||||
// When start path is provided (from fileURLToPath(import.meta.url))
|
||||
possibleRoots.push(
|
||||
@@ -78,25 +78,30 @@ export const findPackageRoot = (startPath?: string): string | null => {
|
||||
// When in dist/ (compiled code) - go up 1 level
|
||||
path.resolve(startPath, '..'),
|
||||
// Direct parent directories
|
||||
path.resolve(startPath)
|
||||
path.resolve(startPath),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Try to use require.resolve to find the module location (works in CommonJS and ESM with createRequire)
|
||||
try {
|
||||
// In ESM, we can use import.meta.resolve, but it's async in some versions
|
||||
// So we'll try to find the module by checking the node_modules structure
|
||||
|
||||
|
||||
// Check if this file is in a node_modules installation
|
||||
const currentFile = new Error().stack?.split('\n')[2]?.match(/\((.+?):\d+:\d+\)$/)?.[1];
|
||||
if (currentFile) {
|
||||
const nodeModulesIndex = currentFile.indexOf('node_modules');
|
||||
if (nodeModulesIndex !== -1) {
|
||||
// Extract the package path from node_modules
|
||||
const afterNodeModules = currentFile.substring(nodeModulesIndex + 'node_modules'.length + 1);
|
||||
const afterNodeModules = currentFile.substring(
|
||||
nodeModulesIndex + 'node_modules'.length + 1,
|
||||
);
|
||||
const packageNameEnd = afterNodeModules.indexOf(path.sep);
|
||||
if (packageNameEnd !== -1) {
|
||||
const packagePath = currentFile.substring(0, nodeModulesIndex + 'node_modules'.length + 1 + packageNameEnd);
|
||||
const packagePath = currentFile.substring(
|
||||
0,
|
||||
nodeModulesIndex + 'node_modules'.length + 1 + packageNameEnd,
|
||||
);
|
||||
possibleRoots.push(packagePath);
|
||||
}
|
||||
}
|
||||
@@ -108,18 +113,15 @@ export const findPackageRoot = (startPath?: string): string | null => {
|
||||
// Check module.filename location (works in Node.js when available)
|
||||
if (typeof __filename !== 'undefined') {
|
||||
const moduleDir = path.dirname(__filename);
|
||||
possibleRoots.push(
|
||||
path.resolve(moduleDir, '..', '..'),
|
||||
path.resolve(moduleDir, '..')
|
||||
);
|
||||
possibleRoots.push(path.resolve(moduleDir, '..', '..'), path.resolve(moduleDir, '..'));
|
||||
}
|
||||
|
||||
|
||||
// Check common installation locations
|
||||
possibleRoots.push(
|
||||
// Current working directory (for development/tests)
|
||||
process.cwd(),
|
||||
// Parent of cwd
|
||||
path.resolve(process.cwd(), '..')
|
||||
path.resolve(process.cwd(), '..'),
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
@@ -157,12 +159,12 @@ export const findPackageRoot = (startPath?: string): string | null => {
|
||||
if (debug) {
|
||||
console.warn('DEBUG: Could not find package root directory');
|
||||
}
|
||||
|
||||
|
||||
// Cache null result as well to avoid repeated searches
|
||||
if (!startPath) {
|
||||
cachedPackageRoot = null;
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
@@ -11,13 +11,13 @@ export const getPackageVersion = (searchPath?: string): string => {
|
||||
try {
|
||||
// Use provided path or fallback to current working directory
|
||||
const startPath = searchPath || process.cwd();
|
||||
|
||||
|
||||
const packageRoot = findPackageRoot(startPath);
|
||||
if (!packageRoot) {
|
||||
console.warn('Could not find package root, using default version');
|
||||
return 'dev';
|
||||
}
|
||||
|
||||
|
||||
const packageJsonPath = path.join(packageRoot, 'package.json');
|
||||
const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf8');
|
||||
const packageJson = JSON.parse(packageJsonContent);
|
||||
|
||||
343
tests/config/replaceEnvVars.test.ts
Normal file
343
tests/config/replaceEnvVars.test.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
import { replaceEnvVars, expandEnvVars } from '../../src/config/index.js';
|
||||
|
||||
describe('Environment Variable Expansion - Comprehensive Tests', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset process.env before each test
|
||||
jest.resetModules();
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Restore original environment
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('expandEnvVars - String expansion', () => {
|
||||
it('should expand ${VAR} format', () => {
|
||||
process.env.TEST_VAR = 'test-value';
|
||||
expect(expandEnvVars('${TEST_VAR}')).toBe('test-value');
|
||||
});
|
||||
|
||||
it('should expand $VAR format', () => {
|
||||
process.env.TEST_VAR = 'test-value';
|
||||
expect(expandEnvVars('$TEST_VAR')).toBe('test-value');
|
||||
});
|
||||
|
||||
it('should expand multiple variables', () => {
|
||||
process.env.HOST = 'localhost';
|
||||
process.env.PORT = '3000';
|
||||
expect(expandEnvVars('http://${HOST}:${PORT}')).toBe('http://localhost:3000');
|
||||
});
|
||||
|
||||
it('should return empty string for undefined variables', () => {
|
||||
expect(expandEnvVars('${UNDEFINED_VAR}')).toBe('');
|
||||
});
|
||||
|
||||
it('should handle strings without variables', () => {
|
||||
expect(expandEnvVars('plain-string')).toBe('plain-string');
|
||||
});
|
||||
|
||||
it('should handle mixed variable formats', () => {
|
||||
process.env.VAR1 = 'value1';
|
||||
process.env.VAR2 = 'value2';
|
||||
expect(expandEnvVars('$VAR1-${VAR2}')).toBe('value1-value2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceEnvVars - Recursive expansion', () => {
|
||||
it('should expand environment variables in nested objects', () => {
|
||||
process.env.API_KEY = 'secret123';
|
||||
process.env.BASE_URL = 'https://api.example.com';
|
||||
|
||||
const config = {
|
||||
url: '${BASE_URL}/endpoint',
|
||||
headers: {
|
||||
'X-API-Key': '${API_KEY}',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
nested: {
|
||||
value: '$API_KEY',
|
||||
},
|
||||
};
|
||||
|
||||
const result = replaceEnvVars(config);
|
||||
|
||||
expect(result).toEqual({
|
||||
url: 'https://api.example.com/endpoint',
|
||||
headers: {
|
||||
'X-API-Key': 'secret123',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
nested: {
|
||||
value: 'secret123',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should expand environment variables in arrays', () => {
|
||||
process.env.ARG1 = 'value1';
|
||||
process.env.ARG2 = 'value2';
|
||||
|
||||
const args = ['--arg1', '${ARG1}', '--arg2', '${ARG2}'];
|
||||
const result = replaceEnvVars(args);
|
||||
|
||||
expect(result).toEqual(['--arg1', 'value1', '--arg2', 'value2']);
|
||||
});
|
||||
|
||||
it('should expand environment variables in nested arrays', () => {
|
||||
process.env.ITEM = 'test-item';
|
||||
|
||||
const config = {
|
||||
items: ['${ITEM}', 'static-item'],
|
||||
};
|
||||
|
||||
const result = replaceEnvVars(config);
|
||||
|
||||
expect(result).toEqual({
|
||||
items: ['test-item', 'static-item'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve non-string values', () => {
|
||||
const config = {
|
||||
enabled: true,
|
||||
timeout: 3000,
|
||||
ratio: 0.5,
|
||||
nullable: null,
|
||||
};
|
||||
|
||||
const result = replaceEnvVars(config);
|
||||
|
||||
expect(result).toEqual({
|
||||
enabled: true,
|
||||
timeout: 3000,
|
||||
ratio: 0.5,
|
||||
nullable: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should expand deeply nested structures', () => {
|
||||
process.env.DEEP_VALUE = 'deep-secret';
|
||||
|
||||
const config = {
|
||||
level1: {
|
||||
level2: {
|
||||
level3: {
|
||||
value: '${DEEP_VALUE}',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = replaceEnvVars(config);
|
||||
|
||||
expect(result).toEqual({
|
||||
level1: {
|
||||
level2: {
|
||||
level3: {
|
||||
value: 'deep-secret',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should expand environment variables in mixed nested structures', () => {
|
||||
process.env.VAR1 = 'value1';
|
||||
process.env.VAR2 = 'value2';
|
||||
|
||||
const config = {
|
||||
array: [
|
||||
{
|
||||
key: '${VAR1}',
|
||||
},
|
||||
{
|
||||
key: '${VAR2}',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = replaceEnvVars(config);
|
||||
|
||||
expect(result).toEqual({
|
||||
array: [
|
||||
{
|
||||
key: 'value1',
|
||||
},
|
||||
{
|
||||
key: 'value2',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ServerConfig scenarios', () => {
|
||||
it('should expand URL with environment variables', () => {
|
||||
process.env.SERVER_HOST = 'api.example.com';
|
||||
process.env.SERVER_PORT = '8080';
|
||||
|
||||
const config = {
|
||||
type: 'sse',
|
||||
url: 'https://${SERVER_HOST}:${SERVER_PORT}/mcp',
|
||||
};
|
||||
|
||||
const result = replaceEnvVars(config);
|
||||
|
||||
expect(result.url).toBe('https://api.example.com:8080/mcp');
|
||||
});
|
||||
|
||||
it('should expand command with environment variables', () => {
|
||||
process.env.PYTHON_PATH = '/usr/bin/python3';
|
||||
|
||||
const config = {
|
||||
type: 'stdio',
|
||||
command: '${PYTHON_PATH}',
|
||||
args: ['-m', 'my_module'],
|
||||
};
|
||||
|
||||
const result = replaceEnvVars(config);
|
||||
|
||||
expect(result.command).toBe('/usr/bin/python3');
|
||||
});
|
||||
|
||||
it('should expand OpenAPI configuration', () => {
|
||||
process.env.API_BASE_URL = 'https://api.example.com';
|
||||
process.env.API_KEY = 'secret-key-123';
|
||||
|
||||
const config = {
|
||||
type: 'openapi',
|
||||
openapi: {
|
||||
url: '${API_BASE_URL}/openapi.json',
|
||||
security: {
|
||||
type: 'apiKey',
|
||||
apiKey: {
|
||||
name: 'X-API-Key',
|
||||
in: 'header',
|
||||
value: '${API_KEY}',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = replaceEnvVars(config);
|
||||
|
||||
expect(result.openapi.url).toBe('https://api.example.com/openapi.json');
|
||||
expect(result.openapi.security.apiKey.value).toBe('secret-key-123');
|
||||
});
|
||||
|
||||
it('should expand OAuth configuration', () => {
|
||||
process.env.CLIENT_ID = 'my-client-id';
|
||||
process.env.CLIENT_SECRET = 'my-client-secret';
|
||||
process.env.ACCESS_TOKEN = 'my-access-token';
|
||||
|
||||
const config = {
|
||||
type: 'sse',
|
||||
url: 'https://mcp.example.com',
|
||||
oauth: {
|
||||
clientId: '${CLIENT_ID}',
|
||||
clientSecret: '${CLIENT_SECRET}',
|
||||
accessToken: '${ACCESS_TOKEN}',
|
||||
scopes: ['read', 'write'],
|
||||
},
|
||||
};
|
||||
|
||||
const result = replaceEnvVars(config);
|
||||
|
||||
expect(result.oauth.clientId).toBe('my-client-id');
|
||||
expect(result.oauth.clientSecret).toBe('my-client-secret');
|
||||
expect(result.oauth.accessToken).toBe('my-access-token');
|
||||
expect(result.oauth.scopes).toEqual(['read', 'write']);
|
||||
});
|
||||
|
||||
it('should expand environment variables in env object', () => {
|
||||
process.env.API_KEY = 'my-api-key';
|
||||
process.env.DEBUG = 'true';
|
||||
|
||||
const config = {
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
env: {
|
||||
MY_API_KEY: '${API_KEY}',
|
||||
DEBUG: '${DEBUG}',
|
||||
},
|
||||
};
|
||||
|
||||
const result = replaceEnvVars(config);
|
||||
|
||||
expect(result.env.MY_API_KEY).toBe('my-api-key');
|
||||
expect(result.env.DEBUG).toBe('true');
|
||||
});
|
||||
|
||||
it('should handle complete server configuration', () => {
|
||||
process.env.SERVER_URL = 'https://mcp.example.com';
|
||||
process.env.AUTH_TOKEN = 'bearer-token-123';
|
||||
process.env.TIMEOUT = '60000';
|
||||
|
||||
const config = {
|
||||
type: 'streamable-http',
|
||||
url: '${SERVER_URL}/mcp',
|
||||
headers: {
|
||||
Authorization: 'Bearer ${AUTH_TOKEN}',
|
||||
'User-Agent': 'MCPHub/1.0',
|
||||
},
|
||||
options: {
|
||||
timeout: 30000,
|
||||
},
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const result = replaceEnvVars(config);
|
||||
|
||||
expect(result.url).toBe('https://mcp.example.com/mcp');
|
||||
expect(result.headers.Authorization).toBe('Bearer bearer-token-123');
|
||||
expect(result.headers['User-Agent']).toBe('MCPHub/1.0');
|
||||
expect(result.options.timeout).toBe(30000);
|
||||
expect(result.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle empty string values', () => {
|
||||
const config = {
|
||||
value: '',
|
||||
};
|
||||
|
||||
const result = replaceEnvVars(config);
|
||||
|
||||
expect(result.value).toBe('');
|
||||
});
|
||||
|
||||
it('should handle undefined values', () => {
|
||||
const result = replaceEnvVars(undefined);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle null values in objects', () => {
|
||||
const config = {
|
||||
value: null,
|
||||
};
|
||||
|
||||
const result = replaceEnvVars(config);
|
||||
|
||||
expect(result.value).toBe(null);
|
||||
});
|
||||
|
||||
it('should not break on circular references prevention', () => {
|
||||
// Note: This test ensures we don't have infinite recursion issues
|
||||
// by using a deeply nested structure
|
||||
process.env.DEEP = 'value';
|
||||
|
||||
const config = {
|
||||
a: { b: { c: { d: { e: { f: { g: { h: { i: { j: '${DEEP}' } } } } } } } } },
|
||||
};
|
||||
|
||||
const result = replaceEnvVars(config);
|
||||
|
||||
expect(result.a.b.c.d.e.f.g.h.i.j).toBe('value');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,73 +1,7 @@
|
||||
// Simple unit test to validate the type conversion logic
|
||||
describe('Parameter Type Conversion Logic', () => {
|
||||
// Extract the conversion function for testing
|
||||
function convertQueryParametersToTypes(
|
||||
queryParams: Record<string, any>,
|
||||
inputSchema: Record<string, any>
|
||||
): Record<string, any> {
|
||||
if (!inputSchema || typeof inputSchema !== 'object' || !inputSchema.properties) {
|
||||
return queryParams;
|
||||
}
|
||||
|
||||
const convertedParams: Record<string, any> = {};
|
||||
const properties = inputSchema.properties;
|
||||
|
||||
for (const [key, value] of Object.entries(queryParams)) {
|
||||
const propDef = properties[key];
|
||||
if (!propDef || typeof propDef !== 'object') {
|
||||
// No schema definition found, keep as is
|
||||
convertedParams[key] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
const propType = propDef.type;
|
||||
|
||||
try {
|
||||
switch (propType) {
|
||||
case 'integer':
|
||||
case 'number':
|
||||
// Convert string to number
|
||||
if (typeof value === 'string') {
|
||||
const numValue = propType === 'integer' ? parseInt(value, 10) : parseFloat(value);
|
||||
convertedParams[key] = isNaN(numValue) ? value : numValue;
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'boolean':
|
||||
// Convert string to boolean
|
||||
if (typeof value === 'string') {
|
||||
convertedParams[key] = value.toLowerCase() === 'true' || value === '1';
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'array':
|
||||
// Handle array conversion if needed (e.g., comma-separated strings)
|
||||
if (typeof value === 'string' && value.includes(',')) {
|
||||
convertedParams[key] = value.split(',').map(item => item.trim());
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// For string and other types, keep as is
|
||||
convertedParams[key] = value;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
// If conversion fails, keep the original value
|
||||
console.warn(`Failed to convert parameter '${key}' to type '${propType}':`, error);
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return convertedParams;
|
||||
}
|
||||
import { convertParametersToTypes } from '../../src/utils/parameterConversion.js';
|
||||
|
||||
// Integration tests for OpenAPI controller's parameter type conversion
|
||||
describe('OpenAPI Controller - Parameter Type Conversion Integration', () => {
|
||||
test('should convert integer parameters correctly', () => {
|
||||
const queryParams = {
|
||||
limit: '5',
|
||||
@@ -84,7 +18,7 @@ describe('Parameter Type Conversion Logic', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const result = convertQueryParametersToTypes(queryParams, inputSchema);
|
||||
const result = convertParametersToTypes(queryParams, inputSchema);
|
||||
|
||||
expect(result).toEqual({
|
||||
limit: 5, // Converted to integer
|
||||
@@ -107,7 +41,7 @@ describe('Parameter Type Conversion Logic', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const result = convertQueryParametersToTypes(queryParams, inputSchema);
|
||||
const result = convertParametersToTypes(queryParams, inputSchema);
|
||||
|
||||
expect(result).toEqual({
|
||||
price: 19.99,
|
||||
@@ -133,7 +67,7 @@ describe('Parameter Type Conversion Logic', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const result = convertQueryParametersToTypes(queryParams, inputSchema);
|
||||
const result = convertParametersToTypes(queryParams, inputSchema);
|
||||
|
||||
expect(result).toEqual({
|
||||
enabled: true,
|
||||
@@ -157,7 +91,7 @@ describe('Parameter Type Conversion Logic', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const result = convertQueryParametersToTypes(queryParams, inputSchema);
|
||||
const result = convertParametersToTypes(queryParams, inputSchema);
|
||||
|
||||
expect(result).toEqual({
|
||||
tags: ['tag1', 'tag2', 'tag3'],
|
||||
@@ -171,7 +105,7 @@ describe('Parameter Type Conversion Logic', () => {
|
||||
name: 'test'
|
||||
};
|
||||
|
||||
const result = convertQueryParametersToTypes(queryParams, {});
|
||||
const result = convertParametersToTypes(queryParams, {});
|
||||
|
||||
expect(result).toEqual({
|
||||
limit: '5', // Should remain as string
|
||||
@@ -192,7 +126,7 @@ describe('Parameter Type Conversion Logic', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const result = convertQueryParametersToTypes(queryParams, inputSchema);
|
||||
const result = convertParametersToTypes(queryParams, inputSchema);
|
||||
|
||||
expect(result).toEqual({
|
||||
limit: 5, // Converted based on schema
|
||||
@@ -214,7 +148,7 @@ describe('Parameter Type Conversion Logic', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const result = convertQueryParametersToTypes(queryParams, inputSchema);
|
||||
const result = convertParametersToTypes(queryParams, inputSchema);
|
||||
|
||||
expect(result).toEqual({
|
||||
limit: 'not-a-number', // Should remain as string when conversion fails
|
||||
@@ -299,4 +233,16 @@ describe('OpenAPI Granular Endpoints', () => {
|
||||
const group = mockGetGroupByIdOrName('nonexistent');
|
||||
expect(group).toBeNull();
|
||||
});
|
||||
|
||||
test('should decode URL-encoded server and tool names with slashes', () => {
|
||||
// Test that URL-encoded names with slashes are properly decoded
|
||||
const encodedServerName = 'com.atlassian%2Fatlassian-mcp-server';
|
||||
const encodedToolName = 'atlassianUserInfo';
|
||||
|
||||
const decodedServerName = decodeURIComponent(encodedServerName);
|
||||
const decodedToolName = decodeURIComponent(encodedToolName);
|
||||
|
||||
expect(decodedServerName).toBe('com.atlassian/atlassian-mcp-server');
|
||||
expect(decodedToolName).toBe('atlassianUserInfo');
|
||||
});
|
||||
});
|
||||
98
tests/integration/server-smart-routing.test.ts
Normal file
98
tests/integration/server-smart-routing.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
|
||||
import request from 'supertest';
|
||||
|
||||
const handleSseConnectionMock = jest.fn();
|
||||
const handleSseMessageMock = jest.fn();
|
||||
const handleMcpPostRequestMock = jest.fn();
|
||||
const handleMcpOtherRequestMock = jest.fn();
|
||||
const sseUserContextMiddlewareMock = jest.fn((_req, _res, next) => next());
|
||||
|
||||
jest.mock('../../src/utils/i18n.js', () => ({
|
||||
__esModule: true,
|
||||
initI18n: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/models/User.js', () => ({
|
||||
__esModule: true,
|
||||
initializeDefaultUser: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/oauthService.js', () => ({
|
||||
__esModule: true,
|
||||
initOAuthProvider: jest.fn(),
|
||||
getOAuthRouter: jest.fn(() => null),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/middlewares/index.js', () => ({
|
||||
__esModule: true,
|
||||
initMiddlewares: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/routes/index.js', () => ({
|
||||
__esModule: true,
|
||||
initRoutes: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/mcpService.js', () => ({
|
||||
__esModule: true,
|
||||
initUpstreamServers: jest.fn().mockResolvedValue(undefined),
|
||||
connected: jest.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/sseService.js', () => ({
|
||||
__esModule: true,
|
||||
handleSseConnection: handleSseConnectionMock,
|
||||
handleSseMessage: handleSseMessageMock,
|
||||
handleMcpPostRequest: handleMcpPostRequestMock,
|
||||
handleMcpOtherRequest: handleMcpOtherRequestMock,
|
||||
}));
|
||||
|
||||
jest.mock('../../src/middlewares/userContext.js', () => ({
|
||||
__esModule: true,
|
||||
userContextMiddleware: jest.fn((_req, _res, next) => next()),
|
||||
sseUserContextMiddleware: sseUserContextMiddlewareMock,
|
||||
}));
|
||||
|
||||
import { AppServer } from '../../src/server.js';
|
||||
|
||||
const flushPromises = async () => {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
};
|
||||
|
||||
describe('AppServer smart routing group paths', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
handleMcpPostRequestMock.mockImplementation(async (_req, res) => {
|
||||
res.status(204).send();
|
||||
});
|
||||
sseUserContextMiddlewareMock.mockImplementation((_req, _res, next) => next());
|
||||
});
|
||||
|
||||
const createApp = async () => {
|
||||
const appServer = new AppServer();
|
||||
await appServer.initialize();
|
||||
await flushPromises();
|
||||
return appServer.getApp();
|
||||
};
|
||||
|
||||
it('routes global MCP requests with nested smart group segments', async () => {
|
||||
const app = await createApp();
|
||||
|
||||
await request(app).post('/mcp/$smart/test-group').send({}).expect(204);
|
||||
|
||||
expect(handleMcpPostRequestMock).toHaveBeenCalledTimes(1);
|
||||
const [req] = handleMcpPostRequestMock.mock.calls[0];
|
||||
expect(req.params.group).toBe('$smart/test-group');
|
||||
});
|
||||
|
||||
it('routes user-scoped MCP requests with nested smart group segments', async () => {
|
||||
const app = await createApp();
|
||||
|
||||
await request(app).post('/alice/mcp/$smart/staging').send({}).expect(204);
|
||||
|
||||
expect(handleMcpPostRequestMock).toHaveBeenCalledTimes(1);
|
||||
const [req] = handleMcpPostRequestMock.mock.calls[0];
|
||||
expect(req.params.group).toBe('$smart/staging');
|
||||
expect(req.params.user).toBe('alice');
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,17 @@
|
||||
// Mock openid-client before importing services
|
||||
jest.mock('openid-client', () => ({
|
||||
discovery: jest.fn(),
|
||||
dynamicClientRegistration: jest.fn(),
|
||||
ClientSecretPost: jest.fn(() => jest.fn()),
|
||||
ClientSecretBasic: jest.fn(() => jest.fn()),
|
||||
None: jest.fn(() => jest.fn()),
|
||||
calculatePKCECodeChallenge: jest.fn(),
|
||||
randomPKCECodeVerifier: jest.fn(),
|
||||
buildAuthorizationUrl: jest.fn(),
|
||||
authorizationCodeGrant: jest.fn(),
|
||||
refreshTokenGrant: jest.fn(),
|
||||
}));
|
||||
|
||||
import { Server } from 'http';
|
||||
import { AppServer } from '../../src/server.js';
|
||||
import { TestServerHelper } from '../utils/testServerHelper.js';
|
||||
|
||||
335
tests/services/clusterService.test.ts
Normal file
335
tests/services/clusterService.test.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Cluster Service Tests
|
||||
*/
|
||||
|
||||
import {
|
||||
isClusterEnabled,
|
||||
getClusterMode,
|
||||
registerNode,
|
||||
updateNodeHeartbeat,
|
||||
getActiveNodes,
|
||||
getAllNodes,
|
||||
getServerReplicas,
|
||||
getNodeForSession,
|
||||
getSessionAffinity,
|
||||
removeSessionAffinity,
|
||||
getClusterStats,
|
||||
shutdownClusterService,
|
||||
} from '../../src/services/clusterService';
|
||||
import { ClusterNode } from '../../src/types/index';
|
||||
import * as configModule from '../../src/config/index.js';
|
||||
|
||||
// Mock the config module
|
||||
jest.mock('../../src/config/index.js', () => ({
|
||||
loadSettings: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('Cluster Service', () => {
|
||||
const loadSettings = configModule.loadSettings as jest.MockedFunction<typeof configModule.loadSettings>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up cluster service to reset state
|
||||
shutdownClusterService();
|
||||
});
|
||||
|
||||
describe('Configuration', () => {
|
||||
it('should return false when cluster is not enabled', () => {
|
||||
loadSettings.mockReturnValue({
|
||||
mcpServers: {},
|
||||
});
|
||||
|
||||
expect(isClusterEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when cluster is enabled', () => {
|
||||
loadSettings.mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
cluster: {
|
||||
enabled: true,
|
||||
mode: 'coordinator',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(isClusterEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return standalone mode when cluster is not configured', () => {
|
||||
loadSettings.mockReturnValue({
|
||||
mcpServers: {},
|
||||
});
|
||||
|
||||
expect(getClusterMode()).toBe('standalone');
|
||||
});
|
||||
|
||||
it('should return configured mode when cluster is enabled', () => {
|
||||
loadSettings.mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
cluster: {
|
||||
enabled: true,
|
||||
mode: 'coordinator',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(getClusterMode()).toBe('coordinator');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Node Management', () => {
|
||||
beforeEach(() => {
|
||||
loadSettings.mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
cluster: {
|
||||
enabled: true,
|
||||
mode: 'coordinator',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should register a new node', () => {
|
||||
const node: ClusterNode = {
|
||||
id: 'node-test-1',
|
||||
name: 'Test Node 1',
|
||||
host: 'localhost',
|
||||
port: 3001,
|
||||
url: 'http://localhost:3001',
|
||||
status: 'active',
|
||||
lastHeartbeat: Date.now(),
|
||||
servers: ['server1', 'server2'],
|
||||
};
|
||||
|
||||
registerNode(node);
|
||||
const nodes = getAllNodes();
|
||||
|
||||
// Find our node (there might be others from previous tests)
|
||||
const registeredNode = nodes.find(n => n.id === 'node-test-1');
|
||||
expect(registeredNode).toBeTruthy();
|
||||
expect(registeredNode?.name).toBe('Test Node 1');
|
||||
expect(registeredNode?.servers).toEqual(['server1', 'server2']);
|
||||
});
|
||||
|
||||
it('should update node heartbeat', () => {
|
||||
const node: ClusterNode = {
|
||||
id: 'node-test-2',
|
||||
name: 'Test Node 2',
|
||||
host: 'localhost',
|
||||
port: 3001,
|
||||
url: 'http://localhost:3001',
|
||||
status: 'active',
|
||||
lastHeartbeat: Date.now() - 10000,
|
||||
servers: ['server1'],
|
||||
};
|
||||
|
||||
registerNode(node);
|
||||
const beforeHeartbeat = getAllNodes().find(n => n.id === 'node-test-2')?.lastHeartbeat || 0;
|
||||
|
||||
// Wait a bit to ensure timestamp changes
|
||||
setTimeout(() => {
|
||||
updateNodeHeartbeat('node-test-2', ['server1', 'server2']);
|
||||
const updatedNode = getAllNodes().find(n => n.id === 'node-test-2');
|
||||
const afterHeartbeat = updatedNode?.lastHeartbeat || 0;
|
||||
|
||||
expect(afterHeartbeat).toBeGreaterThan(beforeHeartbeat);
|
||||
expect(updatedNode?.servers).toEqual(['server1', 'server2']);
|
||||
}, 10);
|
||||
});
|
||||
|
||||
it('should get active nodes only', () => {
|
||||
const node1: ClusterNode = {
|
||||
id: 'node-active-1',
|
||||
name: 'Active Node',
|
||||
host: 'localhost',
|
||||
port: 3001,
|
||||
url: 'http://localhost:3001',
|
||||
status: 'active',
|
||||
lastHeartbeat: Date.now(),
|
||||
servers: ['server1'],
|
||||
};
|
||||
|
||||
registerNode(node1);
|
||||
|
||||
const activeNodes = getActiveNodes();
|
||||
const activeNode = activeNodes.find(n => n.id === 'node-active-1');
|
||||
expect(activeNode).toBeTruthy();
|
||||
expect(activeNode?.status).toBe('active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Server Replicas', () => {
|
||||
beforeEach(() => {
|
||||
loadSettings.mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
cluster: {
|
||||
enabled: true,
|
||||
mode: 'coordinator',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should track server replicas across nodes', () => {
|
||||
const node1: ClusterNode = {
|
||||
id: 'node-replica-1',
|
||||
name: 'Node 1',
|
||||
host: 'localhost',
|
||||
port: 3001,
|
||||
url: 'http://localhost:3001',
|
||||
status: 'active',
|
||||
lastHeartbeat: Date.now(),
|
||||
servers: ['test-server-1', 'test-server-2'],
|
||||
};
|
||||
|
||||
const node2: ClusterNode = {
|
||||
id: 'node-replica-2',
|
||||
name: 'Node 2',
|
||||
host: 'localhost',
|
||||
port: 3002,
|
||||
url: 'http://localhost:3002',
|
||||
status: 'active',
|
||||
lastHeartbeat: Date.now(),
|
||||
servers: ['test-server-1', 'test-server-3'],
|
||||
};
|
||||
|
||||
registerNode(node1);
|
||||
registerNode(node2);
|
||||
|
||||
const server1Replicas = getServerReplicas('test-server-1');
|
||||
expect(server1Replicas.length).toBeGreaterThanOrEqual(2);
|
||||
expect(server1Replicas.map(r => r.nodeId)).toContain('node-replica-1');
|
||||
expect(server1Replicas.map(r => r.nodeId)).toContain('node-replica-2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session Affinity', () => {
|
||||
beforeEach(() => {
|
||||
loadSettings.mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
cluster: {
|
||||
enabled: true,
|
||||
mode: 'coordinator',
|
||||
stickySession: {
|
||||
enabled: true,
|
||||
strategy: 'consistent-hash',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should maintain session affinity with consistent hash', () => {
|
||||
const node1: ClusterNode = {
|
||||
id: 'node-affinity-1',
|
||||
name: 'Node 1',
|
||||
host: 'localhost',
|
||||
port: 3001,
|
||||
url: 'http://localhost:3001',
|
||||
status: 'active',
|
||||
lastHeartbeat: Date.now(),
|
||||
servers: ['server1'],
|
||||
};
|
||||
|
||||
registerNode(node1);
|
||||
|
||||
const sessionId = 'test-session-consistent-hash';
|
||||
const firstNode = getNodeForSession(sessionId);
|
||||
const secondNode = getNodeForSession(sessionId);
|
||||
|
||||
expect(firstNode).toBeTruthy();
|
||||
expect(secondNode).toBeTruthy();
|
||||
expect(firstNode?.id).toBe(secondNode?.id);
|
||||
});
|
||||
|
||||
it('should create and retrieve session affinity', () => {
|
||||
const node1: ClusterNode = {
|
||||
id: 'node-affinity-2',
|
||||
name: 'Node 1',
|
||||
host: 'localhost',
|
||||
port: 3001,
|
||||
url: 'http://localhost:3001',
|
||||
status: 'active',
|
||||
lastHeartbeat: Date.now(),
|
||||
servers: ['server1'],
|
||||
};
|
||||
|
||||
registerNode(node1);
|
||||
|
||||
const sessionId = 'test-session-retrieve';
|
||||
const selectedNode = getNodeForSession(sessionId);
|
||||
|
||||
const affinity = getSessionAffinity(sessionId);
|
||||
expect(affinity).toBeTruthy();
|
||||
expect(affinity?.sessionId).toBe(sessionId);
|
||||
expect(affinity?.nodeId).toBe(selectedNode?.id);
|
||||
});
|
||||
|
||||
it('should remove session affinity', () => {
|
||||
const node1: ClusterNode = {
|
||||
id: 'node-affinity-3',
|
||||
name: 'Node 1',
|
||||
host: 'localhost',
|
||||
port: 3001,
|
||||
url: 'http://localhost:3001',
|
||||
status: 'active',
|
||||
lastHeartbeat: Date.now(),
|
||||
servers: ['server1'],
|
||||
};
|
||||
|
||||
registerNode(node1);
|
||||
|
||||
const sessionId = 'test-session-remove';
|
||||
getNodeForSession(sessionId);
|
||||
|
||||
let affinity = getSessionAffinity(sessionId);
|
||||
expect(affinity).toBeTruthy();
|
||||
|
||||
removeSessionAffinity(sessionId);
|
||||
affinity = getSessionAffinity(sessionId);
|
||||
expect(affinity).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cluster Statistics', () => {
|
||||
beforeEach(() => {
|
||||
loadSettings.mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
cluster: {
|
||||
enabled: true,
|
||||
mode: 'coordinator',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return cluster statistics', () => {
|
||||
const node1: ClusterNode = {
|
||||
id: 'node-stats-1',
|
||||
name: 'Node 1',
|
||||
host: 'localhost',
|
||||
port: 3001,
|
||||
url: 'http://localhost:3001',
|
||||
status: 'active',
|
||||
lastHeartbeat: Date.now(),
|
||||
servers: ['unique-server-1', 'unique-server-2'],
|
||||
};
|
||||
|
||||
registerNode(node1);
|
||||
|
||||
const stats = getClusterStats();
|
||||
expect(stats.nodes).toBeGreaterThanOrEqual(1);
|
||||
expect(stats.activeNodes).toBeGreaterThanOrEqual(1);
|
||||
expect(stats.servers).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
179
tests/services/mcpService-headers.test.ts
Normal file
179
tests/services/mcpService-headers.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { expandEnvVars, replaceEnvVars } from '../../src/config/index.js';
|
||||
|
||||
describe('MCP Service - Headers Environment Variable Expansion', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset process.env before each test
|
||||
jest.resetModules();
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Restore original environment
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('expandEnvVars', () => {
|
||||
it('should expand environment variables in ${VAR} format', () => {
|
||||
process.env.CONTEXT7_API_KEY = 'ctx7sk-test123';
|
||||
const result = expandEnvVars('${CONTEXT7_API_KEY}');
|
||||
expect(result).toBe('ctx7sk-test123');
|
||||
});
|
||||
|
||||
it('should expand environment variables in $VAR format', () => {
|
||||
process.env.TEST_VAR = 'test-value';
|
||||
const result = expandEnvVars('$TEST_VAR');
|
||||
expect(result).toBe('test-value');
|
||||
});
|
||||
|
||||
it('should expand multiple environment variables', () => {
|
||||
process.env.VAR1 = 'value1';
|
||||
process.env.VAR2 = 'value2';
|
||||
const result = expandEnvVars('${VAR1}-and-${VAR2}');
|
||||
expect(result).toBe('value1-and-value2');
|
||||
});
|
||||
|
||||
it('should return empty string for undefined variables', () => {
|
||||
const result = expandEnvVars('${UNDEFINED_VAR}');
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should handle strings without variables', () => {
|
||||
const result = expandEnvVars('plain-string');
|
||||
expect(result).toBe('plain-string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceEnvVars - Object (Headers)', () => {
|
||||
it('should expand environment variables in header values', () => {
|
||||
process.env.CONTEXT7_API_KEY = 'ctx7sk-d16example123';
|
||||
const headers = {
|
||||
CONTEXT7_API_KEY: '${CONTEXT7_API_KEY}',
|
||||
};
|
||||
const result = replaceEnvVars(headers);
|
||||
expect(result).toEqual({
|
||||
CONTEXT7_API_KEY: 'ctx7sk-d16example123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should expand multiple headers with environment variables', () => {
|
||||
process.env.API_KEY = 'test-api-key';
|
||||
process.env.AUTH_TOKEN = 'test-auth-token';
|
||||
const headers = {
|
||||
'X-API-Key': '${API_KEY}',
|
||||
Authorization: 'Bearer ${AUTH_TOKEN}',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const result = replaceEnvVars(headers);
|
||||
expect(result).toEqual({
|
||||
'X-API-Key': 'test-api-key',
|
||||
Authorization: 'Bearer test-auth-token',
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle $VAR format in headers', () => {
|
||||
process.env.MY_KEY = 'my-value';
|
||||
const headers = {
|
||||
'X-Custom-Header': '$MY_KEY',
|
||||
};
|
||||
const result = replaceEnvVars(headers);
|
||||
expect(result).toEqual({
|
||||
'X-Custom-Header': 'my-value',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty string for undefined variables in headers', () => {
|
||||
const headers = {
|
||||
'X-Undefined': '${UNDEFINED_VAR}',
|
||||
};
|
||||
const result = replaceEnvVars(headers);
|
||||
expect(result).toEqual({
|
||||
'X-Undefined': '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle mix of variables and static values', () => {
|
||||
process.env.TOKEN = 'secret123';
|
||||
const headers = {
|
||||
Authorization: 'Bearer ${TOKEN}',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Custom': 'static-value',
|
||||
};
|
||||
const result = replaceEnvVars(headers);
|
||||
expect(result).toEqual({
|
||||
Authorization: 'Bearer secret123',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Custom': 'static-value',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty object', () => {
|
||||
const headers = {};
|
||||
const result = replaceEnvVars(headers);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceEnvVars - Array (Args)', () => {
|
||||
it('should expand environment variables in array elements', () => {
|
||||
process.env.PORT = '3000';
|
||||
const args = ['--port', '${PORT}'];
|
||||
const result = replaceEnvVars(args);
|
||||
expect(result).toEqual(['--port', '3000']);
|
||||
});
|
||||
|
||||
it('should handle multiple variables in array', () => {
|
||||
process.env.HOST = 'localhost';
|
||||
process.env.PORT = '8080';
|
||||
const args = ['--host', '${HOST}', '--port', '${PORT}'];
|
||||
const result = replaceEnvVars(args);
|
||||
expect(result).toEqual(['--host', 'localhost', '--port', '8080']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real-world Context7 Scenario', () => {
|
||||
it('should correctly expand Context7 API key from environment', () => {
|
||||
// Simulate the environment variable being set in the container
|
||||
process.env.CONTEXT7_API_KEY = 'ctx7sk-d16examplekey123';
|
||||
|
||||
// Simulate the configuration from mcp_settings.json
|
||||
const serverConfig = {
|
||||
type: 'streamable-http',
|
||||
url: 'https://mcp.context7.com/mcp',
|
||||
headers: {
|
||||
CONTEXT7_API_KEY: '${CONTEXT7_API_KEY}',
|
||||
},
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
// Simulate what happens in createTransportFromConfig
|
||||
const expandedHeaders = replaceEnvVars(serverConfig.headers);
|
||||
|
||||
// Verify that the environment variable was correctly expanded
|
||||
expect(expandedHeaders.CONTEXT7_API_KEY).toBe('ctx7sk-d16examplekey123');
|
||||
expect(expandedHeaders.CONTEXT7_API_KEY).not.toBe('${CONTEXT7_API_KEY}');
|
||||
expect(expandedHeaders.CONTEXT7_API_KEY).toMatch(/^ctx7sk-/);
|
||||
});
|
||||
|
||||
it('should handle case when environment variable is not set', () => {
|
||||
// Don't set the environment variable
|
||||
delete process.env.CONTEXT7_API_KEY;
|
||||
|
||||
const serverConfig = {
|
||||
type: 'streamable-http',
|
||||
url: 'https://mcp.context7.com/mcp',
|
||||
headers: {
|
||||
CONTEXT7_API_KEY: '${CONTEXT7_API_KEY}',
|
||||
},
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const expandedHeaders = replaceEnvVars(serverConfig.headers);
|
||||
|
||||
// Should be empty string when env var is not set
|
||||
expect(expandedHeaders.CONTEXT7_API_KEY).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
222
tests/services/mcpService-smart-routing-group.test.ts
Normal file
222
tests/services/mcpService-smart-routing-group.test.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
|
||||
// Mock dependencies before importing mcpService
|
||||
jest.mock('../../src/services/oauthService.js', () => ({
|
||||
initializeAllOAuthClients: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/oauthClientRegistration.js', () => ({
|
||||
registerOAuthClient: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/mcpOAuthProvider.js', () => ({
|
||||
createOAuthProvider: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/groupService.js', () => ({
|
||||
getServersInGroup: jest.fn((groupId: string) => {
|
||||
if (groupId === 'test-group') {
|
||||
return ['server1', 'server2'];
|
||||
}
|
||||
if (groupId === 'empty-group') {
|
||||
return [];
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
getServerConfigInGroup: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/sseService.js', () => ({
|
||||
getGroup: jest.fn((sessionId: string) => {
|
||||
if (sessionId === 'session-smart') return '$smart';
|
||||
if (sessionId === 'session-smart-group') return '$smart/test-group';
|
||||
if (sessionId === 'session-smart-empty') return '$smart/empty-group';
|
||||
return '';
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/dao/index.js', () => ({
|
||||
getServerDao: jest.fn(() => ({
|
||||
findById: jest.fn(),
|
||||
findAll: jest.fn(() => Promise.resolve([])),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/services.js', () => ({
|
||||
getDataService: jest.fn(() => ({
|
||||
filterData: (data: any) => data,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/vectorSearchService.js', () => ({
|
||||
searchToolsByVector: jest.fn(),
|
||||
saveToolsAsVectorEmbeddings: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/config/index.js', () => ({
|
||||
loadSettings: jest.fn(),
|
||||
expandEnvVars: jest.fn((val: string) => val),
|
||||
replaceEnvVars: jest.fn((val: any) => val),
|
||||
getNameSeparator: jest.fn(() => '::'),
|
||||
default: {
|
||||
mcpHubName: 'test-hub',
|
||||
mcpHubVersion: '1.0.0',
|
||||
},
|
||||
}));
|
||||
|
||||
// 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 { searchToolsByVector } from '../../src/services/vectorSearchService.js';
|
||||
|
||||
describe('MCP Service - Smart Routing with Group Support', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('handleListToolsRequest', () => {
|
||||
it('should return search_tools and call_tool for $smart group', async () => {
|
||||
const result = await handleListToolsRequest({}, { sessionId: 'session-smart' });
|
||||
|
||||
expect(result.tools).toHaveLength(2);
|
||||
expect(result.tools[0].name).toBe('search_tools');
|
||||
expect(result.tools[1].name).toBe('call_tool');
|
||||
expect(result.tools[0].description).toContain('all available servers');
|
||||
});
|
||||
|
||||
it('should return filtered tools for $smart/{group} pattern', async () => {
|
||||
const result = await handleListToolsRequest({}, { sessionId: 'session-smart-group' });
|
||||
|
||||
expect(getGroup).toHaveBeenCalledWith('session-smart-group');
|
||||
expect(getServersInGroup).toHaveBeenCalledWith('test-group');
|
||||
|
||||
expect(result.tools).toHaveLength(2);
|
||||
expect(result.tools[0].name).toBe('search_tools');
|
||||
expect(result.tools[1].name).toBe('call_tool');
|
||||
expect(result.tools[0].description).toContain('servers in the "test-group" group');
|
||||
});
|
||||
|
||||
it('should handle $smart with empty group', async () => {
|
||||
const result = await handleListToolsRequest({}, { sessionId: 'session-smart-empty' });
|
||||
|
||||
expect(getGroup).toHaveBeenCalledWith('session-smart-empty');
|
||||
expect(getServersInGroup).toHaveBeenCalledWith('empty-group');
|
||||
|
||||
expect(result.tools).toHaveLength(2);
|
||||
expect(result.tools[0].name).toBe('search_tools');
|
||||
expect(result.tools[1].name).toBe('call_tool');
|
||||
// Should still show group-scoped message even if group is empty
|
||||
expect(result.tools[0].description).toContain('servers in the "empty-group" group');
|
||||
});
|
||||
});
|
||||
|
||||
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',
|
||||
arguments: {
|
||||
query: 'test query',
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await handleCallToolRequest(request, { sessionId: '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',
|
||||
arguments: {
|
||||
query: 'test query',
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await handleCallToolRequest(request, { sessionId: 'session-smart-group' });
|
||||
|
||||
expect(getGroup).toHaveBeenCalledWith('session-smart-group');
|
||||
expect(getServersInGroup).toHaveBeenCalledWith('test-group');
|
||||
expect(searchToolsByVector).toHaveBeenCalledWith(
|
||||
'test query',
|
||||
10,
|
||||
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',
|
||||
arguments: {
|
||||
query: 'test query',
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await handleCallToolRequest(request, { sessionId: 'session-smart-empty' });
|
||||
|
||||
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,
|
||||
expect.any(Number),
|
||||
[], // Empty group
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate query parameter', async () => {
|
||||
const request = {
|
||||
params: {
|
||||
name: 'search_tools',
|
||||
arguments: {
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await handleCallToolRequest(request, { sessionId: 'session-smart' });
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain('Query parameter is required');
|
||||
});
|
||||
});
|
||||
});
|
||||
207
tests/services/oauthService.test.ts
Normal file
207
tests/services/oauthService.test.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
// Mock openid-client before importing services
|
||||
jest.mock('openid-client', () => ({
|
||||
discovery: jest.fn(),
|
||||
dynamicClientRegistration: jest.fn(),
|
||||
ClientSecretPost: jest.fn(() => jest.fn()),
|
||||
ClientSecretBasic: jest.fn(() => jest.fn()),
|
||||
None: jest.fn(() => jest.fn()),
|
||||
calculatePKCECodeChallenge: jest.fn(),
|
||||
randomPKCECodeVerifier: jest.fn(),
|
||||
buildAuthorizationUrl: jest.fn(),
|
||||
authorizationCodeGrant: jest.fn(),
|
||||
refreshTokenGrant: jest.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
initOAuthProvider,
|
||||
isOAuthEnabled,
|
||||
getServerOAuthToken,
|
||||
addOAuthHeader,
|
||||
} from '../../src/services/oauthService.js';
|
||||
import * as config from '../../src/config/index.js';
|
||||
|
||||
// Mock the config module
|
||||
jest.mock('../../src/config/index.js', () => ({
|
||||
loadSettings: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('OAuth Service', () => {
|
||||
const mockLoadSettings = config.loadSettings as jest.MockedFunction<typeof config.loadSettings>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('initOAuthProvider', () => {
|
||||
it('should not initialize OAuth when disabled', () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
oauth: {
|
||||
enabled: false,
|
||||
issuerUrl: 'http://auth.example.com',
|
||||
endpoints: {
|
||||
authorizationUrl: 'http://auth.example.com/authorize',
|
||||
tokenUrl: 'http://auth.example.com/token',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
initOAuthProvider();
|
||||
expect(isOAuthEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should not initialize OAuth when not configured', () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {},
|
||||
});
|
||||
|
||||
initOAuthProvider();
|
||||
expect(isOAuthEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should attempt to initialize OAuth when enabled and properly configured', () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
oauth: {
|
||||
enabled: true,
|
||||
issuerUrl: 'http://auth.example.com',
|
||||
endpoints: {
|
||||
authorizationUrl: 'http://auth.example.com/authorize',
|
||||
tokenUrl: 'http://auth.example.com/token',
|
||||
},
|
||||
clients: [
|
||||
{
|
||||
client_id: 'test-client',
|
||||
redirect_uris: ['http://localhost:3000/callback'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// In a test environment, the ProxyOAuthServerProvider may not fully initialize
|
||||
// due to missing dependencies or network issues, which is expected
|
||||
initOAuthProvider();
|
||||
// We just verify that the function doesn't throw an error
|
||||
expect(mockLoadSettings).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getServerOAuthToken', () => {
|
||||
it('should return undefined when server has no OAuth config', async () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
url: 'http://example.com',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const token = await getServerOAuthToken('test-server');
|
||||
expect(token).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when server has no access token', async () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
url: 'http://example.com',
|
||||
oauth: {
|
||||
clientId: 'test-client',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const token = await getServerOAuthToken('test-server');
|
||||
expect(token).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return access token when configured', async () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
url: 'http://example.com',
|
||||
oauth: {
|
||||
clientId: 'test-client',
|
||||
accessToken: 'test-access-token',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const token = await getServerOAuthToken('test-server');
|
||||
expect(token).toBe('test-access-token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addOAuthHeader', () => {
|
||||
it('should not modify headers when no OAuth token is configured', async () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
url: 'http://example.com',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
const result = await addOAuthHeader('test-server', headers);
|
||||
|
||||
expect(result).toEqual(headers);
|
||||
expect(result.Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should add Authorization header when OAuth token is configured', async () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
url: 'http://example.com',
|
||||
oauth: {
|
||||
clientId: 'test-client',
|
||||
accessToken: 'test-access-token',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
const result = await addOAuthHeader('test-server', headers);
|
||||
|
||||
expect(result).toEqual({
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer test-access-token',
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve existing headers when adding OAuth token', async () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
url: 'http://example.com',
|
||||
oauth: {
|
||||
clientId: 'test-client',
|
||||
accessToken: 'test-access-token',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Custom-Header': 'custom-value',
|
||||
};
|
||||
const result = await addOAuthHeader('test-server', headers);
|
||||
|
||||
expect(result).toEqual({
|
||||
'Content-Type': 'application/json',
|
||||
'X-Custom-Header': 'custom-value',
|
||||
Authorization: 'Bearer test-access-token',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,17 @@
|
||||
// Mock openid-client before importing services
|
||||
jest.mock('openid-client', () => ({
|
||||
discovery: jest.fn(),
|
||||
dynamicClientRegistration: jest.fn(),
|
||||
ClientSecretPost: jest.fn(() => jest.fn()),
|
||||
ClientSecretBasic: jest.fn(() => jest.fn()),
|
||||
None: jest.fn(() => jest.fn()),
|
||||
calculatePKCECodeChallenge: jest.fn(),
|
||||
randomPKCECodeVerifier: jest.fn(),
|
||||
buildAuthorizationUrl: jest.fn(),
|
||||
authorizationCodeGrant: jest.fn(),
|
||||
refreshTokenGrant: jest.fn(),
|
||||
}));
|
||||
|
||||
import { generateOpenAPISpec, getToolStats } from '../../src/services/openApiGeneratorService';
|
||||
|
||||
describe('OpenAPI Generator Service', () => {
|
||||
@@ -51,6 +65,27 @@ describe('OpenAPI Generator Service', () => {
|
||||
expect(spec).toHaveProperty('paths');
|
||||
expect(typeof spec.paths).toBe('object');
|
||||
});
|
||||
|
||||
it('should URL-encode server and tool names with slashes in paths', async () => {
|
||||
const spec = await generateOpenAPISpec();
|
||||
|
||||
// Check if any paths contain URL-encoded values
|
||||
// Paths with slashes in server/tool names should be encoded
|
||||
const paths = Object.keys(spec.paths);
|
||||
|
||||
// If there are any servers with slashes, verify encoding
|
||||
// e.g., "com.atlassian/atlassian-mcp-server" should become "com.atlassian%2Fatlassian-mcp-server"
|
||||
for (const path of paths) {
|
||||
// Path should not have unencoded slashes in the middle segments
|
||||
// Valid format: /tools/{encoded-server}/{encoded-tool}
|
||||
const pathSegments = path.split('/').filter((s) => s.length > 0);
|
||||
if (pathSegments[0] === 'tools' && pathSegments.length >= 3) {
|
||||
// The server name (segment 1) and tool name (segment 2+) should not create extra segments
|
||||
// If properly encoded, there should be exactly 3 segments: ['tools', serverName, toolName]
|
||||
expect(pathSegments.length).toBe(3);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getToolStats', () => {
|
||||
|
||||
259
tests/utils/parameterConversion.test.ts
Normal file
259
tests/utils/parameterConversion.test.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { convertParametersToTypes } from '../../src/utils/parameterConversion.js';
|
||||
|
||||
describe('Parameter Conversion Utilities', () => {
|
||||
describe('convertParametersToTypes', () => {
|
||||
it('should convert string to number when schema type is number', () => {
|
||||
const params = { count: '42' };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
count: { type: 'number' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(result.count).toBe(42);
|
||||
expect(typeof result.count).toBe('number');
|
||||
});
|
||||
|
||||
it('should convert string to integer when schema type is integer', () => {
|
||||
const params = { age: '25' };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
age: { type: 'integer' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(result.age).toBe(25);
|
||||
expect(typeof result.age).toBe('number');
|
||||
expect(Number.isInteger(result.age)).toBe(true);
|
||||
});
|
||||
|
||||
it('should convert string to boolean when schema type is boolean', () => {
|
||||
const params = { enabled: 'true', disabled: 'false', flag: '1' };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
enabled: { type: 'boolean' },
|
||||
disabled: { type: 'boolean' },
|
||||
flag: { type: 'boolean' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.disabled).toBe(false);
|
||||
expect(result.flag).toBe(true);
|
||||
});
|
||||
|
||||
it('should convert comma-separated string to array when schema type is array', () => {
|
||||
const params = { tags: 'one,two,three' };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tags: { type: 'array' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(Array.isArray(result.tags)).toBe(true);
|
||||
expect(result.tags).toEqual(['one', 'two', 'three']);
|
||||
});
|
||||
|
||||
it('should parse JSON string to object when schema type is object', () => {
|
||||
const params = { config: '{"key": "value", "nested": {"prop": 123}}' };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
config: { type: 'object' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(typeof result.config).toBe('object');
|
||||
expect(result.config).toEqual({ key: 'value', nested: { prop: 123 } });
|
||||
});
|
||||
|
||||
it('should keep values unchanged when they already have the correct type', () => {
|
||||
const params = { count: 42, enabled: true, tags: ['a', 'b'] };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
count: { type: 'number' },
|
||||
enabled: { type: 'boolean' },
|
||||
tags: { type: 'array' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(result.count).toBe(42);
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.tags).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('should keep string values unchanged when schema type is string', () => {
|
||||
const params = { name: 'John Doe' };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(result.name).toBe('John Doe');
|
||||
expect(typeof result.name).toBe('string');
|
||||
});
|
||||
|
||||
it('should handle parameters without schema definition', () => {
|
||||
const params = { unknown: 'value' };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
known: { type: 'string' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(result.unknown).toBe('value');
|
||||
});
|
||||
|
||||
it('should return original params when schema has no properties', () => {
|
||||
const params = { key: 'value' };
|
||||
const schema = { type: 'object' };
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(result).toEqual(params);
|
||||
});
|
||||
|
||||
it('should return original params when schema is null or undefined', () => {
|
||||
const params = { key: 'value' };
|
||||
|
||||
const resultNull = convertParametersToTypes(params, null as any);
|
||||
const resultUndefined = convertParametersToTypes(params, undefined as any);
|
||||
|
||||
expect(resultNull).toEqual(params);
|
||||
expect(resultUndefined).toEqual(params);
|
||||
});
|
||||
|
||||
it('should handle invalid number conversion gracefully', () => {
|
||||
const params = { count: 'not-a-number' };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
count: { type: 'number' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
// When conversion fails, it should keep original value
|
||||
expect(result.count).toBe('not-a-number');
|
||||
});
|
||||
|
||||
it('should handle invalid JSON string for object gracefully', () => {
|
||||
const params = { config: '{invalid json}' };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
config: { type: 'object' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
// When JSON parsing fails, it should keep original value
|
||||
expect(result.config).toBe('{invalid json}');
|
||||
});
|
||||
|
||||
it('should handle mixed parameter types correctly', () => {
|
||||
const params = {
|
||||
name: 'Test',
|
||||
count: '10',
|
||||
price: '19.99',
|
||||
enabled: 'true',
|
||||
tags: 'tag1,tag2',
|
||||
config: '{"nested": true}',
|
||||
};
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
count: { type: 'integer' },
|
||||
price: { type: 'number' },
|
||||
enabled: { type: 'boolean' },
|
||||
tags: { type: 'array' },
|
||||
config: { type: 'object' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(result.name).toBe('Test');
|
||||
expect(result.count).toBe(10);
|
||||
expect(result.price).toBe(19.99);
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.tags).toEqual(['tag1', 'tag2']);
|
||||
expect(result.config).toEqual({ nested: true });
|
||||
});
|
||||
|
||||
it('should handle empty string values', () => {
|
||||
const params = { name: '', count: '', enabled: '' };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
count: { type: 'number' },
|
||||
enabled: { type: 'boolean' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(result.name).toBe('');
|
||||
// Empty string should remain as empty string for number (NaN check keeps original)
|
||||
expect(result.count).toBe('');
|
||||
// Empty string converts to false for boolean
|
||||
expect(result.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle array that is already an array', () => {
|
||||
const params = { tags: ['existing', 'array'] };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tags: { type: 'array' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(result.tags).toEqual(['existing', 'array']);
|
||||
});
|
||||
|
||||
it('should handle object that is already an object', () => {
|
||||
const params = { config: { key: 'value' } };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
config: { type: 'object' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(result.config).toEqual({ key: 'value' });
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user