"""MCP tool-boundary error mapping for known Gitea client failures (#699). Known authentication / authorization / network / configuration failures must leave the tool boundary as a sanitized structured ``CallToolResult`` with ``isError=True``. The stdio transport must remain connected; callers must never observe EOF for recoverable auth-class defects. Unexpected exceptions are mapped to ``internal_error`` and are never labeled as authentication failures. """ from __future__ import annotations import json import logging import sys from typing import Any logger = logging.getLogger("gitea_mcp.tool_error_boundary") # Stable reason codes (issue #699 AC). REASON_AUTH_FAILED = "auth_failed" REASON_AUTH_INVALID_TOKEN = "auth_invalid_token" REASON_AUTHZ_INSUFFICIENT_SCOPE = "authz_insufficient_scope" REASON_NETWORK_ERROR = "network_error" REASON_CONFIG_ERROR = "config_error" REASON_INTERNAL_ERROR = "internal_error" ERROR_CLASS_AUTHENTICATION = "authentication" ERROR_CLASS_AUTHORIZATION = "authorization" ERROR_CLASS_NETWORK = "network" ERROR_CLASS_CONFIGURATION = "configuration" ERROR_CLASS_INTERNAL = "internal" # Tokens / secret substrings that must never appear in tool error text. _SECRET_MARKERS = ( "token ", "bearer ", "basic ", "authorization:", "password=", "keychain", ) def _redact_text(text: str) -> str: try: from gitea_auth import _redact return _redact(text) except Exception: return str(text) def _safe_message(message: str) -> str: """Redact secrets and drop obviously sensitive fragments.""" redacted = _redact_text(message or "") lower = redacted.lower() for marker in _SECRET_MARKERS: if marker in lower and marker.strip() not in ("keychain",): # Already redacted by gitea_auth; keep length bounded. break # Never echo raw multi-line bodies that might hold tokens. one_line = " ".join(redacted.split()) if len(one_line) > 400: one_line = one_line[:400] + "…" return one_line def classify_exception(exc: BaseException) -> dict[str, Any]: """Return a structured classification for *exc*. Only known auth/authz/network/config classes receive those labels. Everything else is ``internal_error`` — never silently rebranded as auth. """ # Lazy import avoids circular import at module load (gitea_auth imports # are safe; typed exceptions live there). import gitea_auth if isinstance(exc, gitea_auth.GiteaAuthError): return { "reason_code": getattr(exc, "reason_code", None) or REASON_AUTH_FAILED, "error_class": ERROR_CLASS_AUTHENTICATION, "http_status": getattr(exc, "http_status", None) or 401, "message": _safe_message(str(exc)), "transport_survives": True, } if isinstance(exc, gitea_auth.GiteaAuthzError): return { "reason_code": getattr(exc, "reason_code", None) or REASON_AUTHZ_INSUFFICIENT_SCOPE, "error_class": ERROR_CLASS_AUTHORIZATION, "http_status": getattr(exc, "http_status", None) or 403, "message": _safe_message(str(exc)), "transport_survives": True, } if isinstance(exc, gitea_auth.GiteaNetworkError): return { "reason_code": getattr(exc, "reason_code", None) or REASON_NETWORK_ERROR, "error_class": ERROR_CLASS_NETWORK, "http_status": getattr(exc, "http_status", None), "message": _safe_message(str(exc)), "transport_survives": True, } if isinstance(exc, gitea_auth.GiteaConfigError): return { "reason_code": getattr(exc, "reason_code", None) or REASON_CONFIG_ERROR, "error_class": ERROR_CLASS_CONFIGURATION, "http_status": getattr(exc, "http_status", None), "message": _safe_message(str(exc)), "transport_survives": True, } # gitea_config.ConfigError is configuration, not authentication. try: import gitea_config if isinstance(exc, gitea_config.ConfigError): return { "reason_code": REASON_CONFIG_ERROR, "error_class": ERROR_CLASS_CONFIGURATION, "http_status": None, "message": _safe_message(str(exc)), "transport_survives": True, } except Exception: pass # Heuristic fallback only for already-redacted RuntimeError messages that # historically used the plain "HTTP 401/403" form before typed exceptions. # Never treat arbitrary RuntimeError as auth. if isinstance(exc, RuntimeError): text = str(exc) lower = text.lower() if lower.startswith("http 401") or "invalid username, password or token" in lower: return { "reason_code": REASON_AUTH_INVALID_TOKEN, "error_class": ERROR_CLASS_AUTHENTICATION, "http_status": 401, "message": _safe_message(text), "transport_survives": True, } if "insufficient scope" in lower or ( lower.startswith("http 403") and "scope" in lower ): return { "reason_code": REASON_AUTHZ_INSUFFICIENT_SCOPE, "error_class": ERROR_CLASS_AUTHORIZATION, "http_status": 403, "message": _safe_message(text), "transport_survives": True, } if "network error contacting gitea" in lower: return { "reason_code": REASON_NETWORK_ERROR, "error_class": ERROR_CLASS_NETWORK, "http_status": None, "message": _safe_message(text), "transport_survives": True, } return { "reason_code": REASON_INTERNAL_ERROR, "error_class": ERROR_CLASS_INTERNAL, "http_status": None, "message": _safe_message(str(exc) or type(exc).__name__), "transport_survives": True, } def build_structured_error_payload( classification: dict[str, Any], *, tool_name: str | None = None, profile_name: str | None = None, ) -> dict[str, Any]: """LLM-safe structured payload for tool errors (no secrets).""" payload: dict[str, Any] = { "success": False, "isError": True, "reason_code": classification["reason_code"], "error_class": classification["error_class"], "message": classification["message"], "transport_survives": True, "retryable": classification["error_class"] in {ERROR_CLASS_AUTHENTICATION, ERROR_CLASS_NETWORK, ERROR_CLASS_CONFIGURATION}, } if classification.get("http_status") is not None: payload["http_status"] = classification["http_status"] if tool_name: payload["tool"] = tool_name if profile_name: payload["profile"] = profile_name return payload def log_sanitized_daemon_reason( classification: dict[str, Any], *, tool_name: str | None = None, stream=None, ) -> None: """Write an actionable, secret-free reason line to the daemon log.""" stream = stream if stream is not None else sys.stderr parts = [ "mcp_tool_error", f"reason_code={classification.get('reason_code')}", f"error_class={classification.get('error_class')}", ] if tool_name: parts.append(f"tool={tool_name}") status = classification.get("http_status") if status is not None: parts.append(f"http_status={status}") # Message already sanitized; still scan for secret markers. msg = _safe_message(str(classification.get("message") or "")) for marker in ("token ", "Bearer ", "Basic ", "password="): if marker.lower() in msg.lower(): msg = "[redacted]" break parts.append(f"detail={msg}") line = " ".join(parts) try: stream.write(line + "\n") if hasattr(stream, "flush"): stream.flush() except Exception: pass logger.warning(line) def to_call_tool_result( exc: BaseException, *, tool_name: str | None = None, profile_name: str | None = None, log: bool = True, ) -> Any: """Build a FastMCP ``CallToolResult`` with ``isError=True`` for *exc*.""" from mcp.types import CallToolResult, TextContent classification = classify_exception(exc) if log: log_sanitized_daemon_reason(classification, tool_name=tool_name) payload = build_structured_error_payload( classification, tool_name=tool_name, profile_name=profile_name ) text = json.dumps(payload, indent=2, sort_keys=True) return CallToolResult( content=[TextContent(type="text", text=text)], structuredContent=payload, isError=True, ) def is_known_client_failure(exc: BaseException) -> bool: """True when *exc* is a known classified client failure (not internal).""" classification = classify_exception(exc) return classification["error_class"] != ERROR_CLASS_INTERNAL or isinstance( exc, RuntimeError ) def install_tool_run_boundary(Tool) -> None: """Patch FastMCP ``Tool.run`` so failures become structured isError results. Auth/authz/network/config failures carry their reason codes. Unexpected exceptions map to ``internal_error`` — never reclassified as auth. The stdio transport receives ``CallToolResult(isError=True)`` instead of an unhandled raise path that some hosts surface as EOF (#699). """ if getattr(Tool.run, "_gitea_auth_boundary_installed", False): return original_run = Tool.run async def run_boundary( self, arguments: dict[str, Any], context=None, convert_result: bool = False, ) -> Any: try: result = await self.fn_metadata.call_fn_with_arg_validation( self.fn, self.is_async, arguments, {self.context_kwarg: context} if self.context_kwarg is not None else None, ) if convert_result: result = self.fn_metadata.convert_result(result) return result except Exception as exc: profile_name = None try: from gitea_auth import get_profile profile_name = (get_profile() or {}).get("profile_name") except Exception: profile_name = None # Always return structured isError CallToolResult so stdio survives. return to_call_tool_result( exc, tool_name=getattr(self, "name", None), profile_name=profile_name, ) run_boundary._gitea_auth_boundary_installed = True # type: ignore[attr-defined] run_boundary._gitea_auth_boundary_original = original_run # type: ignore[attr-defined] Tool.run = run_boundary # type: ignore[method-assign]