Skip to content

Commit bd187d5

Browse files
committed
🤖 feat: MCP Apps views in the Artifacts tab
- MCP Apps host backend: advertise the ui extension, fetch `ui://` view resources, keep tool results in a bounded side store - MCP Apps views in the Artifacts tab (App views picker group) with a sandboxed frame, CSP and the host bridge - "Open in Artifacts" on MCP tool calls that carry a view - Storybook play test for an MCP Apps view - Codex review round 1 fixes: the stored result record is the authoritative view binding (getView returns invocation), failure snapshots keep resourceUri, records publish only after the atomic write, view tool calls honor the effective tool allowlist, optional resourceUri with independent visibility, consent shows an argument preview, Reload remounts the view, connections made under the other MCP Apps setting restart when idle, frame navigation guard on McpAppFrame - Codex review round 2 fixes: MCP App views fail closed outside the desktop app (no fetch, frame or host), failed or interrupted app calls record their invocation, oversize display snapshots drop only the app link, the picker stays above listing errors while views are open, the narrow layout opens views only in the dialog, the consent strip shows the full arguments and declines calls over 64 KiB --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `high` • Cost: `$117.29`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=high costs=117.29 -->
1 parent 45a22e8 commit bd187d5

38 files changed

Lines changed: 3419 additions & 68 deletions

‎src/browser/components/SelectPrimitive/SelectPrimitive.tsx‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,4 +116,18 @@ const SelectItem = React.forwardRef<
116116
));
117117
SelectItem.displayName = SelectPrimitive.Item.displayName;
118118

119-
export { Select, SelectValue, SelectTrigger, SelectContent, SelectItem };
119+
const SelectGroup = SelectPrimitive.Group;
120+
121+
const SelectLabel = React.forwardRef<
122+
React.ElementRef<typeof SelectPrimitive.Label>,
123+
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
124+
>(({ className, ...props }, ref) => (
125+
<SelectPrimitive.Label
126+
ref={ref}
127+
className={cn("text-muted px-2 py-1 text-[11px] font-medium", className)}
128+
{...props}
129+
/>
130+
));
131+
SelectLabel.displayName = SelectPrimitive.Label.displayName;
132+
133+
export { Select, SelectValue, SelectTrigger, SelectContent, SelectItem, SelectGroup, SelectLabel };

‎src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,23 @@ export const WorkspaceMenuBar: React.FC<WorkspaceMenuBarProps> = ({
299299
return () => window.removeEventListener("keydown", handler);
300300
}, [artifactsExperimentEnabled, isTimelineSidebarHidden, workspaceId]);
301301

302+
// "Open in Artifacts" on an MCP Apps tool card: while the sidebar is hidden, the dialog is
303+
// the only place the view can show.
304+
useEffect(() => {
305+
if (!artifactsExperimentEnabled) {
306+
return;
307+
}
308+
const handler = (event: Event) => {
309+
const detail = (event as CustomEvent<{ workspaceId: string }>).detail;
310+
if (detail?.workspaceId !== workspaceId || !isTimelineSidebarHidden()) {
311+
return;
312+
}
313+
setArtifactsDialogWorkspaceId(workspaceId);
314+
};
315+
window.addEventListener(CUSTOM_EVENTS.OPEN_MCP_APP_VIEW, handler);
316+
return () => window.removeEventListener(CUSTOM_EVENTS.OPEN_MCP_APP_VIEW, handler);
317+
}, [artifactsExperimentEnabled, isTimelineSidebarHidden, workspaceId]);
318+
302319
const isDevcontainerWorkspace = isDevcontainerRuntime(runtimeConfig);
303320
const isRuntimeRunning = isDevcontainerWorkspace && runtimeStatus === "running";
304321
// Mirrors TaskService.isLocalUnrelatedMessagingEndpoint: unrelated delivery requires local or

‎src/browser/features/RightSidebar/ArtifactsTab/ArtifactsPanel.stories.tsx‎

Lines changed: 176 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { useEffect, useRef, type ReactNode } from "react";
21
import type { Meta, StoryObj } from "@storybook/react-vite";
32
import { expect, userEvent, waitFor, within } from "@storybook/test";
3+
import { useEffect, useRef, type ReactNode } from "react";
44
import { APIProvider } from "@/browser/contexts/API";
55
import { updatePersistedState } from "@/browser/hooks/usePersistedState";
66
import { createMockORPCClient } from "@/browser/stories/mocks/orpc";
@@ -15,6 +15,7 @@ import { getArtifactKind } from "@/common/utils/artifactKind";
1515
import { ArtifactsPanel } from "./ArtifactsPanel";
1616
import { writeArtifactSelection } from "./artifactSelection";
1717
import { DESKTOP_ONLY_PREVIEW_NOTICE } from "./executableFrames";
18+
import { openMcpAppView } from "./mcpAppViewsStore";
1819

