mirror of https://github.com/kortix-ai/suna.git
Compare commits
3 Commits
b4bf745103
...
8d0955af53
Author | SHA1 | Date |
---|---|---|
|
8d0955af53 | |
|
9576936bdd | |
|
8be8b5face |
|
@ -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")
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -241,7 +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 Suna
|
||||
Manage your API keys for programmatic access to your agents
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
@ -285,6 +285,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">
|
||||
|
|
Loading…
Reference in New Issue