From 92a0c3bfdad2901ac34c38f5fa664dcac23c1042 Mon Sep 17 00:00:00 2001 From: Alexander Vaagan <2428222+vaaale@users.noreply.github.com> Date: Wed, 1 Apr 2026 08:37:17 +0200 Subject: [PATCH] Implemented Plugin to support MCP Apps --- .../_10_mcp_apps_intercept.py | 108 +++++ .../python/webui_ws_event/_22_mcp_apps.py | 103 +++++ .../get_message_handler/mcp_app_handler.js | 41 ++ .../webui/initFw_end/mcp_apps_init.js | 5 + .../set_messages_after_loop/mcp_app_inject.js | 72 +++ plugins/mcp_apps/helpers/__init__.py | 0 plugins/mcp_apps/helpers/mcp_apps_manager.py | 433 ++++++++++++++++++ plugins/mcp_apps/plugin.yaml | 8 + plugins/mcp_apps/test_mcp/__init__.py | 0 .../test_mcp/mcp_elicitation_test_server.py | 402 ++++++++++++++++ plugins/mcp_apps/webui/mcp-app-bridge.js | 402 ++++++++++++++++ plugins/mcp_apps/webui/mcp-app-renderer.html | 115 +++++ plugins/mcp_apps/webui/mcp-app-sandbox.html | 149 ++++++ plugins/mcp_apps/webui/mcp-app-store.js | 134 ++++++ 14 files changed, 1972 insertions(+) create mode 100644 plugins/mcp_apps/extensions/python/tool_execute_after/_10_mcp_apps_intercept.py create mode 100644 plugins/mcp_apps/extensions/python/webui_ws_event/_22_mcp_apps.py create mode 100644 plugins/mcp_apps/extensions/webui/get_message_handler/mcp_app_handler.js create mode 100644 plugins/mcp_apps/extensions/webui/initFw_end/mcp_apps_init.js create mode 100644 plugins/mcp_apps/extensions/webui/set_messages_after_loop/mcp_app_inject.js create mode 100644 plugins/mcp_apps/helpers/__init__.py create mode 100644 plugins/mcp_apps/helpers/mcp_apps_manager.py create mode 100644 plugins/mcp_apps/plugin.yaml create mode 100644 plugins/mcp_apps/test_mcp/__init__.py create mode 100644 plugins/mcp_apps/test_mcp/mcp_elicitation_test_server.py create mode 100644 plugins/mcp_apps/webui/mcp-app-bridge.js create mode 100644 plugins/mcp_apps/webui/mcp-app-renderer.html create mode 100644 plugins/mcp_apps/webui/mcp-app-sandbox.html create mode 100644 plugins/mcp_apps/webui/mcp-app-store.js diff --git a/plugins/mcp_apps/extensions/python/tool_execute_after/_10_mcp_apps_intercept.py b/plugins/mcp_apps/extensions/python/tool_execute_after/_10_mcp_apps_intercept.py new file mode 100644 index 0000000..f11c733 --- /dev/null +++ b/plugins/mcp_apps/extensions/python/tool_execute_after/_10_mcp_apps_intercept.py @@ -0,0 +1,108 @@ +""" +Extension that runs after MCP tool execution. If the tool has _meta.ui metadata, +fetches the UI resource and registers an app instance, then broadcasts an +mcp_app message to the frontend via the agent context log. +""" + +from helpers.extension import Extension +from helpers.print_style import PrintStyle + + +class McpAppsToolIntercept(Extension): + async def execute(self, **kwargs): + tool_name = kwargs.get("tool_name", "") + response = kwargs.get("response", None) + + PrintStyle(font_color="yellow", padding=True).print( + f"DEBUG McpAppsToolIntercept: called with tool_name='{tool_name}'" + ) + + if not tool_name or "." not in tool_name: + PrintStyle(font_color="yellow", padding=True).print( + f"DEBUG McpAppsToolIntercept: skipping (no dot in tool_name)" + ) + return + + try: + import helpers.mcp_handler as mcp_handler + + mcp_config = mcp_handler.MCPConfig.get_instance() + ui_meta = mcp_config.get_tool_ui_meta(tool_name) + PrintStyle(font_color="yellow", padding=True).print( + f"DEBUG McpAppsToolIntercept: ui_meta for '{tool_name}' = {ui_meta}" + ) + if not ui_meta: + return + + resource_uri = ui_meta.get("resourceUri") + if not resource_uri: + return + + server_name = tool_name.split(".", 1)[0] + + PrintStyle(font_color="cyan", padding=True).print( + f"MCP Apps: Tool '{tool_name}' has UI resource '{resource_uri}', fetching..." + ) + + from usr.plugins.mcp_apps.helpers.mcp_apps_manager import MCPAppsManager + + manager = MCPAppsManager.get_instance() + html_content = await manager.fetch_ui_resource(server_name, resource_uri) + + tool_result_text = response.message if response else "" + tool_args = kwargs.get("tool_args", {}) + + # Look up tool description and input schema from MCP tool cache + short_tool_name = tool_name.split(".", 1)[1] + tool_description = "" + tool_input_schema = None + for srv in mcp_config.servers: + if srv.name == server_name: + for t in srv.get_tools(): + if t.get("name") == short_tool_name: + tool_description = t.get("description", "") + tool_input_schema = t.get("input_schema") + break + break + + app_id = manager.register_app( + server_name=server_name, + tool_name=short_tool_name, + resource_uri=resource_uri, + html_content=html_content, + tool_args=tool_args, + tool_result={"content": [{"type": "text", "text": tool_result_text}]}, + ui_meta=ui_meta, + tool_description=tool_description, + tool_input_schema=tool_input_schema, + ) + + if self.agent and self.agent.context: + csp = ui_meta.get("csp", {}) + permissions = ui_meta.get("permissions", {}) + prefers_border = ui_meta.get("prefersBorder", True) + + self.agent.context.log.log( + type="mcp_app", + heading=f"icon://widgets MCP App: {tool_name}", + content="", + kvps={ + "app_id": app_id, + "server_name": server_name, + "tool_name": tool_name, + "resource_uri": resource_uri, + "csp": csp, + "permissions": permissions, + "prefers_border": prefers_border, + }, + ) + + PrintStyle(font_color="green", padding=True).print( + f"MCP Apps: App '{app_id}' ready for '{tool_name}' " + f"({len(html_content)} bytes HTML)" + ) + + except Exception as e: + PrintStyle(font_color="red", padding=True).print( + f"MCP Apps: Failed to set up app for tool '{tool_name}': {e}" + ) diff --git a/plugins/mcp_apps/extensions/python/webui_ws_event/_22_mcp_apps.py b/plugins/mcp_apps/extensions/python/webui_ws_event/_22_mcp_apps.py new file mode 100644 index 0000000..56abfcb --- /dev/null +++ b/plugins/mcp_apps/extensions/python/webui_ws_event/_22_mcp_apps.py @@ -0,0 +1,103 @@ +""" +WebSocket extension handling MCP Apps events from the frontend iframe bridge. + +Events handled: +- mcp_app_tool_call: Proxy a tools/call from iframe to MCP server +- mcp_app_resource_read: Proxy a resources/read from iframe to MCP server +- mcp_app_get_data: Retrieve app data (HTML, tool result, etc.) for an app_id +- mcp_app_teardown: Clean up an app instance +""" + +from helpers.extension import Extension + + +class McpAppsWsExtension(Extension): + async def execute(self, **kwargs): + event_type = kwargs.get("event_type", "") + data = kwargs.get("data", {}) + response_data = kwargs.get("response_data", {}) + + if event_type == "mcp_app_tool_call": + await self._handle_tool_call(data, response_data) + elif event_type == "mcp_app_resource_read": + await self._handle_resource_read(data, response_data) + elif event_type == "mcp_app_get_data": + await self._handle_get_data(data, response_data) + elif event_type == "mcp_app_teardown": + await self._handle_teardown(data, response_data) + + async def _handle_tool_call(self, data: dict, response_data: dict): + import asyncio + from helpers.print_style import PrintStyle + from usr.plugins.mcp_apps.helpers.mcp_apps_manager import MCPAppsManager + + app_id = data.get("app_id", "") + tool_name = data.get("tool_name", "") + arguments = data.get("arguments", {}) + + if not app_id or not tool_name: + response_data["error"] = "Missing app_id or tool_name" + return + + PrintStyle(font_color="cyan", padding=True).print( + f"MCP Apps WS: tool_call app_id={app_id} tool={tool_name}" + ) + + manager = MCPAppsManager.get_instance() + try: + result = await asyncio.wait_for( + manager.proxy_tool_call(app_id, tool_name, arguments), + timeout=60, + ) + response_data.update(result) + except asyncio.TimeoutError: + PrintStyle(font_color="red", padding=True).print( + f"MCP Apps WS: tool_call TIMEOUT for {tool_name}" + ) + response_data["error"] = {"code": -32000, "message": f"Tool call '{tool_name}' timed out"} + except Exception as e: + PrintStyle(font_color="red", padding=True).print( + f"MCP Apps WS: tool_call ERROR for {tool_name}: {e}" + ) + response_data["error"] = {"code": -32000, "message": str(e)} + + async def _handle_resource_read(self, data: dict, response_data: dict): + from usr.plugins.mcp_apps.helpers.mcp_apps_manager import MCPAppsManager + + app_id = data.get("app_id", "") + uri = data.get("uri", "") + + if not app_id or not uri: + response_data["error"] = "Missing app_id or uri" + return + + manager = MCPAppsManager.get_instance() + result = await manager.proxy_resource_read(app_id, uri) + response_data.update(result) + + async def _handle_get_data(self, data: dict, response_data: dict): + from usr.plugins.mcp_apps.helpers.mcp_apps_manager import MCPAppsManager + + app_id = data.get("app_id", "") + if not app_id: + response_data["error"] = "Missing app_id" + return + + manager = MCPAppsManager.get_instance() + app_data = manager.get_app_data(app_id) + if app_data: + response_data.update(app_data) + else: + response_data["error"] = f"App '{app_id}' not found" + + async def _handle_teardown(self, data: dict, response_data: dict): + from usr.plugins.mcp_apps.helpers.mcp_apps_manager import MCPAppsManager + + app_id = data.get("app_id", "") + if not app_id: + response_data["error"] = "Missing app_id" + return + + manager = MCPAppsManager.get_instance() + manager.remove_app(app_id) + response_data["ok"] = True diff --git a/plugins/mcp_apps/extensions/webui/get_message_handler/mcp_app_handler.js b/plugins/mcp_apps/extensions/webui/get_message_handler/mcp_app_handler.js new file mode 100644 index 0000000..944cccc --- /dev/null +++ b/plugins/mcp_apps/extensions/webui/get_message_handler/mcp_app_handler.js @@ -0,0 +1,41 @@ +/** + * JS extension for get_message_handler โ€” registers the mcp_app message type handler. + * Renders a compact APP process step. The iframe is injected separately + * by the set_messages_after_loop extension once all messages are in the DOM. + */ +import { drawProcessStep } from "/js/messages.js"; + +export default async function(extData) { + if (extData.type !== "mcp_app") return; + + extData.handler = drawMessageMcpApp; +} + +function drawMessageMcpApp({ id, type, heading, content, kvps, timestamp, agentno = 0, ...additional }) { + const toolName = kvps?.tool_name || "MCP App"; + const serverName = kvps?.server_name || ""; + const resourceUri = kvps?.resource_uri || ""; + + const cleanTitle = heading + ? heading.replace(/^icon:\/\/\S+\s*/, "") + : `MCP App: ${toolName}`; + + const result = drawProcessStep({ + id, + title: cleanTitle, + code: "APP", + classes: ["mcp-app-step"], + kvps: { server: serverName, tool: toolName }, + content: resourceUri, + actionButtons: [], + log: { id, type, heading, content, kvps, timestamp, agentno, ...additional }, + allowCompletedGroup: true, + }); + + // Store kvps on the step element so the after-loop extension can find it + if (result.step) { + result.step.setAttribute("data-mcp-app-kvps-json", JSON.stringify(kvps || {})); + } + + return result; +} diff --git a/plugins/mcp_apps/extensions/webui/initFw_end/mcp_apps_init.js b/plugins/mcp_apps/extensions/webui/initFw_end/mcp_apps_init.js new file mode 100644 index 0000000..aaca2eb --- /dev/null +++ b/plugins/mcp_apps/extensions/webui/initFw_end/mcp_apps_init.js @@ -0,0 +1,5 @@ +import { store } from "/usr/plugins/mcp_apps/webui/mcp-app-store.js"; + +export default async function mcpAppsInit(ctx) { + // Import is enough to register the Alpine store +} diff --git a/plugins/mcp_apps/extensions/webui/set_messages_after_loop/mcp_app_inject.js b/plugins/mcp_apps/extensions/webui/set_messages_after_loop/mcp_app_inject.js new file mode 100644 index 0000000..c2da98a --- /dev/null +++ b/plugins/mcp_apps/extensions/webui/set_messages_after_loop/mcp_app_inject.js @@ -0,0 +1,72 @@ +/** + * set_messages_after_loop extension โ€” injects MCP App iframes into + * the .process-group-response container, above the response .message div. + * + * Runs after ALL messages are rendered, so the DOM is stable and + * .process-group-response is guaranteed to exist (if a response was sent). + */ + +export default async function(context) { + // Find all MCP App steps that have stored kvps + const appSteps = document.querySelectorAll(".mcp-app-step[data-mcp-app-kvps-json]"); + + for (const step of appSteps) { + const stepId = step.getAttribute("data-step-id"); + const frameId = `mcp-app-frame-${stepId}`; + + // Already injected + if (document.getElementById(frameId)) continue; + + // Find the process group this step belongs to + const processGroup = step.closest(".process-group"); + if (!processGroup) continue; + + // Find the response container in this process group + const responseContainer = processGroup.querySelector(".process-group-response"); + if (!responseContainer) continue; + + // Parse the stored kvps + let kvps; + try { + kvps = JSON.parse(step.getAttribute("data-mcp-app-kvps-json")); + } catch (e) { + continue; + } + + // Find the .message.message-agent-response div inside the response container + const messageDiv = responseContainer.querySelector(".message.message-agent-response"); + if (!messageDiv) continue; + + // Create the iframe container and prepend it inside the message div (before .message-body) + const frameContainer = document.createElement("div"); + frameContainer.id = frameId; + frameContainer.className = "mcp-app-frame-container"; + frameContainer.style.cssText = "margin-bottom: 12px;"; + frameContainer.setAttribute("data-mcp-app-kvps", ""); + frameContainer.__mcp_app_kvps = kvps; + + messageDiv.prepend(frameContainer); + + // Load the renderer component + await loadRendererComponent(frameContainer, kvps); + } +} + +async function loadRendererComponent(mountEl, kvps) { + try { + const resp = await fetch("/usr/plugins/mcp_apps/webui/mcp-app-renderer.html"); + if (!resp.ok) { + mountEl.innerHTML = `
Failed to load MCP App renderer
`; + return; + } + const html = await resp.text(); + mountEl.innerHTML = html; + + if (window.Alpine) { + window.Alpine.initTree(mountEl); + } + } catch (e) { + console.error("[mcp-apps] Failed to load renderer:", e); + mountEl.innerHTML = `
Error: ${e.message}
`; + } +} diff --git a/plugins/mcp_apps/helpers/__init__.py b/plugins/mcp_apps/helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/mcp_apps/helpers/mcp_apps_manager.py b/plugins/mcp_apps/helpers/mcp_apps_manager.py new file mode 100644 index 0000000..046144e --- /dev/null +++ b/plugins/mcp_apps/helpers/mcp_apps_manager.py @@ -0,0 +1,433 @@ +""" +MCP Apps Manager โ€” singleton that tracks UI-enabled tools, fetches UI resources, +manages active app sessions, and proxies tool calls from iframes back to MCP servers. + +Proxy calls from iframes use persistent MCP sessions to avoid the per-call overhead +of creating new connections and running the MCP handshake each time. +""" + +import asyncio +import threading +import uuid +from contextlib import AsyncExitStack +from datetime import timedelta +from typing import Any, Optional + +from helpers.print_style import PrintStyle + + +class _ActiveApp: + """Tracks a single active MCP App iframe instance.""" + + def __init__( + self, + app_id: str, + server_name: str, + tool_name: str, + resource_uri: str, + html_content: str, + tool_args: dict[str, Any], + tool_result: dict[str, Any] | None, + ui_meta: dict[str, Any], + tool_description: str = "", + tool_input_schema: dict[str, Any] | None = None, + ): + self.app_id = app_id + self.server_name = server_name + self.tool_name = tool_name + self.resource_uri = resource_uri + self.html_content = html_content + self.tool_args = tool_args + self.tool_result = tool_result + self.ui_meta = ui_meta + self.tool_description = tool_description + self.tool_input_schema = tool_input_schema or {"type": "object"} + + +class _ProxySession: + """Maintains a persistent MCP ClientSession in a dedicated background task. + + anyio cancel scopes (used by streamablehttp_client) require enter/exit from + the same asyncio Task. We satisfy this by running the entire session + lifecycle inside a single long-lived task and communicating via a queue. + """ + + def __init__(self): + self.session = None + self._queue: asyncio.Queue | None = None + self._task: asyncio.Task | None = None + self._ready = asyncio.Event() + self._open_error: BaseException | None = None + + async def open(self, server): + """Start the background task that owns the transport + session.""" + self._ready = asyncio.Event() + self._open_error = None + self._queue = asyncio.Queue() + self._task = asyncio.create_task(self._run(server)) + await self._ready.wait() + if self._open_error: + raise self._open_error + + async def _run(self, server): + """Background task โ€” owns the full async-context-manager stack.""" + from mcp import ClientSession + from helpers.mcp_handler import ( + MCPServerRemote, + MCPServerLocal, + _initialize_with_ui_ext, + _is_streaming_http_type, + CustomHTTPClientFactory, + ) + from helpers import settings + + try: + async with AsyncExitStack() as stack: + if isinstance(server, MCPServerRemote): + set_ = settings.get_settings() + init_timeout = server.init_timeout or set_["mcp_client_init_timeout"] or 5 + tool_timeout = server.tool_timeout or set_["mcp_client_tool_timeout"] or 60 + client_factory = CustomHTTPClientFactory(verify=server.verify) + + if _is_streaming_http_type(server.type): + from mcp.client.streamable_http import streamablehttp_client + + read_stream, write_stream, _ = await stack.enter_async_context( + streamablehttp_client( + url=server.url, + headers=server.headers, + timeout=timedelta(seconds=init_timeout), + sse_read_timeout=timedelta(seconds=tool_timeout), + httpx_client_factory=client_factory, + ) + ) + else: + from mcp.client.sse import sse_client + + read_stream, write_stream = await stack.enter_async_context( + sse_client( + url=server.url, + headers=server.headers, + timeout=init_timeout, + sse_read_timeout=tool_timeout, + httpx_client_factory=client_factory, + ) + ) + elif isinstance(server, MCPServerLocal): + from mcp import StdioServerParameters + from mcp.client.stdio import stdio_client + from shutil import which + + if not server.command or not which(server.command): + raise ValueError(f"Command '{server.command}' not found") + + params = StdioServerParameters( + command=server.command, + args=server.args, + env=server.env, + encoding=server.encoding, + encoding_error_handler=server.encoding_error_handler, + ) + read_stream, write_stream = await stack.enter_async_context( + stdio_client(params) + ) + else: + raise TypeError(f"Unsupported server type: {type(server)}") + + self.session = await stack.enter_async_context( + ClientSession( + read_stream, + write_stream, + read_timeout_seconds=timedelta(seconds=120), + ) + ) + await _initialize_with_ui_ext(self.session) + + # Signal that we are ready to accept requests + self._ready.set() + + # Process requests until a None sentinel arrives + while True: + item = await self._queue.get() + if item is None: + break + coro_factory, future = item + try: + result = await coro_factory(self.session) + if not future.done(): + future.set_result(result) + except Exception as exc: + if not future.done(): + future.set_exception(exc) + except Exception as exc: + # If we haven't signalled ready yet, store the error so open() can raise it + if not self._ready.is_set(): + self._open_error = exc + self._ready.set() + else: + PrintStyle(font_color="red", padding=True).print( + f"MCP Apps: Proxy session background task error: {exc}" + ) + finally: + self.session = None + + async def execute(self, coro_factory): + """Submit work to the background task and wait for the result.""" + loop = asyncio.get_running_loop() + future = loop.create_future() + await self._queue.put((coro_factory, future)) + return await future + + async def close(self): + """Signal the background task to shut down and wait for it.""" + self.session = None + if self._queue: + try: + await self._queue.put(None) + except Exception: + pass + if self._task and not self._task.done(): + try: + await asyncio.wait_for(self._task, timeout=5) + except (asyncio.TimeoutError, Exception): + self._task.cancel() + self._task = None + self._queue = None + + +class MCPAppsManager: + """Singleton managing MCP App lifecycle and communication.""" + + _instance: Optional["MCPAppsManager"] = None + _lock = threading.Lock() + + def __init__(self): + self._apps: dict[str, _ActiveApp] = {} + self._resource_cache: dict[str, str] = {} + self._proxy_sessions: dict[str, _ProxySession] = {} + + @classmethod + def get_instance(cls) -> "MCPAppsManager": + if cls._instance is None: + with cls._lock: + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def register_app( + self, + server_name: str, + tool_name: str, + resource_uri: str, + html_content: str, + tool_args: dict[str, Any], + tool_result: dict[str, Any] | None, + ui_meta: dict[str, Any], + tool_description: str = "", + tool_input_schema: dict[str, Any] | None = None, + ) -> str: + """Register a new active app instance. Returns the app_id.""" + app_id = str(uuid.uuid4()) + app = _ActiveApp( + app_id=app_id, + server_name=server_name, + tool_name=tool_name, + resource_uri=resource_uri, + html_content=html_content, + tool_args=tool_args, + tool_result=tool_result, + ui_meta=ui_meta, + tool_description=tool_description, + tool_input_schema=tool_input_schema, + ) + with self._lock: + self._apps[app_id] = app + PrintStyle(font_color="cyan", padding=True).print( + f"MCP Apps: Registered app '{app_id}' for tool '{server_name}.{tool_name}'" + ) + return app_id + + def get_app(self, app_id: str) -> _ActiveApp | None: + with self._lock: + return self._apps.get(app_id) + + def remove_app(self, app_id: str) -> None: + with self._lock: + self._apps.pop(app_id, None) + + def get_app_data(self, app_id: str) -> dict[str, Any] | None: + """Return serializable app data for the frontend.""" + app = self.get_app(app_id) + if not app: + return None + return { + "app_id": app.app_id, + "server_name": app.server_name, + "tool_name": app.tool_name, + "resource_uri": app.resource_uri, + "html_content": app.html_content, + "tool_args": app.tool_args, + "tool_result": app.tool_result, + "ui_meta": app.ui_meta, + "tool_description": app.tool_description, + "tool_input_schema": app.tool_input_schema, + } + + def cache_resource(self, uri: str, html: str) -> None: + with self._lock: + self._resource_cache[uri] = html + + def get_cached_resource(self, uri: str) -> str | None: + with self._lock: + return self._resource_cache.get(uri) + + @staticmethod + def _find_mcp_server(server_name: str, tool_name: str | None = None): + """Find an MCP server (and optionally verify it has a tool). + Returns the server reference so callers can invoke async methods + without holding MCPConfig's threading lock.""" + import helpers.mcp_handler as mcp_handler + + mcp_config = mcp_handler.MCPConfig.get_instance() + for server in mcp_config.servers: + if server.name == server_name: + if tool_name is None or server.has_tool(tool_name): + return server + return None + + async def fetch_ui_resource(self, server_name: str, resource_uri: str) -> str: + """Fetch a ui:// resource from an MCP server. Uses cache if available.""" + cached = self.get_cached_resource(resource_uri) + if cached: + return cached + + import helpers.mcp_handler as mcp_handler + + mcp_config = mcp_handler.MCPConfig.get_instance() + result = await mcp_config.read_resource(server_name, resource_uri) + + html_content = "" + for content in result.contents: + if hasattr(content, "text") and content.text: + html_content = content.text + break + elif hasattr(content, "blob") and content.blob: + import base64 + html_content = base64.b64decode(content.blob).decode("utf-8") + break + + if not html_content: + raise ValueError( + f"UI resource '{resource_uri}' from server '{server_name}' returned no content" + ) + + self.cache_resource(resource_uri, html_content) + return html_content + + async def _get_proxy_session(self, server_name: str) -> _ProxySession: + """Get or create a persistent proxy session for the given server.""" + ps = self._proxy_sessions.get(server_name) + if ps and ps.session is not None: + return ps + + # Create a new persistent session + server = self._find_mcp_server(server_name) + if not server: + raise ValueError(f"MCP server '{server_name}' not found") + + ps = _ProxySession() + await ps.open(server) + self._proxy_sessions[server_name] = ps + PrintStyle(font_color="cyan", padding=True).print( + f"MCP Apps: Opened persistent proxy session for '{server_name}'" + ) + return ps + + async def _close_proxy_session(self, server_name: str): + """Close and discard a persistent proxy session.""" + ps = self._proxy_sessions.pop(server_name, None) + if ps: + await ps.close() + PrintStyle(font_color="cyan", padding=True).print( + f"MCP Apps: Closed proxy session for '{server_name}'" + ) + + async def _proxy_with_retry(self, server_name: str, coro_factory): + """Run coro_factory(session) via the background task, with one retry on failure.""" + for attempt in range(2): + try: + ps = await self._get_proxy_session(server_name) + return await ps.execute(coro_factory) + except Exception: + if attempt == 0: + PrintStyle(font_color="yellow", padding=True).print( + f"MCP Apps: Proxy session error for '{server_name}', recreating..." + ) + await self._close_proxy_session(server_name) + else: + raise + + async def proxy_tool_call( + self, app_id: str, tool_name: str, arguments: dict[str, Any] + ) -> dict[str, Any]: + """Proxy a tools/call request from an iframe back to the MCP server.""" + app = self.get_app(app_id) + if not app: + return {"error": {"code": -32000, "message": f"App '{app_id}' not found"}} + + try: + from mcp.types import CallToolResult + + async def do_call(session): + return await session.call_tool(tool_name, arguments) + + result: CallToolResult = await self._proxy_with_retry(app.server_name, do_call) + content_list = [] + for item in result.content: + if item.type == "text": + content_list.append({"type": "text", "text": item.text}) + elif item.type == "image": + content_list.append({ + "type": "image", + "data": item.data, + "mimeType": item.mimeType, + }) + response = {"content": content_list, "isError": result.isError} + if hasattr(result, "structuredContent") and result.structuredContent: + response["structuredContent"] = result.structuredContent + return response + except Exception as e: + PrintStyle(font_color="red", padding=True).print( + f"MCP Apps: Proxy tool call failed for '{app.server_name}.{tool_name}': {e}" + ) + return {"error": {"code": -32000, "message": str(e)}} + + async def proxy_resource_read( + self, app_id: str, uri: str + ) -> dict[str, Any]: + """Proxy a resources/read request from an iframe back to the MCP server.""" + app = self.get_app(app_id) + if not app: + return {"error": {"code": -32000, "message": f"App '{app_id}' not found"}} + + try: + async def do_read(session): + return await session.read_resource(uri) + + result = await self._proxy_with_retry(app.server_name, do_read) + contents = [] + for c in result.contents: + entry: dict[str, Any] = {"uri": str(c.uri)} + if hasattr(c, "mimeType") and c.mimeType: + entry["mimeType"] = c.mimeType + if hasattr(c, "text") and c.text: + entry["text"] = c.text + elif hasattr(c, "blob") and c.blob: + entry["blob"] = c.blob + contents.append(entry) + return {"contents": contents} + except Exception as e: + PrintStyle(font_color="red", padding=True).print( + f"MCP Apps: Proxy resource read failed for '{uri}': {e}" + ) + return {"error": {"code": -32000, "message": str(e)}} diff --git a/plugins/mcp_apps/plugin.yaml b/plugins/mcp_apps/plugin.yaml new file mode 100644 index 0000000..8eef78d --- /dev/null +++ b/plugins/mcp_apps/plugin.yaml @@ -0,0 +1,8 @@ +name: mcp_apps +title: MCP Apps +description: Renders interactive UI applications from MCP servers in sandboxed iframes within chat messages. Implements the MCP Apps extension (SEP-1865). +version: 0.1.0 +settings_sections: [] +per_project_config: false +per_agent_config: false +always_enabled: false diff --git a/plugins/mcp_apps/test_mcp/__init__.py b/plugins/mcp_apps/test_mcp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/mcp_apps/test_mcp/mcp_elicitation_test_server.py b/plugins/mcp_apps/test_mcp/mcp_elicitation_test_server.py new file mode 100644 index 0000000..c6b7f27 --- /dev/null +++ b/plugins/mcp_apps/test_mcp/mcp_elicitation_test_server.py @@ -0,0 +1,402 @@ +#!/usr/bin/env python3 +""" +Simple MCP server for end-to-end testing of the elicitation feature. + +Usage: + Start the server: + python tests/mcp_elicitation_test_server.py + + Add to Agent Zero MCP config as: + { + "name": "elicitation-test", + "type": "streamable-http", + "url": "http://localhost:8100/mcp" + } + +Tools provided: + - greet_user: Elicits user's name and greeting style, returns a personalized greeting. + - create_task: Elicits task details (title, priority, description), returns summary. + - confirm_action: Elicits a yes/no confirmation before proceeding. + - simple_echo: No elicitation, just echoes input (control test). +""" + +import json +import time +from enum import Enum +from typing import Optional + +from fastmcp import FastMCP, Context +from fastmcp.server.elicitation import AcceptedElicitation +from mcp.types import TextContent, SamplingMessage +from pydantic import BaseModel, Field + + +mcp = FastMCP( + name="elicitation-test", + instructions="A test server for MCP elicitation. Use the tools to test human-in-the-loop input gathering.", +) + + +# --- Elicitation response models --- + +class GreetingInfo(BaseModel): + name: str = Field(description="Your name") + style: str = Field(description="Greeting style: formal, casual, or pirate") + + +class Priority(str, Enum): + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" + + +class TaskInfo(BaseModel): + title: str = Field(description="Task title") + priority: Priority = Field(default=Priority.MEDIUM, description="Task priority level") + description: str = Field(default="", description="Optional task description") + + +class Confirmation(BaseModel): + confirmed: bool = Field(description="Do you want to proceed?") + + +# --- Tools --- + +@mcp.tool() +async def greet_user(ctx: Context, reason: str = "general") -> str: + """Generate a personalized greeting. Will ask for the user's name and preferred greeting style. + + Args: + reason: Why the greeting is being generated (e.g. 'welcome', 'farewell', 'general'). + """ + result = await ctx.elicit( + message="I'd like to greet you! Please provide your name and preferred greeting style.", + response_type=GreetingInfo, + ) + + if isinstance(result, AcceptedElicitation): + name = result.data.name + style = result.data.style.lower() + if style == "formal": + return f"Good day, {name}. It is a pleasure to make your acquaintance." + elif style == "pirate": + return f"Ahoy, {name}! Welcome aboard, ye scallywag!" + else: + return f"Hey {name}! What's up?" + else: + return f"Greeting cancelled (action: {result.action})." + + +@mcp.tool() +async def create_task(ctx: Context, project: str = "default") -> str: + """Create a new task. Will ask for task details via elicitation. + + Args: + project: The project to create the task in. + """ + result = await ctx.elicit( + message=f"Please provide details for the new task in project '{project}'.", + response_type=TaskInfo, + ) + + if isinstance(result, AcceptedElicitation): + task = result.data + return ( + f"Task created in '{project}':\n" + f" Title: {task.title}\n" + f" Priority: {task.priority.value}\n" + f" Description: {task.description or '(none)'}" + ) + else: + return f"Task creation cancelled (action: {result.action})." + + +@mcp.tool() +async def confirm_action(action_description: str, ctx: Context) -> str: + """Ask for user confirmation before performing an action. + + Args: + action_description: Description of the action that needs confirmation. + """ + result = await ctx.elicit( + message=f"Please confirm: {action_description}", + response_type=Confirmation, + ) + + if isinstance(result, AcceptedElicitation): + if result.data.confirmed: + return f"Action confirmed: {action_description}. Proceeding." + else: + return f"User explicitly declined via the form for: {action_description}." + else: + return f"Confirmation cancelled (action: {result.action})." + + +@mcp.tool() +async def simple_echo(message: str) -> str: + """Echo the input message back. No elicitation involved (control test). + + Args: + message: The message to echo. + """ + return f"Echo: {message}" + + +# --- Sampling tools --- + +@mcp.tool() +async def summarize_text(ctx: Context, text: str) -> str: + """Summarize a piece of text using the client's LLM via MCP sampling. + + Args: + text: The text to summarize. + """ + result = await ctx.sample( + messages=[ + SamplingMessage( + role="user", + content=TextContent(type="text", text=f"Please summarize the following text in 2-3 sentences:\n\n{text}"), + ) + ], + system_prompt="You are a concise summarizer. Respond only with the summary.", + max_tokens=256, + temperature=0.3, + ) + return f"Summary: {result.text}" + + +@mcp.tool() +async def analyze_sentiment(ctx: Context, text: str) -> str: + """Analyze the sentiment of text using the client's LLM via MCP sampling. + + Args: + text: The text to analyze. + """ + result = await ctx.sample( + messages=[ + SamplingMessage( + role="user", + content=TextContent(type="text", text=f"Analyze the sentiment of this text and respond with one word (positive, negative, or neutral) followed by a brief explanation:\n\n{text}"), + ) + ], + system_prompt="You are a sentiment analysis expert. Be concise.", + max_tokens=128, + temperature=0.0, + ) + return f"Sentiment analysis: {result.text}" + + +# --- MCP Apps tools --- + +DASHBOARD_HTML = """ + + + +Server Dashboard + + + +

๐Ÿ“Š Server Dashboard

+
+
+

Server Time

+
Loading...
+
Last updated
+
+
+

Status

+
โ—
+
Checking...
+
+
+

Uptime

+
--
+
Hours
+
+
+

Requests

+
--
+
Total served
+
+
+ +
+ + + +""" + +_server_start = time.time() +_request_count = 0 + + +@mcp.resource( + "ui://elicitation-test/dashboard", + name="Server Dashboard", + description="Interactive server monitoring dashboard", + mime_type="text/html", +) +def get_dashboard_html() -> str: + """Serve the dashboard HTML for the MCP App.""" + return DASHBOARD_HTML + + +@mcp.tool( + meta={ + "ui": { + "resourceUri": "ui://elicitation-test/dashboard", + "visibility": ["model", "app"], + } + } +) +async def show_dashboard(ctx: Context, title: str = "Server Dashboard") -> str: + """Show an interactive server monitoring dashboard. Returns live server statistics. + + This tool demonstrates MCP Apps โ€” it renders an interactive UI in the host. + """ + global _request_count + _request_count += 1 + uptime = (time.time() - _server_start) / 3600 + data = { + "time": time.strftime("%Y-%m-%d %H:%M:%S"), + "healthy": True, + "uptime_hours": round(uptime, 2), + "total_requests": _request_count, + } + return json.dumps(data) + + +@mcp.tool( + meta={ + "ui": { + "resourceUri": "ui://elicitation-test/dashboard", + "visibility": ["app"], + } + } +) +async def get_server_stats(name: str = "Server") -> str: + """Get current server statistics. This is an app-only tool (hidden from model). + + Called by the dashboard UI's refresh button. + """ + global _request_count + _request_count += 1 + uptime = (time.time() - _server_start) / 3600 + data = { + "time": time.strftime("%Y-%m-%d %H:%M:%S"), + "healthy": True, + "uptime_hours": round(uptime, 2), + "total_requests": _request_count, + } + return json.dumps(data) + + +if __name__ == "__main__": + mcp.run(transport="streamable-http", host="0.0.0.0", port=8100) diff --git a/plugins/mcp_apps/webui/mcp-app-bridge.js b/plugins/mcp_apps/webui/mcp-app-bridge.js new file mode 100644 index 0000000..69ef4fd --- /dev/null +++ b/plugins/mcp_apps/webui/mcp-app-bridge.js @@ -0,0 +1,402 @@ +/** + * MCP App Bridge โ€” PostMessage JSON-RPC bridge between sandboxed iframes and the host. + * + * Implements the host side of the MCP Apps communication protocol (SEP-1865). + * Handles ui/initialize, tools/call, resources/read, notifications/message, + * and sends tool-input/tool-result notifications to the iframe. + */ + +const PROTOCOL_VERSION = "2025-06-18"; + +export class McpAppBridge { + /** + * @param {HTMLIFrameElement} iframe - The sandbox iframe element + * @param {object} options + * @param {string} options.appId - Unique app instance ID + * @param {string} options.serverName - MCP server name + * @param {string} options.toolName - Tool name (without server prefix) + * @param {object} options.toolArgs - Tool call arguments + * @param {object|null} options.toolResult - Tool call result + * @param {object} options.uiMeta - UI metadata from tool definition + * @param {Function} options.onMessage - Callback for ui/message requests + * @param {Function} options.onSizeChanged - Callback for size change notifications + * @param {Function} options.onTeardownRequest - Callback for teardown request + * @param {Function} options.wsEmit - WebSocket emit function for proxying + */ + constructor(iframe, options) { + this.iframe = iframe; + this.appId = options.appId; + this.serverName = options.serverName; + this.toolName = options.toolName; + this.toolArgs = options.toolArgs || {}; + this.toolResult = options.toolResult || null; + this.toolDescription = options.toolDescription || ""; + this.toolInputSchema = options.toolInputSchema || { type: "object" }; + this.uiMeta = options.uiMeta || {}; + this.onMessage = options.onMessage || (() => {}); + this.onSizeChanged = options.onSizeChanged || (() => {}); + this.onTeardownRequest = options.onTeardownRequest || (() => {}); + this.wsRequest = options.wsRequest; + + this._initialized = false; + this._pendingRequests = new Map(); + this._requestDebounce = new Map(); + this._nextHostId = 1; + this._messageHandler = this._handleMessage.bind(this); + + window.addEventListener("message", this._messageHandler); + } + + destroy() { + window.removeEventListener("message", this._messageHandler); + this._pendingRequests.clear(); + for (const entry of this._requestDebounce.values()) clearTimeout(entry.timer); + this._requestDebounce.clear(); + } + + /** + * Send a JSON-RPC notification to the iframe. + */ + _sendNotification(method, params) { + if (!this.iframe?.contentWindow) return; + this.iframe.contentWindow.postMessage( + { jsonrpc: "2.0", method, params }, + "*" + ); + } + + /** + * Send a JSON-RPC response to the iframe. + */ + _sendResponse(id, result) { + if (!this.iframe?.contentWindow) return; + this.iframe.contentWindow.postMessage( + { jsonrpc: "2.0", id, result }, + "*" + ); + } + + /** + * Send a JSON-RPC error response to the iframe. + */ + _sendError(id, code, message) { + if (!this.iframe?.contentWindow) return; + this.iframe.contentWindow.postMessage( + { jsonrpc: "2.0", id, error: { code, message } }, + "*" + ); + } + + /** + * Send tool input notification after initialization. + */ + _sendToolInput() { + this._sendNotification("ui/notifications/tool-input", { + arguments: this.toolArgs, + }); + } + + /** + * Send tool result notification. + */ + _sendToolResult() { + if (this.toolResult) { + this._sendNotification("ui/notifications/tool-result", this.toolResult); + } + } + + /** + * Handle incoming postMessage events from the iframe. + */ + _handleMessage(event) { + if (event.source !== this.iframe?.contentWindow) return; + + const data = event.data; + if (!data || data.jsonrpc !== "2.0") return; + + // It's a request (has id and method) + if (data.id !== undefined && data.method) { + this._handleRequest(data); + return; + } + + // It's a notification (has method but no id) + if (data.method && data.id === undefined) { + this._handleNotification(data); + return; + } + + // It's a response to a host-initiated request (has id but no method) + if (data.id !== undefined && !data.method) { + const pending = this._pendingRequests.get(data.id); + if (pending) { + this._pendingRequests.delete(data.id); + if (data.error) { + pending.reject(new Error(data.error.message || "Unknown error")); + } else { + pending.resolve(data.result); + } + } + } + } + + /** + * Handle JSON-RPC requests from the iframe. + * + * Uses a short debounce (per method) so that duplicate requests fired in + * rapid succession (e.g. from the MCP Apps SDK re-connecting) are coalesced + * into a single bridge operation (last-write-wins). + */ + _handleRequest(msg) { + const { method } = msg; + + const DEBOUNCE_METHODS = new Set([ + "ui/initialize", "tools/call", "resources/read", + ]); + + if (DEBOUNCE_METHODS.has(method)) { + const existing = this._requestDebounce.get(method); + if (existing) { + clearTimeout(existing.timer); + } + + this._requestDebounce.set(method, { + msg, + timer: setTimeout(() => { + this._requestDebounce.delete(method); + this._dispatchRequest(msg); + }, 15), + }); + return; + } + + this._dispatchRequest(msg); + } + + /** + * Dispatch a (possibly debounced) JSON-RPC request to its handler. + */ + async _dispatchRequest(msg) { + const { id, method, params } = msg; + + switch (method) { + case "ui/initialize": + this._handleInitialize(id, params); + break; + + case "tools/call": + await this._handleToolsCall(id, params); + break; + + case "resources/read": + await this._handleResourcesRead(id, params); + break; + + case "ui/open-link": + this._handleOpenLink(id, params); + break; + + case "ui/message": + this._handleUiMessage(id, params); + break; + + case "ui/update-model-context": + this._sendResponse(id, {}); + break; + + case "ui/request-display-mode": + this._sendResponse(id, { mode: "inline" }); + break; + + case "ping": + this._sendResponse(id, {}); + break; + + default: + this._sendError(id, -32601, `Method not found: ${method}`); + } + } + + /** + * Handle JSON-RPC notifications from the iframe. + */ + _handleNotification(msg) { + const { method, params } = msg; + + switch (method) { + case "ui/notifications/initialized": + if (!this._initialized) { + this._initialized = true; + this._sendToolInput(); + this._sendToolResult(); + } + break; + + case "ui/notifications/size-changed": + if (params) { + this.onSizeChanged(params); + } + break; + + case "ui/notifications/request-teardown": + this.onTeardownRequest(); + break; + + case "notifications/cancelled": + // Advisory per MCP spec โ€” acknowledged but no action needed. + break; + + case "notifications/message": + // Log message from app โ€” just consume silently + break; + } + } + + /** + * Handle ui/initialize request. + */ + _handleInitialize(id, params) { + // Each ui/initialize starts a fresh session โ€” reset so that + // tool-input / tool-result are re-sent after the next initialized notification. + this._initialized = false; + + const toolInfo = { + tool: { + name: this.toolName, + description: this.toolDescription || "", + inputSchema: this.toolInputSchema || { type: "object" }, + }, + }; + + const hostCapabilities = { + serverTools: { listChanged: false }, + serverResources: { listChanged: false }, + logging: {}, + }; + + const hostContext = { + toolInfo, + theme: document.documentElement.classList.contains("dark") ? "dark" : "light", + displayMode: "inline", + availableDisplayModes: ["inline"], + platform: "web", + }; + + this._sendResponse(id, { + protocolVersion: PROTOCOL_VERSION, + hostCapabilities, + hostInfo: { name: "agent-zero", version: "1.0.0" }, + hostContext, + }); + } + + /** + * Proxy tools/call to the backend via WebSocket. + */ + async _handleToolsCall(id, params) { + if (!params?.name) { + this._sendError(id, -32602, "Missing tool name"); + return; + } + + try { + const response = await this.wsRequest("mcp_app_tool_call", { + app_id: this.appId, + tool_name: params.name, + arguments: params.arguments || {}, + }); + + const first = response && Array.isArray(response.results) ? response.results[0] : null; + const result = first?.data; + + if (result?.error) { + this._sendError(id, result.error.code || -32000, result.error.message); + } else { + this._sendResponse(id, result || {}); + } + } catch (e) { + this._sendError(id, -32000, e.message || "Tool call failed"); + } + } + + /** + * Proxy resources/read to the backend via WebSocket. + */ + async _handleResourcesRead(id, params) { + if (!params?.uri) { + this._sendError(id, -32602, "Missing resource URI"); + return; + } + + try { + const response = await this.wsRequest("mcp_app_resource_read", { + app_id: this.appId, + uri: params.uri, + }); + + const first = response && Array.isArray(response.results) ? response.results[0] : null; + const result = first?.data; + + if (result?.error) { + this._sendError(id, result.error.code || -32000, result.error.message); + } else { + this._sendResponse(id, result || {}); + } + } catch (e) { + this._sendError(id, -32000, e.message || "Resource read failed"); + } + } + + /** + * Handle ui/open-link โ€” open URL in new tab. + */ + _handleOpenLink(id, params) { + if (params?.url) { + window.open(params.url, "_blank", "noopener,noreferrer"); + this._sendResponse(id, {}); + } else { + this._sendError(id, -32602, "Missing URL"); + } + } + + /** + * Handle ui/message โ€” forward to host chat. + */ + _handleUiMessage(id, params) { + this.onMessage(params); + this._sendResponse(id, {}); + } + + /** + * Send host context change notification to the iframe. + */ + sendHostContextChanged(context) { + this._sendNotification("ui/notifications/host-context-changed", context); + } + + /** + * Initiate graceful teardown of the app. + */ + async teardown(reason = "host") { + const id = this._nextHostId++; + return new Promise((resolve) => { + this._pendingRequests.set(id, { + resolve: () => resolve(true), + reject: () => resolve(false), + }); + if (this.iframe?.contentWindow) { + this.iframe.contentWindow.postMessage( + { jsonrpc: "2.0", id, method: "ui/resource-teardown", params: { reason } }, + "*" + ); + } + // Timeout: don't wait forever + setTimeout(() => { + if (this._pendingRequests.has(id)) { + this._pendingRequests.delete(id); + resolve(false); + } + }, 3000); + }); + } +} diff --git a/plugins/mcp_apps/webui/mcp-app-renderer.html b/plugins/mcp_apps/webui/mcp-app-renderer.html new file mode 100644 index 0000000..fce9b37 --- /dev/null +++ b/plugins/mcp_apps/webui/mcp-app-renderer.html @@ -0,0 +1,115 @@ + +
+ + + + + + + +
+
+ +
+
+ +
+
+
+ + diff --git a/plugins/mcp_apps/webui/mcp-app-sandbox.html b/plugins/mcp_apps/webui/mcp-app-sandbox.html new file mode 100644 index 0000000..03da51b --- /dev/null +++ b/plugins/mcp_apps/webui/mcp-app-sandbox.html @@ -0,0 +1,149 @@ + + + + +MCP App Sandbox + + + + + + diff --git a/plugins/mcp_apps/webui/mcp-app-store.js b/plugins/mcp_apps/webui/mcp-app-store.js new file mode 100644 index 0000000..09c8f30 --- /dev/null +++ b/plugins/mcp_apps/webui/mcp-app-store.js @@ -0,0 +1,134 @@ +/** + * MCP Apps Alpine.js store โ€” manages active app instances and their iframe bridges. + */ +import { createStore } from "/js/AlpineStore.js"; +import { getNamespacedClient } from "/js/websocket.js"; +import { McpAppBridge } from "/usr/plugins/mcp_apps/webui/mcp-app-bridge.js"; + +const stateSocket = getNamespacedClient("/ws"); + +export const store = createStore("mcpApps", { + /** @type {Map} */ + _bridges: new Map(), + + initialized: false, + + async init() { + if (this.initialized) return; + this.initialized = true; + }, + + /** + * Initialize an app iframe with its bridge. + * Called from the mcp_app message renderer when the DOM element is ready. + * + * @param {string} appId + * @param {HTMLIFrameElement} sandboxIframe - The outer sandbox iframe + * @param {object} appData - App data from the backend + */ + async setupApp(appId, sandboxIframe, appData) { + if (this._bridges.has(appId)) return; + + const bridge = new McpAppBridge(sandboxIframe, { + appId: appData.app_id, + serverName: appData.server_name, + toolName: appData.tool_name, + toolArgs: appData.tool_args || {}, + toolResult: appData.tool_result || null, + toolDescription: appData.tool_description || "", + toolInputSchema: appData.tool_input_schema || { type: "object" }, + uiMeta: appData.ui_meta || {}, + onMessage: (params) => { + console.log("[mcp-apps] ui/message from app:", params); + }, + onSizeChanged: (params) => { + // Only auto-size height; width is controlled by the layout container. + // Applying the app's reported width causes an infinite resize loop. + // Add buffer (+24px) and hysteresis (ignore deltas โ‰ค20px) to prevent + // resize feedback loops between the app's ResizeObserver and the iframe. + if (params.height != null) { + const target = Math.min(params.height + 24, 800); + const current = parseFloat(sandboxIframe.style.height) || 400; + if (Math.abs(target - current) > 20) { + sandboxIframe.style.height = `${target}px`; + } + } + }, + onTeardownRequest: () => { + this.teardownApp(appId); + }, + wsRequest: (event, data) => stateSocket.request(event, data), + }); + + this._bridges.set(appId, bridge); + + // Wait for sandbox proxy ready, then send the HTML resource + const onSandboxReady = (event) => { + if (event.source !== sandboxIframe.contentWindow) return; + const data = event.data; + if (!data || data.method !== "ui/notifications/sandbox-proxy-ready") return; + + window.removeEventListener("message", onSandboxReady); + + sandboxIframe.contentWindow.postMessage({ + jsonrpc: "2.0", + method: "ui/notifications/sandbox-resource-ready", + params: { + html: appData.html_content, + csp: appData.ui_meta?.csp || null, + permissions: appData.ui_meta?.permissions || null, + }, + }, "*"); + }; + + window.addEventListener("message", onSandboxReady); + }, + + /** + * Fetch app data from the backend for a given app_id. + * @param {string} appId + * @returns {Promise} + */ + async fetchAppData(appId) { + try { + const response = await stateSocket.request("mcp_app_get_data", { app_id: appId }); + const first = response && Array.isArray(response.results) ? response.results[0] : null; + if (!first || first.ok !== true || !first.data) { + const errMsg = first?.data?.error || first?.error?.message || "No data returned"; + console.error("[mcp-apps] fetchAppData error:", errMsg); + return { error: errMsg }; + } + return first.data; + } catch (e) { + console.error("[mcp-apps] fetchAppData failed:", e); + return null; + } + }, + + /** + * Tear down an app and clean up its bridge. + * @param {string} appId + */ + async teardownApp(appId) { + const bridge = this._bridges.get(appId); + if (bridge) { + bridge.destroy(); + this._bridges.delete(appId); + } + + try { + await stateSocket.request("mcp_app_teardown", { app_id: appId }); + } catch (e) { + console.warn("[mcp-apps] teardown notify failed:", e); + } + }, + + /** + * Check if an app bridge exists. + * @param {string} appId + * @returns {boolean} + */ + hasApp(appId) { + return this._bridges.has(appId); + }, +});