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...
+
+
+
+
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 @@
+
+
+
+
+
+
progress_activity
+
Loading MCP App...
+
+
+
+
+
+
+ error
+
+
+
+
+
+
+
+
+
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