diff --git a/backend/api.py b/backend/api.py index 2135722e..52834937 100644 --- a/backend/api.py +++ b/backend/api.py @@ -133,7 +133,20 @@ async def log_requests_middleware(request: Request, call_next): allowed_origins = ["https://www.suna.so", "https://suna.so"] allow_origin_regex = None -# Add staging-specific origins +# Add Claude Code origins for MCP +allowed_origins.extend([ + "https://claude.ai", + "https://www.claude.ai", + "https://app.claude.ai", + "http://localhost", + "http://127.0.0.1", + "http://192.168.1.1" +]) + +# Add wildcard for local development and Claude Code CLI +allow_origin_regex = r"https://.*\.claude\.ai|http://localhost.*|http://127\.0\.0\.1.*|http://192\.168\..*|http://10\..*" + +# Add local environment origins if config.ENV_MODE == EnvMode.LOCAL: allowed_origins.append("http://localhost:3000") @@ -149,7 +162,7 @@ app.add_middleware( allow_origin_regex=allow_origin_regex, allow_credentials=True, allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], - allow_headers=["Content-Type", "Authorization", "X-Project-Id", "X-MCP-URL", "X-MCP-Type", "X-MCP-Headers", "X-Refresh-Token", "X-API-Key"], + allow_headers=["Content-Type", "Authorization", "X-Project-Id", "X-MCP-URL", "X-MCP-Type", "X-MCP-Headers", "X-Refresh-Token", "X-API-Key", "Mcp-Session-Id"], ) # Create a main API router @@ -191,6 +204,41 @@ api_router.include_router(admin_api.router) from composio_integration import api as composio_api api_router.include_router(composio_api.router) +# Include MCP Kortix Layer +from mcp_kortix_layer import mcp_router +api_router.include_router(mcp_router) + +# Add OAuth discovery endpoints at root level for Claude Code MCP +@api_router.get("/.well-known/oauth-authorization-server") +async def oauth_authorization_server(): + """OAuth authorization server metadata for Claude Code MCP""" + return { + "issuer": "https://api2.restoned.app", + "authorization_endpoint": "https://api2.restoned.app/api/mcp/oauth/authorize", + "token_endpoint": "https://api2.restoned.app/api/mcp/oauth/token", + "registration_endpoint": "https://api2.restoned.app/register", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code"], + "token_endpoint_auth_methods_supported": ["none"] + } + +@api_router.get("/.well-known/oauth-protected-resource") +async def oauth_protected_resource(): + """OAuth protected resource metadata for Claude Code MCP""" + return { + "resource": "https://api2.restoned.app/api/mcp", + "authorization_servers": ["https://api2.restoned.app"] + } + +@api_router.post("/register") +async def oauth_register(): + """OAuth client registration for Claude Code MCP""" + return { + "client_id": "claude-code-mcp-client", + "client_secret": "not-required-for-api-key-auth", + "message": "AgentPress MCP uses API key authentication - provide your key via Authorization header" + } + @api_router.get("/health") async def health_check(): logger.debug("Health check endpoint called") diff --git a/backend/mcp_kortix_layer.py b/backend/mcp_kortix_layer.py new file mode 100644 index 00000000..608a2c12 --- /dev/null +++ b/backend/mcp_kortix_layer.py @@ -0,0 +1,867 @@ +""" +MCP Layer for AgentPress Agent Invocation + +Allows Claude Code to discover and invoke your custom AgentPress agents and workflows +through MCP (Model Context Protocol) instead of using generic capabilities. + +🚀 ADD TO CLAUDE CODE: +```bash +claude mcp add --transport http "AgentPress" "https://your-backend-domain.com/api/mcp?key=pk_your_key:sk_your_secret" +``` +claude mcp add AgentPress https://api2.restoned.app/api/mcp --header + "Authorization=Bearer pk_your_key:sk_your_secret" + +📋 SETUP STEPS: +1. Deploy your AgentPress backend with this MCP layer +2. Get your API key from your-frontend-domain.com/settings/api-keys +3. Replace your-backend-domain.com and API key in the command above +4. Run the command in Claude Code + +🎯 BENEFITS: +✅ Claude Code uses YOUR specialized agents instead of generic ones +✅ Real execution of your custom prompts and workflows +✅ Uses existing AgentPress authentication and infrastructure +✅ Transforms your agents into Claude Code tools + +📡 TOOLS PROVIDED: +- get_agent_list: List your agents +- get_agent_workflows: List agent workflows +- run_agent: Execute agents with prompts or workflows +""" + +from fastapi import APIRouter, Request, Response +from typing import Dict, Any, Union, Optional +from pydantic import BaseModel +import asyncio +from datetime import datetime + +from utils.logger import logger +from services.supabase import DBConnection + + +# Create MCP router that wraps existing endpoints +mcp_router = APIRouter(prefix="/mcp", tags=["MCP Kortix Layer"]) + +# Initialize database connection +db = DBConnection() + + +class JSONRPCRequest(BaseModel): + """JSON-RPC 2.0 request format""" + jsonrpc: str = "2.0" + method: str + params: Optional[Dict[str, Any]] = None + id: Union[str, int, None] = None + + +class JSONRPCSuccessResponse(BaseModel): + """JSON-RPC 2.0 success response format""" + jsonrpc: str = "2.0" + result: Any + id: Union[str, int, None] + + +class JSONRPCError(BaseModel): + """JSON-RPC 2.0 error object""" + code: int + message: str + data: Optional[Any] = None + + +class JSONRPCErrorResponse(BaseModel): + """JSON-RPC 2.0 error response format""" + jsonrpc: str = "2.0" + error: JSONRPCError + id: Union[str, int, None] + + +# JSON-RPC error codes +class JSONRPCErrorCodes: + PARSE_ERROR = -32700 + INVALID_REQUEST = -32600 + METHOD_NOT_FOUND = -32601 + INVALID_PARAMS = -32602 + INTERNAL_ERROR = -32603 + UNAUTHORIZED = -32001 # Custom error for auth failures + + +def extract_api_key_from_request(request: Request) -> tuple[str, str]: + """Extract and parse API key from request URL parameters or Authorization header.""" + # Try Authorization header first (for Claude Code) + auth_header = request.headers.get("authorization") + if auth_header: + if auth_header.startswith("Bearer "): + key_param = auth_header[7:] # Remove "Bearer " prefix + else: + key_param = auth_header + + if ":" in key_param: + try: + public_key, secret_key = key_param.split(":", 1) + if public_key.startswith("pk_") and secret_key.startswith("sk_"): + return public_key, secret_key + except: + pass + + # Fallback to URL parameter (for curl testing) + key_param = request.query_params.get("key") + if key_param: + if ":" not in key_param: + raise ValueError("Invalid key format. Expected 'pk_xxx:sk_xxx'") + + try: + public_key, secret_key = key_param.split(":", 1) + + if not public_key.startswith("pk_") or not secret_key.startswith("sk_"): + raise ValueError("Invalid key format. Expected 'pk_xxx:sk_xxx'") + + return public_key, secret_key + except Exception as e: + raise ValueError(f"Failed to parse API key: {str(e)}") + + # No valid auth found + raise ValueError("Missing API key. Provide via Authorization header: 'Bearer pk_xxx:sk_xxx' or URL parameter: '?key=pk_xxx:sk_xxx'") + + +async def authenticate_api_key(public_key: str, secret_key: str) -> str: + """Authenticate API key and return account_id.""" + try: + # Use the existing API key service for validation + from services.api_keys import APIKeyService + api_key_service = APIKeyService(db) + + # Validate the API key + validation_result = await api_key_service.validate_api_key(public_key, secret_key) + + if not validation_result.is_valid: + raise ValueError(validation_result.error_message or "Invalid API key") + + account_id = str(validation_result.account_id) + logger.info(f"API key authenticated for account_id: {account_id}") + return account_id + + except Exception as e: + logger.error(f"API key authentication failed: {str(e)}") + raise ValueError(f"Authentication failed: {str(e)}") + + +def extract_last_message(full_output: str) -> str: + """Extract the last meaningful message from agent output.""" + if not full_output.strip(): + return "No output received" + + lines = full_output.strip().split('\n') + + # Look for the last substantial message + for line in reversed(lines): + if line.strip() and not line.startswith('#') and not line.startswith('```'): + try: + line_index = lines.index(line) + start_index = max(0, line_index - 3) + return '\n'.join(lines[start_index:]).strip() + except ValueError: + return line.strip() + + # Fallback: return last 20% of the output + return full_output[-len(full_output)//5:].strip() if len(full_output) > 100 else full_output + + +def truncate_from_end(text: str, max_tokens: int) -> str: + """Truncate text from the beginning, keeping the end.""" + if max_tokens <= 0: + return "" + + max_chars = max_tokens * 4 # Rough token estimation + + if len(text) <= max_chars: + return text + + truncated = text[-max_chars:] + return f"...[truncated {len(text) - max_chars} characters]...\n{truncated}" + + +@mcp_router.post("/") +@mcp_router.post("") # Handle requests without trailing slash +async def mcp_handler( + request: JSONRPCRequest, + http_request: Request +): + """Main MCP endpoint handling JSON-RPC 2.0 requests.""" + try: + # Authenticate API key from URL parameters + try: + public_key, secret_key = extract_api_key_from_request(http_request) + account_id = await authenticate_api_key(public_key, secret_key) + except ValueError as auth_error: + logger.warning(f"Authentication failed: {str(auth_error)}") + return JSONRPCErrorResponse( + error=JSONRPCError( + code=JSONRPCErrorCodes.UNAUTHORIZED, + message=f"Authentication failed: {str(auth_error)}" + ), + id=request.id + ) + + # Validate JSON-RPC format + if request.jsonrpc != "2.0": + return JSONRPCErrorResponse( + error=JSONRPCError( + code=JSONRPCErrorCodes.INVALID_REQUEST, + message="Invalid JSON-RPC version" + ), + id=request.id + ) + + method = request.method + params = request.params or {} + + logger.info(f"MCP JSON-RPC call: {method} for account: {account_id}") + + # Handle different MCP methods + if method == "initialize": + result = { + "protocolVersion": "2024-11-05", + "capabilities": { + "tools": {} + }, + "serverInfo": { + "name": "agentpress", + "version": "1.0.0" + } + } + elif method == "tools/list": + result = await handle_tools_list() + elif method == "tools/call": + tool_name = params.get("name") + arguments = params.get("arguments", {}) + + if not tool_name: + return JSONRPCErrorResponse( + error=JSONRPCError( + code=JSONRPCErrorCodes.INVALID_PARAMS, + message="Missing 'name' parameter for tools/call" + ), + id=request.id + ) + + result = await handle_tool_call(tool_name, arguments, account_id) + else: + return JSONRPCErrorResponse( + error=JSONRPCError( + code=JSONRPCErrorCodes.METHOD_NOT_FOUND, + message=f"Method '{method}' not found" + ), + id=request.id + ) + + return JSONRPCSuccessResponse( + result=result, + id=request.id + ) + + except Exception as e: + logger.error(f"Error in MCP JSON-RPC handler: {str(e)}") + return JSONRPCErrorResponse( + error=JSONRPCError( + code=JSONRPCErrorCodes.INTERNAL_ERROR, + message=f"Internal error: {str(e)}" + ), + id=getattr(request, 'id', None) + ) + + +async def handle_tools_list(): + """Handle tools/list method.""" + tools = [ + { + "name": "get_agent_list", + "description": "Get a list of all available agents in your account. Always call this tool first.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + }, + { + "name": "get_agent_workflows", + "description": "Get a list of available workflows for a specific agent.", + "inputSchema": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "description": "The ID of the agent to get workflows for" + } + }, + "required": ["agent_id"] + } + }, + { + "name": "run_agent", + "description": "Run a specific agent with a message and get formatted output.", + "inputSchema": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "description": "The ID of the agent to run" + }, + "message": { + "type": "string", + "description": "The message/prompt to send to the agent" + }, + "execution_mode": { + "type": "string", + "enum": ["prompt", "workflow"], + "description": "Either 'prompt' for custom prompt execution or 'workflow' for workflow execution" + }, + "workflow_id": { + "type": "string", + "description": "Required when execution_mode is 'workflow' - the ID of the workflow to run" + }, + "output_mode": { + "type": "string", + "enum": ["last_message", "full"], + "description": "How to format output: 'last_message' (default) or 'full'" + }, + "max_tokens": { + "type": "integer", + "description": "Maximum tokens in response" + }, + "model_name": { + "type": "string", + "description": "Model to use for the agent execution. If not specified, uses the agent's configured model or fallback." + } + }, + "required": ["agent_id", "message"] + } + } + ] + + return {"tools": tools} + + +async def handle_tool_call(tool_name: str, arguments: Dict[str, Any], account_id: str): + """Handle tools/call method.""" + try: + if tool_name == "get_agent_list": + result = await call_get_agents_endpoint(account_id) + elif tool_name == "get_agent_workflows": + agent_id = arguments.get("agent_id") + if not agent_id: + raise ValueError("agent_id is required") + result = await call_get_agent_workflows_endpoint(account_id, agent_id) + elif tool_name == "run_agent": + agent_id = arguments.get("agent_id") + message = arguments.get("message") + execution_mode = arguments.get("execution_mode", "prompt") + workflow_id = arguments.get("workflow_id") + output_mode = arguments.get("output_mode", "last_message") + max_tokens = arguments.get("max_tokens", 1000) + model_name = arguments.get("model_name") + + if not agent_id or not message: + raise ValueError("agent_id and message are required") + + if execution_mode == "workflow" and not workflow_id: + raise ValueError("workflow_id is required when execution_mode is 'workflow'") + + result = await call_run_agent_endpoint( + account_id, agent_id, message, execution_mode, workflow_id, output_mode, max_tokens, model_name + ) + else: + raise ValueError(f"Unknown tool: {tool_name}") + + # Return MCP-compatible tool call result + return { + "content": [{ + "type": "text", + "text": result + }] + } + + except Exception as e: + logger.error(f"Error in tool call {tool_name}: {str(e)}") + raise e + + +async def call_get_agents_endpoint(account_id: str) -> str: + """Call the existing /agents endpoint and format for MCP.""" + try: + # Import the get_agents function from agent.api + from agent.api import get_agents + + # Call the existing endpoint + response = await get_agents( + user_id=account_id, + page=1, + limit=100, # Get all agents + search=None, + sort_by="created_at", + sort_order="desc", + has_default=None, + has_mcp_tools=None, + has_agentpress_tools=None, + tools=None + ) + + # Handle both dict and object response formats + if hasattr(response, 'agents'): + agents = response.agents + elif isinstance(response, dict) and 'agents' in response: + agents = response['agents'] + else: + logger.error(f"Unexpected response format from get_agents: {type(response)}") + return f"Error: Unexpected response format from get_agents: {response}" + + if not agents: + return "No agents found in your account. Create some agents first in your frontend." + + agent_list = "🤖 Available Agents in Your Account:\n\n" + for i, agent in enumerate(agents, 1): + # Handle both dict and object formats for individual agents + agent_id = agent.agent_id if hasattr(agent, 'agent_id') else agent.get('agent_id') + name = agent.name if hasattr(agent, 'name') else agent.get('name') + description = agent.description if hasattr(agent, 'description') else agent.get('description') + + agent_list += f"{i}. Agent ID: {agent_id}\n" + agent_list += f" Name: {name}\n" + if description: + agent_list += f" Description: {description}\n" + agent_list += "\n" + + agent_list += "📝 Use the 'run_agent' tool with the Agent ID to invoke any of these agents." + + logger.info(f"Listed {len(agents)} agents via MCP") + return agent_list + + except Exception as e: + logger.error(f"Error in get_agent_list: {str(e)}") + return f"Error listing agents: {str(e)}" + + +async def verify_agent_access(agent_id: str, account_id: str): + """Verify account has access to the agent.""" + try: + client = await db.client + result = await client.table('agents').select('agent_id').eq('agent_id', agent_id).eq('account_id', account_id).execute() + + if not result.data: + raise ValueError("Agent not found or access denied") + except Exception as e: + logger.error(f"Database error in verify_agent_access: {str(e)}") + raise ValueError("Database connection error") + + +async def call_get_agent_workflows_endpoint(account_id: str, agent_id: str) -> str: + """Get workflows for a specific agent.""" + try: + # Verify agent access + await verify_agent_access(agent_id, account_id) + + # Get workflows from database + client = await db.client + result = await client.table('agent_workflows').select('*').eq('agent_id', agent_id).order('created_at', desc=True).execute() + + if not result.data: + return f"No workflows found for agent {agent_id}. This agent can only be run with custom prompts." + + workflow_list = f"🔄 Available Workflows for Agent {agent_id}:\n\n" + for i, workflow in enumerate(result.data, 1): + workflow_list += f"{i}. Workflow ID: {workflow['id']}\n" + workflow_list += f" Name: {workflow['name']}\n" + if workflow.get('description'): + workflow_list += f" Description: {workflow['description']}\n" + workflow_list += f" Status: {workflow.get('status', 'unknown')}\n" + workflow_list += "\n" + + workflow_list += "📝 Use the 'run_agent' tool with execution_mode='workflow' and the Workflow ID to run a workflow." + + logger.info(f"Listed {len(result.data)} workflows for agent {agent_id} via MCP") + return workflow_list + + except Exception as e: + logger.error(f"Error in get_agent_workflows: {str(e)}") + return f"Error listing workflows: {str(e)}" + + +async def call_run_agent_endpoint( + account_id: str, + agent_id: str, + message: str, + execution_mode: str = "prompt", + workflow_id: Optional[str] = None, + output_mode: str = "last_message", + max_tokens: int = 1000, + model_name: Optional[str] = None +) -> str: + """Call the existing agent run endpoints and format for MCP.""" + try: + # Validate execution mode and workflow parameters + if execution_mode not in ["prompt", "workflow"]: + return "Error: execution_mode must be either 'prompt' or 'workflow'" + + if execution_mode == "workflow" and not workflow_id: + return "Error: workflow_id is required when execution_mode is 'workflow'" + + # Verify agent access + await verify_agent_access(agent_id, account_id) + + # Apply model fallback if no model specified + if not model_name: + # Use a reliable free-tier model as fallback + model_name = "openrouter/google/gemini-2.5-flash" + logger.info(f"No model specified for agent {agent_id}, using fallback: {model_name}") + + if execution_mode == "workflow": + # Execute workflow using the existing workflow execution endpoint + result = await execute_agent_workflow_internal(agent_id, workflow_id, message, account_id, model_name) + else: + # Execute agent with prompt using existing agent endpoints + result = await execute_agent_prompt_internal(agent_id, message, account_id, model_name) + + # Process the output based on the requested mode + if output_mode == "last_message": + processed_output = extract_last_message(result) + else: + processed_output = result + + # Apply token limiting + final_output = truncate_from_end(processed_output, max_tokens) + + logger.info(f"MCP agent run completed for agent {agent_id} in {execution_mode} mode") + return final_output + + except Exception as e: + logger.error(f"Error running agent {agent_id}: {str(e)}") + return f"Error running agent: {str(e)}" + + +async def execute_agent_workflow_internal(agent_id: str, workflow_id: str, message: str, account_id: str, model_name: Optional[str] = None) -> str: + """Execute an agent workflow.""" + try: + client = await db.client + + # Verify workflow exists and is active + workflow_result = await client.table('agent_workflows').select('*').eq('id', workflow_id).eq('agent_id', agent_id).execute() + if not workflow_result.data: + return f"Error: Workflow {workflow_id} not found for agent {agent_id}" + + workflow = workflow_result.data[0] + if workflow.get('status') != 'active': + return f"Error: Workflow {workflow['name']} is not active (status: {workflow.get('status')})" + + # Execute workflow through the execution service + try: + from triggers.execution_service import execute_workflow + + # Execute the workflow with the provided message + execution_result = await execute_workflow( + workflow_id=workflow_id, + agent_id=agent_id, + input_data={"message": message}, + user_id=account_id + ) + + if execution_result.get('success'): + return execution_result.get('output', f"Workflow '{workflow['name']}' executed successfully") + else: + return f"Workflow execution failed: {execution_result.get('error', 'Unknown error')}" + + except ImportError: + logger.warning("Execution service not available, using fallback workflow execution") + # Fallback: Create a thread and run the agent with workflow context + from agent.api import create_thread, add_message_to_thread, start_agent, AgentStartRequest + + # Create thread with workflow context + thread_response = await create_thread( + name=f"Workflow: {workflow['name']}", + user_id=account_id + ) + thread_id = thread_response.get('thread_id') if isinstance(thread_response, dict) else thread_response.thread_id + + # Add workflow context message + workflow_context = f"Executing workflow '{workflow['name']}'" + if workflow.get('description'): + workflow_context += f": {workflow['description']}" + workflow_context += f"\n\nUser message: {message}" + + await add_message_to_thread( + thread_id=thread_id, + message=workflow_context, + user_id=account_id + ) + + # Start agent with workflow execution + agent_request = AgentStartRequest( + agent_id=agent_id, + enable_thinking=False, + stream=False, + model_name=model_name + ) + + await start_agent( + thread_id=thread_id, + body=agent_request, + user_id=account_id + ) + + # Wait for completion (similar to prompt execution) + client = await db.client + max_wait = 90 # Longer timeout for workflows + poll_interval = 3 + elapsed = 0 + + while elapsed < max_wait: + messages_result = await client.table('messages').select('*').eq('thread_id', thread_id).order('created_at', desc=True).limit(5).execute() + + if messages_result.data: + for msg in messages_result.data: + # Parse JSON content to check role + content = msg.get('content') + if content: + try: + import json + if isinstance(content, str): + parsed_content = json.loads(content) + else: + parsed_content = content + + if parsed_content.get('role') == 'assistant': + return parsed_content.get('content', '') + except: + # If parsing fails, check if it's a direct assistant message + if msg.get('type') == 'assistant': + return content + + runs_result = await client.table('agent_runs').select('status, error').eq('thread_id', thread_id).order('created_at', desc=True).limit(1).execute() + + if runs_result.data: + run = runs_result.data[0] + if run['status'] in ['completed', 'failed', 'cancelled']: + if run['status'] == 'failed': + return f"Workflow execution failed: {run.get('error', 'Unknown error')}" + elif run['status'] == 'cancelled': + return "Workflow execution was cancelled" + break + + await asyncio.sleep(poll_interval) + elapsed += poll_interval + + return f"Workflow '{workflow['name']}' execution timed out after {max_wait}s. Thread ID: {thread_id}" + + except Exception as e: + logger.error(f"Error executing workflow {workflow_id}: {str(e)}") + return f"Error executing workflow: {str(e)}" + + +async def execute_agent_prompt_internal(agent_id: str, message: str, account_id: str, model_name: Optional[str] = None) -> str: + """Execute an agent with a custom prompt.""" + try: + # Import existing agent execution functions + from agent.api import create_thread, add_message_to_thread, start_agent, AgentStartRequest + + # Create a new thread + thread_response = await create_thread(name="MCP Agent Run", user_id=account_id) + thread_id = thread_response.get('thread_id') if isinstance(thread_response, dict) else thread_response.thread_id + + # Add the message to the thread + await add_message_to_thread( + thread_id=thread_id, + message=message, + user_id=account_id + ) + + # Start the agent + agent_request = AgentStartRequest( + agent_id=agent_id, + enable_thinking=False, + stream=False, + model_name=model_name + ) + + # Start the agent + await start_agent( + thread_id=thread_id, + body=agent_request, + user_id=account_id + ) + + # Wait for agent completion and get response + client = await db.client + + # Poll for completion (max 60 seconds) + max_wait = 60 + poll_interval = 2 + elapsed = 0 + + while elapsed < max_wait: + # Check thread messages for agent response + messages_result = await client.table('messages').select('*').eq('thread_id', thread_id).order('created_at', desc=True).limit(5).execute() + + if messages_result.data: + # Look for the most recent agent message (not user message) + for msg in messages_result.data: + # Parse JSON content to check role + content = msg.get('content') + if content: + try: + import json + if isinstance(content, str): + parsed_content = json.loads(content) + else: + parsed_content = content + + if parsed_content.get('role') == 'assistant': + return parsed_content.get('content', '') + except: + # If parsing fails, check if it's a direct assistant message + if msg.get('type') == 'assistant': + return content + + # Check if agent run is complete by checking agent_runs table + runs_result = await client.table('agent_runs').select('status, error').eq('thread_id', thread_id).order('created_at', desc=True).limit(1).execute() + + if runs_result.data: + run = runs_result.data[0] + if run['status'] in ['completed', 'failed', 'cancelled']: + if run['status'] == 'failed': + return f"Agent execution failed: {run.get('error', 'Unknown error')}" + elif run['status'] == 'cancelled': + return "Agent execution was cancelled" + # If completed, continue to check for messages + break + + await asyncio.sleep(poll_interval) + elapsed += poll_interval + + # Timeout fallback - get latest messages + messages_result = await client.table('messages').select('*').eq('thread_id', thread_id).order('created_at', desc=True).limit(10).execute() + + if messages_result.data: + # Return the most recent assistant message or fallback message + for msg in messages_result.data: + # Parse JSON content to check role + content = msg.get('content') + if content: + try: + import json + if isinstance(content, str): + parsed_content = json.loads(content) + else: + parsed_content = content + + if parsed_content.get('role') == 'assistant': + return parsed_content.get('content', '') + except: + # If parsing fails, check if it's a direct assistant message + if msg.get('type') == 'assistant': + return content + + return f"Agent execution timed out after {max_wait}s. Thread ID: {thread_id}" + + return f"No response received from agent {agent_id}. Thread ID: {thread_id}" + + except Exception as e: + logger.error(f"Error executing agent prompt: {str(e)}") + return f"Error executing agent: {str(e)}" + + + +@mcp_router.get("/health") +async def mcp_health_check(): + """Health check for MCP layer""" + return {"status": "healthy", "service": "mcp-kortix-layer"} + + +# OAuth 2.0 endpoints for Claude Code compatibility +@mcp_router.get("/oauth/authorize") +async def oauth_authorize( + response_type: str = None, + client_id: str = None, + redirect_uri: str = None, + scope: str = None, + state: str = None, + code_challenge: str = None, + code_challenge_method: str = None +): + """OAuth authorization endpoint - redirect with authorization code""" + from fastapi.responses import RedirectResponse + import secrets + + # Generate a dummy authorization code (since we use API keys) + auth_code = f"ac_{secrets.token_urlsafe(32)}" + + # Build redirect URL with authorization code and state + redirect_url = f"{redirect_uri}?code={auth_code}" + if state: + redirect_url += f"&state={state}" + + logger.info(f"OAuth authorize redirecting to: {redirect_url}") + return RedirectResponse(url=redirect_url) + + +@mcp_router.post("/oauth/token") +async def oauth_token( + grant_type: str = None, + code: str = None, + redirect_uri: str = None, + client_id: str = None, + client_secret: str = None, + code_verifier: str = None +): + """OAuth token endpoint - simplified for API key flow with PKCE support""" + return { + "access_token": "use_api_key_instead", + "token_type": "bearer", + "message": "AgentPress MCP Server uses API key authentication", + "instructions": "Use your API key as Bearer token: Authorization: Bearer pk_xxx:sk_xxx", + "pkce_supported": True + } + + +@mcp_router.options("/") +@mcp_router.options("") # Handle OPTIONS without trailing slash +async def mcp_options(): + """Handle CORS preflight for MCP endpoint""" + return Response( + status_code=200, + headers={ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization, Mcp-Session-Id" + } + ) + + +@mcp_router.get("/.well-known/mcp") +async def mcp_discovery(): + """MCP discovery endpoint for Claude Code""" + return { + "mcpVersion": "2024-11-05", + "capabilities": { + "tools": {} + }, + "implementation": { + "name": "AgentPress MCP Server", + "version": "1.0.0" + }, + "oauth": { + "authorization_endpoint": "/api/mcp/oauth/authorize", + "token_endpoint": "/api/mcp/oauth/token", + "supported_flows": ["authorization_code"] + }, + "instructions": "Use API key authentication via Authorization header: Bearer pk_xxx:sk_xxx" + } + + +@mcp_router.get("/") +@mcp_router.get("") +async def mcp_health(): + """Health check endpoint for MCP server""" + return { + "status": "healthy", + "service": "agentpress-mcp-server", + "version": "1.0.0", + "timestamp": datetime.now().isoformat() + } \ No newline at end of file diff --git a/frontend/src/app/(dashboard)/settings/api-keys/page.tsx b/frontend/src/app/(dashboard)/settings/api-keys/page.tsx index 653d491e..a14d1dad 100644 --- a/frontend/src/app/(dashboard)/settings/api-keys/page.tsx +++ b/frontend/src/app/(dashboard)/settings/api-keys/page.tsx @@ -241,6 +241,7 @@ export default function APIKeysPage() {

API Keys

+ Manage your API keys for programmatic access to your agents Manage your API keys for programmatic access to Suna

@@ -285,6 +286,59 @@ export default function APIKeysPage() { + {/* Claude Code Integration Notice */} + + +
+
+
+ +
+
+ + New + +
+
+
+
+

+ Claude Code Integration +

+

+ Connect your agents to Claude Code for seamless AI-powered collaboration. + Use your API key to add an MCP server in Claude Code. +

+
+
+

+ Connection Command: +

+
+ + claude mcp add AgentPress https://YOUR_DOMAIN/api/mcp --header "Authorization=Bearer YOUR_API_KEY" + +
+

+ Replace YOUR_DOMAIN and YOUR_API_KEY with your actual API key from below. +

+
+
+ + Learn about Claude Code MCP + + +
+
+
+
+
+ {/* Header Actions */}