@@ -13,6 +13,7 @@ import type {
1313import { getArtifactKind } from "@/common/utils/artifactKind" ;
1414import { ArtifactsPanel } from "./ArtifactsPanel" ;
1515import { writeArtifactSelection } from "./artifactSelection" ;
16+ import { openMcpAppView } from "./mcpAppViewsStore" ;
1617
1718const 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 ( / N o t g r a n t e d t o t h i s v i e w : h t t p s : \/ \/ t i l e s \. e x a m p l e \. c o m / ) ;
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+ } ;
0 commit comments