Skip to content
Closed
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
Expand Up @@ -28,6 +28,10 @@ interface MockBridge {
onrequestdisplaymode?: (params: {
mode: "inline" | "fullscreen" | "pip";
}) => Promise<{ mode: "inline" | "fullscreen" | "pip" }>;
onmessage?: (params: {
role: "user";
content: unknown[];
}) => Promise<Record<string, unknown>>;
/** Test helper: dispatch a bridge event (e.g. "initialized") to listeners. */
emit: (event: string, payload?: unknown) => void;
}
Expand Down Expand Up @@ -321,6 +325,75 @@ describe("AppRenderer", () => {
).resolves.toEqual({ mode: "inline" });
});

it("forwards MCP log notifications to onLog", async () => {
const bridge = createMockBridge();
const onLog = vi.fn();
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={() => asBridge(bridge)}
onLog={onLog}
/>,
);
await flushAsync();
await act(async () => {
bridge.emit("loggingmessage", { level: "warning", data: "disk full" });
});
expect(onLog).toHaveBeenCalledWith({ level: "warning", data: "disk full" });
});

it("does not throw on a log notification when no onLog is provided", async () => {
const bridge = createMockBridge();
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
await act(async () => {
bridge.emit("loggingmessage", { level: "info", data: "hi" });
});
expect(screen.getByTitle("Cohort App")).toBeInTheDocument();
});

it("routes ui/message to onMessage and returns the spec-required empty result", async () => {
const bridge = createMockBridge();
const onMessage = vi.fn();
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={() => asBridge(bridge)}
onMessage={onMessage}
/>,
);
await flushAsync();
const params = {
role: "user" as const,
content: [{ type: "text", text: "hello host" }],
};
await expect(bridge.onmessage?.(params)).resolves.toEqual({});
expect(onMessage).toHaveBeenCalledWith(params);
});

it("declines ui/message with isError when no onMessage handler is provided", async () => {
const bridge = createMockBridge();
renderWithMantine(
<AppRenderer
sandboxPath="/sandbox.html"
tool={tool}
bridgeFactory={() => asBridge(bridge)}
/>,
);
await flushAsync();
await expect(
bridge.onmessage?.({ role: "user", content: [] }),
).resolves.toEqual({ isError: true });
});

