Skip to content

Commit efda661

Browse files
authored
🤖 feat: MCP Apps views in the Artifacts tab (#5426)
## Summary Implements the host side of [MCP Apps](https://github.com/modelcontextprotocol/ext-apps) (`io.modelcontextprotocol/ui`, spec 2026-01-26) so MCP servers can show their `ui://` views in the Artifacts tab. ## Implementation - The client announces the extension only while the Artifacts experiment is on; tool `_meta.ui` (and the deprecated flat key) is kept; tools not visible to the model are hidden from it. - `resources/read` for `ui://` views: exact MIME type, 2 MB cap. Full `CallToolResult`s for views are kept in a host-only side store (session dir, capped), never sent to the model. - One opaque-origin `srcdoc` frame (desktop-host model; SECURITY AUDIT comment explains why no proxy frame). CSP starts from the spec default and only grants declared https domains that are also on the CDN allowlist with the setting on. - JSON-RPC router (`mcpAppHost.ts`): initialize, tool input/result, size changes, logging, `tools/call` (same server, app-visible tools, consent strip for model-visible tools), `ui/open-link` (https, confirm), `ui/message` (into the composer, never auto-sent); everything else `-32601`. - "Open in Artifacts" on MCP tool cards; views appear under **App views** in the picker. ## Validation Router/CSP/visibility/validation unit tests and a Storybook play test with a fake view (handshake, tool result, consent). Dogfooded against a local stdio MCP server fixture with a real model turn. **Stack:** part 5 of 9 of the Artifacts stack (each PR shows only its own layer). Everything is behind the **Artifacts** experiment (Settings → Experiments, off by default). The user-facing page lands in part 9 (`docs/workspaces/artifacts.mdx`). --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `high` • Cost: `$98.34`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=high costs=98.34 -->
1 parent 968e151 commit efda661

39 files changed

Lines changed: 3763 additions & 69 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
@@ -305,6 +305,23 @@ export const WorkspaceMenuBar: React.FC<WorkspaceMenuBarProps> = ({
305305
return () => window.removeEventListener("keydown", handler);
306306
}, [artifactsExperimentEnabled, isTimelineSidebarHidden, workspaceId]);
307307

308+
// "Open in Artifacts" on an MCP Apps tool card: while the sidebar is hidden, the dialog is
309+
// the only place the view can show.
310+
useEffect(() => {
311+
if (!artifactsExperimentEnabled) {
312+
return;
313+
}
314+
const handler = (event: Event) => {
315+
const detail = (event as CustomEvent<{ workspaceId: string }>).detail;
316+
if (detail?.workspaceId !== workspaceId || !isTimelineSidebarHidden()) {
317+
return;
318+
}
319+
setArtifactsDialogWorkspaceId(workspaceId);
320+
};
321+
window.addEventListener(CUSTOM_EVENTS.OPEN_MCP_APP_VIEW, handler);
322+
return () => window.removeEventListener(CUSTOM_EVENTS.OPEN_MCP_APP_VIEW, handler);
323+
}, [artifactsExperimentEnabled, isTimelineSidebarHidden, workspaceId]);
324+
308325
const isDevcontainerWorkspace = isDevcontainerRuntime(runtimeConfig);
309326
const isRuntimeRunning = isDevcontainerWorkspace && runtimeStatus === "running";
310327
// 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(() => {
@@ -372,4 +376,93 @@ describe("ArtifactsPanel", () => {
372376
const view = renderPanel();
373377
expect(await view.findByText(/is too large to preview/)).toBeTruthy();
374378
});
379+
380+
test("Reload re-fetches a selected app view", async () => {
381+
fake = createFakeArtifactsApi(
382+
{ available: true, dir: "/scratch/artifacts", entries: [], truncated: false },
383+
{}
384+
);
385+
let getViewCalls = 0;
386+
fake.api.mcpApps = {
387+
getView: () => {
388+
getViewCalls += 1;
389+
return Promise.resolve({
390+
success: true as const,
391+
data: {
392+
html: "<p>view</p>",
393+
csp: {},
394+
prefersBorder: null,
395+
resultAvailable: false,
396+
result: null,
397+
invocation: null,
398+
},
399+
});
400+
},
401+
};
402+
openMcpAppView("ws-app-reload", {
403+
toolCallId: "call-1",
404+
serverName: "charts",
405+
resourceUri: "ui://charts/view",
406+
toolName: "show_chart",
407+
label: "Show chart",
408+
arguments: {},
409+
cancelled: false,
410+
});
411+
try {
412+
const view = render(<ArtifactsPanel workspaceId="ws-app-reload" />, {
413+
wrapper: (props: { children: ReactNode }) => (
414+
<ThemeProvider forcedTheme="dark">
415+
<ApiWrapper>{props.children}</ApiWrapper>
416+
</ThemeProvider>
417+
),
418+
});
419+
await view.findByTestId("mcp-app-frame");
420+
expect(getViewCalls).toBe(1);
421+
fireEvent.click(view.getByRole("button", { name: "Reload artifact" }));
422+
await waitFor(() => expect(getViewCalls).toBe(2));
423+
} finally {
424+
closeMcpAppView("ws-app-reload", "call-1");
425+
}
426+
});
427+
428+
test("the remaining app views stay reachable after closing one, without a listing", async () => {
429+
fake = createFakeArtifactsApi(
430+
{ available: true, dir: "/scratch/artifacts", entries: [], truncated: false },
431+
{}
432+
);
433+
fake.api.artifacts = {
434+
...fake.api.artifacts,
435+
list: () => Promise.resolve({ success: false as const, error: "Listing failed" }),
436+
};
437+
fake.api.mcpApps = {
438+
getView: () => Promise.resolve({ success: false as const, error: "No view" }),
439+
};
440+
const ref = (toolCallId: string, label: string) => ({
441+
toolCallId,
442+
serverName: "charts",
443+
resourceUri: "ui://charts/view",
444+
toolName: "show_chart",
445+
label,
446+
arguments: {},
447+
cancelled: false,
448+
});
449+
openMcpAppView("ws-app-close", ref("call-a", "First view"));
450+
openMcpAppView("ws-app-close", ref("call-b", "Second view"));
451+
try {
452+
const view = render(<ArtifactsPanel workspaceId="ws-app-close" />, {
453+
wrapper: (props: { children: ReactNode }) => (
454+
<ThemeProvider forcedTheme="dark">
455+
<ApiWrapper>{props.children}</ApiWrapper>
456+
</ThemeProvider>
457+
),
458+
});
459+
// The most recently opened view is selected; close it.
460+
fireEvent.click(await view.findByRole("button", { name: "Close view" }));
461+
expect(await view.findByText("Listing failed")).toBeTruthy();
462+
expect(view.getByRole("combobox", { name: "Artifact" })).toBeTruthy();
463+
} finally {
464+
closeMcpAppView("ws-app-close", "call-a");
465+
closeMcpAppView("ws-app-close", "call-b");
466+
}
467+
});
375468
});

0 commit comments

Comments
 (0)