Skip to content

Commit 6dfd5cb

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 --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `high` • Cost: `$107.80`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=high costs=107.80 -->
1 parent fc9236d commit 6dfd5cb

37 files changed

Lines changed: 3175 additions & 57 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: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
import { getArtifactKind } from "@/common/utils/artifactKind";
1414
import { ArtifactsPanel } from "./ArtifactsPanel";
1515
import { writeArtifactSelection } from "./artifactSelection";
16+
import { openMcpAppView } from "./mcpAppViewsStore";
1617

1718
const meta: Meta<typeof ArtifactsPanel> = {
1819
title: "Features/RightSidebar/ArtifactsPanel",
@@ -475,3 +476,155 @@ export const BridgeEscapeExitsFullscreen: Story = {
475476
);
476477
},
477478
};
479+
480+
// MCP Apps view (artifacts experiment): a fake server view implementing the spec handshake.
481+
// The frame is opaque-origin, so the play test reads progress from the heights the view
482+
// reports: 321px only after initialize -> initialized -> tool-input -> tool-result, then
483+
// 333px once its own tools/call round trip (behind the consent strip) succeeded.
484+
const MCP_APP_TOOL_CALL_ID = "call-weather-1";
485+
const MCP_APP_VIEW_HTML = `<!doctype html>
486+
<html>
487+
<head>
488+
<style>
489+
body { font-family: system-ui, sans-serif; margin: 0; padding: 16px; color: #0f172a; }
490+
h1 { font-size: 16px; margin: 0 0 8px; }
491+
li { font-size: 13px; }
492+
</style>
493+
</head>
494+
<body>
495+
<h1>Weather view</h1>
496+
<ul id="log"></ul>
497+
<script>
498+
const seen = {};
499+
let nextId = 1;
500+
const pending = new Map();
501+
const log = (text) => {
502+
const li = document.createElement("li");
503+
li.textContent = text;
504+
document.getElementById("log").appendChild(li);
505+
};
506+
const send = (message) => window.parent.postMessage(message, "*");
507+
const request = (method, params) =>
508+
new Promise((resolve, reject) => {
509+
const id = nextId++;
510+
pending.set(id, { resolve, reject });
511+
send({ jsonrpc: "2.0", id, method, params });
512+
});
513+
const notify = (method, params) => send({ jsonrpc: "2.0", method, params });
514+
const maybeReady = () => {
515+
if (!(seen.init && seen.input && seen.result) || seen.sized) return;
516+
seen.sized = true;
517+
notify("ui/notifications/size-changed", { width: 400, height: 321 });
518+
request("tools/call", { name: "get_forecast", arguments: { city: "Berlin" } }).then(
519+
(result) => {
520+
log("Forecast: " + result.content[0].text);
521+
notify("ui/notifications/size-changed", { width: 400, height: 333 });
522+
},
523+
(error) => log("tools/call failed: " + error.message)
524+
);
525+
};
526+
window.addEventListener("message", (event) => {
527+
const message = event.data;
528+
if (message.id !== undefined && pending.has(message.id)) {
529+
const entry = pending.get(message.id);
530+
pending.delete(message.id);
531+
if (message.error) entry.reject(message.error);
532+
else entry.resolve(message.result);
533+
return;
534+
}
535+
if (message.method === "ui/resource-teardown") {
536+
send({ jsonrpc: "2.0", id: message.id, result: {} });
537+
} else if (message.method === "ui/notifications/tool-input") {
538+
seen.input = true;
539+
log("Input: " + JSON.stringify(message.params.arguments));
540+
} else if (message.method === "ui/notifications/tool-result") {
541+
seen.result = true;
542+
log("Result: " + message.params.content[0].text);
543+
}
544+
maybeReady();
545+
});
546+
request("ui/initialize", {
547+
protocolVersion: "2026-01-26",
548+
appInfo: { name: "weather-view", version: "1.0.0" },
549+
appCapabilities: {},
550+
}).then((result) => {
551+
seen.init = true;
552+
log("Host theme: " + result.hostContext.theme);
553+
notify("ui/notifications/initialized", {});
554+
maybeReady();
555+
});
556+
</script>
557+
</body>
558+
</html>`;
559+
560+
function renderMcpAppView() {
561+
updatePersistedState(ARTIFACTS_ALLOW_CDN_SCRIPTS_KEY, true);
562+
openMcpAppView(WORKSPACE_ID, {
563+
toolCallId: MCP_APP_TOOL_CALL_ID,
564+
serverName: "weather",
565+
resourceUri: "ui://weather/view.html",
566+
toolName: "show_weather",
567+
label: "Show weather",
568+
arguments: { city: "Berlin" },
569+
cancelled: false,
570+
});
571+
return (
572+
<APIProvider
573+
client={createMockORPCClient({
574+
artifacts: { listing: listingFor(FILES), files: FILES },
575+
mcpApps: {
576+
views: {
577+
[MCP_APP_TOOL_CALL_ID]: {
578+
html: MCP_APP_VIEW_HTML,
579+
// jsdelivr is on the CDN allowlist; the tile host is not, so it is listed as
580+
// not granted.
581+
csp: { resourceDomains: ["https://cdn.jsdelivr.net", "https://tiles.example.com"] },
582+
prefersBorder: null,
583+
resultAvailable: true,
584+
result: { content: [{ type: "text", text: "Berlin: 18°C, light rain" }] },
585+
invocation: {
586+
serverName: "weather",
587+
toolName: "show_weather",
588+
arguments: { city: "Berlin" },
589+
},
590+
},
591+
},
592+
// get_forecast is visible to the model too: the first call needs the user's consent.
593+
callTool: (input) =>
594+
input.consented
595+
? { status: "ok", result: { content: [{ type: "text", text: "Sunny tomorrow" }] } }
596+
: { status: "consent_required" },
597+
},
598+
})}
599+
>
600+
<div className="bg-background flex h-screen justify-end">
601+
<div className="bg-sidebar border-border-light h-full w-full max-w-[440px] border-l">
602+
<ArtifactsPanel workspaceId={WORKSPACE_ID} />
603+
</div>
604+
</div>
605+
</APIProvider>
606+
);
607+
}
608+
609+
const playMcpAppView = async (canvasElement: HTMLElement) => {
610+
const canvas = within(canvasElement);
611+
const frame = await canvas.findByTestId("mcp-app-frame");
612+
await expect(frame.getAttribute("sandbox")).toBe("allow-scripts");
613+
await canvas.findByText(/Not granted to this view: https:\/\/tiles\.example\.com/);
614+
await waitFor(() => expect(frame.style.height).toBe("321px"), { timeout: 5000 });
615+
const strip = await canvas.findByRole("alert");
616+
await expect(strip.textContent).toContain("Allow get_forecast from weather?");
617+
await userEvent.click(within(strip).getByRole("button", { name: "Allow" }));
618+
await waitFor(() => expect(frame.style.height).toBe("333px"), { timeout: 5000 });
619+
};
620+
621+
export const McpAppViewLaptop: Story = {
622+
...LAPTOP,
623+
render: () => renderMcpAppView(),
624+
play: ({ canvasElement }) => playMcpAppView(canvasElement),
625+
};
626+
export const McpAppViewPhone: Story = {
627+
...PHONE,
628+
render: () => renderMcpAppView(),
629+
play: ({ canvasElement }) => playMcpAppView(canvasElement),
630+
};

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

