Skip to content
Source
Sign inSign up

McpAppSnapshot.ts

94 lines3.7 KB

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​
20function readMcpAppDocument(
21 contents: ReadonlyArray<unknown>,
22 uri: string,
23): { readonly html: string | null; readonly meta: unknown } | undefined {
24 for (const content of contents) {
25 if (!Predicate.isObject(content)) continue;
26 if (content.uri !== undefined && content.uri !== uri) continue;
27 const mimeType = typeof content.mimeType === "string" ? content.mimeType : "";
28 if (mimeType.replace(/\s+/g, "").toLowerCase() !== MCP_APP_MIME_TYPE) continue;
29 // Bound before decoding to avoid oversized copies; UTF-8 is at least string length, base64 decodes to 3/4.
30 const html =
31 typeof content.text === "string"
32 ? content.text.length > MCP_APP_MAX_HTML_BYTES
33 ? null
34 : content.text
35 : typeof content.blob === "string"
36 ? content.blob.length > Math.ceil(MCP_APP_MAX_HTML_BYTES / 3) * 4
37 ? null
38 : Buffer.from(content.blob, "base64").toString("utf8")
39 : undefined;
40 if (html === null) return { html: null, meta: undefined };
41 if (html === undefined || html.trim() === "") continue;
42 const meta = Predicate.isObject(content._meta) ? content._meta.ui : undefined;
43 return { html, meta };
44 }
45 return undefined;
47​
48export const snapshotMcpApp = Effect.fn("McpAppSnapshot.snapshot")(function* (input: {
49 readonly attachmentsDir: string;
50 readonly threadId: ThreadId;
51 readonly server: string;
52 readonly tool: string;
53 readonly resourceUri: string;
54 readonly contents: ReadonlyArray<unknown>;
55}) {
56 if (!input.resourceUri.startsWith(MCP_APP_RESOURCE_SCHEME)) return undefined;
57 const document = readMcpAppDocument(input.contents, input.resourceUri);
58 if (document === undefined) return undefined;
59 if (document.html === null || encoder.encode(document.html).byteLength > MCP_APP_MAX_HTML_BYTES) {
60 yield* Effect.logWarning("MCP app document exceeds the size limit.", {
61 server: input.server,
62 resourceUri: input.resourceUri,
63 });
64 return undefined;
65 }
66 // The `-html` suffix makes the asset route serve it as a sandboxed document.
67 const attachmentId = createAttachmentId(input.threadId, "html");
68 const filePath =
69 attachmentId === null
70 ? null
71 : resolveAttachmentRelativePath({
72 attachmentsDir: input.attachmentsDir,
73 relativePath: `${attachmentId}.html`,
74 });
75 if (attachmentId === null || filePath === null) return undefined;
76 const html = document.html;
77 const ui = Predicate.isObject(document.meta) ? document.meta : {};
78 const csp = readMcpAppCsp(ui.csp);
79 const fileSystem = yield* FileSystem.FileSystem;
80 // Failed or interrupted writes remove documents that thread deletion cannot yet find.
81 yield* fileSystem
82 .writeFileString(filePath, injectMcpAppCsp(html, csp))
83 .pipe(Effect.onError(() => fileSystem.remove(filePath, { force: true }).pipe(Effect.ignore)));
84 const permissions = readMcpAppPermissions(ui.permissions);
85 return {
86 attachmentId,
87 server: input.server,
88 tool: input.tool,
89 resourceUri: input.resourceUri,
90 ...(csp === undefined ? {} : { csp }),
91 ...(permissions === undefined ? {} : { permissions }),
92 ...(typeof ui.prefersBorder === "boolean" ? { prefersBorder: ui.prefersBorder } : {}),
93 } satisfies McpAppReference;
94});