suna/backend/mcp_local/secure_api.py

691 lines
29 KiB
Python

"""
Secure MCP API endpoints
This module provides API endpoints for the secure MCP credential architecture:
1. Credential management (store, retrieve, test, delete)
2. Template management (create, publish, install)
3. Agent instance management
4. Marketplace operations with security
"""
from fastapi import APIRouter, HTTPException, Depends
from typing import List, Optional, Dict, Any
from pydantic import BaseModel, validator
import asyncio
import urllib.parse
from utils.logger import logger
from utils.auth_utils import get_current_user_id_from_jwt
from .credential_manager import credential_manager, MCPCredential
from .template_manager import template_manager
router = APIRouter()
class StoreCredentialRequest(BaseModel):
"""Request model for storing MCP credentials"""
mcp_qualified_name: str
display_name: str
config: Dict[str, Any]
@validator('config')
def validate_config_not_empty(cls, v):
if not v:
raise ValueError('Config cannot be empty')
return v
class StoreCredentialProfileRequest(BaseModel):
"""Request model for storing MCP credential profiles"""
mcp_qualified_name: str
profile_name: str
display_name: str
config: Dict[str, Any]
is_default: bool = False
@validator('config')
def validate_config_not_empty(cls, v):
if not v:
raise ValueError('Config cannot be empty')
return v
class CredentialResponse(BaseModel):
"""Response model for MCP credentials (without sensitive data)"""
credential_id: str
mcp_qualified_name: str
display_name: str
config_keys: List[str]
is_active: bool
last_used_at: Optional[str]
created_at: str
updated_at: str
class CredentialProfileResponse(BaseModel):
"""Response model for MCP credential profiles (without sensitive data)"""
profile_id: str
mcp_qualified_name: str
profile_name: str
display_name: str
config_keys: List[str]
is_active: bool
is_default: bool
last_used_at: Optional[str]
created_at: str
updated_at: str
class SetDefaultProfileRequest(BaseModel):
"""Request model for setting default profile"""
profile_id: str
class TestCredentialResponse(BaseModel):
"""Response model for credential testing"""
success: bool
message: str
error_details: Optional[str] = None
class CreateTemplateRequest(BaseModel):
"""Request model for creating agent template"""
agent_id: str
make_public: bool = False
tags: Optional[List[str]] = None
class InstallTemplateRequest(BaseModel):
"""Request model for installing template"""
template_id: str
instance_name: Optional[str] = None
custom_system_prompt: Optional[str] = None
profile_mappings: Optional[Dict[str, str]] = None
custom_mcp_configs: Optional[Dict[str, Dict[str, Any]]] = None
class PublishTemplateRequest(BaseModel):
"""Request model for publishing template"""
tags: Optional[List[str]] = None
class TemplateResponse(BaseModel):
"""Response model for agent templates"""
template_id: str
name: str
description: Optional[str]
mcp_requirements: List[Dict[str, Any]]
agentpress_tools: Dict[str, Any]
tags: List[str]
is_public: bool
download_count: int
marketplace_published_at: Optional[str]
created_at: str
creator_name: Optional[str] = None
avatar: Optional[str]
avatar_color: Optional[str]
is_kortix_team: Optional[bool] = False
class InstallationResponse(BaseModel):
"""Response model for template installation"""
status: str # 'installed', 'configs_required'
instance_id: Optional[str] = None
missing_regular_credentials: Optional[List[Dict[str, Any]]] = None
missing_custom_configs: Optional[List[Dict[str, Any]]] = None
template: Optional[Dict[str, Any]] = None
@router.post("/credentials", response_model=CredentialResponse)
async def store_mcp_credential(
request: StoreCredentialRequest,
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""Store encrypted MCP credentials for the current user"""
logger.info(f"Storing credential for {request.mcp_qualified_name} for user {user_id}")
try:
credential_id = await credential_manager.store_credential(
account_id=user_id,
mcp_qualified_name=request.mcp_qualified_name,
display_name=request.display_name,
config=request.config
)
# Return credential info without sensitive data
credential = await credential_manager.get_credential(user_id, request.mcp_qualified_name)
if not credential:
raise HTTPException(status_code=500, detail="Failed to retrieve stored credential")
return CredentialResponse(
credential_id=credential.credential_id,
mcp_qualified_name=credential.mcp_qualified_name,
display_name=credential.display_name,
config_keys=list(credential.config.keys()),
is_active=credential.is_active,
last_used_at=credential.last_used_at.isoformat() if credential.last_used_at and hasattr(credential.last_used_at, 'isoformat') else (str(credential.last_used_at) if credential.last_used_at else None),
created_at=credential.created_at.isoformat() if credential.created_at and hasattr(credential.created_at, 'isoformat') else (str(credential.created_at) if credential.created_at else ""),
updated_at=credential.updated_at.isoformat() if credential.updated_at and hasattr(credential.updated_at, 'isoformat') else (str(credential.updated_at) if credential.updated_at else "")
)
except Exception as e:
logger.error(f"Error storing credential: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to store credential: {str(e)}")
@router.get("/credentials", response_model=List[CredentialResponse])
async def get_user_credentials(
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""Get all MCP credentials for the current user"""
logger.info(f"Getting credentials for user {user_id}")
try:
credentials = await credential_manager.get_user_credentials(user_id)
logger.debug(f"Found {len(credentials)} credentials for user {user_id}")
for cred in credentials:
logger.debug(f"Credential: '{cred.mcp_qualified_name}' (ID: {cred.credential_id})")
return [
CredentialResponse(
credential_id=cred.credential_id,
mcp_qualified_name=cred.mcp_qualified_name,
display_name=cred.display_name,
config_keys=list(cred.config.keys()),
is_active=cred.is_active,
last_used_at=cred.last_used_at.isoformat() if cred.last_used_at and hasattr(cred.last_used_at, 'isoformat') else (str(cred.last_used_at) if cred.last_used_at else None),
created_at=cred.created_at.isoformat() if cred.created_at and hasattr(cred.created_at, 'isoformat') else (str(cred.created_at) if cred.created_at else ""),
updated_at=cred.updated_at.isoformat() if cred.updated_at and hasattr(cred.updated_at, 'isoformat') else (str(cred.updated_at) if cred.updated_at else "")
)
for cred in credentials
]
except Exception as e:
logger.error(f"Error getting user credentials: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to get credentials: {str(e)}")
@router.post("/credentials/{mcp_qualified_name:path}/test", response_model=TestCredentialResponse)
async def test_mcp_credential(
mcp_qualified_name: str,
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""Test if an MCP credential is valid by attempting to connect"""
# URL decode the mcp_qualified_name to handle special characters like @
decoded_name = urllib.parse.unquote(mcp_qualified_name)
logger.info(f"Testing credential for '{decoded_name}' (raw: '{mcp_qualified_name}') for user {user_id}")
try:
success = await credential_manager.test_credential(user_id, decoded_name)
return TestCredentialResponse(
success=success,
message="Connection successful" if success else "Connection failed",
error_details=None if success else "Unable to connect with provided credentials"
)
except Exception as e:
logger.error(f"Error testing credential: {str(e)}")
return TestCredentialResponse(
success=False,
message="Test failed",
error_details=str(e)
)
@router.delete("/credentials/{mcp_qualified_name:path}")
async def delete_mcp_credential(
mcp_qualified_name: str,
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""Delete (deactivate) an MCP credential"""
# URL decode the mcp_qualified_name to handle special characters like @
decoded_name = urllib.parse.unquote(mcp_qualified_name)
logger.info(f"Deleting credential for '{decoded_name}' (raw: '{mcp_qualified_name}') for user {user_id}")
try:
# First check if the credential exists
existing_credential = await credential_manager.get_credential(user_id, decoded_name)
if not existing_credential:
logger.warning(f"Credential not found: '{decoded_name}' for user {user_id}")
raise HTTPException(status_code=404, detail=f"Credential not found: {decoded_name}")
success = await credential_manager.delete_credential(user_id, decoded_name)
if not success:
logger.error(f"Failed to delete credential: '{decoded_name}' for user {user_id}")
raise HTTPException(status_code=404, detail="Credential not found")
logger.info(f"Successfully deleted credential: '{decoded_name}' for user {user_id}")
return {"message": "Credential deleted successfully"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error deleting credential '{decoded_name}': {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to delete credential: {str(e)}")
@router.post("/credential-profiles", response_model=CredentialProfileResponse)
async def store_credential_profile(
request: StoreCredentialProfileRequest,
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""Store a named credential profile for an MCP server"""
logger.info(f"Storing credential profile '{request.profile_name}' for {request.mcp_qualified_name} for user {user_id}")
try:
profile_id = await credential_manager.store_credential_profile(
account_id=user_id,
mcp_qualified_name=request.mcp_qualified_name,
profile_name=request.profile_name,
display_name=request.display_name,
config=request.config,
is_default=request.is_default
)
# Return profile info without sensitive data
profile = await credential_manager.get_credential_by_profile(user_id, profile_id)
if not profile:
raise HTTPException(status_code=500, detail="Failed to retrieve stored credential profile")
return CredentialProfileResponse(
profile_id=profile.profile_id,
mcp_qualified_name=profile.mcp_qualified_name,
profile_name=profile.profile_name,
display_name=profile.display_name,
config_keys=list(profile.config.keys()),
is_active=profile.is_active,
is_default=profile.is_default,
last_used_at=profile.last_used_at.isoformat() if profile.last_used_at and hasattr(profile.last_used_at, 'isoformat') else (str(profile.last_used_at) if profile.last_used_at else None),
created_at=profile.created_at.isoformat() if profile.created_at and hasattr(profile.created_at, 'isoformat') else (str(profile.created_at) if profile.created_at else ""),
updated_at=profile.updated_at.isoformat() if profile.updated_at and hasattr(profile.updated_at, 'isoformat') else (str(profile.updated_at) if profile.updated_at else "")
)
except Exception as e:
logger.error(f"Error storing credential profile: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to store credential profile: {str(e)}")
@router.get("/credential-profiles", response_model=List[CredentialProfileResponse])
async def get_all_user_credential_profiles(
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""Get all credential profiles for the current user across all MCP servers"""
logger.info(f"Getting all credential profiles for user {user_id}")
try:
profiles = await credential_manager.get_all_user_credential_profiles(user_id)
return [
CredentialProfileResponse(
profile_id=profile.profile_id,
mcp_qualified_name=profile.mcp_qualified_name,
profile_name=profile.profile_name,
display_name=profile.display_name,
config_keys=list(profile.config.keys()),
is_active=profile.is_active,
is_default=profile.is_default,
last_used_at=profile.last_used_at.isoformat() if profile.last_used_at and hasattr(profile.last_used_at, 'isoformat') else (str(profile.last_used_at) if profile.last_used_at else None),
created_at=profile.created_at.isoformat() if profile.created_at and hasattr(profile.created_at, 'isoformat') else (str(profile.created_at) if profile.created_at else ""),
updated_at=profile.updated_at.isoformat() if profile.updated_at and hasattr(profile.updated_at, 'isoformat') else (str(profile.updated_at) if profile.updated_at else "")
)
for profile in profiles
]
except Exception as e:
logger.error(f"Error getting user credential profiles: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to get credential profiles: {str(e)}")
@router.get("/credential-profiles/{mcp_qualified_name:path}", response_model=List[CredentialProfileResponse])
async def get_credential_profiles_for_mcp(
mcp_qualified_name: str,
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""Get all credential profiles for a specific MCP server"""
decoded_name = urllib.parse.unquote(mcp_qualified_name)
logger.info(f"Getting credential profiles for '{decoded_name}' for user {user_id}")
try:
profiles = await credential_manager.get_credential_profiles(user_id, decoded_name)
return [
CredentialProfileResponse(
profile_id=profile.profile_id,
mcp_qualified_name=profile.mcp_qualified_name,
profile_name=profile.profile_name,
display_name=profile.display_name,
config_keys=list(profile.config.keys()),
is_active=profile.is_active,
is_default=profile.is_default,
last_used_at=profile.last_used_at.isoformat() if profile.last_used_at and hasattr(profile.last_used_at, 'isoformat') else (str(profile.last_used_at) if profile.last_used_at else None),
created_at=profile.created_at.isoformat() if profile.created_at and hasattr(profile.created_at, 'isoformat') else (str(profile.created_at) if profile.created_at else ""),
updated_at=profile.updated_at.isoformat() if profile.updated_at and hasattr(profile.updated_at, 'isoformat') else (str(profile.updated_at) if profile.updated_at else "")
)
for profile in profiles
]
except Exception as e:
logger.error(f"Error getting credential profiles for {decoded_name}: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to get credential profiles: {str(e)}")
@router.get("/credential-profiles/profile/{profile_id}", response_model=CredentialProfileResponse)
async def get_credential_profile_by_id(
profile_id: str,
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""Get a specific credential profile by its ID"""
logger.info(f"Getting credential profile {profile_id} for user {user_id}")
try:
profile = await credential_manager.get_credential_by_profile(user_id, profile_id)
if not profile:
raise HTTPException(status_code=404, detail="Credential profile not found")
return CredentialProfileResponse(
profile_id=profile.profile_id,
mcp_qualified_name=profile.mcp_qualified_name,
profile_name=profile.profile_name,
display_name=profile.display_name,
config_keys=list(profile.config.keys()),
is_active=profile.is_active,
is_default=profile.is_default,
last_used_at=profile.last_used_at.isoformat() if profile.last_used_at and hasattr(profile.last_used_at, 'isoformat') else (str(profile.last_used_at) if profile.last_used_at else None),
created_at=profile.created_at.isoformat() if profile.created_at and hasattr(profile.created_at, 'isoformat') else (str(profile.created_at) if profile.created_at else ""),
updated_at=profile.updated_at.isoformat() if profile.updated_at and hasattr(profile.updated_at, 'isoformat') else (str(profile.updated_at) if profile.updated_at else "")
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting credential profile {profile_id}: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to get credential profile: {str(e)}")
@router.put("/credential-profiles/{profile_id}/set-default")
async def set_default_credential_profile(
profile_id: str,
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""Set a credential profile as the default for its MCP server"""
logger.info(f"Setting credential profile {profile_id} as default for user {user_id}")
try:
success = await credential_manager.set_default_profile(user_id, profile_id)
if not success:
raise HTTPException(status_code=404, detail="Credential profile not found")
return {"message": "Default profile set successfully"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error setting default profile {profile_id}: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to set default profile: {str(e)}")
@router.delete("/credential-profiles/{profile_id}")
async def delete_credential_profile(
profile_id: str,
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""Delete (deactivate) a credential profile"""
logger.info(f"Deleting credential profile {profile_id} for user {user_id}")
try:
success = await credential_manager.delete_credential_profile(user_id, profile_id)
if not success:
raise HTTPException(status_code=404, detail="Credential profile not found")
return {"message": "Credential profile deleted successfully"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error deleting credential profile {profile_id}: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to delete credential profile: {str(e)}")
# =====================================================
# TEMPLATE MANAGEMENT ENDPOINTS
# =====================================================
@router.post("/templates", response_model=Dict[str, str])
async def create_agent_template(
request: CreateTemplateRequest,
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""Create an agent template from an existing agent"""
logger.info(f"Creating template from agent {request.agent_id} for user {user_id}")
try:
template_id = await template_manager.create_template_from_agent(
agent_id=request.agent_id,
creator_id=user_id,
make_public=request.make_public,
tags=request.tags
)
return {
"template_id": template_id,
"message": "Template created successfully"
}
except Exception as e:
logger.error(f"Error creating template: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to create template: {str(e)}")
@router.post("/templates/{template_id}/publish")
async def publish_template(
template_id: str,
request: PublishTemplateRequest,
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""Publish a template to the marketplace"""
logger.info(f"Publishing template {template_id} for user {user_id}")
try:
success = await template_manager.publish_template(
template_id=template_id,
creator_id=user_id,
tags=request.tags
)
if not success:
raise HTTPException(status_code=404, detail="Template not found or access denied")
return {"message": "Template published to marketplace successfully"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error publishing template: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to publish template: {str(e)}")
@router.post("/templates/{template_id}/unpublish")
async def unpublish_template(
template_id: str,
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""Unpublish a template from the marketplace"""
logger.info(f"Unpublishing template {template_id} for user {user_id}")
try:
success = await template_manager.unpublish_template(
template_id=template_id,
creator_id=user_id
)
if not success:
raise HTTPException(status_code=404, detail="Template not found or access denied")
return {"message": "Template unpublished from marketplace successfully"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error unpublishing template: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to unpublish template: {str(e)}")
@router.post("/templates/install", response_model=InstallationResponse)
async def install_template(
request: InstallTemplateRequest,
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""Install a template as an agent instance"""
logger.info(f"Installing template {request.template_id} for user {user_id}")
try:
result = await template_manager.install_template(
template_id=request.template_id,
account_id=user_id,
instance_name=request.instance_name,
custom_system_prompt=request.custom_system_prompt,
profile_mappings=request.profile_mappings,
custom_mcp_configs=request.custom_mcp_configs
)
return InstallationResponse(**result)
except Exception as e:
logger.error(f"Error installing template: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to install template: {str(e)}")
@router.get("/templates/marketplace", response_model=List[TemplateResponse])
async def get_marketplace_templates(
limit: int = 50,
offset: int = 0,
search: Optional[str] = None,
tags: Optional[str] = None, # Comma-separated tags
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""Get public templates from the marketplace"""
logger.info(f"Getting marketplace templates for user {user_id}")
try:
tag_list = None
if tags:
tag_list = [tag.strip() for tag in tags.split(',') if tag.strip()]
templates = await template_manager.get_marketplace_templates(
limit=limit,
offset=offset,
search=search,
tags=tag_list
)
print("templates", templates)
return [TemplateResponse(**template) for template in templates]
except Exception as e:
logger.error(f"Error getting marketplace templates: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to get marketplace templates: {str(e)}")
@router.get("/templates/my", response_model=List[TemplateResponse])
async def get_my_templates(
limit: int = 50,
offset: int = 0,
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""Get all templates created by the current user"""
logger.info(f"Getting user templates for user {user_id}")
try:
templates = await template_manager.get_user_templates(
creator_id=user_id,
limit=limit,
offset=offset
)
return [TemplateResponse(**template) for template in templates]
except Exception as e:
logger.error(f"Error getting user templates: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to get user templates: {str(e)}")
@router.get("/templates/{template_id}", response_model=TemplateResponse)
async def get_template_details(
template_id: str,
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""Get detailed information about a specific template"""
logger.info(f"Getting template {template_id} details for user {user_id}")
try:
template = await template_manager.get_template(template_id)
if not template:
raise HTTPException(status_code=404, detail="Template not found")
# Check access permissions
if not template.is_public and template.creator_id != user_id:
raise HTTPException(status_code=403, detail="Access denied to private template")
return TemplateResponse(
template_id=template.template_id,
name=template.name,
description=template.description,
mcp_requirements=[
{
'qualified_name': req.qualified_name,
'display_name': req.display_name,
'enabled_tools': req.enabled_tools,
'required_config': req.required_config
}
for req in template.mcp_requirements
],
agentpress_tools=template.agentpress_tools,
tags=template.tags,
is_public=template.is_public,
download_count=template.download_count,
marketplace_published_at=template.marketplace_published_at.isoformat() if template.marketplace_published_at else None,
created_at=template.created_at.isoformat() if template.created_at else "",
avatar=template.avatar,
avatar_color=template.avatar_color
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting template details: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to get template details: {str(e)}")
# =====================================================
# AGENT INSTANCE ENDPOINTS
# =====================================================
@router.get("/instances/{instance_id}/config")
async def get_agent_runtime_config(
instance_id: str,
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""Get complete runtime configuration for an agent instance"""
logger.info(f"Getting runtime config for instance {instance_id} for user {user_id}")
try:
config = await template_manager.build_runtime_agent_config(instance_id)
# Verify ownership
if config['account_id'] != user_id:
raise HTTPException(status_code=403, detail="Access denied")
return config
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting runtime config: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to get runtime config: {str(e)}")
# =====================================================
# MIGRATION ENDPOINTS
# =====================================================
@router.post("/migrate/agent/{agent_id}")
async def migrate_agent_to_secure_architecture(
agent_id: str,
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""
Migrate an existing agent to the secure architecture by:
1. Extracting and storing credentials securely
2. Creating a template
3. Creating an agent instance
"""
logger.info(f"Migrating agent {agent_id} to secure architecture for user {user_id}")
try:
# This would be implemented to handle migration of existing agents
# For now, return a placeholder response
return {
"message": "Migration functionality will be implemented in the next phase",
"agent_id": agent_id,
"status": "pending"
}
except Exception as e:
logger.error(f"Error migrating agent: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to migrate agent: {str(e)}")