it("pushes a displayMode change to the running view via host-context-changed", async () => {
const bridge = createMockBridge();
// Stable factory identity so the rerender reuses the live bridge instead of
Expand Down
38 changes: 37 additions & 1 deletion clients/web/src/components/elements/AppRenderer/AppRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,13 @@ import type {
AppBridge,
AppBridgeEventMap,
McpUiDisplayMode,
McpUiMessageRequest,
} from "@modelcontextprotocol/ext-apps/app-bridge";
import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
import type {
CallToolResult,
LoggingMessageNotification,
Tool,
} from "@modelcontextprotocol/sdk/types.js";
import {
currentStyles,
currentTheme,
Expand Down Expand Up @@ -62,6 +67,17 @@ export interface AppRendererProps {
* by returning its current one.
*/
onRequestDisplayMode?: (requested: McpUiDisplayMode) => McpUiDisplayMode;
/**
* Called when the running view submits a user-role message via
* `ui/message`. The renderer returns the spec-required empty result on
* the host's behalf, so the callback is fire-and-forget.
*/
onMessage?: (params: McpUiMessageRequest["params"]) => void;
/**
* Called for each MCP log notification (`notifications/message`) the
* running view emits. Backs the advertised `logging` host capability.
*/
onLog?: (params: LoggingMessageNotification["params"]) => void;
/**
* The host-controlled box the app renders within, used to derive
* `hostContext.containerDimensions`. This MUST be an element whose size is
Expand Down Expand Up @@ -120,6 +136,8 @@ export function AppRenderer({
onSizeChange,
displayMode,
onRequestDisplayMode,
onMessage,
onLog,
containerRef,
ref,
}: AppRendererProps) {
Expand All @@ -143,11 +161,15 @@ export function AppRenderer({
const onSizeChangeRef = useRef(onSizeChange);
const displayModeRef = useRef(displayMode);
const onRequestDisplayModeRef = useRef(onRequestDisplayMode);
const onMessageRef = useRef(onMessage);
const onLogRef = useRef(onLog);
useEffect(() => {
onErrorRef.current = onError;
onSizeChangeRef.current = onSizeChange;
displayModeRef.current = displayMode;
onRequestDisplayModeRef.current = onRequestDisplayMode;
onMessageRef.current = onMessage;
onLogRef.current = onLog;
});

// Flush buffered tool input/result to the view, but only once the bridge
Expand Down Expand Up @@ -277,6 +299,11 @@ export function AppRenderer({
bridge.addEventListener("sizechange", (size) => {
onSizeChangeRef.current?.(size);
});
// Forward the view's MCP log notifications so the host can honor the
// advertised `logging` capability instead of dropping them.
bridge.addEventListener("loggingmessage", (params) => {
onLogRef.current?.(params);
});
// Handle ui/request-display-mode: let the host (AppsScreen) decide what
// mode to actually apply and return that. With no handler the request is
// declined by returning the current host-side mode.
Expand All @@ -287,6 +314,15 @@ export function AppRenderer({
: (displayModeRef.current ?? "inline");
return { mode: applied };
};
// Handle ui/message: surface the submitted content and return the
// spec-required empty result. With no handler the submission is
// declined by returning isError.
bridge.onmessage = async (params) => {
const handler = onMessageRef.current;
if (!handler) return { isError: true };
handler(params);
return {};
};
flushPending();
})
.catch((err) => {
Expand Down
140 changes: 139 additions & 1 deletion clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { act } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type { McpUiDisplayMode } from "@modelcontextprotocol/ext-apps/app-bridge";
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
import type { ContentBlock, Tool } from "@modelcontextprotocol/sdk/types.js";
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
import {
renderWithMantine,
Expand Down Expand Up @@ -119,6 +119,26 @@ async function requestDisplayMode(
return result;
}

// Invoke the onmessage handler the screen attached to the latest bridge,
// mimicking a ui/message request from the running view.
async function sendUiMessage(
bridges: AppBridge[],
content: ContentBlock[],
): Promise<Record<string, unknown>> {
const onmessage = bridges.at(-1)?.onmessage as unknown as
| ((params: {
role: "user";
content: ContentBlock[];
}) => Promise<Record<string, unknown>>)
| undefined;
if (!onmessage) throw new Error("no onmessage handler attached");
let result: Record<string, unknown> = {};
await act(async () => {
result = await onmessage({ role: "user", content });
});
return result;
}

function buildProps(overrides: Partial<AppsScreenProps> = {}): AppsScreenProps {
return {
tools: [fieldedApp, noFieldsApp, cohortApp] as Tool[],
Expand Down Expand Up @@ -416,6 +436,124 @@ describe("AppsScreen", () => {
expect(screen.getByText("MCP Apps (3)")).toBeInTheDocument();
});

it("surfaces ui/message content from the view in the message log", async () => {
const user = userEvent.setup();
const { factory, bridges } = createEventBridgeFactory();
renderWithMantine(<ControlledAppsScreen bridgeFactory={factory} />);
await user.click(screen.getByText("Ops Dashboard"));
await vi.waitFor(() =>
expect(bridges.at(-1)?.onmessage).toBeTypeOf("function"),
);
await sendUiMessage(bridges, [
{ type: "text", text: "hello from the app" },
]);
expect(screen.getByText(/Messages from app \(1\)/)).toBeInTheDocument();
expect(screen.getByText(/hello from the app/)).toBeInTheDocument();
expect(screen.getByTestId("apps-messages")).toBeInTheDocument();
});

it("returns an empty ui/message result (no conversation content leak)", async () => {
const user = userEvent.setup();
const { factory, bridges } = createEventBridgeFactory();
renderWithMantine(<ControlledAppsScreen bridgeFactory={factory} />);
await user.click(screen.getByText("Ops Dashboard"));
await vi.waitFor(() =>
expect(bridges.at(-1)?.onmessage).toBeTypeOf("function"),
);
const result = await sendUiMessage(bridges, [
{ type: "text", text: "secret" },
]);
expect(result).toEqual({});
});

it("clears the message log when the app is closed", async () => {
const user = userEvent.setup();
const { factory, bridges } = createEventBridgeFactory();
renderWithMantine(<ControlledAppsScreen bridgeFactory={factory} />);
await user.click(screen.getByText("Ops Dashboard"));
await vi.waitFor(() =>
expect(bridges.at(-1)?.onmessage).toBeTypeOf("function"),
);
await sendUiMessage(bridges, [
{ type: "text", text: "hello from the app" },
]);
expect(screen.getByText(/Messages from app/)).toBeInTheDocument();
await user.click(screen.getByLabelText("Close"));
expect(screen.queryByText(/Messages from app/)).not.toBeInTheDocument();
});

it("surfaces app log notifications in a default-expanded collapsible panel with a working Clear", async () => {
const user = userEvent.setup();
const { factory, emit } = createEventBridgeFactory();
renderWithMantine(<ControlledAppsScreen bridgeFactory={factory} />);
await user.click(screen.getByText("Ops Dashboard"));
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(screen.queryByText(/App logs/)).not.toBeInTheDocument();
await act(async () => {
emit("loggingmessage", { level: "warning", data: "disk almost full" });
emit("loggingmessage", {
level: "error",
logger: "render",
data: { code: 500 },
});
});
const toggle = screen.getByRole("button", { name: /App logs \(2\)/ });
expect(toggle).toBeInTheDocument();
expect(toggle.getAttribute("aria-expanded")).toBe("true");
// Default-expanded: entries are visible without clicking the toggle.
expect(screen.getByText("disk almost full")).toBeInTheDocument();
expect(screen.getByText("render")).toBeInTheDocument();
expect(screen.getByText('{"code":500}')).toBeInTheDocument();
expect(screen.getByTestId("apps-logs")).toBeInTheDocument();
// Collapsing still works, and Clear empties the panel.
await user.click(toggle);
expect(toggle.getAttribute("aria-expanded")).toBe("false");
// The toggle points at the collapse region for assistive tech.
expect(toggle.getAttribute("aria-controls")).toBe("apps-logs-region");
await user.click(screen.getByRole("button", { name: "Clear" }));
expect(screen.queryByText(/App logs/)).not.toBeInTheDocument();
});

it("renders a log notification that carries no data without crashing", async () => {
const user = userEvent.setup();
const { factory, emit } = createEventBridgeFactory();
renderWithMantine(<ControlledAppsScreen bridgeFactory={factory} />);
await user.click(screen.getByText("Ops Dashboard"));
await act(async () => {
await Promise.resolve();
await Promise.resolve();
// No `data` field — formatLogData must coalesce to "" (not "undefined").
emit("loggingmessage", { level: "info" });
});
expect(
screen.getByRole("button", { name: /App logs \(1\)/ }),
).toBeInTheDocument();
});

it("caps retained app logs at the soft limit, dropping the oldest", async () => {
const user = userEvent.setup();
const { factory, emit } = createEventBridgeFactory();
renderWithMantine(<ControlledAppsScreen bridgeFactory={factory} />);
await user.click(screen.getByText("Ops Dashboard"));
await act(async () => {
await Promise.resolve();
await Promise.resolve();
// 501 entries exceeds the 500 cap → oldest dropped, count clamps at 500.
for (let i = 0; i < 501; i++) {
emit("loggingmessage", { level: "info", data: `log-${i}` });
}
});
expect(
screen.getByRole("button", { name: /App logs \(500\)/ }),
).toBeInTheDocument();
// The very first entry was dropped; the most recent is retained.
expect(screen.queryByText("log-0")).not.toBeInTheDocument();
expect(screen.getByText("log-500")).toBeInTheDocument();
});

it("calls onCloseApp and clears selection on Close", async () => {
const user = userEvent.setup();
const onCloseApp = vi.fn();
Expand Down
Loading
Loading