"""Uniform error handling for MCP tools.""" from __future__ import annotations import functools import inspect from collections.abc import Callable from typing import Any import structlog from marketing_intelligence.evasion.detection import PageSignals from marketing_intelligence.mcp.tools.responses import PageToolResult, ToolResult logger = structlog.get_logger("mcp.tools") def _expected_errors() -> tuple[type[BaseException], ...]: errors: list[type[BaseException]] = [TimeoutError, OSError] try: from playwright.async_api import Error as PlaywrightError errors.append(PlaywrightError) except ImportError: pass return tuple(errors) _EXPECTED = _expected_errors() def _build_error( response_model: type[ToolResult], echo: tuple[str, ...], kind: str, exc: BaseException, bound: inspect.BoundArguments, ) -> ToolResult: fields = {name: bound.arguments[name] for name in echo} payload: dict[str, Any] = { "success": False, "error": f"{kind}: {exc}", **fields, } if issubclass(response_model, PageToolResult): block = "unknown" if kind == "scrape_failed" else "none" payload["signals"] = PageSignals(block_signal=block) return response_model(**payload) def tool_guard( response_model: type[ToolResult], echo: tuple[str, ...] = () ) -> Callable[[Any], Any]: """Wrap an MCP tool with uniform error handling. Expected failures (network, timeouts, browser) become an error envelope with block_signal="unknown". Unexpected exceptions are logged with traceback and returned WITHOUT a block signal so the agent does not waste retries on bugs. echo — names of tool params copied into the error response (tag, url, ...). """ def decorator(fn: Callable[..., Any]) -> Callable[..., Any]: sig = inspect.signature(fn) @functools.wraps(fn) async def wrapper(*args: Any, **kwargs: Any) -> Any: bound = sig.bind(*args, **kwargs) bound.apply_defaults() try: return await fn(*args, **kwargs) except _EXPECTED as e: logger.warning("tool.failed", tool=fn.__name__, error=str(e)[:200]) return _build_error(response_model, echo, "scrape_failed", e, bound) except NotImplementedError as e: logger.warning("tool.unsupported", tool=fn.__name__, error=str(e)[:200]) return _build_error(response_model, echo, "unsupported", e, bound) except Exception as e: logger.error("tool.crashed", tool=fn.__name__, exc_info=True) return _build_error(response_model, echo, "internal_error", e, bound) return wrapper return decorator