diff --git a/backend/apps/apps_sdk/apps_sdk.py b/backend/apps/apps_sdk/apps_sdk.py index 635b4523..844fcac4 100644 --- a/backend/apps/apps_sdk/apps_sdk.py +++ b/backend/apps/apps_sdk/apps_sdk.py @@ -10,7 +10,7 @@ can land its card at a position on the canvas. from contextlib import asynccontextmanager from typing import Any, AsyncIterator, Dict, Optional -from fastapi import HTTPException +from fastapi import HTTPException, Request from pydantic import BaseModel, ConfigDict from typeguard import typechecked @@ -70,7 +70,7 @@ async def llm(body: LlmRequest) -> LlmReply: resp = await stream.get_final_message() except Exception as e: raise HTTPException(status_code=502, detail=f"LLM call failed: {e}") - text = "".join(b.text for b in resp.content if getattr(b, "type", "") == "text") + text = "".join(getattr(b, "text", "") for b in resp.content if getattr(b, "type", "") == "text") return LlmReply(text=text, model=api_model) @@ -92,6 +92,116 @@ class SpawnAgentReply(BaseModel): session_id: str +class ToolsListRequest(BaseModel): + model_config = ConfigDict(validate_assignment=True) + + output_id: Optional[str] = None + + +@typechecked +def resolve_app_from_origin(origin: str) -> Optional[str]: + """Server-derived app identity: a webview app's fetch carries Origin http://127.0.0.1:, + and the runtime manager knows which app owns that port. Stronger than a self-reported id.""" + try: + from urllib.parse import urlparse + + from backend.apps.outputs.runtime import manager + + parsed = urlparse(origin) + if parsed.hostname not in ("127.0.0.1", "localhost") or not parsed.port: + return None + for registry in (manager.runtimes, manager.idle_lru): + for rt in registry.values(): + if parsed.port in (rt.frontend_port, rt.port): + return rt.workspace_id + except Exception: + return None + return None + + +class AppToolServerRow(BaseModel): + model_config = ConfigDict(validate_assignment=True) + + id: str + name: str + description: str + + +@apps_sdk.router.post("/tools/list") +@typechecked +async def tools_list(body: ToolsListRequest) -> Dict[str, Any]: + """Connected tool servers an app could ask to use: the SAME enabled, vetted set agents see, + nothing wider. Sub-tools come from POST /api/tools/{id}/discover; calls go through the grant.""" + from backend.apps.tools_lib.tools_lib import load_all_tools + + rows = [ + AppToolServerRow(id=tool.id, name=tool.name, description=tool.description[:200]) + for tool in load_all_tools() + if tool.mcp_config and tool.enabled and tool.auth_status in ("configured", "connected") + ] + return {"servers": [r.model_dump() for r in rows]} + + +class ToolCallRequest(BaseModel): + model_config = ConfigDict(validate_assignment=True) + + # App backends name themselves via OPENSWARM_OUTPUT_ID; webview apps are identified by Origin instead. + output_id: Optional[str] = None + # ":" from /tools/list. + tool: str + args: Dict[str, Any] = {} + + +@apps_sdk.router.post("/tools/call") +@typechecked +async def tools_call(body: ToolCallRequest, request: Request) -> Dict[str, Any]: + """The grant gate: denied is refused flat, ungranted blocks on a user approval card, granted + dispatches through the same transport + credential path agents use. Enforced server-side.""" + import json as p_json + + from backend.apps.apps_sdk.tool_grants import grant_status, request_grant + from backend.apps.outputs.workspace_io import load_output + from backend.apps.tools_lib.mcp_call import call_mcp_tool + + # Origin wins over the body: it's derived from which live app runtime owns the calling port. + output_id = resolve_app_from_origin(request.headers.get("origin", "")) or body.output_id + if not output_id: + raise HTTPException(status_code=403, detail="Could not identify the calling app; tool access is per-app.") + tool_id, sep, tool_name = body.tool.partition(":") + if not sep or not tool_id or not tool_name: + raise HTTPException(status_code=422, detail="tool must be ':' from /tools/list") + status = grant_status(output_id, body.tool) + if status == "denied": + raise HTTPException(status_code=403, detail=f"The user has denied this app access to {tool_name}.") + if status != "granted": + try: + output = load_output(output_id) + app_name = output.name if output else output_id + except Exception: + app_name = output_id + allowed = await request_grant(output_id, app_name, body.tool, tool_name, p_json.dumps(body.args)[:400]) + if not allowed: + raise HTTPException(status_code=403, detail=f"The user did not approve this app using {tool_name}.") + text = await call_mcp_tool(tool_id, tool_name, body.args) + return {"result": text} + + +class GrantResolveRequest(BaseModel): + model_config = ConfigDict(validate_assignment=True) + + request_id: str + allow: bool + remember: bool = False + + +@apps_sdk.router.post("/tools/grant") +@typechecked +async def tools_grant(body: GrantResolveRequest) -> Dict[str, bool]: + from backend.apps.apps_sdk.tool_grants import resolve_grant + + return {"ok": resolve_grant(body.request_id, body.allow, body.remember)} + + @apps_sdk.router.post("/agents/spawn") @typechecked async def spawn_agent(body: SpawnAgentRequest) -> SpawnAgentReply: diff --git a/backend/apps/apps_sdk/tool_grants.py b/backend/apps/apps_sdk/tool_grants.py new file mode 100644 index 00000000..a02113a8 --- /dev/null +++ b/backend/apps/apps_sdk/tool_grants.py @@ -0,0 +1,105 @@ +"""Per-app grants for MCP tool calls made FROM apps: default is ask-the-user, decisions can be +remembered per app+tool, and the deny path is enforced HERE, server-side, so no app-side code +can widen its own surface. The grant decides IF a call may happen; mcp_call is HOW.""" + +import asyncio +import json +import os +import threading +import uuid +from typing import Dict, Literal, Optional + +from pydantic import BaseModel, ConfigDict, InstanceOf +from typeguard import typechecked + +from backend.apps.settings.store import DATA_DIR + +GRANTS_FILE = os.path.join(DATA_DIR, "app_tool_grants.json") +GRANT_WAIT_SECONDS = 120.0 + +p_lock = threading.Lock() + + +class PendingGrant(BaseModel): + model_config = ConfigDict(validate_assignment=True) + request_id: str + output_id: str + tool_key: str + event: InstanceOf[asyncio.Event] + allow: bool = False + remember: bool = False + + +p_pending: Dict[str, PendingGrant] = {} + + +@typechecked +def p_read_grants() -> Dict[str, Dict[str, str]]: + try: + with open(GRANTS_FILE, "r", encoding="utf-8") as f: + raw = json.load(f) + return {str(k): {str(t): str(d) for t, d in v.items()} for k, v in raw.items()} + except Exception: + return {} + + +@typechecked +def p_write_grants(grants: Dict[str, Dict[str, str]]) -> None: + os.makedirs(DATA_DIR, exist_ok=True) + tmp = GRANTS_FILE + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(grants, f, indent=2) + os.replace(tmp, GRANTS_FILE) + + +@typechecked +def grant_status(output_id: str, tool_key: str) -> Optional[str]: + with p_lock: + return p_read_grants().get(output_id, {}).get(tool_key) + + +@typechecked +def set_grant(output_id: str, tool_key: str, decision: Literal["granted", "denied"]) -> None: + with p_lock: + grants = p_read_grants() + grants.setdefault(output_id, {})[tool_key] = decision + p_write_grants(grants) + + +@typechecked +async def request_grant(output_id: str, app_name: str, tool_key: str, tool_label: str, args_preview: str) -> bool: + """Ask the user over the websocket and block until they answer or the wait expires. Timeout and + a closed dialog both read as deny: silence is never consent.""" + from backend.apps.agents.core.ws_manager import ws_manager + + pending = PendingGrant(request_id=uuid.uuid4().hex, output_id=output_id, tool_key=tool_key, event=asyncio.Event()) + p_pending[pending.request_id] = pending + try: + await ws_manager.broadcast_global("apps_sdk:tool_grant_request", { + "request_id": pending.request_id, + "output_id": output_id, + "app_name": app_name, + "tool_key": tool_key, + "tool_label": tool_label, + "args_preview": args_preview[:400], + }) + try: + await asyncio.wait_for(pending.event.wait(), timeout=GRANT_WAIT_SECONDS) + except asyncio.TimeoutError: + return False + if pending.remember: + set_grant(output_id, tool_key, "granted" if pending.allow else "denied") + return pending.allow + finally: + p_pending.pop(pending.request_id, None) + + +@typechecked +def resolve_grant(request_id: str, allow: bool, remember: bool) -> bool: + pending = p_pending.get(request_id) + if pending is None: + return False + pending.allow = allow + pending.remember = remember + pending.event.set() + return True diff --git a/backend/apps/outputs/app_builder_skill.md b/backend/apps/outputs/app_builder_skill.md index e7ec2d9b..18d7d442 100644 --- a/backend/apps/outputs/app_builder_skill.md +++ b/backend/apps/outputs/app_builder_skill.md @@ -361,8 +361,13 @@ listing + firing the user's workflows and reading run results, and spawning real cards on the canvas (optionally positioned). **Read `SDK.md` at the workspace root before building any feature that needs intelligence, automation, or agents** — the helpers are: -- Frontend: `import { llm, listWorkflows, runWorkflow, spawnAgent } from '@/openswarmHost'` -- Backend: `from backend.apps.openswarm_host.openswarm_host import llm, run_workflow, spawn_agent` +- Frontend: `import { llm, listWorkflows, runWorkflow, spawnAgent, listTools, callTool } from '@/openswarmHost'` +- Backend: `from backend.apps.openswarm_host.openswarm_host import llm, run_workflow, spawn_agent, list_tools, call_tool` + +The user's connected tools (Gmail, Calendar, custom MCP connectors) are callable too, behind a +PER-APP grant: the first `callTool(':', args)` pops an approval card and +blocks on the user's answer; a deny is a 403 and means no, never retry it in a loop. Design tool +features to degrade cleanly when the connector is absent or the user declines. Auth is automatic (host-injected token); never hand-roll fetches against host routes. The SDK works in preview and installed apps; for features that must survive PUBLISHING to the public diff --git a/backend/apps/outputs/runtime.py b/backend/apps/outputs/runtime.py index 0f69e874..d8012b7c 100644 --- a/backend/apps/outputs/runtime.py +++ b/backend/apps/outputs/runtime.py @@ -432,6 +432,8 @@ class AppRuntime: env = {k: v for k, v in os.environ.items() if k != "OPENSWARM_AUTH_TOKEN"} # Where the token lives, not the token itself: an app that legitimately calls our REST API reads it from disk and so picks up rotations, without the value sitting in its env for any child to inherit. Dev and packaged builds keep their data roots in different places, so an app hardcoding one of them is silently wrong in the other. env["OPENSWARM_HOST_TOKEN_FILE"] = AUTH_TOKEN_FILE + # Process-scoped identity for the apps-SDK grant gate: the app's own backend names itself with this, and the gate keys tool grants on it. + env["OPENSWARM_OUTPUT_ID"] = self.workspace_id # Hand the workspace's backend/run.sh the exact interpreter we're running on. In the packaged build that's the bundled standalone Python, so a fresh machine with no system `python3` still works; in dev it's whatever launched uvicorn. OPENSWARM_NODE_PATH already rides in via os.environ (set by the Electron shell) for run.sh's Node resolution. env["OPENSWARM_PYTHON"] = sys.executable # Force npm to skip dependency lifecycle scripts for every install run.sh triggers. An imported app's package.json is untrusted (it brings its own run.sh, so we can't gate the flag there); a malicious dep's postinstall would otherwise run arbitrary code on the host the moment its preview boots. Vite/esbuild get their platform binary via optionalDependencies, not a script, so this doesn't break the build. diff --git a/backend/apps/outputs/webapp_template/SDK.md b/backend/apps/outputs/webapp_template/SDK.md index 7a8be494..46626fd7 100644 --- a/backend/apps/outputs/webapp_template/SDK.md +++ b/backend/apps/outputs/webapp_template/SDK.md @@ -80,11 +80,19 @@ Each component folder carries its own README + zod schema (`@toolui/registry` ma name -> schema). They style themselves (scoped Tailwind, no preflight), so they drop into the MUI app without fights, and they follow the app's light/dark mode. -## What the SDK does NOT give you (yet) +## Tools (the user's connected MCP connectors), behind per-app grants -- Direct calls to the user's connected tools/MCP connectors (Gmail, Slack, ...). That surface - needs per-app permission grants and is not wired; do not fake it by calling other host routes. - If your app needs a tool action today, spawn an agent and ask it to do the task. +Apps can call the user's connected tools, but every tool is gated per app: the first call to a +tool pops an approval card in OpenSwarm (Allow once / Always allow / Never allow), and the call +blocks until the user answers. A deny (or ignoring the card for 2 minutes) rejects with a 403; +treat that as the user's answer, never retry in a loop. + +Frontend: `listTools()` -> servers, `discoverTools(serverId)` -> that server's tools with input +schemas, `callTool(':', args)` -> result text. +Backend: `list_tools()`, `discover_tools(server_id)`, `call_tool(':', args)`. + +Only servers the user has connected and enabled are reachable; there is no way to widen that from +app code, so design the feature to degrade when the tool it wants is absent or denied. ## Ground rules diff --git a/backend/apps/outputs/webapp_template/backend/apps/openswarm_host/openswarm_host.py b/backend/apps/outputs/webapp_template/backend/apps/openswarm_host/openswarm_host.py index ce931f8e..97491bd0 100644 --- a/backend/apps/outputs/webapp_template/backend/apps/openswarm_host/openswarm_host.py +++ b/backend/apps/outputs/webapp_template/backend/apps/openswarm_host/openswarm_host.py @@ -91,3 +91,33 @@ def spawn_agent( def agent_session(session_id: str) -> Dict[str, Any]: """A spawned agent's live state: status plus its transcript so far.""" return p_request("GET", f"/api/agents/sessions/{session_id}") + + +@typechecked +def list_tools() -> List[Dict[str, Any]]: + """The user's connected tool servers (Gmail, Calendar, custom MCP connectors, ...).""" + reply = p_request("POST", "/api/apps-sdk/tools/list", {}) + servers = reply.get("servers", []) + return servers if isinstance(servers, list) else [] + + +@typechecked +def discover_tools(server_id: str) -> List[Dict[str, Any]]: + """The individual tools a server exposes (name + input schema), straight from the live server.""" + reply = p_request("POST", f"/api/tools/{server_id}/discover", {}) + tools = reply.get("tools", []) + return tools if isinstance(tools, list) else [] + + +@typechecked +def call_tool(tool: str, args: Optional[Dict[str, Any]] = None) -> str: + """Call one tool as ':'. The FIRST call per tool shows the user an + approval card (Allow once / Always / Never); a deny or an unanswered card raises with a 403. + Design for that: it is the user saying no, not an error to retry. Backend processes are + identified by OPENSWARM_OUTPUT_ID (injected by the host runtime).""" + reply = p_request("POST", "/api/apps-sdk/tools/call", { + "tool": tool, + "args": args or {}, + "output_id": os.environ.get("OPENSWARM_OUTPUT_ID") or None, + }) + return str(reply.get("result", "")) diff --git a/backend/apps/outputs/webapp_template/frontend/src/openswarmHost.ts b/backend/apps/outputs/webapp_template/frontend/src/openswarmHost.ts index 1446dd59..d789f917 100644 --- a/backend/apps/outputs/webapp_template/frontend/src/openswarmHost.ts +++ b/backend/apps/outputs/webapp_template/frontend/src/openswarmHost.ts @@ -104,3 +104,35 @@ export async function spawnAgent(prompt: string, opts: SpawnAgentOptions = {}): export async function agentSession(sessionId: string): Promise> { return hostFetch>(`/api/agents/sessions/${encodeURIComponent(sessionId)}`); } + +export interface ToolServer { + id: string; + name: string; + description: string; +} + +/** The user's connected tool servers (Gmail, Calendar, custom MCP connectors, ...). */ +export async function listTools(): Promise { + const reply = await hostFetch<{ servers: ToolServer[] }>('/api/apps-sdk/tools/list', { + method: 'POST', + body: JSON.stringify({}), + }); + return reply.servers; +} + +/** The individual tools a server exposes (name + input schema), straight from the live server. */ +export async function discoverTools(serverId: string): Promise[]> { + const reply = await hostFetch<{ tools: Record[] }>(`/api/tools/${encodeURIComponent(serverId)}/discover`, { method: 'POST', body: JSON.stringify({}) }); + return reply.tools ?? []; +} + +/** Call one tool as `:`. The FIRST call per tool shows the user an approval + * card (Allow once / Always / Never); a deny or an unanswered card rejects with a 403. Design for + * that: it is the user saying no, not an error to retry. */ +export async function callTool(tool: string, args: Record = {}): Promise { + const reply = await hostFetch<{ result: string }>('/api/apps-sdk/tools/call', { + method: 'POST', + body: JSON.stringify({ tool, args }), + }); + return reply.result; +} diff --git a/backend/apps/tools_lib/mcp_call.py b/backend/apps/tools_lib/mcp_call.py new file mode 100644 index 00000000..70c50828 --- /dev/null +++ b/backend/apps/tools_lib/mcp_call.py @@ -0,0 +1,150 @@ +"""Call ONE tool on a connected MCP server, over whichever transport its config names. + +The dispatch half of the apps-SDK tool grant gate: the grant decides IF a call may happen, +this module is HOW it happens. Reuses the exact credential guards and config derivation the +discovery path uses, so an app can never reach a server an agent could not.""" + +import asyncio +import json +import os +import time +from typing import Any, Dict, List, Optional + +import httpx +from fastapi import HTTPException +from typeguard import typechecked + + +@typechecked +def render_tool_result(content: List[Any]) -> str: + """Flatten MCP content blocks to the text an app can actually use.""" + parts: List[str] = [] + for block in content: + kind = getattr(block, "type", None) or (block.get("type") if isinstance(block, dict) else None) + if kind == "text": + parts.append(getattr(block, "text", None) or (block.get("text", "") if isinstance(block, dict) else "")) + else: + try: + parts.append(json.dumps(block if isinstance(block, dict) else block.__dict__)) + except Exception: + parts.append(str(block)) + return "\n".join(p for p in parts if p) + + +@typechecked +async def call_mcp_tool_stdio(command: str, args: Optional[List[str]], env: Optional[Dict[str, str]], tool_name: str, arguments: Dict[str, Any]) -> str: + from mcp import ClientSession, StdioServerParameters + from mcp.client.stdio import stdio_client + + params = StdioServerParameters(command=command, args=args or [], env={**os.environ, **(env or {})}) + async with stdio_client(params) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + result = await session.call_tool(tool_name, arguments) + text = render_tool_result(list(result.content)) + if getattr(result, "isError", False): + raise HTTPException(status_code=502, detail=text or f"{tool_name} returned an error") + return text + + +@typechecked +async def call_mcp_tool_http(url: str, headers: Optional[Dict[str, str]], tool_name: str, arguments: Dict[str, Any]) -> str: + from backend.apps.tools_lib.mcp_discovery import parse_sse_json + + h = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream", **(headers or {})} + async with httpx.AsyncClient(timeout=90.0) as client: + init_resp = await client.post(url, headers=h, json={ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": {"protocolVersion": "2025-03-26", "capabilities": {}, + "clientInfo": {"name": "self-swarm", "version": "0.1.0"}}, + }) + if init_resp.status_code not in (200, 201): + raise HTTPException(status_code=502, detail=f"MCP initialize failed: {init_resp.status_code}") + session_id = init_resp.headers.get("mcp-session-id", "") + if session_id: + h["mcp-session-id"] = session_id + await client.post(url, headers=h, json={"jsonrpc": "2.0", "method": "notifications/initialized"}) + call_resp = await client.post(url, headers=h, json={ + "jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": {"name": tool_name, "arguments": arguments}, + }) + if call_resp.status_code not in (200, 201): + raise HTTPException(status_code=502, detail=f"MCP tools/call failed: {call_resp.status_code}") + data = parse_sse_json(call_resp.text) if "text/event-stream" in call_resp.headers.get("content-type", "") else call_resp.json() + if not data: + raise HTTPException(status_code=502, detail="Empty response from MCP server") + if data.get("error"): + raise HTTPException(status_code=502, detail=str(data["error"].get("message", data["error"]))) + result = data.get("result", {}) + text = render_tool_result(result.get("content", [])) + if result.get("isError"): + raise HTTPException(status_code=502, detail=text or f"{tool_name} returned an error") + return text + + +@typechecked +async def call_mcp_tool_sse(url: str, headers: Optional[Dict[str, str]], tool_name: str, arguments: Dict[str, Any]) -> str: + from mcp import ClientSession + from mcp.client.sse import sse_client + from mcp.types import Implementation + + try: + async with sse_client(url=url, headers=headers, timeout=30, sse_read_timeout=90) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream, client_info=Implementation(name="self-swarm", version="0.1.0")) as session: + await session.initialize() + result = await session.call_tool(tool_name, arguments) + text = render_tool_result(list(result.content)) + if getattr(result, "isError", False): + raise HTTPException(status_code=502, detail=text or f"{tool_name} returned an error") + return text + except BaseExceptionGroup as eg: + first = eg.exceptions[0] if eg.exceptions else eg + raise HTTPException(status_code=502, detail=f"SSE tool call failed: {first}") from first + + +@typechecked +async def call_mcp_tool(tool_id: str, tool_name: str, arguments: Dict[str, Any]) -> str: + """Resolve the tool's transport + credentials exactly like discovery does, then call it.""" + from backend.apps.tools_lib.mcp_config import derive_mcp_config + from backend.apps.tools_lib.oauth_tokens import refresh_airtable_token, refresh_google_token, refresh_hubspot_token + from backend.apps.tools_lib.tools_lib import load + + tool = load(tool_id) + if not tool.enabled: + raise HTTPException(status_code=403, detail=f"{tool.name} is disabled in Settings.") + if tool.auth_type == "env_vars" and not tool.credentials: + raise HTTPException(status_code=409, detail=f"{tool.name} isn't connected yet.") + # Same refresh dance as discover_tools: a stale OAuth token fails the child, not the user. + if tool.auth_type == "oauth2" and tool.auth_status == "connected" and tool.oauth_tokens.get("refresh_token"): + if tool.name.lower() == "airtable": + refreshed = await refresh_airtable_token(tool) + elif tool.name.lower() == "hubspot": + refreshed = await refresh_hubspot_token(tool) + else: + refreshed = await refresh_google_token(tool) + if not refreshed and tool.oauth_tokens.get("access_token") and time.time() >= tool.oauth_tokens.get("token_expiry", 0) - 60: + raise HTTPException(status_code=502, detail=f"OAuth token expired and refresh failed. Reconnect {tool.name}.") + + config = derive_mcp_config(tool) + if not config: + raise HTTPException(status_code=400, detail="Cannot derive MCP config for tool") + transport = config.get("type", "") + call = None + if transport == "stdio": + if not config.get("command"): + raise HTTPException(status_code=400, detail="stdio transport requires a 'command'") + call = call_mcp_tool_stdio(config["command"], config.get("args"), config.get("env"), tool_name, arguments) + elif transport in ("http", "sse") or config.get("url"): + url = config.get("url", "") + if not url: + raise HTTPException(status_code=400, detail="HTTP/SSE transport requires a 'url'") + if transport == "sse": + call = call_mcp_tool_sse(url, config.get("headers"), tool_name, arguments) + else: + call = call_mcp_tool_http(url, config.get("headers"), tool_name, arguments) + else: + raise HTTPException(status_code=400, detail=f"Unsupported MCP transport type: '{transport}'.") + try: + return await asyncio.wait_for(call, timeout=120.0) + except asyncio.TimeoutError: + raise HTTPException(status_code=504, detail=f"{tool_name} timed out after 120s") diff --git a/backend/apps/tools_lib/mcp_discovery.py b/backend/apps/tools_lib/mcp_discovery.py index 01b9bc0c..0d2dd607 100644 --- a/backend/apps/tools_lib/mcp_discovery.py +++ b/backend/apps/tools_lib/mcp_discovery.py @@ -14,7 +14,7 @@ from backend.apps.tools_lib.mcp_failure_reason import readable_mcp_failure logger = logging.getLogger(__name__) -def p_parse_sse_json(text: str) -> dict | None: +def parse_sse_json(text: str) -> dict | None: """Extract JSON from an SSE response body (handles `data: {...}` lines).""" for line in text.splitlines(): stripped = line.strip() @@ -63,7 +63,7 @@ async def discover_mcp_tools_http(url: str, headers: dict | None = None) -> list ct = list_resp.headers.get("content-type", "") if "text/event-stream" in ct: - data = p_parse_sse_json(list_resp.text) + data = parse_sse_json(list_resp.text) else: data = list_resp.json() diff --git a/backend/config/entity_references.py b/backend/config/entity_references.py index b1fe0833..856c1f76 100644 --- a/backend/config/entity_references.py +++ b/backend/config/entity_references.py @@ -93,6 +93,11 @@ CROSS_ENTITY_REFERENCES: List[EntityReference] = [ EntityReference(module="backend.apps.outputs.models", model="OutputCreate", field="session_id", target=EntityKind.SESSION), EntityReference(module="backend.apps.outputs.models", model="OutputCreate", field="workspace_id", target=EntityKind.WORKSPACE), EntityReference(module="backend.apps.outputs.models", model="OutputExecute", field="output_id", target=EntityKind.OUTPUT), + EntityReference(module="backend.apps.apps_sdk.apps_sdk", model="SpawnAgentRequest", field="dashboard_id", target=EntityKind.DASHBOARD), + EntityReference(module="backend.apps.apps_sdk.apps_sdk", model="SpawnAgentReply", field="session_id", target=EntityKind.SESSION), + EntityReference(module="backend.apps.apps_sdk.apps_sdk", model="ToolsListRequest", field="output_id", target=EntityKind.OUTPUT), + EntityReference(module="backend.apps.apps_sdk.apps_sdk", model="ToolCallRequest", field="output_id", target=EntityKind.OUTPUT), + EntityReference(module="backend.apps.apps_sdk.tool_grants", model="PendingGrant", field="output_id", target=EntityKind.OUTPUT), EntityReference(module="backend.apps.outputs.models", model="OutputExecuteResult", field="output_id", target=EntityKind.OUTPUT), EntityReference(module="backend.apps.outputs.models", model="OutputUpdate", field="session_id", target=EntityKind.SESSION), EntityReference(module="backend.apps.outputs.models", model="OutputUpdate", field="workspace_id", target=EntityKind.WORKSPACE), diff --git a/backend/tests/test_apps_sdk.py b/backend/tests/test_apps_sdk.py index 7f5012e1..c663bd72 100644 --- a/backend/tests/test_apps_sdk.py +++ b/backend/tests/test_apps_sdk.py @@ -159,7 +159,84 @@ def test_template_ships_both_sdk_helpers_and_the_skill_references_them(): with open(skill_path, "r", encoding="utf-8") as f: skill = f.read() assert "SDK.md" in skill and "openswarmHost" in skill and "openswarm_host" in skill - # The tools/MCP surface is deliberately not wired yet; the guide must not advertise it as available. + # The tools surface is wired behind per-app grants; the guide must teach the deny contract, not hide the gate. with open(guide, "r", encoding="utf-8") as f: text = f.read() - assert "does NOT give you" in text + assert "per-app" in text and "Allow once" in text and "never retry" in text + + +def p_isolated_grants(tmp_path, monkeypatch): + from backend.apps.apps_sdk import tool_grants + monkeypatch.setattr(tool_grants, "GRANTS_FILE", str(tmp_path / "grants.json")) + return tool_grants + + +def test_tool_call_denied_grant_is_refused_flat(tmp_path, monkeypatch): + grants = p_isolated_grants(tmp_path, monkeypatch) + grants.set_grant("app1", "srv1:SendEmail", "denied") + r = client.post("/api/apps-sdk/tools/call", headers=p_auth(), + json={"output_id": "app1", "tool": "srv1:SendEmail", "args": {}}) + assert r.status_code == 403 + assert "denied" in r.json()["detail"] + + +def test_tool_call_ungranted_times_out_to_deny(tmp_path, monkeypatch): + from backend.apps.apps_sdk import tool_grants + p_isolated_grants(tmp_path, monkeypatch) + monkeypatch.setattr(tool_grants, "GRANT_WAIT_SECONDS", 0.05) + called = {"n": 0} + + async def p_never(*a, **k): + called["n"] += 1 + return "should not run" + import backend.apps.tools_lib.mcp_call as mcp_call + monkeypatch.setattr(mcp_call, "call_mcp_tool", p_never) + r = client.post("/api/apps-sdk/tools/call", headers=p_auth(), + json={"output_id": "app1", "tool": "srv1:SendEmail", "args": {}}) + assert r.status_code == 403 + assert called["n"] == 0 + + +def test_tool_call_granted_dispatches(tmp_path, monkeypatch): + grants = p_isolated_grants(tmp_path, monkeypatch) + grants.set_grant("app1", "srv1:SendEmail", "granted") + + async def p_fake_call(tool_id, tool_name, arguments): + assert (tool_id, tool_name) == ("srv1", "SendEmail") + return "sent: " + arguments["to"] + import backend.apps.tools_lib.mcp_call as mcp_call + monkeypatch.setattr(mcp_call, "call_mcp_tool", p_fake_call) + r = client.post("/api/apps-sdk/tools/call", headers=p_auth(), + json={"output_id": "app1", "tool": "srv1:SendEmail", "args": {"to": "a@b.c"}}) + assert r.status_code == 200 + assert r.json()["result"] == "sent: a@b.c" + + +def test_grant_prompt_approval_flow_allows_and_remembers(tmp_path, monkeypatch): + import asyncio + grants = p_isolated_grants(tmp_path, monkeypatch) + + async def p_fake_call(tool_id, tool_name, arguments): + return "ok" + import backend.apps.tools_lib.mcp_call as mcp_call + monkeypatch.setattr(mcp_call, "call_mcp_tool", p_fake_call) + + captured = {} + + async def p_capture_broadcast(event, payload): + captured.update(payload) + asyncio.get_running_loop().call_soon( + lambda: grants.resolve_grant(payload["request_id"], True, True)) + from backend.apps.agents.core import ws_manager as wsm + monkeypatch.setattr(wsm.ws_manager, "broadcast_global", p_capture_broadcast) + r = client.post("/api/apps-sdk/tools/call", headers=p_auth(), + json={"output_id": "app2", "tool": "srv9:ReadSheet", "args": {}}) + assert r.status_code == 200 + assert captured["tool_label"] == "ReadSheet" + assert grants.grant_status("app2", "srv9:ReadSheet") == "granted" + + +def test_tools_grant_route_unknown_request_is_no_op(): + r = client.post("/api/apps-sdk/tools/grant", headers=p_auth(), + json={"request_id": "nope", "allow": True}) + assert r.status_code == 200 and r.json()["ok"] is False diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index a125a4d3..4d4641b8 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -27,6 +27,7 @@ import { fetchOutputs } from '@/shared/state/outputsSlice'; import UpdateReadyPill from '@/app/components/Layout/UpdateReadyPill'; import WhatsNewCard from '@/app/components/Layout/WhatsNewCard'; import ShareRequestHost from '@/app/components/share/ShareRequestHost'; +import AppToolGrantHost from '@/app/components/apps/AppToolGrantHost'; import CardContextMenu from '@/app/pages/Dashboard/desktop/CardContextMenu'; import { findBrowserByWebContentsId } from '@/shared/browserRegistry'; import { byPreviewRecency } from '@/shared/previewOrder'; @@ -604,6 +605,7 @@ const AppShell: React.FC = () => { + {/* Shell-global right-click host (portals to body): chat surfaces render on non-dashboard routes too, so the menu can't live inside DashboardCanvas. */} diff --git a/frontend/src/app/components/apps/AppToolGrantHost.tsx b/frontend/src/app/components/apps/AppToolGrantHost.tsx new file mode 100644 index 00000000..72426e3c --- /dev/null +++ b/frontend/src/app/components/apps/AppToolGrantHost.tsx @@ -0,0 +1,69 @@ +import React, { useEffect, useState } from 'react'; +import Box from '@mui/material/Box'; +import Dialog from '@mui/material/Dialog'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import { API_BASE } from '@/shared/config'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +export const APP_TOOL_GRANT_EVENT = 'openswarm:app-tool-grant'; + +export interface AppToolGrantRequest { + request_id: string; + output_id: string; + app_name: string; + tool_key: string; + tool_label: string; + args_preview: string; +} + +/** One global mount that turns backend tool-grant requests (an app asking to use one of the user's + * connected MCP tools) into an approval dialog. The backend blocks the call until this answers; + * closing the dialog, denying, and the backend's own timeout ALL read as deny. */ +const AppToolGrantHost: React.FC = () => { + const c = useClaudeTokens(); + const [req, setReq] = useState(null); + useEffect(() => { + const onGrant = (e: Event): void => { + const detail = (e as CustomEvent).detail as AppToolGrantRequest | undefined; + if (detail && detail.request_id && detail.tool_key) setReq(detail); + }; + window.addEventListener(APP_TOOL_GRANT_EVENT, onGrant); + return () => window.removeEventListener(APP_TOOL_GRANT_EVENT, onGrant); + }, []); + if (!req) return null; + const answer = (allow: boolean, remember: boolean): void => { + void fetch(`${API_BASE}/apps-sdk/tools/grant`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ request_id: req.request_id, allow, remember }), + }).catch(() => { /* backend timeout already reads as deny */ }); + setReq(null); + }; + return ( + answer(false, false)} maxWidth="xs" fullWidth> + + + "{req.app_name}" wants to use {req.tool_label} + + + This app is asking to call one of your connected tools. Only allow it if you trust the app + with this action. + + {req.args_preview && req.args_preview !== '{}' && ( + + {req.args_preview} + + )} + + + + + + + + + ); +}; + +export default AppToolGrantHost; diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index 31dbfce3..ce6fefbd 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -538,6 +538,11 @@ class WebSocketManager { } break; + case 'apps_sdk:tool_grant_request': + // An app asked to use a connected MCP tool; AppToolGrantHost (global mount) owns the dialog. + window.dispatchEvent(new CustomEvent('openswarm:app-tool-grant', { detail: data })); + break; + case 'apps_sdk:place_agent_card': { // An app asked for its spawned agent at a specific canvas spot; the card is created async // by the session lifecycle, so nudge it into place with a short bounded retry. diff --git a/linter/config/config.json b/linter/config/config.json index 301b0886..8ea3fbec 100644 --- a/linter/config/config.json +++ b/linter/config/config.json @@ -22,7 +22,7 @@ "max-file-lines-exceptions": "Grandfather list of pre-existing >300-line files (existing debt, not new). Paths updated after the folder-tree restructure moved several of them. The two manager/prompt/* entries are from the agent_manager decomposition: prompt_context.py aggregates the system-prompt context builders and attachments.py is one cohesive 230-line attachment resolver; both are single-responsibility and a few lines over, not splittable without an artificial seam.", "max-folder-items-exceptions": "Exact-path allow for folders intentionally over the cap. The rule trips at >7 (7 items is fine, the 8th tips it), so only genuinely 8+ folders are listed. backend/ and backend/apps are FastAPI feature-package registries (each child is an app mounted in main.py); agents/ aggregates agent subsystems; agents/manager/ is the agent_manager god-object decomposition (cohesive AgentManager mixins + standalone run helpers + the streaming/permissions/prompt/session subtrees), conventionally flat like agents/ and core/ since its standalone helpers are heterogeneous and don't group cleanly; agents/manager/streaming and agents/manager/session are flat peer collections of one-module-per-concern handlers; core/, tools_lib/, tests/ are conventionally flat. Frontend: app/pages is the page registry, AgentChat/ChatInput/Settings-sections/Onboarding are organizational parents, and shared/state (Redux slices) plus hooks/steps/mcp-cards/Views are flat peer collections. scripts/, electron/, linter/checks/ are flat tool dirs. These replaced blanket .lintignore-max-folder-items sentinels (backend, frontend, scripts, electron, linter/checks) so the rule still catches NEW unplanned bloat everywhere else. Kept as whole-subtree sentinels on purpose: debugger/ (self-contained injected sub-tool with its own Vite GUI), webapp_template (Vite scaffold payload), and vendored mcp-bundles. 2026-07 desktop-shell additions: Dashboard canvas/cards/desktop + hooks/interaction + hooks/lifecycle, AgentChat bubbles/tool-ui, and shared/styles are flat peer collections (one component or hook per concern) that crossed 7 as the redesign surface grew. frontend/src/toolui carries a whole-subtree .lintignore: vendored tool-ui component library (pierre), same treatment as mcp-bundles. openswarm-edge/app is the edge's flat one-module-per-concern set (routing, bundles, inject, ratelimit, sandbox, and the vendored code_safety gate); it crossed 7 when the sandbox's static gate was split out to mirror the desktop file byte for byte. AgentChat/parsing joined when the narration/deliverable classifier landed: it is the same flat one-module-per-parser collection as the rest of that subtree. 2026-08-03 browser merge: agents/browser is the flat one-module-per-concern browser tier (40 modules) that arrived whole from eric/browser-merged; .github/workflows crossed 7 when the packaged-smoke and intel-verify workflows landed; frontend/ is a package root, not a code folder. components/overlays is the flat one-component-per-overlay collection; it crossed 7 when the mandatory sign-in gate landed as component + pure predicate + its test.", "import-cycles": "Flags RUNTIME circular imports only (SCC>1). Skips type-only imports (import type / export type) and dynamic import() since neither runs at module init, which is why the idiomatic Redux store<->hooks type cycle is not flagged. Frontend alias resolution comes from import-cycle-aliases. Zero cycles today; the check keeps it that way.", - "ruff + pyright": "Ported from Haik's linter (haik/feat/ingest). ruff is narrowed to F401/F811/F841 (unused imports/redefs/locals) and intentionally DROPS Haik's ARG001/ARG002 (unused args): our SDK-callback signatures require unused params (can_use_tool/pre_tool_hook take a `context` they don't use) and we ban the `_unused` prefix, so ARG is noise here. pyright runs Haik's existence-only config (typeCheckingMode off) with reportAttributeAccessIssue ENABLED: the AgentManager behavior classes now inherit a typing-only AgentManagerProtocol base (manager/AgentManagerProtocol.py) that declares the composed __init__ state + cross-class methods, so the checker sees self.sessions etc. from inside a mixin. pyright caught real bugs: a dangling `_conns` ref + TWO broken lazy imports (`_load_all`/`_load` from outputs.py, renamed to load_all/load in workspace_io but the import sites weren't updated — App Builder workspace seeding/name-sync was silently failing in a try/except). The one grandfathered SURFACE file (handle_assistant_message) is the SDK-optional try/except-import boundary (TextBlock=object fallback defeats isinstance narrowing). Both grandfather pre-existing debt by file; the refactor surface is clean. Requires `ruff` + `pyright` on PATH (added to requirements-dev.txt); pyright's config expects the venv at backend/.venv.", + "ruff + pyright": "Ported from Haik's linter (haik/feat/ingest). ruff is narrowed to F401/F811/F841 (unused imports/redefs/locals) and intentionally DROPS Haik's ARG001/ARG002 (unused args): our SDK-callback signatures require unused params (can_use_tool/pre_tool_hook take a `context` they don't use) and we ban the `_unused` prefix, so ARG is noise here. pyright runs Haik's existence-only config (typeCheckingMode off) with reportAttributeAccessIssue ENABLED: the AgentManager behavior classes now inherit a typing-only AgentManagerProtocol base (manager/AgentManagerProtocol.py) that declares the composed __init__ state + cross-class methods, so the checker sees self.sessions etc. from inside a mixin. pyright caught real bugs: a dangling `_conns` ref + TWO broken lazy imports (`_load_all`/`_load` from outputs.py, renamed to load_all/load in workspace_io but the import sites weren't updated \u2014 App Builder workspace seeding/name-sync was silently failing in a try/except). The one grandfathered SURFACE file (handle_assistant_message) is the SDK-optional try/except-import boundary (TextBlock=object fallback defeats isinstance narrowing). Both grandfather pre-existing debt by file; the refactor surface is clean. Requires `ruff` + `pyright` on PATH (added to requirements-dev.txt); pyright's config expects the venv at backend/.venv.", "no-underscore-names + p-private": "Convention checks ported verbatim from Haik's linter (haik/feat/ingest): no-underscore-names bans leading-underscore names (a dead-code-tooling blind spot; use p_ for private), p-private enforces that p_-prefixed names are accessed only inside their owning file/class (cross-file/class use means the name should be public). Backend Python only. The exception lists grandfather pre-existing debt that landed with the workflows/analytics forward-ports (eric's 'don't mass-migrate untouched files' rule); the agent_manager refactor surface is clean. NOTE: Haik's full linter (his branch also adds pyright + ruff and runs a different enabled set) should eventually supersede this; these two were lifted to enforce the p_ conventions on eric/dev now. browser_cookies.py and its Windows round-trip test are excepted for `_fields_` only: a ctypes.Structure protocol name required by the ctypes metaclass, not our naming.", "dangling-refs": "Every *_id / *_ids field on a backend pydantic model must name the entity it points at, in backend/config/entity_references.py. A model's own primary key is spelled `id`, which never matches the suffix, and neither do words that merely END in id (uuid, grid, valid) since the underscore is required. 42 of the 74 existing fields are declared in the registry (sessions, dashboards, workflows, workflow runs, apps/outputs, workspaces); the 32 listed here are grandfathered debt, and the entry is keyed ::. rather than by file ON PURPOSE, so a NEW id field added to an already-listed model is still caught (a file glob would exempt workflows/models.py forever, which is exactly where the next dangling pointer lands). The grandfathered set is what does not resolve against a store: renderer-owned live objects (browser_id, selected_browser_ids, selected_setting_ids), ids internal to a single record (active_branch_id, msg_id, parent_id, fork_point_message_id, compacted_through_msg_id), external protocol ids we do not own (sdk_session_id, client_message_id, connection_id, installation_id, user_id), telemetry echoes (analytics bridges), and the skill-registry / .swarm-bundle entities that have no backend store module yet. Move an entry out of this list and into the registry when its entity gets one. backend/tests/*::* is blanket-exempt: a test-local model is not a persisted entity. The registry is checked back both ways, so an entry for a deleted field, or a store whose lookup function was renamed, is an error too." }, @@ -270,6 +270,8 @@ "backend/apps/agents/manager/streaming/PartialReply.py::PartialReply.msg_id", "backend/apps/agents/manager/streaming/state.py::ThinkingState.msg_id", "backend/apps/agents/manager/streaming/state.py::TurnState.stream_text_msg_id", + "backend/apps/apps_sdk/apps_sdk.py::GrantResolveRequest.request_id", + "backend/apps/apps_sdk/tool_grants.py::PendingGrant.request_id", "backend/apps/dashboards/models.py::BrowserCardPosition.browser_id", "backend/apps/nine_router/credential_store.py::ProviderCredential.connection_id", "backend/apps/outputs/models.py::OutputVersion.parent_id", @@ -349,4 +351,4 @@ "backend/config/Apps.py" ] } -} +} \ No newline at end of file