Compare commits

..
Author SHA1 Message Date
sysadmin ebd06b73c9 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.
2026-07-09 18:44:04 -04:00
sysadmin af9f1c5944 style: fix trailing newline in mcp_namespace_health.py 2026-07-09 18:39:41 -04:00
sysadminandClaude Opus 4.8 f83d2c2027 docs: add MCP namespace client is closing: EOF recovery runbook (#543)
Adds docs/mcp-namespace-eof-recovery.md documenting the correct recovery
path when a Gitea MCP namespace (gitea-author/reviewer/merger/tools)
returns `client is closing: EOF`.

Satisfies acceptance criterion 7 of #543: symptom, root cause (closed
client transport vs a live-but-stale process), the sanctioned
reconnect/relaunch sequence, diagnostics to capture, and how
test_mcp_conn.py reproduces the registered-vs-callable gap. Distinguishes
this transport-close failure from the ps-based stale-runtime family in
#531/#544 and reinforces the no-direct-import guard (#558).

Docs-only; no code or test behavior changed.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-09 18:39:41 -04:00
sysadminandClaude Opus 4.8 93781af7e9 feat: enforce live-namespace block in review/merge state machine (#543 AC5)
The namespace health check (05fdcee) classified a false-ready namespace but
only returned an advisory blocks_merge_workflow flag; nothing in the merge
gate consumed it. Wire it in so a broken live namespace hard-blocks merge.

- review_merge_state_machine.assess_workflow_blockers: add live_namespace_broken
  blocker (registered-in-FastMCP but not callable-through-namespace).
- assess_state_advancement: forward **blocker_kwargs so can_approve/can_merge/
  workflow_status honor the full blocker set (also fixes latent drop of
  mcp_reconnect_failed/stale_capability_state through those paths).
- gitea_assess_review_merge_state_machine tool: accept live_namespace_broken and
  thread it through all state-machine calls.
- Tests: prove can_merge/workflow_status/tool block on live_namespace_broken even
  with every review state + pre-merge gate satisfied; bridge classify verdict.
- Docs: enforcement section wiring blocks_merge_workflow -> live_namespace_broken.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-09 18:39:41 -04:00
sysadmin 314615ec2c feat: diagnose live MCP namespace EOF health 2026-07-09 18:39:41 -04:00
10 changed files with 1057 additions and 479 deletions
+118
View File
@@ -0,0 +1,118 @@
# Recovering from `client is closing: EOF` on a Gitea MCP namespace (#543)
## Symptom
A tool call through a Gitea MCP namespace — `gitea-author`, `gitea-reviewer`,
`gitea-merger`, or the shared `gitea-tools` namespace — fails immediately with:
```
client is closing: EOF
```
Every subsequent call to that same namespace returns the same error, including
cheap read tools such as `gitea_whoami` and `gitea_list_profiles`. Other MCP
servers registered with the same client (for example `context7`) keep working,
so this is **not** a global MCP-client outage.
## Why this is not a code defect
This failure is a **transport-level** condition in the IDE / MCP client manager,
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
(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
does **not** re-spawn the child on the next tool call — it just replays
`client is closing: EOF`. The OS process may even still be alive if a parent
language-server process is holding the stdio pipes open.
This is the canonical "registered in FastMCP ≠ callable through the namespace"
false-ready state. It is distinct from the **stale-runtime** family in #531 /
#544, where the process is reachable but running behind `master`; that case is
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 (canonical — client reconnect only)
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
different MCP server (e.g. `context7`).
- Only the Gitea namespace fails → single-namespace transport close. Continue.
- Every server fails → restart the whole MCP client, not just one namespace.
2. **Reconnect the namespace through the client, not the shell.** Use the IDE /
client MCP-reconnect action for that server entry (in Claude Code:
`/mcp` → reconnect the affected `gitea-*` server). Reconnecting forces the
client to spawn a fresh subprocess and re-open the pipe. This clears the
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,
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. Still
do not use PID kill or config-touch as the primary recovery.
## Diagnostics to capture when reporting EOF
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
(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?).
## Offline spawn probe (non-authoritative)
`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 **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.
+88
View File
@@ -0,0 +1,88 @@
# 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
`client is closing: EOF`, `transport closed`, or an empty response when calling
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 **client-namespace** evidence
that the required tool is callable through the configured IDE MCP namespace.
## Probe sources (do not confuse them)
| 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
```
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 |
| --- | --- |
| `gitea-author` | `gitea_whoami` |
| `gitea-reviewer` | `gitea_whoami` |
| `gitea-merger` | `gitea_whoami` |
| `gitea-tools` | `gitea_list_profiles` |
## Recovery (canonical)
When a namespace returns EOF, follow
`docs/mcp-namespace-eof-recovery.md` in order:
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.
## Enforcement
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).
When blocked, repair the IDE namespace and re-record a healthy
`client_namespace` assessment before retrying the mutation.
+108 -77
View File
@@ -191,6 +191,7 @@ MERGER_WORKTREE_ENV = "GITEA_MERGER_WORKTREE"
RECONCILER_WORKTREE_ENV = "GITEA_RECONCILER_WORKTREE"
import namespace_workspace_binding as nwb # noqa: E402
import mcp_namespace_health # noqa: E402
def _preflight_in_test_mode() -> bool:
@@ -2824,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."""
@@ -3514,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(
@@ -4615,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(
@@ -6609,39 +6650,10 @@ def _try_auto_switch_for_operation(op: str, host: str | None = None) -> bool:
return False
def _git_default_remote_name(root: str) -> str:
"""First configured git remote name for *root*, defaulting to 'origin'.
Used to resolve the live remote master target for parity (#610). Best
effort: any failure falls back to 'origin' so callers never raise.
"""
try:
res = subprocess.run(
["git", "-C", root, "remote"],
capture_output=True, text=True, check=False,
)
except Exception:
return "origin"
if res.returncode != 0:
return "origin"
names = [n.strip() for n in (res.stdout or "").splitlines() if n.strip()]
return names[0] if names else "origin"
def _current_master_parity() -> dict:
"""Assess this process's code against local and live remote master (#420/#610).
Compares the daemon's startup commit, the on-disk checkout HEAD, and the
live remote master target. A stale daemon relative to live master fails
closed for mutations even when the local checkout HEAD still matches the
startup commit. The live-remote read is best effort: an unresolved live
head leaves read-only diagnostics unblocked but is never mutation-safe.
"""
"""Assess this process's code against the on-disk master HEAD (#420)."""
current_head = master_parity_gate.read_git_head(PROJECT_ROOT)
live_head = master_parity_gate.read_remote_master_head(
PROJECT_ROOT, remote=_git_default_remote_name(PROJECT_ROOT))
return master_parity_gate.assess_master_parity(
_STARTUP_PARITY, current_head, live_remote_head=live_head)
return master_parity_gate.assess_master_parity(_STARTUP_PARITY, current_head)
def _master_parity_block(op: str) -> list[str]:
@@ -8653,40 +8665,45 @@ def gitea_assess_review_merge_state_machine(
pre_merge_gates: dict[str, bool] | None = None,
infra_stop: bool = False,
capability_blocked: bool = False,
live_namespace_broken: bool = False,
recovery_handoff_text: str | None = None,
final_report_text: str | None = None,
) -> dict:
"""Read-only: assess enforced PR review/merge workflow state (#290)."""
"""Read-only: assess enforced PR review/merge workflow state (#290).
``live_namespace_broken`` fails review/merge closed when the live MCP
namespace call path is unusable even though the tool is registered in
FastMCP (#543 AC5). Supply the ``blocks_merge_workflow`` verdict from
``gitea_assess_mcp_namespace_health``.
"""
completion = state_completion or {}
blockers = review_merge_state_machine.assess_workflow_blockers(
infra_stop=infra_stop,
capability_blocked=capability_blocked,
)
blocker_kwargs = {
"infra_stop": infra_stop,
"capability_blocked": capability_blocked,
"live_namespace_broken": live_namespace_broken,
}
blockers = review_merge_state_machine.assess_workflow_blockers(**blocker_kwargs)
result = {
"workflow": review_merge_state_machine.workflow_status(
completion,
infra_stop=infra_stop,
capability_blocked=capability_blocked,
**blocker_kwargs,
),
"blockers": blockers,
"approve": review_merge_state_machine.can_approve(
completion,
infra_stop=infra_stop,
capability_blocked=capability_blocked,
**blocker_kwargs,
),
"merge": review_merge_state_machine.can_merge(
completion,
pre_merge_gates=pre_merge_gates,
infra_stop=infra_stop,
capability_blocked=capability_blocked,
**blocker_kwargs,
),
}
if target_state:
result["advancement"] = review_merge_state_machine.assess_state_advancement(
completion,
target_state=target_state,
infra_stop=infra_stop,
capability_blocked=capability_blocked,
**blocker_kwargs,
)
if recovery_handoff_text is not None:
result["recovery_handoff"] = (
@@ -8971,34 +8988,15 @@ def gitea_get_runtime_context(
"restart_required": parity["restart_required"],
"startup_head": parity["startup_head"],
"current_head": parity["current_head"],
# #610 distinguished mutation-safety signals:
"daemon_start_head": parity["daemon_start_head"],
"local_head": parity["local_head"],
"live_remote_head": parity["live_remote_head"],
"live_known": parity["live_known"],
"live_stale": parity["live_stale"],
"mutation_safe": parity["mutation_safe"],
"summary": master_parity_gate.format_parity(parity),
"mutation_gate_enforced": not master_parity_gate.gate_disabled(),
# #610: the capability resolver is authoritative for mutation safety;
# local parity alone must never authorize a mutation.
"resolver_authoritative_for_mutation_safety": True,
}
if parity["restart_required"] and not master_parity_gate.gate_disabled():
if parity["live_stale"]:
safe_next_action = (
"Daemon is stale relative to LIVE remote master "
f"(started {parity['startup_head'][:12] if parity['startup_head'] else 'unknown'}, "
f"live master {parity['live_remote_head'][:12] if parity['live_remote_head'] else 'unknown'}); "
"restart/reconnect the Gitea MCP server before mutating. The "
"capability resolver is authoritative for mutation safety."
)
else:
safe_next_action = (
"Server code is stale relative to master; restart the Gitea MCP "
"server to load current capability gates before mutating. "
f"({master_parity_gate.format_parity(parity)})"
)
if parity["stale"] and not master_parity_gate.gate_disabled():
safe_next_action = (
"Server code is stale relative to master; restart the Gitea MCP "
"server to load current capability gates before mutating. "
f"({master_parity_gate.format_parity(parity)})"
)
result["safe_next_action"] = safe_next_action
if reveal and h:
@@ -9037,19 +9035,12 @@ def gitea_assess_master_parity(
"determinable": parity["determinable"],
"startup_head": parity["startup_head"],
"current_head": parity["current_head"],
# #610 distinguished mutation-safety signals:
"daemon_start_head": parity["daemon_start_head"],
"local_head": parity["local_head"],
"live_remote_head": parity["live_remote_head"],
"live_known": parity["live_known"],
"live_stale": parity["live_stale"],
"mutation_safe": parity["mutation_safe"],
"mutation_gate_enforced": enforced,
"summary": master_parity_gate.format_parity(parity),
"reasons": parity["reasons"],
"process_root": PROJECT_ROOT,
}
if parity["restart_required"] and enforced:
if parity["stale"] and enforced:
out["report"] = master_parity_gate.parity_report(parity)
return out
def gitea_record_pre_review_command(
@@ -9242,6 +9233,46 @@ def gitea_list_profiles() -> dict:
return {"profiles": profiles_out}
@mcp.tool()
def gitea_assess_mcp_namespace_health(
namespace: str,
required_tool: str | None = None,
registered_tools: list[str] | None = None,
probe_result: dict | None = None,
process: dict | None = None,
config_path: str | None = None,
profile: str | None = None,
configured: bool = True,
probe_source: str | None = None,
) -> dict:
"""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``. 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.
"""
result = mcp_namespace_health.classify_namespace_probe(
namespace,
required_tool=required_tool,
registered_tools=registered_tools,
probe_result=probe_result,
process=process,
config_path=config_path,
profile=profile,
configured=configured,
probe_source=probe_source,
)
_record_live_namespace_health(result)
return result
@mcp.tool()
def gitea_activate_profile(
profile_name: str,
+17 -171
View File
@@ -24,26 +24,12 @@ from __future__ import annotations
import os
import subprocess
import time
# Live-remote head cache: the parity gate runs on every mutation and every
# runtime-context read, so the ``git ls-remote`` result is cached briefly to
# avoid a network round-trip per call (#610). Keyed by (root, remote, branch).
_REMOTE_HEAD_CACHE: dict[tuple[str, str, str], tuple[float, str | None]] = {}
_REMOTE_HEAD_TTL = 60.0
def _clear_remote_head_cache() -> None:
"""Reset the live-remote head cache (test isolation / forced refresh)."""
_REMOTE_HEAD_CACHE.clear()
# Environment escape hatches (ops + tests):
# GITEA_MCP_DISABLE_PARITY_GATE -> disable enforcement entirely (fail open).
# GITEA_TEST_CURRENT_HEAD -> force the "current" HEAD read, for tests.
ENV_DISABLE = "GITEA_MCP_DISABLE_PARITY_GATE"
ENV_TEST_CURRENT_HEAD = "GITEA_TEST_CURRENT_HEAD"
# GITEA_TEST_LIVE_REMOTE_HEAD -> force the live remote master read, for tests.
ENV_TEST_LIVE_REMOTE_HEAD = "GITEA_TEST_LIVE_REMOTE_HEAD"
def read_git_head(root: str) -> str | None:
@@ -72,57 +58,6 @@ def read_git_head(root: str) -> str | None:
return (res.stdout or "").strip() or None
def read_remote_master_head(
root: str,
remote: str = "origin",
branch: str = "master",
ttl: float = _REMOTE_HEAD_TTL,
) -> str | None:
"""Return the live remote ``branch`` commit SHA, or ``None`` (#610).
Resolves the *live* target commit via ``git ls-remote`` so parity can tell
a daemon that is behind the live remote master apart from one whose local
checkout simply hasn't been pulled. ``None`` means the live head could not
be resolved (offline, no such remote, git unavailable, error) -- callers
must treat unknown live state as *not mutation-safe* while never blocking
read-only diagnostics. A ``GITEA_TEST_LIVE_REMOTE_HEAD`` override takes
precedence so the wiring can be exercised deterministically and offline.
The result is cached for *ttl* seconds per (root, remote, branch) so the
gate does not run a network probe on every mutation/read (``ttl=0`` forces
a live probe). Both hits and ``None`` misses are cached to bound offline
latency; the env override bypasses the cache and the subprocess entirely.
"""
forced = os.environ.get(ENV_TEST_LIVE_REMOTE_HEAD)
if forced is not None:
return forced.strip() or None
if not root:
return None
key = (root, remote, branch)
now = time.monotonic()
if ttl > 0:
cached = _REMOTE_HEAD_CACHE.get(key)
if cached is not None and (now - cached[0]) < ttl:
return cached[1]
sha: str | None = None
try:
res = subprocess.run(
["git", "-C", root, "ls-remote", remote, f"refs/heads/{branch}"],
capture_output=True,
text=True,
check=False,
timeout=5,
)
if res.returncode == 0:
lines = (res.stdout or "").strip().splitlines()
if lines:
sha = lines[0].split("\t", 1)[0].split()[0].strip() or None
except Exception:
sha = None
_REMOTE_HEAD_CACHE[key] = (now, sha)
return sha
def capture_startup_parity(root: str, head: str | None = None) -> dict:
"""Capture the process source-tree baseline once at server startup.
@@ -137,38 +72,18 @@ def _short(sha: str | None) -> str:
return sha[:12] if sha else "unknown"
def assess_master_parity(
startup: dict | None,
current_head: str | None,
live_remote_head: str | None = None,
) -> dict:
def assess_master_parity(startup: dict | None, current_head: str | None) -> dict:
"""Compare the startup baseline against the current on-disk ``HEAD``.
Pure: all HEADs are supplied by the caller. Returns a structured result:
Pure: both HEADs are supplied by the caller. Returns a structured result:
- ``in_parity`` -- server code matches the on-disk master (or parity
could not be determined, which is not treated as stale).
- ``stale`` -- the on-disk master has definitively advanced past the
running process.
- ``restart_required`` -- ``stale`` or ``live_stale``; the recovery action.
- ``determinable`` -- whether both local HEADs were known well enough to
compare.
- ``restart_required`` -- alias of ``stale``; the recovery action.
- ``determinable`` -- whether both HEADs were known well enough to compare.
- ``startup_head`` / ``current_head`` / ``reasons``.
#610 adds live-remote awareness so a daemon that is stale relative to the
*live* remote master cannot report a mutation-safe result even when the
local checkout HEAD still matches the daemon's startup commit:
- ``daemon_start_head`` -- the commit the running process started at
(alias of ``startup_head``, named for clarity in reports).
- ``local_head`` -- the on-disk checkout HEAD (alias of ``current_head``).
- ``live_remote_head`` -- the live remote target commit, or ``None`` when it
could not be fetched.
- ``live_known`` -- whether the live remote target was resolved.
- ``live_stale`` -- the live remote master has advanced past the running
process (daemon is behind live master) even if local parity is green.
- ``mutation_safe`` -- the daemon code, local checkout, and live remote
target all agree; the only state in which a mutation may rely on parity.
"""
startup_head = (startup or {}).get("startup_head")
reasons: list[str] = []
@@ -176,56 +91,32 @@ def assess_master_parity(
if startup_head is None:
reasons.append(
"startup commit was not captured; code parity cannot be enforced")
return _result(True, False, False, startup_head, current_head,
live_remote_head, False, reasons)
return _result(True, False, False, startup_head, current_head, reasons)
if current_head is None:
reasons.append(
"current workspace HEAD could not be read; code parity cannot be "
"enforced")
return _result(True, False, False, startup_head, current_head,
live_remote_head, False, reasons)
return _result(True, False, False, startup_head, current_head, reasons)
local_in_parity = startup_head == current_head
local_stale = not local_in_parity
if local_stale:
reasons.append(
f"MCP server started at commit {_short(startup_head)} but the "
f"workspace master is now {_short(current_head)}; restart the "
f"server to load the current capability gates")
if startup_head == current_head:
return _result(True, False, True, startup_head, current_head, reasons)
live_known = live_remote_head is not None
live_stale = live_known and live_remote_head != startup_head
if live_stale:
reasons.append(
f"live remote master is {_short(live_remote_head)} but the MCP "
f"server started at {_short(startup_head)}; the daemon is stale "
f"relative to live master -- restart/reconnect before mutating")
return _result(
local_in_parity, local_stale, True, startup_head, current_head,
live_remote_head, live_stale, reasons)
reasons.append(
f"MCP server started at commit {_short(startup_head)} but the workspace "
f"master is now {_short(current_head)}; restart the server to load the "
f"current capability gates")
return _result(False, True, True, startup_head, current_head, reasons)
def _result(in_parity, stale, determinable, startup_head, current_head,
live_remote_head, live_stale, reasons):
live_known = live_remote_head is not None
mutation_safe = (
determinable and in_parity and live_known and not live_stale)
def _result(in_parity, stale, determinable, startup_head, current_head, reasons):
return {
"in_parity": in_parity,
"stale": stale,
"restart_required": stale or live_stale,
"restart_required": stale,
"determinable": determinable,
"startup_head": startup_head,
"current_head": current_head,
# #610 distinguished signals:
"daemon_start_head": startup_head,
"local_head": current_head,
"live_remote_head": live_remote_head,
"live_known": live_known,
"live_stale": live_stale,
"mutation_safe": mutation_safe,
"reasons": list(reasons),
}
@@ -239,13 +130,11 @@ def parity_block_reasons(assessment: dict) -> list[str]:
"""Block reasons for a mutation gate (empty when the mutation may proceed).
A disabled gate or an in-parity / non-determinable assessment yields no
reasons. A definitively stale server blocks, and (#610) a daemon that is
stale relative to the *live* remote master blocks even when the local
checkout HEAD still matches the daemon's startup commit.
reasons; only a definitively stale server blocks.
"""
if gate_disabled():
return []
if assessment.get("stale") or assessment.get("live_stale"):
if assessment.get("stale"):
return list(assessment.get("reasons") or
["server code is stale relative to master (fail closed)"])
return []
@@ -258,10 +147,6 @@ def parity_report(assessment: dict) -> dict:
"restart_required": True,
"startup_head": assessment.get("startup_head"),
"current_head": assessment.get("current_head"),
# #610: name the live remote target so the report distinguishes a
# local-code stale from a daemon-behind-live-master stale.
"live_remote_head": assessment.get("live_remote_head"),
"live_stale": bool(assessment.get("live_stale")),
"reasons": list(assessment.get("reasons") or []),
"recovery": [
"The running MCP server is executing code older than the current "
@@ -272,45 +157,6 @@ def parity_report(assessment: dict) -> dict:
}
def parity_resolver_disagreement(
assessment: dict,
resolver_restart_required: bool,
) -> dict | None:
"""Typed blocker when the resolver requires restart but parity looks green.
The capability resolver (``gitea_resolve_task_capability``) detects stale
runtime authoritatively for mutation safety (#610). When it requires a
restart, local-only parity must never override it: this returns a typed,
fail-closed blocker that names the resolver as authoritative. Returns
``None`` when the resolver does not require a restart.
"""
if not resolver_restart_required:
return None
parity_optimistic = bool(assessment.get("in_parity")) and not (
assessment.get("stale") or assessment.get("live_stale"))
return {
"kind": "parity_resolver_disagreement",
"restart_required": True,
"resolver_authoritative": True,
"parity_optimistic": parity_optimistic,
"daemon_start_head": assessment.get("daemon_start_head"),
"local_head": assessment.get("local_head"),
"live_remote_head": assessment.get("live_remote_head"),
"reasons": [
"The capability resolver requires a restart/reconnect (stale "
"runtime) but master-parity reported local code as in-parity. "
"The resolver is authoritative for mutation safety; do not mutate "
"on local parity alone. Restart/reconnect the Gitea MCP server "
"and re-verify before mutating.",
],
"recovery": [
"Trust the resolver: treat this session as stale.",
"Restart or /mcp reconnect the Gitea MCP namespace so it reloads "
"current master and live target state, then re-run preflight.",
],
}
def format_parity(assessment: dict) -> str:
"""One-line human summary for logs / runtime context."""
if assessment.get("stale"):
+306
View File
@@ -0,0 +1,306 @@
"""Assess live MCP namespace health without trusting static registration.
The IDE/client namespace can fail with EOF even when this Python process still
registers the Gitea tools with FastMCP. These helpers keep that distinction
explicit so reviewer/merger flows can fail closed on the live path.
Probe sources
-------------
* ``client_namespace`` — evidence from the IDE-managed MCP client path (the
only source that can prove the workflow namespace is healthy).
* ``offline_spawn`` — a separate ``subprocess.Popen`` JSON-RPC handshake
(e.g. ``test_mcp_conn.py``). Useful offline, but **never** proves the
IDE-managed namespace is callable.
* ``unknown`` — legacy/unspecified; treated as not IDE-proven.
"""
from __future__ import annotations
from typing import Any
REQUIRED_NAMESPACE_TOOLS = {
"gitea-author": "gitea_whoami",
"gitea-reviewer": "gitea_whoami",
"gitea-merger": "gitea_whoami",
"gitea-tools": "gitea_list_profiles",
}
DEFAULT_NAMESPACES = tuple(REQUIRED_NAMESPACE_TOOLS)
# Namespaces that must be healthy for a given mutation task.
TASK_REQUIRED_NAMESPACES = {
"review_pr": "gitea-reviewer",
"submit_review": "gitea-reviewer",
"merge_pr": "gitea-merger",
}
PROBE_SOURCE_CLIENT = "client_namespace"
PROBE_SOURCE_OFFLINE = "offline_spawn"
PROBE_SOURCE_UNKNOWN = "unknown"
VALID_PROBE_SOURCES = frozenset(
{PROBE_SOURCE_CLIENT, PROBE_SOURCE_OFFLINE, PROBE_SOURCE_UNKNOWN}
)
EOF_PATTERNS = (
"client is closing: eof",
"transport closed",
"connection closed",
"broken pipe",
"end of file",
"eof",
)
SAFE_ENV_KEYS = (
"GITEA_MCP_PROFILE",
"GITEA_PROFILE_NAME",
"GITEA_SERVICE",
"GITEA_EXECUTION_ROLE",
"GITEA_MCP_CONFIG",
)
def _as_list(value: Any) -> list[str] | None:
if value is None:
return None
if isinstance(value, (list, tuple, set)):
return [str(v) for v in value]
return [str(value)]
def _contains_eof(text: str | None) -> bool:
lowered = (text or "").lower()
return any(pattern in lowered for pattern in EOF_PATTERNS)
def _normalize_probe_source(probe_source: str | None) -> str:
raw = (probe_source or PROBE_SOURCE_UNKNOWN).strip().lower()
if raw in VALID_PROBE_SOURCES:
return raw
return PROBE_SOURCE_UNKNOWN
def _safe_env_summary(process: dict[str, Any] | None) -> dict[str, str]:
if not process:
return {}
env = process.get("env") or process.get("environment") or {}
if not isinstance(env, dict):
return {}
return {
key: str(env[key])
for key in SAFE_ENV_KEYS
if key in env and env[key] not in (None, "")
}
def classify_namespace_probe(
namespace: str,
*,
required_tool: str | None = None,
registered_tools: list[str] | tuple[str, ...] | set[str] | None = None,
probe_result: dict[str, Any] | None = None,
process: dict[str, Any] | None = None,
config_path: str | None = None,
profile: str | None = None,
configured: bool = True,
probe_source: str | None = None,
) -> dict[str, Any]:
"""Classify whether a required tool is callable through a live namespace.
``registered_tools`` is static/server-side evidence. ``probe_result`` is
live invocation evidence. Only ``probe_source=client_namespace`` proves the
IDE-managed path; ``offline_spawn`` is an offline subprocess check only.
"""
ns = (namespace or "").strip()
tool = required_tool or REQUIRED_NAMESPACE_TOOLS.get(ns) or "gitea_whoami"
source = _normalize_probe_source(probe_source)
registered_list = _as_list(registered_tools)
registered = None if registered_list is None else tool in registered_list
probe = probe_result or {}
probe_success = bool(probe.get("success"))
error_message = str(
probe.get("error")
or probe.get("message")
or probe.get("stderr")
or probe.get("exception")
or ""
)
error_type = str(probe.get("error_type") or "").strip()
if not error_type and error_message:
if _contains_eof(error_message):
error_type = "namespace_eof"
elif "timeout" in error_message.lower():
error_type = "namespace_timeout"
else:
error_type = "namespace_call_failed"
if not configured:
error_type = "namespace_not_configured"
elif registered is False:
error_type = "tool_missing"
elif not probe_result:
error_type = "live_probe_missing"
elif not probe_success and not error_type:
error_type = "namespace_call_failed"
callable_live = bool(configured and probe_result and probe_success)
# Probe-path health (spawn or client). IDE-proven only for client path.
healthy = bool(configured and registered is not False and callable_live)
ide_namespace_proven = bool(healthy and source == PROBE_SOURCE_CLIENT)
process_pid = process.get("pid") if isinstance(process, dict) else None
profile_name = profile or (
process.get("profile") if isinstance(process, dict) else None
)
env_summary = _safe_env_summary(process)
reasons: list[str] = []
if not configured:
reasons.append(f"MCP namespace '{ns}' is not configured.")
if registered is False:
reasons.append(
f"Required tool '{tool}' is not registered in namespace '{ns}'."
)
if error_type == "live_probe_missing":
reasons.append(
f"No live client invocation proof was supplied for '{ns}.{tool}'."
)
elif error_type == "namespace_eof":
reasons.append(
f"Live MCP namespace '{ns}' returned EOF while invoking '{tool}'."
)
elif error_type == "namespace_timeout":
reasons.append(
f"Live MCP namespace '{ns}' timed out while invoking '{tool}'."
)
elif error_type == "namespace_call_failed":
reasons.append(
f"Live MCP namespace '{ns}' failed while invoking '{tool}'."
)
if source == PROBE_SOURCE_OFFLINE:
reasons.append(
"Probe source is offline_spawn (subprocess JSON-RPC); this does "
"not prove the IDE-managed MCP namespace is healthy."
)
elif source == PROBE_SOURCE_UNKNOWN and probe_result:
reasons.append(
"Probe source unspecified; treat as not IDE-namespace proof unless "
"re-supplied with probe_source='client_namespace'."
)
remediation = []
if not healthy or not ide_namespace_proven:
remediation.append(
"Reconnect the IDE MCP client namespace (client reconnect / "
f"relaunch), then invoke '{tool}' through namespace '{ns}' and "
"record the result with probe_source='client_namespace'."
)
remediation.append(
"Do not treat offline subprocess probes (test_mcp_conn.py) or "
"shell kill/PID respawn as proof the IDE namespace is repaired."
)
if process_pid and source != PROBE_SOURCE_OFFLINE:
remediation.append(
f"Diagnostics may include PID {process_pid}; process details "
"are informational only — recovery is client-layer reconnect."
)
else:
remediation.append(
f"IDE-managed namespace '{ns}' can invoke '{tool}' "
f"(probe_source={source})."
)
# Client-namespace broken health blocks review/merge. Offline probes never
# authorize mutations and only block when they report unhealthy (still
# fail-closed for known bad spawn evidence).
blocks = False
if source == PROBE_SOURCE_CLIENT:
blocks = namespace_health_blocks_task("merge_pr", healthy)
elif source == PROBE_SOURCE_OFFLINE:
# Offline never proves IDE health; never unblock. Unhealthy offline
# still surfaces as a soft diagnostic, not a mutation-ledger block.
blocks = False
else:
# Unknown source: only block when evidence is unhealthy (fail closed
# on bad data without treating success as IDE proof).
blocks = namespace_health_blocks_task("merge_pr", healthy)
return {
"success": healthy,
"healthy": healthy,
"namespace": ns,
"required_tool": tool,
"configured": configured,
"registered_tools_checked": registered_list is not None,
"required_tool_registered": registered,
"required_tool_callable": callable_live,
"probe_source": source,
"ide_namespace_proven": ide_namespace_proven,
"error_type": None if healthy else error_type,
"error_message": error_message or None,
"reasons": reasons,
"remediation": remediation,
"diagnostics": {
"namespace": ns,
"required_tool": tool,
"process_pid": process_pid,
"profile": profile_name,
"env": env_summary,
"config_path": config_path,
"probe_source": source,
},
"blocks_merge_workflow": blocks,
}
def namespace_health_blocks_task(task: str, healthy: bool) -> bool:
"""Return whether a broken namespace must block a workflow task."""
if healthy:
return False
return (task or "").strip() in {"merge_pr", "review_pr", "submit_review"}
def required_namespace_for_task(task: str) -> str | None:
"""Map a mutation task to the MCP namespace that must be healthy."""
return TASK_REQUIRED_NAMESPACES.get((task or "").strip())
def mutation_gate_from_session(
task: str,
session_health: dict[str, dict[str, Any]] | None,
) -> list[str]:
"""Fail-closed gate using recorded client-namespace health assessments.
* Missing session entry → no gate (caller has not assessed yet).
* Client-namespace unhealthy / not IDE-proven → block mutation.
* Offline-only session entries never authorize mutations.
"""
ns = required_namespace_for_task(task)
if not ns:
return []
store = session_health or {}
entry = store.get(ns)
if not entry:
return []
source = _normalize_probe_source(entry.get("probe_source"))
if source != PROBE_SOURCE_CLIENT:
return [
f"recorded namespace health for '{ns}' is probe_source={source}, "
"not client_namespace; re-probe through the IDE-managed path "
f"before {(task or 'mutation')}"
]
if entry.get("ide_namespace_proven") and entry.get("healthy"):
return []
if entry.get("blocks_merge_workflow") or not entry.get("healthy"):
detail = entry.get("error_type") or "unhealthy"
return [
f"live MCP namespace '{ns}' is recorded {detail} "
f"(probe_source={source}); repair the IDE namespace before "
f"{(task or 'mutation')} (fail closed, #543)"
]
if not entry.get("ide_namespace_proven"):
return [
f"live MCP namespace '{ns}' is not IDE-proven; supply a "
"client_namespace probe before mutation (fail closed, #543)"
]
return []
+25 -8
View File
@@ -93,8 +93,16 @@ def assess_workflow_blockers(
capability_blocked: bool = False,
mcp_reconnect_failed: bool = False,
stale_capability_state: bool = False,
live_namespace_broken: bool = False,
) -> dict[str, Any]:
"""Return hard blockers that forbid all PR queue work (#290 AC3)."""
"""Return hard blockers that forbid all PR queue work (#290 AC3).
``live_namespace_broken`` fails the merge/review path closed when the live
MCP namespace call path is unusable (e.g. ``client is closing: EOF``) even
though the tool is registered in FastMCP (#543 AC5). Feed it the
``blocks_merge_workflow`` verdict from
``mcp_namespace_health.classify_namespace_probe``.
"""
reasons: list[str] = []
if infra_stop:
reasons.append("infra_stop is active; PR selection/review/merge is forbidden")
@@ -104,6 +112,12 @@ def assess_workflow_blockers(
reasons.append("MCP reconnect failed; stale session state cannot be reused")
if stale_capability_state:
reasons.append("stale MCP capability state detected after reconnect failure")
if live_namespace_broken:
reasons.append(
"live MCP namespace call path is broken (registered in FastMCP but "
"not callable through the namespace); repair the namespace before "
"review/merge"
)
return {
"block": bool(reasons),
"reasons": reasons,
@@ -118,16 +132,19 @@ def assess_state_advancement(
state_completion: dict[str, bool] | None,
*,
target_state: str,
infra_stop: bool = False,
capability_blocked: bool = False,
**blocker_kwargs,
) -> dict[str, Any]:
"""Fail closed when *target_state* is requested before upstream gates pass."""
"""Fail closed when *target_state* is requested before upstream gates pass.
Forwards every blocker flag (``infra_stop``, ``capability_blocked``,
``mcp_reconnect_failed``, ``stale_capability_state``,
``live_namespace_broken``) to :func:`assess_workflow_blockers` so
``can_approve``/``can_merge`` honor the full blocker set, not just
infra_stop/capability_blocked (#543 AC5).
"""
completion = dict(state_completion or {})
target = _clean(target_state).upper()
blockers = assess_workflow_blockers(
infra_stop=infra_stop,
capability_blocked=capability_blocked,
)
blockers = assess_workflow_blockers(**blocker_kwargs)
reasons = list(blockers["reasons"])
try:
+186 -25
View File
@@ -1,20 +1,45 @@
#!/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 the MCP server processes as defined in the IDE's global config,
performs the JSON-RPC handshake, and queries the tools list to verify
that the connection is fully operational and doesn't return EOF.
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
import json
import os
import subprocess
import sys
def run_connection_test(name, config):
from mcp_namespace_health import REQUIRED_NAMESPACE_TOOLS, classify_namespace_probe
def _read_json_line(proc):
line = proc.stdout.readline()
if not line:
stderr_content = proc.stderr.read()
return None, stderr_content
return json.loads(line), None
def _write_message(proc, payload):
proc.stdin.write(json.dumps(payload) + "\n")
proc.stdin.flush()
def run_connection_test(name, config, *, required_tool=None, config_path=None):
print(f"Testing MCP connection for '{name}'...")
command = config.get("command")
args = config.get("args", [])
env = config.get("env", {})
tool_name = required_tool or REQUIRED_NAMESPACE_TOOLS.get(name) or "gitea_whoami"
# Merge current environment
run_env = os.environ.copy()
@@ -31,8 +56,17 @@ def run_connection_test(name, config):
bufsize=1,
env=run_env
)
except Exception as e:
print(f" [FAIL] Failed to spawn process: {e}")
except Exception as exc:
print(f" [FAIL] Failed to spawn process: {exc}")
assessment = classify_namespace_probe(
name,
required_tool=tool_name,
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
# Send initialize request
@@ -48,26 +82,36 @@ def run_connection_test(name, config):
}
try:
proc.stdin.write(json.dumps(init_req) + "\n")
proc.stdin.flush()
_write_message(proc, init_req)
# Read response
line = proc.stdout.readline()
if not line:
stderr_content = proc.stderr.read()
res, stderr_content = _read_json_line(proc)
if res is None:
print(f" [FAIL] Received EOF from process. Stderr:\n{stderr_content}")
assessment = classify_namespace_probe(
name,
required_tool=tool_name,
probe_result={"success": False, "error": stderr_content or "EOF"},
process={
"pid": proc.pid,
"profile": env.get("GITEA_MCP_PROFILE"),
"env": env,
},
config_path=config_path,
probe_source="offline_spawn",
)
print(f" remediation: {' '.join(assessment['remediation'])}")
proc.terminate()
return False
print(f" [OK] Received initialize response: {line.strip()[:150]}...")
print(f" [OK] Received initialize response: {str(res)[:150]}...")
# Send initialized notification
init_notif = {
"jsonrpc": "2.0",
"method": "notifications/initialized"
}
proc.stdin.write(json.dumps(init_notif) + "\n")
proc.stdin.flush()
_write_message(proc, init_notif)
# Send tools/list request
list_req = {
@@ -76,16 +120,27 @@ def run_connection_test(name, config):
"params": {},
"id": 2
}
proc.stdin.write(json.dumps(list_req) + "\n")
proc.stdin.flush()
_write_message(proc, list_req)
line = proc.stdout.readline()
if not line:
res, stderr_content = _read_json_line(proc)
if res is None:
print(" [FAIL] Received EOF on tools/list request.")
assessment = classify_namespace_probe(
name,
required_tool=tool_name,
probe_result={"success": False, "error": stderr_content or "EOF"},
process={
"pid": proc.pid,
"profile": env.get("GITEA_MCP_PROFILE"),
"env": env,
},
config_path=config_path,
probe_source="offline_spawn",
)
print(f" remediation: {' '.join(assessment['remediation'])}")
proc.terminate()
return False
res = json.loads(line)
if "error" in res:
print(f" [FAIL] Server returned error: {res['error']}")
proc.terminate()
@@ -94,16 +149,115 @@ def run_connection_test(name, config):
tools = res.get("result", {}).get("tools", [])
tool_names = [t.get("name") for t in tools]
print(f" [OK] Successfully retrieved {len(tool_names)} tools: {tool_names[:5]}...")
if tool_name not in tool_names:
assessment = classify_namespace_probe(
name,
required_tool=tool_name,
registered_tools=tool_names,
probe_result={"success": False, "error": "required tool missing"},
process={
"pid": proc.pid,
"profile": env.get("GITEA_MCP_PROFILE"),
"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'])}")
proc.terminate()
return False
call_req = {
"jsonrpc": "2.0",
"method": "tools/call",
"params": {"name": tool_name, "arguments": {}},
"id": 3,
}
_write_message(proc, call_req)
call_res, stderr_content = _read_json_line(proc)
if call_res is None:
assessment = classify_namespace_probe(
name,
required_tool=tool_name,
registered_tools=tool_names,
probe_result={"success": False, "error": stderr_content or "EOF"},
process={
"pid": proc.pid,
"profile": env.get("GITEA_MCP_PROFILE"),
"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)}")
print(f" remediation: {' '.join(assessment['remediation'])}")
proc.terminate()
return False
if "error" in call_res:
assessment = classify_namespace_probe(
name,
required_tool=tool_name,
registered_tools=tool_names,
probe_result={"success": False, "error": call_res["error"]},
process={
"pid": proc.pid,
"profile": env.get("GITEA_MCP_PROFILE"),
"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'])}")
proc.terminate()
return False
assessment = classify_namespace_probe(
name,
required_tool=tool_name,
registered_tools=tool_names,
probe_result={"success": True, "result": call_res.get("result")},
process={
"pid": proc.pid,
"profile": env.get("GITEA_MCP_PROFILE"),
"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)}")
proc.terminate()
return True
except Exception as e:
print(f" [FAIL] Error during handshake: {e}")
except Exception as exc:
print(f" [FAIL] Error during handshake: {exc}")
proc.terminate()
return False
def main():
config_path = "/Users/jasonwalker/.gemini/config/mcp_config.json"
parser = argparse.ArgumentParser()
parser.add_argument(
"--config",
default=os.environ.get(
"MCP_CONFIG_PATH",
os.path.expanduser("~/.gemini/config/mcp_config.json"),
),
help="Path to MCP config JSON.",
)
parser.add_argument(
"--namespace",
action="append",
dest="namespaces",
help="Namespace to test. May be repeated.",
)
args = parser.parse_args()
config_path = args.config
try:
with open(config_path) as f:
mcp_config = json.load(f)
@@ -112,10 +266,17 @@ def main():
sys.exit(1)
servers = mcp_config.get("mcpServers", {})
namespaces = args.namespaces or list(REQUIRED_NAMESPACE_TOOLS)
failed = False
for name in ["gitea-author", "gitea-reviewer"]:
for name in namespaces:
if name in servers:
if not run_connection_test(name, servers[name]):
if not run_connection_test(
name,
servers[name],
required_tool=REQUIRED_NAMESPACE_TOOLS.get(name),
config_path=config_path,
probe_source="offline_spawn",
):
failed = True
else:
print(f"Server '{name}' not found in mcp_config.json")
+1
View File
@@ -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)
-198
View File
@@ -78,95 +78,6 @@ class TestBlockReasonsAndReport(unittest.TestCase):
self.assertTrue(report["recovery"])
class TestLiveRemoteParity(unittest.TestCase):
"""#610: parity must account for the live remote master, not just local.
The daemon can be stale relative to the live remote target while the local
checkout HEAD still matches the daemon's startup commit, so local parity
reports green even though a mutation would run against outdated code.
"""
SHA_C = "c" * 40
def test_distinguishes_three_shas(self):
res = mp.assess_master_parity(
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
self.assertEqual(res["daemon_start_head"], SHA_A)
self.assertEqual(res["local_head"], SHA_A)
self.assertEqual(res["live_remote_head"], SHA_B)
def test_mutation_safe_only_when_all_three_match(self):
res = mp.assess_master_parity(
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_A)
self.assertTrue(res["mutation_safe"])
self.assertTrue(res["live_known"])
self.assertFalse(res["live_stale"])
def test_live_stale_when_remote_advanced_past_daemon(self):
# Local checkout still matches the daemon start (local parity green),
# but the live remote master has advanced -> daemon is live-stale.
res = mp.assess_master_parity(
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
self.assertTrue(res["in_parity"]) # local parity still green
self.assertTrue(res["live_stale"])
self.assertFalse(res["mutation_safe"])
self.assertTrue(any("live" in r.lower() for r in res["reasons"]))
def test_live_unknown_is_not_mutation_safe_but_not_stale(self):
# Non-goal: unfetchable live remote must not be treated as stale for
# read-only, but a mutation-safe claim fails closed.
res = mp.assess_master_parity(
{"startup_head": SHA_A}, SHA_A, live_remote_head=None)
self.assertFalse(res["live_known"])
self.assertFalse(res["mutation_safe"])
self.assertFalse(res["live_stale"])
self.assertTrue(res["in_parity"])
def test_default_live_remote_preserves_legacy_shape(self):
# Callers that do not supply a live head keep the pre-#610 behavior:
# in-parity, not live-stale, no live-derived block.
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
self.assertFalse(res["live_stale"])
self.assertEqual(mp.parity_block_reasons(res), [])
class TestLiveStaleBlockAndReport(unittest.TestCase):
"""#610: live-staleness must block mutations and surface a typed blocker."""
def test_live_stale_produces_block_reasons(self):
res = mp.assess_master_parity(
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
self.assertTrue(mp.parity_block_reasons(res))
def test_disable_env_suppresses_live_stale_block(self):
res = mp.assess_master_parity(
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
with patch.dict(os.environ, {mp.ENV_DISABLE: "1"}):
self.assertEqual(mp.parity_block_reasons(res), [])
def test_resolver_disagreement_returns_typed_blocker(self):
# Parity says local-green, resolver says restart required -> disagreement
# is a typed, fail-closed blocker naming the resolver as authoritative.
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
blocker = mp.parity_resolver_disagreement(res, resolver_restart_required=True)
self.assertIsNotNone(blocker)
self.assertEqual(blocker["kind"], "parity_resolver_disagreement")
self.assertTrue(blocker["restart_required"])
self.assertTrue(blocker["resolver_authoritative"])
def test_no_disagreement_when_resolver_agrees(self):
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
self.assertIsNone(
mp.parity_resolver_disagreement(res, resolver_restart_required=False))
def test_live_stale_report_names_live_remote(self):
res = mp.assess_master_parity(
{"startup_head": SHA_A}, SHA_A, live_remote_head=SHA_B)
report = mp.parity_report(res)
self.assertEqual(report["live_remote_head"], SHA_B)
self.assertTrue(report["restart_required"])
class TestReadGitHead(unittest.TestCase):
def test_test_override_takes_precedence(self):
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}):
@@ -184,78 +95,6 @@ class TestReadGitHead(unittest.TestCase):
self.assertIsNone(mp.read_git_head(""))
class TestReadRemoteMasterHead(unittest.TestCase):
"""#610: live remote master head reader (env-overridable, fails to None)."""
def test_test_override_takes_precedence(self):
with patch.dict(os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_B}):
self.assertEqual(mp.read_remote_master_head("/nonexistent"), SHA_B)
def test_blank_override_is_none(self):
with patch.dict(os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: " "}):
self.assertIsNone(mp.read_remote_master_head("/nonexistent"))
def test_unfetchable_remote_is_none(self):
# No override; a bogus root/remote must fail closed to None, never raise.
env = {k: v for k, v in os.environ.items()
if k != mp.ENV_TEST_LIVE_REMOTE_HEAD}
with patch.dict(os.environ, env, clear=True):
self.assertIsNone(
mp.read_remote_master_head("/nonexistent", remote="nope"))
class TestRemoteHeadCache(unittest.TestCase):
"""#610: live remote reads are cached with a TTL to stay off the network.
The parity gate runs on every mutation and every runtime-context read, so an
unbounded ``git ls-remote`` per call would be a latency/flakiness regression.
"""
def setUp(self):
mp._clear_remote_head_cache()
env = {k: v for k, v in os.environ.items()
if k != mp.ENV_TEST_LIVE_REMOTE_HEAD}
self._env = patch.dict(os.environ, env, clear=True)
self._env.start()
self.addCleanup(self._env.stop)
self.addCleanup(mp._clear_remote_head_cache)
def _fake_run(self, sha):
class _R:
returncode = 0
stdout = f"{sha}\trefs/heads/master\n"
calls = {"n": 0}
def run(*args, **kwargs):
calls["n"] += 1
return _R()
return run, calls
def test_second_call_within_ttl_uses_cache(self):
run, calls = self._fake_run(SHA_B)
with patch.object(mp.subprocess, "run", run):
a = mp.read_remote_master_head("/repo", remote="prgs", ttl=100)
b = mp.read_remote_master_head("/repo", remote="prgs", ttl=100)
self.assertEqual(a, SHA_B)
self.assertEqual(b, SHA_B)
self.assertEqual(calls["n"], 1)
def test_zero_ttl_bypasses_cache(self):
run, calls = self._fake_run(SHA_B)
with patch.object(mp.subprocess, "run", run):
mp.read_remote_master_head("/repo", remote="prgs", ttl=0)
mp.read_remote_master_head("/repo", remote="prgs", ttl=0)
self.assertEqual(calls["n"], 2)
def test_env_override_never_touches_subprocess(self):
run, calls = self._fake_run(SHA_B)
with patch.dict(os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_A}):
with patch.object(mp.subprocess, "run", run):
self.assertEqual(
mp.read_remote_master_head("/repo", remote="prgs"), SHA_A)
self.assertEqual(calls["n"], 0)
class TestServerWiring(unittest.TestCase):
"""Integration with the gate choke point in the server namespace."""
@@ -266,13 +105,6 @@ class TestServerWiring(unittest.TestCase):
self._saved = self.srv._STARTUP_PARITY
self.srv._STARTUP_PARITY = {"root": self.srv.PROJECT_ROOT,
"startup_head": SHA_A}
# Keep the live-remote read hermetic (no real ls-remote network call):
# default the live master to the daemon start so parity is fully green
# unless a test overrides the live head explicitly (#610).
self._live_patch = patch.dict(
os.environ, {mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_A})
self._live_patch.start()
self.addCleanup(self._live_patch.stop)
def tearDown(self):
self.srv._STARTUP_PARITY = self._saved
@@ -315,36 +147,6 @@ class TestServerWiring(unittest.TestCase):
self.assertTrue(out["in_parity"])
self.assertNotIn("report", out)
# --- #610: live-remote wiring -------------------------------------------
def test_live_stale_blocks_mutation_though_local_green(self):
# Local checkout matches the daemon start (local parity green) but the
# live remote master has advanced -> mutations must fail closed.
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A,
mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_B}):
self.assertEqual(self.srv._master_parity_block("gitea.read"), [])
self.assertTrue(
self.srv._master_parity_block("gitea.pr.create"))
def test_assess_tool_exposes_three_distinct_shas(self):
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A,
mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_B}):
out = self.srv.gitea_assess_master_parity(remote="prgs")
self.assertEqual(out["daemon_start_head"], SHA_A)
self.assertEqual(out["local_head"], SHA_A)
self.assertEqual(out["live_remote_head"], SHA_B)
self.assertTrue(out["live_stale"])
self.assertFalse(out["mutation_safe"])
self.assertIn("report", out)
def test_assess_tool_mutation_safe_when_all_three_match(self):
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A,
mp.ENV_TEST_LIVE_REMOTE_HEAD: SHA_A}):
out = self.srv.gitea_assess_master_parity(remote="prgs")
self.assertTrue(out["mutation_safe"])
self.assertFalse(out["live_stale"])
self.assertNotIn("report", out)
if __name__ == "__main__":
unittest.main()
+208
View File
@@ -0,0 +1,208 @@
import unittest
import gitea_mcp_server
import mcp_namespace_health
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",
required_tool="gitea_whoami",
registered_tools=["gitea_whoami", "gitea_list_profiles"],
probe_result={
"success": False,
"error": "client is closing: EOF",
},
process={
"pid": 4321,
"profile": "prgs-reviewer",
"env": {
"GITEA_MCP_PROFILE": "prgs-reviewer",
"GITEA_TOKEN": "must-not-leak",
},
},
config_path="/Users/jasonwalker/.gemini/config/mcp_config.json",
probe_source="client_namespace",
)
self.assertFalse(result["healthy"])
self.assertEqual(result["error_type"], "namespace_eof")
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(
result["diagnostics"]["env"]["GITEA_MCP_PROFILE"],
"prgs-reviewer",
)
self.assertNotIn("GITEA_TOKEN", result["diagnostics"]["env"])
self.assertIn("gitea-reviewer", result["reasons"][0])
self.assertIn("gitea_whoami", result["reasons"][0])
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"])
self.assertEqual(result["required_tool"], "gitea_list_profiles")
self.assertFalse(result["required_tool_registered"])
self.assertEqual(result["error_type"], "tool_missing")
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).
idx = rmsm.REVIEW_MERGE_STATES.index("PRE_MERGE_RECHECK")
return {state: True for state in rmsm.REVIEW_MERGE_STATES[: idx + 1]}
def _all_gates(self):
return {gate: True for gate in rmsm._PRE_MERGE_REQUIRED_GATES}
def test_assess_workflow_blockers_flags_live_namespace_broken(self):
clean = rmsm.assess_workflow_blockers()
self.assertFalse(clean["block"])
broken = rmsm.assess_workflow_blockers(live_namespace_broken=True)
self.assertTrue(broken["block"])
self.assertTrue(any("namespace" in r.lower() for r in broken["reasons"]))
def test_can_merge_blocks_even_when_all_gates_pass(self):
# Without the namespace blocker a fully-complete workflow can merge...
allowed = rmsm.can_merge(
self._merge_ready_completion(), pre_merge_gates=self._all_gates()
)
self.assertTrue(allowed["allowed"])
# ...but a broken live namespace overrides every satisfied gate.
blocked = rmsm.can_merge(
self._merge_ready_completion(),
pre_merge_gates=self._all_gates(),
live_namespace_broken=True,
)
self.assertTrue(blocked["block"])
self.assertFalse(blocked["allowed"])
def test_workflow_status_forwards_namespace_blocker(self):
status = rmsm.workflow_status(
self._merge_ready_completion(), live_namespace_broken=True
)
self.assertFalse(status["merge_allowed"])
self.assertFalse(status["approve_allowed"])
def test_server_tool_blocks_merge_on_live_namespace_broken(self):
result = gitea_mcp_server.gitea_assess_review_merge_state_machine(
state_completion=self._merge_ready_completion(),
pre_merge_gates=self._all_gates(),
live_namespace_broken=True,
)
self.assertTrue(result["blockers"]["block"])
self.assertFalse(result["merge"]["allowed"])
def test_classify_verdict_bridges_into_merge_block(self):
# The classify verdict is the intended feed for live_namespace_broken.
verdict = mcp_namespace_health.classify_namespace_probe(
"gitea-merger",
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(
self._merge_ready_completion(),
pre_merge_gates=self._all_gates(),
live_namespace_broken=verdict["blocks_merge_workflow"],
)
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()