templates wip

This commit is contained in:
marko-kraemer 2025-07-06 18:17:29 +02:00
parent abe835260c
commit 9c00c04c63
11 changed files with 583 additions and 978 deletions

View File

@ -1,4 +1,4 @@
from fastapi import FastAPI, Request, HTTPException, Response, Depends
from fastapi import FastAPI, Request, HTTPException, Response, Depends, APIRouter
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
import sentry
@ -98,7 +98,7 @@ async def log_requests_middleware(request: Request, call_next):
request_id = str(uuid.uuid4())
start_time = time.time()
client_ip = request.client.host
client_ip = request.client.host if request.client else "unknown"
method = request.method
path = request.url.path
query_params = str(request.query_params)
@ -147,32 +147,36 @@ app.add_middleware(
allow_headers=["Content-Type", "Authorization", "X-Project-Id"],
)
app.include_router(agent_api.router, prefix="/api")
# Create a main API router
api_router = APIRouter()
app.include_router(sandbox_api.router, prefix="/api")
app.include_router(billing_api.router, prefix="/api")
app.include_router(feature_flags_api.router, prefix="/api")
# Include all API routers without individual prefixes
api_router.include_router(agent_api.router)
api_router.include_router(sandbox_api.router)
api_router.include_router(billing_api.router)
api_router.include_router(feature_flags_api.router)
from mcp_service import api as mcp_api
from mcp_service import secure_api as secure_mcp_api
from mcp_service import template_api as template_api
app.include_router(mcp_api.router, prefix="/api")
app.include_router(secure_mcp_api.router, prefix="/api/secure-mcp")
api_router.include_router(mcp_api.router)
api_router.include_router(secure_mcp_api.router, prefix="/secure-mcp")
api_router.include_router(template_api.router, prefix="/templates")
app.include_router(transcription_api.router, prefix="/api")
app.include_router(email_api.router, prefix="/api")
api_router.include_router(transcription_api.router)
api_router.include_router(email_api.router)
from knowledge_base import api as knowledge_base_api
app.include_router(knowledge_base_api.router, prefix="/api")
api_router.include_router(knowledge_base_api.router)
from triggers import api as triggers_api
from triggers import unified_oauth_api
app.include_router(triggers_api.router)
app.include_router(unified_oauth_api.router)
api_router.include_router(triggers_api.router)
api_router.include_router(unified_oauth_api.router)
@app.get("/api/health")
# Add health check to API router
@api_router.get("/health")
async def health_check():
"""Health check endpoint to verify API is working."""
logger.info("Health check endpoint called")
@ -182,6 +186,9 @@ async def health_check():
"instance_id": instance_id
}
# Include the main API router with /api prefix
app.include_router(api_router, prefix="/api")
if __name__ == "__main__":
import uvicorn

View File

