mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 18:59:30 -05:00
Compare commits
3 Commits
v0.9.16
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9732fccb6 | ||
|
|
7b3d441046 | ||
|
|
55a7d0b183 |
124
.github/DOCKER_CLI_TEST.md
vendored
124
.github/DOCKER_CLI_TEST.md
vendored
@@ -1,124 +0,0 @@
|
||||
# Docker Engine Installation Test Procedure
|
||||
|
||||
This document describes how to test the Docker Engine installation feature added with the `INSTALL_EXT=true` build argument.
|
||||
|
||||
## Test 1: Build with INSTALL_EXT=false (default)
|
||||
|
||||
```bash
|
||||
# Build without extended features
|
||||
docker build -t mcphub:base .
|
||||
|
||||
# Run the container
|
||||
docker run --rm mcphub:base docker --version
|
||||
```
|
||||
|
||||
**Expected Result**: `docker: not found` error (Docker is NOT installed)
|
||||
|
||||
## Test 2: Build with INSTALL_EXT=true
|
||||
|
||||
```bash
|
||||
# Build with extended features
|
||||
docker build --build-arg INSTALL_EXT=true -t mcphub:extended .
|
||||
|
||||
# Test Docker CLI is available
|
||||
docker run --rm mcphub:extended docker --version
|
||||
```
|
||||
|
||||
**Expected Result**: Docker version output (e.g., `Docker version 27.x.x, build xxxxx`)
|
||||
|
||||
## Test 3: Docker-in-Docker with Auto-start Daemon
|
||||
|
||||
```bash
|
||||
# Build with extended features
|
||||
docker build --build-arg INSTALL_EXT=true -t mcphub:extended .
|
||||
|
||||
# Run with privileged mode (allows Docker daemon to start)
|
||||
docker run -d \
|
||||
--name mcphub-test \
|
||||
--privileged \
|
||||
-p 3000:3000 \
|
||||
mcphub:extended
|
||||
|
||||
# Wait a few seconds for daemon to start
|
||||
sleep 5
|
||||
|
||||
# Test Docker commands from inside the container
|
||||
docker exec mcphub-test docker ps
|
||||
docker exec mcphub-test docker images
|
||||
docker exec mcphub-test docker info
|
||||
|
||||
# Cleanup
|
||||
docker stop mcphub-test
|
||||
docker rm mcphub-test
|
||||
```
|
||||
|
||||
**Expected Result**:
|
||||
- Docker daemon should auto-start inside the container
|
||||
- Docker commands should work without mounting the host's Docker socket
|
||||
- `docker info` should show the container's own Docker daemon
|
||||
|
||||
## Test 4: Docker-in-Docker with Host Socket (Alternative)
|
||||
|
||||
```bash
|
||||
# Build with extended features
|
||||
docker build --build-arg INSTALL_EXT=true -t mcphub:extended .
|
||||
|
||||
# Run with Docker socket mounted (uses host's daemon)
|
||||
docker run -d \
|
||||
--name mcphub-test \
|
||||
-p 3000:3000 \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
mcphub:extended
|
||||
|
||||
# Test Docker commands from inside the container
|
||||
docker exec mcphub-test docker ps
|
||||
docker exec mcphub-test docker images
|
||||
|
||||
# Cleanup
|
||||
docker stop mcphub-test
|
||||
docker rm mcphub-test
|
||||
```
|
||||
|
||||
**Expected Result**:
|
||||
- Docker daemon should NOT auto-start (socket already exists from host)
|
||||
- Docker commands should work and show the host's containers and images
|
||||
|
||||
## Test 5: Verify Image Size
|
||||
|
||||
```bash
|
||||
# Build both versions
|
||||
docker build -t mcphub:base .
|
||||
docker build --build-arg INSTALL_EXT=true -t mcphub:extended .
|
||||
|
||||
# Compare image sizes
|
||||
docker images mcphub:*
|
||||
```
|
||||
|
||||
**Expected Result**:
|
||||
- The `extended` image should be larger than the `base` image
|
||||
- The size difference should be reasonable (Docker Engine adds ~100-150MB)
|
||||
|
||||
## Test 6: Architecture Support
|
||||
|
||||
```bash
|
||||
# On AMD64/x86_64
|
||||
docker build --build-arg INSTALL_EXT=true --platform linux/amd64 -t mcphub:extended-amd64 .
|
||||
|
||||
# On ARM64
|
||||
docker build --build-arg INSTALL_EXT=true --platform linux/arm64 -t mcphub:extended-arm64 .
|
||||
```
|
||||
|
||||
**Expected Result**:
|
||||
- Both builds should succeed
|
||||
- AMD64 includes Chrome/Playwright + Docker Engine
|
||||
- ARM64 includes Docker Engine only (Chrome installation is skipped)
|
||||
|
||||
## Notes
|
||||
|
||||
- The Docker Engine installation follows the official Docker documentation
|
||||
- Includes full Docker daemon (`dockerd`), CLI (`docker`), and containerd
|
||||
- The daemon auto-starts when running in privileged mode
|
||||
- The installation uses the Debian Bookworm repository
|
||||
- All temporary files are cleaned up to minimize image size
|
||||
- The feature is opt-in via the `INSTALL_EXT` build argument
|
||||
- `iptables` is installed as it's required for Docker networking
|
||||
34
.github/copilot-instructions.md
vendored
34
.github/copilot-instructions.md
vendored
@@ -13,7 +13,6 @@ MCPHub is a TypeScript/Node.js MCP (Model Context Protocol) server management hu
|
||||
- **MCP Integration**: Connects multiple MCP servers (`src/services/mcpService.ts`)
|
||||
- **Authentication**: JWT-based with bcrypt password hashing
|
||||
- **Configuration**: JSON-based MCP server definitions (`mcp_settings.json`)
|
||||
- **Documentation**: API docs and usage instructions(`docs/`)
|
||||
|
||||
## Working Effectively
|
||||
|
||||
@@ -31,7 +30,7 @@ cp .env.example .env
|
||||
|
||||
# Build and test to verify setup
|
||||
pnpm lint # ~3 seconds - NEVER CANCEL
|
||||
pnpm backend:build # ~5 seconds - NEVER CANCEL
|
||||
pnpm backend:build # ~5 seconds - NEVER CANCEL
|
||||
pnpm test:ci # ~16 seconds - NEVER CANCEL. Set timeout to 60+ seconds
|
||||
pnpm frontend:build # ~5 seconds - NEVER CANCEL
|
||||
pnpm build # ~10 seconds total - NEVER CANCEL. Set timeout to 60+ seconds
|
||||
@@ -49,7 +48,7 @@ pnpm dev # Backend on :3001, Frontend on :5173
|
||||
# Terminal 1: Backend only
|
||||
pnpm backend:dev # Runs on port 3000 (or PORT env var)
|
||||
|
||||
# Terminal 2: Frontend only
|
||||
# Terminal 2: Frontend only
|
||||
pnpm frontend:dev # Runs on port 5173, proxies API to backend
|
||||
```
|
||||
|
||||
@@ -63,7 +62,7 @@ pnpm build # NEVER CANCEL - Set timeout to 60+ seconds
|
||||
|
||||
# Individual builds
|
||||
pnpm backend:build # TypeScript compilation - ~5 seconds
|
||||
pnpm frontend:build # Vite build - ~5 seconds
|
||||
pnpm frontend:build # Vite build - ~5 seconds
|
||||
|
||||
# Start production server
|
||||
pnpm start # Requires dist/ and frontend/dist/ to exist
|
||||
@@ -92,7 +91,6 @@ pnpm format # Prettier formatting - ~3 seconds
|
||||
**ALWAYS perform these validation steps after making changes:**
|
||||
|
||||
### 1. Basic Application Functionality
|
||||
|
||||
```bash
|
||||
# Start the application
|
||||
pnpm dev
|
||||
@@ -107,7 +105,6 @@ curl -I http://localhost:3000/
|
||||
```
|
||||
|
||||
### 2. MCP Server Integration Test
|
||||
|
||||
```bash
|
||||
# Check MCP servers are loading (look for log messages)
|
||||
# Expected log output should include:
|
||||
@@ -117,7 +114,6 @@ curl -I http://localhost:3000/
|
||||
```
|
||||
|
||||
### 3. Build Verification
|
||||
|
||||
```bash
|
||||
# Verify production build works
|
||||
pnpm build
|
||||
@@ -130,7 +126,6 @@ node scripts/verify-dist.js
|
||||
## Project Structure and Key Files
|
||||
|
||||
### Critical Backend Files
|
||||
|
||||
- `src/index.ts` - Application entry point
|
||||
- `src/server.ts` - Express server setup and middleware
|
||||
- `src/services/mcpService.ts` - **Core MCP server management logic**
|
||||
@@ -141,14 +136,11 @@ node scripts/verify-dist.js
|
||||
- `src/types/index.ts` - TypeScript type definitions
|
||||
|
||||
### Critical Frontend Files
|
||||
|
||||
- `frontend/src/` - React application source
|
||||
- `frontend/src/pages/` - Page components (development entry point)
|
||||
- `frontend/src/components/` - Reusable UI components
|
||||
- `frontend/src/utils/fetchInterceptor.js` - Backend API interaction
|
||||
|
||||
### Configuration Files
|
||||
|
||||
- `mcp_settings.json` - **MCP server definitions and user accounts**
|
||||
- `package.json` - Dependencies and scripts
|
||||
- `tsconfig.json` - TypeScript configuration
|
||||
@@ -156,7 +148,6 @@ node scripts/verify-dist.js
|
||||
- `.eslintrc.json` - Linting rules
|
||||
|
||||
### Docker and Deployment
|
||||
|
||||
- `Dockerfile` - Multi-stage build with Python base + Node.js
|
||||
- `entrypoint.sh` - Docker startup script
|
||||
- `bin/cli.js` - NPM package CLI entry point
|
||||
@@ -164,14 +155,12 @@ node scripts/verify-dist.js
|
||||
## Development Process and Conventions
|
||||
|
||||
### Code Style Requirements
|
||||
|
||||
- **ESM modules**: Always use `.js` extensions in imports, not `.ts`
|
||||
- **English only**: All code comments must be written in English
|
||||
- **TypeScript strict**: Follow strict type checking rules
|
||||
- **Import style**: `import { something } from './file.js'` (note .js extension)
|
||||
|
||||
### Key Configuration Notes
|
||||
|
||||
- **MCP servers**: Defined in `mcp_settings.json` with command/args
|
||||
- **Endpoints**: `/mcp/{group|server}` and `/mcp/$smart` for routing
|
||||
- **i18n**: Frontend uses react-i18next with files in `locales/` folder
|
||||
@@ -179,7 +168,6 @@ node scripts/verify-dist.js
|
||||
- **Default credentials**: admin/admin123 (configured in mcp_settings.json)
|
||||
|
||||
### Development Entry Points
|
||||
|
||||
- **Add MCP server**: Modify `mcp_settings.json` and restart
|
||||
- **New API endpoint**: Add route in `src/routes/`, controller in `src/controllers/`
|
||||
- **Frontend feature**: Start from `frontend/src/pages/` or `frontend/src/components/`
|
||||
@@ -188,38 +176,29 @@ node scripts/verify-dist.js
|
||||
### Common Development Tasks
|
||||
|
||||
#### Adding a new MCP server:
|
||||
|
||||
1. Add server definition to `mcp_settings.json`
|
||||
2. Restart backend to load new server
|
||||
3. Check logs for successful connection
|
||||
4. Test via dashboard or API endpoints
|
||||
|
||||
#### API development:
|
||||
|
||||
1. Define route in `src/routes/`
|
||||
2. Implement controller in `src/controllers/`
|
||||
3. Add types in `src/types/index.ts` if needed
|
||||
4. Write tests in `tests/controllers/`
|
||||
|
||||
#### Frontend development:
|
||||
|
||||
1. Create/modify components in `frontend/src/components/`
|
||||
2. Add pages in `frontend/src/pages/`
|
||||
3. Update routing if needed
|
||||
4. Test in development mode with `pnpm frontend:dev`
|
||||
|
||||
#### Documentation:
|
||||
|
||||
1. Update or add docs in `docs/` folder
|
||||
2. Ensure README.md reflects any major changes
|
||||
|
||||
## Validation and CI Requirements
|
||||
|
||||
### Before Committing - ALWAYS Run:
|
||||
|
||||
```bash
|
||||
pnpm lint # Must pass - ~3 seconds
|
||||
pnpm backend:build # Must compile - ~5 seconds
|
||||
pnpm backend:build # Must compile - ~5 seconds
|
||||
pnpm test:ci # All tests must pass - ~16 seconds
|
||||
pnpm build # Full build must work - ~10 seconds
|
||||
```
|
||||
@@ -227,7 +206,6 @@ pnpm build # Full build must work - ~10 seconds
|
||||
**CRITICAL**: CI will fail if any of these commands fail. Fix issues locally first.
|
||||
|
||||
### CI Pipeline (.github/workflows/ci.yml)
|
||||
|
||||
- Runs on Node.js 20.x
|
||||
- Tests: linting, type checking, unit tests with coverage
|
||||
- **NEVER CANCEL**: CI builds may take 2-3 minutes total
|
||||
@@ -235,26 +213,22 @@ pnpm build # Full build must work - ~10 seconds
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
- **"uvx command not found"**: Some MCP servers require `uvx` (Python package manager) - this is expected in development
|
||||
- **Port already in use**: Change PORT environment variable or kill existing processes
|
||||
- **Frontend not loading**: Ensure frontend was built with `pnpm frontend:build`
|
||||
- **MCP server connection failed**: Check server command/args in `mcp_settings.json`
|
||||
|
||||
### Build Failures
|
||||
|
||||
- **TypeScript errors**: Run `pnpm backend:build` to see compilation errors
|
||||
- **Test failures**: Run `pnpm test:verbose` for detailed test output
|
||||
- **Lint errors**: Run `pnpm lint` and fix reported issues
|
||||
|
||||
### Development Issues
|
||||
|
||||
- **Backend not starting**: Check for port conflicts, verify `mcp_settings.json` syntax
|
||||
- **Frontend proxy errors**: Ensure backend is running before starting frontend
|
||||
- **Hot reload not working**: Restart development server
|
||||
|
||||
## Performance Notes
|
||||
|
||||
- **Install time**: pnpm install takes ~30 seconds
|
||||
- **Build time**: Full build takes ~10 seconds
|
||||
- **Test time**: Complete test suite takes ~16 seconds
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2
|
||||
}
|
||||
}
|
||||
|
||||
10
Dockerfile
10
Dockerfile
@@ -22,16 +22,6 @@ RUN if [ "$INSTALL_EXT" = "true" ]; then \
|
||||
else \
|
||||
echo "Skipping Chrome installation on non-amd64 architecture: $ARCH"; \
|
||||
fi; \
|
||||
# Install Docker Engine (includes CLI and daemon) \
|
||||
apt-get update && \
|
||||
apt-get install -y ca-certificates curl iptables && \
|
||||
install -m 0755 -d /etc/apt/keyrings && \
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc && \
|
||||
chmod a+r /etc/apt/keyrings/docker.asc && \
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null && \
|
||||
apt-get update && \
|
||||
apt-get install -y docker-ce docker-ce-cli containerd.io && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/*; \
|
||||
fi
|
||||
|
||||
RUN uv tool install mcp-server-fetch
|
||||
|
||||
@@ -41,50 +41,6 @@ docker run -d \
|
||||
mcphub:local
|
||||
```
|
||||
|
||||
### Building with Extended Features
|
||||
|
||||
The Docker image supports an `INSTALL_EXT` build argument to include additional tools:
|
||||
|
||||
```bash
|
||||
# Build with extended features (includes Docker Engine, Chrome/Playwright)
|
||||
docker build --build-arg INSTALL_EXT=true -t mcphub:extended .
|
||||
|
||||
# Option 1: Run with automatic Docker-in-Docker (requires privileged mode)
|
||||
docker run -d \
|
||||
--name mcphub \
|
||||
--privileged \
|
||||
-p 3000:3000 \
|
||||
-v $(pwd)/mcp_settings.json:/app/mcp_settings.json \
|
||||
mcphub:extended
|
||||
|
||||
# Option 2: Run with Docker socket mounted (use host's Docker daemon)
|
||||
docker run -d \
|
||||
--name mcphub \
|
||||
-p 3000:3000 \
|
||||
-v $(pwd)/mcp_settings.json:/app/mcp_settings.json \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
mcphub:extended
|
||||
|
||||
# Verify Docker is available
|
||||
docker exec mcphub docker --version
|
||||
docker exec mcphub docker ps
|
||||
```
|
||||
|
||||
<Note>
|
||||
**What's included with INSTALL_EXT=true:**
|
||||
- **Docker Engine**: Full Docker daemon with CLI for container management. The daemon auto-starts when the container runs in privileged mode.
|
||||
- **Chrome/Playwright** (amd64 only): For browser automation tasks
|
||||
|
||||
The extended image is larger but provides additional capabilities for advanced use cases.
|
||||
</Note>
|
||||
|
||||
<Warning>
|
||||
**Docker-in-Docker Security Considerations:**
|
||||
- **Privileged mode** (`--privileged`): Required for the Docker daemon to start inside the container. This gives the container elevated permissions on the host.
|
||||
- **Docker socket mounting** (`/var/run/docker.sock`): Gives the container access to the host's Docker daemon. Both approaches should only be used in trusted environments.
|
||||
- For production, consider using Docker socket mounting instead of privileged mode for better security.
|
||||
</Warning>
|
||||
|
||||
## Docker Compose Setup
|
||||
|
||||
### Basic Configuration
|
||||
|
||||
@@ -47,7 +47,8 @@ MCPHub uses several configuration files:
|
||||
},
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--headless"]
|
||||
"args": ["@playwright/mcp@latest", "--headless", "--isolated"],
|
||||
"perSession": true
|
||||
},
|
||||
"slack": {
|
||||
"command": "npx",
|
||||
@@ -75,6 +76,42 @@ MCPHub uses several configuration files:
|
||||
| Field | Type | Default | Description |
|
||||
| -------------- | ------- | --------------- | --------------------------- |
|
||||
| `env` | object | `{}` | Environment variables |
|
||||
| `perSession` | boolean | `false` | Create separate server instance per user session (for stateful servers like playwright) |
|
||||
| `enabled` | boolean | `true` | Enable/disable the server |
|
||||
| `timeout` | number | `60000` | Request timeout in milliseconds |
|
||||
| `keepAliveInterval` | number | `60000` | Keep-alive ping interval for SSE servers (ms) |
|
||||
|
||||
## Per-Session Server Instances
|
||||
|
||||
Some MCP servers maintain state that should be isolated between different users. For example, the Playwright server maintains browser sessions that could leak form data or other state between concurrent users.
|
||||
|
||||
To prevent this, you can set `perSession: true` in the server configuration. This creates a separate server instance for each user session instead of sharing a single instance across all users.
|
||||
|
||||
### When to Use Per-Session Servers
|
||||
|
||||
Use `perSession: true` for servers that:
|
||||
- Maintain browser state (like Playwright)
|
||||
- Store user-specific data in memory
|
||||
- Have file handles or database connections that shouldn't be shared
|
||||
- Could cause race conditions when multiple users access simultaneously
|
||||
|
||||
### Example Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--headless", "--isolated"],
|
||||
"perSession": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important Notes:**
|
||||
- Each per-session server instance consumes additional resources (memory, CPU)
|
||||
- Per-session servers are automatically cleaned up when the user session ends
|
||||
- For Playwright, also use the `--isolated` flag to ensure browser contexts are isolated
|
||||
- Not recommended for stateless servers that can safely be shared
|
||||
|
||||
## Common MCP Server Examples
|
||||
|
||||
@@ -101,8 +138,9 @@ MCPHub uses several configuration files:
|
||||
{
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--headless"],
|
||||
"args": ["@playwright/mcp@latest", "--headless", "--isolated"],
|
||||
"timeout": 60000,
|
||||
"perSession": true,
|
||||
"env": {
|
||||
"PLAYWRIGHT_BROWSERS_PATH": "/tmp/browsers"
|
||||
}
|
||||
@@ -110,6 +148,8 @@ MCPHub uses several configuration files:
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: The `--isolated` flag ensures each browser session is isolated, and `perSession: true` creates a separate server instance for each user session, preventing state leakage between concurrent users.
|
||||
|
||||
### File and System Servers
|
||||
|
||||
#### Filesystem Server
|
||||
|
||||
@@ -41,50 +41,6 @@ docker run -d \
|
||||
mcphub:local
|
||||
```
|
||||
|
||||
### 构建扩展功能版本
|
||||
|
||||
Docker 镜像支持 `INSTALL_EXT` 构建参数以包含额外工具:
|
||||
|
||||
```bash
|
||||
# 构建扩展功能版本(包含 Docker 引擎、Chrome/Playwright)
|
||||
docker build --build-arg INSTALL_EXT=true -t mcphub:extended .
|
||||
|
||||
# 方式 1: 使用自动 Docker-in-Docker(需要特权模式)
|
||||
docker run -d \
|
||||
--name mcphub \
|
||||
--privileged \
|
||||
-p 3000:3000 \
|
||||
-v $(pwd)/mcp_settings.json:/app/mcp_settings.json \
|
||||
mcphub:extended
|
||||
|
||||
# 方式 2: 挂载 Docker socket(使用宿主机的 Docker 守护进程)
|
||||
docker run -d \
|
||||
--name mcphub \
|
||||
-p 3000:3000 \
|
||||
-v $(pwd)/mcp_settings.json:/app/mcp_settings.json \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
mcphub:extended
|
||||
|
||||
# 验证 Docker 可用
|
||||
docker exec mcphub docker --version
|
||||
docker exec mcphub docker ps
|
||||
```
|
||||
|
||||
<Note>
|
||||
**INSTALL_EXT=true 包含的功能:**
|
||||
- **Docker 引擎**:完整的 Docker 守护进程和 CLI,用于容器管理。在特权模式下运行时,守护进程会自动启动。
|
||||
- **Chrome/Playwright**(仅 amd64):用于浏览器自动化任务
|
||||
|
||||
扩展镜像较大,但为高级用例提供了额外功能。
|
||||
</Note>
|
||||
|
||||
<Warning>
|
||||
**Docker-in-Docker 安全注意事项:**
|
||||
- **特权模式**(`--privileged`):容器内启动 Docker 守护进程需要此权限。这会授予容器在宿主机上的提升权限。
|
||||
- **Docker socket 挂载**(`/var/run/docker.sock`):使容器可以访问宿主机的 Docker 守护进程。两种方式都应仅在可信环境中使用。
|
||||
- 生产环境建议使用 Docker socket 挂载而非特权模式,以提高安全性。
|
||||
</Warning>
|
||||
|
||||
## Docker Compose 设置
|
||||
|
||||
### 基本配置
|
||||
|
||||
@@ -50,8 +50,9 @@ MCPHub 使用几个配置文件:
|
||||
},
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--headless"],
|
||||
"timeout": 60000
|
||||
"args": ["@playwright/mcp@latest", "--headless", "--isolated"],
|
||||
"timeout": 60000,
|
||||
"perSession": true
|
||||
},
|
||||
"slack": {
|
||||
"command": "npx",
|
||||
@@ -79,13 +80,48 @@ MCPHub 使用几个配置文件:
|
||||
| 字段 | 类型 | 默认值 | 描述 |
|
||||
| -------------- | ------- | --------------- | ------------------ |
|
||||
| `env` | object | `{}` | 环境变量 |
|
||||
| `perSession` | boolean | `false` | 为每个用户会话创建独立的服务器实例(用于有状态的服务器,如 playwright) |
|
||||
| `enabled` | boolean | `true` | 启用/禁用服务器 |
|
||||
| `timeout` | number | `60000` | 请求超时(毫秒) |
|
||||
| `keepAliveInterval` | number | `60000` | SSE 服务器的保活 ping 间隔(毫秒) |
|
||||
| `cwd` | string | `process.cwd()` | 工作目录 |
|
||||
| `timeout` | number | `30000` | 启动超时(毫秒) |
|
||||
| `restart` | boolean | `true` | 失败时自动重启 |
|
||||
| `maxRestarts` | number | `5` | 最大重启次数 |
|
||||
| `restartDelay` | number | `5000` | 重启间延迟(毫秒) |
|
||||
| `stdio` | string | `pipe` | stdio 配置 |
|
||||
|
||||
## 会话隔离的服务器实例
|
||||
|
||||
某些 MCP 服务器会维护应该在不同用户之间隔离的状态。例如,Playwright 服务器维护可能在并发用户之间泄漏表单数据或其他状态的浏览器会话。
|
||||
|
||||
为了防止这种情况,您可以在服务器配置中设置 `perSession: true`。这将为每个用户会话创建一个单独的服务器实例,而不是在所有用户之间共享单个实例。
|
||||
|
||||
### 何时使用会话隔离的服务器
|
||||
|
||||
对于以下服务器使用 `perSession: true`:
|
||||
- 维护浏览器状态(如 Playwright)
|
||||
- 在内存中存储用户特定数据
|
||||
- 具有不应共享的文件句柄或数据库连接
|
||||
- 当多个用户同时访问时可能导致竞争条件
|
||||
|
||||
### 示例配置
|
||||
|
||||
```json
|
||||
{
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--headless", "--isolated"],
|
||||
"perSession": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**重要提示:**
|
||||
- 每个会话隔离的服务器实例都会消耗额外的资源(内存、CPU)
|
||||
- 会话隔离的服务器在用户会话结束时会自动清理
|
||||
- 对于 Playwright,还要使用 `--isolated` 标志以确保浏览器上下文被隔离
|
||||
- 不建议用于可以安全共享的无状态服务器
|
||||
|
||||
## 常见 MCP 服务器示例
|
||||
|
||||
### Web 和 API 服务器
|
||||
@@ -111,8 +147,9 @@ MCPHub 使用几个配置文件:
|
||||
{
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--headless"],
|
||||
"args": ["@playwright/mcp@latest", "--headless", "--isolated"],
|
||||
"timeout": 60000,
|
||||
"perSession": true,
|
||||
"env": {
|
||||
"PLAYWRIGHT_BROWSERS_PATH": "/tmp/browsers"
|
||||
}
|
||||
@@ -120,6 +157,8 @@ MCPHub 使用几个配置文件:
|
||||
}
|
||||
```
|
||||
|
||||
**注意**: `--isolated` 标志确保每个浏览器会话是隔离的,而 `perSession: true` 为每个用户会话创建单独的服务器实例,防止并发用户之间的状态泄漏。
|
||||
|
||||
### 文件和系统服务器
|
||||
|
||||
#### 文件系统服务器
|
||||
|
||||
@@ -4,7 +4,7 @@ NPM_REGISTRY=${NPM_REGISTRY:-https://registry.npmjs.org/}
|
||||
echo "Setting npm registry to ${NPM_REGISTRY}"
|
||||
npm config set registry "$NPM_REGISTRY"
|
||||
|
||||
# Handle HTTP_PROXY and HTTPS_PROXY environment variables
|
||||
# 处理 HTTP_PROXY 和 HTTPS_PROXY 环境变量
|
||||
if [ -n "$HTTP_PROXY" ]; then
|
||||
echo "Setting HTTP proxy to ${HTTP_PROXY}"
|
||||
npm config set proxy "$HTTP_PROXY"
|
||||
@@ -19,33 +19,4 @@ fi
|
||||
|
||||
echo "Using REQUEST_TIMEOUT: $REQUEST_TIMEOUT"
|
||||
|
||||
# Auto-start Docker daemon if Docker is installed
|
||||
if command -v dockerd >/dev/null 2>&1; then
|
||||
echo "Docker daemon detected, starting dockerd..."
|
||||
|
||||
# Create docker directory if it doesn't exist
|
||||
mkdir -p /var/lib/docker
|
||||
|
||||
# Start dockerd in the background
|
||||
dockerd --host=unix:///var/run/docker.sock --storage-driver=vfs > /var/log/dockerd.log 2>&1 &
|
||||
|
||||
# Wait for Docker daemon to be ready
|
||||
echo "Waiting for Docker daemon to be ready..."
|
||||
TIMEOUT=15
|
||||
ELAPSED=0
|
||||
while ! docker info >/dev/null 2>&1; do
|
||||
if [ $ELAPSED -ge $TIMEOUT ]; then
|
||||
echo "WARNING: Docker daemon failed to start within ${TIMEOUT} seconds"
|
||||
echo "Check /var/log/dockerd.log for details"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
ELAPSED=$((ELAPSED + 1))
|
||||
done
|
||||
|
||||
if docker info >/dev/null 2>&1; then
|
||||
echo "Docker daemon started successfully"
|
||||
fi
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
|
||||
@@ -1,52 +1,51 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Server } from '@/types';
|
||||
import { apiPut } from '../utils/fetchInterceptor';
|
||||
import ServerForm from './ServerForm';
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Server } from '@/types'
|
||||
import { apiPut } from '../utils/fetchInterceptor'
|
||||
import ServerForm from './ServerForm'
|
||||
|
||||
interface EditServerFormProps {
|
||||
server: Server;
|
||||
onEdit: () => void;
|
||||
onCancel: () => void;
|
||||
server: Server
|
||||
onEdit: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const EditServerForm = ({ server, onEdit, onCancel }: EditServerFormProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { t } = useTranslation()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async (payload: any) => {
|
||||
try {
|
||||
setError(null);
|
||||
const encodedServerName = encodeURIComponent(server.name);
|
||||
const result = await apiPut(`/servers/${encodedServerName}`, payload);
|
||||
setError(null)
|
||||
const result = await apiPut(`/servers/${server.name}`, payload)
|
||||
|
||||
if (!result.success) {
|
||||
// Use specific error message from the response if available
|
||||
if (result && result.message) {
|
||||
setError(result.message);
|
||||
setError(result.message)
|
||||
} else {
|
||||
setError(t('server.updateError', { serverName: server.name }));
|
||||
setError(t('server.updateError', { serverName: server.name }))
|
||||
}
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
onEdit();
|
||||
onEdit()
|
||||
} catch (err) {
|
||||
console.error('Error updating server:', err);
|
||||
console.error('Error updating server:', err)
|
||||
|
||||
// Use friendly error messages based on error type
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'));
|
||||
} else if (
|
||||
err instanceof TypeError &&
|
||||
(err.message.includes('NetworkError') || err.message.includes('Failed to fetch'))
|
||||
) {
|
||||
setError(t('errors.serverConnection'));
|
||||
setError(t('errors.network'))
|
||||
} else if (err instanceof TypeError && (
|
||||
err.message.includes('NetworkError') ||
|
||||
err.message.includes('Failed to fetch')
|
||||
)) {
|
||||
setError(t('errors.serverConnection'))
|
||||
} else {
|
||||
setError(t('errors.serverUpdate', { serverName: server.name }));
|
||||
setError(t('errors.serverUpdate', { serverName: server.name }))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
@@ -58,7 +57,7 @@ const EditServerForm = ({ server, onEdit, onCancel }: EditServerFormProps) => {
|
||||
formError={error}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default EditServerForm;
|
||||
export default EditServerForm
|
||||
@@ -1,205 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RegistryServerEntry } from '@/types';
|
||||
|
||||
interface RegistryServerCardProps {
|
||||
serverEntry: RegistryServerEntry;
|
||||
onClick: (serverEntry: RegistryServerEntry) => void;
|
||||
}
|
||||
|
||||
const RegistryServerCard: React.FC<RegistryServerCardProps> = ({ serverEntry, onClick }) => {
|
||||
const { t } = useTranslation();
|
||||
const { server, _meta } = serverEntry;
|
||||
|
||||
const handleClick = () => {
|
||||
onClick(serverEntry);
|
||||
};
|
||||
|
||||
// Get display description
|
||||
const getDisplayDescription = () => {
|
||||
if (server.description && server.description.length <= 150) {
|
||||
return server.description;
|
||||
}
|
||||
return server.description
|
||||
? server.description.slice(0, 150) + '...'
|
||||
: t('registry.noDescription');
|
||||
};
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return '';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
const year = date.getFullYear();
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
return `${year}/${month}/${day}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Get icon to display
|
||||
const getIcon = () => {
|
||||
if (server.icons && server.icons.length > 0) {
|
||||
// Prefer light theme icon
|
||||
const lightIcon = server.icons.find((icon) => !icon.theme || icon.theme === 'light');
|
||||
return lightIcon || server.icons[0];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const icon = getIcon();
|
||||
const officialMeta = _meta?.['io.modelcontextprotocol.registry/official'];
|
||||
const isLatest = officialMeta?.isLatest;
|
||||
const publishedAt = officialMeta?.publishedAt;
|
||||
const updatedAt = officialMeta?.updatedAt;
|
||||
|
||||
// Count packages and remotes
|
||||
const packageCount = server.packages?.length || 0;
|
||||
const remoteCount = server.remotes?.length || 0;
|
||||
const totalOptions = packageCount + remoteCount;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-white border border-gray-200 rounded-xl p-4 hover:shadow-lg hover:border-blue-400 hover:-translate-y-1 transition-all duration-300 cursor-pointer group relative overflow-hidden h-full flex flex-col"
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Background gradient overlay on hover */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-50/0 to-purple-50/0 group-hover:from-blue-50/30 group-hover:to-purple-50/30 transition-all duration-300 pointer-events-none" />
|
||||
|
||||
{/* Server Header */}
|
||||
<div className="relative z-10 flex-1 flex flex-col">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-start space-x-3 flex-1">
|
||||
{/* Icon */}
|
||||
{icon ? (
|
||||
<img
|
||||
src={icon.src}
|
||||
alt={server.title}
|
||||
className="w-12 h-12 rounded-lg object-cover flex-shrink-0"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center text-white text-xl font-semibold flex-shrink-0">
|
||||
M
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title and badges */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-bold text-gray-900 group-hover:text-blue-600 transition-colors duration-200 mb-1 line-clamp-2">
|
||||
{server.name}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1 mb-1">
|
||||
{isLatest && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
{t('registry.latest')}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
v{server.version}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Server Name */}
|
||||
{/* <div className="mb-2">
|
||||
<p className="text-xs text-gray-500 font-mono">{server.name}</p>
|
||||
</div> */}
|
||||
|
||||
{/* Description */}
|
||||
<div className="mb-3 flex-1">
|
||||
<p className="text-gray-600 text-sm leading-relaxed line-clamp-3">
|
||||
{getDisplayDescription()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Installation Options Info */}
|
||||
{totalOptions > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center space-x-4">
|
||||
{packageCount > 0 && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<svg
|
||||
className="w-4 h-4 text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-gray-600">
|
||||
{packageCount}{' '}
|
||||
{packageCount === 1 ? t('registry.package') : t('registry.packages')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{remoteCount > 0 && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<svg
|
||||
className="w-4 h-4 text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-gray-600">
|
||||
{remoteCount} {remoteCount === 1 ? t('registry.remote') : t('registry.remotes')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer - fixed at bottom */}
|
||||
<div className="flex items-center justify-between pt-3 border-t border-gray-100 mt-auto">
|
||||
<div className="flex items-center space-x-2 text-xs text-gray-500">
|
||||
{(publishedAt || updatedAt) && (
|
||||
<>
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>{formatDate(updatedAt || publishedAt)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-blue-600 text-sm font-medium group-hover:text-blue-700 transition-colors">
|
||||
<span>{t('registry.viewDetails')}</span>
|
||||
<svg
|
||||
className="w-4 h-4 ml-1 transform group-hover:translate-x-1 transition-transform"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegistryServerCard;
|
||||
@@ -1,698 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
RegistryServerEntry,
|
||||
RegistryPackage,
|
||||
RegistryRemote,
|
||||
RegistryServerData,
|
||||
ServerConfig,
|
||||
} from '@/types';
|
||||
import ServerForm from './ServerForm';
|
||||
|
||||
interface RegistryServerDetailProps {
|
||||
serverEntry: RegistryServerEntry;
|
||||
onBack: () => void;
|
||||
onInstall?: (server: RegistryServerData, config: ServerConfig) => void;
|
||||
installing?: boolean;
|
||||
isInstalled?: boolean;
|
||||
fetchVersions?: (serverName: string) => Promise<RegistryServerEntry[]>;
|
||||
}
|
||||
|
||||
const RegistryServerDetail: React.FC<RegistryServerDetailProps> = ({
|
||||
serverEntry,
|
||||
onBack,
|
||||
onInstall,
|
||||
installing = false,
|
||||
isInstalled = false,
|
||||
fetchVersions,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { server, _meta } = serverEntry;
|
||||
|
||||
const [_selectedVersion, _setSelectedVersion] = useState<string>(server.version);
|
||||
const [_availableVersions, setAvailableVersions] = useState<RegistryServerEntry[]>([]);
|
||||
const [_loadingVersions, setLoadingVersions] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [selectedInstallType, setSelectedInstallType] = useState<'package' | 'remote' | null>(null);
|
||||
const [selectedOption, setSelectedOption] = useState<RegistryPackage | RegistryRemote | null>(
|
||||
null,
|
||||
);
|
||||
const [installError, setInstallError] = useState<string | null>(null);
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
|
||||
packages: true,
|
||||
remotes: true,
|
||||
repository: true,
|
||||
});
|
||||
|
||||
const officialMeta = _meta?.['io.modelcontextprotocol.registry/official'];
|
||||
|
||||
// Load available versions
|
||||
useEffect(() => {
|
||||
const loadVersions = async () => {
|
||||
if (fetchVersions) {
|
||||
setLoadingVersions(true);
|
||||
try {
|
||||
const versions = await fetchVersions(server.name);
|
||||
setAvailableVersions(versions);
|
||||
} catch (error) {
|
||||
console.error('Failed to load versions:', error);
|
||||
} finally {
|
||||
setLoadingVersions(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadVersions();
|
||||
}, [server.name, fetchVersions]);
|
||||
|
||||
// Get icon to display
|
||||
const getIcon = () => {
|
||||
if (server.icons && server.icons.length > 0) {
|
||||
const lightIcon = server.icons.find((icon) => !icon.theme || icon.theme === 'light');
|
||||
return lightIcon || server.icons[0];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const icon = getIcon();
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return '';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle section expansion
|
||||
const toggleSection = (section: string) => {
|
||||
setExpandedSections((prev) => ({ ...prev, [section]: !prev[section] }));
|
||||
};
|
||||
|
||||
// Handle install button click
|
||||
const handleInstallClick = (
|
||||
type: 'package' | 'remote',
|
||||
option: RegistryPackage | RegistryRemote,
|
||||
) => {
|
||||
setSelectedInstallType(type);
|
||||
setSelectedOption(option);
|
||||
setInstallError(null);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
// Handle modal close
|
||||
const handleModalClose = () => {
|
||||
setModalVisible(false);
|
||||
setInstallError(null);
|
||||
};
|
||||
|
||||
// Handle install submission
|
||||
const handleInstallSubmit = async (payload: any) => {
|
||||
try {
|
||||
if (!onInstall || !selectedOption || !selectedInstallType) return;
|
||||
|
||||
setInstallError(null);
|
||||
|
||||
// Extract the ServerConfig from the payload
|
||||
const config: ServerConfig = payload.config;
|
||||
|
||||
// Call onInstall with server data and config
|
||||
onInstall(server, config);
|
||||
setModalVisible(false);
|
||||
} catch (err) {
|
||||
console.error('Error installing server:', err);
|
||||
setInstallError(t('errors.serverInstall'));
|
||||
}
|
||||
};
|
||||
|
||||
// Build initial data for ServerForm
|
||||
const getInitialFormData = () => {
|
||||
if (!selectedOption || !selectedInstallType) return null;
|
||||
console.log('Building initial form data for:', selectedOption);
|
||||
|
||||
if (selectedInstallType === 'package' && 'identifier' in selectedOption) {
|
||||
const pkg = selectedOption as RegistryPackage;
|
||||
|
||||
// Build environment variables from package definition
|
||||
const env: Record<string, string> = {};
|
||||
if (pkg.environmentVariables) {
|
||||
pkg.environmentVariables.forEach((envVar) => {
|
||||
env[envVar.name] = envVar.default || '';
|
||||
});
|
||||
}
|
||||
|
||||
const command = getCommand(pkg.registryType);
|
||||
return {
|
||||
name: server.name,
|
||||
status: 'disconnected' as const,
|
||||
config: {
|
||||
type: 'stdio' as const,
|
||||
command: command,
|
||||
args: getArgs(command, pkg),
|
||||
env: Object.keys(env).length > 0 ? env : undefined,
|
||||
},
|
||||
};
|
||||
} else if (selectedInstallType === 'remote' && 'url' in selectedOption) {
|
||||
const remote = selectedOption as RegistryRemote;
|
||||
|
||||
// Build headers from remote definition
|
||||
const headers: Record<string, string> = {};
|
||||
if (remote.headers) {
|
||||
remote.headers.forEach((header) => {
|
||||
headers[header.name] = header.default || header.value || '';
|
||||
});
|
||||
}
|
||||
|
||||
// Determine transport type - default to streamable-http for remotes
|
||||
const transportType = remote.type === 'sse' ? ('sse' as const) : ('streamable-http' as const);
|
||||
|
||||
return {
|
||||
name: server.name,
|
||||
status: 'disconnected' as const,
|
||||
config: {
|
||||
type: transportType,
|
||||
url: remote.url,
|
||||
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Render package option
|
||||
const renderPackage = (pkg: RegistryPackage, index: number) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="border border-gray-200 rounded-lg p-4 mb-3 hover:border-blue-400 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-900">{pkg.identifier}</h4>
|
||||
{pkg.version && <p className="text-sm text-gray-500">Version: {pkg.version}</p>}
|
||||
{pkg.runtimeHint && <p className="text-sm text-gray-600 mt-1">{pkg.runtimeHint}</p>}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleInstallClick('package', pkg)}
|
||||
disabled={isInstalled || installing}
|
||||
className={`px-4 py-2 rounded text-sm font-medium transition-colors ${
|
||||
isInstalled
|
||||
? 'bg-green-600 text-white cursor-default'
|
||||
: installing
|
||||
? 'bg-gray-400 text-white cursor-not-allowed'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{isInstalled
|
||||
? t('registry.installed')
|
||||
: installing
|
||||
? t('registry.installing')
|
||||
: t('registry.install')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Package details */}
|
||||
{pkg.registryType && (
|
||||
<div className="text-sm text-gray-600 mb-2">
|
||||
<span className="font-medium">Registry:</span> {pkg.registryType}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transport type */}
|
||||
{pkg.transport && (
|
||||
<div className="text-sm text-gray-600 mb-2">
|
||||
<span className="font-medium">Transport:</span> {pkg.transport.type}
|
||||
{pkg.transport.url && <span className="ml-2 text-gray-500">({pkg.transport.url})</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Environment Variables */}
|
||||
{pkg.environmentVariables && pkg.environmentVariables.length > 0 && (
|
||||
<div className="mt-3 border-t border-gray-200 pt-3">
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-2">
|
||||
{t('registry.environmentVariables')}:
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
{pkg.environmentVariables.map((envVar, envIndex) => (
|
||||
<div key={envIndex} className="text-sm">
|
||||
<div className="flex items-start">
|
||||
<span className="font-mono text-gray-900 font-medium">{envVar.name}</span>
|
||||
{envVar.isRequired && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
|
||||
{t('common.required')}
|
||||
</span>
|
||||
)}
|
||||
{envVar.isSecret && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
{t('common.secret')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{envVar.description && <p className="text-gray-600 mt-1">{envVar.description}</p>}
|
||||
{envVar.default && (
|
||||
<p className="text-gray-500 mt-1">
|
||||
<span className="font-medium">{t('common.default')}:</span>{' '}
|
||||
<span className="font-mono">{envVar.default}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Package Arguments */}
|
||||
{pkg.packageArguments && pkg.packageArguments.length > 0 && (
|
||||
<div className="mt-3 border-t border-gray-200 pt-3">
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-2">
|
||||
{t('registry.packageArguments')}:
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
{pkg.packageArguments.map((arg, argIndex) => (
|
||||
<div key={argIndex} className="text-sm">
|
||||
<div className="flex items-start">
|
||||
<span className="font-mono text-gray-900 font-medium">{arg.name}</span>
|
||||
{arg.isRequired && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
|
||||
{t('common.required')}
|
||||
</span>
|
||||
)}
|
||||
{arg.isSecret && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
{t('common.secret')}
|
||||
</span>
|
||||
)}
|
||||
{arg.isRepeated && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800">
|
||||
{t('common.repeated')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{arg.description && <p className="text-gray-600 mt-1">{arg.description}</p>}
|
||||
{arg.type && (
|
||||
<p className="text-gray-500 mt-1">
|
||||
<span className="font-medium">{t('common.type')}:</span>{' '}
|
||||
<span className="font-mono">{arg.type}</span>
|
||||
</p>
|
||||
)}
|
||||
{arg.default && (
|
||||
<p className="text-gray-500 mt-1">
|
||||
<span className="font-medium">{t('common.default')}:</span>{' '}
|
||||
<span className="font-mono">{arg.default}</span>
|
||||
</p>
|
||||
)}
|
||||
{arg.value && (
|
||||
<p className="text-gray-500 mt-1">
|
||||
<span className="font-medium">{t('common.value')}:</span>{' '}
|
||||
<span className="font-mono">{arg.value}</span>
|
||||
</p>
|
||||
)}
|
||||
{arg.valueHint && (
|
||||
<p className="text-gray-500 mt-1">
|
||||
<span className="font-medium">{t('common.valueHint')}:</span>{' '}
|
||||
<span className="font-mono">{arg.valueHint}</span>
|
||||
</p>
|
||||
)}
|
||||
{arg.choices && arg.choices.length > 0 && (
|
||||
<p className="text-gray-500 mt-1">
|
||||
<span className="font-medium">{t('common.choices')}:</span>{' '}
|
||||
<span className="font-mono">{arg.choices.join(', ')}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render remote option
|
||||
const renderRemote = (remote: RegistryRemote, index: number) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="border border-gray-200 rounded-lg p-4 mb-3 hover:border-blue-400 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-900">{remote.type}</h4>
|
||||
<p className="text-sm text-gray-600 mt-1 break-all">{remote.url}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleInstallClick('remote', remote)}
|
||||
disabled={isInstalled || installing}
|
||||
className={`px-4 py-2 rounded text-sm font-medium transition-colors ${
|
||||
isInstalled
|
||||
? 'bg-green-600 text-white cursor-default'
|
||||
: installing
|
||||
? 'bg-gray-400 text-white cursor-not-allowed'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{isInstalled
|
||||
? t('registry.installed')
|
||||
: installing
|
||||
? t('registry.installing')
|
||||
: t('registry.install')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Headers */}
|
||||
{remote.headers && remote.headers.length > 0 && (
|
||||
<div className="mt-3 border-t border-gray-200 pt-3">
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-2">{t('registry.headers')}:</h5>
|
||||
<div className="space-y-2">
|
||||
{remote.headers.map((header, headerIndex) => (
|
||||
<div key={headerIndex} className="text-sm">
|
||||
<div className="flex items-start">
|
||||
<span className="font-mono text-gray-900 font-medium">{header.name}</span>
|
||||
{header.isRequired && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
|
||||
{t('common.required')}
|
||||
</span>
|
||||
)}
|
||||
{header.isSecret && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
{t('common.secret')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{header.description && <p className="text-gray-600 mt-1">{header.description}</p>}
|
||||
{header.value && (
|
||||
<p className="text-gray-500 mt-1">
|
||||
<span className="font-medium">{t('common.value')}:</span>{' '}
|
||||
<span className="font-mono">{header.value}</span>
|
||||
</p>
|
||||
)}
|
||||
{header.default && (
|
||||
<p className="text-gray-500 mt-1">
|
||||
<span className="font-medium">{t('common.default')}:</span>{' '}
|
||||
<span className="font-mono">{header.default}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center text-blue-600 hover:text-blue-800 mb-4 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
{t('registry.backToList')}
|
||||
</button>
|
||||
|
||||
<div className="flex items-start space-x-4">
|
||||
{/* Icon */}
|
||||
{icon ? (
|
||||
<img
|
||||
src={icon.src}
|
||||
alt={server.title}
|
||||
className="w-20 h-20 rounded-lg object-cover flex-shrink-0"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-20 h-20 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center text-white text-3xl font-semibold flex-shrink-0">
|
||||
M
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title and metadata */}
|
||||
<div className="flex-1">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">{server.name}</h1>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{officialMeta?.isLatest && (
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800">
|
||||
{t('registry.latest')}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800">
|
||||
v{server.version}
|
||||
</span>
|
||||
{officialMeta?.status && (
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-800">
|
||||
{officialMeta.status}
|
||||
</span>
|
||||
)}
|
||||
{/* Dates */}
|
||||
<span className="flex flex-wrap items-center gap-4 text-sm text-gray-600">
|
||||
{officialMeta?.publishedAt && (
|
||||
<div>
|
||||
<span className="font-medium">{t('registry.published')}:</span>{' '}
|
||||
{formatDate(officialMeta.publishedAt)}
|
||||
</div>
|
||||
)}
|
||||
{officialMeta?.updatedAt && (
|
||||
<div>
|
||||
<span className="font-medium">{t('registry.updated')}:</span>{' '}
|
||||
{formatDate(officialMeta.updatedAt)}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-3">{t('registry.description')}</h2>
|
||||
<p className="text-gray-700 leading-relaxed whitespace-pre-wrap">{server.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Website */}
|
||||
{server.websiteUrl && (
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-3">{t('registry.website')}</h2>
|
||||
<a
|
||||
href={server.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 hover:underline"
|
||||
>
|
||||
{server.websiteUrl}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Packages */}
|
||||
{server.packages && server.packages.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => toggleSection('packages')}
|
||||
className="flex items-center justify-between w-full text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
<span>
|
||||
{t('registry.packages')} ({server.packages.length})
|
||||
</span>
|
||||
<svg
|
||||
className={`w-5 h-5 transform transition-transform ${expandedSections.packages ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{expandedSections.packages && (
|
||||
<div className="space-y-3">{server.packages.map(renderPackage)}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remotes */}
|
||||
{server.remotes && server.remotes.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => toggleSection('remotes')}
|
||||
className="flex items-center justify-between w-full text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
<span>
|
||||
{t('registry.remotes')} ({server.remotes.length})
|
||||
</span>
|
||||
<svg
|
||||
className={`w-5 h-5 transform transition-transform ${expandedSections.remotes ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{expandedSections.remotes && (
|
||||
<div className="space-y-3">{server.remotes.map(renderRemote)}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Repository */}
|
||||
{server.repository && (
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => toggleSection('repository')}
|
||||
className="flex items-center justify-between w-full text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
<span>{t('registry.repository')}</span>
|
||||
<svg
|
||||
className={`w-5 h-5 transform transition-transform ${expandedSections.repository ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{expandedSections.repository && (
|
||||
<div className="border border-gray-200 rounded-lg p-4">
|
||||
{server.repository.url && (
|
||||
<div className="mb-2">
|
||||
<span className="font-medium text-gray-700">URL:</span>{' '}
|
||||
<a
|
||||
href={server.repository.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 hover:underline break-all"
|
||||
>
|
||||
{server.repository.url}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{server.repository.source && (
|
||||
<div className="mb-2">
|
||||
<span className="font-medium text-gray-700">Source:</span>{' '}
|
||||
{server.repository.source}
|
||||
</div>
|
||||
)}
|
||||
{server.repository.subfolder && (
|
||||
<div className="mb-2">
|
||||
<span className="font-medium text-gray-700">Subfolder:</span>{' '}
|
||||
{server.repository.subfolder}
|
||||
</div>
|
||||
)}
|
||||
{server.repository.id && (
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">ID:</span> {server.repository.id}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Install Modal */}
|
||||
{modalVisible && selectedOption && selectedInstallType && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<ServerForm
|
||||
onSubmit={handleInstallSubmit}
|
||||
onCancel={handleModalClose}
|
||||
modalTitle={t('registry.installServer', { name: server.title || server.name })}
|
||||
formError={installError}
|
||||
initialData={getInitialFormData()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegistryServerDetail;
|
||||
// Helper function to determine command based on registry type
|
||||
function getCommand(registryType: string): string {
|
||||
// Map registry types to appropriate commands
|
||||
switch (registryType.toLowerCase()) {
|
||||
case 'pypi':
|
||||
case 'python':
|
||||
return 'uvx';
|
||||
case 'npm':
|
||||
case 'node':
|
||||
return 'npx';
|
||||
case 'oci':
|
||||
case 'docker':
|
||||
return 'docker';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get appropriate args based on command type and package identifier
|
||||
function getArgs(command: string, pkg: RegistryPackage): string[] {
|
||||
const identifier = [pkg.identifier + (pkg.version ? `@${pkg.version}` : '')];
|
||||
|
||||
// Build package arguments if available
|
||||
const packageArgs: string[] = [];
|
||||
if (pkg.packageArguments && pkg.packageArguments.length > 0) {
|
||||
pkg.packageArguments.forEach((arg) => {
|
||||
// Add required arguments or arguments with default values
|
||||
if (arg.isRequired || arg.default || arg.value) {
|
||||
const argName = `--${arg.name}`;
|
||||
// Priority: value > default > placeholder
|
||||
const argValue = arg.value || arg.default || `\${${arg.name.toUpperCase()}}`;
|
||||
packageArgs.push(argName, argValue);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Map commands to appropriate argument patterns
|
||||
switch (command.toLowerCase()) {
|
||||
case 'uvx':
|
||||
// For Python packages: uvx package-name --arg1 value1 --arg2 value2
|
||||
return [...identifier, ...packageArgs];
|
||||
case 'npx':
|
||||
// For Node.js packages: npx package-name --arg1 value1 --arg2 value2
|
||||
return [...identifier, ...packageArgs];
|
||||
case 'docker': {
|
||||
// add envs from environment variables if available
|
||||
const envs: string[] = [];
|
||||
if (pkg.environmentVariables) {
|
||||
pkg.environmentVariables.forEach((env) => {
|
||||
envs.push('-e', `${env.name}`);
|
||||
});
|
||||
}
|
||||
// For Docker images: docker run -i package-name --arg1 value1 --arg2 value2
|
||||
return ['run', '-i', '--rm', ...envs, ...identifier, ...packageArgs];
|
||||
}
|
||||
default:
|
||||
// If no specific pattern is defined, return identifier with package args
|
||||
return [...identifier, ...packageArgs];
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import ToolCard from '@/components/ui/ToolCard'
|
||||
import PromptCard from '@/components/ui/PromptCard'
|
||||
import DeleteDialog from '@/components/ui/DeleteDialog'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
import { useSettingsData } from '@/hooks/useSettingsData'
|
||||
|
||||
interface ServerCardProps {
|
||||
server: Server
|
||||
@@ -40,8 +39,6 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
}
|
||||
}, [])
|
||||
|
||||
const { exportMCPSettings } = useSettingsData()
|
||||
|
||||
const handleRemove = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setShowDeleteDialog(true)
|
||||
@@ -102,39 +99,6 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyServerConfig = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
try {
|
||||
const result = await exportMCPSettings(server.name)
|
||||
const configJson = JSON.stringify(result.data, null, 2)
|
||||
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(configJson)
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
|
||||
} else {
|
||||
// Fallback for HTTP or unsupported clipboard API
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = configJson
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-9999px'
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
|
||||
} catch (err) {
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error')
|
||||
console.error('Copy to clipboard failed:', err)
|
||||
}
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error copying server configuration:', error)
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
onRemove(server.name)
|
||||
setShowDeleteDialog(false)
|
||||
@@ -147,7 +111,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
if (result.success) {
|
||||
showToast(
|
||||
t(enabled ? 'tool.enableSuccess' : 'tool.disableSuccess', { name: toolName }),
|
||||
'success',
|
||||
'success'
|
||||
)
|
||||
// Trigger refresh to update the tool's state in the UI
|
||||
if (onRefresh) {
|
||||
@@ -169,7 +133,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
if (result.success) {
|
||||
showToast(
|
||||
t(enabled ? 'tool.enableSuccess' : 'tool.disableSuccess', { name: promptName }),
|
||||
'success',
|
||||
'success'
|
||||
)
|
||||
// Trigger refresh to update the prompt's state in the UI
|
||||
if (onRefresh) {
|
||||
@@ -186,33 +150,21 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`bg-white shadow rounded-lg p-6 mb-6 page-card transition-all duration-200 ${server.enabled === false ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<div className={`bg-white shadow rounded-lg p-6 mb-6 page-card transition-all duration-200 ${server.enabled === false ? 'opacity-60' : ''}`}>
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<h2
|
||||
className={`text-xl font-semibold ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'}`}
|
||||
>
|
||||
{server.name}
|
||||
</h2>
|
||||
<h2 className={`text-xl font-semibold ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'}`}>{server.name}</h2>
|
||||
<StatusBadge status={server.status} />
|
||||
|
||||
{/* Tool count display */}
|
||||
<div className="flex items-center px-2 py-1 bg-blue-50 text-blue-700 rounded-full text-sm btn-primary">
|
||||
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>
|
||||
{server.tools?.length || 0} {t('server.tools')}
|
||||
</span>
|
||||
<span>{server.tools?.length || 0} {t('server.tools')}</span>
|
||||
</div>
|
||||
|
||||
{/* Prompt count display */}
|
||||
@@ -221,9 +173,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
<path d="M2 5a2 2 0 012-2h7a2 2 0 012 2v4a2 2 0 01-2 2H9l-3 3v-3H4a2 2 0 01-2-2V5z" />
|
||||
<path d="M15 7v2a4 4 0 01-4 4H9.828l-1.766 1.767c.28.149.599.233.938.233h2l3 3v-3h2a2 2 0 002-2V9a2 2 0 00-2-2h-1z" />
|
||||
</svg>
|
||||
<span>
|
||||
{server.prompts?.length || 0} {t('server.prompts')}
|
||||
</span>
|
||||
<span>{server.prompts?.length || 0} {t('server.prompts')}</span>
|
||||
</div>
|
||||
|
||||
{server.error && (
|
||||
@@ -246,25 +196,19 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
width: '480px',
|
||||
transform: 'translateX(50%)',
|
||||
transform: 'translateX(50%)'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex justify-between items-center sticky top-0 bg-white py-2 px-4 border-b border-gray-200 z-20 shadow-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h4 className="text-sm font-medium text-red-600">
|
||||
{t('server.errorDetails')}
|
||||
</h4>
|
||||
<h4 className="text-sm font-medium text-red-600">{t('server.errorDetails')}</h4>
|
||||
<button
|
||||
onClick={copyToClipboard}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors btn-secondary"
|
||||
title={t('common.copy')}
|
||||
>
|
||||
{copied ? (
|
||||
<Check size={14} className="text-green-500" />
|
||||
) : (
|
||||
<Copy size={14} />
|
||||
)}
|
||||
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@@ -278,9 +222,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 pt-2">
|
||||
<pre className="text-sm text-gray-700 break-words whitespace-pre-wrap">
|
||||
{server.error}
|
||||
</pre>
|
||||
<pre className="text-sm text-gray-700 break-words whitespace-pre-wrap">{server.error}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -288,9 +230,6 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
)}
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button onClick={handleCopyServerConfig} className={`px-3 py-1 btn-secondary`}>
|
||||
{t('server.copy')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
className="px-3 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary"
|
||||
@@ -300,20 +239,20 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className={`px-3 py-1 text-sm rounded transition-colors ${
|
||||
isToggling
|
||||
? 'bg-gray-200 text-gray-500'
|
||||
: server.enabled !== false
|
||||
? 'bg-green-100 text-green-800 hover:bg-green-200 btn-secondary'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-primary'
|
||||
}`}
|
||||
className={`px-3 py-1 text-sm rounded transition-colors ${isToggling
|
||||
? 'bg-gray-200 text-gray-500'
|
||||
: server.enabled !== false
|
||||
? 'bg-green-100 text-green-800 hover:bg-green-200 btn-secondary'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-primary'
|
||||
}`}
|
||||
disabled={isToggling}
|
||||
>
|
||||
{isToggling
|
||||
? t('common.processing')
|
||||
: server.enabled !== false
|
||||
? t('server.disable')
|
||||
: t('server.enable')}
|
||||
: t('server.enable')
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@@ -332,19 +271,10 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
<>
|
||||
{server.tools && (
|
||||
<div className="mt-6">
|
||||
<h6
|
||||
className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}
|
||||
>
|
||||
{t('server.tools')}
|
||||
</h6>
|
||||
<h6 className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}>{t('server.tools')}</h6>
|
||||
<div className="space-y-4">
|
||||
{server.tools.map((tool, index) => (
|
||||
<ToolCard
|
||||
key={index}
|
||||
server={server.name}
|
||||
tool={tool}
|
||||
onToggle={handleToolToggle}
|
||||
/>
|
||||
<ToolCard key={index} server={server.name} tool={tool} onToggle={handleToolToggle} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -352,18 +282,14 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
|
||||
{server.prompts && (
|
||||
<div className="mt-6">
|
||||
<h6
|
||||
className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}
|
||||
>
|
||||
{t('server.prompts')}
|
||||
</h6>
|
||||
<h6 className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}>{t('server.prompts')}</h6>
|
||||
<div className="space-y-4">
|
||||
{server.prompts.map((prompt, index) => (
|
||||
<PromptCard
|
||||
key={index}
|
||||
server={server.name}
|
||||
prompt={prompt}
|
||||
onToggle={handlePromptToggle}
|
||||
<PromptCard
|
||||
key={index}
|
||||
server={server.name}
|
||||
prompt={prompt}
|
||||
onToggle={handlePromptToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -383,4 +309,4 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
)
|
||||
}
|
||||
|
||||
export default ServerCard
|
||||
export default ServerCard
|
||||
@@ -1,23 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Server, EnvVar, ServerFormData } from '@/types';
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Server, EnvVar, ServerFormData } from '@/types'
|
||||
|
||||
interface ServerFormProps {
|
||||
onSubmit: (payload: any) => void;
|
||||
onCancel: () => void;
|
||||
initialData?: Server | null;
|
||||
modalTitle: string;
|
||||
formError?: string | null;
|
||||
onSubmit: (payload: any) => void
|
||||
onCancel: () => void
|
||||
initialData?: Server | null
|
||||
modalTitle: string
|
||||
formError?: string | null
|
||||
}
|
||||
|
||||
const ServerForm = ({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
initialData = null,
|
||||
modalTitle,
|
||||
formError = null,
|
||||
}: ServerFormProps) => {
|
||||
const { t } = useTranslation();
|
||||
const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formError = null }: ServerFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Determine the initial server type from the initialData
|
||||
const getInitialServerType = () => {
|
||||
@@ -32,19 +26,7 @@ const ServerForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const getInitialServerEnvVars = (data: Server | null): EnvVar[] => {
|
||||
if (!data || !data.config || !data.config.env) return [];
|
||||
|
||||
return Object.entries(data.config.env).map(([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
description: '', // You can set a default description if needed
|
||||
}));
|
||||
};
|
||||
|
||||
const [serverType, setServerType] = useState<'stdio' | 'sse' | 'streamable-http' | 'openapi'>(
|
||||
getInitialServerType(),
|
||||
);
|
||||
const [serverType, setServerType] = useState<'stdio' | 'sse' | 'streamable-http' | 'openapi'>(getInitialServerType());
|
||||
|
||||
const [formData, setFormData] = useState<ServerFormData>({
|
||||
name: (initialData && initialData.name) || '',
|
||||
@@ -58,178 +40,149 @@ const ServerForm = ({
|
||||
: '',
|
||||
args: (initialData && initialData.config && initialData.config.args) || [],
|
||||
type: getInitialServerType(), // Initialize the type field
|
||||
env: getInitialServerEnvVars(initialData),
|
||||
env: [],
|
||||
headers: [],
|
||||
options: {
|
||||
timeout:
|
||||
(initialData &&
|
||||
initialData.config &&
|
||||
initialData.config.options &&
|
||||
initialData.config.options.timeout) ||
|
||||
60000,
|
||||
resetTimeoutOnProgress:
|
||||
(initialData &&
|
||||
initialData.config &&
|
||||
initialData.config.options &&
|
||||
initialData.config.options.resetTimeoutOnProgress) ||
|
||||
false,
|
||||
maxTotalTimeout:
|
||||
(initialData &&
|
||||
initialData.config &&
|
||||
initialData.config.options &&
|
||||
initialData.config.options.maxTotalTimeout) ||
|
||||
undefined,
|
||||
timeout: (initialData && initialData.config && initialData.config.options && initialData.config.options.timeout) || 60000,
|
||||
resetTimeoutOnProgress: (initialData && initialData.config && initialData.config.options && initialData.config.options.resetTimeoutOnProgress) || false,
|
||||
maxTotalTimeout: (initialData && initialData.config && initialData.config.options && initialData.config.options.maxTotalTimeout) || undefined,
|
||||
},
|
||||
// OpenAPI configuration initialization
|
||||
openapi:
|
||||
initialData && initialData.config && initialData.config.openapi
|
||||
? {
|
||||
url: initialData.config.openapi.url || '',
|
||||
schema: initialData.config.openapi.schema
|
||||
? JSON.stringify(initialData.config.openapi.schema, null, 2)
|
||||
: '',
|
||||
inputMode: initialData.config.openapi.url
|
||||
? 'url'
|
||||
: initialData.config.openapi.schema
|
||||
? 'schema'
|
||||
: 'url',
|
||||
version: initialData.config.openapi.version || '3.1.0',
|
||||
securityType: initialData.config.openapi.security?.type || 'none',
|
||||
// API Key initialization
|
||||
apiKeyName: initialData.config.openapi.security?.apiKey?.name || '',
|
||||
apiKeyIn: initialData.config.openapi.security?.apiKey?.in || 'header',
|
||||
apiKeyValue: initialData.config.openapi.security?.apiKey?.value || '',
|
||||
// HTTP auth initialization
|
||||
httpScheme: initialData.config.openapi.security?.http?.scheme || 'bearer',
|
||||
httpCredentials: initialData.config.openapi.security?.http?.credentials || '',
|
||||
// OAuth2 initialization
|
||||
oauth2Token: initialData.config.openapi.security?.oauth2?.token || '',
|
||||
// OpenID Connect initialization
|
||||
openIdConnectUrl: initialData.config.openapi.security?.openIdConnect?.url || '',
|
||||
openIdConnectToken: initialData.config.openapi.security?.openIdConnect?.token || '',
|
||||
// Passthrough headers initialization
|
||||
passthroughHeaders: initialData.config.openapi.passthroughHeaders
|
||||
? initialData.config.openapi.passthroughHeaders.join(', ')
|
||||
: '',
|
||||
}
|
||||
: {
|
||||
inputMode: 'url',
|
||||
url: '',
|
||||
schema: '',
|
||||
version: '3.1.0',
|
||||
securityType: 'none',
|
||||
passthroughHeaders: '',
|
||||
},
|
||||
});
|
||||
openapi: initialData && initialData.config && initialData.config.openapi ? {
|
||||
url: initialData.config.openapi.url || '',
|
||||
schema: initialData.config.openapi.schema ? JSON.stringify(initialData.config.openapi.schema, null, 2) : '',
|
||||
inputMode: initialData.config.openapi.url ? 'url' : (initialData.config.openapi.schema ? 'schema' : 'url'),
|
||||
version: initialData.config.openapi.version || '3.1.0',
|
||||
securityType: initialData.config.openapi.security?.type || 'none',
|
||||
// API Key initialization
|
||||
apiKeyName: initialData.config.openapi.security?.apiKey?.name || '',
|
||||
apiKeyIn: initialData.config.openapi.security?.apiKey?.in || 'header',
|
||||
apiKeyValue: initialData.config.openapi.security?.apiKey?.value || '',
|
||||
// HTTP auth initialization
|
||||
httpScheme: initialData.config.openapi.security?.http?.scheme || 'bearer',
|
||||
httpCredentials: initialData.config.openapi.security?.http?.credentials || '',
|
||||
// OAuth2 initialization
|
||||
oauth2Token: initialData.config.openapi.security?.oauth2?.token || '',
|
||||
// OpenID Connect initialization
|
||||
openIdConnectUrl: initialData.config.openapi.security?.openIdConnect?.url || '',
|
||||
openIdConnectToken: initialData.config.openapi.security?.openIdConnect?.token || '',
|
||||
// Passthrough headers initialization
|
||||
passthroughHeaders: initialData.config.openapi.passthroughHeaders ? initialData.config.openapi.passthroughHeaders.join(', ') : '',
|
||||
} : {
|
||||
inputMode: 'url',
|
||||
url: '',
|
||||
schema: '',
|
||||
version: '3.1.0',
|
||||
securityType: 'none',
|
||||
passthroughHeaders: '',
|
||||
}
|
||||
})
|
||||
|
||||
const [envVars, setEnvVars] = useState<EnvVar[]>(
|
||||
initialData && initialData.config && initialData.config.env
|
||||
? Object.entries(initialData.config.env).map(([key, value]) => ({ key, value }))
|
||||
: [],
|
||||
);
|
||||
)
|
||||
|
||||
const [headerVars, setHeaderVars] = useState<EnvVar[]>(
|
||||
initialData && initialData.config && initialData.config.headers
|
||||
? Object.entries(initialData.config.headers).map(([key, value]) => ({ key, value }))
|
||||
: [],
|
||||
);
|
||||
)
|
||||
|
||||
const [isRequestOptionsExpanded, setIsRequestOptionsExpanded] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isEdit = !!initialData;
|
||||
const [isRequestOptionsExpanded, setIsRequestOptionsExpanded] = useState<boolean>(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const isEdit = !!initialData
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData({ ...formData, [name]: value });
|
||||
};
|
||||
const { name, value } = e.target
|
||||
setFormData({ ...formData, [name]: value })
|
||||
}
|
||||
|
||||
// Transform space-separated arguments string into array
|
||||
const handleArgsChange = (value: string) => {
|
||||
const args = value.split(' ').filter((arg) => arg.trim() !== '');
|
||||
setFormData({ ...formData, arguments: value, args });
|
||||
};
|
||||
const args = value.split(' ').filter((arg) => arg.trim() !== '')
|
||||
setFormData({ ...formData, arguments: value, args })
|
||||
}
|
||||
|
||||
const updateServerType = (type: 'stdio' | 'sse' | 'streamable-http' | 'openapi') => {
|
||||
setServerType(type);
|
||||
setFormData((prev) => ({ ...prev, type }));
|
||||
};
|
||||
setFormData(prev => ({ ...prev, type }));
|
||||
}
|
||||
|
||||
const handleEnvVarChange = (index: number, field: 'key' | 'value', value: string) => {
|
||||
const newEnvVars = [...envVars];
|
||||
newEnvVars[index][field] = value;
|
||||
setEnvVars(newEnvVars);
|
||||
};
|
||||
const newEnvVars = [...envVars]
|
||||
newEnvVars[index][field] = value
|
||||
setEnvVars(newEnvVars)
|
||||
}
|
||||
|
||||
const addEnvVar = () => {
|
||||
setEnvVars([...envVars, { key: '', value: '' }]);
|
||||
};
|
||||
setEnvVars([...envVars, { key: '', value: '' }])
|
||||
}
|
||||
|
||||
const removeEnvVar = (index: number) => {
|
||||
const newEnvVars = [...envVars];
|
||||
newEnvVars.splice(index, 1);
|
||||
setEnvVars(newEnvVars);
|
||||
};
|
||||
const newEnvVars = [...envVars]
|
||||
newEnvVars.splice(index, 1)
|
||||
setEnvVars(newEnvVars)
|
||||
}
|
||||
|
||||
const handleHeaderVarChange = (index: number, field: 'key' | 'value', value: string) => {
|
||||
const newHeaderVars = [...headerVars];
|
||||
newHeaderVars[index][field] = value;
|
||||
setHeaderVars(newHeaderVars);
|
||||
};
|
||||
const newHeaderVars = [...headerVars]
|
||||
newHeaderVars[index][field] = value
|
||||
setHeaderVars(newHeaderVars)
|
||||
}
|
||||
|
||||
const addHeaderVar = () => {
|
||||
setHeaderVars([...headerVars, { key: '', value: '' }]);
|
||||
};
|
||||
setHeaderVars([...headerVars, { key: '', value: '' }])
|
||||
}
|
||||
|
||||
const removeHeaderVar = (index: number) => {
|
||||
const newHeaderVars = [...headerVars];
|
||||
newHeaderVars.splice(index, 1);
|
||||
setHeaderVars(newHeaderVars);
|
||||
};
|
||||
const newHeaderVars = [...headerVars]
|
||||
newHeaderVars.splice(index, 1)
|
||||
setHeaderVars(newHeaderVars)
|
||||
}
|
||||
|
||||
// Handle options changes
|
||||
const handleOptionsChange = (
|
||||
field: 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout',
|
||||
value: number | boolean | undefined,
|
||||
) => {
|
||||
setFormData((prev) => ({
|
||||
const handleOptionsChange = (field: 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout', value: number | boolean | undefined) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
options: {
|
||||
...prev.options,
|
||||
[field]: value,
|
||||
},
|
||||
}));
|
||||
};
|
||||
[field]: value
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// Submit handler for server configuration
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const env: Record<string, string> = {};
|
||||
const env: Record<string, string> = {}
|
||||
envVars.forEach(({ key, value }) => {
|
||||
if (key.trim()) {
|
||||
env[key.trim()] = value;
|
||||
env[key.trim()] = value
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
const headers: Record<string, string> = {}
|
||||
headerVars.forEach(({ key, value }) => {
|
||||
if (key.trim()) {
|
||||
headers[key.trim()] = value;
|
||||
headers[key.trim()] = value
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
// Prepare options object, only include defined values
|
||||
const options: any = {};
|
||||
const options: any = {}
|
||||
if (formData.options?.timeout && formData.options.timeout !== 60000) {
|
||||
options.timeout = formData.options.timeout;
|
||||
options.timeout = formData.options.timeout
|
||||
}
|
||||
if (formData.options?.resetTimeoutOnProgress) {
|
||||
options.resetTimeoutOnProgress = formData.options.resetTimeoutOnProgress;
|
||||
options.resetTimeoutOnProgress = formData.options.resetTimeoutOnProgress
|
||||
}
|
||||
if (formData.options?.maxTotalTimeout) {
|
||||
options.maxTotalTimeout = formData.options.maxTotalTimeout;
|
||||
options.maxTotalTimeout = formData.options.maxTotalTimeout
|
||||
}
|
||||
|
||||
const payload = {
|
||||
@@ -238,87 +191,85 @@ const ServerForm = ({
|
||||
type: serverType, // Always include the type
|
||||
...(serverType === 'openapi'
|
||||
? {
|
||||
openapi: (() => {
|
||||
const openapi: any = {
|
||||
version: formData.openapi?.version || '3.1.0',
|
||||
openapi: (() => {
|
||||
const openapi: any = {
|
||||
version: formData.openapi?.version || '3.1.0'
|
||||
};
|
||||
|
||||
// Add URL or schema based on input mode
|
||||
if (formData.openapi?.inputMode === 'url') {
|
||||
openapi.url = formData.openapi?.url || '';
|
||||
} else if (formData.openapi?.inputMode === 'schema' && formData.openapi?.schema) {
|
||||
try {
|
||||
openapi.schema = JSON.parse(formData.openapi.schema);
|
||||
} catch (e) {
|
||||
throw new Error('Invalid JSON schema format');
|
||||
}
|
||||
}
|
||||
|
||||
// Add security configuration if provided
|
||||
if (formData.openapi?.securityType && formData.openapi.securityType !== 'none') {
|
||||
openapi.security = {
|
||||
type: formData.openapi.securityType,
|
||||
...(formData.openapi.securityType === 'apiKey' && {
|
||||
apiKey: {
|
||||
name: formData.openapi.apiKeyName || '',
|
||||
in: formData.openapi.apiKeyIn || 'header',
|
||||
value: formData.openapi.apiKeyValue || ''
|
||||
}
|
||||
}),
|
||||
...(formData.openapi.securityType === 'http' && {
|
||||
http: {
|
||||
scheme: formData.openapi.httpScheme || 'bearer',
|
||||
credentials: formData.openapi.httpCredentials || ''
|
||||
}
|
||||
}),
|
||||
...(formData.openapi.securityType === 'oauth2' && {
|
||||
oauth2: {
|
||||
token: formData.openapi.oauth2Token || ''
|
||||
}
|
||||
}),
|
||||
...(formData.openapi.securityType === 'openIdConnect' && {
|
||||
openIdConnect: {
|
||||
url: formData.openapi.openIdConnectUrl || '',
|
||||
token: formData.openapi.openIdConnectToken || ''
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
// Add URL or schema based on input mode
|
||||
if (formData.openapi?.inputMode === 'url') {
|
||||
openapi.url = formData.openapi?.url || '';
|
||||
} else if (formData.openapi?.inputMode === 'schema' && formData.openapi?.schema) {
|
||||
try {
|
||||
openapi.schema = JSON.parse(formData.openapi.schema);
|
||||
} catch (e) {
|
||||
throw new Error('Invalid JSON schema format');
|
||||
}
|
||||
}
|
||||
// Add passthrough headers if provided
|
||||
if (formData.openapi?.passthroughHeaders && formData.openapi.passthroughHeaders.trim()) {
|
||||
openapi.passthroughHeaders = formData.openapi.passthroughHeaders
|
||||
.split(',')
|
||||
.map(header => header.trim())
|
||||
.filter(header => header.length > 0);
|
||||
}
|
||||
|
||||
// Add security configuration if provided
|
||||
if (formData.openapi?.securityType && formData.openapi.securityType !== 'none') {
|
||||
openapi.security = {
|
||||
type: formData.openapi.securityType,
|
||||
...(formData.openapi.securityType === 'apiKey' && {
|
||||
apiKey: {
|
||||
name: formData.openapi.apiKeyName || '',
|
||||
in: formData.openapi.apiKeyIn || 'header',
|
||||
value: formData.openapi.apiKeyValue || '',
|
||||
},
|
||||
}),
|
||||
...(formData.openapi.securityType === 'http' && {
|
||||
http: {
|
||||
scheme: formData.openapi.httpScheme || 'bearer',
|
||||
credentials: formData.openapi.httpCredentials || '',
|
||||
},
|
||||
}),
|
||||
...(formData.openapi.securityType === 'oauth2' && {
|
||||
oauth2: {
|
||||
token: formData.openapi.oauth2Token || '',
|
||||
},
|
||||
}),
|
||||
...(formData.openapi.securityType === 'openIdConnect' && {
|
||||
openIdConnect: {
|
||||
url: formData.openapi.openIdConnectUrl || '',
|
||||
token: formData.openapi.openIdConnectToken || '',
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Add passthrough headers if provided
|
||||
if (
|
||||
formData.openapi?.passthroughHeaders &&
|
||||
formData.openapi.passthroughHeaders.trim()
|
||||
) {
|
||||
openapi.passthroughHeaders = formData.openapi.passthroughHeaders
|
||||
.split(',')
|
||||
.map((header) => header.trim())
|
||||
.filter((header) => header.length > 0);
|
||||
}
|
||||
|
||||
return openapi;
|
||||
})(),
|
||||
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
||||
}
|
||||
return openapi;
|
||||
})(),
|
||||
...(Object.keys(headers).length > 0 ? { headers } : {})
|
||||
}
|
||||
: serverType === 'sse' || serverType === 'streamable-http'
|
||||
? {
|
||||
url: formData.url,
|
||||
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
||||
}
|
||||
url: formData.url,
|
||||
...(Object.keys(headers).length > 0 ? { headers } : {})
|
||||
}
|
||||
: {
|
||||
command: formData.command,
|
||||
args: formData.args,
|
||||
env: Object.keys(env).length > 0 ? env : undefined,
|
||||
}),
|
||||
...(Object.keys(options).length > 0 ? { options } : {}),
|
||||
},
|
||||
};
|
||||
command: formData.command,
|
||||
args: formData.args,
|
||||
env: Object.keys(env).length > 0 ? env : undefined,
|
||||
}
|
||||
),
|
||||
...(Object.keys(options).length > 0 ? { options } : {})
|
||||
}
|
||||
}
|
||||
|
||||
onSubmit(payload);
|
||||
onSubmit(payload)
|
||||
} catch (err) {
|
||||
setError(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
setError(`Error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg p-6 w-full max-w-xl max-h-screen overflow-y-auto">
|
||||
@@ -330,7 +281,9 @@ const ServerForm = ({
|
||||
</div>
|
||||
|
||||
{(error || formError) && (
|
||||
<div className="bg-red-50 text-red-700 p-3 rounded mb-4">{formError || error}</div>
|
||||
<div className="bg-red-50 text-red-700 p-3 rounded mb-4">
|
||||
{formError || error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
@@ -420,12 +373,10 @@ const ServerForm = ({
|
||||
name="inputMode"
|
||||
value="url"
|
||||
checked={formData.openapi?.inputMode === 'url'}
|
||||
onChange={() =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
openapi: { ...prev.openapi!, inputMode: 'url' },
|
||||
}))
|
||||
}
|
||||
onChange={() => setFormData(prev => ({
|
||||
...prev,
|
||||
openapi: { ...prev.openapi!, inputMode: 'url' }
|
||||
}))}
|
||||
className="mr-1"
|
||||
/>
|
||||
<label htmlFor="input-mode-url">{t('server.openapi.inputModeUrl')}</label>
|
||||
@@ -437,12 +388,10 @@ const ServerForm = ({
|
||||
name="inputMode"
|
||||
value="schema"
|
||||
checked={formData.openapi?.inputMode === 'schema'}
|
||||
onChange={() =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
openapi: { ...prev.openapi!, inputMode: 'schema' },
|
||||
}))
|
||||
}
|
||||
onChange={() => setFormData(prev => ({
|
||||
...prev,
|
||||
openapi: { ...prev.openapi!, inputMode: 'schema' }
|
||||
}))}
|
||||
className="mr-1"
|
||||
/>
|
||||
<label htmlFor="input-mode-schema">{t('server.openapi.inputModeSchema')}</label>
|
||||
@@ -461,12 +410,10 @@ const ServerForm = ({
|
||||
name="openapi-url"
|
||||
id="openapi-url"
|
||||
value={formData.openapi?.url || ''}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
openapi: { ...prev.openapi!, url: e.target.value },
|
||||
}))
|
||||
}
|
||||
onChange={(e) => setFormData(prev => ({
|
||||
...prev,
|
||||
openapi: { ...prev.openapi!, url: e.target.value }
|
||||
}))}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
placeholder="e.g.: https://api.example.com/openapi.json"
|
||||
required={serverType === 'openapi' && formData.openapi?.inputMode === 'url'}
|
||||
@@ -477,10 +424,7 @@ const ServerForm = ({
|
||||
{/* Schema Input */}
|
||||
{formData.openapi?.inputMode === 'schema' && (
|
||||
<div className="mb-4">
|
||||
<label
|
||||
className="block text-gray-700 text-sm font-bold mb-2"
|
||||
htmlFor="openapi-schema"
|
||||
>
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="openapi-schema">
|
||||
{t('server.openapi.schema')}
|
||||
</label>
|
||||
<textarea
|
||||
@@ -488,12 +432,10 @@ const ServerForm = ({
|
||||
id="openapi-schema"
|
||||
rows={10}
|
||||
value={formData.openapi?.schema || ''}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
openapi: { ...prev.openapi!, schema: e.target.value },
|
||||
}))
|
||||
}
|
||||
onChange={(e) => setFormData(prev => ({
|
||||
...prev,
|
||||
openapi: { ...prev.openapi!, schema: e.target.value }
|
||||
}))}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline font-mono text-sm"
|
||||
placeholder={`{
|
||||
"openapi": "3.1.0",
|
||||
@@ -523,16 +465,14 @@ const ServerForm = ({
|
||||
</label>
|
||||
<select
|
||||
value={formData.openapi?.securityType || 'none'}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
openapi: {
|
||||
...prev.openapi,
|
||||
securityType: e.target.value as any,
|
||||
url: prev.openapi?.url || '',
|
||||
},
|
||||
}))
|
||||
}
|
||||
onChange={(e) => setFormData(prev => ({
|
||||
...prev,
|
||||
openapi: {
|
||||
...prev.openapi,
|
||||
securityType: e.target.value as any,
|
||||
url: prev.openapi?.url || ''
|
||||
}
|
||||
}))}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
>
|
||||
<option value="none">{t('server.openapi.securityNone')}</option>
|
||||
@@ -546,47 +486,29 @@ const ServerForm = ({
|
||||
{/* API Key Configuration */}
|
||||
{formData.openapi?.securityType === 'apiKey' && (
|
||||
<div className="mb-4 p-4 border border-gray-200 rounded bg-gray-50">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">
|
||||
{t('server.openapi.apiKeyConfig')}
|
||||
</h4>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">{t('server.openapi.apiKeyConfig')}</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">
|
||||
{t('server.openapi.apiKeyName')}
|
||||
</label>
|
||||
<label className="block text-xs text-gray-600 mb-1">{t('server.openapi.apiKeyName')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.openapi?.apiKeyName || ''}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
openapi: {
|
||||
...prev.openapi,
|
||||
apiKeyName: e.target.value,
|
||||
url: prev.openapi?.url || '',
|
||||
},
|
||||
}))
|
||||
}
|
||||
onChange={(e) => setFormData(prev => ({
|
||||
...prev,
|
||||
openapi: { ...prev.openapi, apiKeyName: e.target.value, url: prev.openapi?.url || '' }
|
||||
}))}
|
||||
className="w-full border rounded px-2 py-1 text-sm form-input focus:outline-none"
|
||||
placeholder="Authorization"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">
|
||||
{t('server.openapi.apiKeyIn')}
|
||||
</label>
|
||||
<label className="block text-xs text-gray-600 mb-1">{t('server.openapi.apiKeyIn')}</label>
|
||||
<select
|
||||
value={formData.openapi?.apiKeyIn || 'header'}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
openapi: {
|
||||
...prev.openapi,
|
||||
apiKeyIn: e.target.value as any,
|
||||
url: prev.openapi?.url || '',
|
||||
},
|
||||
}))
|
||||
}
|
||||
onChange={(e) => setFormData(prev => ({
|
||||
...prev,
|
||||
openapi: { ...prev.openapi, apiKeyIn: e.target.value as any, url: prev.openapi?.url || '' }
|
||||
}))}
|
||||
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
|
||||
>
|
||||
<option value="header">{t('server.openapi.apiKeyInHeader')}</option>
|
||||
@@ -595,22 +517,14 @@ const ServerForm = ({
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">
|
||||
{t('server.openapi.apiKeyValue')}
|
||||
</label>
|
||||
<label className="block text-xs text-gray-600 mb-1">{t('server.openapi.apiKeyValue')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.openapi?.apiKeyValue || ''}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
openapi: {
|
||||
...prev.openapi,
|
||||
apiKeyValue: e.target.value,
|
||||
url: prev.openapi?.url || '',
|
||||
},
|
||||
}))
|
||||
}
|
||||
onChange={(e) => setFormData(prev => ({
|
||||
...prev,
|
||||
openapi: { ...prev.openapi, apiKeyValue: e.target.value, url: prev.openapi?.url || '' }
|
||||
}))}
|
||||
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
|
||||
placeholder="your-api-key"
|
||||
/>
|
||||
@@ -622,26 +536,16 @@ const ServerForm = ({
|
||||
{/* HTTP Authentication Configuration */}
|
||||
{formData.openapi?.securityType === 'http' && (
|
||||
<div className="mb-4 p-4 border border-gray-200 rounded bg-gray-50">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">
|
||||
{t('server.openapi.httpAuthConfig')}
|
||||
</h4>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">{t('server.openapi.httpAuthConfig')}</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">
|
||||
{t('server.openapi.httpScheme')}
|
||||
</label>
|
||||
<label className="block text-xs text-gray-600 mb-1">{t('server.openapi.httpScheme')}</label>
|
||||
<select
|
||||
value={formData.openapi?.httpScheme || 'bearer'}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
openapi: {
|
||||
...prev.openapi,
|
||||
httpScheme: e.target.value as any,
|
||||
url: prev.openapi?.url || '',
|
||||
},
|
||||
}))
|
||||
}
|
||||
onChange={(e) => setFormData(prev => ({
|
||||
...prev,
|
||||
openapi: { ...prev.openapi, httpScheme: e.target.value as any, url: prev.openapi?.url || '' }
|
||||
}))}
|
||||
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
|
||||
>
|
||||
<option value="basic">{t('server.openapi.httpSchemeBasic')}</option>
|
||||
@@ -650,28 +554,16 @@ const ServerForm = ({
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">
|
||||
{t('server.openapi.httpCredentials')}
|
||||
</label>
|
||||
<label className="block text-xs text-gray-600 mb-1">{t('server.openapi.httpCredentials')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.openapi?.httpCredentials || ''}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
openapi: {
|
||||
...prev.openapi,
|
||||
httpCredentials: e.target.value,
|
||||
url: prev.openapi?.url || '',
|
||||
},
|
||||
}))
|
||||
}
|
||||
onChange={(e) => setFormData(prev => ({
|
||||
...prev,
|
||||
openapi: { ...prev.openapi, httpCredentials: e.target.value, url: prev.openapi?.url || '' }
|
||||
}))}
|
||||
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
|
||||
placeholder={
|
||||
formData.openapi?.httpScheme === 'basic'
|
||||
? 'base64-encoded-credentials'
|
||||
: 'bearer-token'
|
||||
}
|
||||
placeholder={formData.openapi?.httpScheme === 'basic' ? 'base64-encoded-credentials' : 'bearer-token'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -681,27 +573,17 @@ const ServerForm = ({
|
||||
{/* OAuth2 Configuration */}
|
||||
{formData.openapi?.securityType === 'oauth2' && (
|
||||
<div className="mb-4 p-4 border border-gray-200 rounded bg-gray-50">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">
|
||||
{t('server.openapi.oauth2Config')}
|
||||
</h4>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">{t('server.openapi.oauth2Config')}</h4>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">
|
||||
{t('server.openapi.oauth2Token')}
|
||||
</label>
|
||||
<label className="block text-xs text-gray-600 mb-1">{t('server.openapi.oauth2Token')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.openapi?.oauth2Token || ''}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
openapi: {
|
||||
...prev.openapi,
|
||||
oauth2Token: e.target.value,
|
||||
url: prev.openapi?.url || '',
|
||||
},
|
||||
}))
|
||||
}
|
||||
onChange={(e) => setFormData(prev => ({
|
||||
...prev,
|
||||
openapi: { ...prev.openapi, oauth2Token: e.target.value, url: prev.openapi?.url || '' }
|
||||
}))}
|
||||
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
|
||||
placeholder="access-token"
|
||||
/>
|
||||
@@ -713,48 +595,30 @@ const ServerForm = ({
|
||||
{/* OpenID Connect Configuration */}
|
||||
{formData.openapi?.securityType === 'openIdConnect' && (
|
||||
<div className="mb-4 p-4 border border-gray-200 rounded bg-gray-50">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">
|
||||
{t('server.openapi.openIdConnectConfig')}
|
||||
</h4>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">{t('server.openapi.openIdConnectConfig')}</h4>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">
|
||||
{t('server.openapi.openIdConnectUrl')}
|
||||
</label>
|
||||
<label className="block text-xs text-gray-600 mb-1">{t('server.openapi.openIdConnectUrl')}</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.openapi?.openIdConnectUrl || ''}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
openapi: {
|
||||
...prev.openapi,
|
||||
openIdConnectUrl: e.target.value,
|
||||
url: prev.openapi?.url || '',
|
||||
},
|
||||
}))
|
||||
}
|
||||
onChange={(e) => setFormData(prev => ({
|
||||
...prev,
|
||||
openapi: { ...prev.openapi, openIdConnectUrl: e.target.value, url: prev.openapi?.url || '' }
|
||||
}))}
|
||||
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
|
||||
placeholder="https://example.com/.well-known/openid_configuration"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">
|
||||
{t('server.openapi.openIdConnectToken')}
|
||||
</label>
|
||||
<label className="block text-xs text-gray-600 mb-1">{t('server.openapi.openIdConnectToken')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.openapi?.openIdConnectToken || ''}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
openapi: {
|
||||
...prev.openapi,
|
||||
openIdConnectToken: e.target.value,
|
||||
url: prev.openapi?.url || '',
|
||||
},
|
||||
}))
|
||||
}
|
||||
onChange={(e) => setFormData(prev => ({
|
||||
...prev,
|
||||
openapi: { ...prev.openapi, openIdConnectToken: e.target.value, url: prev.openapi?.url || '' }
|
||||
}))}
|
||||
className="w-full border rounded px-2 py-1 text-sm focus:outline-none form-input"
|
||||
placeholder="id-token"
|
||||
/>
|
||||
@@ -771,22 +635,14 @@ const ServerForm = ({
|
||||
<input
|
||||
type="text"
|
||||
value={formData.openapi?.passthroughHeaders || ''}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
openapi: {
|
||||
...prev.openapi,
|
||||
passthroughHeaders: e.target.value,
|
||||
url: prev.openapi?.url || '',
|
||||
},
|
||||
}))
|
||||
}
|
||||
onChange={(e) => setFormData(prev => ({
|
||||
...prev,
|
||||
openapi: { ...prev.openapi, passthroughHeaders: e.target.value, url: prev.openapi?.url || '' }
|
||||
}))}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
placeholder="Authorization, X-API-Key, X-Custom-Header"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{t('server.openapi.passthroughHeadersHelp')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">{t('server.openapi.passthroughHeadersHelp')}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
@@ -845,11 +701,7 @@ const ServerForm = ({
|
||||
value={formData.url}
|
||||
onChange={handleInputChange}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
placeholder={
|
||||
serverType === 'streamable-http'
|
||||
? 'e.g.: http://localhost:3000/mcp'
|
||||
: 'e.g.: http://localhost:3000/sse'
|
||||
}
|
||||
placeholder={serverType === 'streamable-http' ? "e.g.: http://localhost:3000/mcp" : "e.g.: http://localhost:3000/sse"}
|
||||
required={serverType === 'sse' || serverType === 'streamable-http'}
|
||||
/>
|
||||
</div>
|
||||
@@ -985,26 +837,23 @@ const ServerForm = ({
|
||||
<label className="text-gray-700 text-sm font-bold">
|
||||
{t('server.requestOptions')}
|
||||
</label>
|
||||
<span className="text-gray-500 text-sm">{isRequestOptionsExpanded ? '▼' : '▶'}</span>
|
||||
<span className="text-gray-500 text-sm">
|
||||
{isRequestOptionsExpanded ? '▼' : '▶'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isRequestOptionsExpanded && (
|
||||
<div className="border border-gray-200 rounded-b p-4 bg-gray-50 border-t-0">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label
|
||||
className="block text-gray-600 text-sm font-medium mb-1"
|
||||
htmlFor="timeout"
|
||||
>
|
||||
<label className="block text-gray-600 text-sm font-medium mb-1" htmlFor="timeout">
|
||||
{t('server.timeout')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="timeout"
|
||||
value={formData.options?.timeout || 60000}
|
||||
onChange={(e) =>
|
||||
handleOptionsChange('timeout', parseInt(e.target.value) || 60000)
|
||||
}
|
||||
onChange={(e) => handleOptionsChange('timeout', parseInt(e.target.value) || 60000)}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
placeholder="30000"
|
||||
min="1000"
|
||||
@@ -1014,29 +863,19 @@ const ServerForm = ({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className="block text-gray-600 text-sm font-medium mb-1"
|
||||
htmlFor="maxTotalTimeout"
|
||||
>
|
||||
<label className="block text-gray-600 text-sm font-medium mb-1" htmlFor="maxTotalTimeout">
|
||||
{t('server.maxTotalTimeout')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="maxTotalTimeout"
|
||||
value={formData.options?.maxTotalTimeout || ''}
|
||||
onChange={(e) =>
|
||||
handleOptionsChange(
|
||||
'maxTotalTimeout',
|
||||
e.target.value ? parseInt(e.target.value) : undefined,
|
||||
)
|
||||
}
|
||||
onChange={(e) => handleOptionsChange('maxTotalTimeout', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
placeholder="Optional"
|
||||
min="1000"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{t('server.maxTotalTimeoutDescription')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">{t('server.maxTotalTimeoutDescription')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1045,14 +884,10 @@ const ServerForm = ({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.options?.resetTimeoutOnProgress || false}
|
||||
onChange={(e) =>
|
||||
handleOptionsChange('resetTimeoutOnProgress', e.target.checked)
|
||||
}
|
||||
onChange={(e) => handleOptionsChange('resetTimeoutOnProgress', e.target.checked)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-gray-600 text-sm">
|
||||
{t('server.resetTimeoutOnProgress')}
|
||||
</span>
|
||||
<span className="text-gray-600 text-sm">{t('server.resetTimeoutOnProgress')}</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1 ml-6">
|
||||
{t('server.resetTimeoutOnProgressDescription')}
|
||||
@@ -1080,7 +915,7 @@ const ServerForm = ({
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default ServerForm;
|
||||
export default ServerForm
|
||||
@@ -1,78 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CursorPaginationProps {
|
||||
currentPage: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
onNextPage: () => void;
|
||||
onPreviousPage: () => void;
|
||||
}
|
||||
|
||||
const CursorPagination: React.FC<CursorPaginationProps> = ({
|
||||
currentPage,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
onNextPage,
|
||||
onPreviousPage,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center space-x-2 my-6">
|
||||
{/* Previous button */}
|
||||
<button
|
||||
onClick={onPreviousPage}
|
||||
disabled={!hasPreviousPage}
|
||||
className={`px-4 py-2 rounded transition-all duration-200 ${
|
||||
hasPreviousPage
|
||||
? 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
|
||||
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5 inline-block mr-1"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Prev
|
||||
</button>
|
||||
|
||||
{/* Current page indicator */}
|
||||
<span className="px-4 py-2 bg-blue-500 text-white rounded btn-primary">
|
||||
Page {currentPage}
|
||||
</span>
|
||||
|
||||
{/* Next button */}
|
||||
<button
|
||||
onClick={onNextPage}
|
||||
disabled={!hasNextPage}
|
||||
className={`px-4 py-2 rounded transition-all duration-200 ${
|
||||
hasNextPage
|
||||
? 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
|
||||
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Next
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5 inline-block ml-1"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CursorPagination;
|
||||
@@ -4,7 +4,6 @@ export const PERMISSIONS = {
|
||||
SETTINGS_SMART_ROUTING: 'settings:smart_routing',
|
||||
SETTINGS_SKIP_AUTH: 'settings:skip_auth',
|
||||
SETTINGS_INSTALL_CONFIG: 'settings:install_config',
|
||||
SETTINGS_EXPORT_CONFIG: 'settings:export_config',
|
||||
} as const;
|
||||
|
||||
export default PERMISSIONS;
|
||||
|
||||
@@ -63,58 +63,55 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
};
|
||||
|
||||
// Start normal polling
|
||||
const startNormalPolling = useCallback(
|
||||
(options?: { immediate?: boolean }) => {
|
||||
const immediate = options?.immediate ?? true;
|
||||
// Ensure no other timers are running
|
||||
clearTimer();
|
||||
const startNormalPolling = useCallback((options?: { immediate?: boolean }) => {
|
||||
const immediate = options?.immediate ?? true;
|
||||
// Ensure no other timers are running
|
||||
clearTimer();
|
||||
|
||||
const fetchServers = async () => {
|
||||
try {
|
||||
console.log('[ServerContext] Fetching servers from API...');
|
||||
const data = await apiGet('/servers');
|
||||
const fetchServers = async () => {
|
||||
try {
|
||||
console.log('[ServerContext] Fetching servers from API...');
|
||||
const data = await apiGet('/servers');
|
||||
|
||||
// Update last fetch time
|
||||
lastFetchTimeRef.current = Date.now();
|
||||
|
||||
// Update last fetch time
|
||||
lastFetchTimeRef.current = Date.now();
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data);
|
||||
} else if (data && Array.isArray(data)) {
|
||||
setServers(data);
|
||||
} else {
|
||||
console.error('Invalid server data format:', data);
|
||||
setServers([]);
|
||||
}
|
||||
|
||||
// Reset error state
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error fetching servers during normal polling:', err);
|
||||
|
||||
// Use friendly error message
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'));
|
||||
} else if (
|
||||
err instanceof TypeError &&
|
||||
(err.message.includes('NetworkError') || err.message.includes('Failed to fetch'))
|
||||
) {
|
||||
setError(t('errors.serverConnection'));
|
||||
} else {
|
||||
setError(t('errors.serverFetch'));
|
||||
}
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data);
|
||||
} else if (data && Array.isArray(data)) {
|
||||
setServers(data);
|
||||
} else {
|
||||
console.error('Invalid server data format:', data);
|
||||
setServers([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Execute immediately unless explicitly skipped
|
||||
if (immediate) {
|
||||
fetchServers();
|
||||
// Reset error state
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error fetching servers during normal polling:', err);
|
||||
|
||||
// Use friendly error message
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'));
|
||||
} else if (
|
||||
err instanceof TypeError &&
|
||||
(err.message.includes('NetworkError') || err.message.includes('Failed to fetch'))
|
||||
) {
|
||||
setError(t('errors.serverConnection'));
|
||||
} else {
|
||||
setError(t('errors.serverFetch'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Set up regular polling
|
||||
intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
// Execute immediately unless explicitly skipped
|
||||
if (immediate) {
|
||||
fetchServers();
|
||||
}
|
||||
|
||||
// Set up regular polling
|
||||
intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval);
|
||||
}, [t]);
|
||||
|
||||
// Watch for authentication status changes
|
||||
useEffect(() => {
|
||||
@@ -150,7 +147,7 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
try {
|
||||
console.log('[ServerContext] Initial fetch - attempt', attemptsRef.current + 1);
|
||||
const data = await apiGet('/servers');
|
||||
|
||||
|
||||
// Update last fetch time
|
||||
lastFetchTimeRef.current = Date.now();
|
||||
|
||||
@@ -248,30 +245,16 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
const refreshIfNeeded = useCallback(() => {
|
||||
const now = Date.now();
|
||||
const timeSinceLastFetch = now - lastFetchTimeRef.current;
|
||||
|
||||
|
||||
// Log who is calling this
|
||||
console.log(
|
||||
'[ServerContext] refreshIfNeeded called, time since last fetch:',
|
||||
timeSinceLastFetch,
|
||||
'ms',
|
||||
);
|
||||
|
||||
console.log('[ServerContext] refreshIfNeeded called, time since last fetch:', timeSinceLastFetch, 'ms');
|
||||
|
||||
// Only refresh if enough time has passed since last fetch
|
||||
if (timeSinceLastFetch >= MIN_REFRESH_INTERVAL) {
|
||||
console.log(
|
||||
'[ServerContext] Triggering refresh (exceeded MIN_REFRESH_INTERVAL:',
|
||||
MIN_REFRESH_INTERVAL,
|
||||
'ms)',
|
||||
);
|
||||
console.log('[ServerContext] Triggering refresh (exceeded MIN_REFRESH_INTERVAL:', MIN_REFRESH_INTERVAL, 'ms)');
|
||||
triggerRefresh();
|
||||
} else {
|
||||
console.log(
|
||||
'[ServerContext] Skipping refresh (MIN_REFRESH_INTERVAL:',
|
||||
MIN_REFRESH_INTERVAL,
|
||||
'ms, time since last:',
|
||||
timeSinceLastFetch,
|
||||
'ms)',
|
||||
);
|
||||
console.log('[ServerContext] Skipping refresh (MIN_REFRESH_INTERVAL:', MIN_REFRESH_INTERVAL, 'ms, time since last:', timeSinceLastFetch, 'ms)');
|
||||
}
|
||||
}, [triggerRefresh]);
|
||||
|
||||
@@ -280,85 +263,74 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
}, []);
|
||||
|
||||
const handleServerEdit = useCallback(
|
||||
async (server: Server) => {
|
||||
try {
|
||||
// Fetch settings to get the full server config before editing
|
||||
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> =
|
||||
await apiGet('/settings');
|
||||
const handleServerEdit = useCallback(async (server: Server) => {
|
||||
try {
|
||||
// Fetch settings to get the full server config before editing
|
||||
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> =
|
||||
await apiGet('/settings');
|
||||
|
||||
if (
|
||||
settingsData &&
|
||||
settingsData.success &&
|
||||
settingsData.data &&
|
||||
settingsData.data.mcpServers &&
|
||||
settingsData.data.mcpServers[server.name]
|
||||
) {
|
||||
const serverConfig = settingsData.data.mcpServers[server.name];
|
||||
return {
|
||||
name: server.name,
|
||||
status: server.status,
|
||||
tools: server.tools || [],
|
||||
config: serverConfig,
|
||||
};
|
||||
} else {
|
||||
console.error('Failed to get server config from settings:', settingsData);
|
||||
setError(t('server.invalidConfig', { serverName: server.name }));
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching server settings:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
if (
|
||||
settingsData &&
|
||||
settingsData.success &&
|
||||
settingsData.data &&
|
||||
settingsData.data.mcpServers &&
|
||||
settingsData.data.mcpServers[server.name]
|
||||
) {
|
||||
const serverConfig = settingsData.data.mcpServers[server.name];
|
||||
return {
|
||||
name: server.name,
|
||||
status: server.status,
|
||||
tools: server.tools || [],
|
||||
config: serverConfig,
|
||||
};
|
||||
} else {
|
||||
console.error('Failed to get server config from settings:', settingsData);
|
||||
setError(t('server.invalidConfig', { serverName: server.name }));
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Error fetching server settings:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return null;
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleServerRemove = useCallback(
|
||||
async (serverName: string) => {
|
||||
try {
|
||||
const encodedServerName = encodeURIComponent(serverName);
|
||||
const result = await apiDelete(`/servers/${encodedServerName}`);
|
||||
const handleServerRemove = useCallback(async (serverName: string) => {
|
||||
try {
|
||||
const result = await apiDelete(`/servers/${serverName}`);
|
||||
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('server.deleteError', { serverName }));
|
||||
return false;
|
||||
}
|
||||
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(t('errors.general') + ': ' + (err instanceof Error ? err.message : String(err)));
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('server.deleteError', { serverName }));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleServerToggle = useCallback(
|
||||
async (server: Server, enabled: boolean) => {
|
||||
try {
|
||||
const encodedServerName = encodeURIComponent(server.name);
|
||||
const result = await apiPost(`/servers/${encodedServerName}/toggle`, { enabled });
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(t('errors.general') + ': ' + (err instanceof Error ? err.message : String(err)));
|
||||
return false;
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
if (!result || !result.success) {
|
||||
console.error('Failed to toggle server:', result);
|
||||
setError(result?.message || t('server.toggleError', { serverName: server.name }));
|
||||
return false;
|
||||
}
|
||||
const handleServerToggle = useCallback(async (server: Server, enabled: boolean) => {
|
||||
try {
|
||||
const result = await apiPost(`/servers/${server.name}/toggle`, { enabled });
|
||||
|
||||
// Update the UI immediately to reflect the change
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error toggling server:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
if (!result || !result.success) {
|
||||
console.error('Failed to toggle server:', result);
|
||||
setError(result?.message || t('server.toggleError', { serverName: server.name }));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// Update the UI immediately to reflect the change
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error toggling server:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const value: ServerContextType = {
|
||||
servers,
|
||||
@@ -384,4 +356,4 @@ export const useServerContext = () => {
|
||||
throw new Error('useServerContext must be used within a ServerProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
};
|
||||
@@ -1,283 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
RegistryServerEntry,
|
||||
RegistryServersResponse,
|
||||
RegistryServerVersionResponse,
|
||||
RegistryServerVersionsResponse,
|
||||
} from '@/types';
|
||||
import { apiGet } from '../utils/fetchInterceptor';
|
||||
|
||||
export const useRegistryData = () => {
|
||||
const { t } = useTranslation();
|
||||
const [servers, setServers] = useState<RegistryServerEntry[]>([]);
|
||||
const [allServers, setAllServers] = useState<RegistryServerEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
|
||||
// Cursor-based pagination states
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [serversPerPage, setServersPerPage] = useState(9);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [hasNextPage, setHasNextPage] = useState(false);
|
||||
const [cursorHistory, setCursorHistory] = useState<string[]>([]);
|
||||
const [totalPages] = useState(1); // Legacy support, not used in cursor pagination
|
||||
|
||||
// Fetch registry servers with cursor-based pagination
|
||||
const fetchRegistryServers = useCallback(
|
||||
async (cursor?: string, search?: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Build query parameters
|
||||
const params = new URLSearchParams();
|
||||
params.append('limit', serversPerPage.toString());
|
||||
if (cursor) {
|
||||
params.append('cursor', cursor);
|
||||
}
|
||||
const queryToUse = search !== undefined ? search : searchQuery;
|
||||
if (queryToUse.trim()) {
|
||||
params.append('search', queryToUse.trim());
|
||||
}
|
||||
|
||||
const response = await apiGet(`/registry/servers?${params.toString()}`);
|
||||
|
||||
if (response && response.success && response.data) {
|
||||
const data: RegistryServersResponse = response.data;
|
||||
if (data.servers && Array.isArray(data.servers)) {
|
||||
setServers(data.servers);
|
||||
// Update pagination state
|
||||
const hasMore = data.metadata.count === serversPerPage && !!data.metadata.nextCursor;
|
||||
setHasNextPage(hasMore);
|
||||
setNextCursor(data.metadata.nextCursor || null);
|
||||
|
||||
// For display purposes, keep track of all loaded servers
|
||||
if (!cursor) {
|
||||
// First page
|
||||
setAllServers(data.servers);
|
||||
} else {
|
||||
// Subsequent pages - append to all servers
|
||||
setAllServers((prev) => [...prev, ...data.servers]);
|
||||
}
|
||||
} else {
|
||||
console.error('Invalid registry servers data format:', data);
|
||||
setError(t('registry.fetchError'));
|
||||
}
|
||||
} else {
|
||||
setError(t('registry.fetchError'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching registry servers:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[t, serversPerPage],
|
||||
);
|
||||
|
||||
// Navigate to next page
|
||||
const goToNextPage = useCallback(async () => {
|
||||
if (!hasNextPage || !nextCursor) return;
|
||||
|
||||
// Save current cursor to history for back navigation
|
||||
const currentCursor = cursorHistory[cursorHistory.length - 1] || '';
|
||||
setCursorHistory((prev) => [...prev, currentCursor]);
|
||||
|
||||
setCurrentPage((prev) => prev + 1);
|
||||
await fetchRegistryServers(nextCursor, searchQuery);
|
||||
}, [hasNextPage, nextCursor, cursorHistory, searchQuery, fetchRegistryServers]);
|
||||
|
||||
// Navigate to previous page
|
||||
const goToPreviousPage = useCallback(async () => {
|
||||
if (currentPage <= 1) return;
|
||||
|
||||
// Get the previous cursor from history
|
||||
const newHistory = [...cursorHistory];
|
||||
newHistory.pop(); // Remove current position
|
||||
const previousCursor = newHistory[newHistory.length - 1];
|
||||
|
||||
setCursorHistory(newHistory);
|
||||
setCurrentPage((prev) => prev - 1);
|
||||
|
||||
// Fetch with previous cursor (undefined for first page)
|
||||
await fetchRegistryServers(previousCursor || undefined, searchQuery);
|
||||
}, [currentPage, cursorHistory, searchQuery, fetchRegistryServers]);
|
||||
|
||||
// Change page (legacy support for page number navigation)
|
||||
const changePage = useCallback(
|
||||
async (page: number) => {
|
||||
if (page === currentPage) return;
|
||||
|
||||
if (page > currentPage && hasNextPage) {
|
||||
await goToNextPage();
|
||||
} else if (page < currentPage && currentPage > 1) {
|
||||
await goToPreviousPage();
|
||||
}
|
||||
},
|
||||
[currentPage, hasNextPage, goToNextPage, goToPreviousPage],
|
||||
);
|
||||
|
||||
// Change items per page
|
||||
const changeServersPerPage = useCallback(
|
||||
async (newServersPerPage: number) => {
|
||||
setServersPerPage(newServersPerPage);
|
||||
setCurrentPage(1);
|
||||
setCursorHistory([]);
|
||||
setAllServers([]);
|
||||
await fetchRegistryServers(undefined, searchQuery);
|
||||
},
|
||||
[searchQuery, fetchRegistryServers],
|
||||
);
|
||||
|
||||
// Fetch server by name
|
||||
const fetchServerByName = useCallback(
|
||||
async (serverName: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// URL encode the server name
|
||||
const encodedName = encodeURIComponent(serverName);
|
||||
const response = await apiGet(`/registry/servers/${encodedName}/versions`);
|
||||
|
||||
if (response && response.success && response.data) {
|
||||
const data: RegistryServerVersionsResponse = response.data;
|
||||
if (data.servers && Array.isArray(data.servers) && data.servers.length > 0) {
|
||||
// Return the first server entry (should be the latest or specified version)
|
||||
return data.servers[0];
|
||||
} else {
|
||||
console.error('Invalid registry server data format:', data);
|
||||
setError(t('registry.serverNotFound'));
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
setError(t('registry.serverNotFound'));
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error fetching registry server ${serverName}:`, err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
setError(errorMessage);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// Fetch all versions of a server
|
||||
const fetchServerVersions = useCallback(async (serverName: string) => {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
// URL encode the server name
|
||||
const encodedName = encodeURIComponent(serverName);
|
||||
const response = await apiGet(`/registry/servers/${encodedName}/versions`);
|
||||
|
||||
if (response && response.success && response.data) {
|
||||
const data: RegistryServerVersionsResponse = response.data;
|
||||
if (data.servers && Array.isArray(data.servers)) {
|
||||
return data.servers;
|
||||
} else {
|
||||
console.error('Invalid registry server versions data format:', data);
|
||||
return [];
|
||||
}
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error fetching versions for server ${serverName}:`, err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
setError(errorMessage);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch specific version of a server
|
||||
const fetchServerVersion = useCallback(async (serverName: string, version: string) => {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
// URL encode the server name and version
|
||||
const encodedName = encodeURIComponent(serverName);
|
||||
const encodedVersion = encodeURIComponent(version);
|
||||
const response = await apiGet(`/registry/servers/${encodedName}/versions/${encodedVersion}`);
|
||||
|
||||
if (response && response.success && response.data) {
|
||||
const data: RegistryServerVersionResponse = response.data;
|
||||
if (data && data.server) {
|
||||
return data;
|
||||
} else {
|
||||
console.error('Invalid registry server version data format:', data);
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error fetching version ${version} for server ${serverName}:`, err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
setError(errorMessage);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Search servers by query (client-side filtering on loaded data)
|
||||
const searchServers = useCallback(
|
||||
async (query: string) => {
|
||||
console.log('Searching registry servers with query:', query);
|
||||
setSearchQuery(query);
|
||||
setCurrentPage(1);
|
||||
setCursorHistory([]);
|
||||
setAllServers([]);
|
||||
|
||||
await fetchRegistryServers(undefined, query);
|
||||
},
|
||||
[fetchRegistryServers],
|
||||
);
|
||||
|
||||
// Clear search
|
||||
const clearSearch = useCallback(async () => {
|
||||
setSearchQuery('');
|
||||
setCurrentPage(1);
|
||||
setCursorHistory([]);
|
||||
setAllServers([]);
|
||||
await fetchRegistryServers(undefined, '');
|
||||
}, [fetchRegistryServers]);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
fetchRegistryServers(undefined, searchQuery);
|
||||
// Only run on mount
|
||||
// eslint-disable-next-line
|
||||
}, []);
|
||||
|
||||
return {
|
||||
servers,
|
||||
allServers,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
searchQuery,
|
||||
searchServers,
|
||||
clearSearch,
|
||||
fetchServerByName,
|
||||
fetchServerVersions,
|
||||
fetchServerVersion,
|
||||
// Cursor-based pagination
|
||||
currentPage,
|
||||
totalPages,
|
||||
hasNextPage,
|
||||
hasPreviousPage: currentPage > 1,
|
||||
changePage,
|
||||
goToNextPage,
|
||||
goToPreviousPage,
|
||||
serversPerPage,
|
||||
changeServersPerPage,
|
||||
};
|
||||
};
|
||||
@@ -420,21 +420,6 @@ export const useSettingsData = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const exportMCPSettings = async (serverName?: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
return await apiGet(`/mcp-settings/export?serverName=${serverName ? serverName : ''}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to export MCP settings:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to export MCP settings';
|
||||
setError(errorMessage);
|
||||
showToast(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch settings when the component mounts or refreshKey changes
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
@@ -469,6 +454,5 @@ export const useSettingsData = () => {
|
||||
updateMCPRouterConfig,
|
||||
updateMCPRouterConfigBatch,
|
||||
updateNameSeparator,
|
||||
exportMCPSettings,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,27 +1,17 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
MarketServer,
|
||||
CloudServer,
|
||||
ServerConfig,
|
||||
RegistryServerEntry,
|
||||
RegistryServerData,
|
||||
} from '@/types';
|
||||
import { MarketServer, CloudServer, ServerConfig } from '@/types';
|
||||
import { useMarketData } from '@/hooks/useMarketData';
|
||||
import { useCloudData } from '@/hooks/useCloudData';
|
||||
import { useRegistryData } from '@/hooks/useRegistryData';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import { apiPost } from '@/utils/fetchInterceptor';
|
||||
import MarketServerCard from '@/components/MarketServerCard';
|
||||
import MarketServerDetail from '@/components/MarketServerDetail';
|
||||
import CloudServerCard from '@/components/CloudServerCard';
|
||||
import CloudServerDetail from '@/components/CloudServerDetail';
|
||||
import RegistryServerCard from '@/components/RegistryServerCard';
|
||||
import RegistryServerDetail from '@/components/RegistryServerDetail';
|
||||
import MCPRouterApiKeyError from '@/components/MCPRouterApiKeyError';
|
||||
import Pagination from '@/components/ui/Pagination';
|
||||
import CursorPagination from '@/components/ui/CursorPagination';
|
||||
|
||||
const MarketPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -29,7 +19,7 @@ const MarketPage: React.FC = () => {
|
||||
const { serverName } = useParams<{ serverName?: string }>();
|
||||
const { showToast } = useToast();
|
||||
|
||||
// Get tab from URL search params
|
||||
// Get tab from URL search params, default to cloud market
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const currentTab = searchParams.get('tab') || 'cloud';
|
||||
|
||||
@@ -54,10 +44,10 @@ const MarketPage: React.FC = () => {
|
||||
totalPages: localTotalPages,
|
||||
changePage: changeLocalPage,
|
||||
serversPerPage: localServersPerPage,
|
||||
changeServersPerPage: changeLocalServersPerPage,
|
||||
changeServersPerPage: changeLocalServersPerPage
|
||||
} = useMarketData();
|
||||
|
||||
// Cloud market data
|
||||
// Cloud market data
|
||||
const {
|
||||
servers: cloudServers,
|
||||
allServers: allCloudServers,
|
||||
@@ -71,67 +61,29 @@ const MarketPage: React.FC = () => {
|
||||
totalPages: cloudTotalPages,
|
||||
changePage: changeCloudPage,
|
||||
serversPerPage: cloudServersPerPage,
|
||||
changeServersPerPage: changeCloudServersPerPage,
|
||||
changeServersPerPage: changeCloudServersPerPage
|
||||
} = useCloudData();
|
||||
|
||||
// Registry data
|
||||
const {
|
||||
servers: registryServers,
|
||||
allServers: allRegistryServers,
|
||||
loading: registryLoading,
|
||||
error: registryError,
|
||||
setError: setRegistryError,
|
||||
searchServers: searchRegistryServers,
|
||||
clearSearch: clearRegistrySearch,
|
||||
fetchServerByName: fetchRegistryServerByName,
|
||||
fetchServerVersions: fetchRegistryServerVersions,
|
||||
// Cursor-based pagination
|
||||
currentPage: registryCurrentPage,
|
||||
totalPages: registryTotalPages,
|
||||
hasNextPage: registryHasNextPage,
|
||||
hasPreviousPage: registryHasPreviousPage,
|
||||
changePage: changeRegistryPage,
|
||||
goToNextPage: goToRegistryNextPage,
|
||||
goToPreviousPage: goToRegistryPreviousPage,
|
||||
serversPerPage: registryServersPerPage,
|
||||
changeServersPerPage: changeRegistryServersPerPage,
|
||||
} = useRegistryData();
|
||||
|
||||
const [selectedServer, setSelectedServer] = useState<MarketServer | null>(null);
|
||||
const [selectedCloudServer, setSelectedCloudServer] = useState<CloudServer | null>(null);
|
||||
const [selectedRegistryServer, setSelectedRegistryServer] = useState<RegistryServerEntry | null>(
|
||||
null,
|
||||
);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [registrySearchQuery, setRegistrySearchQuery] = useState('');
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [installedCloudServers, setInstalledCloudServers] = useState<Set<string>>(new Set());
|
||||
const [installedRegistryServers, setInstalledRegistryServers] = useState<Set<string>>(new Set());
|
||||
|
||||
// Load server details if a server name is in the URL
|
||||
useEffect(() => {
|
||||
const loadServerDetails = async () => {
|
||||
if (serverName) {
|
||||
// Determine if it's a cloud, local, or registry server based on the current tab
|
||||
// Determine if it's a cloud or local server based on the current tab
|
||||
if (currentTab === 'cloud') {
|
||||
// Try to find the server in cloud servers
|
||||
const server = cloudServers.find((s) => s.name === serverName);
|
||||
const server = cloudServers.find(s => s.name === serverName);
|
||||
if (server) {
|
||||
setSelectedCloudServer(server);
|
||||
} else {
|
||||
// If server not found, navigate back to market page
|
||||
navigate('/market?tab=cloud');
|
||||
}
|
||||
} else if (currentTab === 'registry') {
|
||||
console.log('Loading registry server details for:', serverName);
|
||||
// Registry market
|
||||
const serverEntry = await fetchRegistryServerByName(serverName);
|
||||
if (serverEntry) {
|
||||
setSelectedRegistryServer(serverEntry);
|
||||
} else {
|
||||
// If server not found, navigate back to market page
|
||||
navigate('/market?tab=registry');
|
||||
}
|
||||
} else {
|
||||
// Local market
|
||||
const server = await fetchLocalServerByName(serverName);
|
||||
@@ -145,22 +97,14 @@ const MarketPage: React.FC = () => {
|
||||
} else {
|
||||
setSelectedServer(null);
|
||||
setSelectedCloudServer(null);
|
||||
setSelectedRegistryServer(null);
|
||||
}
|
||||
};
|
||||
|
||||
loadServerDetails();
|
||||
}, [
|
||||
serverName,
|
||||
currentTab,
|
||||
cloudServers,
|
||||
fetchLocalServerByName,
|
||||
fetchRegistryServerByName,
|
||||
navigate,
|
||||
]);
|
||||
}, [serverName, currentTab, cloudServers, fetchLocalServerByName, navigate]);
|
||||
|
||||
// Tab switching handler
|
||||
const switchTab = (tab: 'local' | 'cloud' | 'registry') => {
|
||||
const switchTab = (tab: 'local' | 'cloud') => {
|
||||
const newSearchParams = new URLSearchParams(searchParams);
|
||||
newSearchParams.set('tab', tab);
|
||||
setSearchParams(newSearchParams);
|
||||
@@ -174,8 +118,6 @@ const MarketPage: React.FC = () => {
|
||||
e.preventDefault();
|
||||
if (currentTab === 'local') {
|
||||
searchLocalServers(searchQuery);
|
||||
} else if (currentTab === 'registry') {
|
||||
searchRegistryServers(registrySearchQuery);
|
||||
}
|
||||
// Cloud search is not implemented in the original cloud page
|
||||
};
|
||||
@@ -187,35 +129,18 @@ const MarketPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setSearchQuery('');
|
||||
if (currentTab === 'local') {
|
||||
setSearchQuery('');
|
||||
filterLocalByCategory('');
|
||||
filterLocalByTag('');
|
||||
} else if (currentTab === 'registry') {
|
||||
setRegistrySearchQuery('');
|
||||
clearRegistrySearch();
|
||||
}
|
||||
};
|
||||
|
||||
const handleServerClick = (server: MarketServer | CloudServer | RegistryServerEntry) => {
|
||||
const handleServerClick = (server: MarketServer | CloudServer) => {
|
||||
if (currentTab === 'cloud') {
|
||||
const cloudServer = server as CloudServer;
|
||||
navigate(`/market/${cloudServer.name}?tab=cloud`);
|
||||
} else if (currentTab === 'registry') {
|
||||
const registryServer = server as RegistryServerEntry;
|
||||
console.log('Registry server clicked:', registryServer);
|
||||
const serverName = registryServer.server?.name;
|
||||
console.log('Server name extracted:', serverName);
|
||||
if (serverName) {
|
||||
const targetUrl = `/market/${encodeURIComponent(serverName)}?tab=registry`;
|
||||
console.log('Navigating to:', targetUrl);
|
||||
navigate(targetUrl);
|
||||
} else {
|
||||
console.error('Server name is undefined in registry server:', registryServer);
|
||||
}
|
||||
navigate(`/market/${server.name}?tab=cloud`);
|
||||
} else {
|
||||
const marketServer = server as MarketServer;
|
||||
navigate(`/market/${marketServer.name}?tab=local`);
|
||||
navigate(`/market/${server.name}?tab=local`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -242,7 +167,7 @@ const MarketPage: React.FC = () => {
|
||||
|
||||
const payload = {
|
||||
name: server.name,
|
||||
config: config,
|
||||
config: config
|
||||
};
|
||||
|
||||
const result = await apiPost('/servers', payload);
|
||||
@@ -254,8 +179,9 @@ const MarketPage: React.FC = () => {
|
||||
}
|
||||
|
||||
// Update installed servers set
|
||||
setInstalledCloudServers((prev) => new Set(prev).add(server.name));
|
||||
setInstalledCloudServers(prev => new Set(prev).add(server.name));
|
||||
showToast(t('cloud.installSuccess', { name: server.title || server.name }), 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error installing cloud server:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
@@ -265,41 +191,7 @@ const MarketPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle registry server installation
|
||||
const handleRegistryInstall = async (server: RegistryServerData, config: ServerConfig) => {
|
||||
try {
|
||||
setInstalling(true);
|
||||
|
||||
const payload = {
|
||||
name: server.name,
|
||||
config: config,
|
||||
};
|
||||
|
||||
const result = await apiPost('/servers', payload);
|
||||
|
||||
if (!result.success) {
|
||||
const errorMessage = result?.message || t('server.addError');
|
||||
showToast(errorMessage, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update installed servers set
|
||||
setInstalledRegistryServers((prev) => new Set(prev).add(server.name));
|
||||
showToast(t('registry.installSuccess', { name: server.title || server.name }), 'success');
|
||||
} catch (error) {
|
||||
console.error('Error installing registry server:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
showToast(t('registry.installError', { error: errorMessage }), 'error');
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCallTool = async (
|
||||
serverName: string,
|
||||
toolName: string,
|
||||
args: Record<string, any>,
|
||||
) => {
|
||||
const handleCallTool = async (serverName: string, toolName: string, args: Record<string, any>) => {
|
||||
try {
|
||||
const result = await callServerTool(serverName, toolName, args);
|
||||
showToast(t('cloud.toolCallSuccess', { toolName }), 'success');
|
||||
@@ -316,17 +208,13 @@ const MarketPage: React.FC = () => {
|
||||
|
||||
// Helper function to check if error is MCPRouter API key not configured
|
||||
const isMCPRouterApiKeyError = (errorMessage: string) => {
|
||||
return (
|
||||
errorMessage === 'MCPROUTER_API_KEY_NOT_CONFIGURED' ||
|
||||
errorMessage.toLowerCase().includes('mcprouter api key not configured')
|
||||
);
|
||||
return errorMessage === 'MCPROUTER_API_KEY_NOT_CONFIGURED' ||
|
||||
errorMessage.toLowerCase().includes('mcprouter api key not configured');
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
if (currentTab === 'local') {
|
||||
changeLocalPage(page);
|
||||
} else if (currentTab === 'registry') {
|
||||
changeRegistryPage(page);
|
||||
} else {
|
||||
changeCloudPage(page);
|
||||
}
|
||||
@@ -338,8 +226,6 @@ const MarketPage: React.FC = () => {
|
||||
const newValue = parseInt(e.target.value, 10);
|
||||
if (currentTab === 'local') {
|
||||
changeLocalServersPerPage(newValue);
|
||||
} else if (currentTab === 'registry') {
|
||||
changeRegistryServersPerPage(newValue);
|
||||
} else {
|
||||
changeCloudServersPerPage(newValue);
|
||||
}
|
||||
@@ -373,50 +259,19 @@ const MarketPage: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Render registry server detail if selected
|
||||
if (selectedRegistryServer) {
|
||||
return (
|
||||
<RegistryServerDetail
|
||||
serverEntry={selectedRegistryServer}
|
||||
onBack={handleBackToList}
|
||||
onInstall={handleRegistryInstall}
|
||||
installing={installing}
|
||||
isInstalled={installedRegistryServers.has(selectedRegistryServer.server.name)}
|
||||
fetchVersions={fetchRegistryServerVersions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Get current data based on active tab
|
||||
const isLocalTab = currentTab === 'local';
|
||||
const isRegistryTab = currentTab === 'registry';
|
||||
const servers = isLocalTab ? localServers : isRegistryTab ? registryServers : cloudServers;
|
||||
const allServers = isLocalTab
|
||||
? allLocalServers
|
||||
: isRegistryTab
|
||||
? allRegistryServers
|
||||
: allCloudServers;
|
||||
const servers = isLocalTab ? localServers : cloudServers;
|
||||
const allServers = isLocalTab ? allLocalServers : allCloudServers;
|
||||
const categories = isLocalTab ? localCategories : [];
|
||||
const loading = isLocalTab ? localLoading : isRegistryTab ? registryLoading : cloudLoading;
|
||||
const error = isLocalTab ? localError : isRegistryTab ? registryError : cloudError;
|
||||
const setError = isLocalTab ? setLocalError : isRegistryTab ? setRegistryError : setCloudError;
|
||||
const loading = isLocalTab ? localLoading : cloudLoading;
|
||||
const error = isLocalTab ? localError : cloudError;
|
||||
const setError = isLocalTab ? setLocalError : setCloudError;
|
||||
const selectedCategory = isLocalTab ? selectedLocalCategory : '';
|
||||
const selectedTag = isLocalTab ? selectedLocalTag : '';
|
||||
const currentPage = isLocalTab
|
||||
? localCurrentPage
|
||||
: isRegistryTab
|
||||
? registryCurrentPage
|
||||
: cloudCurrentPage;
|
||||
const totalPages = isLocalTab
|
||||
? localTotalPages
|
||||
: isRegistryTab
|
||||
? registryTotalPages
|
||||
: cloudTotalPages;
|
||||
const serversPerPage = isLocalTab
|
||||
? localServersPerPage
|
||||
: isRegistryTab
|
||||
? registryServersPerPage
|
||||
: cloudServersPerPage;
|
||||
const currentPage = isLocalTab ? localCurrentPage : cloudCurrentPage;
|
||||
const totalPages = isLocalTab ? localTotalPages : cloudTotalPages;
|
||||
const serversPerPage = isLocalTab ? localServersPerPage : cloudServersPerPage;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -426,15 +281,13 @@ const MarketPage: React.FC = () => {
|
||||
<nav className="-mb-px flex space-x-3">
|
||||
<button
|
||||
onClick={() => switchTab('cloud')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${
|
||||
!isLocalTab && !isRegistryTab
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${!isLocalTab
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t('cloud.title')}
|
||||
<span className="text-xs text-gray-400 font-normal ml-1">
|
||||
(
|
||||
<span className="text-xs text-gray-400 font-normal ml-1">(
|
||||
<a
|
||||
href="https://mcprouter.co"
|
||||
target="_blank"
|
||||
@@ -448,15 +301,13 @@ const MarketPage: React.FC = () => {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => switchTab('local')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${
|
||||
isLocalTab
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${isLocalTab
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t('market.title')}
|
||||
<span className="text-xs text-gray-400 font-normal ml-1">
|
||||
(
|
||||
<span className="text-xs text-gray-400 font-normal ml-1">(
|
||||
<a
|
||||
href="https://mcpm.sh"
|
||||
target="_blank"
|
||||
@@ -468,28 +319,6 @@ const MarketPage: React.FC = () => {
|
||||
)
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => switchTab('registry')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${
|
||||
isRegistryTab
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t('registry.title')}
|
||||
<span className="text-xs text-gray-400 font-normal ml-1">
|
||||
(
|
||||
<a
|
||||
href="https://registry.modelcontextprotocol.io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="external-link"
|
||||
>
|
||||
{t('registry.official')}
|
||||
</a>
|
||||
)
|
||||
</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
@@ -506,17 +335,8 @@ const MarketPage: React.FC = () => {
|
||||
onClick={() => setError(null)}
|
||||
className="text-red-700 hover:text-red-900 transition-colors duration-200"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 01.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 01.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -525,24 +345,16 @@ const MarketPage: React.FC = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Search bar for local market and registry */}
|
||||
{(isLocalTab || isRegistryTab) && (
|
||||
{/* Search bar for local market only */}
|
||||
{isLocalTab && (
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6 page-card">
|
||||
<form onSubmit={handleSearch} className="flex space-x-4 mb-0">
|
||||
<div className="flex-grow">
|
||||
<input
|
||||
type="text"
|
||||
value={isRegistryTab ? registrySearchQuery : searchQuery}
|
||||
onChange={(e) => {
|
||||
if (isRegistryTab) {
|
||||
setRegistrySearchQuery(e.target.value);
|
||||
} else {
|
||||
setSearchQuery(e.target.value);
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
isRegistryTab ? t('registry.searchPlaceholder') : t('market.searchPlaceholder')
|
||||
}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('market.searchPlaceholder')}
|
||||
className="shadow appearance-none border border-gray-200 rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -550,16 +362,15 @@ const MarketPage: React.FC = () => {
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
|
||||
>
|
||||
{isRegistryTab ? t('registry.search') : t('market.search')}
|
||||
{t('market.search')}
|
||||
</button>
|
||||
{((isLocalTab && (searchQuery || selectedCategory || selectedTag)) ||
|
||||
(isRegistryTab && registrySearchQuery)) && (
|
||||
{(searchQuery || selectedCategory || selectedTag) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearFilters}
|
||||
className="border border-gray-300 text-gray-700 font-medium py-2 px-4 rounded hover:bg-gray-50 btn-secondary transition-all duration-200"
|
||||
>
|
||||
{isRegistryTab ? t('registry.clearFilters') : t('market.clearFilters')}
|
||||
{t('market.clearFilters')}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
@@ -577,10 +388,7 @@ const MarketPage: React.FC = () => {
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
|
||||
{selectedCategory && (
|
||||
<span
|
||||
className="text-xs text-blue-600 cursor-pointer hover:underline transition-colors duration-200"
|
||||
onClick={() => filterLocalByCategory('')}
|
||||
>
|
||||
<span className="text-xs text-blue-600 cursor-pointer hover:underline transition-colors duration-200" onClick={() => filterLocalByCategory('')}>
|
||||
{t('market.clearCategoryFilter')}
|
||||
</span>
|
||||
)}
|
||||
@@ -590,11 +398,10 @@ const MarketPage: React.FC = () => {
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => handleCategoryClick(category)}
|
||||
className={`px-3 py-2 rounded text-sm text-left transition-all duration-200 ${
|
||||
selectedCategory === category
|
||||
? 'bg-blue-100 text-blue-800 font-medium btn-primary'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-secondary'
|
||||
}`}
|
||||
className={`px-3 py-2 rounded text-sm text-left transition-all duration-200 ${selectedCategory === category
|
||||
? 'bg-blue-100 text-blue-800 font-medium btn-primary'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-secondary'
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
@@ -607,25 +414,9 @@ const MarketPage: React.FC = () => {
|
||||
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 items-center py-4 loading-container">
|
||||
<svg
|
||||
className="animate-spin h-6 w-6 text-blue-500 mb-2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
<svg className="animate-spin h-6 w-6 text-blue-500 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p className="text-sm text-gray-600">{t('app.loading')}</p>
|
||||
</div>
|
||||
@@ -647,110 +438,61 @@ const MarketPage: React.FC = () => {
|
||||
{loading ? (
|
||||
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<svg
|
||||
className="animate-spin h-10 w-10 text-blue-500 mb-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p className="text-gray-600">{t('app.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : servers.length === 0 ? (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<p className="text-gray-600">
|
||||
{isLocalTab
|
||||
? t('market.noServers')
|
||||
: isRegistryTab
|
||||
? t('registry.noServers')
|
||||
: t('cloud.noServers')}
|
||||
</p>
|
||||
<p className="text-gray-600">{isLocalTab ? t('market.noServers') : t('cloud.noServers')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{servers.map((server, index) =>
|
||||
{servers.map((server, index) => (
|
||||
isLocalTab ? (
|
||||
<MarketServerCard
|
||||
key={index}
|
||||
server={server as MarketServer}
|
||||
onClick={handleServerClick}
|
||||
/>
|
||||
) : isRegistryTab ? (
|
||||
<RegistryServerCard
|
||||
key={index}
|
||||
serverEntry={server as RegistryServerEntry}
|
||||
onClick={handleServerClick}
|
||||
/>
|
||||
) : (
|
||||
<CloudServerCard
|
||||
key={index}
|
||||
server={server as CloudServer}
|
||||
onClick={handleServerClick}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="flex-[2] text-sm text-gray-500">
|
||||
{isLocalTab
|
||||
? t('market.showing', {
|
||||
from: (currentPage - 1) * serversPerPage + 1,
|
||||
to: Math.min(currentPage * serversPerPage, allServers.length),
|
||||
total: allServers.length,
|
||||
})
|
||||
: isRegistryTab
|
||||
? t('registry.showing', {
|
||||
from: (currentPage - 1) * serversPerPage + 1,
|
||||
to: (currentPage - 1) * serversPerPage + servers.length,
|
||||
total: allServers.length + (registryHasNextPage ? '+' : ''),
|
||||
})
|
||||
: t('cloud.showing', {
|
||||
from: (currentPage - 1) * serversPerPage + 1,
|
||||
to: Math.min(currentPage * serversPerPage, allServers.length),
|
||||
total: allServers.length,
|
||||
})}
|
||||
</div>
|
||||
<div className="flex-[4] flex justify-center">
|
||||
{isRegistryTab ? (
|
||||
<CursorPagination
|
||||
currentPage={currentPage}
|
||||
hasNextPage={registryHasNextPage}
|
||||
hasPreviousPage={registryHasPreviousPage}
|
||||
onNextPage={goToRegistryNextPage}
|
||||
onPreviousPage={goToRegistryPreviousPage}
|
||||
/>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="text-sm text-gray-500">
|
||||
{isLocalTab ? (
|
||||
t('market.showing', {
|
||||
from: (currentPage - 1) * serversPerPage + 1,
|
||||
to: Math.min(currentPage * serversPerPage, allServers.length),
|
||||
total: allServers.length
|
||||
})
|
||||
) : (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
t('cloud.showing', {
|
||||
from: (currentPage - 1) * serversPerPage + 1,
|
||||
to: Math.min(currentPage * serversPerPage, allServers.length),
|
||||
total: allServers.length
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-[2] flex items-center justify-end space-x-2">
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<label htmlFor="perPage" className="text-sm text-gray-600">
|
||||
{isLocalTab
|
||||
? t('market.perPage')
|
||||
: isRegistryTab
|
||||
? t('registry.perPage')
|
||||
: t('cloud.perPage')}
|
||||
:
|
||||
{isLocalTab ? t('market.perPage') : t('cloud.perPage')}:
|
||||
</label>
|
||||
<select
|
||||
id="perPage"
|
||||
@@ -765,6 +507,9 @@ const MarketPage: React.FC = () => {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,55 +1,54 @@
|
||||
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';
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [installConfig, setInstallConfig] = useState<{
|
||||
pythonIndexUrl: string
|
||||
npmRegistry: string
|
||||
baseUrl: string
|
||||
pythonIndexUrl: string;
|
||||
npmRegistry: string;
|
||||
baseUrl: string;
|
||||
}>({
|
||||
pythonIndexUrl: '',
|
||||
npmRegistry: '',
|
||||
baseUrl: 'http://localhost:3000',
|
||||
})
|
||||
});
|
||||
|
||||
const [tempSmartRoutingConfig, setTempSmartRoutingConfig] = useState<{
|
||||
dbUrl: string
|
||||
openaiApiBaseUrl: string
|
||||
openaiApiKey: string
|
||||
openaiApiEmbeddingModel: string
|
||||
dbUrl: string;
|
||||
openaiApiBaseUrl: string;
|
||||
openaiApiKey: string;
|
||||
openaiApiEmbeddingModel: string;
|
||||
}>({
|
||||
dbUrl: '',
|
||||
openaiApiBaseUrl: '',
|
||||
openaiApiKey: '',
|
||||
openaiApiEmbeddingModel: '',
|
||||
})
|
||||
});
|
||||
|
||||
const [tempMCPRouterConfig, setTempMCPRouterConfig] = useState<{
|
||||
apiKey: string
|
||||
referer: string
|
||||
title: string
|
||||
baseUrl: string
|
||||
apiKey: string;
|
||||
referer: string;
|
||||
title: string;
|
||||
baseUrl: string;
|
||||
}>({
|
||||
apiKey: '',
|
||||
referer: 'https://www.mcphubx.com',
|
||||
title: 'MCPHub',
|
||||
baseUrl: 'https://api.mcprouter.to/v1',
|
||||
})
|
||||
});
|
||||
|
||||
const [tempNameSeparator, setTempNameSeparator] = useState<string>('-')
|
||||
const [tempNameSeparator, setTempNameSeparator] = useState<string>('-');
|
||||
|
||||
const {
|
||||
routingConfig,
|
||||
@@ -67,15 +66,14 @@ const SettingsPage: React.FC = () => {
|
||||
updateSmartRoutingConfigBatch,
|
||||
updateMCPRouterConfig,
|
||||
updateNameSeparator,
|
||||
exportMCPSettings,
|
||||
} = useSettingsData()
|
||||
} = useSettingsData();
|
||||
|
||||
// Update local installConfig when savedInstallConfig changes
|
||||
useEffect(() => {
|
||||
if (savedInstallConfig) {
|
||||
setInstallConfig(savedInstallConfig)
|
||||
setInstallConfig(savedInstallConfig);
|
||||
}
|
||||
}, [savedInstallConfig])
|
||||
}, [savedInstallConfig]);
|
||||
|
||||
// Update local tempSmartRoutingConfig when smartRoutingConfig changes
|
||||
useEffect(() => {
|
||||
@@ -85,9 +83,9 @@ const SettingsPage: React.FC = () => {
|
||||
openaiApiBaseUrl: smartRoutingConfig.openaiApiBaseUrl || '',
|
||||
openaiApiKey: smartRoutingConfig.openaiApiKey || '',
|
||||
openaiApiEmbeddingModel: smartRoutingConfig.openaiApiEmbeddingModel || '',
|
||||
})
|
||||
});
|
||||
}
|
||||
}, [smartRoutingConfig])
|
||||
}, [smartRoutingConfig]);
|
||||
|
||||
// Update local tempMCPRouterConfig when mcpRouterConfig changes
|
||||
useEffect(() => {
|
||||
@@ -97,14 +95,14 @@ const SettingsPage: React.FC = () => {
|
||||
referer: mcpRouterConfig.referer || 'https://www.mcphubx.com',
|
||||
title: mcpRouterConfig.title || 'MCPHub',
|
||||
baseUrl: mcpRouterConfig.baseUrl || 'https://api.mcprouter.to/v1',
|
||||
})
|
||||
});
|
||||
}
|
||||
}, [mcpRouterConfig])
|
||||
}, [mcpRouterConfig]);
|
||||
|
||||
// Update local tempNameSeparator when nameSeparator changes
|
||||
useEffect(() => {
|
||||
setTempNameSeparator(nameSeparator)
|
||||
}, [nameSeparator])
|
||||
setTempNameSeparator(nameSeparator);
|
||||
}, [nameSeparator]);
|
||||
|
||||
const [sectionsVisible, setSectionsVisible] = useState({
|
||||
routingConfig: false,
|
||||
@@ -112,244 +110,138 @@ const SettingsPage: React.FC = () => {
|
||||
smartRoutingConfig: false,
|
||||
mcpRouterConfig: false,
|
||||
nameSeparator: false,
|
||||
password: false,
|
||||
exportConfig: false,
|
||||
})
|
||||
password: false
|
||||
});
|
||||
|
||||
const toggleSection = (
|
||||
section:
|
||||
| 'routingConfig'
|
||||
| 'installConfig'
|
||||
| 'smartRoutingConfig'
|
||||
| 'mcpRouterConfig'
|
||||
| 'nameSeparator'
|
||||
| 'password'
|
||||
| 'exportConfig',
|
||||
) => {
|
||||
setSectionsVisible((prev) => ({
|
||||
const toggleSection = (section: 'routingConfig' | 'installConfig' | 'smartRoutingConfig' | 'mcpRouterConfig' | 'nameSeparator' | 'password') => {
|
||||
setSectionsVisible(prev => ({
|
||||
...prev,
|
||||
[section]: !prev[section],
|
||||
}))
|
||||
}
|
||||
[section]: !prev[section]
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRoutingConfigChange = async (
|
||||
key:
|
||||
| 'enableGlobalRoute'
|
||||
| 'enableGroupNameRoute'
|
||||
| 'enableBearerAuth'
|
||||
| 'bearerAuthKey'
|
||||
| 'skipAuth',
|
||||
value: boolean | string,
|
||||
) => {
|
||||
const handleRoutingConfigChange = async (key: 'enableGlobalRoute' | 'enableGroupNameRoute' | 'enableBearerAuth' | 'bearerAuthKey' | 'skipAuth', value: boolean | string) => {
|
||||
// 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,
|
||||
})
|
||||
bearerAuthKey: newKey
|
||||
});
|
||||
|
||||
if (success) {
|
||||
// Update tempRoutingConfig to reflect the saved values
|
||||
setTempRoutingConfig((prev) => ({
|
||||
setTempRoutingConfig(prev => ({
|
||||
...prev,
|
||||
bearerAuthKey: newKey,
|
||||
}))
|
||||
bearerAuthKey: newKey
|
||||
}));
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await updateRoutingConfig(key, value)
|
||||
}
|
||||
await updateRoutingConfig(key, value);
|
||||
};
|
||||
|
||||
const handleBearerAuthKeyChange = (value: string) => {
|
||||
setTempRoutingConfig((prev) => ({
|
||||
setTempRoutingConfig(prev => ({
|
||||
...prev,
|
||||
bearerAuthKey: value,
|
||||
}))
|
||||
}
|
||||
bearerAuthKey: value
|
||||
}));
|
||||
};
|
||||
|
||||
const saveBearerAuthKey = async () => {
|
||||
await updateRoutingConfig('bearerAuthKey', tempRoutingConfig.bearerAuthKey)
|
||||
}
|
||||
await updateRoutingConfig('bearerAuthKey', tempRoutingConfig.bearerAuthKey);
|
||||
};
|
||||
|
||||
const handleInstallConfigChange = (
|
||||
key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl',
|
||||
value: string,
|
||||
) => {
|
||||
const handleInstallConfigChange = (key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl', value: string) => {
|
||||
setInstallConfig({
|
||||
...installConfig,
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
[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',
|
||||
value: string,
|
||||
) => {
|
||||
const handleSmartRoutingConfigChange = (key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel', value: string) => {
|
||||
setTempSmartRoutingConfig({
|
||||
...tempSmartRoutingConfig,
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
[key]: value
|
||||
});
|
||||
};
|
||||
|
||||
const saveSmartRoutingConfig = async (
|
||||
key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel',
|
||||
) => {
|
||||
await updateSmartRoutingConfig(key, tempSmartRoutingConfig[key])
|
||||
}
|
||||
const saveSmartRoutingConfig = async (key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel') => {
|
||||
await updateSmartRoutingConfig(key, tempSmartRoutingConfig[key]);
|
||||
};
|
||||
|
||||
const handleMCPRouterConfigChange = (
|
||||
key: 'apiKey' | 'referer' | 'title' | 'baseUrl',
|
||||
value: string,
|
||||
) => {
|
||||
const handleMCPRouterConfigChange = (key: 'apiKey' | 'referer' | 'title' | 'baseUrl', value: string) => {
|
||||
setTempMCPRouterConfig({
|
||||
...tempMCPRouterConfig,
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
[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 currentOpenaiApiKey =
|
||||
tempSmartRoutingConfig.openaiApiKey || smartRoutingConfig.openaiApiKey
|
||||
const currentDbUrl = tempSmartRoutingConfig.dbUrl || smartRoutingConfig.dbUrl;
|
||||
const currentOpenaiApiKey = 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
|
||||
showToast(t('settings.smartRoutingValidationError', {
|
||||
fields: missingFields.join(', ')
|
||||
}));
|
||||
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
|
||||
if (tempSmartRoutingConfig.openaiApiEmbeddingModel !== smartRoutingConfig.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)
|
||||
}
|
||||
|
||||
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)
|
||||
} catch (error) {
|
||||
console.error('Error fetching MCP settings:', error)
|
||||
showToast(t('settings.exportError') || 'Failed to fetch settings', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (sectionsVisible.exportConfig && !mcpSettingsJson) {
|
||||
fetchMcpSettings()
|
||||
}
|
||||
}, [sectionsVisible.exportConfig])
|
||||
|
||||
const handleCopyConfig = async () => {
|
||||
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)
|
||||
} 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()
|
||||
try {
|
||||
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)
|
||||
}
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error copying configuration:', error)
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadConfig = () => {
|
||||
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')
|
||||
}
|
||||
navigate('/');
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto">
|
||||
@@ -373,9 +265,7 @@ const SettingsPage: React.FC = () => {
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableSmartRouting')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.enableSmartRoutingDescription')}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{t('settings.enableSmartRoutingDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
@@ -387,8 +277,7 @@ const SettingsPage: React.FC = () => {
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">
|
||||
<span className="text-red-500 px-1">*</span>
|
||||
{t('settings.dbUrl')}
|
||||
<span className="text-red-500 px-1">*</span>{t('settings.dbUrl')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -413,8 +302,7 @@ const SettingsPage: React.FC = () => {
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">
|
||||
<span className="text-red-500 px-1">*</span>
|
||||
{t('settings.openaiApiKey')}
|
||||
<span className="text-red-500 px-1">*</span>{t('settings.openaiApiKey')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -444,9 +332,7 @@ const SettingsPage: React.FC = () => {
|
||||
<input
|
||||
type="text"
|
||||
value={tempSmartRoutingConfig.openaiApiBaseUrl}
|
||||
onChange={(e) =>
|
||||
handleSmartRoutingConfigChange('openaiApiBaseUrl', e.target.value)
|
||||
}
|
||||
onChange={(e) => handleSmartRoutingConfigChange('openaiApiBaseUrl', e.target.value)}
|
||||
placeholder={t('settings.openaiApiBaseUrlPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading}
|
||||
@@ -463,17 +349,13 @@ const SettingsPage: React.FC = () => {
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">
|
||||
{t('settings.openaiApiEmbeddingModel')}
|
||||
</h3>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.openaiApiEmbeddingModel')}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempSmartRoutingConfig.openaiApiEmbeddingModel}
|
||||
onChange={(e) =>
|
||||
handleSmartRoutingConfigChange('openaiApiEmbeddingModel', e.target.value)
|
||||
}
|
||||
onChange={(e) => handleSmartRoutingConfigChange('openaiApiEmbeddingModel', e.target.value)}
|
||||
placeholder={t('settings.openaiApiEmbeddingModelPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading}
|
||||
@@ -510,9 +392,7 @@ const SettingsPage: React.FC = () => {
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.mcpRouterApiKey')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.mcpRouterApiKeyDescription')}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{t('settings.mcpRouterApiKeyDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
@@ -536,9 +416,7 @@ const SettingsPage: React.FC = () => {
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.mcpRouterBaseUrl')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.mcpRouterBaseUrlDescription')}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{t('settings.mcpRouterBaseUrlDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
@@ -570,7 +448,9 @@ const SettingsPage: React.FC = () => {
|
||||
onClick={() => toggleSection('nameSeparator')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('settings.systemSettings')}</h2>
|
||||
<span className="text-gray-500">{sectionsVisible.nameSeparator ? '▼' : '►'}</span>
|
||||
<span className="text-gray-500">
|
||||
{sectionsVisible.nameSeparator ? '▼' : '►'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.nameSeparator && (
|
||||
@@ -610,7 +490,9 @@ const SettingsPage: React.FC = () => {
|
||||
onClick={() => toggleSection('routingConfig')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('pages.settings.routeConfig')}</h2>
|
||||
<span className="text-gray-500">{sectionsVisible.routingConfig ? '▼' : '►'}</span>
|
||||
<span className="text-gray-500">
|
||||
{sectionsVisible.routingConfig ? '▼' : '►'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.routingConfig && (
|
||||
@@ -623,9 +505,7 @@ const SettingsPage: React.FC = () => {
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableBearerAuth}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableBearerAuth', checked)
|
||||
}
|
||||
onCheckedChange={(checked) => handleRoutingConfigChange('enableBearerAuth', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -658,32 +538,24 @@ const SettingsPage: React.FC = () => {
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableGlobalRoute')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.enableGlobalRouteDescription')}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{t('settings.enableGlobalRouteDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableGlobalRoute}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableGlobalRoute', checked)
|
||||
}
|
||||
onCheckedChange={(checked) => handleRoutingConfigChange('enableGlobalRoute', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableGroupNameRoute')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.enableGroupNameRouteDescription')}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{t('settings.enableGroupNameRouteDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableGroupNameRoute}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableGroupNameRoute', checked)
|
||||
}
|
||||
onCheckedChange={(checked) => handleRoutingConfigChange('enableGroupNameRoute', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -700,6 +572,7 @@ const SettingsPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</PermissionChecker>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -712,7 +585,9 @@ const SettingsPage: React.FC = () => {
|
||||
onClick={() => toggleSection('installConfig')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('settings.installConfig')}</h2>
|
||||
<span className="text-gray-500">{sectionsVisible.installConfig ? '▼' : '►'}</span>
|
||||
<span className="text-gray-500">
|
||||
{sectionsVisible.installConfig ? '▼' : '►'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.installConfig && (
|
||||
@@ -800,7 +675,9 @@ const SettingsPage: React.FC = () => {
|
||||
onClick={() => toggleSection('password')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('auth.changePassword')}</h2>
|
||||
<span className="text-gray-500">{sectionsVisible.password ? '▼' : '►'}</span>
|
||||
<span className="text-gray-500">
|
||||
{sectionsVisible.password ? '▼' : '►'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.password && (
|
||||
@@ -809,61 +686,8 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
};
|
||||
|
||||
{/* Export MCP Settings */}
|
||||
<PermissionChecker permissions={PERMISSIONS.SETTINGS_EXPORT_CONFIG}>
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('exportConfig')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('settings.exportMcpSettings')}</h2>
|
||||
<span className="text-gray-500">{sectionsVisible.exportConfig ? '▼' : '►'}</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.exportConfig && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-4">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.mcpSettingsJson')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.mcpSettingsJsonDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleCopyConfig}
|
||||
disabled={!mcpSettingsJson}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{copiedConfig ? <Check size={16} /> : <Copy size={16} />}
|
||||
{copiedConfig ? t('common.copied') : t('settings.copyToClipboard')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownloadConfig}
|
||||
disabled={!mcpSettingsJson}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
<Download size={16} />
|
||||
{t('settings.downloadJson')}
|
||||
</button>
|
||||
</div>
|
||||
{mcpSettingsJson && (
|
||||
<div className="mt-3">
|
||||
<pre className="bg-gray-900 text-gray-100 p-4 rounded-md overflow-x-auto text-xs max-h-96">
|
||||
{mcpSettingsJson}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PermissionChecker>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsPage
|
||||
export default SettingsPage;
|
||||
@@ -309,148 +309,3 @@ export interface AuthResponse {
|
||||
user?: IUser;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// Official Registry types (from registry.modelcontextprotocol.io)
|
||||
export interface RegistryVariable {
|
||||
choices?: string[];
|
||||
default?: string;
|
||||
description?: string;
|
||||
format?: string;
|
||||
isRequired?: boolean;
|
||||
isSecret?: boolean;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface RegistryVariables {
|
||||
[key: string]: RegistryVariable;
|
||||
}
|
||||
|
||||
export interface RegistryEnvironmentVariable {
|
||||
choices?: string[];
|
||||
default?: string;
|
||||
description?: string;
|
||||
format?: string;
|
||||
isRequired?: boolean;
|
||||
isSecret?: boolean;
|
||||
name: string;
|
||||
value?: string;
|
||||
variables?: RegistryVariables;
|
||||
}
|
||||
|
||||
export interface RegistryPackageArgument {
|
||||
choices?: string[];
|
||||
default?: string;
|
||||
description?: string;
|
||||
format?: string;
|
||||
isRepeated?: boolean;
|
||||
isRequired?: boolean;
|
||||
isSecret?: boolean;
|
||||
name: string;
|
||||
type?: string;
|
||||
value?: string;
|
||||
valueHint?: string;
|
||||
variables?: RegistryVariables;
|
||||
}
|
||||
|
||||
export interface RegistryTransportHeader {
|
||||
choices?: string[];
|
||||
default?: string;
|
||||
description?: string;
|
||||
format?: string;
|
||||
isRequired?: boolean;
|
||||
isSecret?: boolean;
|
||||
name: string;
|
||||
value?: string;
|
||||
variables?: RegistryVariables;
|
||||
}
|
||||
|
||||
export interface RegistryTransport {
|
||||
headers?: RegistryTransportHeader[];
|
||||
type: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface RegistryPackage {
|
||||
environmentVariables?: RegistryEnvironmentVariable[];
|
||||
fileSha256?: string;
|
||||
identifier: string;
|
||||
packageArguments?: RegistryPackageArgument[];
|
||||
registryBaseUrl?: string;
|
||||
registryType: string;
|
||||
runtimeArguments?: RegistryPackageArgument[];
|
||||
runtimeHint?: string;
|
||||
transport?: RegistryTransport;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface RegistryRemote {
|
||||
headers?: RegistryTransportHeader[];
|
||||
type: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface RegistryRepository {
|
||||
id?: string;
|
||||
source?: string;
|
||||
subfolder?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface RegistryIcon {
|
||||
mimeType: string;
|
||||
sizes?: string[];
|
||||
src: string;
|
||||
theme?: string;
|
||||
}
|
||||
|
||||
export interface RegistryServerData {
|
||||
$schema?: string;
|
||||
_meta?: {
|
||||
'io.modelcontextprotocol.registry/publisher-provided'?: Record<string, any>;
|
||||
};
|
||||
description: string;
|
||||
icons?: RegistryIcon[];
|
||||
name: string;
|
||||
packages?: RegistryPackage[];
|
||||
remotes?: RegistryRemote[];
|
||||
repository?: RegistryRepository;
|
||||
title: string;
|
||||
version: string;
|
||||
websiteUrl?: string;
|
||||
}
|
||||
|
||||
export interface RegistryOfficialMeta {
|
||||
isLatest?: boolean;
|
||||
publishedAt?: string;
|
||||
status?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface RegistryServerEntry {
|
||||
_meta?: {
|
||||
'io.modelcontextprotocol.registry/official'?: RegistryOfficialMeta;
|
||||
};
|
||||
server: RegistryServerData;
|
||||
}
|
||||
|
||||
export interface RegistryMetadata {
|
||||
count: number;
|
||||
nextCursor?: string;
|
||||
}
|
||||
|
||||
export interface RegistryServersResponse {
|
||||
metadata: RegistryMetadata;
|
||||
servers: RegistryServerEntry[];
|
||||
}
|
||||
|
||||
export interface RegistryServerVersionsResponse {
|
||||
metadata: RegistryMetadata;
|
||||
servers: RegistryServerEntry[];
|
||||
}
|
||||
|
||||
export interface RegistryServerVersionResponse {
|
||||
_meta?: {
|
||||
'io.modelcontextprotocol.registry/official'?: RegistryOfficialMeta;
|
||||
};
|
||||
server: RegistryServerData;
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@
|
||||
"addServer": "Add Server",
|
||||
"add": "Add",
|
||||
"edit": "Edit",
|
||||
"copy": "Copy",
|
||||
"delete": "Delete",
|
||||
"confirmDelete": "Are you sure you want to delete this server?",
|
||||
"deleteWarning": "Deleting server '{{name}}' will remove it and all its data. This action cannot be undone.",
|
||||
@@ -125,7 +124,6 @@
|
||||
"argumentsPlaceholder": "Enter arguments",
|
||||
"errorDetails": "Error Details",
|
||||
"viewErrorDetails": "View error details",
|
||||
"copyConfig": "Copy Configuration",
|
||||
"confirmVariables": "Confirm Variable Configuration",
|
||||
"variablesDetected": "Variables detected in configuration. Please confirm these variables are properly configured:",
|
||||
"detectedVariables": "Detected Variables",
|
||||
@@ -202,7 +200,6 @@
|
||||
"copyJson": "Copy JSON",
|
||||
"copySuccess": "Copied to clipboard",
|
||||
"copyFailed": "Copy failed",
|
||||
"copied": "Copied",
|
||||
"close": "Close",
|
||||
"confirm": "Confirm",
|
||||
"language": "Language",
|
||||
@@ -211,15 +208,7 @@
|
||||
"dismiss": "Dismiss",
|
||||
"github": "GitHub",
|
||||
"wechat": "WeChat",
|
||||
"discord": "Discord",
|
||||
"required": "Required",
|
||||
"secret": "Secret",
|
||||
"default": "Default",
|
||||
"value": "Value",
|
||||
"type": "Type",
|
||||
"repeated": "Repeated",
|
||||
"valueHint": "Value Hint",
|
||||
"choices": "Choices"
|
||||
"discord": "Discord"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -409,41 +398,6 @@
|
||||
"installSuccess": "Server {{name}} installed successfully",
|
||||
"installError": "Failed to install server: {{error}}"
|
||||
},
|
||||
"registry": {
|
||||
"title": "Registry",
|
||||
"official": "Official",
|
||||
"latest": "Latest",
|
||||
"description": "Description",
|
||||
"website": "Website",
|
||||
"repository": "Repository",
|
||||
"packages": "Packages",
|
||||
"package": "package",
|
||||
"remotes": "Remotes",
|
||||
"remote": "remote",
|
||||
"published": "Published",
|
||||
"updated": "Updated",
|
||||
"install": "Install",
|
||||
"installing": "Installing...",
|
||||
"installed": "Installed",
|
||||
"installServer": "Install {{name}}",
|
||||
"installSuccess": "Server {{name}} installed successfully",
|
||||
"installError": "Failed to install server: {{error}}",
|
||||
"noDescription": "No description available",
|
||||
"viewDetails": "View Details",
|
||||
"backToList": "Back to Registry",
|
||||
"search": "Search",
|
||||
"searchPlaceholder": "Search registry servers by name",
|
||||
"clearFilters": "Clear",
|
||||
"noServers": "No registry servers found",
|
||||
"fetchError": "Error fetching registry servers",
|
||||
"serverNotFound": "Registry server not found",
|
||||
"showing": "Showing {{from}}-{{to}} of {{total}} registry servers",
|
||||
"perPage": "Per page",
|
||||
"environmentVariables": "Environment Variables",
|
||||
"packageArguments": "Package Arguments",
|
||||
"runtimeArguments": "Runtime Arguments",
|
||||
"headers": "Headers"
|
||||
},
|
||||
"tool": {
|
||||
"run": "Run",
|
||||
"running": "Running...",
|
||||
@@ -548,14 +502,7 @@
|
||||
"systemSettings": "System Settings",
|
||||
"nameSeparatorLabel": "Name Separator",
|
||||
"nameSeparatorDescription": "Character used to separate server name and tool/prompt name (default: -)",
|
||||
"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",
|
||||
"mcpSettingsJsonDescription": "View, copy, or download your current mcp_settings.json configuration for backup or migration to other tools",
|
||||
"copyToClipboard": "Copy to Clipboard",
|
||||
"downloadJson": "Download JSON",
|
||||
"exportSuccess": "Settings exported successfully",
|
||||
"exportError": "Failed to fetch settings"
|
||||
"restartRequired": "Configuration saved. It is recommended to restart the application to ensure all services load the new settings correctly."
|
||||
},
|
||||
"dxt": {
|
||||
"upload": "Upload",
|
||||
|
||||
@@ -75,7 +75,6 @@
|
||||
"addServer": "Ajouter un serveur",
|
||||
"add": "Ajouter",
|
||||
"edit": "Modifier",
|
||||
"copy": "Copier",
|
||||
"delete": "Supprimer",
|
||||
"confirmDelete": "Êtes-vous sûr de vouloir supprimer ce serveur ?",
|
||||
"deleteWarning": "La suppression du serveur '{{name}}' le supprimera ainsi que toutes ses données. Cette action est irréversible.",
|
||||
@@ -125,7 +124,6 @@
|
||||
"argumentsPlaceholder": "Entrez les arguments",
|
||||
"errorDetails": "Détails de l'erreur",
|
||||
"viewErrorDetails": "Voir les détails de l'erreur",
|
||||
"copyConfig": "Copier la configuration",
|
||||
"confirmVariables": "Confirmer la configuration des variables",
|
||||
"variablesDetected": "Variables détectées dans la configuration. Veuillez confirmer que ces variables sont correctement configurées :",
|
||||
"detectedVariables": "Variables détectées",
|
||||
@@ -202,7 +200,6 @@
|
||||
"copyJson": "Copier le JSON",
|
||||
"copySuccess": "Copié dans le presse-papiers",
|
||||
"copyFailed": "Échec de la copie",
|
||||
"copied": "Copié",
|
||||
"close": "Fermer",
|
||||
"confirm": "Confirmer",
|
||||
"language": "Langue",
|
||||
@@ -211,15 +208,7 @@
|
||||
"dismiss": "Rejeter",
|
||||
"github": "GitHub",
|
||||
"wechat": "WeChat",
|
||||
"discord": "Discord",
|
||||
"required": "Requis",
|
||||
"secret": "Secret",
|
||||
"default": "Défaut",
|
||||
"value": "Valeur",
|
||||
"type": "Type",
|
||||
"repeated": "Répété",
|
||||
"valueHint": "Indice de valeur",
|
||||
"choices": "Choix"
|
||||
"discord": "Discord"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Tableau de bord",
|
||||
@@ -409,41 +398,6 @@
|
||||
"installSuccess": "Serveur {{name}} installé avec succès",
|
||||
"installError": "Échec de l'installation du serveur : {{error}}"
|
||||
},
|
||||
"registry": {
|
||||
"title": "Registre",
|
||||
"official": "Officiel",
|
||||
"latest": "Dernière version",
|
||||
"description": "Description",
|
||||
"website": "Site web",
|
||||
"repository": "Dépôt",
|
||||
"packages": "Paquets",
|
||||
"package": "paquet",
|
||||
"remotes": "Services distants",
|
||||
"remote": "service distant",
|
||||
"published": "Publié",
|
||||
"updated": "Mis à jour",
|
||||
"install": "Installer",
|
||||
"installing": "Installation...",
|
||||
"installed": "Installé",
|
||||
"installServer": "Installer {{name}}",
|
||||
"installSuccess": "Serveur {{name}} installé avec succès",
|
||||
"installError": "Échec de l'installation du serveur : {{error}}",
|
||||
"noDescription": "Aucune description disponible",
|
||||
"viewDetails": "Voir les détails",
|
||||
"backToList": "Retour au registre",
|
||||
"search": "Rechercher",
|
||||
"searchPlaceholder": "Rechercher des serveurs par nom",
|
||||
"clearFilters": "Effacer",
|
||||
"noServers": "Aucun serveur trouvé dans le registre",
|
||||
"fetchError": "Erreur lors de la récupération des serveurs du registre",
|
||||
"serverNotFound": "Serveur du registre non trouvé",
|
||||
"showing": "Affichage de {{from}}-{{to}} sur {{total}} serveurs du registre",
|
||||
"perPage": "Par page",
|
||||
"environmentVariables": "Variables d'environnement",
|
||||
"packageArguments": "Arguments du paquet",
|
||||
"runtimeArguments": "Arguments d'exécution",
|
||||
"headers": "En-têtes"
|
||||
},
|
||||
"tool": {
|
||||
"run": "Exécuter",
|
||||
"running": "Exécution en cours...",
|
||||
@@ -548,14 +502,7 @@
|
||||
"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 : -)",
|
||||
"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",
|
||||
"mcpSettingsJsonDescription": "Afficher, copier ou télécharger votre configuration mcp_settings.json actuelle pour la sauvegarde ou la migration vers d'autres outils",
|
||||
"copyToClipboard": "Copier dans le presse-papiers",
|
||||
"downloadJson": "Télécharger JSON",
|
||||
"exportSuccess": "Paramètres exportés avec succès",
|
||||
"exportError": "Échec de la récupération des paramètres"
|
||||
"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."
|
||||
},
|
||||
"dxt": {
|
||||
"upload": "Télécharger",
|
||||
|
||||
@@ -75,7 +75,6 @@
|
||||
"addServer": "添加服务器",
|
||||
"add": "添加",
|
||||
"edit": "编辑",
|
||||
"copy": "复制",
|
||||
"delete": "删除",
|
||||
"confirmDelete": "您确定要删除此服务器吗?",
|
||||
"deleteWarning": "删除服务器 '{{name}}' 将会移除该服务器及其所有数据。此操作无法撤销。",
|
||||
@@ -125,7 +124,6 @@
|
||||
"argumentsPlaceholder": "请输入参数",
|
||||
"errorDetails": "错误详情",
|
||||
"viewErrorDetails": "查看错误详情",
|
||||
"copyConfig": "复制配置",
|
||||
"confirmVariables": "确认变量配置",
|
||||
"variablesDetected": "检测到配置中包含变量,请确认这些变量是否已正确配置:",
|
||||
"detectedVariables": "检测到的变量",
|
||||
@@ -203,7 +201,6 @@
|
||||
"copyJson": "复制JSON",
|
||||
"copySuccess": "已复制到剪贴板",
|
||||
"copyFailed": "复制失败",
|
||||
"copied": "已复制",
|
||||
"close": "关闭",
|
||||
"confirm": "确认",
|
||||
"language": "语言",
|
||||
@@ -212,15 +209,7 @@
|
||||
"dismiss": "忽略",
|
||||
"github": "GitHub",
|
||||
"wechat": "微信",
|
||||
"discord": "Discord",
|
||||
"required": "必填",
|
||||
"secret": "敏感",
|
||||
"default": "默认值",
|
||||
"value": "值",
|
||||
"type": "类型",
|
||||
"repeated": "可重复",
|
||||
"valueHint": "值提示",
|
||||
"choices": "可选值"
|
||||
"discord": "Discord"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "仪表盘",
|
||||
@@ -410,41 +399,6 @@
|
||||
"installSuccess": "服务器 {{name}} 安装成功",
|
||||
"installError": "安装服务器失败:{{error}}"
|
||||
},
|
||||
"registry": {
|
||||
"title": "注册中心",
|
||||
"official": "官方",
|
||||
"latest": "最新版本",
|
||||
"description": "描述",
|
||||
"website": "网站",
|
||||
"repository": "代码仓库",
|
||||
"packages": "安装包",
|
||||
"package": "安装包",
|
||||
"remotes": "远程服务",
|
||||
"remote": "远程服务",
|
||||
"published": "发布时间",
|
||||
"updated": "更新时间",
|
||||
"install": "安装",
|
||||
"installing": "安装中...",
|
||||
"installed": "已安装",
|
||||
"installServer": "安装 {{name}}",
|
||||
"installSuccess": "服务器 {{name}} 安装成功",
|
||||
"installError": "安装服务器失败:{{error}}",
|
||||
"noDescription": "无描述信息",
|
||||
"viewDetails": "查看详情",
|
||||
"backToList": "返回注册中心",
|
||||
"search": "搜索",
|
||||
"searchPlaceholder": "按名称搜索注册中心服务器",
|
||||
"clearFilters": "清除",
|
||||
"noServers": "未找到注册中心服务器",
|
||||
"fetchError": "获取注册中心服务器失败",
|
||||
"serverNotFound": "未找到注册中心服务器",
|
||||
"showing": "显示 {{from}}-{{to}}/{{total}} 个注册中心服务器",
|
||||
"perPage": "每页显示",
|
||||
"environmentVariables": "环境变量",
|
||||
"packageArguments": "安装包参数",
|
||||
"runtimeArguments": "运行时参数",
|
||||
"headers": "请求头"
|
||||
},
|
||||
"tool": {
|
||||
"run": "运行",
|
||||
"running": "运行中...",
|
||||
@@ -550,14 +504,7 @@
|
||||
"systemSettings": "系统设置",
|
||||
"nameSeparatorLabel": "名称分隔符",
|
||||
"nameSeparatorDescription": "用于分隔服务器名称和工具/提示名称(默认:-)",
|
||||
"restartRequired": "配置已保存。为确保所有服务正确加载新设置,建议重启应用。",
|
||||
"exportMcpSettings": "导出配置",
|
||||
"mcpSettingsJson": "MCP 配置 JSON",
|
||||
"mcpSettingsJsonDescription": "查看、复制或下载当前的 mcp_settings.json 配置,可用于备份或迁移到其他工具",
|
||||
"copyToClipboard": "复制到剪贴板",
|
||||
"downloadJson": "下载 JSON",
|
||||
"exportSuccess": "配置导出成功",
|
||||
"exportError": "获取配置失败"
|
||||
"restartRequired": "配置已保存。为确保所有服务正确加载新设置,建议重启应用。"
|
||||
},
|
||||
"dxt": {
|
||||
"upload": "上传",
|
||||
|
||||
@@ -14,8 +14,10 @@
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"@playwright/mcp@latest",
|
||||
"--headless"
|
||||
]
|
||||
"--headless",
|
||||
"--isolated"
|
||||
],
|
||||
"perSession": true
|
||||
},
|
||||
"fetch": {
|
||||
"command": "uvx",
|
||||
|
||||
528
pnpm-lock.yaml
generated
528
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,12 @@
|
||||
import dotenv from 'dotenv'
|
||||
import fs from 'fs'
|
||||
import { McpSettings, IUser } from '../types/index.js'
|
||||
import { getConfigFilePath } from '../utils/path.js'
|
||||
import { getPackageVersion } from '../utils/version.js'
|
||||
import { getDataService } from '../services/services.js'
|
||||
import { DataService } from '../services/dataService.js'
|
||||
import dotenv from 'dotenv';
|
||||
import fs from 'fs';
|
||||
import { McpSettings, IUser } from '../types/index.js';
|
||||
import { getConfigFilePath } from '../utils/path.js';
|
||||
import { getPackageVersion } from '../utils/version.js';
|
||||
import { getDataService } from '../services/services.js';
|
||||
import { DataService } from '../services/dataService.js';
|
||||
|
||||
dotenv.config()
|
||||
dotenv.config();
|
||||
|
||||
const defaultConfig = {
|
||||
port: process.env.PORT || 3000,
|
||||
@@ -15,74 +15,71 @@ const defaultConfig = {
|
||||
readonly: 'true' === process.env.READONLY || false,
|
||||
mcpHubName: 'mcphub',
|
||||
mcpHubVersion: getPackageVersion(),
|
||||
}
|
||||
};
|
||||
|
||||
const dataService: DataService = getDataService()
|
||||
const dataService: DataService = getDataService();
|
||||
|
||||
// Settings cache
|
||||
let settingsCache: McpSettings | null = null
|
||||
let settingsCache: McpSettings | null = null;
|
||||
|
||||
export const getSettingsPath = (): string => {
|
||||
return getConfigFilePath('mcp_settings.json', 'Settings')
|
||||
}
|
||||
return getConfigFilePath('mcp_settings.json', 'Settings');
|
||||
};
|
||||
|
||||
export const loadOriginalSettings = (): McpSettings => {
|
||||
// If cache exists, return cached data directly
|
||||
if (settingsCache) {
|
||||
return settingsCache
|
||||
}
|
||||
|
||||
const settingsPath = getSettingsPath()
|
||||
// check if file exists
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
console.warn(`Settings file not found at ${settingsPath}, using default settings.`)
|
||||
const defaultSettings = { mcpServers: {}, users: [] }
|
||||
// Cache default settings
|
||||
settingsCache = defaultSettings
|
||||
return defaultSettings
|
||||
return settingsCache;
|
||||
}
|
||||
|
||||
const settingsPath = getSettingsPath();
|
||||
try {
|
||||
// Read and parse settings file
|
||||
const settingsData = fs.readFileSync(settingsPath, 'utf8')
|
||||
const settings = JSON.parse(settingsData)
|
||||
const settingsData = fs.readFileSync(settingsPath, 'utf8');
|
||||
const settings = JSON.parse(settingsData);
|
||||
|
||||
// Update cache
|
||||
settingsCache = settings
|
||||
settingsCache = settings;
|
||||
|
||||
console.log(`Loaded settings from ${settingsPath}`)
|
||||
return settings
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to load settings from ${settingsPath}: ${error}`)
|
||||
console.log(`Loaded settings from ${settingsPath}`);
|
||||
return settings;
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`Failed to load settings from ${settingsPath}:`, errorMessage);
|
||||
const defaultSettings = { mcpServers: {}, users: [] };
|
||||
|
||||
// Cache default settings
|
||||
settingsCache = defaultSettings;
|
||||
|
||||
return defaultSettings;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const loadSettings = (user?: IUser): McpSettings => {
|
||||
return dataService.filterSettings!(loadOriginalSettings(), user)
|
||||
}
|
||||
return dataService.filterSettings!(loadOriginalSettings(), user);
|
||||
};
|
||||
|
||||
export const saveSettings = (settings: McpSettings, user?: IUser): boolean => {
|
||||
const settingsPath = getSettingsPath()
|
||||
const settingsPath = getSettingsPath();
|
||||
try {
|
||||
const mergedSettings = dataService.mergeSettings!(loadOriginalSettings(), settings, user)
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(mergedSettings, null, 2), 'utf8')
|
||||
const mergedSettings = dataService.mergeSettings!(loadOriginalSettings(), settings, user);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(mergedSettings, null, 2), 'utf8');
|
||||
|
||||
// Update cache after successful save
|
||||
settingsCache = mergedSettings
|
||||
settingsCache = mergedSettings;
|
||||
|
||||
return true
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Failed to save settings to ${settingsPath}:`, error)
|
||||
return false
|
||||
console.error(`Failed to save settings to ${settingsPath}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear settings cache, force next loadSettings call to re-read from file
|
||||
*/
|
||||
export const clearSettingsCache = (): void => {
|
||||
settingsCache = null
|
||||
}
|
||||
settingsCache = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current cache status (for debugging)
|
||||
@@ -90,60 +87,60 @@ export const clearSettingsCache = (): void => {
|
||||
export const getSettingsCacheInfo = (): { hasCache: boolean } => {
|
||||
return {
|
||||
hasCache: settingsCache !== null,
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export function replaceEnvVars(input: Record<string, any>): Record<string, any>
|
||||
export function replaceEnvVars(input: string[] | undefined): string[]
|
||||
export function replaceEnvVars(input: string): string
|
||||
export function replaceEnvVars(input: Record<string, any>): Record<string, any>;
|
||||
export function replaceEnvVars(input: string[] | undefined): string[];
|
||||
export function replaceEnvVars(input: string): string;
|
||||
export function replaceEnvVars(
|
||||
input: Record<string, any> | string[] | string | undefined,
|
||||
): Record<string, any> | string[] | string {
|
||||
// Handle object input
|
||||
if (input && typeof input === 'object' && !Array.isArray(input)) {
|
||||
const res: Record<string, string> = {}
|
||||
const res: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (typeof value === 'string') {
|
||||
res[key] = expandEnvVars(value)
|
||||
res[key] = expandEnvVars(value);
|
||||
} else {
|
||||
res[key] = String(value)
|
||||
res[key] = String(value);
|
||||
}
|
||||
}
|
||||
return res
|
||||
return res;
|
||||
}
|
||||
|
||||
// Handle array input
|
||||
if (Array.isArray(input)) {
|
||||
return input.map((item) => expandEnvVars(item))
|
||||
return input.map((item) => expandEnvVars(item));
|
||||
}
|
||||
|
||||
// Handle string input
|
||||
if (typeof input === 'string') {
|
||||
return expandEnvVars(input)
|
||||
return expandEnvVars(input);
|
||||
}
|
||||
|
||||
// Handle undefined/null array input
|
||||
if (input === undefined || input === null) {
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
|
||||
return input
|
||||
return input;
|
||||
}
|
||||
|
||||
export const expandEnvVars = (value: string): string => {
|
||||
if (typeof value !== 'string') {
|
||||
return String(value)
|
||||
return String(value);
|
||||
}
|
||||
// Replace ${VAR} format
|
||||
let result = value.replace(/\$\{([^}]+)\}/g, (_, key) => process.env[key] || '')
|
||||
let result = value.replace(/\$\{([^}]+)\}/g, (_, key) => process.env[key] || '');
|
||||
// Also replace $VAR format (common on Unix-like systems)
|
||||
result = result.replace(/\$([A-Z_][A-Z0-9_]*)/g, (_, key) => process.env[key] || '')
|
||||
return result
|
||||
}
|
||||
result = result.replace(/\$([A-Z_][A-Z0-9_]*)/g, (_, key) => process.env[key] || '');
|
||||
return result;
|
||||
};
|
||||
|
||||
export default defaultConfig
|
||||
export default defaultConfig;
|
||||
|
||||
export function getNameSeparator(): string {
|
||||
const settings = loadSettings()
|
||||
return settings.systemConfig?.nameSeparator || '-'
|
||||
const settings = loadSettings();
|
||||
return settings.systemConfig?.nameSeparator || '-';
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response } from 'express';
|
||||
import config from '../config/index.js';
|
||||
import { loadSettings, loadOriginalSettings } from '../config/index.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import { getDataService } from '../services/services.js';
|
||||
import { DataService } from '../services/dataService.js';
|
||||
import { IUser } from '../types/index.js';
|
||||
@@ -72,46 +72,3 @@ export const getPublicConfig = (req: Request, res: Response): void => {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get MCP settings in JSON format for export/copy
|
||||
* Supports both full settings and individual server configuration
|
||||
*/
|
||||
export const getMcpSettingsJson = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { serverName } = req.query;
|
||||
const settings = loadOriginalSettings();
|
||||
if (serverName && typeof serverName === 'string') {
|
||||
// Return individual server configuration
|
||||
const serverConfig = settings.mcpServers[serverName];
|
||||
if (!serverConfig) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: `Server '${serverName}' not found`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
mcpServers: {
|
||||
[serverName]: serverConfig,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Return full settings
|
||||
res.json({
|
||||
success: true,
|
||||
data: settings,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting MCP settings JSON:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get MCP settings',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
|
||||
const REGISTRY_BASE_URL = 'https://registry.modelcontextprotocol.io/v0.1';
|
||||
|
||||
/**
|
||||
* Get all MCP servers from the official registry
|
||||
* Proxies the request to avoid CORS issues in the frontend
|
||||
* Supports cursor-based pagination
|
||||
*/
|
||||
export const getAllRegistryServers = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { cursor, limit, search } = req.query;
|
||||
|
||||
// Build URL with query parameters
|
||||
const url = new URL(`${REGISTRY_BASE_URL}/servers`);
|
||||
if (cursor && typeof cursor === 'string') {
|
||||
url.searchParams.append('cursor', cursor);
|
||||
}
|
||||
if (limit && typeof limit === 'string') {
|
||||
const limitNum = parseInt(limit, 10);
|
||||
if (!isNaN(limitNum) && limitNum > 0) {
|
||||
url.searchParams.append('limit', limit);
|
||||
}
|
||||
}
|
||||
if (search && typeof search === 'string') {
|
||||
url.searchParams.append('search', search);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: {
|
||||
Accept: 'application/json, application/problem+json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const apiResponse: ApiResponse<typeof data> = {
|
||||
success: true,
|
||||
data: data,
|
||||
};
|
||||
|
||||
res.json(apiResponse);
|
||||
} catch (error) {
|
||||
console.error('Error fetching registry servers:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to fetch registry servers';
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all versions of a specific MCP server
|
||||
* Proxies the request to avoid CORS issues in the frontend
|
||||
*/
|
||||
export const getRegistryServerVersions = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName } = req.params;
|
||||
|
||||
if (!serverName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Server name is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// URL encode the server name
|
||||
const encodedName = encodeURIComponent(serverName);
|
||||
const response = await fetch(`${REGISTRY_BASE_URL}/servers/${encodedName}/versions`, {
|
||||
headers: {
|
||||
Accept: 'application/json, application/problem+json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Server not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const apiResponse: ApiResponse<typeof data> = {
|
||||
success: true,
|
||||
data: data,
|
||||
};
|
||||
|
||||
res.json(apiResponse);
|
||||
} catch (error) {
|
||||
console.error('Error fetching registry server versions:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to fetch registry server versions';
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a specific version of an MCP server
|
||||
* Proxies the request to avoid CORS issues in the frontend
|
||||
*/
|
||||
export const getRegistryServerVersion = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName, version } = req.params;
|
||||
|
||||
if (!serverName || !version) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Server name and version are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// URL encode the server name and version
|
||||
const encodedName = encodeURIComponent(serverName);
|
||||
const encodedVersion = encodeURIComponent(version);
|
||||
const response = await fetch(
|
||||
`${REGISTRY_BASE_URL}/servers/${encodedName}/versions/${encodedVersion}`,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/json, application/problem+json',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Server version not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const apiResponse: ApiResponse<typeof data> = {
|
||||
success: true,
|
||||
data: data,
|
||||
};
|
||||
|
||||
res.json(apiResponse);
|
||||
} catch (error) {
|
||||
console.error('Error fetching registry server version:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to fetch registry server version';
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -56,18 +56,9 @@ import {
|
||||
getCloudServerToolsList,
|
||||
callCloudTool,
|
||||
} from '../controllers/cloudController.js';
|
||||
import {
|
||||
getAllRegistryServers,
|
||||
getRegistryServerVersions,
|
||||
getRegistryServerVersion,
|
||||
} from '../controllers/registryController.js';
|
||||
import { login, register, getCurrentUser, changePassword } from '../controllers/authController.js';
|
||||
import { getAllLogs, clearLogs, streamLogs } from '../controllers/logController.js';
|
||||
import {
|
||||
getRuntimeConfig,
|
||||
getPublicConfig,
|
||||
getMcpSettingsJson,
|
||||
} from '../controllers/configController.js';
|
||||
import { getRuntimeConfig, getPublicConfig } from '../controllers/configController.js';
|
||||
import { callTool } from '../controllers/toolController.js';
|
||||
import { getPrompt } from '../controllers/promptController.js';
|
||||
import { uploadDxtFile, uploadMiddleware } from '../controllers/dxtController.js';
|
||||
@@ -153,19 +144,11 @@ export const initRoutes = (app: express.Application): void => {
|
||||
router.get('/cloud/servers/:serverName/tools', getCloudServerToolsList);
|
||||
router.post('/cloud/servers/:serverName/tools/:toolName/call', callCloudTool);
|
||||
|
||||
// Registry routes (proxy to official MCP registry)
|
||||
router.get('/registry/servers', getAllRegistryServers);
|
||||
router.get('/registry/servers/:serverName/versions', getRegistryServerVersions);
|
||||
router.get('/registry/servers/:serverName/versions/:version', getRegistryServerVersion);
|
||||
|
||||
// Log routes
|
||||
router.get('/logs', getAllLogs);
|
||||
router.delete('/logs', clearLogs);
|
||||
router.get('/logs/stream', streamLogs);
|
||||
|
||||
// MCP settings export route
|
||||
router.get('/mcp-settings/export', getMcpSettingsJson);
|
||||
|
||||
// Auth routes - move to router instead of app directly
|
||||
router.post(
|
||||
'/auth/login',
|
||||
|
||||
@@ -15,26 +15,9 @@ import {
|
||||
} from './services/sseService.js';
|
||||
import { initializeDefaultUser } from './models/User.js';
|
||||
import { sseUserContextMiddleware } from './middlewares/userContext.js';
|
||||
import { findPackageRoot } from './utils/path.js';
|
||||
import { getCurrentModuleDir } from './utils/moduleDir.js';
|
||||
|
||||
/**
|
||||
* Get the directory of the current module
|
||||
* This is wrapped in a function to allow easy mocking in test environments
|
||||
*/
|
||||
function getCurrentFileDir(): string {
|
||||
// In test environments, use process.cwd() to avoid import.meta issues
|
||||
if (process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined) {
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
try {
|
||||
return getCurrentModuleDir();
|
||||
} catch {
|
||||
// Fallback for environments where import.meta might not be available
|
||||
return process.cwd();
|
||||
}
|
||||
}
|
||||
// Get the current working directory (will be project root in most cases)
|
||||
const currentFileDir = process.cwd() + '/src';
|
||||
|
||||
export class AppServer {
|
||||
private app: express.Application;
|
||||
@@ -184,11 +167,10 @@ export class AppServer {
|
||||
private findFrontendDistPath(): string | null {
|
||||
// Debug flag for detailed logging
|
||||
const debug = process.env.DEBUG === 'true';
|
||||
const currentDir = getCurrentFileDir();
|
||||
|
||||
if (debug) {
|
||||
console.log('DEBUG: Current directory:', process.cwd());
|
||||
console.log('DEBUG: Script directory:', currentDir);
|
||||
console.log('DEBUG: Script directory:', currentFileDir);
|
||||
}
|
||||
|
||||
// First, find the package root directory
|
||||
@@ -223,9 +205,51 @@ export class AppServer {
|
||||
|
||||
// Helper method to find the package root (where package.json is located)
|
||||
private findPackageRoot(): string | null {
|
||||
// Use the shared utility function which properly handles ESM module paths
|
||||
const currentDir = getCurrentFileDir();
|
||||
return findPackageRoot(currentDir);
|
||||
const debug = process.env.DEBUG === 'true';
|
||||
|
||||
// Possible locations for package.json
|
||||
const possibleRoots = [
|
||||
// Standard npm package location
|
||||
path.resolve(currentFileDir, '..', '..'),
|
||||
// Current working directory
|
||||
process.cwd(),
|
||||
// When running from dist directory
|
||||
path.resolve(currentFileDir, '..'),
|
||||
// When installed via npx
|
||||
path.resolve(currentFileDir, '..', '..', '..'),
|
||||
];
|
||||
|
||||
// Special handling for npx
|
||||
if (process.argv[1] && process.argv[1].includes('_npx')) {
|
||||
const npxDir = path.dirname(process.argv[1]);
|
||||
possibleRoots.unshift(path.resolve(npxDir, '..'));
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.log('DEBUG: Checking for package.json in:', possibleRoots);
|
||||
}
|
||||
|
||||
for (const root of possibleRoots) {
|
||||
const packageJsonPath = path.join(root, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
if (pkg.name === 'mcphub' || pkg.name === '@samanhappy/mcphub') {
|
||||
if (debug) {
|
||||
console.log(`DEBUG: Found package.json at ${packageJsonPath}`);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
} catch (e) {
|
||||
if (debug) {
|
||||
console.error(`DEBUG: Failed to parse package.json at ${packageJsonPath}:`, e);
|
||||
}
|
||||
// Continue to the next potential root
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,6 @@ export const getMarketServers = (): Record<string, MarketServer> => {
|
||||
const data = fs.readFileSync(serversJsonPath, 'utf8');
|
||||
const serversObj = JSON.parse(data) as Record<string, MarketServer>;
|
||||
|
||||
// use key as name field
|
||||
Object.entries(serversObj).forEach(([key, server]) => {
|
||||
server.name = key;
|
||||
});
|
||||
|
||||
const sortedEntries = Object.entries(serversObj).sort(([, serverA], [, serverB]) => {
|
||||
if (serverA.is_official && !serverB.is_official) return -1;
|
||||
if (!serverA.is_official && serverB.is_official) return 1;
|
||||
|
||||
@@ -24,6 +24,10 @@ import { getServerDao, ServerConfigWithName } from '../dao/index.js';
|
||||
|
||||
const servers: { [sessionId: string]: Server } = {};
|
||||
|
||||
// Per-session server instances for servers with perSession=true
|
||||
// Key format: `${sessionId}:${serverName}`
|
||||
const perSessionServerInfos: { [key: string]: ServerInfo } = {};
|
||||
|
||||
const serverDao = getServerDao();
|
||||
|
||||
// Helper function to set up keep-alive ping for SSE connections
|
||||
@@ -79,6 +83,8 @@ export const getMcpServer = (sessionId?: string, group?: string): Server => {
|
||||
|
||||
export const deleteMcpServer = (sessionId: string): void => {
|
||||
delete servers[sessionId];
|
||||
// Clean up any per-session servers for this session
|
||||
cleanupPerSessionServers(sessionId);
|
||||
};
|
||||
|
||||
export const notifyToolChanged = async (name?: string) => {
|
||||
@@ -223,6 +229,144 @@ const createTransportFromConfig = (name: string, conf: ServerConfig): any => {
|
||||
return transport;
|
||||
};
|
||||
|
||||
// Helper function to get or create per-session server instance
|
||||
export const getOrCreatePerSessionServer = async (
|
||||
sessionId: string,
|
||||
serverName: string,
|
||||
serverConfig: ServerConfig,
|
||||
): Promise<ServerInfo> => {
|
||||
const key = `${sessionId}:${serverName}`;
|
||||
|
||||
// Return existing session server if it exists
|
||||
if (perSessionServerInfos[key]) {
|
||||
return perSessionServerInfos[key];
|
||||
}
|
||||
|
||||
console.log(`Creating per-session server instance for session ${sessionId}, server ${serverName}`);
|
||||
|
||||
// Create new transport for this session
|
||||
const transport = createTransportFromConfig(serverName, serverConfig);
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
name: `mcp-client-${serverName}-${sessionId}`,
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
prompts: {},
|
||||
resources: {},
|
||||
tools: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Get request options from server configuration, with fallbacks
|
||||
const serverRequestOptions = serverConfig.options || {};
|
||||
const requestOptions = {
|
||||
timeout: serverRequestOptions.timeout || 60000,
|
||||
resetTimeoutOnProgress: serverRequestOptions.resetTimeoutOnProgress || false,
|
||||
maxTotalTimeout: serverRequestOptions.maxTotalTimeout,
|
||||
};
|
||||
|
||||
// Create server info for this session
|
||||
const serverInfo: ServerInfo = {
|
||||
name: serverName,
|
||||
owner: serverConfig.owner,
|
||||
status: 'connecting',
|
||||
error: null,
|
||||
tools: [],
|
||||
prompts: [],
|
||||
client,
|
||||
transport,
|
||||
options: requestOptions,
|
||||
createTime: Date.now(),
|
||||
config: serverConfig,
|
||||
sessionId: sessionId,
|
||||
};
|
||||
|
||||
perSessionServerInfos[key] = serverInfo;
|
||||
|
||||
// Connect asynchronously
|
||||
client
|
||||
.connect(transport, requestOptions)
|
||||
.then(() => {
|
||||
console.log(`Successfully connected per-session client for server: ${serverName}, session: ${sessionId}`);
|
||||
const capabilities = client.getServerCapabilities();
|
||||
|
||||
if (capabilities?.tools) {
|
||||
client
|
||||
.listTools({}, requestOptions)
|
||||
.then((tools) => {
|
||||
console.log(`Successfully listed ${tools.tools.length} tools for per-session server: ${serverName}, session: ${sessionId}`);
|
||||
serverInfo.tools = tools.tools.map((tool) => ({
|
||||
name: `${serverName}${getNameSeparator()}${tool.name}`,
|
||||
description: tool.description || '',
|
||||
inputSchema: cleanInputSchema(tool.inputSchema || {}),
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Failed to list tools for per-session server ${serverName}, session ${sessionId}:`, error);
|
||||
});
|
||||
}
|
||||
|
||||
if (capabilities?.prompts) {
|
||||
client
|
||||
.listPrompts({}, requestOptions)
|
||||
.then((prompts) => {
|
||||
console.log(`Successfully listed ${prompts.prompts.length} prompts for per-session server: ${serverName}, session: ${sessionId}`);
|
||||
serverInfo.prompts = prompts.prompts.map((prompt) => ({
|
||||
name: `${serverName}${getNameSeparator()}${prompt.name}`,
|
||||
title: prompt.title,
|
||||
description: prompt.description,
|
||||
arguments: prompt.arguments,
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Failed to list prompts for per-session server ${serverName}, session ${sessionId}:`, error);
|
||||
});
|
||||
}
|
||||
|
||||
serverInfo.status = 'connected';
|
||||
serverInfo.error = null;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Failed to connect per-session client for server ${serverName}, session ${sessionId}:`, error);
|
||||
serverInfo.status = 'disconnected';
|
||||
serverInfo.error = `Failed to connect: ${error.stack}`;
|
||||
});
|
||||
|
||||
return serverInfo;
|
||||
};
|
||||
|
||||
// Helper function to clean up per-session servers for a session
|
||||
export const cleanupPerSessionServers = (sessionId: string): void => {
|
||||
const keysToDelete: string[] = [];
|
||||
|
||||
for (const key in perSessionServerInfos) {
|
||||
if (key.startsWith(`${sessionId}:`)) {
|
||||
const serverInfo = perSessionServerInfos[key];
|
||||
try {
|
||||
if (serverInfo.client) {
|
||||
serverInfo.client.close();
|
||||
}
|
||||
if (serverInfo.transport) {
|
||||
serverInfo.transport.close();
|
||||
}
|
||||
if (serverInfo.keepAliveIntervalId) {
|
||||
clearInterval(serverInfo.keepAliveIntervalId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Error closing per-session server ${key}:`, error);
|
||||
}
|
||||
keysToDelete.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
keysToDelete.forEach(key => delete perSessionServerInfos[key]);
|
||||
console.log(`Cleaned up ${keysToDelete.length} per-session servers for session ${sessionId}`);
|
||||
};
|
||||
|
||||
// Helper function to handle client.callTool with reconnection logic
|
||||
const callToolWithReconnect = async (
|
||||
serverInfo: ServerInfo,
|
||||
@@ -243,14 +387,10 @@ const callToolWithReconnect = async (
|
||||
const isHttp40xError = error?.message?.startsWith?.('Error POSTing to endpoint (HTTP 40');
|
||||
// Only retry for StreamableHTTPClientTransport
|
||||
const isStreamableHttp = serverInfo.transport instanceof StreamableHTTPClientTransport;
|
||||
const isSSE = serverInfo.transport instanceof SSEClientTransport;
|
||||
if (
|
||||
attempt < maxRetries &&
|
||||
serverInfo.transport &&
|
||||
((isStreamableHttp && isHttp40xError) || isSSE)
|
||||
) {
|
||||
|
||||
if (isHttp40xError && attempt < maxRetries && serverInfo.transport && isStreamableHttp) {
|
||||
console.warn(
|
||||
`${isHttp40xError ? 'HTTP 40x error' : 'error'} detected for ${isStreamableHttp ? 'StreamableHTTP' : 'SSE'} server ${serverInfo.name}, attempting reconnection (attempt ${attempt + 1}/${maxRetries + 1})`,
|
||||
`HTTP 40x error detected for StreamableHTTP server ${serverInfo.name}, attempting reconnection (attempt ${attempt + 1}/${maxRetries + 1})`,
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -629,6 +769,45 @@ export const getServerByName = (name: string): ServerInfo | undefined => {
|
||||
return serverInfos.find((serverInfo) => serverInfo.name === name);
|
||||
};
|
||||
|
||||
// Get server by name with session support (for per-session servers)
|
||||
const getServerByNameWithSession = async (name: string, sessionId?: string): Promise<ServerInfo | undefined> => {
|
||||
// First check if this server is configured for per-session instances
|
||||
const serverConfig = await serverDao.findById(name);
|
||||
|
||||
if (serverConfig?.perSession && sessionId) {
|
||||
// Try to get or create per-session server
|
||||
const key = `${sessionId}:${name}`;
|
||||
if (perSessionServerInfos[key]) {
|
||||
return perSessionServerInfos[key];
|
||||
}
|
||||
// Create new per-session server instance
|
||||
return await getOrCreatePerSessionServer(sessionId, name, serverConfig);
|
||||
}
|
||||
|
||||
// Fall back to shared server
|
||||
return serverInfos.find((serverInfo) => serverInfo.name === name && !serverInfo.sessionId);
|
||||
};
|
||||
|
||||
// Get server by tool name with session support (for per-session servers)
|
||||
const getServerByToolWithSession = async (toolName: string, sessionId?: string): Promise<ServerInfo | undefined> => {
|
||||
// First try to find in per-session servers if sessionId is provided
|
||||
if (sessionId) {
|
||||
for (const key in perSessionServerInfos) {
|
||||
if (key.startsWith(`${sessionId}:`)) {
|
||||
const serverInfo = perSessionServerInfos[key];
|
||||
if (serverInfo.tools.some((tool) => tool.name === toolName)) {
|
||||
return serverInfo;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to shared servers
|
||||
return serverInfos.find((serverInfo) =>
|
||||
!serverInfo.sessionId && serverInfo.tools.some((tool) => tool.name === toolName)
|
||||
);
|
||||
};
|
||||
|
||||
// Filter tools by server configuration
|
||||
const filterToolsByConfig = async (serverName: string, tools: Tool[]): Promise<Tool[]> => {
|
||||
const serverConfig = await serverDao.findById(serverName);
|
||||
@@ -644,8 +823,8 @@ const filterToolsByConfig = async (serverName: string, tools: Tool[]): Promise<T
|
||||
});
|
||||
};
|
||||
|
||||
// Get server by tool name
|
||||
const getServerByTool = (toolName: string): ServerInfo | undefined => {
|
||||
// Get server by tool name (legacy - use getServerByToolWithSession instead)
|
||||
const _getServerByTool = (toolName: string): ServerInfo | undefined => {
|
||||
return serverInfos.find((serverInfo) => serverInfo.tools.some((tool) => tool.name === toolName));
|
||||
};
|
||||
|
||||
@@ -830,15 +1009,35 @@ Available servers: ${serversList}`;
|
||||
};
|
||||
}
|
||||
|
||||
const allServerInfos = getDataService()
|
||||
// Get shared servers
|
||||
let allServerInfos = getDataService()
|
||||
.filterData(serverInfos)
|
||||
.filter((serverInfo) => {
|
||||
if (serverInfo.enabled === false) return false;
|
||||
if (serverInfo.sessionId) return false; // Exclude per-session servers from shared list
|
||||
if (!group) return true;
|
||||
const serversInGroup = getServersInGroup(group);
|
||||
if (!serversInGroup || serversInGroup.length === 0) return serverInfo.name === group;
|
||||
return serversInGroup.includes(serverInfo.name);
|
||||
});
|
||||
|
||||
// Add per-session servers for this session
|
||||
if (sessionId) {
|
||||
const sessionServers = Object.values(perSessionServerInfos).filter(
|
||||
(serverInfo) => serverInfo.sessionId === sessionId && serverInfo.status === 'connected'
|
||||
);
|
||||
|
||||
// Filter session servers by group if applicable
|
||||
const filteredSessionServers = sessionServers.filter((serverInfo) => {
|
||||
if (serverInfo.enabled === false) return false;
|
||||
if (!group) return true;
|
||||
const serversInGroup = getServersInGroup(group);
|
||||
if (!serversInGroup || serversInGroup.length === 0) return serverInfo.name === group;
|
||||
return serversInGroup.includes(serverInfo.name);
|
||||
});
|
||||
|
||||
allServerInfos = [...allServerInfos, ...filteredSessionServers];
|
||||
}
|
||||
|
||||
const allTools = [];
|
||||
for (const serverInfo of allServerInfos) {
|
||||
@@ -1002,17 +1201,13 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
|
||||
}
|
||||
|
||||
const { arguments: toolArgs = {} } = request.params.arguments || {};
|
||||
const sessionId = extra?.sessionId;
|
||||
let targetServerInfo: ServerInfo | undefined;
|
||||
if (extra && extra.server) {
|
||||
targetServerInfo = getServerByName(extra.server);
|
||||
targetServerInfo = await getServerByNameWithSession(extra.server, sessionId);
|
||||
} else {
|
||||
// Find the first server that has this tool
|
||||
targetServerInfo = serverInfos.find(
|
||||
(serverInfo) =>
|
||||
serverInfo.status === 'connected' &&
|
||||
serverInfo.enabled !== false &&
|
||||
serverInfo.tools.some((tool) => tool.name === toolName),
|
||||
);
|
||||
// Find the first server that has this tool (session-aware)
|
||||
targetServerInfo = await getServerByToolWithSession(toolName, sessionId);
|
||||
}
|
||||
|
||||
if (!targetServerInfo) {
|
||||
@@ -1101,7 +1296,9 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
|
||||
|
||||
const separator = getNameSeparator();
|
||||
const prefix = `${targetServerInfo.name}${separator}`;
|
||||
toolName = toolName.startsWith(prefix) ? toolName.substring(prefix.length) : toolName;
|
||||
toolName = toolName.startsWith(prefix)
|
||||
? toolName.substring(prefix.length)
|
||||
: toolName;
|
||||
const result = await callToolWithReconnect(
|
||||
targetServerInfo,
|
||||
{
|
||||
@@ -1116,7 +1313,8 @@ export const handleCallToolRequest = async (request: any, extra: any) => {
|
||||
}
|
||||
|
||||
// Regular tool handling
|
||||
const serverInfo = getServerByTool(request.params.name);
|
||||
const sessionId = extra?.sessionId;
|
||||
const serverInfo = await getServerByToolWithSession(request.params.name, sessionId);
|
||||
if (!serverInfo) {
|
||||
throw new Error(`Server not found: ${request.params.name}`);
|
||||
}
|
||||
@@ -1235,7 +1433,9 @@ export const handleGetPromptRequest = async (request: any, extra: any) => {
|
||||
// Remove server prefix from prompt name if present
|
||||
const separator = getNameSeparator();
|
||||
const prefix = `${server.name}${separator}`;
|
||||
const cleanPromptName = name.startsWith(prefix) ? name.substring(prefix.length) : name;
|
||||
const cleanPromptName = name.startsWith(prefix)
|
||||
? name.substring(prefix.length)
|
||||
: name;
|
||||
|
||||
const promptParams = {
|
||||
name: cleanPromptName || '',
|
||||
|
||||
@@ -178,6 +178,7 @@ export interface ServerConfig {
|
||||
enabled?: boolean; // Flag to enable/disable the server
|
||||
owner?: string; // Owner of the server, defaults to 'admin' user
|
||||
keepAliveInterval?: number; // Keep-alive ping interval in milliseconds (default: 60000ms for SSE servers)
|
||||
perSession?: boolean; // If true, creates a separate server instance for each session (useful for stateful servers like playwright)
|
||||
tools?: Record<string, { enabled: boolean; description?: string }>; // Tool-specific configurations with enable/disable state and custom descriptions
|
||||
prompts?: Record<string, { enabled: boolean; description?: string }>; // Prompt-specific configurations with enable/disable state and custom descriptions
|
||||
options?: Partial<Pick<RequestOptions, 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout'>>; // MCP request options configuration
|
||||
@@ -239,6 +240,7 @@ export interface ServerInfo {
|
||||
enabled?: boolean; // Flag to indicate if the server is enabled
|
||||
keepAliveIntervalId?: NodeJS.Timeout; // Timer ID for keep-alive ping interval
|
||||
config?: ServerConfig; // Reference to the original server configuration for OpenAPI passthrough headers
|
||||
sessionId?: string; // Session ID for per-session server instances (undefined for shared servers)
|
||||
}
|
||||
|
||||
// Details about a tool available on the server
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { fileURLToPath } from 'url';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Get the directory of the current module
|
||||
* This is in a separate file to allow mocking in test environments
|
||||
*/
|
||||
export function getCurrentModuleDir(): string {
|
||||
const currentModuleFile = fileURLToPath(import.meta.url);
|
||||
return path.dirname(currentModuleFile);
|
||||
}
|
||||
@@ -1,171 +1,10 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { dirname } from 'path';
|
||||
import { getCurrentModuleDir } from './moduleDir.js';
|
||||
|
||||
// Project root directory - use process.cwd() as a simpler alternative
|
||||
const rootDir = process.cwd();
|
||||
|
||||
// Cache the package root for performance
|
||||
let cachedPackageRoot: string | null | undefined = undefined;
|
||||
|
||||
/**
|
||||
* Initialize package root by trying to find it using the module directory
|
||||
* This should be called when the module is first loaded
|
||||
*/
|
||||
function initializePackageRoot(): void {
|
||||
// Skip initialization in test environments
|
||||
if (process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to get the current module's directory
|
||||
const currentModuleDir = getCurrentModuleDir();
|
||||
|
||||
// This file is in src/utils/path.ts (or dist/utils/path.js when compiled)
|
||||
// So package.json should be 2 levels up
|
||||
const possibleRoots = [
|
||||
path.resolve(currentModuleDir, '..', '..'), // dist -> package root
|
||||
path.resolve(currentModuleDir, '..'), // dist/utils -> dist -> package root
|
||||
];
|
||||
|
||||
for (const root of possibleRoots) {
|
||||
const packageJsonPath = path.join(root, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
if (pkg.name === 'mcphub' || pkg.name === '@samanhappy/mcphub') {
|
||||
cachedPackageRoot = root;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Continue checking
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If initialization fails, cachedPackageRoot remains undefined
|
||||
// and findPackageRoot will search normally
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on module load (unless in test environment)
|
||||
initializePackageRoot();
|
||||
|
||||
/**
|
||||
* Find the package root directory (where package.json is located)
|
||||
* This works correctly when the package is installed globally or locally
|
||||
* @param startPath Starting path to search from (defaults to checking module paths)
|
||||
* @returns The package root directory path, or null if not found
|
||||
*/
|
||||
export const findPackageRoot = (startPath?: string): string | null => {
|
||||
// Return cached value if available and no specific start path is requested
|
||||
if (cachedPackageRoot !== undefined && !startPath) {
|
||||
return cachedPackageRoot;
|
||||
}
|
||||
|
||||
const debug = process.env.DEBUG === 'true';
|
||||
|
||||
// Possible locations for package.json relative to the search path
|
||||
const possibleRoots: string[] = [];
|
||||
|
||||
if (startPath) {
|
||||
// When start path is provided (from fileURLToPath(import.meta.url))
|
||||
possibleRoots.push(
|
||||
// When in dist/utils (compiled code) - go up 2 levels
|
||||
path.resolve(startPath, '..', '..'),
|
||||
// When in dist/ (compiled code) - go up 1 level
|
||||
path.resolve(startPath, '..'),
|
||||
// Direct parent directories
|
||||
path.resolve(startPath)
|
||||
);
|
||||
}
|
||||
|
||||
// Try to use require.resolve to find the module location (works in CommonJS and ESM with createRequire)
|
||||
try {
|
||||
// In ESM, we can use import.meta.resolve, but it's async in some versions
|
||||
// So we'll try to find the module by checking the node_modules structure
|
||||
|
||||
// Check if this file is in a node_modules installation
|
||||
const currentFile = new Error().stack?.split('\n')[2]?.match(/\((.+?):\d+:\d+\)$/)?.[1];
|
||||
if (currentFile) {
|
||||
const nodeModulesIndex = currentFile.indexOf('node_modules');
|
||||
if (nodeModulesIndex !== -1) {
|
||||
// Extract the package path from node_modules
|
||||
const afterNodeModules = currentFile.substring(nodeModulesIndex + 'node_modules'.length + 1);
|
||||
const packageNameEnd = afterNodeModules.indexOf(path.sep);
|
||||
if (packageNameEnd !== -1) {
|
||||
const packagePath = currentFile.substring(0, nodeModulesIndex + 'node_modules'.length + 1 + packageNameEnd);
|
||||
possibleRoots.push(packagePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
// Check module.filename location (works in Node.js when available)
|
||||
if (typeof __filename !== 'undefined') {
|
||||
const moduleDir = path.dirname(__filename);
|
||||
possibleRoots.push(
|
||||
path.resolve(moduleDir, '..', '..'),
|
||||
path.resolve(moduleDir, '..')
|
||||
);
|
||||
}
|
||||
|
||||
// Check common installation locations
|
||||
possibleRoots.push(
|
||||
// Current working directory (for development/tests)
|
||||
process.cwd(),
|
||||
// Parent of cwd
|
||||
path.resolve(process.cwd(), '..')
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
console.log('DEBUG: Searching for package.json from:', startPath || 'multiple locations');
|
||||
console.log('DEBUG: Checking paths:', possibleRoots);
|
||||
}
|
||||
|
||||
// Remove duplicates
|
||||
const uniqueRoots = [...new Set(possibleRoots)];
|
||||
|
||||
for (const root of uniqueRoots) {
|
||||
const packageJsonPath = path.join(root, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
if (pkg.name === 'mcphub' || pkg.name === '@samanhappy/mcphub') {
|
||||
if (debug) {
|
||||
console.log(`DEBUG: Found package.json at ${packageJsonPath}`);
|
||||
}
|
||||
// Cache the result if no specific start path was requested
|
||||
if (!startPath) {
|
||||
cachedPackageRoot = root;
|
||||
}
|
||||
return root;
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue to the next potential root
|
||||
if (debug) {
|
||||
console.error(`DEBUG: Failed to parse package.json at ${packageJsonPath}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.warn('DEBUG: Could not find package root directory');
|
||||
}
|
||||
|
||||
// Cache null result as well to avoid repeated searches
|
||||
if (!startPath) {
|
||||
cachedPackageRoot = null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
function getParentPath(p: string, filename: string): string {
|
||||
if (p.endsWith(filename)) {
|
||||
p = p.slice(0, -filename.length);
|
||||
@@ -201,36 +40,22 @@ export const getConfigFilePath = (filename: string, description = 'Configuration
|
||||
}
|
||||
|
||||
const potentialPaths = [
|
||||
// Prioritize process.cwd() as the first location to check
|
||||
path.resolve(process.cwd(), filename),
|
||||
// Use path relative to the root directory
|
||||
path.join(rootDir, filename),
|
||||
// If installed with npx, may need to look one level up
|
||||
path.join(dirname(rootDir), filename),
|
||||
...[
|
||||
// Prioritize process.cwd() as the first location to check
|
||||
path.resolve(process.cwd(), filename),
|
||||
// Use path relative to the root directory
|
||||
path.join(rootDir, filename),
|
||||
// If installed with npx, may need to look one level up
|
||||
path.join(dirname(rootDir), filename),
|
||||
],
|
||||
];
|
||||
|
||||
// Also check in the installed package root directory
|
||||
const packageRoot = findPackageRoot();
|
||||
if (packageRoot) {
|
||||
potentialPaths.push(path.join(packageRoot, filename));
|
||||
}
|
||||
|
||||
for (const filePath of potentialPaths) {
|
||||
if (fs.existsSync(filePath)) {
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
|
||||
// If all paths do not exist, check if we have a fallback in the package root
|
||||
// If the file exists in the package root, use it as the default
|
||||
if (packageRoot) {
|
||||
const packageConfigPath = path.join(packageRoot, filename);
|
||||
if (fs.existsSync(packageConfigPath)) {
|
||||
console.log(`Using ${description} from package: ${packageConfigPath}`);
|
||||
return packageConfigPath;
|
||||
}
|
||||
}
|
||||
|
||||
// If all paths do not exist, use default path
|
||||
// Using the default path is acceptable because it ensures the application can proceed
|
||||
// even if the configuration file is missing. This fallback is particularly useful in
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { findPackageRoot } from './path.js';
|
||||
|
||||
/**
|
||||
* Gets the package version from package.json
|
||||
* @param searchPath Optional path to start searching from (defaults to cwd)
|
||||
* @returns The version string from package.json, or 'dev' if not found
|
||||
*/
|
||||
export const getPackageVersion = (searchPath?: string): string => {
|
||||
export const getPackageVersion = (): string => {
|
||||
try {
|
||||
// Use provided path or fallback to current working directory
|
||||
const startPath = searchPath || process.cwd();
|
||||
|
||||
const packageRoot = findPackageRoot(startPath);
|
||||
if (!packageRoot) {
|
||||
console.warn('Could not find package root, using default version');
|
||||
return 'dev';
|
||||
}
|
||||
|
||||
const packageJsonPath = path.join(packageRoot, 'package.json');
|
||||
const packageJsonPath = path.resolve(process.cwd(), 'package.json');
|
||||
const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf8');
|
||||
const packageJson = JSON.parse(packageJsonContent);
|
||||
return packageJson.version || 'dev';
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import { getMcpSettingsJson } from '../../src/controllers/configController.js'
|
||||
import * as config from '../../src/config/index.js'
|
||||
import { Request, Response } from 'express'
|
||||
|
||||
// Mock the config module
|
||||
jest.mock('../../src/config/index.js')
|
||||
|
||||
describe('ConfigController - getMcpSettingsJson', () => {
|
||||
let mockRequest: Partial<Request>
|
||||
let mockResponse: Partial<Response>
|
||||
let mockJson: jest.Mock
|
||||
let mockStatus: jest.Mock
|
||||
|
||||
beforeEach(() => {
|
||||
mockJson = jest.fn()
|
||||
mockStatus = jest.fn().mockReturnThis()
|
||||
mockRequest = {
|
||||
query: {},
|
||||
}
|
||||
mockResponse = {
|
||||
json: mockJson,
|
||||
status: mockStatus,
|
||||
}
|
||||
|
||||
// Reset mocks
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Full Settings Export', () => {
|
||||
it('should handle settings without users array', () => {
|
||||
const mockSettings = {
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
command: 'test',
|
||||
args: ['--test'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
;(config.loadOriginalSettings as jest.Mock).mockReturnValue(mockSettings)
|
||||
|
||||
getMcpSettingsJson(mockRequest as Request, mockResponse as Response)
|
||||
|
||||
expect(mockJson).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
data: {
|
||||
mcpServers: mockSettings.mcpServers,
|
||||
users: undefined,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Individual Server Export', () => {
|
||||
it('should return individual server configuration when serverName is specified', () => {
|
||||
const mockSettings = {
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
command: 'test',
|
||||
args: ['--test'],
|
||||
env: {
|
||||
TEST_VAR: 'test-value',
|
||||
},
|
||||
},
|
||||
'another-server': {
|
||||
command: 'another',
|
||||
args: ['--another'],
|
||||
},
|
||||
},
|
||||
users: [
|
||||
{
|
||||
username: 'admin',
|
||||
password: '$2b$10$hashedpassword',
|
||||
isAdmin: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
mockRequest.query = { serverName: 'test-server' }
|
||||
;(config.loadOriginalSettings as jest.Mock).mockReturnValue(mockSettings)
|
||||
|
||||
getMcpSettingsJson(mockRequest as Request, mockResponse as Response)
|
||||
|
||||
expect(mockJson).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
data: {
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
command: 'test',
|
||||
args: ['--test'],
|
||||
env: {
|
||||
TEST_VAR: 'test-value',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should return 404 when server does not exist', () => {
|
||||
const mockSettings = {
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
command: 'test',
|
||||
args: ['--test'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockRequest.query = { serverName: 'non-existent-server' }
|
||||
;(config.loadOriginalSettings as jest.Mock).mockReturnValue(mockSettings)
|
||||
|
||||
getMcpSettingsJson(mockRequest as Request, mockResponse as Response)
|
||||
|
||||
expect(mockStatus).toHaveBeenCalledWith(404)
|
||||
expect(mockJson).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
message: "Server 'non-existent-server' not found",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle errors gracefully and return 500', () => {
|
||||
const errorMessage = 'Failed to load settings'
|
||||
;(config.loadOriginalSettings as jest.Mock).mockImplementation(() => {
|
||||
throw new Error(errorMessage)
|
||||
})
|
||||
|
||||
getMcpSettingsJson(mockRequest as Request, mockResponse as Response)
|
||||
|
||||
expect(mockStatus).toHaveBeenCalledWith(500)
|
||||
expect(mockJson).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
message: 'Failed to get MCP settings',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
111
tests/services/perSessionServers.test.ts
Normal file
111
tests/services/perSessionServers.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
getOrCreatePerSessionServer,
|
||||
cleanupPerSessionServers,
|
||||
} from '../../src/services/mcpService';
|
||||
import { ServerConfig } from '../../src/types';
|
||||
|
||||
// Mock the serverDao
|
||||
jest.mock('../../src/dao/index.js', () => ({
|
||||
getServerDao: () => ({
|
||||
findById: jest.fn((name: string) => {
|
||||
if (name === 'playwright') {
|
||||
return Promise.resolve({
|
||||
name: 'playwright',
|
||||
command: 'npx',
|
||||
args: ['@playwright/mcp@latest', '--headless', '--isolated'],
|
||||
perSession: true,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
}),
|
||||
findAll: jest.fn(() => Promise.resolve([])),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the Client and Transport classes
|
||||
jest.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
|
||||
Client: jest.fn().mockImplementation(() => ({
|
||||
connect: jest.fn(() => Promise.resolve()),
|
||||
close: jest.fn(),
|
||||
listTools: jest.fn(() => Promise.resolve({ tools: [] })),
|
||||
listPrompts: jest.fn(() => Promise.resolve({ prompts: [] })),
|
||||
getServerCapabilities: jest.fn(() => ({ tools: true, prompts: true })),
|
||||
callTool: jest.fn((params) => Promise.resolve({ content: [{ type: 'text', text: `Tool ${params.name} called` }] })),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
|
||||
StdioClientTransport: jest.fn().mockImplementation(() => ({
|
||||
close: jest.fn(),
|
||||
stderr: {
|
||||
on: jest.fn(),
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('Per-Session Server Instances', () => {
|
||||
afterEach(() => {
|
||||
// Clean up any created sessions
|
||||
cleanupPerSessionServers('session1');
|
||||
cleanupPerSessionServers('session2');
|
||||
});
|
||||
|
||||
it('should create separate server instances for different sessions', async () => {
|
||||
const config: ServerConfig = {
|
||||
command: 'npx',
|
||||
args: ['@playwright/mcp@latest', '--headless', '--isolated'],
|
||||
perSession: true,
|
||||
};
|
||||
|
||||
// Create server for session1
|
||||
const server1 = await getOrCreatePerSessionServer('session1', 'playwright', config);
|
||||
expect(server1).toBeDefined();
|
||||
expect(server1.sessionId).toBe('session1');
|
||||
|
||||
// Create server for session2
|
||||
const server2 = await getOrCreatePerSessionServer('session2', 'playwright', config);
|
||||
expect(server2).toBeDefined();
|
||||
expect(server2.sessionId).toBe('session2');
|
||||
|
||||
// They should be different instances
|
||||
expect(server1).not.toBe(server2);
|
||||
});
|
||||
|
||||
it('should reuse existing per-session server for the same session', async () => {
|
||||
const config: ServerConfig = {
|
||||
command: 'npx',
|
||||
args: ['@playwright/mcp@latest', '--headless', '--isolated'],
|
||||
perSession: true,
|
||||
};
|
||||
|
||||
// Create server for session1
|
||||
const server1 = await getOrCreatePerSessionServer('session1', 'playwright', config);
|
||||
|
||||
// Request the same server again
|
||||
const server2 = await getOrCreatePerSessionServer('session1', 'playwright', config);
|
||||
|
||||
// Should be the same instance
|
||||
expect(server1).toBe(server2);
|
||||
});
|
||||
|
||||
it('should clean up per-session servers when session ends', async () => {
|
||||
const config: ServerConfig = {
|
||||
command: 'npx',
|
||||
args: ['@playwright/mcp@latest', '--headless', '--isolated'],
|
||||
perSession: true,
|
||||
};
|
||||
|
||||
// Create server for session1
|
||||
const server1 = await getOrCreatePerSessionServer('session1', 'playwright', config);
|
||||
expect(server1).toBeDefined();
|
||||
|
||||
// Clean up session1
|
||||
cleanupPerSessionServers('session1');
|
||||
|
||||
// Create again should create a new instance (not the same object)
|
||||
const server2 = await getOrCreatePerSessionServer('session1', 'playwright', config);
|
||||
expect(server2).toBeDefined();
|
||||
expect(server2).not.toBe(server1);
|
||||
});
|
||||
});
|
||||
@@ -8,11 +8,6 @@ Object.assign(process.env, {
|
||||
DATABASE_URL: 'sqlite::memory:',
|
||||
});
|
||||
|
||||
// Mock moduleDir to avoid import.meta parsing issues in Jest
|
||||
jest.mock('../src/utils/moduleDir.js', () => ({
|
||||
getCurrentModuleDir: jest.fn(() => process.cwd()),
|
||||
}));
|
||||
|
||||
// Global test utilities
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
|
||||
Reference in New Issue
Block a user