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
@@ -4,11 +4,16 @@ import mcp_namespace_health
def test_assess_connected_namespace_attachment_success():
# required_namespaces is declared explicitly: these three are the namespaces this case
# is about. Relying on the default (which also requires gitea-tools) would ask for a
# healthy verdict covering a required namespace that was never connected — exactly the
# false-healthy classification these tests now forbid.
connected = ["gitea-author", "gitea-reviewer", "gitea-merger"]
attached = ["gitea-author", "gitea-reviewer", "gitea-merger"]
res = mcp_namespace_health.assess_connected_namespace_attachment(
connected_servers=connected,
attached_session_namespaces=attached,
required_namespaces=connected,
)
assert res["success"] is True
assert res["attachment_healthy"] is True
@@ -19,11 +24,14 @@ def test_assess_connected_namespace_attachment_success():
def test_assess_connected_namespace_attachment_missing():
# Every required namespace here *is* connected, so the only condition present is the
# #708 one: Connected at the host, absent from the session tool surface.
connected = ["gitea-author", "gitea-reviewer", "gitea-merger"]
attached = ["gitea-author"]
res = mcp_namespace_health.assess_connected_namespace_attachment(
connected_servers=connected,
attached_session_namespaces=attached,
required_namespaces=connected,
)
assert res["success"] is False
assert res["attachment_healthy"] is False
@@ -0,0 +1,283 @@
"""Regression tests for Issue #708 B2: not-connected is not Connected-but-unattached.
The first #708 slice counted a namespace as ``missing`` only when it appeared in
``connected_servers``. A *required* namespace absent from that inventory was therefore
never counted at all, so the session reported ``attachment_healthy: true`` with
``error_type: None`` while holding no attachment proof for it — and the gate text told the
operator "the host reports Connected" about a service nothing had reported Connected.
These tests pin the corrected distinction:
* required and not connected never yields a healthy verdict,
* it is typed ``mcp_required_namespaces_not_connected``, never the #708 condition,
* ``mcp_connected_namespaces_missing`` stays reserved for genuinely Connected namespaces,
* both categories still fail review and merge closed, with their own reason,
* per-namespace ``connected``/``attached`` evidence is reported accurately,
* one session's evidence cannot clear another session's block.
"""
import mcp_namespace_health
ROLES = ["gitea-author", "gitea-reviewer", "gitea-merger", "gitea-tools"]
NOT_CONNECTED = mcp_namespace_health.ERROR_REQUIRED_NAMESPACES_NOT_CONNECTED
CONNECTED_MISSING = mcp_namespace_health.ERROR_CONNECTED_NAMESPACES_MISSING
def _assess(connected, attached, required):
return mcp_namespace_health.assess_connected_namespace_attachment(
connected_servers=connected,
attached_session_namespaces=attached,
required_namespaces=required,
)
def _store(assessment):
"""The recorder contract: per-namespace verdicts feed the gate."""
return dict(assessment["namespace_conditions"])
# --- the four evidence combinations ----------------------------------------
def test_connected_and_attached_is_healthy():
res = _assess(ROLES, ROLES, ROLES)
assert res["attachment_healthy"] is True
assert res["error_type"] is None
assert res["missing_namespaces"] == []
assert res["not_connected_namespaces"] == []
assert res["discovery_status"] == "namespaces_attached"
def test_connected_but_unattached_keeps_the_708_condition():
res = _assess(ROLES, ["gitea-author"], ROLES)
assert res["attachment_healthy"] is False
assert res["error_type"] == CONNECTED_MISSING
assert res["not_connected_namespaces"] == []
assert sorted(res["missing_namespaces"]) == [
"gitea-merger",
"gitea-reviewer",
"gitea-tools",
]
assert res["discovery_status"] == "connected_but_namespaces_missing"
def test_required_but_not_connected_is_never_healthy():
"""The reviewer's exact reproduction from review 637."""
res = _assess(
["gitea-reviewer"], ["gitea-reviewer"], ["gitea-reviewer", "gitea-merger"]
)
assert res["attachment_healthy"] is False
assert res["success"] is False
assert res["error_type"] is not None
assert res["telemetry"]["not_connected_count"] == 1
def test_required_but_not_connected_is_typed_distinctly():
res = _assess(
["gitea-reviewer"], ["gitea-reviewer"], ["gitea-reviewer", "gitea-merger"]
)
assert res["error_type"] == NOT_CONNECTED
assert res["discovery_status"] == "required_namespaces_not_connected"
assert res["not_connected_namespaces"] == ["gitea-merger"]
# Not collapsed into the #708 condition, nor into config drift (#672) or
# transport-closed (#584).
assert CONNECTED_MISSING not in res["error_types"]
assert res["missing_namespaces"] == []
assert res["error_type"] not in {"mcp_config_drift", "transport_closed"}
def test_not_connected_reason_makes_no_connected_claim():
res = _assess(
["gitea-reviewer"], ["gitea-reviewer"], ["gitea-reviewer", "gitea-merger"]
)
about_merger = [r for r in res["reasons"] if "gitea-merger" in r]
assert about_merger, "the not-connected namespace must be named in the reasons"
for reason in about_merger:
assert "report Connected at host/CLI layer" not in reason
assert "gitea-merger" in res["exact_next_action"]
def test_neither_connected_nor_attached_reports_disconnected():
res = _assess([], [], ROLES)
assert res["attachment_healthy"] is False
assert res["discovery_status"] == "disconnected"
assert res["error_type"] == NOT_CONNECTED
assert sorted(res["not_connected_namespaces"]) == sorted(ROLES)
assert res["missing_namespaces"] == []
# --- mixed required namespaces ---------------------------------------------
def test_mixed_connected_attached_and_not_connected():
res = _assess(
["gitea-author"], ["gitea-author"], ["gitea-author", "gitea-merger"]
)
assert res["attachment_healthy"] is False
assert res["not_connected_namespaces"] == ["gitea-merger"]
assert res["missing_namespaces"] == []
conditions = res["namespace_conditions"]
assert conditions["gitea-author"]["attachment_healthy"] is True
assert conditions["gitea-author"]["condition"] is None
assert conditions["gitea-merger"]["attachment_healthy"] is False
assert conditions["gitea-merger"]["condition"] == NOT_CONNECTED
def test_both_conditions_present_are_both_reported():
"""One namespace Connected-but-unattached, another never connected."""
res = _assess(
["gitea-author", "gitea-reviewer"],
["gitea-author"],
["gitea-author", "gitea-reviewer", "gitea-merger"],
)
assert res["missing_namespaces"] == ["gitea-reviewer"]
assert res["not_connected_namespaces"] == ["gitea-merger"]
# Neither condition is hidden by the other; error_type names the primary one.
assert sorted(res["error_types"]) == sorted([NOT_CONNECTED, CONNECTED_MISSING])
assert res["error_type"] == NOT_CONNECTED
# --- unknown, partial, malformed, contradictory evidence --------------------
def test_unknown_attachment_evidence_is_not_healthy():
"""No session tool surface reported at all is unproven, not proven good."""
res = _assess(ROLES, None, ROLES)
assert res["attachment_healthy"] is False
assert res["telemetry"]["attached_count"] == 0
assert res["error_type"] == CONNECTED_MISSING
def test_partial_evidence_gates_only_the_unproven_roles():
res = _assess(ROLES, ["gitea-author", "gitea-tools"], ROLES)
store = _store(res)
assert mcp_namespace_health.attachment_gate_from_session("work_issue", store) == []
assert mcp_namespace_health.attachment_gate_from_session("review_pr", store)
assert mcp_namespace_health.attachment_gate_from_session("merge_pr", store)
def test_malformed_namespace_entries_are_discarded_not_trusted():
"""Blank and whitespace-only names must not become namespaces or proof."""
res = mcp_namespace_health.assess_connected_namespace_attachment(
connected_servers=["gitea-author", "", " "],
attached_session_namespaces=["gitea-author", ""],
required_namespaces=["gitea-author", " "],
)
assert "" not in res["proof_of_connected_vs_attached"]
assert " " not in res["proof_of_connected_vs_attached"]
assert res["attachment_healthy"] is True
assert res["telemetry"]["required_count"] == 1
def test_contradictory_attached_without_connected_fails_closed():
"""Attached in the session yet absent from the connected inventory."""
res = _assess(["gitea-author"], ["gitea-author", "gitea-merger"], ROLES)
assert res["attachment_healthy"] is False
assert "gitea-merger" in res["contradictory_namespaces"]
assert "gitea-merger" in res["not_connected_namespaces"]
assert res["namespace_conditions"]["gitea-merger"]["contradictory_evidence"] is True
assert res["namespace_conditions"]["gitea-merger"]["attachment_healthy"] is False
assert any("contradictory evidence" in r for r in res["reasons"])
assert res["telemetry"]["contradictory_count"] >= 1
def test_duplicate_required_entries_are_counted_once():
res = _assess(["gitea-author"], ["gitea-author"], ["gitea-author", "gitea-author"])
assert res["telemetry"]["required_count"] == 1
assert res["attachment_healthy"] is True
# --- review and merge behaviour for both failure categories -----------------
def test_merge_fails_closed_when_required_namespace_never_connected():
store = _store(_assess(["gitea-author"], ["gitea-author"], ROLES))
reasons = mcp_namespace_health.attachment_gate_from_session("merge_pr", store)
assert reasons
assert NOT_CONNECTED in reasons[0]
assert "fail closed, #708" in reasons[0]
def test_review_fails_closed_when_required_namespace_never_connected():
store = _store(_assess(["gitea-author"], ["gitea-author"], ROLES))
reasons = mcp_namespace_health.attachment_gate_from_session("review_pr", store)
assert reasons
assert NOT_CONNECTED in reasons[0]
assert "fail closed, #708" in reasons[0]
def test_not_connected_block_never_claims_the_host_reports_connected():
store = _store(_assess(["gitea-author"], ["gitea-author"], ROLES))
reasons = mcp_namespace_health.attachment_gate_from_session("merge_pr", store)
assert "the host reports Connected" not in reasons[0]
assert "absent from the connected-service inventory" in reasons[0]
def test_connected_but_unattached_block_still_says_connected():
store = _store(_assess(ROLES, ["gitea-author"], ROLES))
reasons = mcp_namespace_health.attachment_gate_from_session("merge_pr", store)
assert CONNECTED_MISSING in reasons[0]
assert "the host reports Connected" in reasons[0]
def test_both_categories_offer_only_the_sanctioned_recovery():
for store in (
_store(_assess(ROLES, [], ROLES)),
_store(_assess(["gitea-author"], ["gitea-author"], ROLES)),
):
reasons = mcp_namespace_health.attachment_gate_from_session("merge_pr", store)
joined = " ".join(reasons)
assert "reconnect" in joined.lower()
assert "Workflow Safety Hard Stop (#708)" in joined
for forbidden in ("pkill", "chmod", "curl ", ".env", "sys.path"):
assert forbidden not in reasons[0]
def test_entry_without_connected_evidence_blocks_without_asserting_either():
"""A legacy/partial store entry must fail closed and claim nothing it cannot prove."""
store = {"gitea-merger": {"namespace": "gitea-merger", "attached": False}}
reasons = mcp_namespace_health.attachment_gate_from_session("merge_pr", store)
assert reasons
assert "no connected-status evidence" in reasons[0]
assert "the host reports Connected" not in reasons[0]
assert "fail closed, #708" in reasons[0]
# --- session isolation ------------------------------------------------------
def test_one_session_evidence_does_not_clear_another_session_block():
"""Attachment evidence is per-session state; it must not travel between sessions."""
blocked_session = _store(_assess(["gitea-author"], ["gitea-author"], ROLES))
healthy_session = _store(_assess(ROLES, ROLES, ROLES))
assert (
mcp_namespace_health.attachment_gate_from_session("merge_pr", healthy_session)
== []
)
# The healthy session's verdict is not consulted for the blocked session.
assert mcp_namespace_health.attachment_gate_from_session("merge_pr", blocked_session)
# And the blocked session's store is unchanged by the healthy one existing.
assert blocked_session["gitea-merger"]["attachment_healthy"] is False
def test_gate_reads_only_the_store_it_is_given():
healthy_session = _store(_assess(ROLES, ROLES, ROLES))
assert mcp_namespace_health.attachment_gate_from_session("merge_pr", {}) == []
assert mcp_namespace_health.attachment_gate_from_session("merge_pr", None) == []
assert (
mcp_namespace_health.attachment_gate_from_session("merge_pr", healthy_session)
== []
)
# --- telemetry stays secret-free -------------------------------------------
def test_not_connected_telemetry_leaks_no_secrets():
res = _assess(["gitea-author"], ["gitea-author"], ROLES)
blob = repr(res["telemetry"]).lower()
for leak in ("token", "authorization", "password", "secret", "/users/", "http"):
assert leak not in blob