mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 02:39:19 -05:00
Compare commits
1 Commits
copilot/fi
...
codex/impl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a55405b974 |
22
Dockerfile
22
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
|
||||
|
||||
@@ -57,15 +57,6 @@ Créez un fichier `mcp_settings.json` pour personnaliser les paramètres de votr
|
||||
}
|
||||
```
|
||||
|
||||
#### Exemples de Configuration
|
||||
|
||||
Pour des configurations spécifiques de serveurs MCP, consultez le répertoire [examples](./examples/) :
|
||||
|
||||
- **[Démarrage rapide Jira Cloud](./examples/QUICK_START_JIRA.md)** - Guide de configuration en 5 minutes pour Jira Cloud
|
||||
- **[Guide complet Atlassian/Jira](./examples/README_ATLASSIAN_JIRA.md)** - Configuration détaillée pour Jira et Confluence
|
||||
- **[Variables d'environnement](./examples/mcp_settings_with_env_vars.json)** - Utilisation de variables d'environnement dans la configuration
|
||||
- **[OpenAPI Schema](./examples/openapi-schema-config.json)** - Serveurs MCP basés sur OpenAPI
|
||||
|
||||
### Déploiement avec Docker
|
||||
|
||||
**Recommandé** : Montez votre configuration personnalisée :
|
||||
|
||||
@@ -59,15 +59,6 @@ Create a `mcp_settings.json` file to customize your server settings:
|
||||
}
|
||||
```
|
||||
|
||||
#### Configuration Examples
|
||||
|
||||
For specific MCP server configurations, see the [examples](./examples/) directory:
|
||||
|
||||
- **[Jira Cloud Quick Start](./examples/QUICK_START_JIRA.md)** - 5-minute setup guide for Jira Cloud
|
||||
- **[Atlassian/Jira Complete Guide](./examples/README_ATLASSIAN_JIRA.md)** - Detailed setup for Jira and Confluence
|
||||
- **[Environment Variables](./examples/mcp_settings_with_env_vars.json)** - Using environment variables in configuration
|
||||
- **[OpenAPI Schema](./examples/openapi-schema-config.json)** - OpenAPI-based MCP servers
|
||||
|
||||
#### 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:
|
||||
|
||||
@@ -57,15 +57,6 @@ MCPHub 通过将多个 MCP(Model Context Protocol)服务器组织为灵活
|
||||
}
|
||||
```
|
||||
|
||||
#### 配置示例
|
||||
|
||||
有关特定 MCP 服务器配置,请参阅 [examples](./examples/) 目录:
|
||||
|
||||
- **[Jira Cloud 快速入门](./examples/QUICK_START_JIRA.md)** - Jira Cloud 5 分钟配置指南
|
||||
- **[Atlassian/Jira 完整指南](./examples/README_ATLASSIAN_JIRA.md)** - Jira 和 Confluence 详细设置
|
||||
- **[环境变量](./examples/mcp_settings_with_env_vars.json)** - 在配置中使用环境变量
|
||||
- **[OpenAPI Schema](./examples/openapi-schema-config.json)** - 基于 OpenAPI 的 MCP 服务器
|
||||
|
||||
#### OAuth 配置(可选)
|
||||
|
||||
MCPHub 支持通过 OAuth 2.0 访问上游 MCP 服务器。完整说明请参阅[《OAuth 功能指南》](docs/zh/features/oauth.mdx)。实际使用中通常会遇到两类配置:
|
||||
|
||||
@@ -207,55 +207,6 @@ MCPHub uses several configuration files:
|
||||
}
|
||||
```
|
||||
|
||||
### Productivity and Project Management
|
||||
|
||||
#### Atlassian (Jira & Confluence) Server
|
||||
|
||||
```json
|
||||
{
|
||||
"atlassian": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"mcp-atlassian",
|
||||
"--jira-url=${JIRA_URL}",
|
||||
"--jira-username=${JIRA_USERNAME}",
|
||||
"--jira-token=${JIRA_TOKEN}",
|
||||
"--confluence-url=${CONFLUENCE_URL}",
|
||||
"--confluence-username=${CONFLUENCE_USERNAME}",
|
||||
"--confluence-token=${CONFLUENCE_TOKEN}"
|
||||
],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Jira Cloud Only Configuration:**
|
||||
|
||||
```json
|
||||
{
|
||||
"jira": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"mcp-atlassian",
|
||||
"--jira-url=${JIRA_URL}",
|
||||
"--jira-username=${JIRA_USERNAME}",
|
||||
"--jira-token=${JIRA_TOKEN}"
|
||||
],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Required Environment Variables:**
|
||||
- `JIRA_URL`: Your Jira Cloud URL (e.g., `https://your-company.atlassian.net`)
|
||||
- `JIRA_USERNAME`: Your Atlassian account email
|
||||
- `JIRA_TOKEN`: API token from [Atlassian API Tokens](https://id.atlassian.com/manage-profile/security/api-tokens)
|
||||
- `CONFLUENCE_URL`: Your Confluence URL (e.g., `https://your-company.atlassian.net/wiki`)
|
||||
- `CONFLUENCE_USERNAME`: Your Confluence account email (often same as Jira)
|
||||
- `CONFLUENCE_TOKEN`: Confluence API token (can be same as Jira token for Cloud)
|
||||
|
||||
**Setup Guide:** See [examples/README_ATLASSIAN_JIRA.md](../../examples/README_ATLASSIAN_JIRA.md) for detailed setup instructions.
|
||||
|
||||
### Map and Location Services
|
||||
|
||||
#### Amap (高德地图) Server
|
||||
|
||||
@@ -1,27 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
DATA_DIR=${MCP_DATA_DIR:-/app/data}
|
||||
SERVERS_DIR=${MCP_SERVERS_DIR:-$DATA_DIR/servers}
|
||||
NPM_SERVER_DIR=${MCP_NPM_DIR:-$SERVERS_DIR/npm}
|
||||
PYTHON_SERVER_DIR=${MCP_PYTHON_DIR:-$SERVERS_DIR/python}
|
||||
PNPM_HOME=${PNPM_HOME:-$DATA_DIR/pnpm}
|
||||
NPM_CONFIG_PREFIX=${NPM_CONFIG_PREFIX:-$DATA_DIR/npm-global}
|
||||
NPM_CONFIG_CACHE=${NPM_CONFIG_CACHE:-$DATA_DIR/npm-cache}
|
||||
UV_TOOL_DIR=${UV_TOOL_DIR:-$DATA_DIR/uv/tools}
|
||||
UV_CACHE_DIR=${UV_CACHE_DIR:-$DATA_DIR/uv/cache}
|
||||
|
||||
mkdir -p \
|
||||
"$PNPM_HOME" \
|
||||
"$NPM_CONFIG_PREFIX/bin" \
|
||||
"$NPM_CONFIG_PREFIX/lib/node_modules" \
|
||||
"$NPM_CONFIG_CACHE" \
|
||||
"$UV_TOOL_DIR" \
|
||||
"$UV_CACHE_DIR" \
|
||||
"$NPM_SERVER_DIR" \
|
||||
"$PYTHON_SERVER_DIR"
|
||||
|
||||
export PATH="$PNPM_HOME:$NPM_CONFIG_PREFIX/bin:$UV_TOOL_DIR/bin:$PATH"
|
||||
|
||||
NPM_REGISTRY=${NPM_REGISTRY:-https://registry.npmjs.org/}
|
||||
echo "Setting npm registry to ${NPM_REGISTRY}"
|
||||
npm config set registry "$NPM_REGISTRY"
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# Atlassian Jira Cloud Configuration Example
|
||||
# Copy this to your .env file and fill in your actual values
|
||||
|
||||
# Jira Configuration (Required)
|
||||
JIRA_URL=https://your-company.atlassian.net
|
||||
JIRA_USERNAME=your.email@company.com
|
||||
JIRA_TOKEN=your_jira_api_token_here
|
||||
|
||||
# Confluence Configuration (Optional - only if you want to use Confluence)
|
||||
CONFLUENCE_URL=https://your-company.atlassian.net/wiki
|
||||
CONFLUENCE_USERNAME=your.email@company.com
|
||||
CONFLUENCE_TOKEN=your_confluence_api_token_here
|
||||
|
||||
# Notes:
|
||||
# 1. Get your API token from: https://id.atlassian.com/manage-profile/security/api-tokens
|
||||
# 2. For Atlassian Cloud, you can often use the same API token for both Jira and Confluence
|
||||
# 3. The username should be your Atlassian account email address
|
||||
# 4. Never commit your .env file to version control
|
||||
@@ -1,264 +0,0 @@
|
||||
# Atlassian/Jira Configuration Screenshot Guide
|
||||
|
||||
This guide shows what your configuration should look like at each step.
|
||||
|
||||
## 📋 Configuration File Structure
|
||||
|
||||
Your `mcp_settings.json` should look like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"jira": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"mcp-atlassian",
|
||||
"--jira-url=${JIRA_URL}",
|
||||
"--jira-username=${JIRA_USERNAME}",
|
||||
"--jira-token=${JIRA_TOKEN}"
|
||||
]
|
||||
}
|
||||
},
|
||||
"users": [
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "${ADMIN_PASSWORD_HASH}",
|
||||
"isAdmin": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Security Note:** The `password` field must contain a bcrypt hash, not plain text.
|
||||
|
||||
**To generate a bcrypt hash:**
|
||||
```bash
|
||||
node -e "console.log(require('bcrypt').hashSync('your-password', 10))"
|
||||
```
|
||||
|
||||
**⚠️ CRITICAL SECURITY:**
|
||||
- Never use default credentials in production
|
||||
- Always change the admin password before deploying
|
||||
- Store password hashes, never plain text passwords
|
||||
|
||||
## 📁 File Structure
|
||||
|
||||
Your project should have these files:
|
||||
|
||||
```
|
||||
mcphub/
|
||||
├── mcp_settings.json ← Your configuration file
|
||||
├── .env ← Your environment variables (DO NOT COMMIT!)
|
||||
├── data/ ← Database directory (auto-created)
|
||||
└── ...
|
||||
```
|
||||
|
||||
## 🔐 Environment Variables (.env file)
|
||||
|
||||
```env
|
||||
# .env file content
|
||||
JIRA_URL=https://mycompany.atlassian.net
|
||||
JIRA_USERNAME=myemail@company.com
|
||||
JIRA_TOKEN=ATBBxxxxxxxxxxxxxxxxxxx
|
||||
```
|
||||
|
||||
## 🎯 Expected Dashboard View
|
||||
|
||||
After starting MCPHub, you should see:
|
||||
|
||||
### 1. Server List View
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ MCP Servers │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ✅ jira │
|
||||
│ Status: Connected │
|
||||
│ Type: stdio │
|
||||
│ Command: uvx mcp-atlassian │
|
||||
│ Tools: 15 available │
|
||||
│ │
|
||||
│ [View Details] [Restart] [Stop] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2. Server Details View
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Server: jira │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Status: ✅ Connected │
|
||||
│ Type: stdio │
|
||||
│ Command: uvx │
|
||||
│ │
|
||||
│ Available Tools: │
|
||||
│ • jira_search_issues │
|
||||
│ • jira_get_issue │
|
||||
│ • jira_list_projects │
|
||||
│ • jira_get_project │
|
||||
│ • ... and 11 more │
|
||||
│ │
|
||||
│ Logs: │
|
||||
│ [INFO] Successfully connected to Jira │
|
||||
│ [INFO] Loaded 15 tools │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3. Connection Endpoints
|
||||
|
||||
Once connected, your Jira server is available at:
|
||||
|
||||
| Endpoint | URL | Description |
|
||||
|----------|-----|-------------|
|
||||
| All Servers | `http://localhost:3000/mcp` | Access all configured MCP servers |
|
||||
| Jira Only | `http://localhost:3000/mcp/jira` | Direct access to Jira server |
|
||||
| SSE (Legacy) | `http://localhost:3000/sse/jira` | SSE endpoint for Jira |
|
||||
|
||||
## ✅ Success Indicators
|
||||
|
||||
You'll know the configuration is working when you see:
|
||||
|
||||
1. **✅ Green status indicator** next to the server name
|
||||
2. **"Connected" status** in the server details
|
||||
3. **Tool count showing** (e.g., "15 tools available")
|
||||
4. **No error messages** in the logs
|
||||
5. **Server responds** to health check requests
|
||||
|
||||
## ❌ Common Error Indicators
|
||||
|
||||
### Connection Failed
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ ❌ jira │
|
||||
│ Status: Disconnected │
|
||||
│ Error: Failed to start server │
|
||||
│ Last error: 401 Unauthorized │
|
||||
│ │
|
||||
│ Possible causes: │
|
||||
│ • Invalid API token │
|
||||
│ • Wrong username/email │
|
||||
│ • Incorrect Jira URL │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### UVX Not Found
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ ❌ jira │
|
||||
│ Status: Error │
|
||||
│ Error: Command not found: uvx │
|
||||
│ │
|
||||
│ Solution: Install UV │
|
||||
│ curl -LsSf https://astral.sh/uv/install.sh | sh │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Environment Variable Not Set
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ ⚠️ jira │
|
||||
│ Status: Configuration Error │
|
||||
│ Error: Environment variable JIRA_TOKEN not found │
|
||||
│ │
|
||||
│ Solution: Check your .env file │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 🧪 Testing Your Configuration
|
||||
|
||||
### Test 1: Health Check
|
||||
```bash
|
||||
curl http://localhost:3000/api/health
|
||||
```
|
||||
|
||||
Expected response:
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"servers": {
|
||||
"jira": "connected"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Test 2: List Servers
|
||||
```bash
|
||||
curl http://localhost:3000/api/servers
|
||||
```
|
||||
|
||||
Expected response:
|
||||
```json
|
||||
{
|
||||
"servers": [
|
||||
{
|
||||
"name": "jira",
|
||||
"status": "connected",
|
||||
"type": "stdio",
|
||||
"toolCount": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Test 3: MCP Endpoint
|
||||
```bash
|
||||
curl http://localhost:3000/mcp/jira \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"method": "tools/list",
|
||||
"params": {}
|
||||
}'
|
||||
```
|
||||
|
||||
Expected response: List of available Jira tools
|
||||
|
||||
## 📊 Log Messages Explained
|
||||
|
||||
### Successful Startup
|
||||
```
|
||||
[INFO] Loading configuration from mcp_settings.json
|
||||
[INFO] Found 1 MCP server(s) to initialize
|
||||
[INFO] Starting server: jira
|
||||
[INFO] Executing: uvx mcp-atlassian --jira-url=https://...
|
||||
[INFO] Successfully connected client for server: jira
|
||||
[INFO] Successfully listed 15 tools for server: jira
|
||||
[INFO] ✅ Server jira is ready
|
||||
```
|
||||
|
||||
### Connection Issues
|
||||
```
|
||||
[ERROR] Failed to start server: jira
|
||||
[ERROR] Error: spawn uvx ENOENT
|
||||
[WARN] Server jira will retry in 5 seconds
|
||||
```
|
||||
|
||||
### Authentication Issues
|
||||
```
|
||||
[ERROR] Failed to connect to Jira
|
||||
[ERROR] HTTP 401: Unauthorized
|
||||
[ERROR] Please check your API token and credentials
|
||||
```
|
||||
|
||||
## 🔍 Debugging Steps
|
||||
|
||||
If your server shows as disconnected:
|
||||
|
||||
1. **Check logs** in the dashboard or console
|
||||
2. **Verify environment variables** are set correctly
|
||||
3. **Test manually** with uvx:
|
||||
```bash
|
||||
uvx mcp-atlassian --jira-url=https://your-company.atlassian.net --jira-username=your@email.com --jira-token=your_token
|
||||
```
|
||||
4. **Check network connectivity** to Jira
|
||||
5. **Verify API token** is still valid
|
||||
6. **Restart MCPHub** after making changes
|
||||
|
||||
## 📚 Additional Resources
|
||||
|
||||
- [Quick Start Guide](./QUICK_START_JIRA.md)
|
||||
- [Complete Setup Guide](./README_ATLASSIAN_JIRA.md)
|
||||
- [MCPHub Documentation](https://docs.mcphubx.com/)
|
||||
- [Atlassian API Tokens](https://id.atlassian.com/manage-profile/security/api-tokens)
|
||||
@@ -1,134 +0,0 @@
|
||||
# Quick Start: Jira Cloud Integration
|
||||
|
||||
This is a quick 5-minute setup guide for connecting MCPHub to Jira Cloud.
|
||||
|
||||
## ⚡ Quick Setup (5 minutes)
|
||||
|
||||
### Step 1: Get Your Jira API Token (2 minutes)
|
||||
|
||||
1. Go to https://id.atlassian.com/manage-profile/security/api-tokens
|
||||
2. Click **"Create API token"**
|
||||
3. Label it "MCPHub Integration"
|
||||
4. **Copy the token** (you can't see it again!)
|
||||
|
||||
### Step 2: Find Your Jira URL (30 seconds)
|
||||
|
||||
Your Jira URL is what you see in your browser:
|
||||
- Example: `https://mycompany.atlassian.net`
|
||||
- ✅ Include: `https://` protocol
|
||||
- ❌ Don't include: trailing `/` or `/jira`
|
||||
|
||||
### Step 3: Create .env File (1 minute)
|
||||
|
||||
Create a `.env` file in your MCPHub root directory:
|
||||
|
||||
```bash
|
||||
JIRA_URL=https://mycompany.atlassian.net
|
||||
JIRA_USERNAME=myemail@company.com
|
||||
JIRA_TOKEN=paste_your_token_here
|
||||
```
|
||||
|
||||
Replace with your actual values!
|
||||
|
||||
### Step 4: Update mcp_settings.json (1 minute)
|
||||
|
||||
Add this to your `mcp_settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"jira": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"mcp-atlassian",
|
||||
"--jira-url=${JIRA_URL}",
|
||||
"--jira-username=${JIRA_USERNAME}",
|
||||
"--jira-token=${JIRA_TOKEN}"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: Install UV & Start MCPHub (1 minute)
|
||||
|
||||
#### Install UV (if not already installed):
|
||||
|
||||
**macOS/Linux:**
|
||||
```bash
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
```powershell
|
||||
irm https://astral.sh/uv/install.ps1 | iex
|
||||
```
|
||||
|
||||
#### Start MCPHub:
|
||||
|
||||
**With Docker:**
|
||||
```bash
|
||||
docker run -p 3000:3000 \
|
||||
--env-file .env \
|
||||
-v ./mcp_settings.json:/app/mcp_settings.json \
|
||||
samanhappy/mcphub
|
||||
```
|
||||
|
||||
**Without Docker:**
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Step 6: Verify Connection (30 seconds)
|
||||
|
||||
1. Open http://localhost:3000
|
||||
2. Login with default credentials (see [README_ATLASSIAN_JIRA.md](./README_ATLASSIAN_JIRA.md#verification) for credentials)
|
||||
|
||||
**⚠️ CRITICAL:** Immediately change the admin password through dashboard Settings → Users
|
||||
|
||||
3. Check dashboard - you should see "jira" server as "Connected" ✅
|
||||
|
||||
## 🎉 That's It!
|
||||
|
||||
You can now use Jira through MCPHub at:
|
||||
- All servers: `http://localhost:3000/mcp`
|
||||
- Jira only: `http://localhost:3000/mcp/jira`
|
||||
|
||||
## 🐛 Common Issues
|
||||
|
||||
### "uvx command not found"
|
||||
```bash
|
||||
# Install UV first (see Step 5)
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
```
|
||||
|
||||
### "401 Unauthorized"
|
||||
- Double-check your API token
|
||||
- Make sure username is your email
|
||||
- Try regenerating the API token
|
||||
|
||||
### Server shows "Disconnected"
|
||||
- Check logs for specific errors
|
||||
- Verify .env file is in the correct location
|
||||
- Ensure no trailing slashes in JIRA_URL
|
||||
|
||||
### "Downloading cryptography" errors
|
||||
- This is usually temporary
|
||||
- Wait and restart MCPHub
|
||||
- Check internet connection
|
||||
|
||||
## 📚 Need More Help?
|
||||
|
||||
See [README_ATLASSIAN_JIRA.md](./README_ATLASSIAN_JIRA.md) for the complete guide with:
|
||||
- Both Jira + Confluence setup
|
||||
- Detailed troubleshooting
|
||||
- Security best practices
|
||||
- Example use cases
|
||||
|
||||
## 🔒 Security Reminder
|
||||
|
||||
- ✅ Never commit `.env` to git
|
||||
- ✅ Keep API tokens secret
|
||||
- ✅ Rotate tokens regularly
|
||||
- ✅ Use different tokens for dev/prod
|
||||
@@ -1,233 +0,0 @@
|
||||
# MCPHub Configuration Examples
|
||||
|
||||
This directory contains example configurations for various MCP servers and use cases.
|
||||
|
||||
## 📁 Directory Contents
|
||||
|
||||
### Atlassian/Jira Configuration
|
||||
|
||||
| File | Description | Best For |
|
||||
|------|-------------|----------|
|
||||
| [QUICK_START_JIRA.md](./QUICK_START_JIRA.md) | 5-minute quick start guide | Getting started fast with Jira Cloud |
|
||||
| [README_ATLASSIAN_JIRA.md](./README_ATLASSIAN_JIRA.md) | Complete setup guide | Comprehensive setup with troubleshooting |
|
||||
| [CONFIGURATION_SCREENSHOT_GUIDE.md](./CONFIGURATION_SCREENSHOT_GUIDE.md) | Visual configuration guide | Understanding the dashboard and logs |
|
||||
| [mcp_settings_atlassian_jira.json](./mcp_settings_atlassian_jira.json) | Basic Jira configuration | Copy-paste configuration template |
|
||||
| [.env.atlassian.example](./.env.atlassian.example) | Environment variables template | Setting up credentials securely |
|
||||
|
||||
### General Configuration Examples
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| [mcp_settings_with_env_vars.json](./mcp_settings_with_env_vars.json) | Environment variable examples for various server types (SSE, HTTP, stdio, OpenAPI) |
|
||||
| [openapi-schema-config.json](./openapi-schema-config.json) | OpenAPI-based MCP server configuration examples |
|
||||
|
||||
## 🚀 Quick Start Guides
|
||||
|
||||
### For Jira Cloud Users
|
||||
|
||||
**New to MCPHub?** Start here: [QUICK_START_JIRA.md](./QUICK_START_JIRA.md)
|
||||
|
||||
This 5-minute guide covers:
|
||||
- ✅ Getting your API token
|
||||
- ✅ Basic configuration
|
||||
- ✅ Starting MCPHub
|
||||
- ✅ Verifying connection
|
||||
|
||||
### For Experienced Users
|
||||
|
||||
**Need detailed setup?** See: [README_ATLASSIAN_JIRA.md](./README_ATLASSIAN_JIRA.md)
|
||||
|
||||
This comprehensive guide includes:
|
||||
- 📋 Both Jira and Confluence configuration
|
||||
- 🔧 Multiple installation methods (uvx, python, docker)
|
||||
- 🐛 Extensive troubleshooting section
|
||||
- 🔒 Security best practices
|
||||
- 💡 Example use cases
|
||||
|
||||
### Need Visual Guidance?
|
||||
|
||||
**Want to see what to expect?** Check: [CONFIGURATION_SCREENSHOT_GUIDE.md](./CONFIGURATION_SCREENSHOT_GUIDE.md)
|
||||
|
||||
This visual guide shows:
|
||||
- 📊 Expected dashboard views
|
||||
- ✅ Success indicators
|
||||
- ❌ Common error messages
|
||||
- 🧪 Test commands and expected outputs
|
||||
|
||||
## 📝 Configuration Templates
|
||||
|
||||
### Jira Cloud Only
|
||||
|
||||
Minimal configuration for Jira Cloud:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"jira": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"mcp-atlassian",
|
||||
"--jira-url=${JIRA_URL}",
|
||||
"--jira-username=${JIRA_USERNAME}",
|
||||
"--jira-token=${JIRA_TOKEN}"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Jira + Confluence
|
||||
|
||||
Combined configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"atlassian": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"mcp-atlassian",
|
||||
"--jira-url=${JIRA_URL}",
|
||||
"--jira-username=${JIRA_USERNAME}",
|
||||
"--jira-token=${JIRA_TOKEN}",
|
||||
"--confluence-url=${CONFLUENCE_URL}",
|
||||
"--confluence-username=${CONFLUENCE_USERNAME}",
|
||||
"--confluence-token=${CONFLUENCE_TOKEN}"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Create a `.env` file based on [.env.atlassian.example](./.env.atlassian.example):
|
||||
|
||||
```env
|
||||
JIRA_URL=https://your-company.atlassian.net
|
||||
JIRA_USERNAME=your.email@company.com
|
||||
JIRA_TOKEN=your_api_token_here
|
||||
```
|
||||
|
||||
## 🔐 Security Best Practices
|
||||
|
||||
1. **Never commit sensitive data**
|
||||
- ✅ Use `.env` files for credentials
|
||||
- ✅ Add `.env` to `.gitignore`
|
||||
- ✅ Use environment variable substitution: `${VAR_NAME}`
|
||||
|
||||
2. **Protect your API tokens**
|
||||
- ✅ Rotate tokens regularly
|
||||
- ✅ Use different tokens for dev/staging/prod
|
||||
- ✅ Revoke unused tokens immediately
|
||||
|
||||
3. **Secure your configuration**
|
||||
- ✅ Restrict file permissions on `.env` files
|
||||
- ✅ Use secrets management in production
|
||||
- ✅ Audit token usage regularly
|
||||
|
||||
## 🛠️ Common Use Cases
|
||||
|
||||
### Case 1: Development Environment
|
||||
|
||||
**Scenario**: Testing Jira integration locally
|
||||
|
||||
**Files needed**:
|
||||
- `mcp_settings_atlassian_jira.json` → Copy to `mcp_settings.json`
|
||||
- `.env.atlassian.example` → Copy to `.env` and fill in values
|
||||
|
||||
**Steps**:
|
||||
1. Copy template files
|
||||
2. Fill in your credentials
|
||||
3. Run `pnpm dev`
|
||||
|
||||
### Case 2: Production Deployment
|
||||
|
||||
**Scenario**: Deploying MCPHub with Jira to production
|
||||
|
||||
**Approach**:
|
||||
- Use environment variables in configuration
|
||||
- Store secrets in your deployment platform's secrets manager
|
||||
- Use Docker with environment file: `docker run --env-file .env ...`
|
||||
|
||||
### Case 3: Multiple Environments
|
||||
|
||||
**Scenario**: Separate dev, staging, prod configurations
|
||||
|
||||
**Structure**:
|
||||
```
|
||||
.env.development
|
||||
.env.staging
|
||||
.env.production
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
```bash
|
||||
# Development
|
||||
docker run --env-file .env.development ...
|
||||
|
||||
# Staging
|
||||
docker run --env-file .env.staging ...
|
||||
|
||||
# Production
|
||||
docker run --env-file .env.production ...
|
||||
```
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Quick Diagnostics
|
||||
|
||||
| Symptom | Likely Cause | Quick Fix |
|
||||
|---------|--------------|-----------|
|
||||
| "uvx command not found" | UV not installed | Install UV: `curl -LsSf https://astral.sh/uv/install.sh | sh` |
|
||||
| "401 Unauthorized" | Wrong API token | Regenerate token at Atlassian settings |
|
||||
| Server "Disconnected" | Missing env vars | Check `.env` file exists and has values |
|
||||
| "Downloading cryptography" errors | Network/Python issue | Wait and retry, check internet connection |
|
||||
|
||||
### Detailed Troubleshooting
|
||||
|
||||
For comprehensive troubleshooting steps, see:
|
||||
- [README_ATLASSIAN_JIRA.md - Troubleshooting Section](./README_ATLASSIAN_JIRA.md#troubleshooting)
|
||||
- [CONFIGURATION_SCREENSHOT_GUIDE.md - Error Indicators](./CONFIGURATION_SCREENSHOT_GUIDE.md#-common-error-indicators)
|
||||
|
||||
## 📚 Additional Resources
|
||||
|
||||
### Official Documentation
|
||||
|
||||
- [MCPHub Documentation](https://docs.mcphubx.com/)
|
||||
- [MCPHub GitHub Repository](https://github.com/samanhappy/mcphub)
|
||||
- [MCP Protocol Specification](https://modelcontextprotocol.io/)
|
||||
|
||||
### Atlassian Resources
|
||||
|
||||
- [Atlassian API Tokens](https://id.atlassian.com/manage-profile/security/api-tokens)
|
||||
- [Jira Cloud REST API](https://developer.atlassian.com/cloud/jira/platform/rest/v3/)
|
||||
- [Confluence Cloud REST API](https://developer.atlassian.com/cloud/confluence/rest/v2/)
|
||||
- [MCP Atlassian Server](https://github.com/sooperset/mcp-atlassian)
|
||||
|
||||
### Community Support
|
||||
|
||||
- [MCPHub Discord Community](https://discord.gg/qMKNsn5Q)
|
||||
- [GitHub Issues](https://github.com/samanhappy/mcphub/issues)
|
||||
- [GitHub Discussions](https://github.com/samanhappy/mcphub/discussions)
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Have a useful configuration example? We'd love to include it!
|
||||
|
||||
1. Create your example configuration
|
||||
2. Add documentation explaining the setup
|
||||
3. Submit a pull request to the repository
|
||||
|
||||
Example contributions:
|
||||
- Configuration for other MCP servers
|
||||
- Multi-server setup examples
|
||||
- Docker Compose configurations
|
||||
- Kubernetes deployment examples
|
||||
- CI/CD integration examples
|
||||
|
||||
## 📄 License
|
||||
|
||||
All examples in this directory are provided under the same license as MCPHub (Apache 2.0).
|
||||
|
||||
Feel free to use, modify, and distribute these examples as needed for your projects.
|
||||
@@ -1,319 +0,0 @@
|
||||
# Atlassian Jira Cloud MCP Server Configuration
|
||||
|
||||
This guide provides detailed instructions for configuring the MCP Atlassian server to connect to Jira Cloud in MCPHub.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Jira Cloud Account**: You need access to a Jira Cloud instance
|
||||
2. **API Token**: Generate an API token from your Atlassian account
|
||||
3. **Python/UV**: The mcp-atlassian server requires Python and `uvx` (UV package manager)
|
||||
|
||||
## Step 1: Generate Jira API Token
|
||||
|
||||
1. Go to [Atlassian Account Settings](https://id.atlassian.com/manage-profile/security/api-tokens)
|
||||
2. Click **"Create API token"**
|
||||
3. Give it a label (e.g., "MCPHub Integration")
|
||||
4. Copy the generated token (you won't be able to see it again!)
|
||||
5. Save it securely
|
||||
|
||||
## Step 2: Get Your Jira Information
|
||||
|
||||
You'll need the following information:
|
||||
|
||||
- **JIRA_URL**: Your Jira Cloud URL (e.g., `https://your-company.atlassian.net`)
|
||||
- **JIRA_USERNAME**: Your Atlassian account email (e.g., `your.email@company.com`)
|
||||
- **JIRA_TOKEN**: The API token you generated in Step 1
|
||||
|
||||
## Step 3: Set Environment Variables
|
||||
|
||||
Create or update your `.env` file in the MCPHub root directory:
|
||||
|
||||
```bash
|
||||
# Jira Configuration
|
||||
JIRA_URL=https://your-company.atlassian.net
|
||||
JIRA_USERNAME=your.email@company.com
|
||||
JIRA_TOKEN=your_api_token_here
|
||||
```
|
||||
|
||||
**Important Security Note**: Never commit your `.env` file to version control. It should be listed in `.gitignore`.
|
||||
|
||||
## Step 4: Configure MCPHub
|
||||
|
||||
### Option 1: Using Environment Variables (Recommended)
|
||||
|
||||
Update your `mcp_settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"atlassian": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"mcp-atlassian",
|
||||
"--jira-url=${JIRA_URL}",
|
||||
"--jira-username=${JIRA_USERNAME}",
|
||||
"--jira-token=${JIRA_TOKEN}"
|
||||
],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Option 2: Direct Configuration (Not Recommended)
|
||||
|
||||
If you prefer not to use environment variables (less secure):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"atlassian": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"mcp-atlassian",
|
||||
"--jira-url=https://your-company.atlassian.net",
|
||||
"--jira-username=your.email@company.com",
|
||||
"--jira-token=your_api_token_here"
|
||||
],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Option 3: Jira Only (Without Confluence)
|
||||
|
||||
If you only want to use Jira and not Confluence:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"jira": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"mcp-atlassian",
|
||||
"--jira-url=${JIRA_URL}",
|
||||
"--jira-username=${JIRA_USERNAME}",
|
||||
"--jira-token=${JIRA_TOKEN}"
|
||||
],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Option 4: Both Jira and Confluence
|
||||
|
||||
To use both Jira and Confluence, you'll need API tokens for both:
|
||||
|
||||
```bash
|
||||
# .env file
|
||||
JIRA_URL=https://your-company.atlassian.net
|
||||
JIRA_USERNAME=your.email@company.com
|
||||
JIRA_TOKEN=your_jira_api_token
|
||||
|
||||
CONFLUENCE_URL=https://your-company.atlassian.net/wiki
|
||||
CONFLUENCE_USERNAME=your.email@company.com
|
||||
CONFLUENCE_TOKEN=your_confluence_api_token
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"atlassian": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"mcp-atlassian",
|
||||
"--confluence-url=${CONFLUENCE_URL}",
|
||||
"--confluence-username=${CONFLUENCE_USERNAME}",
|
||||
"--confluence-token=${CONFLUENCE_TOKEN}",
|
||||
"--jira-url=${JIRA_URL}",
|
||||
"--jira-username=${JIRA_USERNAME}",
|
||||
"--jira-token=${JIRA_TOKEN}"
|
||||
],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: For Atlassian Cloud, you can often use the same API token for both Jira and Confluence.
|
||||
|
||||
## Step 5: Install UV (if not already installed)
|
||||
|
||||
The mcp-atlassian server uses `uvx` to run. Install UV if you haven't already:
|
||||
|
||||
### On macOS/Linux:
|
||||
```bash
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
```
|
||||
|
||||
### On Windows:
|
||||
```powershell
|
||||
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
|
||||
```
|
||||
|
||||
### Using pip:
|
||||
```bash
|
||||
pip install uv
|
||||
```
|
||||
|
||||
## Step 6: Start MCPHub
|
||||
|
||||
### Using Docker:
|
||||
```bash
|
||||
docker run -p 3000:3000 \
|
||||
-v ./mcp_settings.json:/app/mcp_settings.json \
|
||||
-v ./data:/app/data \
|
||||
-e JIRA_URL="${JIRA_URL}" \
|
||||
-e JIRA_USERNAME="${JIRA_USERNAME}" \
|
||||
-e JIRA_TOKEN="${JIRA_TOKEN}" \
|
||||
samanhappy/mcphub
|
||||
```
|
||||
|
||||
### Using Development Mode:
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Using Production Mode:
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm start
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
After starting MCPHub:
|
||||
|
||||
1. Open `http://localhost:3000` in your browser
|
||||
2. Log in with default credentials: `admin` / `admin123`
|
||||
|
||||
**⚠️ SECURITY WARNING:** Change the default admin password immediately in production!
|
||||
|
||||
**To change the password:**
|
||||
- Option 1: Use the dashboard after logging in (Settings → Users → Change Password)
|
||||
- Option 2: Generate a bcrypt hash and update `mcp_settings.json`:
|
||||
```bash
|
||||
node -e "console.log(require('bcrypt').hashSync('your-new-password', 10))"
|
||||
```
|
||||
|
||||
3. Check the dashboard to see if the Atlassian server is connected
|
||||
4. Look for the server status - it should show as "Connected" or "Running"
|
||||
5. Check the logs for any connection errors
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Error: "uvx command not found"
|
||||
|
||||
**Solution**: Install UV as described in Step 5 above.
|
||||
|
||||
### Error: "Traceback (most recent call last): File ... mcp-atlassian"
|
||||
|
||||
This error usually indicates:
|
||||
1. Missing or incorrect API credentials
|
||||
2. Network connectivity issues
|
||||
3. Python dependency issues
|
||||
|
||||
**Solutions**:
|
||||
- Verify your API token is correct
|
||||
- Ensure your Jira URL doesn't have trailing slashes
|
||||
- Check that your username is the email address you use for Atlassian
|
||||
- Verify network connectivity to your Jira instance
|
||||
- Try regenerating your API token
|
||||
|
||||
### Error: "401 Unauthorized"
|
||||
|
||||
**Solution**:
|
||||
- Double-check your API token is correct
|
||||
- Ensure you're using the email address associated with your Atlassian account
|
||||
- Regenerate your API token if needed
|
||||
|
||||
### Error: "403 Forbidden"
|
||||
|
||||
**Solution**:
|
||||
- Check that your account has appropriate permissions in Jira
|
||||
- Verify your Jira administrator hasn't restricted API access
|
||||
|
||||
### Error: Downloading cryptography errors
|
||||
|
||||
**Solution**:
|
||||
- This is usually a transient network or Python package installation issue
|
||||
- Wait a moment and try restarting MCPHub
|
||||
- Ensure you have a stable internet connection
|
||||
- If the issue persists, try installing mcp-atlassian manually:
|
||||
```bash
|
||||
uvx mcp-atlassian --help
|
||||
```
|
||||
|
||||
### Server shows as "Disconnected"
|
||||
|
||||
**Solution**:
|
||||
1. Check MCPHub logs for specific error messages
|
||||
2. Verify all environment variables are set correctly
|
||||
3. Test the connection manually:
|
||||
```bash
|
||||
uvx mcp-atlassian \
|
||||
--jira-url=https://your-company.atlassian.net \
|
||||
--jira-username=your.email@company.com \
|
||||
--jira-token=your_token
|
||||
```
|
||||
|
||||
## Using the Jira MCP Server
|
||||
|
||||
Once connected, you can use the Jira MCP server to:
|
||||
|
||||
- **Search Issues**: Query Jira issues using JQL
|
||||
- **Read Issues**: Get detailed information about specific issues
|
||||
- **Access Projects**: List and retrieve project metadata
|
||||
- **View Comments**: Read issue comments and discussions
|
||||
- **Get Transitions**: Check available status transitions for issues
|
||||
|
||||
Access the server through:
|
||||
- **All servers**: `http://localhost:3000/mcp`
|
||||
- **Specific server**: `http://localhost:3000/mcp/atlassian`
|
||||
- **Server groups**: `http://localhost:3000/mcp/{group}` (if configured)
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [MCP Atlassian GitHub Repository](https://github.com/sooperset/mcp-atlassian)
|
||||
- [Atlassian API Token Documentation](https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/)
|
||||
- [Jira Cloud REST API](https://developer.atlassian.com/cloud/jira/platform/rest/v3/)
|
||||
- [MCPHub Documentation](https://docs.mcphubx.com/)
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. ✅ **Always use environment variables** for sensitive credentials
|
||||
2. ✅ **Never commit `.env` files** to version control
|
||||
3. ✅ **Rotate API tokens** regularly
|
||||
4. ✅ **Use separate tokens** for different environments (dev, staging, prod)
|
||||
5. ✅ **Restrict API token permissions** to only what's needed
|
||||
6. ✅ **Monitor token usage** in Atlassian account settings
|
||||
7. ✅ **Revoke unused tokens** immediately
|
||||
|
||||
## Example Use Cases
|
||||
|
||||
### Example 1: Search for Issues
|
||||
Query: "List all open bugs assigned to me"
|
||||
- Tool: `jira_search_issues`
|
||||
- JQL: `project = MYPROJECT AND status = Open AND assignee = currentUser() AND type = Bug`
|
||||
|
||||
### Example 2: Get Issue Details
|
||||
Query: "Show me details of issue PROJ-123"
|
||||
- Tool: `jira_get_issue`
|
||||
- Issue Key: `PROJ-123`
|
||||
|
||||
### Example 3: List Projects
|
||||
Query: "What Jira projects do I have access to?"
|
||||
- Tool: `jira_list_projects`
|
||||
|
||||
## Need Help?
|
||||
|
||||
If you're still experiencing issues:
|
||||
|
||||
1. Check the [MCPHub Discord community](https://discord.gg/qMKNsn5Q)
|
||||
2. Review [MCPHub GitHub Issues](https://github.com/samanhappy/mcphub/issues)
|
||||
3. Check [mcp-atlassian Issues](https://github.com/sooperset/mcp-atlassian/issues)
|
||||
4. Contact your Jira administrator for API access questions
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"atlassian": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"mcp-atlassian",
|
||||
"--jira-url=${JIRA_URL}",
|
||||
"--jira-username=${JIRA_USERNAME}",
|
||||
"--jira-token=${JIRA_TOKEN}"
|
||||
],
|
||||
"env": {}
|
||||
}
|
||||
},
|
||||
"users": [
|
||||
{
|
||||
"username": "admin",
|
||||
"_comment": "Password must be a bcrypt hash. Generate with: node -e \"console.log(require('bcrypt').hashSync('your-password', 10))\"",
|
||||
"password": "${ADMIN_PASSWORD_HASH}",
|
||||
"isAdmin": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -78,28 +78,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 +108,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,
|
||||
);
|
||||
|
||||
457
src/services/clusterService.ts
Normal file
457
src/services/clusterService.ts
Normal file
@@ -0,0 +1,457 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { URL } from 'url';
|
||||
import config, { loadSettings } from '../config/index.js';
|
||||
import { ClusterConfig, ClusterNodeConfig } from '../types/index.js';
|
||||
|
||||
interface ProxyContext {
|
||||
node: ClusterNodeConfig;
|
||||
targetUrl: URL;
|
||||
}
|
||||
|
||||
const sessionBindings = new Map<string, string>();
|
||||
const groupCounters = new Map<string, number>();
|
||||
|
||||
const DEFAULT_GROUP_KEY = '__default__';
|
||||
|
||||
const isIterableHeaderValue = (value: string | string[] | undefined): value is string[] => {
|
||||
return Array.isArray(value);
|
||||
};
|
||||
|
||||
const createHeadersFromRequest = (req: Request, node: ClusterNodeConfig): Headers => {
|
||||
const headers = new Headers();
|
||||
for (const [key, rawValue] of Object.entries(req.headers)) {
|
||||
if (rawValue === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (key.toLowerCase() === 'host') {
|
||||
continue;
|
||||
}
|
||||
if (isIterableHeaderValue(rawValue)) {
|
||||
for (const value of rawValue) {
|
||||
headers.append(key, value);
|
||||
}
|
||||
} else {
|
||||
headers.set(key, String(rawValue));
|
||||
}
|
||||
}
|
||||
|
||||
if (node.forwardHeaders) {
|
||||
for (const [key, value] of Object.entries(node.forwardHeaders)) {
|
||||
if (value !== undefined) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
};
|
||||
|
||||
const getClusterConfig = (): ClusterConfig | undefined => {
|
||||
const settings = loadSettings();
|
||||
return settings.systemConfig?.cluster;
|
||||
};
|
||||
|
||||
const getClusterNodes = (): ClusterNodeConfig[] => {
|
||||
const config = getClusterConfig();
|
||||
if (!config?.enabled) {
|
||||
return [];
|
||||
}
|
||||
return config.nodes ?? [];
|
||||
};
|
||||
|
||||
const isClusterEnabled = (): boolean => {
|
||||
return getClusterNodes().length > 0;
|
||||
};
|
||||
|
||||
const sanitizePathSegment = (segment: string): string => {
|
||||
return segment.replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
};
|
||||
|
||||
const joinUrlPaths = (...segments: (string | undefined)[]): string => {
|
||||
const sanitizedSegments = segments
|
||||
.filter((segment): segment is string => segment !== undefined && segment !== null && segment !== '')
|
||||
.map((segment) => sanitizePathSegment(segment));
|
||||
|
||||
if (!sanitizedSegments.length) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
const joined = sanitizedSegments.filter((segment) => segment.length > 0).join('/');
|
||||
return joined ? `/${joined}` : '/';
|
||||
};
|
||||
|
||||
const normalizeBasePath = (path?: string): string => {
|
||||
if (!path) {
|
||||
return '';
|
||||
}
|
||||
const normalized = path.startsWith('/') ? path : `/${path}`;
|
||||
if (normalized === '/') {
|
||||
return '';
|
||||
}
|
||||
if (normalized !== '/' && normalized.endsWith('/')) {
|
||||
return normalized.slice(0, -1);
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const buildTargetUrl = (node: ClusterNodeConfig, originalUrl: string): URL => {
|
||||
const placeholderBase = 'http://cluster.local';
|
||||
const requestUrl = new URL(originalUrl, placeholderBase);
|
||||
const requestPath = requestUrl.pathname;
|
||||
const hubBasePath = normalizeBasePath(config.basePath);
|
||||
const relativePath = requestPath.startsWith(hubBasePath)
|
||||
? requestPath.slice(hubBasePath.length) || '/'
|
||||
: requestPath;
|
||||
|
||||
const nodePrefix = normalizeBasePath(node.pathPrefix ?? hubBasePath);
|
||||
const targetUrl = new URL(node.url);
|
||||
targetUrl.pathname = joinUrlPaths(targetUrl.pathname, nodePrefix, relativePath);
|
||||
targetUrl.search = requestUrl.search;
|
||||
targetUrl.hash = requestUrl.hash;
|
||||
return targetUrl;
|
||||
};
|
||||
|
||||
const matchesNodeGroup = (nodeGroup: string, targetGroup: string): boolean => {
|
||||
if (!targetGroup) {
|
||||
return nodeGroup === '' || nodeGroup === '*' || nodeGroup === 'global' || nodeGroup === 'default';
|
||||
}
|
||||
|
||||
if (nodeGroup === '*') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return nodeGroup === targetGroup;
|
||||
};
|
||||
|
||||
const selectNodeForGroup = (group?: string): ClusterNodeConfig | undefined => {
|
||||
const nodes = getClusterNodes();
|
||||
if (!nodes.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const key = group ?? DEFAULT_GROUP_KEY;
|
||||
const normalizedGroup = group ?? '';
|
||||
const candidates = nodes.filter((node) => {
|
||||
if (!node.groups || node.groups.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return node.groups.some((nodeGroup) => matchesNodeGroup(nodeGroup, normalizedGroup));
|
||||
});
|
||||
|
||||
if (!candidates.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const weightedCandidates: ClusterNodeConfig[] = [];
|
||||
for (const candidate of candidates) {
|
||||
const weight = Math.max(1, candidate.weight ?? 1);
|
||||
for (let i = 0; i < weight; i += 1) {
|
||||
weightedCandidates.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
const index = groupCounters.get(key) ?? 0;
|
||||
const selected = weightedCandidates[index % weightedCandidates.length];
|
||||
groupCounters.set(key, index + 1);
|
||||
return selected;
|
||||
};
|
||||
|
||||
const bindSessionToNode = (sessionId: string, nodeId: string): void => {
|
||||
sessionBindings.set(sessionId, nodeId);
|
||||
};
|
||||
|
||||
const releaseSession = (sessionId: string): void => {
|
||||
sessionBindings.delete(sessionId);
|
||||
};
|
||||
|
||||
const getNodeForSession = (sessionId: string): ClusterNodeConfig | undefined => {
|
||||
const nodeId = sessionBindings.get(sessionId);
|
||||
if (!nodeId) {
|
||||
return undefined;
|
||||
}
|
||||
return getClusterNodes().find((node) => node.id === nodeId);
|
||||
};
|
||||
|
||||
const resolveProxyContext = (req: Request, group?: string, sessionId?: string): ProxyContext | undefined => {
|
||||
if (!isClusterEnabled()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (sessionId) {
|
||||
const node = getNodeForSession(sessionId);
|
||||
if (node) {
|
||||
return { node, targetUrl: buildTargetUrl(node, req.originalUrl) };
|
||||
}
|
||||
}
|
||||
|
||||
const node = selectNodeForGroup(group);
|
||||
if (!node) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
node,
|
||||
targetUrl: buildTargetUrl(node, req.originalUrl),
|
||||
};
|
||||
};
|
||||
|
||||
const pipeReadableStreamToResponse = async (
|
||||
response: globalThis.Response,
|
||||
res: Response,
|
||||
onData?: (chunk: string) => void,
|
||||
): Promise<void> => {
|
||||
if (!response.body) {
|
||||
const text = await response.text();
|
||||
res.send(text);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
try {
|
||||
let finished = false;
|
||||
while (!finished) {
|
||||
const { value, done } = await reader.read();
|
||||
finished = Boolean(done);
|
||||
if (value) {
|
||||
const chunkString = decoder.decode(value, { stream: true });
|
||||
if (onData) {
|
||||
onData(chunkString);
|
||||
}
|
||||
res.write(Buffer.from(value));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as Error).name !== 'AbortError') {
|
||||
console.error('Cluster proxy stream error:', error);
|
||||
}
|
||||
} finally {
|
||||
const finalChunk = decoder.decode();
|
||||
if (finalChunk && onData) {
|
||||
onData(finalChunk);
|
||||
}
|
||||
res.end();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSseStream = async (
|
||||
node: ClusterNodeConfig,
|
||||
req: Request,
|
||||
res: Response,
|
||||
context: ProxyContext,
|
||||
): Promise<void> => {
|
||||
const controller = new AbortController();
|
||||
const sessionIds = new Set<string>();
|
||||
req.on('close', () => {
|
||||
controller.abort();
|
||||
for (const sessionId of sessionIds) {
|
||||
releaseSession(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
let response: globalThis.Response;
|
||||
try {
|
||||
response = await fetch(context.targetUrl, {
|
||||
method: 'GET',
|
||||
headers: createHeadersFromRequest(req, node),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to proxy SSE request to cluster node:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(502).send('Failed to reach cluster node');
|
||||
}
|
||||
for (const sessionId of sessionIds) {
|
||||
releaseSession(sessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(response.status);
|
||||
response.headers.forEach((value, key) => {
|
||||
if (key.toLowerCase() === 'content-length') {
|
||||
return;
|
||||
}
|
||||
res.setHeader(key, value);
|
||||
});
|
||||
|
||||
if (typeof res.flushHeaders === 'function') {
|
||||
res.flushHeaders();
|
||||
}
|
||||
|
||||
const isSse = response.headers.get('content-type')?.includes('text/event-stream');
|
||||
let buffer = '';
|
||||
await pipeReadableStreamToResponse(
|
||||
response,
|
||||
res,
|
||||
isSse
|
||||
? (chunk) => {
|
||||
buffer += chunk;
|
||||
let boundaryIndex = buffer.indexOf('\n\n');
|
||||
while (boundaryIndex !== -1) {
|
||||
const rawEvent = buffer.slice(0, boundaryIndex);
|
||||
buffer = buffer.slice(boundaryIndex + 2);
|
||||
const normalizedEvent = rawEvent.replace(/\r\n/g, '\n');
|
||||
const lines = normalizedEvent.split('\n');
|
||||
let eventName = '';
|
||||
let data = '';
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.slice(6).trim();
|
||||
}
|
||||
if (line.startsWith('data:')) {
|
||||
data += `${line.slice(5).trim()}`;
|
||||
}
|
||||
}
|
||||
if (eventName === 'endpoint' && data) {
|
||||
try {
|
||||
const sessionUrl = new URL(data, 'http://localhost');
|
||||
const sessionId = sessionUrl.searchParams.get('sessionId');
|
||||
if (sessionId) {
|
||||
bindSessionToNode(sessionId, node.id);
|
||||
sessionIds.add(sessionId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse session endpoint from cluster response:', error);
|
||||
}
|
||||
}
|
||||
boundaryIndex = buffer.indexOf('\n\n');
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
for (const sessionId of sessionIds) {
|
||||
releaseSession(sessionId);
|
||||
}
|
||||
};
|
||||
|
||||
const forwardRequest = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
context: ProxyContext,
|
||||
options?: { releaseSession?: string },
|
||||
): Promise<void> => {
|
||||
const { node, targetUrl } = context;
|
||||
const method = req.method.toUpperCase();
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers: createHeadersFromRequest(req, node),
|
||||
};
|
||||
|
||||
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
||||
if (req.body !== undefined) {
|
||||
init.body = typeof req.body === 'string' ? req.body : JSON.stringify(req.body);
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
init.signal = controller.signal;
|
||||
req.on('close', () => {
|
||||
controller.abort();
|
||||
});
|
||||
|
||||
let response: globalThis.Response;
|
||||
try {
|
||||
response = await fetch(targetUrl, init);
|
||||
} catch (error) {
|
||||
if ((error as Error).name !== 'AbortError') {
|
||||
console.error('Failed to proxy request to cluster node:', error);
|
||||
}
|
||||
if (!res.headersSent) {
|
||||
res.status(502).send('Failed to reach cluster node');
|
||||
}
|
||||
if (options?.releaseSession) {
|
||||
releaseSession(options.releaseSession);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const newSessionId = response.headers.get('mcp-session-id');
|
||||
if (newSessionId) {
|
||||
bindSessionToNode(newSessionId, node.id);
|
||||
}
|
||||
|
||||
res.status(response.status);
|
||||
response.headers.forEach((value, key) => {
|
||||
if (key.toLowerCase() === 'content-length') {
|
||||
return;
|
||||
}
|
||||
res.setHeader(key, value);
|
||||
});
|
||||
|
||||
if (response.headers.get('content-type')?.includes('text/event-stream')) {
|
||||
await pipeReadableStreamToResponse(response, res);
|
||||
} else {
|
||||
const buffer = await response.arrayBuffer();
|
||||
if (buffer.byteLength === 0) {
|
||||
res.end();
|
||||
} else {
|
||||
res.send(Buffer.from(buffer));
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.releaseSession) {
|
||||
releaseSession(options.releaseSession);
|
||||
}
|
||||
};
|
||||
|
||||
export const tryProxySseConnection = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
group?: string,
|
||||
): Promise<boolean> => {
|
||||
const context = resolveProxyContext(req, group);
|
||||
if (!context) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await handleSseStream(context.node, req, res, context);
|
||||
return true;
|
||||
};
|
||||
|
||||
export const tryProxySseMessage = async (req: Request, res: Response): Promise<boolean> => {
|
||||
const sessionId = typeof req.query.sessionId === 'string' ? req.query.sessionId : undefined;
|
||||
if (!sessionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const context = resolveProxyContext(req, undefined, sessionId);
|
||||
if (!context) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await forwardRequest(req, res, context);
|
||||
return true;
|
||||
};
|
||||
|
||||
export const tryProxyMcpRequest = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
group?: string,
|
||||
): Promise<boolean> => {
|
||||
const sessionIdHeader = req.headers['mcp-session-id'];
|
||||
const sessionId = Array.isArray(sessionIdHeader) ? sessionIdHeader[0] : sessionIdHeader;
|
||||
const context = resolveProxyContext(req, group, sessionId);
|
||||
if (!context) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const releaseTarget = req.method.toUpperCase() === 'DELETE' ? sessionId : undefined;
|
||||
await forwardRequest(req, res, context, { releaseSession: releaseTarget });
|
||||
return true;
|
||||
};
|
||||
|
||||
export const clearClusterSessionBindings = (): void => {
|
||||
sessionBindings.clear();
|
||||
groupCounters.clear();
|
||||
};
|
||||
|
||||
export const __clusterInternals = {
|
||||
joinUrlPaths,
|
||||
normalizeBasePath,
|
||||
matchesNodeGroup,
|
||||
buildTargetUrl,
|
||||
};
|
||||
@@ -1,6 +1,4 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
@@ -28,82 +26,12 @@ import { getDataService } from './services.js';
|
||||
import { getServerDao, ServerConfigWithName } from '../dao/index.js';
|
||||
import { initializeAllOAuthClients } from './oauthService.js';
|
||||
import { createOAuthProvider } from './mcpOAuthProvider.js';
|
||||
import { clearClusterSessionBindings } from './clusterService.js';
|
||||
|
||||
const servers: { [sessionId: string]: Server } = {};
|
||||
|
||||
const serverDao = getServerDao();
|
||||
|
||||
const ensureDirExists = (dir: string | undefined): string => {
|
||||
if (!dir) {
|
||||
throw new Error('Directory path is undefined');
|
||||
}
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
return dir;
|
||||
};
|
||||
|
||||
const getDataRootDir = (): string => {
|
||||
return ensureDirExists(process.env.MCP_DATA_DIR || path.join(process.cwd(), 'data'));
|
||||
};
|
||||
|
||||
const getServersStorageRoot = (): string => {
|
||||
return ensureDirExists(process.env.MCP_SERVERS_DIR || path.join(getDataRootDir(), 'servers'));
|
||||
};
|
||||
|
||||
const getNpmBaseDir = (): string => {
|
||||
return ensureDirExists(process.env.MCP_NPM_DIR || path.join(getServersStorageRoot(), 'npm'));
|
||||
};
|
||||
|
||||
const getPythonBaseDir = (): string => {
|
||||
return ensureDirExists(
|
||||
process.env.MCP_PYTHON_DIR || path.join(getServersStorageRoot(), 'python'),
|
||||
);
|
||||
};
|
||||
|
||||
const getNpmCacheDir = (): string => {
|
||||
return ensureDirExists(process.env.NPM_CONFIG_CACHE || path.join(getDataRootDir(), 'npm-cache'));
|
||||
};
|
||||
|
||||
const getNpmPrefixDir = (): string => {
|
||||
const dir = ensureDirExists(
|
||||
process.env.NPM_CONFIG_PREFIX || path.join(getDataRootDir(), 'npm-global'),
|
||||
);
|
||||
ensureDirExists(path.join(dir, 'bin'));
|
||||
ensureDirExists(path.join(dir, 'lib', 'node_modules'));
|
||||
return dir;
|
||||
};
|
||||
|
||||
const getUvCacheDir = (): string => {
|
||||
return ensureDirExists(process.env.UV_CACHE_DIR || path.join(getDataRootDir(), 'uv', 'cache'));
|
||||
};
|
||||
|
||||
const getUvToolDir = (): string => {
|
||||
const dir = ensureDirExists(process.env.UV_TOOL_DIR || path.join(getDataRootDir(), 'uv', 'tools'));
|
||||
ensureDirExists(path.join(dir, 'bin'));
|
||||
return dir;
|
||||
};
|
||||
|
||||
const getServerInstallDir = (serverName: string, kind: 'npm' | 'python'): string => {
|
||||
const baseDir = kind === 'npm' ? getNpmBaseDir() : getPythonBaseDir();
|
||||
return ensureDirExists(path.join(baseDir, serverName));
|
||||
};
|
||||
|
||||
const prependToPath = (currentPath: string, dir: string): string => {
|
||||
if (!dir) {
|
||||
return currentPath;
|
||||
}
|
||||
const delimiter = path.delimiter;
|
||||
const segments = currentPath ? currentPath.split(delimiter) : [];
|
||||
if (segments.includes(dir)) {
|
||||
return currentPath;
|
||||
}
|
||||
return currentPath ? `${dir}${delimiter}${currentPath}` : dir;
|
||||
};
|
||||
|
||||
const NODE_COMMANDS = new Set(['npm', 'npx', 'pnpm', 'yarn', 'node', 'bun', 'bunx']);
|
||||
const PYTHON_COMMANDS = new Set(['uv', 'uvx', 'python', 'pip', 'pip3', 'pipx']);
|
||||
|
||||
// Helper function to set up keep-alive ping for SSE connections
|
||||
const setupKeepAlive = (serverInfo: ServerInfo, serverConfig: ServerConfig): void => {
|
||||
// Only set up keep-alive for SSE connections
|
||||
@@ -234,6 +162,8 @@ export const cleanupAllServers = (): void => {
|
||||
Object.keys(servers).forEach((sessionId) => {
|
||||
delete servers[sessionId];
|
||||
});
|
||||
|
||||
clearClusterSessionBindings();
|
||||
};
|
||||
|
||||
// Helper function to create transport based on server configuration
|
||||
@@ -286,7 +216,7 @@ export const createTransportFromConfig = async (name: string, conf: ServerConfig
|
||||
...(process.env as Record<string, string>),
|
||||
...replaceEnvVars(conf.env || {}),
|
||||
};
|
||||
env['PATH'] = expandEnvVars(env['PATH'] || process.env.PATH || '');
|
||||
env['PATH'] = expandEnvVars(process.env.PATH as string) || '';
|
||||
|
||||
const settings = loadSettings();
|
||||
// Add UV_DEFAULT_INDEX and npm_config_registry if needed
|
||||
@@ -308,52 +238,9 @@ export const createTransportFromConfig = async (name: string, conf: ServerConfig
|
||||
env['npm_config_registry'] = settings.systemConfig.install.npmRegistry;
|
||||
}
|
||||
|
||||
// Ensure stdio servers use persistent directories under /app/data (or configured override)
|
||||
let workingDirectory = os.homedir();
|
||||
const commandLower = conf.command.toLowerCase();
|
||||
|
||||
if (NODE_COMMANDS.has(commandLower)) {
|
||||
const serverDir = getServerInstallDir(name, 'npm');
|
||||
workingDirectory = serverDir;
|
||||
|
||||
const npmCacheDir = getNpmCacheDir();
|
||||
const npmPrefixDir = getNpmPrefixDir();
|
||||
|
||||
if (!env['npm_config_cache']) {
|
||||
env['npm_config_cache'] = npmCacheDir;
|
||||
}
|
||||
if (!env['NPM_CONFIG_CACHE']) {
|
||||
env['NPM_CONFIG_CACHE'] = env['npm_config_cache'];
|
||||
}
|
||||
|
||||
if (!env['npm_config_prefix']) {
|
||||
env['npm_config_prefix'] = npmPrefixDir;
|
||||
}
|
||||
if (!env['NPM_CONFIG_PREFIX']) {
|
||||
env['NPM_CONFIG_PREFIX'] = env['npm_config_prefix'];
|
||||
}
|
||||
|
||||
env['PATH'] = prependToPath(env['PATH'], path.join(env['npm_config_prefix'], 'bin'));
|
||||
} else if (PYTHON_COMMANDS.has(commandLower)) {
|
||||
const serverDir = getServerInstallDir(name, 'python');
|
||||
workingDirectory = serverDir;
|
||||
|
||||
const uvCacheDir = getUvCacheDir();
|
||||
const uvToolDir = getUvToolDir();
|
||||
|
||||
if (!env['UV_CACHE_DIR']) {
|
||||
env['UV_CACHE_DIR'] = uvCacheDir;
|
||||
}
|
||||
if (!env['UV_TOOL_DIR']) {
|
||||
env['UV_TOOL_DIR'] = uvToolDir;
|
||||
}
|
||||
|
||||
env['PATH'] = prependToPath(env['PATH'], path.join(env['UV_TOOL_DIR'], 'bin'));
|
||||
}
|
||||
|
||||
// Expand environment variables in command
|
||||
transport = new StdioClientTransport({
|
||||
cwd: workingDirectory,
|
||||
cwd: os.homedir(),
|
||||
command: conf.command,
|
||||
args: replaceEnvVars(conf.args) as string[],
|
||||
env: env,
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { loadSettings } from '../config/index.js';
|
||||
import config from '../config/index.js';
|
||||
import { UserContextService } from './userContextService.js';
|
||||
import { RequestContextService } from './requestContextService.js';
|
||||
import { tryProxyMcpRequest, tryProxySseConnection, tryProxySseMessage } from './clusterService.js';
|
||||
|
||||
const transports: { [sessionId: string]: { transport: Transport; group: string } } = {};
|
||||
|
||||
@@ -81,6 +82,10 @@ export const handleSseConnection = async (req: Request, res: Response): Promise<
|
||||
|
||||
console.log(`Creating SSE transport with messages path: ${messagesPath}`);
|
||||
|
||||
if (await tryProxySseConnection(req, res, group)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const transport = new SSEServerTransport(messagesPath, res);
|
||||
transports[transport.sessionId] = { transport, group: group };
|
||||
|
||||
@@ -117,6 +122,10 @@ export const handleSseMessage = async (req: Request, res: Response): Promise<voi
|
||||
return;
|
||||
}
|
||||
|
||||
if (await tryProxySseMessage(req, res)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if transport exists before destructuring
|
||||
const transportData = transports[sessionId];
|
||||
if (!transportData) {
|
||||
@@ -174,6 +183,10 @@ export const handleMcpPostRequest = async (req: Request, res: Response): Promise
|
||||
return;
|
||||
}
|
||||
|
||||
if (await tryProxyMcpRequest(req, res, group)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let transport: StreamableHTTPServerTransport;
|
||||
if (sessionId && transports[sessionId]) {
|
||||
console.log(`Reusing existing transport for sessionId: ${sessionId}`);
|
||||
@@ -239,6 +252,11 @@ export const handleMcpOtherRequest = async (req: Request, res: Response) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const group = req.params.group;
|
||||
if (await tryProxyMcpRequest(req, res, group)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||
if (!sessionId || !transports[sessionId]) {
|
||||
res.status(400).send('Invalid or missing session ID');
|
||||
|
||||
@@ -62,6 +62,20 @@ export interface MarketServerTool {
|
||||
inputSchema: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ClusterNodeConfig {
|
||||
id: string; // Unique identifier for the node
|
||||
url: string; // Base URL for the node (e.g. http://node-a:3000)
|
||||
groups?: string[]; // Optional list of group identifiers served by this node; include empty string for global routes
|
||||
weight?: number; // Optional weight for load balancing
|
||||
forwardHeaders?: Record<string, string>; // Additional headers forwarded to the node on every request
|
||||
pathPrefix?: string; // Optional prefix prepended before forwarding paths (defaults to hub base path)
|
||||
}
|
||||
|
||||
export interface ClusterConfig {
|
||||
enabled?: boolean; // Flag to enable/disable cluster routing
|
||||
nodes?: ClusterNodeConfig[]; // Cluster node definitions
|
||||
}
|
||||
|
||||
export interface MarketServer {
|
||||
name: string;
|
||||
display_name: string;
|
||||
@@ -171,6 +185,7 @@ export interface SystemConfig {
|
||||
};
|
||||
nameSeparator?: string; // Separator used between server name and tool/prompt name (default: '-')
|
||||
oauth?: OAuthProviderConfig; // OAuth provider configuration for upstream MCP servers
|
||||
cluster?: ClusterConfig; // Cluster configuration for multi-node deployments
|
||||
}
|
||||
|
||||
export interface UserConfig {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
67
tests/clusterService.test.ts
Normal file
67
tests/clusterService.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { ClusterNodeConfig } from '../src/types/index.js';
|
||||
import config from '../src/config/index.js';
|
||||
import { __clusterInternals } from '../src/services/clusterService.js';
|
||||
|
||||
const { buildTargetUrl, normalizeBasePath, matchesNodeGroup, joinUrlPaths } = __clusterInternals;
|
||||
|
||||
describe('clusterService internals', () => {
|
||||
const originalBasePath = config.basePath;
|
||||
|
||||
afterEach(() => {
|
||||
config.basePath = originalBasePath;
|
||||
});
|
||||
|
||||
test('normalizeBasePath trims trailing slashes and enforces leading slash', () => {
|
||||
expect(normalizeBasePath('')).toBe('');
|
||||
expect(normalizeBasePath('/')).toBe('');
|
||||
expect(normalizeBasePath('/api/')).toBe('/api');
|
||||
expect(normalizeBasePath('api')).toBe('/api');
|
||||
});
|
||||
|
||||
test('matchesNodeGroup recognises global shortcuts', () => {
|
||||
expect(matchesNodeGroup('', '')).toBe(true);
|
||||
expect(matchesNodeGroup('global', '')).toBe(true);
|
||||
expect(matchesNodeGroup('default', '')).toBe(true);
|
||||
expect(matchesNodeGroup('*', '')).toBe(true);
|
||||
expect(matchesNodeGroup('*', 'group-a')).toBe(true);
|
||||
expect(matchesNodeGroup('group-a', 'group-a')).toBe(true);
|
||||
expect(matchesNodeGroup('group-a', 'group-b')).toBe(false);
|
||||
});
|
||||
|
||||
test('joinUrlPaths combines segments without duplicating slashes', () => {
|
||||
expect(joinUrlPaths('/', '/api', '/messages')).toBe('/api/messages');
|
||||
expect(joinUrlPaths('/root', '', '/')).toBe('/root');
|
||||
expect(joinUrlPaths('', '', '/tools')).toBe('/tools');
|
||||
});
|
||||
|
||||
test('buildTargetUrl respects hub base path and node prefix', () => {
|
||||
config.basePath = '/hub';
|
||||
const node: ClusterNodeConfig = {
|
||||
id: 'node-1',
|
||||
url: 'http://backend:3000',
|
||||
};
|
||||
const target = buildTargetUrl(node, '/hub/mcp/alpha?foo=bar');
|
||||
expect(target.toString()).toBe('http://backend:3000/hub/mcp/alpha?foo=bar');
|
||||
});
|
||||
|
||||
test('buildTargetUrl can override base path using node prefix', () => {
|
||||
config.basePath = '/hub';
|
||||
const node: ClusterNodeConfig = {
|
||||
id: 'node-1',
|
||||
url: 'http://backend:3000',
|
||||
pathPrefix: '/',
|
||||
};
|
||||
const target = buildTargetUrl(node, '/hub/mcp/alpha?foo=bar');
|
||||
expect(target.toString()).toBe('http://backend:3000/mcp/alpha?foo=bar');
|
||||
});
|
||||
|
||||
test('buildTargetUrl appends to node URL path when provided', () => {
|
||||
config.basePath = '';
|
||||
const node: ClusterNodeConfig = {
|
||||
id: 'node-1',
|
||||
url: 'http://backend:3000/root',
|
||||
};
|
||||
const target = buildTargetUrl(node, '/messages?sessionId=123');
|
||||
expect(target.toString()).toBe('http://backend:3000/root/messages?sessionId=123');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -65,27 +65,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', () => {
|
||||
|
||||
@@ -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