From ebd06b73c97cee12101a162b1e0456066d592ff1 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Thu, 9 Jul 2026 18:44:04 -0400 Subject: [PATCH] fix: address PR #587 REQUEST_CHANGES for MCP namespace health (#543) - Distinguish client_namespace vs offline_spawn probe sources; only IDE client probes prove namespace health and feed mutation gates. - Record assessments in session; gate gitea_submit_pr_review and gitea_merge_pr when client-namespace health is unhealthy/non-proven. - Mark test_mcp_conn.py offline-only; align recovery docs to client reconnect (no PID-kill/config-touch as canonical recovery). - Rebase onto current master so #590 ledger isolation keeps TestMergePR.test_unknown_profile_blocks green. --- docs/mcp-namespace-eof-recovery.md | 47 ++++++---- docs/mcp-namespace-health.md | 110 +++++++++++++---------- gitea_mcp_server.py | 59 ++++++++++-- mcp_namespace_health.py | 138 ++++++++++++++++++++++++++--- test_mcp_conn.py | 23 +++-- tests/conftest.py | 1 + tests/test_mcp_namespace_health.py | 57 +++++++++++- 7 files changed, 341 insertions(+), 94 deletions(-) diff --git a/docs/mcp-namespace-eof-recovery.md b/docs/mcp-namespace-eof-recovery.md index 7f79023..3bd3e96 100644 --- a/docs/mcp-namespace-eof-recovery.md +++ b/docs/mcp-namespace-eof-recovery.md @@ -21,7 +21,8 @@ not a missing or broken tool: - The tool can be present and registered in the Python `FastMCP` tool manager. - Direct Python inspection of the server confirms the tool exists. -- Running the server manually and sending JSON-RPC over stdio works fine. +- Running the server manually and sending JSON-RPC over stdio works fine + (offline spawn) — that path does **not** prove the IDE namespace is healthy. The client manager entered a closed state after the backing subprocess for that namespace terminated (or was killed) behind its back. Once closed, the client @@ -36,9 +37,9 @@ detected by the `ps`-based `_check_mcp_runtimes_diagnostics` in `gitea_mcp_server.py`. The EOF case is a dead/closed transport, not a stale one, so the `ps` check alone will not surface it. -## Recovery path +## Recovery path (canonical — client reconnect only) -Do the steps in order. Stop as soon as a live namespace call succeeds. +Do the steps in order. Stop as soon as a live **client-namespace** call succeeds. 1. **Confirm the blast radius.** Call a cheap read tool on the failing namespace (`gitea_whoami` or `gitea_list_profiles`). Then call the same tool on a @@ -53,22 +54,28 @@ Do the steps in order. Stop as soon as a live namespace call succeeds. closed-client state that a bare `kill`/respawn from a terminal does **not**. 3. **Do not "fix" it by importing the server or poking the process.** Reaching - for `python -c 'import gitea_mcp_server ...'`, raw JSON-RPC from a shell, or - killing PIDs to force a respawn does not restore the *client's* view of the - namespace and violates the daemon-import guard (#558, - `docs/mcp-daemon-import-guard.md`). The only sanctioned repair is a client - reconnect / relaunch. + for `python -c 'import gitea_mcp_server ...'`, raw JSON-RPC from a shell, + killing PIDs to force a respawn, or touching MCP config mtimes does **not** + restore the *client's* view of the namespace and violates the daemon-import + guard (#558, `docs/mcp-daemon-import-guard.md`). The only sanctioned repair + is a **client reconnect / relaunch**. 4. **Verify through the same path the workflow will use.** After reconnect, call the specific tool the blocked workflow needs — not just any tool — through the target namespace. For a merge that means calling the merger-authorized adoption/merge tool through `gitea-merger`. A green `gitea_whoami` on one namespace does **not** prove another namespace or another tool is callable. + Record success with: + + ```text + gitea_assess_mcp_namespace_health(..., probe_source="client_namespace") + ``` 5. **If reconnect does not clear it,** relaunch the client entirely, then repeat step 4. If EOF persists after a full relaunch, the backing subprocess is failing to start — inspect its stderr / launch config (command path, venv, - `*_MCP_CONFIG`, `*_MCP_PROFILE` env) rather than retrying the call. + `*_MCP_CONFIG`, `*_MCP_PROFILE` env) rather than retrying the call. Still + do not use PID kill or config-touch as the primary recovery. ## Diagnostics to capture when reporting EOF @@ -77,33 +84,35 @@ Include all of these so the failure is actionable and reproducible: - **Namespace name** that returned EOF (`gitea-author` / `gitea-reviewer` / `gitea-merger` / `gitea-tools`). - **Tool** that was called and the **exact** error string. -- **PID** of the backing process (if any) and whether it was still alive. +- **PID** of the backing process (if any) and whether it was still alive + (informational only — not a recovery action). - **Profile / env** for that namespace (execution profile, `*_MCP_PROFILE`, worktree binding such as `GITEA_AUTHOR_WORKTREE`). - **Config path** the client launched the server from. - Result of the **cross-server control** call (did `context7` succeed?). -## Reproducing the registered-vs-callable gap +## Offline spawn probe (non-authoritative) -`test_mcp_conn.py` performs a full JSON-RPC handshake -(`initialize` → `initialized` → `tools/list`) plus a direct tool call against a -namespace, which is what distinguishes "registered in FastMCP" from "callable -through the client." Use it to confirm a namespace is genuinely reachable after -a reconnect. It probes a namespace end-to-end; a namespace that returns EOF at -the client fails this handshake, while `ps`-only checks still report the process -as present. +`test_mcp_conn.py` performs a full JSON-RPC handshake against a **fresh +subprocess** (`initialize` → `initialized` → `tools/list` → `tools/call`) and +classifies with `probe_source=offline_spawn`. That is useful for offline +launch/registration debugging. It is **not** proof the IDE-managed namespace is +healthy. See `docs/mcp-namespace-health.md`. ## Do-not list during EOF recovery - Do **not** retry a blocked merge/adoption until the required tool is confirmed - callable through the merger-authorized namespace (see #543 canonical handoff). + callable through the merger-authorized **client** namespace (see #543). - Do **not** clean, reset, or rebind a **foreign** worktree to work around the error. - Do **not** bypass the namespace with direct imports, raw API/curl, or in-memory state restoration. +- Do **not** kill MCP PIDs or touch config mtimes as a substitute for client + reconnect. ## Related - #531 / #544 — stale-runtime detection (`ps`-based); sibling failure mode. - #558 / `docs/mcp-daemon-import-guard.md` — why shell imports are not a repair. - `docs/mcp-client-registration.md` — per-server registration contract. +- `docs/mcp-namespace-health.md` — probe sources and mutation enforcement. diff --git a/docs/mcp-namespace-health.md b/docs/mcp-namespace-health.md index 206446a..17ed150 100644 --- a/docs/mcp-namespace-health.md +++ b/docs/mcp-namespace-health.md @@ -1,4 +1,4 @@ -# MCP namespace EOF recovery +# MCP namespace health diagnostics (#543) Gitea MCP tools can be registered in the Python FastMCP server while the IDE's live MCP namespace is still unusable. The failure usually appears as @@ -6,19 +6,54 @@ live MCP namespace is still unusable. The failure usually appears as a tool such as `gitea_whoami`. Do not treat static tool registration as proof that review or merge workflows -can proceed. A reviewer or merger flow must have live namespace evidence that -the required tool is callable through the configured namespace. +can proceed. A reviewer or merger flow must have **client-namespace** evidence +that the required tool is callable through the configured IDE MCP namespace. -## Required namespace probes +## Probe sources (do not confuse them) -Run the health check after changing MCP config, switching branches that affect -server code, or seeing EOF from any Gitea namespace: +| Source | How obtained | Proves IDE namespace? | +| --- | --- | --- | +| `client_namespace` | Tool call through the IDE-managed MCP client | **Yes** | +| `offline_spawn` | `test_mcp_conn.py` subprocess JSON-RPC handshake | **No** (offline only) | + +`gitea_assess_mcp_namespace_health` accepts `probe_source` and only sets +`ide_namespace_proven=true` for `client_namespace` success. Offline spawn +success never clears review/merge mutation gates. + +## Client-namespace health check (canonical) + +1. Through the IDE client, call a cheap tool on the target namespace + (`gitea_whoami` or `gitea_list_profiles`). +2. Feed the live result into: + +```text +gitea_assess_mcp_namespace_health( + namespace="gitea-merger", # or reviewer / author / tools + registered_tools=[...], # optional static list + probe_result={"success": true, "result": {...}}, + probe_source="client_namespace", +) +``` + +3. A healthy client-namespace assessment is recorded in the MCP session and + consulted by **live** `gitea_submit_pr_review` / `gitea_merge_pr` gates. +4. If the probe fails with EOF, recover via **client reconnect only** — see + `docs/mcp-namespace-eof-recovery.md`. Do **not** kill PIDs or touch MCP + config mtimes as a recovery procedure. + +## Offline spawn probe (non-authoritative) ```bash python3 test_mcp_conn.py --config ~/.gemini/config/mcp_config.json ``` -By default the script checks these namespaces and required tools: +This script spawns a **separate** server process from config, performs +JSON-RPC `initialize` → `tools/list` → `tools/call`, and classifies the +result with `probe_source=offline_spawn`. Use it for offline debugging of +launch command / registration. It does **not** prove the IDE-managed +namespace is healthy. + +By default the script checks: | Namespace | Required tool | | --- | --- | @@ -27,50 +62,27 @@ By default the script checks these namespaces and required tools: | `gitea-merger` | `gitea_whoami` | | `gitea-tools` | `gitea_list_profiles` | -Use `--namespace gitea-reviewer` to test only one namespace. A passing probe -requires: +## Recovery (canonical) -1. The namespace exists in the MCP config. -2. JSON-RPC initialize succeeds. -3. `tools/list` returns the required tool. -4. `tools/call` successfully invokes the required tool. +When a namespace returns EOF, follow +`docs/mcp-namespace-eof-recovery.md` in order: -## Recovery steps +1. Confirm blast radius (Gitea namespace vs all MCP servers). +2. **Reconnect the namespace through the client** (IDE reconnect / relaunch). +3. Do not repair via shell imports, raw JSON-RPC, PID kills, or config mtime + touches — those do not restore the client's closed transport. +4. Re-verify the **specific** required tool through the target namespace. +5. Resume review/merge only after a successful `client_namespace` assessment. -When a namespace returns EOF: +## Enforcement -1. Note the reported namespace, required tool, PID, profile, environment - summary, and config path. -2. Stop any stale MCP server process for that PID. -3. Reload or touch the MCP config so the IDE reconnects the namespace. -4. Verify the namespace command, args, profile env, and checkout path point at - the current repository. -5. Re-run `python3 test_mcp_conn.py --namespace `. -6. Resume review or merge work only after the required tool call passes. +1. **State machine (read-only):** feed `blocks_merge_workflow` from a + `client_namespace` assessment into + `gitea_assess_review_merge_state_machine(live_namespace_broken=...)`. +2. **Live mutations:** `gitea_submit_pr_review` and `gitea_merge_pr` call + `_live_namespace_health_gate` and fail closed when the session has a + recorded unhealthy or non-client probe for the required namespace + (`gitea-reviewer` for review, `gitea-merger` for merge). -The read-only `gitea_assess_mcp_namespace_health` tool can classify probe -evidence supplied by a client. It intentionally distinguishes -`required_tool_registered=true` from `required_tool_callable=false`; the latter -must block reviewer and merger workflows until the live namespace is repaired. - -## Enforcement in the review/merge state machine - -The block is not advisory. Feed the `blocks_merge_workflow` verdict from -`gitea_assess_mcp_namespace_health` into -`gitea_assess_review_merge_state_machine` as `live_namespace_broken`: - -```text -health = gitea_assess_mcp_namespace_health(namespace="gitea-merger", ...) -state = gitea_assess_review_merge_state_machine( - state_completion=..., - pre_merge_gates=..., - live_namespace_broken=health["blocks_merge_workflow"], - ) -# state["merge"]["allowed"] is False whenever the live namespace is broken, -# even when every review state and pre-merge gate is otherwise satisfied. -``` - -When `live_namespace_broken=True`, `assess_workflow_blockers`, -`can_approve`, `can_merge`, and `workflow_status` all fail closed. This is the -guard that stops a merge from proceeding on a false-ready namespace, as in the -PR #418 halt that motivated this work (#543). +When blocked, repair the IDE namespace and re-record a healthy +`client_namespace` assessment before retrying the mutation. diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index 308c367..cb03af0 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -2825,6 +2825,35 @@ _TERMINAL_REVIEW_ACTIONS = frozenset({"approve", "request_changes"}) # remote + profile identity with TTL. _REVIEW_DECISION_LOCK: dict | None = None +# Session-scoped live MCP namespace health assessments (#543). +# Keyed by namespace name. Only client_namespace entries authorize mutations. +_LIVE_NAMESPACE_HEALTH: dict[str, dict] = {} + + +def _record_live_namespace_health(assessment: dict | None) -> None: + """Store a namespace health assessment for mutation gates.""" + if not isinstance(assessment, dict): + return + ns = str(assessment.get("namespace") or "").strip() + if not ns: + return + _LIVE_NAMESPACE_HEALTH[ns] = { + "namespace": ns, + "healthy": bool(assessment.get("healthy")), + "ide_namespace_proven": bool(assessment.get("ide_namespace_proven")), + "probe_source": assessment.get("probe_source"), + "blocks_merge_workflow": bool(assessment.get("blocks_merge_workflow")), + "error_type": assessment.get("error_type"), + "required_tool": assessment.get("required_tool"), + } + + +def _live_namespace_health_gate(task: str) -> list[str]: + """Fail closed on recorded broken/non-client IDE namespace health (#543).""" + return mcp_namespace_health.mutation_gate_from_session( + task, _LIVE_NAMESPACE_HEALTH + ) + def _decision_lock_binding(lock: dict | None = None) -> dict: """Resolve key fields for durable decision-lock storage.""" @@ -3515,6 +3544,11 @@ def _evaluate_pr_review_submission( reasons.extend(workflow_blockers) reasons.extend(review_workflow_load.recovery_handoff_without_replay()) return result + if live: + ns_gate = _live_namespace_health_gate("review_pr") + if ns_gate: + reasons.extend(ns_gate) + return result if action not in _REVIEW_ACTIONS: reasons.append( @@ -4616,6 +4650,12 @@ def gitea_merge_pr( reasons.extend(review_workflow_load.recovery_handoff_without_replay()) return result + # Gate 0b — recorded live MCP namespace health must not be broken (#543). + ns_gate = _live_namespace_health_gate("merge_pr") + if ns_gate: + reasons.extend(ns_gate) + return result + # Gate 1 — valid merge method (no API call on a bad method). if do not in _MERGE_METHODS: reasons.append( @@ -9203,16 +9243,22 @@ def gitea_assess_mcp_namespace_health( config_path: str | None = None, profile: str | None = None, configured: bool = True, + probe_source: str | None = None, ) -> dict: - """Read-only: classify live MCP namespace health for required Gitea tools. + """Classify MCP namespace health for required Gitea tools (#543). Static FastMCP registration is not enough to prove a namespace works: IDE clients can keep a registered tool list while live calls fail with - ``client is closing: EOF``. This diagnostic accepts static registration - evidence plus live invocation evidence and returns fail-closed guidance for - reviewer/merger workflows. + ``client is closing: EOF``. Pass live IDE invocation evidence with + ``probe_source='client_namespace'``. Offline subprocess probes + (``test_mcp_conn.py``) must use ``probe_source='offline_spawn'`` and never + count as IDE proof. + + Assessments are recorded in the session so live + ``gitea_submit_pr_review`` / ``gitea_merge_pr`` can fail closed when a + client-namespace probe reported unhealthy. """ - return mcp_namespace_health.classify_namespace_probe( + result = mcp_namespace_health.classify_namespace_probe( namespace, required_tool=required_tool, registered_tools=registered_tools, @@ -9221,7 +9267,10 @@ def gitea_assess_mcp_namespace_health( config_path=config_path, profile=profile, configured=configured, + probe_source=probe_source, ) + _record_live_namespace_health(result) + return result @mcp.tool() diff --git a/mcp_namespace_health.py b/mcp_namespace_health.py index 4d8fac0..a3c2da1 100644 --- a/mcp_namespace_health.py +++ b/mcp_namespace_health.py @@ -3,6 +3,15 @@ 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 @@ -19,6 +28,20 @@ REQUIRED_NAMESPACE_TOOLS = { 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", @@ -50,6 +73,13 @@ def _contains_eof(text: str | None) -> bool: 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 {} @@ -73,15 +103,17 @@ def classify_namespace_probe( 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 client evidence from a real namespace invocation. The assessment is - healthy only when the required tool is registered and callable. + 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 @@ -113,7 +145,9 @@ def classify_namespace_probe( 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 @@ -143,24 +177,53 @@ def classify_namespace_probe( 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: + if not healthy or not ide_namespace_proven: remediation.append( - "Restart or reconnect the IDE MCP client namespace, then retry " - f"'{tool}' through namespace '{ns}'." + "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( - "Reload the MCP config and verify the configured command, args, " - "profile environment, and working tree point at the current checkout." + "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: + if process_pid and source != PROBE_SOURCE_OFFLINE: remediation.append( - f"If PID {process_pid} belongs to a stale MCP process, stop it " - "before reconnecting the namespace." + f"Diagnostics may include PID {process_pid}; process details " + "are informational only — recovery is client-layer reconnect." ) else: - remediation.append(f"Namespace '{ns}' can invoke '{tool}'.") + 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, @@ -171,6 +234,8 @@ def classify_namespace_probe( "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, @@ -182,10 +247,9 @@ def classify_namespace_probe( "profile": profile_name, "env": env_summary, "config_path": config_path, + "probe_source": source, }, - "blocks_merge_workflow": namespace_health_blocks_task( - "merge_pr", healthy - ), + "blocks_merge_workflow": blocks, } @@ -194,3 +258,49 @@ def namespace_health_blocks_task(task: str, healthy: bool) -> bool: 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 [] diff --git a/test_mcp_conn.py b/test_mcp_conn.py index 1667a60..6b5f655 100644 --- a/test_mcp_conn.py +++ b/test_mcp_conn.py @@ -1,10 +1,15 @@ #!/usr/bin/env python3 -"""Live health-check script to verify Gitea MCP namespace connections. +"""Offline-only MCP namespace spawn probe (NOT IDE-namespace proof). -Spawns MCP server processes from the IDE config, performs the JSON-RPC -handshake, verifies the required tool is registered, and invokes that tool. -The final invocation is what catches client/namespace EOF failures that a -static FastMCP registration check cannot see. +Spawns a *separate* MCP server process from config via subprocess.Popen, +performs the JSON-RPC handshake, verifies the required tool is registered, +and invokes that tool. Results are classified with +``probe_source=offline_spawn``. + +This path is useful for offline launch/registration debugging. It does +**not** prove the IDE-managed MCP client namespace is healthy (#543). For +workflow gates, pass live IDE call evidence with +``probe_source=client_namespace`` to ``gitea_assess_mcp_namespace_health``. """ import argparse @@ -59,6 +64,7 @@ def run_connection_test(name, config, *, required_tool=None, config_path=None): probe_result={"success": False, "error": str(exc)}, process={"profile": env.get("GITEA_MCP_PROFILE"), "env": env}, config_path=config_path, + probe_source="offline_spawn", ) print(f" diagnostics: {json.dumps(assessment['diagnostics'], sort_keys=True)}") return False @@ -92,6 +98,7 @@ def run_connection_test(name, config, *, required_tool=None, config_path=None): "env": env, }, config_path=config_path, + probe_source="offline_spawn", ) print(f" remediation: {' '.join(assessment['remediation'])}") proc.terminate() @@ -128,6 +135,7 @@ def run_connection_test(name, config, *, required_tool=None, config_path=None): "env": env, }, config_path=config_path, + probe_source="offline_spawn", ) print(f" remediation: {' '.join(assessment['remediation'])}") proc.terminate() @@ -153,6 +161,7 @@ def run_connection_test(name, config, *, required_tool=None, config_path=None): "env": env, }, config_path=config_path, + probe_source="offline_spawn", ) print(f" [FAIL] Required tool '{tool_name}' is not registered.") print(f" remediation: {' '.join(assessment['remediation'])}") @@ -180,6 +189,7 @@ def run_connection_test(name, config, *, required_tool=None, config_path=None): "env": env, }, config_path=config_path, + probe_source="offline_spawn", ) print(f" [FAIL] Received EOF on {tool_name} invocation.") print(f" diagnostics: {json.dumps(assessment['diagnostics'], sort_keys=True)}") @@ -198,6 +208,7 @@ def run_connection_test(name, config, *, required_tool=None, config_path=None): "env": env, }, config_path=config_path, + probe_source="offline_spawn", ) print(f" [FAIL] {tool_name} invocation returned error: {call_res['error']}") print(f" remediation: {' '.join(assessment['remediation'])}") @@ -215,6 +226,7 @@ def run_connection_test(name, config, *, required_tool=None, config_path=None): "env": env, }, config_path=config_path, + probe_source="offline_spawn", ) print(f" [OK] Successfully invoked required tool '{tool_name}'.") print(f" diagnostics: {json.dumps(assessment['diagnostics'], sort_keys=True)}") @@ -263,6 +275,7 @@ def main(): servers[name], required_tool=REQUIRED_NAMESPACE_TOOLS.get(name), config_path=config_path, + probe_source="offline_spawn", ): failed = True else: diff --git a/tests/conftest.py b/tests/conftest.py index 7a9d84f..178380e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -66,6 +66,7 @@ def _reset_mutation_authority(monkeypatch): monkeypatch.setattr(mcp_server, "_MUTATION_AUTHORITY", None) monkeypatch.setattr(mcp_server, "_IDENTITY_CACHE", {}) monkeypatch.setattr(mcp_server, "_REVIEW_DECISION_LOCK", None) + monkeypatch.setattr(mcp_server, "_LIVE_NAMESPACE_HEALTH", {}) monkeypatch.setattr(mcp_server, "_preflight_whoami_called", False) monkeypatch.setattr(mcp_server, "_preflight_capability_called", False) monkeypatch.setattr(mcp_server, "_preflight_resolved_role", None) diff --git a/tests/test_mcp_namespace_health.py b/tests/test_mcp_namespace_health.py index 44f81f1..f267695 100644 --- a/tests/test_mcp_namespace_health.py +++ b/tests/test_mcp_namespace_health.py @@ -6,6 +6,9 @@ import review_merge_state_machine as rmsm class TestMcpNamespaceHealth(unittest.TestCase): + def setUp(self): + gitea_mcp_server._LIVE_NAMESPACE_HEALTH.clear() + def test_registered_tool_but_live_eof_blocks_merge(self): result = mcp_namespace_health.classify_namespace_probe( "gitea-reviewer", @@ -24,6 +27,7 @@ class TestMcpNamespaceHealth(unittest.TestCase): }, }, config_path="/Users/jasonwalker/.gemini/config/mcp_config.json", + probe_source="client_namespace", ) self.assertFalse(result["healthy"]) @@ -31,6 +35,8 @@ class TestMcpNamespaceHealth(unittest.TestCase): self.assertTrue(result["required_tool_registered"]) self.assertFalse(result["required_tool_callable"]) self.assertTrue(result["blocks_merge_workflow"]) + self.assertFalse(result["ide_namespace_proven"]) + self.assertEqual(result["probe_source"], "client_namespace") self.assertEqual(result["diagnostics"]["process_pid"], 4321) self.assertEqual(result["diagnostics"]["profile"], "prgs-reviewer") self.assertEqual( @@ -41,26 +47,44 @@ class TestMcpNamespaceHealth(unittest.TestCase): self.assertIn("gitea-reviewer", result["reasons"][0]) self.assertIn("gitea_whoami", result["reasons"][0]) - def test_successful_live_invocation_is_healthy(self): + def test_successful_client_namespace_invocation_is_ide_proven(self): result = mcp_namespace_health.classify_namespace_probe( "gitea-author", registered_tools=["gitea_whoami"], probe_result={"success": True, "result": {"authenticated": True}}, process={"pid": 1234, "profile": "prgs-author"}, config_path="/tmp/mcp_config.json", + probe_source="client_namespace", ) self.assertTrue(result["healthy"]) self.assertTrue(result["required_tool_registered"]) self.assertTrue(result["required_tool_callable"]) + self.assertTrue(result["ide_namespace_proven"]) self.assertFalse(result["blocks_merge_workflow"]) self.assertIsNone(result["error_type"]) + def test_offline_spawn_success_is_not_ide_proof(self): + result = mcp_namespace_health.classify_namespace_probe( + "gitea-merger", + registered_tools=["gitea_whoami"], + probe_result={"success": True, "result": {}}, + process={"pid": 99, "profile": "prgs-merger"}, + probe_source="offline_spawn", + ) + self.assertTrue(result["healthy"]) + self.assertFalse(result["ide_namespace_proven"]) + self.assertFalse(result["blocks_merge_workflow"]) + self.assertTrue( + any("offline_spawn" in r for r in result["reasons"]) + ) + def test_registered_missing_required_tool_fails_before_probe_success(self): result = mcp_namespace_health.classify_namespace_probe( "gitea-tools", registered_tools=["gitea_whoami"], probe_result={"success": True, "result": {}}, + probe_source="client_namespace", ) self.assertFalse(result["healthy"]) @@ -68,23 +92,31 @@ class TestMcpNamespaceHealth(unittest.TestCase): self.assertFalse(result["required_tool_registered"]) self.assertEqual(result["error_type"], "tool_missing") - def test_server_tool_exposes_same_assessment(self): + def test_server_tool_exposes_same_assessment_and_records_session(self): result = gitea_mcp_server.gitea_assess_mcp_namespace_health( "gitea-merger", registered_tools=["gitea_whoami"], probe_result={"success": False, "error": "transport closed"}, process={"pid": 9876, "profile": "prgs-merger"}, + probe_source="client_namespace", ) self.assertFalse(result["success"]) self.assertEqual(result["error_type"], "namespace_eof") self.assertEqual(result["namespace"], "gitea-merger") self.assertEqual(result["diagnostics"]["process_pid"], 9876) + self.assertIn("gitea-merger", gitea_mcp_server._LIVE_NAMESPACE_HEALTH) + gate = gitea_mcp_server._live_namespace_health_gate("merge_pr") + self.assertTrue(gate) + self.assertTrue(any("gitea-merger" in r for r in gate)) class TestLiveNamespaceBlocksMerge(unittest.TestCase): """AC5: a broken live namespace must hard-block the review/merge state machine.""" + def setUp(self): + gitea_mcp_server._LIVE_NAMESPACE_HEALTH.clear() + def _merge_ready_completion(self): # Completion through PRE_MERGE_RECHECK is the state where a clean merge # is otherwise allowed (see test_merge_allowed_with_pre_merge_gates). @@ -141,6 +173,7 @@ class TestLiveNamespaceBlocksMerge(unittest.TestCase): registered_tools=["gitea_whoami", "gitea_adopt_merger_pr_lease"], probe_result={"success": False, "error": "client is closing: EOF"}, process={"pid": 555, "profile": "prgs-merger"}, + probe_source="client_namespace", ) self.assertTrue(verdict["blocks_merge_workflow"]) blocked = rmsm.can_merge( @@ -150,6 +183,26 @@ class TestLiveNamespaceBlocksMerge(unittest.TestCase): ) self.assertFalse(blocked["allowed"]) + def test_offline_spawn_does_not_authorize_mutation_gate(self): + gitea_mcp_server.gitea_assess_mcp_namespace_health( + "gitea-merger", + registered_tools=["gitea_whoami"], + probe_result={"success": True, "result": {}}, + probe_source="offline_spawn", + ) + gate = gitea_mcp_server._live_namespace_health_gate("merge_pr") + self.assertTrue(gate) + self.assertTrue(any("offline_spawn" in r or "client_namespace" in r for r in gate)) + + def test_client_namespace_healthy_clears_mutation_gate(self): + gitea_mcp_server.gitea_assess_mcp_namespace_health( + "gitea-merger", + registered_tools=["gitea_whoami"], + probe_result={"success": True, "result": {}}, + probe_source="client_namespace", + ) + self.assertEqual(gitea_mcp_server._live_namespace_health_gate("merge_pr"), []) + if __name__ == "__main__": unittest.main()