- 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:
@@ -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.
|
||||
|
||||
@@ -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 <name>`.
|
||||
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.
|
||||
|
||||
+54
-5
@@ -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()
|
||||
|
||||
+124
-14
@@ -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 []
|
||||
|
||||
+18
-5
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user