mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 02:39:19 -05:00
Compare commits
3 Commits
copilot/fi
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9732fccb6 | ||
|
|
7b3d441046 | ||
|
|
55a7d0b183 |
124
.github/DOCKER_CLI_TEST.md
vendored
124
.github/DOCKER_CLI_TEST.md
vendored
@@ -1,124 +0,0 @@
|
||||
# Docker Engine Installation Test Procedure
|
||||
|
||||
This document describes how to test the Docker Engine installation feature added with the `INSTALL_EXT=true` build argument.
|
||||
|
||||
## Test 1: Build with INSTALL_EXT=false (default)
|
||||
|
||||
```bash
|
||||
# Build without extended features
|
||||
docker build -t mcphub:base .
|
||||
|
||||
# Run the container
|
||||
docker run --rm mcphub:base docker --version
|
||||
```
|
||||
|
||||
**Expected Result**: `docker: not found` error (Docker is NOT installed)
|
||||
|
||||
## Test 2: Build with INSTALL_EXT=true
|
||||
|
||||
```bash
|
||||
# Build with extended features
|
||||
docker build --build-arg INSTALL_EXT=true -t mcphub:extended .
|
||||
|
||||
# Test Docker CLI is available
|
||||
docker run --rm mcphub:extended docker --version
|
||||
```
|
||||
|
||||
**Expected Result**: Docker version output (e.g., `Docker version 27.x.x, build xxxxx`)
|
||||
|
||||
## Test 3: Docker-in-Docker with Auto-start Daemon
|
||||
|
||||
```bash
|
||||
# Build with extended features
|
||||
docker build --build-arg INSTALL_EXT=true -t mcphub:extended .
|
||||
|
||||
# Run with privileged mode (allows Docker daemon to start)
|
||||
docker run -d \
|
||||
--name mcphub-test \
|
||||
--privileged \
|
||||
-p 3000:3000 \
|
||||
mcphub:extended
|
||||
|
||||
# Wait a few seconds for daemon to start
|
||||
sleep 5
|
||||
|
||||
# Test Docker commands from inside the container
|
||||
docker exec mcphub-test docker ps
|
||||
docker exec mcphub-test docker images
|
||||
docker exec mcphub-test docker info
|
||||
|
||||
# Cleanup
|
||||
docker stop mcphub-test
|
||||
docker rm mcphub-test
|
||||
```
|
||||
|
||||
**Expected Result**:
|
||||
- Docker daemon should auto-start inside the container
|
||||
- Docker commands should work without mounting the host's Docker socket
|
||||
- `docker info` should show the container's own Docker daemon
|
||||
|
||||
## Test 4: Docker-in-Docker with Host Socket (Alternative)
|
||||
|
||||
```bash
|
||||
# Build with extended features
|
||||
docker build --build-arg INSTALL_EXT=true -t mcphub:extended .
|
||||
|
||||
# Run with Docker socket mounted (uses host's daemon)
|
||||
docker run -d \
|
||||
--name mcphub-test \
|
||||
-p 3000:3000 \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
mcphub:extended
|
||||
|
||||
# Test Docker commands from inside the container
|
||||
docker exec mcphub-test docker ps
|
||||
docker exec mcphub-test docker images
|
||||
|
||||
# Cleanup
|
||||
docker stop mcphub-test
|
||||
docker rm mcphub-test
|
||||
```
|
||||
|
||||
**Expected Result**:
|
||||
- Docker daemon should NOT auto-start (socket already exists from host)
|
||||
- Docker commands should work and show the host's containers and images
|
||||
|
||||
## Test 5: Verify Image Size
|
||||
|
||||
```bash
|
||||
# Build both versions
|
||||
docker build -t mcphub:base .
|
||||
docker build --build-arg INSTALL_EXT=true -t mcphub:extended .
|
||||
|
||||
# Compare image sizes
|
||||
docker images mcphub:*
|
||||
```
|
||||
|
||||
**Expected Result**:
|
||||
- The `extended` image should be larger than the `base` image
|
||||
- The size difference should be reasonable (Docker Engine adds ~100-150MB)
|
||||
|
||||
## Test 6: Architecture Support
|
||||
|
||||
```bash
|
||||
# On AMD64/x86_64
|
||||
docker build --build-arg INSTALL_EXT=true --platform linux/amd64 -t mcphub:extended-amd64 .
|
||||
|
||||
# On ARM64
|
||||
docker build --build-arg INSTALL_EXT=true --platform linux/arm64 -t mcphub:extended-arm64 .
|
||||
```
|
||||
|
||||
**Expected Result**:
|
||||
- Both builds should succeed
|
||||
- AMD64 includes Chrome/Playwright + Docker Engine
|
||||
- ARM64 includes Docker Engine only (Chrome installation is skipped)
|
||||
|
||||
## Notes
|
||||
|
||||
- The Docker Engine installation follows the official Docker documentation
|
||||
- Includes full Docker daemon (`dockerd`), CLI (`docker`), and containerd
|
||||
- The daemon auto-starts when running in privileged mode
|
||||
- The installation uses the Debian Bookworm repository
|
||||
- All temporary files are cleaned up to minimize image size
|
||||
- The feature is opt-in via the `INSTALL_EXT` build argument
|
||||
- `iptables` is installed as it's required for Docker networking
|
||||
34
.github/copilot-instructions.md
vendored
34
.github/copilot-instructions.md
vendored
@@ -13,7 +13,6 @@ MCPHub is a TypeScript/Node.js MCP (Model Context Protocol) server management hu
|
||||
- **MCP Integration**: Connects multiple MCP servers (`src/services/mcpService.ts`)
|
||||
- **Authentication**: JWT-based with bcrypt password hashing
|
||||
- **Configuration**: JSON-based MCP server definitions (`mcp_settings.json`)
|
||||
- **Documentation**: API docs and usage instructions(`docs/`)
|
||||
|
||||
## Working Effectively
|
||||
|
||||
@@ -31,7 +30,7 @@ cp .env.example .env
|
||||
|
||||
# Build and test to verify setup
|
||||
pnpm lint # ~3 seconds - NEVER CANCEL
|
||||
pnpm backend:build # ~5 seconds - NEVER CANCEL
|
||||
pnpm backend:build # ~5 seconds - NEVER CANCEL
|
||||
pnpm test:ci # ~16 seconds - NEVER CANCEL. Set timeout to 60+ seconds
|
||||
pnpm frontend:build # ~5 seconds - NEVER CANCEL
|
||||
pnpm build # ~10 seconds total - NEVER CANCEL. Set timeout to 60+ seconds
|
||||
@@ -49,7 +48,7 @@ pnpm dev # Backend on :3001, Frontend on :5173
|
||||
# Terminal 1: Backend only
|
||||
pnpm backend:dev # Runs on port 3000 (or PORT env var)
|
||||
|
||||
# Terminal 2: Frontend only
|
||||
# Terminal 2: Frontend only
|
||||
pnpm frontend:dev # Runs on port 5173, proxies API to backend
|
||||
```
|
||||
|
||||
@@ -63,7 +62,7 @@ pnpm build # NEVER CANCEL - Set timeout to 60+ seconds
|
||||
|
||||
# Individual builds
|
||||
pnpm backend:build # TypeScript compilation - ~5 seconds
|
||||
pnpm frontend:build # Vite build - ~5 seconds
|
||||
pnpm frontend:build # Vite build - ~5 seconds
|
||||
|
||||
# Start production server
|
||||
pnpm start # Requires dist/ and frontend/dist/ to exist
|
||||
@@ -92,7 +91,6 @@ pnpm format # Prettier formatting - ~3 seconds
|
||||
**ALWAYS perform these validation steps after making changes:**
|
||||
|
||||
### 1. Basic Application Functionality
|
||||
|
||||
```bash
|
||||
# Start the application
|
||||
pnpm dev
|
||||
@@ -107,7 +105,6 @@ curl -I http://localhost:3000/
|
||||
```
|
||||
|
||||
### 2. MCP Server Integration Test
|
||||
|
||||
```bash
|
||||
# Check MCP servers are loading (look for log messages)
|
||||
# Expected log output should include:
|
||||
@@ -117,7 +114,6 @@ curl -I http://localhost:3000/
|
||||
```
|
||||
|
||||
### 3. Build Verification
|
||||
|
||||
```bash
|
||||
# Verify production build works
|
||||
pnpm build
|
||||
@@ -130,7 +126,6 @@ node scripts/verify-dist.js
|
||||
## Project Structure and Key Files
|
||||
|
||||
### Critical Backend Files
|
||||
|
||||
- `src/index.ts` - Application entry point
|
||||
- `src/server.ts` - Express server setup and middleware
|
||||
- `src/services/mcpService.ts` - **Core MCP server management logic**
|
||||
@@ -141,14 +136,11 @@ node scripts/verify-dist.js
|
||||
- `src/types/index.ts` - TypeScript type definitions
|
||||
|
||||
### Critical Frontend Files
|
||||
|
||||
- `frontend/src/` - React application source
|
||||
- `frontend/src/pages/` - Page components (development entry point)
|
||||
- `frontend/src/components/` - Reusable UI components
|
||||
- `frontend/src/utils/fetchInterceptor.js` - Backend API interaction
|
||||
|
||||
### Configuration Files
|
||||
|
||||
- `mcp_settings.json` - **MCP server definitions and user accounts**
|
||||
- `package.json` - Dependencies and scripts
|
||||
- `tsconfig.json` - TypeScript configuration
|
||||
@@ -156,7 +148,6 @@ node scripts/verify-dist.js
|
||||
- `.eslintrc.json` - Linting rules
|
||||
|
||||
### Docker and Deployment
|
||||
|
||||
- `Dockerfile` - Multi-stage build with Python base + Node.js
|
||||
- `entrypoint.sh` - Docker startup script
|
||||
- `bin/cli.js` - NPM package CLI entry point
|
||||
@@ -164,14 +155,12 @@ node scripts/verify-dist.js
|
||||
## Development Process and Conventions
|
||||
|
||||
### Code Style Requirements
|
||||
|
||||
- **ESM modules**: Always use `.js` extensions in imports, not `.ts`
|
||||
- **English only**: All code comments must be written in English
|
||||
- **TypeScript strict**: Follow strict type checking rules
|
||||
- **Import style**: `import { something } from './file.js'` (note .js extension)
|
||||
|
||||
### Key Configuration Notes
|
||||
|
||||
- **MCP servers**: Defined in `mcp_settings.json` with command/args
|
||||
- **Endpoints**: `/mcp/{group|server}` and `/mcp/$smart` for routing
|
||||
- **i18n**: Frontend uses react-i18next with files in `locales/` folder
|
||||
@@ -179,7 +168,6 @@ node scripts/verify-dist.js
|
||||
- **Default credentials**: admin/admin123 (configured in mcp_settings.json)
|
||||
|
||||
### Development Entry Points
|
||||
|
||||
- **Add MCP server**: Modify `mcp_settings.json` and restart
|
||||
- **New API endpoint**: Add route in `src/routes/`, controller in `src/controllers/`
|
||||
- **Frontend feature**: Start from `frontend/src/pages/` or `frontend/src/components/`
|
||||
@@ -188,38 +176,29 @@ node scripts/verify-dist.js
|
||||
### Common Development Tasks
|
||||
|
||||
#### Adding a new MCP server:
|
||||
|
||||
1. Add server definition to `mcp_settings.json`
|
||||
2. Restart backend to load new server
|
||||
3. Check logs for successful connection
|
||||
4. Test via dashboard or API endpoints
|
||||
|
||||
#### API development:
|
||||
|
||||
1. Define route in `src/routes/`
|
||||
2. Implement controller in `src/controllers/`
|
||||
3. Add types in `src/types/index.ts` if needed
|
||||
4. Write tests in `tests/controllers/`
|
||||
|
||||
#### Frontend development:
|
||||
|
||||
1. Create/modify components in `frontend/src/components/`
|
||||
2. Add pages in `frontend/src/pages/`
|
||||
3. Update routing if needed
|
||||
4. Test in development mode with `pnpm frontend:dev`
|
||||
|
||||
#### Documentation:
|
||||
|
||||
1. Update or add docs in `docs/` folder
|
||||
2. Ensure README.md reflects any major changes
|
||||
|
||||
## Validation and CI Requirements
|
||||
|
||||
### Before Committing - ALWAYS Run:
|
||||
|
||||
```bash
|
||||
pnpm lint # Must pass - ~3 seconds
|
||||
pnpm backend:build # Must compile - ~5 seconds
|
||||
pnpm backend:build # Must compile - ~5 seconds
|
||||
pnpm test:ci # All tests must pass - ~16 seconds
|
||||
pnpm build # Full build must work - ~10 seconds
|
||||
```
|
||||
@@ -227,7 +206,6 @@ pnpm build # Full build must work - ~10 seconds
|
||||
**CRITICAL**: CI will fail if any of these commands fail. Fix issues locally first.
|
||||
|
||||
### CI Pipeline (.github/workflows/ci.yml)
|
||||
|
||||
- Runs on Node.js 20.x
|
||||
- Tests: linting, type checking, unit tests with coverage
|
||||
- **NEVER CANCEL**: CI builds may take 2-3 minutes total
|
||||
@@ -235,26 +213,22 @@ pnpm build # Full build must work - ~10 seconds
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
- **"uvx command not found"**: Some MCP servers require `uvx` (Python package manager) - this is expected in development
|
||||
- **Port already in use**: Change PORT environment variable or kill existing processes
|
||||
- **Frontend not loading**: Ensure frontend was built with `pnpm frontend:build`
|
||||
- **MCP server connection failed**: Check server command/args in `mcp_settings.json`
|
||||
|
||||
### Build Failures
|
||||
|
||||
- **TypeScript errors**: Run `pnpm backend:build` to see compilation errors
|
||||
- **Test failures**: Run `pnpm test:verbose` for detailed test output
|
||||
- **Lint errors**: Run `pnpm lint` and fix reported issues
|
||||
|
||||
### Development Issues
|
||||
|
||||
- **Backend not starting**: Check for port conflicts, verify `mcp_settings.json` syntax
|
||||
- **Frontend proxy errors**: Ensure backend is running before starting frontend
|
||||
- **Hot reload not working**: Restart development server
|
||||
|
||||
## Performance Notes
|
||||
|
||||
- **Install time**: pnpm install takes ~30 seconds
|
||||
- **Build time**: Full build takes ~10 seconds
|
||||
- **Test time**: Complete test suite takes ~16 seconds
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,7 +2,6 @@
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
package-lock.json
|
||||
|
||||
# production
|
||||
dist
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2
|
||||
}
|
||||
}
|
||||
|
||||
30
AGENTS.md
30
AGENTS.md
@@ -1,30 +0,0 @@
|
||||
# Repository Guidelines
|
||||
|
||||
These notes align current contributors around the code layout, daily commands, and collaboration habits that keep `@samanhappy/mcphub` moving quickly.
|
||||
|
||||
## Project Structure & Module Organization
|
||||
- Backend services live in `src`, grouped by responsibility (`controllers/`, `services/`, `dao/`, `routes/`, `utils/`), with `server.ts` orchestrating HTTP bootstrap.
|
||||
- `frontend/src` contains the Vite + React dashboard; `frontend/public` hosts static assets and translations sit in `locales/`.
|
||||
- Jest-aware test code is split between colocated specs (`src/**/*.{test,spec}.ts`) and higher-level suites in `tests/`; use `tests/utils/` helpers when exercising the CLI or SSE flows.
|
||||
- Build artifacts and bundles are generated into `dist/`, `frontend/dist/`, and `coverage/`; never edit these manually.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- `pnpm dev` runs backend (`tsx watch src/index.ts`) and frontend (`vite`) together for local iteration.
|
||||
- `pnpm backend:dev`, `pnpm frontend:dev`, and `pnpm frontend:preview` target each surface independently; prefer them when debugging one stack.
|
||||
- `pnpm build` executes `pnpm backend:build` (TypeScript to `dist/`) and `pnpm frontend:build`; run before release or publishing.
|
||||
- `pnpm test`, `pnpm test:watch`, and `pnpm test:coverage` drive Jest; `pnpm lint` and `pnpm format` enforce style via ESLint and Prettier.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
- TypeScript everywhere; default to 2-space indentation and single quotes, letting Prettier settle formatting. ESLint configuration assumes ES modules.
|
||||
- Name services and data access layers with suffixes (`UserService`, `AuthDao`), React components and files in `PascalCase`, and utility modules in `camelCase`.
|
||||
- Keep DTOs and shared types in `src/types` to avoid duplication; re-export through index files only when it clarifies imports.
|
||||
|
||||
## Testing Guidelines
|
||||
- Use Jest with the `ts-jest` ESM preset; place shared setup in `tests/setup.ts` and mock helpers under `tests/utils/`.
|
||||
- Mirror production directory names when adding new suites and end filenames with `.test.ts` or `.spec.ts` for automatic discovery.
|
||||
- Aim to maintain or raise coverage when touching critical flows (auth, OAuth, SSE); add integration tests under `tests/integration/` when touching cross-service logic.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
- Follow the existing Conventional Commit pattern (`feat:`, `fix:`, `chore:`, etc.) with imperative, present-tense summaries and optional multi-line context.
|
||||
- Each PR should describe the behavior change, list testing performed, and link issues; include before/after screenshots or GIFs for frontend tweaks.
|
||||
- Re-run `pnpm build` and `pnpm test` before requesting review, and ensure generated artifacts stay out of the diff.
|
||||
32
Dockerfile
32
Dockerfile
@@ -9,25 +9,9 @@ RUN apt-get update && apt-get install -y curl gnupg git \
|
||||
|
||||
RUN npm install -g pnpm
|
||||
|
||||
ENV MCP_DATA_DIR=/app/data
|
||||
ENV MCP_SERVERS_DIR=$MCP_DATA_DIR/servers
|
||||
ENV MCP_NPM_DIR=$MCP_SERVERS_DIR/npm
|
||||
ENV MCP_PYTHON_DIR=$MCP_SERVERS_DIR/python
|
||||
ENV PNPM_HOME=$MCP_DATA_DIR/pnpm
|
||||
ENV NPM_CONFIG_PREFIX=$MCP_DATA_DIR/npm-global
|
||||
ENV NPM_CONFIG_CACHE=$MCP_DATA_DIR/npm-cache
|
||||
ENV UV_TOOL_DIR=$MCP_DATA_DIR/uv/tools
|
||||
ENV UV_CACHE_DIR=$MCP_DATA_DIR/uv/cache
|
||||
ENV PATH=$PNPM_HOME:$NPM_CONFIG_PREFIX/bin:$UV_TOOL_DIR/bin:$PATH
|
||||
RUN mkdir -p \
|
||||
$PNPM_HOME \
|
||||
$NPM_CONFIG_PREFIX/bin \
|
||||
$NPM_CONFIG_PREFIX/lib/node_modules \
|
||||
$NPM_CONFIG_CACHE \
|
||||
$UV_TOOL_DIR \
|
||||
$UV_CACHE_DIR \
|
||||
$MCP_NPM_DIR \
|
||||
$MCP_PYTHON_DIR && \
|
||||
ENV PNPM_HOME=/usr/local/share/pnpm
|
||||
ENV PATH=$PNPM_HOME:$PATH
|
||||
RUN mkdir -p $PNPM_HOME && \
|
||||
pnpm add -g @amap/amap-maps-mcp-server @playwright/mcp@latest tavily-mcp@latest @modelcontextprotocol/server-github @modelcontextprotocol/server-slack
|
||||
|
||||
ARG INSTALL_EXT=false
|
||||
@@ -38,16 +22,6 @@ RUN if [ "$INSTALL_EXT" = "true" ]; then \
|
||||
else \
|
||||
echo "Skipping Chrome installation on non-amd64 architecture: $ARCH"; \
|
||||
fi; \
|
||||
# Install Docker Engine (includes CLI and daemon) \
|
||||
apt-get update && \
|
||||
apt-get install -y ca-certificates curl iptables && \
|
||||
install -m 0755 -d /etc/apt/keyrings && \
|
||||
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc && \
|
||||
chmod a+r /etc/apt/keyrings/docker.asc && \
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null && \
|
||||
apt-get update && \
|
||||
apt-get install -y docker-ce docker-ce-cli containerd.io && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/*; \
|
||||
fi
|
||||
|
||||
RUN uv tool install mcp-server-fetch
|
||||
|
||||
95
README.md
95
README.md
@@ -19,8 +19,6 @@ MCPHub makes it easy to manage and scale multiple MCP (Model Context Protocol) s
|
||||
- **Hot-Swappable Configuration**: Add, remove, or update MCP servers on the fly — no downtime required.
|
||||
- **Group-Based Access Control**: Organize servers into customizable groups for streamlined permissions management.
|
||||
- **Secure Authentication**: Built-in user management with role-based access powered by JWT and bcrypt.
|
||||
- **OAuth 2.0 Support**: Full OAuth support for upstream MCP servers with proxy authorization capabilities.
|
||||
- **Environment Variable Expansion**: Use environment variables anywhere in your configuration for secure credential management. See [Environment Variables Guide](docs/environment-variables.md).
|
||||
- **Docker-Ready**: Deploy instantly with our containerized setup.
|
||||
|
||||
## 🔧 Quick Start
|
||||
@@ -59,73 +57,6 @@ Create a `mcp_settings.json` file to customize your server settings:
|
||||
}
|
||||
```
|
||||
|
||||
#### OAuth Configuration (Optional)
|
||||
|
||||
MCPHub supports OAuth 2.0 for authenticating with upstream MCP servers. See the [OAuth feature guide](docs/features/oauth.mdx) for a full walkthrough. In practice you will run into two configuration patterns:
|
||||
|
||||
- **Dynamic registration servers** (e.g., Vercel, Linear) publish all metadata and allow MCPHub to self-register. Simply declare the server URL and MCPHub handles the rest.
|
||||
- **Manually provisioned servers** (e.g., GitHub Copilot) require you to create an OAuth App and provide the issued client ID/secret to MCPHub.
|
||||
|
||||
Dynamic registration example:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"vercel": {
|
||||
"type": "sse",
|
||||
"url": "https://mcp.vercel.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Manual registration example:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
"type": "sse",
|
||||
"url": "https://api.githubcopilot.com/mcp/",
|
||||
"oauth": {
|
||||
"clientId": "${GITHUB_OAUTH_APP_ID}",
|
||||
"clientSecret": "${GITHUB_OAUTH_APP_SECRET}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For manual providers, create the OAuth App in the upstream console, set the redirect URI to `http://localhost:3000/oauth/callback` (or your deployed domain), and then plug the credentials into the dashboard or config file.
|
||||
|
||||
#### Connection Modes (Optional)
|
||||
|
||||
MCPHub supports two connection strategies:
|
||||
|
||||
- **`persistent` (default)**: Maintains long-running connections for stateful servers
|
||||
- **`on-demand`**: Connects only when needed, ideal for ephemeral servers that exit after operations
|
||||
|
||||
Example for one-time use servers:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"pdf-reader": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "pdf-mcp-server"],
|
||||
"connectionMode": "on-demand"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use `on-demand` mode for servers that:
|
||||
- Don't support long-running connections
|
||||
- Exit automatically after handling requests
|
||||
- Experience "Connection closed" errors
|
||||
|
||||
See the [Configuration Guide](docs/configuration/mcp-settings.mdx) for more details.
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
**Recommended**: Mount your custom config:
|
||||
@@ -175,11 +106,7 @@ This endpoint provides a unified streamable HTTP interface for all your MCP serv
|
||||
Smart Routing is MCPHub's intelligent tool discovery system that uses vector semantic search to automatically find the most relevant tools for any given task.
|
||||
|
||||
```
|
||||
# Search across all servers
|
||||
http://localhost:3000/mcp/$smart
|
||||
|
||||
# Search within a specific group
|
||||
http://localhost:3000/mcp/$smart/{group}
|
||||
```
|
||||
|
||||
**How it Works:**
|
||||
@@ -188,7 +115,6 @@ http://localhost:3000/mcp/$smart/{group}
|
||||
2. **Semantic Search**: User queries are converted to vectors and matched against tool embeddings using cosine similarity
|
||||
3. **Intelligent Filtering**: Dynamic thresholds ensure relevant results without noise
|
||||
4. **Precise Execution**: Found tools can be directly executed with proper parameter validation
|
||||
5. **Group Scoping**: Optionally limit searches to servers within a specific group for focused results
|
||||
|
||||
**Setup Requirements:**
|
||||
|
||||
@@ -200,23 +126,6 @@ To enable Smart Routing, you need:
|
||||
- OpenAI API key (or compatible embedding service)
|
||||
- Enable Smart Routing in MCPHub settings
|
||||
|
||||
**Group-Scoped Smart Routing**:
|
||||
|
||||
You can combine Smart Routing with group filtering to search only within specific server groups:
|
||||
|
||||
```
|
||||
# Search only within production servers
|
||||
http://localhost:3000/mcp/$smart/production
|
||||
|
||||
# Search only within development servers
|
||||
http://localhost:3000/mcp/$smart/development
|
||||
```
|
||||
|
||||
This enables:
|
||||
- **Focused Discovery**: Find tools only from relevant servers
|
||||
- **Environment Isolation**: Separate tool discovery by environment (dev, staging, prod)
|
||||
- **Team-Based Access**: Limit tool search to team-specific server groups
|
||||
|
||||
**Group-Specific Endpoints (Recommended)**:
|
||||
|
||||

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

|
||||
要针对特定服务器分组进行访问,请使用基于分组的 HTTP 端点:
|
||||
@@ -225,11 +164,7 @@ http://localhost:3000/sse
|
||||
要启用智能路由,请使用:
|
||||
|
||||
```
|
||||
# 在所有服务器中搜索
|
||||
http://localhost:3000/sse/$smart
|
||||
|
||||
# 在特定分组中搜索
|
||||
http://localhost:3000/sse/$smart/{group}
|
||||
```
|
||||
|
||||
要针对特定服务器分组进行访问,请使用基于分组的 SSE 端点:
|
||||
|
||||
@@ -41,50 +41,6 @@ docker run -d \
|
||||
mcphub:local
|
||||
```
|
||||
|
||||
### Building with Extended Features
|
||||
|
||||
The Docker image supports an `INSTALL_EXT` build argument to include additional tools:
|
||||
|
||||
```bash
|
||||
# Build with extended features (includes Docker Engine, Chrome/Playwright)
|
||||
docker build --build-arg INSTALL_EXT=true -t mcphub:extended .
|
||||
|
||||
# Option 1: Run with automatic Docker-in-Docker (requires privileged mode)
|
||||
docker run -d \
|
||||
--name mcphub \
|
||||
--privileged \
|
||||
-p 3000:3000 \
|
||||
-v $(pwd)/mcp_settings.json:/app/mcp_settings.json \
|
||||
mcphub:extended
|
||||
|
||||
# Option 2: Run with Docker socket mounted (use host's Docker daemon)
|
||||
docker run -d \
|
||||
--name mcphub \
|
||||
-p 3000:3000 \
|
||||
-v $(pwd)/mcp_settings.json:/app/mcp_settings.json \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
mcphub:extended
|
||||
|
||||
# Verify Docker is available
|
||||
docker exec mcphub docker --version
|
||||
docker exec mcphub docker ps
|
||||
```
|
||||
|
||||
<Note>
|
||||
**What's included with INSTALL_EXT=true:**
|
||||
- **Docker Engine**: Full Docker daemon with CLI for container management. The daemon auto-starts when the container runs in privileged mode.
|
||||
- **Chrome/Playwright** (amd64 only): For browser automation tasks
|
||||
|
||||
The extended image is larger but provides additional capabilities for advanced use cases.
|
||||
</Note>
|
||||
|
||||
<Warning>
|
||||
**Docker-in-Docker Security Considerations:**
|
||||
- **Privileged mode** (`--privileged`): Required for the Docker daemon to start inside the container. This gives the container elevated permissions on the host.
|
||||
- **Docker socket mounting** (`/var/run/docker.sock`): Gives the container access to the host's Docker daemon. Both approaches should only be used in trusted environments.
|
||||
- For production, consider using Docker socket mounting instead of privileged mode for better security.
|
||||
</Warning>
|
||||
|
||||
## Docker Compose Setup
|
||||
|
||||
### Basic Configuration
|
||||
|
||||
@@ -47,7 +47,8 @@ MCPHub uses several configuration files:
|
||||
},
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--headless"]
|
||||
"args": ["@playwright/mcp@latest", "--headless", "--isolated"],
|
||||
"perSession": true
|
||||
},
|
||||
"slack": {
|
||||
"command": "npx",
|
||||
@@ -72,13 +73,45 @@ MCPHub uses several configuration files:
|
||||
|
||||
### Optional Fields
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
| ---------------- | ------- | --------------- | --------------------------------------------------------------------- |
|
||||
| `env` | object | `{}` | Environment variables |
|
||||
| `connectionMode` | string | `"persistent"` | Connection strategy: `"persistent"` or `"on-demand"` |
|
||||
| `enabled` | boolean | `true` | Enable/disable the server |
|
||||
| `keepAliveInterval` | number | `60000` | Keep-alive ping interval for SSE connections (milliseconds) |
|
||||
| `options` | object | `{}` | MCP request options (timeout, resetTimeoutOnProgress, maxTotalTimeout)|
|
||||
| Field | Type | Default | Description |
|
||||
| -------------- | ------- | --------------- | --------------------------- |
|
||||
| `env` | object | `{}` | Environment variables |
|
||||
| `perSession` | boolean | `false` | Create separate server instance per user session (for stateful servers like playwright) |
|
||||
| `enabled` | boolean | `true` | Enable/disable the server |
|
||||
| `timeout` | number | `60000` | Request timeout in milliseconds |
|
||||
| `keepAliveInterval` | number | `60000` | Keep-alive ping interval for SSE servers (ms) |
|
||||
|
||||
## Per-Session Server Instances
|
||||
|
||||
Some MCP servers maintain state that should be isolated between different users. For example, the Playwright server maintains browser sessions that could leak form data or other state between concurrent users.
|
||||
|
||||
To prevent this, you can set `perSession: true` in the server configuration. This creates a separate server instance for each user session instead of sharing a single instance across all users.
|
||||
|
||||
### When to Use Per-Session Servers
|
||||
|
||||
Use `perSession: true` for servers that:
|
||||
- Maintain browser state (like Playwright)
|
||||
- Store user-specific data in memory
|
||||
- Have file handles or database connections that shouldn't be shared
|
||||
- Could cause race conditions when multiple users access simultaneously
|
||||
|
||||
### Example Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--headless", "--isolated"],
|
||||
"perSession": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important Notes:**
|
||||
- Each per-session server instance consumes additional resources (memory, CPU)
|
||||
- Per-session servers are automatically cleaned up when the user session ends
|
||||
- For Playwright, also use the `--isolated` flag to ensure browser contexts are isolated
|
||||
- Not recommended for stateless servers that can safely be shared
|
||||
|
||||
## Common MCP Server Examples
|
||||
|
||||
@@ -105,8 +138,9 @@ MCPHub uses several configuration files:
|
||||
{
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--headless"],
|
||||
"args": ["@playwright/mcp@latest", "--headless", "--isolated"],
|
||||
"timeout": 60000,
|
||||
"perSession": true,
|
||||
"env": {
|
||||
"PLAYWRIGHT_BROWSERS_PATH": "/tmp/browsers"
|
||||
}
|
||||
@@ -114,6 +148,8 @@ MCPHub uses several configuration files:
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: The `--isolated` flag ensures each browser session is isolated, and `perSession: true` creates a separate server instance for each user session, preventing state leakage between concurrent users.
|
||||
|
||||
### File and System Servers
|
||||
|
||||
#### Filesystem Server
|
||||
@@ -242,68 +278,6 @@ MCPHub uses several configuration files:
|
||||
}
|
||||
```
|
||||
|
||||
## Connection Modes
|
||||
|
||||
MCPHub supports two connection strategies for MCP servers:
|
||||
|
||||
### Persistent Connection (Default)
|
||||
|
||||
Persistent mode maintains a long-running connection to the MCP server. This is the default and recommended mode for most servers.
|
||||
|
||||
**Use cases:**
|
||||
- Servers that maintain state between requests
|
||||
- Servers with slow startup times
|
||||
- Servers designed for long-running connections
|
||||
|
||||
**Example:**
|
||||
```json
|
||||
{
|
||||
"github": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-github"],
|
||||
"connectionMode": "persistent",
|
||||
"env": {
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### On-Demand Connection
|
||||
|
||||
On-demand mode connects only when a tool is invoked, then disconnects immediately after. This is ideal for servers that:
|
||||
- Don't support long-running connections
|
||||
- Are designed for one-time use
|
||||
- Exit automatically after handling requests
|
||||
|
||||
**Use cases:**
|
||||
- PDF processing tools that exit after each operation
|
||||
- One-time command-line utilities
|
||||
- Servers with connection stability issues
|
||||
- Resource-intensive servers that shouldn't run continuously
|
||||
|
||||
**Example:**
|
||||
```json
|
||||
{
|
||||
"pdf-reader": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "pdf-mcp-server"],
|
||||
"connectionMode": "on-demand",
|
||||
"env": {
|
||||
"PDF_CACHE_DIR": "/tmp/pdf-cache"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits of on-demand mode:**
|
||||
- Avoids "Connection closed" errors for ephemeral services
|
||||
- Reduces resource usage for infrequently used tools
|
||||
- Better suited for stateless operations
|
||||
- Handles servers that automatically exit after operations
|
||||
|
||||
**Note:** On-demand servers briefly connect during initialization to discover available tools, then disconnect. The connection is re-established only when a tool from that server is actually invoked.
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Environment Variable Substitution
|
||||
|
||||
@@ -27,8 +27,7 @@
|
||||
"pages": [
|
||||
"features/server-management",
|
||||
"features/group-management",
|
||||
"features/smart-routing",
|
||||
"features/oauth"
|
||||
"features/smart-routing"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -58,8 +57,7 @@
|
||||
"pages": [
|
||||
"zh/features/server-management",
|
||||
"zh/features/group-management",
|
||||
"zh/features/smart-routing",
|
||||
"zh/features/oauth"
|
||||
"zh/features/smart-routing"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -161,4 +159,4 @@
|
||||
"discord": "https://discord.gg/qMKNsn5Q"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
# Environment Variable Expansion in mcp_settings.json
|
||||
|
||||
## Overview
|
||||
|
||||
MCPHub now supports comprehensive environment variable expansion throughout the entire `mcp_settings.json` configuration file. This allows you to externalize sensitive information and configuration values, making your setup more secure and flexible.
|
||||
|
||||
## Supported Formats
|
||||
|
||||
MCPHub supports two environment variable formats:
|
||||
|
||||
1. **${VAR}** - Standard format (recommended)
|
||||
2. **$VAR** - Unix-style format (variable name must start with an uppercase letter or underscore, followed by uppercase letters, numbers, or underscores)
|
||||
|
||||
## What Can Be Expanded
|
||||
|
||||
Environment variables can now be used in **ANY** string value throughout your configuration:
|
||||
|
||||
- Server URLs
|
||||
- Commands and arguments
|
||||
- Headers
|
||||
- Environment variables passed to child processes
|
||||
- OpenAPI specifications and security configurations
|
||||
- OAuth credentials
|
||||
- System configuration values
|
||||
- Any other string fields
|
||||
|
||||
## Examples
|
||||
|
||||
### 1. SSE/HTTP Server Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-api-server": {
|
||||
"type": "sse",
|
||||
"url": "${MCP_SERVER_URL}",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${API_TOKEN}",
|
||||
"X-Custom-Header": "${CUSTOM_VALUE}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
```bash
|
||||
export MCP_SERVER_URL="https://api.example.com/mcp"
|
||||
export API_TOKEN="secret-token-123"
|
||||
export CUSTOM_VALUE="my-custom-value"
|
||||
```
|
||||
|
||||
### 2. Stdio Server Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"my-python-server": {
|
||||
"type": "stdio",
|
||||
"command": "${PYTHON_PATH}",
|
||||
"args": ["-m", "${MODULE_NAME}", "--api-key", "${API_KEY}"],
|
||||
"env": {
|
||||
"DATABASE_URL": "${DATABASE_URL}",
|
||||
"DEBUG": "${DEBUG_MODE}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
```bash
|
||||
export PYTHON_PATH="/usr/bin/python3"
|
||||
export MODULE_NAME="my_mcp_server"
|
||||
export API_KEY="secret-api-key"
|
||||
export DATABASE_URL="postgresql://localhost/mydb"
|
||||
export DEBUG_MODE="true"
|
||||
```
|
||||
|
||||
### 3. OpenAPI Server Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"openapi-service": {
|
||||
"type": "openapi",
|
||||
"openapi": {
|
||||
"url": "${OPENAPI_SPEC_URL}",
|
||||
"security": {
|
||||
"type": "apiKey",
|
||||
"apiKey": {
|
||||
"name": "X-API-Key",
|
||||
"in": "header",
|
||||
"value": "${OPENAPI_API_KEY}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
```bash
|
||||
export OPENAPI_SPEC_URL="https://api.example.com/openapi.json"
|
||||
export OPENAPI_API_KEY="your-api-key-here"
|
||||
```
|
||||
|
||||
### 4. OAuth Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"oauth-server": {
|
||||
"type": "sse",
|
||||
"url": "${OAUTH_SERVER_URL}",
|
||||
"oauth": {
|
||||
"clientId": "${OAUTH_CLIENT_ID}",
|
||||
"clientSecret": "${OAUTH_CLIENT_SECRET}",
|
||||
"accessToken": "${OAUTH_ACCESS_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
```bash
|
||||
export OAUTH_SERVER_URL="https://oauth.example.com/mcp"
|
||||
export OAUTH_CLIENT_ID="my-client-id"
|
||||
export OAUTH_CLIENT_SECRET="my-client-secret"
|
||||
export OAUTH_ACCESS_TOKEN="my-access-token"
|
||||
```
|
||||
|
||||
### 5. System Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"systemConfig": {
|
||||
"install": {
|
||||
"pythonIndexUrl": "${PYTHON_INDEX_URL}",
|
||||
"npmRegistry": "${NPM_REGISTRY}"
|
||||
},
|
||||
"mcpRouter": {
|
||||
"apiKey": "${MCPROUTER_API_KEY}",
|
||||
"referer": "${MCPROUTER_REFERER}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
```bash
|
||||
export PYTHON_INDEX_URL="https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||
export NPM_REGISTRY="https://registry.npmmirror.com"
|
||||
export MCPROUTER_API_KEY="router-api-key"
|
||||
export MCPROUTER_REFERER="https://myapp.com"
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
See [examples/mcp_settings_with_env_vars.json](../examples/mcp_settings_with_env_vars.json) for a comprehensive example configuration using environment variables.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Security
|
||||
|
||||
1. **Never commit sensitive values to version control** - Use environment variables for all secrets
|
||||
2. **Use .env files for local development** - MCPHub automatically loads `.env` files
|
||||
3. **Use secure secret management in production** - Consider using Docker secrets, Kubernetes secrets, or cloud provider secret managers
|
||||
|
||||
### Organization
|
||||
|
||||
1. **Group related variables** - Use prefixes for related configuration (e.g., `API_`, `DB_`, `OAUTH_`)
|
||||
2. **Document required variables** - Maintain a list of required environment variables in your README
|
||||
3. **Provide example .env file** - Create a `.env.example` file with placeholder values
|
||||
|
||||
### Example .env File
|
||||
|
||||
```bash
|
||||
# Server Configuration
|
||||
MCP_SERVER_URL=https://api.example.com/mcp
|
||||
API_TOKEN=your-api-token-here
|
||||
|
||||
# Python Server
|
||||
PYTHON_PATH=/usr/bin/python3
|
||||
MODULE_NAME=my_mcp_server
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://localhost/mydb
|
||||
|
||||
# OpenAPI
|
||||
OPENAPI_SPEC_URL=https://api.example.com/openapi.json
|
||||
OPENAPI_API_KEY=your-openapi-key
|
||||
|
||||
# OAuth
|
||||
OAUTH_CLIENT_ID=your-client-id
|
||||
OAUTH_CLIENT_SECRET=your-client-secret
|
||||
OAUTH_ACCESS_TOKEN=your-access-token
|
||||
```
|
||||
|
||||
## Docker Usage
|
||||
|
||||
When using Docker, pass environment variables using `-e` flag or `--env-file`:
|
||||
|
||||
```bash
|
||||
# Using individual variables
|
||||
docker run -e API_TOKEN=secret -e SERVER_URL=https://api.example.com mcphub
|
||||
|
||||
# Using env file
|
||||
docker run --env-file .env mcphub
|
||||
```
|
||||
|
||||
Or in docker-compose.yml:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
mcphub:
|
||||
image: mcphub
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- MCP_SERVER_URL=${MCP_SERVER_URL}
|
||||
- API_TOKEN=${API_TOKEN}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Variable Not Expanding
|
||||
|
||||
If a variable is not expanding:
|
||||
|
||||
1. Check that the variable is set: `echo $VAR_NAME`
|
||||
2. Verify the variable name matches exactly (case-sensitive)
|
||||
3. Ensure the variable is exported: `export VAR_NAME=value`
|
||||
4. Restart MCPHub after setting environment variables
|
||||
|
||||
### Empty Values
|
||||
|
||||
If an environment variable is not set, it will be replaced with an empty string. Make sure all required variables are set before starting MCPHub.
|
||||
|
||||
### Nested Variables
|
||||
|
||||
Environment variables in nested objects and arrays are fully supported:
|
||||
|
||||
```json
|
||||
{
|
||||
"nested": {
|
||||
"deep": {
|
||||
"value": "${MY_VAR}"
|
||||
}
|
||||
},
|
||||
"array": ["${VAR1}", "${VAR2}"]
|
||||
}
|
||||
```
|
||||
|
||||
## Migration from Previous Version
|
||||
|
||||
If you were previously using environment variables only in headers, no changes are needed. The new implementation is backward compatible and simply extends support to all configuration fields.
|
||||
|
||||
## Technical Details
|
||||
|
||||
- Environment variables are expanded once when the configuration is loaded
|
||||
- Expansion is recursive and handles nested objects and arrays
|
||||
- Non-string values (booleans, numbers, null) are preserved as-is
|
||||
- Empty string is used when an environment variable is not set
|
||||
@@ -1,141 +0,0 @@
|
||||
# OAuth Support
|
||||
|
||||
## At a Glance
|
||||
- Covers end-to-end OAuth 2.0 Authorization Code with PKCE for upstream MCP servers.
|
||||
- Supports automatic discovery from `WWW-Authenticate` responses and RFC 8414 metadata.
|
||||
- Implements dynamic client registration (RFC 7591) and resource indicators (RFC 8707).
|
||||
- Persists client credentials and tokens to `mcp_settings.json` for reconnects.
|
||||
|
||||
## When MCPHub Switches to OAuth
|
||||
1. MCPHub calls an MCP server that requires authorization and receives `401 Unauthorized`.
|
||||
2. The response exposes a `WWW-Authenticate` header pointing to protected resource metadata (`authorization_server` or `as_uri`).
|
||||
3. MCPHub discovers the authorization server metadata, registers (if needed), and opens the browser so the user can authorize once.
|
||||
4. After the callback is handled, MCPHub reconnects with fresh tokens and resumes requests transparently.
|
||||
|
||||
> MCPHub logs each stage (discovery, registration, authorization URL, token exchange) in the server detail view and the backend logs.
|
||||
|
||||
## Quick Start by Server Type
|
||||
|
||||
### Servers with Dynamic Registration Support
|
||||
Some servers expose complete OAuth metadata and allow dynamic client registration. For example, Vercel and Linear MCP servers only need their SSE endpoint configured:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"vercel": {
|
||||
"type": "sse",
|
||||
"url": "https://mcp.vercel.com"
|
||||
},
|
||||
"linear": {
|
||||
"type": "sse",
|
||||
"url": "https://mcp.linear.app/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- MCPHub discovers the authorization server, registers the client, and handles PKCE automatically.
|
||||
- Tokens are stored in `mcp_settings.json`; no additional dashboard configuration is needed.
|
||||
|
||||
### Servers Requiring Manual Client Provisioning
|
||||
Other providers do not support dynamic registration. GitHub’s MCP endpoint (`https://api.githubcopilot.com/mcp/`) is one example. To connect:
|
||||
|
||||
1. Create an OAuth App in the provider’s console (for GitHub, go to **Settings → Developer settings → OAuth Apps**).
|
||||
2. Set the callback/redirect URL to `http://localhost:3000/oauth/callback` (or your deployed dashboard domain).
|
||||
3. Copy the issued client ID and client secret.
|
||||
4. Supply the credentials through the MCPHub dashboard or by editing `mcp_settings.json` as shown below.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
"type": "sse",
|
||||
"url": "https://api.githubcopilot.com/mcp/",
|
||||
"oauth": {
|
||||
"clientId": "${GITHUB_OAUTH_APP_ID}",
|
||||
"clientSecret": "${GITHUB_OAUTH_APP_SECRET}",
|
||||
"scopes": ["replace-with-provider-scope"],
|
||||
"resource": "https://api.githubcopilot.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- MCPHub skips dynamic registration and uses the credentials you provide to complete the OAuth exchange.
|
||||
- Update the dashboard or configuration file whenever you rotate secrets.
|
||||
- Replace `scopes` with the exact scope strings required by the provider.
|
||||
|
||||
## Configuration Options
|
||||
You can rely on auto-detection for most servers or declare OAuth settings explicitly in `mcp_settings.json`. Only populate the fields you need.
|
||||
|
||||
### Basic Auto Detection (Minimal Config)
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"secured-sse": {
|
||||
"type": "sse",
|
||||
"url": "https://mcp.example.com/sse",
|
||||
"oauth": {
|
||||
"scopes": ["mcp.tools", "mcp.prompts"],
|
||||
"resource": "https://mcp.example.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- MCPHub will discover the authorization server from challenge headers and walk the user through authorization automatically.
|
||||
- Tokens (including refresh tokens) are stored on disk and reused on restart.
|
||||
|
||||
### Static Client Credentials (Bring Your Own Client)
|
||||
```json
|
||||
{
|
||||
"oauth": {
|
||||
"clientId": "mcphub-client",
|
||||
"clientSecret": "replace-me-if-required",
|
||||
"authorizationEndpoint": "https://auth.example.com/oauth/authorize",
|
||||
"tokenEndpoint": "https://auth.example.com/oauth/token",
|
||||
"redirectUri": "http://localhost:3000/oauth/callback"
|
||||
}
|
||||
}
|
||||
```
|
||||
- Use this when the authorization server requires manual client provisioning.
|
||||
- `redirectUri` defaults to `http://localhost:3000/oauth/callback`; override it when running behind a custom domain.
|
||||
|
||||
### Dynamic Client Registration (RFC 7591)
|
||||
```json
|
||||
{
|
||||
"oauth": {
|
||||
"dynamicRegistration": {
|
||||
"enabled": true,
|
||||
"issuer": "https://auth.example.com",
|
||||
"metadata": {
|
||||
"client_name": "MCPHub",
|
||||
"redirect_uris": [
|
||||
"http://localhost:3000/oauth/callback",
|
||||
"https://mcphub.example.com/oauth/callback"
|
||||
],
|
||||
"scope": "mcp.tools mcp.prompts",
|
||||
"grant_types": ["authorization_code", "refresh_token"]
|
||||
},
|
||||
"initialAccessToken": "optional-token-if-required"
|
||||
},
|
||||
"scopes": ["mcp.tools", "mcp.prompts"],
|
||||
"resource": "https://mcp.example.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
- MCPHub discovers endpoints via `issuer`, registers itself, and persists the issued `client_id`/`client_secret`.
|
||||
- Provide `initialAccessToken` only when the registration endpoint is protected.
|
||||
|
||||
## Authorization Flow
|
||||
1. **Initialization** – On startup MCPHub processes every server entry, discovers metadata, and registers the client if `dynamicRegistration.enabled` is true.
|
||||
2. **User Authorization** – Initiating a connection launches the system browser to the server’s authorize page with PKCE parameters.
|
||||
3. **Callback Handling** – The built-in route (`/oauth/callback`) verifies the `state`, completes the token exchange, and saves the tokens via the MCP SDK.
|
||||
4. **Token Lifecycle** – Access and refresh tokens are cached in memory, refreshed automatically, and written back to `mcp_settings.json`.
|
||||
|
||||
## Tips & Troubleshooting
|
||||
- Confirm that the redirect URI used during authorization exactly matches one of the `redirect_uris` registered with the authorization server.
|
||||
- When running behind HTTPS, expose the callback URL publicly or configure a reverse proxy at `/oauth/callback`.
|
||||
- If discovery fails, supply `authorizationEndpoint` and `tokenEndpoint` explicitly to bypass metadata lookup.
|
||||
- Remove stale tokens from `mcp_settings.json` if an authorization server revokes access—MCPHub will prompt for a fresh login on the next request.
|
||||
@@ -276,92 +276,17 @@ Access Smart Routing through the special `$smart` endpoint:
|
||||
<Tabs>
|
||||
<Tab title="HTTP MCP">
|
||||
```
|
||||
# Search across all servers
|
||||
http://localhost:3000/mcp/$smart
|
||||
|
||||
# Search within a specific group
|
||||
http://localhost:3000/mcp/$smart/{group}
|
||||
```
|
||||
</Tab>
|
||||
|
||||
<Tab title="SSE (Legacy)">
|
||||
```
|
||||
# Search across all servers
|
||||
http://localhost:3000/sse/$smart
|
||||
|
||||
# Search within a specific group
|
||||
http://localhost:3000/sse/$smart/{group}
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Group-Scoped Smart Routing
|
||||
|
||||
Smart Routing now supports group-scoped searches, allowing you to limit tool discovery to servers within a specific group:
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Using Group-Scoped Smart Routing">
|
||||
Connect your AI client to a group-specific Smart Routing endpoint:
|
||||
|
||||
```
|
||||
http://localhost:3000/mcp/$smart/production
|
||||
```
|
||||
|
||||
This endpoint will only search for tools within servers that belong to the "production" group.
|
||||
|
||||
**Benefits:**
|
||||
- **Focused Results**: Only tools from relevant servers are returned
|
||||
- **Better Performance**: Reduced search space for faster queries
|
||||
- **Environment Isolation**: Keep development, staging, and production tools separate
|
||||
- **Access Control**: Limit tool discovery based on user permissions
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Example: Environment-Based Groups">
|
||||
Create groups for different environments:
|
||||
|
||||
```bash
|
||||
# Development environment
|
||||
http://localhost:3000/mcp/$smart/development
|
||||
|
||||
# Staging environment
|
||||
http://localhost:3000/mcp/$smart/staging
|
||||
|
||||
# Production environment
|
||||
http://localhost:3000/mcp/$smart/production
|
||||
```
|
||||
|
||||
Each endpoint will only return tools from servers in that specific environment group.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Example: Team-Based Groups">
|
||||
Organize tools by team or department:
|
||||
|
||||
```bash
|
||||
# Backend team tools
|
||||
http://localhost:3000/mcp/$smart/backend-team
|
||||
|
||||
# Frontend team tools
|
||||
http://localhost:3000/mcp/$smart/frontend-team
|
||||
|
||||
# DevOps team tools
|
||||
http://localhost:3000/mcp/$smart/devops-team
|
||||
```
|
||||
|
||||
This enables teams to have focused access to their relevant toolsets.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="How It Works">
|
||||
When using `$smart/{group}`:
|
||||
|
||||
1. The system identifies the specified group
|
||||
2. Retrieves all servers belonging to that group
|
||||
3. Filters the tool search to only those servers
|
||||
4. Returns results scoped to the group's servers
|
||||
|
||||
If the group doesn't exist or has no servers, the search will return no results.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
{/* ### Basic Usage
|
||||
|
||||
Connect your AI client to the Smart Routing endpoint and make natural language requests:
|
||||
|
||||
@@ -41,50 +41,6 @@ docker run -d \
|
||||
mcphub:local
|
||||
```
|
||||
|
||||
### 构建扩展功能版本
|
||||
|
||||
Docker 镜像支持 `INSTALL_EXT` 构建参数以包含额外工具:
|
||||
|
||||
```bash
|
||||
# 构建扩展功能版本(包含 Docker 引擎、Chrome/Playwright)
|
||||
docker build --build-arg INSTALL_EXT=true -t mcphub:extended .
|
||||
|
||||
# 方式 1: 使用自动 Docker-in-Docker(需要特权模式)
|
||||
docker run -d \
|
||||
--name mcphub \
|
||||
--privileged \
|
||||
-p 3000:3000 \
|
||||
-v $(pwd)/mcp_settings.json:/app/mcp_settings.json \
|
||||
mcphub:extended
|
||||
|
||||
# 方式 2: 挂载 Docker socket(使用宿主机的 Docker 守护进程)
|
||||
docker run -d \
|
||||
--name mcphub \
|
||||
-p 3000:3000 \
|
||||
-v $(pwd)/mcp_settings.json:/app/mcp_settings.json \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
mcphub:extended
|
||||
|
||||
# 验证 Docker 可用
|
||||
docker exec mcphub docker --version
|
||||
docker exec mcphub docker ps
|
||||
```
|
||||
|
||||
<Note>
|
||||
**INSTALL_EXT=true 包含的功能:**
|
||||
- **Docker 引擎**:完整的 Docker 守护进程和 CLI,用于容器管理。在特权模式下运行时,守护进程会自动启动。
|
||||
- **Chrome/Playwright**(仅 amd64):用于浏览器自动化任务
|
||||
|
||||
扩展镜像较大,但为高级用例提供了额外功能。
|
||||
</Note>
|
||||
|
||||
<Warning>
|
||||
**Docker-in-Docker 安全注意事项:**
|
||||
- **特权模式**(`--privileged`):容器内启动 Docker 守护进程需要此权限。这会授予容器在宿主机上的提升权限。
|
||||
- **Docker socket 挂载**(`/var/run/docker.sock`):使容器可以访问宿主机的 Docker 守护进程。两种方式都应仅在可信环境中使用。
|
||||
- 生产环境建议使用 Docker socket 挂载而非特权模式,以提高安全性。
|
||||
</Warning>
|
||||
|
||||
## Docker Compose 设置
|
||||
|
||||
### 基本配置
|
||||
|
||||
@@ -50,8 +50,9 @@ MCPHub 使用几个配置文件:
|
||||
},
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--headless"],
|
||||
"timeout": 60000
|
||||
"args": ["@playwright/mcp@latest", "--headless", "--isolated"],
|
||||
"timeout": 60000,
|
||||
"perSession": true
|
||||
},
|
||||
"slack": {
|
||||
"command": "npx",
|
||||
@@ -79,13 +80,48 @@ MCPHub 使用几个配置文件:
|
||||
| 字段 | 类型 | 默认值 | 描述 |
|
||||
| -------------- | ------- | --------------- | ------------------ |
|
||||
| `env` | object | `{}` | 环境变量 |
|
||||
| `perSession` | boolean | `false` | 为每个用户会话创建独立的服务器实例(用于有状态的服务器,如 playwright) |
|
||||
| `enabled` | boolean | `true` | 启用/禁用服务器 |
|
||||
| `timeout` | number | `60000` | 请求超时(毫秒) |
|
||||
| `keepAliveInterval` | number | `60000` | SSE 服务器的保活 ping 间隔(毫秒) |
|
||||
| `cwd` | string | `process.cwd()` | 工作目录 |
|
||||
| `timeout` | number | `30000` | 启动超时(毫秒) |
|
||||
| `restart` | boolean | `true` | 失败时自动重启 |
|
||||
| `maxRestarts` | number | `5` | 最大重启次数 |
|
||||
| `restartDelay` | number | `5000` | 重启间延迟(毫秒) |
|
||||
| `stdio` | string | `pipe` | stdio 配置 |
|
||||
|
||||
## 会话隔离的服务器实例
|
||||
|
||||
某些 MCP 服务器会维护应该在不同用户之间隔离的状态。例如,Playwright 服务器维护可能在并发用户之间泄漏表单数据或其他状态的浏览器会话。
|
||||
|
||||
为了防止这种情况,您可以在服务器配置中设置 `perSession: true`。这将为每个用户会话创建一个单独的服务器实例,而不是在所有用户之间共享单个实例。
|
||||
|
||||
### 何时使用会话隔离的服务器
|
||||
|
||||
对于以下服务器使用 `perSession: true`:
|
||||
- 维护浏览器状态(如 Playwright)
|
||||
- 在内存中存储用户特定数据
|
||||
- 具有不应共享的文件句柄或数据库连接
|
||||
- 当多个用户同时访问时可能导致竞争条件
|
||||
|
||||
### 示例配置
|
||||
|
||||
```json
|
||||
{
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--headless", "--isolated"],
|
||||
"perSession": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**重要提示:**
|
||||
- 每个会话隔离的服务器实例都会消耗额外的资源(内存、CPU)
|
||||
- 会话隔离的服务器在用户会话结束时会自动清理
|
||||
- 对于 Playwright,还要使用 `--isolated` 标志以确保浏览器上下文被隔离
|
||||
- 不建议用于可以安全共享的无状态服务器
|
||||
|
||||
## 常见 MCP 服务器示例
|
||||
|
||||
### Web 和 API 服务器
|
||||
@@ -111,8 +147,9 @@ MCPHub 使用几个配置文件:
|
||||
{
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--headless"],
|
||||
"args": ["@playwright/mcp@latest", "--headless", "--isolated"],
|
||||
"timeout": 60000,
|
||||
"perSession": true,
|
||||
"env": {
|
||||
"PLAYWRIGHT_BROWSERS_PATH": "/tmp/browsers"
|
||||
}
|
||||
@@ -120,6 +157,8 @@ MCPHub 使用几个配置文件:
|
||||
}
|
||||
```
|
||||
|
||||
**注意**: `--isolated` 标志确保每个浏览器会话是隔离的,而 `perSession: true` 为每个用户会话创建单独的服务器实例,防止并发用户之间的状态泄漏。
|
||||
|
||||
### 文件和系统服务器
|
||||
|
||||
#### 文件系统服务器
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
# OAuth 支持
|
||||
|
||||
## 核心亮点
|
||||
- 覆盖上游 MCP 服务器的 OAuth 2.0 授权码(PKCE)全流程。
|
||||
- 支持从 `WWW-Authenticate` 响应和 RFC 8414 元数据自动发现。
|
||||
- 实现动态客户端注册(RFC 7591)以及资源指示(RFC 8707)。
|
||||
- 会将客户端凭据与令牌持久化到 `mcp_settings.json`,重启后直接复用。
|
||||
|
||||
## MCPHub 何时启用 OAuth
|
||||
1. MCPHub 调用需要授权的 MCP 服务器并收到 `401 Unauthorized`。
|
||||
2. 响应通过 `WWW-Authenticate` header 暴露受保护资源的元数据(`authorization_server` 或 `as_uri`)。
|
||||
3. MCPHub 自动发现授权服务器、按需注册客户端,并引导用户完成一次授权。
|
||||
4. 回调处理完成后,MCPHub 使用新令牌重新连接并继续请求。
|
||||
|
||||
> MCPHub 会在服务器详情视图和后端日志中记录发现、注册、授权链接、换取令牌等关键步骤。
|
||||
|
||||
## 按服务器类型快速上手
|
||||
|
||||
### 支持动态注册的服务器
|
||||
部分服务器会公开完整的 OAuth 元数据,并允许客户端动态注册。例如 Vercel 与 Linear 的 MCP 服务器只需配置 SSE 地址即可:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"vercel": {
|
||||
"type": "sse",
|
||||
"url": "https://mcp.vercel.com"
|
||||
},
|
||||
"linear": {
|
||||
"type": "sse",
|
||||
"url": "https://mcp.linear.app/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- MCPHub 会自动发现授权服务器、完成注册,并处理整个 PKCE 流程。
|
||||
- 所有凭据与令牌会写入 `mcp_settings.json`,无须在控制台额外配置。
|
||||
|
||||
### 需要手动配置客户端的服务器
|
||||
另有一些服务端并不支持动态注册。GitHub 的 MCP 端点(`https://api.githubcopilot.com/mcp/`)就是典型例子,接入步骤如下:
|
||||
|
||||
1. 在服务提供商控制台创建 OAuth 应用(GitHub 路径为 **Settings → Developer settings → OAuth Apps**)。
|
||||
2. 将回调/重定向地址设置为 `http://localhost:3000/oauth/callback`(或你的部署域名)。
|
||||
3. 复制生成的 Client ID 与 Client Secret。
|
||||
4. 通过 MCPHub 控制台或直接编辑 `mcp_settings.json` 写入如下配置。
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
"type": "sse",
|
||||
"url": "https://api.githubcopilot.com/mcp/",
|
||||
"oauth": {
|
||||
"clientId": "${GITHUB_OAUTH_APP_ID}",
|
||||
"clientSecret": "${GITHUB_OAUTH_APP_SECRET}",
|
||||
"scopes": ["replace-with-provider-scope"],
|
||||
"resource": "https://api.githubcopilot.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- MCPHub 会跳过动态注册,直接使用你提供的凭据完成授权流程。
|
||||
- 凭据轮换时需要同步更新控制台或配置文件。
|
||||
- 将 `scopes` 替换为服务端要求的具体 Scope。
|
||||
|
||||
## 配置方式
|
||||
大多数场景可依赖自动检测,也可以在 `mcp_settings.json` 中显式声明 OAuth 配置。只填写确实需要的字段。
|
||||
|
||||
### 自动检测(最小配置)
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"secured-sse": {
|
||||
"type": "sse",
|
||||
"url": "https://mcp.example.com/sse",
|
||||
"oauth": {
|
||||
"scopes": ["mcp.tools", "mcp.prompts"],
|
||||
"resource": "https://mcp.example.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- MCPHub 会根据挑战头自动发现授权服务器,并引导用户完成授权。
|
||||
- 令牌(包含刷新令牌)会写入磁盘,重启后自动复用。
|
||||
|
||||
### 静态客户端凭据(自带 Client)
|
||||
```json
|
||||
{
|
||||
"oauth": {
|
||||
"clientId": "mcphub-client",
|
||||
"clientSecret": "replace-me-if-required",
|
||||
"authorizationEndpoint": "https://auth.example.com/oauth/authorize",
|
||||
"tokenEndpoint": "https://auth.example.com/oauth/token",
|
||||
"redirectUri": "http://localhost:3000/oauth/callback"
|
||||
}
|
||||
}
|
||||
```
|
||||
- 适用于需要手动注册客户端的授权服务器。
|
||||
- `redirectUri` 默认是 `http://localhost:3000/oauth/callback`,如在自定义域部署请同步更新。
|
||||
|
||||
### 动态客户端注册(RFC 7591)
|
||||
```json
|
||||
{
|
||||
"oauth": {
|
||||
"dynamicRegistration": {
|
||||
"enabled": true,
|
||||
"issuer": "https://auth.example.com",
|
||||
"metadata": {
|
||||
"client_name": "MCPHub",
|
||||
"redirect_uris": [
|
||||
"http://localhost:3000/oauth/callback",
|
||||
"https://mcphub.example.com/oauth/callback"
|
||||
],
|
||||
"scope": "mcp.tools mcp.prompts",
|
||||
"grant_types": ["authorization_code", "refresh_token"]
|
||||
},
|
||||
"initialAccessToken": "optional-token-if-required"
|
||||
},
|
||||
"scopes": ["mcp.tools", "mcp.prompts"],
|
||||
"resource": "https://mcp.example.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
- MCPHub 会通过 `issuer` 发现端点、完成注册,并持久化下发的 `client_id`/`client_secret`。
|
||||
- 只有当注册端点受保护时才需要提供 `initialAccessToken`。
|
||||
|
||||
## 授权流程
|
||||
1. **初始化**:启动时遍历服务器配置,发现元数据并在启用 `dynamicRegistration` 时注册客户端。
|
||||
2. **用户授权**:建立连接时自动打开系统浏览器,携带 PKCE 参数访问授权页。
|
||||
3. **回调处理**:内置路径 `/oauth/callback` 校验 `state`、完成换取令牌,并通过 MCP SDK 保存结果。
|
||||
4. **令牌生命周期**:访问令牌与刷新令牌会缓存于内存,自动刷新,并写回 `mcp_settings.json`。
|
||||
|
||||
## 提示与排障
|
||||
- 确保授权过程中使用的回调地址与已注册的 `redirect_uris` 完全一致。
|
||||
- 若部署在 HTTPS 域名下,请对外暴露 `/oauth/callback` 或通过反向代理转发。
|
||||
- 如无法完成自动发现,可显式提供 `authorizationEndpoint` 与 `tokenEndpoint`。
|
||||
- 授权服务器吊销令牌后,可手动清理 `mcp_settings.json` 中的旧令牌,MCPHub 会在下一次请求时重新触发授权。
|
||||
@@ -1,32 +1,10 @@
|
||||
#!/bin/bash
|
||||
|
||||
DATA_DIR=${MCP_DATA_DIR:-/app/data}
|
||||
SERVERS_DIR=${MCP_SERVERS_DIR:-$DATA_DIR/servers}
|
||||
NPM_SERVER_DIR=${MCP_NPM_DIR:-$SERVERS_DIR/npm}
|
||||
PYTHON_SERVER_DIR=${MCP_PYTHON_DIR:-$SERVERS_DIR/python}
|
||||
PNPM_HOME=${PNPM_HOME:-$DATA_DIR/pnpm}
|
||||
NPM_CONFIG_PREFIX=${NPM_CONFIG_PREFIX:-$DATA_DIR/npm-global}
|
||||
NPM_CONFIG_CACHE=${NPM_CONFIG_CACHE:-$DATA_DIR/npm-cache}
|
||||
UV_TOOL_DIR=${UV_TOOL_DIR:-$DATA_DIR/uv/tools}
|
||||
UV_CACHE_DIR=${UV_CACHE_DIR:-$DATA_DIR/uv/cache}
|
||||
|
||||
mkdir -p \
|
||||
"$PNPM_HOME" \
|
||||
"$NPM_CONFIG_PREFIX/bin" \
|
||||
"$NPM_CONFIG_PREFIX/lib/node_modules" \
|
||||
"$NPM_CONFIG_CACHE" \
|
||||
"$UV_TOOL_DIR" \
|
||||
"$UV_CACHE_DIR" \
|
||||
"$NPM_SERVER_DIR" \
|
||||
"$PYTHON_SERVER_DIR"
|
||||
|
||||
export PATH="$PNPM_HOME:$NPM_CONFIG_PREFIX/bin:$UV_TOOL_DIR/bin:$PATH"
|
||||
|
||||
NPM_REGISTRY=${NPM_REGISTRY:-https://registry.npmjs.org/}
|
||||
echo "Setting npm registry to ${NPM_REGISTRY}"
|
||||
npm config set registry "$NPM_REGISTRY"
|
||||
|
||||
# Handle HTTP_PROXY and HTTPS_PROXY environment variables
|
||||
# 处理 HTTP_PROXY 和 HTTPS_PROXY 环境变量
|
||||
if [ -n "$HTTP_PROXY" ]; then
|
||||
echo "Setting HTTP proxy to ${HTTP_PROXY}"
|
||||
npm config set proxy "$HTTP_PROXY"
|
||||
@@ -41,33 +19,4 @@ fi
|
||||
|
||||
echo "Using REQUEST_TIMEOUT: $REQUEST_TIMEOUT"
|
||||
|
||||
# Auto-start Docker daemon if Docker is installed
|
||||
if command -v dockerd >/dev/null 2>&1; then
|
||||
echo "Docker daemon detected, starting dockerd..."
|
||||
|
||||
# Create docker directory if it doesn't exist
|
||||
mkdir -p /var/lib/docker
|
||||
|
||||
# Start dockerd in the background
|
||||
dockerd --host=unix:///var/run/docker.sock --storage-driver=vfs > /var/log/dockerd.log 2>&1 &
|
||||
|
||||
# Wait for Docker daemon to be ready
|
||||
echo "Waiting for Docker daemon to be ready..."
|
||||
TIMEOUT=15
|
||||
ELAPSED=0
|
||||
while ! docker info >/dev/null 2>&1; do
|
||||
if [ $ELAPSED -ge $TIMEOUT ]; then
|
||||
echo "WARNING: Docker daemon failed to start within ${TIMEOUT} seconds"
|
||||
echo "Check /var/log/dockerd.log for details"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
ELAPSED=$((ELAPSED + 1))
|
||||
done
|
||||
|
||||
if docker info >/dev/null 2>&1; then
|
||||
echo "Docker daemon started successfully"
|
||||
fi
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft-07/schema",
|
||||
"description": "Example MCP settings showing different connection modes",
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-github"],
|
||||
"connectionMode": "persistent",
|
||||
"env": {
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_TOKEN}"
|
||||
},
|
||||
"enabled": true
|
||||
},
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--headless"],
|
||||
"connectionMode": "persistent",
|
||||
"enabled": true
|
||||
},
|
||||
"pdf-reader": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "pdf-mcp-server"],
|
||||
"connectionMode": "on-demand",
|
||||
"env": {
|
||||
"PDF_CACHE_DIR": "/tmp/pdf-cache"
|
||||
},
|
||||
"enabled": true
|
||||
},
|
||||
"image-processor": {
|
||||
"command": "python",
|
||||
"args": ["-m", "image_mcp_server"],
|
||||
"connectionMode": "on-demand",
|
||||
"env": {
|
||||
"IMAGE_OUTPUT_DIR": "/tmp/images"
|
||||
},
|
||||
"enabled": true
|
||||
},
|
||||
"fetch": {
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server-fetch"],
|
||||
"enabled": true
|
||||
},
|
||||
"slack": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-slack"],
|
||||
"connectionMode": "persistent",
|
||||
"env": {
|
||||
"SLACK_BOT_TOKEN": "${SLACK_BOT_TOKEN}",
|
||||
"SLACK_TEAM_ID": "${SLACK_TEAM_ID}"
|
||||
},
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"users": [
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "$2b$10$Vt7krIvjNgyN67LXqly0uOcTpN0LI55cYRbcKC71pUDAP0nJ7RPa.",
|
||||
"isAdmin": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"example-sse-server": {
|
||||
"type": "sse",
|
||||
"url": "${MCP_SERVER_URL}",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${API_TOKEN}",
|
||||
"X-Custom-Header": "${CUSTOM_HEADER_VALUE}"
|
||||
},
|
||||
"enabled": true
|
||||
},
|
||||
"example-streamable-http": {
|
||||
"type": "streamable-http",
|
||||
"url": "https://${SERVER_HOST}/mcp",
|
||||
"headers": {
|
||||
"API-Key": "${API_KEY}"
|
||||
}
|
||||
},
|
||||
"example-stdio-server": {
|
||||
"type": "stdio",
|
||||
"command": "${PYTHON_PATH}",
|
||||
"args": [
|
||||
"-m",
|
||||
"${MODULE_NAME}",
|
||||
"--config",
|
||||
"${CONFIG_PATH}"
|
||||
],
|
||||
"env": {
|
||||
"API_KEY": "${MY_API_KEY}",
|
||||
"DEBUG": "${DEBUG_MODE}",
|
||||
"DATABASE_URL": "${DATABASE_URL}"
|
||||
}
|
||||
},
|
||||
"example-openapi-server": {
|
||||
"type": "openapi",
|
||||
"openapi": {
|
||||
"url": "${OPENAPI_SPEC_URL}",
|
||||
"security": {
|
||||
"type": "apiKey",
|
||||
"apiKey": {
|
||||
"name": "X-API-Key",
|
||||
"in": "header",
|
||||
"value": "${OPENAPI_API_KEY}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"headers": {
|
||||
"User-Agent": "MCPHub/${VERSION}"
|
||||
}
|
||||
},
|
||||
"example-oauth-server": {
|
||||
"type": "sse",
|
||||
"url": "${OAUTH_SERVER_URL}",
|
||||
"oauth": {
|
||||
"clientId": "${OAUTH_CLIENT_ID}",
|
||||
"clientSecret": "${OAUTH_CLIENT_SECRET}",
|
||||
"accessToken": "${OAUTH_ACCESS_TOKEN}",
|
||||
"scopes": ["read", "write"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"users": [
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "${ADMIN_PASSWORD_HASH}",
|
||||
"isAdmin": true
|
||||
}
|
||||
],
|
||||
"systemConfig": {
|
||||
"install": {
|
||||
"pythonIndexUrl": "${PYTHON_INDEX_URL}",
|
||||
"npmRegistry": "${NPM_REGISTRY}"
|
||||
},
|
||||
"mcpRouter": {
|
||||
"apiKey": "${MCPROUTER_API_KEY}",
|
||||
"referer": "${MCPROUTER_REFERER}",
|
||||
"baseUrl": "${MCPROUTER_BASE_URL}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChangePasswordCredentials } from '../types';
|
||||
import { changePassword } from '../services/authService';
|
||||
import { validatePasswordStrength } from '../utils/passwordValidation';
|
||||
|
||||
interface ChangePasswordFormProps {
|
||||
onSuccess?: () => void;
|
||||
@@ -19,7 +18,6 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [passwordErrors, setPasswordErrors] = useState<string[]>([]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
@@ -27,12 +25,6 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
setConfirmPassword(value);
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
|
||||
// Validate password strength on change for new password
|
||||
if (name === 'newPassword') {
|
||||
const validation = validatePasswordStrength(value);
|
||||
setPasswordErrors(validation.errors);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -40,14 +32,6 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
// Validate password strength
|
||||
const validation = validatePasswordStrength(formData.newPassword);
|
||||
if (!validation.isValid) {
|
||||
setError(t('auth.passwordStrengthError'));
|
||||
setPasswordErrors(validation.errors);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate passwords match
|
||||
if (formData.newPassword !== confirmPassword) {
|
||||
setError(t('auth.passwordsNotMatch'));
|
||||
@@ -116,24 +100,8 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
value={formData.newPassword}
|
||||
onChange={handleChange}
|
||||
required
|
||||
minLength={8}
|
||||
minLength={6}
|
||||
/>
|
||||
{/* Password strength hints */}
|
||||
{formData.newPassword && passwordErrors.length > 0 && (
|
||||
<div className="mt-2 text-sm text-gray-600">
|
||||
<p className="font-semibold mb-1">{t('auth.passwordStrengthHint')}</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
{passwordErrors.map((errorKey) => (
|
||||
<li key={errorKey} className="text-red-600">
|
||||
{t(`auth.${errorKey}`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{formData.newPassword && passwordErrors.length === 0 && (
|
||||
<p className="mt-2 text-sm text-green-600">✓ {t('auth.passwordStrengthHint')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
@@ -148,7 +116,7 @@ const ChangePasswordForm: React.FC<ChangePasswordFormProps> = ({ onSuccess, onCa
|
||||
value={confirmPassword}
|
||||
onChange={handleChange}
|
||||
required
|
||||
minLength={8}
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,52 +1,51 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Server } from '@/types';
|
||||
import { apiPut } from '../utils/fetchInterceptor';
|
||||
import ServerForm from './ServerForm';
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Server } from '@/types'
|
||||
import { apiPut } from '../utils/fetchInterceptor'
|
||||
import ServerForm from './ServerForm'
|
||||
|
||||
interface EditServerFormProps {
|
||||
server: Server;
|
||||
onEdit: () => void;
|
||||
onCancel: () => void;
|
||||
server: Server
|
||||
onEdit: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const EditServerForm = ({ server, onEdit, onCancel }: EditServerFormProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { t } = useTranslation()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = async (payload: any) => {
|
||||
try {
|
||||
setError(null);
|
||||
const encodedServerName = encodeURIComponent(server.name);
|
||||
const result = await apiPut(`/servers/${encodedServerName}`, payload);
|
||||
setError(null)
|
||||
const result = await apiPut(`/servers/${server.name}`, payload)
|
||||
|
||||
if (!result.success) {
|
||||
// Use specific error message from the response if available
|
||||
if (result && result.message) {
|
||||
setError(result.message);
|
||||
setError(result.message)
|
||||
} else {
|
||||
setError(t('server.updateError', { serverName: server.name }));
|
||||
setError(t('server.updateError', { serverName: server.name }))
|
||||
}
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
onEdit();
|
||||
onEdit()
|
||||
} catch (err) {
|
||||
console.error('Error updating server:', err);
|
||||
console.error('Error updating server:', err)
|
||||
|
||||
// Use friendly error messages based on error type
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'));
|
||||
} else if (
|
||||
err instanceof TypeError &&
|
||||
(err.message.includes('NetworkError') || err.message.includes('Failed to fetch'))
|
||||
) {
|
||||
setError(t('errors.serverConnection'));
|
||||
setError(t('errors.network'))
|
||||
} else if (err instanceof TypeError && (
|
||||
err.message.includes('NetworkError') ||
|
||||
err.message.includes('Failed to fetch')
|
||||
)) {
|
||||
setError(t('errors.serverConnection'))
|
||||
} else {
|
||||
setError(t('errors.serverUpdate', { serverName: server.name }));
|
||||
setError(t('errors.serverUpdate', { serverName: server.name }))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
@@ -58,7 +57,7 @@ const EditServerForm = ({ server, onEdit, onCancel }: EditServerFormProps) => {
|
||||
formError={error}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default EditServerForm;
|
||||
export default EditServerForm
|
||||
@@ -1,311 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { apiPost } from '@/utils/fetchInterceptor';
|
||||
|
||||
interface JSONImportFormProps {
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
interface McpServerConfig {
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
type?: string;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ImportJsonFormat {
|
||||
mcpServers: Record<string, McpServerConfig>;
|
||||
}
|
||||
|
||||
const JSONImportForm: React.FC<JSONImportFormProps> = ({ onSuccess, onCancel }) => {
|
||||
const { t } = useTranslation();
|
||||
const [jsonInput, setJsonInput] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [previewServers, setPreviewServers] = useState<Array<{ name: string; config: any }> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const examplePlaceholder = `STDIO example:
|
||||
{
|
||||
"mcpServers": {
|
||||
"stdio-server-example": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "mcp-server-example"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SSE example:
|
||||
{
|
||||
"mcpServers": {
|
||||
"sse-server-example": {
|
||||
"type": "sse",
|
||||
"url": "http://localhost:3000"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HTTP example:
|
||||
{
|
||||
"mcpServers": {
|
||||
"http-server-example": {
|
||||
"type": "streamable-http",
|
||||
"url": "http://localhost:3001",
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer your-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const parseAndValidateJson = (input: string): ImportJsonFormat | null => {
|
||||
try {
|
||||
const parsed = JSON.parse(input.trim());
|
||||
|
||||
// Validate structure
|
||||
if (!parsed.mcpServers || typeof parsed.mcpServers !== 'object') {
|
||||
setError(t('jsonImport.invalidFormat'));
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed as ImportJsonFormat;
|
||||
} catch (e) {
|
||||
setError(t('jsonImport.parseError'));
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreview = () => {
|
||||
setError(null);
|
||||
const parsed = parseAndValidateJson(jsonInput);
|
||||
if (!parsed) return;
|
||||
|
||||
const servers = Object.entries(parsed.mcpServers).map(([name, config]) => {
|
||||
// Normalize config to MCPHub format
|
||||
const normalizedConfig: any = {};
|
||||
|
||||
if (config.type === 'sse' || config.type === 'streamable-http') {
|
||||
normalizedConfig.type = config.type;
|
||||
normalizedConfig.url = config.url;
|
||||
if (config.headers) {
|
||||
normalizedConfig.headers = config.headers;
|
||||
}
|
||||
} else {
|
||||
// Default to stdio
|
||||
normalizedConfig.type = 'stdio';
|
||||
normalizedConfig.command = config.command;
|
||||
normalizedConfig.args = config.args || [];
|
||||
if (config.env) {
|
||||
normalizedConfig.env = config.env;
|
||||
}
|
||||
}
|
||||
|
||||
return { name, config: normalizedConfig };
|
||||
});
|
||||
|
||||
setPreviewServers(servers);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!previewServers) return;
|
||||
|
||||
setIsImporting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let successCount = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const server of previewServers) {
|
||||
try {
|
||||
const result = await apiPost('/servers', {
|
||||
name: server.name,
|
||||
config: server.config,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
errors.push(`${server.name}: ${result.message || t('jsonImport.addFailed')}`);
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push(
|
||||
`${server.name}: ${err instanceof Error ? err.message : t('jsonImport.addFailed')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
setError(
|
||||
t('jsonImport.partialSuccess', { count: successCount, total: previewServers.length }) +
|
||||
'\n' +
|
||||
errors.join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Import error:', err);
|
||||
setError(t('jsonImport.importFailed'));
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white shadow rounded-lg p-6 w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900">{t('jsonImport.title')}</h2>
|
||||
<button onClick={onCancel} className="text-gray-500 hover:text-gray-700">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4 rounded">
|
||||
<p className="text-red-700 whitespace-pre-wrap">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!previewServers ? (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{t('jsonImport.inputLabel')}
|
||||
</label>
|
||||
<textarea
|
||||
value={jsonInput}
|
||||
onChange={(e) => setJsonInput(e.target.value)}
|
||||
className="w-full h-96 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"
|
||||
placeholder={examplePlaceholder}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-2">{t('jsonImport.inputHelp')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-4">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 btn-secondary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePreview}
|
||||
disabled={!jsonInput.trim()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{t('jsonImport.preview')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-3">
|
||||
{t('jsonImport.previewTitle')}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{previewServers.map((server, index) => (
|
||||
<div key={index} className="bg-gray-50 p-4 rounded-lg border border-gray-200">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-900">{server.name}</h4>
|
||||
<div className="mt-2 space-y-1 text-sm text-gray-600">
|
||||
<div>
|
||||
<strong>{t('server.type')}:</strong> {server.config.type || 'stdio'}
|
||||
</div>
|
||||
{server.config.command && (
|
||||
<div>
|
||||
<strong>{t('server.command')}:</strong> {server.config.command}
|
||||
</div>
|
||||
)}
|
||||
{server.config.args && server.config.args.length > 0 && (
|
||||
<div>
|
||||
<strong>{t('server.arguments')}:</strong>{' '}
|
||||
{server.config.args.join(' ')}
|
||||
</div>
|
||||
)}
|
||||
{server.config.url && (
|
||||
<div>
|
||||
<strong>{t('server.url')}:</strong> {server.config.url}
|
||||
</div>
|
||||
)}
|
||||
{server.config.env && Object.keys(server.config.env).length > 0 && (
|
||||
<div>
|
||||
<strong>{t('server.envVars')}:</strong>{' '}
|
||||
{Object.keys(server.config.env).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{server.config.headers &&
|
||||
Object.keys(server.config.headers).length > 0 && (
|
||||
<div>
|
||||
<strong>{t('server.headers')}:</strong>{' '}
|
||||
{Object.keys(server.config.headers).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-4">
|
||||
<button
|
||||
onClick={() => setPreviewServers(null)}
|
||||
disabled={isImporting}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-200 rounded hover:bg-gray-300 disabled:opacity-50 btn-secondary"
|
||||
>
|
||||
{t('common.back')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={isImporting}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center btn-primary"
|
||||
>
|
||||
{isImporting ? (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin h-4 w-4 mr-2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{t('jsonImport.importing')}
|
||||
</>
|
||||
) : (
|
||||
t('jsonImport.import')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JSONImportForm;
|
||||
@@ -19,7 +19,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
onBack,
|
||||
onInstall,
|
||||
installing = false,
|
||||
isInstalled = false,
|
||||
isInstalled = false
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
@@ -32,23 +32,21 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
const getButtonProps = () => {
|
||||
if (isInstalled) {
|
||||
return {
|
||||
className: 'bg-green-600 cursor-default px-4 py-2 rounded text-sm font-medium text-white',
|
||||
className: "bg-green-600 cursor-default px-4 py-2 rounded text-sm font-medium text-white",
|
||||
disabled: true,
|
||||
text: t('market.installed'),
|
||||
text: t('market.installed')
|
||||
};
|
||||
} else if (installing) {
|
||||
return {
|
||||
className:
|
||||
'bg-gray-400 cursor-not-allowed px-4 py-2 rounded text-sm font-medium text-white',
|
||||
className: "bg-gray-400 cursor-not-allowed px-4 py-2 rounded text-sm font-medium text-white",
|
||||
disabled: true,
|
||||
text: t('market.installing'),
|
||||
text: t('market.installing')
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
className:
|
||||
'bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm font-medium text-white btn-primary',
|
||||
className: "bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded text-sm font-medium text-white btn-primary",
|
||||
disabled: false,
|
||||
text: t('market.install'),
|
||||
text: t('market.install')
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -135,18 +133,12 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="mb-4">
|
||||
<button onClick={onBack} className="text-gray-600 hover:text-gray-900 flex items-center">
|
||||
<svg
|
||||
className="h-5 w-5 mr-1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-gray-600 hover:text-gray-900 flex items-center"
|
||||
>
|
||||
<svg className="h-5 w-5 mr-1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{t('market.backToList')}
|
||||
</button>
|
||||
@@ -158,8 +150,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
{server.display_name}
|
||||
<span className="text-sm font-normal text-gray-500 ml-2">({server.name})</span>
|
||||
<span className="text-sm font-normal text-gray-600 ml-4">
|
||||
{t('market.author')}: {server.author?.name || t('market.unknown')} •{' '}
|
||||
{t('market.license')}: {server.license} •
|
||||
{t('market.author')}: {server.author.name} • {t('market.license')}: {server.license} •
|
||||
<a
|
||||
href={server.repository.url}
|
||||
target="_blank"
|
||||
@@ -191,24 +182,18 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
<p className="text-gray-700 mb-6">{server.description}</p>
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold mb-3">
|
||||
{t('market.categories')} & {t('market.tags')}
|
||||
</h3>
|
||||
<h3 className="text-lg font-semibold mb-3">{t('market.categories')} & {t('market.tags')}</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{server.categories?.map((category, index) => (
|
||||
<span key={`cat-${index}`} className="bg-gray-100 text-gray-800 px-3 py-1 rounded">
|
||||
{category}
|
||||
</span>
|
||||
))}
|
||||
{server.tags &&
|
||||
server.tags.map((tag, index) => (
|
||||
<span
|
||||
key={`tag-${index}`}
|
||||
className="bg-gray-100 text-green-700 px-2 py-1 rounded text-sm"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
{server.tags && server.tags.map((tag, index) => (
|
||||
<span key={`tag-${index}`} className="bg-gray-100 text-green-700 px-2 py-1 rounded text-sm">
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -239,7 +224,9 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{name}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">{arg.description}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">
|
||||
{arg.description}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{arg.required ? (
|
||||
<span className="text-green-600">✓</span>
|
||||
@@ -281,10 +268,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
</h4>
|
||||
<p className="text-gray-600 mb-2">{tool.description}</p>
|
||||
<div className="mt-2">
|
||||
<pre
|
||||
id={`schema-${index}`}
|
||||
className="hidden bg-gray-50 p-3 rounded text-sm overflow-auto mt-2"
|
||||
>
|
||||
<pre id={`schema-${index}`} className="hidden bg-gray-50 p-3 rounded text-sm overflow-auto mt-2">
|
||||
{JSON.stringify(tool.inputSchema, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -301,7 +285,9 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
<div key={index} className="border border-gray-200 rounded p-4">
|
||||
<h4 className="font-medium mb-2">{example.title}</h4>
|
||||
<p className="text-gray-600 mb-2">{example.description}</p>
|
||||
<pre className="bg-gray-50 p-3 rounded text-sm overflow-auto">{example.prompt}</pre>
|
||||
<pre className="bg-gray-50 p-3 rounded text-sm overflow-auto">
|
||||
{example.prompt}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -330,11 +316,11 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
status: 'disconnected',
|
||||
config: preferredInstallation
|
||||
? {
|
||||
command: preferredInstallation.command || '',
|
||||
args: preferredInstallation.args || [],
|
||||
env: preferredInstallation.env || {},
|
||||
}
|
||||
: undefined,
|
||||
command: preferredInstallation.command || '',
|
||||
args: preferredInstallation.args || [],
|
||||
env: preferredInstallation.env || {}
|
||||
}
|
||||
: undefined
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -346,16 +332,14 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
{t('server.confirmVariables')}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">{t('server.variablesDetected')}</p>
|
||||
<p className="text-gray-600 mb-4">
|
||||
{t('server.variablesDetected')}
|
||||
</p>
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 mb-4">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
@@ -372,12 +356,14 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm mb-6">{t('market.confirmVariablesMessage')}</p>
|
||||
<p className="text-gray-600 text-sm mb-6">
|
||||
{t('market.confirmVariablesMessage')}
|
||||
</p>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setConfirmationVisible(false);
|
||||
setPendingPayload(null);
|
||||
setConfirmationVisible(false)
|
||||
setPendingPayload(null)
|
||||
}}
|
||||
className="px-4 py-2 text-gray-600 border border-gray-300 rounded hover:bg-gray-50 btn-secondary"
|
||||
>
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RegistryServerEntry } from '@/types';
|
||||
|
||||
interface RegistryServerCardProps {
|
||||
serverEntry: RegistryServerEntry;
|
||||
onClick: (serverEntry: RegistryServerEntry) => void;
|
||||
}
|
||||
|
||||
const RegistryServerCard: React.FC<RegistryServerCardProps> = ({ serverEntry, onClick }) => {
|
||||
const { t } = useTranslation();
|
||||
const { server, _meta } = serverEntry;
|
||||
|
||||
const handleClick = () => {
|
||||
onClick(serverEntry);
|
||||
};
|
||||
|
||||
// Get display description
|
||||
const getDisplayDescription = () => {
|
||||
if (server.description && server.description.length <= 150) {
|
||||
return server.description;
|
||||
}
|
||||
return server.description
|
||||
? server.description.slice(0, 150) + '...'
|
||||
: t('registry.noDescription');
|
||||
};
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return '';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
const year = date.getFullYear();
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
return `${year}/${month}/${day}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Get icon to display
|
||||
const getIcon = () => {
|
||||
if (server.icons && server.icons.length > 0) {
|
||||
// Prefer light theme icon
|
||||
const lightIcon = server.icons.find((icon) => !icon.theme || icon.theme === 'light');
|
||||
return lightIcon || server.icons[0];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const icon = getIcon();
|
||||
const officialMeta = _meta?.['io.modelcontextprotocol.registry/official'];
|
||||
const isLatest = officialMeta?.isLatest;
|
||||
const publishedAt = officialMeta?.publishedAt;
|
||||
const updatedAt = officialMeta?.updatedAt;
|
||||
|
||||
// Count packages and remotes
|
||||
const packageCount = server.packages?.length || 0;
|
||||
const remoteCount = server.remotes?.length || 0;
|
||||
const totalOptions = packageCount + remoteCount;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-white border border-gray-200 rounded-xl p-4 hover:shadow-lg hover:border-blue-400 hover:-translate-y-1 transition-all duration-300 cursor-pointer group relative overflow-hidden h-full flex flex-col"
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Background gradient overlay on hover */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-50/0 to-purple-50/0 group-hover:from-blue-50/30 group-hover:to-purple-50/30 transition-all duration-300 pointer-events-none" />
|
||||
|
||||
{/* Server Header */}
|
||||
<div className="relative z-10 flex-1 flex flex-col">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-start space-x-3 flex-1">
|
||||
{/* Icon */}
|
||||
{icon ? (
|
||||
<img
|
||||
src={icon.src}
|
||||
alt={server.title}
|
||||
className="w-12 h-12 rounded-lg object-cover flex-shrink-0"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center text-white text-xl font-semibold flex-shrink-0">
|
||||
M
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title and badges */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-bold text-gray-900 group-hover:text-blue-600 transition-colors duration-200 mb-1 line-clamp-2">
|
||||
{server.name}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1 mb-1">
|
||||
{isLatest && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
{t('registry.latest')}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
v{server.version}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Server Name */}
|
||||
{/* <div className="mb-2">
|
||||
<p className="text-xs text-gray-500 font-mono">{server.name}</p>
|
||||
</div> */}
|
||||
|
||||
{/* Description */}
|
||||
<div className="mb-3 flex-1">
|
||||
<p className="text-gray-600 text-sm leading-relaxed line-clamp-3">
|
||||
{getDisplayDescription()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Installation Options Info */}
|
||||
{totalOptions > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center space-x-4">
|
||||
{packageCount > 0 && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<svg
|
||||
className="w-4 h-4 text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-gray-600">
|
||||
{packageCount}{' '}
|
||||
{packageCount === 1 ? t('registry.package') : t('registry.packages')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{remoteCount > 0 && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<svg
|
||||
className="w-4 h-4 text-gray-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-gray-600">
|
||||
{remoteCount} {remoteCount === 1 ? t('registry.remote') : t('registry.remotes')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer - fixed at bottom */}
|
||||
<div className="flex items-center justify-between pt-3 border-t border-gray-100 mt-auto">
|
||||
<div className="flex items-center space-x-2 text-xs text-gray-500">
|
||||
{(publishedAt || updatedAt) && (
|
||||
<>
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>{formatDate(updatedAt || publishedAt)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-blue-600 text-sm font-medium group-hover:text-blue-700 transition-colors">
|
||||
<span>{t('registry.viewDetails')}</span>
|
||||
<svg
|
||||
className="w-4 h-4 ml-1 transform group-hover:translate-x-1 transition-transform"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegistryServerCard;
|
||||
@@ -1,698 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
RegistryServerEntry,
|
||||
RegistryPackage,
|
||||
RegistryRemote,
|
||||
RegistryServerData,
|
||||
ServerConfig,
|
||||
} from '@/types';
|
||||
import ServerForm from './ServerForm';
|
||||
|
||||
interface RegistryServerDetailProps {
|
||||
serverEntry: RegistryServerEntry;
|
||||
onBack: () => void;
|
||||
onInstall?: (server: RegistryServerData, config: ServerConfig) => void;
|
||||
installing?: boolean;
|
||||
isInstalled?: boolean;
|
||||
fetchVersions?: (serverName: string) => Promise<RegistryServerEntry[]>;
|
||||
}
|
||||
|
||||
const RegistryServerDetail: React.FC<RegistryServerDetailProps> = ({
|
||||
serverEntry,
|
||||
onBack,
|
||||
onInstall,
|
||||
installing = false,
|
||||
isInstalled = false,
|
||||
fetchVersions,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { server, _meta } = serverEntry;
|
||||
|
||||
const [_selectedVersion, _setSelectedVersion] = useState<string>(server.version);
|
||||
const [_availableVersions, setAvailableVersions] = useState<RegistryServerEntry[]>([]);
|
||||
const [_loadingVersions, setLoadingVersions] = useState(false);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [selectedInstallType, setSelectedInstallType] = useState<'package' | 'remote' | null>(null);
|
||||
const [selectedOption, setSelectedOption] = useState<RegistryPackage | RegistryRemote | null>(
|
||||
null,
|
||||
);
|
||||
const [installError, setInstallError] = useState<string | null>(null);
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
|
||||
packages: true,
|
||||
remotes: true,
|
||||
repository: true,
|
||||
});
|
||||
|
||||
const officialMeta = _meta?.['io.modelcontextprotocol.registry/official'];
|
||||
|
||||
// Load available versions
|
||||
useEffect(() => {
|
||||
const loadVersions = async () => {
|
||||
if (fetchVersions) {
|
||||
setLoadingVersions(true);
|
||||
try {
|
||||
const versions = await fetchVersions(server.name);
|
||||
setAvailableVersions(versions);
|
||||
} catch (error) {
|
||||
console.error('Failed to load versions:', error);
|
||||
} finally {
|
||||
setLoadingVersions(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadVersions();
|
||||
}, [server.name, fetchVersions]);
|
||||
|
||||
// Get icon to display
|
||||
const getIcon = () => {
|
||||
if (server.icons && server.icons.length > 0) {
|
||||
const lightIcon = server.icons.find((icon) => !icon.theme || icon.theme === 'light');
|
||||
return lightIcon || server.icons[0];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const icon = getIcon();
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return '';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle section expansion
|
||||
const toggleSection = (section: string) => {
|
||||
setExpandedSections((prev) => ({ ...prev, [section]: !prev[section] }));
|
||||
};
|
||||
|
||||
// Handle install button click
|
||||
const handleInstallClick = (
|
||||
type: 'package' | 'remote',
|
||||
option: RegistryPackage | RegistryRemote,
|
||||
) => {
|
||||
setSelectedInstallType(type);
|
||||
setSelectedOption(option);
|
||||
setInstallError(null);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
// Handle modal close
|
||||
const handleModalClose = () => {
|
||||
setModalVisible(false);
|
||||
setInstallError(null);
|
||||
};
|
||||
|
||||
// Handle install submission
|
||||
const handleInstallSubmit = async (payload: any) => {
|
||||
try {
|
||||
if (!onInstall || !selectedOption || !selectedInstallType) return;
|
||||
|
||||
setInstallError(null);
|
||||
|
||||
// Extract the ServerConfig from the payload
|
||||
const config: ServerConfig = payload.config;
|
||||
|
||||
// Call onInstall with server data and config
|
||||
onInstall(server, config);
|
||||
setModalVisible(false);
|
||||
} catch (err) {
|
||||
console.error('Error installing server:', err);
|
||||
setInstallError(t('errors.serverInstall'));
|
||||
}
|
||||
};
|
||||
|
||||
// Build initial data for ServerForm
|
||||
const getInitialFormData = () => {
|
||||
if (!selectedOption || !selectedInstallType) return null;
|
||||
console.log('Building initial form data for:', selectedOption);
|
||||
|
||||
if (selectedInstallType === 'package' && 'identifier' in selectedOption) {
|
||||
const pkg = selectedOption as RegistryPackage;
|
||||
|
||||
// Build environment variables from package definition
|
||||
const env: Record<string, string> = {};
|
||||
if (pkg.environmentVariables) {
|
||||
pkg.environmentVariables.forEach((envVar) => {
|
||||
env[envVar.name] = envVar.default || '';
|
||||
});
|
||||
}
|
||||
|
||||
const command = getCommand(pkg.registryType);
|
||||
return {
|
||||
name: server.name,
|
||||
status: 'disconnected' as const,
|
||||
config: {
|
||||
type: 'stdio' as const,
|
||||
command: command,
|
||||
args: getArgs(command, pkg),
|
||||
env: Object.keys(env).length > 0 ? env : undefined,
|
||||
},
|
||||
};
|
||||
} else if (selectedInstallType === 'remote' && 'url' in selectedOption) {
|
||||
const remote = selectedOption as RegistryRemote;
|
||||
|
||||
// Build headers from remote definition
|
||||
const headers: Record<string, string> = {};
|
||||
if (remote.headers) {
|
||||
remote.headers.forEach((header) => {
|
||||
headers[header.name] = header.default || header.value || '';
|
||||
});
|
||||
}
|
||||
|
||||
// Determine transport type - default to streamable-http for remotes
|
||||
const transportType = remote.type === 'sse' ? ('sse' as const) : ('streamable-http' as const);
|
||||
|
||||
return {
|
||||
name: server.name,
|
||||
status: 'disconnected' as const,
|
||||
config: {
|
||||
type: transportType,
|
||||
url: remote.url,
|
||||
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Render package option
|
||||
const renderPackage = (pkg: RegistryPackage, index: number) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="border border-gray-200 rounded-lg p-4 mb-3 hover:border-blue-400 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-900">{pkg.identifier}</h4>
|
||||
{pkg.version && <p className="text-sm text-gray-500">Version: {pkg.version}</p>}
|
||||
{pkg.runtimeHint && <p className="text-sm text-gray-600 mt-1">{pkg.runtimeHint}</p>}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleInstallClick('package', pkg)}
|
||||
disabled={isInstalled || installing}
|
||||
className={`px-4 py-2 rounded text-sm font-medium transition-colors ${
|
||||
isInstalled
|
||||
? 'bg-green-600 text-white cursor-default'
|
||||
: installing
|
||||
? 'bg-gray-400 text-white cursor-not-allowed'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{isInstalled
|
||||
? t('registry.installed')
|
||||
: installing
|
||||
? t('registry.installing')
|
||||
: t('registry.install')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Package details */}
|
||||
{pkg.registryType && (
|
||||
<div className="text-sm text-gray-600 mb-2">
|
||||
<span className="font-medium">Registry:</span> {pkg.registryType}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transport type */}
|
||||
{pkg.transport && (
|
||||
<div className="text-sm text-gray-600 mb-2">
|
||||
<span className="font-medium">Transport:</span> {pkg.transport.type}
|
||||
{pkg.transport.url && <span className="ml-2 text-gray-500">({pkg.transport.url})</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Environment Variables */}
|
||||
{pkg.environmentVariables && pkg.environmentVariables.length > 0 && (
|
||||
<div className="mt-3 border-t border-gray-200 pt-3">
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-2">
|
||||
{t('registry.environmentVariables')}:
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
{pkg.environmentVariables.map((envVar, envIndex) => (
|
||||
<div key={envIndex} className="text-sm">
|
||||
<div className="flex items-start">
|
||||
<span className="font-mono text-gray-900 font-medium">{envVar.name}</span>
|
||||
{envVar.isRequired && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
|
||||
{t('common.required')}
|
||||
</span>
|
||||
)}
|
||||
{envVar.isSecret && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
{t('common.secret')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{envVar.description && <p className="text-gray-600 mt-1">{envVar.description}</p>}
|
||||
{envVar.default && (
|
||||
<p className="text-gray-500 mt-1">
|
||||
<span className="font-medium">{t('common.default')}:</span>{' '}
|
||||
<span className="font-mono">{envVar.default}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Package Arguments */}
|
||||
{pkg.packageArguments && pkg.packageArguments.length > 0 && (
|
||||
<div className="mt-3 border-t border-gray-200 pt-3">
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-2">
|
||||
{t('registry.packageArguments')}:
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
{pkg.packageArguments.map((arg, argIndex) => (
|
||||
<div key={argIndex} className="text-sm">
|
||||
<div className="flex items-start">
|
||||
<span className="font-mono text-gray-900 font-medium">{arg.name}</span>
|
||||
{arg.isRequired && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
|
||||
{t('common.required')}
|
||||
</span>
|
||||
)}
|
||||
{arg.isSecret && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
{t('common.secret')}
|
||||
</span>
|
||||
)}
|
||||
{arg.isRepeated && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800">
|
||||
{t('common.repeated')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{arg.description && <p className="text-gray-600 mt-1">{arg.description}</p>}
|
||||
{arg.type && (
|
||||
<p className="text-gray-500 mt-1">
|
||||
<span className="font-medium">{t('common.type')}:</span>{' '}
|
||||
<span className="font-mono">{arg.type}</span>
|
||||
</p>
|
||||
)}
|
||||
{arg.default && (
|
||||
<p className="text-gray-500 mt-1">
|
||||
<span className="font-medium">{t('common.default')}:</span>{' '}
|
||||
<span className="font-mono">{arg.default}</span>
|
||||
</p>
|
||||
)}
|
||||
{arg.value && (
|
||||
<p className="text-gray-500 mt-1">
|
||||
<span className="font-medium">{t('common.value')}:</span>{' '}
|
||||
<span className="font-mono">{arg.value}</span>
|
||||
</p>
|
||||
)}
|
||||
{arg.valueHint && (
|
||||
<p className="text-gray-500 mt-1">
|
||||
<span className="font-medium">{t('common.valueHint')}:</span>{' '}
|
||||
<span className="font-mono">{arg.valueHint}</span>
|
||||
</p>
|
||||
)}
|
||||
{arg.choices && arg.choices.length > 0 && (
|
||||
<p className="text-gray-500 mt-1">
|
||||
<span className="font-medium">{t('common.choices')}:</span>{' '}
|
||||
<span className="font-mono">{arg.choices.join(', ')}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render remote option
|
||||
const renderRemote = (remote: RegistryRemote, index: number) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="border border-gray-200 rounded-lg p-4 mb-3 hover:border-blue-400 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-900">{remote.type}</h4>
|
||||
<p className="text-sm text-gray-600 mt-1 break-all">{remote.url}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleInstallClick('remote', remote)}
|
||||
disabled={isInstalled || installing}
|
||||
className={`px-4 py-2 rounded text-sm font-medium transition-colors ${
|
||||
isInstalled
|
||||
? 'bg-green-600 text-white cursor-default'
|
||||
: installing
|
||||
? 'bg-gray-400 text-white cursor-not-allowed'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{isInstalled
|
||||
? t('registry.installed')
|
||||
: installing
|
||||
? t('registry.installing')
|
||||
: t('registry.install')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Headers */}
|
||||
{remote.headers && remote.headers.length > 0 && (
|
||||
<div className="mt-3 border-t border-gray-200 pt-3">
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-2">{t('registry.headers')}:</h5>
|
||||
<div className="space-y-2">
|
||||
{remote.headers.map((header, headerIndex) => (
|
||||
<div key={headerIndex} className="text-sm">
|
||||
<div className="flex items-start">
|
||||
<span className="font-mono text-gray-900 font-medium">{header.name}</span>
|
||||
{header.isRequired && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
|
||||
{t('common.required')}
|
||||
</span>
|
||||
)}
|
||||
{header.isSecret && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
{t('common.secret')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{header.description && <p className="text-gray-600 mt-1">{header.description}</p>}
|
||||
{header.value && (
|
||||
<p className="text-gray-500 mt-1">
|
||||
<span className="font-medium">{t('common.value')}:</span>{' '}
|
||||
<span className="font-mono">{header.value}</span>
|
||||
</p>
|
||||
)}
|
||||
{header.default && (
|
||||
<p className="text-gray-500 mt-1">
|
||||
<span className="font-medium">{t('common.default')}:</span>{' '}
|
||||
<span className="font-mono">{header.default}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center text-blue-600 hover:text-blue-800 mb-4 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
{t('registry.backToList')}
|
||||
</button>
|
||||
|
||||
<div className="flex items-start space-x-4">
|
||||
{/* Icon */}
|
||||
{icon ? (
|
||||
<img
|
||||
src={icon.src}
|
||||
alt={server.title}
|
||||
className="w-20 h-20 rounded-lg object-cover flex-shrink-0"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-20 h-20 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center text-white text-3xl font-semibold flex-shrink-0">
|
||||
M
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title and metadata */}
|
||||
<div className="flex-1">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">{server.name}</h1>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{officialMeta?.isLatest && (
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800">
|
||||
{t('registry.latest')}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800">
|
||||
v{server.version}
|
||||
</span>
|
||||
{officialMeta?.status && (
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-800">
|
||||
{officialMeta.status}
|
||||
</span>
|
||||
)}
|
||||
{/* Dates */}
|
||||
<span className="flex flex-wrap items-center gap-4 text-sm text-gray-600">
|
||||
{officialMeta?.publishedAt && (
|
||||
<div>
|
||||
<span className="font-medium">{t('registry.published')}:</span>{' '}
|
||||
{formatDate(officialMeta.publishedAt)}
|
||||
</div>
|
||||
)}
|
||||
{officialMeta?.updatedAt && (
|
||||
<div>
|
||||
<span className="font-medium">{t('registry.updated')}:</span>{' '}
|
||||
{formatDate(officialMeta.updatedAt)}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-3">{t('registry.description')}</h2>
|
||||
<p className="text-gray-700 leading-relaxed whitespace-pre-wrap">{server.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Website */}
|
||||
{server.websiteUrl && (
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-3">{t('registry.website')}</h2>
|
||||
<a
|
||||
href={server.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 hover:underline"
|
||||
>
|
||||
{server.websiteUrl}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Packages */}
|
||||
{server.packages && server.packages.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => toggleSection('packages')}
|
||||
className="flex items-center justify-between w-full text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
<span>
|
||||
{t('registry.packages')} ({server.packages.length})
|
||||
</span>
|
||||
<svg
|
||||
className={`w-5 h-5 transform transition-transform ${expandedSections.packages ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{expandedSections.packages && (
|
||||
<div className="space-y-3">{server.packages.map(renderPackage)}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remotes */}
|
||||
{server.remotes && server.remotes.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => toggleSection('remotes')}
|
||||
className="flex items-center justify-between w-full text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
<span>
|
||||
{t('registry.remotes')} ({server.remotes.length})
|
||||
</span>
|
||||
<svg
|
||||
className={`w-5 h-5 transform transition-transform ${expandedSections.remotes ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{expandedSections.remotes && (
|
||||
<div className="space-y-3">{server.remotes.map(renderRemote)}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Repository */}
|
||||
{server.repository && (
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => toggleSection('repository')}
|
||||
className="flex items-center justify-between w-full text-xl font-semibold text-gray-900 mb-3 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
<span>{t('registry.repository')}</span>
|
||||
<svg
|
||||
className={`w-5 h-5 transform transition-transform ${expandedSections.repository ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{expandedSections.repository && (
|
||||
<div className="border border-gray-200 rounded-lg p-4">
|
||||
{server.repository.url && (
|
||||
<div className="mb-2">
|
||||
<span className="font-medium text-gray-700">URL:</span>{' '}
|
||||
<a
|
||||
href={server.repository.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800 hover:underline break-all"
|
||||
>
|
||||
{server.repository.url}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{server.repository.source && (
|
||||
<div className="mb-2">
|
||||
<span className="font-medium text-gray-700">Source:</span>{' '}
|
||||
{server.repository.source}
|
||||
</div>
|
||||
)}
|
||||
{server.repository.subfolder && (
|
||||
<div className="mb-2">
|
||||
<span className="font-medium text-gray-700">Subfolder:</span>{' '}
|
||||
{server.repository.subfolder}
|
||||
</div>
|
||||
)}
|
||||
{server.repository.id && (
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">ID:</span> {server.repository.id}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Install Modal */}
|
||||
{modalVisible && selectedOption && selectedInstallType && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<ServerForm
|
||||
onSubmit={handleInstallSubmit}
|
||||
onCancel={handleModalClose}
|
||||
modalTitle={t('registry.installServer', { name: server.title || server.name })}
|
||||
formError={installError}
|
||||
initialData={getInitialFormData()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegistryServerDetail;
|
||||
// Helper function to determine command based on registry type
|
||||
function getCommand(registryType: string): string {
|
||||
// Map registry types to appropriate commands
|
||||
switch (registryType.toLowerCase()) {
|
||||
case 'pypi':
|
||||
case 'python':
|
||||
return 'uvx';
|
||||
case 'npm':
|
||||
case 'node':
|
||||
return 'npx';
|
||||
case 'oci':
|
||||
case 'docker':
|
||||
return 'docker';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get appropriate args based on command type and package identifier
|
||||
function getArgs(command: string, pkg: RegistryPackage): string[] {
|
||||
const identifier = [pkg.identifier + (pkg.version ? `@${pkg.version}` : '')];
|
||||
|
||||
// Build package arguments if available
|
||||
const packageArgs: string[] = [];
|
||||
if (pkg.packageArguments && pkg.packageArguments.length > 0) {
|
||||
pkg.packageArguments.forEach((arg) => {
|
||||
// Add required arguments or arguments with default values
|
||||
if (arg.isRequired || arg.default || arg.value) {
|
||||
const argName = `--${arg.name}`;
|
||||
// Priority: value > default > placeholder
|
||||
const argValue = arg.value || arg.default || `\${${arg.name.toUpperCase()}}`;
|
||||
packageArgs.push(argName, argValue);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Map commands to appropriate argument patterns
|
||||
switch (command.toLowerCase()) {
|
||||
case 'uvx':
|
||||
// For Python packages: uvx package-name --arg1 value1 --arg2 value2
|
||||
return [...identifier, ...packageArgs];
|
||||
case 'npx':
|
||||
// For Node.js packages: npx package-name --arg1 value1 --arg2 value2
|
||||
return [...identifier, ...packageArgs];
|
||||
case 'docker': {
|
||||
// add envs from environment variables if available
|
||||
const envs: string[] = [];
|
||||
if (pkg.environmentVariables) {
|
||||
pkg.environmentVariables.forEach((env) => {
|
||||
envs.push('-e', `${env.name}`);
|
||||
});
|
||||
}
|
||||
// For Docker images: docker run -i package-name --arg1 value1 --arg2 value2
|
||||
return ['run', '-i', '--rm', ...envs, ...identifier, ...packageArgs];
|
||||
}
|
||||
default:
|
||||
// If no specific pattern is defined, return identifier with package args
|
||||
return [...identifier, ...packageArgs];
|
||||
}
|
||||
}
|
||||
@@ -1,237 +1,170 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Server } from '@/types';
|
||||
import { ChevronDown, ChevronRight, AlertCircle, Copy, Check } from 'lucide-react';
|
||||
import { StatusBadge } from '@/components/ui/Badge';
|
||||
import ToolCard from '@/components/ui/ToolCard';
|
||||
import PromptCard from '@/components/ui/PromptCard';
|
||||
import DeleteDialog from '@/components/ui/DeleteDialog';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import { useSettingsData } from '@/hooks/useSettingsData';
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Server } from '@/types'
|
||||
import { ChevronDown, ChevronRight, AlertCircle, Copy, Check } from 'lucide-react'
|
||||
import { StatusBadge } from '@/components/ui/Badge'
|
||||
import ToolCard from '@/components/ui/ToolCard'
|
||||
import PromptCard from '@/components/ui/PromptCard'
|
||||
import DeleteDialog from '@/components/ui/DeleteDialog'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
|
||||
interface ServerCardProps {
|
||||
server: Server;
|
||||
onRemove: (serverName: string) => void;
|
||||
onEdit: (server: Server) => void;
|
||||
onToggle?: (server: Server, enabled: boolean) => Promise<boolean>;
|
||||
onRefresh?: () => void;
|
||||
server: Server
|
||||
onRemove: (serverName: string) => void
|
||||
onEdit: (server: Server) => void
|
||||
onToggle?: (server: Server, enabled: boolean) => Promise<boolean>
|
||||
onRefresh?: () => void
|
||||
}
|
||||
|
||||
const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [isToggling, setIsToggling] = useState(false);
|
||||
const [showErrorPopover, setShowErrorPopover] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const errorPopoverRef = useRef<HTMLDivElement>(null);
|
||||
const { t } = useTranslation()
|
||||
const { showToast } = useToast()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isToggling, setIsToggling] = useState(false)
|
||||
const [showErrorPopover, setShowErrorPopover] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const errorPopoverRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (errorPopoverRef.current && !errorPopoverRef.current.contains(event.target as Node)) {
|
||||
setShowErrorPopover(false);
|
||||
setShowErrorPopover(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { exportMCPSettings } = useSettingsData();
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleRemove = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setShowDeleteDialog(true);
|
||||
};
|
||||
e.stopPropagation()
|
||||
setShowDeleteDialog(true)
|
||||
}
|
||||
|
||||
const handleEdit = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onEdit(server);
|
||||
};
|
||||
e.stopPropagation()
|
||||
onEdit(server)
|
||||
}
|
||||
|
||||
const handleToggle = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (isToggling || !onToggle) return;
|
||||
e.stopPropagation()
|
||||
if (isToggling || !onToggle) return
|
||||
|
||||
setIsToggling(true);
|
||||
setIsToggling(true)
|
||||
try {
|
||||
await onToggle(server, !(server.enabled !== false));
|
||||
await onToggle(server, !(server.enabled !== false))
|
||||
} finally {
|
||||
setIsToggling(false);
|
||||
setIsToggling(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleErrorIconClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setShowErrorPopover(!showErrorPopover);
|
||||
};
|
||||
e.stopPropagation()
|
||||
setShowErrorPopover(!showErrorPopover)
|
||||
}
|
||||
|
||||
const copyToClipboard = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!server.error) return;
|
||||
e.stopPropagation()
|
||||
if (!server.error) return
|
||||
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(server.error).then(() => {
|
||||
setCopied(true);
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
setCopied(true)
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
} else {
|
||||
// Fallback for HTTP or unsupported clipboard API
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = server.error;
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = server.error
|
||||
// Avoid scrolling to bottom
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-9999px'
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
setCopied(true);
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
document.execCommand('copy')
|
||||
setCopied(true)
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (err) {
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error');
|
||||
console.error('Copy to clipboard failed:', err);
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error')
|
||||
console.error('Copy to clipboard failed:', err)
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyServerConfig = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
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);
|
||||
};
|
||||
onRemove(server.name)
|
||||
setShowDeleteDialog(false)
|
||||
}
|
||||
|
||||
const handleToolToggle = async (toolName: string, enabled: boolean) => {
|
||||
try {
|
||||
const { toggleTool } = await import('@/services/toolService');
|
||||
const result = await toggleTool(server.name, toolName, enabled);
|
||||
const { toggleTool } = await import('@/services/toolService')
|
||||
const result = await toggleTool(server.name, toolName, enabled)
|
||||
if (result.success) {
|
||||
showToast(
|
||||
t(enabled ? 'tool.enableSuccess' : 'tool.disableSuccess', { name: toolName }),
|
||||
'success',
|
||||
);
|
||||
'success'
|
||||
)
|
||||
// Trigger refresh to update the tool's state in the UI
|
||||
if (onRefresh) {
|
||||
onRefresh();
|
||||
onRefresh()
|
||||
}
|
||||
} else {
|
||||
showToast(result.error || t('tool.toggleFailed'), 'error');
|
||||
showToast(result.error || t('tool.toggleFailed'), 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling tool:', error);
|
||||
showToast(t('tool.toggleFailed'), 'error');
|
||||
console.error('Error toggling tool:', error)
|
||||
showToast(t('tool.toggleFailed'), 'error')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handlePromptToggle = async (promptName: string, enabled: boolean) => {
|
||||
try {
|
||||
const { togglePrompt } = await import('@/services/promptService');
|
||||
const result = await togglePrompt(server.name, promptName, enabled);
|
||||
const { togglePrompt } = await import('@/services/promptService')
|
||||
const result = await togglePrompt(server.name, promptName, enabled)
|
||||
if (result.success) {
|
||||
showToast(
|
||||
t(enabled ? 'tool.enableSuccess' : 'tool.disableSuccess', { name: promptName }),
|
||||
'success',
|
||||
);
|
||||
'success'
|
||||
)
|
||||
// Trigger refresh to update the prompt's state in the UI
|
||||
if (onRefresh) {
|
||||
onRefresh();
|
||||
onRefresh()
|
||||
}
|
||||
} else {
|
||||
showToast(result.error || t('tool.toggleFailed'), 'error');
|
||||
showToast(result.error || t('tool.toggleFailed'), 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling prompt:', error);
|
||||
showToast(t('tool.toggleFailed'), 'error');
|
||||
console.error('Error toggling prompt:', error)
|
||||
showToast(t('tool.toggleFailed'), 'error')
|
||||
}
|
||||
};
|
||||
|
||||
const handleOAuthAuthorization = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
// Open the OAuth authorization URL in a new window
|
||||
if (server.oauth?.authorizationUrl) {
|
||||
const width = 600;
|
||||
const height = 700;
|
||||
const left = window.screen.width / 2 - width / 2;
|
||||
const top = window.screen.height / 2 - height / 2;
|
||||
|
||||
window.open(
|
||||
server.oauth.authorizationUrl,
|
||||
'OAuth Authorization',
|
||||
`width=${width},height=${height},left=${left},top=${top}`,
|
||||
);
|
||||
|
||||
showToast(t('status.oauthWindowOpened'), 'info');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
<StatusBadge status={server.status} onAuthClick={handleOAuthAuthorization} />
|
||||
<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 */}
|
||||
@@ -240,9 +173,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
<path d="M2 5a2 2 0 012-2h7a2 2 0 012 2v4a2 2 0 01-2 2H9l-3 3v-3H4a2 2 0 01-2-2V5z" />
|
||||
<path d="M15 7v2a4 4 0 01-4 4H9.828l-1.766 1.767c.28.149.599.233.938.233h2l3 3v-3h2a2 2 0 002-2V9a2 2 0 00-2-2h-1z" />
|
||||
</svg>
|
||||
<span>
|
||||
{server.prompts?.length || 0} {t('server.prompts')}
|
||||
</span>
|
||||
<span>{server.prompts?.length || 0} {t('server.prompts')}</span>
|
||||
</div>
|
||||
|
||||
{server.error && (
|
||||
@@ -265,31 +196,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
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowErrorPopover(false);
|
||||
e.stopPropagation()
|
||||
setShowErrorPopover(false)
|
||||
}}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
@@ -297,9 +222,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 pt-2">
|
||||
<pre className="text-sm text-gray-700 break-words whitespace-pre-wrap">
|
||||
{server.error}
|
||||
</pre>
|
||||
<pre className="text-sm text-gray-700 break-words whitespace-pre-wrap">{server.error}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -307,9 +230,6 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
)}
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button onClick={handleCopyServerConfig} className={`px-3 py-1 btn-secondary`}>
|
||||
{t('server.copy')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
className="px-3 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm btn-primary"
|
||||
@@ -319,20 +239,20 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className={`px-3 py-1 text-sm rounded transition-colors ${
|
||||
isToggling
|
||||
? 'bg-gray-200 text-gray-500'
|
||||
: server.enabled !== false
|
||||
? 'bg-green-100 text-green-800 hover:bg-green-200 btn-secondary'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-primary'
|
||||
}`}
|
||||
className={`px-3 py-1 text-sm rounded transition-colors ${isToggling
|
||||
? 'bg-gray-200 text-gray-500'
|
||||
: server.enabled !== false
|
||||
? 'bg-green-100 text-green-800 hover:bg-green-200 btn-secondary'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-primary'
|
||||
}`}
|
||||
disabled={isToggling}
|
||||
>
|
||||
{isToggling
|
||||
? t('common.processing')
|
||||
: server.enabled !== false
|
||||
? t('server.disable')
|
||||
: t('server.enable')}
|
||||
: t('server.enable')
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@@ -351,19 +271,10 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
<>
|
||||
{server.tools && (
|
||||
<div className="mt-6">
|
||||
<h6
|
||||
className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}
|
||||
>
|
||||
{t('server.tools')}
|
||||
</h6>
|
||||
<h6 className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}>{t('server.tools')}</h6>
|
||||
<div className="space-y-4">
|
||||
{server.tools.map((tool, index) => (
|
||||
<ToolCard
|
||||
key={index}
|
||||
server={server.name}
|
||||
tool={tool}
|
||||
onToggle={handleToolToggle}
|
||||
/>
|
||||
<ToolCard key={index} server={server.name} tool={tool} onToggle={handleToolToggle} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -371,18 +282,14 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
|
||||
{server.prompts && (
|
||||
<div className="mt-6">
|
||||
<h6
|
||||
className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}
|
||||
>
|
||||
{t('server.prompts')}
|
||||
</h6>
|
||||
<h6 className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}>{t('server.prompts')}</h6>
|
||||
<div className="space-y-4">
|
||||
{server.prompts.map((prompt, index) => (
|
||||
<PromptCard
|
||||
key={index}
|
||||
server={server.name}
|
||||
prompt={prompt}
|
||||
onToggle={handlePromptToggle}
|
||||
<PromptCard
|
||||
key={index}
|
||||
server={server.name}
|
||||
prompt={prompt}
|
||||
onToggle={handlePromptToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -399,7 +306,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle, onRefresh }: ServerCar
|
||||
serverName={server.name}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default ServerCard;
|
||||
export default ServerCard
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,21 +13,24 @@ type BadgeProps = {
|
||||
|
||||
const badgeVariants = {
|
||||
default: 'bg-blue-500 text-white hover:bg-blue-600',
|
||||
secondary:
|
||||
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600',
|
||||
outline:
|
||||
'bg-transparent border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800',
|
||||
secondary: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600',
|
||||
outline: 'bg-transparent border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800',
|
||||
destructive: 'bg-red-500 text-white hover:bg-red-600',
|
||||
};
|
||||
|
||||
export function Badge({ children, variant = 'default', className, onClick }: BadgeProps) {
|
||||
export function Badge({
|
||||
children,
|
||||
variant = 'default',
|
||||
className,
|
||||
onClick
|
||||
}: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors',
|
||||
badgeVariants[variant],
|
||||
onClick ? 'cursor-pointer' : '',
|
||||
className,
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
@@ -37,40 +40,27 @@ export function Badge({ children, variant = 'default', className, onClick }: Bad
|
||||
}
|
||||
|
||||
// For backward compatibility with existing code
|
||||
export const StatusBadge = ({
|
||||
status,
|
||||
onAuthClick,
|
||||
}: {
|
||||
status: 'connected' | 'disconnected' | 'connecting' | 'oauth_required';
|
||||
onAuthClick?: (e: React.MouseEvent) => void;
|
||||
}) => {
|
||||
export const StatusBadge = ({ status }: { status: 'connected' | 'disconnected' | 'connecting' }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const colors = {
|
||||
connecting: 'status-badge-connecting',
|
||||
connected: 'status-badge-online',
|
||||
disconnected: 'status-badge-offline',
|
||||
oauth_required: 'status-badge-oauth-required',
|
||||
};
|
||||
|
||||
// Map status to translation keys
|
||||
const statusTranslations = {
|
||||
connected: 'status.online',
|
||||
disconnected: 'status.offline',
|
||||
connecting: 'status.connecting',
|
||||
oauth_required: 'status.oauthRequired',
|
||||
connecting: 'status.connecting'
|
||||
};
|
||||
|
||||
const isOAuthRequired = status === 'oauth_required';
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${colors[status]} ${isOAuthRequired && onAuthClick ? 'cursor-pointer hover:opacity-80' : ''}`}
|
||||
onClick={isOAuthRequired && onAuthClick ? (e) => onAuthClick(e) : undefined}
|
||||
title={isOAuthRequired ? t('status.clickToAuthorize') : undefined}
|
||||
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${colors[status]}`}
|
||||
>
|
||||
{isOAuthRequired && '🔐 '}
|
||||
{t(statusTranslations[status] || status)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -1,78 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CursorPaginationProps {
|
||||
currentPage: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
onNextPage: () => void;
|
||||
onPreviousPage: () => void;
|
||||
}
|
||||
|
||||
const CursorPagination: React.FC<CursorPaginationProps> = ({
|
||||
currentPage,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
onNextPage,
|
||||
onPreviousPage,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center space-x-2 my-6">
|
||||
{/* Previous button */}
|
||||
<button
|
||||
onClick={onPreviousPage}
|
||||
disabled={!hasPreviousPage}
|
||||
className={`px-4 py-2 rounded transition-all duration-200 ${
|
||||
hasPreviousPage
|
||||
? 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
|
||||
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5 inline-block mr-1"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Prev
|
||||
</button>
|
||||
|
||||
{/* Current page indicator */}
|
||||
<span className="px-4 py-2 bg-blue-500 text-white rounded btn-primary">
|
||||
Page {currentPage}
|
||||
</span>
|
||||
|
||||
{/* Next button */}
|
||||
<button
|
||||
onClick={onNextPage}
|
||||
disabled={!hasNextPage}
|
||||
className={`px-4 py-2 rounded transition-all duration-200 ${
|
||||
hasNextPage
|
||||
? 'bg-gray-200 hover:bg-gray-300 text-gray-700 btn-secondary'
|
||||
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Next
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5 inline-block ml-1"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CursorPagination;
|
||||
@@ -1,116 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface DefaultPasswordWarningModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const DefaultPasswordWarningModal: React.FC<DefaultPasswordWarningModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleGoToSettings = () => {
|
||||
onClose();
|
||||
navigate('/settings');
|
||||
// Auto-scroll to password section after a small delay to ensure page is loaded
|
||||
setTimeout(() => {
|
||||
const passwordSection = document.querySelector('[data-section="password"]');
|
||||
if (passwordSection) {
|
||||
passwordSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
// If the section is collapsed, expand it
|
||||
const clickTarget = passwordSection.querySelector('[role="button"]');
|
||||
if (clickTarget && !passwordSection.querySelector('.mt-4')) {
|
||||
(clickTarget as HTMLElement).click();
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-[100] flex items-center justify-center p-4"
|
||||
onClick={handleBackdropClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full transform transition-all duration-200 ease-out"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="password-warning-title"
|
||||
aria-describedby="password-warning-message"
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="flex-shrink-0">
|
||||
<svg
|
||||
className="w-6 h-6 text-yellow-600 dark:text-yellow-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3
|
||||
id="password-warning-title"
|
||||
className="text-lg font-medium text-gray-900 dark:text-white mb-2"
|
||||
>
|
||||
{t('auth.defaultPasswordWarning')}
|
||||
</h3>
|
||||
<p
|
||||
id="password-warning-message"
|
||||
className="text-gray-600 dark:text-gray-300 leading-relaxed"
|
||||
>
|
||||
{t('auth.defaultPasswordMessage')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-600 dark:text-gray-300 hover:text-gray-800 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors duration-150 btn-secondary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGoToSettings}
|
||||
className="px-4 py-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded-md transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yellow-500 btn-warning"
|
||||
autoFocus
|
||||
>
|
||||
{t('auth.goToSettings')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DefaultPasswordWarningModal;
|
||||
@@ -4,7 +4,6 @@ export const PERMISSIONS = {
|
||||
SETTINGS_SMART_ROUTING: 'settings:smart_routing',
|
||||
SETTINGS_SKIP_AUTH: 'settings:skip_auth',
|
||||
SETTINGS_INSTALL_CONFIG: 'settings:install_config',
|
||||
SETTINGS_EXPORT_CONFIG: 'settings:export_config',
|
||||
} as const;
|
||||
|
||||
export default PERMISSIONS;
|
||||
|
||||
@@ -14,12 +14,12 @@ const initialState: AuthState = {
|
||||
// Create auth context
|
||||
const AuthContext = createContext<{
|
||||
auth: AuthState;
|
||||
login: (username: string, password: string) => Promise<{ success: boolean; isUsingDefaultPassword?: boolean }>;
|
||||
login: (username: string, password: string) => Promise<boolean>;
|
||||
register: (username: string, password: string, isAdmin?: boolean) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
}>({
|
||||
auth: initialState,
|
||||
login: async () => ({ success: false }),
|
||||
login: async () => false,
|
||||
register: async () => false,
|
||||
logout: () => { },
|
||||
});
|
||||
@@ -90,7 +90,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
}, []);
|
||||
|
||||
// Login function
|
||||
const login = async (username: string, password: string): Promise<{ success: boolean; isUsingDefaultPassword?: boolean }> => {
|
||||
const login = async (username: string, password: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await authService.login({ username, password });
|
||||
|
||||
@@ -101,17 +101,14 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
user: response.user,
|
||||
error: null,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
isUsingDefaultPassword: response.isUsingDefaultPassword,
|
||||
};
|
||||
return true;
|
||||
} else {
|
||||
setAuth({
|
||||
...initialState,
|
||||
loading: false,
|
||||
error: response.message || 'Authentication failed',
|
||||
});
|
||||
return { success: false };
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
setAuth({
|
||||
@@ -119,7 +116,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
loading: false,
|
||||
error: 'Authentication failed',
|
||||
});
|
||||
return { success: false };
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -63,58 +63,55 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
};
|
||||
|
||||
// Start normal polling
|
||||
const startNormalPolling = useCallback(
|
||||
(options?: { immediate?: boolean }) => {
|
||||
const immediate = options?.immediate ?? true;
|
||||
// Ensure no other timers are running
|
||||
clearTimer();
|
||||
const startNormalPolling = useCallback((options?: { immediate?: boolean }) => {
|
||||
const immediate = options?.immediate ?? true;
|
||||
// Ensure no other timers are running
|
||||
clearTimer();
|
||||
|
||||
const fetchServers = async () => {
|
||||
try {
|
||||
console.log('[ServerContext] Fetching servers from API...');
|
||||
const data = await apiGet('/servers');
|
||||
const fetchServers = async () => {
|
||||
try {
|
||||
console.log('[ServerContext] Fetching servers from API...');
|
||||
const data = await apiGet('/servers');
|
||||
|
||||
// Update last fetch time
|
||||
lastFetchTimeRef.current = Date.now();
|
||||
|
||||
// Update last fetch time
|
||||
lastFetchTimeRef.current = Date.now();
|
||||
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data);
|
||||
} else if (data && Array.isArray(data)) {
|
||||
setServers(data);
|
||||
} else {
|
||||
console.error('Invalid server data format:', data);
|
||||
setServers([]);
|
||||
}
|
||||
|
||||
// Reset error state
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error fetching servers during normal polling:', err);
|
||||
|
||||
// Use friendly error message
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'));
|
||||
} else if (
|
||||
err instanceof TypeError &&
|
||||
(err.message.includes('NetworkError') || err.message.includes('Failed to fetch'))
|
||||
) {
|
||||
setError(t('errors.serverConnection'));
|
||||
} else {
|
||||
setError(t('errors.serverFetch'));
|
||||
}
|
||||
if (data && data.success && Array.isArray(data.data)) {
|
||||
setServers(data.data);
|
||||
} else if (data && Array.isArray(data)) {
|
||||
setServers(data);
|
||||
} else {
|
||||
console.error('Invalid server data format:', data);
|
||||
setServers([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Execute immediately unless explicitly skipped
|
||||
if (immediate) {
|
||||
fetchServers();
|
||||
// Reset error state
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Error fetching servers during normal polling:', err);
|
||||
|
||||
// Use friendly error message
|
||||
if (!navigator.onLine) {
|
||||
setError(t('errors.network'));
|
||||
} else if (
|
||||
err instanceof TypeError &&
|
||||
(err.message.includes('NetworkError') || err.message.includes('Failed to fetch'))
|
||||
) {
|
||||
setError(t('errors.serverConnection'));
|
||||
} else {
|
||||
setError(t('errors.serverFetch'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Set up regular polling
|
||||
intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
// Execute immediately unless explicitly skipped
|
||||
if (immediate) {
|
||||
fetchServers();
|
||||
}
|
||||
|
||||
// Set up regular polling
|
||||
intervalRef.current = setInterval(fetchServers, CONFIG.normal.pollingInterval);
|
||||
}, [t]);
|
||||
|
||||
// Watch for authentication status changes
|
||||
useEffect(() => {
|
||||
@@ -150,7 +147,7 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
try {
|
||||
console.log('[ServerContext] Initial fetch - attempt', attemptsRef.current + 1);
|
||||
const data = await apiGet('/servers');
|
||||
|
||||
|
||||
// Update last fetch time
|
||||
lastFetchTimeRef.current = Date.now();
|
||||
|
||||
@@ -248,30 +245,16 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
const refreshIfNeeded = useCallback(() => {
|
||||
const now = Date.now();
|
||||
const timeSinceLastFetch = now - lastFetchTimeRef.current;
|
||||
|
||||
|
||||
// Log who is calling this
|
||||
console.log(
|
||||
'[ServerContext] refreshIfNeeded called, time since last fetch:',
|
||||
timeSinceLastFetch,
|
||||
'ms',
|
||||
);
|
||||
|
||||
console.log('[ServerContext] refreshIfNeeded called, time since last fetch:', timeSinceLastFetch, 'ms');
|
||||
|
||||
// Only refresh if enough time has passed since last fetch
|
||||
if (timeSinceLastFetch >= MIN_REFRESH_INTERVAL) {
|
||||
console.log(
|
||||
'[ServerContext] Triggering refresh (exceeded MIN_REFRESH_INTERVAL:',
|
||||
MIN_REFRESH_INTERVAL,
|
||||
'ms)',
|
||||
);
|
||||
console.log('[ServerContext] Triggering refresh (exceeded MIN_REFRESH_INTERVAL:', MIN_REFRESH_INTERVAL, 'ms)');
|
||||
triggerRefresh();
|
||||
} else {
|
||||
console.log(
|
||||
'[ServerContext] Skipping refresh (MIN_REFRESH_INTERVAL:',
|
||||
MIN_REFRESH_INTERVAL,
|
||||
'ms, time since last:',
|
||||
timeSinceLastFetch,
|
||||
'ms)',
|
||||
);
|
||||
console.log('[ServerContext] Skipping refresh (MIN_REFRESH_INTERVAL:', MIN_REFRESH_INTERVAL, 'ms, time since last:', timeSinceLastFetch, 'ms)');
|
||||
}
|
||||
}, [triggerRefresh]);
|
||||
|
||||
@@ -280,85 +263,74 @@ export const ServerProvider: React.FC<{ children: React.ReactNode }> = ({ childr
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
}, []);
|
||||
|
||||
const handleServerEdit = useCallback(
|
||||
async (server: Server) => {
|
||||
try {
|
||||
// Fetch settings to get the full server config before editing
|
||||
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> =
|
||||
await apiGet('/settings');
|
||||
const handleServerEdit = useCallback(async (server: Server) => {
|
||||
try {
|
||||
// Fetch settings to get the full server config before editing
|
||||
const settingsData: ApiResponse<{ mcpServers: Record<string, any> }> =
|
||||
await apiGet('/settings');
|
||||
|
||||
if (
|
||||
settingsData &&
|
||||
settingsData.success &&
|
||||
settingsData.data &&
|
||||
settingsData.data.mcpServers &&
|
||||
settingsData.data.mcpServers[server.name]
|
||||
) {
|
||||
const serverConfig = settingsData.data.mcpServers[server.name];
|
||||
return {
|
||||
name: server.name,
|
||||
status: server.status,
|
||||
tools: server.tools || [],
|
||||
config: serverConfig,
|
||||
};
|
||||
} else {
|
||||
console.error('Failed to get server config from settings:', settingsData);
|
||||
setError(t('server.invalidConfig', { serverName: server.name }));
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching server settings:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
if (
|
||||
settingsData &&
|
||||
settingsData.success &&
|
||||
settingsData.data &&
|
||||
settingsData.data.mcpServers &&
|
||||
settingsData.data.mcpServers[server.name]
|
||||
) {
|
||||
const serverConfig = settingsData.data.mcpServers[server.name];
|
||||
return {
|
||||
name: server.name,
|
||||
status: server.status,
|
||||
tools: server.tools || [],
|
||||
config: serverConfig,
|
||||
};
|
||||
} else {
|
||||
console.error('Failed to get server config from settings:', settingsData);
|
||||
setError(t('server.invalidConfig', { serverName: server.name }));
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Error fetching server settings:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return null;
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const handleServerRemove = useCallback(
|
||||
async (serverName: string) => {
|
||||
try {
|
||||
const encodedServerName = encodeURIComponent(serverName);
|
||||
const result = await apiDelete(`/servers/${encodedServerName}`);
|
||||
const handleServerRemove = useCallback(async (serverName: string) => {
|
||||
try {
|
||||
const result = await apiDelete(`/servers/${serverName}`);
|
||||
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('server.deleteError', { serverName }));
|
||||
return false;
|
||||
}
|
||||
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(t('errors.general') + ': ' + (err instanceof Error ? err.message : String(err)));
|
||||
if (!result || !result.success) {
|
||||
setError(result?.message || t('server.deleteError', { serverName }));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleServerToggle = useCallback(
|
||||
async (server: Server, enabled: boolean) => {
|
||||
try {
|
||||
const encodedServerName = encodeURIComponent(server.name);
|
||||
const result = await apiPost(`/servers/${encodedServerName}/toggle`, { enabled });
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(t('errors.general') + ': ' + (err instanceof Error ? err.message : String(err)));
|
||||
return false;
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
if (!result || !result.success) {
|
||||
console.error('Failed to toggle server:', result);
|
||||
setError(result?.message || t('server.toggleError', { serverName: server.name }));
|
||||
return false;
|
||||
}
|
||||
const handleServerToggle = useCallback(async (server: Server, enabled: boolean) => {
|
||||
try {
|
||||
const result = await apiPost(`/servers/${server.name}/toggle`, { enabled });
|
||||
|
||||
// Update the UI immediately to reflect the change
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error toggling server:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
if (!result || !result.success) {
|
||||
console.error('Failed to toggle server:', result);
|
||||
setError(result?.message || t('server.toggleError', { serverName: server.name }));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// Update the UI immediately to reflect the change
|
||||
setRefreshKey((prevKey) => prevKey + 1);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error toggling server:', err);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const value: ServerContextType = {
|
||||
servers,
|
||||
@@ -384,4 +356,4 @@ export const useServerContext = () => {
|
||||
throw new Error('useServerContext must be used within a ServerProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
};
|
||||
@@ -287,13 +287,9 @@ export const useCloudData = () => {
|
||||
const callServerTool = useCallback(
|
||||
async (serverName: string, toolName: string, args: Record<string, any>) => {
|
||||
try {
|
||||
// URL-encode server and tool names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
|
||||
const data = await apiPost(
|
||||
`/cloud/servers/${encodeURIComponent(serverName)}/tools/${encodeURIComponent(toolName)}/call`,
|
||||
{
|
||||
arguments: args,
|
||||
},
|
||||
);
|
||||
const data = await apiPost(`/cloud/servers/${serverName}/tools/${toolName}/call`, {
|
||||
arguments: args,
|
||||
});
|
||||
|
||||
if (data && data.success) {
|
||||
return data.data;
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
RegistryServerEntry,
|
||||
RegistryServersResponse,
|
||||
RegistryServerVersionResponse,
|
||||
RegistryServerVersionsResponse,
|
||||
} from '@/types';
|
||||
import { apiGet } from '../utils/fetchInterceptor';
|
||||
|
||||
export const useRegistryData = () => {
|
||||
const { t } = useTranslation();
|
||||
const [servers, setServers] = useState<RegistryServerEntry[]>([]);
|
||||
const [allServers, setAllServers] = useState<RegistryServerEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
|
||||
// Cursor-based pagination states
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [serversPerPage, setServersPerPage] = useState(9);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [hasNextPage, setHasNextPage] = useState(false);
|
||||
const [cursorHistory, setCursorHistory] = useState<string[]>([]);
|
||||
const [totalPages] = useState(1); // Legacy support, not used in cursor pagination
|
||||
|
||||
// Fetch registry servers with cursor-based pagination
|
||||
const fetchRegistryServers = useCallback(
|
||||
async (cursor?: string, search?: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Build query parameters
|
||||
const params = new URLSearchParams();
|
||||
params.append('limit', serversPerPage.toString());
|
||||
if (cursor) {
|
||||
params.append('cursor', cursor);
|
||||
}
|
||||
const queryToUse = search !== undefined ? search : searchQuery;
|
||||
if (queryToUse.trim()) {
|
||||
params.append('search', queryToUse.trim());
|
||||
}
|
||||
|
||||
const response = await apiGet(`/registry/servers?${params.toString()}`);
|
||||
|
||||
if (response && response.success && response.data) {
|
||||
const data: RegistryServersResponse = response.data;
|
||||
if (data.servers && Array.isArray(data.servers)) {
|
||||
setServers(data.servers);
|
||||
// Update pagination state
|
||||
const hasMore = data.metadata.count === serversPerPage && !!data.metadata.nextCursor;
|
||||
setHasNextPage(hasMore);
|
||||
setNextCursor(data.metadata.nextCursor || null);
|
||||
|
||||
// For display purposes, keep track of all loaded servers
|
||||
if (!cursor) {
|
||||
// First page
|
||||
setAllServers(data.servers);
|
||||
} else {
|
||||
// Subsequent pages - append to all servers
|
||||
setAllServers((prev) => [...prev, ...data.servers]);
|
||||
}
|
||||
} else {
|
||||
console.error('Invalid registry servers data format:', data);
|
||||
setError(t('registry.fetchError'));
|
||||
}
|
||||
} else {
|
||||
setError(t('registry.fetchError'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching registry servers:', err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[t, serversPerPage],
|
||||
);
|
||||
|
||||
// Navigate to next page
|
||||
const goToNextPage = useCallback(async () => {
|
||||
if (!hasNextPage || !nextCursor) return;
|
||||
|
||||
// Save current cursor to history for back navigation
|
||||
const currentCursor = cursorHistory[cursorHistory.length - 1] || '';
|
||||
setCursorHistory((prev) => [...prev, currentCursor]);
|
||||
|
||||
setCurrentPage((prev) => prev + 1);
|
||||
await fetchRegistryServers(nextCursor, searchQuery);
|
||||
}, [hasNextPage, nextCursor, cursorHistory, searchQuery, fetchRegistryServers]);
|
||||
|
||||
// Navigate to previous page
|
||||
const goToPreviousPage = useCallback(async () => {
|
||||
if (currentPage <= 1) return;
|
||||
|
||||
// Get the previous cursor from history
|
||||
const newHistory = [...cursorHistory];
|
||||
newHistory.pop(); // Remove current position
|
||||
const previousCursor = newHistory[newHistory.length - 1];
|
||||
|
||||
setCursorHistory(newHistory);
|
||||
setCurrentPage((prev) => prev - 1);
|
||||
|
||||
// Fetch with previous cursor (undefined for first page)
|
||||
await fetchRegistryServers(previousCursor || undefined, searchQuery);
|
||||
}, [currentPage, cursorHistory, searchQuery, fetchRegistryServers]);
|
||||
|
||||
// Change page (legacy support for page number navigation)
|
||||
const changePage = useCallback(
|
||||
async (page: number) => {
|
||||
if (page === currentPage) return;
|
||||
|
||||
if (page > currentPage && hasNextPage) {
|
||||
await goToNextPage();
|
||||
} else if (page < currentPage && currentPage > 1) {
|
||||
await goToPreviousPage();
|
||||
}
|
||||
},
|
||||
[currentPage, hasNextPage, goToNextPage, goToPreviousPage],
|
||||
);
|
||||
|
||||
// Change items per page
|
||||
const changeServersPerPage = useCallback(
|
||||
async (newServersPerPage: number) => {
|
||||
setServersPerPage(newServersPerPage);
|
||||
setCurrentPage(1);
|
||||
setCursorHistory([]);
|
||||
setAllServers([]);
|
||||
await fetchRegistryServers(undefined, searchQuery);
|
||||
},
|
||||
[searchQuery, fetchRegistryServers],
|
||||
);
|
||||
|
||||
// Fetch server by name
|
||||
const fetchServerByName = useCallback(
|
||||
async (serverName: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// URL encode the server name
|
||||
const encodedName = encodeURIComponent(serverName);
|
||||
const response = await apiGet(`/registry/servers/${encodedName}/versions`);
|
||||
|
||||
if (response && response.success && response.data) {
|
||||
const data: RegistryServerVersionsResponse = response.data;
|
||||
if (data.servers && Array.isArray(data.servers) && data.servers.length > 0) {
|
||||
// Return the first server entry (should be the latest or specified version)
|
||||
return data.servers[0];
|
||||
} else {
|
||||
console.error('Invalid registry server data format:', data);
|
||||
setError(t('registry.serverNotFound'));
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
setError(t('registry.serverNotFound'));
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error fetching registry server ${serverName}:`, err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
setError(errorMessage);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// Fetch all versions of a server
|
||||
const fetchServerVersions = useCallback(async (serverName: string) => {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
// URL encode the server name
|
||||
const encodedName = encodeURIComponent(serverName);
|
||||
const response = await apiGet(`/registry/servers/${encodedName}/versions`);
|
||||
|
||||
if (response && response.success && response.data) {
|
||||
const data: RegistryServerVersionsResponse = response.data;
|
||||
if (data.servers && Array.isArray(data.servers)) {
|
||||
return data.servers;
|
||||
} else {
|
||||
console.error('Invalid registry server versions data format:', data);
|
||||
return [];
|
||||
}
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error fetching versions for server ${serverName}:`, err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
setError(errorMessage);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch specific version of a server
|
||||
const fetchServerVersion = useCallback(async (serverName: string, version: string) => {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
// URL encode the server name and version
|
||||
const encodedName = encodeURIComponent(serverName);
|
||||
const encodedVersion = encodeURIComponent(version);
|
||||
const response = await apiGet(`/registry/servers/${encodedName}/versions/${encodedVersion}`);
|
||||
|
||||
if (response && response.success && response.data) {
|
||||
const data: RegistryServerVersionResponse = response.data;
|
||||
if (data && data.server) {
|
||||
return data;
|
||||
} else {
|
||||
console.error('Invalid registry server version data format:', data);
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error fetching version ${version} for server ${serverName}:`, err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
setError(errorMessage);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Search servers by query (client-side filtering on loaded data)
|
||||
const searchServers = useCallback(
|
||||
async (query: string) => {
|
||||
console.log('Searching registry servers with query:', query);
|
||||
setSearchQuery(query);
|
||||
setCurrentPage(1);
|
||||
setCursorHistory([]);
|
||||
setAllServers([]);
|
||||
|
||||
await fetchRegistryServers(undefined, query);
|
||||
},
|
||||
[fetchRegistryServers],
|
||||
);
|
||||
|
||||
// Clear search
|
||||
const clearSearch = useCallback(async () => {
|
||||
setSearchQuery('');
|
||||
setCurrentPage(1);
|
||||
setCursorHistory([]);
|
||||
setAllServers([]);
|
||||
await fetchRegistryServers(undefined, '');
|
||||
}, [fetchRegistryServers]);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
fetchRegistryServers(undefined, searchQuery);
|
||||
// Only run on mount
|
||||
// eslint-disable-next-line
|
||||
}, []);
|
||||
|
||||
return {
|
||||
servers,
|
||||
allServers,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
searchQuery,
|
||||
searchServers,
|
||||
clearSearch,
|
||||
fetchServerByName,
|
||||
fetchServerVersions,
|
||||
fetchServerVersion,
|
||||
// Cursor-based pagination
|
||||
currentPage,
|
||||
totalPages,
|
||||
hasNextPage,
|
||||
hasPreviousPage: currentPage > 1,
|
||||
changePage,
|
||||
goToNextPage,
|
||||
goToPreviousPage,
|
||||
serversPerPage,
|
||||
changeServersPerPage,
|
||||
};
|
||||
};
|
||||
@@ -420,21 +420,6 @@ export const useSettingsData = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const exportMCPSettings = async (serverName?: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
return await apiGet(`/mcp-settings/export?serverName=${serverName ? serverName : ''}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to export MCP settings:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to export MCP settings';
|
||||
setError(errorMessage);
|
||||
showToast(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch settings when the component mounts or refreshKey changes
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
@@ -469,6 +454,5 @@ export const useSettingsData = () => {
|
||||
updateMCPRouterConfig,
|
||||
updateMCPRouterConfigBatch,
|
||||
updateNameSeparator,
|
||||
exportMCPSettings,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -144,18 +144,6 @@ body {
|
||||
border: 1px solid rgba(255, 193, 7, 0.3);
|
||||
}
|
||||
|
||||
.status-badge-oauth-required {
|
||||
background-color: white !important;
|
||||
color: rgba(156, 39, 176, 0.9) !important;
|
||||
border: 1px solid #ba68c8;
|
||||
}
|
||||
|
||||
.dark .status-badge-oauth-required {
|
||||
background-color: rgba(156, 39, 176, 0.15) !important;
|
||||
color: rgba(186, 104, 200, 0.9) !important;
|
||||
border: 1px solid rgba(156, 39, 176, 0.3);
|
||||
}
|
||||
|
||||
/* Enhanced status icons for dark theme */
|
||||
.dark .status-icon-blue {
|
||||
background-color: rgba(59, 130, 246, 0.15) !important;
|
||||
|
||||
@@ -12,16 +12,14 @@ const DashboardPage: React.FC = () => {
|
||||
total: servers.length,
|
||||
online: servers.filter((server: Server) => server.status === 'connected').length,
|
||||
offline: servers.filter((server: Server) => server.status === 'disconnected').length,
|
||||
connecting: servers.filter((server: Server) => server.status === 'connecting').length,
|
||||
oauthRequired: servers.filter((server: Server) => server.status === 'oauth_required').length,
|
||||
connecting: servers.filter((server: Server) => server.status === 'connecting').length
|
||||
};
|
||||
|
||||
// Map status to translation keys
|
||||
const statusTranslations: Record<string, string> = {
|
||||
connected: 'status.online',
|
||||
disconnected: 'status.offline',
|
||||
connecting: 'status.connecting',
|
||||
oauth_required: 'status.oauthRequired',
|
||||
connecting: 'status.connecting'
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -40,17 +38,8 @@ const DashboardPage: React.FC = () => {
|
||||
className="ml-4 text-gray-500 hover:text-gray-700 transition-colors duration-200"
|
||||
aria-label={t('app.closeButton')}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 111.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 111.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -60,25 +49,9 @@ const DashboardPage: React.FC = () => {
|
||||
{isLoading && (
|
||||
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center loading-container">
|
||||
<div className="flex flex-col items-center">
|
||||
<svg
|
||||
className="animate-spin h-10 w-10 text-blue-500 mb-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p className="text-gray-600">{t('app.loading')}</p>
|
||||
</div>
|
||||
@@ -91,25 +64,12 @@ const DashboardPage: React.FC = () => {
|
||||
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-blue-100 text-blue-800 icon-container status-icon-blue">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-8 w-8"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"
|
||||
/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h2 className="text-xl font-semibold text-gray-700">
|
||||
{t('pages.dashboard.totalServers')}
|
||||
</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-700">{t('pages.dashboard.totalServers')}</h2>
|
||||
<p className="text-3xl font-bold text-gray-900">{serverStats.total}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,25 +79,12 @@ const DashboardPage: React.FC = () => {
|
||||
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-green-100 text-green-800 icon-container status-icon-green">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-8 w-8"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h2 className="text-xl font-semibold text-gray-700">
|
||||
{t('pages.dashboard.onlineServers')}
|
||||
</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-700">{t('pages.dashboard.onlineServers')}</h2>
|
||||
<p className="text-3xl font-bold text-gray-900">{serverStats.online}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,25 +94,12 @@ const DashboardPage: React.FC = () => {
|
||||
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-red-100 text-red-800 icon-container status-icon-red">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-8 w-8"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h2 className="text-xl font-semibold text-gray-700">
|
||||
{t('pages.dashboard.offlineServers')}
|
||||
</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-700">{t('pages.dashboard.offlineServers')}</h2>
|
||||
<p className="text-3xl font-bold text-gray-900">{serverStats.offline}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -175,28 +109,16 @@ const DashboardPage: React.FC = () => {
|
||||
<div className="bg-white rounded-lg shadow p-6 dashboard-card">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-yellow-100 text-yellow-800 icon-container status-icon-yellow">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-8 w-8"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h2 className="text-xl font-semibold text-gray-700">
|
||||
{t('pages.dashboard.connectingServers')}
|
||||
</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-700">{t('pages.dashboard.connectingServers')}</h2>
|
||||
<p className="text-3xl font-bold text-gray-900">{serverStats.connecting}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -204,41 +126,24 @@ const DashboardPage: React.FC = () => {
|
||||
{/* Recent activity list */}
|
||||
{servers.length > 0 && !isLoading && (
|
||||
<div className="mt-8">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">
|
||||
{t('pages.dashboard.recentServers')}
|
||||
</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">{t('pages.dashboard.recentServers')}</h2>
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden table-container">
|
||||
<table className="min-w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('server.name')}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('server.status')}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('server.tools')}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('server.prompts')}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
<th scope="col" className="px-6 py-5 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('server.enabled')}
|
||||
</th>
|
||||
</tr>
|
||||
@@ -250,18 +155,12 @@ const DashboardPage: React.FC = () => {
|
||||
{server.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<span
|
||||
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||||
server.status === 'connected'
|
||||
? 'status-badge-online'
|
||||
: server.status === 'disconnected'
|
||||
? 'status-badge-offline'
|
||||
: server.status === 'oauth_required'
|
||||
? 'status-badge-oauth-required'
|
||||
: 'status-badge-connecting'
|
||||
}`}
|
||||
>
|
||||
{server.status === 'oauth_required' && '🔐 '}
|
||||
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${server.status === 'connected'
|
||||
? 'status-badge-online'
|
||||
: server.status === 'disconnected'
|
||||
? 'status-badge-offline'
|
||||
: 'status-badge-connecting'
|
||||
}`}>
|
||||
{t(statusTranslations[server.status] || server.status)}
|
||||
</span>
|
||||
</td>
|
||||
@@ -289,4 +188,4 @@ const DashboardPage: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
export default DashboardPage;
|
||||
@@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ThemeSwitch from '@/components/ui/ThemeSwitch';
|
||||
import LanguageSwitch from '@/components/ui/LanguageSwitch';
|
||||
import DefaultPasswordWarningModal from '@/components/ui/DefaultPasswordWarningModal';
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -12,7 +11,6 @@ const LoginPage: React.FC = () => {
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showDefaultPasswordWarning, setShowDefaultPasswordWarning] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -28,15 +26,10 @@ const LoginPage: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await login(username, password);
|
||||
const success = await login(username, password);
|
||||
|
||||
if (result.success) {
|
||||
if (result.isUsingDefaultPassword) {
|
||||
// Show warning modal instead of navigating immediately
|
||||
setShowDefaultPasswordWarning(true);
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
if (success) {
|
||||
navigate('/');
|
||||
} else {
|
||||
setError(t('auth.loginFailed'));
|
||||
}
|
||||
@@ -47,11 +40,6 @@ const LoginPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseWarning = () => {
|
||||
setShowDefaultPasswordWarning(false);
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen w-full overflow-hidden bg-gray-50 dark:bg-gray-950">
|
||||
{/* Top-right controls */}
|
||||
@@ -150,12 +138,6 @@ const LoginPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Default Password Warning Modal */}
|
||||
<DefaultPasswordWarningModal
|
||||
isOpen={showDefaultPasswordWarning}
|
||||
onClose={handleCloseWarning}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,27 +1,17 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
MarketServer,
|
||||
CloudServer,
|
||||
ServerConfig,
|
||||
RegistryServerEntry,
|
||||
RegistryServerData,
|
||||
} from '@/types';
|
||||
import { MarketServer, CloudServer, ServerConfig } from '@/types';
|
||||
import { useMarketData } from '@/hooks/useMarketData';
|
||||
import { useCloudData } from '@/hooks/useCloudData';
|
||||
import { useRegistryData } from '@/hooks/useRegistryData';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import { apiPost } from '@/utils/fetchInterceptor';
|
||||
import MarketServerCard from '@/components/MarketServerCard';
|
||||
import MarketServerDetail from '@/components/MarketServerDetail';
|
||||
import CloudServerCard from '@/components/CloudServerCard';
|
||||
import CloudServerDetail from '@/components/CloudServerDetail';
|
||||
import RegistryServerCard from '@/components/RegistryServerCard';
|
||||
import RegistryServerDetail from '@/components/RegistryServerDetail';
|
||||
import MCPRouterApiKeyError from '@/components/MCPRouterApiKeyError';
|
||||
import Pagination from '@/components/ui/Pagination';
|
||||
import CursorPagination from '@/components/ui/CursorPagination';
|
||||
|
||||
const MarketPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -29,7 +19,7 @@ const MarketPage: React.FC = () => {
|
||||
const { serverName } = useParams<{ serverName?: string }>();
|
||||
const { showToast } = useToast();
|
||||
|
||||
// Get tab from URL search params
|
||||
// Get tab from URL search params, default to cloud market
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const currentTab = searchParams.get('tab') || 'cloud';
|
||||
|
||||
@@ -54,10 +44,10 @@ const MarketPage: React.FC = () => {
|
||||
totalPages: localTotalPages,
|
||||
changePage: changeLocalPage,
|
||||
serversPerPage: localServersPerPage,
|
||||
changeServersPerPage: changeLocalServersPerPage,
|
||||
changeServersPerPage: changeLocalServersPerPage
|
||||
} = useMarketData();
|
||||
|
||||
// Cloud market data
|
||||
// Cloud market data
|
||||
const {
|
||||
servers: cloudServers,
|
||||
allServers: allCloudServers,
|
||||
@@ -71,67 +61,29 @@ const MarketPage: React.FC = () => {
|
||||
totalPages: cloudTotalPages,
|
||||
changePage: changeCloudPage,
|
||||
serversPerPage: cloudServersPerPage,
|
||||
changeServersPerPage: changeCloudServersPerPage,
|
||||
changeServersPerPage: changeCloudServersPerPage
|
||||
} = useCloudData();
|
||||
|
||||
// Registry data
|
||||
const {
|
||||
servers: registryServers,
|
||||
allServers: allRegistryServers,
|
||||
loading: registryLoading,
|
||||
error: registryError,
|
||||
setError: setRegistryError,
|
||||
searchServers: searchRegistryServers,
|
||||
clearSearch: clearRegistrySearch,
|
||||
fetchServerByName: fetchRegistryServerByName,
|
||||
fetchServerVersions: fetchRegistryServerVersions,
|
||||
// Cursor-based pagination
|
||||
currentPage: registryCurrentPage,
|
||||
totalPages: registryTotalPages,
|
||||
hasNextPage: registryHasNextPage,
|
||||
hasPreviousPage: registryHasPreviousPage,
|
||||
changePage: changeRegistryPage,
|
||||
goToNextPage: goToRegistryNextPage,
|
||||
goToPreviousPage: goToRegistryPreviousPage,
|
||||
serversPerPage: registryServersPerPage,
|
||||
changeServersPerPage: changeRegistryServersPerPage,
|
||||
} = useRegistryData();
|
||||
|
||||
const [selectedServer, setSelectedServer] = useState<MarketServer | null>(null);
|
||||
const [selectedCloudServer, setSelectedCloudServer] = useState<CloudServer | null>(null);
|
||||
const [selectedRegistryServer, setSelectedRegistryServer] = useState<RegistryServerEntry | null>(
|
||||
null,
|
||||
);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [registrySearchQuery, setRegistrySearchQuery] = useState('');
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [installedCloudServers, setInstalledCloudServers] = useState<Set<string>>(new Set());
|
||||
const [installedRegistryServers, setInstalledRegistryServers] = useState<Set<string>>(new Set());
|
||||
|
||||
// Load server details if a server name is in the URL
|
||||
useEffect(() => {
|
||||
const loadServerDetails = async () => {
|
||||
if (serverName) {
|
||||
// Determine if it's a cloud, local, or registry server based on the current tab
|
||||
// Determine if it's a cloud or local server based on the current tab
|
||||
if (currentTab === 'cloud') {
|
||||
// Try to find the server in cloud servers
|
||||
const server = cloudServers.find((s) => s.name === serverName);
|
||||
const server = cloudServers.find(s => s.name === serverName);
|
||||
if (server) {
|
||||
setSelectedCloudServer(server);
|
||||
} else {
|
||||
// If server not found, navigate back to market page
|
||||
navigate('/market?tab=cloud');
|
||||
}
|
||||
} else if (currentTab === 'registry') {
|
||||
console.log('Loading registry server details for:', serverName);
|
||||
// Registry market
|
||||
const serverEntry = await fetchRegistryServerByName(serverName);
|
||||
if (serverEntry) {
|
||||
setSelectedRegistryServer(serverEntry);
|
||||
} else {
|
||||
// If server not found, navigate back to market page
|
||||
navigate('/market?tab=registry');
|
||||
}
|
||||
} else {
|
||||
// Local market
|
||||
const server = await fetchLocalServerByName(serverName);
|
||||
@@ -145,22 +97,14 @@ const MarketPage: React.FC = () => {
|
||||
} else {
|
||||
setSelectedServer(null);
|
||||
setSelectedCloudServer(null);
|
||||
setSelectedRegistryServer(null);
|
||||
}
|
||||
};
|
||||
|
||||
loadServerDetails();
|
||||
}, [
|
||||
serverName,
|
||||
currentTab,
|
||||
cloudServers,
|
||||
fetchLocalServerByName,
|
||||
fetchRegistryServerByName,
|
||||
navigate,
|
||||
]);
|
||||
}, [serverName, currentTab, cloudServers, fetchLocalServerByName, navigate]);
|
||||
|
||||
// Tab switching handler
|
||||
const switchTab = (tab: 'local' | 'cloud' | 'registry') => {
|
||||
const switchTab = (tab: 'local' | 'cloud') => {
|
||||
const newSearchParams = new URLSearchParams(searchParams);
|
||||
newSearchParams.set('tab', tab);
|
||||
setSearchParams(newSearchParams);
|
||||
@@ -174,8 +118,6 @@ const MarketPage: React.FC = () => {
|
||||
e.preventDefault();
|
||||
if (currentTab === 'local') {
|
||||
searchLocalServers(searchQuery);
|
||||
} else if (currentTab === 'registry') {
|
||||
searchRegistryServers(registrySearchQuery);
|
||||
}
|
||||
// Cloud search is not implemented in the original cloud page
|
||||
};
|
||||
@@ -187,35 +129,18 @@ const MarketPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setSearchQuery('');
|
||||
if (currentTab === 'local') {
|
||||
setSearchQuery('');
|
||||
filterLocalByCategory('');
|
||||
filterLocalByTag('');
|
||||
} else if (currentTab === 'registry') {
|
||||
setRegistrySearchQuery('');
|
||||
clearRegistrySearch();
|
||||
}
|
||||
};
|
||||
|
||||
const handleServerClick = (server: MarketServer | CloudServer | RegistryServerEntry) => {
|
||||
const handleServerClick = (server: MarketServer | CloudServer) => {
|
||||
if (currentTab === 'cloud') {
|
||||
const cloudServer = server as CloudServer;
|
||||
navigate(`/market/${cloudServer.name}?tab=cloud`);
|
||||
} else if (currentTab === 'registry') {
|
||||
const registryServer = server as RegistryServerEntry;
|
||||
console.log('Registry server clicked:', registryServer);
|
||||
const serverName = registryServer.server?.name;
|
||||
console.log('Server name extracted:', serverName);
|
||||
if (serverName) {
|
||||
const targetUrl = `/market/${encodeURIComponent(serverName)}?tab=registry`;
|
||||
console.log('Navigating to:', targetUrl);
|
||||
navigate(targetUrl);
|
||||
} else {
|
||||
console.error('Server name is undefined in registry server:', registryServer);
|
||||
}
|
||||
navigate(`/market/${server.name}?tab=cloud`);
|
||||
} else {
|
||||
const marketServer = server as MarketServer;
|
||||
navigate(`/market/${marketServer.name}?tab=local`);
|
||||
navigate(`/market/${server.name}?tab=local`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -242,7 +167,7 @@ const MarketPage: React.FC = () => {
|
||||
|
||||
const payload = {
|
||||
name: server.name,
|
||||
config: config,
|
||||
config: config
|
||||
};
|
||||
|
||||
const result = await apiPost('/servers', payload);
|
||||
@@ -254,8 +179,9 @@ const MarketPage: React.FC = () => {
|
||||
}
|
||||
|
||||
// Update installed servers set
|
||||
setInstalledCloudServers((prev) => new Set(prev).add(server.name));
|
||||
setInstalledCloudServers(prev => new Set(prev).add(server.name));
|
||||
showToast(t('cloud.installSuccess', { name: server.title || server.name }), 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error installing cloud server:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
@@ -265,41 +191,7 @@ const MarketPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle registry server installation
|
||||
const handleRegistryInstall = async (server: RegistryServerData, config: ServerConfig) => {
|
||||
try {
|
||||
setInstalling(true);
|
||||
|
||||
const payload = {
|
||||
name: server.name,
|
||||
config: config,
|
||||
};
|
||||
|
||||
const result = await apiPost('/servers', payload);
|
||||
|
||||
if (!result.success) {
|
||||
const errorMessage = result?.message || t('server.addError');
|
||||
showToast(errorMessage, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update installed servers set
|
||||
setInstalledRegistryServers((prev) => new Set(prev).add(server.name));
|
||||
showToast(t('registry.installSuccess', { name: server.title || server.name }), 'success');
|
||||
} catch (error) {
|
||||
console.error('Error installing registry server:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
showToast(t('registry.installError', { error: errorMessage }), 'error');
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCallTool = async (
|
||||
serverName: string,
|
||||
toolName: string,
|
||||
args: Record<string, any>,
|
||||
) => {
|
||||
const handleCallTool = async (serverName: string, toolName: string, args: Record<string, any>) => {
|
||||
try {
|
||||
const result = await callServerTool(serverName, toolName, args);
|
||||
showToast(t('cloud.toolCallSuccess', { toolName }), 'success');
|
||||
@@ -316,17 +208,13 @@ const MarketPage: React.FC = () => {
|
||||
|
||||
// Helper function to check if error is MCPRouter API key not configured
|
||||
const isMCPRouterApiKeyError = (errorMessage: string) => {
|
||||
return (
|
||||
errorMessage === 'MCPROUTER_API_KEY_NOT_CONFIGURED' ||
|
||||
errorMessage.toLowerCase().includes('mcprouter api key not configured')
|
||||
);
|
||||
return errorMessage === 'MCPROUTER_API_KEY_NOT_CONFIGURED' ||
|
||||
errorMessage.toLowerCase().includes('mcprouter api key not configured');
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
if (currentTab === 'local') {
|
||||
changeLocalPage(page);
|
||||
} else if (currentTab === 'registry') {
|
||||
changeRegistryPage(page);
|
||||
} else {
|
||||
changeCloudPage(page);
|
||||
}
|
||||
@@ -338,8 +226,6 @@ const MarketPage: React.FC = () => {
|
||||
const newValue = parseInt(e.target.value, 10);
|
||||
if (currentTab === 'local') {
|
||||
changeLocalServersPerPage(newValue);
|
||||
} else if (currentTab === 'registry') {
|
||||
changeRegistryServersPerPage(newValue);
|
||||
} else {
|
||||
changeCloudServersPerPage(newValue);
|
||||
}
|
||||
@@ -373,50 +259,19 @@ const MarketPage: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Render registry server detail if selected
|
||||
if (selectedRegistryServer) {
|
||||
return (
|
||||
<RegistryServerDetail
|
||||
serverEntry={selectedRegistryServer}
|
||||
onBack={handleBackToList}
|
||||
onInstall={handleRegistryInstall}
|
||||
installing={installing}
|
||||
isInstalled={installedRegistryServers.has(selectedRegistryServer.server.name)}
|
||||
fetchVersions={fetchRegistryServerVersions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Get current data based on active tab
|
||||
const isLocalTab = currentTab === 'local';
|
||||
const isRegistryTab = currentTab === 'registry';
|
||||
const servers = isLocalTab ? localServers : isRegistryTab ? registryServers : cloudServers;
|
||||
const allServers = isLocalTab
|
||||
? allLocalServers
|
||||
: isRegistryTab
|
||||
? allRegistryServers
|
||||
: allCloudServers;
|
||||
const servers = isLocalTab ? localServers : cloudServers;
|
||||
const allServers = isLocalTab ? allLocalServers : allCloudServers;
|
||||
const categories = isLocalTab ? localCategories : [];
|
||||
const loading = isLocalTab ? localLoading : isRegistryTab ? registryLoading : cloudLoading;
|
||||
const error = isLocalTab ? localError : isRegistryTab ? registryError : cloudError;
|
||||
const setError = isLocalTab ? setLocalError : isRegistryTab ? setRegistryError : setCloudError;
|
||||
const loading = isLocalTab ? localLoading : cloudLoading;
|
||||
const error = isLocalTab ? localError : cloudError;
|
||||
const setError = isLocalTab ? setLocalError : setCloudError;
|
||||
const selectedCategory = isLocalTab ? selectedLocalCategory : '';
|
||||
const selectedTag = isLocalTab ? selectedLocalTag : '';
|
||||
const currentPage = isLocalTab
|
||||
? localCurrentPage
|
||||
: isRegistryTab
|
||||
? registryCurrentPage
|
||||
: cloudCurrentPage;
|
||||
const totalPages = isLocalTab
|
||||
? localTotalPages
|
||||
: isRegistryTab
|
||||
? registryTotalPages
|
||||
: cloudTotalPages;
|
||||
const serversPerPage = isLocalTab
|
||||
? localServersPerPage
|
||||
: isRegistryTab
|
||||
? registryServersPerPage
|
||||
: cloudServersPerPage;
|
||||
const currentPage = isLocalTab ? localCurrentPage : cloudCurrentPage;
|
||||
const totalPages = isLocalTab ? localTotalPages : cloudTotalPages;
|
||||
const serversPerPage = isLocalTab ? localServersPerPage : cloudServersPerPage;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -426,15 +281,13 @@ const MarketPage: React.FC = () => {
|
||||
<nav className="-mb-px flex space-x-3">
|
||||
<button
|
||||
onClick={() => switchTab('cloud')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${
|
||||
!isLocalTab && !isRegistryTab
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${!isLocalTab
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t('cloud.title')}
|
||||
<span className="text-xs text-gray-400 font-normal ml-1">
|
||||
(
|
||||
<span className="text-xs text-gray-400 font-normal ml-1">(
|
||||
<a
|
||||
href="https://mcprouter.co"
|
||||
target="_blank"
|
||||
@@ -448,15 +301,13 @@ const MarketPage: React.FC = () => {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => switchTab('local')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${
|
||||
isLocalTab
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${isLocalTab
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t('market.title')}
|
||||
<span className="text-xs text-gray-400 font-normal ml-1">
|
||||
(
|
||||
<span className="text-xs text-gray-400 font-normal ml-1">(
|
||||
<a
|
||||
href="https://mcpm.sh"
|
||||
target="_blank"
|
||||
@@ -468,28 +319,6 @@ const MarketPage: React.FC = () => {
|
||||
)
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => switchTab('registry')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-lg hover:cursor-pointer transition-colors duration-200 ${
|
||||
isRegistryTab
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{t('registry.title')}
|
||||
<span className="text-xs text-gray-400 font-normal ml-1">
|
||||
(
|
||||
<a
|
||||
href="https://registry.modelcontextprotocol.io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="external-link"
|
||||
>
|
||||
{t('registry.official')}
|
||||
</a>
|
||||
)
|
||||
</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
@@ -506,17 +335,8 @@ const MarketPage: React.FC = () => {
|
||||
onClick={() => setError(null)}
|
||||
className="text-red-700 hover:text-red-900 transition-colors duration-200"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 01.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 011.414 0L10 8.586l4.293-4.293a1 1 01.414 1.414L11.414 10l4.293 4.293a1 1 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 01-1.414-1.414L8.586 10 4.293 5.707a1 1 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -525,24 +345,16 @@ const MarketPage: React.FC = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Search bar for local market and registry */}
|
||||
{(isLocalTab || isRegistryTab) && (
|
||||
{/* Search bar for local market only */}
|
||||
{isLocalTab && (
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6 page-card">
|
||||
<form onSubmit={handleSearch} className="flex space-x-4 mb-0">
|
||||
<div className="flex-grow">
|
||||
<input
|
||||
type="text"
|
||||
value={isRegistryTab ? registrySearchQuery : searchQuery}
|
||||
onChange={(e) => {
|
||||
if (isRegistryTab) {
|
||||
setRegistrySearchQuery(e.target.value);
|
||||
} else {
|
||||
setSearchQuery(e.target.value);
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
isRegistryTab ? t('registry.searchPlaceholder') : t('market.searchPlaceholder')
|
||||
}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('market.searchPlaceholder')}
|
||||
className="shadow appearance-none border border-gray-200 rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline form-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -550,16 +362,15 @@ const MarketPage: React.FC = () => {
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
|
||||
>
|
||||
{isRegistryTab ? t('registry.search') : t('market.search')}
|
||||
{t('market.search')}
|
||||
</button>
|
||||
{((isLocalTab && (searchQuery || selectedCategory || selectedTag)) ||
|
||||
(isRegistryTab && registrySearchQuery)) && (
|
||||
{(searchQuery || selectedCategory || selectedTag) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearFilters}
|
||||
className="border border-gray-300 text-gray-700 font-medium py-2 px-4 rounded hover:bg-gray-50 btn-secondary transition-all duration-200"
|
||||
>
|
||||
{isRegistryTab ? t('registry.clearFilters') : t('market.clearFilters')}
|
||||
{t('market.clearFilters')}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
@@ -577,10 +388,7 @@ const MarketPage: React.FC = () => {
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
|
||||
{selectedCategory && (
|
||||
<span
|
||||
className="text-xs text-blue-600 cursor-pointer hover:underline transition-colors duration-200"
|
||||
onClick={() => filterLocalByCategory('')}
|
||||
>
|
||||
<span className="text-xs text-blue-600 cursor-pointer hover:underline transition-colors duration-200" onClick={() => filterLocalByCategory('')}>
|
||||
{t('market.clearCategoryFilter')}
|
||||
</span>
|
||||
)}
|
||||
@@ -590,11 +398,10 @@ const MarketPage: React.FC = () => {
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => handleCategoryClick(category)}
|
||||
className={`px-3 py-2 rounded text-sm text-left transition-all duration-200 ${
|
||||
selectedCategory === category
|
||||
? 'bg-blue-100 text-blue-800 font-medium btn-primary'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-secondary'
|
||||
}`}
|
||||
className={`px-3 py-2 rounded text-sm text-left transition-all duration-200 ${selectedCategory === category
|
||||
? 'bg-blue-100 text-blue-800 font-medium btn-primary'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200 btn-secondary'
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
@@ -607,25 +414,9 @@ const MarketPage: React.FC = () => {
|
||||
<h3 className="font-medium text-gray-900">{t('market.categories')}</h3>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 items-center py-4 loading-container">
|
||||
<svg
|
||||
className="animate-spin h-6 w-6 text-blue-500 mb-2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
<svg className="animate-spin h-6 w-6 text-blue-500 mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p className="text-sm text-gray-600">{t('app.loading')}</p>
|
||||
</div>
|
||||
@@ -647,110 +438,61 @@ const MarketPage: React.FC = () => {
|
||||
{loading ? (
|
||||
<div className="bg-white shadow rounded-lg p-6 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<svg
|
||||
className="animate-spin h-10 w-10 text-blue-500 mb-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
<svg className="animate-spin h-10 w-10 text-blue-500 mb-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<p className="text-gray-600">{t('app.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : servers.length === 0 ? (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<p className="text-gray-600">
|
||||
{isLocalTab
|
||||
? t('market.noServers')
|
||||
: isRegistryTab
|
||||
? t('registry.noServers')
|
||||
: t('cloud.noServers')}
|
||||
</p>
|
||||
<p className="text-gray-600">{isLocalTab ? t('market.noServers') : t('cloud.noServers')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{servers.map((server, index) =>
|
||||
{servers.map((server, index) => (
|
||||
isLocalTab ? (
|
||||
<MarketServerCard
|
||||
key={index}
|
||||
server={server as MarketServer}
|
||||
onClick={handleServerClick}
|
||||
/>
|
||||
) : isRegistryTab ? (
|
||||
<RegistryServerCard
|
||||
key={index}
|
||||
serverEntry={server as RegistryServerEntry}
|
||||
onClick={handleServerClick}
|
||||
/>
|
||||
) : (
|
||||
<CloudServerCard
|
||||
key={index}
|
||||
server={server as CloudServer}
|
||||
onClick={handleServerClick}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="flex-[2] text-sm text-gray-500">
|
||||
{isLocalTab
|
||||
? t('market.showing', {
|
||||
from: (currentPage - 1) * serversPerPage + 1,
|
||||
to: Math.min(currentPage * serversPerPage, allServers.length),
|
||||
total: allServers.length,
|
||||
})
|
||||
: isRegistryTab
|
||||
? t('registry.showing', {
|
||||
from: (currentPage - 1) * serversPerPage + 1,
|
||||
to: (currentPage - 1) * serversPerPage + servers.length,
|
||||
total: allServers.length + (registryHasNextPage ? '+' : ''),
|
||||
})
|
||||
: t('cloud.showing', {
|
||||
from: (currentPage - 1) * serversPerPage + 1,
|
||||
to: Math.min(currentPage * serversPerPage, allServers.length),
|
||||
total: allServers.length,
|
||||
})}
|
||||
</div>
|
||||
<div className="flex-[4] flex justify-center">
|
||||
{isRegistryTab ? (
|
||||
<CursorPagination
|
||||
currentPage={currentPage}
|
||||
hasNextPage={registryHasNextPage}
|
||||
hasPreviousPage={registryHasPreviousPage}
|
||||
onNextPage={goToRegistryNextPage}
|
||||
onPreviousPage={goToRegistryPreviousPage}
|
||||
/>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="text-sm text-gray-500">
|
||||
{isLocalTab ? (
|
||||
t('market.showing', {
|
||||
from: (currentPage - 1) * serversPerPage + 1,
|
||||
to: Math.min(currentPage * serversPerPage, allServers.length),
|
||||
total: allServers.length
|
||||
})
|
||||
) : (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
t('cloud.showing', {
|
||||
from: (currentPage - 1) * serversPerPage + 1,
|
||||
to: Math.min(currentPage * serversPerPage, allServers.length),
|
||||
total: allServers.length
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-[2] flex items-center justify-end space-x-2">
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<label htmlFor="perPage" className="text-sm text-gray-600">
|
||||
{isLocalTab
|
||||
? t('market.perPage')
|
||||
: isRegistryTab
|
||||
? t('registry.perPage')
|
||||
: t('cloud.perPage')}
|
||||
:
|
||||
{isLocalTab ? t('market.perPage') : t('cloud.perPage')}:
|
||||
</label>
|
||||
<select
|
||||
id="perPage"
|
||||
@@ -765,6 +507,9 @@ const MarketPage: React.FC = () => {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,6 @@ import AddServerForm from '@/components/AddServerForm';
|
||||
import EditServerForm from '@/components/EditServerForm';
|
||||
import { useServerData } from '@/hooks/useServerData';
|
||||
import DxtUploadForm from '@/components/DxtUploadForm';
|
||||
import JSONImportForm from '@/components/JSONImportForm';
|
||||
|
||||
const ServersPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -26,7 +25,6 @@ const ServersPage: React.FC = () => {
|
||||
const [editingServer, setEditingServer] = useState<Server | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [showDxtUpload, setShowDxtUpload] = useState(false);
|
||||
const [showJsonImport, setShowJsonImport] = useState(false);
|
||||
|
||||
const handleEditClick = async (server: Server) => {
|
||||
const fullServerData = await handleServerEdit(server);
|
||||
@@ -57,12 +55,6 @@ const ServersPage: React.FC = () => {
|
||||
triggerRefresh();
|
||||
};
|
||||
|
||||
const handleJsonImportSuccess = () => {
|
||||
// Close import dialog and refresh servers
|
||||
setShowJsonImport(false);
|
||||
triggerRefresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
@@ -78,15 +70,6 @@ const ServersPage: React.FC = () => {
|
||||
{t('nav.market')}
|
||||
</button>
|
||||
<AddServerForm onAdd={handleServerAdd} />
|
||||
<button
|
||||
onClick={() => setShowJsonImport(true)}
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM6.293 6.707a1 1 0 010-1.414l3-3a1 1 0 011.414 0l3 3a1 1 0 01-1.414 1.414L11 5.414V13a1 1 0 11-2 0V5.414L7.707 6.707a1 1 0 01-1.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{t('jsonImport.button')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDxtUpload(true)}
|
||||
className="px-4 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 flex items-center btn-primary transition-all duration-200"
|
||||
@@ -178,13 +161,6 @@ const ServersPage: React.FC = () => {
|
||||
onCancel={() => setShowDxtUpload(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showJsonImport && (
|
||||
<JSONImportForm
|
||||
onSuccess={handleJsonImportSuccess}
|
||||
onCancel={() => setShowJsonImport(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,55 +1,54 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import ChangePasswordForm from '@/components/ChangePasswordForm'
|
||||
import { Switch } from '@/components/ui/ToggleGroup'
|
||||
import { useSettingsData } from '@/hooks/useSettingsData'
|
||||
import { useToast } from '@/contexts/ToastContext'
|
||||
import { generateRandomKey } from '@/utils/key'
|
||||
import { PermissionChecker } from '@/components/PermissionChecker'
|
||||
import { PERMISSIONS } from '@/constants/permissions'
|
||||
import { Copy, Check, Download } from 'lucide-react'
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ChangePasswordForm from '@/components/ChangePasswordForm';
|
||||
import { Switch } from '@/components/ui/ToggleGroup';
|
||||
import { useSettingsData } from '@/hooks/useSettingsData';
|
||||
import { useToast } from '@/contexts/ToastContext';
|
||||
import { generateRandomKey } from '@/utils/key';
|
||||
import { PermissionChecker } from '@/components/PermissionChecker';
|
||||
import { PERMISSIONS } from '@/constants/permissions';
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useToast()
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [installConfig, setInstallConfig] = useState<{
|
||||
pythonIndexUrl: string
|
||||
npmRegistry: string
|
||||
baseUrl: string
|
||||
pythonIndexUrl: string;
|
||||
npmRegistry: string;
|
||||
baseUrl: string;
|
||||
}>({
|
||||
pythonIndexUrl: '',
|
||||
npmRegistry: '',
|
||||
baseUrl: 'http://localhost:3000',
|
||||
})
|
||||
});
|
||||
|
||||
const [tempSmartRoutingConfig, setTempSmartRoutingConfig] = useState<{
|
||||
dbUrl: string
|
||||
openaiApiBaseUrl: string
|
||||
openaiApiKey: string
|
||||
openaiApiEmbeddingModel: string
|
||||
dbUrl: string;
|
||||
openaiApiBaseUrl: string;
|
||||
openaiApiKey: string;
|
||||
openaiApiEmbeddingModel: string;
|
||||
}>({
|
||||
dbUrl: '',
|
||||
openaiApiBaseUrl: '',
|
||||
openaiApiKey: '',
|
||||
openaiApiEmbeddingModel: '',
|
||||
})
|
||||
});
|
||||
|
||||
const [tempMCPRouterConfig, setTempMCPRouterConfig] = useState<{
|
||||
apiKey: string
|
||||
referer: string
|
||||
title: string
|
||||
baseUrl: string
|
||||
apiKey: string;
|
||||
referer: string;
|
||||
title: string;
|
||||
baseUrl: string;
|
||||
}>({
|
||||
apiKey: '',
|
||||
referer: 'https://www.mcphubx.com',
|
||||
title: 'MCPHub',
|
||||
baseUrl: 'https://api.mcprouter.to/v1',
|
||||
})
|
||||
});
|
||||
|
||||
const [tempNameSeparator, setTempNameSeparator] = useState<string>('-')
|
||||
const [tempNameSeparator, setTempNameSeparator] = useState<string>('-');
|
||||
|
||||
const {
|
||||
routingConfig,
|
||||
@@ -67,15 +66,14 @@ const SettingsPage: React.FC = () => {
|
||||
updateSmartRoutingConfigBatch,
|
||||
updateMCPRouterConfig,
|
||||
updateNameSeparator,
|
||||
exportMCPSettings,
|
||||
} = useSettingsData()
|
||||
} = useSettingsData();
|
||||
|
||||
// Update local installConfig when savedInstallConfig changes
|
||||
useEffect(() => {
|
||||
if (savedInstallConfig) {
|
||||
setInstallConfig(savedInstallConfig)
|
||||
setInstallConfig(savedInstallConfig);
|
||||
}
|
||||
}, [savedInstallConfig])
|
||||
}, [savedInstallConfig]);
|
||||
|
||||
// Update local tempSmartRoutingConfig when smartRoutingConfig changes
|
||||
useEffect(() => {
|
||||
@@ -85,9 +83,9 @@ const SettingsPage: React.FC = () => {
|
||||
openaiApiBaseUrl: smartRoutingConfig.openaiApiBaseUrl || '',
|
||||
openaiApiKey: smartRoutingConfig.openaiApiKey || '',
|
||||
openaiApiEmbeddingModel: smartRoutingConfig.openaiApiEmbeddingModel || '',
|
||||
})
|
||||
});
|
||||
}
|
||||
}, [smartRoutingConfig])
|
||||
}, [smartRoutingConfig]);
|
||||
|
||||
// Update local tempMCPRouterConfig when mcpRouterConfig changes
|
||||
useEffect(() => {
|
||||
@@ -97,14 +95,14 @@ const SettingsPage: React.FC = () => {
|
||||
referer: mcpRouterConfig.referer || 'https://www.mcphubx.com',
|
||||
title: mcpRouterConfig.title || 'MCPHub',
|
||||
baseUrl: mcpRouterConfig.baseUrl || 'https://api.mcprouter.to/v1',
|
||||
})
|
||||
});
|
||||
}
|
||||
}, [mcpRouterConfig])
|
||||
}, [mcpRouterConfig]);
|
||||
|
||||
// Update local tempNameSeparator when nameSeparator changes
|
||||
useEffect(() => {
|
||||
setTempNameSeparator(nameSeparator)
|
||||
}, [nameSeparator])
|
||||
setTempNameSeparator(nameSeparator);
|
||||
}, [nameSeparator]);
|
||||
|
||||
const [sectionsVisible, setSectionsVisible] = useState({
|
||||
routingConfig: false,
|
||||
@@ -112,244 +110,138 @@ const SettingsPage: React.FC = () => {
|
||||
smartRoutingConfig: false,
|
||||
mcpRouterConfig: false,
|
||||
nameSeparator: false,
|
||||
password: false,
|
||||
exportConfig: false,
|
||||
})
|
||||
password: false
|
||||
});
|
||||
|
||||
const toggleSection = (
|
||||
section:
|
||||
| 'routingConfig'
|
||||
| 'installConfig'
|
||||
| 'smartRoutingConfig'
|
||||
| 'mcpRouterConfig'
|
||||
| 'nameSeparator'
|
||||
| 'password'
|
||||
| 'exportConfig',
|
||||
) => {
|
||||
setSectionsVisible((prev) => ({
|
||||
const toggleSection = (section: 'routingConfig' | 'installConfig' | 'smartRoutingConfig' | 'mcpRouterConfig' | 'nameSeparator' | 'password') => {
|
||||
setSectionsVisible(prev => ({
|
||||
...prev,
|
||||
[section]: !prev[section],
|
||||
}))
|
||||
}
|
||||
[section]: !prev[section]
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRoutingConfigChange = async (
|
||||
key:
|
||||
| 'enableGlobalRoute'
|
||||
| 'enableGroupNameRoute'
|
||||
| 'enableBearerAuth'
|
||||
| 'bearerAuthKey'
|
||||
| 'skipAuth',
|
||||
value: boolean | string,
|
||||
) => {
|
||||
const handleRoutingConfigChange = async (key: 'enableGlobalRoute' | 'enableGroupNameRoute' | 'enableBearerAuth' | 'bearerAuthKey' | 'skipAuth', value: boolean | string) => {
|
||||
// If enableBearerAuth is turned on and there's no key, generate one first
|
||||
if (key === 'enableBearerAuth' && value === true) {
|
||||
if (!tempRoutingConfig.bearerAuthKey && !routingConfig.bearerAuthKey) {
|
||||
const newKey = generateRandomKey()
|
||||
handleBearerAuthKeyChange(newKey)
|
||||
const newKey = generateRandomKey();
|
||||
handleBearerAuthKeyChange(newKey);
|
||||
|
||||
// Update both enableBearerAuth and bearerAuthKey in a single call
|
||||
const success = await updateRoutingConfigBatch({
|
||||
enableBearerAuth: true,
|
||||
bearerAuthKey: newKey,
|
||||
})
|
||||
bearerAuthKey: newKey
|
||||
});
|
||||
|
||||
if (success) {
|
||||
// Update tempRoutingConfig to reflect the saved values
|
||||
setTempRoutingConfig((prev) => ({
|
||||
setTempRoutingConfig(prev => ({
|
||||
...prev,
|
||||
bearerAuthKey: newKey,
|
||||
}))
|
||||
bearerAuthKey: newKey
|
||||
}));
|
||||
}
|
||||
return
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await updateRoutingConfig(key, value)
|
||||
}
|
||||
await updateRoutingConfig(key, value);
|
||||
};
|
||||
|
||||
const handleBearerAuthKeyChange = (value: string) => {
|
||||
setTempRoutingConfig((prev) => ({
|
||||
setTempRoutingConfig(prev => ({
|
||||
...prev,
|
||||
bearerAuthKey: value,
|
||||
}))
|
||||
}
|
||||
bearerAuthKey: value
|
||||
}));
|
||||
};
|
||||
|
||||
const saveBearerAuthKey = async () => {
|
||||
await updateRoutingConfig('bearerAuthKey', tempRoutingConfig.bearerAuthKey)
|
||||
}
|
||||
await updateRoutingConfig('bearerAuthKey', tempRoutingConfig.bearerAuthKey);
|
||||
};
|
||||
|
||||
const handleInstallConfigChange = (
|
||||
key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl',
|
||||
value: string,
|
||||
) => {
|
||||
const handleInstallConfigChange = (key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl', value: string) => {
|
||||
setInstallConfig({
|
||||
...installConfig,
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
[key]: value
|
||||
});
|
||||
};
|
||||
|
||||
const saveInstallConfig = async (key: 'pythonIndexUrl' | 'npmRegistry' | 'baseUrl') => {
|
||||
await updateInstallConfig(key, installConfig[key])
|
||||
}
|
||||
await updateInstallConfig(key, installConfig[key]);
|
||||
};
|
||||
|
||||
const handleSmartRoutingConfigChange = (
|
||||
key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel',
|
||||
value: string,
|
||||
) => {
|
||||
const handleSmartRoutingConfigChange = (key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel', value: string) => {
|
||||
setTempSmartRoutingConfig({
|
||||
...tempSmartRoutingConfig,
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
[key]: value
|
||||
});
|
||||
};
|
||||
|
||||
const saveSmartRoutingConfig = async (
|
||||
key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel',
|
||||
) => {
|
||||
await updateSmartRoutingConfig(key, tempSmartRoutingConfig[key])
|
||||
}
|
||||
const saveSmartRoutingConfig = async (key: 'dbUrl' | 'openaiApiBaseUrl' | 'openaiApiKey' | 'openaiApiEmbeddingModel') => {
|
||||
await updateSmartRoutingConfig(key, tempSmartRoutingConfig[key]);
|
||||
};
|
||||
|
||||
const handleMCPRouterConfigChange = (
|
||||
key: 'apiKey' | 'referer' | 'title' | 'baseUrl',
|
||||
value: string,
|
||||
) => {
|
||||
const handleMCPRouterConfigChange = (key: 'apiKey' | 'referer' | 'title' | 'baseUrl', value: string) => {
|
||||
setTempMCPRouterConfig({
|
||||
...tempMCPRouterConfig,
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
[key]: value
|
||||
});
|
||||
};
|
||||
|
||||
const saveMCPRouterConfig = async (key: 'apiKey' | 'referer' | 'title' | 'baseUrl') => {
|
||||
await updateMCPRouterConfig(key, tempMCPRouterConfig[key])
|
||||
}
|
||||
await updateMCPRouterConfig(key, tempMCPRouterConfig[key]);
|
||||
};
|
||||
|
||||
const saveNameSeparator = async () => {
|
||||
await updateNameSeparator(tempNameSeparator)
|
||||
}
|
||||
await updateNameSeparator(tempNameSeparator);
|
||||
};
|
||||
|
||||
const handleSmartRoutingEnabledChange = async (value: boolean) => {
|
||||
// If enabling Smart Routing, validate required fields and save any unsaved changes
|
||||
if (value) {
|
||||
const currentDbUrl = tempSmartRoutingConfig.dbUrl || smartRoutingConfig.dbUrl
|
||||
const currentOpenaiApiKey =
|
||||
tempSmartRoutingConfig.openaiApiKey || smartRoutingConfig.openaiApiKey
|
||||
const currentDbUrl = tempSmartRoutingConfig.dbUrl || smartRoutingConfig.dbUrl;
|
||||
const currentOpenaiApiKey = tempSmartRoutingConfig.openaiApiKey || smartRoutingConfig.openaiApiKey;
|
||||
|
||||
if (!currentDbUrl || !currentOpenaiApiKey) {
|
||||
const missingFields = []
|
||||
if (!currentDbUrl) missingFields.push(t('settings.dbUrl'))
|
||||
if (!currentOpenaiApiKey) missingFields.push(t('settings.openaiApiKey'))
|
||||
const missingFields = [];
|
||||
if (!currentDbUrl) missingFields.push(t('settings.dbUrl'));
|
||||
if (!currentOpenaiApiKey) missingFields.push(t('settings.openaiApiKey'));
|
||||
|
||||
showToast(
|
||||
t('settings.smartRoutingValidationError', {
|
||||
fields: missingFields.join(', '),
|
||||
}),
|
||||
)
|
||||
return
|
||||
showToast(t('settings.smartRoutingValidationError', {
|
||||
fields: missingFields.join(', ')
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare updates object with unsaved changes and enabled status
|
||||
const updates: any = { enabled: value }
|
||||
const updates: any = { enabled: value };
|
||||
|
||||
// Check for unsaved changes and include them in the batch update
|
||||
if (tempSmartRoutingConfig.dbUrl !== smartRoutingConfig.dbUrl) {
|
||||
updates.dbUrl = tempSmartRoutingConfig.dbUrl
|
||||
updates.dbUrl = tempSmartRoutingConfig.dbUrl;
|
||||
}
|
||||
if (tempSmartRoutingConfig.openaiApiBaseUrl !== smartRoutingConfig.openaiApiBaseUrl) {
|
||||
updates.openaiApiBaseUrl = tempSmartRoutingConfig.openaiApiBaseUrl
|
||||
updates.openaiApiBaseUrl = tempSmartRoutingConfig.openaiApiBaseUrl;
|
||||
}
|
||||
if (tempSmartRoutingConfig.openaiApiKey !== smartRoutingConfig.openaiApiKey) {
|
||||
updates.openaiApiKey = tempSmartRoutingConfig.openaiApiKey
|
||||
updates.openaiApiKey = tempSmartRoutingConfig.openaiApiKey;
|
||||
}
|
||||
if (
|
||||
tempSmartRoutingConfig.openaiApiEmbeddingModel !==
|
||||
smartRoutingConfig.openaiApiEmbeddingModel
|
||||
) {
|
||||
updates.openaiApiEmbeddingModel = tempSmartRoutingConfig.openaiApiEmbeddingModel
|
||||
if (tempSmartRoutingConfig.openaiApiEmbeddingModel !== smartRoutingConfig.openaiApiEmbeddingModel) {
|
||||
updates.openaiApiEmbeddingModel = tempSmartRoutingConfig.openaiApiEmbeddingModel;
|
||||
}
|
||||
|
||||
// Save all changes in a single batch update
|
||||
await updateSmartRoutingConfigBatch(updates)
|
||||
await updateSmartRoutingConfigBatch(updates);
|
||||
} else {
|
||||
// If disabling, just update the enabled status
|
||||
await updateSmartRoutingConfig('enabled', value)
|
||||
await updateSmartRoutingConfig('enabled', value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordChangeSuccess = () => {
|
||||
setTimeout(() => {
|
||||
navigate('/')
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const [copiedConfig, setCopiedConfig] = useState(false)
|
||||
const [mcpSettingsJson, setMcpSettingsJson] = useState<string>('')
|
||||
|
||||
const fetchMcpSettings = async () => {
|
||||
try {
|
||||
const result = await exportMCPSettings()
|
||||
console.log('Fetched MCP settings:', result)
|
||||
const configJson = JSON.stringify(result.data, null, 2)
|
||||
setMcpSettingsJson(configJson)
|
||||
} catch (error) {
|
||||
console.error('Error fetching MCP settings:', error)
|
||||
showToast(t('settings.exportError') || 'Failed to fetch settings', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (sectionsVisible.exportConfig && !mcpSettingsJson) {
|
||||
fetchMcpSettings()
|
||||
}
|
||||
}, [sectionsVisible.exportConfig])
|
||||
|
||||
const handleCopyConfig = async () => {
|
||||
if (!mcpSettingsJson) return
|
||||
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(mcpSettingsJson)
|
||||
setCopiedConfig(true)
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
|
||||
setTimeout(() => setCopiedConfig(false), 2000)
|
||||
} else {
|
||||
// Fallback for HTTP or unsupported clipboard API
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = mcpSettingsJson
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-9999px'
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
setCopiedConfig(true)
|
||||
showToast(t('common.copySuccess') || 'Copied to clipboard', 'success')
|
||||
setTimeout(() => setCopiedConfig(false), 2000)
|
||||
} catch (err) {
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error')
|
||||
console.error('Copy to clipboard failed:', err)
|
||||
}
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error copying configuration:', error)
|
||||
showToast(t('common.copyFailed') || 'Copy failed', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadConfig = () => {
|
||||
if (!mcpSettingsJson) return
|
||||
|
||||
const blob = new Blob([mcpSettingsJson], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'mcp_settings.json'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
showToast(t('settings.exportSuccess') || 'Settings exported successfully', 'success')
|
||||
}
|
||||
navigate('/');
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto">
|
||||
@@ -373,9 +265,7 @@ const SettingsPage: React.FC = () => {
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableSmartRouting')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.enableSmartRoutingDescription')}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{t('settings.enableSmartRoutingDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
@@ -387,8 +277,7 @@ const SettingsPage: React.FC = () => {
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">
|
||||
<span className="text-red-500 px-1">*</span>
|
||||
{t('settings.dbUrl')}
|
||||
<span className="text-red-500 px-1">*</span>{t('settings.dbUrl')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -413,8 +302,7 @@ const SettingsPage: React.FC = () => {
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">
|
||||
<span className="text-red-500 px-1">*</span>
|
||||
{t('settings.openaiApiKey')}
|
||||
<span className="text-red-500 px-1">*</span>{t('settings.openaiApiKey')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -444,9 +332,7 @@ const SettingsPage: React.FC = () => {
|
||||
<input
|
||||
type="text"
|
||||
value={tempSmartRoutingConfig.openaiApiBaseUrl}
|
||||
onChange={(e) =>
|
||||
handleSmartRoutingConfigChange('openaiApiBaseUrl', e.target.value)
|
||||
}
|
||||
onChange={(e) => handleSmartRoutingConfigChange('openaiApiBaseUrl', e.target.value)}
|
||||
placeholder={t('settings.openaiApiBaseUrlPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading}
|
||||
@@ -463,17 +349,13 @@ const SettingsPage: React.FC = () => {
|
||||
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">
|
||||
{t('settings.openaiApiEmbeddingModel')}
|
||||
</h3>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.openaiApiEmbeddingModel')}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={tempSmartRoutingConfig.openaiApiEmbeddingModel}
|
||||
onChange={(e) =>
|
||||
handleSmartRoutingConfigChange('openaiApiEmbeddingModel', e.target.value)
|
||||
}
|
||||
onChange={(e) => handleSmartRoutingConfigChange('openaiApiEmbeddingModel', e.target.value)}
|
||||
placeholder={t('settings.openaiApiEmbeddingModelPlaceholder')}
|
||||
className="flex-1 mt-1 block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm form-input"
|
||||
disabled={loading}
|
||||
@@ -510,9 +392,7 @@ const SettingsPage: React.FC = () => {
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.mcpRouterApiKey')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.mcpRouterApiKeyDescription')}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{t('settings.mcpRouterApiKeyDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
@@ -536,9 +416,7 @@ const SettingsPage: React.FC = () => {
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-2">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.mcpRouterBaseUrl')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.mcpRouterBaseUrlDescription')}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{t('settings.mcpRouterBaseUrlDescription')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
@@ -570,7 +448,9 @@ const SettingsPage: React.FC = () => {
|
||||
onClick={() => toggleSection('nameSeparator')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('settings.systemSettings')}</h2>
|
||||
<span className="text-gray-500">{sectionsVisible.nameSeparator ? '▼' : '►'}</span>
|
||||
<span className="text-gray-500">
|
||||
{sectionsVisible.nameSeparator ? '▼' : '►'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.nameSeparator && (
|
||||
@@ -610,7 +490,9 @@ const SettingsPage: React.FC = () => {
|
||||
onClick={() => toggleSection('routingConfig')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('pages.settings.routeConfig')}</h2>
|
||||
<span className="text-gray-500">{sectionsVisible.routingConfig ? '▼' : '►'}</span>
|
||||
<span className="text-gray-500">
|
||||
{sectionsVisible.routingConfig ? '▼' : '►'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.routingConfig && (
|
||||
@@ -623,9 +505,7 @@ const SettingsPage: React.FC = () => {
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableBearerAuth}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableBearerAuth', checked)
|
||||
}
|
||||
onCheckedChange={(checked) => handleRoutingConfigChange('enableBearerAuth', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -658,32 +538,24 @@ const SettingsPage: React.FC = () => {
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableGlobalRoute')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.enableGlobalRouteDescription')}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{t('settings.enableGlobalRouteDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableGlobalRoute}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableGlobalRoute', checked)
|
||||
}
|
||||
onCheckedChange={(checked) => handleRoutingConfigChange('enableGlobalRoute', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-md">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700">{t('settings.enableGroupNameRoute')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.enableGroupNameRouteDescription')}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{t('settings.enableGroupNameRouteDescription')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
disabled={loading}
|
||||
checked={routingConfig.enableGroupNameRoute}
|
||||
onCheckedChange={(checked) =>
|
||||
handleRoutingConfigChange('enableGroupNameRoute', checked)
|
||||
}
|
||||
onCheckedChange={(checked) => handleRoutingConfigChange('enableGroupNameRoute', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -700,6 +572,7 @@ const SettingsPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</PermissionChecker>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -712,7 +585,9 @@ const SettingsPage: React.FC = () => {
|
||||
onClick={() => toggleSection('installConfig')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('settings.installConfig')}</h2>
|
||||
<span className="text-gray-500">{sectionsVisible.installConfig ? '▼' : '►'}</span>
|
||||
<span className="text-gray-500">
|
||||
{sectionsVisible.installConfig ? '▼' : '►'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.installConfig && (
|
||||
@@ -794,14 +669,15 @@ const SettingsPage: React.FC = () => {
|
||||
</PermissionChecker>
|
||||
|
||||
{/* Change Password */}
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card" data-section="password">
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('password')}
|
||||
role="button"
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('auth.changePassword')}</h2>
|
||||
<span className="text-gray-500">{sectionsVisible.password ? '▼' : '►'}</span>
|
||||
<span className="text-gray-500">
|
||||
{sectionsVisible.password ? '▼' : '►'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.password && (
|
||||
@@ -810,61 +686,8 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
};
|
||||
|
||||
{/* Export MCP Settings */}
|
||||
<PermissionChecker permissions={PERMISSIONS.SETTINGS_EXPORT_CONFIG}>
|
||||
<div className="bg-white shadow rounded-lg py-4 px-6 mb-6 dashboard-card">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => toggleSection('exportConfig')}
|
||||
>
|
||||
<h2 className="font-semibold text-gray-800">{t('settings.exportMcpSettings')}</h2>
|
||||
<span className="text-gray-500">{sectionsVisible.exportConfig ? '▼' : '►'}</span>
|
||||
</div>
|
||||
|
||||
{sectionsVisible.exportConfig && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="mb-4">
|
||||
<h3 className="font-medium text-gray-700">{t('settings.mcpSettingsJson')}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('settings.mcpSettingsJsonDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleCopyConfig}
|
||||
disabled={!mcpSettingsJson}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
{copiedConfig ? <Check size={16} /> : <Copy size={16} />}
|
||||
{copiedConfig ? t('common.copied') : t('settings.copyToClipboard')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownloadConfig}
|
||||
disabled={!mcpSettingsJson}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md text-sm font-medium disabled:opacity-50 btn-primary"
|
||||
>
|
||||
<Download size={16} />
|
||||
{t('settings.downloadJson')}
|
||||
</button>
|
||||
</div>
|
||||
{mcpSettingsJson && (
|
||||
<div className="mt-3">
|
||||
<pre className="bg-gray-900 text-gray-100 p-4 rounded-md overflow-x-auto text-xs max-h-96">
|
||||
{mcpSettingsJson}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PermissionChecker>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SettingsPage
|
||||
export default SettingsPage;
|
||||
@@ -59,9 +59,8 @@ export const getPrompt = async (
|
||||
server?: string,
|
||||
): Promise<GetPromptResult> => {
|
||||
try {
|
||||
// URL-encode server and prompt names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
|
||||
const response = await apiPost(
|
||||
`/mcp/${encodeURIComponent(server || '')}/prompts/${encodeURIComponent(request.promptName)}`,
|
||||
`/mcp/${server}/prompts/${encodeURIComponent(request.promptName)}`,
|
||||
{
|
||||
name: request.promptName,
|
||||
arguments: request.arguments,
|
||||
@@ -95,13 +94,9 @@ export const togglePrompt = async (
|
||||
enabled: boolean,
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
// URL-encode server and prompt names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
|
||||
const response = await apiPost<any>(
|
||||
`/servers/${encodeURIComponent(serverName)}/prompts/${encodeURIComponent(promptName)}/toggle`,
|
||||
{
|
||||
enabled,
|
||||
},
|
||||
);
|
||||
const response = await apiPost<any>(`/servers/${serverName}/prompts/${promptName}/toggle`, {
|
||||
enabled,
|
||||
});
|
||||
|
||||
return {
|
||||
success: response.success,
|
||||
@@ -125,9 +120,8 @@ export const updatePromptDescription = async (
|
||||
description: string,
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
// URL-encode server and prompt names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
|
||||
const response = await apiPut<any>(
|
||||
`/servers/${encodeURIComponent(serverName)}/prompts/${encodeURIComponent(promptName)}/description`,
|
||||
`/servers/${serverName}/prompts/${promptName}/description`,
|
||||
{ description },
|
||||
{
|
||||
headers: {
|
||||
|
||||
@@ -25,10 +25,7 @@ export const callTool = async (
|
||||
): Promise<ToolCallResult> => {
|
||||
try {
|
||||
// Construct the URL with optional server parameter
|
||||
// URL-encode server and tool names to handle slashes in names (e.g., "com.atlassian/atlassian-mcp-server")
|
||||
const url = server
|
||||
? `/tools/${encodeURIComponent(server)}/${encodeURIComponent(request.toolName)}`
|
||||
: '/tools/call';
|
||||
const url = server ? `/tools/${server}/${request.toolName}` : '/tools/call';
|
||||
|
||||
const response = await apiPost<any>(url, request.arguments, {
|
||||
headers: {
|
||||
@@ -65,9 +62,8 @@ export const toggleTool = async (
|
||||
enabled: boolean,
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
// URL-encode server and tool names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
|
||||
const response = await apiPost<any>(
|
||||
`/servers/${encodeURIComponent(serverName)}/tools/${encodeURIComponent(toolName)}/toggle`,
|
||||
`/servers/${serverName}/tools/${toolName}/toggle`,
|
||||
{ enabled },
|
||||
{
|
||||
headers: {
|
||||
@@ -98,9 +94,8 @@ export const updateToolDescription = async (
|
||||
description: string,
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
// URL-encode server and tool names to handle slashes (e.g., "com.atlassian/atlassian-mcp-server")
|
||||
const response = await apiPut<any>(
|
||||
`/servers/${encodeURIComponent(serverName)}/tools/${encodeURIComponent(toolName)}/description`,
|
||||
`/servers/${serverName}/tools/${toolName}/description`,
|
||||
{ description },
|
||||
{
|
||||
headers: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Server status types
|
||||
export type ServerStatus = 'connecting' | 'connected' | 'disconnected' | 'oauth_required';
|
||||
export type ServerStatus = 'connecting' | 'connected' | 'disconnected';
|
||||
|
||||
// Market server types
|
||||
export interface MarketServerRepository {
|
||||
@@ -121,43 +121,6 @@ export interface ServerConfig {
|
||||
resetTimeoutOnProgress?: boolean; // Reset timeout on progress notifications
|
||||
maxTotalTimeout?: number; // Maximum total timeout in milliseconds
|
||||
}; // MCP request options configuration
|
||||
// OAuth authentication for upstream MCP servers
|
||||
oauth?: {
|
||||
clientId?: string; // OAuth client ID
|
||||
clientSecret?: string; // OAuth client secret
|
||||
scopes?: string[]; // Required OAuth scopes
|
||||
accessToken?: string; // Pre-obtained access token (if available)
|
||||
refreshToken?: string; // Refresh token for renewing access
|
||||
dynamicRegistration?: {
|
||||
enabled?: boolean; // Enable/disable dynamic registration
|
||||
issuer?: string; // OAuth issuer URL for discovery
|
||||
registrationEndpoint?: string; // Direct registration endpoint URL
|
||||
metadata?: {
|
||||
client_name?: string;
|
||||
client_uri?: string;
|
||||
logo_uri?: string;
|
||||
scope?: string;
|
||||
redirect_uris?: string[];
|
||||
grant_types?: string[];
|
||||
response_types?: string[];
|
||||
token_endpoint_auth_method?: string;
|
||||
contacts?: string[];
|
||||
software_id?: string;
|
||||
software_version?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
initialAccessToken?: string;
|
||||
};
|
||||
resource?: string; // OAuth resource parameter (RFC8707)
|
||||
authorizationEndpoint?: string; // Authorization endpoint (authorization code flow)
|
||||
tokenEndpoint?: string; // Token endpoint for exchanging authorization codes for tokens
|
||||
pendingAuthorization?: {
|
||||
authorizationUrl?: string;
|
||||
state?: string;
|
||||
codeVerifier?: string;
|
||||
createdAt?: number;
|
||||
};
|
||||
};
|
||||
// OpenAPI specific configuration
|
||||
openapi?: {
|
||||
url?: string; // OpenAPI specification URL
|
||||
@@ -209,10 +172,6 @@ export interface Server {
|
||||
prompts?: Prompt[];
|
||||
config?: ServerConfig;
|
||||
enabled?: boolean;
|
||||
oauth?: {
|
||||
authorizationUrl?: string;
|
||||
state?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Group types
|
||||
@@ -250,16 +209,6 @@ export interface ServerFormData {
|
||||
resetTimeoutOnProgress?: boolean;
|
||||
maxTotalTimeout?: number;
|
||||
};
|
||||
oauth?: {
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
scopes?: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
authorizationEndpoint?: string;
|
||||
tokenEndpoint?: string;
|
||||
resource?: string;
|
||||
};
|
||||
// OpenAPI specific fields
|
||||
openapi?: {
|
||||
url?: string;
|
||||
@@ -359,150 +308,4 @@ export interface AuthResponse {
|
||||
token?: string;
|
||||
user?: IUser;
|
||||
message?: string;
|
||||
isUsingDefaultPassword?: boolean;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* Frontend password strength validation utility
|
||||
* Should match backend validation rules
|
||||
*/
|
||||
|
||||
export interface PasswordValidationResult {
|
||||
isValid: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export const validatePasswordStrength = (password: string): PasswordValidationResult => {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Check minimum length
|
||||
if (password.length < 8) {
|
||||
errors.push('passwordMinLength');
|
||||
}
|
||||
|
||||
// Check for at least one letter
|
||||
if (!/[a-zA-Z]/.test(password)) {
|
||||
errors.push('passwordRequireLetter');
|
||||
}
|
||||
|
||||
// Check for at least one number
|
||||
if (!/\d/.test(password)) {
|
||||
errors.push('passwordRequireNumber');
|
||||
}
|
||||
|
||||
// Check for at least one special character
|
||||
if (!/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password)) {
|
||||
errors.push('passwordRequireSpecial');
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
};
|
||||
@@ -40,7 +40,7 @@ module.exports = {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||
},
|
||||
transformIgnorePatterns: ['node_modules/(?!(@modelcontextprotocol|openid-client|oauth4webapi)/)'],
|
||||
transformIgnorePatterns: ['node_modules/(?!(@modelcontextprotocol|other-esm-packages)/)'],
|
||||
extensionsToTreatAsEsm: ['.ts'],
|
||||
testTimeout: 30000,
|
||||
verbose: true,
|
||||
|
||||
130
locales/en.json
130
locales/en.json
@@ -69,22 +69,12 @@
|
||||
"changePasswordError": "Failed to change password",
|
||||
"changePassword": "Change Password",
|
||||
"passwordChanged": "Password changed successfully",
|
||||
"passwordChangeError": "Failed to change password",
|
||||
"defaultPasswordWarning": "Default Password Security Warning",
|
||||
"defaultPasswordMessage": "You are using the default password (admin123), which poses a security risk. Please change your password immediately to protect your account.",
|
||||
"goToSettings": "Go to Settings",
|
||||
"passwordStrengthError": "Password does not meet security requirements",
|
||||
"passwordMinLength": "Password must be at least 8 characters long",
|
||||
"passwordRequireLetter": "Password must contain at least one letter",
|
||||
"passwordRequireNumber": "Password must contain at least one number",
|
||||
"passwordRequireSpecial": "Password must contain at least one special character",
|
||||
"passwordStrengthHint": "Password must be at least 8 characters and contain letters, numbers, and special characters"
|
||||
"passwordChangeError": "Failed to change password"
|
||||
},
|
||||
"server": {
|
||||
"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.",
|
||||
@@ -116,7 +106,7 @@
|
||||
"enabled": "Enabled",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"requestOptions": "Connection Configuration",
|
||||
"requestOptions": "Configuration",
|
||||
"timeout": "Request Timeout",
|
||||
"timeoutDescription": "Timeout for requests to the MCP server (ms)",
|
||||
"maxTotalTimeout": "Maximum Total Timeout",
|
||||
@@ -134,7 +124,6 @@
|
||||
"argumentsPlaceholder": "Enter arguments",
|
||||
"errorDetails": "Error Details",
|
||||
"viewErrorDetails": "View error details",
|
||||
"copyConfig": "Copy Configuration",
|
||||
"confirmVariables": "Confirm Variable Configuration",
|
||||
"variablesDetected": "Variables detected in configuration. Please confirm these variables are properly configured:",
|
||||
"detectedVariables": "Detected Variables",
|
||||
@@ -173,28 +162,12 @@
|
||||
"apiKeyInCookie": "Cookie",
|
||||
"passthroughHeaders": "Passthrough Headers",
|
||||
"passthroughHeadersHelp": "Comma-separated list of header names to pass through from tool call requests to upstream OpenAPI endpoints (e.g., Authorization, X-API-Key)"
|
||||
},
|
||||
"oauth": {
|
||||
"sectionTitle": "OAuth Configuration",
|
||||
"sectionDescription": "Configure client credentials for OAuth-protected servers (optional).",
|
||||
"clientId": "Client ID",
|
||||
"clientSecret": "Client Secret",
|
||||
"authorizationEndpoint": "Authorization Endpoint",
|
||||
"tokenEndpoint": "Token Endpoint",
|
||||
"scopes": "Scopes",
|
||||
"scopesPlaceholder": "scope1 scope2",
|
||||
"resource": "Resource / Audience",
|
||||
"accessToken": "Access Token",
|
||||
"refreshToken": "Refresh Token"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"connecting": "Connecting",
|
||||
"oauthRequired": "OAuth Required",
|
||||
"clickToAuthorize": "Click to authorize with OAuth",
|
||||
"oauthWindowOpened": "OAuth authorization window opened. Please complete the authorization."
|
||||
"connecting": "Connecting"
|
||||
},
|
||||
"errors": {
|
||||
"general": "Something went wrong",
|
||||
@@ -213,7 +186,6 @@
|
||||
"processing": "Processing...",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"back": "Back",
|
||||
"refresh": "Refresh",
|
||||
"create": "Create",
|
||||
"creating": "Creating...",
|
||||
@@ -228,7 +200,6 @@
|
||||
"copyJson": "Copy JSON",
|
||||
"copySuccess": "Copied to clipboard",
|
||||
"copyFailed": "Copy failed",
|
||||
"copied": "Copied",
|
||||
"close": "Close",
|
||||
"confirm": "Confirm",
|
||||
"language": "Language",
|
||||
@@ -237,15 +208,7 @@
|
||||
"dismiss": "Dismiss",
|
||||
"github": "GitHub",
|
||||
"wechat": "WeChat",
|
||||
"discord": "Discord",
|
||||
"required": "Required",
|
||||
"secret": "Secret",
|
||||
"default": "Default",
|
||||
"value": "Value",
|
||||
"type": "Type",
|
||||
"repeated": "Repeated",
|
||||
"valueHint": "Value Hint",
|
||||
"choices": "Choices"
|
||||
"discord": "Discord"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -435,41 +398,6 @@
|
||||
"installSuccess": "Server {{name}} installed successfully",
|
||||
"installError": "Failed to install server: {{error}}"
|
||||
},
|
||||
"registry": {
|
||||
"title": "Registry",
|
||||
"official": "Official",
|
||||
"latest": "Latest",
|
||||
"description": "Description",
|
||||
"website": "Website",
|
||||
"repository": "Repository",
|
||||
"packages": "Packages",
|
||||
"package": "package",
|
||||
"remotes": "Remotes",
|
||||
"remote": "remote",
|
||||
"published": "Published",
|
||||
"updated": "Updated",
|
||||
"install": "Install",
|
||||
"installing": "Installing...",
|
||||
"installed": "Installed",
|
||||
"installServer": "Install {{name}}",
|
||||
"installSuccess": "Server {{name}} installed successfully",
|
||||
"installError": "Failed to install server: {{error}}",
|
||||
"noDescription": "No description available",
|
||||
"viewDetails": "View Details",
|
||||
"backToList": "Back to Registry",
|
||||
"search": "Search",
|
||||
"searchPlaceholder": "Search registry servers by name",
|
||||
"clearFilters": "Clear",
|
||||
"noServers": "No registry servers found",
|
||||
"fetchError": "Error fetching registry servers",
|
||||
"serverNotFound": "Registry server not found",
|
||||
"showing": "Showing {{from}}-{{to}} of {{total}} registry servers",
|
||||
"perPage": "Per page",
|
||||
"environmentVariables": "Environment Variables",
|
||||
"packageArguments": "Package Arguments",
|
||||
"runtimeArguments": "Runtime Arguments",
|
||||
"headers": "Headers"
|
||||
},
|
||||
"tool": {
|
||||
"run": "Run",
|
||||
"running": "Running...",
|
||||
@@ -574,14 +502,7 @@
|
||||
"systemSettings": "System Settings",
|
||||
"nameSeparatorLabel": "Name Separator",
|
||||
"nameSeparatorDescription": "Character used to separate server name and tool/prompt name (default: -)",
|
||||
"restartRequired": "Configuration saved. It is recommended to restart the application to ensure all services load the new settings correctly.",
|
||||
"exportMcpSettings": "Export Settings",
|
||||
"mcpSettingsJson": "MCP Settings JSON",
|
||||
"mcpSettingsJsonDescription": "View, copy, or download your current mcp_settings.json configuration for backup or migration to other tools",
|
||||
"copyToClipboard": "Copy to Clipboard",
|
||||
"downloadJson": "Download JSON",
|
||||
"exportSuccess": "Settings exported successfully",
|
||||
"exportError": "Failed to fetch settings"
|
||||
"restartRequired": "Configuration saved. It is recommended to restart the application to ensure all services load the new settings correctly."
|
||||
},
|
||||
"dxt": {
|
||||
"upload": "Upload",
|
||||
@@ -608,21 +529,6 @@
|
||||
"serverExistsConfirm": "Server '{{serverName}}' already exists. Do you want to override it with the new version?",
|
||||
"override": "Override"
|
||||
},
|
||||
"jsonImport": {
|
||||
"button": "Import",
|
||||
"title": "Import Servers from JSON",
|
||||
"inputLabel": "Server Configuration JSON",
|
||||
"inputHelp": "Paste your server configuration JSON. Supports STDIO, SSE, and HTTP (streamable-http) server types.",
|
||||
"preview": "Preview",
|
||||
"previewTitle": "Preview Servers to Import",
|
||||
"import": "Import",
|
||||
"importing": "Importing...",
|
||||
"invalidFormat": "Invalid JSON format. The JSON must contain an 'mcpServers' object.",
|
||||
"parseError": "Failed to parse JSON. Please check the format and try again.",
|
||||
"addFailed": "Failed to add server",
|
||||
"importFailed": "Failed to import servers",
|
||||
"partialSuccess": "Imported {{count}} of {{total}} servers successfully. Some servers failed:"
|
||||
},
|
||||
"users": {
|
||||
"add": "Add User",
|
||||
"addNew": "Add New User",
|
||||
@@ -717,31 +623,5 @@
|
||||
"serverRemovedFromGroup": "Server removed from group successfully",
|
||||
"serverToolsUpdated": "Server tools updated successfully"
|
||||
}
|
||||
},
|
||||
"oauthCallback": {
|
||||
"authorizationFailed": "Authorization Failed",
|
||||
"authorizationFailedError": "Error",
|
||||
"authorizationFailedDetails": "Details",
|
||||
"invalidRequest": "Invalid Request",
|
||||
"missingStateParameter": "Missing required OAuth state parameter.",
|
||||
"missingCodeParameter": "Missing required authorization code parameter.",
|
||||
"serverNotFound": "Server Not Found",
|
||||
"serverNotFoundMessage": "Could not find server associated with this authorization request.",
|
||||
"sessionExpiredMessage": "The authorization session may have expired. Please try authorizing again.",
|
||||
"authorizationSuccessful": "Authorization Successful",
|
||||
"server": "Server",
|
||||
"status": "Status",
|
||||
"connected": "Connected",
|
||||
"successMessage": "The server has been successfully authorized and connected.",
|
||||
"autoCloseMessage": "This window will close automatically in 3 seconds...",
|
||||
"closeNow": "Close Now",
|
||||
"connectionError": "Connection Error",
|
||||
"connectionErrorMessage": "Authorization was successful, but failed to connect to the server.",
|
||||
"reconnectMessage": "Please try reconnecting from the dashboard.",
|
||||
"configurationError": "Configuration Error",
|
||||
"configurationErrorMessage": "Server transport does not support OAuth finishAuth(). Please ensure the server is configured with streamable-http transport.",
|
||||
"internalError": "Internal Error",
|
||||
"internalErrorMessage": "An unexpected error occurred while processing the OAuth callback.",
|
||||
"closeWindow": "Close Window"
|
||||
}
|
||||
}
|
||||
130
locales/fr.json
130
locales/fr.json
@@ -69,22 +69,12 @@
|
||||
"changePasswordError": "Échec du changement de mot de passe",
|
||||
"changePassword": "Changer le mot de passe",
|
||||
"passwordChanged": "Mot de passe changé avec succès",
|
||||
"passwordChangeError": "Échec du changement de mot de passe",
|
||||
"defaultPasswordWarning": "Avertissement de sécurité du mot de passe par défaut",
|
||||
"defaultPasswordMessage": "Vous utilisez le mot de passe par défaut (admin123), ce qui présente un risque de sécurité. Veuillez changer votre mot de passe immédiatement pour protéger votre compte.",
|
||||
"goToSettings": "Aller aux paramètres",
|
||||
"passwordStrengthError": "Le mot de passe ne répond pas aux exigences de sécurité",
|
||||
"passwordMinLength": "Le mot de passe doit contenir au moins 8 caractères",
|
||||
"passwordRequireLetter": "Le mot de passe doit contenir au moins une lettre",
|
||||
"passwordRequireNumber": "Le mot de passe doit contenir au moins un chiffre",
|
||||
"passwordRequireSpecial": "Le mot de passe doit contenir au moins un caractère spécial",
|
||||
"passwordStrengthHint": "Le mot de passe doit contenir au moins 8 caractères et inclure des lettres, des chiffres et des caractères spéciaux"
|
||||
"passwordChangeError": "Échec du changement de mot de passe"
|
||||
},
|
||||
"server": {
|
||||
"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.",
|
||||
@@ -116,7 +106,7 @@
|
||||
"enabled": "Activé",
|
||||
"enable": "Activer",
|
||||
"disable": "Désactiver",
|
||||
"requestOptions": "Configuration de la connexion",
|
||||
"requestOptions": "Configuration",
|
||||
"timeout": "Délai d'attente de la requête",
|
||||
"timeoutDescription": "Délai d'attente pour les requêtes vers le serveur MCP (ms)",
|
||||
"maxTotalTimeout": "Délai d'attente total maximum",
|
||||
@@ -134,7 +124,6 @@
|
||||
"argumentsPlaceholder": "Entrez les arguments",
|
||||
"errorDetails": "Détails de l'erreur",
|
||||
"viewErrorDetails": "Voir les détails de l'erreur",
|
||||
"copyConfig": "Copier la configuration",
|
||||
"confirmVariables": "Confirmer la configuration des variables",
|
||||
"variablesDetected": "Variables détectées dans la configuration. Veuillez confirmer que ces variables sont correctement configurées :",
|
||||
"detectedVariables": "Variables détectées",
|
||||
@@ -173,28 +162,12 @@
|
||||
"apiKeyInCookie": "Cookie",
|
||||
"passthroughHeaders": "En-têtes de transmission",
|
||||
"passthroughHeadersHelp": "Liste séparée par des virgules des noms d'en-têtes à transmettre des requêtes d'appel d'outils vers les points de terminaison OpenAPI en amont (par ex. : Authorization, X-API-Key)"
|
||||
},
|
||||
"oauth": {
|
||||
"sectionTitle": "Configuration OAuth",
|
||||
"sectionDescription": "Configurez les identifiants client pour les serveurs protégés par OAuth (optionnel).",
|
||||
"clientId": "Identifiant client",
|
||||
"clientSecret": "Secret client",
|
||||
"authorizationEndpoint": "Point de terminaison d'autorisation",
|
||||
"tokenEndpoint": "Point de terminaison de jeton",
|
||||
"scopes": "Scopes",
|
||||
"scopesPlaceholder": "scope1 scope2",
|
||||
"resource": "Ressource / Audience",
|
||||
"accessToken": "Jeton d'accès",
|
||||
"refreshToken": "Jeton d'actualisation"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"online": "En ligne",
|
||||
"offline": "Hors ligne",
|
||||
"connecting": "Connexion en cours",
|
||||
"oauthRequired": "OAuth requis",
|
||||
"clickToAuthorize": "Cliquez pour autoriser avec OAuth",
|
||||
"oauthWindowOpened": "Fenêtre d'autorisation OAuth ouverte. Veuillez compléter l'autorisation."
|
||||
"connecting": "Connexion en cours"
|
||||
},
|
||||
"errors": {
|
||||
"general": "Une erreur est survenue",
|
||||
@@ -213,7 +186,6 @@
|
||||
"processing": "En cours de traitement...",
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
"back": "Retour",
|
||||
"refresh": "Actualiser",
|
||||
"create": "Créer",
|
||||
"creating": "Création en cours...",
|
||||
@@ -228,7 +200,6 @@
|
||||
"copyJson": "Copier le JSON",
|
||||
"copySuccess": "Copié dans le presse-papiers",
|
||||
"copyFailed": "Échec de la copie",
|
||||
"copied": "Copié",
|
||||
"close": "Fermer",
|
||||
"confirm": "Confirmer",
|
||||
"language": "Langue",
|
||||
@@ -237,15 +208,7 @@
|
||||
"dismiss": "Rejeter",
|
||||
"github": "GitHub",
|
||||
"wechat": "WeChat",
|
||||
"discord": "Discord",
|
||||
"required": "Requis",
|
||||
"secret": "Secret",
|
||||
"default": "Défaut",
|
||||
"value": "Valeur",
|
||||
"type": "Type",
|
||||
"repeated": "Répété",
|
||||
"valueHint": "Indice de valeur",
|
||||
"choices": "Choix"
|
||||
"discord": "Discord"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Tableau de bord",
|
||||
@@ -435,41 +398,6 @@
|
||||
"installSuccess": "Serveur {{name}} installé avec succès",
|
||||
"installError": "Échec de l'installation du serveur : {{error}}"
|
||||
},
|
||||
"registry": {
|
||||
"title": "Registre",
|
||||
"official": "Officiel",
|
||||
"latest": "Dernière version",
|
||||
"description": "Description",
|
||||
"website": "Site web",
|
||||
"repository": "Dépôt",
|
||||
"packages": "Paquets",
|
||||
"package": "paquet",
|
||||
"remotes": "Services distants",
|
||||
"remote": "service distant",
|
||||
"published": "Publié",
|
||||
"updated": "Mis à jour",
|
||||
"install": "Installer",
|
||||
"installing": "Installation...",
|
||||
"installed": "Installé",
|
||||
"installServer": "Installer {{name}}",
|
||||
"installSuccess": "Serveur {{name}} installé avec succès",
|
||||
"installError": "Échec de l'installation du serveur : {{error}}",
|
||||
"noDescription": "Aucune description disponible",
|
||||
"viewDetails": "Voir les détails",
|
||||
"backToList": "Retour au registre",
|
||||
"search": "Rechercher",
|
||||
"searchPlaceholder": "Rechercher des serveurs par nom",
|
||||
"clearFilters": "Effacer",
|
||||
"noServers": "Aucun serveur trouvé dans le registre",
|
||||
"fetchError": "Erreur lors de la récupération des serveurs du registre",
|
||||
"serverNotFound": "Serveur du registre non trouvé",
|
||||
"showing": "Affichage de {{from}}-{{to}} sur {{total}} serveurs du registre",
|
||||
"perPage": "Par page",
|
||||
"environmentVariables": "Variables d'environnement",
|
||||
"packageArguments": "Arguments du paquet",
|
||||
"runtimeArguments": "Arguments d'exécution",
|
||||
"headers": "En-têtes"
|
||||
},
|
||||
"tool": {
|
||||
"run": "Exécuter",
|
||||
"running": "Exécution en cours...",
|
||||
@@ -574,14 +502,7 @@
|
||||
"systemSettings": "Paramètres système",
|
||||
"nameSeparatorLabel": "Séparateur de noms",
|
||||
"nameSeparatorDescription": "Caractère utilisé pour séparer le nom du serveur et le nom de l'outil/prompt (par défaut : -)",
|
||||
"restartRequired": "Configuration enregistrée. Il est recommandé de redémarrer l'application pour s'assurer que tous les services chargent correctement les nouveaux paramètres.",
|
||||
"exportMcpSettings": "Exporter les paramètres",
|
||||
"mcpSettingsJson": "JSON des paramètres MCP",
|
||||
"mcpSettingsJsonDescription": "Afficher, copier ou télécharger votre configuration mcp_settings.json actuelle pour la sauvegarde ou la migration vers d'autres outils",
|
||||
"copyToClipboard": "Copier dans le presse-papiers",
|
||||
"downloadJson": "Télécharger JSON",
|
||||
"exportSuccess": "Paramètres exportés avec succès",
|
||||
"exportError": "Échec de la récupération des paramètres"
|
||||
"restartRequired": "Configuration enregistrée. Il est recommandé de redémarrer l'application pour s'assurer que tous les services chargent correctement les nouveaux paramètres."
|
||||
},
|
||||
"dxt": {
|
||||
"upload": "Télécharger",
|
||||
@@ -608,21 +529,6 @@
|
||||
"serverExistsConfirm": "Le serveur '{{serverName}}' existe déjà. Voulez-vous le remplacer par la nouvelle version ?",
|
||||
"override": "Remplacer"
|
||||
},
|
||||
"jsonImport": {
|
||||
"button": "Importer",
|
||||
"title": "Importer des serveurs depuis JSON",
|
||||
"inputLabel": "Configuration JSON du serveur",
|
||||
"inputHelp": "Collez votre configuration JSON de serveur. Prend en charge les types de serveurs STDIO, SSE et HTTP (streamable-http).",
|
||||
"preview": "Aperçu",
|
||||
"previewTitle": "Aperçu des serveurs à importer",
|
||||
"import": "Importer",
|
||||
"importing": "Importation en cours...",
|
||||
"invalidFormat": "Format JSON invalide. Le JSON doit contenir un objet 'mcpServers'.",
|
||||
"parseError": "Échec de l'analyse du JSON. Veuillez vérifier le format et réessayer.",
|
||||
"addFailed": "Échec de l'ajout du serveur",
|
||||
"importFailed": "Échec de l'importation des serveurs",
|
||||
"partialSuccess": "{{count}} serveur(s) sur {{total}} importé(s) avec succès. Certains serveurs ont échoué :"
|
||||
},
|
||||
"users": {
|
||||
"add": "Ajouter un utilisateur",
|
||||
"addNew": "Ajouter un nouvel utilisateur",
|
||||
@@ -717,31 +623,5 @@
|
||||
"serverRemovedFromGroup": "Serveur supprimé du groupe avec succès",
|
||||
"serverToolsUpdated": "Outils du serveur mis à jour avec succès"
|
||||
}
|
||||
},
|
||||
"oauthCallback": {
|
||||
"authorizationFailed": "Échec de l'autorisation",
|
||||
"authorizationFailedError": "Erreur",
|
||||
"authorizationFailedDetails": "Détails",
|
||||
"invalidRequest": "Requête invalide",
|
||||
"missingStateParameter": "Paramètre d'état OAuth requis manquant.",
|
||||
"missingCodeParameter": "Paramètre de code d'autorisation requis manquant.",
|
||||
"serverNotFound": "Serveur introuvable",
|
||||
"serverNotFoundMessage": "Impossible de trouver le serveur associé à cette demande d'autorisation.",
|
||||
"sessionExpiredMessage": "La session d'autorisation a peut-être expiré. Veuillez réessayer l'autorisation.",
|
||||
"authorizationSuccessful": "Autorisation réussie",
|
||||
"server": "Serveur",
|
||||
"status": "État",
|
||||
"connected": "Connecté",
|
||||
"successMessage": "Le serveur a été autorisé et connecté avec succès.",
|
||||
"autoCloseMessage": "Cette fenêtre se fermera automatiquement dans 3 secondes...",
|
||||
"closeNow": "Fermer maintenant",
|
||||
"connectionError": "Erreur de connexion",
|
||||
"connectionErrorMessage": "L'autorisation a réussi, mais la connexion au serveur a échoué.",
|
||||
"reconnectMessage": "Veuillez essayer de vous reconnecter à partir du tableau de bord.",
|
||||
"configurationError": "Erreur de configuration",
|
||||
"configurationErrorMessage": "Le transport du serveur ne prend pas en charge OAuth finishAuth(). Veuillez vous assurer que le serveur est configuré avec le transport streamable-http.",
|
||||
"internalError": "Erreur interne",
|
||||
"internalErrorMessage": "Une erreur inattendue s'est produite lors du traitement du callback OAuth.",
|
||||
"closeWindow": "Fermer la fenêtre"
|
||||
}
|
||||
}
|
||||
130
locales/zh.json
130
locales/zh.json
@@ -69,22 +69,12 @@
|
||||
"changePasswordError": "修改密码失败",
|
||||
"changePassword": "修改密码",
|
||||
"passwordChanged": "密码修改成功",
|
||||
"passwordChangeError": "修改密码失败",
|
||||
"defaultPasswordWarning": "默认密码安全警告",
|
||||
"defaultPasswordMessage": "您正在使用默认密码(admin123),这存在安全风险。为了保护您的账户安全,请立即修改密码。",
|
||||
"goToSettings": "前往修改",
|
||||
"passwordStrengthError": "密码不符合安全要求",
|
||||
"passwordMinLength": "密码长度至少为 8 个字符",
|
||||
"passwordRequireLetter": "密码必须包含至少一个字母",
|
||||
"passwordRequireNumber": "密码必须包含至少一个数字",
|
||||
"passwordRequireSpecial": "密码必须包含至少一个特殊字符",
|
||||
"passwordStrengthHint": "密码必须至少 8 个字符,且包含字母、数字和特殊字符"
|
||||
"passwordChangeError": "修改密码失败"
|
||||
},
|
||||
"server": {
|
||||
"addServer": "添加服务器",
|
||||
"add": "添加",
|
||||
"edit": "编辑",
|
||||
"copy": "复制",
|
||||
"delete": "删除",
|
||||
"confirmDelete": "您确定要删除此服务器吗?",
|
||||
"deleteWarning": "删除服务器 '{{name}}' 将会移除该服务器及其所有数据。此操作无法撤销。",
|
||||
@@ -116,7 +106,7 @@
|
||||
"enabled": "已启用",
|
||||
"enable": "启用",
|
||||
"disable": "禁用",
|
||||
"requestOptions": "连接配置",
|
||||
"requestOptions": "配置",
|
||||
"timeout": "请求超时",
|
||||
"timeoutDescription": "请求超时时间(毫秒)",
|
||||
"maxTotalTimeout": "最大总超时",
|
||||
@@ -134,7 +124,6 @@
|
||||
"argumentsPlaceholder": "请输入参数",
|
||||
"errorDetails": "错误详情",
|
||||
"viewErrorDetails": "查看错误详情",
|
||||
"copyConfig": "复制配置",
|
||||
"confirmVariables": "确认变量配置",
|
||||
"variablesDetected": "检测到配置中包含变量,请确认这些变量是否已正确配置:",
|
||||
"detectedVariables": "检测到的变量",
|
||||
@@ -173,28 +162,12 @@
|
||||
"apiKeyInCookie": "Cookie",
|
||||
"passthroughHeaders": "透传请求头",
|
||||
"passthroughHeadersHelp": "要从工具调用请求透传到上游OpenAPI接口的请求头名称列表,用逗号分隔(如:Authorization, X-API-Key)"
|
||||
},
|
||||
"oauth": {
|
||||
"sectionTitle": "OAuth 配置",
|
||||
"sectionDescription": "为需要 OAuth 的服务器配置客户端凭据(可选)。",
|
||||
"clientId": "客户端 ID",
|
||||
"clientSecret": "客户端密钥",
|
||||
"authorizationEndpoint": "授权端点",
|
||||
"tokenEndpoint": "令牌端点",
|
||||
"scopes": "权限范围(Scopes)",
|
||||
"scopesPlaceholder": "scope1 scope2",
|
||||
"resource": "资源 / 受众",
|
||||
"accessToken": "访问令牌",
|
||||
"refreshToken": "刷新令牌"
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"online": "在线",
|
||||
"offline": "离线",
|
||||
"connecting": "连接中",
|
||||
"oauthRequired": "需要OAuth授权",
|
||||
"clickToAuthorize": "点击进行OAuth授权",
|
||||
"oauthWindowOpened": "OAuth授权窗口已打开,请完成授权。"
|
||||
"connecting": "连接中"
|
||||
},
|
||||
"errors": {
|
||||
"general": "发生错误",
|
||||
@@ -214,7 +187,6 @@
|
||||
"processing": "处理中...",
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"back": "返回",
|
||||
"refresh": "刷新",
|
||||
"create": "创建",
|
||||
"creating": "创建中...",
|
||||
@@ -229,7 +201,6 @@
|
||||
"copyJson": "复制JSON",
|
||||
"copySuccess": "已复制到剪贴板",
|
||||
"copyFailed": "复制失败",
|
||||
"copied": "已复制",
|
||||
"close": "关闭",
|
||||
"confirm": "确认",
|
||||
"language": "语言",
|
||||
@@ -238,15 +209,7 @@
|
||||
"dismiss": "忽略",
|
||||
"github": "GitHub",
|
||||
"wechat": "微信",
|
||||
"discord": "Discord",
|
||||
"required": "必填",
|
||||
"secret": "敏感",
|
||||
"default": "默认值",
|
||||
"value": "值",
|
||||
"type": "类型",
|
||||
"repeated": "可重复",
|
||||
"valueHint": "值提示",
|
||||
"choices": "可选值"
|
||||
"discord": "Discord"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "仪表盘",
|
||||
@@ -436,41 +399,6 @@
|
||||
"installSuccess": "服务器 {{name}} 安装成功",
|
||||
"installError": "安装服务器失败:{{error}}"
|
||||
},
|
||||
"registry": {
|
||||
"title": "注册中心",
|
||||
"official": "官方",
|
||||
"latest": "最新版本",
|
||||
"description": "描述",
|
||||
"website": "网站",
|
||||
"repository": "代码仓库",
|
||||
"packages": "安装包",
|
||||
"package": "安装包",
|
||||
"remotes": "远程服务",
|
||||
"remote": "远程服务",
|
||||
"published": "发布时间",
|
||||
"updated": "更新时间",
|
||||
"install": "安装",
|
||||
"installing": "安装中...",
|
||||
"installed": "已安装",
|
||||
"installServer": "安装 {{name}}",
|
||||
"installSuccess": "服务器 {{name}} 安装成功",
|
||||
"installError": "安装服务器失败:{{error}}",
|
||||
"noDescription": "无描述信息",
|
||||
"viewDetails": "查看详情",
|
||||
"backToList": "返回注册中心",
|
||||
"search": "搜索",
|
||||
"searchPlaceholder": "按名称搜索注册中心服务器",
|
||||
"clearFilters": "清除",
|
||||
"noServers": "未找到注册中心服务器",
|
||||
"fetchError": "获取注册中心服务器失败",
|
||||
"serverNotFound": "未找到注册中心服务器",
|
||||
"showing": "显示 {{from}}-{{to}}/{{total}} 个注册中心服务器",
|
||||
"perPage": "每页显示",
|
||||
"environmentVariables": "环境变量",
|
||||
"packageArguments": "安装包参数",
|
||||
"runtimeArguments": "运行时参数",
|
||||
"headers": "请求头"
|
||||
},
|
||||
"tool": {
|
||||
"run": "运行",
|
||||
"running": "运行中...",
|
||||
@@ -576,14 +504,7 @@
|
||||
"systemSettings": "系统设置",
|
||||
"nameSeparatorLabel": "名称分隔符",
|
||||
"nameSeparatorDescription": "用于分隔服务器名称和工具/提示名称(默认:-)",
|
||||
"restartRequired": "配置已保存。为确保所有服务正确加载新设置,建议重启应用。",
|
||||
"exportMcpSettings": "导出配置",
|
||||
"mcpSettingsJson": "MCP 配置 JSON",
|
||||
"mcpSettingsJsonDescription": "查看、复制或下载当前的 mcp_settings.json 配置,可用于备份或迁移到其他工具",
|
||||
"copyToClipboard": "复制到剪贴板",
|
||||
"downloadJson": "下载 JSON",
|
||||
"exportSuccess": "配置导出成功",
|
||||
"exportError": "获取配置失败"
|
||||
"restartRequired": "配置已保存。为确保所有服务正确加载新设置,建议重启应用。"
|
||||
},
|
||||
"dxt": {
|
||||
"upload": "上传",
|
||||
@@ -610,21 +531,6 @@
|
||||
"serverExistsConfirm": "服务器 '{{serverName}}' 已存在。是否要用新版本覆盖它?",
|
||||
"override": "覆盖"
|
||||
},
|
||||
"jsonImport": {
|
||||
"button": "导入",
|
||||
"title": "从 JSON 导入服务器",
|
||||
"inputLabel": "服务器配置 JSON",
|
||||
"inputHelp": "粘贴您的服务器配置 JSON。支持 STDIO、SSE 和 HTTP (streamable-http) 服务器类型。",
|
||||
"preview": "预览",
|
||||
"previewTitle": "预览要导入的服务器",
|
||||
"import": "导入",
|
||||
"importing": "导入中...",
|
||||
"invalidFormat": "无效的 JSON 格式。JSON 必须包含 'mcpServers' 对象。",
|
||||
"parseError": "解析 JSON 失败。请检查格式后重试。",
|
||||
"addFailed": "添加服务器失败",
|
||||
"importFailed": "导入服务器失败",
|
||||
"partialSuccess": "成功导入 {{count}} / {{total}} 个服务器。部分服务器失败:"
|
||||
},
|
||||
"users": {
|
||||
"add": "添加",
|
||||
"addNew": "添加新用户",
|
||||
@@ -719,31 +625,5 @@
|
||||
"serverRemovedFromGroup": "服务器从分组移除成功",
|
||||
"serverToolsUpdated": "服务器工具更新成功"
|
||||
}
|
||||
},
|
||||
"oauthCallback": {
|
||||
"authorizationFailed": "授权失败",
|
||||
"authorizationFailedError": "错误",
|
||||
"authorizationFailedDetails": "详情",
|
||||
"invalidRequest": "无效请求",
|
||||
"missingStateParameter": "缺少必需的 OAuth 状态参数。",
|
||||
"missingCodeParameter": "缺少必需的授权码参数。",
|
||||
"serverNotFound": "服务器未找到",
|
||||
"serverNotFoundMessage": "无法找到与此授权请求关联的服务器。",
|
||||
"sessionExpiredMessage": "授权会话可能已过期。请重新进行授权。",
|
||||
"authorizationSuccessful": "授权成功",
|
||||
"server": "服务器",
|
||||
"status": "状态",
|
||||
"connected": "已连接",
|
||||
"successMessage": "服务器已成功授权并连接。",
|
||||
"autoCloseMessage": "此窗口将在 3 秒后自动关闭...",
|
||||
"closeNow": "立即关闭",
|
||||
"connectionError": "连接错误",
|
||||
"connectionErrorMessage": "授权成功,但连接服务器失败。",
|
||||
"reconnectMessage": "请尝试从控制面板重新连接。",
|
||||
"configurationError": "配置错误",
|
||||
"configurationErrorMessage": "服务器传输不支持 OAuth finishAuth()。请确保服务器配置为 streamable-http 传输。",
|
||||
"internalError": "内部错误",
|
||||
"internalErrorMessage": "处理 OAuth 回调时发生意外错误。",
|
||||
"closeWindow": "关闭窗口"
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,10 @@
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"@playwright/mcp@latest",
|
||||
"--headless"
|
||||
]
|
||||
"--headless",
|
||||
"--isolated"
|
||||
],
|
||||
"perSession": true
|
||||
},
|
||||
"fetch": {
|
||||
"command": "uvx",
|
||||
|
||||
13310
package-lock.json
generated
Normal file
13310
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -46,7 +46,7 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@apidevtools/swagger-parser": "^12.0.0",
|
||||
"@modelcontextprotocol/sdk": "^1.20.2",
|
||||
"@modelcontextprotocol/sdk": "^1.18.1",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/multer": "^1.4.13",
|
||||
@@ -66,7 +66,6 @@
|
||||
"multer": "^2.0.2",
|
||||
"openai": "^4.104.0",
|
||||
"openapi-types": "^12.1.3",
|
||||
"openid-client": "^6.8.1",
|
||||
"pg": "^8.16.3",
|
||||
"pgvector": "^0.2.1",
|
||||
"postgres": "^3.4.7",
|
||||
|
||||
559
pnpm-lock.yaml
generated
559
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -33,17 +33,7 @@ export const loadOriginalSettings = (): McpSettings => {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
// Read and parse settings file
|
||||
const settingsData = fs.readFileSync(settingsPath, 'utf8');
|
||||
const settings = JSON.parse(settingsData);
|
||||
|
||||
@@ -52,8 +42,15 @@ export const loadOriginalSettings = (): McpSettings => {
|
||||
|
||||
console.log(`Loaded settings from ${settingsPath}`);
|
||||
return settings;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to load settings from ${settingsPath}: ${error}`);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`Failed to load settings from ${settingsPath}:`, errorMessage);
|
||||
const defaultSettings = { mcpServers: {}, users: [] };
|
||||
|
||||
// Cache default settings
|
||||
settingsCache = defaultSettings;
|
||||
|
||||
return defaultSettings;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,33 +96,22 @@ export function replaceEnvVars(input: string): string;
|
||||
export function replaceEnvVars(
|
||||
input: Record<string, any> | string[] | string | undefined,
|
||||
): Record<string, any> | string[] | string {
|
||||
// Handle object input - recursively expand all nested values
|
||||
// Handle object input
|
||||
if (input && typeof input === 'object' && !Array.isArray(input)) {
|
||||
const res: Record<string, any> = {};
|
||||
const res: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (typeof value === 'string') {
|
||||
res[key] = expandEnvVars(value);
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
// Recursively handle nested objects and arrays
|
||||
res[key] = replaceEnvVars(value as any);
|
||||
} else {
|
||||
// Preserve non-string, non-object values (numbers, booleans, etc.)
|
||||
res[key] = value;
|
||||
res[key] = String(value);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// Handle array input - recursively expand all elements
|
||||
// Handle array input
|
||||
if (Array.isArray(input)) {
|
||||
return input.map((item) => {
|
||||
if (typeof item === 'string') {
|
||||
return expandEnvVars(item);
|
||||
} else if (typeof item === 'object' && item !== null) {
|
||||
return replaceEnvVars(item as any);
|
||||
}
|
||||
return item;
|
||||
});
|
||||
return input.map((item) => expandEnvVars(item));
|
||||
}
|
||||
|
||||
// Handle string input
|
||||
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
import { getDataService } from '../services/services.js';
|
||||
import { DataService } from '../services/dataService.js';
|
||||
import { JWT_SECRET } from '../config/jwt.js';
|
||||
import { validatePasswordStrength, isDefaultPassword } from '../utils/passwordValidation.js';
|
||||
import { getPackageVersion } from '../utils/version.js';
|
||||
|
||||
const dataService: DataService = getDataService();
|
||||
|
||||
@@ -66,11 +64,6 @@ export const login = async (req: Request, res: Response): Promise<void> => {
|
||||
},
|
||||
};
|
||||
|
||||
// Check if user is admin with default password
|
||||
const version = getPackageVersion();
|
||||
const isUsingDefaultPassword =
|
||||
user.username === 'admin' && user.isAdmin && isDefaultPassword(password) && version !== 'dev';
|
||||
|
||||
jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY }, (err, token) => {
|
||||
if (err) throw err;
|
||||
res.json({
|
||||
@@ -82,7 +75,6 @@ export const login = async (req: Request, res: Response): Promise<void> => {
|
||||
isAdmin: user.isAdmin,
|
||||
permissions: dataService.getPermissions(user),
|
||||
},
|
||||
isUsingDefaultPassword,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -180,17 +172,6 @@ export const changePassword = async (req: Request, res: Response): Promise<void>
|
||||
const username = (req as any).user.username;
|
||||
|
||||
try {
|
||||
// Validate new password strength
|
||||
const validationResult = validatePasswordStrength(newPassword);
|
||||
if (!validationResult.isValid) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Password does not meet security requirements',
|
||||
errors: validationResult.errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Find user by username
|
||||
const user = findUserByUsername(username);
|
||||
|
||||
|
||||
@@ -207,8 +207,7 @@ export const getCloudServersByTag = async (req: Request, res: Response): Promise
|
||||
// Get tools for a specific cloud server
|
||||
export const getCloudServerToolsList = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Decode URL-encoded parameter to handle slashes in server name
|
||||
const serverName = decodeURIComponent(req.params.serverName);
|
||||
const { serverName } = req.params;
|
||||
if (!serverName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -237,9 +236,7 @@ export const getCloudServerToolsList = async (req: Request, res: Response): Prom
|
||||
// Call a tool on a cloud server
|
||||
export const callCloudTool = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Decode URL-encoded parameters to handle slashes in server/tool names
|
||||
const serverName = decodeURIComponent(req.params.serverName);
|
||||
const toolName = decodeURIComponent(req.params.toolName);
|
||||
const { serverName, toolName } = req.params;
|
||||
const { arguments: args } = req.body;
|
||||
|
||||
if (!serverName) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response } from 'express';
|
||||
import config from '../config/index.js';
|
||||
import { loadSettings, loadOriginalSettings } from '../config/index.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import { getDataService } from '../services/services.js';
|
||||
import { DataService } from '../services/dataService.js';
|
||||
import { IUser } from '../types/index.js';
|
||||
@@ -72,46 +72,3 @@ export const getPublicConfig = (req: Request, res: Response): void => {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get MCP settings in JSON format for export/copy
|
||||
* Supports both full settings and individual server configuration
|
||||
*/
|
||||
export const getMcpSettingsJson = (req: Request, res: Response): void => {
|
||||
try {
|
||||
const { serverName } = req.query;
|
||||
const settings = loadOriginalSettings();
|
||||
if (serverName && typeof serverName === 'string') {
|
||||
// Return individual server configuration
|
||||
const serverConfig = settings.mcpServers[serverName];
|
||||
if (!serverConfig) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: `Server '${serverName}' not found`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
mcpServers: {
|
||||
[serverName]: serverConfig,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Return full settings
|
||||
res.json({
|
||||
success: true,
|
||||
data: settings,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting MCP settings JSON:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to get MCP settings',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,388 +0,0 @@
|
||||
/**
|
||||
* OAuth Callback Controller
|
||||
*
|
||||
* Handles OAuth 2.0 authorization callbacks for upstream MCP servers.
|
||||
*
|
||||
* This controller implements a simplified callback flow that relies on the MCP SDK
|
||||
* to handle the complete OAuth token exchange:
|
||||
*
|
||||
* 1. Extract authorization code from callback URL
|
||||
* 2. Find the corresponding server using the state parameter
|
||||
* 3. Store the authorization code temporarily
|
||||
* 4. Reconnect the server - SDK's auth() function will:
|
||||
* - Automatically discover OAuth endpoints
|
||||
* - Exchange the code for tokens using PKCE
|
||||
* - Save tokens via our OAuthClientProvider.saveTokens()
|
||||
*/
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import {
|
||||
getServerByName,
|
||||
getServerByOAuthState,
|
||||
createTransportFromConfig,
|
||||
} from '../services/mcpService.js';
|
||||
import { getNameSeparator, loadSettings } from '../config/index.js';
|
||||
import type { ServerInfo } from '../types/index.js';
|
||||
|
||||
/**
|
||||
* Generate HTML response page with i18n support
|
||||
*/
|
||||
const generateHtmlResponse = (
|
||||
type: 'error' | 'success',
|
||||
title: string,
|
||||
message: string,
|
||||
details?: { label: string; value: string }[],
|
||||
autoClose: boolean = false,
|
||||
): string => {
|
||||
const backgroundColor = type === 'error' ? '#fee' : '#efe';
|
||||
const borderColor = type === 'error' ? '#fcc' : '#cfc';
|
||||
const titleColor = type === 'error' ? '#c33' : '#3c3';
|
||||
const buttonColor = type === 'error' ? '#c33' : '#3c3';
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>${title}</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
|
||||
.container { background-color: ${backgroundColor}; border: 1px solid ${borderColor}; padding: 20px; border-radius: 8px; }
|
||||
h1 { color: ${titleColor}; margin-top: 0; }
|
||||
.detail { margin-top: 10px; padding: 10px; background: #f9f9f9; border-radius: 4px; ${type === 'error' ? 'font-family: monospace; font-size: 12px; white-space: pre-wrap;' : ''} }
|
||||
.close-btn { margin-top: 20px; padding: 10px 20px; background: ${buttonColor}; color: white; border: none; border-radius: 4px; cursor: pointer; }
|
||||
</style>
|
||||
${autoClose ? '<script>setTimeout(() => { window.close(); }, 3000);</script>' : ''}
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>${type === 'success' ? '✓ ' : ''}${title}</h1>
|
||||
${details ? details.map((d) => `<div class="detail"><strong>${d.label}:</strong> ${d.value}</div>`).join('') : ''}
|
||||
<p>${message}</p>
|
||||
${autoClose ? '<p>This window will close automatically in 3 seconds...</p>' : ''}
|
||||
<button class="close-btn" onclick="window.close()">${autoClose ? 'Close Now' : 'Close Window'}</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
};
|
||||
|
||||
const normalizeQueryParam = (value: unknown): string | undefined => {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
const [first] = value;
|
||||
return typeof first === 'string' ? first : undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const extractServerNameFromState = (stateValue: string): string | undefined => {
|
||||
try {
|
||||
const normalized = stateValue.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padding = (4 - (normalized.length % 4)) % 4;
|
||||
const base64 = normalized + '='.repeat(padding);
|
||||
const decoded = Buffer.from(base64, 'base64').toString('utf8');
|
||||
const payload = JSON.parse(decoded);
|
||||
|
||||
if (payload && typeof payload.server === 'string') {
|
||||
return payload.server;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore decoding errors and fall back to delimiter-based parsing
|
||||
}
|
||||
|
||||
const separatorIndex = stateValue.indexOf(':');
|
||||
if (separatorIndex > 0) {
|
||||
return stateValue.slice(0, separatorIndex);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle OAuth callback after user authorization
|
||||
*
|
||||
* This endpoint receives the authorization code from the OAuth provider
|
||||
* and initiates the server reconnection process.
|
||||
*
|
||||
* Expected query parameters:
|
||||
* - code: Authorization code from OAuth provider
|
||||
* - state: Encoded server identifier used for OAuth session validation
|
||||
* - error: Optional error code if authorization failed
|
||||
* - error_description: Optional error description
|
||||
*/
|
||||
export const handleOAuthCallback = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { code, state, error, error_description } = req.query;
|
||||
const codeParam = normalizeQueryParam(code);
|
||||
const stateParam = normalizeQueryParam(state);
|
||||
|
||||
// Get translation function from request (set by i18n middleware)
|
||||
const t = (req as any).t || ((key: string) => key);
|
||||
|
||||
// Check for authorization errors
|
||||
if (error) {
|
||||
console.error(`OAuth authorization failed: ${error} - ${error_description || ''}`);
|
||||
return res.status(400).send(
|
||||
generateHtmlResponse('error', t('oauthCallback.authorizationFailed'), '', [
|
||||
{ label: t('oauthCallback.authorizationFailedError'), value: String(error) },
|
||||
...(error_description
|
||||
? [
|
||||
{
|
||||
label: t('oauthCallback.authorizationFailedDetails'),
|
||||
value: String(error_description),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
// Validate required parameters
|
||||
if (!stateParam) {
|
||||
console.error('OAuth callback missing state parameter');
|
||||
return res
|
||||
.status(400)
|
||||
.send(
|
||||
generateHtmlResponse(
|
||||
'error',
|
||||
t('oauthCallback.invalidRequest'),
|
||||
t('oauthCallback.missingStateParameter'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!codeParam) {
|
||||
console.error('OAuth callback missing authorization code');
|
||||
return res
|
||||
.status(400)
|
||||
.send(
|
||||
generateHtmlResponse(
|
||||
'error',
|
||||
t('oauthCallback.invalidRequest'),
|
||||
t('oauthCallback.missingCodeParameter'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`OAuth callback received - code: present, state: ${stateParam}`);
|
||||
|
||||
// Find server by state parameter
|
||||
let serverInfo: ServerInfo | undefined;
|
||||
|
||||
serverInfo = getServerByOAuthState(stateParam);
|
||||
|
||||
let decodedServerName: string | undefined;
|
||||
if (!serverInfo) {
|
||||
decodedServerName = extractServerNameFromState(stateParam);
|
||||
if (decodedServerName) {
|
||||
console.log(`State lookup failed; decoding server name from state: ${decodedServerName}`);
|
||||
serverInfo = getServerByName(decodedServerName);
|
||||
}
|
||||
}
|
||||
|
||||
if (!serverInfo) {
|
||||
console.error(
|
||||
`No server found for OAuth callback. State: ${stateParam}${
|
||||
decodedServerName ? `, decoded server: ${decodedServerName}` : ''
|
||||
}`,
|
||||
);
|
||||
return res
|
||||
.status(400)
|
||||
.send(
|
||||
generateHtmlResponse(
|
||||
'error',
|
||||
t('oauthCallback.serverNotFound'),
|
||||
`${t('oauthCallback.serverNotFoundMessage')}\n${t('oauthCallback.sessionExpiredMessage')}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Optional: Validate state parameter for additional security
|
||||
if (serverInfo.oauth?.state && serverInfo.oauth.state !== stateParam) {
|
||||
console.warn(
|
||||
`State mismatch for server ${serverInfo.name}. Expected: ${serverInfo.oauth.state}, Got: ${stateParam}`,
|
||||
);
|
||||
// Note: We log a warning but don't fail the request since we have server name as primary identifier
|
||||
}
|
||||
|
||||
console.log(`Processing OAuth callback for server: ${serverInfo.name}`);
|
||||
|
||||
// For StreamableHTTPClientTransport, we need to call finishAuth() on the transport
|
||||
// This will exchange the authorization code for tokens automatically
|
||||
if (serverInfo.transport && 'finishAuth' in serverInfo.transport) {
|
||||
try {
|
||||
console.log(`Calling transport.finishAuth() for server: ${serverInfo.name}`);
|
||||
const currentTransport = serverInfo.transport as any;
|
||||
await currentTransport.finishAuth(codeParam);
|
||||
|
||||
console.log(`Successfully exchanged authorization code for tokens: ${serverInfo.name}`);
|
||||
|
||||
// Refresh server configuration from disk to ensure we pick up newly saved tokens
|
||||
const settings = loadSettings();
|
||||
const storedConfig = settings.mcpServers?.[serverInfo.name];
|
||||
const effectiveConfig = storedConfig || serverInfo.config;
|
||||
|
||||
if (!effectiveConfig) {
|
||||
throw new Error(
|
||||
`Missing server configuration for ${serverInfo.name} after OAuth callback`,
|
||||
);
|
||||
}
|
||||
|
||||
// Keep latest configuration cached on serverInfo
|
||||
serverInfo.config = effectiveConfig;
|
||||
|
||||
// Ensure we have up-to-date request options for the reconnect attempt
|
||||
if (!serverInfo.options) {
|
||||
const requestConfig = effectiveConfig.options || {};
|
||||
serverInfo.options = {
|
||||
timeout: requestConfig.timeout || 60000,
|
||||
resetTimeoutOnProgress: requestConfig.resetTimeoutOnProgress || false,
|
||||
maxTotalTimeout: requestConfig.maxTotalTimeout,
|
||||
};
|
||||
}
|
||||
|
||||
// Replace the existing transport instance to avoid reusing a closed/aborted transport
|
||||
try {
|
||||
if (serverInfo.transport && 'close' in serverInfo.transport) {
|
||||
await (serverInfo.transport as any).close();
|
||||
}
|
||||
} catch (closeError) {
|
||||
console.warn(`Failed to close existing transport for ${serverInfo.name}:`, closeError);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Rebuilding transport with refreshed credentials for server: ${serverInfo.name}`,
|
||||
);
|
||||
const refreshedTransport = await createTransportFromConfig(
|
||||
serverInfo.name,
|
||||
effectiveConfig,
|
||||
);
|
||||
serverInfo.transport = refreshedTransport;
|
||||
|
||||
// Update server status to indicate OAuth is complete
|
||||
serverInfo.status = 'connected';
|
||||
if (serverInfo.oauth) {
|
||||
serverInfo.oauth.authorizationUrl = undefined;
|
||||
serverInfo.oauth.state = undefined;
|
||||
serverInfo.oauth.codeVerifier = undefined;
|
||||
}
|
||||
|
||||
// Check if client needs to be connected
|
||||
const isClientConnected = serverInfo.client && serverInfo.client.getServerCapabilities();
|
||||
|
||||
if (!isClientConnected) {
|
||||
// Client is not connected yet, connect it
|
||||
if (serverInfo.client && serverInfo.transport) {
|
||||
console.log(`Connecting client with refreshed transport for: ${serverInfo.name}`);
|
||||
try {
|
||||
await serverInfo.client.connect(serverInfo.transport, serverInfo.options);
|
||||
console.log(`Client connected successfully for: ${serverInfo.name}`);
|
||||
|
||||
// List tools after successful connection
|
||||
const capabilities = serverInfo.client.getServerCapabilities();
|
||||
console.log(
|
||||
`Server capabilities for ${serverInfo.name}:`,
|
||||
JSON.stringify(capabilities),
|
||||
);
|
||||
|
||||
if (capabilities?.tools) {
|
||||
console.log(`Listing tools for server: ${serverInfo.name}`);
|
||||
const toolsResult = await serverInfo.client.listTools({}, serverInfo.options);
|
||||
const separator = getNameSeparator();
|
||||
serverInfo.tools = toolsResult.tools.map((tool) => ({
|
||||
name: `${serverInfo.name}${separator}${tool.name}`,
|
||||
description: tool.description || '',
|
||||
inputSchema: tool.inputSchema || {},
|
||||
}));
|
||||
console.log(
|
||||
`Listed ${serverInfo.tools.length} tools for server: ${serverInfo.name}`,
|
||||
);
|
||||
} else {
|
||||
console.log(`Server ${serverInfo.name} does not support tools capability`);
|
||||
}
|
||||
} catch (connectError) {
|
||||
console.error(`Error connecting client for ${serverInfo.name}:`, connectError);
|
||||
if (connectError instanceof Error) {
|
||||
console.error(
|
||||
`Connect error details for ${serverInfo.name}: ${connectError.message}`,
|
||||
connectError.stack,
|
||||
);
|
||||
}
|
||||
// Even if connection fails, mark OAuth as complete
|
||||
// The user can try reconnecting from the dashboard
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`Cannot connect client for ${serverInfo.name}: client or transport missing`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(`Client already connected for server: ${serverInfo.name}`);
|
||||
}
|
||||
|
||||
console.log(`Successfully completed OAuth flow for server: ${serverInfo.name}`);
|
||||
|
||||
// Return success page
|
||||
return res.status(200).send(
|
||||
generateHtmlResponse(
|
||||
'success',
|
||||
t('oauthCallback.authorizationSuccessful'),
|
||||
`${t('oauthCallback.successMessage')}\n${t('oauthCallback.autoCloseMessage')}`,
|
||||
[
|
||||
{ label: t('oauthCallback.server'), value: serverInfo.name },
|
||||
{ label: t('oauthCallback.status'), value: t('oauthCallback.connected') },
|
||||
],
|
||||
true, // auto-close
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Failed to complete OAuth flow for server ${serverInfo.name}:`, error);
|
||||
console.error(`Error type: ${typeof error}, Error name: ${error?.constructor?.name}`);
|
||||
console.error(`Error message: ${error instanceof Error ? error.message : String(error)}`);
|
||||
console.error(`Error stack:`, error instanceof Error ? error.stack : 'No stack trace');
|
||||
|
||||
return res
|
||||
.status(500)
|
||||
.send(
|
||||
generateHtmlResponse(
|
||||
'error',
|
||||
t('oauthCallback.connectionError'),
|
||||
`${t('oauthCallback.connectionErrorMessage')}\n${t('oauthCallback.reconnectMessage')}`,
|
||||
[{ label: '', value: error instanceof Error ? error.message : String(error) }],
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// No transport available or transport doesn't support finishAuth
|
||||
console.error(`Transport for server ${serverInfo.name} does not support finishAuth()`);
|
||||
return res
|
||||
.status(500)
|
||||
.send(
|
||||
generateHtmlResponse(
|
||||
'error',
|
||||
t('oauthCallback.configurationError'),
|
||||
t('oauthCallback.configurationErrorMessage'),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Unexpected error handling OAuth callback:', error);
|
||||
|
||||
// Get translation function from request (set by i18n middleware)
|
||||
const t = (req as any).t || ((key: string) => key);
|
||||
|
||||
return res
|
||||
.status(500)
|
||||
.send(
|
||||
generateHtmlResponse(
|
||||
'error',
|
||||
t('oauthCallback.internalError'),
|
||||
t('oauthCallback.internalErrorMessage'),
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -8,13 +8,82 @@ import {
|
||||
import { getServerByName } from '../services/mcpService.js';
|
||||
import { getGroupByIdOrName } from '../services/groupService.js';
|
||||
import { getNameSeparator } from '../config/index.js';
|
||||
import { convertParametersToTypes } from '../utils/parameterConversion.js';
|
||||
|
||||
/**
|
||||
* Controller for OpenAPI generation endpoints
|
||||
* Provides OpenAPI specifications for MCP tools to enable OpenWebUI integration
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convert query parameters to their proper types based on the tool's input schema
|
||||
*/
|
||||
function convertQueryParametersToTypes(
|
||||
queryParams: Record<string, any>,
|
||||
inputSchema: Record<string, any>,
|
||||
): Record<string, any> {
|
||||
if (!inputSchema || typeof inputSchema !== 'object' || !inputSchema.properties) {
|
||||
return queryParams;
|
||||
}
|
||||
|
||||
const convertedParams: Record<string, any> = {};
|
||||
const properties = inputSchema.properties;
|
||||
|
||||
for (const [key, value] of Object.entries(queryParams)) {
|
||||
const propDef = properties[key];
|
||||
if (!propDef || typeof propDef !== 'object') {
|
||||
// No schema definition found, keep as is
|
||||
convertedParams[key] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
const propType = propDef.type;
|
||||
|
||||
try {
|
||||
switch (propType) {
|
||||
case 'integer':
|
||||
case 'number':
|
||||
// Convert string to number
|
||||
if (typeof value === 'string') {
|
||||
const numValue = propType === 'integer' ? parseInt(value, 10) : parseFloat(value);
|
||||
convertedParams[key] = isNaN(numValue) ? value : numValue;
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'boolean':
|
||||
// Convert string to boolean
|
||||
if (typeof value === 'string') {
|
||||
convertedParams[key] = value.toLowerCase() === 'true' || value === '1';
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'array':
|
||||
// Handle array conversion if needed (e.g., comma-separated strings)
|
||||
if (typeof value === 'string' && value.includes(',')) {
|
||||
convertedParams[key] = value.split(',').map((item) => item.trim());
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// For string and other types, keep as is
|
||||
convertedParams[key] = value;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
// If conversion fails, keep the original value
|
||||
console.warn(`Failed to convert parameter '${key}' to type '${propType}':`, error);
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return convertedParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and return OpenAPI specification
|
||||
* GET /api/openapi.json
|
||||
@@ -98,9 +167,7 @@ export const getOpenAPIStats = async (req: Request, res: Response): Promise<void
|
||||
*/
|
||||
export const executeToolViaOpenAPI = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Decode URL-encoded parameters to handle slashes in server/tool names
|
||||
const serverName = decodeURIComponent(req.params.serverName);
|
||||
const toolName = decodeURIComponent(req.params.toolName);
|
||||
const { serverName, toolName } = req.params;
|
||||
|
||||
// Import handleCallToolRequest function
|
||||
const { handleCallToolRequest } = await import('../services/mcpService.js');
|
||||
@@ -122,7 +189,7 @@ export const executeToolViaOpenAPI = async (req: Request, res: Response): Promis
|
||||
|
||||
// Prepare arguments from query params (GET) or body (POST)
|
||||
let args = req.method === 'GET' ? req.query : req.body || {};
|
||||
args = convertParametersToTypes(args, inputSchema);
|
||||
args = convertQueryParametersToTypes(args, inputSchema);
|
||||
|
||||
// Create a mock request structure that matches what handleCallToolRequest expects
|
||||
const mockRequest = {
|
||||
|
||||
@@ -7,9 +7,7 @@ import { handleGetPromptRequest } from '../services/mcpService.js';
|
||||
*/
|
||||
export const getPrompt = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Decode URL-encoded parameters to handle slashes in server/prompt names
|
||||
const serverName = decodeURIComponent(req.params.serverName);
|
||||
const promptName = decodeURIComponent(req.params.promptName);
|
||||
const { serverName, promptName } = req.params;
|
||||
if (!serverName || !promptName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
|
||||
const REGISTRY_BASE_URL = 'https://registry.modelcontextprotocol.io/v0.1';
|
||||
|
||||
/**
|
||||
* Get all MCP servers from the official registry
|
||||
* Proxies the request to avoid CORS issues in the frontend
|
||||
* Supports cursor-based pagination
|
||||
*/
|
||||
export const getAllRegistryServers = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { cursor, limit, search } = req.query;
|
||||
|
||||
// Build URL with query parameters
|
||||
const url = new URL(`${REGISTRY_BASE_URL}/servers`);
|
||||
if (cursor && typeof cursor === 'string') {
|
||||
url.searchParams.append('cursor', cursor);
|
||||
}
|
||||
if (limit && typeof limit === 'string') {
|
||||
const limitNum = parseInt(limit, 10);
|
||||
if (!isNaN(limitNum) && limitNum > 0) {
|
||||
url.searchParams.append('limit', limit);
|
||||
}
|
||||
}
|
||||
if (search && typeof search === 'string') {
|
||||
url.searchParams.append('search', search);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: {
|
||||
Accept: 'application/json, application/problem+json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const apiResponse: ApiResponse<typeof data> = {
|
||||
success: true,
|
||||
data: data,
|
||||
};
|
||||
|
||||
res.json(apiResponse);
|
||||
} catch (error) {
|
||||
console.error('Error fetching registry servers:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to fetch registry servers';
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all versions of a specific MCP server
|
||||
* Proxies the request to avoid CORS issues in the frontend
|
||||
*/
|
||||
export const getRegistryServerVersions = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName } = req.params;
|
||||
|
||||
if (!serverName) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Server name is required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// URL encode the server name
|
||||
const encodedName = encodeURIComponent(serverName);
|
||||
const response = await fetch(`${REGISTRY_BASE_URL}/servers/${encodedName}/versions`, {
|
||||
headers: {
|
||||
Accept: 'application/json, application/problem+json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Server not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const apiResponse: ApiResponse<typeof data> = {
|
||||
success: true,
|
||||
data: data,
|
||||
};
|
||||
|
||||
res.json(apiResponse);
|
||||
} catch (error) {
|
||||
console.error('Error fetching registry server versions:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to fetch registry server versions';
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a specific version of an MCP server
|
||||
* Proxies the request to avoid CORS issues in the frontend
|
||||
*/
|
||||
export const getRegistryServerVersion = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { serverName, version } = req.params;
|
||||
|
||||
if (!serverName || !version) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Server name and version are required',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// URL encode the server name and version
|
||||
const encodedName = encodeURIComponent(serverName);
|
||||
const encodedVersion = encodeURIComponent(version);
|
||||
const response = await fetch(
|
||||
`${REGISTRY_BASE_URL}/servers/${encodedName}/versions/${encodedVersion}`,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/json, application/problem+json',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'Server version not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const apiResponse: ApiResponse<typeof data> = {
|
||||
success: true,
|
||||
data: data,
|
||||
};
|
||||
|
||||
res.json(apiResponse);
|
||||
} catch (error) {
|
||||
console.error('Error fetching registry server version:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to fetch registry server version';
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -375,9 +375,7 @@ export const toggleServer = async (req: Request, res: Response): Promise<void> =
|
||||
// Toggle tool status for a specific server
|
||||
export const toggleTool = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Decode URL-encoded parameters to handle slashes in server/tool names
|
||||
const serverName = decodeURIComponent(req.params.serverName);
|
||||
const toolName = decodeURIComponent(req.params.toolName);
|
||||
const { serverName, toolName } = req.params;
|
||||
const { enabled } = req.body;
|
||||
|
||||
if (!serverName || !toolName) {
|
||||
@@ -439,9 +437,7 @@ export const toggleTool = async (req: Request, res: Response): Promise<void> =>
|
||||
// Update tool description for a specific server
|
||||
export const updateToolDescription = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Decode URL-encoded parameters to handle slashes in server/tool names
|
||||
const serverName = decodeURIComponent(req.params.serverName);
|
||||
const toolName = decodeURIComponent(req.params.toolName);
|
||||
const { serverName, toolName } = req.params;
|
||||
const { description } = req.body;
|
||||
|
||||
if (!serverName || !toolName) {
|
||||
@@ -533,7 +529,7 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
typeof mcpRouter.referer !== 'string' &&
|
||||
typeof mcpRouter.title !== 'string' &&
|
||||
typeof mcpRouter.baseUrl !== 'string')) &&
|
||||
typeof nameSeparator !== 'string'
|
||||
(typeof nameSeparator !== 'string')
|
||||
) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
@@ -751,9 +747,7 @@ export const updateSystemConfig = (req: Request, res: Response): void => {
|
||||
// Toggle prompt status for a specific server
|
||||
export const togglePrompt = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Decode URL-encoded parameters to handle slashes in server/prompt names
|
||||
const serverName = decodeURIComponent(req.params.serverName);
|
||||
const promptName = decodeURIComponent(req.params.promptName);
|
||||
const { serverName, promptName } = req.params;
|
||||
const { enabled } = req.body;
|
||||
|
||||
if (!serverName || !promptName) {
|
||||
@@ -815,9 +809,7 @@ export const togglePrompt = async (req: Request, res: Response): Promise<void> =
|
||||
// Update prompt description for a specific server
|
||||
export const updatePromptDescription = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Decode URL-encoded parameters to handle slashes in server/prompt names
|
||||
const serverName = decodeURIComponent(req.params.serverName);
|
||||
const promptName = decodeURIComponent(req.params.promptName);
|
||||
const { serverName, promptName } = req.params;
|
||||
const { description } = req.body;
|
||||
|
||||
if (!serverName || !promptName) {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
import { handleCallToolRequest, getServerByName } from '../services/mcpService.js';
|
||||
import { convertParametersToTypes } from '../utils/parameterConversion.js';
|
||||
import { getNameSeparator } from '../config/index.js';
|
||||
import { handleCallToolRequest } from '../services/mcpService.js';
|
||||
|
||||
/**
|
||||
* Interface for tool call request
|
||||
@@ -49,31 +47,13 @@ export const callTool = async (req: Request, res: Response): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the server info to access the tool's input schema
|
||||
const serverInfo = getServerByName(server);
|
||||
let inputSchema: Record<string, any> = {};
|
||||
|
||||
if (serverInfo) {
|
||||
// Find the tool in the server's tools list
|
||||
const fullToolName = `${server}${getNameSeparator()}${toolName}`;
|
||||
const tool = serverInfo.tools.find(
|
||||
(t: any) => t.name === fullToolName || t.name === toolName,
|
||||
);
|
||||
if (tool && tool.inputSchema) {
|
||||
inputSchema = tool.inputSchema as Record<string, any>;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert parameters to proper types based on the tool's input schema
|
||||
const convertedArgs = convertParametersToTypes(toolArgs, inputSchema);
|
||||
|
||||
// Create a mock request structure for handleCallToolRequest
|
||||
const mockRequest = {
|
||||
params: {
|
||||
name: 'call_tool',
|
||||
arguments: {
|
||||
toolName,
|
||||
arguments: convertedArgs,
|
||||
arguments: toolArgs,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -91,7 +71,7 @@ export const callTool = async (req: Request, res: Response): Promise<void> => {
|
||||
data: {
|
||||
content: result.content || [],
|
||||
toolName,
|
||||
arguments: convertedArgs,
|
||||
arguments: toolArgs,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
getAdminCount,
|
||||
} from '../services/userService.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import { validatePasswordStrength } from '../utils/passwordValidation.js';
|
||||
|
||||
// Admin permission check middleware function
|
||||
const requireAdmin = (req: Request, res: Response): boolean => {
|
||||
@@ -101,17 +100,6 @@ export const createUser = async (req: Request, res: Response): Promise<void> =>
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate password strength
|
||||
const validationResult = validatePasswordStrength(password);
|
||||
if (!validationResult.isValid) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Password does not meet security requirements',
|
||||
errors: validationResult.errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const newUser = await createNewUser(username, password, isAdmin || false);
|
||||
if (!newUser) {
|
||||
res.status(400).json({
|
||||
@@ -175,19 +163,7 @@ export const updateExistingUser = async (req: Request, res: Response): Promise<v
|
||||
|
||||
const updateData: any = {};
|
||||
if (isAdmin !== undefined) updateData.isAdmin = isAdmin;
|
||||
if (newPassword) {
|
||||
// Validate new password strength
|
||||
const validationResult = validatePasswordStrength(newPassword);
|
||||
if (!validationResult.isValid) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Password does not meet security requirements',
|
||||
errors: validationResult.errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateData.newPassword = newPassword;
|
||||
}
|
||||
if (newPassword) updateData.newPassword = newPassword;
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
res.status(400).json({
|
||||
|
||||
@@ -56,18 +56,9 @@ import {
|
||||
getCloudServerToolsList,
|
||||
callCloudTool,
|
||||
} from '../controllers/cloudController.js';
|
||||
import {
|
||||
getAllRegistryServers,
|
||||
getRegistryServerVersions,
|
||||
getRegistryServerVersion,
|
||||
} from '../controllers/registryController.js';
|
||||
import { login, register, getCurrentUser, changePassword } from '../controllers/authController.js';
|
||||
import { getAllLogs, clearLogs, streamLogs } from '../controllers/logController.js';
|
||||
import {
|
||||
getRuntimeConfig,
|
||||
getPublicConfig,
|
||||
getMcpSettingsJson,
|
||||
} from '../controllers/configController.js';
|
||||
import { getRuntimeConfig, getPublicConfig } from '../controllers/configController.js';
|
||||
import { callTool } from '../controllers/toolController.js';
|
||||
import { getPrompt } from '../controllers/promptController.js';
|
||||
import { uploadDxtFile, uploadMiddleware } from '../controllers/dxtController.js';
|
||||
@@ -79,7 +70,6 @@ import {
|
||||
executeToolViaOpenAPI,
|
||||
getGroupOpenAPISpec,
|
||||
} from '../controllers/openApiController.js';
|
||||
import { handleOAuthCallback } from '../controllers/oauthCallbackController.js';
|
||||
import { auth } from '../middlewares/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -88,9 +78,6 @@ export const initRoutes = (app: express.Application): void => {
|
||||
// Health check endpoint (no auth required, accessible at /health)
|
||||
app.get('/health', healthCheck);
|
||||
|
||||
// OAuth callback endpoint (no auth required, public callback URL)
|
||||
app.get('/oauth/callback', handleOAuthCallback);
|
||||
|
||||
// API routes protected by auth middleware in middlewares/index.ts
|
||||
router.get('/servers', getAllServers);
|
||||
router.get('/settings', getAllSettings);
|
||||
@@ -157,19 +144,11 @@ export const initRoutes = (app: express.Application): void => {
|
||||
router.get('/cloud/servers/:serverName/tools', getCloudServerToolsList);
|
||||
router.post('/cloud/servers/:serverName/tools/:toolName/call', callCloudTool);
|
||||
|
||||
// Registry routes (proxy to official MCP registry)
|
||||
router.get('/registry/servers', getAllRegistryServers);
|
||||
router.get('/registry/servers/:serverName/versions', getRegistryServerVersions);
|
||||
router.get('/registry/servers/:serverName/versions/:version', getRegistryServerVersion);
|
||||
|
||||
// Log routes
|
||||
router.get('/logs', getAllLogs);
|
||||
router.delete('/logs', clearLogs);
|
||||
router.get('/logs/stream', streamLogs);
|
||||
|
||||
// MCP settings export route
|
||||
router.get('/mcp-settings/export', getMcpSettingsJson);
|
||||
|
||||
// Auth routes - move to router instead of app directly
|
||||
router.post(
|
||||
'/auth/login',
|
||||
|
||||
@@ -15,27 +15,9 @@ import {
|
||||
} from './services/sseService.js';
|
||||
import { initializeDefaultUser } from './models/User.js';
|
||||
import { sseUserContextMiddleware } from './middlewares/userContext.js';
|
||||
import { findPackageRoot } from './utils/path.js';
|
||||
import { getCurrentModuleDir } from './utils/moduleDir.js';
|
||||
import { initOAuthProvider, getOAuthRouter } from './services/oauthService.js';
|
||||
|
||||
/**
|
||||
* Get the directory of the current module
|
||||
* This is wrapped in a function to allow easy mocking in test environments
|
||||
*/
|
||||
function getCurrentFileDir(): string {
|
||||
// In test environments, use process.cwd() to avoid import.meta issues
|
||||
if (process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined) {
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
try {
|
||||
return getCurrentModuleDir();
|
||||
} catch {
|
||||
// Fallback for environments where import.meta might not be available
|
||||
return process.cwd();
|
||||
}
|
||||
}
|
||||
// Get the current working directory (will be project root in most cases)
|
||||
const currentFileDir = process.cwd() + '/src';
|
||||
|
||||
export class AppServer {
|
||||
private app: express.Application;
|
||||
@@ -59,16 +41,6 @@ export class AppServer {
|
||||
// Initialize default admin user if no users exist
|
||||
await initializeDefaultUser();
|
||||
|
||||
// Initialize OAuth provider if configured
|
||||
initOAuthProvider();
|
||||
const oauthRouter = getOAuthRouter();
|
||||
if (oauthRouter) {
|
||||
// Mount OAuth router at the root level (before other routes)
|
||||
// This must be at root level as per MCP OAuth specification
|
||||
this.app.use(oauthRouter);
|
||||
console.log('OAuth router mounted successfully');
|
||||
}
|
||||
|
||||
initMiddlewares(this.app);
|
||||
initRoutes(this.app);
|
||||
console.log('Server initialized successfully');
|
||||
@@ -78,28 +50,28 @@ export class AppServer {
|
||||
console.log('MCP server initialized successfully');
|
||||
|
||||
// Original routes (global and group-based)
|
||||
this.app.get(`${this.basePath}/sse/:group(.*)?`, sseUserContextMiddleware, (req, res) =>
|
||||
this.app.get(`${this.basePath}/sse/:group?`, sseUserContextMiddleware, (req, res) =>
|
||||
handleSseConnection(req, res),
|
||||
);
|
||||
this.app.post(`${this.basePath}/messages`, sseUserContextMiddleware, handleSseMessage);
|
||||
this.app.post(
|
||||
`${this.basePath}/mcp/:group(.*)?`,
|
||||
`${this.basePath}/mcp/:group?`,
|
||||
sseUserContextMiddleware,
|
||||
handleMcpPostRequest,
|
||||
);
|
||||
this.app.get(
|
||||
`${this.basePath}/mcp/:group(.*)?`,
|
||||
`${this.basePath}/mcp/:group?`,
|
||||
sseUserContextMiddleware,
|
||||
handleMcpOtherRequest,
|
||||
);
|
||||
this.app.delete(
|
||||
`${this.basePath}/mcp/:group(.*)?`,
|
||||
`${this.basePath}/mcp/:group?`,
|
||||
sseUserContextMiddleware,
|
||||
handleMcpOtherRequest,
|
||||
);
|
||||
|
||||
// User-scoped routes with user context middleware
|
||||
this.app.get(`${this.basePath}/:user/sse/:group(.*)?`, sseUserContextMiddleware, (req, res) =>
|
||||
this.app.get(`${this.basePath}/:user/sse/:group?`, sseUserContextMiddleware, (req, res) =>
|
||||
handleSseConnection(req, res),
|
||||
);
|
||||
this.app.post(
|
||||
@@ -108,17 +80,17 @@ export class AppServer {
|
||||
handleSseMessage,
|
||||
);
|
||||
this.app.post(
|
||||
`${this.basePath}/:user/mcp/:group(.*)?`,
|
||||
`${this.basePath}/:user/mcp/:group?`,
|
||||
sseUserContextMiddleware,
|
||||
handleMcpPostRequest,
|
||||
);
|
||||
this.app.get(
|
||||
`${this.basePath}/:user/mcp/:group(.*)?`,
|
||||
`${this.basePath}/:user/mcp/:group?`,
|
||||
sseUserContextMiddleware,
|
||||
handleMcpOtherRequest,
|
||||
);
|
||||
this.app.delete(
|
||||
`${this.basePath}/:user/mcp/:group(.*)?`,
|
||||
`${this.basePath}/:user/mcp/:group?`,
|
||||
sseUserContextMiddleware,
|
||||
handleMcpOtherRequest,
|
||||
);
|
||||
@@ -195,11 +167,10 @@ export class AppServer {
|
||||
private findFrontendDistPath(): string | null {
|
||||
// Debug flag for detailed logging
|
||||
const debug = process.env.DEBUG === 'true';
|
||||
const currentDir = getCurrentFileDir();
|
||||
|
||||
if (debug) {
|
||||
console.log('DEBUG: Current directory:', process.cwd());
|
||||
console.log('DEBUG: Script directory:', currentDir);
|
||||
console.log('DEBUG: Script directory:', currentFileDir);
|
||||
}
|
||||
|
||||
// First, find the package root directory
|
||||
@@ -234,9 +205,51 @@ export class AppServer {
|
||||
|
||||
// Helper method to find the package root (where package.json is located)
|
||||
private findPackageRoot(): string | null {
|
||||
// Use the shared utility function which properly handles ESM module paths
|
||||
const currentDir = getCurrentFileDir();
|
||||
return findPackageRoot(currentDir);
|
||||
const debug = process.env.DEBUG === 'true';
|
||||
|
||||
// Possible locations for package.json
|
||||
const possibleRoots = [
|
||||
// Standard npm package location
|
||||
path.resolve(currentFileDir, '..', '..'),
|
||||
// Current working directory
|
||||
process.cwd(),
|
||||
// When running from dist directory
|
||||
path.resolve(currentFileDir, '..'),
|
||||
// When installed via npx
|
||||
path.resolve(currentFileDir, '..', '..', '..'),
|
||||
];
|
||||
|
||||
// Special handling for npx
|
||||
if (process.argv[1] && process.argv[1].includes('_npx')) {
|
||||
const npxDir = path.dirname(process.argv[1]);
|
||||
possibleRoots.unshift(path.resolve(npxDir, '..'));
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.log('DEBUG: Checking for package.json in:', possibleRoots);
|
||||
}
|
||||
|
||||
for (const root of possibleRoots) {
|
||||
const packageJsonPath = path.join(root, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
if (pkg.name === 'mcphub' || pkg.name === '@samanhappy/mcphub') {
|
||||
if (debug) {
|
||||
console.log(`DEBUG: Found package.json at ${packageJsonPath}`);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
} catch (e) {
|
||||
if (debug) {
|
||||
console.error(`DEBUG: Failed to parse package.json at ${packageJsonPath}:`, e);
|
||||
}
|
||||
// Continue to the next potential root
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,6 @@ export const getMarketServers = (): Record<string, MarketServer> => {
|
||||
const data = fs.readFileSync(serversJsonPath, 'utf8');
|
||||
const serversObj = JSON.parse(data) as Record<string, MarketServer>;
|
||||
|
||||
// use key as name field
|
||||
Object.entries(serversObj).forEach(([key, server]) => {
|
||||
server.name = key;
|
||||
});
|
||||
|
||||
const sortedEntries = Object.entries(serversObj).sort(([, serverA], [, serverB]) => {
|
||||
if (serverA.is_official && !serverB.is_official) return -1;
|
||||
if (!serverA.is_official && serverB.is_official) return 1;
|
||||
|
||||
@@ -1,593 +0,0 @@
|
||||
/**
|
||||
* MCP OAuth Provider Implementation
|
||||
*
|
||||
* Implements OAuthClientProvider interface from @modelcontextprotocol/sdk/client/auth.js
|
||||
* to handle OAuth 2.0 authentication for upstream MCP servers using the SDK's built-in
|
||||
* OAuth support.
|
||||
*
|
||||
* This provider integrates with our existing OAuth infrastructure:
|
||||
* - Dynamic client registration (RFC7591)
|
||||
* - Token storage and refresh
|
||||
* - Authorization flow handling
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
|
||||
import type {
|
||||
OAuthClientInformation,
|
||||
OAuthClientInformationFull,
|
||||
OAuthClientMetadata,
|
||||
OAuthTokens,
|
||||
} from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
import { ServerConfig } from '../types/index.js';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import {
|
||||
initializeOAuthForServer,
|
||||
getRegisteredClient,
|
||||
removeRegisteredClient,
|
||||
fetchScopesFromServer,
|
||||
} from './oauthClientRegistration.js';
|
||||
import {
|
||||
clearOAuthData,
|
||||
loadServerConfig,
|
||||
mutateOAuthSettings,
|
||||
persistClientCredentials,
|
||||
persistTokens,
|
||||
updatePendingAuthorization,
|
||||
ServerConfigWithOAuth,
|
||||
} from './oauthSettingsStore.js';
|
||||
|
||||
// Import getServerByName to access ServerInfo
|
||||
import { getServerByName } from './mcpService.js';
|
||||
|
||||
/**
|
||||
* MCPHub OAuth Provider for server-side OAuth flows
|
||||
*
|
||||
* This provider handles OAuth authentication for upstream MCP servers.
|
||||
* Unlike browser-based providers, this runs in a Node.js server environment,
|
||||
* so the authorization flow requires external handling (e.g., via web UI).
|
||||
*/
|
||||
export class MCPHubOAuthProvider implements OAuthClientProvider {
|
||||
private serverName: string;
|
||||
private serverConfig: ServerConfig;
|
||||
private _codeVerifier?: string;
|
||||
private _currentState?: string;
|
||||
|
||||
constructor(serverName: string, serverConfig: ServerConfig) {
|
||||
this.serverName = serverName;
|
||||
this.serverConfig = serverConfig;
|
||||
}
|
||||
|
||||
private getSystemInstallBaseUrl(): string | undefined {
|
||||
const settings = loadSettings();
|
||||
return settings.systemConfig?.install?.baseUrl;
|
||||
}
|
||||
|
||||
private sanitizeRedirectUri(input?: string): string | null {
|
||||
if (!input) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(input);
|
||||
url.searchParams.delete('server');
|
||||
const params = url.searchParams.toString();
|
||||
url.search = params ? `?${params}` : '';
|
||||
return url.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private buildRedirectUriFromBase(baseUrl?: string): string | null {
|
||||
if (!baseUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = baseUrl.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const normalizedBase = trimmed.endsWith('/') ? trimmed : `${trimmed}/`;
|
||||
const redirect = new URL('oauth/callback', normalizedBase);
|
||||
return this.sanitizeRedirectUri(redirect.toString());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get redirect URL for OAuth callback
|
||||
*/
|
||||
get redirectUrl(): string {
|
||||
const dynamicConfig = this.serverConfig.oauth?.dynamicRegistration;
|
||||
const metadata = dynamicConfig?.metadata || {};
|
||||
const fallback = 'http://localhost:3000/oauth/callback';
|
||||
const systemConfigured = this.buildRedirectUriFromBase(this.getSystemInstallBaseUrl());
|
||||
const metadataConfigured = this.sanitizeRedirectUri(metadata.redirect_uris?.[0]);
|
||||
|
||||
return systemConfigured ?? metadataConfigured ?? fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client metadata for dynamic registration or static configuration
|
||||
*/
|
||||
get clientMetadata(): OAuthClientMetadata {
|
||||
const dynamicConfig = this.serverConfig.oauth?.dynamicRegistration;
|
||||
const metadata = dynamicConfig?.metadata || {};
|
||||
|
||||
// Use redirectUrl getter to ensure consistent callback URL
|
||||
const redirectUri = this.redirectUrl;
|
||||
const systemConfigured = this.buildRedirectUriFromBase(this.getSystemInstallBaseUrl());
|
||||
const metadataRedirects =
|
||||
metadata.redirect_uris && metadata.redirect_uris.length > 0
|
||||
? metadata.redirect_uris
|
||||
.map((uri) => this.sanitizeRedirectUri(uri))
|
||||
.filter((uri): uri is string => Boolean(uri))
|
||||
: [];
|
||||
const redirectUris: string[] = [];
|
||||
|
||||
if (systemConfigured) {
|
||||
redirectUris.push(systemConfigured);
|
||||
}
|
||||
|
||||
for (const uri of metadataRedirects) {
|
||||
if (!redirectUris.includes(uri)) {
|
||||
redirectUris.push(uri);
|
||||
}
|
||||
}
|
||||
|
||||
if (!redirectUris.includes(redirectUri)) {
|
||||
redirectUris.push(redirectUri);
|
||||
}
|
||||
|
||||
const tokenEndpointAuthMethod =
|
||||
metadata.token_endpoint_auth_method && metadata.token_endpoint_auth_method !== ''
|
||||
? metadata.token_endpoint_auth_method
|
||||
: this.serverConfig.oauth?.clientSecret
|
||||
? 'client_secret_post'
|
||||
: 'none';
|
||||
|
||||
return {
|
||||
...metadata, // Include any additional custom metadata
|
||||
client_name: metadata.client_name || `MCPHub - ${this.serverName}`,
|
||||
redirect_uris: redirectUris,
|
||||
grant_types: metadata.grant_types || ['authorization_code', 'refresh_token'],
|
||||
response_types: metadata.response_types || ['code'],
|
||||
token_endpoint_auth_method: tokenEndpointAuthMethod,
|
||||
scope: metadata.scope || this.serverConfig.oauth?.scopes?.join(' ') || 'openid',
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureScopesFromServer(): Promise<string[] | undefined> {
|
||||
const serverUrl = this.serverConfig.url;
|
||||
const existingScopes = this.serverConfig.oauth?.scopes;
|
||||
|
||||
if (!serverUrl) {
|
||||
return existingScopes;
|
||||
}
|
||||
|
||||
if (existingScopes && existingScopes.length > 0) {
|
||||
return existingScopes;
|
||||
}
|
||||
|
||||
try {
|
||||
const scopes = await fetchScopesFromServer(serverUrl);
|
||||
if (scopes && scopes.length > 0) {
|
||||
const updatedConfig = await mutateOAuthSettings(this.serverName, ({ oauth }) => {
|
||||
oauth.scopes = scopes;
|
||||
});
|
||||
if (updatedConfig) {
|
||||
this.serverConfig = updatedConfig;
|
||||
}
|
||||
console.log(`Stored auto-detected scopes for ${this.serverName}: ${scopes.join(', ')}`);
|
||||
return scopes;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to auto-detect scopes for ${this.serverName}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
return existingScopes;
|
||||
}
|
||||
|
||||
private generateState(): string {
|
||||
const payload = {
|
||||
server: this.serverName,
|
||||
nonce: randomBytes(16).toString('hex'),
|
||||
};
|
||||
const base64 = Buffer.from(JSON.stringify(payload)).toString('base64');
|
||||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
async state(): Promise<string> {
|
||||
if (!this._currentState) {
|
||||
this._currentState = this.generateState();
|
||||
}
|
||||
return this._currentState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get previously registered client information
|
||||
*/
|
||||
clientInformation(): OAuthClientInformation | undefined {
|
||||
const clientInfo = getRegisteredClient(this.serverName);
|
||||
|
||||
if (!clientInfo) {
|
||||
// Try to use static client configuration from cached serverConfig first
|
||||
let serverConfig = this.serverConfig;
|
||||
|
||||
// If cached config doesn't have clientId, reload from settings
|
||||
if (!serverConfig?.oauth?.clientId) {
|
||||
const storedConfig = loadServerConfig(this.serverName);
|
||||
|
||||
if (storedConfig) {
|
||||
this.serverConfig = storedConfig;
|
||||
serverConfig = storedConfig;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to use static client configuration from serverConfig
|
||||
if (serverConfig?.oauth?.clientId) {
|
||||
return {
|
||||
client_id: serverConfig.oauth.clientId,
|
||||
client_secret: serverConfig.oauth.clientSecret,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
client_id: clientInfo.clientId,
|
||||
client_secret: clientInfo.clientSecret,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save registered client information
|
||||
* Called by SDK after successful dynamic registration
|
||||
*/
|
||||
async saveClientInformation(info: OAuthClientInformationFull): Promise<void> {
|
||||
console.log(`Saving OAuth client information for server: ${this.serverName}`);
|
||||
|
||||
const scopeString = info.scope?.trim();
|
||||
const scopes =
|
||||
scopeString && scopeString.length > 0
|
||||
? scopeString.split(/\s+/).filter((value) => value.length > 0)
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const updatedConfig = await persistClientCredentials(this.serverName, {
|
||||
clientId: info.client_id,
|
||||
clientSecret: info.client_secret,
|
||||
scopes,
|
||||
});
|
||||
|
||||
if (updatedConfig) {
|
||||
this.serverConfig = updatedConfig;
|
||||
}
|
||||
|
||||
if (!scopes || scopes.length === 0) {
|
||||
await this.ensureScopesFromServer();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to persist OAuth client credentials for server ${this.serverName}:`,
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored OAuth tokens
|
||||
*/
|
||||
tokens(): OAuthTokens | undefined {
|
||||
// Use cached config first, but reload if needed
|
||||
let serverConfig = this.serverConfig;
|
||||
|
||||
// If cached config doesn't have tokens, try reloading
|
||||
if (!serverConfig?.oauth?.accessToken) {
|
||||
const storedConfig = loadServerConfig(this.serverName);
|
||||
if (storedConfig) {
|
||||
this.serverConfig = storedConfig;
|
||||
serverConfig = storedConfig;
|
||||
}
|
||||
}
|
||||
|
||||
if (!serverConfig?.oauth?.accessToken) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
access_token: serverConfig.oauth.accessToken,
|
||||
token_type: 'Bearer',
|
||||
refresh_token: serverConfig.oauth.refreshToken,
|
||||
// Note: expires_in is not typically stored, only the token itself
|
||||
// The SDK will handle token refresh when needed
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save OAuth tokens
|
||||
* Called by SDK after successful token exchange or refresh
|
||||
*/
|
||||
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||
const currentOAuth = this.serverConfig.oauth;
|
||||
const accessTokenChanged = currentOAuth?.accessToken !== tokens.access_token;
|
||||
const refreshTokenProvided = tokens.refresh_token !== undefined;
|
||||
const refreshTokenChanged =
|
||||
refreshTokenProvided && currentOAuth?.refreshToken !== tokens.refresh_token;
|
||||
const hadPending = Boolean(currentOAuth?.pendingAuthorization);
|
||||
|
||||
if (!accessTokenChanged && !refreshTokenChanged && !hadPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Saving OAuth tokens for server: ${this.serverName}`);
|
||||
|
||||
const updatedConfig = await persistTokens(this.serverName, {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: refreshTokenProvided ? (tokens.refresh_token ?? null) : undefined,
|
||||
clearPendingAuthorization: hadPending,
|
||||
});
|
||||
|
||||
if (updatedConfig) {
|
||||
this.serverConfig = updatedConfig;
|
||||
}
|
||||
|
||||
this._codeVerifier = undefined;
|
||||
this._currentState = undefined;
|
||||
|
||||
const serverInfo = getServerByName(this.serverName);
|
||||
if (serverInfo) {
|
||||
serverInfo.oauth = undefined;
|
||||
}
|
||||
|
||||
console.log(`Saved OAuth tokens for server: ${this.serverName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect to authorization URL
|
||||
* In a server environment, we can't directly redirect the user
|
||||
* Instead, we store the URL in ServerInfo for the frontend to access
|
||||
*/
|
||||
async redirectToAuthorization(url: URL): Promise<void> {
|
||||
console.log('='.repeat(80));
|
||||
console.log(`OAuth Authorization Required for server: ${this.serverName}`);
|
||||
console.log(`Authorization URL: ${url.toString()}`);
|
||||
console.log('='.repeat(80));
|
||||
let state = url.searchParams.get('state') || undefined;
|
||||
|
||||
if (!state) {
|
||||
state = await this.state();
|
||||
url.searchParams.set('state', state);
|
||||
} else {
|
||||
this._currentState = state;
|
||||
}
|
||||
|
||||
const authorizationUrl = url.toString();
|
||||
|
||||
try {
|
||||
const pendingUpdate: Partial<NonNullable<ServerConfig['oauth']>['pendingAuthorization']> = {
|
||||
authorizationUrl,
|
||||
state,
|
||||
};
|
||||
|
||||
if (this._codeVerifier) {
|
||||
pendingUpdate.codeVerifier = this._codeVerifier;
|
||||
}
|
||||
|
||||
const updatedConfig = await updatePendingAuthorization(this.serverName, pendingUpdate);
|
||||
if (updatedConfig) {
|
||||
this.serverConfig = updatedConfig;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to persist pending OAuth authorization state for ${this.serverName}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
// Store the authorization URL in ServerInfo for the frontend to access
|
||||
const serverInfo = getServerByName(this.serverName);
|
||||
if (serverInfo) {
|
||||
serverInfo.status = 'oauth_required';
|
||||
serverInfo.oauth = {
|
||||
authorizationUrl,
|
||||
state,
|
||||
codeVerifier: this._codeVerifier,
|
||||
};
|
||||
console.log(`Stored OAuth authorization URL in ServerInfo for server: ${this.serverName}`);
|
||||
} else {
|
||||
console.warn(`ServerInfo not found for ${this.serverName}, cannot store authorization URL`);
|
||||
}
|
||||
|
||||
// Throw error to indicate authorization is needed
|
||||
// The error will be caught in the connection flow and handled appropriately
|
||||
throw new Error(
|
||||
`OAuth authorization required for server ${this.serverName}. Please complete OAuth flow via web UI.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save PKCE code verifier for later use in token exchange
|
||||
*/
|
||||
async saveCodeVerifier(verifier: string): Promise<void> {
|
||||
this._codeVerifier = verifier;
|
||||
try {
|
||||
const updatedConfig = await updatePendingAuthorization(this.serverName, {
|
||||
codeVerifier: verifier,
|
||||
});
|
||||
if (updatedConfig) {
|
||||
this.serverConfig = updatedConfig;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to persist OAuth code verifier for ${this.serverName}:`, error);
|
||||
}
|
||||
console.log(`Saved code verifier for server: ${this.serverName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve PKCE code verifier for token exchange
|
||||
*/
|
||||
async codeVerifier(): Promise<string> {
|
||||
if (this._codeVerifier) {
|
||||
return this._codeVerifier;
|
||||
}
|
||||
|
||||
const storedConfig = loadServerConfig(this.serverName);
|
||||
const storedVerifier = storedConfig?.oauth?.pendingAuthorization?.codeVerifier;
|
||||
|
||||
if (storedVerifier) {
|
||||
this.serverConfig = storedConfig || this.serverConfig;
|
||||
this._codeVerifier = storedVerifier;
|
||||
return storedVerifier;
|
||||
}
|
||||
|
||||
throw new Error(`No code verifier stored for server: ${this.serverName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cached OAuth credentials when the SDK detects they are no longer valid.
|
||||
* This keeps stored configuration in sync and forces a fresh authorization flow.
|
||||
*/
|
||||
async invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier'): Promise<void> {
|
||||
const storedConfig = loadServerConfig(this.serverName);
|
||||
|
||||
if (!storedConfig?.oauth) {
|
||||
if (scope === 'verifier' || scope === 'all') {
|
||||
this._codeVerifier = undefined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let currentConfig = storedConfig as ServerConfigWithOAuth;
|
||||
const assignUpdatedConfig = (updated?: ServerConfigWithOAuth) => {
|
||||
if (updated) {
|
||||
currentConfig = updated;
|
||||
this.serverConfig = updated;
|
||||
} else {
|
||||
this.serverConfig = currentConfig;
|
||||
}
|
||||
};
|
||||
|
||||
assignUpdatedConfig(currentConfig);
|
||||
let changed = false;
|
||||
|
||||
if (scope === 'tokens' || scope === 'all') {
|
||||
if (currentConfig.oauth.accessToken || currentConfig.oauth.refreshToken) {
|
||||
const updated = await clearOAuthData(this.serverName, 'tokens');
|
||||
assignUpdatedConfig(updated);
|
||||
changed = true;
|
||||
console.warn(`Cleared OAuth tokens for server: ${this.serverName}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (scope === 'client' || scope === 'all') {
|
||||
const supportsDynamicClient = currentConfig.oauth.dynamicRegistration?.enabled === true;
|
||||
|
||||
if (
|
||||
supportsDynamicClient &&
|
||||
(currentConfig.oauth.clientId || currentConfig.oauth.clientSecret)
|
||||
) {
|
||||
removeRegisteredClient(this.serverName);
|
||||
const updated = await clearOAuthData(this.serverName, 'client');
|
||||
assignUpdatedConfig(updated);
|
||||
changed = true;
|
||||
console.warn(`Cleared OAuth client registration for server: ${this.serverName}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (scope === 'verifier' || scope === 'all') {
|
||||
this._codeVerifier = undefined;
|
||||
this._currentState = undefined;
|
||||
if (currentConfig.oauth.pendingAuthorization) {
|
||||
const updated = await clearOAuthData(this.serverName, 'verifier');
|
||||
assignUpdatedConfig(updated);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
this._currentState = undefined;
|
||||
const serverInfo = getServerByName(this.serverName);
|
||||
if (serverInfo) {
|
||||
serverInfo.status = 'oauth_required';
|
||||
serverInfo.oauth = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const prepopulateScopesIfMissing = async (
|
||||
serverName: string,
|
||||
serverConfig: ServerConfig,
|
||||
): Promise<void> => {
|
||||
if (!serverConfig.oauth || serverConfig.oauth.scopes?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!serverConfig.url) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const scopes = await fetchScopesFromServer(serverConfig.url);
|
||||
if (scopes && scopes.length > 0) {
|
||||
const updatedConfig = await mutateOAuthSettings(serverName, ({ oauth }) => {
|
||||
oauth.scopes = scopes;
|
||||
});
|
||||
|
||||
if (!serverConfig.oauth) {
|
||||
serverConfig.oauth = {};
|
||||
}
|
||||
serverConfig.oauth.scopes = scopes;
|
||||
|
||||
if (updatedConfig) {
|
||||
console.log(`Stored auto-detected scopes for ${serverName}: ${scopes.join(', ')}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to auto-detect scopes for ${serverName} during provider initialization: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create an OAuth provider for a server if OAuth is configured
|
||||
*
|
||||
* @param serverName - Name of the server
|
||||
* @param serverConfig - Server configuration
|
||||
* @returns OAuthClientProvider instance or undefined if OAuth not configured
|
||||
*/
|
||||
export const createOAuthProvider = async (
|
||||
serverName: string,
|
||||
serverConfig: ServerConfig,
|
||||
): Promise<OAuthClientProvider | undefined> => {
|
||||
// Ensure scopes are pre-populated if dynamic registration already ran previously
|
||||
await prepopulateScopesIfMissing(serverName, serverConfig);
|
||||
|
||||
// Initialize OAuth for the server (performs registration if needed)
|
||||
// This ensures the client is registered before the SDK tries to use it
|
||||
try {
|
||||
await initializeOAuthForServer(serverName, serverConfig);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to initialize OAuth for server ${serverName}:`, error);
|
||||
// Continue anyway - the SDK might be able to handle it
|
||||
}
|
||||
|
||||
// Create and return the provider
|
||||
const provider = new MCPHubOAuthProvider(serverName, serverConfig);
|
||||
|
||||
console.log(`Created OAuth provider for server: ${serverName}`);
|
||||
return provider;
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,584 +0,0 @@
|
||||
/**
|
||||
* OAuth 2.0 Dynamic Client Registration Service
|
||||
*
|
||||
* Implements dynamic client registration for upstream MCP servers based on:
|
||||
* - RFC7591: OAuth 2.0 Dynamic Client Registration Protocol
|
||||
* - RFC8414: OAuth 2.0 Authorization Server Metadata
|
||||
* - MCP Authorization Specification
|
||||
*
|
||||
* Uses the standard openid-client library for OAuth operations.
|
||||
*/
|
||||
|
||||
import * as client from 'openid-client';
|
||||
import { ServerConfig } from '../types/index.js';
|
||||
import {
|
||||
mutateOAuthSettings,
|
||||
persistClientCredentials,
|
||||
persistTokens,
|
||||
} from './oauthSettingsStore.js';
|
||||
|
||||
interface RegisteredClientInfo {
|
||||
config: client.Configuration;
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
registrationAccessToken?: string;
|
||||
registrationClientUri?: string;
|
||||
expiresAt?: number;
|
||||
metadata: any;
|
||||
}
|
||||
|
||||
// Cache for registered clients to avoid re-registering on every restart
|
||||
const registeredClients = new Map<string, RegisteredClientInfo>();
|
||||
|
||||
export const removeRegisteredClient = (serverName: string): void => {
|
||||
registeredClients.delete(serverName);
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse WWW-Authenticate header to extract resource server metadata URL
|
||||
* Following RFC9728 Protected Resource Metadata specification
|
||||
*
|
||||
* Example header: WWW-Authenticate: Bearer resource="https://mcp.example.com/.well-known/oauth-protected-resource"
|
||||
*/
|
||||
export const parseWWWAuthenticateHeader = (header: string): string | null => {
|
||||
if (!header || !header.toLowerCase().startsWith('bearer ')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract resource parameter from WWW-Authenticate header
|
||||
const resourceMatch = header.match(/resource="([^"]+)"/i);
|
||||
if (resourceMatch && resourceMatch[1]) {
|
||||
return resourceMatch[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch protected resource metadata from MCP server
|
||||
* Following RFC9728 section 3
|
||||
*
|
||||
* @param resourceMetadataUrl - URL to fetch resource metadata (from WWW-Authenticate header)
|
||||
* @returns Authorization server URLs and other metadata
|
||||
*/
|
||||
export const fetchProtectedResourceMetadata = async (
|
||||
resourceMetadataUrl: string,
|
||||
): Promise<{
|
||||
authorization_servers: string[];
|
||||
resource?: string;
|
||||
[key: string]: any;
|
||||
}> => {
|
||||
try {
|
||||
console.log(`Fetching protected resource metadata from: ${resourceMetadataUrl}`);
|
||||
|
||||
const response = await fetch(resourceMetadataUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch resource metadata: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const metadata = await response.json();
|
||||
|
||||
if (!metadata.authorization_servers || !Array.isArray(metadata.authorization_servers)) {
|
||||
throw new Error('Invalid resource metadata: missing authorization_servers field');
|
||||
}
|
||||
|
||||
console.log(`Found ${metadata.authorization_servers.length} authorization server(s)`);
|
||||
return metadata;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to fetch protected resource metadata:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch scopes from protected resource metadata by trying the well-known URL
|
||||
*
|
||||
* @param serverUrl - The MCP server URL
|
||||
* @returns Array of supported scopes or undefined if not available
|
||||
*/
|
||||
export const fetchScopesFromServer = async (serverUrl: string): Promise<string[] | undefined> => {
|
||||
try {
|
||||
// Construct the well-known protected resource metadata URL
|
||||
// Format: https://example.com/.well-known/oauth-protected-resource/path/to/resource
|
||||
const url = new URL(serverUrl);
|
||||
const resourcePath = url.pathname + url.search;
|
||||
const wellKnownUrl = `${url.origin}/.well-known/oauth-protected-resource${resourcePath}`;
|
||||
|
||||
console.log(`Attempting to fetch scopes from: ${wellKnownUrl}`);
|
||||
|
||||
const metadata = await fetchProtectedResourceMetadata(wellKnownUrl);
|
||||
|
||||
if (metadata.scopes_supported && Array.isArray(metadata.scopes_supported)) {
|
||||
console.log(`Fetched scopes from server: ${metadata.scopes_supported.join(', ')}`);
|
||||
return metadata.scopes_supported as string[];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`Could not fetch scopes from server (this is normal if not using OAuth discovery): ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Auto-detect OAuth configuration from 401 response
|
||||
* Following MCP Authorization Specification for automatic discovery
|
||||
*
|
||||
* @param wwwAuthenticateHeader - The WWW-Authenticate header value from 401 response
|
||||
* @param serverUrl - The MCP server URL that returned 401
|
||||
* @returns Issuer URL and resource URL for OAuth configuration
|
||||
*/
|
||||
export const autoDetectOAuthConfig = async (
|
||||
wwwAuthenticateHeader: string,
|
||||
serverUrl: string,
|
||||
): Promise<{ issuer: string; resource: string; scopes?: string[] } | null> => {
|
||||
try {
|
||||
// Step 1: Parse WWW-Authenticate header to get resource metadata URL
|
||||
const resourceMetadataUrl = parseWWWAuthenticateHeader(wwwAuthenticateHeader);
|
||||
|
||||
if (!resourceMetadataUrl) {
|
||||
console.log('No resource metadata URL found in WWW-Authenticate header');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Step 2: Fetch protected resource metadata
|
||||
const resourceMetadata = await fetchProtectedResourceMetadata(resourceMetadataUrl);
|
||||
|
||||
// Step 3: Select first authorization server (TODO: implement proper selection logic)
|
||||
const issuer = resourceMetadata.authorization_servers[0];
|
||||
|
||||
if (!issuer) {
|
||||
throw new Error('No authorization servers found in resource metadata');
|
||||
}
|
||||
|
||||
// Step 4: Determine resource URL (canonical URI of MCP server)
|
||||
const resource = resourceMetadata.resource || new URL(serverUrl).origin;
|
||||
|
||||
// Step 5: Extract supported scopes from resource metadata
|
||||
const scopes = resourceMetadata.scopes_supported as string[] | undefined;
|
||||
|
||||
console.log(`Auto-detected OAuth configuration:`);
|
||||
console.log(` Issuer: ${issuer}`);
|
||||
console.log(` Resource: ${resource}`);
|
||||
if (scopes && scopes.length > 0) {
|
||||
console.log(` Scopes: ${scopes.join(', ')}`);
|
||||
}
|
||||
|
||||
return { issuer, resource, scopes };
|
||||
} catch (error) {
|
||||
console.error('Failed to auto-detect OAuth configuration:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Perform OAuth 2.0 issuer discovery to get authorization server metadata
|
||||
*/
|
||||
export const discoverIssuer = async (
|
||||
issuerUrl: string,
|
||||
clientId: string = 'mcphub-temp',
|
||||
clientSecret?: string,
|
||||
): Promise<client.Configuration> => {
|
||||
try {
|
||||
console.log(`Discovering OAuth issuer: ${issuerUrl}`);
|
||||
const server = new URL(issuerUrl);
|
||||
|
||||
const clientAuth = clientSecret ? client.ClientSecretPost(clientSecret) : client.None();
|
||||
|
||||
const config = await client.discovery(server, clientId, undefined, clientAuth);
|
||||
console.log(`Successfully discovered OAuth issuer: ${issuerUrl}`);
|
||||
return config;
|
||||
} catch (error) {
|
||||
console.error(`Failed to discover OAuth issuer ${issuerUrl}:`, error);
|
||||
throw new Error(
|
||||
`OAuth issuer discovery failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Register a new OAuth client dynamically using RFC7591
|
||||
* Can be called with auto-detected configuration from 401 response
|
||||
*/
|
||||
export const registerClient = async (
|
||||
serverName: string,
|
||||
serverConfig: ServerConfig,
|
||||
autoDetectedIssuer?: string,
|
||||
autoDetectedScopes?: string[],
|
||||
): Promise<RegisteredClientInfo> => {
|
||||
// Check if we already have a registered client for this server
|
||||
const cached = registeredClients.get(serverName);
|
||||
if (cached && (!cached.expiresAt || cached.expiresAt > Date.now())) {
|
||||
console.log(`Using cached OAuth client for server: ${serverName}`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const dynamicConfig = serverConfig.oauth?.dynamicRegistration;
|
||||
|
||||
try {
|
||||
let serverUrl: URL;
|
||||
|
||||
// Step 1: Determine the authorization server URL
|
||||
// Priority: autoDetectedIssuer > configured issuer > registration endpoint
|
||||
const issuerUrl = autoDetectedIssuer || dynamicConfig?.issuer;
|
||||
|
||||
if (issuerUrl) {
|
||||
serverUrl = new URL(issuerUrl);
|
||||
} else if (dynamicConfig?.registrationEndpoint) {
|
||||
// Extract server URL from registration endpoint
|
||||
const regUrl = new URL(dynamicConfig.registrationEndpoint);
|
||||
serverUrl = new URL(`${regUrl.protocol}//${regUrl.host}`);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Cannot register OAuth client: no issuer URL available. Either provide 'issuer' in configuration or ensure server returns proper 401 with WWW-Authenticate header.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: Prepare client metadata for registration
|
||||
const metadata = dynamicConfig?.metadata || {};
|
||||
|
||||
// Determine scopes: priority is metadata.scope > autoDetectedScopes > configured scopes > 'openid'
|
||||
let scopeValue: string;
|
||||
if (metadata.scope) {
|
||||
scopeValue = metadata.scope;
|
||||
} else if (autoDetectedScopes && autoDetectedScopes.length > 0) {
|
||||
scopeValue = autoDetectedScopes.join(' ');
|
||||
} else if (serverConfig.oauth?.scopes) {
|
||||
scopeValue = serverConfig.oauth.scopes.join(' ');
|
||||
} else {
|
||||
scopeValue = 'openid';
|
||||
}
|
||||
|
||||
const clientMetadata: Partial<client.ClientMetadata> = {
|
||||
client_name: metadata.client_name || `MCPHub - ${serverName}`,
|
||||
redirect_uris: metadata.redirect_uris || ['http://localhost:3000/oauth/callback'],
|
||||
grant_types: metadata.grant_types || ['authorization_code', 'refresh_token'],
|
||||
response_types: metadata.response_types || ['code'],
|
||||
token_endpoint_auth_method: metadata.token_endpoint_auth_method || 'client_secret_post',
|
||||
scope: scopeValue,
|
||||
...metadata, // Include any additional custom metadata
|
||||
};
|
||||
|
||||
console.log(`Registering OAuth client for server: ${serverName}`);
|
||||
console.log(`Server URL: ${serverUrl}`);
|
||||
console.log(`Client metadata:`, JSON.stringify(clientMetadata, null, 2));
|
||||
|
||||
// Step 3: Perform dynamic client registration
|
||||
const clientAuth = dynamicConfig?.initialAccessToken
|
||||
? client.ClientSecretPost(dynamicConfig.initialAccessToken)
|
||||
: client.None();
|
||||
|
||||
const config = await client.dynamicClientRegistration(serverUrl, clientMetadata, clientAuth);
|
||||
|
||||
console.log(`Successfully registered OAuth client for server: ${serverName}`);
|
||||
|
||||
// Extract client ID from the configuration
|
||||
const clientId = (config as any).client_id || (config as any).clientId;
|
||||
console.log(`Client ID: ${clientId}`);
|
||||
|
||||
// Step 4: Store registered client information
|
||||
const clientInfo: RegisteredClientInfo = {
|
||||
config,
|
||||
clientId,
|
||||
clientSecret: (config as any).client_secret, // Access client secret if available
|
||||
registrationAccessToken: (config as any).registrationAccessToken,
|
||||
registrationClientUri: (config as any).registrationClientUri,
|
||||
expiresAt: (config as any).client_secret_expires_at
|
||||
? (config as any).client_secret_expires_at * 1000
|
||||
: undefined,
|
||||
metadata: config,
|
||||
};
|
||||
|
||||
// Cache the registered client
|
||||
registeredClients.set(serverName, clientInfo);
|
||||
|
||||
// Persist the client credentials and scopes to configuration
|
||||
const persistedConfig = await persistClientCredentials(serverName, {
|
||||
clientId,
|
||||
clientSecret: clientInfo.clientSecret,
|
||||
scopes: autoDetectedScopes,
|
||||
authorizationEndpoint: clientInfo.config.serverMetadata().authorization_endpoint,
|
||||
tokenEndpoint: clientInfo.config.serverMetadata().token_endpoint,
|
||||
});
|
||||
|
||||
if (persistedConfig) {
|
||||
serverConfig.oauth = {
|
||||
...(serverConfig.oauth || {}),
|
||||
...persistedConfig.oauth,
|
||||
};
|
||||
}
|
||||
|
||||
return clientInfo;
|
||||
} catch (error) {
|
||||
console.error(`Failed to register OAuth client for server ${serverName}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get authorization URL for user authorization (OAuth 2.0 authorization code flow)
|
||||
*/
|
||||
export const getAuthorizationUrl = async (
|
||||
serverName: string,
|
||||
serverConfig: ServerConfig,
|
||||
clientInfo: RegisteredClientInfo,
|
||||
redirectUri: string,
|
||||
state: string,
|
||||
codeVerifier: string,
|
||||
): Promise<string> => {
|
||||
try {
|
||||
// Generate code challenge for PKCE (required by MCP spec)
|
||||
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
|
||||
|
||||
// Build authorization parameters
|
||||
const params: Record<string, string> = {
|
||||
redirect_uri: redirectUri,
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
scope: serverConfig.oauth?.scopes?.join(' ') || 'openid',
|
||||
};
|
||||
|
||||
// Add resource parameter for MCP (RFC8707)
|
||||
if (serverConfig.oauth?.resource) {
|
||||
params.resource = serverConfig.oauth.resource;
|
||||
}
|
||||
|
||||
const authUrl = client.buildAuthorizationUrl(clientInfo.config, params);
|
||||
return authUrl.toString();
|
||||
} catch (error) {
|
||||
console.error(`Failed to generate authorization URL for server ${serverName}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Exchange authorization code for access token
|
||||
*/
|
||||
export const exchangeCodeForToken = async (
|
||||
serverName: string,
|
||||
serverConfig: ServerConfig,
|
||||
clientInfo: RegisteredClientInfo,
|
||||
currentUrl: string,
|
||||
codeVerifier: string,
|
||||
): Promise<{ accessToken: string; refreshToken?: string; expiresIn?: number }> => {
|
||||
try {
|
||||
console.log(`Exchanging authorization code for access token for server: ${serverName}`);
|
||||
|
||||
// Prepare token endpoint parameters
|
||||
const tokenParams: Record<string, string> = {
|
||||
code_verifier: codeVerifier,
|
||||
};
|
||||
|
||||
// Add resource parameter for MCP (RFC8707)
|
||||
if (serverConfig.oauth?.resource) {
|
||||
tokenParams.resource = serverConfig.oauth.resource;
|
||||
}
|
||||
|
||||
const tokens = await client.authorizationCodeGrant(
|
||||
clientInfo.config,
|
||||
new URL(currentUrl),
|
||||
{ expectedState: undefined }, // State is already validated
|
||||
tokenParams,
|
||||
);
|
||||
|
||||
console.log(`Successfully obtained access token for server: ${serverName}`);
|
||||
|
||||
await persistTokens(serverName, {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token ?? undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Failed to exchange code for token for server ${serverName}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh token
|
||||
*/
|
||||
export const refreshAccessToken = async (
|
||||
serverName: string,
|
||||
serverConfig: ServerConfig,
|
||||
clientInfo: RegisteredClientInfo,
|
||||
refreshToken: string,
|
||||
): Promise<{ accessToken: string; refreshToken?: string; expiresIn?: number }> => {
|
||||
try {
|
||||
console.log(`Refreshing access token for server: ${serverName}`);
|
||||
|
||||
// Prepare refresh token parameters
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
// Add resource parameter for MCP (RFC8707)
|
||||
if (serverConfig.oauth?.resource) {
|
||||
params.resource = serverConfig.oauth.resource;
|
||||
}
|
||||
|
||||
const tokens = await client.refreshTokenGrant(clientInfo.config, refreshToken, params);
|
||||
|
||||
console.log(`Successfully refreshed access token for server: ${serverName}`);
|
||||
|
||||
await persistTokens(serverName, {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token ?? undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Failed to refresh access token for server ${serverName}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate PKCE code verifier
|
||||
*/
|
||||
export const generateCodeVerifier = (): string => {
|
||||
return client.randomPKCECodeVerifier();
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate PKCE code challenge from verifier
|
||||
*/
|
||||
export const calculateCodeChallenge = async (codeVerifier: string): Promise<string> => {
|
||||
return client.calculatePKCECodeChallenge(codeVerifier);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get registered client info from cache
|
||||
*/
|
||||
export const getRegisteredClient = (serverName: string): RegisteredClientInfo | undefined => {
|
||||
return registeredClients.get(serverName);
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize OAuth for a server (performs registration if needed)
|
||||
* Now supports auto-detection via 401 responses with WWW-Authenticate header
|
||||
*
|
||||
* @param serverName - Name of the server
|
||||
* @param serverConfig - Server configuration
|
||||
* @param autoDetectedIssuer - Optional issuer URL from auto-detection
|
||||
* @param autoDetectedScopes - Optional scopes from auto-detection
|
||||
* @returns RegisteredClientInfo or null
|
||||
*/
|
||||
export const initializeOAuthForServer = async (
|
||||
serverName: string,
|
||||
serverConfig: ServerConfig,
|
||||
autoDetectedIssuer?: string,
|
||||
autoDetectedScopes?: string[],
|
||||
): Promise<RegisteredClientInfo | null> => {
|
||||
if (!serverConfig.oauth) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if dynamic registration should be attempted
|
||||
const shouldAttemptRegistration =
|
||||
autoDetectedIssuer || // Auto-detected from 401 response
|
||||
serverConfig.oauth.dynamicRegistration?.enabled === true || // Explicitly enabled
|
||||
(serverConfig.oauth.dynamicRegistration && !serverConfig.oauth.clientId); // Configured but no static client
|
||||
|
||||
if (shouldAttemptRegistration) {
|
||||
try {
|
||||
// Perform dynamic client registration
|
||||
const clientInfo = await registerClient(
|
||||
serverName,
|
||||
serverConfig,
|
||||
autoDetectedIssuer,
|
||||
autoDetectedScopes,
|
||||
);
|
||||
return clientInfo;
|
||||
} catch (error) {
|
||||
console.error(`Failed to initialize OAuth for server ${serverName}:`, error);
|
||||
// If auto-detection failed, don't throw - allow fallback to static config
|
||||
if (!autoDetectedIssuer) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Static client configuration - create Configuration from static values
|
||||
if (serverConfig.oauth.clientId) {
|
||||
// Try to fetch and store scopes if not already configured
|
||||
if (!serverConfig.oauth.scopes && serverConfig.url) {
|
||||
try {
|
||||
const fetchedScopes = await fetchScopesFromServer(serverConfig.url);
|
||||
if (fetchedScopes && fetchedScopes.length > 0) {
|
||||
await mutateOAuthSettings(serverName, ({ oauth }) => {
|
||||
oauth.scopes = fetchedScopes;
|
||||
});
|
||||
|
||||
if (!serverConfig.oauth) {
|
||||
serverConfig.oauth = {};
|
||||
}
|
||||
serverConfig.oauth.scopes = fetchedScopes;
|
||||
console.log(`Stored fetched scopes for ${serverName}: ${fetchedScopes.join(', ')}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Failed to fetch scopes for ${serverName}, will use defaults`);
|
||||
}
|
||||
}
|
||||
|
||||
// For static config, we need the authorization server URL
|
||||
let serverUrl: URL;
|
||||
|
||||
if (serverConfig.oauth.authorizationEndpoint) {
|
||||
const authUrl = new URL(serverConfig.oauth.authorizationEndpoint!);
|
||||
serverUrl = new URL(`${authUrl.protocol}//${authUrl.host}`);
|
||||
} else if (serverConfig.oauth.tokenEndpoint) {
|
||||
const tokenUrl = new URL(serverConfig.oauth.tokenEndpoint!);
|
||||
serverUrl = new URL(`${tokenUrl.protocol}//${tokenUrl.host}`);
|
||||
} else {
|
||||
console.warn(`Server ${serverName} has static OAuth config but missing endpoints`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Discover the server configuration
|
||||
const clientAuth = serverConfig.oauth.clientSecret
|
||||
? client.ClientSecretPost(serverConfig.oauth.clientSecret)
|
||||
: client.None();
|
||||
|
||||
const config = await client.discovery(
|
||||
serverUrl,
|
||||
serverConfig.oauth.clientId!,
|
||||
undefined,
|
||||
clientAuth,
|
||||
);
|
||||
|
||||
const clientInfo: RegisteredClientInfo = {
|
||||
config,
|
||||
clientId: serverConfig.oauth.clientId!,
|
||||
clientSecret: serverConfig.oauth.clientSecret,
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
registeredClients.set(serverName, clientInfo);
|
||||
return clientInfo;
|
||||
} catch (error) {
|
||||
console.error(`Failed to discover OAuth server for ${serverName}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -1,271 +0,0 @@
|
||||
import { ProxyOAuthServerProvider } from '@modelcontextprotocol/sdk/server/auth/providers/proxyProvider.js';
|
||||
import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js';
|
||||
import { RequestHandler } from 'express';
|
||||
import { loadSettings } from '../config/index.js';
|
||||
import { initializeOAuthForServer, refreshAccessToken } from './oauthClientRegistration.js';
|
||||
|
||||
// Re-export for external use
|
||||
export {
|
||||
getRegisteredClient,
|
||||
getAuthorizationUrl,
|
||||
exchangeCodeForToken,
|
||||
generateCodeVerifier,
|
||||
calculateCodeChallenge,
|
||||
autoDetectOAuthConfig,
|
||||
parseWWWAuthenticateHeader,
|
||||
fetchProtectedResourceMetadata,
|
||||
} from './oauthClientRegistration.js';
|
||||
|
||||
let oauthProvider: ProxyOAuthServerProvider | null = null;
|
||||
let oauthRouter: RequestHandler | null = null;
|
||||
|
||||
/**
|
||||
* Initialize OAuth provider from system configuration
|
||||
*/
|
||||
export const initOAuthProvider = (): void => {
|
||||
const settings = loadSettings();
|
||||
const oauthConfig = settings.systemConfig?.oauth;
|
||||
|
||||
if (!oauthConfig || !oauthConfig.enabled) {
|
||||
console.log('OAuth provider is disabled or not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create proxy OAuth provider
|
||||
oauthProvider = new ProxyOAuthServerProvider({
|
||||
endpoints: {
|
||||
authorizationUrl: oauthConfig.endpoints.authorizationUrl,
|
||||
tokenUrl: oauthConfig.endpoints.tokenUrl,
|
||||
revocationUrl: oauthConfig.endpoints.revocationUrl,
|
||||
},
|
||||
verifyAccessToken: async (token: string) => {
|
||||
// If a verification endpoint is configured, use it
|
||||
if (oauthConfig.verifyAccessToken?.endpoint) {
|
||||
const response = await fetch(oauthConfig.verifyAccessToken.endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...oauthConfig.verifyAccessToken.headers,
|
||||
},
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Token verification failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return {
|
||||
token,
|
||||
clientId: result.client_id || result.clientId || 'unknown',
|
||||
scopes: result.scopes || result.scope?.split(' ') || [],
|
||||
};
|
||||
}
|
||||
|
||||
// Default verification - just extract basic info from token
|
||||
// In production, you should decode/verify JWT or call an introspection endpoint
|
||||
return {
|
||||
token,
|
||||
clientId: 'default',
|
||||
scopes: oauthConfig.scopesSupported || [],
|
||||
};
|
||||
},
|
||||
getClient: async (clientId: string) => {
|
||||
// Find client in configuration
|
||||
const client = oauthConfig.clients?.find((c) => c.client_id === clientId);
|
||||
|
||||
if (!client) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
client_id: client.client_id,
|
||||
redirect_uris: client.redirect_uris,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Create OAuth router
|
||||
const issuerUrl = new URL(oauthConfig.issuerUrl);
|
||||
const baseUrl = oauthConfig.baseUrl ? new URL(oauthConfig.baseUrl) : issuerUrl;
|
||||
|
||||
oauthRouter = mcpAuthRouter({
|
||||
provider: oauthProvider,
|
||||
issuerUrl,
|
||||
baseUrl,
|
||||
serviceDocumentationUrl: oauthConfig.serviceDocumentationUrl
|
||||
? new URL(oauthConfig.serviceDocumentationUrl)
|
||||
: undefined,
|
||||
scopesSupported: oauthConfig.scopesSupported,
|
||||
});
|
||||
|
||||
console.log('OAuth provider initialized successfully');
|
||||
console.log(`OAuth issuer URL: ${issuerUrl.origin}`);
|
||||
// Only log endpoint URLs, not full config which might contain sensitive data
|
||||
console.log(
|
||||
'OAuth endpoints configured: authorization, token' +
|
||||
(oauthConfig.endpoints.revocationUrl ? ', revocation' : ''),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize OAuth provider:', error);
|
||||
oauthProvider = null;
|
||||
oauthRouter = null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the OAuth router if available
|
||||
*/
|
||||
export const getOAuthRouter = (): RequestHandler | null => {
|
||||
return oauthRouter;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the OAuth provider if available
|
||||
*/
|
||||
export const getOAuthProvider = (): ProxyOAuthServerProvider | null => {
|
||||
return oauthProvider;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if OAuth is enabled
|
||||
*/
|
||||
export const isOAuthEnabled = (): boolean => {
|
||||
return oauthProvider !== null && oauthRouter !== null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get OAuth access token for a server if configured
|
||||
* Handles both static tokens and dynamic OAuth flows with automatic token refresh
|
||||
*/
|
||||
export const getServerOAuthToken = async (serverName: string): Promise<string | undefined> => {
|
||||
const settings = loadSettings();
|
||||
const serverConfig = settings.mcpServers[serverName];
|
||||
|
||||
if (!serverConfig?.oauth) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// If a pre-configured access token exists, use it
|
||||
if (serverConfig.oauth.accessToken) {
|
||||
// TODO: In a production system, check if token is expired and refresh if needed
|
||||
// For now, just return the configured token
|
||||
return serverConfig.oauth.accessToken;
|
||||
}
|
||||
|
||||
// If dynamic registration is enabled, initialize OAuth and get token
|
||||
if (serverConfig.oauth.dynamicRegistration?.enabled) {
|
||||
try {
|
||||
// Initialize OAuth for this server (registers client if needed)
|
||||
const clientInfo = await initializeOAuthForServer(serverName, serverConfig);
|
||||
|
||||
if (!clientInfo) {
|
||||
console.warn(`Failed to initialize OAuth for server: ${serverName}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// If we have a refresh token, try to get a new access token
|
||||
if (serverConfig.oauth.refreshToken) {
|
||||
try {
|
||||
const tokens = await refreshAccessToken(
|
||||
serverName,
|
||||
serverConfig,
|
||||
clientInfo,
|
||||
serverConfig.oauth.refreshToken,
|
||||
);
|
||||
return tokens.accessToken;
|
||||
} catch (error) {
|
||||
console.error(`Failed to refresh token for server ${serverName}:`, error);
|
||||
// Token refresh failed - user needs to re-authorize
|
||||
// In a production system, you would trigger a new authorization flow here
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// No access token and no refresh token available
|
||||
// User needs to go through the authorization flow
|
||||
// This would typically be triggered by an API endpoint that initiates the OAuth flow
|
||||
console.log(`Server ${serverName} requires user authorization via OAuth flow`);
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
console.error(`Failed to get OAuth token for server ${serverName}:`, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Static client configuration - check for existing token
|
||||
if (serverConfig.oauth.clientId && serverConfig.oauth.accessToken) {
|
||||
return serverConfig.oauth.accessToken;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Add OAuth authorization header to request headers if token is available
|
||||
*/
|
||||
export const addOAuthHeader = async (
|
||||
serverName: string,
|
||||
headers: Record<string, string>,
|
||||
): Promise<Record<string, string>> => {
|
||||
const token = await getServerOAuthToken(serverName);
|
||||
|
||||
if (token) {
|
||||
return {
|
||||
...headers,
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
}
|
||||
|
||||
return headers;
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize OAuth for all configured servers with explicit dynamic registration enabled
|
||||
* Servers without explicit configuration will be registered on-demand when receiving 401
|
||||
* Call this at application startup to pre-register known OAuth servers
|
||||
*/
|
||||
export const initializeAllOAuthClients = async (): Promise<void> => {
|
||||
const settings = loadSettings();
|
||||
|
||||
console.log('Initializing OAuth clients for explicitly configured servers...');
|
||||
|
||||
const serverNames = Object.keys(settings.mcpServers);
|
||||
const registrationPromises: Promise<void>[] = [];
|
||||
|
||||
for (const serverName of serverNames) {
|
||||
const serverConfig = settings.mcpServers[serverName];
|
||||
|
||||
// Only initialize servers with explicitly enabled dynamic registration
|
||||
// Others will be auto-detected and registered on first 401 response
|
||||
if (serverConfig.oauth?.dynamicRegistration?.enabled === true) {
|
||||
registrationPromises.push(
|
||||
initializeOAuthForServer(serverName, serverConfig)
|
||||
.then((clientInfo) => {
|
||||
if (clientInfo) {
|
||||
console.log(`✓ OAuth client pre-registered for server: ${serverName}`);
|
||||
} else {
|
||||
console.warn(`✗ Failed to pre-register OAuth client for server: ${serverName}`);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`✗ Error pre-registering OAuth client for server ${serverName}:`,
|
||||
error.message,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all registrations to complete
|
||||
if (registrationPromises.length > 0) {
|
||||
await Promise.all(registrationPromises);
|
||||
console.log(
|
||||
`OAuth client pre-registration completed for ${registrationPromises.length} server(s)`,
|
||||
);
|
||||
} else {
|
||||
console.log('No servers configured for pre-registration (will auto-detect on 401 responses)');
|
||||
}
|
||||
};
|
||||
@@ -1,158 +0,0 @@
|
||||
import { loadSettings, saveSettings } from '../config/index.js';
|
||||
import { McpSettings, ServerConfig } from '../types/index.js';
|
||||
|
||||
type OAuthConfig = NonNullable<ServerConfig['oauth']>;
|
||||
export type ServerConfigWithOAuth = ServerConfig & { oauth: OAuthConfig };
|
||||
|
||||
export interface OAuthSettingsContext {
|
||||
settings: McpSettings;
|
||||
serverConfig: ServerConfig;
|
||||
oauth: OAuthConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the latest server configuration from disk.
|
||||
*/
|
||||
export const loadServerConfig = (serverName: string): ServerConfig | undefined => {
|
||||
const settings = loadSettings();
|
||||
return settings.mcpServers?.[serverName];
|
||||
};
|
||||
|
||||
/**
|
||||
* Mutate OAuth configuration for a server and persist the updated settings.
|
||||
* The mutator receives the shared settings object to allow related updates when needed.
|
||||
*/
|
||||
export const mutateOAuthSettings = async (
|
||||
serverName: string,
|
||||
mutator: (context: OAuthSettingsContext) => void,
|
||||
): Promise<ServerConfigWithOAuth | undefined> => {
|
||||
const settings = loadSettings();
|
||||
const serverConfig = settings.mcpServers?.[serverName];
|
||||
|
||||
if (!serverConfig) {
|
||||
console.warn(`Server ${serverName} not found while updating OAuth settings`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!serverConfig.oauth) {
|
||||
serverConfig.oauth = {};
|
||||
}
|
||||
|
||||
const context: OAuthSettingsContext = {
|
||||
settings,
|
||||
serverConfig,
|
||||
oauth: serverConfig.oauth,
|
||||
};
|
||||
|
||||
mutator(context);
|
||||
|
||||
const saved = saveSettings(settings);
|
||||
if (!saved) {
|
||||
throw new Error(`Failed to persist OAuth settings for server ${serverName}`);
|
||||
}
|
||||
|
||||
return context.serverConfig as ServerConfigWithOAuth;
|
||||
};
|
||||
|
||||
export const persistClientCredentials = async (
|
||||
serverName: string,
|
||||
credentials: {
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
scopes?: string[];
|
||||
authorizationEndpoint?: string;
|
||||
tokenEndpoint?: string;
|
||||
},
|
||||
): Promise<ServerConfigWithOAuth | undefined> => {
|
||||
const updated = await mutateOAuthSettings(serverName, ({ oauth }) => {
|
||||
oauth.clientId = credentials.clientId;
|
||||
oauth.clientSecret = credentials.clientSecret;
|
||||
|
||||
if (credentials.scopes && credentials.scopes.length > 0) {
|
||||
oauth.scopes = credentials.scopes;
|
||||
}
|
||||
if (credentials.authorizationEndpoint) {
|
||||
oauth.authorizationEndpoint = credentials.authorizationEndpoint;
|
||||
}
|
||||
if (credentials.tokenEndpoint) {
|
||||
oauth.tokenEndpoint = credentials.tokenEndpoint;
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Persisted OAuth client credentials for server: ${serverName}`);
|
||||
if (credentials.scopes && credentials.scopes.length > 0) {
|
||||
console.log(`Stored OAuth scopes for ${serverName}: ${credentials.scopes.join(', ')}`);
|
||||
}
|
||||
|
||||
return updated;
|
||||
};
|
||||
|
||||
/**
|
||||
* Persist OAuth tokens and optionally replace the stored refresh token.
|
||||
*/
|
||||
export const persistTokens = async (
|
||||
serverName: string,
|
||||
tokens: {
|
||||
accessToken: string;
|
||||
refreshToken?: string | null;
|
||||
clearPendingAuthorization?: boolean;
|
||||
},
|
||||
): Promise<ServerConfigWithOAuth | undefined> => {
|
||||
return mutateOAuthSettings(serverName, ({ oauth }) => {
|
||||
oauth.accessToken = tokens.accessToken;
|
||||
|
||||
if (tokens.refreshToken !== undefined) {
|
||||
if (tokens.refreshToken) {
|
||||
oauth.refreshToken = tokens.refreshToken;
|
||||
} else {
|
||||
delete oauth.refreshToken;
|
||||
}
|
||||
}
|
||||
|
||||
if (tokens.clearPendingAuthorization && oauth.pendingAuthorization) {
|
||||
delete oauth.pendingAuthorization;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Update or create a pending authorization record.
|
||||
*/
|
||||
export const updatePendingAuthorization = async (
|
||||
serverName: string,
|
||||
pending: Partial<NonNullable<OAuthConfig['pendingAuthorization']>>,
|
||||
): Promise<ServerConfigWithOAuth | undefined> => {
|
||||
return mutateOAuthSettings(serverName, ({ oauth }) => {
|
||||
oauth.pendingAuthorization = {
|
||||
...(oauth.pendingAuthorization || {}),
|
||||
...pending,
|
||||
createdAt: pending.createdAt ?? Date.now(),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear cached OAuth data using shared helpers.
|
||||
*/
|
||||
export const clearOAuthData = async (
|
||||
serverName: string,
|
||||
scope: 'all' | 'client' | 'tokens' | 'verifier',
|
||||
): Promise<ServerConfigWithOAuth | undefined> => {
|
||||
return mutateOAuthSettings(serverName, ({ oauth }) => {
|
||||
if (scope === 'tokens' || scope === 'all') {
|
||||
delete oauth.accessToken;
|
||||
delete oauth.refreshToken;
|
||||
}
|
||||
|
||||
if (scope === 'client' || scope === 'all') {
|
||||
delete oauth.clientId;
|
||||
delete oauth.clientSecret;
|
||||
}
|
||||
|
||||
if (scope === 'verifier' || scope === 'all') {
|
||||
if (oauth.pendingAuthorization) {
|
||||
delete oauth.pendingAuthorization;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -225,22 +225,13 @@ export async function generateOpenAPISpec(
|
||||
|
||||
// Generate paths from tools
|
||||
const paths: OpenAPIV3.PathsObject = {};
|
||||
const separator = getNameSeparator();
|
||||
|
||||
for (const { tool, serverName } of allTools) {
|
||||
const operation = generateOperationFromTool(tool, serverName);
|
||||
const { requestBody } = convertToolSchemaToOpenAPI(tool);
|
||||
|
||||
// Extract the tool name without server prefix
|
||||
// Tool names are in format: serverName + separator + toolName
|
||||
const prefix = `${serverName}${separator}`;
|
||||
const toolNameOnly = tool.name.startsWith(prefix)
|
||||
? tool.name.substring(prefix.length)
|
||||
: tool.name;
|
||||
|
||||
// Create path for the tool with URL-encoded server and tool names
|
||||
// This handles cases where names contain slashes (e.g., "com.atlassian/atlassian-mcp-server")
|
||||
const pathName = `/tools/${encodeURIComponent(serverName)}/${encodeURIComponent(toolNameOnly)}`;
|
||||
// Create path for the tool
|
||||
const pathName = `/tools/${serverName}/${tool.name}`;
|
||||
const method = requestBody ? 'post' : 'get';
|
||||
|
||||
if (!paths[pathName]) {
|
||||
|
||||
@@ -124,31 +124,6 @@ export interface MCPRouterCallToolResponse {
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
// OAuth Provider Configuration for MCP Authorization Server
|
||||
export interface OAuthProviderConfig {
|
||||
enabled?: boolean; // Enable/disable OAuth provider
|
||||
issuerUrl: string; // Authorization server's issuer identifier (e.g., 'http://auth.external.com')
|
||||
baseUrl?: string; // Base URL for the authorization server metadata endpoints (defaults to issuerUrl)
|
||||
serviceDocumentationUrl?: string; // URL for human-readable OAuth documentation
|
||||
scopesSupported?: string[]; // List of OAuth scopes supported
|
||||
endpoints: {
|
||||
authorizationUrl: string; // External OAuth authorization endpoint
|
||||
tokenUrl: string; // External OAuth token endpoint
|
||||
revocationUrl?: string; // External OAuth revocation endpoint (optional)
|
||||
};
|
||||
// Token verification function details
|
||||
verifyAccessToken?: {
|
||||
endpoint?: string; // Optional: External endpoint to verify access tokens
|
||||
headers?: Record<string, string>; // Optional: Headers for token verification requests
|
||||
};
|
||||
// Client management
|
||||
clients?: Array<{
|
||||
client_id: string; // Client identifier
|
||||
redirect_uris: string[]; // Allowed redirect URIs for this client
|
||||
scopes?: string[]; // Scopes this client can request
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface SystemConfig {
|
||||
routing?: {
|
||||
enableGlobalRoute?: boolean; // Controls whether the /sse endpoint without group is enabled
|
||||
@@ -170,7 +145,6 @@ export interface SystemConfig {
|
||||
baseUrl?: string; // Base URL for MCPRouter API (default: https://api.mcprouter.to/v1)
|
||||
};
|
||||
nameSeparator?: string; // Separator used between server name and tool/prompt name (default: '-')
|
||||
oauth?: OAuthProviderConfig; // OAuth provider configuration for upstream MCP servers
|
||||
}
|
||||
|
||||
export interface UserConfig {
|
||||
@@ -204,59 +178,10 @@ export interface ServerConfig {
|
||||
enabled?: boolean; // Flag to enable/disable the server
|
||||
owner?: string; // Owner of the server, defaults to 'admin' user
|
||||
keepAliveInterval?: number; // Keep-alive ping interval in milliseconds (default: 60000ms for SSE servers)
|
||||
connectionMode?: 'persistent' | 'on-demand'; // Connection strategy: 'persistent' maintains long-running connections (default), 'on-demand' connects only when tools are called
|
||||
perSession?: boolean; // If true, creates a separate server instance for each session (useful for stateful servers like playwright)
|
||||
tools?: Record<string, { enabled: boolean; description?: string }>; // Tool-specific configurations with enable/disable state and custom descriptions
|
||||
prompts?: Record<string, { enabled: boolean; description?: string }>; // Prompt-specific configurations with enable/disable state and custom descriptions
|
||||
options?: Partial<Pick<RequestOptions, 'timeout' | 'resetTimeoutOnProgress' | 'maxTotalTimeout'>>; // MCP request options configuration
|
||||
// OAuth authentication for upstream MCP servers
|
||||
oauth?: {
|
||||
// Static client configuration (traditional OAuth flow)
|
||||
clientId?: string; // OAuth client ID
|
||||
clientSecret?: string; // OAuth client secret
|
||||
scopes?: string[]; // Required OAuth scopes
|
||||
accessToken?: string; // Pre-obtained access token (if available)
|
||||
refreshToken?: string; // Refresh token for renewing access
|
||||
|
||||
// Dynamic client registration (RFC7591)
|
||||
// If not explicitly configured, will auto-detect via WWW-Authenticate header on 401 responses
|
||||
dynamicRegistration?: {
|
||||
enabled?: boolean; // Enable/disable dynamic registration (default: auto-detect on 401)
|
||||
issuer?: string; // OAuth issuer URL for discovery (e.g., 'https://auth.example.com')
|
||||
registrationEndpoint?: string; // Direct registration endpoint URL (if discovery is not used)
|
||||
metadata?: {
|
||||
// Client metadata for registration (RFC7591 section 2)
|
||||
client_name?: string; // Human-readable client name
|
||||
client_uri?: string; // URL of client's home page
|
||||
logo_uri?: string; // URL of client's logo
|
||||
scope?: string; // Space-separated list of scope values
|
||||
redirect_uris?: string[]; // Array of redirect URIs
|
||||
grant_types?: string[]; // Array of OAuth 2.0 grant types (e.g., ['authorization_code', 'refresh_token'])
|
||||
response_types?: string[]; // Array of OAuth 2.0 response types (e.g., ['code'])
|
||||
token_endpoint_auth_method?: string; // Token endpoint authentication method (e.g., 'client_secret_basic', 'none')
|
||||
contacts?: string[]; // Array of contact email addresses
|
||||
software_id?: string; // Unique identifier for the client software
|
||||
software_version?: string; // Version of the client software
|
||||
[key: string]: any; // Additional metadata fields
|
||||
};
|
||||
// Optional: Initial access token for protected registration endpoints
|
||||
initialAccessToken?: string;
|
||||
};
|
||||
|
||||
// MCP resource parameter (RFC8707) - the canonical URI of the MCP server
|
||||
resource?: string; // e.g., 'https://mcp.example.com/mcp'
|
||||
|
||||
// Authorization endpoint for user authorization (for authorization code flow)
|
||||
authorizationEndpoint?: string;
|
||||
// Token endpoint for exchanging authorization codes for tokens
|
||||
tokenEndpoint?: string;
|
||||
// Pending OAuth session metadata for PKCE/state recovery between restarts
|
||||
pendingAuthorization?: {
|
||||
authorizationUrl?: string;
|
||||
state?: string;
|
||||
codeVerifier?: string;
|
||||
createdAt?: number;
|
||||
};
|
||||
};
|
||||
// OpenAPI specific configuration
|
||||
openapi?: {
|
||||
url?: string; // OpenAPI specification URL
|
||||
@@ -303,7 +228,7 @@ export interface OpenAPISecurityConfig {
|
||||
export interface ServerInfo {
|
||||
name: string; // Unique name of the server
|
||||
owner?: string; // Owner of the server, defaults to 'admin' user
|
||||
status: 'connected' | 'connecting' | 'disconnected' | 'oauth_required'; // Current connection status
|
||||
status: 'connected' | 'connecting' | 'disconnected'; // Current connection status
|
||||
error: string | null; // Error message if any
|
||||
tools: Tool[]; // List of tools available on the server
|
||||
prompts: Prompt[]; // List of prompts available on the server
|
||||
@@ -313,15 +238,9 @@ export interface ServerInfo {
|
||||
options?: RequestOptions; // Options for requests
|
||||
createTime: number; // Timestamp of when the server was created
|
||||
enabled?: boolean; // Flag to indicate if the server is enabled
|
||||
connectionMode?: 'persistent' | 'on-demand'; // Connection strategy for this server
|
||||
keepAliveIntervalId?: NodeJS.Timeout; // Timer ID for keep-alive ping interval
|
||||
config?: ServerConfig; // Reference to the original server configuration for OpenAPI passthrough headers
|
||||
oauth?: {
|
||||
// OAuth authorization state
|
||||
authorizationUrl?: string; // OAuth authorization URL for user to visit
|
||||
state?: string; // OAuth state parameter for CSRF protection
|
||||
codeVerifier?: string; // PKCE code verifier
|
||||
};
|
||||
sessionId?: string; // Session ID for per-session server instances (undefined for shared servers)
|
||||
}
|
||||
|
||||
// Details about a tool available on the server
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { fileURLToPath } from 'url';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Get the directory of the current module
|
||||
* This is in a separate file to allow mocking in test environments
|
||||
*/
|
||||
export function getCurrentModuleDir(): string {
|
||||
const currentModuleFile = fileURLToPath(import.meta.url);
|
||||
return path.dirname(currentModuleFile);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* Utility functions for converting parameter types based on JSON schema definitions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convert parameters to their proper types based on the tool's input schema
|
||||
* This ensures that form-submitted string values are converted to the correct types
|
||||
* (e.g., numbers, booleans, arrays) before being passed to MCP tools.
|
||||
*
|
||||
* @param params - The parameters to convert (typically from form submission)
|
||||
* @param inputSchema - The JSON schema definition for the tool's input
|
||||
* @returns The converted parameters with proper types
|
||||
*/
|
||||
export function convertParametersToTypes(
|
||||
params: Record<string, any>,
|
||||
inputSchema: Record<string, any>,
|
||||
): Record<string, any> {
|
||||
if (!inputSchema || typeof inputSchema !== 'object' || !inputSchema.properties) {
|
||||
return params;
|
||||
}
|
||||
|
||||
const convertedParams: Record<string, any> = {};
|
||||
const properties = inputSchema.properties;
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
const propDef = properties[key];
|
||||
if (!propDef || typeof propDef !== 'object') {
|
||||
// No schema definition found, keep as is
|
||||
convertedParams[key] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
const propType = propDef.type;
|
||||
|
||||
try {
|
||||
switch (propType) {
|
||||
case 'integer':
|
||||
case 'number':
|
||||
// Convert string to number
|
||||
if (typeof value === 'string') {
|
||||
const numValue = propType === 'integer' ? parseInt(value, 10) : parseFloat(value);
|
||||
convertedParams[key] = isNaN(numValue) ? value : numValue;
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'boolean':
|
||||
// Convert string to boolean
|
||||
if (typeof value === 'string') {
|
||||
convertedParams[key] = value.toLowerCase() === 'true' || value === '1';
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'array':
|
||||
// Handle array conversion if needed (e.g., comma-separated strings)
|
||||
if (typeof value === 'string' && value.includes(',')) {
|
||||
convertedParams[key] = value.split(',').map((item) => item.trim());
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'object':
|
||||
// Handle object conversion if needed
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
convertedParams[key] = JSON.parse(value);
|
||||
} catch {
|
||||
// If parsing fails, keep as is
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// For string and other types, keep as is
|
||||
convertedParams[key] = value;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
// If conversion fails, keep the original value
|
||||
console.warn(`Failed to convert parameter '${key}' to type '${propType}':`, error);
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return convertedParams;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* Password strength validation utility
|
||||
* Requirements:
|
||||
* - At least 8 characters
|
||||
* - Contains at least one letter
|
||||
* - Contains at least one number
|
||||
* - Contains at least one special character
|
||||
*/
|
||||
|
||||
export interface PasswordValidationResult {
|
||||
isValid: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export const validatePasswordStrength = (password: string): PasswordValidationResult => {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Check minimum length
|
||||
if (password.length < 8) {
|
||||
errors.push('Password must be at least 8 characters long');
|
||||
}
|
||||
|
||||
// Check for at least one letter
|
||||
if (!/[a-zA-Z]/.test(password)) {
|
||||
errors.push('Password must contain at least one letter');
|
||||
}
|
||||
|
||||
// Check for at least one number
|
||||
if (!/\d/.test(password)) {
|
||||
errors.push('Password must contain at least one number');
|
||||
}
|
||||
|
||||
// Check for at least one special character
|
||||
if (!/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password)) {
|
||||
errors.push('Password must contain at least one special character');
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a password is the default password (admin123)
|
||||
*/
|
||||
export const isDefaultPassword = (plainPassword: string): boolean => {
|
||||
return plainPassword === 'admin123';
|
||||
};
|
||||
@@ -1,173 +1,10 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { dirname } from 'path';
|
||||
import { getCurrentModuleDir } from './moduleDir.js';
|
||||
|
||||
// Project root directory - use process.cwd() as a simpler alternative
|
||||
const rootDir = process.cwd();
|
||||
|
||||
// Cache the package root for performance
|
||||
let cachedPackageRoot: string | null | undefined = undefined;
|
||||
|
||||
/**
|
||||
* Initialize package root by trying to find it using the module directory
|
||||
* This should be called when the module is first loaded
|
||||
*/
|
||||
function initializePackageRoot(): void {
|
||||
// Skip initialization in test environments
|
||||
if (process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to get the current module's directory
|
||||
const currentModuleDir = getCurrentModuleDir();
|
||||
|
||||
// This file is in src/utils/path.ts (or dist/utils/path.js when compiled)
|
||||
// So package.json should be 2 levels up
|
||||
const possibleRoots = [
|
||||
path.resolve(currentModuleDir, '..', '..'), // dist -> package root
|
||||
path.resolve(currentModuleDir, '..'), // dist/utils -> dist -> package root
|
||||
];
|
||||
|
||||
for (const root of possibleRoots) {
|
||||
const packageJsonPath = path.join(root, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
if (pkg.name === 'mcphub' || pkg.name === '@samanhappy/mcphub') {
|
||||
cachedPackageRoot = root;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Continue checking
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If initialization fails, cachedPackageRoot remains undefined
|
||||
// and findPackageRoot will search normally
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on module load (unless in test environment)
|
||||
initializePackageRoot();
|
||||
|
||||
/**
|
||||
* Find the package root directory (where package.json is located)
|
||||
* This works correctly when the package is installed globally or locally
|
||||
* @param startPath Starting path to search from (defaults to checking module paths)
|
||||
* @returns The package root directory path, or null if not found
|
||||
*/
|
||||
export const findPackageRoot = (startPath?: string): string | null => {
|
||||
// Return cached value if available and no specific start path is requested
|
||||
if (cachedPackageRoot !== undefined && !startPath) {
|
||||
return cachedPackageRoot;
|
||||
}
|
||||
|
||||
const debug = process.env.DEBUG === 'true';
|
||||
|
||||
// Possible locations for package.json relative to the search path
|
||||
const possibleRoots: string[] = [];
|
||||
|
||||
if (startPath) {
|
||||
// When start path is provided (from fileURLToPath(import.meta.url))
|
||||
possibleRoots.push(
|
||||
// When in dist/utils (compiled code) - go up 2 levels
|
||||
path.resolve(startPath, '..', '..'),
|
||||
// When in dist/ (compiled code) - go up 1 level
|
||||
path.resolve(startPath, '..'),
|
||||
// Direct parent directories
|
||||
path.resolve(startPath),
|
||||
);
|
||||
}
|
||||
|
||||
// Try to use require.resolve to find the module location (works in CommonJS and ESM with createRequire)
|
||||
try {
|
||||
// In ESM, we can use import.meta.resolve, but it's async in some versions
|
||||
// So we'll try to find the module by checking the node_modules structure
|
||||
|
||||
// Check if this file is in a node_modules installation
|
||||
const currentFile = new Error().stack?.split('\n')[2]?.match(/\((.+?):\d+:\d+\)$/)?.[1];
|
||||
if (currentFile) {
|
||||
const nodeModulesIndex = currentFile.indexOf('node_modules');
|
||||
if (nodeModulesIndex !== -1) {
|
||||
// Extract the package path from node_modules
|
||||
const afterNodeModules = currentFile.substring(
|
||||
nodeModulesIndex + 'node_modules'.length + 1,
|
||||
);
|
||||
const packageNameEnd = afterNodeModules.indexOf(path.sep);
|
||||
if (packageNameEnd !== -1) {
|
||||
const packagePath = currentFile.substring(
|
||||
0,
|
||||
nodeModulesIndex + 'node_modules'.length + 1 + packageNameEnd,
|
||||
);
|
||||
possibleRoots.push(packagePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
// Check module.filename location (works in Node.js when available)
|
||||
if (typeof __filename !== 'undefined') {
|
||||
const moduleDir = path.dirname(__filename);
|
||||
possibleRoots.push(path.resolve(moduleDir, '..', '..'), path.resolve(moduleDir, '..'));
|
||||
}
|
||||
|
||||
// Check common installation locations
|
||||
possibleRoots.push(
|
||||
// Current working directory (for development/tests)
|
||||
process.cwd(),
|
||||
// Parent of cwd
|
||||
path.resolve(process.cwd(), '..'),
|
||||
);
|
||||
|
||||
if (debug) {
|
||||
console.log('DEBUG: Searching for package.json from:', startPath || 'multiple locations');
|
||||
console.log('DEBUG: Checking paths:', possibleRoots);
|
||||
}
|
||||
|
||||
// Remove duplicates
|
||||
const uniqueRoots = [...new Set(possibleRoots)];
|
||||
|
||||
for (const root of uniqueRoots) {
|
||||
const packageJsonPath = path.join(root, 'package.json');
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
if (pkg.name === 'mcphub' || pkg.name === '@samanhappy/mcphub') {
|
||||
if (debug) {
|
||||
console.log(`DEBUG: Found package.json at ${packageJsonPath}`);
|
||||
}
|
||||
// Cache the result if no specific start path was requested
|
||||
if (!startPath) {
|
||||
cachedPackageRoot = root;
|
||||
}
|
||||
return root;
|
||||
}
|
||||
} catch (e) {
|
||||
// Continue to the next potential root
|
||||
if (debug) {
|
||||
console.error(`DEBUG: Failed to parse package.json at ${packageJsonPath}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
console.warn('DEBUG: Could not find package root directory');
|
||||
}
|
||||
|
||||
// Cache null result as well to avoid repeated searches
|
||||
if (!startPath) {
|
||||
cachedPackageRoot = null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
function getParentPath(p: string, filename: string): string {
|
||||
if (p.endsWith(filename)) {
|
||||
p = p.slice(0, -filename.length);
|
||||
@@ -203,36 +40,22 @@ export const getConfigFilePath = (filename: string, description = 'Configuration
|
||||
}
|
||||
|
||||
const potentialPaths = [
|
||||
// Prioritize process.cwd() as the first location to check
|
||||
path.resolve(process.cwd(), filename),
|
||||
// Use path relative to the root directory
|
||||
path.join(rootDir, filename),
|
||||
// If installed with npx, may need to look one level up
|
||||
path.join(dirname(rootDir), filename),
|
||||
...[
|
||||
// Prioritize process.cwd() as the first location to check
|
||||
path.resolve(process.cwd(), filename),
|
||||
// Use path relative to the root directory
|
||||
path.join(rootDir, filename),
|
||||
// If installed with npx, may need to look one level up
|
||||
path.join(dirname(rootDir), filename),
|
||||
],
|
||||
];
|
||||
|
||||
// Also check in the installed package root directory
|
||||
const packageRoot = findPackageRoot();
|
||||
if (packageRoot) {
|
||||
potentialPaths.push(path.join(packageRoot, filename));
|
||||
}
|
||||
|
||||
for (const filePath of potentialPaths) {
|
||||
if (fs.existsSync(filePath)) {
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
|
||||
// If all paths do not exist, check if we have a fallback in the package root
|
||||
// If the file exists in the package root, use it as the default
|
||||
if (packageRoot) {
|
||||
const packageConfigPath = path.join(packageRoot, filename);
|
||||
if (fs.existsSync(packageConfigPath)) {
|
||||
console.log(`Using ${description} from package: ${packageConfigPath}`);
|
||||
return packageConfigPath;
|
||||
}
|
||||
}
|
||||
|
||||
// If all paths do not exist, use default path
|
||||
// Using the default path is acceptable because it ensures the application can proceed
|
||||
// even if the configuration file is missing. This fallback is particularly useful in
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { findPackageRoot } from './path.js';
|
||||
|
||||
/**
|
||||
* Gets the package version from package.json
|
||||
* @param searchPath Optional path to start searching from (defaults to cwd)
|
||||
* @returns The version string from package.json, or 'dev' if not found
|
||||
*/
|
||||
export const getPackageVersion = (searchPath?: string): string => {
|
||||
export const getPackageVersion = (): string => {
|
||||
try {
|
||||
// Use provided path or fallback to current working directory
|
||||
const startPath = searchPath || process.cwd();
|
||||
|
||||
const packageRoot = findPackageRoot(startPath);
|
||||
if (!packageRoot) {
|
||||
console.warn('Could not find package root, using default version');
|
||||
return 'dev';
|
||||
}
|
||||
|
||||
const packageJsonPath = path.join(packageRoot, 'package.json');
|
||||
const packageJsonPath = path.resolve(process.cwd(), 'package.json');
|
||||
const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf8');
|
||||
const packageJson = JSON.parse(packageJsonContent);
|
||||
return packageJson.version || 'dev';
|
||||
|
||||
@@ -1,343 +0,0 @@
|
||||
import { replaceEnvVars, expandEnvVars } from '../../src/config/index.js';
|
||||
|
||||
describe('Environment Variable Expansion - Comprehensive Tests', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset process.env before each test
|
||||
jest.resetModules();
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Restore original environment
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('expandEnvVars - String expansion', () => {
|
||||
it('should expand ${VAR} format', () => {
|
||||
process.env.TEST_VAR = 'test-value';
|
||||
expect(expandEnvVars('${TEST_VAR}')).toBe('test-value');
|
||||
});
|
||||
|
||||
it('should expand $VAR format', () => {
|
||||
process.env.TEST_VAR = 'test-value';
|
||||
expect(expandEnvVars('$TEST_VAR')).toBe('test-value');
|
||||
});
|
||||
|
||||
it('should expand multiple variables', () => {
|
||||
process.env.HOST = 'localhost';
|
||||
process.env.PORT = '3000';
|
||||
expect(expandEnvVars('http://${HOST}:${PORT}')).toBe('http://localhost:3000');
|
||||
});
|
||||
|
||||
it('should return empty string for undefined variables', () => {
|
||||
expect(expandEnvVars('${UNDEFINED_VAR}')).toBe('');
|
||||
});
|
||||
|
||||
it('should handle strings without variables', () => {
|
||||
expect(expandEnvVars('plain-string')).toBe('plain-string');
|
||||
});
|
||||
|
||||
it('should handle mixed variable formats', () => {
|
||||
process.env.VAR1 = 'value1';
|
||||
process.env.VAR2 = 'value2';
|
||||
expect(expandEnvVars('$VAR1-${VAR2}')).toBe('value1-value2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceEnvVars - Recursive expansion', () => {
|
||||
it('should expand environment variables in nested objects', () => {
|
||||
process.env.API_KEY = 'secret123';
|
||||
process.env.BASE_URL = 'https://api.example.com';
|
||||
|
||||
const config = {
|
||||
url: '${BASE_URL}/endpoint',
|
||||
headers: {
|
||||
'X-API-Key': '${API_KEY}',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
nested: {
|
||||
value: '$API_KEY',
|
||||
},
|
||||
};
|
||||
|
||||
const result = replaceEnvVars(config);
|
||||
|
||||
expect(result).toEqual({
|
||||
url: 'https://api.example.com/endpoint',
|
||||
headers: {
|
||||
'X-API-Key': 'secret123',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
nested: {
|
||||
value: 'secret123',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should expand environment variables in arrays', () => {
|
||||
process.env.ARG1 = 'value1';
|
||||
process.env.ARG2 = 'value2';
|
||||
|
||||
const args = ['--arg1', '${ARG1}', '--arg2', '${ARG2}'];
|
||||
const result = replaceEnvVars(args);
|
||||
|
||||
expect(result).toEqual(['--arg1', 'value1', '--arg2', 'value2']);
|
||||
});
|
||||
|
||||
it('should expand environment variables in nested arrays', () => {
|
||||
process.env.ITEM = 'test-item';
|
||||
|
||||
const config = {
|
||||
items: ['${ITEM}', 'static-item'],
|
||||
};
|
||||
|
||||
const result = replaceEnvVars(config);
|
||||
|
||||
expect(result).toEqual({
|
||||
items: ['test-item', 'static-item'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve non-string values', () => {
|
||||
const config = {
|
||||
enabled: true,
|
||||
timeout: 3000,
|
||||
ratio: 0.5,
|
||||
nullable: null,
|
||||
};
|
||||
|
||||
const result = replaceEnvVars(config);
|
||||
|
||||
expect(result).toEqual({
|
||||
enabled: true,
|
||||
timeout: 3000,
|
||||
ratio: 0.5,
|
||||
nullable: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should expand deeply nested structures', () => {
|
||||
process.env.DEEP_VALUE = 'deep-secret';
|
||||
|
||||
const config = {
|
||||
level1: {
|
||||
level2: {
|
||||
level3: {
|
||||
value: '${DEEP_VALUE}',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = replaceEnvVars(config);
|
||||
|
||||
expect(result).toEqual({
|
||||
level1: {
|
||||
level2: {
|
||||
level3: {
|
||||
value: 'deep-secret',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should expand environment variables in mixed nested structures', () => {
|
||||
process.env.VAR1 = 'value1';
|
||||
process.env.VAR2 = 'value2';
|
||||
|
||||
const config = {
|
||||
array: [
|
||||
{
|
||||
key: '${VAR1}',
|
||||
},
|
||||
{
|
||||
key: '${VAR2}',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = replaceEnvVars(config);
|
||||
|
||||
expect(result).toEqual({
|
||||
array: [
|
||||
{
|
||||
key: 'value1',
|
||||
},
|
||||
{
|
||||
key: 'value2',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ServerConfig scenarios', () => {
|
||||
it('should expand URL with environment variables', () => {
|
||||
process.env.SERVER_HOST = 'api.example.com';
|
||||
process.env.SERVER_PORT = '8080';
|
||||
|
||||
const config = {
|
||||
type: 'sse',
|
||||
url: 'https://${SERVER_HOST}:${SERVER_PORT}/mcp',
|
||||
};
|
||||
|
||||
const result = replaceEnvVars(config);
|
||||
|
||||
expect(result.url).toBe('https://api.example.com:8080/mcp');
|
||||
});
|
||||
|
||||
it('should expand command with environment variables', () => {
|
||||
process.env.PYTHON_PATH = '/usr/bin/python3';
|
||||
|
||||
const config = {
|
||||
type: 'stdio',
|
||||
command: '${PYTHON_PATH}',
|
||||
args: ['-m', 'my_module'],
|
||||
};
|
||||
|
||||
const result = replaceEnvVars(config);
|
||||
|
||||
expect(result.command).toBe('/usr/bin/python3');
|
||||
});
|
||||
|
||||
it('should expand OpenAPI configuration', () => {
|
||||
process.env.API_BASE_URL = 'https://api.example.com';
|
||||
process.env.API_KEY = 'secret-key-123';
|
||||
|
||||
const config = {
|
||||
type: 'openapi',
|
||||
openapi: {
|
||||
url: '${API_BASE_URL}/openapi.json',
|
||||
security: {
|
||||
type: 'apiKey',
|
||||
apiKey: {
|
||||
name: 'X-API-Key',
|
||||
in: 'header',
|
||||
value: '${API_KEY}',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = replaceEnvVars(config);
|
||||
|
||||
expect(result.openapi.url).toBe('https://api.example.com/openapi.json');
|
||||
expect(result.openapi.security.apiKey.value).toBe('secret-key-123');
|
||||
});
|
||||
|
||||
it('should expand OAuth configuration', () => {
|
||||
process.env.CLIENT_ID = 'my-client-id';
|
||||
process.env.CLIENT_SECRET = 'my-client-secret';
|
||||
process.env.ACCESS_TOKEN = 'my-access-token';
|
||||
|
||||
const config = {
|
||||
type: 'sse',
|
||||
url: 'https://mcp.example.com',
|
||||
oauth: {
|
||||
clientId: '${CLIENT_ID}',
|
||||
clientSecret: '${CLIENT_SECRET}',
|
||||
accessToken: '${ACCESS_TOKEN}',
|
||||
scopes: ['read', 'write'],
|
||||
},
|
||||
};
|
||||
|
||||
const result = replaceEnvVars(config);
|
||||
|
||||
expect(result.oauth.clientId).toBe('my-client-id');
|
||||
expect(result.oauth.clientSecret).toBe('my-client-secret');
|
||||
expect(result.oauth.accessToken).toBe('my-access-token');
|
||||
expect(result.oauth.scopes).toEqual(['read', 'write']);
|
||||
});
|
||||
|
||||
it('should expand environment variables in env object', () => {
|
||||
process.env.API_KEY = 'my-api-key';
|
||||
process.env.DEBUG = 'true';
|
||||
|
||||
const config = {
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
env: {
|
||||
MY_API_KEY: '${API_KEY}',
|
||||
DEBUG: '${DEBUG}',
|
||||
},
|
||||
};
|
||||
|
||||
const result = replaceEnvVars(config);
|
||||
|
||||
expect(result.env.MY_API_KEY).toBe('my-api-key');
|
||||
expect(result.env.DEBUG).toBe('true');
|
||||
});
|
||||
|
||||
it('should handle complete server configuration', () => {
|
||||
process.env.SERVER_URL = 'https://mcp.example.com';
|
||||
process.env.AUTH_TOKEN = 'bearer-token-123';
|
||||
process.env.TIMEOUT = '60000';
|
||||
|
||||
const config = {
|
||||
type: 'streamable-http',
|
||||
url: '${SERVER_URL}/mcp',
|
||||
headers: {
|
||||
Authorization: 'Bearer ${AUTH_TOKEN}',
|
||||
'User-Agent': 'MCPHub/1.0',
|
||||
},
|
||||
options: {
|
||||
timeout: 30000,
|
||||
},
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const result = replaceEnvVars(config);
|
||||
|
||||
expect(result.url).toBe('https://mcp.example.com/mcp');
|
||||
expect(result.headers.Authorization).toBe('Bearer bearer-token-123');
|
||||
expect(result.headers['User-Agent']).toBe('MCPHub/1.0');
|
||||
expect(result.options.timeout).toBe(30000);
|
||||
expect(result.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle empty string values', () => {
|
||||
const config = {
|
||||
value: '',
|
||||
};
|
||||
|
||||
const result = replaceEnvVars(config);
|
||||
|
||||
expect(result.value).toBe('');
|
||||
});
|
||||
|
||||
it('should handle undefined values', () => {
|
||||
const result = replaceEnvVars(undefined);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle null values in objects', () => {
|
||||
const config = {
|
||||
value: null,
|
||||
};
|
||||
|
||||
const result = replaceEnvVars(config);
|
||||
|
||||
expect(result.value).toBe(null);
|
||||
});
|
||||
|
||||
it('should not break on circular references prevention', () => {
|
||||
// Note: This test ensures we don't have infinite recursion issues
|
||||
// by using a deeply nested structure
|
||||
process.env.DEEP = 'value';
|
||||
|
||||
const config = {
|
||||
a: { b: { c: { d: { e: { f: { g: { h: { i: { j: '${DEEP}' } } } } } } } } },
|
||||
};
|
||||
|
||||
const result = replaceEnvVars(config);
|
||||
|
||||
expect(result.a.b.c.d.e.f.g.h.i.j).toBe('value');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,139 +0,0 @@
|
||||
import { getMcpSettingsJson } from '../../src/controllers/configController.js'
|
||||
import * as config from '../../src/config/index.js'
|
||||
import { Request, Response } from 'express'
|
||||
|
||||
// Mock the config module
|
||||
jest.mock('../../src/config/index.js')
|
||||
|
||||
describe('ConfigController - getMcpSettingsJson', () => {
|
||||
let mockRequest: Partial<Request>
|
||||
let mockResponse: Partial<Response>
|
||||
let mockJson: jest.Mock
|
||||
let mockStatus: jest.Mock
|
||||
|
||||
beforeEach(() => {
|
||||
mockJson = jest.fn()
|
||||
mockStatus = jest.fn().mockReturnThis()
|
||||
mockRequest = {
|
||||
query: {},
|
||||
}
|
||||
mockResponse = {
|
||||
json: mockJson,
|
||||
status: mockStatus,
|
||||
}
|
||||
|
||||
// Reset mocks
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Full Settings Export', () => {
|
||||
it('should handle settings without users array', () => {
|
||||
const mockSettings = {
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
command: 'test',
|
||||
args: ['--test'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
;(config.loadOriginalSettings as jest.Mock).mockReturnValue(mockSettings)
|
||||
|
||||
getMcpSettingsJson(mockRequest as Request, mockResponse as Response)
|
||||
|
||||
expect(mockJson).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
data: {
|
||||
mcpServers: mockSettings.mcpServers,
|
||||
users: undefined,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Individual Server Export', () => {
|
||||
it('should return individual server configuration when serverName is specified', () => {
|
||||
const mockSettings = {
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
command: 'test',
|
||||
args: ['--test'],
|
||||
env: {
|
||||
TEST_VAR: 'test-value',
|
||||
},
|
||||
},
|
||||
'another-server': {
|
||||
command: 'another',
|
||||
args: ['--another'],
|
||||
},
|
||||
},
|
||||
users: [
|
||||
{
|
||||
username: 'admin',
|
||||
password: '$2b$10$hashedpassword',
|
||||
isAdmin: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
mockRequest.query = { serverName: 'test-server' }
|
||||
;(config.loadOriginalSettings as jest.Mock).mockReturnValue(mockSettings)
|
||||
|
||||
getMcpSettingsJson(mockRequest as Request, mockResponse as Response)
|
||||
|
||||
expect(mockJson).toHaveBeenCalledWith({
|
||||
success: true,
|
||||
data: {
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
command: 'test',
|
||||
args: ['--test'],
|
||||
env: {
|
||||
TEST_VAR: 'test-value',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should return 404 when server does not exist', () => {
|
||||
const mockSettings = {
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
command: 'test',
|
||||
args: ['--test'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
mockRequest.query = { serverName: 'non-existent-server' }
|
||||
;(config.loadOriginalSettings as jest.Mock).mockReturnValue(mockSettings)
|
||||
|
||||
getMcpSettingsJson(mockRequest as Request, mockResponse as Response)
|
||||
|
||||
expect(mockStatus).toHaveBeenCalledWith(404)
|
||||
expect(mockJson).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
message: "Server 'non-existent-server' not found",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle errors gracefully and return 500', () => {
|
||||
const errorMessage = 'Failed to load settings'
|
||||
;(config.loadOriginalSettings as jest.Mock).mockImplementation(() => {
|
||||
throw new Error(errorMessage)
|
||||
})
|
||||
|
||||
getMcpSettingsJson(mockRequest as Request, mockResponse as Response)
|
||||
|
||||
expect(mockStatus).toHaveBeenCalledWith(500)
|
||||
expect(mockJson).toHaveBeenCalledWith({
|
||||
success: false,
|
||||
message: 'Failed to get MCP settings',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,73 @@
|
||||
import { convertParametersToTypes } from '../../src/utils/parameterConversion.js';
|
||||
// Simple unit test to validate the type conversion logic
|
||||
describe('Parameter Type Conversion Logic', () => {
|
||||
// Extract the conversion function for testing
|
||||
function convertQueryParametersToTypes(
|
||||
queryParams: Record<string, any>,
|
||||
inputSchema: Record<string, any>
|
||||
): Record<string, any> {
|
||||
if (!inputSchema || typeof inputSchema !== 'object' || !inputSchema.properties) {
|
||||
return queryParams;
|
||||
}
|
||||
|
||||
const convertedParams: Record<string, any> = {};
|
||||
const properties = inputSchema.properties;
|
||||
|
||||
for (const [key, value] of Object.entries(queryParams)) {
|
||||
const propDef = properties[key];
|
||||
if (!propDef || typeof propDef !== 'object') {
|
||||
// No schema definition found, keep as is
|
||||
convertedParams[key] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
const propType = propDef.type;
|
||||
|
||||
try {
|
||||
switch (propType) {
|
||||
case 'integer':
|
||||
case 'number':
|
||||
// Convert string to number
|
||||
if (typeof value === 'string') {
|
||||
const numValue = propType === 'integer' ? parseInt(value, 10) : parseFloat(value);
|
||||
convertedParams[key] = isNaN(numValue) ? value : numValue;
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'boolean':
|
||||
// Convert string to boolean
|
||||
if (typeof value === 'string') {
|
||||
convertedParams[key] = value.toLowerCase() === 'true' || value === '1';
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'array':
|
||||
// Handle array conversion if needed (e.g., comma-separated strings)
|
||||
if (typeof value === 'string' && value.includes(',')) {
|
||||
convertedParams[key] = value.split(',').map(item => item.trim());
|
||||
} else {
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// For string and other types, keep as is
|
||||
convertedParams[key] = value;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
// If conversion fails, keep the original value
|
||||
console.warn(`Failed to convert parameter '${key}' to type '${propType}':`, error);
|
||||
convertedParams[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return convertedParams;
|
||||
}
|
||||
|
||||
// Integration tests for OpenAPI controller's parameter type conversion
|
||||
describe('OpenAPI Controller - Parameter Type Conversion Integration', () => {
|
||||
test('should convert integer parameters correctly', () => {
|
||||
const queryParams = {
|
||||
limit: '5',
|
||||
@@ -18,7 +84,7 @@ describe('OpenAPI Controller - Parameter Type Conversion Integration', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(queryParams, inputSchema);
|
||||
const result = convertQueryParametersToTypes(queryParams, inputSchema);
|
||||
|
||||
expect(result).toEqual({
|
||||
limit: 5, // Converted to integer
|
||||
@@ -41,7 +107,7 @@ describe('OpenAPI Controller - Parameter Type Conversion Integration', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(queryParams, inputSchema);
|
||||
const result = convertQueryParametersToTypes(queryParams, inputSchema);
|
||||
|
||||
expect(result).toEqual({
|
||||
price: 19.99,
|
||||
@@ -67,7 +133,7 @@ describe('OpenAPI Controller - Parameter Type Conversion Integration', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(queryParams, inputSchema);
|
||||
const result = convertQueryParametersToTypes(queryParams, inputSchema);
|
||||
|
||||
expect(result).toEqual({
|
||||
enabled: true,
|
||||
@@ -91,7 +157,7 @@ describe('OpenAPI Controller - Parameter Type Conversion Integration', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(queryParams, inputSchema);
|
||||
const result = convertQueryParametersToTypes(queryParams, inputSchema);
|
||||
|
||||
expect(result).toEqual({
|
||||
tags: ['tag1', 'tag2', 'tag3'],
|
||||
@@ -105,7 +171,7 @@ describe('OpenAPI Controller - Parameter Type Conversion Integration', () => {
|
||||
name: 'test'
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(queryParams, {});
|
||||
const result = convertQueryParametersToTypes(queryParams, {});
|
||||
|
||||
expect(result).toEqual({
|
||||
limit: '5', // Should remain as string
|
||||
@@ -126,7 +192,7 @@ describe('OpenAPI Controller - Parameter Type Conversion Integration', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(queryParams, inputSchema);
|
||||
const result = convertQueryParametersToTypes(queryParams, inputSchema);
|
||||
|
||||
expect(result).toEqual({
|
||||
limit: 5, // Converted based on schema
|
||||
@@ -148,7 +214,7 @@ describe('OpenAPI Controller - Parameter Type Conversion Integration', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(queryParams, inputSchema);
|
||||
const result = convertQueryParametersToTypes(queryParams, inputSchema);
|
||||
|
||||
expect(result).toEqual({
|
||||
limit: 'not-a-number', // Should remain as string when conversion fails
|
||||
@@ -233,16 +299,4 @@ describe('OpenAPI Granular Endpoints', () => {
|
||||
const group = mockGetGroupByIdOrName('nonexistent');
|
||||
expect(group).toBeNull();
|
||||
});
|
||||
|
||||
test('should decode URL-encoded server and tool names with slashes', () => {
|
||||
// Test that URL-encoded names with slashes are properly decoded
|
||||
const encodedServerName = 'com.atlassian%2Fatlassian-mcp-server';
|
||||
const encodedToolName = 'atlassianUserInfo';
|
||||
|
||||
const decodedServerName = decodeURIComponent(encodedServerName);
|
||||
const decodedToolName = decodeURIComponent(encodedToolName);
|
||||
|
||||
expect(decodedServerName).toBe('com.atlassian/atlassian-mcp-server');
|
||||
expect(decodedToolName).toBe('atlassianUserInfo');
|
||||
});
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
|
||||
import request from 'supertest';
|
||||
|
||||
const handleSseConnectionMock = jest.fn();
|
||||
const handleSseMessageMock = jest.fn();
|
||||
const handleMcpPostRequestMock = jest.fn();
|
||||
const handleMcpOtherRequestMock = jest.fn();
|
||||
const sseUserContextMiddlewareMock = jest.fn((_req, _res, next) => next());
|
||||
|
||||
jest.mock('../../src/utils/i18n.js', () => ({
|
||||
__esModule: true,
|
||||
initI18n: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/models/User.js', () => ({
|
||||
__esModule: true,
|
||||
initializeDefaultUser: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/oauthService.js', () => ({
|
||||
__esModule: true,
|
||||
initOAuthProvider: jest.fn(),
|
||||
getOAuthRouter: jest.fn(() => null),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/middlewares/index.js', () => ({
|
||||
__esModule: true,
|
||||
initMiddlewares: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/routes/index.js', () => ({
|
||||
__esModule: true,
|
||||
initRoutes: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/mcpService.js', () => ({
|
||||
__esModule: true,
|
||||
initUpstreamServers: jest.fn().mockResolvedValue(undefined),
|
||||
connected: jest.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/sseService.js', () => ({
|
||||
__esModule: true,
|
||||
handleSseConnection: handleSseConnectionMock,
|
||||
handleSseMessage: handleSseMessageMock,
|
||||
handleMcpPostRequest: handleMcpPostRequestMock,
|
||||
handleMcpOtherRequest: handleMcpOtherRequestMock,
|
||||
}));
|
||||
|
||||
jest.mock('../../src/middlewares/userContext.js', () => ({
|
||||
__esModule: true,
|
||||
userContextMiddleware: jest.fn((_req, _res, next) => next()),
|
||||
sseUserContextMiddleware: sseUserContextMiddlewareMock,
|
||||
}));
|
||||
|
||||
import { AppServer } from '../../src/server.js';
|
||||
|
||||
const flushPromises = async () => {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
};
|
||||
|
||||
describe('AppServer smart routing group paths', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
handleMcpPostRequestMock.mockImplementation(async (_req, res) => {
|
||||
res.status(204).send();
|
||||
});
|
||||
sseUserContextMiddlewareMock.mockImplementation((_req, _res, next) => next());
|
||||
});
|
||||
|
||||
const createApp = async () => {
|
||||
const appServer = new AppServer();
|
||||
await appServer.initialize();
|
||||
await flushPromises();
|
||||
return appServer.getApp();
|
||||
};
|
||||
|
||||
it('routes global MCP requests with nested smart group segments', async () => {
|
||||
const app = await createApp();
|
||||
|
||||
await request(app).post('/mcp/$smart/test-group').send({}).expect(204);
|
||||
|
||||
expect(handleMcpPostRequestMock).toHaveBeenCalledTimes(1);
|
||||
const [req] = handleMcpPostRequestMock.mock.calls[0];
|
||||
expect(req.params.group).toBe('$smart/test-group');
|
||||
});
|
||||
|
||||
it('routes user-scoped MCP requests with nested smart group segments', async () => {
|
||||
const app = await createApp();
|
||||
|
||||
await request(app).post('/alice/mcp/$smart/staging').send({}).expect(204);
|
||||
|
||||
expect(handleMcpPostRequestMock).toHaveBeenCalledTimes(1);
|
||||
const [req] = handleMcpPostRequestMock.mock.calls[0];
|
||||
expect(req.params.group).toBe('$smart/staging');
|
||||
expect(req.params.user).toBe('alice');
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,3 @@
|
||||
// Mock openid-client before importing services
|
||||
jest.mock('openid-client', () => ({
|
||||
discovery: jest.fn(),
|
||||
dynamicClientRegistration: jest.fn(),
|
||||
ClientSecretPost: jest.fn(() => jest.fn()),
|
||||
ClientSecretBasic: jest.fn(() => jest.fn()),
|
||||
None: jest.fn(() => jest.fn()),
|
||||
calculatePKCECodeChallenge: jest.fn(),
|
||||
randomPKCECodeVerifier: jest.fn(),
|
||||
buildAuthorizationUrl: jest.fn(),
|
||||
authorizationCodeGrant: jest.fn(),
|
||||
refreshTokenGrant: jest.fn(),
|
||||
}));
|
||||
|
||||
import { Server } from 'http';
|
||||
import { AppServer } from '../../src/server.js';
|
||||
import { TestServerHelper } from '../utils/testServerHelper.js';
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
import { expandEnvVars, replaceEnvVars } from '../../src/config/index.js';
|
||||
|
||||
describe('MCP Service - Headers Environment Variable Expansion', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset process.env before each test
|
||||
jest.resetModules();
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Restore original environment
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('expandEnvVars', () => {
|
||||
it('should expand environment variables in ${VAR} format', () => {
|
||||
process.env.CONTEXT7_API_KEY = 'ctx7sk-test123';
|
||||
const result = expandEnvVars('${CONTEXT7_API_KEY}');
|
||||
expect(result).toBe('ctx7sk-test123');
|
||||
});
|
||||
|
||||
it('should expand environment variables in $VAR format', () => {
|
||||
process.env.TEST_VAR = 'test-value';
|
||||
const result = expandEnvVars('$TEST_VAR');
|
||||
expect(result).toBe('test-value');
|
||||
});
|
||||
|
||||
it('should expand multiple environment variables', () => {
|
||||
process.env.VAR1 = 'value1';
|
||||
process.env.VAR2 = 'value2';
|
||||
const result = expandEnvVars('${VAR1}-and-${VAR2}');
|
||||
expect(result).toBe('value1-and-value2');
|
||||
});
|
||||
|
||||
it('should return empty string for undefined variables', () => {
|
||||
const result = expandEnvVars('${UNDEFINED_VAR}');
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should handle strings without variables', () => {
|
||||
const result = expandEnvVars('plain-string');
|
||||
expect(result).toBe('plain-string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceEnvVars - Object (Headers)', () => {
|
||||
it('should expand environment variables in header values', () => {
|
||||
process.env.CONTEXT7_API_KEY = 'ctx7sk-d16example123';
|
||||
const headers = {
|
||||
CONTEXT7_API_KEY: '${CONTEXT7_API_KEY}',
|
||||
};
|
||||
const result = replaceEnvVars(headers);
|
||||
expect(result).toEqual({
|
||||
CONTEXT7_API_KEY: 'ctx7sk-d16example123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should expand multiple headers with environment variables', () => {
|
||||
process.env.API_KEY = 'test-api-key';
|
||||
process.env.AUTH_TOKEN = 'test-auth-token';
|
||||
const headers = {
|
||||
'X-API-Key': '${API_KEY}',
|
||||
Authorization: 'Bearer ${AUTH_TOKEN}',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const result = replaceEnvVars(headers);
|
||||
expect(result).toEqual({
|
||||
'X-API-Key': 'test-api-key',
|
||||
Authorization: 'Bearer test-auth-token',
|
||||
'Content-Type': 'application/json',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle $VAR format in headers', () => {
|
||||
process.env.MY_KEY = 'my-value';
|
||||
const headers = {
|
||||
'X-Custom-Header': '$MY_KEY',
|
||||
};
|
||||
const result = replaceEnvVars(headers);
|
||||
expect(result).toEqual({
|
||||
'X-Custom-Header': 'my-value',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty string for undefined variables in headers', () => {
|
||||
const headers = {
|
||||
'X-Undefined': '${UNDEFINED_VAR}',
|
||||
};
|
||||
const result = replaceEnvVars(headers);
|
||||
expect(result).toEqual({
|
||||
'X-Undefined': '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle mix of variables and static values', () => {
|
||||
process.env.TOKEN = 'secret123';
|
||||
const headers = {
|
||||
Authorization: 'Bearer ${TOKEN}',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Custom': 'static-value',
|
||||
};
|
||||
const result = replaceEnvVars(headers);
|
||||
expect(result).toEqual({
|
||||
Authorization: 'Bearer secret123',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Custom': 'static-value',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty object', () => {
|
||||
const headers = {};
|
||||
const result = replaceEnvVars(headers);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceEnvVars - Array (Args)', () => {
|
||||
it('should expand environment variables in array elements', () => {
|
||||
process.env.PORT = '3000';
|
||||
const args = ['--port', '${PORT}'];
|
||||
const result = replaceEnvVars(args);
|
||||
expect(result).toEqual(['--port', '3000']);
|
||||
});
|
||||
|
||||
it('should handle multiple variables in array', () => {
|
||||
process.env.HOST = 'localhost';
|
||||
process.env.PORT = '8080';
|
||||
const args = ['--host', '${HOST}', '--port', '${PORT}'];
|
||||
const result = replaceEnvVars(args);
|
||||
expect(result).toEqual(['--host', 'localhost', '--port', '8080']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real-world Context7 Scenario', () => {
|
||||
it('should correctly expand Context7 API key from environment', () => {
|
||||
// Simulate the environment variable being set in the container
|
||||
process.env.CONTEXT7_API_KEY = 'ctx7sk-d16examplekey123';
|
||||
|
||||
// Simulate the configuration from mcp_settings.json
|
||||
const serverConfig = {
|
||||
type: 'streamable-http',
|
||||
url: 'https://mcp.context7.com/mcp',
|
||||
headers: {
|
||||
CONTEXT7_API_KEY: '${CONTEXT7_API_KEY}',
|
||||
},
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
// Simulate what happens in createTransportFromConfig
|
||||
const expandedHeaders = replaceEnvVars(serverConfig.headers);
|
||||
|
||||
// Verify that the environment variable was correctly expanded
|
||||
expect(expandedHeaders.CONTEXT7_API_KEY).toBe('ctx7sk-d16examplekey123');
|
||||
expect(expandedHeaders.CONTEXT7_API_KEY).not.toBe('${CONTEXT7_API_KEY}');
|
||||
expect(expandedHeaders.CONTEXT7_API_KEY).toMatch(/^ctx7sk-/);
|
||||
});
|
||||
|
||||
it('should handle case when environment variable is not set', () => {
|
||||
// Don't set the environment variable
|
||||
delete process.env.CONTEXT7_API_KEY;
|
||||
|
||||
const serverConfig = {
|
||||
type: 'streamable-http',
|
||||
url: 'https://mcp.context7.com/mcp',
|
||||
headers: {
|
||||
CONTEXT7_API_KEY: '${CONTEXT7_API_KEY}',
|
||||
},
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const expandedHeaders = replaceEnvVars(serverConfig.headers);
|
||||
|
||||
// Should be empty string when env var is not set
|
||||
expect(expandedHeaders.CONTEXT7_API_KEY).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,340 +0,0 @@
|
||||
import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
|
||||
|
||||
// Mock dependencies before importing mcpService
|
||||
jest.mock('../../src/services/oauthService.js', () => ({
|
||||
initializeAllOAuthClients: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/oauthClientRegistration.js', () => ({
|
||||
registerOAuthClient: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/mcpOAuthProvider.js', () => ({
|
||||
createOAuthProvider: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/groupService.js', () => ({
|
||||
getServersInGroup: jest.fn(),
|
||||
getServerConfigInGroup: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/sseService.js', () => ({
|
||||
getGroup: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/vectorSearchService.js', () => ({
|
||||
saveToolsAsVectorEmbeddings: jest.fn(),
|
||||
searchToolsByVector: jest.fn(() => Promise.resolve([])),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/services.js', () => ({
|
||||
getDataService: jest.fn(() => ({
|
||||
filterData: (data: any) => data,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/config/index.js', () => ({
|
||||
default: {
|
||||
mcpHubName: 'test-hub',
|
||||
mcpHubVersion: '1.0.0',
|
||||
initTimeout: 60000,
|
||||
},
|
||||
loadSettings: jest.fn(() => ({})),
|
||||
expandEnvVars: jest.fn((val: string) => val),
|
||||
replaceEnvVars: jest.fn((obj: any) => obj),
|
||||
getNameSeparator: jest.fn(() => '-'),
|
||||
}));
|
||||
|
||||
// Mock Client
|
||||
const mockClient = {
|
||||
connect: jest.fn(),
|
||||
close: jest.fn(),
|
||||
listTools: jest.fn(),
|
||||
listPrompts: jest.fn(),
|
||||
getServerCapabilities: jest.fn(() => ({
|
||||
tools: {},
|
||||
prompts: {},
|
||||
})),
|
||||
callTool: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
|
||||
Client: jest.fn(() => mockClient),
|
||||
}));
|
||||
|
||||
// Mock StdioClientTransport
|
||||
const mockTransport = {
|
||||
close: jest.fn(),
|
||||
stderr: null,
|
||||
};
|
||||
|
||||
jest.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
|
||||
StdioClientTransport: jest.fn(() => mockTransport),
|
||||
}));
|
||||
|
||||
// Mock DAO
|
||||
const mockServerDao = {
|
||||
findAll: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
exists: jest.fn(),
|
||||
setEnabled: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('../../src/dao/index.js', () => ({
|
||||
getServerDao: jest.fn(() => mockServerDao),
|
||||
}));
|
||||
|
||||
import { initializeClientsFromSettings, handleCallToolRequest } from '../../src/services/mcpService.js';
|
||||
|
||||
describe('On-Demand MCP Server Connection Mode', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockClient.connect.mockResolvedValue(undefined);
|
||||
mockClient.close.mockReturnValue(undefined);
|
||||
mockClient.listTools.mockResolvedValue({
|
||||
tools: [
|
||||
{
|
||||
name: 'test-tool',
|
||||
description: 'Test tool',
|
||||
inputSchema: { type: 'object' },
|
||||
},
|
||||
],
|
||||
});
|
||||
mockClient.listPrompts.mockResolvedValue({
|
||||
prompts: [],
|
||||
});
|
||||
mockClient.callTool.mockResolvedValue({
|
||||
content: [{ type: 'text', text: 'Success' }],
|
||||
});
|
||||
mockTransport.close.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Server Initialization', () => {
|
||||
it('should not maintain persistent connection for on-demand servers', async () => {
|
||||
mockServerDao.findAll.mockResolvedValue([
|
||||
{
|
||||
name: 'on-demand-server',
|
||||
command: 'node',
|
||||
args: ['test.js'],
|
||||
connectionMode: 'on-demand',
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const serverInfos = await initializeClientsFromSettings(true);
|
||||
|
||||
expect(serverInfos).toHaveLength(1);
|
||||
expect(serverInfos[0].name).toBe('on-demand-server');
|
||||
expect(serverInfos[0].connectionMode).toBe('on-demand');
|
||||
expect(serverInfos[0].status).toBe('disconnected');
|
||||
// Should connect once to get tools, then disconnect
|
||||
expect(mockClient.connect).toHaveBeenCalledTimes(1);
|
||||
expect(mockTransport.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should load tools during initialization for on-demand servers', async () => {
|
||||
mockServerDao.findAll.mockResolvedValue([
|
||||
{
|
||||
name: 'on-demand-server',
|
||||
command: 'node',
|
||||
args: ['test.js'],
|
||||
connectionMode: 'on-demand',
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const serverInfos = await initializeClientsFromSettings(true);
|
||||
|
||||
expect(serverInfos[0].tools).toHaveLength(1);
|
||||
expect(serverInfos[0].tools[0].name).toBe('on-demand-server-test-tool');
|
||||
expect(mockClient.listTools).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should maintain persistent connection for default connection mode', async () => {
|
||||
mockServerDao.findAll.mockResolvedValue([
|
||||
{
|
||||
name: 'persistent-server',
|
||||
command: 'node',
|
||||
args: ['test.js'],
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const serverInfos = await initializeClientsFromSettings(true);
|
||||
|
||||
expect(serverInfos).toHaveLength(1);
|
||||
expect(serverInfos[0].connectionMode).toBe('persistent');
|
||||
expect(mockClient.connect).toHaveBeenCalledTimes(1);
|
||||
// Should not disconnect immediately
|
||||
expect(mockTransport.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle initialization errors for on-demand servers gracefully', async () => {
|
||||
mockClient.connect.mockRejectedValueOnce(new Error('Connection failed'));
|
||||
mockServerDao.findAll.mockResolvedValue([
|
||||
{
|
||||
name: 'failing-server',
|
||||
command: 'node',
|
||||
args: ['test.js'],
|
||||
connectionMode: 'on-demand',
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const serverInfos = await initializeClientsFromSettings(true);
|
||||
|
||||
expect(serverInfos).toHaveLength(1);
|
||||
expect(serverInfos[0].status).toBe('disconnected');
|
||||
expect(serverInfos[0].error).toContain('Failed to initialize');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool Invocation with On-Demand Servers', () => {
|
||||
beforeEach(async () => {
|
||||
// Set up server infos with an on-demand server that's disconnected
|
||||
mockServerDao.findAll.mockResolvedValue([
|
||||
{
|
||||
name: 'on-demand-server',
|
||||
command: 'node',
|
||||
args: ['test.js'],
|
||||
connectionMode: 'on-demand',
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
|
||||
// Initialize to get the server set up
|
||||
await initializeClientsFromSettings(true);
|
||||
|
||||
// Clear mocks after initialization
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Reset mock implementations
|
||||
mockClient.connect.mockResolvedValue(undefined);
|
||||
mockClient.listTools.mockResolvedValue({
|
||||
tools: [
|
||||
{
|
||||
name: 'test-tool',
|
||||
description: 'Test tool',
|
||||
inputSchema: { type: 'object' },
|
||||
},
|
||||
],
|
||||
});
|
||||
mockClient.callTool.mockResolvedValue({
|
||||
content: [{ type: 'text', text: 'Success' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should connect on-demand server before tool invocation', async () => {
|
||||
const request = {
|
||||
params: {
|
||||
name: 'on-demand-server-test-tool',
|
||||
arguments: { arg1: 'value1' },
|
||||
},
|
||||
};
|
||||
|
||||
await handleCallToolRequest(request, {});
|
||||
|
||||
// Should connect before calling the tool
|
||||
expect(mockClient.connect).toHaveBeenCalledTimes(1);
|
||||
expect(mockClient.callTool).toHaveBeenCalledWith(
|
||||
{
|
||||
name: 'test-tool',
|
||||
arguments: { arg1: 'value1' },
|
||||
},
|
||||
undefined,
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should disconnect on-demand server after tool invocation', async () => {
|
||||
const request = {
|
||||
params: {
|
||||
name: 'on-demand-server-test-tool',
|
||||
arguments: {},
|
||||
},
|
||||
};
|
||||
|
||||
await handleCallToolRequest(request, {});
|
||||
|
||||
// Should disconnect after calling the tool
|
||||
expect(mockTransport.close).toHaveBeenCalledTimes(1);
|
||||
expect(mockClient.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should disconnect on-demand server even if tool invocation fails', async () => {
|
||||
mockClient.callTool.mockRejectedValueOnce(new Error('Tool execution failed'));
|
||||
|
||||
const request = {
|
||||
params: {
|
||||
name: 'on-demand-server-test-tool',
|
||||
arguments: {},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await handleCallToolRequest(request, {});
|
||||
} catch (error) {
|
||||
// Expected to fail
|
||||
}
|
||||
|
||||
// Should still disconnect after error
|
||||
expect(mockTransport.close).toHaveBeenCalledTimes(1);
|
||||
expect(mockClient.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return error for call_tool if server not found', async () => {
|
||||
const request = {
|
||||
params: {
|
||||
name: 'call_tool',
|
||||
arguments: {
|
||||
toolName: 'nonexistent-server-tool',
|
||||
arguments: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await handleCallToolRequest(request, {});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain('No available servers found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mixed Server Modes', () => {
|
||||
it('should handle both persistent and on-demand servers together', async () => {
|
||||
mockServerDao.findAll.mockResolvedValue([
|
||||
{
|
||||
name: 'persistent-server',
|
||||
command: 'node',
|
||||
args: ['persistent.js'],
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: 'on-demand-server',
|
||||
command: 'node',
|
||||
args: ['on-demand.js'],
|
||||
connectionMode: 'on-demand',
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const serverInfos = await initializeClientsFromSettings(true);
|
||||
|
||||
expect(serverInfos).toHaveLength(2);
|
||||
|
||||
const persistentServer = serverInfos.find(s => s.name === 'persistent-server');
|
||||
const onDemandServer = serverInfos.find(s => s.name === 'on-demand-server');
|
||||
|
||||
expect(persistentServer?.connectionMode).toBe('persistent');
|
||||
expect(onDemandServer?.connectionMode).toBe('on-demand');
|
||||
expect(onDemandServer?.status).toBe('disconnected');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,222 +0,0 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
|
||||
// Mock dependencies before importing mcpService
|
||||
jest.mock('../../src/services/oauthService.js', () => ({
|
||||
initializeAllOAuthClients: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/oauthClientRegistration.js', () => ({
|
||||
registerOAuthClient: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/mcpOAuthProvider.js', () => ({
|
||||
createOAuthProvider: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/groupService.js', () => ({
|
||||
getServersInGroup: jest.fn((groupId: string) => {
|
||||
if (groupId === 'test-group') {
|
||||
return ['server1', 'server2'];
|
||||
}
|
||||
if (groupId === 'empty-group') {
|
||||
return [];
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
getServerConfigInGroup: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/sseService.js', () => ({
|
||||
getGroup: jest.fn((sessionId: string) => {
|
||||
if (sessionId === 'session-smart') return '$smart';
|
||||
if (sessionId === 'session-smart-group') return '$smart/test-group';
|
||||
if (sessionId === 'session-smart-empty') return '$smart/empty-group';
|
||||
return '';
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/dao/index.js', () => ({
|
||||
getServerDao: jest.fn(() => ({
|
||||
findById: jest.fn(),
|
||||
findAll: jest.fn(() => Promise.resolve([])),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/services.js', () => ({
|
||||
getDataService: jest.fn(() => ({
|
||||
filterData: (data: any) => data,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/vectorSearchService.js', () => ({
|
||||
searchToolsByVector: jest.fn(),
|
||||
saveToolsAsVectorEmbeddings: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/config/index.js', () => ({
|
||||
loadSettings: jest.fn(),
|
||||
expandEnvVars: jest.fn((val: string) => val),
|
||||
replaceEnvVars: jest.fn((val: any) => val),
|
||||
getNameSeparator: jest.fn(() => '::'),
|
||||
default: {
|
||||
mcpHubName: 'test-hub',
|
||||
mcpHubVersion: '1.0.0',
|
||||
},
|
||||
}));
|
||||
|
||||
// Import after mocks are set up
|
||||
import { handleListToolsRequest, handleCallToolRequest } from '../../src/services/mcpService.js';
|
||||
import { getServersInGroup } from '../../src/services/groupService.js';
|
||||
import { getGroup } from '../../src/services/sseService.js';
|
||||
import { searchToolsByVector } from '../../src/services/vectorSearchService.js';
|
||||
|
||||
describe('MCP Service - Smart Routing with Group Support', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('handleListToolsRequest', () => {
|
||||
it('should return search_tools and call_tool for $smart group', async () => {
|
||||
const result = await handleListToolsRequest({}, { sessionId: 'session-smart' });
|
||||
|
||||
expect(result.tools).toHaveLength(2);
|
||||
expect(result.tools[0].name).toBe('search_tools');
|
||||
expect(result.tools[1].name).toBe('call_tool');
|
||||
expect(result.tools[0].description).toContain('all available servers');
|
||||
});
|
||||
|
||||
it('should return filtered tools for $smart/{group} pattern', async () => {
|
||||
const result = await handleListToolsRequest({}, { sessionId: 'session-smart-group' });
|
||||
|
||||
expect(getGroup).toHaveBeenCalledWith('session-smart-group');
|
||||
expect(getServersInGroup).toHaveBeenCalledWith('test-group');
|
||||
|
||||
expect(result.tools).toHaveLength(2);
|
||||
expect(result.tools[0].name).toBe('search_tools');
|
||||
expect(result.tools[1].name).toBe('call_tool');
|
||||
expect(result.tools[0].description).toContain('servers in the "test-group" group');
|
||||
});
|
||||
|
||||
it('should handle $smart with empty group', async () => {
|
||||
const result = await handleListToolsRequest({}, { sessionId: 'session-smart-empty' });
|
||||
|
||||
expect(getGroup).toHaveBeenCalledWith('session-smart-empty');
|
||||
expect(getServersInGroup).toHaveBeenCalledWith('empty-group');
|
||||
|
||||
expect(result.tools).toHaveLength(2);
|
||||
expect(result.tools[0].name).toBe('search_tools');
|
||||
expect(result.tools[1].name).toBe('call_tool');
|
||||
// Should still show group-scoped message even if group is empty
|
||||
expect(result.tools[0].description).toContain('servers in the "empty-group" group');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleCallToolRequest - search_tools', () => {
|
||||
it('should search across all servers when using $smart', async () => {
|
||||
const mockSearchResults = [
|
||||
{
|
||||
serverName: 'server1',
|
||||
toolName: 'server1::tool1',
|
||||
description: 'Test tool 1',
|
||||
inputSchema: {},
|
||||
},
|
||||
];
|
||||
(searchToolsByVector as jest.Mock).mockResolvedValue(mockSearchResults);
|
||||
|
||||
const request = {
|
||||
params: {
|
||||
name: 'search_tools',
|
||||
arguments: {
|
||||
query: 'test query',
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await handleCallToolRequest(request, { sessionId: 'session-smart' });
|
||||
|
||||
expect(searchToolsByVector).toHaveBeenCalledWith(
|
||||
'test query',
|
||||
10,
|
||||
expect.any(Number),
|
||||
undefined, // No server filtering
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter servers when using $smart/{group}', async () => {
|
||||
const mockSearchResults = [
|
||||
{
|
||||
serverName: 'server1',
|
||||
toolName: 'server1::tool1',
|
||||
description: 'Test tool 1',
|
||||
inputSchema: {},
|
||||
},
|
||||
];
|
||||
(searchToolsByVector as jest.Mock).mockResolvedValue(mockSearchResults);
|
||||
|
||||
const request = {
|
||||
params: {
|
||||
name: 'search_tools',
|
||||
arguments: {
|
||||
query: 'test query',
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await handleCallToolRequest(request, { sessionId: 'session-smart-group' });
|
||||
|
||||
expect(getGroup).toHaveBeenCalledWith('session-smart-group');
|
||||
expect(getServersInGroup).toHaveBeenCalledWith('test-group');
|
||||
expect(searchToolsByVector).toHaveBeenCalledWith(
|
||||
'test query',
|
||||
10,
|
||||
expect.any(Number),
|
||||
['server1', 'server2'], // Filtered to group servers
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty group in $smart/{group}', async () => {
|
||||
const mockSearchResults: any[] = [];
|
||||
(searchToolsByVector as jest.Mock).mockResolvedValue(mockSearchResults);
|
||||
|
||||
const request = {
|
||||
params: {
|
||||
name: 'search_tools',
|
||||
arguments: {
|
||||
query: 'test query',
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await handleCallToolRequest(request, { sessionId: 'session-smart-empty' });
|
||||
|
||||
expect(getGroup).toHaveBeenCalledWith('session-smart-empty');
|
||||
expect(getServersInGroup).toHaveBeenCalledWith('empty-group');
|
||||
// Empty group returns empty array, which should still be passed to search
|
||||
expect(searchToolsByVector).toHaveBeenCalledWith(
|
||||
'test query',
|
||||
10,
|
||||
expect.any(Number),
|
||||
[], // Empty group
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate query parameter', async () => {
|
||||
const request = {
|
||||
params: {
|
||||
name: 'search_tools',
|
||||
arguments: {
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await handleCallToolRequest(request, { sessionId: 'session-smart' });
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.content[0].text).toContain('Query parameter is required');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,207 +0,0 @@
|
||||
// Mock openid-client before importing services
|
||||
jest.mock('openid-client', () => ({
|
||||
discovery: jest.fn(),
|
||||
dynamicClientRegistration: jest.fn(),
|
||||
ClientSecretPost: jest.fn(() => jest.fn()),
|
||||
ClientSecretBasic: jest.fn(() => jest.fn()),
|
||||
None: jest.fn(() => jest.fn()),
|
||||
calculatePKCECodeChallenge: jest.fn(),
|
||||
randomPKCECodeVerifier: jest.fn(),
|
||||
buildAuthorizationUrl: jest.fn(),
|
||||
authorizationCodeGrant: jest.fn(),
|
||||
refreshTokenGrant: jest.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
initOAuthProvider,
|
||||
isOAuthEnabled,
|
||||
getServerOAuthToken,
|
||||
addOAuthHeader,
|
||||
} from '../../src/services/oauthService.js';
|
||||
import * as config from '../../src/config/index.js';
|
||||
|
||||
// Mock the config module
|
||||
jest.mock('../../src/config/index.js', () => ({
|
||||
loadSettings: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('OAuth Service', () => {
|
||||
const mockLoadSettings = config.loadSettings as jest.MockedFunction<typeof config.loadSettings>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('initOAuthProvider', () => {
|
||||
it('should not initialize OAuth when disabled', () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
oauth: {
|
||||
enabled: false,
|
||||
issuerUrl: 'http://auth.example.com',
|
||||
endpoints: {
|
||||
authorizationUrl: 'http://auth.example.com/authorize',
|
||||
tokenUrl: 'http://auth.example.com/token',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
initOAuthProvider();
|
||||
expect(isOAuthEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should not initialize OAuth when not configured', () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {},
|
||||
});
|
||||
|
||||
initOAuthProvider();
|
||||
expect(isOAuthEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should attempt to initialize OAuth when enabled and properly configured', () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {},
|
||||
systemConfig: {
|
||||
oauth: {
|
||||
enabled: true,
|
||||
issuerUrl: 'http://auth.example.com',
|
||||
endpoints: {
|
||||
authorizationUrl: 'http://auth.example.com/authorize',
|
||||
tokenUrl: 'http://auth.example.com/token',
|
||||
},
|
||||
clients: [
|
||||
{
|
||||
client_id: 'test-client',
|
||||
redirect_uris: ['http://localhost:3000/callback'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// In a test environment, the ProxyOAuthServerProvider may not fully initialize
|
||||
// due to missing dependencies or network issues, which is expected
|
||||
initOAuthProvider();
|
||||
// We just verify that the function doesn't throw an error
|
||||
expect(mockLoadSettings).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getServerOAuthToken', () => {
|
||||
it('should return undefined when server has no OAuth config', async () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
url: 'http://example.com',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const token = await getServerOAuthToken('test-server');
|
||||
expect(token).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when server has no access token', async () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
url: 'http://example.com',
|
||||
oauth: {
|
||||
clientId: 'test-client',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const token = await getServerOAuthToken('test-server');
|
||||
expect(token).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return access token when configured', async () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
url: 'http://example.com',
|
||||
oauth: {
|
||||
clientId: 'test-client',
|
||||
accessToken: 'test-access-token',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const token = await getServerOAuthToken('test-server');
|
||||
expect(token).toBe('test-access-token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addOAuthHeader', () => {
|
||||
it('should not modify headers when no OAuth token is configured', async () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
url: 'http://example.com',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
const result = await addOAuthHeader('test-server', headers);
|
||||
|
||||
expect(result).toEqual(headers);
|
||||
expect(result.Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should add Authorization header when OAuth token is configured', async () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
url: 'http://example.com',
|
||||
oauth: {
|
||||
clientId: 'test-client',
|
||||
accessToken: 'test-access-token',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
const result = await addOAuthHeader('test-server', headers);
|
||||
|
||||
expect(result).toEqual({
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer test-access-token',
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve existing headers when adding OAuth token', async () => {
|
||||
mockLoadSettings.mockReturnValue({
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
url: 'http://example.com',
|
||||
oauth: {
|
||||
clientId: 'test-client',
|
||||
accessToken: 'test-access-token',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Custom-Header': 'custom-value',
|
||||
};
|
||||
const result = await addOAuthHeader('test-server', headers);
|
||||
|
||||
expect(result).toEqual({
|
||||
'Content-Type': 'application/json',
|
||||
'X-Custom-Header': 'custom-value',
|
||||
Authorization: 'Bearer test-access-token',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,3 @@
|
||||
// Mock openid-client before importing services
|
||||
jest.mock('openid-client', () => ({
|
||||
discovery: jest.fn(),
|
||||
dynamicClientRegistration: jest.fn(),
|
||||
ClientSecretPost: jest.fn(() => jest.fn()),
|
||||
ClientSecretBasic: jest.fn(() => jest.fn()),
|
||||
None: jest.fn(() => jest.fn()),
|
||||
calculatePKCECodeChallenge: jest.fn(),
|
||||
randomPKCECodeVerifier: jest.fn(),
|
||||
buildAuthorizationUrl: jest.fn(),
|
||||
authorizationCodeGrant: jest.fn(),
|
||||
refreshTokenGrant: jest.fn(),
|
||||
}));
|
||||
|
||||
import { generateOpenAPISpec, getToolStats } from '../../src/services/openApiGeneratorService';
|
||||
|
||||
describe('OpenAPI Generator Service', () => {
|
||||
@@ -65,27 +51,6 @@ describe('OpenAPI Generator Service', () => {
|
||||
expect(spec).toHaveProperty('paths');
|
||||
expect(typeof spec.paths).toBe('object');
|
||||
});
|
||||
|
||||
it('should URL-encode server and tool names with slashes in paths', async () => {
|
||||
const spec = await generateOpenAPISpec();
|
||||
|
||||
// Check if any paths contain URL-encoded values
|
||||
// Paths with slashes in server/tool names should be encoded
|
||||
const paths = Object.keys(spec.paths);
|
||||
|
||||
// If there are any servers with slashes, verify encoding
|
||||
// e.g., "com.atlassian/atlassian-mcp-server" should become "com.atlassian%2Fatlassian-mcp-server"
|
||||
for (const path of paths) {
|
||||
// Path should not have unencoded slashes in the middle segments
|
||||
// Valid format: /tools/{encoded-server}/{encoded-tool}
|
||||
const pathSegments = path.split('/').filter((s) => s.length > 0);
|
||||
if (pathSegments[0] === 'tools' && pathSegments.length >= 3) {
|
||||
// The server name (segment 1) and tool name (segment 2+) should not create extra segments
|
||||
// If properly encoded, there should be exactly 3 segments: ['tools', serverName, toolName]
|
||||
expect(pathSegments.length).toBe(3);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getToolStats', () => {
|
||||
|
||||
111
tests/services/perSessionServers.test.ts
Normal file
111
tests/services/perSessionServers.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
getOrCreatePerSessionServer,
|
||||
cleanupPerSessionServers,
|
||||
} from '../../src/services/mcpService';
|
||||
import { ServerConfig } from '../../src/types';
|
||||
|
||||
// Mock the serverDao
|
||||
jest.mock('../../src/dao/index.js', () => ({
|
||||
getServerDao: () => ({
|
||||
findById: jest.fn((name: string) => {
|
||||
if (name === 'playwright') {
|
||||
return Promise.resolve({
|
||||
name: 'playwright',
|
||||
command: 'npx',
|
||||
args: ['@playwright/mcp@latest', '--headless', '--isolated'],
|
||||
perSession: true,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
}),
|
||||
findAll: jest.fn(() => Promise.resolve([])),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the Client and Transport classes
|
||||
jest.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
|
||||
Client: jest.fn().mockImplementation(() => ({
|
||||
connect: jest.fn(() => Promise.resolve()),
|
||||
close: jest.fn(),
|
||||
listTools: jest.fn(() => Promise.resolve({ tools: [] })),
|
||||
listPrompts: jest.fn(() => Promise.resolve({ prompts: [] })),
|
||||
getServerCapabilities: jest.fn(() => ({ tools: true, prompts: true })),
|
||||
callTool: jest.fn((params) => Promise.resolve({ content: [{ type: 'text', text: `Tool ${params.name} called` }] })),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
|
||||
StdioClientTransport: jest.fn().mockImplementation(() => ({
|
||||
close: jest.fn(),
|
||||
stderr: {
|
||||
on: jest.fn(),
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('Per-Session Server Instances', () => {
|
||||
afterEach(() => {
|
||||
// Clean up any created sessions
|
||||
cleanupPerSessionServers('session1');
|
||||
cleanupPerSessionServers('session2');
|
||||
});
|
||||
|
||||
it('should create separate server instances for different sessions', async () => {
|
||||
const config: ServerConfig = {
|
||||
command: 'npx',
|
||||
args: ['@playwright/mcp@latest', '--headless', '--isolated'],
|
||||
perSession: true,
|
||||
};
|
||||
|
||||
// Create server for session1
|
||||
const server1 = await getOrCreatePerSessionServer('session1', 'playwright', config);
|
||||
expect(server1).toBeDefined();
|
||||
expect(server1.sessionId).toBe('session1');
|
||||
|
||||
// Create server for session2
|
||||
const server2 = await getOrCreatePerSessionServer('session2', 'playwright', config);
|
||||
expect(server2).toBeDefined();
|
||||
expect(server2.sessionId).toBe('session2');
|
||||
|
||||
// They should be different instances
|
||||
expect(server1).not.toBe(server2);
|
||||
});
|
||||
|
||||
it('should reuse existing per-session server for the same session', async () => {
|
||||
const config: ServerConfig = {
|
||||
command: 'npx',
|
||||
args: ['@playwright/mcp@latest', '--headless', '--isolated'],
|
||||
perSession: true,
|
||||
};
|
||||
|
||||
// Create server for session1
|
||||
const server1 = await getOrCreatePerSessionServer('session1', 'playwright', config);
|
||||
|
||||
// Request the same server again
|
||||
const server2 = await getOrCreatePerSessionServer('session1', 'playwright', config);
|
||||
|
||||
// Should be the same instance
|
||||
expect(server1).toBe(server2);
|
||||
});
|
||||
|
||||
it('should clean up per-session servers when session ends', async () => {
|
||||
const config: ServerConfig = {
|
||||
command: 'npx',
|
||||
args: ['@playwright/mcp@latest', '--headless', '--isolated'],
|
||||
perSession: true,
|
||||
};
|
||||
|
||||
// Create server for session1
|
||||
const server1 = await getOrCreatePerSessionServer('session1', 'playwright', config);
|
||||
expect(server1).toBeDefined();
|
||||
|
||||
// Clean up session1
|
||||
cleanupPerSessionServers('session1');
|
||||
|
||||
// Create again should create a new instance (not the same object)
|
||||
const server2 = await getOrCreatePerSessionServer('session1', 'playwright', config);
|
||||
expect(server2).toBeDefined();
|
||||
expect(server2).not.toBe(server1);
|
||||
});
|
||||
});
|
||||
@@ -8,11 +8,6 @@ Object.assign(process.env, {
|
||||
DATABASE_URL: 'sqlite::memory:',
|
||||
});
|
||||
|
||||
// Mock moduleDir to avoid import.meta parsing issues in Jest
|
||||
jest.mock('../src/utils/moduleDir.js', () => ({
|
||||
getCurrentModuleDir: jest.fn(() => process.cwd()),
|
||||
}));
|
||||
|
||||
// Global test utilities
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
import { convertParametersToTypes } from '../../src/utils/parameterConversion.js';
|
||||
|
||||
describe('Parameter Conversion Utilities', () => {
|
||||
describe('convertParametersToTypes', () => {
|
||||
it('should convert string to number when schema type is number', () => {
|
||||
const params = { count: '42' };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
count: { type: 'number' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(result.count).toBe(42);
|
||||
expect(typeof result.count).toBe('number');
|
||||
});
|
||||
|
||||
it('should convert string to integer when schema type is integer', () => {
|
||||
const params = { age: '25' };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
age: { type: 'integer' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(result.age).toBe(25);
|
||||
expect(typeof result.age).toBe('number');
|
||||
expect(Number.isInteger(result.age)).toBe(true);
|
||||
});
|
||||
|
||||
it('should convert string to boolean when schema type is boolean', () => {
|
||||
const params = { enabled: 'true', disabled: 'false', flag: '1' };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
enabled: { type: 'boolean' },
|
||||
disabled: { type: 'boolean' },
|
||||
flag: { type: 'boolean' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.disabled).toBe(false);
|
||||
expect(result.flag).toBe(true);
|
||||
});
|
||||
|
||||
it('should convert comma-separated string to array when schema type is array', () => {
|
||||
const params = { tags: 'one,two,three' };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tags: { type: 'array' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(Array.isArray(result.tags)).toBe(true);
|
||||
expect(result.tags).toEqual(['one', 'two', 'three']);
|
||||
});
|
||||
|
||||
it('should parse JSON string to object when schema type is object', () => {
|
||||
const params = { config: '{"key": "value", "nested": {"prop": 123}}' };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
config: { type: 'object' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(typeof result.config).toBe('object');
|
||||
expect(result.config).toEqual({ key: 'value', nested: { prop: 123 } });
|
||||
});
|
||||
|
||||
it('should keep values unchanged when they already have the correct type', () => {
|
||||
const params = { count: 42, enabled: true, tags: ['a', 'b'] };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
count: { type: 'number' },
|
||||
enabled: { type: 'boolean' },
|
||||
tags: { type: 'array' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(result.count).toBe(42);
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.tags).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('should keep string values unchanged when schema type is string', () => {
|
||||
const params = { name: 'John Doe' };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(result.name).toBe('John Doe');
|
||||
expect(typeof result.name).toBe('string');
|
||||
});
|
||||
|
||||
it('should handle parameters without schema definition', () => {
|
||||
const params = { unknown: 'value' };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
known: { type: 'string' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(result.unknown).toBe('value');
|
||||
});
|
||||
|
||||
it('should return original params when schema has no properties', () => {
|
||||
const params = { key: 'value' };
|
||||
const schema = { type: 'object' };
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(result).toEqual(params);
|
||||
});
|
||||
|
||||
it('should return original params when schema is null or undefined', () => {
|
||||
const params = { key: 'value' };
|
||||
|
||||
const resultNull = convertParametersToTypes(params, null as any);
|
||||
const resultUndefined = convertParametersToTypes(params, undefined as any);
|
||||
|
||||
expect(resultNull).toEqual(params);
|
||||
expect(resultUndefined).toEqual(params);
|
||||
});
|
||||
|
||||
it('should handle invalid number conversion gracefully', () => {
|
||||
const params = { count: 'not-a-number' };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
count: { type: 'number' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
// When conversion fails, it should keep original value
|
||||
expect(result.count).toBe('not-a-number');
|
||||
});
|
||||
|
||||
it('should handle invalid JSON string for object gracefully', () => {
|
||||
const params = { config: '{invalid json}' };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
config: { type: 'object' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
// When JSON parsing fails, it should keep original value
|
||||
expect(result.config).toBe('{invalid json}');
|
||||
});
|
||||
|
||||
it('should handle mixed parameter types correctly', () => {
|
||||
const params = {
|
||||
name: 'Test',
|
||||
count: '10',
|
||||
price: '19.99',
|
||||
enabled: 'true',
|
||||
tags: 'tag1,tag2',
|
||||
config: '{"nested": true}',
|
||||
};
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
count: { type: 'integer' },
|
||||
price: { type: 'number' },
|
||||
enabled: { type: 'boolean' },
|
||||
tags: { type: 'array' },
|
||||
config: { type: 'object' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(result.name).toBe('Test');
|
||||
expect(result.count).toBe(10);
|
||||
expect(result.price).toBe(19.99);
|
||||
expect(result.enabled).toBe(true);
|
||||
expect(result.tags).toEqual(['tag1', 'tag2']);
|
||||
expect(result.config).toEqual({ nested: true });
|
||||
});
|
||||
|
||||
it('should handle empty string values', () => {
|
||||
const params = { name: '', count: '', enabled: '' };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
count: { type: 'number' },
|
||||
enabled: { type: 'boolean' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(result.name).toBe('');
|
||||
// Empty string should remain as empty string for number (NaN check keeps original)
|
||||
expect(result.count).toBe('');
|
||||
// Empty string converts to false for boolean
|
||||
expect(result.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle array that is already an array', () => {
|
||||
const params = { tags: ['existing', 'array'] };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
tags: { type: 'array' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(result.tags).toEqual(['existing', 'array']);
|
||||
});
|
||||
|
||||
it('should handle object that is already an object', () => {
|
||||
const params = { config: { key: 'value' } };
|
||||
const schema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
config: { type: 'object' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertParametersToTypes(params, schema);
|
||||
|
||||
expect(result.config).toEqual({ key: 'value' });
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user