mirror of https://github.com/kortix-ai/suna.git
templates wip
This commit is contained in:
parent
abe835260c
commit
9c00c04c63
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)}")
|
||||
|
|
|
@ -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
|
|
@ -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)}")
|
|
@ -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 {
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -194,7 +194,7 @@ export function AgentSelector({
|
|||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem onClick={handleCreateAgent} className="cursor-pointer">
|
||||
Agent Playground
|
||||
Agents
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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',
|
||||
|
|
Loading…
Reference in New Issue