Compare commits

...

9 Commits

Author SHA1 Message Date
samanhappy
86367a4875 feat: integrate offcial mcp server registry (#374) 2025-10-19 21:15:25 +08:00
samanhappy
bd4c546bba fix settings data export & parsing error (#373) 2025-10-16 13:08:28 +08:00
Copilot
3e9e5cc3c9 feat: Auto-start Docker daemon when installed in container (#370)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
Co-authored-by: samanhappy <samanhappy@gmail.com>
2025-10-13 22:38:13 +08:00
samanhappy
16a92096b3 feat: Enhance package root detection and version retrieval using ESM-compatible methods (#371) 2025-10-13 22:36:29 +08:00
Copilot
4d736c543d feat: Add MCP settings export and copy functionality (#367)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
Co-authored-by: samanhappy <samanhappy@gmail.com>
2025-10-13 19:39:01 +08:00
samanhappy
f53c4a0e3b fix: assign server name from key in getMarketServers function (#369) 2025-10-13 18:19:21 +08:00
Copilot
d4bdb099d0 Add Docker CLI support to Docker image with INSTALL_EXT build argument (#366)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-12 16:51:02 +08:00
samanhappy
435227cbd4 fix: improve error handling and directory creation for settings path (#364) 2025-10-12 15:30:40 +08:00
Copilot
6a59becd8d Fix Windows startup error: Convert paths to file:// URLs for ESM dynamic imports (#363)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
Co-authored-by: samanhappy <samanhappy@gmail.com>
2025-10-12 11:31:44 +08:00
39 changed files with 4045 additions and 781 deletions

124
.github/DOCKER_CLI_TEST.md vendored Normal file
View File

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

View File

@@ -13,6 +13,7 @@ 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
@@ -30,7 +31,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
@@ -48,7 +49,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
```
@@ -62,7 +63,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
@@ -91,6 +92,7 @@ pnpm format # Prettier formatting - ~3 seconds
**ALWAYS perform these validation steps after making changes:**
### 1. Basic Application Functionality
```bash
# Start the application
pnpm dev
@@ -105,6 +107,7 @@ 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:
@@ -114,6 +117,7 @@ curl -I http://localhost:3000/
```
### 3. Build Verification
```bash
# Verify production build works
pnpm build
@@ -126,6 +130,7 @@ 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**
@@ -136,11 +141,14 @@ 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
@@ -148,6 +156,7 @@ 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
@@ -155,12 +164,14 @@ 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
@@ -168,6 +179,7 @@ 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/`
@@ -176,29 +188,38 @@ 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
```
@@ -206,6 +227,7 @@ 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
@@ -213,22 +235,26 @@ 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

3
.gitignore vendored
View File

@@ -25,4 +25,5 @@ yarn-error.log*
*.log
coverage/
data/
data/
temp-test-config/

View File

@@ -4,4 +4,4 @@
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2
}
}

View File

@@ -22,6 +22,16 @@ 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

View File

@@ -1,8 +1,7 @@
#!/usr/bin/env node
import path from 'path';
import { fileURLToPath } from 'url';
import { execSync } from 'child_process';
import { fileURLToPath, pathToFileURL } from 'url';
import fs from 'fs';
// Enable debug logging if needed
@@ -90,7 +89,10 @@ checkFrontend(projectRoot);
// Start the server
console.log('🚀 Starting MCPHub server...');
import(path.join(projectRoot, 'dist', 'index.js')).catch(err => {
const entryPath = path.join(projectRoot, 'dist', 'index.js');
// Convert to file:// URL for cross-platform ESM compatibility (required on Windows)
const entryUrl = pathToFileURL(entryPath).href;
import(entryUrl).catch(err => {
console.error('Failed to start MCPHub:', err);
process.exit(1);
});

View File

@@ -41,6 +41,50 @@ 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

View File

@@ -41,6 +41,50 @@ 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 设置
### 基本配置

View File

@@ -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"
# 处理 HTTP_PROXY HTTPS_PROXY 环境变量
# Handle HTTP_PROXY and HTTPS_PROXY environment variables
if [ -n "$HTTP_PROXY" ]; then
echo "Setting HTTP proxy to ${HTTP_PROXY}"
npm config set proxy "$HTTP_PROXY"
@@ -19,4 +19,33 @@ 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 "$@"

View File

@@ -1,51 +1,52 @@
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 result = await apiPut(`/servers/${server.name}`, payload)
setError(null);
const encodedServerName = encodeURIComponent(server.name);
const result = await apiPut(`/servers/${encodedServerName}`, 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">
@@ -57,7 +58,7 @@ const EditServerForm = ({ server, onEdit, onCancel }: EditServerFormProps) => {
formError={error}
/>
</div>
)
}
);
};
export default EditServerForm
export default EditServerForm;

View File

@@ -0,0 +1,205 @@
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;

View File

@@ -0,0 +1,698 @@
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];
}
}

View File

@@ -7,6 +7,7 @@ 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
@@ -39,6 +40,8 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
}
}, [])
const { exportMCPSettings } = useSettingsData()
const handleRemove = (e: React.MouseEvent) => {
e.stopPropagation()
setShowDeleteDialog(true)
@@ -99,6 +102,39 @@ 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)
@@ -111,7 +147,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) {
@@ -133,7 +169,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) {
@@ -150,21 +186,33 @@ 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 */}
@@ -173,7 +221,9 @@ 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 && (
@@ -196,19 +246,25 @@ 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
@@ -222,7 +278,9 @@ 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>
)}
@@ -230,6 +288,9 @@ 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"
@@ -239,20 +300,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
@@ -271,10 +332,19 @@ 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>
@@ -282,14 +352,18 @@ 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>
@@ -309,4 +383,4 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
)
}
export default ServerCard
export default ServerCard

