fix(mcp): separate not-connected from Connected-but-unattached (review 637 B2)

A required namespace absent from the connected-service inventory was never
counted as missing, because missing_namespaces was populated only while
walking connected_servers. The session therefore reported attachment_healthy
true, discovery_status namespaces_attached and error_type None while holding
no attachment proof for that namespace, and the gate text told the operator
"the host reports Connected" about a service nothing had reported Connected.

assess_connected_namespace_attachment now classifies the two conditions
apart. missing_namespaces keeps its meaning: required, Connected at the host,
absent from the session tool surface -- the actual #708 defect, still typed
mcp_connected_namespaces_missing. Required namespaces absent from the
connected inventory land in the new not_connected_namespaces list, typed
mcp_required_namespaces_not_connected, so a caller is never pointed at an
attachment recovery for a service that never connected. attachment_healthy
is false whenever any required namespace lacks attachment proof under either
condition, error_types reports every condition present so neither hides the
other, and namespace_conditions carries the per-namespace connected/attached
verdict. Evidence that disagrees with itself -- attached in the session yet
absent from the connected inventory -- is reported as contradictory and fails
closed rather than being read as proof.

attachment_gate_from_session states only what the recorded evidence supports:
the Connected wording appears solely when connected is true, the not-connected
wording when it is false, and a neutral refusal when connected status was
never recorded. Review and merge stay fail-closed in all three cases.

_record_live_namespace_attachment consumes the per-namespace verdicts instead
of pinning the session-wide error_type onto every unattached namespace and
instead of deriving health from a missing list that tracked only one of the
two conditions.

The two existing cases in test_issue_708_mcp_namespace_attachment.py declared
no required_namespaces, so they inherited a default set containing a namespace
their connected list omitted. Under the corrected rule that is a false-healthy
assertion, so each now declares the required set it actually means.

Closes #708

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-07-28 17:08:44 -05:00
co-authored by Claude Opus 4.8
parent 126d76ad28
commit ca5f078d8a
4 changed files with 473 additions and 39 deletions
+162 -35
View File
@@ -53,6 +53,19 @@ EOF_PATTERNS = (
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
@@ -115,27 +128,78 @@ def assess_connected_namespace_attachment(
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]] = {}
missing: list[str] = []
for s in connected:
is_attached = s in attached
proof[s] = {"connected": True, "attached": is_attached}
if not is_attached and s in req:
missing.append(s)
for r in req:
proof[s] = {"connected": True, "attached": s in attached}
for r in required:
if r not in proof:
proof[r] = {"connected": r in connected, "attached": r in attached}
if r in connected and r not in attached and r not in missing:
missing.append(r)
proof[r] = {"connected": r in connected_set, "attached": r in attached}
attachment_healthy = len(missing) == 0 and len(connected) > 0
discovery_status = (
"namespaces_attached" if attachment_healthy
else ("connected_but_namespaces_missing" if len(connected) > 0 else "disconnected")
# 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 = {
@@ -156,6 +220,9 @@ def assess_connected_namespace_attachment(
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 "
@@ -167,11 +234,28 @@ def assess_connected_namespace_attachment(
"(gitea_whoami -> gitea_resolve_task_capability -> task). "
"Do not use direct imports, CLI API mutations, profile hopping, or session file overrides."
)
elif not connected:
reasons.append("No MCP servers reported Connected.")
remediation.append("Start or reconnect Gitea MCP servers in client config.")
else:
reasons.append("All connected MCP server namespaces are attached to the active session.")
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(
@@ -193,15 +277,29 @@ def assess_connected_namespace_attachment(
"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,
"error_type": None if attachment_healthy else ERROR_CONNECTED_NAMESPACES_MISSING,
"namespace_conditions": namespace_conditions,
"error_type": error_type,
"error_types": error_types,
"reasons": reasons,
"remediation": remediation,
"exact_next_action": (
"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."
if not attachment_healthy
else "None; session tool namespaces attached."
"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,
@@ -215,8 +313,11 @@ def assess_connected_namespace_attachment(
"telemetry": {
"connected_count": len(connected),
"attached_count": len(attached),
"required_count": len(req),
"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)
@@ -230,7 +331,8 @@ def assess_connected_namespace_attachment(
"auto_attach_attempted": bool(auto_attach_attempted),
"auto_recovered": auto_recovered,
"startup_ordering_race": startup_ordering_race,
"error_type": None if attachment_healthy else ERROR_CONNECTED_NAMESPACES_MISSING,
"error_type": error_type,
"error_types": error_types,
},
}
@@ -261,14 +363,39 @@ def attachment_gate_from_session(
return []
if entry.get("attached") and entry.get("attachment_healthy"):
return []
detail = entry.get("error_type") or ERROR_CONNECTED_NAMESPACES_MISSING
return [
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 {(task or 'mutation')} "
"(fail closed, #708)",
UNSAFE_FALLBACK_WARNING,
]
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]