Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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}"
)
103 changes: 103 additions & 0 deletions plugins/mcp_apps/extensions/python/webui_ws_event/_22_mcp_apps.py
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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;
}
5 changes: 5 additions & 0 deletions plugins/mcp_apps/extensions/webui/initFw_end/mcp_apps_init.js
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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 = `<div style="color: var(--color-error, #c00); padding: 8px;">Failed to load MCP App renderer</div>`;
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 = `<div style="color: var(--color-error, #c00); padding: 8px;">Error: ${e.message}</div>`;
}
}
Empty file.
Loading
Loading