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.
This commit is contained in:
2026-07-09 18:44:04 -04:00
parent af9f1c5944
commit ebd06b73c9
7 changed files with 341 additions and 94 deletions
+28 -19
View File
@@ -21,7 +21,8 @@ not a missing or broken tool:
- The tool can be present and registered in the Python `FastMCP` tool manager. - The tool can be present and registered in the Python `FastMCP` tool manager.
- Direct Python inspection of the server confirms the tool exists. - 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 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 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, `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. 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 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 (`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**. 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 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 for `python -c 'import gitea_mcp_server ...'`, raw JSON-RPC from a shell,
killing PIDs to force a respawn does not restore the *client's* view of the killing PIDs to force a respawn, or touching MCP config mtimes does **not**
namespace and violates the daemon-import guard (#558, restore the *client's* view of the namespace and violates the daemon-import
`docs/mcp-daemon-import-guard.md`). The only sanctioned repair is a client guard (#558, `docs/mcp-daemon-import-guard.md`). The only sanctioned repair
reconnect / relaunch. is a **client reconnect / relaunch**.
4. **Verify through the same path the workflow will use.** After reconnect, call 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 specific tool the blocked workflow needs — not just any tool — through
the target namespace. For a merge that means calling the merger-authorized the target namespace. For a merge that means calling the merger-authorized
adoption/merge tool through `gitea-merger`. A green `gitea_whoami` on one adoption/merge tool through `gitea-merger`. A green `gitea_whoami` on one
namespace does **not** prove another namespace or another tool is callable. 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 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 step 4. If EOF persists after a full relaunch, the backing subprocess is
failing to start — inspect its stderr / launch config (command path, venv, 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 ## 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` / - **Namespace name** that returned EOF (`gitea-author` / `gitea-reviewer` /
`gitea-merger` / `gitea-tools`). `gitea-merger` / `gitea-tools`).
- **Tool** that was called and the **exact** error string. - **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`, - **Profile / env** for that namespace (execution profile, `*_MCP_PROFILE`,
worktree binding such as `GITEA_AUTHOR_WORKTREE`). worktree binding such as `GITEA_AUTHOR_WORKTREE`).
- **Config path** the client launched the server from. - **Config path** the client launched the server from.
- Result of the **cross-server control** call (did `context7` succeed?). - 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 `test_mcp_conn.py` performs a full JSON-RPC handshake against a **fresh
(`initialize``initialized``tools/list`) plus a direct tool call against a subprocess** (`initialize` → `initialized` → `tools/list` → `tools/call`) and
namespace, which is what distinguishes "registered in FastMCP" from "callable classifies with `probe_source=offline_spawn`. That is useful for offline
through the client." Use it to confirm a namespace is genuinely reachable after launch/registration debugging. It is **not** proof the IDE-managed namespace is
a reconnect. It probes a namespace end-to-end; a namespace that returns EOF at healthy. See `docs/mcp-namespace-health.md`.
the client fails this handshake, while `ps`-only checks still report the process
as present.
## Do-not list during EOF recovery ## Do-not list during EOF recovery
- Do **not** retry a blocked merge/adoption until the required tool is confirmed - 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 - Do **not** clean, reset, or rebind a **foreign** worktree to work around the
error. error.
- Do **not** bypass the namespace with direct imports, raw API/curl, or - Do **not** bypass the namespace with direct imports, raw API/curl, or
in-memory state restoration. in-memory state restoration.
- Do **not** kill MCP PIDs or touch config mtimes as a substitute for client
reconnect.
## Related ## Related
- #531 / #544 — stale-runtime detection (`ps`-based); sibling failure mode. - #531 / #544 — stale-runtime detection (`ps`-based); sibling failure mode.
- #558 / `docs/mcp-daemon-import-guard.md` — why shell imports are not a repair. - #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-client-registration.md` — per-server registration contract.
- `docs/mcp-namespace-health.md` — probe sources and mutation enforcement.
+61 -49
View File
@@ -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 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 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`. a tool such as `gitea_whoami`.
Do not treat static tool registration as proof that review or merge workflows 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 can proceed. A reviewer or merger flow must have **client-namespace** evidence
the required tool is callable through the configured namespace. 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 | Source | How obtained | Proves IDE namespace? |
server code, or seeing EOF from any Gitea 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 ```bash
python3 test_mcp_conn.py --config ~/.gemini/config/mcp_config.json 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 | | Namespace | Required tool |
| --- | --- | | --- | --- |
@@ -27,50 +62,27 @@ By default the script checks these namespaces and required tools:
| `gitea-merger` | `gitea_whoami` | | `gitea-merger` | `gitea_whoami` |
| `gitea-tools` | `gitea_list_profiles` | | `gitea-tools` | `gitea_list_profiles` |
Use `--namespace gitea-reviewer` to test only one namespace. A passing probe ## Recovery (canonical)
requires:
1. The namespace exists in the MCP config. When a namespace returns EOF, follow
2. JSON-RPC initialize succeeds. `docs/mcp-namespace-eof-recovery.md` in order:
3. `tools/list` returns the required tool.
4. `tools/call` successfully invokes the required tool.
## 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 1. **State machine (read-only):** feed `blocks_merge_workflow` from a
summary, and config path. `client_namespace` assessment into
2. Stop any stale MCP server process for that PID. `gitea_assess_review_merge_state_machine(live_namespace_broken=...)`.
3. Reload or touch the MCP config so the IDE reconnects the namespace. 2. **Live mutations:** `gitea_submit_pr_review` and `gitea_merge_pr` call
4. Verify the namespace command, args, profile env, and checkout path point at `_live_namespace_health_gate` and fail closed when the session has a
the current repository. recorded unhealthy or non-client probe for the required namespace
5. Re-run `python3 test_mcp_conn.py --namespace <name>`. (`gitea-reviewer` for review, `gitea-merger` for merge).
6. Resume review or merge work only after the required tool call passes.
The read-only `gitea_assess_mcp_namespace_health` tool can classify probe When blocked, repair the IDE namespace and re-record a healthy
evidence supplied by a client. It intentionally distinguishes `client_namespace` assessment before retrying the mutation.
`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).
+54 -5
View File
@@ -2825,6 +2825,35 @@ _TERMINAL_REVIEW_ACTIONS = frozenset({"approve", "request_changes"})
# remote + profile identity with TTL. # remote + profile identity with TTL.
_REVIEW_DECISION_LOCK: dict | None = None _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: def _decision_lock_binding(lock: dict | None = None) -> dict:
"""Resolve key fields for durable decision-lock storage.""" """Resolve key fields for durable decision-lock storage."""
@@ -3515,6 +3544,11 @@ def _evaluate_pr_review_submission(
reasons.extend(workflow_blockers) reasons.extend(workflow_blockers)
reasons.extend(review_workflow_load.recovery_handoff_without_replay()) reasons.extend(review_workflow_load.recovery_handoff_without_replay())
return result 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: if action not in _REVIEW_ACTIONS:
reasons.append( reasons.append(
@@ -4616,6 +4650,12 @@ def gitea_merge_pr(
reasons.extend(review_workflow_load.recovery_handoff_without_replay()) reasons.extend(review_workflow_load.recovery_handoff_without_replay())
return result 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). # Gate 1 — valid merge method (no API call on a bad method).
if do not in _MERGE_METHODS: if do not in _MERGE_METHODS:
reasons.append( reasons.append(
@@ -9203,16 +9243,22 @@ def gitea_assess_mcp_namespace_health(
config_path: str | None = None, config_path: str | None = None,
profile: str | None = None, profile: str | None = None,
configured: bool = True, configured: bool = True,
probe_source: str | None = None,
) -> dict: ) -> 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 Static FastMCP registration is not enough to prove a namespace works: IDE
clients can keep a registered tool list while live calls fail with clients can keep a registered tool list while live calls fail with
``client is closing: EOF``. This diagnostic accepts static registration ``client is closing: EOF``. Pass live IDE invocation evidence with
evidence plus live invocation evidence and returns fail-closed guidance for ``probe_source='client_namespace'``. Offline subprocess probes
reviewer/merger workflows. (``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, namespace,
required_tool=required_tool, required_tool=required_tool,
registered_tools=registered_tools, registered_tools=registered_tools,
@@ -9221,7 +9267,10 @@ def gitea_assess_mcp_namespace_health(
config_path=config_path, config_path=config_path,
profile=profile, profile=profile,
configured=configured, configured=configured,
probe_source=probe_source,
) )
_record_live_namespace_health(result)
return result
@mcp.tool() @mcp.tool()
+124 -14
View File
@@ -3,6 +3,15 @@
The IDE/client namespace can fail with EOF even when this Python process still 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 registers the Gitea tools with FastMCP. These helpers keep that distinction
explicit so reviewer/merger flows can fail closed on the live path. 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 __future__ import annotations
@@ -19,6 +28,20 @@ REQUIRED_NAMESPACE_TOOLS = {
DEFAULT_NAMESPACES = tuple(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 = ( EOF_PATTERNS = (
"client is closing: eof", "client is closing: eof",
"transport closed", "transport closed",
@@ -50,6 +73,13 @@ def _contains_eof(text: str | None) -> bool:
return any(pattern in lowered for pattern in EOF_PATTERNS) 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]: def _safe_env_summary(process: dict[str, Any] | None) -> dict[str, str]:
if not process: if not process:
return {} return {}
@@ -73,15 +103,17 @@ def classify_namespace_probe(
config_path: str | None = None, config_path: str | None = None,
profile: str | None = None, profile: str | None = None,
configured: bool = True, configured: bool = True,
probe_source: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Classify whether a required tool is callable through a live namespace. """Classify whether a required tool is callable through a live namespace.
``registered_tools`` is static/server-side evidence. ``probe_result`` is ``registered_tools`` is static/server-side evidence. ``probe_result`` is
live client evidence from a real namespace invocation. The assessment is live invocation evidence. Only ``probe_source=client_namespace`` proves the
healthy only when the required tool is registered and callable. IDE-managed path; ``offline_spawn`` is an offline subprocess check only.
""" """
ns = (namespace or "").strip() ns = (namespace or "").strip()
tool = required_tool or REQUIRED_NAMESPACE_TOOLS.get(ns) or "gitea_whoami" 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_list = _as_list(registered_tools)
registered = None if registered_list is None else tool in registered_list 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" error_type = "namespace_call_failed"
callable_live = bool(configured and probe_result and probe_success) 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) 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 process_pid = process.get("pid") if isinstance(process, dict) else None
profile_name = profile or ( profile_name = profile or (
process.get("profile") if isinstance(process, dict) else None process.get("profile") if isinstance(process, dict) else None
@@ -143,24 +177,53 @@ def classify_namespace_probe(
reasons.append( reasons.append(
f"Live MCP namespace '{ns}' failed while invoking '{tool}'." 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 = [] remediation = []
if not healthy: if not healthy or not ide_namespace_proven:
remediation.append( remediation.append(
"Restart or reconnect the IDE MCP client namespace, then retry " "Reconnect the IDE MCP client namespace (client reconnect / "
f"'{tool}' through namespace '{ns}'." f"relaunch), then invoke '{tool}' through namespace '{ns}' and "
"record the result with probe_source='client_namespace'."
) )
remediation.append( remediation.append(
"Reload the MCP config and verify the configured command, args, " "Do not treat offline subprocess probes (test_mcp_conn.py) or "
"profile environment, and working tree point at the current checkout." "shell kill/PID respawn as proof the IDE namespace is repaired."
) )
if process_pid: if process_pid and source != PROBE_SOURCE_OFFLINE:
remediation.append( remediation.append(
f"If PID {process_pid} belongs to a stale MCP process, stop it " f"Diagnostics may include PID {process_pid}; process details "
"before reconnecting the namespace." "are informational only — recovery is client-layer reconnect."
) )
else: 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 { return {
"success": healthy, "success": healthy,
@@ -171,6 +234,8 @@ def classify_namespace_probe(
"registered_tools_checked": registered_list is not None, "registered_tools_checked": registered_list is not None,
"required_tool_registered": registered, "required_tool_registered": registered,
"required_tool_callable": callable_live, "required_tool_callable": callable_live,
"probe_source": source,
"ide_namespace_proven": ide_namespace_proven,
"error_type": None if healthy else error_type, "error_type": None if healthy else error_type,
"error_message": error_message or None, "error_message": error_message or None,
"reasons": reasons, "reasons": reasons,
@@ -182,10 +247,9 @@ def classify_namespace_probe(
"profile": profile_name, "profile": profile_name,
"env": env_summary, "env": env_summary,
"config_path": config_path, "config_path": config_path,
"probe_source": source,
}, },
"blocks_merge_workflow": namespace_health_blocks_task( "blocks_merge_workflow": blocks,
"merge_pr", healthy
),
} }
@@ -194,3 +258,49 @@ def namespace_health_blocks_task(task: str, healthy: bool) -> bool:
if healthy: if healthy:
return False return False
return (task or "").strip() in {"merge_pr", "review_pr", "submit_review"} 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 []
+18 -5
View File
@@ -1,10 +1,15 @@
#!/usr/bin/env python3 #!/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 Spawns a *separate* MCP server process from config via subprocess.Popen,
handshake, verifies the required tool is registered, and invokes that tool. performs the JSON-RPC handshake, verifies the required tool is registered,
The final invocation is what catches client/namespace EOF failures that a and invokes that tool. Results are classified with
static FastMCP registration check cannot see. ``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 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)}, probe_result={"success": False, "error": str(exc)},
process={"profile": env.get("GITEA_MCP_PROFILE"), "env": env}, process={"profile": env.get("GITEA_MCP_PROFILE"), "env": env},
config_path=config_path, config_path=config_path,
probe_source="offline_spawn",
) )
print(f" diagnostics: {json.dumps(assessment['diagnostics'], sort_keys=True)}") print(f" diagnostics: {json.dumps(assessment['diagnostics'], sort_keys=True)}")
return False return False
@@ -92,6 +98,7 @@ def run_connection_test(name, config, *, required_tool=None, config_path=None):
"env": env, "env": env,
}, },
config_path=config_path, config_path=config_path,
probe_source="offline_spawn",
) )
print(f" remediation: {' '.join(assessment['remediation'])}") print(f" remediation: {' '.join(assessment['remediation'])}")
proc.terminate() proc.terminate()
@@ -128,6 +135,7 @@ def run_connection_test(name, config, *, required_tool=None, config_path=None):
"env": env, "env": env,
}, },
config_path=config_path, config_path=config_path,
probe_source="offline_spawn",
) )
print(f" remediation: {' '.join(assessment['remediation'])}") print(f" remediation: {' '.join(assessment['remediation'])}")
proc.terminate() proc.terminate()
@@ -153,6 +161,7 @@ def run_connection_test(name, config, *, required_tool=None, config_path=None):
"env": env, "env": env,
}, },
config_path=config_path, config_path=config_path,
probe_source="offline_spawn",
) )
print(f" [FAIL] Required tool '{tool_name}' is not registered.") print(f" [FAIL] Required tool '{tool_name}' is not registered.")
print(f" remediation: {' '.join(assessment['remediation'])}") print(f" remediation: {' '.join(assessment['remediation'])}")
@@ -180,6 +189,7 @@ def run_connection_test(name, config, *, required_tool=None, config_path=None):
"env": env, "env": env,
}, },
config_path=config_path, config_path=config_path,
probe_source="offline_spawn",
) )
print(f" [FAIL] Received EOF on {tool_name} invocation.") print(f" [FAIL] Received EOF on {tool_name} invocation.")
print(f" diagnostics: {json.dumps(assessment['diagnostics'], sort_keys=True)}") 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, "env": env,
}, },
config_path=config_path, config_path=config_path,
probe_source="offline_spawn",
) )
print(f" [FAIL] {tool_name} invocation returned error: {call_res['error']}") print(f" [FAIL] {tool_name} invocation returned error: {call_res['error']}")
print(f" remediation: {' '.join(assessment['remediation'])}") print(f" remediation: {' '.join(assessment['remediation'])}")
@@ -215,6 +226,7 @@ def run_connection_test(name, config, *, required_tool=None, config_path=None):
"env": env, "env": env,
}, },
config_path=config_path, config_path=config_path,
probe_source="offline_spawn",
) )
print(f" [OK] Successfully invoked required tool '{tool_name}'.") print(f" [OK] Successfully invoked required tool '{tool_name}'.")
print(f" diagnostics: {json.dumps(assessment['diagnostics'], sort_keys=True)}") print(f" diagnostics: {json.dumps(assessment['diagnostics'], sort_keys=True)}")
@@ -263,6 +275,7 @@ def main():
servers[name], servers[name],
required_tool=REQUIRED_NAMESPACE_TOOLS.get(name), required_tool=REQUIRED_NAMESPACE_TOOLS.get(name),
config_path=config_path, config_path=config_path,
probe_source="offline_spawn",
): ):
failed = True failed = True
else: else:
+1
View File
@@ -66,6 +66,7 @@ def _reset_mutation_authority(monkeypatch):
monkeypatch.setattr(mcp_server, "_MUTATION_AUTHORITY", None) monkeypatch.setattr(mcp_server, "_MUTATION_AUTHORITY", None)
monkeypatch.setattr(mcp_server, "_IDENTITY_CACHE", {}) monkeypatch.setattr(mcp_server, "_IDENTITY_CACHE", {})
monkeypatch.setattr(mcp_server, "_REVIEW_DECISION_LOCK", None) 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_whoami_called", False)
monkeypatch.setattr(mcp_server, "_preflight_capability_called", False) monkeypatch.setattr(mcp_server, "_preflight_capability_called", False)
monkeypatch.setattr(mcp_server, "_preflight_resolved_role", None) monkeypatch.setattr(mcp_server, "_preflight_resolved_role", None)
+55 -2
View File
@@ -6,6 +6,9 @@ import review_merge_state_machine as rmsm
class TestMcpNamespaceHealth(unittest.TestCase): class TestMcpNamespaceHealth(unittest.TestCase):
def setUp(self):
gitea_mcp_server._LIVE_NAMESPACE_HEALTH.clear()
def test_registered_tool_but_live_eof_blocks_merge(self): def test_registered_tool_but_live_eof_blocks_merge(self):
result = mcp_namespace_health.classify_namespace_probe( result = mcp_namespace_health.classify_namespace_probe(
"gitea-reviewer", "gitea-reviewer",
@@ -24,6 +27,7 @@ class TestMcpNamespaceHealth(unittest.TestCase):
}, },
}, },
config_path="/Users/jasonwalker/.gemini/config/mcp_config.json", config_path="/Users/jasonwalker/.gemini/config/mcp_config.json",
probe_source="client_namespace",
) )
self.assertFalse(result["healthy"]) self.assertFalse(result["healthy"])
@@ -31,6 +35,8 @@ class TestMcpNamespaceHealth(unittest.TestCase):
self.assertTrue(result["required_tool_registered"]) self.assertTrue(result["required_tool_registered"])
self.assertFalse(result["required_tool_callable"]) self.assertFalse(result["required_tool_callable"])
self.assertTrue(result["blocks_merge_workflow"]) 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"]["process_pid"], 4321)
self.assertEqual(result["diagnostics"]["profile"], "prgs-reviewer") self.assertEqual(result["diagnostics"]["profile"], "prgs-reviewer")
self.assertEqual( self.assertEqual(
@@ -41,26 +47,44 @@ class TestMcpNamespaceHealth(unittest.TestCase):
self.assertIn("gitea-reviewer", result["reasons"][0]) self.assertIn("gitea-reviewer", result["reasons"][0])
self.assertIn("gitea_whoami", 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( result = mcp_namespace_health.classify_namespace_probe(
"gitea-author", "gitea-author",
registered_tools=["gitea_whoami"], registered_tools=["gitea_whoami"],
probe_result={"success": True, "result": {"authenticated": True}}, probe_result={"success": True, "result": {"authenticated": True}},
process={"pid": 1234, "profile": "prgs-author"}, process={"pid": 1234, "profile": "prgs-author"},
config_path="/tmp/mcp_config.json", config_path="/tmp/mcp_config.json",
probe_source="client_namespace",
) )
self.assertTrue(result["healthy"]) self.assertTrue(result["healthy"])
self.assertTrue(result["required_tool_registered"]) self.assertTrue(result["required_tool_registered"])
self.assertTrue(result["required_tool_callable"]) self.assertTrue(result["required_tool_callable"])
self.assertTrue(result["ide_namespace_proven"])
self.assertFalse(result["blocks_merge_workflow"]) self.assertFalse(result["blocks_merge_workflow"])
self.assertIsNone(result["error_type"]) 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): def test_registered_missing_required_tool_fails_before_probe_success(self):
result = mcp_namespace_health.classify_namespace_probe( result = mcp_namespace_health.classify_namespace_probe(
"gitea-tools", "gitea-tools",
registered_tools=["gitea_whoami"], registered_tools=["gitea_whoami"],
probe_result={"success": True, "result": {}}, probe_result={"success": True, "result": {}},
probe_source="client_namespace",
) )
self.assertFalse(result["healthy"]) self.assertFalse(result["healthy"])
@@ -68,23 +92,31 @@ class TestMcpNamespaceHealth(unittest.TestCase):
self.assertFalse(result["required_tool_registered"]) self.assertFalse(result["required_tool_registered"])
self.assertEqual(result["error_type"], "tool_missing") 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( result = gitea_mcp_server.gitea_assess_mcp_namespace_health(
"gitea-merger", "gitea-merger",
registered_tools=["gitea_whoami"], registered_tools=["gitea_whoami"],
probe_result={"success": False, "error": "transport closed"}, probe_result={"success": False, "error": "transport closed"},
process={"pid": 9876, "profile": "prgs-merger"}, process={"pid": 9876, "profile": "prgs-merger"},
probe_source="client_namespace",
) )
self.assertFalse(result["success"]) self.assertFalse(result["success"])
self.assertEqual(result["error_type"], "namespace_eof") self.assertEqual(result["error_type"], "namespace_eof")
self.assertEqual(result["namespace"], "gitea-merger") self.assertEqual(result["namespace"], "gitea-merger")
self.assertEqual(result["diagnostics"]["process_pid"], 9876) 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): class TestLiveNamespaceBlocksMerge(unittest.TestCase):
"""AC5: a broken live namespace must hard-block the review/merge state machine.""" """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): def _merge_ready_completion(self):
# Completion through PRE_MERGE_RECHECK is the state where a clean merge # Completion through PRE_MERGE_RECHECK is the state where a clean merge
# is otherwise allowed (see test_merge_allowed_with_pre_merge_gates). # 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"], registered_tools=["gitea_whoami", "gitea_adopt_merger_pr_lease"],
probe_result={"success": False, "error": "client is closing: EOF"}, probe_result={"success": False, "error": "client is closing: EOF"},
process={"pid": 555, "profile": "prgs-merger"}, process={"pid": 555, "profile": "prgs-merger"},
probe_source="client_namespace",
) )
self.assertTrue(verdict["blocks_merge_workflow"]) self.assertTrue(verdict["blocks_merge_workflow"])
blocked = rmsm.can_merge( blocked = rmsm.can_merge(
@@ -150,6 +183,26 @@ class TestLiveNamespaceBlocksMerge(unittest.TestCase):
) )
self.assertFalse(blocked["allowed"]) 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__": if __name__ == "__main__":
unittest.main() unittest.main()