From 9c00c04c639828169463fa6be24dbe3899803332 Mon Sep 17 00:00:00 2001 From: marko-kraemer Date: Sun, 6 Jul 2025 18:17:29 +0200 Subject: [PATCH] templates wip --- backend/api.py | 39 +- backend/mcp_service/credential_manager.py | 47 +- backend/mcp_service/secure_api.py | 274 +--------- backend/mcp_service/secure_client.py | 469 ------------------ backend/mcp_service/template_api.py | 269 ++++++++++ .../agents/_components/agents-grid.tsx | 12 +- .../agents/new/[agentId]/layout.tsx | 6 +- .../marketplace/my-templates/page.tsx | 400 ++++++++++----- .../components/dashboard/agent-selector.tsx | 2 +- .../src/components/sidebar/sidebar-left.tsx | 2 +- .../react-query/secure-mcp/use-secure-mcp.ts | 41 +- 11 files changed, 583 insertions(+), 978 deletions(-) delete mode 100644 backend/mcp_service/secure_client.py create mode 100644 backend/mcp_service/template_api.py diff --git a/backend/api.py b/backend/api.py index 9da070c4..6920b960 100644 --- a/backend/api.py +++ b/backend/api.py @@ -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 diff --git a/backend/mcp_service/credential_manager.py b/backend/mcp_service/credential_manager.py index 81418a9f..2d0c8de2 100644 --- a/backend/mcp_service/credential_manager.py +++ b/backend/mcp_service/credential_manager.py @@ -326,52 +326,7 @@ class CredentialManager: except Exception as e: 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, diff --git a/backend/mcp_service/secure_api.py b/backend/mcp_service/secure_api.py index 207c4793..cd7adb23 100644 --- a/backend/mcp_service/secure_api.py +++ b/backend/mcp_service/secure_api.py @@ -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)}") diff --git a/backend/mcp_service/secure_client.py b/backend/mcp_service/secure_client.py deleted file mode 100644 index 584eea37..00000000 --- a/backend/mcp_service/secure_client.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/backend/mcp_service/template_api.py b/backend/mcp_service/template_api.py new file mode 100644 index 00000000..4cc6af79 --- /dev/null +++ b/backend/mcp_service/template_api.py @@ -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)}") \ No newline at end of file diff --git a/frontend/src/app/(dashboard)/agents/_components/agents-grid.tsx b/frontend/src/app/(dashboard)/agents/_components/agents-grid.tsx index efee70f4..f5015327 100644 --- a/frontend/src/app/(dashboard)/agents/_components/agents-grid.tsx +++ b/frontend/src/app/(dashboard)/agents/_components/agents-grid.tsx @@ -186,7 +186,7 @@ export const AgentsGrid = ({ const [unpublishingId, setUnpublishingId] = useState(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 { diff --git a/frontend/src/app/(dashboard)/agents/new/[agentId]/layout.tsx b/frontend/src/app/(dashboard)/agents/new/[agentId]/layout.tsx index 17f94dec..b4c188d7 100644 --- a/frontend/src/app/(dashboard)/agents/new/[agentId]/layout.tsx +++ b/frontend/src/app/(dashboard)/agents/new/[agentId]/layout.tsx @@ -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', }, }; diff --git a/frontend/src/app/(dashboard)/marketplace/my-templates/page.tsx b/frontend/src/app/(dashboard)/marketplace/my-templates/page.tsx index c1d30c8e..1536937c 100644 --- a/frontend/src/app/(dashboard)/marketplace/my-templates/page.tsx +++ b/frontend/src/app/(dashboard)/marketplace/my-templates/page.tsx @@ -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(null); + const [actioningId, setActioningId] = useState(null); + const [publishDialog, setPublishDialog] = useState(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,139 +102,242 @@ export default function MyTemplatesPage() { } return ( -
-
-
-
-
-

- My Templates -

-

- Manage your secure agent templates and marketplace presence -

-
-
-
- - {isLoading ? ( -
- {Array.from({ length: 8 }).map((_, i) => ( -
- -
- - - - -
+ <> +
+
+
+
+
+

+ My Templates +

+

+ Manage your secure agent templates and marketplace presence +

- ))} -
- ) : templates?.length === 0 ? ( -
-
-
-

No templates yet

-

- Create your first secure agent template to share with the community while keeping your credentials safe. -

- - -
- ) : ( -
- {templates?.map((template) => { - const { avatar, color } = getTemplateStyling(template); - const isUnpublishing = unpublishingId === template.template_id; - - return ( -
-
-
- {avatar} -
+ + {isLoading ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ +
+ + + +
-
-
-

- {template.name} -

- {template.metadata?.source_version_name && ( - - - {template.metadata.source_version_name} - - )} -
-

- {template.description || 'No description available'} -

- - {template.tags && template.tags.length > 0 && ( -
- {template.tags.slice(0, 2).map(tag => ( - - {tag} +
+ ))} +
+ ) : templates?.length === 0 ? ( +
+
+ +
+

No templates yet

+

+ Create your first secure agent template to share with the community while keeping your credentials safe. +

+ + + +
+ ) : ( +
+ {templates?.map((template) => { + const { avatar, color } = getTemplateStyling(template); + const isActioning = actioningId === template.template_id; + + return ( +
+
+
+ {avatar} +
+
+ {template.is_public ? ( + + + Public - ))} - {template.tags.length > 2 && ( - - +{template.tags.length - 2} + ) : ( + + + Private )}
- )} - -
-
- - Created {new Date(template.created_at).toLocaleDateString()} -
- -
- {template.is_public ? ( - - ) : ( -
- Private template
)} + +
+
+ + Created {new Date(template.created_at).toLocaleDateString()} +
+ {template.is_public && template.marketplace_published_at && ( +
+ + Published {new Date(template.marketplace_published_at).toLocaleDateString()} +
+ )} + {template.is_public && ( +
+ + {template.download_count} downloads +
+ )} +
+ +
+ {template.is_public ? ( + <> + + + + + + ) : ( + + )} +
-
- ); - })} -
- )} + ); + })} +
+ )} +
-
+ + {/* Publish Dialog */} + setPublishDialog(null)}> + + + Publish Template to Marketplace + + Make "{publishDialog?.templateName}" available for the community to discover and install. + + +
+
+ + setPublishTags(e.target.value)} + className="mt-1" + /> +

