From 2e19207b179dca57d57ecd24a4b0b0776a41e0bf Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 11 Oct 2026 11:33:42 -0700 Subject: [PATCH] fix(client): apply host color scheme to saved MCP apps (#17026) --- .../src/features/threads/McpAppWebView.tsx | 1 + apps/server/src/assets/AssetAccess.test.ts | 104 +++++++++++++++++- apps/server/src/assets/AssetAccess.ts | 26 +++++ apps/server/src/http.test.ts | 69 ++++++++++++ apps/server/src/http.ts | 21 +++- apps/web/src/components/chat/McpAppFrame.tsx | 1 + packages/contracts/src/assets.ts | 2 + packages/shared/src/mcpApp.test.ts | 61 ++++++++++ packages/shared/src/mcpApp.ts | 34 ++++++ 9 files changed, 317 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/McpAppWebView.tsx b/apps/mobile/src/features/threads/McpAppWebView.tsx index 6367a36f7bdaad0554bdcb403a75888e3837f4f9..7515ed9e41df81a3fed2b5086c08b7db27566752 100644 --- a/apps/mobile/src/features/threads/McpAppWebView.tsx +++ b/apps/mobile/src/features/threads/McpAppWebView.tsx @@ -162,6 +162,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 4e71c68f875c8302aaa33943c058479f6e063117..e32f41372528f4f4083283ddea4387e1e82084ea 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -9,6 +9,7 @@ import { TurnItemId, } from "@t3tools/contracts"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; +import { MCP_APP_MAX_HTML_BYTES } from "@t3tools/shared/mcpApp"; import { describe, expect, it } from "@effect/vitest"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -30,7 +31,7 @@ import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import { assetFileResponse } from "../http.ts"; -import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts"; +import { ASSET_ROUTE_PREFIX, issueAssetUrl, readMcpAppHtml, resolveAsset } from "./AssetAccess.ts"; import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; import { openMediaFile } from "./MediaFile.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; @@ -121,6 +122,64 @@ const layerTest = Layer.mergeAll( ).pipe(Layer.provideMerge(NodeServices.layer)); describe("AssetAccess", () => { + describe("readMcpAppHtml", () => { + it.effect("accepts snapshots at the stored HTML byte limit", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-mcp-html-" }); + const file = path.join(directory, "app.html"); + const limit = MCP_APP_MAX_HTML_BYTES + 128 * 1024; + yield* fileSystem.writeFile(file, new Uint8Array(limit).fill(120)); + + const bytes = yield* readMcpAppHtml(file); + expect(bytes?.byteLength).toBe(limit); + expect(bytes?.every((byte) => byte === 120)).toBe(true); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("rejects snapshots one byte past the limit and much larger files", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-mcp-html-" }); + const file = path.join(directory, "app.html"); + const limit = MCP_APP_MAX_HTML_BYTES + 128 * 1024; + for (const size of [limit + 1, limit * 2]) { + yield* fileSystem.writeFile(file, new Uint8Array(size).fill(120)); + expect(yield* readMcpAppHtml(file)).toBeNull(); + } + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("preserves UTF-8 bytes across stream chunks and accepts empty snapshots", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-mcp-html-" }); + const file = path.join(directory, "app.html"); + for (const html of ["", "" + "☃".repeat(100_000) + ""]) { + const expected = new TextEncoder().encode(html); + yield* fileSystem.writeFile(file, expected); + const bytes = yield* readMcpAppHtml(file); + expect(bytes?.byteLength).toBe(expected.byteLength); + expect(bytes && new TextDecoder().decode(bytes)).toBe(html); + } + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("preserves filesystem failures instead of reporting oversized HTML", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-mcp-html-" }); + const error = yield* readMcpAppHtml(path.join(directory, "missing.html")).pipe(Effect.flip); + expect(error._tag).toBe("PlatformError"); + expect(error.reason._tag).toBe("NotFound"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + }); + it.effect("loads private media immediately after login with the GitHub credential", () => { let lookups = 0; const authorizations: Array = []; @@ -903,6 +962,49 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(layerTest)), ); + it.effect("signs MCP render intent only for inline HTML attachments", () => + 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 of ["html", "pdf"]) { + const attachmentId = `thread-1-00000000-0000-4000-8000-000000000031-${extension}`; + yield* fs.writeFileString( + path.join(config.attachmentsDir, `${attachmentId}.${extension}`), + "capture", + ); + for (const disposition of ["inline", "attachment"] as const) { + const result = yield* issueAssetUrl({ + resource: { + _tag: "attachment", + attachmentId, + disposition, + renderIntent: "mcp-app", + }, + }); + 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), + ); + if (asset?.kind !== "file") throw new Error("Expected attachment file"); + expect("renderIntent" in asset ? asset.renderIntent : undefined).toBe( + extension === "html" && disposition === "inline" ? "mcp-app" : undefined, + ); + // Changing signed claims cannot opt an ordinary download into rendering. + const token = suffix.slice(0, separator); + const [payload, signature] = token.split("."); + const claims = JSON.parse(Buffer.from(payload!, "base64url").toString("utf8")); + claims.renderIntent = claims.renderIntent ? undefined : "mcp-app"; + const tampered = `${Buffer.from(JSON.stringify(claims)).toString("base64url")}.${signature}`; + expect(yield* resolveAsset(tampered, suffix.slice(separator + 1))).toBeNull(); + } + } + }).pipe(Effect.provide(layerTest)), + ); + it.effect("issues exact attachment capabilities by attachment id", () => 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 303137c149cdf82ce51afccff3cb0de2c76583bd..6b736c68f74efbb1359145328b121f4bd6658bac 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -31,6 +31,7 @@ import { type ImageDimensions, } from "@t3tools/shared/imageDimensions"; import { githubMediaFetchUrl, githubMediaFileName } from "@t3tools/shared/githubMedia"; +import { MCP_APP_MAX_HTML_BYTES } from "@t3tools/shared/mcpApp"; import { isProjectFaviconPath, PROJECT_FAVICON_FALLBACK_MARKER, @@ -45,6 +46,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import { base64UrlDecodeUtf8, @@ -128,6 +130,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 +183,7 @@ export type ResolvedAsset = readonly fileName?: string; readonly mimeType?: string; readonly file?: OpenMediaFile; + readonly renderIntent?: "mcp-app"; } | { readonly kind: "bytes"; @@ -222,6 +226,24 @@ const optionOnNotFound = ( }), ); +/** Reads a stored MCP HTML snapshot; null means it exceeds the preview limit. */ +export const readMcpAppHtml = Effect.fn("AssetAccess.readMcpAppHtml")(function* (filePath: string) { + const fileSystem = yield* FileSystem.FileSystem; + // Stored snapshots also contain a bounded CSP meta tag (up to 32 domains + // per directive). Bound the read even if the attachment grows after minting. + const limit = MCP_APP_MAX_HTML_BYTES + 128 * 1024; + const chunks = yield* Stream.runCollect(fileSystem.stream(filePath, { bytesToRead: limit + 1 })); + const size = chunks.reduce((total, chunk) => total + chunk.byteLength, 0); + if (size > limit) return null; + const bytes = new Uint8Array(size); + let position = 0; + for (const chunk of chunks) { + bytes.set(chunk, position); + position += chunk.byteLength; + } + return bytes; +}); + /** * Decodes one image a tool returned inline; null when the stored item has no * such image, or it is larger than a provider turn accepts. @@ -622,6 +644,9 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i : input.resource.mimeType !== undefined ? { mimeType: isVideo ? videoMimeType : input.resource.mimeType } : {}), + ...(input.resource.renderIntent === "mcp-app" && inlinePreviewMimeType === "text/html" + ? { renderIntent: "mcp-app" as const } + : {}), expiresAt, }; fileName = input.resource.fileName ?? path.basename(attachmentPath); @@ -853,6 +878,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 === "mcp-app" ? { renderIntent: "mcp-app" as const } : {}), } satisfies ResolvedAsset) : null; } diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index dc4ff0283d2e7783c8505cb667b9fbab4b707346..0ee1ca9c186f8bfc23e0160547203f25f1aca9f9 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -16,6 +16,11 @@ import * as Tracer from "effect/Tracer"; import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/http"; import { openMediaFile } from "./assets/MediaFile.ts"; +import { + injectMcpAppCsp, + injectMcpAppThemeBootstrap, + MCP_APP_MAX_HTML_BYTES, +} from "@t3tools/shared/mcpApp"; import { ORCHESTRATION_PROTOCOL_HEADER } from "@t3tools/contracts"; import * as ServerConfig from "./config.ts"; @@ -30,6 +35,70 @@ import { } from "./http.ts"; import * as ServerHttp from "./http.ts"; +describe("MCP app asset responses", () => { + it.effect( + "transforms existing snapshots only with explicit render intent and fresh metadata", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-mcp-theme-" }); + const file = path.join(directory, "app.html"); + const html = injectMcpAppCsp( + "☃", + undefined, + ); + yield* fs.writeFileString(file, html); + for (const method of ["GET", "HEAD"] as const) { + const response = HttpServerResponse.toWeb( + yield* assetFileResponse( + { path: file, mimeType: "text/html", renderIntent: "mcp-app" }, + "bytes=0-1", + undefined, + method, + ), + ); + const expected = injectMcpAppThemeBootstrap(html); + expect(response.status).toBe(200); + expect(response.headers.get("content-length")).toBe( + String(new TextEncoder().encode(expected).byteLength), + ); + expect(response.headers.get("etag")).toBeNull(); + expect(response.headers.get("last-modified")).toBeNull(); + expect(response.headers.get("content-security-policy")).toBe( + "sandbox allow-scripts allow-forms allow-popups allow-downloads", + ); + expect(yield* Effect.promise(() => response.text())).toBe( + method === "HEAD" ? "" : expected, + ); + } + for (const download of [false, true]) { + const response = HttpServerResponse.toWeb( + yield* assetFileResponse({ + path: file, + mimeType: "text/html", + download, + ...(download ? { renderIntent: "mcp-app" as const } : {}), + }), + ); + expect(yield* Effect.promise(() => response.text())).toBe(html); + } + }).pipe(Effect.provide(layerFileResponse)), + ); + + it.effect("rejects oversized captures before transforming them", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-mcp-theme-size-" }); + const file = path.join(directory, "app.html"); + yield* fs.writeFileString(file, "x".repeat(MCP_APP_MAX_HTML_BYTES + 128 * 1024 + 1)); + const response = yield* assetFileResponse({ path: file, renderIntent: "mcp-app" }); + expect(response.status).toBe(413); + }).pipe(Effect.provide(layerFileResponse)), + ); +}); + describe("untraced requests", () => { it.effect("drops the HTTP server span for browser trace exports, query string included", () => { const spanNames: Array = []; diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index de4ce26cc915d4576624b6064c0255f9107fd940..1e9c3d714d1cea54e9ca8e59526ed7ae0390b58c 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -5,6 +5,7 @@ import { EnvironmentHttpApi, } from "@t3tools/contracts"; import { isDevProxiedPath } from "@t3tools/shared/devProxy"; +import { injectMcpAppThemeBootstrap } from "@t3tools/shared/mcpApp"; import { decodeOtlpTraceRecords } from "@t3tools/shared/observability"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; @@ -28,7 +29,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, readMcpAppHtml, resolveAsset } from "./assets/AssetAccess.ts"; import { githubMediaResponse } from "./assets/GitHubMediaFetch.ts"; import { statMediaFile, streamMediaFile, type OpenMediaFile } from "./assets/MediaFile.ts"; import { @@ -167,12 +168,30 @@ 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 bytes = yield* readMcpAppHtml(asset.path); + if (bytes === null) { + return HttpServerResponse.text("MCP app is too large to preview.", { status: 413 }); + } + const body = new TextEncoder().encode( + injectMcpAppThemeBootstrap(new TextDecoder().decode(bytes)), + ); + headers["Content-Length"] = String(body.byteLength); + return method === "HEAD" + ? HttpServerResponse.empty({ status: 200, headers }) + : HttpServerResponse.uint8Array(body, { headers }); + } 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 b7864c1035d3d064987733f57db9ddaac0575b0a..9fdf62872d9e29e3f0711588c9d4c0f02ccdcab7 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 8d7b8b5d2d558c984fb63f87cc122a29bd38e8ee..dd7d3eb7c5428ddea5cfb636a881282577d075ea 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"])), + /** Render an MCP App snapshot with the host theme bootstrap. */ + 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 b5c68a3f65356126669149dbcdbce902a1113d6a..bca145a4fba88dc4ee9daac8fa7377ff3fa68cc5 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, + injectMcpAppThemeBootstrap, mcpAppContentSecurityPolicy, mcpAppToolCallableByApp, readMcpAppCsp, @@ -41,6 +43,65 @@ describe("injectMcpAppCsp", () => { }); }); +describe("injectMcpAppThemeBootstrap", () => { + it("keeps stored CSP before the bootstrap and vendor scripts after it", () => { + const snapshot = injectMcpAppCsp( + "", + undefined, + ); + const html = injectMcpAppThemeBootstrap(snapshot); + expect(html.indexOf("Content-Security-Policy")).toBeLessThan(html.indexOf("")); + const parent = {}; + const style = { colorScheme: "normal" }; + let receive: ((event: { source: object; data: unknown }) => void) | undefined; + NodeVM.runInNewContext(script, { + window: { + parent, + addEventListener: (_type: string, callback: typeof receive) => { + receive = callback; + }, + }, + document: { documentElement: { style } }, + }); + const send = (data: unknown, source = parent) => receive?.({ source, data }); + const initialized = { jsonrpc: "2.0", id: 1, result: { hostContext: { theme: "dark" } } }; + send(initialized, {}); + expect(style.colorScheme).toBe("normal"); + send(initialized); + expect(style.colorScheme).toBe("dark"); + for (const data of [ + null, + "bad", + {}, + { ...initialized, jsonrpc: "1.0" }, + { jsonrpc: "2.0", method: "other", params: { theme: "light" } }, + { + jsonrpc: "2.0", + method: "ui/notifications/host-context-changed", + params: { theme: "normal" }, + }, + { jsonrpc: "2.0", result: { hostContext: { theme: "light" } } }, + ]) + send(data); + expect(style.colorScheme).toBe("dark"); + send({ + jsonrpc: "2.0", + method: "ui/notifications/host-context-changed", + params: { theme: "light" }, + }); + expect(style.colorScheme).toBe("light"); + send(initialized); + expect(style.colorScheme).toBe("dark"); + }); +}); + describe("readMcpAppCsp", () => { it("drops entries that could widen or break the policy", () => { expect( diff --git a/packages/shared/src/mcpApp.ts b/packages/shared/src/mcpApp.ts index e3d43eb49aa16f9d6b4be7a2349bacfd2ed944a8..b99defbb504779a1ad5ead59d36015cb25419d5f 100644 --- a/packages/shared/src/mcpApp.ts +++ b/packages/shared/src/mcpApp.ts @@ -215,6 +215,40 @@ export function injectMcpAppCsp(html: string, csp: McpAppCsp | undefined): strin : `${meta}${html}`; } +/** + * Installed before vendor scripts so apps that omit their own theme handling + * still give their document canvas and native controls the host's appearance. + * The bridge uses an opaque origin, so authenticate messages by parent window. + */ +export function injectMcpAppThemeBootstrap(html: string): string { + const script = ``; + // Snapshots start with our doctype and CSP meta. Keep that policy ahead of + // the bootstrap, rather than letting an injected script precede it. + const prefix = + /^\uFEFF?\s*]*>\s*(?:]*>)?/i.exec( + html, + ); + return prefix + ? `${prefix[0]}${script}${html.slice(prefix[0].length)}` + : `${script}${html}`; +} + /** The iframe `allow` attribute for the permissions an app declared. */ export function mcpAppAllowAttribute(permissions: McpAppPermissions | undefined): string { const features: Array = []; -- 2.54.0