1920
/**
2021
* HTML/SVG frames mount only in the desktop app, detected by its preload bridge
@@ -520,3 +521,177 @@ export const BridgeEscapeExitsFullscreen: Story = {
520521
);
521522
},
522523
};
524+
525+
// MCP Apps view (artifacts experiment): a fake server view implementing the spec handshake.
526+
// The frame is opaque-origin, so the play test reads progress from the heights the view
527+
// reports: 321px only after initialize -> initialized -> tool-input -> tool-result, then
528+
// 333px once its own tools/call round trip (behind the consent strip) succeeded.
529+
const MCP_APP_TOOL_CALL_ID = "call-weather-1";
530+
const MCP_APP_VIEW_HTML = `<!doctype html>
531+
<html>
532+
<head>
533+
<style>
534+
body { font-family: system-ui, sans-serif; margin: 0; padding: 16px; color: #0f172a; }
535+
h1 { font-size: 16px; margin: 0 0 8px; }
536+
li { font-size: 13px; }
537+
</style>
538+
</head>
539+
<body>
540+
<h1>Weather view</h1>
541+
<ul id="log"></ul>
542+
<script>
543+
const seen = {};
544+
let nextId = 1;
545+
const pending = new Map();
546+
const log = (text) => {
547+
const li = document.createElement("li");
548+
li.textContent = text;
549+
document.getElementById("log").appendChild(li);
550+
};
551+
const send = (message) => window.parent.postMessage(message, "*");
552+
const request = (method, params) =>
553+
new Promise((resolve, reject) => {
554+
const id = nextId++;
555+
pending.set(id, { resolve, reject });
556+
send({ jsonrpc: "2.0", id, method, params });
557+
});
558+
const notify = (method, params) => send({ jsonrpc: "2.0", method, params });
559+
const maybeReady = () => {
560+
if (!(seen.init && seen.input && seen.result) || seen.sized) return;
561+
seen.sized = true;
562+
notify("ui/notifications/size-changed", { width: 400, height: 321 });
563+
request("tools/call", { name: "get_forecast", arguments: { city: "Berlin" } }).then(
564+
(result) => {
565+
log("Forecast: " + result.content[0].text);
566+
notify("ui/notifications/size-changed", { width: 400, height: 333 });
567+
},
568+
(error) => log("tools/call failed: " + error.message)
569+
);
570+
};
571+
window.addEventListener("message", (event) => {
572+
const message = event.data;
573+
if (message.id !== undefined && pending.has(message.id)) {
574+
const entry = pending.get(message.id);
575+
pending.delete(message.id);
576+
if (message.error) entry.reject(message.error);
577+
else entry.resolve(message.result);
578+
return;
579+
}
580+
if (message.method === "ui/resource-teardown") {
581+
send({ jsonrpc: "2.0", id: message.id, result: {} });
582+
} else if (message.method === "ui/notifications/tool-input") {
583+
seen.input = true;
584+
log("Input: " + JSON.stringify(message.params.arguments));
585+
} else if (message.method === "ui/notifications/tool-result") {
586+
seen.result = true;
587+
log("Result: " + message.params.content[0].text);
588+
}
589+
maybeReady();
590+
});
591+
request("ui/initialize", {
592+
protocolVersion: "2026-01-26",
593+
appInfo: { name: "weather-view", version: "1.0.0" },
594+
appCapabilities: {},
595+
}).then((result) => {
596+
seen.init = true;
597+
log("Host theme: " + result.hostContext.theme);
598+
notify("ui/notifications/initialized", {});
599+
maybeReady();
600+
});
601+
</script>
602+
</body>
603+
</html>`;
604+
605+
/**
606+
* MCP App views mount only in the desktop app (executableFrames.ts). Storybook has no preload
607+
* bridge, so these stories stand one in and restore the original afterwards.
608+
*/
609+
function DesktopApiStub(props: { children: ReactNode }) {
610+
const originalApiRef = useRef(window.api);
611+
window.api = {
612+
platform: "linux",
613+
versions: { node: "20.0.0", chrome: "120.0.0", electron: "28.0.0" },
614+
getIsRosetta: () => Promise.resolve(false),
615+
};
616+
useEffect(() => {
617+
const savedApi = originalApiRef.current;
618+
return () => {
619+
window.api = savedApi;
620+
};
621+
}, []);
622+
return <>{props.children}</>;
623+
}
624+
625+
function renderMcpAppView() {
626+
updatePersistedState(ARTIFACTS_ALLOW_CDN_SCRIPTS_KEY, true);
627+
openMcpAppView(WORKSPACE_ID, {
628+
toolCallId: MCP_APP_TOOL_CALL_ID,
629+
serverName: "weather",
630+
resourceUri: "ui://weather/view.html",
631+
toolName: "show_weather",
632+
label: "Show weather",
633+
arguments: { city: "Berlin" },
634+
cancelled: false,
635+
});
636+
return (
637+
<APIProvider
638+
client={createMockORPCClient({
639+
artifacts: { listing: listingFor(FILES), files: FILES },
640+
mcpApps: {
641+
views: {
642+
[MCP_APP_TOOL_CALL_ID]: {
643+
html: MCP_APP_VIEW_HTML,
644+
// jsdelivr is on the CDN allowlist; the tile host is not, so it is listed as
645+
// not granted.
646+
csp: { resourceDomains: ["https://cdn.jsdelivr.net", "https://tiles.example.com"] },
647+
prefersBorder: null,
648+
resultAvailable: true,
649+
result: { content: [{ type: "text", text: "Berlin: 18°C, light rain" }] },
650+
invocation: {
651+
serverName: "weather",
652+
toolName: "show_weather",
653+
arguments: { city: "Berlin" },
654+
},
655+
},
656+
},
657+
// get_forecast is visible to the model too: the first call needs the user's consent.
658+
callTool: (input) =>
659+
input.consented
660+
? { status: "ok", result: { content: [{ type: "text", text: "Sunny tomorrow" }] } }
661+
: { status: "consent_required" },
662+
},
663+
})}
664+
>
665+
<DesktopApiStub>
666+
<div className="bg-background flex h-screen justify-end">
667+
<div className="bg-sidebar border-border-light h-full w-full max-w-[440px] border-l">
668+
<ArtifactsPanel workspaceId={WORKSPACE_ID} />
669+
</div>
670+
</div>
671+
</DesktopApiStub>
672+
</APIProvider>
673+
);
674+
}
675+
676+
const playMcpAppView = async (canvasElement: HTMLElement) => {
677+
const canvas = within(canvasElement);
678+
const frame = await canvas.findByTestId("mcp-app-frame");
679+
await expect(frame.getAttribute("sandbox")).toBe("allow-scripts");
680+
await canvas.findByText(/Not granted to this view: https:\/\/tiles\.example\.com/);
681+
await waitFor(() => expect(frame.style.height).toBe("321px"), { timeout: 5000 });
682+
const strip = await canvas.findByRole("alert");
683+
await expect(strip.textContent).toContain("Allow get_forecast from weather?");
684+
await userEvent.click(within(strip).getByRole("button", { name: "Allow" }));
685+
await waitFor(() => expect(frame.style.height).toBe("333px"), { timeout: 5000 });
686+
};
687+
688+
export const McpAppViewLaptop: Story = {
689+
...LAPTOP,
690+
render: () => renderMcpAppView(),
691+
play: ({ canvasElement }) => playMcpAppView(canvasElement),
692+
};
693+
export const McpAppViewPhone: Story = {
694+
...PHONE,
695+
render: () => renderMcpAppView(),
696+
play: ({ canvasElement }) => playMcpAppView(canvasElement),
697+
};

