Compare commits

...

11 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
eb93edbe40 Enhance security documentation: add password generation instructions and reduce default credential exposure
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-31 14:53:06 +00:00
copilot-swe-agent[bot]
173ef5c0f3 Address security concerns: use env vars for passwords and add warnings about default credentials
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-31 14:50:31 +00:00
copilot-swe-agent[bot]
582be6e035 Add comprehensive examples directory README with navigation and troubleshooting guide
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-31 14:47:44 +00:00
copilot-swe-agent[bot]
9084772ade Update README files with Atlassian/Jira configuration examples and add screenshot guide
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-31 14:46:01 +00:00
copilot-swe-agent[bot]
ccd8ff50aa Add comprehensive Atlassian/Jira MCP server configuration examples and documentation
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-31 14:43:51 +00:00
copilot-swe-agent[bot]
420214259c Initial plan 2025-10-31 14:35:10 +00:00
samanhappy
44e0309fd4 Feat: Enhance package cache for stdio servers (#400) 2025-10-31 21:56:43 +08:00
Copilot
7e570a900a Fix: Convert form parameters to schema-defined types before MCP tool calls (#397)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-29 18:41:23 +08:00
Copilot
6268a02c0e Fix URL routing for MCP servers with slashes in names (#396)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: samanhappy <2755122+samanhappy@users.noreply.github.com>
2025-10-29 18:37:24 +08:00
samanhappy
695d663939 Fix display for null server author (#398) 2025-10-29 14:44:09 +08:00
samanhappy
d595e5d874 Fix support for nested smart group segments in MCP routing (#394) 2025-10-28 17:51:58 +08:00
29 changed files with 1866 additions and 225 deletions

View File

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

View File

@@ -57,6 +57,15 @@ 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 :

View File

@@ -59,6 +59,15 @@ 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:

View File

@@ -57,6 +57,15 @@ MCPHub 通过将多个 MCPModel 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)。实际使用中通常会遇到两类配置:

View File

@@ -207,6 +207,55 @@ 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

View File

@@ -1,5 +1,27 @@
#!/bin/bash
DATA_DIR=${MCP_DATA_DIR:-/app/data}
SERVERS_DIR=${MCP_SERVERS_DIR:-$DATA_DIR/servers}
NPM_SERVER_DIR=${MCP_NPM_DIR:-$SERVERS_DIR/npm}
PYTHON_SERVER_DIR=${MCP_PYTHON_DIR:-$SERVERS_DIR/python}
PNPM_HOME=${PNPM_HOME:-$DATA_DIR/pnpm}
NPM_CONFIG_PREFIX=${NPM_CONFIG_PREFIX:-$DATA_DIR/npm-global}
NPM_CONFIG_CACHE=${NPM_CONFIG_CACHE:-$DATA_DIR/npm-cache}
UV_TOOL_DIR=${UV_TOOL_DIR:-$DATA_DIR/uv/tools}
UV_CACHE_DIR=${UV_CACHE_DIR:-$DATA_DIR/uv/cache}
mkdir -p \
"$PNPM_HOME" \
"$NPM_CONFIG_PREFIX/bin" \
"$NPM_CONFIG_PREFIX/lib/node_modules" \
"$NPM_CONFIG_CACHE" \
"$UV_TOOL_DIR" \
"$UV_CACHE_DIR" \
"$NPM_SERVER_DIR" \
"$PYTHON_SERVER_DIR"
export PATH="$PNPM_HOME:$NPM_CONFIG_PREFIX/bin:$UV_TOOL_DIR/bin:$PATH"
NPM_REGISTRY=${NPM_REGISTRY:-https://registry.npmjs.org/}
echo "Setting npm registry to ${NPM_REGISTRY}"
npm config set registry "$NPM_REGISTRY"

View File

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

View File

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

View File

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

233
examples/README.md Normal file
View File

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

View File

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

View File

@@ -0,0 +1,22 @@
{
"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
}
]
}

View File

@@ -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,21 +32,23 @@ 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'),
};
}
};
@@ -133,12 +135,18 @@ 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>
@@ -150,7 +158,8 @@ 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.license')}: {server.license}
{t('market.author')}: {server.author?.name || t('market.unknown')} {' '}
{t('market.license')}: {server.license}
<a
href={server.repository.url}
target="_blank"
@@ -182,18 +191,24 @@ 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>
@@ -224,9 +239,7 @@ 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>
@@ -268,7 +281,10 @@ 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>
@@ -285,9 +301,7 @@ 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>
@@ -316,11 +330,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>
@@ -332,14 +346,16 @@ 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">
@@ -356,14 +372,12 @@ 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"
>

