diff --git a/backend/apps/agents/combined_meta_mcp_server.py b/backend/apps/agents/combined_meta_mcp_server.py index da315ae1..302dfa59 100644 --- a/backend/apps/agents/combined_meta_mcp_server.py +++ b/backend/apps/agents/combined_meta_mcp_server.py @@ -1,17 +1,16 @@ #!/usr/bin/env python3 -"""One stdio MCP process hosting the always-on, ungated meta tools that used to be three separate -python interpreters per agent CLI (MCPList/Search/Activate, SettingsRead/Write, CreateApp). +"""One stdio MCP process per agent session hosting ALL the builtin tool servers that used to be +up to ten separate python interpreters (ENG-208: 5 parked chats measured 56 python processes and +756MB before any real work). -Why merge only these three: their tool NAMES are globally unique, none of them is referenced by -the non-bypassable permission gate (grep for `mcp__openswarm-mcp-meta__` etc. finds nothing in -build_effective_tool_lists / path_gate), and all three are registered unconditionally with the -same env. So collapsing them into one process is pure fan-out reduction with zero behavior change: -5 parked CLIs drop 15 idle interpreters to 5 (ENG-208). The gate-coupled servers (schedule, web, -browser-agent) and the conditional ones (skill, show-ui, spawn, invoke) stay separate on purpose. +Which sub-servers load is decided by the SAME permission logic that used to decide which processes +to spawn, passed in as OSW_MCP_MODULES by register_builtin_mcp_servers, so a denied capability's +tools are absent from tools/list exactly like its dead process used to be. Tool NAMES are globally +unique across sub-servers (asserted below); full ids all live under the one server name +"openswarm-core", and every gate reference was renamed with them in the same commit. -We reuse each sub-server's own TOOLS + handle_tool_call; we own the stdio loop so their main() and -send_response never run. Sibling import (not backend.*) matches how these scripts are launched by -path in both dev and the packaged bundle.""" +Each sub-server keeps reading its per-session context from env exactly as before, because this is +still one process per session with the union env. We own the stdio loop; their main() never runs.""" import json import os @@ -19,18 +18,48 @@ import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import apps_mcp_server as p_apps # noqa: E402 -import mcp_meta_server as p_meta # noqa: E402 -import settings_meta_server as p_settings # noqa: E402 +# Import cost is paid only for modules this session actually gets; all are stdlib-only thin proxies. +P_MODULE_FILES = { + "meta": "mcp_meta_server", + "settings": "settings_meta_server", + "apps": "apps_mcp_server", + "spawn": "spawn_agent_mcp_server", + "invoke": "invoke_agent_mcp_server", + "skill": "skill_mcp_server", + "ui": "show_ui_mcp_server", + "schedule": "schedule_mcp_server", + "web": "web_mcp_server", + "browser": "browser_agent_mcp_server", +} -P_SUBSERVERS = [p_meta, p_settings, p_apps] +P_ENABLED = [m.strip() for m in os.environ.get("OSW_MCP_MODULES", "meta,settings,apps").split(",") if m.strip()] TOOLS = [] P_ROUTE = {} -for p_mod in P_SUBSERVERS: +for p_key in P_ENABLED: + p_file = P_MODULE_FILES.get(p_key) + if p_file is None: + sys.stderr.write(f"[openswarm-core] unknown module key: {p_key}\n") + continue + p_mod = __import__(p_file) for p_tool in p_mod.TOOLS: + p_name = p_tool["name"] + if p_name in P_ROUTE: + sys.stderr.write(f"[openswarm-core] duplicate tool {p_name}; keeping first\n") + continue TOOLS.append(p_tool) - P_ROUTE[p_tool["name"]] = p_mod + P_ROUTE[p_name] = p_mod + + +def p_call(mod, tool_name: str, arguments: dict) -> dict: + handler = getattr(mod, "handle_tool_call", None) + if handler is not None: + return handler(tool_name, arguments) + # schedule_mcp_server dispatches through a HANDLERS dict instead of one entry function. + fn = mod.HANDLERS.get(tool_name) + if fn is None: + return {"content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}], "isError": True} + return fn(arguments) def send_response(id_, result=None, error=None): @@ -73,7 +102,7 @@ def main(): send_response(id_, {"content": [{"type": "text", "text": f"Unknown tool: {tool_name}"}], "isError": True}) continue try: - send_response(id_, mod.handle_tool_call(tool_name, arguments)) + send_response(id_, p_call(mod, tool_name, arguments)) except Exception as e: send_response(id_, error={"code": -32000, "message": str(e)}) elif method in ("resources/list",): diff --git a/backend/apps/agents/manager/permissions/build_effective_tool_lists.py b/backend/apps/agents/manager/permissions/build_effective_tool_lists.py index 5a6f17d8..2c27fe1c 100644 --- a/backend/apps/agents/manager/permissions/build_effective_tool_lists.py +++ b/backend/apps/agents/manager/permissions/build_effective_tool_lists.py @@ -49,57 +49,58 @@ def build_effective_tool_lists( if mcp_servers: all_tools_list = load_all_tools() for name in mcp_servers: - if name == "openswarm-browser-agent": - for bt in browser_delegation_tools: - policy = builtin_perms.get(bt, "always_allow") + if name == "openswarm-core": + # Every builtin tool rides ONE combined process now (ENG-208), so the old per-server + # wildcard would blanket gated tools; enumerate per module instead, same policies as + # when each module was its own process. Module list = what registration wired. + p_modules = [m for m in mcp_servers[name].get("env", {}).get("OSW_MCP_MODULES", "").split(",") if m] + from backend.apps.agents import apps_mcp_server, mcp_meta_server, schedule_mcp_server, settings_meta_server + for p_t in (x["name"] for x in mcp_meta_server.TOOLS + settings_meta_server.TOOLS + apps_mcp_server.TOOLS): + effective_allowed.append(f"mcp__openswarm-core__{p_t}") + if "schedule" in p_modules: + for p_t in (x["name"] for x in schedule_mcp_server.TOOLS): + effective_allowed.append(f"mcp__openswarm-core__{p_t}") + if "browser" in p_modules: + for bt in browser_delegation_tools: + policy = builtin_perms.get(bt, "always_allow") + if policy == "always_allow": + effective_allowed.append(f"mcp__openswarm-core__{bt}") + elif policy == "deny": + effective_disallowed.append(f"mcp__openswarm-core__{bt}") + if "invoke" in p_modules: + for it in invoke_agent_tools: + policy = builtin_perms.get(it, "always_allow") + if policy == "always_allow": + effective_allowed.append(f"mcp__openswarm-core__{it}") + elif policy == "deny": + effective_disallowed.append(f"mcp__openswarm-core__{it}") + if "spawn" in p_modules: + policy = builtin_perms.get("Agent", "always_allow") if policy == "always_allow": - effective_allowed.append(f"mcp__openswarm-browser-agent__{bt}") + effective_allowed.append("mcp__openswarm-core__SpawnAgent") elif policy == "deny": - effective_disallowed.append(f"mcp__openswarm-browser-agent__{bt}") - continue - - if name == "openswarm-invoke-agent": - for it in invoke_agent_tools: - policy = builtin_perms.get(it, "always_allow") + effective_disallowed.append("mcp__openswarm-core__SpawnAgent") + if "skill" in p_modules: + policy = builtin_perms.get("Skill", "always_allow") if policy == "always_allow": - effective_allowed.append(f"mcp__openswarm-invoke-agent__{it}") - elif policy == "deny": - effective_disallowed.append(f"mcp__openswarm-invoke-agent__{it}") - continue - - if name == "openswarm-spawn-agent": - policy = builtin_perms.get("Agent", "always_allow") - if policy == "always_allow": - effective_allowed.append("mcp__openswarm-spawn-agent__SpawnAgent") - elif policy == "deny": - effective_disallowed.append("mcp__openswarm-spawn-agent__SpawnAgent") - continue - - if name == "openswarm-skill": - policy = builtin_perms.get("Skill", "always_allow") - if policy == "always_allow": - effective_allowed.append("mcp__openswarm-skill__Skill") - else: - effective_disallowed.append("mcp__openswarm-skill__Skill") - continue - - if name == "openswarm-ui": - policy = builtin_perms.get("ShowUI", "always_allow") - for ui_tool in ("ShowUI", "AskUI"): - if policy == "always_allow": - effective_allowed.append(f"mcp__openswarm-ui__{ui_tool}") + effective_allowed.append("mcp__openswarm-core__Skill") else: - effective_disallowed.append(f"mcp__openswarm-ui__{ui_tool}") - continue - - if name == "openswarm-web": - # Expose our DDG-backed web tools under an MCP prefix. Honor existing WebSearch/WebFetch permission policy, if the user disabled them in Settings, don't offer the MCP variants either. - for wt in ("WebSearch", "WebFetch"): - policy = builtin_perms.get(wt, "always_allow") - if policy == "always_allow": - effective_allowed.append(f"mcp__openswarm-web__{wt}") - elif policy == "deny": - effective_disallowed.append(f"mcp__openswarm-web__{wt}") + effective_disallowed.append("mcp__openswarm-core__Skill") + if "ui" in p_modules: + policy = builtin_perms.get("ShowUI", "always_allow") + for ui_tool in ("ShowUI", "AskUI"): + if policy == "always_allow": + effective_allowed.append(f"mcp__openswarm-core__{ui_tool}") + else: + effective_disallowed.append(f"mcp__openswarm-core__{ui_tool}") + if "web" in p_modules: + # Honor existing WebSearch/WebFetch permission policy, if the user disabled them in Settings, don't offer the MCP variants either. + for wt in ("WebSearch", "WebFetch"): + policy = builtin_perms.get(wt, "always_allow") + if policy == "always_allow": + effective_allowed.append(f"mcp__openswarm-core__{wt}") + elif policy == "deny": + effective_disallowed.append(f"mcp__openswarm-core__{wt}") continue tool_def = next( @@ -141,7 +142,7 @@ def build_effective_tool_lists( for bt in path_gate.UNDELIVERABLE_BACKGROUND_TOOLS: if bt not in effective_disallowed: effective_disallowed.append(bt) - # The claude_code preset ships its own bare `Skill` tool that reads ~/.claude/skills directly; always withhold it so skills only ever load through our provider-agnostic mcp__openswarm-skill__Skill (or not at all). + # The claude_code preset ships its own bare `Skill` tool that reads ~/.claude/skills directly; always withhold it so skills only ever load through our provider-agnostic mcp__openswarm-core__Skill (or not at all). if "Skill" not in effective_disallowed: effective_disallowed.append("Skill") # Read-only session (onboarding's unattended audit over the user's real files): the mutation/exec diff --git a/backend/apps/agents/manager/permissions/gate_hooks.py b/backend/apps/agents/manager/permissions/gate_hooks.py index c83725fb..76864ab3 100644 --- a/backend/apps/agents/manager/permissions/gate_hooks.py +++ b/backend/apps/agents/manager/permissions/gate_hooks.py @@ -47,7 +47,7 @@ async def can_use_tool( if is_claude_schedule_skill(tool_name, input_data): note_tool_used(ctx.session_id, tool_name, False) return PermissionResultDeny( - message="Use the openswarm-schedule MCP tools instead of Claude's internal schedule skill." + message="Use the ScheduleWorkflow MCP tools instead of Claude's internal schedule skill." ) sensitive_pattern: Optional[str] = None if tool_name != "AskUserQuestion": @@ -153,7 +153,7 @@ async def pre_tool_hook(ctx: HookContext, input_data: dict, tool_use_id: Optiona "hookSpecificOutput": { "hookEventName": hook_event, "permissionDecision": "deny", - "permissionDecisionReason": "Use the openswarm-schedule MCP tools instead of Claude's internal schedule skill.", + "permissionDecisionReason": "Use the ScheduleWorkflow MCP tools instead of Claude's internal schedule skill.", } } policy, sensitive_pattern = path_gate.maybe_override_policy( diff --git a/backend/apps/agents/manager/permissions/path_gate.py b/backend/apps/agents/manager/permissions/path_gate.py index d215ec3d..cd8c508b 100644 --- a/backend/apps/agents/manager/permissions/path_gate.py +++ b/backend/apps/agents/manager/permissions/path_gate.py @@ -151,10 +151,10 @@ def extract_target_path(tool_name: str, tool_input: object) -> str: # Native-scheduler MCP tools that commit or mutate a recurring schedule. Always-on MCP servers fall through to the always_allow default, so these would otherwise fire silently; force them through ApprovalBar. The Cron* tools are Claude's own internal scheduler, denied outright in favour of the visible/auditable native one. p_SCHEDULE_GATED = { - "mcp__openswarm-schedule__ScheduleWorkflow", - "mcp__openswarm-schedule__UpdateScheduledWorkflow", - "mcp__openswarm-schedule__DeleteScheduledWorkflow", - "mcp__openswarm-schedule__PauseAllWorkflows", + "mcp__openswarm-core__ScheduleWorkflow", + "mcp__openswarm-core__UpdateScheduledWorkflow", + "mcp__openswarm-core__DeleteScheduledWorkflow", + "mcp__openswarm-core__PauseAllWorkflows", } CLAUDE_INTERNAL_SCHEDULER_TOOLS = ("CronCreate", "CronList", "CronDelete") # Preset built-ins that deliver their payload BETWEEN turns. We drive the CLI one turn at a time and stop reading at the ResultMessage, so nothing ever consumes the event: the agent arms a Monitor, promises "I'll report back", ends the turn, and the user waits forever for a message that cannot arrive. Withheld here rather than only in the tool manifest, because the manifest has a kill switch (OSW_TOOL_MANIFEST=0) that would otherwise hand the model a promise we cannot keep. diff --git a/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py b/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py index 5f7a0cbe..605ccb7e 100644 --- a/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py +++ b/backend/apps/agents/manager/prompt/compose_turn_system_prompt.py @@ -100,7 +100,7 @@ def compose_turn_system_prompt( rich_ui_note = ( "\n" "Strongly prefer rendering rich UI over prose, every time the content fits. The tools " - "are mcp__openswarm-ui__ShowUI and mcp__openswarm-ui__AskUI; they are always available " + "are mcp__openswarm-core__ShowUI and mcp__openswarm-core__AskUI; they are always available " "this session, so call them DIRECTLY by that name, no ToolSearch step needed.\n" "- ShowUI for any structured result. Use the EXACT component name: a table = data-table, " "stats = stats-display, links = link-preview, a plan = plan, steps = progress-tracker, " diff --git a/backend/apps/agents/manager/prompt/tool_catalog.py b/backend/apps/agents/manager/prompt/tool_catalog.py index b6574f7e..9c1a33b1 100644 --- a/backend/apps/agents/manager/prompt/tool_catalog.py +++ b/backend/apps/agents/manager/prompt/tool_catalog.py @@ -13,7 +13,7 @@ from backend.apps.tools_lib.tools_lib import ( logger = logging.getLogger(__name__) -# Cron* live only in the force-deny list (path_gate): our Schedule MCP replaces the CLI scheduler, so allowing them here just churned the allow/deny lists. InvokeAgent's real tool is the mcp__openswarm-invoke-agent__ ref; the bare name was a no-op. +# Cron* live only in the force-deny list (path_gate): our Schedule MCP replaces the CLI scheduler, so allowing them here just churned the allow/deny lists. InvokeAgent's real tool is the mcp__openswarm-core__ ref; the bare name was a no-op. FULL_TOOLS = [ "Read", "Edit", "Write", "Bash", "Glob", "Grep", "AskUserQuestion", "WebSearch", "WebFetch", "NotebookEdit", "TodoWrite", diff --git a/backend/apps/agents/manager/register_builtin_mcp_servers.py b/backend/apps/agents/manager/register_builtin_mcp_servers.py index f72ff938..3baa37c9 100644 --- a/backend/apps/agents/manager/register_builtin_mcp_servers.py +++ b/backend/apps/agents/manager/register_builtin_mcp_servers.py @@ -1,8 +1,10 @@ -"""Register the always-on + delegation MCP servers (browser-agent, invoke-agent, meta, -settings-meta) into the per-turn mcp_servers map. The server scripts live in the agents -package, so we resolve their directory off that package here, NOT off a dir a caller passes -in: a caller in a moved file would compute the wrong dir (this bit us once). Returns the -browser/invoke delegation tool-name lists the allowlist gate needs.""" +"""Register the builtin tool servers into the per-turn mcp_servers map as ONE combined stdio +process ("openswarm-core"), instead of one python interpreter per server (ENG-208). The SAME +permission conditions that used to skip a server's process now skip its module inside the combined +process (OSW_MCP_MODULES), so a denied capability's tools stay exactly as absent as before. The +server script lives in the agents package, so we resolve its directory off that package here, NOT +off a dir a caller passes in: a caller in a moved file would compute the wrong dir (this bit us +once). Returns the browser/invoke delegation tool-name lists the allowlist gate needs.""" import os import sys @@ -25,145 +27,64 @@ def register_builtin_mcp_servers( ) -> Tuple[List[str], List[str]]: import backend.apps.agents as p_agents_pkg agents_dir = os.path.dirname(p_agents_pkg.__file__) - # With no renderer for a webview and no human for a prompt, we shadow the map once here and let the existing deny short-circuits skip those servers; nothing below may read the un-shadowed one. + # With no renderer for a webview and no human for a prompt, we shadow the map once here and let the existing deny short-circuits skip those modules; nothing below may read the un-shadowed one. builtin_perms = apply_unreachable_denies(builtin_perms) browser_delegation_tools = ["CreateBrowserAgent", "BrowserAgent", "BrowserAgents", "AppAgent"] + invoke_agent_tools = ["InvokeAgent"] + + # The always-on trio: MCP discovery (the activation gate's one doorway), agent-editable + # Settings, and CreateApp. + modules = ["meta", "settings", "apps"] + 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] - # Apps the user selected this turn; the AppAgent tool may only target these (anti-hallucination gate in the MCP server, which reads this at startup). - selected_app_ids = [a for a in (selected_app_output_ids or []) if a] - 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_SELECTED_APP_IDS": ",".join(selected_app_ids), - "OPENSWARM_PARENT_SESSION_ID": session.id, - }, - "type": "stdio", - } + modules.append("browser") - 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", - } + if not all(builtin_perms.get(t, "always_allow") == "deny" for t in invoke_agent_tools): + modules.append("invoke") # SpawnAgent replaces the CLI's built-in Agent tool (blocked in RunOptions); gated by the same "Agent" permission so the Tools-page toggle keeps working. if builtin_perms.get("Agent", "always_allow") != "deny": - spawn_agent_server_path = os.path.join( - agents_dir, "spawn_agent_mcp_server.py" - ) - mcp_servers["openswarm-spawn-agent"] = { - "command": sys.executable, - "args": [spawn_agent_server_path], - "env": { - "OPENSWARM_PORT": os.environ.get("OPENSWARM_PORT", "8324"), - "OPENSWARM_AUTH_TOKEN": get_auth_token(), - "OPENSWARM_PARENT_SESSION_ID": session.id, - "OPENSWARM_DASHBOARD_ID": session.dashboard_id or "", - }, - "type": "stdio", - } + modules.append("spawn") - # Always-on core meta-MCP server: ONE process hosting the three ungated always-on servers that - # used to be three interpreters (MCP discovery MCPList/Search/Activate, SettingsRead/Write, - # CreateApp). None is referenced by the permission gate, so this is pure fan-out reduction - # (ENG-208). The activation gate still governs external MCPs exactly as before, via MCPActivate. - combined_meta_path = os.path.join(agents_dir, "combined_meta_mcp_server.py") - mcp_servers["openswarm-core"] = { - "command": sys.executable, - "args": [combined_meta_path], - "env": { - "OPENSWARM_PORT": os.environ.get("OPENSWARM_PORT", "8324"), - "OPENSWARM_AUTH_TOKEN": get_auth_token(), - "OPENSWARM_PARENT_SESSION_ID": session.id, - }, - "type": "stdio", - } - - # Skill server: exposes the Skill tool so the agent can load an installed skill on its own (the catalog in the prompt lists what's available). Gated on at least one non-built-in skill existing AND Skill not being denied, so we never offer a tool with an empty catalog. Kept in sync with build_installed_skills_catalog, which omits the catalog under the same conditions. - skill_denied = builtin_perms.get("Skill", "always_allow") == "deny" - if not skill_denied: + # Skill module: gated on at least one non-built-in skill existing AND Skill not being denied, so we never offer a tool with an empty catalog. Kept in sync with build_installed_skills_catalog, which omits the catalog under the same conditions. + if builtin_perms.get("Skill", "always_allow") != "deny": try: from backend.apps.skills.skills import sync_skills has_loadable_skill = any(not s.built_in and s.enabled for s in sync_skills()) except Exception: has_loadable_skill = False if has_loadable_skill: - skill_server_path = os.path.join(agents_dir, "skill_mcp_server.py") - mcp_servers["openswarm-skill"] = { - "command": sys.executable, - "args": [skill_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", - } + modules.append("skill") - # ShowUI renders rich inline components from the tool_call input (display only, server just - # validates); AskUI renders an interactive component and BLOCKS on /api/ui-requests/wait until - # the user answers in the transcript. Gated on the ShowUI builtin perm. - show_ui_denied = builtin_perms.get("ShowUI", "always_allow") == "deny" - if not show_ui_denied: - show_ui_server_path = os.path.join(agents_dir, "show_ui_mcp_server.py") - mcp_servers["openswarm-ui"] = { - "command": sys.executable, - "args": [show_ui_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", - } + # ShowUI renders rich inline components from the tool_call input (display only, server just validates); AskUI renders an interactive component and BLOCKS on /api/ui-requests/wait until the user answers in the transcript. Gated on the ShowUI builtin perm. + if builtin_perms.get("ShowUI", "always_allow") != "deny": + modules.append("ui") - # Always-on schedule server: ScheduleWorkflow + CRUD + AddWorkflowStep/EditWorkflowStep so the agent (and the workflow Edit Agent) can build and schedule recurring work via the native scheduler instead of cron/launchctl. The 4 scheduling tools are force-gated in path_gate; Cron* is denied in build_effective_tool_lists. - schedule_server_path = os.path.join( - agents_dir, "schedule_mcp_server.py" - ) - mcp_servers["openswarm-schedule"] = { + # Schedule module: ScheduleWorkflow + CRUD + step editing so the agent (and the workflow Edit Agent) can build and schedule recurring work via the native scheduler. The 4 scheduling tools are force-gated in path_gate; Cron* is denied in build_effective_tool_lists. + modules.append("schedule") + + # 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] + # Apps the user selected this turn; the AppAgent tool may only target these (anti-hallucination gate in the MCP server, which reads this at startup). + selected_app_ids = [a for a in (selected_app_output_ids or []) if a] + + combined_path = os.path.join(agents_dir, "combined_meta_mcp_server.py") + mcp_servers["openswarm-core"] = { "command": sys.executable, - "args": [schedule_server_path], + "args": [combined_path], "env": { + "OSW_MCP_MODULES": ",".join(modules), "OPENSWARM_PORT": os.environ.get("OPENSWARM_PORT", "8324"), "OPENSWARM_AUTH_TOKEN": get_auth_token(), "OPENSWARM_PARENT_SESSION_ID": session.id, "OPENSWARM_DASHBOARD_ID": session.dashboard_id or "", + "OPENSWARM_AGENT_MODEL": session.model, + "OPENSWARM_PRE_SELECTED_BROWSER_IDS": ",".join(pre_selected_bids), + "OPENSWARM_SELECTED_APP_IDS": ",".join(selected_app_ids), }, "type": "stdio", } diff --git a/backend/apps/agents/manager/run/RunOptions.py b/backend/apps/agents/manager/run/RunOptions.py index dc90f6b1..4f32d68a 100644 --- a/backend/apps/agents/manager/run/RunOptions.py +++ b/backend/apps/agents/manager/run/RunOptions.py @@ -145,7 +145,7 @@ class RunOptions(AgentManagerProtocol): register_web_mcp_server( mcp_servers, p_m, browser_ok=bool(browser_delegation_tools), - rich_ui_ok="openswarm-ui" in mcp_servers, + rich_ui_ok="ui" in mcp_servers.get("openswarm-core", {}).get("env", {}).get("OSW_MCP_MODULES", "").split(","), ) effective_allowed, effective_disallowed = build_effective_tool_lists( diff --git a/backend/apps/agents/manager/run/run_options_helpers.py b/backend/apps/agents/manager/run/run_options_helpers.py index 72dbf00c..54ec7078 100644 --- a/backend/apps/agents/manager/run/run_options_helpers.py +++ b/backend/apps/agents/manager/run/run_options_helpers.py @@ -104,13 +104,9 @@ def set_framework_overhead(session: AgentSession, composed_prompt: Optional[str] @typechecked def register_web_mcp_server(mcp_servers: Dict, p_m: str, browser_ok: bool = False, rich_ui_ok: bool = False) -> None: - """Register the DDG-backed openswarm-web stdio MCP into the server set when the primary has no - reliable native web path. The server script lives in the agents package (not here), so resolve - it off that package dir, not __file__.""" - import os - import sys - import backend.apps.agents as p_agents_pkg - web_mcp_server_path = os.path.join(os.path.dirname(p_agents_pkg.__file__), "web_mcp_server.py") + """Add the DDG-backed web tools when the primary has no reliable native web path. They ride the + combined openswarm-core process now (ENG-208): flip the module flag and env on the entry that + register_builtin_mcp_servers already made, instead of spawning an eleventh interpreter.""" # Tell the MCP which primary the session is using so it can route to that provider's native search tool. if p_m.startswith(("gc/", "gemini/", "ag/")): p_primary_hint = "gemini" @@ -118,11 +114,28 @@ def register_web_mcp_server(mcp_servers: Dict, p_m: str, browser_ok: bool = Fals p_primary_hint = "openai" else: p_primary_hint = "" + core = mcp_servers.get("openswarm-core") + if core is not None: + env = core["env"] + modules = [m for m in env.get("OSW_MCP_MODULES", "").split(",") if m] + if "web" not in modules: + modules.append("web") + env["OSW_MCP_MODULES"] = ",".join(modules) + env["OPENSWARM_PRIMARY_API"] = p_primary_hint + env["OPENSWARM_BROWSER_OK"] = "1" if browser_ok else "0" + env["OPENSWARM_RICH_UI_OK"] = "1" if rich_ui_ok else "0" + return + # Belt for a caller that skipped register_builtin (none today): web tools still arrive, alone. + import os + import sys + import backend.apps.agents as p_agents_pkg + combined_path = os.path.join(os.path.dirname(p_agents_pkg.__file__), "combined_meta_mcp_server.py") from backend.auth import get_auth_token as p_get_auth_token3 - mcp_servers["openswarm-web"] = { + mcp_servers["openswarm-core"] = { "command": sys.executable, - "args": [web_mcp_server_path], + "args": [combined_path], "env": { + "OSW_MCP_MODULES": "web", "OPENSWARM_PORT": os.environ.get("OPENSWARM_PORT", "8324"), "OPENSWARM_AUTH_TOKEN": p_get_auth_token3(), "OPENSWARM_PRIMARY_API": p_primary_hint, @@ -142,8 +155,8 @@ def append_web_tools_hint(composed_prompt: Optional[str], need_web_mcp: bool, ef """Append a block naming the MCP-backed WebSearch/WebFetch when the deferred bare WebSearch tool isn't usable on this session, so smaller models don't thrash on ToolSearch.""" p_web_tools_available = need_web_mcp and ( - "mcp__openswarm-web__WebSearch" in effective_allowed - or "mcp__openswarm-web__WebFetch" in effective_allowed + "mcp__openswarm-core__WebSearch" in effective_allowed + or "mcp__openswarm-core__WebFetch" in effective_allowed ) if not p_web_tools_available: return composed_prompt @@ -155,14 +168,14 @@ def append_web_tools_hint(composed_prompt: Optional[str], need_web_mcp: bool, ef "equivalents instead, call them DIRECTLY, no ToolSearch " "step needed:" ) - if "mcp__openswarm-web__WebSearch" in effective_allowed: + if "mcp__openswarm-core__WebSearch" in effective_allowed: p_hint_lines.append( - "- `mcp__openswarm-web__WebSearch(query: str, " + "- `mcp__openswarm-core__WebSearch(query: str, " "num_results?: int)`, DuckDuckGo search." ) - if "mcp__openswarm-web__WebFetch" in effective_allowed: + if "mcp__openswarm-core__WebFetch" in effective_allowed: p_hint_lines.append( - "- `mcp__openswarm-web__WebFetch(url: str, prompt?: " + "- `mcp__openswarm-core__WebFetch(url: str, prompt?: " "str)`, fetch a URL and return readable text." ) p_hint_lines.append( diff --git a/backend/apps/agents/manager/run_browser_fast_path.py b/backend/apps/agents/manager/run_browser_fast_path.py index d2cdc82f..f7491b74 100644 --- a/backend/apps/agents/manager/run_browser_fast_path.py +++ b/backend/apps/agents/manager/run_browser_fast_path.py @@ -38,7 +38,7 @@ async def run_browser_fast_path( logger.info(f"[browser-fast-path] direct dispatch for session {session_id} ({verdict})") text = "" # The fast-path skips the orchestrator, so the UI never gets the BrowserAgent tool-call that draws the "Browser Agent" bubble. Emit a synthetic tool_call/ tool_result pair (same shape + mcp__ name the orchestrator uses) so the bubble shows here too. None until we actually dispatch a browser (a pure READ answer has no browser, so no bubble). - p_browser_tool = "mcp__openswarm-browser-agent__CreateBrowserAgent" + p_browser_tool = "mcp__openswarm-core__CreateBrowserAgent" p_bubble_tid: Optional[str] = None p_action_logs: List[List[Dict[str, object]]] = [] p_last_result: Dict[str, object] = {} diff --git a/backend/apps/tools_lib/tools_lib.py b/backend/apps/tools_lib/tools_lib.py index 039c4ff1..2453242e 100644 --- a/backend/apps/tools_lib/tools_lib.py +++ b/backend/apps/tools_lib/tools_lib.py @@ -208,10 +208,10 @@ def resolve_policy_slot(tool_name: str, tools: list[ToolDefinition]) -> PolicySl differently. That divergence was the bug behind 'Always approve' acting like a one-time accept: writes landed under the raw mcp__server__action name while the gate read the parsed inner action, so the next call never saw the policy.""" - bm = re.match(r"mcp__openswarm-browser-agent__(.+)", tool_name) + bm = re.match(r"mcp__openswarm-core__(.+)", tool_name) if bm: return PolicySlot("builtin", bm.group(1), None) - im = re.match(r"mcp__openswarm-invoke-agent__(.+)", tool_name) + im = re.match(r"mcp__openswarm-core__(.+)", tool_name) if im: return PolicySlot("builtin", im.group(1), None) m = re.match(r"mcp__([^_]+(?:-[^_]+)*)__(.+)", tool_name) diff --git a/backend/tests/test_accept_all_midrun.py b/backend/tests/test_accept_all_midrun.py index f9329495..b46e78be 100644 --- a/backend/tests/test_accept_all_midrun.py +++ b/backend/tests/test_accept_all_midrun.py @@ -33,7 +33,7 @@ def test_always_allow_applies_to_next_call_same_run(p_isolated_persistence): def test_always_allow_namespaced_builtin_uses_inner_slot(p_isolated_persistence): # Our browser/invoke delegation tools live in builtin_permissions under the INNER name; a write through the namespaced name must land where the next read looks. live_perms: dict = {} - name = "mcp__openswarm-browser-agent__BrowserAgent" + name = "mcp__openswarm-core__BrowserAgent" decision.set_tool_policy(name, "always_allow", live_perms) assert live_perms == {"BrowserAgent": "always_allow"} assert decision.effective_policy(name, live_perms, {}) == "always_allow" diff --git a/backend/tests/test_empty_finish.py b/backend/tests/test_empty_finish.py index 3eb7c3b2..15283893 100644 --- a/backend/tests/test_empty_finish.py +++ b/backend/tests/test_empty_finish.py @@ -45,7 +45,7 @@ def test_empty_assistant_text_is_an_empty_finish(): def test_ui_answer_tools_are_a_legit_finish(): s = p_session(("user", "show me"), - ("tool_call", {"tool": "mcp__openswarm-ui__ShowUI", "input": {}}), + ("tool_call", {"tool": "mcp__openswarm-core__ShowUI", "input": {}}), ("tool_result", {"text": "rendered"})) assert turn_finished_empty(s) is False diff --git a/backend/tests/test_headless_mode.py b/backend/tests/test_headless_mode.py index edc65f27..881bd9f7 100644 --- a/backend/tests/test_headless_mode.py +++ b/backend/tests/test_headless_mode.py @@ -78,40 +78,40 @@ def test_a_desktop_launch_denies_nothing_even_before_its_window_loads(monkeypatc def test_headless_with_a_renderer_offers_the_browser_server_again(monkeypatch, renderer_attached): monkeypatch.setenv("OPENSWARM_HEADLESS", "1") mcp_servers, allowed, disallowed = p_run_the_real_pipeline() - assert "openswarm-browser-agent" in mcp_servers + assert "browser" in mcp_servers["openswarm-core"]["env"]["OSW_MCP_MODULES"].split(",") for tool in BROWSER_DELEGATION: - assert f"mcp__openswarm-browser-agent__{tool}" in allowed + assert f"mcp__openswarm-core__{tool}" in allowed # Still nobody to answer, so the human-bound pair stays gone. - assert "openswarm-ui" not in mcp_servers + assert "ui" not in mcp_servers["openswarm-core"]["env"]["OSW_MCP_MODULES"].split(",") assert "AskUserQuestion" in disallowed def test_headless_drops_the_renderer_bound_servers_and_tools(monkeypatch, no_renderer): monkeypatch.setenv("OPENSWARM_HEADLESS", "1") mcp_servers, allowed, disallowed = p_run_the_real_pipeline() - assert "openswarm-browser-agent" not in mcp_servers - assert "openswarm-ui" not in mcp_servers + assert "browser" not in mcp_servers["openswarm-core"]["env"]["OSW_MCP_MODULES"].split(",") + assert "ui" not in mcp_servers["openswarm-core"]["env"]["OSW_MCP_MODULES"].split(",") for tool in BROWSER_DELEGATION: - assert f"mcp__openswarm-browser-agent__{tool}" not in allowed + assert f"mcp__openswarm-core__{tool}" not in allowed for ui_tool in ("ShowUI", "AskUI"): - assert f"mcp__openswarm-ui__{ui_tool}" not in allowed + assert f"mcp__openswarm-core__{ui_tool}" not in allowed assert "AskUserQuestion" not in allowed assert "AskUserQuestion" in disallowed # The rest of the surface is untouched; headless prunes the renderer, it doesn't lobotomise the agent. assert "Read" in allowed and "Bash" in allowed - assert "openswarm-invoke-agent" in mcp_servers + assert "invoke" in mcp_servers["openswarm-core"]["env"]["OSW_MCP_MODULES"].split(",") assert "openswarm-core" in mcp_servers def test_without_headless_every_one_of_them_is_offered(monkeypatch, no_renderer): monkeypatch.delenv("OPENSWARM_HEADLESS", raising=False) mcp_servers, allowed, _ = p_run_the_real_pipeline() - assert "openswarm-browser-agent" in mcp_servers - assert "openswarm-ui" in mcp_servers + assert "browser" in mcp_servers["openswarm-core"]["env"]["OSW_MCP_MODULES"].split(",") + assert "ui" in mcp_servers["openswarm-core"]["env"]["OSW_MCP_MODULES"].split(",") for tool in BROWSER_DELEGATION: - assert f"mcp__openswarm-browser-agent__{tool}" in allowed + assert f"mcp__openswarm-core__{tool}" in allowed for ui_tool in ("ShowUI", "AskUI"): - assert f"mcp__openswarm-ui__{ui_tool}" in allowed + assert f"mcp__openswarm-core__{ui_tool}" in allowed def test_askuserquestion_survives_when_the_ui_server_is_absent(monkeypatch): @@ -127,7 +127,7 @@ def test_askuserquestion_survives_when_the_ui_server_is_absent(monkeypatch): def test_only_the_exact_flag_value_turns_headless_on(monkeypatch, no_renderer): monkeypatch.setenv("OPENSWARM_HEADLESS", "0") _, allowed, _ = p_run_the_real_pipeline() - assert "mcp__openswarm-ui__ShowUI" in allowed + assert "mcp__openswarm-core__ShowUI" in allowed @pytest.mark.asyncio diff --git a/backend/tests/test_register_builtin_mcp_servers.py b/backend/tests/test_register_builtin_mcp_servers.py index 217e3808..321211e1 100644 --- a/backend/tests/test_register_builtin_mcp_servers.py +++ b/backend/tests/test_register_builtin_mcp_servers.py @@ -21,21 +21,20 @@ def test_registers_always_on_and_delegation_servers(): assert "openswarm-mcp-meta" not in mcp_servers assert "openswarm-settings-meta" not in mcp_servers assert "openswarm-apps" not in mcp_servers - # delegation (not denied) - assert "openswarm-browser-agent" in mcp_servers - assert "openswarm-invoke-agent" in mcp_servers + # delegation (not denied) rides the combined process as module flags + mods = mcp_servers["openswarm-core"]["env"]["OSW_MCP_MODULES"].split(",") + assert "browser" in mods and "invoke" in mods assert browser_tools == ["CreateBrowserAgent", "BrowserAgent", "BrowserAgents", "AppAgent"] assert invoke_tools == ["InvokeAgent"] # Every registered server's script path must resolve to a file that ACTUALLY EXISTS. This is the assertion that catches a moved-caller resolving the wrong agents dir. - for name in ("openswarm-core", "openswarm-browser-agent", "openswarm-invoke-agent"): - script = mcp_servers[name]["args"][0] - assert os.path.isfile(script), f"{name} script does not exist on disk: {script}" + script = mcp_servers["openswarm-core"]["args"][0] + assert os.path.isfile(script), f"combined server script does not exist on disk: {script}" def test_fully_denied_delegation_servers_are_not_registered(): mcp_servers = {} perms = {t: "deny" for t in ("CreateBrowserAgent", "BrowserAgent", "BrowserAgents", "AppAgent", "InvokeAgent")} register_builtin_mcp_servers(mcp_servers, p_session(), perms, None, None) - assert "openswarm-browser-agent" not in mcp_servers # all browser tools denied -> skip - assert "openswarm-invoke-agent" not in mcp_servers - assert "openswarm-core" in mcp_servers # always-on regardless + mods = mcp_servers["openswarm-core"]["env"]["OSW_MCP_MODULES"].split(",") + assert "browser" not in mods and "invoke" not in mods # all denied -> module skipped + assert "meta" in mods and "apps" in mods # always-on regardless diff --git a/backend/tests/test_schedule_gate.py b/backend/tests/test_schedule_gate.py index 37a58a05..2945086a 100644 --- a/backend/tests/test_schedule_gate.py +++ b/backend/tests/test_schedule_gate.py @@ -10,10 +10,10 @@ from backend.apps.agents.manager.permissions.workflow_approval import is_claude_ def test_schedule_commit_tools_force_ask_even_when_always_allow(): for tool in ( - "mcp__openswarm-schedule__ScheduleWorkflow", - "mcp__openswarm-schedule__UpdateScheduledWorkflow", - "mcp__openswarm-schedule__DeleteScheduledWorkflow", - "mcp__openswarm-schedule__PauseAllWorkflows", + "mcp__openswarm-core__ScheduleWorkflow", + "mcp__openswarm-core__UpdateScheduledWorkflow", + "mcp__openswarm-core__DeleteScheduledWorkflow", + "mcp__openswarm-core__PauseAllWorkflows", ): policy, _ = path_gate.maybe_override_policy("always_allow", tool, {}) assert policy == "ask", f"{tool} must force an approval, not silently always_allow" diff --git a/backend/tests/test_tool_policy_slot.py b/backend/tests/test_tool_policy_slot.py index d4da1770..133c16c9 100644 --- a/backend/tests/test_tool_policy_slot.py +++ b/backend/tests/test_tool_policy_slot.py @@ -26,9 +26,9 @@ def test_slot_for_builtin_tool(): def test_slot_for_our_browser_and_invoke_agents_uses_inner_name(): # These live in builtin_permissions under the INNER name, not the namespaced one. - assert resolve_policy_slot("mcp__openswarm-browser-agent__BrowserAgent", []) == \ + assert resolve_policy_slot("mcp__openswarm-core__BrowserAgent", []) == \ PolicySlot("builtin", "BrowserAgent", None) - assert resolve_policy_slot("mcp__openswarm-invoke-agent__InvokeAgent", []) == \ + assert resolve_policy_slot("mcp__openswarm-core__InvokeAgent", []) == \ PolicySlot("builtin", "InvokeAgent", None) @@ -79,8 +79,8 @@ def test_always_approve_round_trips_for_every_tool_shape(): shapes = [ "Bash", "Read", - "mcp__openswarm-browser-agent__BrowserAgent", - "mcp__openswarm-invoke-agent__InvokeAgent", + "mcp__openswarm-core__BrowserAgent", + "mcp__openswarm-core__InvokeAgent", f"mcp__{slug}__notion-fetch", ] for tool_name in shapes: