"""MCP tool execution with intelligent strategy selection and streaming."""

import asyncio
import json
from datetime import UTC, datetime
from typing import Any

from fastapi import (
  APIRouter,
  Body,
  Depends,
  HTTPException,
  Path,
  Request,
)
from fastapi import (
  Query as QueryParam,
)
from fastapi import status as http_status
from fastapi.responses import JSONResponse, StreamingResponse
from sse_starlette.sse import EventSourceResponse

from robosystems.config.query_queue import QueryQueueConfig
from robosystems.logger import api_logger, logger
from robosystems.middleware.auth.dependencies import get_current_user_with_graph
from robosystems.middleware.graph import get_graph_repository
from robosystems.middleware.graph.query_queue import get_query_queue
from robosystems.middleware.graph.types import GRAPH_OR_SUBGRAPH_ID_PATTERN
from robosystems.middleware.graph.utils import MultiTenantUtils
from robosystems.middleware.otel.metrics import (
  endpoint_metrics_decorator,
  get_endpoint_metrics,
)
from robosystems.middleware.rate_limits import (
  subscription_aware_rate_limit_dependency,
)
from robosystems.middleware.robustness import (
  CircuitBreakerManager,
  OperationStatus,
  OperationType,
  record_operation_metric,
)
from robosystems.middleware.sse.operation_manager import create_operation_response
from robosystems.models.api.common import RESOURCE_ERROR_RESPONSES
from robosystems.models.api.graphs.mcp import MCPToolCall, MCPToolResult
from robosystems.models.core import User
from robosystems.security.cypher_analyzer import (
  is_admin_operation,
  is_bulk_operation,
  is_write_operation,
)

# Import MCP components
from .handlers import MCPHandler, validate_mcp_access
from .strategies import (
  MCPClientDetector,
  MCPExecutionStrategy,
  MCPStrategySelector,
)
from .streaming import (
  aggregate_streamed_results,
  stream_mcp_tool_execution,
)

router = APIRouter()

# Circuit breaker instance
circuit_breaker = CircuitBreakerManager()

# MCP result cache TTLs (seconds).
# To manually invalidate: just admin dev cache flush mcp_cache
_MCP_INFO_CACHE_TTL = 1800  # 30 minutes
_MCP_SCHEMA_CACHE_TTL = 3600  # 1 hour

# Shared module-level Redis client for MCP cache — created once, reuses connection pool.
# Lazily initialized on first use to avoid import-time URL resolution issues.
_mcp_redis_client: Any = None


def _get_mcp_redis_client() -> Any:
  """Return the shared async Redis client for MCP_CACHE, creating it on first call."""
  global _mcp_redis_client
  if _mcp_redis_client is None:
    from robosystems.config.valkey_registry import (
      ValkeyDatabase,
      create_async_redis_client,
    )

    _mcp_redis_client = create_async_redis_client(ValkeyDatabase.MCP_CACHE)
  return _mcp_redis_client


def _mcp_cache_key(graph_id: str, tool_name: str) -> str:
  """Build a Valkey cache key for MCP tool results.

  Both get-graph-schema and get-graph-info accept no arguments (empty inputSchema),
  so the graph_id + tool_name combination is sufficient to uniquely identify results.
  """
  return f"mcp:{graph_id}:{tool_name}"


async def _get_mcp_cache(graph_id: str, tool_name: str) -> dict[str, Any] | None:
  """Get a cached MCP tool result from Valkey. Returns None on miss or error."""
  try:
    data = await _get_mcp_redis_client().get(_mcp_cache_key(graph_id, tool_name))
    if data:
      logger.debug(f"MCP cache hit for {tool_name} on {graph_id}")
      return json.loads(data)
  except Exception as e:
    logger.debug(f"MCP cache read error for {tool_name} on {graph_id}: {e}")
  return None


async def _set_mcp_cache(
  graph_id: str, tool_name: str, result: dict[str, Any], ttl: int
) -> None:
  """Store an MCP tool result in Valkey cache."""
  try:
    await _get_mcp_redis_client().set(
      _mcp_cache_key(graph_id, tool_name),
      json.dumps(result),
      ex=ttl,
    )
    logger.debug(f"MCP cache set for {tool_name} on {graph_id} (ttl={ttl}s)")
  except Exception as e:
    logger.debug(f"MCP cache write error for {tool_name} on {graph_id}: {e}")