@ -327,51 +327,6 @@ class CredentialManager:
logger.error(f"Error deleting credential for {mcp_qualified_name}: {str(e)}")
return False
async def test_credential(self, account_id: str, mcp_qualified_name: str) -> bool:
"""Test if a credential is valid by attempting to connect"""
try:
credential = await self.get_credential(account_id, mcp_qualified_name)
if not credential:
return False
# Import here to avoid circular imports
from .client import MCPManager
# Create a test MCP configuration
test_config = {
"name": credential.display_name,
"qualifiedName": credential.mcp_qualified_name,
"config": credential.config,
"enabledTools": [] # Empty for testing
}
# Try to connect
mcp_manager = MCPManager()
try:
await mcp_manager.connect_server(test_config)
await self._log_credential_usage(
credential.credential_id,
None,
"test_connection",
True
)
return True
except Exception as e:
await self._log_credential_usage(
credential.credential_id,
None,
"test_connection",
False,
str(e)
)
return False
finally:
await mcp_manager.disconnect_all()
except Exception as e:
logger.error(f"Error testing credential for {mcp_qualified_name}: {str(e)}")
return False
async def _log_credential_usage(
self,
credential_id: str,

View File

@ -3,9 +3,7 @@ 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
2. Credential profile management (create, set default, delete)
"""
from fastapi import APIRouter, HTTPException, Depends
@ -17,7 +15,6 @@ 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()
@ -81,48 +78,7 @@ class TestCredentialResponse(BaseModel):
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(
@ -192,33 +148,6 @@ async def get_user_credentials(
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,
@ -430,205 +359,4 @@ async def delete_credential_profile(
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)}")

View File

@ -1,469 +0,0 @@
"""
Secure MCP Client
This module provides a secure MCP client that:
1. Uses encrypted credentials from the credential manager
2. Builds runtime configurations from agent instances
3. Maintains backward compatibility with existing agents
4. Logs credential usage for auditing
"""
import asyncio
import json
import base64
from typing import Dict, List, Any, Optional, Tuple
from dataclasses import dataclass
# Import MCP components
from mcp import ClientSession
try:
from mcp.client.streamable_http import streamablehttp_client
except ImportError:
try:
from mcp.client import streamablehttp_client
except ImportError:
raise ImportError(
"Could not import streamablehttp_client. "
"Make sure you have installed mcp with: pip install 'mcp[cli]'"
)
try:
from mcp.types import Tool, CallToolResult as ToolResult
except ImportError:
try:
from mcp import types
Tool = types.Tool
ToolResult = types.CallToolResult
except AttributeError:
Tool = Any
ToolResult = Any
from utils.logger import logger
from .credential_manager import credential_manager
from .template_manager import template_manager
import os
# Get Smithery API key from environment
SMITHERY_API_KEY = os.getenv("SMITHERY_API_KEY")
SMITHERY_SERVER_BASE_URL = "https://server.smithery.ai"
@dataclass
class SecureMCPConnection:
"""Represents a secure connection to an MCP server"""
qualified_name: str
name: str
credential_id: str
enabled_tools: List[str]
session: Optional[ClientSession] = None
tools: Optional[List[Tool]] = None
class SecureMCPManager:
"""Manages secure connections to multiple MCP servers using encrypted credentials"""
def __init__(self):
self.connections: Dict[str, SecureMCPConnection] = {}
self._sessions: Dict[str, Tuple[Any, Any, Any]] = {}
async def connect_from_agent_instance(self, instance_id: str, account_id: str) -> None:
"""
Connect to all MCP servers for an agent instance using secure credentials
Args:
instance_id: ID of the agent instance
account_id: ID of the account (for verification)
"""
logger.info(f"Connecting to MCP servers for agent instance {instance_id}")
try:
# Get the runtime configuration
agent_config = await template_manager.build_runtime_agent_config(instance_id)
# Verify ownership
if agent_config['account_id'] != account_id:
raise ValueError("Access denied: not agent owner")
# Connect to each configured MCP
for mcp_config in agent_config.get('configured_mcps', []):
try:
await self._connect_secure_server(mcp_config, instance_id)
except Exception as e:
logger.error(f"Failed to connect to {mcp_config['qualifiedName']}: {str(e)}")
# Continue with other servers even if one fails
except Exception as e:
logger.error(f"Error connecting MCP servers for instance {instance_id}: {str(e)}")
raise
async def connect_from_legacy_agent(self, agent_config: Dict[str, Any]) -> None:
"""
Connect to MCP servers using legacy agent configuration (backward compatibility)
Args:
agent_config: Legacy agent configuration with configured_mcps
"""
logger.info(f"Connecting to MCP servers for legacy agent {agent_config.get('agent_id')}")
try:
# Connect to each configured MCP using the old method
for mcp_config in agent_config.get('configured_mcps', []):
try:
await self._connect_legacy_server(mcp_config)
except Exception as e:
logger.error(f"Failed to connect to {mcp_config['qualifiedName']}: {str(e)}")
# Continue with other servers even if one fails
except Exception as e:
logger.error(f"Error connecting MCP servers for legacy agent: {str(e)}")
raise
async def _connect_secure_server(self, mcp_config: Dict[str, Any], instance_id: str) -> SecureMCPConnection:
"""Connect to an MCP server using secure credentials"""
qualified_name = mcp_config["qualifiedName"]
# Check if already connected
if qualified_name in self.connections:
logger.info(f"MCP server {qualified_name} already connected")
return self.connections[qualified_name]
logger.info(f"Connecting to secure MCP server: {qualified_name}")
# Check if Smithery API key is available
if not SMITHERY_API_KEY:
raise ValueError(
"SMITHERY_API_KEY environment variable is not set. "
"Please set it to use MCP servers from Smithery."
)
try:
# Encode config in base64
config_json = json.dumps(mcp_config["config"])
config_b64 = base64.b64encode(config_json.encode()).decode()
# Create server URL
url = f"{SMITHERY_SERVER_BASE_URL}/{qualified_name}/mcp?config={config_b64}&api_key={SMITHERY_API_KEY}"
# Test connection and get available tools
async with streamablehttp_client(url) as (read_stream, write_stream, _):
async with ClientSession(read_stream, write_stream) as session:
# Initialize the connection
await session.initialize()
logger.info(f"Secure MCP session initialized for {qualified_name}")
# List available tools
tools_result = await session.list_tools()
tools = tools_result.tools if hasattr(tools_result, 'tools') else tools_result
logger.info(f"Available tools from {qualified_name}: {[t.name for t in tools]}")
# Create connection object (without persistent session)
connection = SecureMCPConnection(
qualified_name=qualified_name,
name=mcp_config["name"],
credential_id="", # We don't store credential_id in mcp_config anymore
enabled_tools=mcp_config.get("enabledTools", []),
session=None, # No persistent session
tools=tools
)
self.connections[qualified_name] = connection
# Log successful connection
await self._log_connection_usage(instance_id, qualified_name, True)
return connection
except Exception as e:
logger.error(f"Failed to connect to secure MCP server {qualified_name}: {str(e)}")
# Log failed connection
await self._log_connection_usage(instance_id, qualified_name, False, str(e))
raise
async def _connect_legacy_server(self, mcp_config: Dict[str, Any]) -> SecureMCPConnection:
"""Connect to an MCP server using legacy configuration (backward compatibility)"""
qualified_name = mcp_config["qualifiedName"]
# Check if already connected
if qualified_name in self.connections:
logger.info(f"Legacy MCP server {qualified_name} already connected")
return self.connections[qualified_name]
logger.info(f"Connecting to legacy MCP server: {qualified_name}")
# Check if Smithery API key is available
if not SMITHERY_API_KEY:
raise ValueError(
"SMITHERY_API_KEY environment variable is not set. "
"Please set it to use MCP servers from Smithery."
)
try:
# Encode config in base64
config_json = json.dumps(mcp_config["config"])
config_b64 = base64.b64encode(config_json.encode()).decode()
# Create server URL
url = f"{SMITHERY_SERVER_BASE_URL}/{qualified_name}/mcp?config={config_b64}&api_key={SMITHERY_API_KEY}"
# Test connection and get available tools
async with streamablehttp_client(url) as (read_stream, write_stream, _):
async with ClientSession(read_stream, write_stream) as session:
# Initialize the connection
await session.initialize()
logger.info(f"Legacy MCP session initialized for {qualified_name}")
# List available tools
tools_result = await session.list_tools()
tools = tools_result.tools if hasattr(tools_result, 'tools') else tools_result
logger.info(f"Available tools from legacy {qualified_name}: {[t.name for t in tools]}")
# Create connection object (without persistent session)
connection = SecureMCPConnection(
qualified_name=qualified_name,
name=mcp_config["name"],
credential_id="legacy",
enabled_tools=mcp_config.get("enabledTools", []),
session=None, # No persistent session
tools=tools
)
self.connections[qualified_name] = connection
return connection
except Exception as e:
logger.error(f"Failed to connect to legacy MCP server {qualified_name}: {str(e)}")
raise
def get_all_tools_openapi(self) -> List[Dict[str, Any]]:
"""
Convert all connected MCP tools to OpenAPI format for LLM
Returns a list of tool definitions in OpenAPI format
"""
all_tools = []
for conn in self.connections.values():
if not conn.tools:
continue
for tool in conn.tools:
# Skip tools that are not enabled
if conn.enabled_tools and tool.name not in conn.enabled_tools:
continue
# Convert MCP tool to OpenAPI format
openapi_tool = {
"name": f"mcp_{conn.qualified_name}_{tool.name}", # Prefix to avoid conflicts
"description": tool.description or f"MCP tool from {conn.name}",
"parameters": {
"type": "object",
"properties": {},
"required": []
}
}
# Convert input schema if available
if hasattr(tool, 'inputSchema') and tool.inputSchema:
schema = tool.inputSchema
if isinstance(schema, dict):
openapi_tool["parameters"]["properties"] = schema.get("properties", {})
openapi_tool["parameters"]["required"] = schema.get("required", [])
all_tools.append(openapi_tool)
return all_tools
async def execute_tool(self, tool_name: str, arguments: Dict[str, Any], instance_id: Optional[str] = None) -> Dict[str, Any]:
"""
Execute an MCP tool call with secure credential handling
Args:
tool_name: Name in format "mcp_{qualified_name}_{original_tool_name}"
arguments: Tool arguments
instance_id: Optional instance ID for logging
Returns:
Tool execution result
"""
# Parse the tool name to get server and original tool name
parts = tool_name.split("_", 2)
if len(parts) != 3 or parts[0] != "mcp":
raise ValueError(f"Invalid MCP tool name format: {tool_name}")
_, qualified_name, original_tool_name = parts
# Find the connection
if qualified_name not in self.connections:
raise ValueError(f"MCP server {qualified_name} not connected")
conn = self.connections[qualified_name]
logger.info(f"Executing secure MCP tool {original_tool_name} on server {qualified_name}")
# Check if Smithery API key is available
if not SMITHERY_API_KEY:
raise ValueError("SMITHERY_API_KEY environment variable is not set")
try:
# For secure connections, we need to get the config from the credential manager
# For now, we'll use a placeholder approach
# In a full implementation, we'd need to pass the account_id and get the credential
# Create fresh connection for this tool call
# This is a simplified approach - in production, you'd want to cache credentials
config = {} # This would be retrieved from credential manager
config_json = json.dumps(config)
config_b64 = base64.b64encode(config_json.encode()).decode()
url = f"{SMITHERY_SERVER_BASE_URL}/{qualified_name}/mcp?config={config_b64}&api_key={SMITHERY_API_KEY}"
# Use the documented pattern with proper context management
async with streamablehttp_client(url) as (read_stream, write_stream, _):
async with ClientSession(read_stream, write_stream) as session:
# Initialize the connection
await session.initialize()
# Call the tool
result = await session.call_tool(original_tool_name, arguments)
# Convert result to dict - handle MCP response properly
if hasattr(result, 'content'):
# Handle content which might be a list of TextContent objects
content = result.content
if isinstance(content, list):
# Extract text from TextContent objects
text_parts = []
for item in content:
if hasattr(item, 'text'):
text_parts.append(item.text)
elif hasattr(item, 'content'):
text_parts.append(str(item.content))
else:
text_parts.append(str(item))
content_str = "\n".join(text_parts)
elif hasattr(content, 'text'):
# Single TextContent object
content_str = content.text
elif hasattr(content, 'content'):
content_str = str(content.content)
else:
content_str = str(content)
is_error = getattr(result, 'isError', False)
else:
content_str = str(result)
is_error = False
# Log tool usage
await self._log_tool_usage(instance_id, qualified_name, original_tool_name, True)
return {
"content": content_str,
"isError": is_error
}
except Exception as e:
logger.error(f"Error executing secure MCP tool {tool_name}: {str(e)}")
# Log failed tool usage
await self._log_tool_usage(instance_id, qualified_name, original_tool_name, False, str(e))
return {
"content": f"Error executing tool: {str(e)}",
"isError": True
}
async def disconnect_all(self):
"""Disconnect all MCP servers (clear stored configurations)"""
for qualified_name in list(self.connections.keys()):
try:
del self.connections[qualified_name]
logger.info(f"Cleared secure MCP server configuration for {qualified_name}")
except Exception as e:
logger.error(f"Error clearing configuration for {qualified_name}: {str(e)}")
# Clear sessions dict
self._sessions.clear()
def get_tool_info(self, tool_name: str) -> Optional[Dict[str, Any]]:
"""Get information about a specific tool"""
parts = tool_name.split("_", 2)
if len(parts) != 3 or parts[0] != "mcp":
return None
_, qualified_name, original_tool_name = parts
if qualified_name not in self.connections:
return None
conn = self.connections[qualified_name]
if not conn.tools:
return None
for tool in conn.tools:
if tool.name == original_tool_name:
return {
"server": conn.name,
"qualified_name": qualified_name,
"original_name": tool.name,
"description": tool.description,
"enabled": not conn.enabled_tools or tool.name in conn.enabled_tools,
"credential_id": conn.credential_id
}
return None
async def _log_connection_usage(self, instance_id: str, qualified_name: str, success: bool, error_message: Optional[str] = None):
"""Log MCP connection usage for auditing"""
try:
# This would log to the credential_usage_log table
# For now, just log to the application logger
status = "SUCCESS" if success else "FAILED"
logger.info(f"MCP Connection {status}: instance={instance_id}, server={qualified_name}")
if error_message:
logger.error(f"Connection error: {error_message}")
except Exception as e:
logger.error(f"Failed to log connection usage: {e}")
async def _log_tool_usage(self, instance_id: Optional[str], qualified_name: str, tool_name: str, success: bool, error_message: Optional[str] = None):
"""Log MCP tool usage for auditing"""
try:
# This would log to the credential_usage_log table
# For now, just log to the application logger
status = "SUCCESS" if success else "FAILED"
logger.info(f"MCP Tool {status}: instance={instance_id}, server={qualified_name}, tool={tool_name}")
if error_message:
logger.error(f"Tool execution error: {error_message}")
except Exception as e:
logger.error(f"Failed to log tool usage: {e}")
# Factory function to create the appropriate MCP manager
async def create_mcp_manager_for_agent(agent_config: Dict[str, Any], account_id: str) -> SecureMCPManager:
"""
Create and configure an MCP manager for an agent
Args:
agent_config: Agent configuration (could be legacy or instance-based)
account_id: Account ID for verification
Returns:
Configured SecureMCPManager
"""
manager = SecureMCPManager()
# Check if this is an agent instance (has template_id) or legacy agent
if 'template_id' in agent_config and agent_config['template_id']:
# This is an agent instance - use secure credential system
await manager.connect_from_agent_instance(agent_config['agent_id'], account_id)
else:
# This is a legacy agent - use backward compatibility
await manager.connect_from_legacy_agent(agent_config)
return manager

View File

@ -0,0 +1,269 @@
"""
Template API endpoints
This module provides API endpoints for template management:
1. Creating agent templates from existing agents
2. Publishing/unpublishing templates to marketplace
3. Installing templates as agent instances
4. Browsing marketplace and user templates
"""
from fastapi import APIRouter, HTTPException, Depends
from typing import List, Optional, Dict, Any
from pydantic import BaseModel
from utils.logger import logger
from utils.auth_utils import get_current_user_id_from_jwt
from .template_manager import template_manager
router = APIRouter()
# =====================================================
# PYDANTIC MODELS
# =====================================================
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
# =====================================================
# TEMPLATE MANAGEMENT ENDPOINTS
# =====================================================
@router.post("", 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("/{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("/{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("/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("/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("/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("/{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)}")

View File

@ -186,7 +186,7 @@ export const AgentsGrid = ({
const [unpublishingId, setUnpublishingId] = useState<string | null>(null);
const router = useRouter();
const unpublishAgentMutation = useUnpublishAgent();
const unpublishAgentMutation = useUnpublishTemplate();
const createTemplateMutation = useCreateTemplate();
const handleAgentClick = (agent: Agent) => {
@ -233,16 +233,6 @@ export const AgentsGrid = ({
}
};
const handleQuickPublish = async (agentId: string, event: React.MouseEvent) => {
event.stopPropagation();
await handlePublish(agentId);
};
const handleQuickUnpublish = async (agentId: string, event: React.MouseEvent) => {
event.stopPropagation();
await handleUnpublish(agentId);
};
const getAgentStyling = (agent: Agent) => {
if (agent.avatar && agent.avatar_color) {
return {

View File

@ -2,10 +2,10 @@ import { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Create Agent | Kortix Suna',
description: 'Interactive agent playground powered by Kortix Suna',
description: 'Create an agent',
openGraph: {
title: 'Agent Playground | Kortix Suna',
description: 'Interactive agent playground powered by Kortix Suna',
title: 'Create Agent | Kortix Suna',
description: 'Create an agent',
type: 'website',
},
};

View File

@ -1,31 +1,80 @@
'use client';
import React, { useState } from 'react';
import { Globe, GlobeLock, Download, Calendar, User, Tags, Loader2, AlertTriangle, Plus, GitBranch } from 'lucide-react';
import { Globe, GlobeLock, Download, Calendar, User, Tags, Loader2, AlertTriangle, Plus, GitBranch, Edit2, Eye } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { useMyTemplates, useUnpublishTemplate } from '@/hooks/react-query/secure-mcp/use-secure-mcp';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { useMyTemplates, useUnpublishTemplate, usePublishTemplate } from '@/hooks/react-query/secure-mcp/use-secure-mcp';
import { toast } from 'sonner';
import { getAgentAvatar } from '../../agents/_utils/get-agent-style';
import { Skeleton } from '@/components/ui/skeleton';
import Link from 'next/link';
interface PublishDialogData {
templateId: string;
templateName: string;
currentTags: string[];
}
export default function MyTemplatesPage() {
const [unpublishingId, setUnpublishingId] = useState<string | null>(null);
const [actioningId, setActioningId] = useState<string | null>(null);
const [publishDialog, setPublishDialog] = useState<PublishDialogData | null>(null);
const [publishTags, setPublishTags] = useState('');
const { data: templates, isLoading, error } = useMyTemplates();
const unpublishMutation = useUnpublishTemplate();
const publishMutation = usePublishTemplate();
const handleUnpublish = async (templateId: string, templateName: string) => {
try {
setUnpublishingId(templateId);
setActioningId(templateId);
await unpublishMutation.mutateAsync(templateId);
toast.success(`${templateName} has been unpublished from the marketplace`);
} catch (error: any) {
toast.error(error.message || 'Failed to unpublish template');
} finally {
setUnpublishingId(null);
setActioningId(null);
}
};
const openPublishDialog = (template: any) => {
setPublishDialog({
templateId: template.template_id,
templateName: template.name,
currentTags: template.tags || []
});
setPublishTags((template.tags || []).join(', '));
};
const handlePublish = async () => {
if (!publishDialog) return;
try {
setActioningId(publishDialog.templateId);
// Parse tags from comma-separated string
const tags = publishTags
.split(',')
.map(tag => tag.trim())
.filter(tag => tag.length > 0);
await publishMutation.mutateAsync({
template_id: publishDialog.templateId,
tags: tags.length > 0 ? tags : undefined
});
toast.success(`${publishDialog.templateName} has been published to the marketplace`);
setPublishDialog(null);
setPublishTags('');
} catch (error: any) {
toast.error(error.message || 'Failed to publish template');
} finally {
setActioningId(null);
}
};
@ -53,6 +102,7 @@ export default function MyTemplatesPage() {
}
return (
<>
<div className="container mx-auto max-w-6xl px-4 py-8">
<div className="space-y-8">
<div className="space-y-4">
@ -102,7 +152,7 @@ export default function MyTemplatesPage() {
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{templates?.map((template) => {
const { avatar, color } = getTemplateStyling(template);
const isUnpublishing = unpublishingId === template.template_id;
const isActioning = actioningId === template.template_id;
return (
<div
@ -113,6 +163,19 @@ export default function MyTemplatesPage() {
<div className="text-4xl">
{avatar}
</div>
<div className="absolute top-2 right-2">
{template.is_public ? (
<Badge variant="default" className="bg-green-100 text-green-800 border-green-200">
<Globe className="h-3 w-3 mr-1" />
Public
</Badge>
) : (
<Badge variant="secondary" className="bg-gray-100 text-gray-700 border-gray-200">
<GlobeLock className="h-3 w-3 mr-1" />
Private
</Badge>
)}
</div>
</div>
<div className="p-4 flex flex-col flex-1">
<div className="flex items-center gap-2 mb-1">
@ -150,33 +213,69 @@ export default function MyTemplatesPage() {
<Calendar className="h-3 w-3" />
<span>Created {new Date(template.created_at).toLocaleDateString()}</span>
</div>
{template.is_public && template.marketplace_published_at && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Globe className="h-3 w-3" />
<span>Published {new Date(template.marketplace_published_at).toLocaleDateString()}</span>
</div>
)}
{template.is_public && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Download className="h-3 w-3" />
<span>{template.download_count} downloads</span>
</div>
)}
</div>
<div className="mt-auto">
<div className="mt-auto space-y-2">
{template.is_public ? (
<>
<Button
onClick={() => handleUnpublish(template.template_id, template.name)}
disabled={isUnpublishing}
disabled={isActioning}
variant="outline"
className="w-full"
size="sm"
>
{isUnpublishing ? (
{isActioning ? (
<>
<Loader2 className="h-3 w-3 animate-spin" />
<Loader2 className="h-3 w-3 animate-spin mr-2" />
Unpublishing...
</>
) : (
<>
<GlobeLock className="h-3 w-3" />
<GlobeLock className="h-3 w-3 mr-2" />
Make Private
</>
)}
</Button>
<Link href={`/marketplace?search=${encodeURIComponent(template.name)}`} className="w-full">
<Button variant="ghost" size="sm" className="w-full">
<Eye className="h-3 w-3 mr-2" />
View in Marketplace
</Button>
</Link>
</>
) : (
<div className="text-center text-xs text-muted-foreground py-2">
Private template
</div>
<Button
onClick={() => openPublishDialog(template)}
disabled={isActioning}
variant="default"
className="w-full"
size="sm"
>
{isActioning ? (
<>
<Loader2 className="h-3 w-3 animate-spin mr-2" />
Publishing...
</>
) : (
<>
<Globe className="h-3 w-3 mr-2" />
Publish to Marketplace
</>
)}
</Button>
)}
</div>
</div>
@ -187,5 +286,58 @@ export default function MyTemplatesPage() {
)}
</div>
</div>
{/* Publish Dialog */}
<Dialog open={!!publishDialog} onOpenChange={() => setPublishDialog(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Publish Template to Marketplace</DialogTitle>
<DialogDescription>
Make "{publishDialog?.templateName}" available for the community to discover and install.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="tags">Tags (optional)</Label>
<Input
id="tags"
placeholder="automation, productivity, data-analysis"
value={publishTags}
onChange={(e) => setPublishTags(e.target.value)}
className="mt-1"
/>
<p className="text-xs text-muted-foreground mt-1">
Separate tags with commas to help users discover your template
</p>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setPublishDialog(null)}
disabled={!!actioningId}
>
Cancel
</Button>
<Button
onClick={handlePublish}
disabled={!!actioningId}
>
{actioningId ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Publishing...
</>
) : (
<>
<Globe className="h-4 w-4 mr-2" />
Publish Template
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -194,7 +194,7 @@ export function AgentSelector({
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleCreateAgent} className="cursor-pointer">
Agent Playground
Agents
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@ -161,7 +161,7 @@ export function SidebarLeft({
})}>
<Bot className="h-4 w-4 mr-2" />
<span className="flex items-center justify-between w-full">
Agent Playground
Agents
</span>
</SidebarMenuButton>
</Link>

View File

@ -160,33 +160,6 @@ export function useStoreCredential() {
});
}
export function useTestCredential() {
return useMutation({
mutationFn: async (mcp_qualified_name: string): Promise<TestCredentialResponse> => {
const supabase = createClient();
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
throw new Error('You must be logged in to test credentials');
}
const response = await fetch(`${API_URL}/secure-mcp/credentials/${encodeURIComponent(mcp_qualified_name)}/test`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${session.access_token}`,
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Unknown error' }));
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
},
});
}
export function useDeleteCredential() {
const queryClient = useQueryClient();
@ -243,7 +216,7 @@ export function useMarketplaceTemplates(params?: {
if (params?.search) searchParams.set('search', params.search);
if (params?.tags) searchParams.set('tags', params.tags);
const response = await fetch(`${API_URL}/secure-mcp/templates/marketplace?${searchParams}`, {
const response = await fetch(`${API_URL}/templates/marketplace?${searchParams}`, {
headers: {
'Authorization': `Bearer ${session.access_token}`,
},
@ -270,7 +243,7 @@ export function useTemplateDetails(template_id: string) {
throw new Error('You must be logged in to view template details');
}
const response = await fetch(`${API_URL}/secure-mcp/templates/${template_id}`, {
const response = await fetch(`${API_URL}/templates/${template_id}`, {
headers: {
'Authorization': `Bearer ${session.access_token}`,
},
@ -299,7 +272,7 @@ export function useCreateTemplate() {
throw new Error('You must be logged in to create templates');
}
const response = await fetch(`${API_URL}/secure-mcp/templates`, {
const response = await fetch(`${API_URL}/templates`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -333,7 +306,7 @@ export function useMyTemplates() {
throw new Error('You must be logged in to view your templates');
}
const response = await fetch(`${API_URL}/secure-mcp/templates/my`, {
const response = await fetch(`${API_URL}/templates/my`, {
headers: {
'Authorization': `Bearer ${session.access_token}`,
},
@ -361,7 +334,7 @@ export function usePublishTemplate() {
throw new Error('You must be logged in to publish templates');
}
const response = await fetch(`${API_URL}/secure-mcp/templates/${template_id}/publish`, {
const response = await fetch(`${API_URL}/templates/${template_id}/publish`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -396,7 +369,7 @@ export function useUnpublishTemplate() {
throw new Error('You must be logged in to unpublish templates');
}
const response = await fetch(`${API_URL}/secure-mcp/templates/${template_id}/unpublish`, {
const response = await fetch(`${API_URL}/templates/${template_id}/unpublish`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -428,7 +401,7 @@ export function useInstallTemplate() {
if (!session) {
throw new Error('You must be logged in to install templates');
}
const response = await fetch(`${API_URL}/secure-mcp/templates/install`, {
const response = await fetch(`${API_URL}/templates/install`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',