diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 716e3879..96d438e6 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -64,6 +64,7 @@ from backend.apps.agents.manager.streaming.upsert_message import upsert_message from backend.apps.agents.manager.prompt.system_prompt import compose_turn_system_prompt from backend.apps.agents.tools.web import should_register_web_mcp from backend.apps.agents.manager.permissions.effective_tools import build_effective_tool_lists +from backend.apps.agents.manager.builtin_mcp_servers import register_builtin_mcp_servers from backend.apps.agents.manager.session.SessionLifecycleMixin import SessionLifecycleMixin from backend.apps.agents.manager.MessagingMixin import MessagingMixin from backend.apps.agents.manager.AgentLaunchMixin import AgentLaunchMixin @@ -252,103 +253,10 @@ class AgentManager(SessionLifecycleMixin, MessagingMixin, AgentLaunchMixin, RunS # dispatch layer (see p_build_mcp_servers docstring). mcp_servers = await self.p_build_mcp_servers(session.allowed_tools, session.active_mcps) - p_browser_delegation_tools = ["CreateBrowserAgent", "BrowserAgent", "BrowserAgents"] - p_browser_all_denied = all( - builtin_perms.get(t, "always_allow") == "deny" - for t in p_browser_delegation_tools + browser_delegation_tools, invoke_agent_tools = register_builtin_mcp_servers( + mcp_servers, session, builtin_perms, selected_browser_ids, os.path.dirname(__file__) ) - if not p_browser_all_denied: - browser_agent_server_path = os.path.join( - os.path.dirname(__file__), "browser_agent_mcp_server.py" - ) - backend_port = os.environ.get("OPENSWARM_PORT", "8324") - # Only the card the user actually picked in select-mode gets claimed for the - # task, so the sub drives that one instead of opening its own duplicate. Passing - # EVERY dashboard card here (the old behavior) made the sub force-grab a random, - # usually-parked card and never navigate it, which broke the bulk of browser tasks. - pre_selected_bids = [b for b in (selected_browser_ids or []) if b] - from backend.auth import get_auth_token as p_get_auth_token - p_auth_tok = p_get_auth_token() - mcp_servers["openswarm-browser-agent"] = { - "command": sys.executable, - "args": [browser_agent_server_path], - "env": { - "OPENSWARM_PORT": backend_port, - "OPENSWARM_AUTH_TOKEN": p_auth_tok, - "OPENSWARM_AGENT_MODEL": session.model, - "OPENSWARM_DASHBOARD_ID": session.dashboard_id or "", - "OPENSWARM_PRE_SELECTED_BROWSER_IDS": ",".join(pre_selected_bids), - "OPENSWARM_PARENT_SESSION_ID": session.id, - }, - "type": "stdio", - } - - p_invoke_agent_tools = ["InvokeAgent"] - p_invoke_all_denied = all( - builtin_perms.get(t, "always_allow") == "deny" - for t in p_invoke_agent_tools - ) - - if not p_invoke_all_denied: - invoke_agent_server_path = os.path.join( - os.path.dirname(__file__), "invoke_agent_mcp_server.py" - ) - backend_port = os.environ.get("OPENSWARM_PORT", "8324") - from backend.auth import get_auth_token as p_get_auth_token2 - mcp_servers["openswarm-invoke-agent"] = { - "command": sys.executable, - "args": [invoke_agent_server_path], - "env": { - "OPENSWARM_PORT": backend_port, - "OPENSWARM_AUTH_TOKEN": p_get_auth_token2(), - "OPENSWARM_PARENT_SESSION_ID": session.id, - "OPENSWARM_DASHBOARD_ID": session.dashboard_id or "", - }, - "type": "stdio", - } - - # Always-on meta-MCP server. Exposes MCPList / MCPSearch / - # MCPActivate so the model can discover and activate user MCPs at - # runtime. The activation gate (active_mcps filter in - # p_build_mcp_servers above) ensures the model cannot reach any - # other MCP server's tools without going through this layer first. - mcp_meta_server_path = os.path.join( - os.path.dirname(__file__), "mcp_meta_server.py" - ) - from backend.auth import get_auth_token as p_get_auth_token3 - mcp_servers["openswarm-mcp-meta"] = { - "command": sys.executable, - "args": [mcp_meta_server_path], - "env": { - "OPENSWARM_PORT": os.environ.get("OPENSWARM_PORT", "8324"), - "OPENSWARM_AUTH_TOKEN": p_get_auth_token3(), - "OPENSWARM_PARENT_SESSION_ID": session.id, - }, - "type": "stdio", - } - - # Always-on settings-meta server: SettingsRead / SettingsWrite let the - # agent read and edit its own OpenSwarm Settings autonomously. The - # backend (/api/settings-meta) enforces the only two guardrails: it - # can't disconnect the credential powering this run, and reads come - # back with secrets redacted. No activation gate, Settings is the - # agent's own house, not a third-party MCP. - settings_meta_server_path = os.path.join( - os.path.dirname(__file__), "settings_meta_server.py" - ) - from backend.auth import get_auth_token as p_get_auth_token4 - mcp_servers["openswarm-settings-meta"] = { - "command": sys.executable, - "args": [settings_meta_server_path], - "env": { - "OPENSWARM_PORT": os.environ.get("OPENSWARM_PORT", "8324"), - "OPENSWARM_AUTH_TOKEN": p_get_auth_token4(), - "OPENSWARM_PARENT_SESSION_ID": session.id, - }, - "type": "stdio", - } - # Register the DDG-backed openswarm-web MCP only when the primary has no reliable # native Anthropic web path (decided in tools/web.py); p_m feeds the registration log @@ -378,7 +286,7 @@ class AgentManager(SessionLifecycleMixin, MessagingMixin, AgentLaunchMixin, RunS "command": sys.executable, "args": [web_mcp_server_path], "env": { - "OPENSWARM_PORT": backend_port, + "OPENSWARM_PORT": os.environ.get("OPENSWARM_PORT", "8324"), "OPENSWARM_AUTH_TOKEN": p_get_auth_token3(), "OPENSWARM_PRIMARY_API": p_primary_hint, }, @@ -391,7 +299,7 @@ class AgentManager(SessionLifecycleMixin, MessagingMixin, AgentLaunchMixin, RunS effective_allowed, effective_disallowed = build_effective_tool_lists( session, mcp_servers, builtin_perms, need_web_mcp, - p_browser_delegation_tools, p_invoke_agent_tools, + browser_delegation_tools, invoke_agent_tools, ) # Tell the model directly which web tools work for this session. diff --git a/backend/apps/agents/manager/builtin_mcp_servers.py b/backend/apps/agents/manager/builtin_mcp_servers.py new file mode 100644 index 00000000..427d911e --- /dev/null +++ b/backend/apps/agents/manager/builtin_mcp_servers.py @@ -0,0 +1,116 @@ +"""Register the always-on + delegation MCP servers (browser-agent, invoke-agent, meta, +settings-meta) into the per-turn mcp_servers map. Lifted out of the agent loop; the server +scripts live beside agent_manager.py, so the loop passes their directory in. Returns the +browser/invoke delegation tool-name lists the allowlist gate needs.""" + +import os +import sys +from typing import Dict, List, Optional, Tuple + +from typeguard import typechecked + +from backend.apps.agents.core.models import AgentSession +from backend.auth import get_auth_token + + +@typechecked +def register_builtin_mcp_servers( + mcp_servers: Dict, + session: AgentSession, + builtin_perms: Dict[str, str], + selected_browser_ids: Optional[List[str]], + agents_dir: str, +) -> Tuple[List[str], List[str]]: + browser_delegation_tools = ["CreateBrowserAgent", "BrowserAgent", "BrowserAgents"] + browser_all_denied = all( + builtin_perms.get(t, "always_allow") == "deny" + for t in browser_delegation_tools + ) + + if not browser_all_denied: + browser_agent_server_path = os.path.join( + agents_dir, "browser_agent_mcp_server.py" + ) + backend_port = os.environ.get("OPENSWARM_PORT", "8324") + # Only the card the user actually picked in select-mode gets claimed for the + # task, so the sub drives that one instead of opening its own duplicate. Passing + # EVERY dashboard card here (the old behavior) made the sub force-grab a random, + # usually-parked card and never navigate it, which broke the bulk of browser tasks. + pre_selected_bids = [b for b in (selected_browser_ids or []) if b] + auth_tok = get_auth_token() + mcp_servers["openswarm-browser-agent"] = { + "command": sys.executable, + "args": [browser_agent_server_path], + "env": { + "OPENSWARM_PORT": backend_port, + "OPENSWARM_AUTH_TOKEN": auth_tok, + "OPENSWARM_AGENT_MODEL": session.model, + "OPENSWARM_DASHBOARD_ID": session.dashboard_id or "", + "OPENSWARM_PRE_SELECTED_BROWSER_IDS": ",".join(pre_selected_bids), + "OPENSWARM_PARENT_SESSION_ID": session.id, + }, + "type": "stdio", + } + + invoke_agent_tools = ["InvokeAgent"] + invoke_all_denied = all( + builtin_perms.get(t, "always_allow") == "deny" + for t in invoke_agent_tools + ) + + if not invoke_all_denied: + invoke_agent_server_path = os.path.join( + agents_dir, "invoke_agent_mcp_server.py" + ) + backend_port = os.environ.get("OPENSWARM_PORT", "8324") + mcp_servers["openswarm-invoke-agent"] = { + "command": sys.executable, + "args": [invoke_agent_server_path], + "env": { + "OPENSWARM_PORT": backend_port, + "OPENSWARM_AUTH_TOKEN": get_auth_token(), + "OPENSWARM_PARENT_SESSION_ID": session.id, + "OPENSWARM_DASHBOARD_ID": session.dashboard_id or "", + }, + "type": "stdio", + } + + # Always-on meta-MCP server. Exposes MCPList / MCPSearch / + # MCPActivate so the model can discover and activate user MCPs at + # runtime. The activation gate (active_mcps filter in + # p_build_mcp_servers above) ensures the model cannot reach any + # other MCP server's tools without going through this layer first. + mcp_meta_server_path = os.path.join( + agents_dir, "mcp_meta_server.py" + ) + mcp_servers["openswarm-mcp-meta"] = { + "command": sys.executable, + "args": [mcp_meta_server_path], + "env": { + "OPENSWARM_PORT": os.environ.get("OPENSWARM_PORT", "8324"), + "OPENSWARM_AUTH_TOKEN": get_auth_token(), + "OPENSWARM_PARENT_SESSION_ID": session.id, + }, + "type": "stdio", + } + + # Always-on settings-meta server: SettingsRead / SettingsWrite let the + # agent read and edit its own OpenSwarm Settings autonomously. The + # backend (/api/settings-meta) enforces the only two guardrails: it + # can't disconnect the credential powering this run, and reads come + # back with secrets redacted. No activation gate, Settings is the + # agent's own house, not a third-party MCP. + settings_meta_server_path = os.path.join( + agents_dir, "settings_meta_server.py" + ) + mcp_servers["openswarm-settings-meta"] = { + "command": sys.executable, + "args": [settings_meta_server_path], + "env": { + "OPENSWARM_PORT": os.environ.get("OPENSWARM_PORT", "8324"), + "OPENSWARM_AUTH_TOKEN": get_auth_token(), + "OPENSWARM_PARENT_SESSION_ID": session.id, + }, + "type": "stdio", + } + return browser_delegation_tools, invoke_agent_tools diff --git a/backend/tests/test_builtin_mcp_servers.py b/backend/tests/test_builtin_mcp_servers.py new file mode 100644 index 00000000..726303e3 --- /dev/null +++ b/backend/tests/test_builtin_mcp_servers.py @@ -0,0 +1,35 @@ +"""Direct coverage for register_builtin_mcp_servers: the always-on meta + settings-meta servers +are always registered, the browser/invoke delegation servers register unless their tools are +fully denied, and the delegation tool-name lists come back for the allowlist gate.""" + +from backend.apps.agents.core.models import AgentSession +from backend.apps.agents.manager.builtin_mcp_servers import register_builtin_mcp_servers + + +def _session(): + return AgentSession(name="t", model="sonnet", dashboard_id="d") + + +def test_registers_always_on_and_delegation_servers(): + mcp_servers = {} + browser_tools, invoke_tools = register_builtin_mcp_servers( + mcp_servers, _session(), {}, None, "/agents") + # always-on + assert "openswarm-mcp-meta" in mcp_servers + assert "openswarm-settings-meta" in mcp_servers + # delegation (not denied) + assert "openswarm-browser-agent" in mcp_servers + assert "openswarm-invoke-agent" in mcp_servers + assert browser_tools == ["CreateBrowserAgent", "BrowserAgent", "BrowserAgents"] + assert invoke_tools == ["InvokeAgent"] + # server scripts resolve under the passed agents dir + assert mcp_servers["openswarm-mcp-meta"]["args"][0].startswith("/agents") + + +def test_fully_denied_delegation_servers_are_not_registered(): + mcp_servers = {} + perms = {t: "deny" for t in ("CreateBrowserAgent", "BrowserAgent", "BrowserAgents", "InvokeAgent")} + register_builtin_mcp_servers(mcp_servers, _session(), perms, None, "/agents") + assert "openswarm-browser-agent" not in mcp_servers # all browser tools denied -> skip + assert "openswarm-invoke-agent" not in mcp_servers + assert "openswarm-mcp-meta" in mcp_servers # always-on regardless