Lines changed: 50 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 };
@@ -280,4 +282,52 @@ describe("ArtifactsPanel", () => {
280282
const view = renderPanel();
281283
expect(await view.findByText(/is too large to preview/)).toBeTruthy();
282284
});
285+
286+
test("Reload re-fetches a selected app view", async () => {
287+
fake = createFakeArtifactsApi(
288+
{ available: true, dir: "/scratch/artifacts", entries: [], truncated: false },
289+
{}
290+
);
291+
let getViewCalls = 0;
292+
fake.api.mcpApps = {
293+
getView: () => {
294+
getViewCalls += 1;
295+
return Promise.resolve({
296+
success: true as const,
297+
data: {
298+
html: "<p>view</p>",
299+
csp: {},
300+
prefersBorder: null,
301+
resultAvailable: false,
302+
result: null,
303+
invocation: null,
304+
},
305+
});
306+
},
307+
};
308+
openMcpAppView("ws-app-reload", {
309+
toolCallId: "call-1",
310+
serverName: "charts",
311+
resourceUri: "ui://charts/view",
312+
toolName: "show_chart",
313+
label: "Show chart",
314+
arguments: {},
315+
cancelled: false,
316+
});
317+
try {
318+
const view = render(<ArtifactsPanel workspaceId="ws-app-reload" />, {
319+
wrapper: (props: { children: ReactNode }) => (
320+
<ThemeProvider forcedTheme="dark">
321+
<ApiWrapper>{props.children}</ApiWrapper>
322+
</ThemeProvider>
323+
),
324+
});
325+
await view.findByTestId("mcp-app-frame");
326+
expect(getViewCalls).toBe(1);
327+
fireEvent.click(view.getByRole("button", { name: "Reload artifact" }));
328+
await waitFor(() => expect(getViewCalls).toBe(2));
329+
} finally {
330+
closeMcpAppView("ws-app-reload", "call-1");
331+
}
332+
});
283333
});

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

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import {
99
import {
1010
Select,
1111
SelectContent,
12+
SelectGroup,
1213
SelectItem,
14+
SelectLabel,
1315
SelectTrigger,
1416
SelectValue,
1517
} from "@/browser/components/SelectPrimitive/SelectPrimitive";
@@ -27,6 +29,8 @@ import { getErrorMessage } from "@/common/utils/errors";
2729
import { useArtifactSelection, writeArtifactSelection } from "./artifactSelection";
2830
import { downloadArtifact } from "./artifactDownload";
2931
import { ArtifactViewer } from "./ArtifactViewer";
32+
import { McpAppFrame } from "./McpAppFrame";
33+
import { mcpAppSelectionKey, useMcpAppViews } from "./mcpAppViewsStore";
3034
import type { ArtifactFrameKey } from "./SandboxedArtifactFrame";
3135

3236
/** While the tab is visible, re-list this often to catch writes no tool event reports. */
@@ -60,8 +64,12 @@ export function ArtifactsPanel(props: { workspaceId: string; inDialog?: boolean
6064
const panelRef = useRef<HTMLDivElement | null>(null);
6165
// Set while a list request runs, so a slow walk is not aborted by the next poll tick.
6266
const listInFlightRef = useRef(false);
67+
// MCP Apps: "Open in Artifacts" on a tool card selects its view through the selection map.
6368
const { path: selectedPath } = useArtifactSelection(props.workspaceId);
6469
const setSelectedPath = (path: string) => writeArtifactSelection(props.workspaceId, { path });
70+
const appViews = useMcpAppViews(props.workspaceId);
71+
const selectedApp =
72+
appViews.find((view) => mcpAppSelectionKey(view.toolCallId) === selectedPath) ?? null;
6573

6674
useEffect(() => {
6775
if (!api) return;
@@ -113,7 +121,10 @@ export function ArtifactsPanel(props: { workspaceId: string; inDialog?: boolean
113121
}, []);
114122

115123
const entries: ArtifactEntry[] = listing?.available === true ? listing.entries : [];
116-
const selected = entries.find((entry) => entry.path === selectedPath) ?? entries[0] ?? null;
124+
const selected =
125+
selectedApp != null
126+
? null
127+
: (entries.find((entry) => entry.path === selectedPath) ?? entries[0] ?? null);
117128
const selectedKey = selected ? `${selected.path}\u0000${selected.modifiedMs}` : null;
118129

119130
useEffect(() => {
@@ -192,7 +203,14 @@ export function ArtifactsPanel(props: { workspaceId: string; inDialog?: boolean
192203
const downloadableResult = selectedResult?.status === "ok" ? selectedResult : null;
193204

194205
const viewerBody =
195-
selected == null ? null : readState?.path === selected.path && readState.result ? (
206+
selectedApp != null ? (
207+
<McpAppFrame
208+
// Reload remounts the view, so it re-fetches its resource and result.
209+
key={`${selectedApp.toolCallId}\u0000${reloadTick}`}
210+
workspaceId={props.workspaceId}
211+
view={selectedApp}
212+
/>
213+
) : selected == null ? null : readState?.path === selected.path && readState.result ? (
196214
<ArtifactViewer
197215
// Remount per file version so renderer state (zoom, JSON mode, frames) starts fresh.
198216
key={`${readState.path}\u0000${readState.modifiedMs}`}
@@ -225,7 +243,10 @@ export function ArtifactsPanel(props: { workspaceId: string; inDialog?: boolean
225243
// The version menu joins the actions once artifact versions exist.
226244
const artbar = (
227245
<div className="border-border-light bg-sidebar flex shrink-0 items-center gap-1.5 border-b px-2 py-1.5">
228-
<Select value={selected?.path ?? ""} onValueChange={setSelectedPath}>
246+
<Select
247+
value={selectedApp ? mcpAppSelectionKey(selectedApp.toolCallId) : (selected?.path ?? "")}
248+
onValueChange={setSelectedPath}
249+
>
229250
<SelectTrigger
230251
aria-label="Artifact"
231252
className="h-6 min-w-0 flex-1 justify-between px-2 text-xs [&>span]:min-w-0"
@@ -246,6 +267,22 @@ export function ArtifactsPanel(props: { workspaceId: string; inDialog?: boolean
246267
</span>
247268
</SelectItem>
248269
))}
270+
{appViews.length > 0 && (
271+
<SelectGroup>
272+
<SelectLabel>App views</SelectLabel>
273+
{appViews.map((view) => (
274+
<SelectItem
275+
key={view.toolCallId}
276+
value={mcpAppSelectionKey(view.toolCallId)}
277+
className="text-xs"
278+
>
279+
<span className="min-w-0 truncate">
280+
{view.label} · {view.serverName}
281+
</span>
282+
</SelectItem>
283+
))}
284+
</SelectGroup>
285+
)}
249286
</SelectContent>
250287
</Select>
251288
{changedPaths.size > 0 && (
@@ -303,13 +340,21 @@ export function ArtifactsPanel(props: { workspaceId: string; inDialog?: boolean
303340
);
304341

305342
let body: React.ReactNode;
306-
if (listError != null) {
343+
// App views do not depend on the artifacts folder, so they show even without a listing.
344+
if (selectedApp != null) {
345+
body = (
346+
<>
347+
{artbar}
348+
<div className="min-h-0 flex-1 overflow-auto">{viewerBody}</div>
349+
</>
350+
);
351+
} else if (listError != null) {
307352
body = <div className="text-danger p-4 text-xs">{listError}</div>;
308353
} else if (listing == null) {
309354
body = <div className="text-muted p-4 text-xs">Loading…</div>;
310355
} else if (!listing.available) {
311356
body = <div className="text-muted p-4 text-xs leading-relaxed">{listing.reason}</div>;
312-
} else if (entries.length === 0) {
357+
} else if (entries.length === 0 && appViews.length === 0) {
313358
body = (
314359
<div className="text-muted p-4 text-xs leading-relaxed">
315360
No artifacts yet. Files the agent writes to{" "}

0 commit comments

Comments
 (0)