suna/backend/mcp_service/api.py

556 lines
22 KiB
Python

"""
MCP (Model Context Protocol) API module
This module handles MCP server discovery and configuration management.
Architecture:
1. Registry API (https://registry.smithery.ai) - For discovering MCP servers and getting metadata
2. Server API (https://server.smithery.ai) - For actually connecting to and using MCP servers
The flow:
1. Browse available MCP servers from the registry (this module)
2. Configure MCP servers with credentials and save to agent's configured_mcps
3. When agent runs, it connects to MCP servers using:
https://server.smithery.ai/{qualifiedName}/mcp?config={base64_encoded_config}&api_key={smithery_api_key}
"""
from fastapi import APIRouter, HTTPException, Depends, Query
from typing import List, Optional, Dict, Any
from pydantic import BaseModel, validator, HttpUrl
import httpx
import os
from urllib.parse import quote
from utils.logger import logger
from utils.auth_utils import get_current_user_id_from_jwt
from collections import OrderedDict
router = APIRouter()
# Smithery API configuration
SMITHERY_API_BASE_URL = "https://registry.smithery.ai"
SMITHERY_SERVER_BASE_URL = "https://server.smithery.ai"
SMITHERY_API_KEY = os.getenv("SMITHERY_API_KEY")
class MCPServer(BaseModel):
"""Represents an MCP server from Smithery"""
qualifiedName: str
displayName: str
description: str
createdAt: str
useCount: int # Changed from str to int
homepage: str
# These fields are only available in the detail endpoint
iconUrl: Optional[str] = None
isDeployed: Optional[bool] = None
connections: Optional[List[Dict[str, Any]]] = None
tools: Optional[List[Dict[str, Any]]] = None
security: Optional[Dict[str, Any]] = None
class MCPServerListResponse(BaseModel):
"""Response model for MCP server list"""
servers: List[MCPServer]
pagination: Dict[str, int]
class MCPServerDetailResponse(BaseModel):
"""Response model for detailed MCP server information"""
qualifiedName: str
displayName: str
iconUrl: Optional[str] = None
deploymentUrl: Optional[str] = None
connections: List[Dict[str, Any]]
security: Optional[Dict[str, Any]] = None
tools: Optional[List[Dict[str, Any]]] = None
class PopularServersV2Response(BaseModel):
"""Response model for v2 popular servers with categorization"""
success: bool
servers: List[Dict[str, Any]]
categorized: Dict[str, List[Dict[str, Any]]]
total: int
categoryCount: int
pagination: Dict[str, int]
class CustomMCPConnectionRequest(BaseModel):
"""Request model for connecting to a custom MCP server"""
url: str
config: Optional[Dict[str, Any]] = {}
@validator('url')
def validate_smithery_url(cls, v):
"""Validate that the URL is a Smithery server URL"""
if not v.startswith('https://server.smithery.ai/'):
raise ValueError('URL must be a Smithery server URL starting with https://server.smithery.ai/')
return v
class CustomMCPConnectionResponse(BaseModel):
"""Response model for custom MCP connection"""
success: bool
qualifiedName: str
displayName: str
tools: list[Dict[str, Any]]
config: Dict[str, Any]
url: str
message: str
@router.get("/mcp/servers", response_model=MCPServerListResponse)
async def list_mcp_servers(
q: Optional[str] = Query(None, description="Search query for semantic search"),
page: int = Query(1, ge=1, description="Page number"),
pageSize: int = Query(20, ge=1, le=100, description="Items per page"),
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""
List available MCP servers from Smithery.
Query parameters:
- q: Search query (semantic search)
- page: Page number (default: 1)
- pageSize: Number of items per page (default: 20, max: 100)
Example queries:
- "machine learning" - semantic search
- "owner:smithery-ai" - filter by owner
- "repo:fetch" - filter by repository
- "is:deployed" - only deployed servers
- "is:verified" - only verified servers
"""
logger.info(f"Fetching MCP servers from Smithery for user {user_id} with query: {q}")
try:
async with httpx.AsyncClient() as client:
headers = {
"Accept": "application/json",
"User-Agent": "Suna-MCP-Integration/1.0"
}
# Add API key if available
if SMITHERY_API_KEY:
headers["Authorization"] = f"Bearer {SMITHERY_API_KEY}"
logger.debug("Using Smithery API key for authentication")
else:
logger.warning("No Smithery API key found in environment variables")
params = {
"page": page,
"pageSize": pageSize
}
if q:
params["q"] = q
response = await client.get(
f"{SMITHERY_API_BASE_URL}/servers",
headers=headers,
params=params,
timeout=30.0
)
if response.status_code == 401:
logger.warning("Smithery API authentication failed. API key may be required.")
# Continue without auth - public servers should still be accessible
response.raise_for_status()
data = response.json()
logger.info(f"Successfully fetched {len(data.get('servers', []))} MCP servers")
return MCPServerListResponse(**data)
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error fetching MCP servers: {e.response.status_code} - {e.response.text}")
raise HTTPException(
status_code=e.response.status_code,
detail=f"Failed to fetch MCP servers from Smithery: {e.response.text}"
)
except Exception as e:
logger.error(f"Error fetching MCP servers: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Failed to fetch MCP servers: {str(e)}"
)
@router.get("/mcp/servers/{qualified_name:path}", response_model=MCPServerDetailResponse)
async def get_mcp_server_details(
qualified_name: str,
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""
Get detailed information about a specific MCP server.
Parameters:
- qualified_name: The unique identifier for the server (e.g., "exa", "@smithery-ai/github")
"""
logger.info(f"Fetching details for MCP server: {qualified_name} for user {user_id}")
try:
async with httpx.AsyncClient() as client:
headers = {
"Accept": "application/json",
"User-Agent": "Suna-MCP-Integration/1.0"
}
# Add API key if available
if SMITHERY_API_KEY:
headers["Authorization"] = f"Bearer {SMITHERY_API_KEY}"
# URL encode the qualified name only if it contains special characters
if '@' in qualified_name or '/' in qualified_name:
encoded_name = quote(qualified_name, safe='')
else:
# Don't encode simple names like "exa"
encoded_name = qualified_name
url = f"{SMITHERY_API_BASE_URL}/servers/{encoded_name}"
logger.debug(f"Requesting MCP server details from: {url}")
response = await client.get(
url, # Use registry API for metadata
headers=headers,
timeout=30.0
)
logger.debug(f"Response status: {response.status_code}")
response.raise_for_status()
data = response.json()
logger.info(f"Successfully fetched details for MCP server: {qualified_name}")
logger.debug(f"Response data keys: {list(data.keys()) if isinstance(data, dict) else 'not a dict'}")
return MCPServerDetailResponse(**data)
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
logger.error(f"Server not found. Response: {e.response.text}")
raise HTTPException(status_code=404, detail=f"MCP server '{qualified_name}' not found")
logger.error(f"HTTP error fetching MCP server details: {e.response.status_code} - {e.response.text}")
raise HTTPException(
status_code=e.response.status_code,
detail=f"Failed to fetch MCP server details: {e.response.text}"
)
except Exception as e:
logger.error(f"Error fetching MCP server details: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Failed to fetch MCP server details: {str(e)}"
)
@router.get("/mcp/popular-servers")
async def get_popular_mcp_servers(
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""
Get a curated list of popular/recommended MCP servers.
This is a convenience endpoint that returns commonly used servers.
"""
# Define some popular servers based on actual Smithery data
popular_servers = [
{
"qualifiedName": "@wonderwhy-er/desktop-commander",
"displayName": "Desktop Commander",
"description": "Execute terminal commands and manage files with diff editing capabilities. Coding, shell and terminal, task automation",
"icon": "💻",
"category": "development"
},
{
"qualifiedName": "@smithery-ai/server-sequential-thinking",
"displayName": "Sequential Thinking",
"description": "Dynamic and reflective problem-solving through a structured thinking process",
"icon": "🧠",
"category": "ai"
},
{
"qualifiedName": "@microsoft/playwright-mcp",
"displayName": "Playwright Automation",
"description": "Automate web interactions, navigate, extract data, and perform actions on web pages",
"icon": "🎭",
"category": "automation"
},
{
"qualifiedName": "exa",
"displayName": "Exa Search",
"description": "Fast, intelligent web search and crawling. Combines embeddings and traditional search",
"icon": "🔍",
"category": "search"
},
{
"qualifiedName": "@smithery-ai/github",
"displayName": "GitHub",
"description": "Access the GitHub API, enabling file operations, repository management, and search",
"icon": "🐙",
"category": "development"
},
{
"qualifiedName": "@nickclyde/duckduckgo-mcp-server",
"displayName": "DuckDuckGo Search",
"description": "Enable web search capabilities through DuckDuckGo. Fetch and parse webpage content",
"icon": "🦆",
"category": "search"
}
]
return {"servers": popular_servers}
@router.get("/mcp/popular-servers/v2", response_model=PopularServersV2Response)
async def get_popular_mcp_servers_v2(
page: int = Query(1, ge=1, description="Page number"),
pageSize: int = Query(100, ge=1, le=200, description="Items per page (max 500 for comprehensive categorization)"),
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""
Get a comprehensive categorized list of popular MCP servers from Smithery Registry.
Returns servers grouped by category with proper metadata and usage statistics.
This endpoint fetches real data from the Smithery registry API and categorizes it.
Query parameters:
- page: Page number (default: 1)
- pageSize: Number of items per page (default: 200, max: 500)
"""
logger.info(f"Fetching v2 popular MCP servers for user {user_id}")
try:
async with httpx.AsyncClient() as client:
headers = {
"Accept": "application/json",
"User-Agent": "Suna-MCP-Integration/1.0"
}
# Add API key if available
if SMITHERY_API_KEY:
headers["Authorization"] = f"Bearer {SMITHERY_API_KEY}"
logger.debug("Using Smithery API key for authentication")
else:
logger.warning("No Smithery API key found in environment variables")
# Use provided pagination parameters
params = {
"page": page,
"pageSize": pageSize
}
response = await client.get(
f"{SMITHERY_API_BASE_URL}/servers",
headers=headers,
params=params,
timeout=30.0
)
if response.status_code != 200:
logger.error(f"Failed to fetch MCP servers: {response.status_code} - {response.text}")
return PopularServersV2Response(
success=False,
servers=[],
categorized={},
total=0,
categoryCount=0,
pagination={"currentPage": page, "pageSize": pageSize, "totalPages": 0, "totalCount": 0}
)
data = response.json()
servers = data.get("servers", [])
pagination_data = data.get("pagination", {})
# Category mappings based on server types and names
category_mappings = {
# AI & Search
"exa": "AI & Search",
"perplexity": "AI & Search",
"openai": "AI & Search",
"anthropic": "AI & Search",
"duckduckgo": "AI & Search",
"brave": "AI & Search",
"google": "AI & Search",
"search": "AI & Search",
# Development & Version Control
"github": "Development & Version Control",
"gitlab": "Development & Version Control",
"bitbucket": "Development & Version Control",
"git": "Development & Version Control",
# Communication & Collaboration
"slack": "Communication & Collaboration",
"discord": "Communication & Collaboration",
"teams": "Communication & Collaboration",
"zoom": "Communication & Collaboration",
"telegram": "Communication & Collaboration",
# Project Management
"linear": "Project Management",
"jira": "Project Management",
"asana": "Project Management",
"notion": "Project Management",
"trello": "Project Management",
"monday": "Project Management",
"clickup": "Project Management",
# Data & Analytics
"postgres": "Data & Analytics",
"mysql": "Data & Analytics",
"mongodb": "Data & Analytics",
"bigquery": "Data & Analytics",
"snowflake": "Data & Analytics",
"sqlite": "Data & Analytics",
"redis": "Data & Analytics",
"database": "Data & Analytics",
# Cloud & Infrastructure
"aws": "Cloud & Infrastructure",
"gcp": "Cloud & Infrastructure",
"azure": "Cloud & Infrastructure",
"vercel": "Cloud & Infrastructure",
"netlify": "Cloud & Infrastructure",
"cloudflare": "Cloud & Infrastructure",
"docker": "Cloud & Infrastructure",
# File Storage
"gdrive": "File Storage",
"google-drive": "File Storage",
"dropbox": "File Storage",
"box": "File Storage",
"onedrive": "File Storage",
"s3": "File Storage",
"drive": "File Storage",
# Customer Support
"zendesk": "Customer Support",
"intercom": "Customer Support",
"freshdesk": "Customer Support",
"helpscout": "Customer Support",
# Marketing & Sales
"hubspot": "Marketing & Sales",
"salesforce": "Marketing & Sales",
"mailchimp": "Marketing & Sales",
"sendgrid": "Marketing & Sales",
# Finance
"stripe": "Finance",
"quickbooks": "Finance",
"xero": "Finance",
"plaid": "Finance",
# Automation & Productivity
"playwright": "Automation & Productivity",
"puppeteer": "Automation & Productivity",
"selenium": "Automation & Productivity",
"desktop-commander": "Automation & Productivity",
"sequential-thinking": "Automation & Productivity",
"automation": "Automation & Productivity",
# Utilities
"filesystem": "Utilities",
"memory": "Utilities",
"fetch": "Utilities",
"time": "Utilities",
"weather": "Utilities",
"currency": "Utilities",
"file": "Utilities",
}
# Categorize servers
categorized_servers = {}
for server in servers:
qualified_name = server.get("qualifiedName", "")
display_name = server.get("displayName", server.get("name", "Unknown"))
description = server.get("description", "")
# Determine category based on qualified name and description
category = "Other"
qualified_lower = qualified_name.lower()
description_lower = description.lower()
# Check qualified name first (most reliable)
for key, cat in category_mappings.items():
if key in qualified_lower:
category = cat
break
# If no match found, check description for category hints
if category == "Other":
for key, cat in category_mappings.items():
if key in description_lower:
category = cat
break
if category not in categorized_servers:
categorized_servers[category] = []
categorized_servers[category].append({
"name": display_name,
"qualifiedName": qualified_name,
"description": description,
"iconUrl": server.get("iconUrl"),
"homepage": server.get("homepage"),
"useCount": server.get("useCount", 0),
"createdAt": server.get("createdAt"),
"isDeployed": server.get("isDeployed", False)
})
# Sort categories and servers within each category
sorted_categories = OrderedDict()
# Define priority order for categories
priority_categories = [
"AI & Search",
"Development & Version Control",
"Automation & Productivity",
"Communication & Collaboration",
"Project Management",
"Data & Analytics",
"Cloud & Infrastructure",
"File Storage",
"Marketing & Sales",
"Customer Support",
"Finance",
"Utilities",
"Other"
]
# Add categories in priority order
for cat in priority_categories:
if cat in categorized_servers:
sorted_categories[cat] = sorted(
categorized_servers[cat],
key=lambda x: (-x.get("useCount", 0), x["name"].lower()) # Sort by useCount desc, then name
)
# Add any remaining categories
for cat in sorted(categorized_servers.keys()):
if cat not in sorted_categories:
sorted_categories[cat] = sorted(
categorized_servers[cat],
key=lambda x: (-x.get("useCount", 0), x["name"].lower())
)
logger.info(f"Successfully categorized {len(servers)} servers into {len(sorted_categories)} categories")
return PopularServersV2Response(
success=True,
servers=servers,
categorized=sorted_categories,
total=pagination_data.get("totalCount", len(servers)),
categoryCount=len(sorted_categories),
pagination={
"currentPage": pagination_data.get("currentPage", page),
"pageSize": pagination_data.get("pageSize", pageSize),
"totalPages": pagination_data.get("totalPages", 1),
"totalCount": pagination_data.get("totalCount", len(servers))
}
)
except Exception as e:
logger.error(f"Error fetching v2 popular MCP servers: {str(e)}")
return PopularServersV2Response(
success=False,
servers=[],
categorized={},
total=0,
categoryCount=0,
pagination={"currentPage": page, "pageSize": pageSize, "totalPages": 0, "totalCount": 0}
)