1import type { ThreadId } from "@t3tools/contracts";
2import {
3 injectMcpAppCsp,
4 MCP_APP_MAX_HTML_BYTES,
5 MCP_APP_MIME_TYPE,
6 MCP_APP_RESOURCE_SCHEME,
7 readMcpAppCsp,
8 readMcpAppPermissions,
9 type McpAppReference,
10} from "@t3tools/shared/mcpApp";
11import * as Effect from "effect/Effect";
12import * as FileSystem from "effect/FileSystem";
13import * as Predicate from "effect/Predicate";
14
15import { resolveAttachmentRelativePath } from "../attachmentPaths.ts";
16import { createAttachmentId } from "../attachmentStore.ts";
17
18const encoder = new TextEncoder();
19
20/**
21 * The HTML document in a `resources/read` result, with the `_meta.ui` the
22 * resource declared. Only `text/html;profile=mcp-app` content counts; a blob is
23 * base64 per MCP. A null document is one too large to store.
24 */
25function readMcpAppDocument(
26 contents: ReadonlyArray<unknown>,
27 uri: string,
28): { readonly html: string | null; readonly meta: unknown } | undefined {
29 for (const content of contents) {
30 if (!Predicate.isObject(content)) continue;
31 if (content.uri !== undefined && content.uri !== uri) continue;
32 const mimeType = typeof content.mimeType === "string" ? content.mimeType : "";
33 if (mimeType.replace(/\s+/g, "").toLowerCase() !== MCP_APP_MIME_TYPE) continue;
34 // Sizes are checked before decoding, so an oversized resource is
35 // refused without first being copied: a string's UTF-8 form is at least
36 // its length, and base64 decodes to three quarters of its own.
37 const html =
38 typeof content.text === "string"
39 ? content.text.length > MCP_APP_MAX_HTML_BYTES
40 ? null
41 : content.text
42 : typeof content.blob === "string"
43 ? content.blob.length > Math.ceil(MCP_APP_MAX_HTML_BYTES / 3) * 4
44 ? null
45 : Buffer.from(content.blob, "base64").toString("utf8")
46 : undefined;
47 if (html === null) return { html: null, meta: undefined };
48 if (html === undefined || html.trim() === "") continue;
49 const meta = Predicate.isObject(content._meta) ? content._meta.ui : undefined;
50 return { html, meta };
51 }
52 return undefined;
53}
54
55/**
56 * Stores an app's document as a thread attachment and returns the reference
57 * clients host it from. Saving a snapshot keeps the app viewable after the
58 * provider exits; interaction still goes through a live provider session.
59 * Undefined when the resource is not an MCP App document or is too large.
60 */
61export const snapshotMcpApp = Effect.fn("McpAppSnapshot.snapshot")(function* (input: {
62 readonly attachmentsDir: string;
63 readonly threadId: ThreadId;
64 readonly server: string;
65 readonly tool: string;
66 readonly resourceUri: string;
67 readonly contents: ReadonlyArray<unknown>;
68}) {
69 if (!input.resourceUri.startsWith(MCP_APP_RESOURCE_SCHEME)) return undefined;
70 const document = readMcpAppDocument(input.contents, input.resourceUri);
71 if (document === undefined) return undefined;
72 if (document.html === null || encoder.encode(document.html).byteLength > MCP_APP_MAX_HTML_BYTES) {
73 yield* Effect.logWarning("MCP app document exceeds the size limit.", {
74 server: input.server,
75 resourceUri: input.resourceUri,
76 });
77 return undefined;
78 }
79 // The `-html` suffix makes the asset route serve it as a sandboxed document.
80 const attachmentId = createAttachmentId(input.threadId, "html");
81 const filePath =
82 attachmentId === null
83 ? null
84 : resolveAttachmentRelativePath({
85 attachmentsDir: input.attachmentsDir,
86 relativePath: `${attachmentId}.html`,
87 });
88 if (attachmentId === null || filePath === null) return undefined;
89 const html = document.html;
90 const ui = Predicate.isObject(document.meta) ? document.meta : {};
91 const csp = readMcpAppCsp(ui.csp);
92 const fileSystem = yield* FileSystem.FileSystem;
93 // Only the returned reference lets thread deletion find the document, so a
94 // write that fails or is interrupted removes it.
95 yield* fileSystem
96 .writeFileString(filePath, injectMcpAppCsp(html, csp))
97 .pipe(Effect.onError(() => fileSystem.remove(filePath, { force: true }).pipe(Effect.ignore)));
98 const permissions = readMcpAppPermissions(ui.permissions);
99 return {
100 attachmentId,
101 server: input.server,
102 tool: input.tool,
103 resourceUri: input.resourceUri,
104 ...(csp === undefined ? {} : { csp }),
105 ...(permissions === undefined ? {} : { permissions }),
106 ...(typeof ui.prefersBorder === "boolean" ? { prefersBorder: ui.prefersBorder } : {}),
107 } satisfies McpAppReference;
108});