def _get_user_priority(user: User) -> int:
  """Get query priority based on user subscription tier."""
  if hasattr(user, "subscription") and user.subscription:
    tier = (
      user.subscription.billing_plan.name if user.subscription.billing_plan else None
    )
    return QueryQueueConfig.get_priority_for_user(tier)
  return QueryQueueConfig.DEFAULT_PRIORITY


def _get_mcp_operation_type(graph_id: str) -> str:
  """Determine the correct operation type for MCP operations."""
  from robosystems.config.shared_repositories import is_shared_repository_or_subgraph

  if is_shared_repository_or_subgraph(graph_id):
    return "read"
  else:
    return "write"


async def execute_tool_directly(
  handler: MCPHandler,
  tool_call: MCPToolCall,
  timeout: int = 60,
) -> dict[str, Any]:
  """Execute MCP tool directly without queuing."""
  try:
    result = await asyncio.wait_for(
      handler.call_tool(tool_call.name, tool_call.arguments), timeout=timeout
    )
    return result
  except TimeoutError:
    raise HTTPException(
      status_code=http_status.HTTP_408_REQUEST_TIMEOUT,
      detail=f"Tool execution timed out after {timeout} seconds",
    )


async def stream_sse_response(request: Request, events_generator):
  """Generate SSE response with disconnect detection."""
  async for event in events_generator:
    if await request.is_disconnected():
      break

    # Format as SSE
    if "event" in event:
      yield {"event": event["event"], "data": json.dumps(event.get("data", {}))}
    else:
      yield {"data": json.dumps(event)}


async def stream_ndjson_response(events_generator):
  """Generate NDJSON response."""
  async for event in events_generator:
    yield json.dumps(event) + "\n"


