diff --git a/docs/mcp-namespace-health.md b/docs/mcp-namespace-health.md index 17ed150..2d02ae9 100644 --- a/docs/mcp-namespace-health.md +++ b/docs/mcp-namespace-health.md @@ -86,3 +86,26 @@ When a namespace returns EOF, follow When blocked, repair the IDE namespace and re-record a healthy `client_namespace` assessment before retrying the mutation. + +## Connected vs Attached Tool Surface (#708) + +MCP servers can report **Connected** at the CLI / host inventory layer while the **active LLM session exposes none of their tool namespaces**. + +### Core principle + +* **Connected status at host layer ≠ attached tools in active session.** +* Required preflight proof is **live tool visibility + `gitea_whoami` call** through the target namespace, not host `Connected` status alone. +* When servers report Connected but namespaces are absent from attached tools, classify as `mcp_connected_namespaces_missing`. + +### Forbidden unsafe fallbacks + +When `mcp_connected_namespaces_missing` is detected, workflows must **fail closed** and must **never** encourage or perform: + +* direct imports of MCP server Python modules +* CLI or raw Gitea API mutations as a substitute for native tools +* profile hopping to another MCP profile/namespace to bypass the empty session +* session-state overrides or hand-edited session/ledger files +* process kills (`pkill`), config mtime touches, or `.env` edits + +Only sanctioned recovery: **client reconnect path**, followed by full preflight (`whoami` → capability resolve → task). + diff --git a/mcp_namespace_health.py b/mcp_namespace_health.py index 4934e21..eec0fec 100644 --- a/mcp_namespace_health.py +++ b/mcp_namespace_health.py @@ -51,6 +51,14 @@ EOF_PATTERNS = ( "eof", ) +ERROR_CONNECTED_NAMESPACES_MISSING = "mcp_connected_namespaces_missing" + +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", @@ -60,6 +68,83 @@ SAFE_ENV_KEYS = ( ) +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, +) -> 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. + """ + 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()] + + 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: + 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) + + 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") + ) + + reasons: list[str] = [] + remediation: list[str] = [] + 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. " + "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.") + + return { + "success": attachment_healthy, + "attachment_healthy": attachment_healthy, + "discovery_status": discovery_status, + "connected_servers": connected, + "attached_session_namespaces": list(attached), + "missing_namespaces": missing, + "proof_of_connected_vs_attached": proof, + "error_type": None if attachment_healthy else ERROR_CONNECTED_NAMESPACES_MISSING, + "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." + ), + "unsafe_fallback_policy": UNSAFE_FALLBACK_WARNING, + } + + + def _as_list(value: Any) -> list[str] | None: if value is None: return None diff --git a/tests/test_issue_708_mcp_namespace_attachment.py b/tests/test_issue_708_mcp_namespace_attachment.py new file mode 100644 index 0000000..3d26b3a --- /dev/null +++ b/tests/test_issue_708_mcp_namespace_attachment.py @@ -0,0 +1,69 @@ +"""Unit regression tests for Issue #708: Connected-but-namespaces-missing detection and attachment safety.""" + +import mcp_namespace_health + + +def test_assess_connected_namespace_attachment_success(): + 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, + ) + assert res["success"] is True + assert res["attachment_healthy"] is True + assert res["discovery_status"] == "namespaces_attached" + assert res["error_type"] is None + assert res["missing_namespaces"] == [] + assert res["exact_next_action"] == "None; session tool namespaces attached." + + +def test_assess_connected_namespace_attachment_missing(): + 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, + ) + assert res["success"] is False + assert res["attachment_healthy"] is False + assert res["discovery_status"] == "connected_but_namespaces_missing" + assert res["error_type"] == "mcp_connected_namespaces_missing" + assert "gitea-reviewer" in res["missing_namespaces"] + assert "gitea-merger" in res["missing_namespaces"] + assert "Reconnect the IDE/client MCP session" in res["exact_next_action"] + + +def test_assess_connected_namespace_attachment_disconnected(): + res = mcp_namespace_health.assess_connected_namespace_attachment( + connected_servers=[], + attached_session_namespaces=[], + ) + assert res["success"] is False + assert res["attachment_healthy"] is False + assert res["discovery_status"] == "disconnected" + + +def test_proof_of_connected_vs_attached_mapping(): + connected = ["gitea-author", "gitea-reviewer"] + attached = ["gitea-author"] + res = mcp_namespace_health.assess_connected_namespace_attachment( + connected_servers=connected, + attached_session_namespaces=attached, + ) + proof = res["proof_of_connected_vs_attached"] + assert proof["gitea-author"] == {"connected": True, "attached": True} + assert proof["gitea-reviewer"] == {"connected": True, "attached": False} + + +def test_unsafe_fallback_policy_enforcement(): + res = mcp_namespace_health.assess_connected_namespace_attachment( + connected_servers=["gitea-author"], + attached_session_namespaces=[], + ) + policy = res["unsafe_fallback_policy"] + assert "Workflow Safety Hard Stop (#708)" in policy + assert "direct imports" in policy + assert "API mutations" in policy + assert "profile hopping" in policy + assert "session-state overrides" in policy