View File

@@ -1,17 +1,23 @@
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 = () => {
@@ -26,7 +32,19 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
}
};
const [serverType, setServerType] = useState<'stdio' | 'sse' | 'streamable-http' | 'openapi'>(getInitialServerType());
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 [formData, setFormData] = useState<ServerFormData>({
name: (initialData && initialData.name) || '',
@@ -40,149 +58,178 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
: '',
args: (initialData && initialData.config && initialData.config.args) || [],
type: getInitialServerType(), // Initialize the type field
env: [],
env: getInitialServerEnvVars(initialData),
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 = {
@@ -191,85 +238,87 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
type: serverType, // Always include the type
...(serverType === 'openapi'
? {
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 || ''
}
})
openapi: (() => {
const openapi: any = {
version: formData.openapi?.version || '3.1.0',
};
}
// 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 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');
}
}
return openapi;
})(),
...(Object.keys(headers).length > 0 ? { headers } : {})
}
// 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 } : {}),
}
: 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">
@@ -281,9 +330,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
</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}>
@@ -373,10 +420,12 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
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>
@@ -388,10 +437,12 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
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>
@@ -410,10 +461,12 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
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'}
@@ -424,7 +477,10 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
{/* 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
@@ -432,10 +488,12 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
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",
@@ -465,14 +523,16 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
</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>
@@ -486,29 +546,47 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
{/* 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>
@@ -517,14 +595,22 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
</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"
/>
@@ -536,16 +622,26 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
{/* 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>
@@ -554,16 +650,28 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
</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>
@@ -573,17 +681,27 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
{/* 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"
/>
@@ -595,30 +713,48 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
{/* 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"
/>
@@ -635,14 +771,22 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
<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">
@@ -701,7 +845,11 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
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>
@@ -837,23 +985,26 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
<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"
@@ -863,19 +1014,29 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
</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>
@@ -884,10 +1045,14 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
<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')}
@@ -915,7 +1080,7 @@ const ServerForm = ({ onSubmit, onCancel, initialData = null, modalTitle, formEr
</div>
</form>
</div>
)
}
);
};
export default ServerForm
export default ServerForm;

View File

@@ -0,0 +1,78 @@
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;

View File

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

View File

@@ -63,55 +63,58 @@ 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');
// Update last fetch time
lastFetchTimeRef.current = Date.now();
const fetchServers = async () => {
try {
console.log('[ServerContext] Fetching servers from API...');
const data = await apiGet('/servers');
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([]);
// 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'));
}
}
};
// 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'));
}
// Execute immediately unless explicitly skipped
if (immediate) {
fetchServers();
}
};
// Execute immediately unless explicitly skipped
if (immediate) {
fetchServers();
}
// Set up regular polling
intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval);
}, [t]);
// Set up regular polling
intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval);
},
[t],
);
// Watch for authentication status changes
useEffect(() => {
@@ -147,7 +150,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();
@@ -245,16 +248,30 @@ 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]);
@@ -263,74 +280,85 @@ 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 }));
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));
return null;
}
} catch (err) {
console.error('Error fetching server settings:', err);
setError(err instanceof Error ? err.message : String(err));
return null;
}
}, [t]);
},
[t],
);
const handleServerRemove = useCallback(async (serverName: string) => {
try {
const result = await apiDelete(`/servers/${serverName}`);
const handleServerRemove = useCallback(
async (serverName: string) => {
try {
const encodedServerName = encodeURIComponent(serverName);
const result = await apiDelete(`/servers/${encodedServerName}`);
if (!result || !result.success) {
setError(result?.message || t('server.deleteError', { 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)));
return false;
}
},
[t],
);
setRefreshKey((prevKey) => prevKey + 1);
return true;
} catch (err) {
setError(t('errors.general') + ': ' + (err instanceof Error ? err.message : String(err)));
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 });
const handleServerToggle = useCallback(async (server: Server, enabled: boolean) => {
try {
const result = await apiPost(`/servers/${server.name}/toggle`, { enabled });
if (!result || !result.success) {
console.error('Failed to toggle server:', result);
setError(result?.message || t('server.toggleError', { serverName: server.name }));
return false;
}
if (!result || !result.success) {
console.error('Failed to toggle server:', result);
setError(result?.message || t('server.toggleError', { serverName: server.name }));
// 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;
}
// 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]);
},
[t],
);
const value: ServerContextType = {
servers,
@@ -356,4 +384,4 @@ export const useServerContext = () => {
throw new Error('useServerContext must be used within a ServerProvider');
}
return context;
};
};

View File

@@ -0,0 +1,283 @@
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,
};
};

View File

@@ -420,6 +420,21 @@ 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();
@@ -454,5 +469,6 @@ export const useSettingsData = () => {
updateMCPRouterConfig,
updateMCPRouterConfigBatch,
updateNameSeparator,
exportMCPSettings,
};
};

View File

@@ -1,17 +1,27 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { MarketServer, CloudServer, ServerConfig } from '@/types';
import {
MarketServer,
CloudServer,
ServerConfig,
RegistryServerEntry,
RegistryServerData,
} 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();
@@ -19,7 +29,7 @@ const MarketPage: React.FC = () => {
const { serverName } = useParams<{ serverName?: string }>();
const { showToast } = useToast();
// Get tab from URL search params, default to cloud market
// Get tab from URL search params
const [searchParams, setSearchParams] = useSearchParams();
const currentTab = searchParams.get('tab') || 'cloud';
@@ -44,10 +54,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,
@@ -61,29 +71,67 @@ 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 or local server based on the current tab
// Determine if it's a cloud, local, or registry 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);
@@ -97,14 +145,22 @@ const MarketPage: React.FC = () => {
} else {
setSelectedServer(null);
setSelectedCloudServer(null);
setSelectedRegistryServer(null);
}
};
loadServerDetails();
}, [serverName, currentTab, cloudServers, fetchLocalServerByName, navigate]);
}, [
serverName,
currentTab,
cloudServers,
fetchLocalServerByName,
fetchRegistryServerByName,
navigate,
]);
// Tab switching handler
const switchTab = (tab: 'local' | 'cloud') => {
const switchTab = (tab: 'local' | 'cloud' | 'registry') => {
const newSearchParams = new URLSearchParams(searchParams);
newSearchParams.set('tab', tab);
setSearchParams(newSearchParams);
@@ -118,6 +174,8 @@ 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
};
@@ -129,18 +187,35 @@ const MarketPage: React.FC = () => {
};
const handleClearFilters = () => {
setSearchQuery('');
if (currentTab === 'local') {
setSearchQuery('');
filterLocalByCategory('');
filterLocalByTag('');
} else if (currentTab === 'registry') {
setRegistrySearchQuery('');
clearRegistrySearch();
}
};
const handleServerClick = (server: MarketServer | CloudServer) => {
const handleServerClick = (server: MarketServer | CloudServer | RegistryServerEntry) => {
if (currentTab === 'cloud') {
navigate(`/market/${server.name}?tab=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);
}
} else {
navigate(`/market/${server.name}?tab=local`);
const marketServer = server as MarketServer;
navigate(`/market/${marketServer.name}?tab=local`);
}
};
@@ -167,7 +242,7 @@ const MarketPage: React.FC = () => {
const payload = {
name: server.name,
config: config
config: config,
};
const result = await apiPost('/servers', payload);
@@ -179,9 +254,8 @@ 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);
@@ -191,7 +265,41 @@ const MarketPage: React.FC = () => {
}
};
const handleCallTool = async (serverName: string, toolName: string, args: Record<string, any>) => {
// 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>,
) => {
try {
const result = await callServerTool(serverName, toolName, args);
showToast(t('cloud.toolCallSuccess', { toolName }), 'success');
@@ -208,13 +316,17 @@ 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);
}
@@ -226,6 +338,8 @@ 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);
}
@@ -259,19 +373,50 @@ 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 servers = isLocalTab ? localServers : cloudServers;
const allServers = isLocalTab ? allLocalServers : allCloudServers;
const isRegistryTab = currentTab === 'registry';
const servers = isLocalTab ? localServers : isRegistryTab ? registryServers : cloudServers;
const allServers = isLocalTab
? allLocalServers
: isRegistryTab
? allRegistryServers
: allCloudServers;
const categories = isLocalTab ? localCategories : [];
const loading = isLocalTab ? localLoading : cloudLoading;
const error = isLocalTab ? localError : cloudError;
const setError = isLocalTab ? setLocalError : setCloudError;
const loading = isLocalTab ? localLoading : isRegistryTab ? registryLoading : cloudLoading;
const error = isLocalTab ? localError : isRegistryTab ? registryError : cloudError;
const setError = isLocalTab ? setLocalError : isRegistryTab ? setRegistryError : setCloudError;
const selectedCategory = isLocalTab ? selectedLocalCategory : '';
const selectedTag = isLocalTab ? selectedLocalTag : '';
const currentPage = isLocalTab ? localCurrentPage : cloudCurrentPage;
const totalPages = isLocalTab ? localTotalPages : cloudTotalPages;
const serversPerPage = isLocalTab ? localServersPerPage : cloudServersPerPage;
const currentPage = isLocalTab
? localCurrentPage
: isRegistryTab
? registryCurrentPage
: cloudCurrentPage;
const totalPages = isLocalTab
? localTotalPages
: isRegistryTab
? registryTotalPages
: cloudTotalPages;
const serversPerPage = isLocalTab
? localServersPerPage
: isRegistryTab
? registryServersPerPage
: cloudServersPerPage;
return (
<div>
@@ -281,13 +426,15 @@ 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
? '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 && !isRegistryTab
? '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"
@@ -301,13 +448,15 @@ 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"
@@ -319,6 +468,28 @@ 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>
@@ -335,8 +506,17 @@ 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>
@@ -345,16 +525,24 @@ const MarketPage: React.FC = () => {
</>
)}
{/* Search bar for local market only */}
{isLocalTab && (
{/* Search bar for local market and registry */}
{(isLocalTab || isRegistryTab) && (
<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={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('market.searchPlaceholder')}
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')
}
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>
@@ -362,15 +550,16 @@ 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"
>
{t('market.search')}
{isRegistryTab ? t('registry.search') : t('market.search')}
</button>
{(searchQuery || selectedCategory || selectedTag) && (
{((isLocalTab && (searchQuery || selectedCategory || selectedTag)) ||
(isRegistryTab && registrySearchQuery)) && (
<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"
>
{t('market.clearFilters')}
{isRegistryTab ? t('registry.clearFilters') : t('market.clearFilters')}
</button>
)}
</form>
@@ -388,7 +577,10 @@ 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>
)}
@@ -398,10 +590,11 @@ 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>
@@ -414,9 +607,25 @@ 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>
@@ -438,61 +647,110 @@ 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') : t('cloud.noServers')}</p>
<p className="text-gray-600">
{isLocalTab
? t('market.noServers')
: isRegistryTab
? t('registry.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 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
})
<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}
/>
) : (
t('cloud.showing', {
from: (currentPage - 1) * serversPerPage + 1,
to: Math.min(currentPage * serversPerPage, allServers.length),
total: allServers.length
})
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
/>
)}
</div>
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
/>
<div className="flex items-center space-x-2">
<div className="flex-[2] flex items-center justify-end space-x-2">
<label htmlFor="perPage" className="text-sm text-gray-600">
{isLocalTab ? t('market.perPage') : t('cloud.perPage')}:
{isLocalTab
? t('market.perPage')
: isRegistryTab
? t('registry.perPage')
: t('cloud.perPage')}
:
</label>
<select
id="perPage"
@@ -507,9 +765,6 @@ const MarketPage: React.FC = () => {
</select>
</div>
</div>
<div className="mt-6">
</div>
</>
)}
</div>