@router.post(
  "/call-tool",
  response_model=None,
  summary="Execute MCP Tool",
  description="Strategy auto-selected by tool type and load: JSON for fast tools, SSE for long queries, NDJSON for large results. Database operations (Cypher, schema, info) consume no credits — only AI LLM calls cost credits.",
  operation_id="callMcpTool",
  responses={
    **RESOURCE_ERROR_RESPONSES,
    202: {"description": "Tool queued for execution"},
    408: {"description": "Execution timeout"},
    503: {"description": "Service unavailable"},
  },
)
@endpoint_metrics_decorator(
  "/v1/graphs/{graph_id}/mcp/call-tool", business_event_type="mcp_tool_called"
)
async def call_mcp_tool(
  full_request: Request,
  graph_id: str = Path(
    ...,
    description="Graph database identifier",
    pattern=GRAPH_OR_SUBGRAPH_ID_PATTERN,
  ),
  tool_call: MCPToolCall = Body(
    ...,
    openapi_examples={
      "cypher_query": {
        "summary": "Execute Cypher Query",
        "description": "Query companies by ticker symbol with parameters",
        "value": {
          "name": "read-graph-cypher",
          "arguments": {
            "query": "MATCH (c:Company {ticker: $ticker})-[:FILED]->(f:Filing) RETURN c.name, f.form_type, f.filing_date LIMIT 10",
            "parameters": {"ticker": "AAPL"},
          },
        },
      },
      "get_schema": {
        "summary": "Get Graph Schema",
        "description": "Retrieve the complete schema of the graph database",
        "value": {
          "name": "get-graph-schema",
          "arguments": {},
        },
      },
      "get_info": {
        "summary": "Get Graph Info",
        "description": "Get statistics and metadata about the graph",
        "value": {
          "name": "get-graph-info",
          "arguments": {},
        },
      },
    },
  ),
  format: str | None = QueryParam(
    default=None,
    description="Response format override (json, sse, ndjson)",
  ),
  test_mode: bool = QueryParam(
    default=False,
    description="Enable test mode for debugging",
  ),
  current_user: User = Depends(get_current_user_with_graph),
  _rate_limit: None = Depends(subscription_aware_rate_limit_dependency),
) -> MCPToolResult | JSONResponse | StreamingResponse | EventSourceResponse:
  start_time = datetime.now(UTC)

  # Import config
  from robosystems.config import env

  # Initialize monitoring and logging
  # (Operation logging is handled by circuit breaker)

  # Record metrics at start
  record_operation_metric(
    operation_type=OperationType.TOOL_EXECUTION,
    status=OperationStatus.SUCCESS,  # Will be updated on completion
    duration_ms=0.0,  # Will be updated on completion
    endpoint=f"/v1/graphs/{graph_id}/mcp/call-tool",
    graph_id=graph_id,
    operation_name=tool_call.name,
    user_id=str(current_user.id),
    metadata={
      "operation": "start",
      "arguments_count": len(tool_call.arguments) if tool_call.arguments else 0,
    },
  )

  # Check circuit breaker
  circuit_breaker.check_circuit(graph_id, tool_call.name)

  try:
    # Validate write operations for Cypher tools
    is_write_query = False
    if tool_call.name in [
      "read-graph-cypher",
      "read-neo4j-cypher",
      "read-ladybug-cypher",
    ]:
      query: str = tool_call.arguments.get("query", "")  # type: ignore[assignment]
      is_write_query = is_write_operation(query)

      # Check for bulk operations (COPY, LOAD, IMPORT)
      if is_bulk_operation(query):
        logger.warning(
          f"User {current_user.id} attempted bulk operation through MCP: {query[:100]}"
        )
        raise HTTPException(
          status_code=http_status.HTTP_400_BAD_REQUEST,
          detail="Bulk operations (COPY, LOAD, IMPORT) are not allowed through the MCP endpoint. "
          "Please use file upload (/v1/graphs/{graph_id}/tables/files/upload) to ingest data via DuckDB staging.",
        )

      # Check for admin operations
      if is_admin_operation(query):
        logger.warning(
          f"User {current_user.id} attempted admin operation through MCP: {query[:100]}"
        )
        # For now, block all admin operations - we can add admin flag to User model later
        raise HTTPException(
          status_code=http_status.HTTP_403_FORBIDDEN,
          detail="Administrative operations require admin privileges.",
        )

      # Block writes on shared repositories
      if is_write_query and MultiTenantUtils.is_shared_repository_or_subgraph(graph_id):
        raise HTTPException(
          status_code=http_status.HTTP_403_FORBIDDEN,
          detail=f"Write operations not allowed on shared repository '{graph_id}'",
        )

    # Validate access using a short-lived session.  The MCP endpoint's
    # tool execution can take minutes; using a scoped session or FastAPI
    # db dependency would hold a pool connection for the entire duration.
    # A plain SessionFactory() session is closed immediately after the
    # DB work, returning the connection to the pool before tool execution.
    from robosystems.database import SessionFactory

    access_type = "write" if is_write_query else "read"
    repo_access = None
    sess = SessionFactory()
    try:
      await validate_mcp_access(graph_id, current_user, sess, access_type)

      # Look up shared-repo subscription while session is still open
      if MultiTenantUtils.is_shared_repository(graph_id):
        from robosystems.models.core.user.user_repository import UserRepository

        repo_access = UserRepository.get_by_user_and_repository(
          current_user.id, graph_id, sess
        )
    finally:
      sess.close()

    # Apply dual-layer rate limiting for shared repositories
    # Rate limiting is optional - skip if disabled (dev environments)
    if MultiTenantUtils.is_shared_repository(graph_id) and env.RATE_LIMIT_ENABLED:
      from robosystems.config.valkey_registry import (
        ValkeyDatabase,
        create_async_redis_client,
      )
      from robosystems.middleware.rate_limits import DualLayerRateLimiter

      if not repo_access:
        raise HTTPException(
          status_code=http_status.HTTP_403_FORBIDDEN,
          detail=f"Access to {graph_id.upper()} repository requires a subscription. Visit https://roboledger.ai/pricing",
        )

      # Get Redis client for rate limiting with proper ElastiCache support
      redis_client = create_async_redis_client(ValkeyDatabase.RATE_LIMITS)

      try:
        limiter = DualLayerRateLimiter(redis_client)

        # Get user's subscription tier for burst protection
        user_tier = "ladybug-standard"  # Default for authenticated users
        if hasattr(current_user, "subscription") and current_user.subscription:
          if current_user.subscription.billing_plan:
            user_tier = current_user.subscription.billing_plan.name

        # Check both rate limit layers
        limit_check = await limiter.check_limits(
          user_id=str(current_user.id),
          graph_id=graph_id,
          operation="mcp",
          endpoint=f"mcp/call-tool/{tool_call.name}",
          user_tier=user_tier,
          repository_plan=repo_access.repository_plan,
        )

        if not limit_check["allowed"]:
          reason = limit_check.get("reason", "unknown")
          message = limit_check.get("message", "Rate limit exceeded")

          if reason == "no_access":
            raise HTTPException(
              status_code=http_status.HTTP_403_FORBIDDEN,
              detail=f"{message}. Subscribe at https://roboledger.ai/pricing",
            )
          elif reason == "endpoint_not_allowed":
            raise HTTPException(
              status_code=http_status.HTTP_403_FORBIDDEN,
              detail=message,
            )
          elif reason == "burst_limit":
            detail = limit_check.get("detail", {})
            raise HTTPException(
              status_code=http_status.HTTP_429_TOO_MANY_REQUESTS,
              detail=f"Rate limit exceeded: {detail.get('current', 0)}/{detail.get('limit', 0)} "
              f"requests per {detail.get('window', 0)} seconds",
              headers={
                "Retry-After": str(detail.get("window", 60)),
                "X-RateLimit-Limit": str(detail.get("limit", 0)),
                "X-RateLimit-Remaining": str(detail.get("remaining", 0)),
              },
            )
          elif reason == "repository_limit":
            detail = limit_check.get("detail", {})
            raise HTTPException(
              status_code=http_status.HTTP_429_TOO_MANY_REQUESTS,
              detail=f"{message}. Limit: {detail.get('limit', 0)} per {detail.get('window', 'period')}. "
              f"Upgrade for higher limits at https://roboledger.ai/pricing",
              headers={
                "Retry-After": str(detail.get("retry_after", 60)),
                "X-RateLimit-Repository": graph_id,
                "X-RateLimit-Plan": str(repo_access.repository_plan),
              },
            )
          else:
            raise HTTPException(
              status_code=http_status.HTTP_429_TOO_MANY_REQUESTS,
              detail=message,
            )
      finally:
        await redis_client.close()

    # Get repository
    operation_type = _get_mcp_operation_type(graph_id)
    repository = await get_graph_repository(graph_id, operation_type)

    # Log structured attempt with business context
    api_logger.info(
      f"MCP tool execution started: {tool_call.name}",
      extra={
        "component": "mcp_api",
        "action": "tool_started",
        "user_id": str(current_user.id),
        "database": graph_id,
        "tool_name": tool_call.name,
        "access_type": access_type,
        "is_write": is_write_query,
        "metadata": {
          "endpoint": "/v1/graphs/{graph_id}/mcp/call-tool",
          "arguments_size": len(str(tool_call.arguments)) if tool_call.arguments else 0,
        },
      },
    )

    # Detect client capabilities
    headers = dict(full_request.headers)
    client_info = MCPClientDetector.detect_client_type(headers)

    # Override for test mode
    if test_mode:
      client_info["is_mcp_client"] = False
      client_info["prefers_streaming"] = True

    # Get system state
    query_queue = get_query_queue()
    tool_stats = query_queue.get_stats()

    # For cypher queries, also check query queue
    if tool_call.name in [
      "read-graph-cypher",
      "read-neo4j-cypher",
      "read-ladybug-cypher",
    ]:
      query_queue = get_query_queue()
      query_stats = query_queue.get_stats()
      system_state = {
        "queue_size": tool_stats["queue_size"] + query_stats["queue_size"],
        "running_queries": tool_stats["running_queries"]
        + query_stats["running_queries"],
        "cache_available": True,
      }
    else:
      system_state = {
        "queue_size": tool_stats["queue_size"],
        "running_queries": tool_stats["running_queries"],
        "cache_available": True,
      }

    # Select execution strategy
    user_tier = None
    if hasattr(current_user, "subscription") and current_user.subscription:
      user_tier = (
        current_user.subscription.billing_plan.name
        if current_user.subscription.billing_plan
        else None
      )

    strategy = MCPStrategySelector.select_strategy(
      tool_name=tool_call.name,
      arguments=tool_call.arguments,
      client_info=client_info,
      system_state=system_state,
      graph_id=graph_id,
      user_tier=user_tier,
    )

    # Allow format override
    if format:
      if format == "sse":
        strategy = MCPExecutionStrategy.SSE_PROGRESS
      elif format == "ndjson":
        strategy = MCPExecutionStrategy.STREAM_AGGREGATED
      elif format == "json":
        strategy = MCPExecutionStrategy.JSON_COMPLETE

    # Log execution attempt (debug level for production to reduce verbosity)
    if env.ENVIRONMENT in ["staging", "prod"]:
      logger.debug(
        f"Executing MCP tool '{tool_call.name}' with strategy {strategy.value}",
        extra={
          "graph_id": graph_id,
          "user_id": str(current_user.id),
          "tool_name": tool_call.name,
          "strategy": strategy.value,
          "is_mcp_client": client_info.get("is_mcp_client", False),
        },
      )
    else:
      logger.info(
        f"Executing MCP tool '{tool_call.name}' with strategy {strategy.value}",
        extra={
          "graph_id": graph_id,
          "user_id": str(current_user.id),
          "tool_name": tool_call.name,
          "strategy": strategy.value,
          "is_mcp_client": client_info.get("is_mcp_client", False),
        },
      )

    # Execute based on strategy
    handler = MCPHandler(repository, graph_id, current_user)

    try:
      # Get timeout for strategy
      timeout = MCPStrategySelector.get_timeout_for_strategy(strategy)

      if strategy == MCPExecutionStrategy.JSON_IMMEDIATE:
        # Direct execution with immediate response
        result = await execute_tool_directly(handler, tool_call, timeout)
        await handler.close()

        return MCPToolResult(result=result)

      elif strategy == MCPExecutionStrategy.JSON_COMPLETE:
        # Execute and wait for complete result
        result = await execute_tool_directly(handler, tool_call, timeout)
        await handler.close()

        return MCPToolResult(result=result)

      elif strategy == MCPExecutionStrategy.STREAM_AGGREGATED:
        # Stream with transparent aggregation for MCP clients
        if client_info.get("is_mcp_client"):
          # For MCP clients, aggregate streaming results
          events = []
          async for event in stream_mcp_tool_execution(
            handler, tool_call.name, tool_call.arguments, strategy.value
          ):
            events.append(event)

          await handler.close()

          # Aggregate and return as JSON
          aggregated = aggregate_streamed_results(events)
          return MCPToolResult(result=aggregated)
        else:
          # For other clients, return NDJSON stream
          async def generate():
            try:
              async for line in stream_ndjson_response(
                stream_mcp_tool_execution(
                  handler, tool_call.name, tool_call.arguments, strategy.value
                )
              ):
                yield line
            finally:
              # Close handler after all streaming is complete
              if handler and not handler._closed:
                await handler.close()

          return StreamingResponse(generate(), media_type="application/x-ndjson")

      elif strategy == MCPExecutionStrategy.SSE_PROGRESS:
        # SSE streaming with progress updates
        async def generate_sse():
          try:
            async for event in stream_sse_response(
              full_request,
              stream_mcp_tool_execution(
                handler, tool_call.name, tool_call.arguments, strategy.value
              ),
            ):
              yield event
          finally:
            # Close handler after all streaming is complete
            if handler and not handler._closed:
              await handler.close()

        return EventSourceResponse(generate_sse())

      elif strategy in [
        MCPExecutionStrategy.QUEUE_WITH_MONITORING,
        MCPExecutionStrategy.QUEUE_SIMPLE,
      ]:
        # Queue for execution
        if tool_call.name in [
          "read-graph-cypher",
          "read-neo4j-cypher",
          "read-ladybug-cypher",
        ]:
          # Use query queue for cypher queries
          query: str = tool_call.arguments.get("query", "")  # type: ignore[assignment]
          parameters = tool_call.arguments.get("parameters", {})

          queue_manager = get_query_queue()
          queue_id = await queue_manager.submit_query(
            cypher=query,
            parameters=parameters,
            graph_id=graph_id,
            user_id=current_user.id,
            credits_required=10.0,  # Calculated by decorator
            priority=_get_user_priority(current_user),
          )

          # Create unified SSE operation for monitoring
          sse_response = await create_operation_response(
            operation_type="mcp_tool_call",
            user_id=current_user.id,
            graph_id=graph_id,
          )

          if strategy == MCPExecutionStrategy.QUEUE_WITH_MONITORING:
            # Return SSE stream for queue monitoring
            async def monitor_queue():
              while True:
                status = await queue_manager.get_query_status(queue_id)
                if not status:
                  break

                yield {"event": "queue_status", "data": status}

                if status["status"] in ["completed", "failed", "cancelled"]:
                  if status["status"] == "completed":
                    result = await queue_manager.get_query_result(queue_id)
                    yield {"event": "result", "data": result}
                  break

                await asyncio.sleep(1)

            await handler.close()
            return EventSourceResponse(monitor_queue())
          else:
            # Return queue info for polling
            await handler.close()
            return JSONResponse(
              status_code=http_status.HTTP_202_ACCEPTED,
              content={
                "queued": True,
                "operation_id": sse_response.operation_id,  # Unified SSE operation ID
                "monitor_url": f"/v1/operations/{sse_response.operation_id}/stream",  # Unified monitoring only
                "message": "Tool execution queued. Monitor via SSE endpoint.",
              },
            )
        else:
          # Execute other tools directly (non-Cypher tools don't need queuing)
          try:
            result = await handler.call_tool(tool_call.name, tool_call.arguments)
            await handler.close()
            return JSONResponse(
              status_code=http_status.HTTP_200_OK, content={"result": result}
            )
          except Exception as e:
            await handler.close()
            logger.error(f"Direct tool execution failed: {e}", exc_info=True)
            raise HTTPException(
              status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
              detail="Tool execution failed.",
            )

      elif strategy == MCPExecutionStrategy.SCHEMA_CACHED:
        cached = await _get_mcp_cache(graph_id, tool_call.name)
        if cached is not None:
          await handler.close()
          return MCPToolResult(result=cached)
        result = await execute_tool_directly(handler, tool_call, timeout)
        await handler.close()
        await _set_mcp_cache(graph_id, tool_call.name, result, _MCP_SCHEMA_CACHE_TTL)
        return MCPToolResult(result=result)

      elif strategy == MCPExecutionStrategy.INFO_CACHED:
        cached = await _get_mcp_cache(graph_id, tool_call.name)
        if cached is not None:
          await handler.close()
          return MCPToolResult(result=cached)
        result = await execute_tool_directly(handler, tool_call, timeout)
        await handler.close()
        await _set_mcp_cache(graph_id, tool_call.name, result, _MCP_INFO_CACHE_TTL)
        return MCPToolResult(result=result)

      else:
        # Fallback to direct execution
        result = await execute_tool_directly(handler, tool_call, timeout)
        await handler.close()
        return MCPToolResult(result=result)

    finally:
      # Ensure handler is closed (but not for streaming responses - they handle it themselves)
      is_streaming = strategy in [
        MCPExecutionStrategy.SSE_PROGRESS,
        MCPExecutionStrategy.SSE_STREAMING,
        MCPExecutionStrategy.NDJSON_STREAMING,
        MCPExecutionStrategy.STREAM_AGGREGATED,
      ]
      if handler and not handler._closed and not is_streaming:
        await handler.close()

      # Record success metrics
      execution_time = (datetime.now(UTC) - start_time).total_seconds() * 1000
      record_operation_metric(
        operation_type=OperationType.TOOL_EXECUTION,
        status=OperationStatus.SUCCESS,
        duration_ms=execution_time,
        endpoint=f"/v1/graphs/{graph_id}/mcp/call-tool",
        graph_id=graph_id,
        operation_name=tool_call.name,
        user_id=str(current_user.id),
        metadata={
          "strategy": strategy.value,
          "is_mcp_client": client_info.get("is_mcp_client", False),
        },
      )

      # Record success in circuit breaker
      circuit_breaker.record_success(graph_id, tool_call.name)

  except HTTPException:
    # Record failure metrics
    execution_time = (datetime.now(UTC) - start_time).total_seconds() * 1000
    record_operation_metric(
      operation_type=OperationType.TOOL_EXECUTION,
      status=OperationStatus.FAILURE,
      duration_ms=execution_time,
      endpoint=f"/v1/graphs/{graph_id}/mcp/call-tool",
      graph_id=graph_id,
      operation_name=tool_call.name,
      user_id=str(current_user.id),
      metadata={
        "error_type": "HTTPException",
      },
    )
    raise
  except Exception as e:
    # Record failure
    circuit_breaker.record_failure(graph_id, tool_call.name)

    logger.error(
      f"MCP tool execution failed: {e}",
      extra={
        "graph_id": graph_id,
        "user_id": str(current_user.id),
        "tool_name": tool_call.name,
        "error": str(e),
      },
    )

    # Record error metrics
    metrics_instance = get_endpoint_metrics()
    metrics_instance.record_business_event(
      endpoint="/v1/graphs/{graph_id}/mcp/call-tool",
      method="POST",
      event_type="mcp_tool_execution_failed",
      event_data={
        "graph_id": graph_id,
        "tool_name": tool_call.name,
        "error_type": type(e).__name__,
      },
      user_id=current_user.id,
    )

    raise HTTPException(
      status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
      detail="Tool execution failed",
    )
