| author | |
| committer | |
| log | 2e19207b179dca57d57ecd24a4b0b0776a41e0bf |
| tree | dbe9f4f16b7e2a6808cbda93a8124f69f99591ad |
| parent | b147dc99e31da173f0bbe2f25ced0f5cb5b2092b |
| signature | Signed by PGP key B5690EEEBB952194 |
9 files changed, 317 insertions(+), 2 deletions(-)
apps/mobile/src/features/threads/McpAppWebView.tsx+1| ... | ... | @@ -162,6 +162,7 @@ export function ThreadMcpApp(props: { |
| 162 | 162 | fileName: mcpAppFileName(app), |
| 163 | 163 | mimeType: "text/html", |
| 164 | 164 | disposition: "inline" as const, |
| 165 | renderIntent: "mcp-app" as const, | |
| 165 | 166 | }), |
| 166 | 167 | [app], |
| 167 | 168 | ); |
apps/server/src/assets/AssetAccess.test.ts+103-1| ... | ... | @@ -9,6 +9,7 @@ import { |
| 9 | 9 | TurnItemId, |
| 10 | 10 | } from "@t3tools/contracts"; |
| 11 | 11 | import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; |
| 12 | import { MCP_APP_MAX_HTML_BYTES } from "@t3tools/shared/mcpApp"; | |
| 12 | 13 | import { describe, expect, it } from "@effect/vitest"; |
| 13 | 14 | import * as Crypto from "effect/Crypto"; |
| 14 | 15 | import * as DateTime from "effect/DateTime"; |
| ... | ... | @@ -30,7 +31,7 @@ import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; |
| 30 | 31 | import * as T3ProjectFileLoader from "../project/T3ProjectFileLoader.ts"; |
| 31 | 32 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| 32 | 33 | import { assetFileResponse } from "../http.ts"; |
| 33 | import { ASSET_ROUTE_PREFIX, issueAssetUrl, resolveAsset } from "./AssetAccess.ts"; | |
| 34 | import { ASSET_ROUTE_PREFIX, issueAssetUrl, readMcpAppHtml, resolveAsset } from "./AssetAccess.ts"; | |
| 34 | 35 | import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; |
| 35 | 36 | import { openMediaFile } from "./MediaFile.ts"; |
| 36 | 37 | import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; |
| ... | ... | @@ -121,6 +122,64 @@ const layerTest = Layer.mergeAll( |
| 121 | 122 | ).pipe(Layer.provideMerge(NodeServices.layer)); |
| 122 | 123 | |
| 123 | 124 | describe("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 | 183 | it.effect("loads private media immediately after login with the GitHub credential", () => { |
| 125 | 184 | let lookups = 0; |
| 126 | 185 | const authorizations: Array<string | undefined> = []; |
| ... | ... | @@ -903,6 +962,49 @@ describe("AssetAccess", () => { |
| 903 | 962 | }).pipe(Effect.provide(layerTest)), |
| 904 | 963 | ); |
| 905 | 964 | |
| 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 | 1008 | it.effect("issues exact attachment capabilities by attachment id", () => |
| 907 | 1009 | Effect.gen(function* () { |
| 908 | 1010 | const config = yield* ServerConfig.ServerConfig; |
apps/server/src/assets/AssetAccess.ts+26| ... | ... | @@ -31,6 +31,7 @@ import { |
| 31 | 31 | type ImageDimensions, |
| 32 | 32 | } from "@t3tools/shared/imageDimensions"; |
| 33 | 33 | import { githubMediaFetchUrl, githubMediaFileName } from "@t3tools/shared/githubMedia"; |
| 34 | import { MCP_APP_MAX_HTML_BYTES } from "@t3tools/shared/mcpApp"; | |
| 34 | 35 | import { |
| 35 | 36 | isProjectFaviconPath, |
| 36 | 37 | PROJECT_FAVICON_FALLBACK_MARKER, |
| ... | ... | @@ -45,6 +46,7 @@ import * as Option from "effect/Option"; |
| 45 | 46 | import * as Path from "effect/Path"; |
| 46 | 47 | import * as PlatformError from "effect/PlatformError"; |
| 47 | 48 | import * as Schema from "effect/Schema"; |
| 49 | import * as Stream from "effect/Stream"; | |
| 48 | 50 | |
| 49 | 51 | import { |
| 50 | 52 | base64UrlDecodeUtf8, |
| ... | ... | @@ -128,6 +130,7 @@ const AssetClaimsSchema = Schema.Union([ |
| 128 | 130 | download filename and Content-Type. */ |
| 129 | 131 | fileName: Schema.optionalKey(Schema.String), |
| 130 | 132 | mimeType: Schema.optionalKey(Schema.String), |
| 133 | renderIntent: Schema.optionalKey(Schema.Literal("mcp-app")), | |
| 131 | 134 | expiresAt: Schema.Number, |
| 132 | 135 | }), |
| 133 | 136 | Schema.Struct({ |
| ... | ... | @@ -180,6 +183,7 @@ export type ResolvedAsset = |
| 180 | 183 | readonly fileName?: string; |
| 181 | 184 | readonly mimeType?: string; |
| 182 | 185 | readonly file?: OpenMediaFile; |
| 186 | readonly renderIntent?: "mcp-app"; | |
| 183 | 187 | } |
| 184 | 188 | | { |
| 185 | 189 | readonly kind: "bytes"; |
| ... | ... | @@ -222,6 +226,24 @@ const optionOnNotFound = <A, R>( |
| 222 | 226 | }), |
| 223 | 227 | ); |
| 224 | 228 | |
| 229 | /** Reads a stored MCP HTML snapshot; null means it exceeds the preview limit. */ | |
| 230 | export 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 | 248 | * Decodes one image a tool returned inline; null when the stored item has no |
| 227 | 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 | 644 | : input.resource.mimeType !== undefined |
| 623 | 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 | 650 | expiresAt, |
| 626 | 651 | }; |
| 627 | 652 | fileName = input.resource.fileName ?? path.basename(attachmentPath); |
| ... | ... | @@ -853,6 +878,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( |
| 853 | 878 | ...(claims.download ? { download: true } : {}), |
| 854 | 879 | ...(claims.fileName !== undefined ? { fileName: claims.fileName } : {}), |
| 855 | 880 | ...(claims.mimeType !== undefined ? { mimeType: claims.mimeType } : {}), |
| 881 | ...(claims.renderIntent === "mcp-app" ? { renderIntent: "mcp-app" as const } : {}), | |
| 856 | 882 | } satisfies ResolvedAsset) |
| 857 | 883 | : null; |
| 858 | 884 | } |
apps/server/src/http.test.ts+69| ... | ... | @@ -16,6 +16,11 @@ import * as Tracer from "effect/Tracer"; |
| 16 | 16 | import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/http"; |
| 17 | 17 | import { openMediaFile } from "./assets/MediaFile.ts"; |
| 18 | 18 | |
| 19 | import { | |
| 20 | injectMcpAppCsp, | |
| 21 | injectMcpAppThemeBootstrap, | |
| 22 | MCP_APP_MAX_HTML_BYTES, | |
| 23 | } from "@t3tools/shared/mcpApp"; | |
| 19 | 24 | import { ORCHESTRATION_PROTOCOL_HEADER } from "@t3tools/contracts"; |
| 20 | 25 | |
| 21 | 26 | import * as ServerConfig from "./config.ts"; |
| ... | ... | @@ -30,6 +35,70 @@ import { |
| 30 | 35 | } from "./http.ts"; |
| 31 | 36 | import * as ServerHttp from "./http.ts"; |
| 32 | 37 | |
| 38 | describe("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 | ||
| 33 | 102 | describe("untraced requests", () => { |
| 34 | 103 | it.effect("drops the HTTP server span for browser trace exports, query string included", () => { |
| 35 | 104 | const spanNames: Array<string> = []; |
apps/server/src/http.ts+20-1| ... | ... | @@ -5,6 +5,7 @@ import { |
| 5 | 5 | EnvironmentHttpApi, |
| 6 | 6 | } from "@t3tools/contracts"; |
| 7 | 7 | import { isDevProxiedPath } from "@t3tools/shared/devProxy"; |
| 8 | import { injectMcpAppThemeBootstrap } from "@t3tools/shared/mcpApp"; | |
| 8 | 9 | import { decodeOtlpTraceRecords } from "@t3tools/shared/observability"; |
| 9 | 10 | import * as Data from "effect/Data"; |
| 10 | 11 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -28,7 +29,7 @@ import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; |
| 28 | 29 | import { OtlpTracer, OtlpSerialization } from "effect/observability"; |
| 29 | 30 | |
| 30 | 31 | import * as ServerConfig from "./config.ts"; |
| 31 | import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts"; | |
| 32 | import { ASSET_ROUTE_PREFIX, readMcpAppHtml, resolveAsset } from "./assets/AssetAccess.ts"; | |
| 32 | 33 | import { githubMediaResponse } from "./assets/GitHubMediaFetch.ts"; |
| 33 | 34 | import { statMediaFile, streamMediaFile, type OpenMediaFile } from "./assets/MediaFile.ts"; |
| 34 | 35 | import { |
| ... | ... | @@ -167,12 +168,30 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* ( |
| 167 | 168 | readonly fileName?: string; |
| 168 | 169 | readonly mimeType?: string; |
| 169 | 170 | readonly file?: OpenMediaFile; |
| 171 | readonly renderIntent?: "mcp-app"; | |
| 170 | 172 | }, |
| 171 | 173 | rangeHeader?: string, |
| 172 | 174 | ifRangeHeader?: string, |
| 173 | 175 | method: "GET" | "HEAD" = "GET", |
| 174 | 176 | ) { |
| 175 | 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 | 195 | const mediaFile = asset.file; |
| 177 | 196 | const mediaInfo = mediaFile ? yield* statMediaFile(asset.path, mediaFile) : undefined; |
| 178 | 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 | 117 | fileName: mcpAppFileName(app), |
| 118 | 118 | mimeType: "text/html", |
| 119 | 119 | disposition: "inline" as const, |
| 120 | renderIntent: "mcp-app" as const, | |
| 120 | 121 | }), |
| 121 | 122 | [app], |
| 122 | 123 | ); |
packages/contracts/src/assets.ts+2| ... | ... | @@ -40,6 +40,8 @@ export const AssetResource = Schema.Union([ |
| 40 | 40 | /** Generic attachments download by default. Document viewers opt into an |
| 41 | 41 | inline response after deciding the file type is safe to preview. */ |
| 42 | 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 | 46 | // An image a tool returned inline, such as a device screenshot, by its order |
| 45 | 47 | // in the stored output. The timeline never carries these bytes. |
packages/shared/src/mcpApp.test.ts+61| ... | ... | @@ -1,7 +1,9 @@ |
| 1 | import * as NodeVM from "node:vm"; | |
| 1 | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 3 | |
| 3 | 4 | import { |
| 4 | 5 | injectMcpAppCsp, |
| 6 | injectMcpAppThemeBootstrap, | |
| 5 | 7 | mcpAppContentSecurityPolicy, |
| 6 | 8 | mcpAppToolCallableByApp, |
| 7 | 9 | readMcpAppCsp, |
| ... | ... | @@ -41,6 +43,65 @@ describe("injectMcpAppCsp", () => { |
| 41 | 43 | }); |
| 42 | 44 | }); |
| 43 | 45 | |
| 46 | describe("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 | ||
| 44 | 105 | describe("readMcpAppCsp", () => { |
| 45 | 106 | it("drops entries that could widen or break the policy", () => { |
| 46 | 107 | expect( |
packages/shared/src/mcpApp.ts+34| ... | ... | @@ -215,6 +215,40 @@ export function injectMcpAppCsp(html: string, csp: McpAppCsp | undefined): strin |
| 215 | 215 | : `<!doctype html>${meta}${html}`; |
| 216 | 216 | } |
| 217 | 217 | |
| 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 | */ | |
| 223 | export 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 | 252 | /** The iframe `allow` attribute for the permissions an app declared. */ |
| 219 | 253 | export function mcpAppAllowAttribute(permissions: McpAppPermissions | undefined): string { |
| 220 | 254 | const features: Array<string> = []; |