"""Sanctioned MCP client reconnect request surface for Codex/LLM sessions (#678). Codex and other agent hosts can detect stale or closed Gitea MCP runtimes, but the host owns the transport. This module never restarts, kills, or reloads a daemon. It builds: 1. A **callable reconnect request** result agents can invoke via ``gitea_request_mcp_reconnect`` (report-only, side-effect free). 2. A **typed blocker** with exact operator UI steps when recovery must be performed by the host/operator. Forbidden recovery paths (must never be recommended): * ``pkill`` / ``kill`` / ``killall`` of MCP daemons * ``touch`` / mtime config reload hacks * ``.env`` or MCP config edits as recovery * session-state file edits * raw Gitea API / direct server-import fallbacks After the operator reconnects, workflows restart from identity / runtime / capability preflight (``gitea_whoami`` → ``gitea_resolve_task_capability`` → task). """ from __future__ import annotations from typing import Any, Mapping # --- Reason vocabulary ------------------------------------------------------- REASON_STALE_RUNTIME = "stale-runtime" REASON_TRANSPORT_EOF = "transport_eof" REASON_MISSING_NAMESPACE = "missing_namespace" REASON_NOT_REQUIRED = "not_required" REASON_UNSPECIFIED = "unspecified" VALID_REASONS = frozenset( { REASON_STALE_RUNTIME, REASON_TRANSPORT_EOF, REASON_MISSING_NAMESPACE, REASON_NOT_REQUIRED, REASON_UNSPECIFIED, } ) # Boundary statuses reported to callers (match review_workflow_boundary style). BOUNDARY_CLEAN = "clean" BOUNDARY_MISMATCH = "mismatch" BOUNDARY_STALE = "stale" BOUNDARY_UNKNOWN = "unknown" # Typed blocker kinds BLOCKER_OPERATOR_RECONNECT = "operator_mcp_reconnect_required" BLOCKER_NONE = "none" FORBIDDEN_RECOVERY_PATHS: tuple[str, ...] = ( "pkill / kill / killall of mcp_server.py, gitea_mcp_server, or broad python sweeps", "touch / mtime-based MCP config reload hacks", ".env edits as recovery", "MCP config file edits as recovery", "session-state file edits as recovery", "raw Gitea API or direct MCP server-import fallbacks", ) # Client-specific operator UI steps. Keep Codex first (issue title surface). OPERATOR_UI_STEPS: dict[str, tuple[str, ...]] = { "codex": ( "In Codex, open the MCP / Developer tools panel for this workspace.", "Locate the named Gitea MCP server entry (namespace) that needs reconnect " "(e.g. gitea-author, gitea-reviewer, gitea-merger, gitea-tools, " "gitea-controller, gitea-reconciler).", "Click 'Reload Developer Tools' or the server reconnect/reload control " "for that entry so the client spawns a fresh MCP subprocess.", "If per-server reconnect is unavailable, fully restart the Codex client " "(quit and relaunch) so all MCP namespaces reattach.", "After reconnect, rerun the blocked workflow from preflight: " "gitea_whoami → gitea_resolve_task_capability → the original task. " "Do not resume mid-mutation.", ), "claude_code": ( "Run `/mcp` (or open the MCP servers UI) in Claude Code.", "Reconnect the affected gitea-* server entry so the client reopens stdio.", "If reconnect fails, relaunch the Claude Code session entirely.", "After reconnect, restart the workflow from gitea_whoami → " "gitea_resolve_task_capability → task.", ), "generic": ( "Use the host/IDE MCP reconnect or reload control for the named namespace.", "If no per-namespace control exists, restart the MCP client/editor.", "After reconnect, restart the workflow from identity/capability preflight.", ), } DEFAULT_CLIENT = "codex" def normalize_reason(reason: str | None) -> str: """Map free-form reason strings onto the closed vocabulary.""" raw = (reason or "").strip().lower() if not raw: return REASON_UNSPECIFIED if raw in VALID_REASONS: return raw text = raw.replace(" ", "_").replace("-", "_") aliases = { "stale_runtime": REASON_STALE_RUNTIME, "staleruntime": REASON_STALE_RUNTIME, "runtime_stale": REASON_STALE_RUNTIME, "stale": REASON_STALE_RUNTIME, "transport_eof": REASON_TRANSPORT_EOF, "transport_closed": REASON_TRANSPORT_EOF, "eof": REASON_TRANSPORT_EOF, "client_is_closing": REASON_TRANSPORT_EOF, "missing_namespace": REASON_MISSING_NAMESPACE, "namespace_missing": REASON_MISSING_NAMESPACE, "not_required": REASON_NOT_REQUIRED, "healthy": REASON_NOT_REQUIRED, "ok": REASON_NOT_REQUIRED, "unspecified": REASON_UNSPECIFIED, } if text in aliases: return aliases[text] hyphenated = text.replace("_", "-") if hyphenated in VALID_REASONS: return hyphenated return REASON_UNSPECIFIED def normalize_client(client: str | None) -> str: """Return a known client key for operator UI steps.""" text = (client or "").strip().lower().replace(" ", "_").replace("-", "_") if text in ("codex", "openai_codex", "openai"): return "codex" if text in ("claude", "claude_code", "claude_desktop", "anthropic"): return "claude_code" if text in OPERATOR_UI_STEPS: return text return DEFAULT_CLIENT def classify_boundary_status( *, startup_sha: str | None, current_master_sha: str | None, live_stale: bool | None = None, in_parity: bool | None = None, ) -> str: """Derive boundary_status from parity evidence.""" if live_stale is True or in_parity is False: return BOUNDARY_STALE start = (startup_sha or "").strip().lower() current = (current_master_sha or "").strip().lower() if start and current and start != current: return BOUNDARY_MISMATCH if start and current and start == current: return BOUNDARY_CLEAN if in_parity is True: return BOUNDARY_CLEAN return BOUNDARY_UNKNOWN def operator_ui_steps(client: str | None, *, namespace: str | None = None) -> list[str]: """Exact operator UI steps for the named client.""" key = normalize_client(client) steps = list(OPERATOR_UI_STEPS.get(key) or OPERATOR_UI_STEPS[DEFAULT_CLIENT]) ns = (namespace or "").strip() if ns: steps = [ s.replace("named Gitea MCP server entry (namespace)", f"namespace '{ns}'") .replace("affected gitea-* server entry", f"server entry '{ns}'") .replace("named namespace", f"namespace '{ns}'") for s in steps ] return steps def build_reconnect_request( *, namespace: str, profile: str | None = None, pid: int | str | None = None, session_id: str | None = None, startup_sha: str | None = None, current_master_sha: str | None = None, boundary_status: str | None = None, reason: str | None = None, client: str | None = DEFAULT_CLIENT, live_stale: bool | None = None, in_parity: bool | None = None, restart_required: bool | None = None, stop_required: bool | None = None, extra: Mapping[str, Any] | None = None, ) -> dict[str, Any]: """Build the structured reconnect-request / typed-blocker payload (#678). Never mutates process, config, or session state. Always side-effect free. """ ns = (namespace or "").strip() or "unknown" normalized_reason = normalize_reason(reason) boundary = (boundary_status or "").strip() or classify_boundary_status( startup_sha=startup_sha, current_master_sha=current_master_sha, live_stale=live_stale, in_parity=in_parity, ) reconnect_needed = True if normalized_reason == REASON_NOT_REQUIRED and boundary == BOUNDARY_CLEAN: reconnect_needed = False if restart_required is False and stop_required is False and boundary == BOUNDARY_CLEAN: # Explicit healthy probe if normalized_reason in (REASON_NOT_REQUIRED, REASON_UNSPECIFIED): reconnect_needed = False normalized_reason = REASON_NOT_REQUIRED if restart_required is True or stop_required is True: reconnect_needed = True if normalized_reason in (REASON_NOT_REQUIRED, REASON_UNSPECIFIED): normalized_reason = REASON_STALE_RUNTIME client_key = normalize_client(client) steps = operator_ui_steps(client_key, namespace=ns) result: dict[str, Any] = { "success": True, "read_only": True, "reconnect_performed": False, "mutation_performed": False, "reconnect_needed": reconnect_needed, "namespace": ns, "profile": (profile or "").strip() or None, "pid": pid, "session_id": (session_id or "").strip() or None, "startup_sha": (startup_sha or "").strip() or None, "current_master_sha": (current_master_sha or "").strip() or None, "boundary_status": boundary, "reason": normalized_reason, "client": client_key, "forbidden_recovery_paths": list(FORBIDDEN_RECOVERY_PATHS), "post_reconnect_preflight": [ "gitea_whoami", "gitea_resolve_task_capability", "original_task", ], "exact_safe_next_action": None, "blocker_kind": BLOCKER_NONE, "operator_ui_steps": steps, "typed_blocker": None, } if reconnect_needed: result["blocker_kind"] = BLOCKER_OPERATOR_RECONNECT result["stop_required"] = True result["restart_required"] = True result["exact_safe_next_action"] = ( f"blocker_kind={BLOCKER_OPERATOR_RECONNECT}: operator must reconnect " f"MCP namespace '{ns}' via the host UI (client={client_key}). " "Do not pkill, touch configs, edit session state, or use raw API. " "After reconnect, restart from gitea_whoami → " "gitea_resolve_task_capability → task." ) result["typed_blocker"] = { "blocker_kind": BLOCKER_OPERATOR_RECONNECT, "namespaces": [ns], "why_reconnect_required": normalized_reason, "operator_ui_steps": steps, "client": client_key, "forbidden_recovery_paths": list(FORBIDDEN_RECOVERY_PATHS), "instruction_after_reconnect": ( "Rerun the blocked workflow from preflight " "(gitea_whoami → gitea_resolve_task_capability → task). " "Do not continue mid-mutation from pre-reconnect state." ), } else: result["stop_required"] = False result["restart_required"] = False result["exact_safe_next_action"] = ( f"Reconnect not required for namespace '{ns}' " f"(boundary_status={boundary}). Proceed with the original task." ) if extra: for key, value in extra.items(): if key not in result: result[key] = value return result def reasons_never_suggest_forbidden(text: str) -> bool: """Return True when *text* does not recommend a forbidden recovery path. Mentions that *ban* a path (e.g. ``Do not pkill`` / ``never edit session state``) are allowed. Positive recommendations such as ``use pkill`` or ``run killall`` fail. """ import re lowered = (text or "").lower() # Strip common ban prefixes so "do not pkill" does not trip positive checks. scrubbed = re.sub( r"\b(?:do not|don't|never|must not|forbid(?:den)?|ban(?:ned)?)\b" r"[^.!;\n]{0,80}", " ", lowered, ) # Positive imperative / advisory forms that would tell an agent to do harm. positive_suggestions = ( "use pkill", "run pkill", "try pkill", "pkill -f", "use killall", "run killall", "killall mcp", "use kill ", "run kill ", "touch the mcp", "touch mcp config", "utime(", "edit the mcp config to recover", "edit .env to recover", "import gitea_mcp_server", "python -c 'import gitea_mcp", ) return not any(frag in scrubbed for frag in positive_suggestions)