From 9775eefeac279bdd48bd6c8f9cdab968a3a64332 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Wed, 7 Oct 2026 18:11:53 -0700 Subject: [PATCH 1/3] fix(client): tolerate blocked cookies in MCP app sandboxes --- .../src/features/threads/McpAppWebView.tsx | 1 + apps/server/src/assets/AssetAccess.test.ts | 41 ++++++++++++ apps/server/src/assets/AssetAccess.ts | 6 ++ apps/server/src/http.test.ts | 63 +++++++++++++++++++ apps/server/src/http.ts | 29 +++++++++ apps/web/src/components/chat/McpAppFrame.tsx | 1 + packages/contracts/src/assets.ts | 2 + packages/shared/src/mcpApp.test.ts | 60 ++++++++++++++++++ packages/shared/src/mcpApp.ts | 16 +++++ 9 files changed, 219 insertions(+) diff --git a/apps/mobile/src/features/threads/McpAppWebView.tsx b/apps/mobile/src/features/threads/McpAppWebView.tsx index 119f5f946817..2787e9263ab7 100644 --- a/apps/mobile/src/features/threads/McpAppWebView.tsx +++ b/apps/mobile/src/features/threads/McpAppWebView.tsx @@ -163,6 +163,7 @@ export function ThreadMcpApp(props: { fileName: mcpAppFileName(app), mimeType: "text/html", disposition: "inline" as const, + renderIntent: "mcp-app" as const, }), [app], ); diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 4e71c68f875c..76f3ba8f2a20 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -927,6 +927,47 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(layerTest)), ); + it.effect( + "signs MCP render intent only for inline HTML attachments, including saved captures", + () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(config.attachmentsDir, { recursive: true }); + for (const [extension, disposition, intent] of [ + ["html", "inline", "mcp-app"], + ["html", "inline", undefined], + ["html", "attachment", "mcp-app"], + ["pdf", "inline", "mcp-app"], + ] as const) { + const attachmentId = `thread-1-00000000-0000-4000-8000-000000000001-${extension}`; + const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.${extension}`); + yield* fs.writeFileString(attachmentPath, "saved capture"); + const result = yield* issueAssetUrl({ + resource: { + _tag: "attachment", + attachmentId, + disposition, + ...(intent ? { renderIntent: intent } : {}), + }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separator = suffix.indexOf("/"); + const asset = yield* resolveAsset( + suffix.slice(0, separator), + suffix.slice(separator + 1), + ); + expect(asset).toMatchObject({ kind: "file", path: attachmentPath }); + if (extension === "html" && disposition === "inline" && intent !== undefined) { + expect(asset).toMatchObject({ renderIntent: intent }); + } else { + expect(asset).not.toHaveProperty("renderIntent"); + } + } + }).pipe(Effect.provide(layerTest)), + ); + it.effect("serves video attachments inline", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 303137c149cd..c7dceebc8daa 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -128,6 +128,7 @@ const AssetClaimsSchema = Schema.Union([ download filename and Content-Type. */ fileName: Schema.optionalKey(Schema.String), mimeType: Schema.optionalKey(Schema.String), + renderIntent: Schema.optionalKey(Schema.Literal("mcp-app")), expiresAt: Schema.Number, }), Schema.Struct({ @@ -180,6 +181,7 @@ export type ResolvedAsset = readonly fileName?: string; readonly mimeType?: string; readonly file?: OpenMediaFile; + readonly renderIntent?: "mcp-app"; } | { readonly kind: "bytes"; @@ -622,6 +624,9 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i : input.resource.mimeType !== undefined ? { mimeType: isVideo ? videoMimeType : input.resource.mimeType } : {}), + ...(inlinePreviewMimeType === "text/html" && input.resource.renderIntent !== undefined + ? { renderIntent: input.resource.renderIntent } + : {}), expiresAt, }; fileName = input.resource.fileName ?? path.basename(attachmentPath); @@ -853,6 +858,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( ...(claims.download ? { download: true } : {}), ...(claims.fileName !== undefined ? { fileName: claims.fileName } : {}), ...(claims.mimeType !== undefined ? { mimeType: claims.mimeType } : {}), + ...(claims.renderIntent !== undefined ? { renderIntent: claims.renderIntent } : {}), } satisfies ResolvedAsset) : null; } diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index dc4ff0283d2e..d3152490fe56 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -1,3 +1,8 @@ +import { + injectMcpAppCookieBootstrap, + injectMcpAppCsp, + MCP_APP_MAX_HTML_BYTES, +} from "@t3tools/shared/mcpApp"; import { expect, it } from "@effect/vitest"; import { describe, vi } from "vite-plus/test"; import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"; @@ -311,6 +316,64 @@ it.layer( ); }); +describe("MCP App asset responses", () => { + it.effect("transforms saved MCP captures while preserving the CSP and ordinary HTML bytes", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-mcp-response-" }); + const file = path.join(directory, "saved.html"); + const saved = injectMcpAppCsp( + '
Résumé
', + undefined, + ); + yield* fs.writeFileString(file, saved); + const transformed = injectMcpAppCookieBootstrap(saved); + const asset = { path: file, mimeType: "text/html", renderIntent: "mcp-app" as const }; + for (const method of ["GET", "HEAD"] as const) { + const response = HttpServerResponse.toWeb( + yield* assetFileResponse(asset, undefined, undefined, method), + ); + expect(response.status).toBe(200); + expect(response.headers.get("content-security-policy")).toBe( + "sandbox allow-scripts allow-forms allow-popups", + ); + expect(response.headers.get("content-length")).toBe( + String(new TextEncoder().encode(transformed).byteLength), + ); + expect(response.headers.get("etag")).toBeNull(); + expect(response.headers.get("last-modified")).toBeNull(); + expect(yield* Effect.promise(() => response.text())).toBe( + method === "GET" ? transformed : "", + ); + } + for (const ordinary of [ + { path: file, mimeType: "text/html" }, + { ...asset, download: true }, + ]) { + const response = HttpServerResponse.toWeb(yield* assetFileResponse(ordinary)); + expect(yield* Effect.promise(() => response.text())).toBe(saved); + } + expect(yield* fs.readFileString(file)).toBe(saved); + }).pipe(Effect.provide(layerFileResponse)), + ); + + it.effect("bounds transformed capture reads by the MCP document limit", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-mcp-response-limit-" }); + const file = path.join(directory, "saved.html"); + const asset = { path: file, renderIntent: "mcp-app" as const }; + const limit = MCP_APP_MAX_HTML_BYTES + 128 * 1024; + yield* fs.writeFileString(file, "x".repeat(limit)); + expect((yield* assetFileResponse(asset)).status).toBe(200); + yield* fs.writeFileString(file, "x".repeat(limit + 1)); + expect((yield* assetFileResponse(asset)).status).toBe(413); + }).pipe(Effect.provide(layerFileResponse)), + ); +}); + describe("video asset byte ranges", () => { it.effect("uses current descriptor metadata after an in-place truncate or extension", () => Effect.gen(function* () { diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index de4ce26cc915..8c6901d20ded 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -4,6 +4,7 @@ import { AuthOrchestrationReadScope, EnvironmentHttpApi, } from "@t3tools/contracts"; +import { injectMcpAppCookieBootstrap, MCP_APP_MAX_HTML_BYTES } from "@t3tools/shared/mcpApp"; import { isDevProxiedPath } from "@t3tools/shared/devProxy"; import { decodeOtlpTraceRecords } from "@t3tools/shared/observability"; import * as Data from "effect/Data"; @@ -167,12 +168,40 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* ( readonly fileName?: string; readonly mimeType?: string; readonly file?: OpenMediaFile; + readonly renderIntent?: "mcp-app"; }, rangeHeader?: string, ifRangeHeader?: string, method: "GET" | "HEAD" = "GET", ) { const headers = assetResponseHeaders(asset.path, asset); + if ( + asset.renderIntent === "mcp-app" && + !asset.download && + headers["Content-Type"] === "text/html; charset=utf-8" + ) { + const fs = yield* FileSystem.FileSystem; + // Saved captures also contain a bounded CSP meta tag added after the raw HTML limit. + const limit = MCP_APP_MAX_HTML_BYTES + 128 * 1024; + const chunks = yield* fs.stream(asset.path, { bytesToRead: limit + 1 }).pipe(Stream.runCollect); + const size = chunks.reduce((total, chunk) => total + chunk.byteLength, 0); + if (size > limit) { + return HttpServerResponse.text("MCP App is too large to preview.", { status: 413 }); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + const html = injectMcpAppCookieBootstrap(new TextDecoder().decode(bytes)); + // The served bytes differ from the saved capture: do not reuse its file + // length or validators. HEAD reports the transformed document's length. + const response = HttpServerResponse.text(html, { headers }); + return method === "HEAD" + ? HttpServerResponse.empty({ status: 200, headers: response.headers }) + : response; + } const mediaFile = asset.file; const mediaInfo = mediaFile ? yield* statMediaFile(asset.path, mediaFile) : undefined; const isMedia = /^(?:audio|video)\//i.test(headers["Content-Type"] ?? ""); diff --git a/apps/web/src/components/chat/McpAppFrame.tsx b/apps/web/src/components/chat/McpAppFrame.tsx index b7864c1035d3..9fdf62872d9e 100644 --- a/apps/web/src/components/chat/McpAppFrame.tsx +++ b/apps/web/src/components/chat/McpAppFrame.tsx @@ -117,6 +117,7 @@ export function McpAppFrame(props: { fileName: mcpAppFileName(app), mimeType: "text/html", disposition: "inline" as const, + renderIntent: "mcp-app" as const, }), [app], ); diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts index 8d7b8b5d2d55..cd6f88478eb0 100644 --- a/packages/contracts/src/assets.ts +++ b/packages/contracts/src/assets.ts @@ -40,6 +40,8 @@ export const AssetResource = Schema.Union([ /** Generic attachments download by default. Document viewers opt into an inline response after deciding the file type is safe to preview. */ disposition: Schema.optionalKey(Schema.Literals(["inline", "attachment"])), + /** Apply MCP App document compatibility when rendering a saved capture. */ + renderIntent: Schema.optionalKey(Schema.Literal("mcp-app")), }), // An image a tool returned inline, such as a device screenshot, by its order // in the stored output. The timeline never carries these bytes. diff --git a/packages/shared/src/mcpApp.test.ts b/packages/shared/src/mcpApp.test.ts index b5c68a3f6535..dd784885a189 100644 --- a/packages/shared/src/mcpApp.test.ts +++ b/packages/shared/src/mcpApp.test.ts @@ -1,7 +1,9 @@ +import * as NodeVM from "node:vm"; import { describe, expect, it } from "vite-plus/test"; import { injectMcpAppCsp, + injectMcpAppCookieBootstrap, mcpAppContentSecurityPolicy, mcpAppToolCallableByApp, readMcpAppCsp, @@ -148,3 +150,61 @@ describe("mcpAppFromToolItem", () => { expect(output?.t3McpApp?.server).toBe("weather"); }); }); + +describe("MCP App cookie bootstrap", () => { + const vendor = ``; + const execute = (html: string, document: object) => { + for (const script of html.matchAll(/`; + // Saved captures begin with the CSP inserted by injectMcpAppCsp. Keep it + // ahead of executable code, including this bootstrap, without rewriting it. + const prefix = + /^(?:\uFEFF?\s*]*>\s*)?(?:)?/i.exec( + html, + )![0]; + return `${prefix}${bootstrap}${html.slice(prefix.length)}`; +} + /** The iframe `allow` attribute for the permissions an app declared. */ export function mcpAppAllowAttribute(permissions: McpAppPermissions | undefined): string { const features: Array = []; From f516c79fe51cf31a2df16b132bf50f71a846a87f Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Wed, 7 Oct 2026 18:46:54 -0700 Subject: [PATCH 2/3] refactor(server): prepare MCP documents in the asset domain --- apps/server/src/assets/AssetAccess.ts | 24 ++++++++++++++++++++++++ apps/server/src/http.test.ts | 2 +- apps/server/src/http.ts | 20 ++++---------------- 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index c7dceebc8daa..41e8058d0746 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -36,6 +36,8 @@ import { PROJECT_FAVICON_FALLBACK_MARKER, } from "@t3tools/shared/projectFavicon"; import { MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH, toolOutputImages } from "@t3tools/shared/toolOutput"; +import { injectMcpAppCookieBootstrap, MCP_APP_MAX_HTML_BYTES } from "@t3tools/shared/mcpApp"; +import * as Stream from "effect/Stream"; import * as Clock from "effect/Clock"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -815,6 +817,28 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }; }); +/** Read and prepare a saved MCP document without trusting its file length. */ +export const readMcpAppDocument = Effect.fn("AssetAccess.readMcpAppDocument")(function* ( + path: string, +) { + const fs = yield* FileSystem.FileSystem; + // Saved captures also contain a bounded CSP meta tag added after the raw HTML limit. + const limit = MCP_APP_MAX_HTML_BYTES + 128 * 1024; + const chunks = yield* fs.stream(path, { bytesToRead: limit + 1 }).pipe(Stream.runCollect); + const size = chunks.reduce((total, chunk) => total + chunk.byteLength, 0); + if (size > limit) return { _tag: "TooLarge" as const }; + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return { + _tag: "Document" as const, + html: injectMcpAppCookieBootstrap(new TextDecoder().decode(bytes)), + }; +}); + export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( token: string, relativePath: string, diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index d3152490fe56..88c1338b247b 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -336,7 +336,7 @@ describe("MCP App asset responses", () => { ); expect(response.status).toBe(200); expect(response.headers.get("content-security-policy")).toBe( - "sandbox allow-scripts allow-forms allow-popups", + "sandbox allow-scripts allow-forms allow-popups allow-downloads", ); expect(response.headers.get("content-length")).toBe( String(new TextEncoder().encode(transformed).byteLength), diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 8c6901d20ded..ca26bc322f8c 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -4,7 +4,6 @@ import { AuthOrchestrationReadScope, EnvironmentHttpApi, } from "@t3tools/contracts"; -import { injectMcpAppCookieBootstrap, MCP_APP_MAX_HTML_BYTES } from "@t3tools/shared/mcpApp"; import { isDevProxiedPath } from "@t3tools/shared/devProxy"; import { decodeOtlpTraceRecords } from "@t3tools/shared/observability"; import * as Data from "effect/Data"; @@ -29,7 +28,7 @@ import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; import { OtlpTracer, OtlpSerialization } from "effect/observability"; import * as ServerConfig from "./config.ts"; -import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts"; +import { ASSET_ROUTE_PREFIX, readMcpAppDocument, resolveAsset } from "./assets/AssetAccess.ts"; import { githubMediaResponse } from "./assets/GitHubMediaFetch.ts"; import { statMediaFile, streamMediaFile, type OpenMediaFile } from "./assets/MediaFile.ts"; import { @@ -180,24 +179,13 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* ( !asset.download && headers["Content-Type"] === "text/html; charset=utf-8" ) { - const fs = yield* FileSystem.FileSystem; - // Saved captures also contain a bounded CSP meta tag added after the raw HTML limit. - const limit = MCP_APP_MAX_HTML_BYTES + 128 * 1024; - const chunks = yield* fs.stream(asset.path, { bytesToRead: limit + 1 }).pipe(Stream.runCollect); - const size = chunks.reduce((total, chunk) => total + chunk.byteLength, 0); - if (size > limit) { + const document = yield* readMcpAppDocument(asset.path); + if (document._tag === "TooLarge") { return HttpServerResponse.text("MCP App is too large to preview.", { status: 413 }); } - const bytes = new Uint8Array(size); - let offset = 0; - for (const chunk of chunks) { - bytes.set(chunk, offset); - offset += chunk.byteLength; - } - const html = injectMcpAppCookieBootstrap(new TextDecoder().decode(bytes)); // The served bytes differ from the saved capture: do not reuse its file // length or validators. HEAD reports the transformed document's length. - const response = HttpServerResponse.text(html, { headers }); + const response = HttpServerResponse.text(document.html, { headers }); return method === "HEAD" ? HttpServerResponse.empty({ status: 200, headers: response.headers }) : response; From b9d1d8405625cd1f1deb328b695791cdb8ce84f6 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Sun, 11 Oct 2026 00:17:04 -0700 Subject: [PATCH 3/3] refactor(server): prepare MCP app documents through an asset service --- apps/server/src/assets/AssetAccess.test.ts | 2 + apps/server/src/assets/AssetAccess.ts | 24 -------- apps/server/src/assets/McpAppDocument.test.ts | 20 +++++++ apps/server/src/assets/McpAppDocument.ts | 58 +++++++++++++++++++ apps/server/src/http.test.ts | 5 +- apps/server/src/http.ts | 6 +- apps/server/src/server.ts | 2 + 7 files changed, 90 insertions(+), 27 deletions(-) create mode 100644 apps/server/src/assets/McpAppDocument.test.ts create mode 100644 apps/server/src/assets/McpAppDocument.ts diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 76f3ba8f2a20..853b57a81146 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -32,6 +32,7 @@ import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import { assetFileResponse } from "../http.ts"; import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts"; import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; +import * as McpAppDocument from "./McpAppDocument.ts"; import { openMediaFile } from "./MediaFile.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import * as GitHubCredentials from "@t3tools/source-control-github/server/GitHubCredentials"; @@ -117,6 +118,7 @@ const layerTest = Layer.mergeAll( Layer.provide(T3ProjectFileLoader.layer), ), NativeAppIconResolver.layer.pipe(Layer.provide(layerConfig)), + McpAppDocument.layer, ServerSecretStore.layer.pipe(Layer.provide(layerConfig)), ).pipe(Layer.provideMerge(NodeServices.layer)); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 41e8058d0746..c7dceebc8daa 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -36,8 +36,6 @@ import { PROJECT_FAVICON_FALLBACK_MARKER, } from "@t3tools/shared/projectFavicon"; import { MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH, toolOutputImages } from "@t3tools/shared/toolOutput"; -import { injectMcpAppCookieBootstrap, MCP_APP_MAX_HTML_BYTES } from "@t3tools/shared/mcpApp"; -import * as Stream from "effect/Stream"; import * as Clock from "effect/Clock"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -817,28 +815,6 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }; }); -/** Read and prepare a saved MCP document without trusting its file length. */ -export const readMcpAppDocument = Effect.fn("AssetAccess.readMcpAppDocument")(function* ( - path: string, -) { - const fs = yield* FileSystem.FileSystem; - // Saved captures also contain a bounded CSP meta tag added after the raw HTML limit. - const limit = MCP_APP_MAX_HTML_BYTES + 128 * 1024; - const chunks = yield* fs.stream(path, { bytesToRead: limit + 1 }).pipe(Stream.runCollect); - const size = chunks.reduce((total, chunk) => total + chunk.byteLength, 0); - if (size > limit) return { _tag: "TooLarge" as const }; - const bytes = new Uint8Array(size); - let offset = 0; - for (const chunk of chunks) { - bytes.set(chunk, offset); - offset += chunk.byteLength; - } - return { - _tag: "Document" as const, - html: injectMcpAppCookieBootstrap(new TextDecoder().decode(bytes)), - }; -}); - export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( token: string, relativePath: string, diff --git a/apps/server/src/assets/McpAppDocument.test.ts b/apps/server/src/assets/McpAppDocument.test.ts new file mode 100644 index 000000000000..39679d0dd385 --- /dev/null +++ b/apps/server/src/assets/McpAppDocument.test.ts @@ -0,0 +1,20 @@ +import { expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as McpAppDocument from "./McpAppDocument.ts"; + +it.effect("preserves filesystem failures as typed MCP document read errors", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-mcp-document-" }); + const missingPath = path.join(directory, "missing.html"); + const documents = yield* McpAppDocument.McpAppDocument; + const error = yield* documents.read(missingPath).pipe(Effect.flip); + expect(error).toBeInstanceOf(McpAppDocument.McpAppDocumentReadError); + expect(error.path).toBe(missingPath); + expect(error.cause).toMatchObject({ _tag: "PlatformError", reason: { _tag: "NotFound" } }); + }).pipe(Effect.provide(McpAppDocument.layer), Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/assets/McpAppDocument.ts b/apps/server/src/assets/McpAppDocument.ts new file mode 100644 index 000000000000..4076ee50b45c --- /dev/null +++ b/apps/server/src/assets/McpAppDocument.ts @@ -0,0 +1,58 @@ +import { injectMcpAppCookieBootstrap, MCP_APP_MAX_HTML_BYTES } from "@t3tools/shared/mcpApp"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +export class McpAppDocumentReadError extends Schema.TaggedError()( + "McpAppDocumentReadError", + { path: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to read the MCP App document."; + } +} + +export class McpAppDocument extends Context.Service< + McpAppDocument, + { + readonly read: ( + path: string, + ) => Effect.Effect< + { readonly _tag: "Document"; readonly html: string } | { readonly _tag: "TooLarge" }, + McpAppDocumentReadError + >; + } +>()("t3/assets/McpAppDocument") {} + +const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + + /** Read and prepare a saved MCP document without trusting its file length. */ + const read = Effect.fn("McpAppDocument.read")(function* (path: string) { + // Saved captures also contain a bounded CSP meta tag added after the raw HTML limit. + const limit = MCP_APP_MAX_HTML_BYTES + 128 * 1024; + const chunks = yield* fileSystem.stream(path, { bytesToRead: limit + 1 }).pipe( + Stream.runCollect, + Effect.mapError((cause) => new McpAppDocumentReadError({ path, cause })), + ); + const size = chunks.reduce((total, chunk) => total + chunk.byteLength, 0); + if (size > limit) return { _tag: "TooLarge" as const }; + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return { + _tag: "Document" as const, + html: injectMcpAppCookieBootstrap(new TextDecoder().decode(bytes)), + }; + }); + + return McpAppDocument.of({ read }); +}); + +export const layer = Layer.effect(McpAppDocument, make); diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 88c1338b247b..4d4c06b0258e 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -20,6 +20,7 @@ import * as Queue from "effect/Queue"; import * as Tracer from "effect/Tracer"; import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/http"; import { openMediaFile } from "./assets/MediaFile.ts"; +import * as McpAppDocument from "./assets/McpAppDocument.ts"; import { ORCHESTRATION_PROTOCOL_HEADER } from "@t3tools/contracts"; @@ -120,7 +121,9 @@ describe("browser API CORS", () => { }); }); -const layerFileResponse = Layer.mergeAll(NodeHttpPlatform.layer, NodeServices.layer); +const layerFileResponse = Layer.merge(NodeHttpPlatform.layer, McpAppDocument.layer).pipe( + Layer.provideMerge(NodeServices.layer), +); const makeStaticRequest = Effect.fn("HttpTest.makeStaticRequest")(function* (staticDir: string) { const config = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index ca26bc322f8c..352ce4cf83c7 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -28,7 +28,8 @@ import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; import { OtlpTracer, OtlpSerialization } from "effect/observability"; import * as ServerConfig from "./config.ts"; -import { ASSET_ROUTE_PREFIX, readMcpAppDocument, resolveAsset } from "./assets/AssetAccess.ts"; +import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts"; +import * as McpAppDocument from "./assets/McpAppDocument.ts"; import { githubMediaResponse } from "./assets/GitHubMediaFetch.ts"; import { statMediaFile, streamMediaFile, type OpenMediaFile } from "./assets/MediaFile.ts"; import { @@ -179,7 +180,8 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* ( !asset.download && headers["Content-Type"] === "text/html; charset=utf-8" ) { - const document = yield* readMcpAppDocument(asset.path); + const mcpAppDocument = yield* McpAppDocument.McpAppDocument; + const document = yield* mcpAppDocument.read(asset.path); if (document._tag === "TooLarge") { return HttpServerResponse.text("MCP App is too large to preview.", { status: 413 }); } diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f1daa720016f..945a54649b1f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -28,6 +28,7 @@ import * as HostPowerMonitor from "./background/HostPowerMonitor.ts"; import * as ServerConfig from "./config.ts"; import { withUntracedRequests } from "./http.ts"; import * as ServerHttp from "./http.ts"; +import * as McpAppDocument from "./assets/McpAppDocument.ts"; import { guardHttpResponseWriteErrors } from "./httpResponseErrorGuard.ts"; import { fixPath } from "./os-jank.ts"; import * as Ws from "./ws.ts"; @@ -1087,6 +1088,7 @@ const layerMakeServer = Layer.unwrap( ); return layerServerApplication.pipe( + Layer.provide(McpAppDocument.layer), // The connect routes and the startup/shutdown link work share one instance. Layer.provide(CloudLink.layer), Layer.provideMerge(layerRuntimeServices),