‎src/browser/features/RightSidebar/ArtifactsTab/ArtifactsPanel.test.tsx‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
77
import type { ReactNode } from "react";
88
import { installDom } from "../../../../../tests/ui/dom";
99
import { APIProvider, type APIClient } from "@/browser/contexts/API";
10+
import { ThemeProvider } from "@/browser/contexts/ThemeContext";
1011
import type {
1112
ArtifactEntry,
1213
ArtifactListing,
@@ -18,6 +19,7 @@ import {
1819
} from "@/common/constants/storage";
1920
import { ArtifactsPanel } from "./ArtifactsPanel";
2021
import { readArtifactSelection, writeArtifactSelection } from "./artifactSelection";
22+
import { closeMcpAppView, openMcpAppView } from "./mcpAppViewsStore";
2123

2224
function entry(path: string, modifiedMs: number, kind: ArtifactEntry["kind"]): ArtifactEntry {
2325
return { path, kind, size: 10, modifiedMs };
@@ -74,6 +76,8 @@ describe("ArtifactsPanel", () => {
7476
beforeEach(() => {
7577
cleanupDom = installDom();
7678
window.localStorage.clear();
79+
// Desktop mode, so app views mount their frame (executableFrames.ts).
80+
window.api = { getIsRosetta: () => Promise.resolve(false) } as unknown as typeof window.api;
7781
});
7882

7983
afterEach(() => {
@@ -302,4 +306,93 @@ describe("ArtifactsPanel", () => {
302306
const view = renderPanel();
303307
expect(await view.findByText(/is too large to preview/)).toBeTruthy();
304308
});
309+
310+
test("Reload re-fetches a selected app view", async () => {
311+
fake = createFakeArtifactsApi(
312+
{ available: true, dir: "/scratch/artifacts", entries: [], truncated: false },
313+
{}
314+
);
315+
let getViewCalls = 0;
316+
fake.api.mcpApps = {
317+
getView: () => {
318+
getViewCalls += 1;
319+
return Promise.resolve({
320+
success: true as const,
321+
data: {
322+
html: "<p>view</p>",
323+
csp: {},
324+
prefersBorder: null,
325+
resultAvailable: false,
326+
result: null,
327+
invocation: null,
328+
},
329+
});
330+
},
331+
};
332+
openMcpAppView("ws-app-reload", {
333+
toolCallId: "call-1",
334+
serverName: "charts",
335+
resourceUri: "ui://charts/view",
336+
toolName: "show_chart",
337+
label: "Show chart",
338+
arguments: {},
339+
cancelled: false,
340+
});
341+
try {
342+
const view = render(<ArtifactsPanel workspaceId="ws-app-reload" />, {
343+
wrapper: (props: { children: ReactNode }) => (
344+
<ThemeProvider forcedTheme="dark">
345+
<ApiWrapper>{props.children}</ApiWrapper>
346+
</ThemeProvider>
347+
),
348+
});
349+
await view.findByTestId("mcp-app-frame");
350+
expect(getViewCalls).toBe(1);
351+
fireEvent.click(view.getByRole("button", { name: "Reload artifact" }));
352+
await waitFor(() => expect(getViewCalls).toBe(2));
353+
} finally {
354+
closeMcpAppView("ws-app-reload", "call-1");
355+
}
356+
});
357+
358+
test("the remaining app views stay reachable after closing one, without a listing", async () => {
359+
fake = createFakeArtifactsApi(
360+
{ available: true, dir: "/scratch/artifacts", entries: [], truncated: false },
361+
{}
362+
);
363+
fake.api.artifacts = {
364+
...fake.api.artifacts,
365+
list: () => Promise.resolve({ success: false as const, error: "Listing failed" }),
366+
};
367+
fake.api.mcpApps = {
368+
getView: () => Promise.resolve({ success: false as const, error: "No view" }),
369+
};
370+
const ref = (toolCallId: string, label: string) => ({
371+
toolCallId,
372+
serverName: "charts",
373+
resourceUri: "ui://charts/view",
374+
toolName: "show_chart",
375+
label,
376+
arguments: {},
377+
cancelled: false,
378+
});
379+
openMcpAppView("ws-app-close", ref("call-a", "First view"));
380+
openMcpAppView("ws-app-close", ref("call-b", "Second view"));
381+
try {
382+
const view = render(<ArtifactsPanel workspaceId="ws-app-close" />, {
383+
wrapper: (props: { children: ReactNode }) => (
384+
<ThemeProvider forcedTheme="dark">
385+
<ApiWrapper>{props.children}</ApiWrapper>
386+
</ThemeProvider>
387+
),
388+
});
389+
// The most recently opened view is selected; close it.
390+
fireEvent.click(await view.findByRole("button", { name: "Close view" }));
391+
expect(await view.findByText("Listing failed")).toBeTruthy();
392+
expect(view.getByRole("combobox", { name: "Artifact" })).toBeTruthy();
393+
} finally {
394+
closeMcpAppView("ws-app-close", "call-a");
395+
closeMcpAppView("ws-app-close", "call-b");
396+
}
397+
});
305398
});

0 commit comments

Comments
 (0)