authorgravatar for me@pixp.ccAlex <me@pixp.cc> 2026-10-11 11:33:42-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-11 11:33:42-07:00
log2e19207b179dca57d57ecd24a4b0b0776a41e0bf
treedbe9f4f16b7e2a6808cbda93a8124f69f99591ad
parentb147dc99e31da173f0bbe2f25ced0f5cb5b2092b
signature Signed by PGP key B5690EEEBB952194

fix(client): apply host color scheme to saved MCP apps (#17026)


9 files changed, 317 insertions(+), 2 deletions(-)

apps/mobile/src/features/threads/McpAppWebView.tsx+1
...@@ -162,6 +162,7 @@ export function ThreadMcpApp(props: {...@@ -162,6 +162,7 @@ export function ThreadMcpApp(props: {
162 fileName: mcpAppFileName(app),162 fileName: mcpAppFileName(app),
163 mimeType: "text/html",163 mimeType: "text/html",
164 disposition: "inline" as const,164 disposition: "inline" as const,
165 renderIntent: "mcp-app" as const,
165 }),166 }),
166 [app],167 [app],
167 );168 );
apps/server/src/assets/AssetAccess.test.ts+103-1
...@@ -9,6 +9,7 @@ import {...@@ -9,6 +9,7 @@ import {
9 TurnItemId,9 TurnItemId,
10} from "@t3tools/contracts";10} from "@t3tools/contracts";
11import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";11import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
12import { MCP_APP_MAX_HTML_BYTES } from "@t3tools/shared/mcpApp";
12import { describe, expect, it } from "@effect/vitest";13import { describe, expect, it } from "@effect/vitest";
13import * as Crypto from "effect/Crypto";14import * as Crypto from "effect/Crypto";
14import * as DateTime from "effect/DateTime";15import * as DateTime from "effect/DateTime";
...@@ -30,7 +31,7 @@ import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";...@@ -30,7 +31,7 @@ import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
30import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts";31import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts";
31import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";32import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
32import { assetFileResponse } from "../http.ts";33import { assetFileResponse } from "../http.ts";
33import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts";34import { ASSET_ROUTE_PREFIX, issueAssetUrl, readMcpAppHtml, resolveAsset } from "./AssetAccess.ts";
34import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";35import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";
35import { openMediaFile } from "./MediaFile.ts";36import { openMediaFile } from "./MediaFile.ts";
36import { symlinksSupported } from "@t3tools/shared/testing/symlinks";37import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
...@@ -121,6 +122,64 @@ const layerTest = Layer.mergeAll(...@@ -121,6 +122,64 @@ const layerTest = Layer.mergeAll(
121).pipe(Layer.provideMerge(NodeServices.layer));122).pipe(Layer.provideMerge(NodeServices.layer));
122123
123describe("AssetAccess", () => {124describe("AssetAccess", () => {
125 describe("readMcpAppHtml", () => {
126 it.effect("accepts snapshots at the stored HTML byte limit", () =>
127 Effect.gen(function* () {
128 const fileSystem = yield* FileSystem.FileSystem;
129 const path = yield* Path.Path;
130 const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-mcp-html-" });
131 const file = path.join(directory, "app.html");
132 const limit = MCP_APP_MAX_HTML_BYTES + 128 * 1024;
133 yield* fileSystem.writeFile(file, new Uint8Array(limit).fill(120));
134
135 const bytes = yield* readMcpAppHtml(file);
136 expect(bytes?.byteLength).toBe(limit);
137 expect(bytes?.every((byte) => byte === 120)).toBe(true);
138 }).pipe(Effect.provide(NodeServices.layer)),
139 );
140
141 it.effect("rejects snapshots one byte past the limit and much larger files", () =>
142 Effect.gen(function* () {
143 const fileSystem = yield* FileSystem.FileSystem;
144 const path = yield* Path.Path;
145 const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-mcp-html-" });
146 const file = path.join(directory, "app.html");
147 const limit = MCP_APP_MAX_HTML_BYTES + 128 * 1024;
148 for (const size of [limit + 1, limit * 2]) {
149 yield* fileSystem.writeFile(file, new Uint8Array(size).fill(120));
150 expect(yield* readMcpAppHtml(file)).toBeNull();
151 }
152 }).pipe(Effect.provide(NodeServices.layer)),
153 );
154
155 it.effect("preserves UTF-8 bytes across stream chunks and accepts empty snapshots", () =>
156 Effect.gen(function* () {
157 const fileSystem = yield* FileSystem.FileSystem;
158 const path = yield* Path.Path;
159 const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-mcp-html-" });
160 const file = path.join(directory, "app.html");
161 for (const html of ["", "<body>" + "☃".repeat(100_000) + "</body>"]) {
162 const expected = new TextEncoder().encode(html);
163 yield* fileSystem.writeFile(file, expected);
164 const bytes = yield* readMcpAppHtml(file);
165 expect(bytes?.byteLength).toBe(expected.byteLength);
166 expect(bytes && new TextDecoder().decode(bytes)).toBe(html);
167 }
168 }).pipe(Effect.provide(NodeServices.layer)),
169 );
170
171 it.effect("preserves filesystem failures instead of reporting oversized HTML", () =>
172 Effect.gen(function* () {
173 const fileSystem = yield* FileSystem.FileSystem;
174 const path = yield* Path.Path;
175 const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-mcp-html-" });
176 const error = yield* readMcpAppHtml(path.join(directory, "missing.html")).pipe(Effect.flip);
177 expect(error._tag).toBe("PlatformError");
178 expect(error.reason._tag).toBe("NotFound");
179 }).pipe(Effect.provide(NodeServices.layer)),
180 );
181 });
182
124 it.effect("loads private media immediately after login with the GitHub credential", () => {183 it.effect("loads private media immediately after login with the GitHub credential", () => {
125 let lookups = 0;184 let lookups = 0;
126 const authorizations: Array<string | undefined> = [];185 const authorizations: Array<string | undefined> = [];
...@@ -903,6 +962,49 @@ describe("AssetAccess", () => {...@@ -903,6 +962,49 @@ describe("AssetAccess", () => {
903 }).pipe(Effect.provide(layerTest)),962 }).pipe(Effect.provide(layerTest)),
904 );963 );
905964
965 it.effect("signs MCP render intent only for inline HTML attachments", () =>
966 Effect.gen(function* () {
967 const config = yield* ServerConfig.ServerConfig;
968 const fs = yield* FileSystem.FileSystem;
969 const path = yield* Path.Path;
970 yield* fs.makeDirectory(config.attachmentsDir, { recursive: true });
971 for (const extension of ["html", "pdf"]) {
972 const attachmentId = `thread-1-00000000-0000-4000-8000-000000000031-${extension}`;
973 yield* fs.writeFileString(
974 path.join(config.attachmentsDir, `${attachmentId}.${extension}`),
975 "capture",
976 );
977 for (const disposition of ["inline", "attachment"] as const) {
978 const result = yield* issueAssetUrl({
979 resource: {
980 _tag: "attachment",
981 attachmentId,
982 disposition,
983 renderIntent: "mcp-app",
984 },
985 });
986 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
987 const separator = suffix.indexOf("/");
988 const asset = yield* resolveAsset(
989 suffix.slice(0, separator),
990 suffix.slice(separator + 1),
991 );
992 if (asset?.kind !== "file") throw new Error("Expected attachment file");
993 expect("renderIntent" in asset ? asset.renderIntent : undefined).toBe(
994 extension === "html" && disposition === "inline" ? "mcp-app" : undefined,
995 );
996 // Changing signed claims cannot opt an ordinary download into rendering.
997 const token = suffix.slice(0, separator);
998 const [payload, signature] = token.split(".");
999 const claims = JSON.parse(Buffer.from(payload!, "base64url").toString("utf8"));
1000 claims.renderIntent = claims.renderIntent ? undefined : "mcp-app";
1001 const tampered = `${Buffer.from(JSON.stringify(claims)).toString("base64url")}.${signature}`;
1002 expect(yield* resolveAsset(tampered, suffix.slice(separator + 1))).toBeNull();
1003 }
1004 }
1005 }).pipe(Effect.provide(layerTest)),
1006 );
1007
906 it.effect("issues exact attachment capabilities by attachment id", () =>1008 it.effect("issues exact attachment capabilities by attachment id", () =>
907 Effect.gen(function* () {1009 Effect.gen(function* () {
908 const config = yield* ServerConfig.ServerConfig;1010 const config = yield* ServerConfig.ServerConfig;
apps/server/src/assets/AssetAccess.ts+26
...@@ -31,6 +31,7 @@ import {...@@ -31,6 +31,7 @@ import {
31 type ImageDimensions,31 type ImageDimensions,
32} from "@t3tools/shared/imageDimensions";32} from "@t3tools/shared/imageDimensions";
33import { githubMediaFetchUrl, githubMediaFileName } from "@t3tools/shared/githubMedia";33import { githubMediaFetchUrl, githubMediaFileName } from "@t3tools/shared/githubMedia";
34import { MCP_APP_MAX_HTML_BYTES } from "@t3tools/shared/mcpApp";
34import {35import {
35 isProjectFaviconPath,36 isProjectFaviconPath,
36 PROJECT_FAVICON_FALLBACK_MARKER,37 PROJECT_FAVICON_FALLBACK_MARKER,
...@@ -45,6 +46,7 @@ import * as Option from "effect/Option";...@@ -45,6 +46,7 @@ import * as Option from "effect/Option";
45import * as Path from "effect/Path";46import * as Path from "effect/Path";
46import * as PlatformError from "effect/PlatformError";47import * as PlatformError from "effect/PlatformError";
47import * as Schema from "effect/Schema";48import * as Schema from "effect/Schema";
49import * as Stream from "effect/Stream";
4850
49import {51import {
50 base64UrlDecodeUtf8,52 base64UrlDecodeUtf8,
...@@ -128,6 +130,7 @@ const AssetClaimsSchema = Schema.Union([...@@ -128,6 +130,7 @@ const AssetClaimsSchema = Schema.Union([
128 download filename and Content-Type. */130 download filename and Content-Type. */
129 fileName: Schema.optionalKey(Schema.String),131 fileName: Schema.optionalKey(Schema.String),
130 mimeType: Schema.optionalKey(Schema.String),132 mimeType: Schema.optionalKey(Schema.String),
133 renderIntent: Schema.optionalKey(Schema.Literal("mcp-app")),
131 expiresAt: Schema.Number,134 expiresAt: Schema.Number,
132 }),135 }),
133 Schema.Struct({136 Schema.Struct({
...@@ -180,6 +183,7 @@ export type ResolvedAsset =...@@ -180,6 +183,7 @@ export type ResolvedAsset =
180 readonly fileName?: string;183 readonly fileName?: string;
181 readonly mimeType?: string;184 readonly mimeType?: string;
182 readonly file?: OpenMediaFile;185 readonly file?: OpenMediaFile;
186 readonly renderIntent?: "mcp-app";
183 }187 }
184 | {188 | {
185 readonly kind: "bytes";189 readonly kind: "bytes";
...@@ -222,6 +226,24 @@ const optionOnNotFound = <A, R>(...@@ -222,6 +226,24 @@ const optionOnNotFound = <A, R>(
222 }),226 }),
223 );227 );
224228
229/** Reads a stored MCP HTML snapshot; null means it exceeds the preview limit. */
230export const readMcpAppHtml = Effect.fn("AssetAccess.readMcpAppHtml")(function* (filePath: string) {
231 const fileSystem = yield* FileSystem.FileSystem;
232 // Stored snapshots also contain a bounded CSP meta tag (up to 32 domains
233 // per directive). Bound the read even if the attachment grows after minting.
234 const limit = MCP_APP_MAX_HTML_BYTES + 128 * 1024;
235 const chunks = yield* Stream.runCollect(fileSystem.stream(filePath, { bytesToRead: limit + 1 }));
236 const size = chunks.reduce((total, chunk) => total + chunk.byteLength, 0);
237 if (size > limit) return null;
238 const bytes = new Uint8Array(size);
239 let position = 0;
240 for (const chunk of chunks) {
241 bytes.set(chunk, position);
242 position += chunk.byteLength;
243 }
244 return bytes;
245});
246
225/**247/**
226 * Decodes one image a tool returned inline; null when the stored item has no248 * Decodes one image a tool returned inline; null when the stored item has no
227 * such image, or it is larger than a provider turn accepts.249 * such image, or it is larger than a provider turn accepts.
...@@ -622,6 +644,9 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -622,6 +644,9 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
622 : input.resource.mimeType !== undefined644 : input.resource.mimeType !== undefined
623 ? { mimeType: isVideo ? videoMimeType : input.resource.mimeType }645 ? { mimeType: isVideo ? videoMimeType : input.resource.mimeType }
624 : {}),646 : {}),
647 ...(input.resource.renderIntent === "mcp-app" && inlinePreviewMimeType === "text/html"
648 ? { renderIntent: "mcp-app" as const }
649 : {}),
625 expiresAt,650 expiresAt,
626 };651 };
627 fileName = input.resource.fileName ?? path.basename(attachmentPath);652 fileName = input.resource.fileName ?? path.basename(attachmentPath);
...@@ -853,6 +878,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (...@@ -853,6 +878,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
853 ...(claims.download ? { download: true } : {}),878 ...(claims.download ? { download: true } : {}),
854 ...(claims.fileName !== undefined ? { fileName: claims.fileName } : {}),879 ...(claims.fileName !== undefined ? { fileName: claims.fileName } : {}),
855 ...(claims.mimeType !== undefined ? { mimeType: claims.mimeType } : {}),880 ...(claims.mimeType !== undefined ? { mimeType: claims.mimeType } : {}),
881 ...(claims.renderIntent === "mcp-app" ? { renderIntent: "mcp-app" as const } : {}),
856 } satisfies ResolvedAsset)882 } satisfies ResolvedAsset)
857 : null;883 : null;
858 }884 }
apps/server/src/http.test.ts+69
...@@ -16,6 +16,11 @@ import * as Tracer from "effect/Tracer";...@@ -16,6 +16,11 @@ import * as Tracer from "effect/Tracer";
16import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/http";16import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/http";
17import { openMediaFile } from "./assets/MediaFile.ts";17import { openMediaFile } from "./assets/MediaFile.ts";
1818
19import {
20 injectMcpAppCsp,
21 injectMcpAppThemeBootstrap,
22 MCP_APP_MAX_HTML_BYTES,
23} from "@t3tools/shared/mcpApp";
19import { ORCHESTRATION_PROTOCOL_HEADER } from "@t3tools/contracts";24import { ORCHESTRATION_PROTOCOL_HEADER } from "@t3tools/contracts";
2025
21import * as ServerConfig from "./config.ts";26import * as ServerConfig from "./config.ts";
...@@ -30,6 +35,70 @@ import {...@@ -30,6 +35,70 @@ import {
30} from "./http.ts";35} from "./http.ts";
31import * as ServerHttp from "./http.ts";36import * as ServerHttp from "./http.ts";
3237
38describe("MCP app asset responses", () => {
39 it.effect(
40 "transforms existing snapshots only with explicit render intent and fresh metadata",
41 () =>
42 Effect.gen(function* () {
43 const fs = yield* FileSystem.FileSystem;
44 const path = yield* Path.Path;
45 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-mcp-theme-" });
46 const file = path.join(directory, "app.html");
47 const html = injectMcpAppCsp(
48 "<html><head><script>vendor()</script></head><body>☃</body></html>",
49 undefined,
50 );
51 yield* fs.writeFileString(file, html);
52 for (const method of ["GET", "HEAD"] as const) {
53 const response = HttpServerResponse.toWeb(
54 yield* assetFileResponse(
55 { path: file, mimeType: "text/html", renderIntent: "mcp-app" },
56 "bytes=0-1",
57 undefined,
58 method,
59 ),
60 );
61 const expected = injectMcpAppThemeBootstrap(html);
62 expect(response.status).toBe(200);
63 expect(response.headers.get("content-length")).toBe(
64 String(new TextEncoder().encode(expected).byteLength),
65 );
66 expect(response.headers.get("etag")).toBeNull();
67 expect(response.headers.get("last-modified")).toBeNull();
68 expect(response.headers.get("content-security-policy")).toBe(
69 "sandbox allow-scripts allow-forms allow-popups allow-downloads",
70 );
71 expect(yield* Effect.promise(() => response.text())).toBe(
72 method === "HEAD" ? "" : expected,
73 );
74 }
75 for (const download of [false, true]) {
76 const response = HttpServerResponse.toWeb(
77 yield* assetFileResponse({
78 path: file,
79 mimeType: "text/html",
80 download,
81 ...(download ? { renderIntent: "mcp-app" as const } : {}),
82 }),
83 );
84 expect(yield* Effect.promise(() => response.text())).toBe(html);
85 }
86 }).pipe(Effect.provide(layerFileResponse)),
87 );
88
89 it.effect("rejects oversized captures before transforming them", () =>
90 Effect.gen(function* () {
91 const fs = yield* FileSystem.FileSystem;
92 const path = yield* Path.Path;
93 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-mcp-theme-size-" });
94 const file = path.join(directory, "app.html");
95 yield* fs.writeFileString(file, "x".repeat(MCP_APP_MAX_HTML_BYTES + 128 * 1024 + 1));
96 const response = yield* assetFileResponse({ path: file, renderIntent: "mcp-app" });
97 expect(response.status).toBe(413);
98 }).pipe(Effect.provide(layerFileResponse)),
99 );
100});
101
33describe("untraced requests", () => {102describe("untraced requests", () => {
34 it.effect("drops the HTTP server span for browser trace exports, query string included", () => {103 it.effect("drops the HTTP server span for browser trace exports, query string included", () => {
35 const spanNames: Array<string> = [];104 const spanNames: Array<string> = [];
apps/server/src/http.ts+20-1
...@@ -5,6 +5,7 @@ import {...@@ -5,6 +5,7 @@ import {
5 EnvironmentHttpApi,5 EnvironmentHttpApi,
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import { isDevProxiedPath } from "@t3tools/shared/devProxy";7import { isDevProxiedPath } from "@t3tools/shared/devProxy";
8import { injectMcpAppThemeBootstrap } from "@t3tools/shared/mcpApp";
8import { decodeOtlpTraceRecords } from "@t3tools/shared/observability";9import { decodeOtlpTraceRecords } from "@t3tools/shared/observability";
9import * as Data from "effect/Data";10import * as Data from "effect/Data";
10import * as Effect from "effect/Effect";11import * as Effect from "effect/Effect";
...@@ -28,7 +29,7 @@ import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";...@@ -28,7 +29,7 @@ import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
28import { OtlpTracer, OtlpSerialization } from "effect/observability";29import { OtlpTracer, OtlpSerialization } from "effect/observability";
2930
30import * as ServerConfig from "./config.ts";31import * as ServerConfig from "./config.ts";
31import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts";32import { ASSET_ROUTE_PREFIX, readMcpAppHtml, resolveAsset } from "./assets/AssetAccess.ts";
32import { githubMediaResponse } from "./assets/GitHubMediaFetch.ts";33import { githubMediaResponse } from "./assets/GitHubMediaFetch.ts";
33import { statMediaFile, streamMediaFile, type OpenMediaFile } from "./assets/MediaFile.ts";34import { statMediaFile, streamMediaFile, type OpenMediaFile } from "./assets/MediaFile.ts";
34import {35import {
...@@ -167,12 +168,30 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* (...@@ -167,12 +168,30 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* (
167 readonly fileName?: string;168 readonly fileName?: string;
168 readonly mimeType?: string;169 readonly mimeType?: string;
169 readonly file?: OpenMediaFile;170 readonly file?: OpenMediaFile;
171 readonly renderIntent?: "mcp-app";
170 },172 },
171 rangeHeader?: string,173 rangeHeader?: string,
172 ifRangeHeader?: string,174 ifRangeHeader?: string,
173 method: "GET" | "HEAD" = "GET",175 method: "GET" | "HEAD" = "GET",
174) {176) {
175 const headers = assetResponseHeaders(asset.path, asset);177 const headers = assetResponseHeaders(asset.path, asset);
178 if (
179 asset.renderIntent === "mcp-app" &&
180 !asset.download &&
181 headers["Content-Type"] === "text/html; charset=utf-8"
182 ) {
183 const bytes = yield* readMcpAppHtml(asset.path);
184 if (bytes === null) {
185 return HttpServerResponse.text("MCP app is too large to preview.", { status: 413 });
186 }
187 const body = new TextEncoder().encode(
188 injectMcpAppThemeBootstrap(new TextDecoder().decode(bytes)),
189 );
190 headers["Content-Length"] = String(body.byteLength);
191 return method === "HEAD"
192 ? HttpServerResponse.empty({ status: 200, headers })
193 : HttpServerResponse.uint8Array(body, { headers });
194 }
176 const mediaFile = asset.file;195 const mediaFile = asset.file;
177 const mediaInfo = mediaFile ? yield* statMediaFile(asset.path, mediaFile) : undefined;196 const mediaInfo = mediaFile ? yield* statMediaFile(asset.path, mediaFile) : undefined;
178 const isMedia = /^(?:audio|video)\//i.test(headers["Content-Type"] ?? "");197 const isMedia = /^(?:audio|video)\//i.test(headers["Content-Type"] ?? "");
apps/web/src/components/chat/McpAppFrame.tsx+1
...@@ -117,6 +117,7 @@ export function McpAppFrame(props: {...@@ -117,6 +117,7 @@ export function McpAppFrame(props: {
117 fileName: mcpAppFileName(app),117 fileName: mcpAppFileName(app),
118 mimeType: "text/html",118 mimeType: "text/html",
119 disposition: "inline" as const,119 disposition: "inline" as const,
120 renderIntent: "mcp-app" as const,
120 }),121 }),
121 [app],122 [app],
122 );123 );
packages/contracts/src/assets.ts+2
...@@ -40,6 +40,8 @@ export const AssetResource = Schema.Union([...@@ -40,6 +40,8 @@ export const AssetResource = Schema.Union([
40 /** Generic attachments download by default. Document viewers opt into an40 /** Generic attachments download by default. Document viewers opt into an
41 inline response after deciding the file type is safe to preview. */41 inline response after deciding the file type is safe to preview. */
42 disposition: Schema.optionalKey(Schema.Literals(["inline", "attachment"])),42 disposition: Schema.optionalKey(Schema.Literals(["inline", "attachment"])),
43 /** Render an MCP App snapshot with the host theme bootstrap. */
44 renderIntent: Schema.optionalKey(Schema.Literal("mcp-app")),
43 }),45 }),
44 // An image a tool returned inline, such as a device screenshot, by its order46 // An image a tool returned inline, such as a device screenshot, by its order
45 // in the stored output. The timeline never carries these bytes.47 // in the stored output. The timeline never carries these bytes.
packages/shared/src/mcpApp.test.ts+61
...@@ -1,7 +1,9 @@...@@ -1,7 +1,9 @@
1import * as NodeVM from "node:vm";
1import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
23
3import {4import {
4 injectMcpAppCsp,5 injectMcpAppCsp,
6 injectMcpAppThemeBootstrap,
5 mcpAppContentSecurityPolicy,7 mcpAppContentSecurityPolicy,
6 mcpAppToolCallableByApp,8 mcpAppToolCallableByApp,
7 readMcpAppCsp,9 readMcpAppCsp,
...@@ -41,6 +43,65 @@ describe("injectMcpAppCsp", () => {...@@ -41,6 +43,65 @@ describe("injectMcpAppCsp", () => {
41 });43 });
42});44});
4345
46describe("injectMcpAppThemeBootstrap", () => {
47 it("keeps stored CSP before the bootstrap and vendor scripts after it", () => {
48 const snapshot = injectMcpAppCsp(
49 "<html><head><script>vendor()</script></head></html>",
50 undefined,
51 );
52 const html = injectMcpAppThemeBootstrap(snapshot);
53 expect(html.indexOf("Content-Security-Policy")).toBeLessThan(html.indexOf("<script>"));
54 expect(html.indexOf("window.addEventListener")).toBeLessThan(html.indexOf("vendor()"));
55 expect(html.endsWith(snapshot.slice(snapshot.indexOf("<html>")))).toBe(true);
56 });
57
58 it("applies parent initialize and theme changes while ignoring unrelated or invalid messages", () => {
59 const html = injectMcpAppThemeBootstrap(injectMcpAppCsp("<p>app</p>", undefined));
60 const script = html.slice(html.indexOf("<script>") + 8, html.indexOf("</script>"));
61 const parent = {};
62 const style = { colorScheme: "normal" };
63 let receive: ((event: { source: object; data: unknown }) => void) | undefined;
64 NodeVM.runInNewContext(script, {
65 window: {
66 parent,
67 addEventListener: (_type: string, callback: typeof receive) => {
68 receive = callback;
69 },
70 },
71 document: { documentElement: { style } },
72 });
73 const send = (data: unknown, source = parent) => receive?.({ source, data });
74 const initialized = { jsonrpc: "2.0", id: 1, result: { hostContext: { theme: "dark" } } };
75 send(initialized, {});
76 expect(style.colorScheme).toBe("normal");
77 send(initialized);
78 expect(style.colorScheme).toBe("dark");
79 for (const data of [
80 null,
81 "bad",
82 {},
83 { ...initialized, jsonrpc: "1.0" },
84 { jsonrpc: "2.0", method: "other", params: { theme: "light" } },
85 {
86 jsonrpc: "2.0",
87 method: "ui/notifications/host-context-changed",
88 params: { theme: "normal" },
89 },
90 { jsonrpc: "2.0", result: { hostContext: { theme: "light" } } },
91 ])
92 send(data);
93 expect(style.colorScheme).toBe("dark");
94 send({
95 jsonrpc: "2.0",
96 method: "ui/notifications/host-context-changed",
97 params: { theme: "light" },
98 });
99 expect(style.colorScheme).toBe("light");
100 send(initialized);
101 expect(style.colorScheme).toBe("dark");
102 });
103});
104
44describe("readMcpAppCsp", () => {105describe("readMcpAppCsp", () => {
45 it("drops entries that could widen or break the policy", () => {106 it("drops entries that could widen or break the policy", () => {
46 expect(107 expect(
packages/shared/src/mcpApp.ts+34
...@@ -215,6 +215,40 @@ export function injectMcpAppCsp(html: string, csp: McpAppCsp | undefined): strin...@@ -215,6 +215,40 @@ export function injectMcpAppCsp(html: string, csp: McpAppCsp | undefined): strin
215 : `<!doctype html>${meta}${html}`;215 : `<!doctype html>${meta}${html}`;
216}216}
217217
218/**
219 * Installed before vendor scripts so apps that omit their own theme handling
220 * still give their document canvas and native controls the host's appearance.
221 * The bridge uses an opaque origin, so authenticate messages by parent window.
222 */
223export function injectMcpAppThemeBootstrap(html: string): string {
224 const script = `<script>
225(() => {
226 window.addEventListener("message", (event) => {
227 if (event.source !== window.parent) return;
228 const message = event.data;
229 if (!message || message.jsonrpc !== "2.0") return;
230 const theme = message.method === "ui/notifications/host-context-changed"
231 ? message.params?.theme
232 : message.id !== undefined && message.method === undefined
233 ? message.result?.hostContext?.theme
234 : undefined;
235 if (theme === "light" || theme === "dark") {
236 document.documentElement.style.colorScheme = theme;
237 }
238 });
239})();
240</script>`;
241 // Snapshots start with our doctype and CSP meta. Keep that policy ahead of
242 // the bootstrap, rather than letting an injected script precede it.
243 const prefix =
244 /^\uFEFF?\s*<!doctype[^>]*>\s*(?:<meta\s+http-equiv="Content-Security-Policy"[^>]*>)?/i.exec(
245 html,
246 );
247 return prefix
248 ? `${prefix[0]}${script}${html.slice(prefix[0].length)}`
249 : `<!doctype html>${script}${html}`;
250}
251
218/** The iframe `allow` attribute for the permissions an app declared. */252/** The iframe `allow` attribute for the permissions an app declared. */
219export function mcpAppAllowAttribute(permissions: McpAppPermissions | undefined): string {253export function mcpAppAllowAttribute(permissions: McpAppPermissions | undefined): string {
220 const features: Array<string> = [];254 const features: Array<string> = [];