View File

@@ -1,54 +1,55 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import ChangePasswordForm from '@/components/ChangePasswordForm';
import { Switch } from '@/components/ui/ToggleGroup';
import { useSettingsData } from '@/hooks/useSettingsData';
import { useToast } from '@/contexts/ToastContext';
import { generateRandomKey } from '@/utils/key';
import { PermissionChecker } from '@/components/PermissionChecker';
import { PERMISSIONS } from '@/constants/permissions';
import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import ChangePasswordForm from '@/components/ChangePasswordForm'
import { Switch } from '@/components/ui/ToggleGroup'
import { useSettingsData } from '@/hooks/useSettingsData'
import { useToast } from '@/contexts/ToastContext'
import { generateRandomKey } from '@/utils/key'
import { PermissionChecker } from '@/components/PermissionChecker'
import { PERMISSIONS } from '@/constants/permissions'
import { Copy, Check, Download } from 'lucide-react'
const SettingsPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { showToast } = useToast();
const { t } = useTranslation()
const navigate = useNavigate()
const { showToast } = useToast()
const [installConfig, setInstallConfig] = useState<{
pythonIndexUrl: string;
npmRegistry: string;
baseUrl: string;
pythonIndexUrl: string
npmRegistry: string
baseUrl: string
}>({
pythonIndexUrl: '',
npmRegistry: '',
baseUrl: 'http://localhost:3000',
});
})
const [tempSmartRoutingConfig, setTempSmartRoutingConfig] = useState<{
dbUrl: string;
openaiApiBaseUrl: string;
openaiApiKey: string;
openaiApiEmbeddingModel: string;
dbUrl: string
openaiApiBaseUrl: string
openaiApiKey: string
openaiApiEmbeddingModel: string
}>({
dbUrl: '',
openaiApiBaseUrl: '',
openaiApiKey: '',
openaiApiEmbeddingModel: '',
});
})
const [tempMCPRouterConfig, setTempMCPRouterConfig] = useState<{
apiKey: string;
referer: string;
title: string;
baseUrl: string;
apiKey: string
referer: string
title: string
baseUrl: string
}>({
apiKey: '',
referer: 'https://www.mcphubx.com',
title: 'MCPHub',
baseUrl: 'https://api.mcprouter.to/v1',
});
})
const [tempNameSeparator, setTempNameSeparator] = useState<string>('-');
const [tempNameSeparator, setTempNameSeparator] = useState<string>('-')
const {
routingConfig,
@@ -66,14 +67,15 @@ const SettingsPage: React.FC = () => {
updateSmartRoutingConfigBatch,
updateMCPRouterConfig,
updateNameSeparator,
} = useSettingsData();
exportMCPSettings,
} = useSettingsData()
// Update local installConfig when savedInstallConfig changes
useEffect(() => {
if (savedInstallConfig) {
setInstallConfig(savedInstallConfig);
setInstallConfig(savedInstallConfig)
}
}, [savedInstallConfig]);
}, [savedInstallConfig])
// Update local tempSmartRoutingConfig when smartRoutingConfig changes
useEffect(() => {
@@ -83,9 +85,9 @@ const SettingsPage: React.FC = () => {
openaiApiBaseUrl: smartRoutingConfig.openaiApiBaseUrl || '',
openaiApiKey: smartRoutingConfig.openaiApiKey || '',
openaiApiEmbeddingModel: smartRoutingConfig.openaiApiEmbeddingModel || '',
});
})
}
}, [smartRoutingConfig]);
}, [smartRoutingConfig])
// Update local tempMCPRouterConfig when mcpRouterConfig changes
useEffect(() => {
@@ -95,14 +97,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,
@@ -110,138 +112,244 @@ const SettingsPage: React.FC = () => {
smartRoutingConfig: false,
mcpRouterConfig: false,
nameSeparator: false,
password: false
});
password: false,
exportConfig: false,
})
const toggleSection = (section: 'routingConfig' | 'installConfig' | 'smartRoutingConfig' | 'mcpRouterConfig' | 'nameSeparator' | 'password') => {
setSectionsVisible(prev => ({
const toggleSection = (
section:
| 'routingConfig'
| 'installConfig'
| 'smartRoutingConfig'
| 'mcpRouterConfig'
| 'nameSeparator'
| 'password'
| 'exportConfig',
) => {
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);
};
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')
}
return (
<div className="container mx-auto">
@@ -265,7 +373,9 @@ 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}
@@ -277,7 +387,8 @@ 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">
@@ -302,7 +413,8 @@ 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">
@@ -332,7 +444,9 @@ 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}
@@ -349,13 +463,17 @@ 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}
@@ -392,7 +510,9 @@ 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
@@ -416,7 +536,9 @@ 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
@@ -448,9 +570,7 @@ 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 && (
@@ -490,9 +610,7 @@ 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 && (
@@ -505,7 +623,9 @@ const SettingsPage: React.FC = () => {
<Switch
disabled={loading}
checked={routingConfig.enableBearerAuth}
onCheckedChange={(checked) => handleRoutingConfigChange('enableBearerAuth', checked)}
onCheckedChange={(checked) =>
handleRoutingConfigChange('enableBearerAuth', checked)
}
/>
</div>
@@ -538,24 +658,32 @@ 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>
@@ -572,7 +700,6 @@ const SettingsPage: React.FC = () => {
/>
</div>
</PermissionChecker>
</div>
)}
</div>
@@ -585,9 +712,7 @@ 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 && (
@@ -675,9 +800,7 @@ 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 && (
@@ -686,8 +809,61 @@ const SettingsPage: React.FC = () => {
</div>
)}
</div>
</div >
);
};
export default SettingsPage;
{/* 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

View File

@@ -309,3 +309,148 @@ 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;
}

View File

@@ -75,6 +75,7 @@
"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.",
@@ -124,6 +125,7 @@
"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",
@@ -200,6 +202,7 @@
"copyJson": "Copy JSON",
"copySuccess": "Copied to clipboard",
"copyFailed": "Copy failed",
"copied": "Copied",
"close": "Close",
"confirm": "Confirm",
"language": "Language",
@@ -208,7 +211,15 @@
"dismiss": "Dismiss",
"github": "GitHub",
"wechat": "WeChat",
"discord": "Discord"
"discord": "Discord",
"required": "Required",
"secret": "Secret",
"default": "Default",
"value": "Value",
"type": "Type",
"repeated": "Repeated",
"valueHint": "Value Hint",
"choices": "Choices"
},
"nav": {
"dashboard": "Dashboard",
@@ -398,6 +409,41 @@
"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...",
@@ -502,7 +548,14 @@
"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."
"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"
},
"dxt": {
"upload": "Upload",

View File

@@ -75,6 +75,7 @@
"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.",
@@ -124,6 +125,7 @@
"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",
@@ -200,6 +202,7 @@
"copyJson": "Copier le JSON",
"copySuccess": "Copié dans le presse-papiers",
"copyFailed": "Échec de la copie",
"copied": "Copié",
"close": "Fermer",
"confirm": "Confirmer",
"language": "Langue",
@@ -208,7 +211,15 @@
"dismiss": "Rejeter",
"github": "GitHub",
"wechat": "WeChat",
"discord": "Discord"
"discord": "Discord",
"required": "Requis",
"secret": "Secret",
"default": "Défaut",
"value": "Valeur",
"type": "Type",
"repeated": "Répété",
"valueHint": "Indice de valeur",
"choices": "Choix"
},
"nav": {
"dashboard": "Tableau de bord",
@@ -398,6 +409,41 @@
"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...",
@@ -502,7 +548,14 @@
"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."
"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"
},
"dxt": {
"upload": "Télécharger",

View File

@@ -75,6 +75,7 @@
"addServer": "添加服务器",
"add": "添加",
"edit": "编辑",
"copy": "复制",
"delete": "删除",
"confirmDelete": "您确定要删除此服务器吗?",
"deleteWarning": "删除服务器 '{{name}}' 将会移除该服务器及其所有数据。此操作无法撤销。",
@@ -124,6 +125,7 @@
"argumentsPlaceholder": "请输入参数",
"errorDetails": "错误详情",
"viewErrorDetails": "查看错误详情",
"copyConfig": "复制配置",
"confirmVariables": "确认变量配置",
"variablesDetected": "检测到配置中包含变量,请确认这些变量是否已正确配置:",
"detectedVariables": "检测到的变量",
@@ -201,6 +203,7 @@
"copyJson": "复制JSON",
"copySuccess": "已复制到剪贴板",
"copyFailed": "复制失败",
"copied": "已复制",
"close": "关闭",
"confirm": "确认",
"language": "语言",
@@ -209,7 +212,15 @@
"dismiss": "忽略",
"github": "GitHub",
"wechat": "微信",
"discord": "Discord"
"discord": "Discord",
"required": "必填",
"secret": "敏感",
"default": "默认值",
"value": "值",
"type": "类型",
"repeated": "可重复",
"valueHint": "值提示",
"choices": "可选值"
},
"nav": {
"dashboard": "仪表盘",
@@ -399,6 +410,41 @@
"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": "运行中...",
@@ -504,7 +550,14 @@
"systemSettings": "系统设置",
"nameSeparatorLabel": "名称分隔符",
"nameSeparatorDescription": "用于分隔服务器名称和工具/提示名称(默认:-",
"restartRequired": "配置已保存。为确保所有服务正确加载新设置,建议重启应用。"
"restartRequired": "配置已保存。为确保所有服务正确加载新设置,建议重启应用。",
"exportMcpSettings": "导出配置",
"mcpSettingsJson": "MCP 配置 JSON",
"mcpSettingsJsonDescription": "查看、复制或下载当前的 mcp_settings.json 配置,可用于备份或迁移到其他工具",
"copyToClipboard": "复制到剪贴板",
"downloadJson": "下载 JSON",
"exportSuccess": "配置导出成功",
"exportError": "获取配置失败"
},
"dxt": {
"upload": "上传",

View File

@@ -60,6 +60,7 @@
"dotenv-expand": "^12.0.2",
"express": "^4.21.2",
"express-validator": "^7.2.1",
"i18next": "^25.5.0",
"i18next-fs-backend": "^2.6.0",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2",
@@ -99,7 +100,6 @@
"clsx": "^2.1.1",
"concurrently": "^9.2.0",
"eslint": "^8.57.1",
"i18next": "^25.5.0",
"i18next-browser-languagedetector": "^8.2.0",
"jest": "^30.2.0",
"jest-environment-node": "^30.0.5",

6
pnpm-lock.yaml generated
View File

@@ -57,6 +57,9 @@ importers:
express-validator:
specifier: ^7.2.1
version: 7.2.1
i18next:
specifier: ^25.5.0
version: 25.5.0(typescript@5.9.2)
i18next-fs-backend:
specifier: ^2.6.0
version: 2.6.0
@@ -169,9 +172,6 @@ importers:
eslint:
specifier: ^8.57.1
version: 8.57.1
i18next:
specifier: ^25.5.0
version: 25.5.0(typescript@5.9.2)
i18next-browser-languagedetector:
specifier: ^8.2.0
version: 8.2.0

View File

@@ -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,70 +15,74 @@ const defaultConfig = {
readonly: 'true' === process.env.READONLY || false,
mcpHubName: 'mcphub',
mcpHubVersion: getPackageVersion(),
};
}
const dataService: DataService = getDataService();
const dataService: DataService = getDataService()
// Settings cache
let settingsCache: McpSettings | null = null;
let settingsCache: McpSettings | null = null
export const getSettingsPath = (): string => {
return getConfigFilePath('mcp_settings.json', 'Settings');
};
return getConfigFilePath('mcp_settings.json', 'Settings')
}
export const loadOriginalSettings = (): McpSettings => {
// If cache exists, return cached data directly
if (settingsCache) {
return settingsCache;
return settingsCache
}
const settingsPath = getSettingsPath()
// 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
}
const settingsPath = getSettingsPath();
try {
const settingsData = fs.readFileSync(settingsPath, 'utf8');
const settings = JSON.parse(settingsData);
// Read and parse settings file
const settingsData = fs.readFileSync(settingsPath, 'utf8')
const settings = JSON.parse(settingsData)
// Update cache
settingsCache = settings;
settingsCache = settings
console.log(`Loaded settings from ${settingsPath}`);
return settings;
console.log(`Loaded settings from ${settingsPath}`)
return settings
} catch (error) {
console.error(`Failed to load settings from ${settingsPath}:`, error);
const defaultSettings = { mcpServers: {}, users: [] };
// Cache default settings
settingsCache = defaultSettings;
return defaultSettings;
throw new Error(`Failed to load settings from ${settingsPath}: ${error}`)
}
};
}
export const loadSettings = (user?: IUser): McpSettings => {
return dataService.filterSettings!(loadOriginalSettings(), user);
};
return dataService.filterSettings!(loadOriginalSettings(), user)
}
export const saveSettings = (settings: McpSettings, user?: IUser): boolean => {
const settingsPath = getSettingsPath();
const settingsPath = getSettingsPath()
try {
const mergedSettings = dataService.mergeSettings!(loadOriginalSettings(), settings, user);
fs.writeFileSync(settingsPath, JSON.stringify(mergedSettings, null, 2), 'utf8');
const mergedSettings = dataService.mergeSettings!(loadOriginalSettings(), settings, user)
fs.writeFileSync(settingsPath, JSON.stringify(mergedSettings, null, 2), 'utf8')
// Update cache after successful save
settingsCache = mergedSettings;
settingsCache = mergedSettings
return true;
return true
} catch (error) {
console.error(`Failed to save settings to ${settingsPath}:`, error);
return false;
console.error(`Failed to save settings to ${settingsPath}:`, error)
return false
}
};
}
/**
* Clear settings cache, force next loadSettings call to re-read from file
*/
export const clearSettingsCache = (): void => {
settingsCache = null;
};
settingsCache = null
}
/**
* Get current cache status (for debugging)
@@ -86,60 +90,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 || '-'
}

View File

@@ -1,6 +1,6 @@
import { Request, Response } from 'express';
import config from '../config/index.js';
import { loadSettings } from '../config/index.js';
import { loadSettings, loadOriginalSettings } from '../config/index.js';
import { getDataService } from '../services/services.js';
import { DataService } from '../services/dataService.js';
import { IUser } from '../types/index.js';
@@ -72,3 +72,46 @@ 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',
});
}
};

View File

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

View File

@@ -56,9 +56,18 @@ 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 } from '../controllers/configController.js';
import {
getRuntimeConfig,
getPublicConfig,
getMcpSettingsJson,
} from '../controllers/configController.js';
import { callTool } from '../controllers/toolController.js';
import { getPrompt } from '../controllers/promptController.js';
import { uploadDxtFile, uploadMiddleware } from '../controllers/dxtController.js';
@@ -144,11 +153,19 @@ 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',

View File

@@ -15,9 +15,26 @@ 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 current working directory (will be project root in most cases)
const currentFileDir = process.cwd() + '/src';
/**
* 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();
}
}
export class AppServer {
private app: express.Application;
@@ -167,10 +184,11 @@ 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:', currentFileDir);
console.log('DEBUG: Script directory:', currentDir);
}
// First, find the package root directory
@@ -205,51 +223,9 @@ export class AppServer {
// Helper method to find the package root (where package.json is located)
private findPackageRoot(): string | null {
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;
// Use the shared utility function which properly handles ESM module paths
const currentDir = getCurrentFileDir();
return findPackageRoot(currentDir);
}
}

View File

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

11
src/utils/moduleDir.ts Normal file
View File

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

View File

@@ -1,10 +1,178 @@
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);
}
return path.resolve(p);
}
/**
* Find the path to a configuration file by checking multiple potential locations.
* @param filename The name of the file to locate (e.g., 'servers.json', 'mcp_settings.json')
@@ -15,33 +183,54 @@ export const getConfigFilePath = (filename: string, description = 'Configuration
if (filename === 'mcp_settings.json') {
const envPath = process.env.MCPHUB_SETTING_PATH;
if (envPath) {
// check envPath is file or directory
const stats = fs.statSync(envPath);
if (stats.isFile()) {
// Ensure directory exists
const dir = getParentPath(envPath, filename);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
console.log(`Created directory for settings at ${dir}`);
}
// if full path, return as is
if (envPath?.endsWith(filename)) {
return envPath;
}
// if directory, return path under that directory
return path.resolve(envPath, filename);
}
}
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

View File

@@ -1,13 +1,24 @@
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 = (): string => {
export const getPackageVersion = (searchPath?: string): string => {
try {
const packageJsonPath = path.resolve(process.cwd(), 'package.json');
// Use provided path or fallback to current working directory
const startPath = searchPath || process.cwd();
const packageRoot = findPackageRoot(startPath);
if (!packageRoot) {
console.warn('Could not find package root, using default version');
return 'dev';
}
const packageJsonPath = path.join(packageRoot, 'package.json');
const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf8');
const packageJson = JSON.parse(packageJsonContent);
return packageJson.version || 'dev';

View File

@@ -0,0 +1,139 @@
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',
})
})
})
})

View File

@@ -8,6 +8,11 @@ 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

View File

@@ -0,0 +1,131 @@
// Test for CLI path handling functionality
import path from 'path';
import { pathToFileURL } from 'url';
describe('CLI Path Handling', () => {
describe('Cross-platform ESM URL conversion', () => {
it('should convert Unix-style absolute path to file:// URL', () => {
const unixPath = '/home/user/project/dist/index.js';
const fileUrl = pathToFileURL(unixPath).href;
expect(fileUrl).toMatch(/^file:\/\//);
expect(fileUrl).toContain('index.js');
});
it('should handle relative paths correctly', () => {
const relativePath = path.join(process.cwd(), 'dist', 'index.js');
const fileUrl = pathToFileURL(relativePath).href;
expect(fileUrl).toMatch(/^file:\/\//);
expect(fileUrl).toContain('dist');
expect(fileUrl).toContain('index.js');
});
it('should produce valid URL format', () => {
const testPath = path.join(process.cwd(), 'test', 'file.js');
const fileUrl = pathToFileURL(testPath).href;
// Should be a valid URL
expect(() => new URL(fileUrl)).not.toThrow();
// Should start with file://
expect(fileUrl.startsWith('file://')).toBe(true);
});
it('should handle paths with spaces', () => {
const pathWithSpaces = path.join(process.cwd(), 'my folder', 'dist', 'index.js');
const fileUrl = pathToFileURL(pathWithSpaces).href;
expect(fileUrl).toMatch(/^file:\/\//);
expect(fileUrl).toContain('index.js');
// Spaces should be URL-encoded
expect(fileUrl).toContain('%20');
});
it('should handle paths with special characters', () => {
const pathWithSpecialChars = path.join(process.cwd(), 'test@dir', 'file#1.js');
const fileUrl = pathToFileURL(pathWithSpecialChars).href;
expect(fileUrl).toMatch(/^file:\/\//);
// Special characters should be URL-encoded
expect(() => new URL(fileUrl)).not.toThrow();
});
// Windows-specific path handling simulation
it('should handle Windows-style paths correctly', () => {
// Simulate a Windows path structure
// Note: On non-Windows systems, this creates a relative path,
// but the test verifies the conversion mechanism works
const mockWindowsPath = 'C:\\Users\\User\\project\\dist\\index.js';
// On Windows, pathToFileURL would convert C:\ to file:///C:/
// On Unix, it treats it as a relative path, but the conversion still works
const fileUrl = pathToFileURL(mockWindowsPath).href;
expect(fileUrl).toMatch(/^file:\/\//);
expect(fileUrl).toContain('index.js');
});
});
describe('Path normalization', () => {
it('should normalize path separators', () => {
const mixedPath = path.join('dist', 'index.js');
const fileUrl = pathToFileURL(path.resolve(mixedPath)).href;
expect(fileUrl).toMatch(/^file:\/\//);
// All separators should be forward slashes in URL
expect(fileUrl.split('file://')[1]).not.toContain('\\');
});
it('should handle multiple consecutive slashes', () => {
const messyPath = path.normalize('/dist//index.js');
const fileUrl = pathToFileURL(path.resolve(messyPath)).href;
expect(fileUrl).toMatch(/^file:\/\//);
expect(() => new URL(fileUrl)).not.toThrow();
});
});
describe('Path resolution for CLI use case', () => {
it('should convert package root path to valid import URL', () => {
const packageRoot = process.cwd();
const entryPath = path.join(packageRoot, 'dist', 'index.js');
const entryUrl = pathToFileURL(entryPath).href;
expect(entryUrl).toMatch(/^file:\/\//);
expect(entryUrl).toContain('dist');
expect(entryUrl).toContain('index.js');
expect(() => new URL(entryUrl)).not.toThrow();
});
it('should handle nested directory structures', () => {
const deepPath = path.join(process.cwd(), 'a', 'b', 'c', 'd', 'file.js');
const fileUrl = pathToFileURL(deepPath).href;
expect(fileUrl).toMatch(/^file:\/\//);
expect(fileUrl).toContain('file.js');
expect(() => new URL(fileUrl)).not.toThrow();
});
it('should produce URL compatible with dynamic import()', () => {
// This test verifies the exact pattern used in bin/cli.js
const projectRoot = process.cwd();
const entryPath = path.join(projectRoot, 'dist', 'index.js');
const entryUrl = pathToFileURL(entryPath).href;
// The URL should be valid for import()
expect(entryUrl).toMatch(/^file:\/\//);
expect(typeof entryUrl).toBe('string');
// Verify the URL format is valid
const urlObj = new URL(entryUrl);
expect(urlObj.protocol).toBe('file:');
expect(urlObj.href).toBe(entryUrl);
// On Windows, pathToFileURL converts 'C:\path' to 'file:///C:/path'
// On Unix, it converts '/path' to 'file:///path'
// Both formats are valid for dynamic import()
expect(entryUrl).toContain('index.js');
});
});
});