mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] agents: every builtin tool server rides ONE process per chat, and the gate enumerates its grants instead of wildcarding
This commit is contained in:
@@ -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",):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -100,7 +100,7 @@ def compose_turn_system_prompt(
|
||||
rich_ui_note = (
|
||||
"<rich_ui>\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, "
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 <skills> 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",
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 <web_tools> 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(
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user