1import type { EnvironmentId, ThreadId, TurnItemId } from "@t3tools/contracts";
2import {
3 makeMcpAppHost,
4 McpAppHostRefusal,
5 mcpAppStyleVariables,
6 mcpResourceBytes,
7 type McpAppCallToolResult,
8 type McpAppDisplayMode,
9 type McpAppHost,
10 type McpAppHostContext,
11} from "@t3tools/client-runtime/mcp-apps";
12import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
13import {
14 clampMcpAppHeight,
15 MCP_APP_DEFAULT_HEIGHT,
16 MCP_APP_MAX_HEIGHT,
17 mcpAppAllowAttribute,
18 mcpAppFileName,
19 type McpAppReference,
20} from "@t3tools/shared/mcpApp";
21import { Minimize2Icon } from "lucide-react";
22import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
23import { flushSync } from "react-dom";
24
25import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls";
26import { APP_VERSION } from "~/branding";
27import { isConfirmDialogActive, requestConfirmDialog } from "~/confirmDialog";
28import { useHtmlRenderTheme } from "~/hooks/useHtmlRenderTheme";
29import { Button } from "~/components/ui/button";
30import { isElectron } from "~/env";
31import { cn } from "~/lib/utils";
32import { useTurnItemDetail } from "~/state/queries";
33import { mcpAppEnvironment } from "~/state/mcpApps";
34import { useAtomCommand } from "~/state/use-atom-command";
35import { observeResize } from "~/lib/observeResize";
36
37const commandFailure = (result: {
38 readonly cause: Parameters<typeof squashAtomCommandFailure>[0]["cause"];
39}) => {
40 const error = squashAtomCommandFailure(result);
41 return new McpAppHostRefusal(
42 error instanceof Error && error.message.trim() !== "" ? error.message : "Request failed.",
43 );
44};
45
46/** Full screen shows the box in the top layer, which needs the Popover API. */
47/** A cached asset URL with less life than this is minted afresh first. */
48const MIN_URL_LIFE_MS = 5 * 60_000;
49const fullscreenSupported =
50 typeof HTMLElement !== "undefined" && "popover" in HTMLElement.prototype;
51
52/** Largest file an app may hand the user through `ui/download-file`. */
53const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024;
54
55/** Saves a file through the browser's own download, which asks where when it is set to. */
56function saveBlob(blob: Blob, name: string) {
57 const url = URL.createObjectURL(blob);
58 const link = document.createElement("a");
59 link.href = url;
60 link.download = name;
61 link.click();
62 // Long after the browser has read it: some start the download a task or
63 // more after the click, and a revoked URL saves nothing.
64 setTimeout(() => URL.revokeObjectURL(url), 60_000);
65}
66
67/**
68 * An MCP App inline in the thread: the captured document in an opaque-origin
69 * frame, speaking the MCP Apps bridge. Its tool calls and resource reads reach
70 * its own MCP server through the environment; calls to tools the server does
71 * not mark read-only, chat messages, and downloads ask first. Full screen keeps
72 * the same frame (and the app's state) and only restyles its box.
73 */
74export function McpAppFrame(props: {
75 readonly environmentId: EnvironmentId;
76 /** The thread that produced the app, which its requests run against. */
77 readonly threadId: ThreadId;
78 /** The thread on screen, whose next turn the app's model context informs. */
79 readonly conversationThreadId: ThreadId;
80 readonly itemId: TurnItemId;
81 /** The item's revision, so its stored call is fetched once per version. */
82 readonly revision: string;
83 readonly app: McpAppReference;
84 readonly onSendMessage: ((text: string) => Promise<void>) | undefined;
85 /** The agent waits on the user, in a panel a full-screen app would cover. */
86 readonly awaitingUser?: boolean;
87 /** Told when the app enters or leaves full screen. */
88 readonly onFullscreenChange?: (fullscreen: boolean) => void;
89}) {
90 const { app } = props;
91 const theme = useHtmlRenderTheme();
92 const frameRef = useRef<HTMLIFrameElement>(null);
93 const boxRef = useRef<HTMLDivElement>(null);
94 const [width, setWidth] = useState(0);
95 const [height, setHeight] = useState(MCP_APP_DEFAULT_HEIGHT);
96 const [navigatedAway, setNavigatedAway] = useState(false);
97 const [displayMode, setDisplayMode] = useState<McpAppDisplayMode>("inline");
98 // The app asked to be closed; the row falls back to its plain tool call.
99 const [closed, setClosed] = useState(false);
100 // Counts the app documents this row has shown. Reopening a closed app loads
101 // a new one, which needs its own host and its own load tracking.
102 const [documentGeneration, setDocumentGeneration] = useState(0);
103 useLayoutEffect(() => {
104 const box = boxRef.current;
105 if (!box) return;
106 setWidth(box.clientWidth);
107 return observeResize(box, ([entry]) => {
108 if (entry) setWidth(Math.round(entry.contentRect.width));
109 });
110 // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Closing and reopening replaces the box.
111 }, [closed]);
112
113 const resource = useMemo(
114 () => ({
115 _tag: "attachment" as const,
116 attachmentId: app.attachmentId,
117 fileName: mcpAppFileName(app),
118 mimeType: "text/html",
119 disposition: "inline" as const,
120 renderIntent: "mcp-app" as const,
121 }),
122 [app],
123 );
124 const asset = useAssetUrlState(props.environmentId, resource);
125 const refreshAsset = useAssetUrlRefresh(props.environmentId, resource);
126 // The frame keeps its first URL: a re-minted one would reload the app. A
127 // cached URL near expiry is minted afresh first, since a frame cannot
128 // report a failed load. At most one mint per document, so a skewed clock
129 // cannot mint on every update.
130 const [src, setSrc] = useState<string | null>(null);
131 const [mintFailed, setMintFailed] = useState(false);
132 const minting = useRef(false);
133 const cachedUrl = asset._tag === "Success" ? asset.url : null;
134 const cachedExpiresAt = asset._tag === "Success" ? asset.expiresAt : 0;
135 useEffect(() => {
136 if (src !== null || cachedUrl === null || minting.current) return;
137 if (cachedExpiresAt - Date.now() > MIN_URL_LIFE_MS) {
138 // oxlint-disable-next-line react/set-state-in-effect -- Adopts the cached URL once it is known to last.
139 setSrc(cachedUrl);
140 return;
141 }
142 minting.current = true;
143 void refreshAsset().then(
144 (url) => (url === null ? setMintFailed(true) : setSrc(url)),
145 () => setMintFailed(true),
146 );
147 }, [src, cachedUrl, cachedExpiresAt, refreshAsset]);
148
149 // The wire timeline omits tool input and output; the app needs both.
150 const detail = useTurnItemDetail({
151 environmentId: props.environmentId,
152 threadId: props.threadId,
153 itemId: props.itemId,
154 revision: props.revision,
155 });
156 const storedItem = detail.data?.item;
157 const toolCall = useMemo(() => {
158 if (storedItem?.type !== "dynamic_tool") return undefined;
159 const output = storedItem.output as { readonly result?: unknown } | undefined;
160 const result = output?.result as McpAppCallToolResult | undefined;
161 return { arguments: storedItem.input, result };
162 }, [storedItem]);
163
164 const callTool = useAtomCommand(mcpAppEnvironment.callTool, { reportFailure: false });
165 const toolInfo = useAtomCommand(mcpAppEnvironment.toolInfo, { reportFailure: false });
166 const readResource = useAtomCommand(mcpAppEnvironment.readResource, { reportFailure: false });
167 const updateModelContext = useAtomCommand(mcpAppEnvironment.updateModelContext, {
168 reportFailure: false,
169 });
170 // The tool's definition, which the app receives as `toolInfo` at initialize.
171 const [toolDefinition, setToolDefinition] = useState<unknown>(undefined);
172
173 // Read by the host on every message, so it always sees current values
174 // without being rebuilt (which would drop the app's session).
175 const live = {
176 theme,
177 width,
178 props,
179 callTool,
180 toolInfo,
181 readResource,
182 updateModelContext,
183 displayMode,
184 toolDefinition,
185 };
186 const hostRef = useRef<McpAppHost | null>(null);
187 const latest = useRef(live);
188 useEffect(() => {
189 latest.current = live;
190 });
191
192 useEffect(() => {
193 const box = boxRef.current;
194 if (box === null || !fullscreenSupported) return;
195 const shown = box.matches(":popover-open");
196 if (displayMode === "fullscreen" && !shown) box.showPopover();
197 if (displayMode !== "fullscreen" && shown) box.hidePopover();
198 // Reported only on a change, so an inline row never touches another
199 // row's pin.
200 if (displayMode === "fullscreen") {
201 latest.current.props.onFullscreenChange?.(true);
202 return () => latest.current.props.onFullscreenChange?.(false);
203 }
204 }, [displayMode]);
205 // The approval or question the agent waits on renders in the page, so a
206 // full-screen app steps aside for it.
207 const [stepAsideFor, setStepAsideFor] = useState(props.awaitingUser);
208 if (stepAsideFor !== props.awaitingUser) {
209 setStepAsideFor(props.awaitingUser);
210 if (props.awaitingUser === true) setDisplayMode("inline");
211 }
212 // Full screen sits above everything else in the page. Anything that takes
213 // focus outside it (an approval, the command palette, a dialog) returns the
214 // app inline so it can be seen; so does Escape pressed outside the app.
215 // Keys pressed inside the app stay with the app.
216 useEffect(() => {
217 if (displayMode !== "fullscreen") return;
218 const leave = () => setDisplayMode("inline");
219 const onFocus = (event: FocusEvent) => {
220 if (event.target instanceof Node && !boxRef.current?.contains(event.target)) leave();
221 };
222 const onKey = (event: KeyboardEvent) => {
223 if (event.key === "Escape") leave();
224 };
225 const onResize = () => hostRef.current?.updateHostContext();
226 document.addEventListener("focusin", onFocus);
227 window.addEventListener("keydown", onKey);
228 window.addEventListener("resize", onResize);
229 return () => {
230 document.removeEventListener("focusin", onFocus);
231 window.removeEventListener("keydown", onKey);
232 window.removeEventListener("resize", onResize);
233 };
234 }, [displayMode]);
235
236 // The definition is read once per app, before the host is built, so the
237 // initialize response can carry it; a failure only leaves it out.
238 useEffect(() => {
239 let cancelled = false;
240 void latest.current
241 .toolInfo({
242 environmentId: props.environmentId,
243 input: { threadId: props.threadId, itemId: props.itemId, name: app.tool },
244 })
245 .then((info) => {
246 if (!cancelled && info._tag === "Success" && info.value.tool !== undefined) {
247 setToolDefinition(info.value.tool);
248 }
249 });
250 return () => {
251 cancelled = true;
252 };
253 }, [props.environmentId, props.threadId, props.itemId, app.tool]);
254 // The bridge belongs to the captured document. A frame that navigates keeps
255 // its window, so a second load stops the app rather than letting a page T3
256 // never served pose as it. This is not a confidentiality boundary: a frame
257 // can always navigate itself, so the app could carry anything it read out
258 // in a URL either way.
259 // Counted per document: a reopened app is a new document, not a navigation.
260 const loads = useRef({ generation: 0, count: 0 });
261 const onFrameLoad = () => {
262 if (loads.current.generation !== documentGeneration) {
263 loads.current = { generation: documentGeneration, count: 0 };
264 }
265 loads.current.count += 1;
266 if (loads.current.count > 1) {
267 hostRef.current?.dispose();
268 setNavigatedAway(true);
269 }
270 };
271
272 useEffect(() => {
273 if (src === null) return;
274 const hostContext = (): McpAppHostContext => {
275 const current = latest.current;
276 return {
277 theme: current.theme.appearance,
278 styles: { variables: mcpAppStyleVariables(current.theme.variables) },
279 displayMode: current.displayMode,
280 availableDisplayModes: fullscreenSupported ? ["inline", "fullscreen"] : ["inline"],
281 containerDimensions:
282 current.displayMode === "fullscreen"
283 ? {
284 width: frameRef.current?.clientWidth || window.innerWidth,
285 height: frameRef.current?.clientHeight || window.innerHeight,
286 }
287 : { width: current.width, maxHeight: MCP_APP_MAX_HEIGHT },
288 platform: isElectron ? "desktop" : "web",
289 locale: navigator.language,
290 timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
291 userAgent: `t3-code/${APP_VERSION}`,
292 deviceCapabilities: {
293 touch: window.matchMedia("(pointer: coarse)").matches,
294 hover: window.matchMedia("(hover: hover)").matches,
295 },
296 safeAreaInsets: { top: 0, right: 0, bottom: 0, left: 0 },
297 ...(current.toolDefinition === undefined
298 ? {}
299 : { toolInfo: { tool: current.toolDefinition } }),
300 };
301 };
302 // Approvals render in the page, beneath the top layer a full-screen app
303 // occupies, so the app returns inline before T3 asks.
304 const ask = async (message: string) => {
305 flushSync(() => setDisplayMode("inline"));
306 const approved = await requestConfirmDialog(message);
307 // No dialog host is mounted, so nobody was asked; say so rather than
308 // reporting that the user declined.
309 if (approved === undefined) {
310 throw new McpAppHostRefusal("T3 could not ask for approval here.");
311 }
312 return approved;
313 };
314 const target = () => frameRef.current?.contentWindow ?? null;
315 const scope = () => {
316 const { environmentId, threadId, itemId } = latest.current.props;
317 return { environmentId, input: { threadId, itemId } };
318 };
319 const host = makeMcpAppHost({
320 app,
321 hostVersion: APP_VERSION,
322 // The document's origin is opaque, so "*" is the only target that reaches it;
323 // the window reference itself is what scopes delivery to this frame.
324 post: (message) => target()?.postMessage(message, "*"),
325 hostContext,
326 callTool: async ({ name, arguments: args }) => {
327 const { environmentId, input } = scope();
328 const info = await latest.current.toolInfo({ environmentId, input: { ...input, name } });
329 if (info._tag !== "Success") throw commandFailure(info);
330 if (!info.value.callable) throw new McpAppHostRefusal("This app cannot call that tool.");
331 if (!info.value.readOnly) {
332 const approved = await ask(
333 `Allow ${app.server} to run ${info.value.title ?? name}?\n${JSON.stringify(args, null, 2)}`,
334 );
335 if (approved !== true) throw new McpAppHostRefusal("Declined by the user.");
336 }
337 const result = await latest.current.callTool({
338 environmentId,
339 input: { ...input, name, arguments: args },
340 });
341 if (result._tag !== "Success") throw commandFailure(result);
342 return result.value;
343 },
344 readResource: async ({ uri }) => {
345 const { environmentId, input } = scope();
346 const result = await latest.current.readResource({
347 environmentId,
348 input: { ...input, uri },
349 });
350 if (result._tag !== "Success") throw commandFailure(result);
351 return result.value;
352 },
353 openLink: async (url) => {
354 // Only right after the reader used this frame, as with HTML renders.
355 if (document.activeElement !== frameRef.current || !navigator.userActivation?.isActive) {
356 throw new McpAppHostRefusal("Links open only from a click in the app.");
357 }
358 window.open(url, "_blank", "noopener,noreferrer");
359 },
360 sendMessage: async (text) => {
361 const send = latest.current.props.onSendMessage;
362 if (send === undefined) throw new McpAppHostRefusal("Messages are not available here.");
363 const approved = await ask(`Send this message from ${app.server}?\n${text}`);
364 if (approved !== true) throw new McpAppHostRefusal("Declined by the user.");
365 await send(text);
366 },
367 updateModelContext: async (context) => {
368 const { environmentId, input } = scope();
369 const result = await latest.current.updateModelContext({
370 environmentId,
371 input: {
372 ...input,
373 ...context,
374 conversationThreadId: latest.current.props.conversationThreadId,
375 },
376 });
377 if (result._tag !== "Success") throw commandFailure(result);
378 },
379 requestDisplayMode: async (mode) => {
380 // Full screen would cover the approval or question the agent waits
381 // on, or a confirmation any app is waiting on. Another app already
382 // full screen keeps the page; two would stack.
383 const otherFullscreen = document.querySelector("[data-mcp-app-fullscreen]");
384 if (
385 mode === "fullscreen" &&
386 (latest.current.props.awaitingUser === true ||
387 isConfirmDialogActive() ||
388 (otherFullscreen !== null && otherFullscreen !== boxRef.current))
389 ) {
390 return latest.current.displayMode;
391 }
392 setDisplayMode(mode);
393 return mode;
394 },
395 downloadFile: async (files) => {
396 const names = files.map((file) => file.name).join(", ");
397 const approved = await ask(`Save ${names} from ${app.server}?`);
398 if (approved !== true) throw new McpAppHostRefusal("Declined by the user.");
399 for (const file of files) {
400 // A linked file is read from the app's own server, like its other reads.
401 let bytes: Uint8Array | undefined;
402 let mimeType = file.mimeType ?? "application/octet-stream";
403 if (file._tag === "embedded") {
404 bytes = file.bytes;
405 } else {
406 const { environmentId, input } = scope();
407 const read = await latest.current.readResource({
408 environmentId,
409 input: { ...input, uri: file.uri },
410 });
411 if (read._tag !== "Success") throw commandFailure(read);
412 const content = read.value.contents[0];
413 bytes = mcpResourceBytes(content);
414 const declared = (content as { readonly mimeType?: unknown } | undefined)?.mimeType;
415 if (typeof declared === "string") mimeType = declared;
416 }
417 if (bytes === undefined) throw new McpAppHostRefusal(`${file.name} has no contents.`);
418 if (bytes.byteLength > MAX_DOWNLOAD_BYTES) {
419 throw new McpAppHostRefusal(`${file.name} is too large to save.`);
420 }
421 // A copy backed by a plain ArrayBuffer, which Blob requires.
422 saveBlob(new Blob([bytes.slice()], { type: mimeType }), file.name);
423 }
424 },
425 onRequestTeardown: () => {
426 if (latest.current.displayMode === "fullscreen") {
427 setDisplayMode("inline");
428 return;
429 }
430 // The app asked to go, so it gets its teardown before the frame does.
431 void host.teardown().then(() => setClosed(true));
432 },
433 onSizeChanged: (size) => {
434 // Full screen is a fixed box; what the app reports there would leave
435 // the inline row its full-screen height on the way back.
436 if (latest.current.displayMode === "fullscreen") return;
437 if (size.height !== undefined) setHeight(clampMcpAppHeight(size.height));
438 },
439 });
440 hostRef.current = host;
441 const receive = (event: MessageEvent) => {
442 // Only messages from this frame's own window reach its host.
443 if (event.source !== null && event.source === target()) host.receive(event.data);
444 };
445 window.addEventListener("message", receive);
446 return () => {
447 window.removeEventListener("message", receive);
448 // An unmount cannot wait: the request goes out before the frame does,
449 // which is all a list that recycles rows can offer the app.
450 void host.teardown();
451 hostRef.current = null;
452 };
453 // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A reopened document needs a new host.
454 }, [src, app, documentGeneration]);
455
456 // The host reads the context through `latest`; these only say when to resend.
457 useEffect(() => {
458 hostRef.current?.updateHostContext();
459 // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Context changes trigger a resend.
460 }, [theme, width, displayMode, toolDefinition]);
461
462 // A new document gets a new host, which needs the call again.
463 useEffect(() => {
464 if (toolCall !== undefined) hostRef.current?.setToolCall(toolCall);
465 // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Each new document needs the call.
466 }, [toolCall, src, documentGeneration]);
467
468 // An app that asked to close keeps the box an inline row had, so the
469 // timeline does not jump, and says how to bring it back.
470 if (closed) {
471 return (
472 <div className="flex items-center justify-between gap-2 rounded-lg border border-border px-3 py-2 text-muted-foreground text-xs">
473 <span>The {app.server} app was closed</span>
474 <Button
475 size="xs"
476 variant="ghost"
477 onClick={() => {
478 // The first URL's token may have expired: the new document
479 // takes a fresh one, minted again if it is near expiry.
480 minting.current = false;
481 setSrc(null);
482 setDocumentGeneration((value) => value + 1);
483 setClosed(false);
484 }}
485 >
486 Show app
487 </Button>
488 </div>
489 );
490 }
491
492 const fullscreen = displayMode === "fullscreen";
493 return (
494 // The row keeps its inline height while the app is full screen, so the
495 // timeline does not jump; the box itself is what goes full screen.
496 <div style={{ height }}>
497 <div
498 ref={boxRef}
499 // Full screen is the same box shown in the browser's top layer: moving
500 // the frame to another parent would reload the app, and the timeline's
501 // rows contain fixed positioning, which the top layer escapes.
502 {...(fullscreenSupported ? { popover: "manual" as const } : {})}
503 data-mcp-app-fullscreen={fullscreen ? "" : undefined}
504 className={cn(
505 "relative size-full overflow-hidden",
506 app.prefersBorder === true && !fullscreen && "rounded-lg border border-border",
507 fullscreen
508 ? "fixed inset-0 m-0 flex h-dvh max-h-none w-dvw max-w-none flex-col border-0 bg-background p-0"
509 : // A closed popover is hidden; inline, the box is a plain block.
510 "block! static! m-0 border-0 bg-transparent p-0 text-inherit",
511 )}
512 >
513 {fullscreen ? (
514 <div className="flex h-10 shrink-0 items-center justify-between border-border border-b px-3 text-sm">
515 <span className="truncate">{app.server}</span>
516 <Button
517 aria-label="Exit full screen"
518 size="icon-sm"
519 variant="ghost"
520 onClick={() => setDisplayMode("inline")}
521 >
522 <Minimize2Icon className="size-4" />
523 </Button>
524 </div>
525 ) : null}
526 {navigatedAway ? (
527 <p className="flex size-full items-center justify-center text-muted-foreground text-xs">
528 The {app.server} app left its page and was stopped
529 </p>
530 ) : src !== null ? (
531 <iframe
532 ref={frameRef}
533 src={src}
534 title={`${app.server} app`}
535 // Never allow-same-origin: the opaque origin keeps the app out of the session.
536 sandbox="allow-scripts allow-forms"
537 allow={mcpAppAllowAttribute(app.permissions)}
538 onLoad={onFrameLoad}
539 className={cn("block w-full border-0", fullscreen ? "min-h-0 flex-1" : "h-full")}
540 style={{ colorScheme: theme.appearance }}
541 />
542 ) : asset._tag === "Failure" || mintFailed ? (
543 <p className="flex size-full items-center justify-center text-muted-foreground text-xs">
544 Unable to load the {app.server} app
545 </p>
546 ) : null}
547 </div>
548 </div>
549 );
550}