Files
Gitea-Tools/mcp_namespace_health.py
T
sysadminandClaude Opus 5 1dd30ecb15 feat(mcp): client/session-aware runtime ownership and provenance (#948)
Two surfaces reported different provenance for one process.
`gitea_get_runtime_context` read the live environment and reported
`client_managed`; `mcp_namespace_health.classify_namespace_probe` derived
provenance from `_safe_env_summary()`, whose `SAFE_ENV_KEYS` allowlist never
contained `GITEA_CLIENT_MANAGED`, `GITEA_MCP_CLIENT_MANAGED`, or
`GITEA_SERVER_PROVENANCE`. That lookup could only ever miss, so the health
surface was structurally incapable of returning anything but `manual_launch`.

Neither model could name which client or which session owned a runtime, so a
healthy daemon serving a second client was indistinguishable from a duplicate,
and the profile-wide duplicate scan walled the whole fleet.

Introduce `mcp_worker_identity` as the one authority, splitting two claims the
old code ran together:

* launch provenance — was this hand-launched from a terminal? Answered from the
  environment, which is legitimate because the launcher sets it. Preserves the
  #686 wall unchanged.
* session ownership — which live client session owns this runtime now? Answered
  only from a live attachment record; no environment flag can establish it.

The module provides collision-resistant worker identities
(`<llm-name>-<UTC-timestamp>-<short-sha>`), an atomic SQLite registry with
fencing epochs, heartbeat-based liveness, generation takeover that supersedes
only a non-live claimant, cohort classification, and failure scoping.

Behaviour changes:

* Registering an existing worker identity fails closed; it is never replaced,
  adopted, or merged with. The caller mints a different identity instead.
* A generation held by a live session cannot be claimed by a second one. A
  generation whose claimant is not live is taken over with a higher fencing
  epoch, so stale ownership cannot permanently strand a healthy daemon.
* A superseded session presenting an old epoch is refused and performs no write.
* Liveness comes from heartbeat freshness; a live PID cannot resurrect an
  expired record, and a dead PID withdraws liveness.
* Workers sharing a role or profile no longer trigger a profile-wide duplicate
  block, provided each carries a distinct identity. Processes with no identity
  evidence remain classified as duplicates, so the #686 wall still holds.
* Runtime failures are scoped to a worker identity or generation, never to a
  profile or the fleet.
* Reconnect guidance no longer defaults to Codex. An unidentified client gets
  host-agnostic steps; `gitea_request_mcp_reconnect(client=...)` defaults to
  resolving the client from the live attachment record.
* `resolve_bound_remote` keeps a bound namespace on its remote instead of
  falling through to the `dadeschools` library default.

Absence of proof is now reported as `unproven` rather than asserted as
`manual_launch`. Both still fail closed — `is_client_managed` is unchanged, so
nothing previously refused is now permitted — but remediation names the proof
that is actually missing instead of describing a terminal launch it cannot
evidence. The #686 test is updated for that vocabulary and keeps every
wall-preserving assertion.

Threat-model anchors and their citations in docs/remote-mcp/threat-model.md are
restamped for the line movement in gitea_mcp_server.py.

Tests: tests/test_issue_948_client_session_provenance.py adds 43 cases covering
Codex/Gemini/Antigravity/Claude attachment, same-client new session, cross-client
takeover after a session ends, two live conflicting sessions, stale records,
missing attachment proof, environment flags without attachment, mixed
generations, duplicate cohorts, the hardcoded-client regression, explicit PRGS
selection, default-remote host drift, cross-surface agreement, and fail-closed
handling without false reconnect loops. Synthetic identifiers throughout.

Full suite from a branches/ worktree: 28F/5953P/6S at head vs 28F/5910P/6S at
merge base 8eada1fb, identical failing ID sets.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01F6Vomtndpq2gSBa88Tfcwy
2026-07-29 02:52:18 -04:00

715 lines
30 KiB
Python

"""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",
)
ERROR_CONNECTED_NAMESPACES_MISSING = "mcp_connected_namespaces_missing"
# Distinct from the condition above. ``mcp_connected_namespaces_missing`` is reserved for
# the actual #708 defect: the host *does* report the service Connected, yet the namespace
# never entered the active session tool surface. A required namespace that is absent from
# the connected-service inventory has no Connected claim behind it at all, so reporting it
# under the #708 condition would assert something the evidence does not support and would
# point an operator at the wrong recovery.
ERROR_REQUIRED_NAMESPACES_NOT_CONNECTED = "mcp_required_namespaces_not_connected"
DISCOVERY_STATUS_ATTACHED = "namespaces_attached"
DISCOVERY_STATUS_CONNECTED_MISSING = "connected_but_namespaces_missing"
DISCOVERY_STATUS_NOT_CONNECTED = "required_namespaces_not_connected"
DISCOVERY_STATUS_DISCONNECTED = "disconnected"
# Namespaces that must be *attached to the active session* for a mutation task (#708).
# Connected-at-host is not attached-in-session; these are gated separately from the
# #543 health map because a namespace can be healthy on probe yet absent from the
# session tool surface.
ATTACHMENT_GATED_TASKS = {
"review_pr": "gitea-reviewer",
"submit_review": "gitea-reviewer",
"merge_pr": "gitea-merger",
"work_issue": "gitea-author",
"create_pr": "gitea-author",
}
# The only sanctioned recovery for an unattached namespace (#678 exposes it natively).
SANCTIONED_ATTACH_RECOVERY_TOOL = "gitea_request_mcp_reconnect"
UNSAFE_FALLBACK_WARNING = (
"Workflow Safety Hard Stop (#708): Connected-but-namespaces-missing recovery must "
"NEVER use direct imports, Gitea API mutations, profile hopping, session-state "
"overrides, PID kills, or config mtime touches. Use client reconnect only."
)
SAFE_ENV_KEYS = (
"GITEA_MCP_PROFILE",
"GITEA_PROFILE_NAME",
"GITEA_SERVICE",
"GITEA_EXECUTION_ROLE",
"GITEA_MCP_CONFIG",
)
# #948: provenance used to be derived from the summary this allowlist produces.
# The allowlist never carried a provenance key, so that derivation could only
# ever evaluate to ``manual_launch`` — whatever the process actually was — while
# ``gitea_get_runtime_context`` read the live environment and reported
# ``client_managed`` for the same process. Provenance is no longer derived here.
# It comes from ``mcp_worker_identity.assess_provenance``, the single authority
# every surface shares. This allowlist keeps its original and only job: deciding
# which env values are safe to echo back in diagnostics.
def assess_connected_namespace_attachment(
*,
connected_servers: list[str] | tuple[str, ...] | set[str] | None = None,
attached_session_namespaces: list[str] | tuple[str, ...] | set[str] | None = None,
required_namespaces: list[str] | tuple[str, ...] | set[str] | None = None,
discovery_cache_age_seconds: float | int | None = None,
discovery_cache_hit: bool | None = None,
auto_attach_attempted: bool = False,
auto_attach_succeeded: bool = False,
session_tool_snapshot_at: float | int | None = None,
namespace_connected_at: dict[str, float] | None = None,
) -> dict[str, Any]:
"""Assess whether host-connected MCP servers have attached tool namespaces in the active session (#708).
Addresses the Connected-but-namespaces-missing defect: CLI/host status may report Connected
while the active LLM session tool surface exposes 0 attached tool namespaces.
This is a *distinct* condition from config drift (#672), transport-closed (#584),
and resolver EOF (#685): the transport is up and the host reports Connected, yet the
namespace never entered the session tool surface.
Startup ordering (``session_tool_snapshot_at`` + ``namespace_connected_at``) identifies
the race where the session tool snapshot was taken before a role server finished
``initialize``/``list_tools``, which is why parallel multi-role startup can leave the
session with an empty namespace set while Connected later flips true.
Returns structured detection details plus secret-free telemetry.
"""
connected = [str(s).strip() for s in (connected_servers or []) if str(s).strip()]
attached = set(str(ns).strip() for ns in (attached_session_namespaces or []) if str(ns).strip())
req = [str(r).strip() for r in (required_namespaces or DEFAULT_NAMESPACES) if str(r).strip()]
required = list(dict.fromkeys(req))
connected_set = set(connected)
proof: dict[str, dict[str, bool]] = {}
for s in connected:
proof[s] = {"connected": True, "attached": s in attached}
for r in required:
if r not in proof:
proof[r] = {"connected": r in connected_set, "attached": r in attached}
# The genuine #708 condition: the host reports the service Connected and the namespace
# still never entered the active session tool surface.
missing = [r for r in required if r in connected_set and r not in attached]
# A separate condition: the service is required but absent from the connected-service
# inventory. Nothing here is Connected, so this must not borrow the #708 wording or its
# recovery — see ERROR_REQUIRED_NAMESPACES_NOT_CONNECTED.
not_connected = [r for r in required if r not in connected_set]
# Evidence that disagrees with itself: reported attached to the session while absent
# from the connected inventory. Neither statement is proof, so it fails closed.
contradictory = [r for r in not_connected if r in attached]
# No required namespace may lack attachment proof and still be called healthy.
attachment_healthy = (
not missing
and not not_connected
and len(connected) > 0
and len(required) > 0
)
if attachment_healthy:
discovery_status = DISCOVERY_STATUS_ATTACHED
elif not connected:
discovery_status = DISCOVERY_STATUS_DISCONNECTED
elif not_connected:
discovery_status = DISCOVERY_STATUS_NOT_CONNECTED
else:
discovery_status = DISCOVERY_STATUS_CONNECTED_MISSING
# Every condition actually present is reported; ``error_type`` names the primary one.
# Not-connected outranks Connected-but-unattached because a service that never
# connected cannot be recovered by attaching its namespace.
error_types: list[str] = []
if not_connected:
error_types.append(ERROR_REQUIRED_NAMESPACES_NOT_CONNECTED)
if missing:
error_types.append(ERROR_CONNECTED_NAMESPACES_MISSING)
if not attachment_healthy and not error_types:
error_types.append(ERROR_REQUIRED_NAMESPACES_NOT_CONNECTED)
error_type = None if attachment_healthy else error_types[0]
# Per-namespace verdict, so a mutation gate never has to infer one namespace's state
# from a whole-session summary. Each entry states only what its own evidence supports.
namespace_conditions: dict[str, dict[str, Any]] = {}
for ns_name, state in proof.items():
ns_connected = bool(state["connected"])
ns_attached = bool(state["attached"])
if ns_connected and ns_attached:
condition = None
elif ns_connected:
condition = ERROR_CONNECTED_NAMESPACES_MISSING
else:
condition = ERROR_REQUIRED_NAMESPACES_NOT_CONNECTED
namespace_conditions[ns_name] = {
"namespace": ns_name,
"required": ns_name in required,
"connected": ns_connected,
"attached": ns_attached,
"attachment_healthy": ns_connected and ns_attached,
"condition": condition,
"contradictory_evidence": ns_attached and not ns_connected,
}
# Startup-ordering race: a namespace that finished connecting *after* the session
# tool snapshot was taken cannot be in that snapshot, however healthy it looks now.
connected_at = {
str(k).strip(): v
for k, v in (namespace_connected_at or {}).items()
if str(k).strip() and isinstance(v, (int, float))
}
late_attaching: list[str] = []
if isinstance(session_tool_snapshot_at, (int, float)):
for ns_name, ts in connected_at.items():
if ts > session_tool_snapshot_at and ns_name not in attached:
late_attaching.append(ns_name)
late_attaching.sort()
startup_ordering_race = bool(late_attaching)
auto_recovered = bool(auto_attach_attempted and auto_attach_succeeded and attachment_healthy)
reconnect_required = not attachment_healthy
reasons: list[str] = []
remediation: list[str] = []
if not connected:
reasons.append("No MCP servers reported Connected.")
remediation.append("Start or reconnect Gitea MCP servers in client config.")
if missing:
reasons.append(
f"MCP server(s) {missing} report Connected at host/CLI layer but tool namespaces "
f"are missing from active session attached tools (Connected ≠ attached tools, #708)."
)
remediation.append(
"Reconnect the IDE/client MCP session to attach namespaces to the active session "
f"(sanctioned path: {SANCTIONED_ATTACH_RECOVERY_TOOL}), then re-run full preflight "
"(gitea_whoami -> gitea_resolve_task_capability -> task). "
"Do not use direct imports, CLI API mutations, profile hopping, or session file overrides."
)
if not_connected:
reasons.append(
f"required MCP namespace(s) {not_connected} are absent from the connected-service "
"inventory: nothing reports them Connected, so there is no attachment to claim and "
"the Connected-but-unattached condition does not apply to them (#708)."
)
remediation.append(
f"Connect the required MCP server(s) {not_connected} through the client, then "
"reconnect the IDE/client MCP session so their namespaces attach "
f"(sanctioned path: {SANCTIONED_ATTACH_RECOVERY_TOOL}), and re-run full preflight. "
"Do not use direct imports, CLI API mutations, profile hopping, or session file overrides."
)
if contradictory:
reasons.append(
f"contradictory evidence for namespace(s) {contradictory}: reported attached to the "
"active session while absent from the connected-service inventory; neither statement "
"is proof, so attachment is treated as unproven (fail closed, #708)."
)
if attachment_healthy:
reasons.append(
"All required MCP server namespaces are connected and attached to the active session."
)
if startup_ordering_race:
reasons.append(
f"startup ordering race: namespace(s) {late_attaching} finished connecting after the "
"active session tool snapshot was taken, so they cannot appear in that snapshot (#708)."
)
if auto_attach_attempted and not auto_attach_succeeded:
reasons.append(
"automatic namespace attachment was attempted and did not succeed; only the sanctioned "
"client reconnect path remains."
)
if auto_recovered:
reasons.append("namespaces were automatically attached; no operator reconnect was required.")
return {
"success": attachment_healthy,
"attachment_healthy": attachment_healthy,
"discovery_status": discovery_status,
"connected_servers": connected,
"attached_session_namespaces": list(attached),
"missing_namespaces": missing,
"not_connected_namespaces": not_connected,
"contradictory_namespaces": contradictory,
"proof_of_connected_vs_attached": proof,
"namespace_conditions": namespace_conditions,
"error_type": error_type,
"error_types": error_types,
"reasons": reasons,
"remediation": remediation,
"exact_next_action": (
"None; session tool namespaces attached."
if attachment_healthy
else (
f"Connect the required MCP server(s) {not_connected} through the client, then "
"reconnect the IDE/client MCP session so their tool namespaces attach to the "
"active session. Do not use direct imports, CLI API mutations, profile hopping, "
"or session-state overrides."
if not_connected
else (
"Reconnect the IDE/client MCP session so tool namespaces attach to the active "
"session. Do not use direct imports, CLI API mutations, profile hopping, or "
"session-state overrides."
)
)
),
"unsafe_fallback_policy": UNSAFE_FALLBACK_WARNING,
"sanctioned_recovery_tool": SANCTIONED_ATTACH_RECOVERY_TOOL,
"reconnect_required": reconnect_required,
"auto_attach_attempted": bool(auto_attach_attempted),
"auto_recovered": auto_recovered,
"startup_ordering_race": startup_ordering_race,
"late_attaching_namespaces": late_attaching,
# Secret-free structured signals (#708 AC5). Namespace names and counts only:
# never tokens, endpoints, env values, or filesystem paths.
"telemetry": {
"connected_count": len(connected),
"attached_count": len(attached),
"required_count": len(required),
"missing_count": len(missing),
"not_connected_count": len(not_connected),
"contradictory_count": len(contradictory),
"required_attached_count": sum(1 for r in required if r in attached),
"discovery_status": discovery_status,
"discovery_cache_hit": (
None if discovery_cache_hit is None else bool(discovery_cache_hit)
),
"discovery_cache_age_seconds": (
float(discovery_cache_age_seconds)
if isinstance(discovery_cache_age_seconds, (int, float))
else None
),
"reconnect_required": reconnect_required,
"auto_attach_attempted": bool(auto_attach_attempted),
"auto_recovered": auto_recovered,
"startup_ordering_race": startup_ordering_race,
"error_type": error_type,
"error_types": error_types,
},
}
def required_namespace_for_attachment(task: str) -> str | None:
"""Map a mutation task to the MCP namespace that must be *attached* (#708)."""
return ATTACHMENT_GATED_TASKS.get((task or "").strip())
def attachment_gate_from_session(
task: str,
session_attachment: dict[str, dict[str, Any]] | None,
) -> list[str]:
"""Fail-closed gate on recorded connected-but-unattached namespaces (#708).
Mirrors :func:`mutation_gate_from_session`: a namespace that has not been
assessed yet does not gate, so this never blocks a session that simply has
not run the assessment. Once an assessment records the namespace required
for *task* as Connected-but-unattached, the mutation fails closed and the
only offered recovery is the sanctioned client reconnect path.
"""
ns = required_namespace_for_attachment(task)
if not ns:
return []
store = session_attachment or {}
entry = store.get(ns)
if not entry:
return []
if entry.get("attached") and entry.get("attachment_healthy"):
return []
what = task or "mutation"
connected = entry.get("connected")
detail = entry.get("condition") or entry.get("error_type")
if connected is True:
# Only here has anything actually reported the service Connected, so only here may
# the block say so.
detail = detail or ERROR_CONNECTED_NAMESPACES_MISSING
blocked = (
f"live MCP namespace '{ns}' is recorded {detail}: the host reports Connected but the "
f"namespace is not attached to the active session tool surface; reconnect the "
f"IDE/client MCP session and re-run preflight before {what} "
"(fail closed, #708)"
)
elif connected is False:
detail = ERROR_REQUIRED_NAMESPACES_NOT_CONNECTED
blocked = (
f"live MCP namespace '{ns}' is recorded {detail}: it is absent from the "
f"connected-service inventory, so it is neither connected nor attached and no "
f"Connected status is claimed for it; connect the required MCP server, then "
f"reconnect the IDE/client MCP session and re-run preflight before {what} "
"(fail closed, #708)"
)
else:
# Connected status was never recorded. Refuse without asserting either condition.
detail = detail or ERROR_CONNECTED_NAMESPACES_MISSING
blocked = (
f"live MCP namespace '{ns}' is recorded {detail} with no connected-status evidence, "
f"so attachment to the active session tool surface is unproven; reconnect the "
f"IDE/client MCP session and re-run preflight before {what} "
"(fail closed, #708)"
)
return [blocked, UNSAFE_FALLBACK_WARNING]
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,
worker_identity: str | None = None,
generation_id: str | None = None,
registry: Any | None = None,
pid_alive_probe: Any | 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.
#948: ``worker_identity``/``generation_id``/``registry`` carry the
client/session ownership evidence. Provenance is resolved by
``mcp_worker_identity.assess_provenance`` — the same call
``gitea_get_runtime_context`` makes — so the two surfaces cannot report
different provenance for one process. Omitting them yields the fail-closed
``unproven`` verdict, never a fabricated ``client_managed``.
"""
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)
import gitea_config
import mcp_worker_identity
raw_env = process.get("env") if isinstance(process, dict) else None
unconsumed_env = gitea_config.get_unconsumed_gitea_env_overrides(raw_env)
# #948: one authority, shared with gitea_get_runtime_context. The env is
# passed whole rather than through SAFE_ENV_KEYS — the allowlist exists to
# decide what may be *echoed*, and using it to decide what may be *believed*
# is what made this surface structurally unable to report client_managed.
# ``declared_only``: ``process`` describes an observed peer, not this
# interpreter. Its stdin is unavailable and its launcher-config env is
# inherited from whatever shell started it, so only an explicit declaration
# is evidence. Absence of one is ``unproven``, not an asserted manual launch.
provenance_verdict = mcp_worker_identity.assess_provenance(
registry=registry,
worker_identity=worker_identity,
generation_id=generation_id,
env=raw_env if isinstance(raw_env, dict) else {},
namespace=ns,
profile=profile_name,
pid_alive_probe=pid_alive_probe,
declared_only=True,
)
provenance = provenance_verdict["provenance"]
is_client_managed = provenance_verdict["is_client_managed"]
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,
"provenance": provenance,
"is_client_managed": is_client_managed,
# Every non-client-session verdict fails closed. Consumers that only
# need "may this mutate?" read this and stay correct across the #948
# vocabulary split between ``manual_launch`` and ``unproven``.
"provenance_fail_closed": provenance_verdict["fail_closed"],
"provenance_assessment": provenance_verdict,
"worker_identity": provenance_verdict["worker_identity"],
"session_id": provenance_verdict["session_id"],
"generation_id": provenance_verdict["generation_id"],
"client_name": provenance_verdict["client_name"],
"unconsumed_gitea_env": unconsumed_env,
"diagnostics": {
"namespace": ns,
"required_tool": tool,
"process_pid": process_pid,
"profile": profile_name,
"env": env_summary,
"config_path": config_path,
"probe_source": source,
"provenance": provenance,
"is_client_managed": is_client_managed,
"provenance_fail_closed": provenance_verdict["fail_closed"],
"provenance_blocker_kind": provenance_verdict["blocker_kind"],
"provenance_scope": provenance_verdict["scope"],
"worker_identity": provenance_verdict["worker_identity"],
"session_id": provenance_verdict["session_id"],
"generation_id": provenance_verdict["generation_id"],
"client_name": provenance_verdict["client_name"],
"unconsumed_gitea_env": unconsumed_env,
},
"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 []