"""Assess live MCP namespace health without trusting static registration. The IDE/client namespace can fail with EOF even when this Python process still registers the Gitea tools with FastMCP. These helpers keep that distinction explicit so reviewer/merger flows can fail closed on the live path. Probe sources ------------- * ``client_namespace`` — evidence from the IDE-managed MCP client path (the only source that can prove the workflow namespace is healthy). * ``offline_spawn`` — a separate ``subprocess.Popen`` JSON-RPC handshake (e.g. ``test_mcp_conn.py``). Useful offline, but **never** proves the IDE-managed namespace is callable. * ``unknown`` — legacy/unspecified; treated as not IDE-proven. """ from __future__ import annotations from typing import Any REQUIRED_NAMESPACE_TOOLS = { "gitea-author": "gitea_whoami", "gitea-reviewer": "gitea_whoami", "gitea-merger": "gitea_whoami", "gitea-tools": "gitea_list_profiles", } DEFAULT_NAMESPACES = tuple(REQUIRED_NAMESPACE_TOOLS) # Namespaces that must be healthy for a given mutation task. TASK_REQUIRED_NAMESPACES = { "review_pr": "gitea-reviewer", "submit_review": "gitea-reviewer", "merge_pr": "gitea-merger", } PROBE_SOURCE_CLIENT = "client_namespace" PROBE_SOURCE_OFFLINE = "offline_spawn" PROBE_SOURCE_UNKNOWN = "unknown" VALID_PROBE_SOURCES = frozenset( {PROBE_SOURCE_CLIENT, PROBE_SOURCE_OFFLINE, PROBE_SOURCE_UNKNOWN} ) EOF_PATTERNS = ( "client is closing: eof", "transport closed", "connection closed", "broken pipe", "end of file", "eof", ) SAFE_ENV_KEYS = ( "GITEA_MCP_PROFILE", "GITEA_PROFILE_NAME", "GITEA_SERVICE", "GITEA_EXECUTION_ROLE", "GITEA_MCP_CONFIG", ) def _as_list(value: Any) -> list[str] | None: if value is None: return None if isinstance(value, (list, tuple, set)): return [str(v) for v in value] return [str(value)] def _contains_eof(text: str | None) -> bool: lowered = (text or "").lower() return any(pattern in lowered for pattern in EOF_PATTERNS) def _normalize_probe_source(probe_source: str | None) -> str: raw = (probe_source or PROBE_SOURCE_UNKNOWN).strip().lower() if raw in VALID_PROBE_SOURCES: return raw return PROBE_SOURCE_UNKNOWN def _safe_env_summary(process: dict[str, Any] | None) -> dict[str, str]: if not process: return {} env = process.get("env") or process.get("environment") or {} if not isinstance(env, dict): return {} return { key: str(env[key]) for key in SAFE_ENV_KEYS if key in env and env[key] not in (None, "") } def classify_namespace_probe( namespace: str, *, required_tool: str | None = None, registered_tools: list[str] | tuple[str, ...] | set[str] | None = None, probe_result: dict[str, Any] | None = None, process: dict[str, Any] | None = None, config_path: str | None = None, profile: str | None = None, configured: bool = True, probe_source: str | None = None, ) -> dict[str, Any]: """Classify whether a required tool is callable through a live namespace. ``registered_tools`` is static/server-side evidence. ``probe_result`` is live invocation evidence. Only ``probe_source=client_namespace`` proves the IDE-managed path; ``offline_spawn`` is an offline subprocess check only. """ ns = (namespace or "").strip() tool = required_tool or REQUIRED_NAMESPACE_TOOLS.get(ns) or "gitea_whoami" source = _normalize_probe_source(probe_source) registered_list = _as_list(registered_tools) registered = None if registered_list is None else tool in registered_list probe = probe_result or {} probe_success = bool(probe.get("success")) error_message = str( probe.get("error") or probe.get("message") or probe.get("stderr") or probe.get("exception") or "" ) error_type = str(probe.get("error_type") or "").strip() if not error_type and error_message: if _contains_eof(error_message): error_type = "namespace_eof" elif "timeout" in error_message.lower(): error_type = "namespace_timeout" else: error_type = "namespace_call_failed" if not configured: error_type = "namespace_not_configured" elif registered is False: error_type = "tool_missing" elif not probe_result: error_type = "live_probe_missing" elif not probe_success and not error_type: error_type = "namespace_call_failed" callable_live = bool(configured and probe_result and probe_success) # Probe-path health (spawn or client). IDE-proven only for client path. healthy = bool(configured and registered is not False and callable_live) ide_namespace_proven = bool(healthy and source == PROBE_SOURCE_CLIENT) process_pid = process.get("pid") if isinstance(process, dict) else None profile_name = profile or ( process.get("profile") if isinstance(process, dict) else None ) env_summary = _safe_env_summary(process) reasons: list[str] = [] if not configured: reasons.append(f"MCP namespace '{ns}' is not configured.") if registered is False: reasons.append( f"Required tool '{tool}' is not registered in namespace '{ns}'." ) if error_type == "live_probe_missing": reasons.append( f"No live client invocation proof was supplied for '{ns}.{tool}'." ) elif error_type == "namespace_eof": reasons.append( f"Live MCP namespace '{ns}' returned EOF while invoking '{tool}'." ) elif error_type == "namespace_timeout": reasons.append( f"Live MCP namespace '{ns}' timed out while invoking '{tool}'." ) elif error_type == "namespace_call_failed": reasons.append( f"Live MCP namespace '{ns}' failed while invoking '{tool}'." ) if source == PROBE_SOURCE_OFFLINE: reasons.append( "Probe source is offline_spawn (subprocess JSON-RPC); this does " "not prove the IDE-managed MCP namespace is healthy." ) elif source == PROBE_SOURCE_UNKNOWN and probe_result: reasons.append( "Probe source unspecified; treat as not IDE-namespace proof unless " "re-supplied with probe_source='client_namespace'." ) remediation = [] if not healthy or not ide_namespace_proven: remediation.append( "Reconnect the IDE MCP client namespace (client reconnect / " f"relaunch), then invoke '{tool}' through namespace '{ns}' and " "record the result with probe_source='client_namespace'." ) remediation.append( "Do not treat offline subprocess probes (test_mcp_conn.py) or " "shell kill/PID respawn as proof the IDE namespace is repaired." ) if process_pid and source != PROBE_SOURCE_OFFLINE: remediation.append( f"Diagnostics may include PID {process_pid}; process details " "are informational only — recovery is client-layer reconnect." ) else: remediation.append( f"IDE-managed namespace '{ns}' can invoke '{tool}' " f"(probe_source={source})." ) # Client-namespace broken health blocks review/merge. Offline probes never # authorize mutations and only block when they report unhealthy (still # fail-closed for known bad spawn evidence). blocks = False if source == PROBE_SOURCE_CLIENT: blocks = namespace_health_blocks_task("merge_pr", healthy) elif source == PROBE_SOURCE_OFFLINE: # Offline never proves IDE health; never unblock. Unhealthy offline # still surfaces as a soft diagnostic, not a mutation-ledger block. blocks = False else: # Unknown source: only block when evidence is unhealthy (fail closed # on bad data without treating success as IDE proof). blocks = namespace_health_blocks_task("merge_pr", healthy) return { "success": healthy, "healthy": healthy, "namespace": ns, "required_tool": tool, "configured": configured, "registered_tools_checked": registered_list is not None, "required_tool_registered": registered, "required_tool_callable": callable_live, "probe_source": source, "ide_namespace_proven": ide_namespace_proven, "error_type": None if healthy else error_type, "error_message": error_message or None, "reasons": reasons, "remediation": remediation, "diagnostics": { "namespace": ns, "required_tool": tool, "process_pid": process_pid, "profile": profile_name, "env": env_summary, "config_path": config_path, "probe_source": source, }, "blocks_merge_workflow": blocks, } def namespace_health_blocks_task(task: str, healthy: bool) -> bool: """Return whether a broken namespace must block a workflow task.""" if healthy: return False return (task or "").strip() in {"merge_pr", "review_pr", "submit_review"} def required_namespace_for_task(task: str) -> str | None: """Map a mutation task to the MCP namespace that must be healthy.""" return TASK_REQUIRED_NAMESPACES.get((task or "").strip()) def mutation_gate_from_session( task: str, session_health: dict[str, dict[str, Any]] | None, ) -> list[str]: """Fail-closed gate using recorded client-namespace health assessments. * Missing session entry → no gate (caller has not assessed yet). * Client-namespace unhealthy / not IDE-proven → block mutation. * Offline-only session entries never authorize mutations. """ ns = required_namespace_for_task(task) if not ns: return [] store = session_health or {} entry = store.get(ns) if not entry: return [] source = _normalize_probe_source(entry.get("probe_source")) if source != PROBE_SOURCE_CLIENT: return [ f"recorded namespace health for '{ns}' is probe_source={source}, " "not client_namespace; re-probe through the IDE-managed path " f"before {(task or 'mutation')}" ] if entry.get("ide_namespace_proven") and entry.get("healthy"): return [] if entry.get("blocks_merge_workflow") or not entry.get("healthy"): detail = entry.get("error_type") or "unhealthy" return [ f"live MCP namespace '{ns}' is recorded {detail} " f"(probe_source={source}); repair the IDE namespace before " f"{(task or 'mutation')} (fail closed, #543)" ] if not entry.get("ide_namespace_proven"): return [ f"live MCP namespace '{ns}' is not IDE-proven; supply a " "client_namespace probe before mutation (fail closed, #543)" ] return []