+ Separate tags with commas to help users discover your template +

+
+
+ + + + +
+
+ ); } \ No newline at end of file diff --git a/frontend/src/components/dashboard/agent-selector.tsx b/frontend/src/components/dashboard/agent-selector.tsx index 1c2cd869..89799c49 100644 --- a/frontend/src/components/dashboard/agent-selector.tsx +++ b/frontend/src/components/dashboard/agent-selector.tsx @@ -194,7 +194,7 @@ export function AgentSelector({ - Agent Playground + Agents diff --git a/frontend/src/components/sidebar/sidebar-left.tsx b/frontend/src/components/sidebar/sidebar-left.tsx index 4ea08c1a..ead4016f 100644 --- a/frontend/src/components/sidebar/sidebar-left.tsx +++ b/frontend/src/components/sidebar/sidebar-left.tsx @@ -161,7 +161,7 @@ export function SidebarLeft({ })}> - Agent Playground + Agents diff --git a/frontend/src/hooks/react-query/secure-mcp/use-secure-mcp.ts b/frontend/src/hooks/react-query/secure-mcp/use-secure-mcp.ts index 18173285..21badbb1 100644 --- a/frontend/src/hooks/react-query/secure-mcp/use-secure-mcp.ts +++ b/frontend/src/hooks/react-query/secure-mcp/use-secure-mcp.ts @@ -160,33 +160,6 @@ export function useStoreCredential() { }); } -export function useTestCredential() { - return useMutation({ - mutationFn: async (mcp_qualified_name: string): Promise => { - 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',