Add Claude Code integration support in API and frontend. Updated allowed origins for CORS, added OAuth endpoints, and enhanced API keys page with integration instructions and connection command.

This commit is contained in:
asemyanov 2025-08-21 14:24:19 +02:00
parent 85866ca7e8
commit 8be8b5face
3 changed files with 971 additions and 2 deletions

View File

@ -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")

867
backend/mcp_kortix_layer.py Normal file
View File

@ -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()
}

View File

@ -241,6 +241,7 @@ export default function APIKeysPage() {
<h1 className="text-2xl font-bold">API Keys</h1>
</div>
<p className="text-muted-foreground">
Manage your API keys for programmatic access to your agents
Manage your API keys for programmatic access to Suna
</p>
</div>
@ -285,6 +286,59 @@ export default function APIKeysPage() {
</CardContent>
</Card>
{/* Claude Code Integration Notice */}
<Card className="border-purple-200/60 bg-gradient-to-br from-purple-50/80 to-violet-50/40 dark:from-purple-950/20 dark:to-violet-950/10 dark:border-purple-800/30">
<CardContent className="p-6">
<div className="flex items-start gap-4">
<div className="relative">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-purple-500/20 to-violet-600/10 border border-purple-500/20">
<Shield className="w-6 h-6 text-purple-600 dark:text-purple-400" />
</div>
<div className="absolute -top-1 -right-1">
<Badge variant="secondary" className="h-5 px-1.5 text-xs bg-purple-100 text-purple-800 border-purple-200 dark:bg-purple-900/30 dark:text-purple-300 dark:border-purple-700">
New
</Badge>
</div>
</div>
<div className="flex-1 space-y-3">
<div>
<h3 className="text-base font-semibold text-purple-900 dark:text-purple-100 mb-1">
Claude Code Integration
</h3>
<p className="text-sm text-purple-700 dark:text-purple-300 leading-relaxed mb-3">
Connect your agents to Claude Code for seamless AI-powered collaboration.
Use your API key to add an MCP server in Claude Code.
</p>
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-purple-800 dark:text-purple-200 mb-1">
Connection Command:
</p>
<div className="bg-purple-900/10 dark:bg-purple-900/30 border border-purple-200/50 dark:border-purple-700/50 rounded-lg p-3">
<code className="text-xs font-mono text-purple-800 dark:text-purple-200 break-all">
claude mcp add AgentPress https://YOUR_DOMAIN/api/mcp --header "Authorization=Bearer YOUR_API_KEY"
</code>
</div>
<p className="text-xs text-purple-600 dark:text-purple-400">
Replace <code className="bg-purple-100 dark:bg-purple-900/50 px-1 rounded">YOUR_DOMAIN</code> and <code className="bg-purple-100 dark:bg-purple-900/50 px-1 rounded">YOUR_API_KEY</code> with your actual API key from below.
</p>
</div>
<div className="flex items-center gap-3">
<a
href="https://docs.anthropic.com/en/docs/claude-code/mcp"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-sm font-medium text-purple-600 hover:text-purple-800 dark:text-purple-400 dark:hover:text-purple-300 transition-colors"
>
<span>Learn about Claude Code MCP</span>
<ExternalLink className="w-4 h-4" />
</a>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Header Actions */}
<div className="flex justify-between items-center">
<div className="flex items-center gap-2 text-sm text-muted-foreground">