View File

@@ -287,9 +287,13 @@ export const useCloudData = () => {
const callServerTool = useCallback(
async (serverName: string, toolName: string, args: Record<string, any>) => {
try {
const data = await apiPost(`/cloud/servers/${serverName}/tools/${toolName}/call`, {
arguments: args,
});
// 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,
},
);
if (data && data.success) {
return data.data;

View File

@@ -59,8 +59,9 @@ 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/${server}/prompts/${encodeURIComponent(request.promptName)}`,
`/mcp/${encodeURIComponent(server || '')}/prompts/${encodeURIComponent(request.promptName)}`,
{
name: request.promptName,
arguments: request.arguments,
@@ -94,9 +95,13 @@ export const togglePrompt = async (
enabled: boolean,
): Promise<{ success: boolean; error?: string }> => {
try {
const response = await apiPost<any>(`/servers/${serverName}/prompts/${promptName}/toggle`, {
enabled,
});
// 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,
},
);
return {
success: response.success,
@@ -120,8 +125,9 @@ 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/${serverName}/prompts/${promptName}/description`,
`/servers/${encodeURIComponent(serverName)}/prompts/${encodeURIComponent(promptName)}/description`,
{ description },
{
headers: {

View File

@@ -25,7 +25,10 @@ export const callTool = async (
): Promise<ToolCallResult> => {
try {
// Construct the URL with optional server parameter
const url = server ? `/tools/${server}/${request.toolName}` : '/tools/call';
// 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 response = await apiPost<any>(url, request.arguments, {
headers: {
@@ -62,8 +65,9 @@ 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/${serverName}/tools/${toolName}/toggle`,
`/servers/${encodeURIComponent(serverName)}/tools/${encodeURIComponent(toolName)}/toggle`,
{ enabled },
{
headers: {
@@ -94,8 +98,9 @@ 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/${serverName}/tools/${toolName}/description`,
`/servers/${encodeURIComponent(serverName)}/tools/${encodeURIComponent(toolName)}/description`,
{ description },
{
headers: {

View File

@@ -207,7 +207,8 @@ 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 {
const { serverName } = req.params;
// Decode URL-encoded parameter to handle slashes in server name
const serverName = decodeURIComponent(req.params.serverName);
if (!serverName) {
res.status(400).json({
success: false,
@@ -236,7 +237,9 @@ 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 {
const { serverName, toolName } = req.params;
// Decode URL-encoded parameters to handle slashes in server/tool names
const serverName = decodeURIComponent(req.params.serverName);
const toolName = decodeURIComponent(req.params.toolName);
const { arguments: args } = req.body;
if (!serverName) {

View File

@@ -8,82 +8,13 @@ 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
@@ -167,7 +98,9 @@ export const getOpenAPIStats = async (req: Request, res: Response): Promise<void
*/
export const executeToolViaOpenAPI = async (req: Request, res: Response): Promise<void> => {
try {
const { serverName, toolName } = req.params;
// Decode URL-encoded parameters to handle slashes in server/tool names
const serverName = decodeURIComponent(req.params.serverName);
const toolName = decodeURIComponent(req.params.toolName);
// Import handleCallToolRequest function
const { handleCallToolRequest } = await import('../services/mcpService.js');
@@ -189,7 +122,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 = convertQueryParametersToTypes(args, inputSchema);
args = convertParametersToTypes(args, inputSchema);
// Create a mock request structure that matches what handleCallToolRequest expects
const mockRequest = {

View File

@@ -7,7 +7,9 @@ import { handleGetPromptRequest } from '../services/mcpService.js';
*/
export const getPrompt = async (req: Request, res: Response): Promise<void> => {
try {
const { serverName, promptName } = req.params;
// Decode URL-encoded parameters to handle slashes in server/prompt names
const serverName = decodeURIComponent(req.params.serverName);
const promptName = decodeURIComponent(req.params.promptName);
if (!serverName || !promptName) {
res.status(400).json({
success: false,

View File

@@ -375,7 +375,9 @@ 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 {
const { serverName, toolName } = req.params;
// Decode URL-encoded parameters to handle slashes in server/tool names
const serverName = decodeURIComponent(req.params.serverName);
const toolName = decodeURIComponent(req.params.toolName);
const { enabled } = req.body;
if (!serverName || !toolName) {
@@ -437,7 +439,9 @@ 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 {
const { serverName, toolName } = req.params;
// Decode URL-encoded parameters to handle slashes in server/tool names
const serverName = decodeURIComponent(req.params.serverName);
const toolName = decodeURIComponent(req.params.toolName);
const { description } = req.body;
if (!serverName || !toolName) {
@@ -747,7 +751,9 @@ 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 {
const { serverName, promptName } = req.params;
// Decode URL-encoded parameters to handle slashes in server/prompt names
const serverName = decodeURIComponent(req.params.serverName);
const promptName = decodeURIComponent(req.params.promptName);
const { enabled } = req.body;
if (!serverName || !promptName) {
@@ -809,7 +815,9 @@ 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 {
const { serverName, promptName } = req.params;
// Decode URL-encoded parameters to handle slashes in server/prompt names
const serverName = decodeURIComponent(req.params.serverName);
const promptName = decodeURIComponent(req.params.promptName);
const { description } = req.body;
if (!serverName || !promptName) {

View File

@@ -1,6 +1,8 @@
import { Request, Response } from 'express';
import { ApiResponse } from '../types/index.js';
import { handleCallToolRequest } from '../services/mcpService.js';
import { handleCallToolRequest, getServerByName } from '../services/mcpService.js';
import { convertParametersToTypes } from '../utils/parameterConversion.js';
import { getNameSeparator } from '../config/index.js';
/**
* Interface for tool call request
@@ -47,13 +49,31 @@ 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: toolArgs,
arguments: convertedArgs,
},
},
};
@@ -71,7 +91,7 @@ export const callTool = async (req: Request, res: Response): Promise<void> => {
data: {
content: result.content || [],
toolName,
arguments: toolArgs,
arguments: convertedArgs,
},
};

View File

@@ -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,
);

View File

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

View File

@@ -225,13 +225,22 @@ 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);
// Create path for the tool
const pathName = `/tools/${serverName}/${tool.name}`;
// 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)}`;
const method = requestBody ? 'post' : 'get';
if (!paths[pathName]) {

View File

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

View File

@@ -1,73 +1,7 @@
// 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;
}
import { convertParametersToTypes } from '../../src/utils/parameterConversion.js';
// 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',
@@ -84,7 +18,7 @@ describe('Parameter Type Conversion Logic', () => {
}
};
const result = convertQueryParametersToTypes(queryParams, inputSchema);
const result = convertParametersToTypes(queryParams, inputSchema);
expect(result).toEqual({
limit: 5, // Converted to integer
@@ -107,7 +41,7 @@ describe('Parameter Type Conversion Logic', () => {
}
};
const result = convertQueryParametersToTypes(queryParams, inputSchema);
const result = convertParametersToTypes(queryParams, inputSchema);
expect(result).toEqual({
price: 19.99,
@@ -133,7 +67,7 @@ describe('Parameter Type Conversion Logic', () => {
}
};
const result = convertQueryParametersToTypes(queryParams, inputSchema);
const result = convertParametersToTypes(queryParams, inputSchema);
expect(result).toEqual({
enabled: true,
@@ -157,7 +91,7 @@ describe('Parameter Type Conversion Logic', () => {
}
};
const result = convertQueryParametersToTypes(queryParams, inputSchema);
const result = convertParametersToTypes(queryParams, inputSchema);
expect(result).toEqual({
tags: ['tag1', 'tag2', 'tag3'],
@@ -171,7 +105,7 @@ describe('Parameter Type Conversion Logic', () => {
name: 'test'
};
const result = convertQueryParametersToTypes(queryParams, {});
const result = convertParametersToTypes(queryParams, {});
expect(result).toEqual({
limit: '5', // Should remain as string
@@ -192,7 +126,7 @@ describe('Parameter Type Conversion Logic', () => {
}
};
const result = convertQueryParametersToTypes(queryParams, inputSchema);
const result = convertParametersToTypes(queryParams, inputSchema);
expect(result).toEqual({
limit: 5, // Converted based on schema
@@ -214,7 +148,7 @@ describe('Parameter Type Conversion Logic', () => {
}
};
const result = convertQueryParametersToTypes(queryParams, inputSchema);
const result = convertParametersToTypes(queryParams, inputSchema);
expect(result).toEqual({
limit: 'not-a-number', // Should remain as string when conversion fails
@@ -299,4 +233,16 @@ 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');
});
});

View File

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

View File

@@ -65,6 +65,27 @@ 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', () => {

View File

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