From 78c0971e88b8db34e9c2867bcdb1a27f294e6475 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 16 Jun 2026 22:34:28 -0700 Subject: [PATCH] [eric] phase2: wire in-task MCP connect offer into the ToolSearch loop-breaker (suggest-only, slug-matched) --- backend/apps/agents/agent_manager.py | 28 +++++++++++++++++++---- backend/apps/agents/core/mcp_preflight.py | 9 +++++++- backend/tests/test_mcp_offer.py | 13 +++++++---- 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 74c8c3c2..30f91c4c 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -847,6 +847,9 @@ class AgentManager: # Counts ToolSearch calls in a row (no other tool between them). A run # of these with empty results is the "looping on ToolSearch" wedge. _ts_loop = {"n": 0} + # One mid-run connect offer per session: a stuck agent fires the loop-breaker repeatedly, + # but the user should see the "connect this MCP" card once, not on every retry. + _mcp_offer_sent = {"done": False} async def pre_tool_hook(input_data, tool_use_id, context): tool_name = input_data.get("tool_name", "") @@ -862,12 +865,29 @@ class AgentManager: if tool_name == "ToolSearch": _ts_loop["n"] += 1 if _ts_loop["n"] >= TOOLSEARCH_LOOP_THRESHOLD: - _reason = toolsearch_loop_redirect( - _ts_loop["n"], - self._gated_mcp_server_names(session.allowed_tools, session.active_mcps), - ) + _gated = self._gated_mcp_server_names(session.allowed_tools, session.active_mcps) + _reason = toolsearch_loop_redirect(_ts_loop["n"], _gated) if _reason: logger.info(f"[MCP-DEBUG] ToolSearch loop-breaker fired for {session_id} (n={_ts_loop['n']})") + # 2B-MCP: also surface a one-click connect offer to the USER for the vetted + # gated servers the agent keeps reaching for. Suggest-only: this just shows a + # card on the same channel the preflight uses; activation still requires + # MCPActivate + the dispatch gate, so it opens no side channel. Once per run, + # fail-open (an offer hiccup must never block the agent). + if not _mcp_offer_sent["done"]: + try: + from backend.apps.agents.core.mcp_preflight import offer_for_gated_server + _s = load_settings() + _offers = [o for o in (offer_for_gated_server(n, _s) for n in _gated) if o] + if _offers: + _mcp_offer_sent["done"] = True + await ws_manager.send_to_session(session_id, "agent:mcp_suggestions", { + "session_id": session_id, + "suggestions": _offers, + "is_vague": False, + }) + except Exception: + logger.debug("mid-run MCP connect offer skipped", exc_info=True) return { "hookSpecificOutput": { "hookEventName": hook_event, diff --git a/backend/apps/agents/core/mcp_preflight.py b/backend/apps/agents/core/mcp_preflight.py index 5db88d3c..cf5fbe16 100644 --- a/backend/apps/agents/core/mcp_preflight.py +++ b/backend/apps/agents/core/mcp_preflight.py @@ -12,6 +12,7 @@ from backend.apps.agents.providers.registry import resolve_aux_model from backend.apps.settings.credentials import get_anthropic_client_for_model from backend.apps.settings.settings import load_settings from backend.apps.tools_lib.tools_lib import _load_all as load_all_tools +from backend.apps.tools_lib.mcp_config import _sanitize_server_name logger = logging.getLogger(__name__) @@ -146,7 +147,13 @@ def offer_for_gated_server(server_name: str, settings) -> CuratedEntry | None: server is vetted AND inactive AND not dismissed, reusing the same filter as the preflight.""" if not server_name or not isinstance(server_name, str): return None - entry = next((e for e in _build_available_shortlist(settings) if e["id"] == server_name), None) + # The hot-path hands us a sanitized slug ("google-workspace"); curated ids are display names + # ("Google Workspace"). Match on the slug of both sides so neither form is a load-bearing string. + slug = _sanitize_server_name(server_name) + entry = next( + (e for e in _build_available_shortlist(settings) if _sanitize_server_name(e["id"]) == slug), + None, + ) if entry is None: return None return {"id": entry["id"], "title": entry["title"], "description": entry["description"], "reason": ""} diff --git a/backend/tests/test_mcp_offer.py b/backend/tests/test_mcp_offer.py index c494d9a5..47684463 100644 --- a/backend/tests/test_mcp_offer.py +++ b/backend/tests/test_mcp_offer.py @@ -22,13 +22,16 @@ def _settings(dismissed=None): return SimpleNamespace(dismissed_mcp_suggestions=dismissed or {}) -def test_offer_only_returns_vetted_inactive(monkeypatch): +def test_offer_resolves_both_display_name_and_hotpath_slug(monkeypatch): + # The hot-path passes a sanitized slug ("google-workspace"); the curated id is a display + # name ("Google Workspace"). Both must resolve, so the wiring isn't a load-bearing string. monkeypatch.setattr(pf, "load_all_tools", lambda: []) # nothing enabled s = _settings() - o = offer_for_gated_server("Google Workspace", s) - assert o is not None - assert o["id"] == "Google Workspace" - assert o["id"] in VETTED + for name in ("Google Workspace", "google-workspace"): + o = offer_for_gated_server(name, s) + assert o is not None, f"{name!r} should resolve to the vetted entry" + assert o["id"] == "Google Workspace" + assert o["id"] in VETTED def test_offer_rejects_unvetted_and_empty(monkeypatch):