feat(mcp): gate Connected-but-unattached MCP namespaces (Closes #708)
The prior #708 slice added assess_connected_namespace_attachment() as a pure
decision function with no call site: nothing invoked it, so a session whose
role namespaces were Connected at the host but absent from the active session
tool surface still passed every mutation gate. Detection existed on paper only.
This makes it load-bearing.
Decision layer (mcp_namespace_health.py)
- assess_connected_namespace_attachment() gains secret-free telemetry
(connected/attached/required/missing counts, discovery cache hit and age,
reconnect_required, auto_attach_attempted, auto_recovered, error_type) and
reports reconnect_required, auto_recovered and startup_ordering_race.
- Startup ordering: a namespace whose connect completed after the session tool
snapshot cannot be in that snapshot, so parallel multi-role startup is
identified as its own race with the affected namespaces listed.
- attachment_gate_from_session() is a fail-closed gate keyed by
ATTACHMENT_GATED_TASKS. An unassessed namespace does not gate, matching #543
semantics, so this never fabricates a block.
- SANCTIONED_ATTACH_RECOVERY_TOOL names gitea_request_mcp_reconnect (#678) as
the only recovery.
Server wiring (gitea_mcp_server.py)
- New tool gitea_assess_mcp_namespace_attachment classifies the condition and
records a per-namespace verdict in _LIVE_NAMESPACE_ATTACHMENT.
- gitea_submit_pr_review and gitea_merge_pr now consult
_namespace_attachment_gate() alongside the existing #543 health gate, so both
fail closed while a required namespace is unattached.
- Watchdog check-in emits status only, never namespace contents.
The typed condition mcp_connected_namespaces_missing stays distinct from config
drift (#672), transport-closed (#584) and resolver EOF (#685). Recovery never
routes through direct imports, CLI or raw API mutation, profile hopping,
session-state overrides, or process kills.
Docs
- docs/mcp-namespace-health.md documents the tool arguments, startup ordering,
the fail-closed gate, and the telemetry contract.
- skills/llm-project-workflow/SKILL.md states Connected is not attached, and
that preflight proof is live tool visibility plus gitea_whoami on the role
namespace rather than host status alone.
- docs/mcp-tool-inventory.md lists the new tool.
- docs/remote-mcp/threat-model-anchors.json and threat-model.md: 21 #956 anchors
restamped for the line shift these additions caused in gitea_mcp_server.py.
Every anchor was re-derived from its recorded expect substring; none guessed.
Tests
- tests/test_issue_708_attachment_wiring.py (19 cases): typed detection, proof
mapping, reconnect-only next action, auto-attach success and failure,
reconnect rediscovery, multi-role startup ordering, telemetry including a
no-secret-leak assertion, fail-closed gate per role, unassessed and unmapped
tasks not gating, partial attachment gating only the affected role, and no
healthy verdict without attachment proof.
Verification
- tests/test_issue_708_attachment_wiring.py + test_issue_708_mcp_namespace_attachment.py: 24 passed
- namespace/session/runtime/review sweep: 427 passed, 12 subtests
- full suite head: 31 failed, 5885 passed, 6 skipped, 1047 subtests
- full suite base 17ba1ff035: 30 failed, 5862 passed, 6 skipped, 1047 subtests
- failing identifier sets match, plus tests/test_mirror_refs.py DryRunBanner,
which fails on the unmodified base in isolation and passes here: flaky, not a
regression from this branch.
- Gate proven by execution, not inspection: registering the assessment blocks
merge_pr and review_pr, and attaching the namespaces clears the block.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
+129
-2
@@ -53,6 +53,21 @@ EOF_PATTERNS = (
|
||||
|
||||
ERROR_CONNECTED_NAMESPACES_MISSING = "mcp_connected_namespaces_missing"
|
||||
|
||||
# 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 "
|
||||
@@ -73,13 +88,28 @@ 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.
|
||||
|
||||
Returns structured telemetry and detection details.
|
||||
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())
|
||||
@@ -106,6 +136,24 @@ def assess_connected_namespace_attachment(
|
||||
else ("connected_but_namespaces_missing" if len(connected) > 0 else "disconnected")
|
||||
)
|
||||
|
||||
# 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 missing:
|
||||
@@ -114,7 +162,9 @@ def assess_connected_namespace_attachment(
|
||||
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. "
|
||||
"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."
|
||||
)
|
||||
elif not connected:
|
||||
@@ -123,6 +173,19 @@ def assess_connected_namespace_attachment(
|
||||
else:
|
||||
reasons.append("All connected MCP server namespaces are 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,
|
||||
@@ -141,9 +204,73 @@ def assess_connected_namespace_attachment(
|
||||
else "None; session tool namespaces attached."
|
||||
),
|
||||
"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(req),
|
||||
"missing_count": len(missing),
|
||||
"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": None if attachment_healthy else ERROR_CONNECTED_NAMESPACES_MISSING,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
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 []
|
||||
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,
|
||||
]
|
||||
|
||||
|
||||
|
||||
def _as_list(value: Any) -> list[str] | None:
|
||||
if value is None:
|
||||
|
||||
Reference in New Issue
Block a user