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 = [];