| 1 | import type { ThreadId } from "@t3tools/contracts"; |
| 2 | import { |
| 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"; |
| 11 | import * as Effect from "effect/Effect"; |
| 12 | import * as FileSystem from "effect/FileSystem"; |
| 13 | import * as Predicate from "effect/Predicate"; |
| 14 | |
| 15 | import { resolveAttachmentRelativePath } from "../attachmentPaths.ts"; |
| 16 | import { createAttachmentId } from "../attachmentStore.ts"; |
| 17 | |
| 18 | const 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 | */ |
| 25 | function 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 | */ |
| 61 | export 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 | }); |