Compare commits

..

9 Commits

Author SHA1 Message Date
Chengwei Guo
a4e4791b60 fix the deployment on kubernetes (#417) 2025-11-03 14:16:12 +08:00
samanhappy
01370ea959 Revert "Feat: Enhance package cache for stdio servers (#400)" (#418) 2025-11-03 13:35:24 +08:00
samanhappy
f5d66c1bb7 fix versions for react and react-dom (#414) 2025-11-02 23:02:25 +08:00
dependabot[bot]
9e59dd9fb0 chore(deps-dev): bump react and @types/react (#407)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-02 22:48:13 +08:00
dependabot[bot]
250487f042 chore(deps-dev): bump lucide-react from 0.486.0 to 0.552.0 (#408)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-02 22:45:01 +08:00
dependabot[bot]
da91708420 chore(deps): bump i18next from 25.5.0 to 25.6.0 (#409)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-02 22:44:42 +08:00
dependabot[bot]
576bba1f9e chore(deps): bump openai from 4.104.0 to 6.7.0 (#410)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-02 22:44:21 +08:00
dependabot[bot]
f4b83929a6 chore(deps): bump axios from 1.12.2 to 1.13.1 (#406)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-02 22:43:57 +08:00
Alptekin Gülcan
3825f389cd Feat: Add Turkish localization (tr) (#411) 2025-11-02 22:43:18 +08:00
28 changed files with 1094 additions and 4235 deletions

View File

@@ -9,25 +9,9 @@ RUN apt-get update && apt-get install -y curl gnupg git \
RUN npm install -g pnpm
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 && \
ENV PNPM_HOME=/usr/local/share/pnpm
ENV PATH=$PNPM_HOME:$PATH
RUN mkdir -p $PNPM_HOME && \
pnpm add -g @amap/amap-maps-mcp-server @playwright/mcp@latest tavily-mcp@latest @modelcontextprotocol/server-github @modelcontextprotocol/server-slack
ARG INSTALL_EXT=false

View File

@@ -21,7 +21,6 @@ MCPHub makes it easy to manage and scale multiple MCP (Model Context Protocol) s
- **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

View File

@@ -19,9 +19,6 @@ MCPHub 通过将多个 MCPModel Context Protocol服务器组织为灵活
- **热插拔式配置**:在运行时动态添加、移除或更新服务器配置,无需停机。
- **基于分组的访问控制**:自定义分组并管理服务器访问权限。
- **安全认证机制**:内置用户管理,基于 JWT 和 bcrypt实现角色权限控制。
- **OAuth 2.0 支持**:完整的 OAuth 支持,用于上游 MCP 服务器的代理授权功能。
- **环境变量扩展**:在配置中的任何位置使用环境变量,实现安全凭证管理。参见[环境变量指南](docs/environment-variables.md)。
- **集群部署**:部署多个节点实现高可用性和负载分配,支持会话粘性。参见[集群部署指南](docs/cluster-deployment.zh.md)。
- **Docker 就绪**:提供容器化镜像,快速部署。
## 🔧 快速开始

View File

@@ -1,516 +0,0 @@
# 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)

View File

@@ -1,510 +0,0 @@
# 集群部署指南
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
## 部署场景
### 场景 1Docker 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
```
### 场景 2Kubernetes
创建 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)

View File

@@ -294,22 +294,47 @@ Optional for Smart Routing:
labels:
app: mcphub
spec:
initContainers:
- name: prepare-config
image: busybox:1.28
command:
[
"sh",
"-c",
"cp /config-ro/mcp_settings.json /etc/mcphub/mcp_settings.json",
]
volumeMounts:
- name: config
mountPath: /config-ro
readOnly: true
- name: app-storage
mountPath: /etc/mcphub
containers:
- name: mcphub
image: samanhappy/mcphub:latest
ports:
- containerPort: 3000
env:
- name: PORT
value: "3000"
volumeMounts:
- name: config
mountPath: /app/mcp_settings.json
subPath: mcp_settings.json
- name: mcphub
image: samanhappy/mcphub:latest
ports:
- containerPort: 3000
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
env:
- name: PORT
value: "3000"
- name: MCPHUB_SETTING_PATH
value: /etc/mcphub/mcp_settings.json
volumeMounts:
- name: app-storage
mountPath: /etc/mcphub
volumes:
- name: config
configMap:
name: mcphub-config
- name: config
configMap:
name: mcphub-config
- name: app-storage
emptyDir: {}
```
#### 3. Service

View File

@@ -1,27 +1,5 @@
#!/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"

View File

@@ -1,444 +0,0 @@
# 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
```

View File

@@ -11,7 +11,8 @@ const LanguageSwitch: React.FC = () => {
const availableLanguages = [
{ code: 'en', label: 'English' },
{ code: 'zh', label: '中文' },
{ code: 'fr', label: 'Français' }
{ code: 'fr', label: 'Français' },
{ code: 'tr', label: 'Türkçe' }
];
// Update current language when it changes

View File

@@ -5,7 +5,6 @@ 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;

View File

@@ -34,35 +34,6 @@ 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;
@@ -70,7 +41,6 @@ interface SystemSettings {
smartRouting?: SmartRoutingConfig;
mcpRouter?: MCPRouterConfig;
nameSeparator?: string;
cluster?: ClusterConfig;
};
}
@@ -115,27 +85,6 @@ 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);
@@ -192,28 +141,6 @@ 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');
@@ -493,39 +420,6 @@ 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);
@@ -561,7 +455,6 @@ export const useSettingsData = () => {
installConfig,
smartRoutingConfig,
mcpRouterConfig,
clusterConfig,
nameSeparator,
loading,
error,
@@ -575,7 +468,6 @@ export const useSettingsData = () => {
updateRoutingConfigBatch,
updateMCPRouterConfig,
updateMCPRouterConfigBatch,
updateClusterConfig,
updateNameSeparator,
exportMCPSettings,
};

View File

@@ -6,6 +6,7 @@ import LanguageDetector from 'i18next-browser-languagedetector';
import enTranslation from '../../locales/en.json';
import zhTranslation from '../../locales/zh.json';
import frTranslation from '../../locales/fr.json';
import trTranslation from '../../locales/tr.json';
i18n
// Detect user language
@@ -24,6 +25,9 @@ i18n
fr: {
translation: frTranslation,
},
tr: {
translation: trTranslation,
},
},
fallbackLng: 'en',
debug: process.env.NODE_ENV === 'development',

View File

@@ -1,99 +1,55 @@
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 [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 [tempNameSeparator, setTempNameSeparator] = useState<string>('-')
const {
routingConfig,
@@ -102,7 +58,6 @@ const SettingsPage: React.FC = () => {
installConfig: savedInstallConfig,
smartRoutingConfig,
mcpRouterConfig,
clusterConfig,
nameSeparator,
loading,
updateRoutingConfig,
@@ -111,17 +66,16 @@ 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(() => {
@@ -131,9 +85,9 @@ const SettingsPage: React.FC = () => {
openaiApiBaseUrl: smartRoutingConfig.openaiApiBaseUrl || '',
openaiApiKey: smartRoutingConfig.openaiApiKey || '',
openaiApiEmbeddingModel: smartRoutingConfig.openaiApiEmbeddingModel || '',
});
})
}
}, [smartRoutingConfig]);
}, [smartRoutingConfig])
// Update local tempMCPRouterConfig when mcpRouterConfig changes
useEffect(() => {
@@ -143,53 +97,24 @@ 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]);
// 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]);
setTempNameSeparator(nameSeparator)
}, [nameSeparator])
const [sectionsVisible, setSectionsVisible] = useState({
routingConfig: false,
installConfig: false,
smartRoutingConfig: false,
mcpRouterConfig: false,
clusterConfig: false,
nameSeparator: false,
password: false,
exportConfig: false,
});
})
const toggleSection = (
section:
@@ -197,7 +122,6 @@ const SettingsPage: React.FC = () => {
| 'installConfig'
| 'smartRoutingConfig'
| 'mcpRouterConfig'
| 'clusterConfig'
| 'nameSeparator'
| 'password'
| 'exportConfig',
@@ -205,8 +129,8 @@ const SettingsPage: React.FC = () => {
setSectionsVisible((prev) => ({
...prev,
[section]: !prev[section],
}));
};
}))
}
const handleRoutingConfigChange = async (
key:
@@ -220,39 +144,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',
@@ -261,12 +185,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',
@@ -275,14 +199,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',
@@ -291,141 +215,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">
@@ -639,432 +563,6 @@ 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
@@ -1296,10 +794,7 @@ const SettingsPage: React.FC = () => {
</PermissionChecker>
{/* Change Password */}
<div
className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card"
data-section="password"
>
<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')}
@@ -1369,7 +864,7 @@ const SettingsPage: React.FC = () => {
</div>
</PermissionChecker>
</div>
);
};
)
}
export default SettingsPage;
export default SettingsPage

View File

@@ -574,53 +574,6 @@
"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",

View File

@@ -574,53 +574,6 @@
"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",

747
locales/tr.json Normal file
View File

@@ -0,0 +1,747 @@
{
"app": {
"title": "MCPHub Kontrol Paneli",
"error": "Hata",
"closeButton": "Kapat",
"noServers": "Kullanılabilir MCP sunucusu yok",
"loading": "Yükleniyor...",
"logout": ıkış Yap",
"profile": "Profil",
"changePassword": "Şifre Değiştir",
"toggleSidebar": "Kenar Çubuğunu Aç/Kapat",
"welcomeUser": "Hoş geldin, {{username}}",
"name": "MCPHub"
},
"about": {
"title": "Hakkında",
"versionInfo": "MCPHub Sürümü: {{version}}",
"newVersion": "Yeni sürüm mevcut!",
"currentVersion": "Mevcut sürüm",
"newVersionAvailable": "Yeni sürüm {{version}} mevcut",
"viewOnGitHub": "GitHub'da Görüntüle",
"checkForUpdates": "Güncellemeleri Kontrol Et",
"checking": "Güncellemeler kontrol ediliyor..."
},
"profile": {
"viewProfile": "Profili görüntüle",
"userCenter": "Kullanıcı Merkezi"
},
"sponsor": {
"label": "Sponsor",
"title": "Projeyi Destekle",
"rewardAlt": "Ödül QR Kodu",
"supportMessage": "Bana bir kahve ısmarlayarak MCPHub'ın geliştirilmesini destekleyin!",
"supportButton": "Ko-fi'de Destek Ol"
},
"wechat": {
"label": "WeChat",
"title": "WeChat ile Bağlan",
"qrCodeAlt": "WeChat QR Kodu",
"scanMessage": "WeChat'te bizimle bağlantı kurmak için bu QR kodunu tarayın"
},
"discord": {
"label": "Discord",
"title": "Discord sunucumuza katılın",
"community": "Destek, tartışmalar ve güncellemeler için büyüyen Discord topluluğumuza katılın!"
},
"theme": {
"title": "Tema",
"light": "Açık",
"dark": "Koyu",
"system": "Sistem"
},
"auth": {
"login": "Giriş Yap",
"loginTitle": "MCPHub'a Giriş Yap",
"slogan": "Birleşik MCP sunucu yönetim platformu",
"subtitle": "Model Context Protocol sunucuları için merkezi yönetim platformu. Esnek yönlendirme stratejileri ile birden fazla MCP sunucusunu organize edin, izleyin ve ölçeklendirin.",
"username": "Kullanıcı Adı",
"password": "Şifre",
"loggingIn": "Giriş yapılıyor...",
"emptyFields": "Kullanıcı adı ve şifre boş olamaz",
"loginFailed": "Giriş başarısız, lütfen kullanıcı adınızı ve şifrenizi kontrol edin",
"loginError": "Giriş sırasında bir hata oluştu",
"currentPassword": "Mevcut Şifre",
"newPassword": "Yeni Şifre",
"confirmPassword": "Şifreyi Onayla",
"passwordsNotMatch": "Yeni şifre ve onay eşleşmiyor",
"changePasswordSuccess": "Şifre başarıyla değiştirildi",
"changePasswordError": "Şifre değişikliği başarısız oldu",
"changePassword": "Şifre Değiştir",
"passwordChanged": "Şifre başarıyla değiştirildi",
"passwordChangeError": "Şifre değişikliği başarısız oldu",
"defaultPasswordWarning": "Varsayılan Şifre Güvenlik Uyarısı",
"defaultPasswordMessage": "Varsayılan şifreyi (admin123) kullanıyorsunuz, bu bir güvenlik riski oluşturur. Hesabınızı korumak için lütfen şifrenizi hemen değiştirin.",
"goToSettings": "Ayarlara Git",
"passwordStrengthError": "Şifre güvenlik gereksinimlerini karşılamıyor",
"passwordMinLength": "Şifre en az 8 karakter uzunluğunda olmalıdır",
"passwordRequireLetter": "Şifre en az bir harf içermelidir",
"passwordRequireNumber": "Şifre en az bir rakam içermelidir",
"passwordRequireSpecial": "Şifre en az bir özel karakter içermelidir",
"passwordStrengthHint": "Şifre en az 8 karakter olmalı ve harf, rakam ve özel karakter içermelidir"
},
"server": {
"addServer": "Sunucu Ekle",
"add": "Ekle",
"edit": "Düzenle",
"copy": "Kopyala",
"delete": "Sil",
"confirmDelete": "Bu sunucuyu silmek istediğinizden emin misiniz?",
"deleteWarning": "'{{name}}' sunucusunu silmek, onu ve tüm verilerini kaldıracaktır. Bu işlem geri alınamaz.",
"status": "Durum",
"tools": "Araçlar",
"prompts": "İstekler",
"name": "Sunucu Adı",
"url": "Sunucu URL'si",
"apiKey": "API Anahtarı",
"save": "Kaydet",
"cancel": "İptal",
"invalidConfig": "{{serverName}} için yapılandırma verisi bulunamadı",
"addError": "Sunucu eklenemedi",
"editError": "{{serverName}} sunucusu düzenlenemedi",
"deleteError": "{{serverName}} sunucusu silinemedi",
"updateError": "Sunucu güncellenemedi",
"editTitle": "Sunucuyu Düzenle: {{serverName}}",
"type": "Sunucu Türü",
"typeStdio": "STDIO",
"typeSse": "SSE",
"typeStreamableHttp": "Akış Yapılabilir HTTP",
"typeOpenapi": "OpenAPI",
"command": "Komut",
"arguments": "Argümanlar",
"envVars": "Ortam Değişkenleri",
"headers": "HTTP Başlıkları",
"key": "anahtar",
"value": "değer",
"enabled": "Etkin",
"enable": "Etkinleştir",
"disable": "Devre Dışı Bırak",
"requestOptions": "Bağlantı Yapılandırması",
"timeout": "İstek Zaman Aşımı",
"timeoutDescription": "MCP sunucusuna yapılan istekler için zaman aşımı (ms)",
"maxTotalTimeout": "Maksimum Toplam Zaman Aşımı",
"maxTotalTimeoutDescription": "MCP sunucusuna gönderilen istekler için maksimum toplam zaman aşımı (ms) (İlerleme bildirimleriyle kullanın)",
"resetTimeoutOnProgress": "İlerlemede Zaman Aşımını Sıfırla",
"resetTimeoutOnProgressDescription": "İlerleme bildirimlerinde zaman aşımını sıfırla",
"remove": "Kaldır",
"toggleError": "{{serverName}} sunucusu açılamadı/kapatılamadı",
"alreadyExists": "{{serverName}} sunucusu zaten mevcut",
"invalidData": "Geçersiz sunucu verisi sağlandı",
"notFound": "{{serverName}} sunucusu bulunamadı",
"namePlaceholder": "Sunucu adını girin",
"urlPlaceholder": "Sunucu URL'sini girin",
"commandPlaceholder": "Komutu girin",
"argumentsPlaceholder": "Argümanları girin",
"errorDetails": "Hata Detayları",
"viewErrorDetails": "Hata detaylarını görüntüle",
"copyConfig": "Yapılandırmayı Kopyala",
"confirmVariables": "Değişken Yapılandırmasını Onayla",
"variablesDetected": "Yapılandırmada değişkenler algılandı. Lütfen bu değişkenlerin düzgün yapılandırıldığını onaylayın:",
"detectedVariables": "Algılanan Değişkenler",
"confirmVariablesMessage": "Lütfen bu değişkenlerin çalışma ortamınızda düzgün tanımlandığından emin olun. Sunucu eklemeye devam edilsin mi?",
"confirmAndAdd": "Onayla ve Ekle",
"openapi": {
"inputMode": "Giriş Modu",
"inputModeUrl": "Şartname URL'si",
"inputModeSchema": "JSON Şeması",
"specUrl": "OpenAPI Şartname URL'si",
"schema": "OpenAPI JSON Şeması",
"schemaHelp": "Eksiksiz OpenAPI JSON şemanızı buraya yapıştırın",
"security": "Güvenlik Türü",
"securityNone": "Yok",
"securityApiKey": "API Anahtarı",
"securityHttp": "HTTP Kimlik Doğrulaması",
"securityOAuth2": "OAuth 2.0",
"securityOpenIdConnect": "OpenID Connect",
"apiKeyConfig": "API Anahtarı Yapılandırması",
"apiKeyName": "Başlık/Parametre Adı",
"apiKeyIn": "Konum",
"apiKeyValue": "API Anahtarı Değeri",
"httpAuthConfig": "HTTP Kimlik Doğrulama Yapılandırması",
"httpScheme": "Kimlik Doğrulama Şeması",
"httpCredentials": "Kimlik Bilgileri",
"httpSchemeBasic": "Basit",
"httpSchemeBearer": "Bearer",
"httpSchemeDigest": "Digest",
"oauth2Config": "OAuth 2.0 Yapılandırması",
"oauth2Token": "Erişim Anahtarı",
"openIdConnectConfig": "OpenID Connect Yapılandırması",
"openIdConnectUrl": "URL'yi Keşfet",
"openIdConnectToken": "ID Token",
"apiKeyInHeader": "Başlık",
"apiKeyInQuery": "Sorgu",
"apiKeyInCookie": "Çerez",
"passthroughHeaders": "Geçiş Başlıkları",
"passthroughHeadersHelp": "Araç çağrısı isteklerinden yukarı akış OpenAPI uç noktalarına geçirilecek başlık adlarının virgülle ayrılmış listesi (örn. Authorization, X-API-Key)"
},
"oauth": {
"sectionTitle": "OAuth Yapılandırması",
"sectionDescription": "OAuth korumalı sunucular için istemci kimlik bilgilerini yapılandırın (isteğe bağlı).",
"clientId": "İstemci ID",
"clientSecret": "İstemci Gizli Anahtarı",
"authorizationEndpoint": "Yetkilendirme Uç Noktası",
"tokenEndpoint": "Token Uç Noktası",
"scopes": "Kapsamlar",
"scopesPlaceholder": "scope1 scope2",
"resource": "Kaynak / Hedef Kitle",
"accessToken": "Erişim Tokeni",
"refreshToken": "Yenileme Tokeni"
}
},
"status": {
"online": "Çevrimiçi",
"offline": "Çevrimdışı",
"connecting": "Bağlanıyor",
"oauthRequired": "OAuth Gerekli",
"clickToAuthorize": "OAuth ile yetkilendirmek için tıklayın",
"oauthWindowOpened": "OAuth yetkilendirme penceresi açıldı. Lütfen yetkilendirmeyi tamamlayın."
},
"errors": {
"general": "Bir şeyler yanlış gitti",
"network": "Ağ bağlantı hatası. Lütfen internet bağlantınızı kontrol edin",
"serverConnection": "Sunucuya bağlanılamıyor. Lütfen sunucunun çalışıp çalışmadığını kontrol edin",
"serverAdd": "Sunucu eklenemedi. Lütfen sunucu durumunu kontrol edin",
"serverUpdate": "{{serverName}} sunucusu düzenlenemedi. Lütfen sunucu durumunu kontrol edin",
"serverFetch": "Sunucu verileri alınamadı. Lütfen daha sonra tekrar deneyin",
"initialStartup": "Sunucu başlatılıyor olabilir. İlk başlatmada bu işlem biraz zaman alabileceğinden lütfen bekleyin...",
"serverInstall": "Sunucu yüklenemedi",
"failedToFetchSettings": "Ayarlar getirilemedi",
"failedToUpdateRouteConfig": "Route yapılandırması güncellenemedi",
"failedToUpdateSmartRoutingConfig": "Akıllı yönlendirme yapılandırması güncellenemedi"
},
"common": {
"processing": "İşleniyor...",
"save": "Kaydet",
"cancel": "İptal",
"back": "Geri",
"refresh": "Yenile",
"create": "Oluştur",
"creating": "Oluşturuluyor...",
"update": "Güncelle",
"updating": "Güncelleniyor...",
"submitting": "Gönderiliyor...",
"delete": "Sil",
"remove": "Kaldır",
"copy": "Kopyala",
"copyId": "ID'yi Kopyala",
"copyUrl": "URL'yi Kopyala",
"copyJson": "JSON'u Kopyala",
"copySuccess": "Panoya kopyalandı",
"copyFailed": "Kopyalama başarısız",
"copied": "Kopyalandı",
"close": "Kapat",
"confirm": "Onayla",
"language": "Dil",
"true": "Doğru",
"false": "Yanlış",
"dismiss": "Anımsatma",
"github": "GitHub",
"wechat": "WeChat",
"discord": "Discord",
"required": "Gerekli",
"secret": "Gizli",
"default": "Varsayılan",
"value": "Değer",
"type": "Tür",
"repeated": "Tekrarlanan",
"valueHint": "Değer İpucu",
"choices": "Seçenekler"
},
"nav": {
"dashboard": "Kontrol Paneli",
"servers": "Sunucular",
"groups": "Gruplar",
"users": "Kullanıcılar",
"settings": "Ayarlar",
"changePassword": "Şifre Değiştir",
"market": "Market",
"cloud": "Bulut Market",
"logs": "Günlükler"
},
"pages": {
"dashboard": {
"title": "Kontrol Paneli",
"totalServers": "Toplam",
"onlineServers": "Çevrimiçi",
"offlineServers": "Çevrimdışı",
"connectingServers": "Bağlanıyor",
"recentServers": "Son Sunucular"
},
"servers": {
"title": "Sunucu Yönetimi"
},
"groups": {
"title": "Grup Yönetimi"
},
"users": {
"title": "Kullanıcı Yönetimi"
},
"settings": {
"title": "Ayarlar",
"language": "Dil",
"account": "Hesap Ayarları",
"password": "Şifre Değiştir",
"appearance": "Görünüm",
"routeConfig": "Güvenlik",
"installConfig": "Kurulum",
"smartRouting": "Akıllı Yönlendirme"
},
"market": {
"title": "Market Yönetimi - Yerel ve Bulut Marketler"
},
"logs": {
"title": "Sistem Günlükleri"
}
},
"logs": {
"filters": "Filtreler",
"search": "Günlüklerde ara...",
"autoScroll": "Otomatik kaydır",
"clearLogs": "Günlükleri temizle",
"loading": "Günlükler yükleniyor...",
"noLogs": "Kullanılabilir günlük yok.",
"noMatch": "Mevcut filtrelerle eşleşen günlük yok.",
"mainProcess": "Ana İşlem",
"childProcess": "Alt İşlem",
"main": "Ana",
"child": "Alt"
},
"groups": {
"add": "Ekle",
"addNew": "Yeni Grup Ekle",
"edit": "Grubu Düzenle",
"delete": "Sil",
"confirmDelete": "Bu grubu silmek istediğinizden emin misiniz?",
"deleteWarning": "'{{name}}' grubunu silmek, onu ve tüm sunucu ilişkilerini kaldıracaktır. Bu işlem geri alınamaz.",
"name": "Grup Adı",
"namePlaceholder": "Grup adını girin",
"nameRequired": "Grup adı gereklidir",
"description": "Açıklama",
"descriptionPlaceholder": "Grup açıklamasını girin (isteğe bağlı)",
"createError": "Grup oluşturulamadı",
"updateError": "Grup güncellenemedi",
"deleteError": "Grup silinemedi",
"serverAddError": "Sunucu gruba eklenemedi",
"serverRemoveError": "Sunucu gruptan kaldırılamadı",
"addServer": "Gruba Sunucu Ekle",
"selectServer": "Eklenecek bir sunucu seçin",
"servers": "Gruptaki Sunucular",
"remove": "Kaldır",
"noGroups": "Kullanılabilir grup yok. Başlamak için yeni bir grup oluşturun.",
"noServers": "Bu grupta sunucu yok.",
"noServerOptions": "Kullanılabilir sunucu yok",
"serverCount": "{{count}} Sunucu",
"toolSelection": "Araç Seçimi",
"toolsSelected": "Seçildi",
"allTools": "Tümü",
"selectedTools": "Seçili araçlar",
"selectAll": "Tümünü Seç",
"selectNone": "Hiçbirini Seçme",
"configureTools": "Araçları Yapılandır"
},
"market": {
"title": "Yerel Kurulum",
"official": "Resmi",
"by": "Geliştirici",
"unknown": "Bilinmeyen",
"tools": "araçlar",
"search": "Ara",
"searchPlaceholder": "Sunucuları isme, kategoriye veya etiketlere göre ara",
"clearFilters": "Temizle",
"clearCategoryFilter": "",
"clearTagFilter": "",
"categories": "Kategoriler",
"tags": "Etiketler",
"showTags": "Etiketleri göster",
"hideTags": "Etiketleri gizle",
"moreTags": "",
"noServers": "Aramanızla eşleşen sunucu bulunamadı",
"backToList": "Listeye dön",
"install": "Yükle",
"installing": "Yükleniyor...",
"installed": "Yüklendi",
"installServer": "Sunucu Yükle: {{name}}",
"installSuccess": "{{serverName}} sunucusu başarıyla yüklendi",
"author": "Yazar",
"license": "Lisans",
"repository": "Depo",
"examples": "Örnekler",
"arguments": "Argümanlar",
"argumentName": "Ad",
"description": "Açıklama",
"required": "Gerekli",
"example": "Örnek",
"viewSchema": "Şemayı görüntüle",
"fetchError": "Market sunucuları getirilirken hata",
"serverNotFound": "Sunucu bulunamadı",
"searchError": "Sunucular aranırken hata",
"filterError": "Sunucular kategoriye göre filtrelenirken hata",
"tagFilterError": "Sunucular etikete göre filtrelenirken hata",
"noInstallationMethod": "Bu sunucu için kullanılabilir kurulum yöntemi yok",
"showing": "{{total}} sunucudan {{from}}-{{to}} arası gösteriliyor",
"perPage": "Sayfa başına",
"confirmVariablesMessage": "Lütfen bu değişkenlerin çalışma ortamınızda düzgün tanımlandığından emin olun. Sunucu yüklemeye devam edilsin mi?",
"confirmAndInstall": "Onayla ve Yükle"
},
"cloud": {
"title": "Bulut Desteği",
"subtitle": "MCPRouter tarafından desteklenmektedir",
"by": "Geliştirici",
"server": "Sunucu",
"config": "Yapılandırma",
"created": "Oluşturuldu",
"updated": "Güncellendi",
"available": "Kullanılabilir",
"description": "Açıklama",
"details": "Detaylar",
"tools": "Araçlar",
"tool": "araç",
"toolsAvailable": "{{count}} araç mevcut",
"loadingTools": "Araçlar yükleniyor...",
"noTools": "Bu sunucu için kullanılabilir araç yok",
"noDescription": "Kullanılabilir açıklama yok",
"viewDetails": "Detayları Görüntüle",
"parameters": "Parametreler",
"result": "Sonuç",
"error": "Hata",
"callTool": "Çalıştır",
"calling": "Çalıştırılıyor...",
"toolCallSuccess": "{{toolName}} aracı başarıyla çalıştırıldı",
"toolCallError": "{{toolName}} aracı çalıştırılamadı: {{error}}",
"viewSchema": "Şemayı Görüntüle",
"backToList": "Bulut Market'e Dön",
"search": "Ara",
"searchPlaceholder": "Bulut sunucularını isme, başlığa veya geliştiriciye göre ara",
"clearFilters": "Filtreleri Temizle",
"clearCategoryFilter": "Temizle",
"clearTagFilter": "Temizle",
"categories": "Kategoriler",
"tags": "Etiketler",
"noCategories": "Kategori bulunamadı",
"noTags": "Etiket bulunamadı",
"noServers": "Bulut sunucusu bulunamadı",
"fetchError": "Bulut sunucuları getirilirken hata",
"serverNotFound": "Bulut sunucusu bulunamadı",
"searchError": "Bulut sunucuları aranırken hata",
"filterError": "Bulut sunucuları kategoriye göre filtrelenirken hata",
"tagFilterError": "Bulut sunucuları etikete göre filtrelenirken hata",
"showing": "{{total}} bulut sunucusundan {{from}}-{{to}} arası gösteriliyor",
"perPage": "Sayfa başına",
"apiKeyNotConfigured": "MCPRouter API anahtarı yapılandırılmamış",
"apiKeyNotConfiguredDescription": "Bulut sunucularını kullanmak için MCPRouter API anahtarınızı yapılandırmanız gerekir.",
"getApiKey": "API Anahtarı Al",
"configureInSettings": "Ayarlarda Yapılandır",
"installServer": "{{name}} Yükle",
"installSuccess": "{{name}} sunucusu başarıyla yüklendi",
"installError": "Sunucu yüklenemedi: {{error}}"
},
"registry": {
"title": "Kayıt",
"official": "Resmi",
"latest": "En Son",
"description": "Açıklama",
"website": "Web Sitesi",
"repository": "Depo",
"packages": "Paketler",
"package": "paket",
"remotes": "Uzak Sunucular",
"remote": "uzak sunucu",
"published": "Yayınlandı",
"updated": "Güncellendi",
"install": "Yükle",
"installing": "Yükleniyor...",
"installed": "Yüklendi",
"installServer": "{{name}} Yükle",
"installSuccess": "{{name}} sunucusu başarıyla yüklendi",
"installError": "Sunucu yüklenemedi: {{error}}",
"noDescription": "Kullanılabilir açıklama yok",
"viewDetails": "Detayları Görüntüle",
"backToList": "Kayda Dön",
"search": "Ara",
"searchPlaceholder": "Kayıt sunucularını isme göre ara",
"clearFilters": "Temizle",
"noServers": "Kayıt sunucusu bulunamadı",
"fetchError": "Kayıt sunucuları getirilirken hata",
"serverNotFound": "Kayıt sunucusu bulunamadı",
"showing": "{{total}} kayıt sunucusundan {{from}}-{{to}} arası gösteriliyor",
"perPage": "Sayfa başına",
"environmentVariables": "Ortam Değişkenleri",
"packageArguments": "Paket Argümanları",
"runtimeArguments": "Çalışma Zamanı Argümanları",
"headers": "Başlıklar"
},
"tool": {
"run": "Çalıştır",
"running": "Çalıştırılıyor...",
"runTool": "Aracı Çalıştır",
"cancel": "İptal",
"noDescription": "Kullanılabilir açıklama yok",
"inputSchema": "Giriş Şeması:",
"runToolWithName": "Aracı Çalıştır: {{name}}",
"execution": "Araç Çalıştırma",
"successful": "Başarılı",
"failed": "Başarısız",
"result": "Sonuç:",
"error": "Hata",
"errorDetails": "Hata Detayları:",
"noContent": "Araç başarıyla çalıştırıldı ancak içerik döndürmedi.",
"unknownError": "Bilinmeyen hata oluştu",
"jsonResponse": "JSON Yanıtı:",
"toolResult": "Araç sonucu",
"noParameters": "Bu araç herhangi bir parametre gerektirmez.",
"selectOption": "Bir seçenek seçin",
"enterValue": "{{type}} değeri girin",
"enabled": "Etkin",
"enableSuccess": "{{name}} aracı başarıyla etkinleştirildi",
"disableSuccess": "{{name}} aracı başarıyla devre dışı bırakıldı",
"toggleFailed": "Araç durumu değiştirilemedi",
"parameters": "Araç Parametreleri",
"formMode": "Form Modu",
"jsonMode": "JSON Modu",
"jsonConfiguration": "JSON Yapılandırması",
"invalidJsonFormat": "Geçersiz JSON formatı",
"fixJsonBeforeSwitching": "Form moduna geçmeden önce lütfen JSON formatını düzeltin",
"item": "Öğe {{index}}",
"addItem": "{{key}} öğesi ekle",
"enterKey": "{{key}} girin"
},
"prompt": {
"run": "Getir",
"running": "Getiriliyor...",
"result": "İstek Sonucu",
"error": "İstek Hatası",
"execution": "İstek Çalıştırma",
"successful": "Başarılı",
"failed": "Başarısız",
"errorDetails": "Hata Detayları:",
"noContent": "İstek başarıyla çalıştırıldı ancak içerik döndürmedi.",
"unknownError": "Bilinmeyen hata oluştu",
"jsonResponse": "JSON Yanıtı:",
"description": "Açıklama",
"messages": "Mesajlar",
"noDescription": "Kullanılabilir açıklama yok",
"runPromptWithName": "İsteği Getir: {{name}}"
},
"settings": {
"enableGlobalRoute": "Global Yönlendirmeyi Etkinleştir",
"enableGlobalRouteDescription": "Grup ID'si belirtmeden /sse uç noktasına bağlantıya izin ver",
"enableGroupNameRoute": "Grup Adı Yönlendirmeyi Etkinleştir",
"enableGroupNameRouteDescription": "Sadece grup ID'leri yerine grup adları kullanarak /sse uç noktasına bağlantıya izin ver",
"enableBearerAuth": "Bearer Kimlik Doğrulamasını Etkinleştir",
"enableBearerAuthDescription": "MCP istekleri için bearer token kimlik doğrulaması gerektir",
"bearerAuthKey": "Bearer Kimlik Doğrulama Anahtarı",
"bearerAuthKeyDescription": "Bearer token'da gerekli olacak kimlik doğrulama anahtarı",
"bearerAuthKeyPlaceholder": "Bearer kimlik doğrulama anahtarını girin",
"skipAuth": "Kimlik Doğrulamayı Atla",
"skipAuthDescription": "Arayüz ve API erişimi için giriş gereksinimini atla (Güvenlik için VARSAYILAN KAPALI)",
"pythonIndexUrl": "Python Paket Deposu URL'si",
"pythonIndexUrlDescription": "Python paket kurulumu için UV_DEFAULT_INDEX ortam değişkenini ayarla",
"pythonIndexUrlPlaceholder": "örn. https://pypi.org/simple",
"npmRegistry": "NPM Kayıt URL'si",
"npmRegistryDescription": "NPM paket kurulumu için npm_config_registry ortam değişkenini ayarla",
"npmRegistryPlaceholder": "örn. https://registry.npmjs.org/",
"baseUrl": "Temel URL",
"baseUrlDescription": "MCP istekleri için temel URL",
"baseUrlPlaceholder": "örn. http://localhost:3000",
"installConfig": "Kurulum",
"systemConfigUpdated": "Sistem yapılandırması başarıyla güncellendi",
"enableSmartRouting": "Akıllı Yönlendirmeyi Etkinleştir",
"enableSmartRoutingDescription": "Girdiye göre en uygun aracı aramak için akıllı yönlendirme özelliğini etkinleştir ($smart grup adını kullanarak)",
"dbUrl": "PostgreSQL URL'si (pgvector desteği gerektirir)",
"dbUrlPlaceholder": "örn. postgresql://kullanıcı:şifre@localhost:5432/veritabanıadı",
"openaiApiBaseUrl": "OpenAI API Temel URL'si",
"openaiApiBaseUrlPlaceholder": "https://api.openai.com/v1",
"openaiApiKey": "OpenAI API Anahtarı",
"openaiApiKeyPlaceholder": "OpenAI API anahtarını girin",
"openaiApiEmbeddingModel": "OpenAI Entegrasyon Modeli",
"openaiApiEmbeddingModelPlaceholder": "text-embedding-3-small",
"smartRoutingConfigUpdated": "Akıllı yönlendirme yapılandırması başarıyla güncellendi",
"smartRoutingRequiredFields": "Akıllı yönlendirmeyi etkinleştirmek için Veritabanı URL'si ve OpenAI API Anahtarı gereklidir",
"smartRoutingValidationError": "Akıllı Yönlendirmeyi etkinleştirmeden önce lütfen gerekli alanları doldurun: {{fields}}",
"mcpRouterConfig": "Bulut Market",
"mcpRouterApiKey": "MCPRouter API Anahtarı",
"mcpRouterApiKeyDescription": "MCPRouter bulut market hizmetlerine erişim için API anahtarı",
"mcpRouterApiKeyPlaceholder": "MCPRouter API anahtarını girin",
"mcpRouterReferer": "Yönlendiren",
"mcpRouterRefererDescription": "MCPRouter API istekleri için Referer başlığı",
"mcpRouterRefererPlaceholder": "https://www.mcphubx.com",
"mcpRouterTitle": "Başlık",
"mcpRouterTitleDescription": "MCPRouter API istekleri için Başlık başlığı",
"mcpRouterTitlePlaceholder": "MCPHub",
"mcpRouterBaseUrl": "Temel URL",
"mcpRouterBaseUrlDescription": "MCPRouter API için temel URL",
"mcpRouterBaseUrlPlaceholder": "https://api.mcprouter.to/v1",
"systemSettings": "Sistem Ayarları",
"nameSeparatorLabel": "İsim Ayırıcı",
"nameSeparatorDescription": "Sunucu adı ile araç/istek adını ayırmak için kullanılan karakter (varsayılan: -)",
"restartRequired": "Yapılandırma kaydedildi. Tüm hizmetlerin yeni ayarları doğru şekilde yüklemesini sağlamak için uygulamayı yeniden başlatmanız önerilir.",
"exportMcpSettings": "Ayarları Dışa Aktar",
"mcpSettingsJson": "MCP Ayarları JSON",
"mcpSettingsJsonDescription": "Yedekleme veya diğer araçlara taşıma için mevcut mcp_settings.json yapılandırmanızı görüntüleyin, kopyalayın veya indirin",
"copyToClipboard": "Panoya Kopyala",
"downloadJson": "JSON Olarak İndir",
"exportSuccess": "Ayarlar başarıyla dışa aktarıldı",
"exportError": "Ayarlar getirilemedi"
},
"dxt": {
"upload": "Yükle",
"uploadTitle": "DXT Uzantısı Yükle",
"dropFileHere": ".dxt dosyanızı buraya bırakın",
"orClickToSelect": "veya bilgisayarınızdan seçmek için tıklayın",
"invalidFileType": "Lütfen geçerli bir .dxt dosyası seçin",
"noFileSelected": "Lütfen yüklemek için bir .dxt dosyası seçin",
"uploading": "Yükleniyor...",
"uploadFailed": "DXT dosyası yüklenemedi",
"installServer": "DXT'den MCP Sunucusu Yükle",
"extensionInfo": "Uzantı Bilgisi",
"name": "Ad",
"version": "Sürüm",
"description": "Açıklama",
"author": "Geliştirici",
"tools": "Araçlar",
"serverName": "Sunucu Adı",
"serverNamePlaceholder": "Bu sunucu için bir ad girin",
"install": "Yükle",
"installing": "Yükleniyor...",
"installFailed": "DXT'den sunucu yüklenemedi",
"serverExistsTitle": "Sunucu Zaten Mevcut",
"serverExistsConfirm": "'{{serverName}}' sunucusu zaten mevcut. Yeni sürümle geçersiz kılmak istiyor musunuz?",
"override": "Geçersiz Kıl"
},
"jsonImport": {
"button": "İçe Aktar",
"title": "JSON'dan Sunucuları İçe Aktar",
"inputLabel": "Sunucu Yapılandırma JSON",
"inputHelp": "Sunucu yapılandırma JSON'unuzu yapıştırın. STDIO, SSE ve HTTP (streamable-http) sunucu türlerini destekler.",
"preview": "Önizle",
"previewTitle": "İçe Aktarılacak Sunucuları Önizle",
"import": "İçe Aktar",
"importing": "İçe aktarılıyor...",
"invalidFormat": "Geçersiz JSON formatı. JSON bir 'mcpServers' nesnesi içermelidir.",
"parseError": "JSON ayrıştırılamadı. Lütfen formatı kontrol edip tekrar deneyin.",
"addFailed": "Sunucu eklenemedi",
"importFailed": "Sunucular içe aktarılamadı",
"partialSuccess": "{{total}} sunucudan {{count}} tanesi başarıyla içe aktarıldı. Bazı sunucular başarısız oldu:"
},
"users": {
"add": "Kullanıcı Ekle",
"addNew": "Yeni Kullanıcı Ekle",
"edit": "Kullanıcıyı Düzenle",
"delete": "Kullanıcıyı Sil",
"create": "Kullanıcı Oluştur",
"update": "Kullanıcıyı Güncelle",
"username": "Kullanıcı Adı",
"password": "Şifre",
"newPassword": "Yeni Şifre",
"confirmPassword": "Şifreyi Onayla",
"adminRole": "Yönetici",
"admin": "Yönetici",
"user": "Kullanıcı",
"permissions": "İzinler",
"adminPermissions": "Tam sistem erişimi",
"userPermissions": "Sınırlı erişim",
"currentUser": "Siz",
"noUsers": "Kullanıcı bulunamadı",
"adminRequired": "Kullanıcıları yönetmek için yönetici erişimi gereklidir",
"usernameRequired": "Kullanıcı adı gereklidir",
"passwordRequired": "Şifre gereklidir",
"passwordTooShort": "Şifre en az 6 karakter uzunluğunda olmalıdır",
"passwordMismatch": "Şifreler eşleşmiyor",
"usernamePlaceholder": "Kullanıcı adını girin",
"passwordPlaceholder": "Şifreyi girin",
"newPasswordPlaceholder": "Mevcut şifreyi korumak için boş bırakın",
"confirmPasswordPlaceholder": "Yeni şifreyi onaylayın",
"createError": "Kullanıcı oluşturulamadı",
"updateError": "Kullanıcı güncellenemedi",
"deleteError": "Kullanıcı silinemedi",
"statsError": "Kullanıcı istatistikleri getirilemedi",
"deleteConfirmation": "'{{username}}' kullanıcısını silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
"confirmDelete": "Kullanıcıyı Sil",
"deleteWarning": "'{{username}}' kullanıcısını silmek istediğinizden emin misiniz? Bu işlem geri alınamaz."
},
"api": {
"errors": {
"readonly": "Demo ortamı için salt okunur",
"invalid_credentials": "Geçersiz kullanıcı adı veya şifre",
"serverNameRequired": "Sunucu adı gereklidir",
"serverConfigRequired": "Sunucu yapılandırması gereklidir",
"serverConfigInvalid": "Sunucu yapılandırması bir URL, OpenAPI şartname URL'si veya şema, ya da argümanlı komut içermelidir",
"serverTypeInvalid": "Sunucu türü şunlardan biri olmalıdır: stdio, sse, streamable-http, openapi",
"urlRequiredForType": "{{type}} sunucu türü için URL gereklidir",
"openapiSpecRequired": "OpenAPI sunucu türü için OpenAPI şartname URL'si veya şema gereklidir",
"headersInvalidFormat": "Başlıklar bir nesne olmalıdır",
"headersNotSupportedForStdio": "Başlıklar stdio sunucu türü için desteklenmez",
"serverNotFound": "Sunucu bulunamadı",
"failedToRemoveServer": "Sunucu bulunamadı veya kaldırılamadı",
"internalServerError": "Dahili sunucu hatası",
"failedToGetServers": "Sunucu bilgileri alınamadı",
"failedToGetServerSettings": "Sunucu ayarları alınamadı",
"failedToGetServerConfig": "Sunucu yapılandırması alınamadı",
"failedToSaveSettings": "Ayarlar kaydedilemedi",
"toolNameRequired": "Sunucu adı ve araç adı gereklidir",
"descriptionMustBeString": "Açıklama bir string olmalıdır",
"groupIdRequired": "Grup ID gereklidir",
"groupNameRequired": "Grup adı gereklidir",
"groupNotFound": "Grup bulunamadı",
"groupIdAndServerNameRequired": "Grup ID ve sunucu adı gereklidir",
"groupOrServerNotFound": "Grup veya sunucu bulunamadı",
"toolsMustBeAllOrArray": "Araçlar \"all\" veya bir string dizisi olmalıdır",
"serverNameAndToolNameRequired": "Sunucu adı ve araç adı gereklidir",
"usernameRequired": "Kullanıcı adı gereklidir",
"userNotFound": "Kullanıcı bulunamadı",
"failedToGetUsers": "Kullanıcı bilgileri alınamadı",
"failedToGetUserInfo": "Kullanıcı bilgisi alınamadı",
"failedToGetUserStats": "Kullanıcı istatistikleri alınamadı",
"marketServerNameRequired": "Sunucu adı gereklidir",
"marketServerNotFound": "Market sunucusu bulunamadı",
"failedToGetMarketServers": "Market sunucuları bilgisi alınamadı",
"failedToGetMarketServer": "Market sunucusu bilgisi alınamadı",
"failedToGetMarketCategories": "Market kategorileri alınamadı",
"failedToGetMarketTags": "Market etiketleri alınamadı",
"failedToSearchMarketServers": "Market sunucuları aranamadı",
"failedToFilterMarketServers": "Market sunucuları filtrelenemedi",
"failedToProcessDxtFile": "DXT dosyası işlenemedi"
},
"success": {
"serverCreated": "Sunucu başarıyla oluşturuldu",
"serverUpdated": "Sunucu başarıyla güncellendi",
"serverRemoved": "Sunucu başarıyla kaldırıldı",
"serverToggled": "Sunucu durumu başarıyla değiştirildi",
"toolToggled": "{{name}} aracı başarıyla {{action}}",
"toolDescriptionUpdated": "{{name}} aracının açıklaması başarıyla güncellendi",
"systemConfigUpdated": "Sistem yapılandırması başarıyla güncellendi",
"groupCreated": "Grup başarıyla oluşturuldu",
"groupUpdated": "Grup başarıyla güncellendi",
"groupDeleted": "Grup başarıyla silindi",
"serverAddedToGroup": "Sunucu başarıyla gruba eklendi",
"serverRemovedFromGroup": "Sunucu başarıyla gruptan kaldırıldı",
"serverToolsUpdated": "Sunucu araçları başarıyla güncellendi"
}
},
"oauthCallback": {
"authorizationFailed": "Yetkilendirme Başarısız",
"authorizationFailedError": "Hata",
"authorizationFailedDetails": "Detaylar",
"invalidRequest": "Geçersiz İstek",
"missingStateParameter": "Gerekli OAuth durum parametresi eksik.",
"missingCodeParameter": "Gerekli yetkilendirme kodu parametresi eksik.",
"serverNotFound": "Sunucu Bulunamadı",
"serverNotFoundMessage": "Bu yetkilendirme isteğiyle ilişkili sunucu bulunamadı.",
"sessionExpiredMessage": "Yetkilendirme oturumunun süresi dolmuş olabilir. Lütfen tekrar yetkilendirmeyi deneyin.",
"authorizationSuccessful": "Yetkilendirme Başarılı",
"server": "Sunucu",
"status": "Durum",
"connected": "Bağlandı",
"successMessage": "Sunucu başarıyla yetkilendirildi ve bağlandı.",
"autoCloseMessage": "Bu pencere 3 saniye içinde otomatik olarak kapanacak...",
"closeNow": "Şimdi Kapat",
"connectionError": "Bağlantı Hatası",
"connectionErrorMessage": "Yetkilendirme başarılı oldu, ancak sunucuya bağlanılamadı.",
"reconnectMessage": "Lütfen kontrol panelinden yeniden bağlanmayı deneyin.",
"configurationError": "Yapılandırma Hatası",
"configurationErrorMessage": "Sunucu aktarımı OAuth finishAuth() desteklemiyor. Lütfen sunucunun streamable-http aktarımıyla yapılandırıldığından emin olun.",
"internalError": "İçsel Hata",
"internalErrorMessage": "OAuth geri araması işlenirken beklenmeyen bir hata oluştu.",
"closeWindow": "Pencereyi Kapat"
}
}

View File

@@ -576,53 +576,6 @@
"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",

View File

@@ -64,7 +64,7 @@
"i18next-fs-backend": "^2.6.0",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2",
"openai": "^4.104.0",
"openai": "^6.7.0",
"openapi-types": "^12.1.3",
"openid-client": "^6.8.1",
"pg": "^8.16.3",
@@ -105,12 +105,12 @@
"jest": "^30.2.0",
"jest-environment-node": "^30.0.5",
"jest-mock-extended": "4.0.0",
"lucide-react": "^0.486.0",
"lucide-react": "^0.552.0",
"next": "^15.5.0",
"postcss": "^8.5.6",
"prettier": "^3.6.2",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react": "19.1.1",
"react-dom": "19.1.1",
"react-i18next": "^15.7.2",
"react-router-dom": "^7.8.2",
"supertest": "^7.1.4",

353
pnpm-lock.yaml generated
View File

@@ -35,7 +35,7 @@ importers:
version: 0.5.16
axios:
specifier: ^1.12.2
version: 1.12.2
version: 1.13.1
bcrypt:
specifier: ^6.0.0
version: 6.0.0
@@ -59,7 +59,7 @@ importers:
version: 7.2.1
i18next:
specifier: ^25.5.0
version: 25.5.0(typescript@5.9.2)
version: 25.6.0(typescript@5.9.2)
i18next-fs-backend:
specifier: ^2.6.0
version: 2.6.0
@@ -70,8 +70,8 @@ importers:
specifier: ^2.0.2
version: 2.0.2
openai:
specifier: ^4.104.0
version: 4.104.0(zod@3.25.76)
specifier: ^6.7.0
version: 6.7.0(zod@3.25.76)
openapi-types:
specifier: ^12.1.3
version: 12.1.3
@@ -99,10 +99,10 @@ importers:
devDependencies:
'@radix-ui/react-accordion':
specifier: ^1.2.12
version: 1.2.12(@types/react-dom@19.1.7(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
version: 1.2.12(@types/react-dom@19.1.7(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-slot':
specifier: ^1.2.3
version: 1.2.3(@types/react@19.1.11)(react@19.1.1)
version: 1.2.3(@types/react@19.2.2)(react@19.1.1)
'@shadcn/ui':
specifier: ^0.0.4
version: 0.0.4
@@ -141,10 +141,10 @@ importers:
version: 24.6.2
'@types/react':
specifier: ^19.1.11
version: 19.1.11
version: 19.2.2
'@types/react-dom':
specifier: ^19.1.7
version: 19.1.7(@types/react@19.1.11)
version: 19.1.7(@types/react@19.2.2)
'@types/supertest':
specifier: ^6.0.3
version: 6.0.3
@@ -188,8 +188,8 @@ importers:
specifier: 4.0.0
version: 4.0.0(@jest/globals@30.2.0)(jest@30.2.0(@types/node@24.6.2)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@24.6.2)(typescript@5.9.2)))(typescript@5.9.2)
lucide-react:
specifier: ^0.486.0
version: 0.486.0(react@19.1.1)
specifier: ^0.552.0
version: 0.552.0(react@19.1.1)
next:
specifier: ^15.5.0
version: 15.5.2(@babel/core@7.28.4)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
@@ -200,14 +200,14 @@ importers:
specifier: ^3.6.2
version: 3.6.2
react:
specifier: ^19.1.1
specifier: 19.1.1
version: 19.1.1
react-dom:
specifier: ^19.1.1
specifier: 19.1.1
version: 19.1.1(react@19.1.1)
react-i18next:
specifier: ^15.7.2
version: 15.7.2(i18next@25.5.0(typescript@5.9.2))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)
version: 15.7.2(i18next@25.6.0(typescript@5.9.2))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2)
react-router-dom:
specifier: ^7.8.2
version: 7.8.2(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
@@ -447,6 +447,10 @@ packages:
resolution: {integrity: sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==}
engines: {node: '>=6.9.0'}
'@babel/runtime@7.28.4':
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
engines: {node: '>=6.9.0'}
'@babel/template@7.27.2':
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
engines: {node: '>=6.9.0'}
@@ -852,78 +856,92 @@ packages:
resolution: {integrity: sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.0':
resolution: {integrity: sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.0':
resolution: {integrity: sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.0':
resolution: {integrity: sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.0':
resolution: {integrity: sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.0':
resolution: {integrity: sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.0':
resolution: {integrity: sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.3':
resolution: {integrity: sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.3':
resolution: {integrity: sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.3':
resolution: {integrity: sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.3':
resolution: {integrity: sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.3':
resolution: {integrity: sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.3':
resolution: {integrity: sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.3':
resolution: {integrity: sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.3':
resolution: {integrity: sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==}
@@ -1115,24 +1133,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@15.5.2':
resolution: {integrity: sha512-s6N8k8dF9YGc5T01UPQ08yxsK6fUow5gG1/axWc1HVVBYQBgOjca4oUZF7s4p+kwhkB1bDSGR8QznWrFZ/Rt5g==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@15.5.2':
resolution: {integrity: sha512-o1RV/KOODQh6dM6ZRJGZbc+MOAHww33Vbs5JC9Mp1gDk8cpEO+cYC/l7rweiEalkSm5/1WGa4zY7xrNwObN4+Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@15.5.2':
resolution: {integrity: sha512-/VUnh7w8RElYZ0IV83nUcP/J4KJ6LLYliiBIri3p3aW2giF+PAVgZb6mk8jbQSB3WlTai8gEmCAr7kptFa1H6g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@15.5.2':
resolution: {integrity: sha512-sMPyTvRcNKXseNQ/7qRfVRLa0VhR0esmQ29DD6pqvG71+JdVnESJaHPA8t7bc67KD5spP3+DOCNLhqlEI2ZgQg==}
@@ -1350,56 +1372,67 @@ packages:
resolution: {integrity: sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.52.5':
resolution: {integrity: sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.52.5':
resolution: {integrity: sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.52.5':
resolution: {integrity: sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.52.5':
resolution: {integrity: sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.52.5':
resolution: {integrity: sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.52.5':
resolution: {integrity: sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.52.5':
resolution: {integrity: sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.52.5':
resolution: {integrity: sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.52.5':
resolution: {integrity: sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.52.5':
resolution: {integrity: sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openharmony-arm64@4.52.5':
resolution: {integrity: sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==}
@@ -1465,24 +1498,28 @@ packages:
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@swc/core-linux-arm64-musl@1.13.5':
resolution: {integrity: sha512-9+ZxFN5GJag4CnYnq6apKTnnezpfJhCumyz0504/JbHLo+Ue+ZtJnf3RhyA9W9TINtLE0bC4hKpWi8ZKoETyOQ==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@swc/core-linux-x64-gnu@1.13.5':
resolution: {integrity: sha512-WD530qvHrki8Ywt/PloKUjaRKgstQqNGvmZl54g06kA+hqtSE2FTG9gngXr3UJxYu/cNAjJYiBifm7+w4nbHbA==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@swc/core-linux-x64-musl@1.13.5':
resolution: {integrity: sha512-Luj8y4OFYx4DHNQTWjdIuKTq2f5k6uSXICqx+FSabnXptaOBAbJHNbHT/06JZh6NRUouaf0mYXN0mcsqvkhd7Q==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@swc/core-win32-arm64-msvc@1.13.5':
resolution: {integrity: sha512-cZ6UpumhF9SDJvv4DA2fo9WIzlNFuKSkZpZmPG1c+4PFSEMy5DFOjBSllCvnqihCabzXzpn6ykCwBmHpy31vQw==}
@@ -1602,48 +1639,56 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-gnu@4.1.14':
resolution: {integrity: sha512-qaEy0dIZ6d9vyLnmeg24yzA8XuEAD9WjpM5nIM1sUgQ/Zv7cVkharPDQcmm/t/TvXoKo/0knI3me3AGfdx6w1w==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.1.12':
resolution: {integrity: sha512-V8pAM3s8gsrXcCv6kCHSuwyb/gPsd863iT+v1PGXC4fSL/OJqsKhfK//v8P+w9ThKIoqNbEnsZqNy+WDnwQqCA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-arm64-musl@4.1.14':
resolution: {integrity: sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.1.12':
resolution: {integrity: sha512-xYfqYLjvm2UQ3TZggTGrwxjYaLB62b1Wiysw/YE3Yqbh86sOMoTn0feF98PonP7LtjsWOWcXEbGqDL7zv0uW8Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-gnu@4.1.14':
resolution: {integrity: sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.1.12':
resolution: {integrity: sha512-ha0pHPamN+fWZY7GCzz5rKunlv9L5R8kdh+YNvP5awe3LtuXb5nRi/H27GeL2U+TdhDOptU7T6Is7mdwh5Ar3A==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-musl@4.1.14':
resolution: {integrity: sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.1.12':
resolution: {integrity: sha512-4tSyu3dW+ktzdEpuk6g49KdEangu3eCYoqPhWNsZgUhyegEda3M9rG0/j1GV/JjVVsj+lG7jWAyrTlLzd/WEBg==}
@@ -1800,12 +1845,6 @@ packages:
'@types/multer@1.4.13':
resolution: {integrity: sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==}
'@types/node-fetch@2.6.13':
resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==}
'@types/node@18.19.129':
resolution: {integrity: sha512-hrmi5jWt2w60ayox3iIXwpMEnfUvOLJCRtrOPbHtH15nTjvO7uhnelvrdAs0dO0/zl5DZ3ZbahiaXEVb54ca/A==}
'@types/node@24.6.2':
resolution: {integrity: sha512-d2L25Y4j+W3ZlNAeMKcy7yDsK425ibcAOO2t7aPTz6gNMH0z2GThtwENCDc0d/Pw9wgyRqE5Px1wkV7naz8ang==}
@@ -1823,8 +1862,8 @@ packages:
peerDependencies:
'@types/react': ^19.0.0
'@types/react@19.1.11':
resolution: {integrity: sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ==}
'@types/react@19.2.2':
resolution: {integrity: sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==}
'@types/semver@7.7.0':
resolution: {integrity: sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==}
@@ -1980,41 +2019,49 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@@ -2042,10 +2089,6 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
@@ -2072,10 +2115,6 @@ packages:
resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==}
engines: {node: '>=12.0'}
agentkeepalive@4.6.0:
resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
engines: {node: '>= 8.0.0'}
ajv-draft-04@1.0.0:
resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==}
peerDependencies:
@@ -2166,8 +2205,8 @@ packages:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
axios@1.12.2:
resolution: {integrity: sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==}
axios@1.13.1:
resolution: {integrity: sha512-hU4EGxxt+j7TQijx1oYdAjw4xuIp1wRQSsbMFwSthCWeBQur1eF+qJ5iQ5sN3Tw8YRzQNKb8jszgBdMDVqwJcw==}
babel-jest@30.2.0:
resolution: {integrity: sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==}
@@ -2673,10 +2712,6 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
event-target-shim@5.0.1:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
eventsource-parser@3.0.5:
resolution: {integrity: sha512-bSRG85ZrMdmWtm7qkF9He9TNRzc/Bm99gEJMaQoHJ9E6Kv9QBbsldh2oMj7iXmYNEAVvNgvv5vPorG6W+XtBhQ==}
engines: {node: '>=20.0.0'}
@@ -2805,17 +2840,10 @@ packages:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
form-data-encoder@1.7.2:
resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==}
form-data@4.0.4:
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
engines: {node: '>= 6'}
formdata-node@4.4.1:
resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==}
engines: {node: '>= 12.20'}
formdata-polyfill@4.0.10:
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
engines: {node: '>=12.20.0'}
@@ -2957,17 +2985,14 @@ packages:
resolution: {integrity: sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==}
engines: {node: '>=14.18.0'}
humanize-ms@1.2.1:
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
i18next-browser-languagedetector@8.2.0:
resolution: {integrity: sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==}
i18next-fs-backend@2.6.0:
resolution: {integrity: sha512-3ZlhNoF9yxnM8pa8bWp5120/Ob6t4lVl1l/tbLmkml/ei3ud8IWySCHt2lrY5xWRlSU5D9IV2sm5bEbGuTqwTw==}
i18next@25.5.0:
resolution: {integrity: sha512-Mm2CgIq0revRFbBvfzqW9kDw1r44M4VDWC+YNRx9vTo5bU/iogSdEAC2HEonDA4czEce/iSbAkK90Tw7UrRZKA==}
i18next@25.6.0:
resolution: {integrity: sha512-tTn8fLrwBYtnclpL5aPXK/tAYBLWVvoHM1zdfXoRNLcI+RvtMsoZRV98ePlaW3khHYKuNh/Q65W/+NVFUeIwVw==}
peerDependencies:
typescript: ^5
peerDependenciesMeta:
@@ -3369,24 +3394,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.30.1:
resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.30.1:
resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.30.1:
resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.30.1:
resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==}
@@ -3455,8 +3484,8 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@0.486.0:
resolution: {integrity: sha512-xWop/wMsC1ikiEVLZrxXjPKw4vU/eAip33G2mZHgbWnr4Nr5Rt4Vx4s/q1D3B/rQVbxjOuqASkEZcUxDEKzecw==}
lucide-react@0.552.0:
resolution: {integrity: sha512-g9WCjmfwqbexSnZE+2cl21PCfXOcqnGeWeMTNAOGEfpPbm/ZF4YIq77Z8qWrxbu660EKuLB4nSLggoKnCb+isw==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -3648,15 +3677,6 @@ packages:
engines: {node: '>=10.5.0'}
deprecated: Use your platform's native DOMException instead
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
node-fetch@3.3.2:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -3713,12 +3733,12 @@ packages:
resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==}
engines: {node: '>=12'}
openai@4.104.0:
resolution: {integrity: sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==}
openai@6.7.0:
resolution: {integrity: sha512-mgSQXa3O/UXTbA8qFzoa7aydbXBJR5dbLQXCRapAOtoNT+v69sLdKMZzgiakpqhclRnhPggPAXoniVGn2kMY2A==}
hasBin: true
peerDependencies:
ws: ^8.18.0
zod: ^3.23.8
zod: ^3.25 || ^4.0
peerDependenciesMeta:
ws:
optional: true
@@ -4366,9 +4386,6 @@ packages:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
@@ -4551,9 +4568,6 @@ packages:
engines: {node: '>=0.8.0'}
hasBin: true
undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
undici-types@7.13.0:
resolution: {integrity: sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==}
@@ -4657,16 +4671,6 @@ packages:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
web-streams-polyfill@4.0.0-beta.3:
resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==}
engines: {node: '>= 14'}
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
which-typed-array@1.1.19:
resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==}
engines: {node: '>= 0.4'}
@@ -4979,6 +4983,8 @@ snapshots:
'@babel/runtime@7.28.3': {}
'@babel/runtime@7.28.4': {}
'@babel/template@7.27.2':
dependencies:
'@babel/code-frame': 7.27.1
@@ -5656,122 +5662,122 @@ snapshots:
'@radix-ui/primitive@1.1.3': {}
'@radix-ui/react-accordion@1.2.12(@types/react-dom@19.1.7(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
'@radix-ui/react-accordion@1.2.12(@types/react-dom@19.1.7(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.1.7(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.7(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.11)(react@19.1.1)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.11)(react@19.1.1)
'@radix-ui/react-direction': 1.1.1(@types/react@19.1.11)(react@19.1.1)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.11)(react@19.1.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.7(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.11)(react@19.1.1)
'@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.1.7(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.7(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.1)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.1)
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.1.1)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.1.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.7(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.1.1)
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.11
'@types/react-dom': 19.1.7(@types/react@19.1.11)
'@types/react': 19.2.2
'@types/react-dom': 19.1.7(@types/react@19.2.2)
'@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.1.7(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
'@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.1.7(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.11)(react@19.1.1)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.11)(react@19.1.1)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.11)(react@19.1.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.7(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.7(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.11)(react@19.1.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.11)(react@19.1.1)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.1)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.1)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.1.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.7(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.7(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.1.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.1.1)
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.11
'@types/react-dom': 19.1.7(@types/react@19.1.11)
'@types/react': 19.2.2
'@types/react-dom': 19.1.7(@types/react@19.2.2)
'@radix-ui/react-collection@1.1.7(@types/react-dom@19.1.7(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
'@radix-ui/react-collection@1.1.7(@types/react-dom@19.1.7(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.11)(react@19.1.1)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.11)(react@19.1.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.7(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.11)(react@19.1.1)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.1)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.7(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.1.1)
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.11
'@types/react-dom': 19.1.7(@types/react@19.1.11)
'@types/react': 19.2.2
'@types/react-dom': 19.1.7(@types/react@19.2.2)
'@radix-ui/react-compose-refs@1.1.2(@types/react@19.1.11)(react@19.1.1)':
'@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.2)(react@19.1.1)':
dependencies:
react: 19.1.1
optionalDependencies:
'@types/react': 19.1.11
'@types/react': 19.2.2
'@radix-ui/react-context@1.1.2(@types/react@19.1.11)(react@19.1.1)':
'@radix-ui/react-context@1.1.2(@types/react@19.2.2)(react@19.1.1)':
dependencies:
react: 19.1.1
optionalDependencies:
'@types/react': 19.1.11
'@types/react': 19.2.2
'@radix-ui/react-direction@1.1.1(@types/react@19.1.11)(react@19.1.1)':
'@radix-ui/react-direction@1.1.1(@types/react@19.2.2)(react@19.1.1)':
dependencies:
react: 19.1.1
optionalDependencies:
'@types/react': 19.1.11
'@types/react': 19.2.2
'@radix-ui/react-id@1.1.1(@types/react@19.1.11)(react@19.1.1)':
'@radix-ui/react-id@1.1.1(@types/react@19.2.2)(react@19.1.1)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.11)(react@19.1.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.1.1)
react: 19.1.1
optionalDependencies:
'@types/react': 19.1.11
'@types/react': 19.2.2
'@radix-ui/react-presence@1.1.5(@types/react-dom@19.1.7(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
'@radix-ui/react-presence@1.1.5(@types/react-dom@19.1.7(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.11)(react@19.1.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.11)(react@19.1.1)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.1.1)
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.11
'@types/react-dom': 19.1.7(@types/react@19.1.11)
'@types/react': 19.2.2
'@types/react-dom': 19.1.7(@types/react@19.2.2)
'@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.7(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
'@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.7(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.11)(react@19.1.1)
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.1.1)
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.11
'@types/react-dom': 19.1.7(@types/react@19.1.11)
'@types/react': 19.2.2
'@types/react-dom': 19.1.7(@types/react@19.2.2)
'@radix-ui/react-slot@1.2.3(@types/react@19.1.11)(react@19.1.1)':
'@radix-ui/react-slot@1.2.3(@types/react@19.2.2)(react@19.1.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.11)(react@19.1.1)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.1)
react: 19.1.1
optionalDependencies:
'@types/react': 19.1.11
'@types/react': 19.2.2
'@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.1.11)(react@19.1.1)':
'@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.2)(react@19.1.1)':
dependencies:
'@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.1.11)(react@19.1.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.11)(react@19.1.1)
'@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.2)(react@19.1.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.1.1)
react: 19.1.1
optionalDependencies:
'@types/react': 19.1.11
'@types/react': 19.2.2
'@radix-ui/react-use-effect-event@0.0.2(@types/react@19.1.11)(react@19.1.1)':
'@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.2)(react@19.1.1)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.11)(react@19.1.1)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.1.1)
react: 19.1.1
optionalDependencies:
'@types/react': 19.1.11
'@types/react': 19.2.2
'@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.1.11)(react@19.1.1)':
'@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.2)(react@19.1.1)':
dependencies:
react: 19.1.1
optionalDependencies:
'@types/react': 19.1.11
'@types/react': 19.2.2
'@rolldown/pluginutils@1.0.0-beta.27': {}
@@ -6185,15 +6191,6 @@ snapshots:
dependencies:
'@types/express': 4.17.23
'@types/node-fetch@2.6.13':
dependencies:
'@types/node': 24.6.2
form-data: 4.0.4
'@types/node@18.19.129':
dependencies:
undici-types: 5.26.5
'@types/node@24.6.2':
dependencies:
undici-types: 7.13.0
@@ -6208,11 +6205,11 @@ snapshots:
'@types/range-parser@1.2.7': {}
'@types/react-dom@19.1.7(@types/react@19.1.11)':
'@types/react-dom@19.1.7(@types/react@19.2.2)':
dependencies:
'@types/react': 19.1.11
'@types/react': 19.2.2
'@types/react@19.1.11':
'@types/react@19.2.2':
dependencies:
csstype: 3.1.3
@@ -6441,10 +6438,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
accepts@1.3.8:
dependencies:
mime-types: 2.1.35
@@ -6467,10 +6460,6 @@ snapshots:
adm-zip@0.5.16: {}
agentkeepalive@4.6.0:
dependencies:
humanize-ms: 1.2.1
ajv-draft-04@1.0.0(ajv@8.17.1):
optionalDependencies:
ajv: 8.17.1
@@ -6548,7 +6537,7 @@ snapshots:
dependencies:
possible-typed-array-names: 1.1.0
axios@1.12.2:
axios@1.13.1:
dependencies:
follow-redirects: 1.15.11
form-data: 4.0.4
@@ -7124,8 +7113,6 @@ snapshots:
etag@1.8.1: {}
event-target-shim@5.0.1: {}
eventsource-parser@3.0.5: {}
eventsource@3.0.7:
@@ -7339,8 +7326,6 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
form-data-encoder@1.7.2: {}
form-data@4.0.4:
dependencies:
asynckit: 0.4.0
@@ -7349,11 +7334,6 @@ snapshots:
hasown: 2.0.2
mime-types: 2.1.35
formdata-node@4.4.1:
dependencies:
node-domexception: 1.0.0
web-streams-polyfill: 4.0.0-beta.3
formdata-polyfill@4.0.10:
dependencies:
fetch-blob: 3.2.0
@@ -7503,19 +7483,15 @@ snapshots:
human-signals@4.3.1: {}
humanize-ms@1.2.1:
dependencies:
ms: 2.1.3
i18next-browser-languagedetector@8.2.0:
dependencies:
'@babel/runtime': 7.28.3
i18next-fs-backend@2.6.0: {}
i18next@25.5.0(typescript@5.9.2):
i18next@25.6.0(typescript@5.9.2):
dependencies:
'@babel/runtime': 7.28.3
'@babel/runtime': 7.28.4
optionalDependencies:
typescript: 5.9.2
@@ -8163,7 +8139,7 @@ snapshots:
dependencies:
yallist: 3.1.1
lucide-react@0.486.0(react@19.1.1):
lucide-react@0.552.0(react@19.1.1):
dependencies:
react: 19.1.1
@@ -8311,10 +8287,6 @@ snapshots:
node-domexception@1.0.0: {}
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
node-fetch@3.3.2:
dependencies:
data-uri-to-buffer: 4.0.1
@@ -8361,19 +8333,9 @@ snapshots:
dependencies:
mimic-fn: 4.0.0
openai@4.104.0(zod@3.25.76):
dependencies:
'@types/node': 18.19.129
'@types/node-fetch': 2.6.13
abort-controller: 3.0.0
agentkeepalive: 4.6.0
form-data-encoder: 1.7.2
formdata-node: 4.4.1
node-fetch: 2.7.0
openai@6.7.0(zod@3.25.76):
optionalDependencies:
zod: 3.25.76
transitivePeerDependencies:
- encoding
openapi-types@12.1.3: {}
@@ -8599,11 +8561,11 @@ snapshots:
react: 19.1.1
scheduler: 0.26.0
react-i18next@15.7.2(i18next@25.5.0(typescript@5.9.2))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2):
react-i18next@15.7.2(i18next@25.6.0(typescript@5.9.2))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(typescript@5.9.2):
dependencies:
'@babel/runtime': 7.28.3
html-parse-stringify: 3.0.1
i18next: 25.5.0(typescript@5.9.2)
i18next: 25.6.0(typescript@5.9.2)
react: 19.1.1
optionalDependencies:
react-dom: 19.1.1(react@19.1.1)
@@ -9059,8 +9021,6 @@ snapshots:
toidentifier@1.0.1: {}
tr46@0.0.3: {}
tree-kill@1.2.2: {}
ts-api-utils@1.4.3(typescript@5.9.2):
@@ -9205,8 +9165,6 @@ snapshots:
uglify-js@3.19.3:
optional: true
undici-types@5.26.5: {}
undici-types@7.13.0: {}
universalify@2.0.1: {}
@@ -9292,15 +9250,6 @@ snapshots:
web-streams-polyfill@3.3.3: {}
web-streams-polyfill@4.0.0-beta.3: {}
webidl-conversions@3.0.1: {}
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
which-typed-array@1.1.19:
dependencies:
available-typed-arrays: 1.0.7

View File

@@ -1,240 +0,0 @@
/**
* 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',
});
}
};

View File

@@ -508,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, cluster } = req.body;
const { routing, install, smartRouting, mcpRouter, nameSeparator } = req.body;
const currentUser = (req as any).user;
if (
@@ -533,8 +533,7 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
typeof mcpRouter.referer !== 'string' &&
typeof mcpRouter.title !== 'string' &&
typeof mcpRouter.baseUrl !== 'string')) &&
typeof nameSeparator !== 'string' &&
!cluster
typeof nameSeparator !== 'string'
) {
res.status(400).json({
success: false,
@@ -611,13 +610,6 @@ 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;
@@ -727,88 +719,6 @@ 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,

View File

@@ -1,176 +0,0 @@
/**
* 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',
});
}
};

View File

@@ -80,14 +80,6 @@ import {
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();
@@ -175,14 +167,6 @@ 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);

View File

@@ -15,11 +15,9 @@ 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
@@ -75,74 +73,53 @@ export class AppServer {
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) 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,
// 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(.*)?`,
sseUserContextMiddleware,
clusterMcpRouting,
handleMcpPostRequest,
);
this.app.get(
`${this.basePath}/mcp/:group(.*)?`,
sseUserContextMiddleware,
clusterMcpRouting,
handleMcpOtherRequest,
);
this.app.delete(
`${this.basePath}/mcp/:group(.*)?`,
sseUserContextMiddleware,
clusterMcpRouting,
handleMcpOtherRequest,
);
// 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),
// User-scoped routes with user context middleware
this.app.get(`${this.basePath}/:user/sse/:group(.*)?`, sseUserContextMiddleware, (req, res) =>
handleSseConnection(req, res),
);
this.app.post(
`${this.basePath}/:user/messages`,
sseUserContextMiddleware,
clusterSseRouting,
handleSseMessage,
);
this.app.post(
`${this.basePath}/:user/mcp/:group(.*)?`,
sseUserContextMiddleware,
clusterMcpRouting,
handleMcpPostRequest,
);
this.app.get(
`${this.basePath}/:user/mcp/:group(.*)?`,
sseUserContextMiddleware,
clusterMcpRouting,
handleMcpOtherRequest,
);
this.app.delete(
`${this.basePath}/:user/mcp/:group(.*)?`,
sseUserContextMiddleware,
clusterMcpRouting,
handleMcpOtherRequest,
);
})
@@ -214,11 +191,6 @@ 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

View File

@@ -1,538 +0,0 @@
/**
* 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,
};
};

View File

@@ -1,6 +1,4 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import {
CallToolRequestSchema,
@@ -33,77 +31,6 @@ 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
@@ -286,7 +213,7 @@ export const createTransportFromConfig = async (name: string, conf: ServerConfig
...(process.env as Record<string, string>),
...replaceEnvVars(conf.env || {}),
};
env['PATH'] = expandEnvVars(env['PATH'] || process.env.PATH || '');
env['PATH'] = expandEnvVars(process.env.PATH as string) || '';
const settings = loadSettings();
// Add UV_DEFAULT_INDEX and npm_config_registry if needed
@@ -308,52 +235,9 @@ export const createTransportFromConfig = async (name: string, conf: ServerConfig
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: workingDirectory,
cwd: os.homedir(),
command: conf.command,
args: replaceEnvVars(conf.args) as string[],
env: env,

View File

@@ -171,7 +171,6 @@ export interface SystemConfig {
};
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 {
@@ -357,63 +356,3 @@ 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
}

View File

@@ -1,335 +0,0 @@
/**
* 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);
});
});
});